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.
- 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/ANCHORING.md +43 -22
- 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 +26 -3
- package/verifier/README.md +584 -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 +5123 -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 +3376 -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,3376 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// verifier/verify-vh.js — the STANDALONE, read-only, OFFLINE verifier (T-31.2).
|
|
5
|
+
//
|
|
6
|
+
// WHY THIS EXISTS
|
|
7
|
+
// The whole verifyhash family sells one promise: "you do NOT have to trust the producer — verify it
|
|
8
|
+
// OFFLINE, independently." `verify-vh` is the artifact that makes that promise real for the party who
|
|
9
|
+
// matters most for a sale: the COUNTERPARTY (an auditor, opposing counsel, a buyer's security team, a
|
|
10
|
+
// design partner). They drop one `*.vhevidence.json` / `*.vhseal` / dataset attestation / proof bundle
|
|
11
|
+
// in front of this command and get a deterministic verdict — WITHOUT installing the producer's heavy
|
|
12
|
+
// ethers/hardhat stack. This tree depends on ONLY `js-sha3` (+ a tiny vendored secp256k1 routine), so a
|
|
13
|
+
// third party can `npm install` it alone and audit it in an afternoon.
|
|
14
|
+
//
|
|
15
|
+
// WHAT IT DOES
|
|
16
|
+
// * AUTO-DETECTS the artifact `kind` (evidence seal, reconciliation/trust seal, dataset attestation,
|
|
17
|
+
// proof bundle — bare or signed).
|
|
18
|
+
// * RE-DERIVES the keccak Merkle root from the bytes REFERENCED by the artifact (resolving sibling
|
|
19
|
+
// files relative to the artifact's own directory, with a `--dir <d>` override), NEVER trusting the
|
|
20
|
+
// artifact's own stored hashes.
|
|
21
|
+
// * RECOVERS the signer of a signed artifact via the independent EIP-191 secp256k1 recovery (T-31.1),
|
|
22
|
+
// PINS it to a caller-supplied `--vendor <0xaddr>` (or REPORTS the recovered signer when no pin is
|
|
23
|
+
// given).
|
|
24
|
+
// * Prints a deterministic verdict: OK / which file CHANGED / MISSING / UNEXPECTED / `bad_signature`
|
|
25
|
+
// / `wrong_issuer`.
|
|
26
|
+
//
|
|
27
|
+
// POSTURE — READ-ONLY. It holds NO key, opens nothing for write, and NEVER writes the cwd (or anywhere).
|
|
28
|
+
// It reads ONLY the artifact and the sibling files it references. Same exit-code contract as
|
|
29
|
+
// `vh verify-seal` / `vh evidence verify`: 0 ok / 3 rejected / 2 usage / 1 IO.
|
|
30
|
+
//
|
|
31
|
+
// FILE-SOURCE SEAM (T-66.1). The verify cores are written against ONE tiny abstraction — a `readEntry`
|
|
32
|
+
// function `(relPath) -> { status: "ok", bytes } | { status: "missing" } | { status: "escaped" }` — so
|
|
33
|
+
// the SAME engine verifies from the DISK (the CLI path below, byte-identical to before) or from an
|
|
34
|
+
// IN-MEMORY `{ relPath: Uint8Array }` map (`verifyArtifactFromBytes`, the seam a browser page / vm
|
|
35
|
+
// sandbox drives with ZERO fs/os/path/process on its code path). The whole pure engine sits between the
|
|
36
|
+
// BEGIN/END markers below; test/verifier.browser-core.test.js proves (statically AND dynamically) that
|
|
37
|
+
// no impure builtin use is reachable from the bytes entry, and that disk/bytes verdicts are DEEP-EQUAL.
|
|
38
|
+
|
|
39
|
+
const fs = require("fs");
|
|
40
|
+
const os = require("os");
|
|
41
|
+
const path = require("path");
|
|
42
|
+
// Node CORE sha256 (no npm dependency — the same zero-install class as fs/path; the bundle already
|
|
43
|
+
// allows `crypto` for its embedded --self-attest). Used ONLY by the T-70.4 anchored-receipt section
|
|
44
|
+
// below (the dataset/parcel attestation digest legs), which lives OUTSIDE the pure engine block.
|
|
45
|
+
const nodeCrypto = require("crypto");
|
|
46
|
+
|
|
47
|
+
const merkle = require("./lib/merkle");
|
|
48
|
+
const canonical = require("./lib/canonical");
|
|
49
|
+
const { recoverPersonalSignAddress } = require("./lib/secp256k1-recover");
|
|
50
|
+
const revocation = require("./lib/revocation");
|
|
51
|
+
|
|
52
|
+
// ============================ BEGIN VERIFY-VH PURE ENGINE (T-66.1) ============================
|
|
53
|
+
// EVERYTHING between this marker and the matching END marker is the PURE verify engine: it performs NO
|
|
54
|
+
// I/O of its own and never touches fs / os / path / process / child_process — every byte it verifies
|
|
55
|
+
// arrives through the injected `readEntry` seam (or as an argument). Its only outside references are the
|
|
56
|
+
// four module bindings above, all of which resolve to PURE modules for the functions used here:
|
|
57
|
+
// `merkle`, `canonical`, `recoverPersonalSignAddress`, and the PURE decision half of `revocation`
|
|
58
|
+
// (./lib/revocation-core.js re-exports — never the fs-backed readRevocationsFromPath/loadAndApply).
|
|
59
|
+
// test/verifier.browser-core.test.js enforces all of this mechanically; the markers also make the block
|
|
60
|
+
// mechanically extractable (vm / browser bundling, EPIC-66).
|
|
61
|
+
|
|
62
|
+
// CI-gateable exit contract, mirroring the producer family (vh verify-seal / vh evidence verify):
|
|
63
|
+
// 0 ok / 3 rejected / 2 usage / 1 IO. Stable; a future CI/indexer keys on these.
|
|
64
|
+
const EXIT = Object.freeze({ OK: 0, IO: 1, USAGE: 2, REJECTED: 3 });
|
|
65
|
+
|
|
66
|
+
// A usage error the CLI maps to exit 2 (vs an IO error -> 1, vs a clean REJECTED verdict -> 3).
|
|
67
|
+
class UsageError extends Error {}
|
|
68
|
+
class IOError extends Error {}
|
|
69
|
+
|
|
70
|
+
// The on-disk `kind` discriminators of every artifact family this verifier understands. Bare and signed
|
|
71
|
+
// variants are listed so auto-detect routes correctly. Disjoint, versioned strings — a foreign/random
|
|
72
|
+
// JSON file falls through to a clear "unrecognized artifact" usage error rather than a misread.
|
|
73
|
+
const KINDS = Object.freeze({
|
|
74
|
+
EVIDENCE_SEAL: "vh.evidence-seal",
|
|
75
|
+
EVIDENCE_SEAL_SIGNED: "vh.evidence-seal-signed",
|
|
76
|
+
TRUST_SEAL: "trustledger.reconcile-seal",
|
|
77
|
+
TRUST_SEAL_SIGNED: "trustledger.reconcile-seal-signed",
|
|
78
|
+
DATASET_ATTESTATION: "verifyhash.dataset-attestation",
|
|
79
|
+
DATASET_ATTESTATION_SIGNED: "verifyhash.dataset-attestation-signed",
|
|
80
|
+
DATASET_ATTESTATION_TIMESTAMPED: "verifyhash.dataset-attestation-timestamped",
|
|
81
|
+
PROOF: "verifyhash.merkle-proof",
|
|
82
|
+
AGENT_PACKET: "vh.agent-session-packet",
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const TRUST_NOTE =
|
|
86
|
+
"verify-vh is an INDEPENDENT, read-only, OFFLINE verifier. It RE-DERIVES the keccak root from the " +
|
|
87
|
+
"bytes you hold and recovers the signer with no producer stack. It proves TAMPER-EVIDENCE + WHO " +
|
|
88
|
+
"vouched — NOT a trusted timestamp and NOT a legal opinion.";
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Address normalization + recovery helpers. The verifier compares addresses as LOWERCASE 0x-hex (the
|
|
92
|
+
// canonical byte-deterministic form the producer records); a caller may paste an EIP-55-checksummed
|
|
93
|
+
// --vendor and we lowercase it (a checksum mismatch is not our concern — we compare 20 raw bytes).
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
97
|
+
|
|
98
|
+
function normalizeAddress(addr, label) {
|
|
99
|
+
if (typeof addr !== "string" || !ADDRESS_RE.test(addr)) {
|
|
100
|
+
throw new UsageError(`${label} must be a 0x-prefixed 20-byte hex address, got: ${String(addr)}`);
|
|
101
|
+
}
|
|
102
|
+
return addr.toLowerCase();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Recover the EIP-191 signer over the embedded canonical bytes. A tampered/corrupt signature can be
|
|
106
|
+
// UNRECOVERABLE (no valid curve point) — that throws, which the caller turns into a `bad_signature`
|
|
107
|
+
// REJECTED verdict, never a crash. Returns lowercase 0x-hex, or null if recovery failed.
|
|
108
|
+
function tryRecover(message, signature) {
|
|
109
|
+
try {
|
|
110
|
+
return recoverPersonalSignAddress(message, signature);
|
|
111
|
+
} catch (_) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Signed-container decoding. A signed artifact carries the embedded UNSIGNED payload as the EXACT
|
|
118
|
+
// canonical bytes (a STRING) in `attestation`, plus a { scheme, signer, signature } block. The signed
|
|
119
|
+
// MESSAGE is that embedded string verbatim, so signer recovery runs over `container.attestation`.
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
function decodeSigned(container) {
|
|
123
|
+
const sig = container && container.signature;
|
|
124
|
+
if (sig == null || typeof sig !== "object" || Array.isArray(sig)) {
|
|
125
|
+
throw new IOError("signed artifact is missing a { scheme, signer, signature } signature block");
|
|
126
|
+
}
|
|
127
|
+
if (sig.scheme !== "eip191-personal-sign") {
|
|
128
|
+
throw new IOError(
|
|
129
|
+
`unsupported signature scheme: ${JSON.stringify(sig.scheme)} ` +
|
|
130
|
+
"(this verifier understands eip191-personal-sign)"
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (typeof container.attestation !== "string") {
|
|
134
|
+
throw new IOError("signed artifact must embed the canonical UNSIGNED bytes as a string `attestation`");
|
|
135
|
+
}
|
|
136
|
+
if (typeof sig.signature !== "string" || !/^0x[0-9a-fA-F]{130}$/.test(sig.signature)) {
|
|
137
|
+
throw new IOError("signed artifact signature must be a 65-byte (r||s||v) 0x-hex string");
|
|
138
|
+
}
|
|
139
|
+
if (typeof sig.signer !== "string" || !ADDRESS_RE.test(sig.signer)) {
|
|
140
|
+
throw new IOError("signed artifact signer must be a 0x-prefixed 20-byte hex address");
|
|
141
|
+
}
|
|
142
|
+
let embedded;
|
|
143
|
+
try {
|
|
144
|
+
embedded = JSON.parse(container.attestation);
|
|
145
|
+
} catch (e) {
|
|
146
|
+
throw new IOError(`embedded attestation is not valid JSON: ${e.message}`);
|
|
147
|
+
}
|
|
148
|
+
return { embedded, message: container.attestation, claimedSigner: sig.signer.toLowerCase(), signature: sig.signature };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Per-file re-derivation, shared by every seal kind AND by both file sources. Given the sealed
|
|
153
|
+
// { relPath, contentHash } entries and a `readEntry` source, fetch each referenced file's bytes through
|
|
154
|
+
// the source, recompute its contentHash, and localize the outcome to MATCH / CHANGED / MISSING /
|
|
155
|
+
// ESCAPED; a file present under a sealed relPath that is NOT in the seal cannot occur here (we only read
|
|
156
|
+
// sealed relPaths) — UNEXPECTED is reported only for seals where the producer enumerates a directory
|
|
157
|
+
// (evidence seal verify re-walks the dir). For artifact verification we follow the producer's read
|
|
158
|
+
// model: read exactly the relPaths the artifact names from the source.
|
|
159
|
+
//
|
|
160
|
+
// SECURITY — CONFINEMENT LIVES IN THE SOURCE. `relPath` values come straight from the attacker-controlled
|
|
161
|
+
// artifact JSON (the threat model is attacker-controls-the-input, victim-runs-on-their-own-machine: a
|
|
162
|
+
// malicious producer hands a counterparty a "verify me" artifact, hoping its relPaths probe the
|
|
163
|
+
// counterparty's filesystem). Each source therefore CONFINES every read BEFORE touching its backing
|
|
164
|
+
// store and answers `{ status: "escaped" }` for a hostile relPath (absolute, a `..` traversal component,
|
|
165
|
+
// or — for the disk source — a resolved/realpath escape of baseDir). An escaped entry is recorded ONLY by
|
|
166
|
+
// relPath (the attacker's string) — we NEVER hash it and NEVER emit an actualContentHash for it, so the
|
|
167
|
+
// verdict can never become a content-confirmation / hash-disclosure oracle over a file outside the
|
|
168
|
+
// source. A `path_escape` entry is a hard REJECTED verdict.
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
function classifyFilesWith(sealedEntries, readEntry) {
|
|
172
|
+
const changed = [];
|
|
173
|
+
const missing = [];
|
|
174
|
+
const matched = [];
|
|
175
|
+
const escaped = []; // { relPath } only — NEVER a hash; a confinement reject, read nothing
|
|
176
|
+
const flat = []; // { relPath, contentHash } actually-present, for the root re-derivation
|
|
177
|
+
|
|
178
|
+
for (const e of sealedEntries) {
|
|
179
|
+
const relPath = e.relPath;
|
|
180
|
+
const r = readEntry(relPath);
|
|
181
|
+
if (r.status === "escaped") {
|
|
182
|
+
escaped.push({ relPath: String(relPath) });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (r.status === "missing") {
|
|
186
|
+
missing.push({ relPath });
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const actual = merkle.hashBytes(r.bytes);
|
|
190
|
+
flat.push({ relPath, contentHash: actual });
|
|
191
|
+
if (actual.toLowerCase() === String(e.contentHash).toLowerCase()) {
|
|
192
|
+
matched.push({ relPath, contentHash: actual });
|
|
193
|
+
} else {
|
|
194
|
+
changed.push({ relPath, expectedContentHash: e.contentHash, actualContentHash: actual });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { matched, changed, missing, escaped, flat };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Verify an EVIDENCE seal (bare or the embedded seal of a signed container). The seal lists `files`
|
|
202
|
+
// [{ relPath, contentHash, leaf }] + `root`. We re-derive the root from the bytes the source holds and
|
|
203
|
+
// localize any tamper. NO header (evidence seals bind only the file set). UNEXPECTED files (present
|
|
204
|
+
// under a sealed-sibling tree but not named) are NOT scanned here — the artifact names exactly what it
|
|
205
|
+
// commits to; the producer's `vh evidence verify` re-walks the dir, but the standalone verifier verifies
|
|
206
|
+
// what the artifact REFERENCES (read-only, no directory walk). NOTE an "extra" file is still caught
|
|
207
|
+
// structurally: the sealed root commits to the FULL file set, so a seal doctored to omit an entry can
|
|
208
|
+
// never keep its root (root_mismatch), and a signed seal edited that way breaks its signature.
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
function verifyEvidenceSealWith(seal, readEntry) {
|
|
212
|
+
if (!Array.isArray(seal.files) || seal.files.length === 0) {
|
|
213
|
+
throw new IOError("evidence seal `files` must be a non-empty array");
|
|
214
|
+
}
|
|
215
|
+
if (typeof seal.root !== "string" || !merkle.HEX32_RE.test(seal.root)) {
|
|
216
|
+
throw new IOError("evidence seal `root` must be a 0x-prefixed 32-byte hex string");
|
|
217
|
+
}
|
|
218
|
+
const { matched, changed, missing, escaped, flat } = classifyFilesWith(seal.files, readEntry);
|
|
219
|
+
|
|
220
|
+
// The AUTHORITATIVE root is re-derived from the bytes actually held — never the seal's stored root.
|
|
221
|
+
// A partial/changed set yields a different root; rootMatches goes false.
|
|
222
|
+
let recomputedRoot = null;
|
|
223
|
+
if (flat.length > 0) {
|
|
224
|
+
try {
|
|
225
|
+
recomputedRoot = merkle.rootFromFlat(flat);
|
|
226
|
+
} catch (_) {
|
|
227
|
+
recomputedRoot = null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const rootMatches =
|
|
231
|
+
missing.length === 0 &&
|
|
232
|
+
changed.length === 0 &&
|
|
233
|
+
escaped.length === 0 &&
|
|
234
|
+
recomputedRoot != null &&
|
|
235
|
+
recomputedRoot.toLowerCase() === seal.root.toLowerCase();
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
matched,
|
|
239
|
+
changed,
|
|
240
|
+
missing,
|
|
241
|
+
escaped,
|
|
242
|
+
unexpected: [],
|
|
243
|
+
sealedRoot: seal.root,
|
|
244
|
+
recomputedRoot,
|
|
245
|
+
rootMatches,
|
|
246
|
+
filesOk: changed.length === 0 && missing.length === 0 && escaped.length === 0 && rootMatches,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// Verify a TRUST (reconciliation) seal (bare or embedded). The seal lists `inputs` (role+relPath+
|
|
252
|
+
// contentHash+leaf) and `outputs` (relPath+contentHash+leaf), plus a `verdict` + `root`. The root commits
|
|
253
|
+
// to all inputs + outputs PLUS a synthetic verdict/role HEADER leaf. We re-derive the root from the held
|
|
254
|
+
// bytes AND the header content recomputed from the seal's OWN verdict + input role bindings — so a
|
|
255
|
+
// verdict/role edit (which lives in the seal, not a file) still changes the recomputed root. Inputs are
|
|
256
|
+
// sealed by basename and resolve through the source (the portable handoff ships sources next to the seal).
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
function verifyTrustSealWith(seal, readEntry) {
|
|
260
|
+
if (!Array.isArray(seal.inputs) || seal.inputs.length === 0) {
|
|
261
|
+
throw new IOError("trust seal `inputs` must be a non-empty array");
|
|
262
|
+
}
|
|
263
|
+
if (!Array.isArray(seal.outputs) || seal.outputs.length === 0) {
|
|
264
|
+
throw new IOError("trust seal `outputs` must be a non-empty array");
|
|
265
|
+
}
|
|
266
|
+
if (typeof seal.root !== "string" || !merkle.HEX32_RE.test(seal.root)) {
|
|
267
|
+
throw new IOError("trust seal `root` must be a 0x-prefixed 32-byte hex string");
|
|
268
|
+
}
|
|
269
|
+
if (seal.verdict == null || typeof seal.verdict !== "object") {
|
|
270
|
+
throw new IOError("trust seal is missing its `verdict` block");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const sealedEntries = [
|
|
274
|
+
...seal.inputs.map((e) => ({ relPath: e.relPath, contentHash: e.contentHash, role: e.role })),
|
|
275
|
+
...seal.outputs.map((e) => ({ relPath: e.relPath, contentHash: e.contentHash, role: null })),
|
|
276
|
+
];
|
|
277
|
+
const { matched, changed, missing, escaped, flat } = classifyFilesWith(sealedEntries, readEntry);
|
|
278
|
+
|
|
279
|
+
// Re-derive the root: the held file leaves PLUS the verdict/role HEADER leaf (content recomputed
|
|
280
|
+
// from the seal's own verdict + input role bindings). The header is folded in as one more (relPath,
|
|
281
|
+
// content) pair under the reserved header relPath — exactly the producer's binding.
|
|
282
|
+
let recomputedRoot = null;
|
|
283
|
+
// Only attempt the root re-derivation when no file is MISSING or ESCAPED (a partial set can never
|
|
284
|
+
// re-derive the sealed root anyway, and the header binds the FULL committed structure).
|
|
285
|
+
if (missing.length === 0 && escaped.length === 0 && flat.length === seal.inputs.length + seal.outputs.length) {
|
|
286
|
+
try {
|
|
287
|
+
const headerBytes = canonical.trustSealHeaderBytes(
|
|
288
|
+
seal.verdict,
|
|
289
|
+
seal.inputs.map((e) => ({ role: e.role, relPath: e.relPath }))
|
|
290
|
+
);
|
|
291
|
+
const committed = [
|
|
292
|
+
...flat,
|
|
293
|
+
{ relPath: canonical.TRUST_SEAL_HEADER_RELPATH, contentHash: merkle.hashBytes(headerBytes) },
|
|
294
|
+
];
|
|
295
|
+
recomputedRoot = merkle.rootFromFlat(committed);
|
|
296
|
+
} catch (_) {
|
|
297
|
+
recomputedRoot = null;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const rootMatches =
|
|
301
|
+
escaped.length === 0 &&
|
|
302
|
+
recomputedRoot != null &&
|
|
303
|
+
recomputedRoot.toLowerCase() === seal.root.toLowerCase();
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
matched,
|
|
307
|
+
changed,
|
|
308
|
+
missing,
|
|
309
|
+
escaped,
|
|
310
|
+
unexpected: [],
|
|
311
|
+
sealedRoot: seal.root,
|
|
312
|
+
recomputedRoot,
|
|
313
|
+
rootMatches,
|
|
314
|
+
filesOk: changed.length === 0 && missing.length === 0 && escaped.length === 0 && rootMatches,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// Verify a DATASET attestation (bare/signed/timestamped). A dataset attestation commits to the dataset
|
|
320
|
+
// IDENTITY (root, fileCount, manifestDigest) — it does NOT carry the per-file list, so there are no
|
|
321
|
+
// sibling bytes to re-derive a Merkle root from without the original manifest. The independent verifier
|
|
322
|
+
// therefore confirms the embedded identity is well-formed + (for signed) recovers/pins the signer; the
|
|
323
|
+
// `root` is the dataset's, carried as-is. (`vh dataset verify <dir> --manifest` is the path that
|
|
324
|
+
// re-derives a root from a live tree; the attestation alone has no tree to re-walk.)
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
function verifyDatasetAttestation(att) {
|
|
328
|
+
for (const f of ["root", "manifestDigest"]) {
|
|
329
|
+
if (typeof att[f] !== "string" || !merkle.HEX32_RE.test(att[f])) {
|
|
330
|
+
throw new IOError(`dataset attestation ${f} must be a 0x-prefixed 32-byte hex string`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (!Number.isInteger(att.fileCount) || att.fileCount < 1) {
|
|
334
|
+
throw new IOError("dataset attestation fileCount must be a positive integer");
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
matched: [],
|
|
338
|
+
changed: [],
|
|
339
|
+
missing: [],
|
|
340
|
+
escaped: [],
|
|
341
|
+
unexpected: [],
|
|
342
|
+
sealedRoot: att.root,
|
|
343
|
+
recomputedRoot: null,
|
|
344
|
+
rootMatches: null, // no sibling bytes to re-derive a root from (identity-only artifact)
|
|
345
|
+
filesOk: true, // structural identity is sound; the binding is via the signature for signed variants
|
|
346
|
+
identityOnly: true,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// Verify a PROOF bundle. A proof artifact carries { root, leaf, contentHash, relPath, proof[] }. We
|
|
352
|
+
// RE-DERIVE the leaf from relPath + contentHash, then fold leafHash(leaf) up through the proof siblings
|
|
353
|
+
// with nodeHash and confirm it reproduces `root` — byte-identically to the on-chain verifyLeaf, but
|
|
354
|
+
// fully OFFLINE. (The on-chain "is this root anchored" check is out of scope for the offline verifier.)
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
function verifyProofBundle(art) {
|
|
358
|
+
for (const f of ["root", "leaf", "contentHash"]) {
|
|
359
|
+
if (typeof art[f] !== "string" || !merkle.HEX32_RE.test(art[f])) {
|
|
360
|
+
throw new IOError(`proof artifact ${f} must be a 0x-prefixed 32-byte hex string`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (typeof art.relPath !== "string" || art.relPath.length === 0) {
|
|
364
|
+
throw new IOError("proof artifact relPath must be a non-empty string");
|
|
365
|
+
}
|
|
366
|
+
if (!Array.isArray(art.proof)) {
|
|
367
|
+
throw new IOError("proof artifact `proof` must be an array of 0x 32-byte hex siblings");
|
|
368
|
+
}
|
|
369
|
+
const derivedLeaf = merkle.pathLeaf(art.relPath, art.contentHash);
|
|
370
|
+
const leafMatches = derivedLeaf.toLowerCase() === art.leaf.toLowerCase();
|
|
371
|
+
let computed = merkle.leafHash(art.leaf);
|
|
372
|
+
for (const sib of art.proof) {
|
|
373
|
+
computed = merkle.nodeHash(computed, sib);
|
|
374
|
+
}
|
|
375
|
+
const foldsToRoot = computed.toLowerCase() === art.root.toLowerCase();
|
|
376
|
+
return {
|
|
377
|
+
matched: leafMatches && foldsToRoot ? [{ relPath: art.relPath, contentHash: art.contentHash }] : [],
|
|
378
|
+
changed:
|
|
379
|
+
leafMatches && foldsToRoot ? [] : [{ relPath: art.relPath, expectedContentHash: art.root, actualContentHash: computed }],
|
|
380
|
+
missing: [],
|
|
381
|
+
escaped: [],
|
|
382
|
+
unexpected: [],
|
|
383
|
+
sealedRoot: art.root,
|
|
384
|
+
recomputedRoot: computed,
|
|
385
|
+
rootMatches: leafMatches && foldsToRoot,
|
|
386
|
+
filesOk: leafMatches && foldsToRoot,
|
|
387
|
+
proof: { derivedLeaf, leafMatches, foldsToRoot },
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
// Verify an AGENT-SESSION packet (T-68.3 — the AgentTrace funnel leg, FREE surface only).
|
|
393
|
+
//
|
|
394
|
+
// A `*.vhagent.json` packet is SELF-CONTAINED: it carries its ordered event list (full and/or
|
|
395
|
+
// REDACTED), a per-event leaf expectation list, and an RFC-6962-style ordered Merkle head
|
|
396
|
+
// { size, root } — there are NO sibling files to read, so `readEntry` is never consulted. This block
|
|
397
|
+
// RE-DERIVES every event leaf and the root from the events the packet holds, exactly as the producer's
|
|
398
|
+
// `vh agent verify` does, but from an INDEPENDENT implementation surface: everything below is written
|
|
399
|
+
// against the verifier's OWN dependency-free keccak (merkle.hashBytes) — it imports NOTHING from cli/.
|
|
400
|
+
//
|
|
401
|
+
// THE CONVENTION (must match cli/core/agent-session.js + cli/journal-log.js VERBATIM):
|
|
402
|
+
// * payloadHash = keccak256(utf8(payload)) (the payload COMMITMENT)
|
|
403
|
+
// * event leaf = keccak256(utf8(JSON.stringify([
|
|
404
|
+
// LEAF_DOMAIN, seq, ts, actor, type, payloadHash, canonicalMetaJson|null ])))
|
|
405
|
+
// — the payload participates ONLY via its commitment, so a FULL event and its REDACTED twin
|
|
406
|
+
// (payload dropped, commitment carried, `redacted: true`) derive the IDENTICAL leaf: redaction
|
|
407
|
+
// changes neither the leaves nor the root (it can WITHHOLD, never silently ALTER).
|
|
408
|
+
// * the ordered tree (RFC 6962, position-bound, NO sorting — the OPPOSITE of the evidence tree):
|
|
409
|
+
// leaf node = keccak256(0x00 || leaf) interior = keccak256(0x01 || left || right)
|
|
410
|
+
// MTH(D[0:n]) = interior(MTH(D[0:k]), MTH(D[k:n])), k = largest power of two < n
|
|
411
|
+
// empty log root = keccak256(utf8("vh.journal-log/v1:empty-root"))
|
|
412
|
+
// * a SIGNED packet carries `headAttestation`: a detached EIP-191 personal-sign over the EXACT
|
|
413
|
+
// canonical head-payload bytes (the embedded `attestation` string). The signature wraps the HEAD,
|
|
414
|
+
// so ONE signature stays valid for every redacted copy of the same sealed session.
|
|
415
|
+
//
|
|
416
|
+
// VERDICTS: event-level tamper (a payload that no longer matches its carried commitment — including a
|
|
417
|
+
// REDACTED event whose commitment was forged — or a leaf that no longer matches its expectation) is a
|
|
418
|
+
// REJECT NAMING THE SEQ; a tampered head is `root_mismatch`; a forged signature is `bad_signature`; a
|
|
419
|
+
// sound signature by the wrong signer under a --vendor pin is `wrong_issuer`; a --vendor pin on an
|
|
420
|
+
// UNSIGNED packet is `unsigned_cannot_pin_vendor` (a stripped signature never passes a pinned verify).
|
|
421
|
+
// The recompute is AUTHORITATIVE: the packet is an untrusted container and its stored hashes are only
|
|
422
|
+
// EXPECTATIONS checked against.
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
// The producer's in-band trust note, REQUIRED verbatim (the packetseal discipline: the caveat may not
|
|
426
|
+
// drift; a packet whose note was edited is structurally invalid, exactly as `vh agent verify` treats it).
|
|
427
|
+
const AGENT_TRUST_NOTE =
|
|
428
|
+
"This agent-session packet is TAMPER-EVIDENT + OFFLINE-RECOMPUTABLE, NOT a trusted timestamp and " +
|
|
429
|
+
"NOT a claim the agent behaved well. Its ordered Merkle `head` {size, root} (RFC-6962-style, " +
|
|
430
|
+
"position-bound) commits to every event: verify RE-DERIVES each event leaf — recomputing the " +
|
|
431
|
+
"payload hash commitment for a FULL event, checking the carried commitment for a REDACTED one — " +
|
|
432
|
+
"and the root from the events you hold, and a REJECT names the first offending event seq. " +
|
|
433
|
+
"Redaction WITHHOLDS a payload behind its hash commitment without changing any leaf or the root: " +
|
|
434
|
+
"it can hide, never silently alter. Event `ts` fields are SELF-ASSERTED metadata (recorded, never " +
|
|
435
|
+
'verified against any clock); "sealed at time T" rides the human-owned signing/timestamp ' +
|
|
436
|
+
"trust-root (STRATEGY.md P-3). Garbage-in is out of scope: the head proves the LOG is intact and " +
|
|
437
|
+
"append-only, not that the log faithfully records what the agent actually did. The packet is an " +
|
|
438
|
+
"UNTRUSTED transport container: verify never trusts the packet's own stored hashes.";
|
|
439
|
+
|
|
440
|
+
const AGENT_SIGNED_HEAD_TRUST_NOTE =
|
|
441
|
+
"This is a SIGNED agent-session HEAD attestation: it WRAPS (never edits) the EXACT canonical head " +
|
|
442
|
+
"bytes in `attestation` and attaches a detached EIP-191 signature. It asserts the holder of the " +
|
|
443
|
+
"`signer` key vouched for THIS session head {size, root} at signing time. Because event leaves " +
|
|
444
|
+
"are redaction-safe, the SAME signature stays valid for every redacted copy of the sealed session " +
|
|
445
|
+
"(redaction changes neither leaves nor root). It does NOT prove a timestamp (no \"sealed since " +
|
|
446
|
+
"T\" — still the human trust-root P-3) and is NOT a legal opinion. Every caveat of the packet " +
|
|
447
|
+
"applies. " +
|
|
448
|
+
AGENT_TRUST_NOTE;
|
|
449
|
+
|
|
450
|
+
const AGENT_HEAD_KIND = "vh.agent-head";
|
|
451
|
+
const AGENT_SIGNED_HEAD_KIND = "vh.agent-head-signed";
|
|
452
|
+
const AGENT_PACKET_SCHEMA_VERSIONS = Object.freeze([1]);
|
|
453
|
+
const AGENT_EVENT_TYPES = Object.freeze(["prompt", "completion", "tool_call", "tool_result", "note"]);
|
|
454
|
+
const AGENT_EVENT_FIELDS = Object.freeze([
|
|
455
|
+
"seq",
|
|
456
|
+
"ts",
|
|
457
|
+
"actor",
|
|
458
|
+
"type",
|
|
459
|
+
"payload",
|
|
460
|
+
"payloadHash",
|
|
461
|
+
"redacted",
|
|
462
|
+
"meta",
|
|
463
|
+
]);
|
|
464
|
+
const AGENT_LEAF_DOMAIN = "vh.agent-session/v1:event-leaf";
|
|
465
|
+
const AGENT_EMPTY_ROOT_DOMAIN = "vh.journal-log/v1:empty-root";
|
|
466
|
+
const AGENT_META_MAX_DEPTH = 32;
|
|
467
|
+
const AGENT_META_MAX_NODES = 100000;
|
|
468
|
+
|
|
469
|
+
// Canonical-case wire shapes (the producer emits lowercase-only hex; mixed case is a foreign artifact).
|
|
470
|
+
const AGENT_HEX32_LC_RE = /^0x[0-9a-f]{64}$/;
|
|
471
|
+
const AGENT_ADDRESS_LC_RE = /^0x[0-9a-f]{40}$/;
|
|
472
|
+
const AGENT_SIG_LC_RE = /^0x[0-9a-f]{130}$/;
|
|
473
|
+
|
|
474
|
+
// STRICT UTF-8 encoder that MIRRORS the producer's ethers `toUtf8Bytes` byte-for-byte (verified over
|
|
475
|
+
// the whole 0x0000..0xFFFF code-unit space + surrogate edge cases). ethers' default error mode THROWS
|
|
476
|
+
// only on a lone HIGH surrogate (an unfinished pair, no code point) — so this returns null there — but
|
|
477
|
+
// it ENCODES a lone LOW surrogate as its literal 3-byte sequence (U+DC00 -> ed b0 80), NOT an error;
|
|
478
|
+
// so a lone low surrogate falls straight through to the c<0x10000 branch below (matching the producer,
|
|
479
|
+
// whose commitment over such a payload is well-defined). Pure JS; no TextEncoder (which would silently
|
|
480
|
+
// substitute U+FFFD and DIVERGE from the producer). null => the event's commitment is undefined here
|
|
481
|
+
// exactly as it is for the producer, so both sides reject in lockstep (fail-closed, never a mismatch).
|
|
482
|
+
function agentUtf8Bytes(str) {
|
|
483
|
+
const out = [];
|
|
484
|
+
for (let i = 0; i < str.length; i++) {
|
|
485
|
+
let c = str.charCodeAt(i);
|
|
486
|
+
if (c >= 0xd800 && c <= 0xdbff) {
|
|
487
|
+
const lo = i + 1 < str.length ? str.charCodeAt(i + 1) : -1;
|
|
488
|
+
if (lo < 0xdc00 || lo > 0xdfff) return null; // lone HIGH surrogate (ethers THROWS; no code point)
|
|
489
|
+
c = (c - 0xd800) * 0x400 + (lo - 0xdc00) + 0x10000;
|
|
490
|
+
i++;
|
|
491
|
+
}
|
|
492
|
+
// A lone LOW surrogate (0xdc00..0xdfff) is NOT special-cased: ethers encodes it as its 3-byte form
|
|
493
|
+
// via the c<0x10000 branch, so we do too — deleting the old lone-low `return null` that FALSELY
|
|
494
|
+
// rejected genuine packets carrying truncated-UTF-16 / arbitrary-tool-result bytes.
|
|
495
|
+
if (c < 0x80) out.push(c);
|
|
496
|
+
else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 63));
|
|
497
|
+
else if (c < 0x10000) out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
|
|
498
|
+
else out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 63), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
|
|
499
|
+
}
|
|
500
|
+
return new Uint8Array(out);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// 0x-hex -> bytes, and a tiny concat — the only byte plumbing the ordered tree needs.
|
|
504
|
+
function agentHexToBytes(hex) {
|
|
505
|
+
const s = hex.slice(2);
|
|
506
|
+
const out = new Uint8Array(s.length / 2);
|
|
507
|
+
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);
|
|
508
|
+
return out;
|
|
509
|
+
}
|
|
510
|
+
function agentConcatBytes(list) {
|
|
511
|
+
let total = 0;
|
|
512
|
+
for (const b of list) total += b.length;
|
|
513
|
+
const out = new Uint8Array(total);
|
|
514
|
+
let off = 0;
|
|
515
|
+
for (const b of list) {
|
|
516
|
+
out.set(b, off);
|
|
517
|
+
off += b.length;
|
|
518
|
+
}
|
|
519
|
+
return out;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// RFC-6962 domain-separated hashing over the verifier's OWN keccak (merkle.hashBytes — the same
|
|
523
|
+
// independent primitive every other artifact family here is re-derived with). Children fold in TREE
|
|
524
|
+
// ORDER (never sorted): position IS meaning in an ordered session log.
|
|
525
|
+
function agentLeafNodeHash(leafHex) {
|
|
526
|
+
return merkle.hashBytes(agentConcatBytes([Uint8Array.of(0x00), agentHexToBytes(leafHex)]));
|
|
527
|
+
}
|
|
528
|
+
function agentInteriorHash(leftHex, rightHex) {
|
|
529
|
+
return merkle.hashBytes(
|
|
530
|
+
agentConcatBytes([Uint8Array.of(0x01), agentHexToBytes(leftHex), agentHexToBytes(rightHex)])
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// MTH (RFC 6962 §2.1) over the ORDERED leaf values; the empty log has a domain-separated constant root.
|
|
535
|
+
function agentTreeRoot(leaves) {
|
|
536
|
+
if (leaves.length === 0) return merkle.hashBytes(agentUtf8Bytes(AGENT_EMPTY_ROOT_DOMAIN));
|
|
537
|
+
function mth(lo, hi) {
|
|
538
|
+
const n = hi - lo;
|
|
539
|
+
if (n === 1) return agentLeafNodeHash(leaves[lo]);
|
|
540
|
+
let k = 1;
|
|
541
|
+
while (k * 2 < n) k *= 2;
|
|
542
|
+
return agentInteriorHash(mth(lo, lo + k), mth(lo + k, hi));
|
|
543
|
+
}
|
|
544
|
+
return mth(0, leaves.length);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// A "plain" JSON-shaped object (prototype Object.prototype or null) — the same strictness the producer
|
|
548
|
+
// applies, so what is hashed is exactly what could be written to disk and read back.
|
|
549
|
+
function agentIsPlainObject(v) {
|
|
550
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
|
|
551
|
+
const proto = Object.getPrototypeOf(v);
|
|
552
|
+
return proto === Object.prototype || proto === null;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Canonical JSON for `meta`: keys SORTED, only JSON-representable values, depth capped, and a TOTAL
|
|
556
|
+
// work budget so a shared-reference DAG can never hang the verifier. Returns the canonical text or
|
|
557
|
+
// null (reject) — byte-identical to the producer's canonicalization for every accepted value.
|
|
558
|
+
function agentCanonicalJson(value, depth, budget) {
|
|
559
|
+
if (depth > AGENT_META_MAX_DEPTH) return null;
|
|
560
|
+
if (++budget.n > AGENT_META_MAX_NODES) return null;
|
|
561
|
+
if (value === null) return "null";
|
|
562
|
+
const t = typeof value;
|
|
563
|
+
if (t === "boolean") return value ? "true" : "false";
|
|
564
|
+
if (t === "number") return Number.isFinite(value) ? JSON.stringify(value) : null;
|
|
565
|
+
if (t === "string") return JSON.stringify(value);
|
|
566
|
+
if (Array.isArray(value)) {
|
|
567
|
+
const parts = [];
|
|
568
|
+
for (const item of value) {
|
|
569
|
+
const p = agentCanonicalJson(item, depth + 1, budget);
|
|
570
|
+
if (p === null) return null;
|
|
571
|
+
parts.push(p);
|
|
572
|
+
}
|
|
573
|
+
return "[" + parts.join(",") + "]";
|
|
574
|
+
}
|
|
575
|
+
if (agentIsPlainObject(value)) {
|
|
576
|
+
const keys = Object.keys(value).sort();
|
|
577
|
+
const parts = [];
|
|
578
|
+
for (const k of keys) {
|
|
579
|
+
const p = agentCanonicalJson(value[k], depth + 1, budget);
|
|
580
|
+
if (p === null) return null;
|
|
581
|
+
parts.push(JSON.stringify(k) + ":" + p);
|
|
582
|
+
}
|
|
583
|
+
return "{" + parts.join(",") + "}";
|
|
584
|
+
}
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// The payload COMMITMENT: keccak256 over the payload's UTF-8 bytes. null on a non-string or a string
|
|
589
|
+
// with no UTF-8 encoding (a lone HIGH surrogate — where ethers throws) — TOTAL, mirrors the producer
|
|
590
|
+
// exactly (a lone LOW surrogate IS encodable, so it commits rather than rejecting).
|
|
591
|
+
function agentPayloadHash(payload) {
|
|
592
|
+
if (typeof payload !== "string") return null;
|
|
593
|
+
const bytes = agentUtf8Bytes(payload);
|
|
594
|
+
return bytes === null ? null : merkle.hashBytes(bytes);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// STRICT validation of one canonical event — an INDEPENDENT re-implementation of the producer's rules
|
|
598
|
+
// (closed field set; exactly the FULL or REDACTED shape; a carried commitment on a full event must
|
|
599
|
+
// equal the recomputed one). Never throws; every failure is a named { ok:false, reason, field? } (the
|
|
600
|
+
// commitment-mismatch reject also carries carried/recomputed so the caller can localize the change).
|
|
601
|
+
function agentValidateEvent(event) {
|
|
602
|
+
try {
|
|
603
|
+
if (!agentIsPlainObject(event)) return { ok: false, reason: "EVENT_NOT_OBJECT" };
|
|
604
|
+
for (const k of Object.keys(event)) {
|
|
605
|
+
if (!AGENT_EVENT_FIELDS.includes(k)) return { ok: false, reason: "EVENT_UNKNOWN_FIELD", field: k };
|
|
606
|
+
}
|
|
607
|
+
if (!Number.isSafeInteger(event.seq) || event.seq < 0) {
|
|
608
|
+
return { ok: false, reason: "EVENT_BAD_SEQ", field: "seq" };
|
|
609
|
+
}
|
|
610
|
+
if (typeof event.ts !== "string") return { ok: false, reason: "EVENT_BAD_TS", field: "ts" };
|
|
611
|
+
if (typeof event.actor !== "string" || event.actor.length === 0) {
|
|
612
|
+
return { ok: false, reason: "EVENT_BAD_ACTOR", field: "actor" };
|
|
613
|
+
}
|
|
614
|
+
if (!AGENT_EVENT_TYPES.includes(event.type)) return { ok: false, reason: "EVENT_BAD_TYPE", field: "type" };
|
|
615
|
+
const hasPayload = "payload" in event;
|
|
616
|
+
const hasHash = "payloadHash" in event;
|
|
617
|
+
if (hasPayload && typeof event.payload !== "string") {
|
|
618
|
+
return { ok: false, reason: "EVENT_BAD_PAYLOAD", field: "payload" };
|
|
619
|
+
}
|
|
620
|
+
if (hasHash && !(typeof event.payloadHash === "string" && merkle.HEX32_RE.test(event.payloadHash))) {
|
|
621
|
+
return { ok: false, reason: "EVENT_BAD_PAYLOAD_HASH", field: "payloadHash" };
|
|
622
|
+
}
|
|
623
|
+
if ("redacted" in event && typeof event.redacted !== "boolean") {
|
|
624
|
+
return { ok: false, reason: "EVENT_BAD_REDACTED_FLAG", field: "redacted" };
|
|
625
|
+
}
|
|
626
|
+
if (!hasPayload && !hasHash) return { ok: false, reason: "EVENT_MISSING_PAYLOAD", field: "payload" };
|
|
627
|
+
if (event.redacted === true && hasPayload) {
|
|
628
|
+
return { ok: false, reason: "EVENT_REDACTED_WITH_PAYLOAD", field: "redacted" };
|
|
629
|
+
}
|
|
630
|
+
if (event.redacted === true && !hasHash) {
|
|
631
|
+
return { ok: false, reason: "EVENT_BAD_PAYLOAD_HASH", field: "payloadHash" };
|
|
632
|
+
}
|
|
633
|
+
if (!hasPayload && event.redacted !== true) {
|
|
634
|
+
return { ok: false, reason: "EVENT_UNFLAGGED_REDACTION", field: "redacted" };
|
|
635
|
+
}
|
|
636
|
+
let commitment;
|
|
637
|
+
if (hasPayload) {
|
|
638
|
+
commitment = agentPayloadHash(event.payload);
|
|
639
|
+
if (commitment === null) return { ok: false, reason: "EVENT_BAD_PAYLOAD", field: "payload" };
|
|
640
|
+
if (hasHash && commitment !== event.payloadHash.toLowerCase()) {
|
|
641
|
+
return {
|
|
642
|
+
ok: false,
|
|
643
|
+
reason: "EVENT_PAYLOAD_HASH_MISMATCH",
|
|
644
|
+
field: "payloadHash",
|
|
645
|
+
carried: event.payloadHash.toLowerCase(),
|
|
646
|
+
recomputed: commitment,
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
} else {
|
|
650
|
+
commitment = event.payloadHash.toLowerCase();
|
|
651
|
+
}
|
|
652
|
+
let metaJson = null;
|
|
653
|
+
if ("meta" in event) {
|
|
654
|
+
metaJson = agentCanonicalJson(event.meta, 0, { n: 0 });
|
|
655
|
+
if (metaJson === null) return { ok: false, reason: "EVENT_BAD_META", field: "meta" };
|
|
656
|
+
}
|
|
657
|
+
return { ok: true, redacted: !hasPayload, payloadHash: commitment, metaJson };
|
|
658
|
+
} catch (_) {
|
|
659
|
+
return { ok: false, reason: "HOSTILE_INPUT" };
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// The redaction-safe LEAF VALUE of one validated event: the fixed-position JSON array preimage with
|
|
664
|
+
// the payload represented ONLY by its commitment (so a full event and its redacted twin derive the
|
|
665
|
+
// identical leaf). Returns null only for an encoding fault (kept total).
|
|
666
|
+
function agentEventLeaf(event, validated) {
|
|
667
|
+
const encoded = JSON.stringify([
|
|
668
|
+
AGENT_LEAF_DOMAIN,
|
|
669
|
+
event.seq,
|
|
670
|
+
event.ts,
|
|
671
|
+
event.actor,
|
|
672
|
+
event.type,
|
|
673
|
+
validated.payloadHash,
|
|
674
|
+
validated.metaJson,
|
|
675
|
+
]);
|
|
676
|
+
const bytes = agentUtf8Bytes(encoded);
|
|
677
|
+
return bytes === null ? null : merkle.hashBytes(bytes);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// The shared { size, root } head shape. Throws IOError (a malformed/foreign artifact, exit 1 — the same
|
|
681
|
+
// class `vh agent verify` gives a structurally invalid packet).
|
|
682
|
+
function validateAgentHeadShape(head, label) {
|
|
683
|
+
if (head == null || typeof head !== "object" || Array.isArray(head)) {
|
|
684
|
+
throw new IOError(`${label} \`head\` must be a { size, root } object`);
|
|
685
|
+
}
|
|
686
|
+
for (const k of Object.keys(head)) {
|
|
687
|
+
if (k !== "size" && k !== "root") {
|
|
688
|
+
throw new IOError(`${label} head has unknown field: ${JSON.stringify(k)}`);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
if (!Number.isSafeInteger(head.size) || head.size < 0) {
|
|
692
|
+
throw new IOError(`${label} head.size must be a non-negative integer, got: ${String(head.size)}`);
|
|
693
|
+
}
|
|
694
|
+
if (typeof head.root !== "string" || !AGENT_HEX32_LC_RE.test(head.root)) {
|
|
695
|
+
throw new IOError(
|
|
696
|
+
`${label} head.root must be a LOWERCASE 0x-bytes32 hex string, got: ${String(head.root)}`
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// STRICT structural validation of the OPTIONAL signed-head container: the exact canonical embedded
|
|
702
|
+
// bytes, a known scheme, lowercase signer/signature, and an embedded head payload in canonical form.
|
|
703
|
+
// Returns { embeddedHead } for the binding check. Throws IOError on any structural defect.
|
|
704
|
+
function validateAgentSignedHead(container) {
|
|
705
|
+
const label = "agent-session packet headAttestation";
|
|
706
|
+
if (container == null || typeof container !== "object" || Array.isArray(container)) {
|
|
707
|
+
throw new IOError(`${label} must be a JSON object`);
|
|
708
|
+
}
|
|
709
|
+
const KNOWN = ["kind", "schemaVersion", "note", "attestation", "signature"];
|
|
710
|
+
for (const k of Object.keys(container)) {
|
|
711
|
+
if (!KNOWN.includes(k)) throw new IOError(`${label} has unknown field: ${JSON.stringify(k)}`);
|
|
712
|
+
}
|
|
713
|
+
if (container.kind !== AGENT_SIGNED_HEAD_KIND) {
|
|
714
|
+
throw new IOError(
|
|
715
|
+
`${label} kind must be ${JSON.stringify(AGENT_SIGNED_HEAD_KIND)}, got: ${JSON.stringify(container.kind)}`
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
if (container.schemaVersion !== 1) {
|
|
719
|
+
throw new IOError(`${label} has unsupported schemaVersion: ${JSON.stringify(container.schemaVersion)}`);
|
|
720
|
+
}
|
|
721
|
+
if (container.note !== AGENT_SIGNED_HEAD_TRUST_NOTE) {
|
|
722
|
+
throw new IOError(`${label} note must be the standing signed-head trust note (caveat must not drift)`);
|
|
723
|
+
}
|
|
724
|
+
if (typeof container.attestation !== "string") {
|
|
725
|
+
throw new IOError(`${label} must embed the canonical UNSIGNED head bytes as a string \`attestation\``);
|
|
726
|
+
}
|
|
727
|
+
let embedded;
|
|
728
|
+
try {
|
|
729
|
+
embedded = JSON.parse(container.attestation);
|
|
730
|
+
} catch (e) {
|
|
731
|
+
throw new IOError(`${label} embedded attestation is not valid JSON: ${e.message}`);
|
|
732
|
+
}
|
|
733
|
+
if (
|
|
734
|
+
embedded == null ||
|
|
735
|
+
typeof embedded !== "object" ||
|
|
736
|
+
Array.isArray(embedded) ||
|
|
737
|
+
embedded.kind !== AGENT_HEAD_KIND ||
|
|
738
|
+
embedded.schemaVersion !== 1 ||
|
|
739
|
+
embedded.note !== AGENT_TRUST_NOTE
|
|
740
|
+
) {
|
|
741
|
+
throw new IOError(`${label} embedded payload is not a canonical ${JSON.stringify(AGENT_HEAD_KIND)} payload`);
|
|
742
|
+
}
|
|
743
|
+
validateAgentHeadShape(embedded.head, `${label} embedded payload`);
|
|
744
|
+
// The embedded string must be the EXACT canonical serialization (the byte-unambiguous signed message);
|
|
745
|
+
// an insignificant-whitespace/reordered variant is a foreign artifact.
|
|
746
|
+
const canonicalText =
|
|
747
|
+
JSON.stringify({
|
|
748
|
+
kind: embedded.kind,
|
|
749
|
+
schemaVersion: embedded.schemaVersion,
|
|
750
|
+
note: embedded.note,
|
|
751
|
+
head: { size: embedded.head.size, root: embedded.head.root },
|
|
752
|
+
}) + "\n";
|
|
753
|
+
if (container.attestation !== canonicalText) {
|
|
754
|
+
throw new IOError(`${label} embedded attestation is not in canonical form (the signed-over bytes are ambiguous)`);
|
|
755
|
+
}
|
|
756
|
+
const sig = container.signature;
|
|
757
|
+
if (sig == null || typeof sig !== "object" || Array.isArray(sig)) {
|
|
758
|
+
throw new IOError(`${label} signature must be a { scheme, signer, signature } object`);
|
|
759
|
+
}
|
|
760
|
+
if (sig.scheme !== "eip191-personal-sign") {
|
|
761
|
+
throw new IOError(
|
|
762
|
+
`${label} has unsupported signature scheme: ${JSON.stringify(sig.scheme)} (this verifier understands eip191-personal-sign)`
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
if (typeof sig.signer !== "string" || !AGENT_ADDRESS_LC_RE.test(sig.signer)) {
|
|
766
|
+
throw new IOError(`${label} signer must be a LOWERCASE 0x-prefixed 20-byte hex address`);
|
|
767
|
+
}
|
|
768
|
+
if (typeof sig.signature !== "string" || !AGENT_SIG_LC_RE.test(sig.signature)) {
|
|
769
|
+
throw new IOError(`${label} signature must be a 65-byte (r||s||v) LOWERCASE 0x-hex string`);
|
|
770
|
+
}
|
|
771
|
+
return { embeddedHead: { size: embedded.head.size, root: embedded.head.root } };
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// STRICT structural validation of a parsed packet (SHAPE only — the per-event/leaf/root RECOMPUTE is
|
|
775
|
+
// verifyAgentSeal's job, so event-level tamper stays a NAMED verdict naming the seq, never a throw).
|
|
776
|
+
// Mirrors the producer's validatePacketShape defect-for-defect. Throws IOError.
|
|
777
|
+
function validateAgentPacketStructure(obj) {
|
|
778
|
+
const label = "agent-session packet";
|
|
779
|
+
const KNOWN = ["kind", "schemaVersion", "note", "head", "counts", "events", "leaves", "headAttestation"];
|
|
780
|
+
for (const k of Object.keys(obj)) {
|
|
781
|
+
if (!KNOWN.includes(k)) throw new IOError(`${label} has unknown field: ${JSON.stringify(k)}`);
|
|
782
|
+
}
|
|
783
|
+
if (!AGENT_PACKET_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
|
|
784
|
+
throw new IOError(
|
|
785
|
+
`unsupported ${label} schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
|
|
786
|
+
`(this verifier understands ${JSON.stringify(AGENT_PACKET_SCHEMA_VERSIONS)})`
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
if (obj.note !== AGENT_TRUST_NOTE) {
|
|
790
|
+
throw new IOError(`${label} \`note\` must be the standing trust note (caveat must not drift)`);
|
|
791
|
+
}
|
|
792
|
+
validateAgentHeadShape(obj.head, label);
|
|
793
|
+
if (obj.counts == null || typeof obj.counts !== "object" || Array.isArray(obj.counts)) {
|
|
794
|
+
throw new IOError(`${label} \`counts\` must be a { events, full, redacted } object`);
|
|
795
|
+
}
|
|
796
|
+
for (const k of Object.keys(obj.counts)) {
|
|
797
|
+
if (!["events", "full", "redacted"].includes(k)) {
|
|
798
|
+
throw new IOError(`${label} counts has unknown field: ${JSON.stringify(k)}`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
for (const k of ["events", "full", "redacted"]) {
|
|
802
|
+
if (!Number.isSafeInteger(obj.counts[k]) || obj.counts[k] < 0) {
|
|
803
|
+
throw new IOError(`${label} counts.${k} must be a non-negative integer, got: ${String(obj.counts[k])}`);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
if (!Array.isArray(obj.events)) throw new IOError(`${label} \`events\` must be an array`);
|
|
807
|
+
if (!Array.isArray(obj.leaves) || obj.leaves.length !== obj.events.length) {
|
|
808
|
+
throw new IOError(`${label} \`leaves\` must be an array with EXACTLY one leaf expectation per event`);
|
|
809
|
+
}
|
|
810
|
+
obj.leaves.forEach((l, i) => {
|
|
811
|
+
if (typeof l !== "string" || !AGENT_HEX32_LC_RE.test(l)) {
|
|
812
|
+
throw new IOError(`${label} leaves[${i}] must be a LOWERCASE 0x-bytes32 hex string, got: ${String(l)}`);
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
if (obj.head.size !== obj.events.length) {
|
|
816
|
+
throw new IOError(
|
|
817
|
+
`${label} head.size (${obj.head.size}) does not match the events length (${obj.events.length})`
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
if (obj.counts.events !== obj.events.length || obj.counts.full + obj.counts.redacted !== obj.counts.events) {
|
|
821
|
+
throw new IOError(
|
|
822
|
+
`${label} \`counts\` is internally inconsistent (events must equal the events length; full + redacted must equal events)`
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
let signedHead = null;
|
|
826
|
+
if (obj.headAttestation !== undefined) signedHead = validateAgentSignedHead(obj.headAttestation);
|
|
827
|
+
return { packet: obj, signedHead };
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// The AUTHORITATIVE per-event/leaf/root/counts RECOMPUTE over a shape-validated packet. Returns the
|
|
831
|
+
// engine's standard fileResult shape (matched/changed/... + roots) PLUS an `agent` sub-verdict block
|
|
832
|
+
// and a `reasonKind` in the verifier's reason vocabulary. Event faults are localized to the FIRST
|
|
833
|
+
// offending seq, exactly as the producer's verify names it. Never throws.
|
|
834
|
+
function verifyAgentSeal(packet) {
|
|
835
|
+
const matched = [];
|
|
836
|
+
const changed = [];
|
|
837
|
+
const withheld = [];
|
|
838
|
+
const agent = {
|
|
839
|
+
head: { size: packet.head.size, root: packet.head.root },
|
|
840
|
+
recomputedHead: null,
|
|
841
|
+
counts: null,
|
|
842
|
+
withheld: null,
|
|
843
|
+
seq: null,
|
|
844
|
+
reason: null,
|
|
845
|
+
};
|
|
846
|
+
const base = {
|
|
847
|
+
matched,
|
|
848
|
+
changed,
|
|
849
|
+
missing: [],
|
|
850
|
+
escaped: [],
|
|
851
|
+
unexpected: [],
|
|
852
|
+
sealedRoot: packet.head.root,
|
|
853
|
+
recomputedRoot: null,
|
|
854
|
+
rootMatches: null,
|
|
855
|
+
filesOk: false,
|
|
856
|
+
reasonKind: null,
|
|
857
|
+
agent,
|
|
858
|
+
};
|
|
859
|
+
const events = packet.events;
|
|
860
|
+
const leaves = [];
|
|
861
|
+
for (let i = 0; i < events.length; i++) {
|
|
862
|
+
const v = agentValidateEvent(events[i]);
|
|
863
|
+
if (!v.ok) {
|
|
864
|
+
agent.seq = i;
|
|
865
|
+
agent.reason = v.reason;
|
|
866
|
+
if (v.field !== undefined) agent.field = v.field;
|
|
867
|
+
if (v.reason === "EVENT_PAYLOAD_HASH_MISMATCH") {
|
|
868
|
+
// The payload no longer matches its carried commitment: a CONTENT change localized to its seq
|
|
869
|
+
// (this is also how a REDACTED event's FORGED commitment surfaces once its leaf is checked).
|
|
870
|
+
changed.push({ relPath: `events[${i}]`, expectedContentHash: v.carried, actualContentHash: v.recomputed });
|
|
871
|
+
base.reasonKind = "CHANGED";
|
|
872
|
+
} else {
|
|
873
|
+
base.reasonKind = "event_invalid";
|
|
874
|
+
}
|
|
875
|
+
return base;
|
|
876
|
+
}
|
|
877
|
+
if (events[i].seq !== i) {
|
|
878
|
+
agent.seq = i;
|
|
879
|
+
agent.reason = "SESSION_SEQ_NOT_CONTIGUOUS";
|
|
880
|
+
base.reasonKind = "event_invalid";
|
|
881
|
+
return base;
|
|
882
|
+
}
|
|
883
|
+
const leaf = agentEventLeaf(events[i], v);
|
|
884
|
+
if (leaf === null || leaf !== packet.leaves[i]) {
|
|
885
|
+
// A bound-field edit (ts/actor/type/meta) or a forged redacted commitment: the re-derived leaf no
|
|
886
|
+
// longer matches the packet's own expectation — named by seq, recompute authoritative.
|
|
887
|
+
agent.seq = i;
|
|
888
|
+
agent.reason = "EVENT_LEAF_MISMATCH";
|
|
889
|
+
changed.push({ relPath: `events[${i}]`, expectedContentHash: packet.leaves[i], actualContentHash: leaf });
|
|
890
|
+
base.reasonKind = "CHANGED";
|
|
891
|
+
return base;
|
|
892
|
+
}
|
|
893
|
+
leaves.push(leaf);
|
|
894
|
+
matched.push({ relPath: `events[${i}]`, contentHash: leaf });
|
|
895
|
+
if (v.redacted) withheld.push(i);
|
|
896
|
+
}
|
|
897
|
+
const recomputedRoot = agentTreeRoot(leaves);
|
|
898
|
+
base.recomputedRoot = recomputedRoot;
|
|
899
|
+
agent.recomputedHead = { size: leaves.length, root: recomputedRoot };
|
|
900
|
+
base.rootMatches = leaves.length === packet.head.size && recomputedRoot === packet.head.root;
|
|
901
|
+
if (!base.rootMatches) {
|
|
902
|
+
agent.reason = "HEAD_MISMATCH";
|
|
903
|
+
base.reasonKind = "root_mismatch";
|
|
904
|
+
return base;
|
|
905
|
+
}
|
|
906
|
+
const full = events.length - withheld.length;
|
|
907
|
+
agent.counts = { events: events.length, full, redacted: withheld.length };
|
|
908
|
+
agent.withheld = withheld;
|
|
909
|
+
if (packet.counts.full !== full || packet.counts.redacted !== withheld.length) {
|
|
910
|
+
agent.reason = "COUNTS_MISMATCH";
|
|
911
|
+
base.reasonKind = "counts_mismatch";
|
|
912
|
+
return base;
|
|
913
|
+
}
|
|
914
|
+
base.filesOk = true;
|
|
915
|
+
return base;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// The artifact-level orchestrator for KINDS.AGENT_PACKET — both entrypoints (disk + bytes) route here
|
|
919
|
+
// through verifyParsedArtifact, so the two paths' verdicts are one code path (deep-equal by
|
|
920
|
+
// construction). Precedence mirrors the producer's `vh agent verify`: event/leaf/head/counts faults
|
|
921
|
+
// (naming the seq) dominate; then head binding, signature genuineness, and the vendor pin.
|
|
922
|
+
function verifyAgentPacketArtifact({ artifact, obj, pinned }) {
|
|
923
|
+
const { signedHead } = validateAgentPacketStructure(obj); // throws IOError on a malformed/foreign packet
|
|
924
|
+
const fileResult = verifyAgentSeal(obj);
|
|
925
|
+
const agent = fileResult.agent;
|
|
926
|
+
|
|
927
|
+
const signed = obj.headAttestation !== undefined;
|
|
928
|
+
let recoveredSigner = null;
|
|
929
|
+
let claimedSigner = null;
|
|
930
|
+
let signatureOk = null;
|
|
931
|
+
let signerMatchesVendor = null;
|
|
932
|
+
let headBound = null;
|
|
933
|
+
if (signed) {
|
|
934
|
+
claimedSigner = obj.headAttestation.signature.signer; // lowercase, structurally enforced
|
|
935
|
+
recoveredSigner = tryRecover(obj.headAttestation.attestation, obj.headAttestation.signature.signature);
|
|
936
|
+
signatureOk = recoveredSigner != null && recoveredSigner === claimedSigner;
|
|
937
|
+
if (agent.recomputedHead != null) {
|
|
938
|
+
// The signature must vouch for THIS session's RECOMPUTED head — a signature pasted from a
|
|
939
|
+
// different session recovers fine but binds a different { size, root }.
|
|
940
|
+
headBound =
|
|
941
|
+
signedHead.embeddedHead.size === agent.recomputedHead.size &&
|
|
942
|
+
signedHead.embeddedHead.root === agent.recomputedHead.root;
|
|
943
|
+
}
|
|
944
|
+
if (signatureOk && pinned != null) signerMatchesVendor = recoveredSigner === pinned;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
let accepted = true;
|
|
948
|
+
let reason = "OK";
|
|
949
|
+
if (!fileResult.filesOk) {
|
|
950
|
+
accepted = false;
|
|
951
|
+
reason = fileResult.reasonKind;
|
|
952
|
+
} else if (signed && headBound === false) {
|
|
953
|
+
accepted = false;
|
|
954
|
+
reason = "head_not_bound";
|
|
955
|
+
agent.reason = "HEAD_NOT_BOUND";
|
|
956
|
+
} else if (signed && !signatureOk) {
|
|
957
|
+
accepted = false;
|
|
958
|
+
reason = "bad_signature";
|
|
959
|
+
agent.reason = "SIGNATURE_FORGED";
|
|
960
|
+
} else if (signed && pinned != null && signerMatchesVendor !== true) {
|
|
961
|
+
accepted = false;
|
|
962
|
+
reason = "wrong_issuer";
|
|
963
|
+
agent.reason = "WRONG_VENDOR";
|
|
964
|
+
} else if (!signed && pinned != null) {
|
|
965
|
+
// Fail-closed pin: a stripped signature can never pass a pinned verify.
|
|
966
|
+
accepted = false;
|
|
967
|
+
reason = "unsigned_cannot_pin_vendor";
|
|
968
|
+
agent.reason = "NOT_SIGNED";
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const result = {
|
|
972
|
+
artifact,
|
|
973
|
+
kind: KINDS.AGENT_PACKET,
|
|
974
|
+
payloadKind: KINDS.AGENT_PACKET,
|
|
975
|
+
signed,
|
|
976
|
+
verdict: accepted ? "OK" : "REJECTED",
|
|
977
|
+
reason,
|
|
978
|
+
accepted,
|
|
979
|
+
recoveredSigner,
|
|
980
|
+
claimedSigner,
|
|
981
|
+
pinnedVendor: pinned,
|
|
982
|
+
signatureOk,
|
|
983
|
+
signerMatchesVendor,
|
|
984
|
+
sealedRoot: fileResult.sealedRoot,
|
|
985
|
+
recomputedRoot: fileResult.recomputedRoot,
|
|
986
|
+
rootMatches: fileResult.rootMatches,
|
|
987
|
+
counts: {
|
|
988
|
+
matched: fileResult.matched.length,
|
|
989
|
+
changed: fileResult.changed.length,
|
|
990
|
+
missing: 0,
|
|
991
|
+
escaped: 0,
|
|
992
|
+
unexpected: 0,
|
|
993
|
+
},
|
|
994
|
+
matched: fileResult.matched,
|
|
995
|
+
changed: fileResult.changed,
|
|
996
|
+
missing: [],
|
|
997
|
+
escaped: [],
|
|
998
|
+
unexpected: [],
|
|
999
|
+
agent,
|
|
1000
|
+
note: TRUST_NOTE,
|
|
1001
|
+
};
|
|
1002
|
+
return { result, code: accepted ? EXIT.OK : EXIT.REJECTED };
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// ---------------------------------------------------------------------------
|
|
1006
|
+
// The core verify orchestration over an ALREADY-PARSED artifact object + an injected file source. This
|
|
1007
|
+
// is the ONE engine BOTH entrypoints drive — `verifyArtifact` (disk: the CLI contract, byte-identical to
|
|
1008
|
+
// before this seam existed) and `verifyArtifactFromBytes` (in-memory map). It auto-detects the artifact
|
|
1009
|
+
// kind, decodes a signed container (recovering + pinning the signer), re-derives the root from
|
|
1010
|
+
// referenced bytes, and assembles a deterministic verdict. PURE: every read goes through `readEntry`.
|
|
1011
|
+
// Returns { result, code } — code is the EXIT-contract integer.
|
|
1012
|
+
// ---------------------------------------------------------------------------
|
|
1013
|
+
|
|
1014
|
+
function verifyParsedArtifact({ artifact, obj, vendor, readEntry }) {
|
|
1015
|
+
const kind = obj.kind;
|
|
1016
|
+
const pinned = vendor != null ? normalizeAddress(vendor, "--vendor") : null;
|
|
1017
|
+
|
|
1018
|
+
// AGENT-SESSION packet (T-68.3): SELF-CONTAINED — no sibling bytes, its own leaf/root convention and
|
|
1019
|
+
// its own in-packet signed head. Routed to the dedicated orchestrator above (`readEntry` unused).
|
|
1020
|
+
if (kind === KINDS.AGENT_PACKET) {
|
|
1021
|
+
return verifyAgentPacketArtifact({ artifact, obj, pinned });
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Detect signed vs bare and the underlying payload kind. A signed container wraps the embedded payload.
|
|
1025
|
+
let signed = false;
|
|
1026
|
+
let recoveredSigner = null;
|
|
1027
|
+
let claimedSigner = null;
|
|
1028
|
+
let signatureOk = null; // null = no signature on this artifact
|
|
1029
|
+
let payload = obj; // the (possibly embedded) thing whose root we re-derive
|
|
1030
|
+
let payloadKind = kind;
|
|
1031
|
+
|
|
1032
|
+
if (
|
|
1033
|
+
kind === KINDS.EVIDENCE_SEAL_SIGNED ||
|
|
1034
|
+
kind === KINDS.TRUST_SEAL_SIGNED ||
|
|
1035
|
+
kind === KINDS.DATASET_ATTESTATION_SIGNED ||
|
|
1036
|
+
kind === KINDS.DATASET_ATTESTATION_TIMESTAMPED
|
|
1037
|
+
) {
|
|
1038
|
+
signed = true;
|
|
1039
|
+
const dec = decodeSigned(obj);
|
|
1040
|
+
payload = dec.embedded;
|
|
1041
|
+
payloadKind = dec.embedded.kind;
|
|
1042
|
+
claimedSigner = dec.claimedSigner;
|
|
1043
|
+
recoveredSigner = tryRecover(dec.message, dec.signature);
|
|
1044
|
+
// signatureOk: the signature recovers AND matches the CLAIMED signer recorded in the container.
|
|
1045
|
+
signatureOk = recoveredSigner != null && recoveredSigner === claimedSigner;
|
|
1046
|
+
} else if (!Object.values(KINDS).includes(kind)) {
|
|
1047
|
+
throw new UsageError(
|
|
1048
|
+
`unrecognized artifact kind: ${JSON.stringify(kind)} ` +
|
|
1049
|
+
"(verify-vh understands evidence seals, reconciliation seals, dataset attestations, and proof bundles)"
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// Re-derive the root from the referenced bytes per the (underlying) kind.
|
|
1054
|
+
let fileResult;
|
|
1055
|
+
if (payloadKind === KINDS.EVIDENCE_SEAL) {
|
|
1056
|
+
fileResult = verifyEvidenceSealWith(payload, readEntry);
|
|
1057
|
+
} else if (payloadKind === KINDS.TRUST_SEAL) {
|
|
1058
|
+
fileResult = verifyTrustSealWith(payload, readEntry);
|
|
1059
|
+
} else if (payloadKind === KINDS.DATASET_ATTESTATION) {
|
|
1060
|
+
fileResult = verifyDatasetAttestation(payload);
|
|
1061
|
+
} else if (payloadKind === KINDS.PROOF) {
|
|
1062
|
+
fileResult = verifyProofBundle(payload);
|
|
1063
|
+
} else {
|
|
1064
|
+
throw new UsageError(
|
|
1065
|
+
`unrecognized embedded artifact kind: ${JSON.stringify(payloadKind)}`
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// --- Decide the verdict + the deterministic reason. ---
|
|
1070
|
+
// Precedence: a structural file tamper (CHANGED/MISSING/root mismatch) is a clean REJECTED. For a
|
|
1071
|
+
// SIGNED artifact, a broken signature is `bad_signature`; a recovered signer that does not equal the
|
|
1072
|
+
// pinned --vendor is `wrong_issuer`. Both are clean REJECTED verdicts (exit 3), never a crash.
|
|
1073
|
+
let reason = "OK";
|
|
1074
|
+
let accepted = true;
|
|
1075
|
+
|
|
1076
|
+
const escaped = fileResult.escaped || [];
|
|
1077
|
+
if (!fileResult.filesOk) {
|
|
1078
|
+
accepted = false;
|
|
1079
|
+
// path_escape DOMINATES: an artifact that tries to read outside its source is malicious by
|
|
1080
|
+
// construction (the threat model is a hostile producer probing the counterparty's filesystem), so it
|
|
1081
|
+
// is reported FIRST — never as a benign CHANGED/MISSING, and never with a leaked out-of-tree content
|
|
1082
|
+
// hash.
|
|
1083
|
+
if (escaped.length > 0) reason = "path_escape";
|
|
1084
|
+
else if (fileResult.changed.length > 0) reason = "CHANGED";
|
|
1085
|
+
else if (fileResult.missing.length > 0) reason = "MISSING";
|
|
1086
|
+
else if (fileResult.unexpected.length > 0) reason = "UNEXPECTED";
|
|
1087
|
+
else reason = "root_mismatch";
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// Signature checks (only for signed artifacts). A bad signature dominates the "issuer" check (you
|
|
1091
|
+
// cannot trust an issuer you cannot recover).
|
|
1092
|
+
let signerMatchesVendor = null;
|
|
1093
|
+
if (signed) {
|
|
1094
|
+
if (!signatureOk) {
|
|
1095
|
+
accepted = false;
|
|
1096
|
+
// bad_signature is the dominant reason ONLY if files were otherwise OK; if a file also changed we
|
|
1097
|
+
// still surface bad_signature because the signature is the trust root of a signed artifact.
|
|
1098
|
+
reason = "bad_signature";
|
|
1099
|
+
} else if (pinned != null) {
|
|
1100
|
+
signerMatchesVendor = recoveredSigner === pinned;
|
|
1101
|
+
if (!signerMatchesVendor) {
|
|
1102
|
+
accepted = false;
|
|
1103
|
+
// wrong_issuer only when the signature itself is sound but the signer is not the pinned vendor.
|
|
1104
|
+
if (fileResult.filesOk) reason = "wrong_issuer";
|
|
1105
|
+
else if (reason === "OK") reason = "wrong_issuer";
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
} else if (pinned != null) {
|
|
1109
|
+
// A --vendor pin on an UNSIGNED artifact cannot be satisfied (there is no signer to recover); this is
|
|
1110
|
+
// a clean REJECTED wrong_issuer-style verdict so a CI gate expecting a signed-by-vendor artifact fails.
|
|
1111
|
+
accepted = false;
|
|
1112
|
+
reason = "unsigned_cannot_pin_vendor";
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const verdict = accepted ? "OK" : "REJECTED";
|
|
1116
|
+
const code = accepted ? EXIT.OK : EXIT.REJECTED;
|
|
1117
|
+
|
|
1118
|
+
const result = {
|
|
1119
|
+
artifact,
|
|
1120
|
+
kind,
|
|
1121
|
+
payloadKind,
|
|
1122
|
+
signed,
|
|
1123
|
+
verdict,
|
|
1124
|
+
reason,
|
|
1125
|
+
accepted,
|
|
1126
|
+
recoveredSigner,
|
|
1127
|
+
claimedSigner,
|
|
1128
|
+
pinnedVendor: pinned,
|
|
1129
|
+
signatureOk,
|
|
1130
|
+
signerMatchesVendor,
|
|
1131
|
+
sealedRoot: fileResult.sealedRoot,
|
|
1132
|
+
recomputedRoot: fileResult.recomputedRoot,
|
|
1133
|
+
rootMatches: fileResult.rootMatches,
|
|
1134
|
+
counts: {
|
|
1135
|
+
matched: fileResult.matched.length,
|
|
1136
|
+
changed: fileResult.changed.length,
|
|
1137
|
+
missing: fileResult.missing.length,
|
|
1138
|
+
escaped: escaped.length,
|
|
1139
|
+
unexpected: fileResult.unexpected.length,
|
|
1140
|
+
},
|
|
1141
|
+
matched: fileResult.matched,
|
|
1142
|
+
changed: fileResult.changed,
|
|
1143
|
+
missing: fileResult.missing,
|
|
1144
|
+
escaped,
|
|
1145
|
+
unexpected: fileResult.unexpected,
|
|
1146
|
+
note: TRUST_NOTE,
|
|
1147
|
+
};
|
|
1148
|
+
if (fileResult.identityOnly) result.identityOnly = true;
|
|
1149
|
+
if (fileResult.proof) result.proof = fileResult.proof;
|
|
1150
|
+
|
|
1151
|
+
return { result, code };
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// ---------------------------------------------------------------------------
|
|
1155
|
+
// The PURE revocation fold for the bytes path. Semantically identical to revocation.loadAndApply (the
|
|
1156
|
+
// disk integration) once the entries are in hand: resolve the as-of instant (defaulting to nowISO),
|
|
1157
|
+
// normalize the caller-supplied revocations input (a JSON string, a container object, or an array of
|
|
1158
|
+
// either), fold the decision onto the result, and recompute the exit code. Uses ONLY the pure decision
|
|
1159
|
+
// functions (./lib/revocation-core.js via the revocation re-exports) — never the fs-backed reader.
|
|
1160
|
+
// ---------------------------------------------------------------------------
|
|
1161
|
+
|
|
1162
|
+
function applyRevocationsDecision(result, revocationsInput, asOf, nowISO) {
|
|
1163
|
+
const resolved = revocation.resolveAsOf(asOf, nowISO);
|
|
1164
|
+
const entries = revocation.normalizeRevocationsInput(revocationsInput);
|
|
1165
|
+
const downgraded = revocation.applyToVerifyResult({ result, revocations: entries, asOf: resolved.asOf });
|
|
1166
|
+
downgraded.trustAsOfDefaulted = resolved.defaulted;
|
|
1167
|
+
return { result: downgraded, code: downgraded.accepted ? EXIT.OK : EXIT.REJECTED };
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// ---------------------------------------------------------------------------
|
|
1171
|
+
// THE IN-MEMORY FILE SOURCE + BYTES ENTRYPOINT (T-66.1).
|
|
1172
|
+
//
|
|
1173
|
+
// `verifyArtifactFromBytes({ artifactText, files, vendor, revocationsText, asOf, nowISO, artifactName })`
|
|
1174
|
+
// drives the EXACT engine above over caller-supplied bytes:
|
|
1175
|
+
// * `artifactText` — the artifact JSON as a STRING (what a browser read out of a dropped file);
|
|
1176
|
+
// * `files` — a plain `{ relPath: Uint8Array|Buffer }` map of the packet's referenced bytes;
|
|
1177
|
+
// * `vendor` — optional 0x-address pin (same semantics as `--vendor`);
|
|
1178
|
+
// * `revocationsText` — optional revocations input (JSON text / container / array; same semantics as
|
|
1179
|
+
// the CONTENT of a `--revocations` file), with optional `asOf` (canonical ISO instant) + `nowISO`;
|
|
1180
|
+
// * `artifactName` — optional label used verbatim as `result.artifact` (defaults below).
|
|
1181
|
+
//
|
|
1182
|
+
// CONTRACT — NEVER THROWS. Hostile input (non-JSON artifact text, an oversized / absolute / `..` map
|
|
1183
|
+
// key, a non-bytes map value, a malformed vendor or asOf) is NAMED-rejected: the return value is
|
|
1184
|
+
// { ok, code, result, error }
|
|
1185
|
+
// where a computed verdict carries `result` (the SAME structured shape `verifyArtifact` returns — the
|
|
1186
|
+
// two are DEEP-EQUAL on identical inputs) + `error: null`, and an input problem carries `result: null` +
|
|
1187
|
+
// `error: { name: "UsageError"|"IOError", code, message }` with the exact defect named. The verdict
|
|
1188
|
+
// classes (missing / extra / content-mismatch / wrong-vendor / tampered-signature / path_escape /
|
|
1189
|
+
// revoked) derive from the MAP exactly as the disk path derives them from the directory.
|
|
1190
|
+
// ---------------------------------------------------------------------------
|
|
1191
|
+
|
|
1192
|
+
// The largest relPath key the in-memory map accepts. Sealed relPaths are short; a multi-kilobyte "key"
|
|
1193
|
+
// is hostile input (an attempted resource-exhaustion / log-flooding vector), rejected by NAME up front.
|
|
1194
|
+
const MAX_RELPATH_CHARS = 4096;
|
|
1195
|
+
|
|
1196
|
+
// PURE string-level confinement for an in-memory relPath — the map-source mirror of the disk source's
|
|
1197
|
+
// string checks (absolute anywhere, or any `..` traversal component, is hostile). Windows-style drive
|
|
1198
|
+
// and UNC prefixes are treated as absolute here too: an in-memory map NEVER has a legitimate absolute
|
|
1199
|
+
// key, whatever platform authored the artifact.
|
|
1200
|
+
function isTraversalOrAbsoluteRelPath(relPath) {
|
|
1201
|
+
if (typeof relPath !== "string" || relPath.length === 0) return true;
|
|
1202
|
+
if (relPath.charAt(0) === "/" || relPath.charAt(0) === "\\") return true;
|
|
1203
|
+
if (/^[A-Za-z]:[\\/]/.test(relPath)) return true;
|
|
1204
|
+
if (relPath.split(/[\\/]/).includes("..")) return true;
|
|
1205
|
+
return false;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// Validate the caller's `{ relPath: bytes }` map SHAPE up front so a hostile map is NAMED-rejected
|
|
1209
|
+
// before any verification work (and before any key is dereferenced). Throws UsageError; the entrypoint
|
|
1210
|
+
// converts that into the structured `{ error }` return — never an uncaught throw.
|
|
1211
|
+
function validateFilesMap(files) {
|
|
1212
|
+
if (files == null || typeof files !== "object" || Array.isArray(files)) {
|
|
1213
|
+
throw new UsageError(
|
|
1214
|
+
"verifyArtifactFromBytes requires `files` as a plain { relPath: Uint8Array|Buffer } object map"
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1217
|
+
for (const key of Object.keys(files)) {
|
|
1218
|
+
if (key.length === 0) {
|
|
1219
|
+
throw new UsageError("files map contains an empty relPath key");
|
|
1220
|
+
}
|
|
1221
|
+
if (key.length > MAX_RELPATH_CHARS) {
|
|
1222
|
+
throw new UsageError(
|
|
1223
|
+
`files map key exceeds ${MAX_RELPATH_CHARS} characters (oversized relPath, starts: ` +
|
|
1224
|
+
`${JSON.stringify(key.slice(0, 64))})`
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1227
|
+
if (isTraversalOrAbsoluteRelPath(key)) {
|
|
1228
|
+
throw new UsageError(
|
|
1229
|
+
`files map key is not a confined relative path: ${JSON.stringify(key.slice(0, 256))}`
|
|
1230
|
+
);
|
|
1231
|
+
}
|
|
1232
|
+
const v = files[key];
|
|
1233
|
+
if (!(v instanceof Uint8Array)) {
|
|
1234
|
+
throw new UsageError(
|
|
1235
|
+
`files map value for ${JSON.stringify(key.slice(0, 256))} must be a Uint8Array/Buffer of the file's bytes`
|
|
1236
|
+
);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// The in-memory `readEntry` source over an (already-validated) map: a hostile relPath from the ARTIFACT
|
|
1242
|
+
// is `escaped` (the same string-level rules as the disk source — so absolute/`..` seal entries produce
|
|
1243
|
+
// the identical path_escape verdict), an absent key is `missing`, and a present key answers its bytes.
|
|
1244
|
+
// Lookups use an own-property check so `__proto__`/`constructor` style keys can never smuggle
|
|
1245
|
+
// prototype-chain values in as file bytes.
|
|
1246
|
+
function makeMapReadEntry(files) {
|
|
1247
|
+
return function readEntry(relPath) {
|
|
1248
|
+
if (isTraversalOrAbsoluteRelPath(relPath)) return { status: "escaped" };
|
|
1249
|
+
if (!Object.prototype.hasOwnProperty.call(files, relPath)) return { status: "missing" };
|
|
1250
|
+
return { status: "ok", bytes: files[relPath] };
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function verifyArtifactFromBytes(params) {
|
|
1255
|
+
try {
|
|
1256
|
+
if (params == null || typeof params !== "object" || Array.isArray(params)) {
|
|
1257
|
+
throw new UsageError(
|
|
1258
|
+
"verifyArtifactFromBytes requires a params object: " +
|
|
1259
|
+
"{ artifactText, files, vendor?, revocationsText?, asOf?, nowISO?, artifactName? }"
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
const { artifactText, files, vendor, revocationsText, asOf, nowISO, artifactName } = params;
|
|
1263
|
+
if (typeof artifactText !== "string") {
|
|
1264
|
+
throw new UsageError("verifyArtifactFromBytes requires `artifactText` (the artifact JSON as a string)");
|
|
1265
|
+
}
|
|
1266
|
+
validateFilesMap(files);
|
|
1267
|
+
|
|
1268
|
+
// Mirror the CLI's flag-shape gate (parseArgs): asOf only means something alongside revocations, and
|
|
1269
|
+
// must be a canonical ISO-8601 UTC instant — a malformed one is a NAMED usage rejection up front,
|
|
1270
|
+
// never a mid-verify throw.
|
|
1271
|
+
if (asOf !== undefined && asOf !== null && (revocationsText === undefined || revocationsText === null)) {
|
|
1272
|
+
throw new UsageError(
|
|
1273
|
+
"asOf requires revocationsText (it pins the instant the revocation decision is made AS OF)"
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
if (asOf !== undefined && asOf !== null) {
|
|
1277
|
+
const ms = Date.parse(asOf);
|
|
1278
|
+
if (
|
|
1279
|
+
typeof asOf !== "string" ||
|
|
1280
|
+
!revocation.ISO_INSTANT_RE.test(asOf) ||
|
|
1281
|
+
Number.isNaN(ms) ||
|
|
1282
|
+
new Date(ms).toISOString() !== asOf
|
|
1283
|
+
) {
|
|
1284
|
+
throw new UsageError(
|
|
1285
|
+
`invalid asOf: ${String(asOf)} (expected a canonical ISO-8601 UTC instant, e.g. 2026-06-01T00:00:00.000Z)`
|
|
1286
|
+
);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
const label = artifactName != null ? String(artifactName) : "(in-memory artifact)";
|
|
1291
|
+
let obj;
|
|
1292
|
+
try {
|
|
1293
|
+
obj = JSON.parse(artifactText);
|
|
1294
|
+
} catch (e) {
|
|
1295
|
+
throw new IOError(`artifact ${label} is not valid JSON: ${e.message}`);
|
|
1296
|
+
}
|
|
1297
|
+
if (obj == null || typeof obj !== "object" || Array.isArray(obj)) {
|
|
1298
|
+
throw new IOError(`artifact ${label} must be a JSON object`);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
const { result, code } = verifyParsedArtifact({
|
|
1302
|
+
artifact: label,
|
|
1303
|
+
obj,
|
|
1304
|
+
vendor,
|
|
1305
|
+
readEntry: makeMapReadEntry(files),
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
// OPTIONAL recipient-side TRUST-DECISION-AS-OF, from caller-supplied revocations INPUT (never a
|
|
1309
|
+
// filesystem read). Same downgrade math as the disk path's revocation.loadAndApply, so the two
|
|
1310
|
+
// paths' results stay deep-equal on identical inputs.
|
|
1311
|
+
if (revocationsText !== undefined && revocationsText !== null) {
|
|
1312
|
+
let applied;
|
|
1313
|
+
try {
|
|
1314
|
+
applied = applyRevocationsDecision(result, revocationsText, asOf, nowISO || new Date().toISOString());
|
|
1315
|
+
} catch (e) {
|
|
1316
|
+
// A non-JSON / wrong-shape revocations input is the bytes-path analogue of an unreadable
|
|
1317
|
+
// --revocations file: a NAMED IO-class rejection, never a silently-skipped downgrade.
|
|
1318
|
+
throw new IOError(`cannot evaluate revocations: ${e.message}`);
|
|
1319
|
+
}
|
|
1320
|
+
return { ok: applied.result.accepted, code: applied.code, result: applied.result, error: null };
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
return { ok: result.accepted, code, result, error: null };
|
|
1324
|
+
} catch (e) {
|
|
1325
|
+
const isUsage = e instanceof UsageError;
|
|
1326
|
+
const code = isUsage ? EXIT.USAGE : EXIT.IO;
|
|
1327
|
+
return {
|
|
1328
|
+
ok: false,
|
|
1329
|
+
code,
|
|
1330
|
+
result: null,
|
|
1331
|
+
error: {
|
|
1332
|
+
name: isUsage ? "UsageError" : "IOError",
|
|
1333
|
+
code,
|
|
1334
|
+
message: String(e && e.message ? e.message : e),
|
|
1335
|
+
},
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// ============================= END VERIFY-VH PURE ENGINE (T-66.1) =============================
|
|
1341
|
+
|
|
1342
|
+
// ===================================================================================================
|
|
1343
|
+
// ANCHORED-RECEIPT OFFLINE BINDING VERIFY (T-70.4) — `verify-vh <receipt> --anchored-artifact <seal>`.
|
|
1344
|
+
//
|
|
1345
|
+
// WHY THIS EXISTS
|
|
1346
|
+
// `vh anchor-artifact` (EPIC-70) emits a canonical `vh-anchored-receipt@1` container binding ONE
|
|
1347
|
+
// sealed artifact's digest to an on-chain registry record. Its OFFLINE binding leg is pure hashing —
|
|
1348
|
+
// but until T-70.4 it ran ONLY through the producer `cli/` stack (which loads `ethers` at module
|
|
1349
|
+
// load), so the family's zero-install "verify without the producer's stack" promise did not reach
|
|
1350
|
+
// the receipt. This section closes that gap: it is an INDEPENDENT, dependency-free port of the
|
|
1351
|
+
// producer core `cli/core/anchor-binding.js` — the receipt container validation, the CLOSED
|
|
1352
|
+
// six-kind digest table, and the binding verdict — written entirely against the verifier's OWN
|
|
1353
|
+
// primitives (lib/merkle keccak, lib/canonical, Node-core sha256). NO `ethers`, NO `cli/` import.
|
|
1354
|
+
//
|
|
1355
|
+
// WHAT IT CHECKS (and what it does NOT)
|
|
1356
|
+
// OFFLINE binding leg ONLY: the receipt is validated STRICTLY (unknown/missing fields, a drifted
|
|
1357
|
+
// trust note, malformed chain facts — each a named `bad-receipt`), the artifact's ONE canonical
|
|
1358
|
+
// digest is RECOMPUTED through the SAME closed kind table the producer uses (each leg re-validating
|
|
1359
|
+
// the artifact through a strict port of its shipped validator first), and the full
|
|
1360
|
+
// { kind, digest, how } triple must match — `kind-mismatch` / `digest-mismatch` / `how-mismatch`
|
|
1361
|
+
// are the specific named rejects, exactly the producer's verdict vocabulary. The receipt's `chain`
|
|
1362
|
+
// facts remain the ANCHORER'S CLAIM: re-checking them against the chain needs a chain endpoint by
|
|
1363
|
+
// definition and stays with the producer cli (`vh verify-anchored --rpc --contract`).
|
|
1364
|
+
//
|
|
1365
|
+
// PARITY DISCIPLINE (pinned by test/verifier.standalone.test.js)
|
|
1366
|
+
// Every wire-format constant here (the receipt kind, the verbatim ANCHOR_TRUST_NOTE, the reason
|
|
1367
|
+
// codes, the closed kind list, the per-kind derivation-rule `how` strings) MUST equal the producer
|
|
1368
|
+
// core's byte-for-byte, and the verdicts on identical inputs MUST match the producer's — the test
|
|
1369
|
+
// asserts both mechanically, so neither side can drift alone. TOTAL: hostile input yields a named
|
|
1370
|
+
// { ok:false, reason, field?, detail? }, never a throw.
|
|
1371
|
+
// ===================================================================================================
|
|
1372
|
+
|
|
1373
|
+
// The container kind + the standing trust note, VERBATIM the producer's (cli/core/anchor-binding.js).
|
|
1374
|
+
const ANCHORED_RECEIPT_KIND = "vh-anchored-receipt@1";
|
|
1375
|
+
|
|
1376
|
+
const ANCHOR_TRUST_NOTE =
|
|
1377
|
+
"This anchored receipt binds the artifact digest above to an on-chain registry record. A receipt " +
|
|
1378
|
+
"from a LOCAL dev chain proves MECHANISM only and is worth NOTHING publicly until a human deploys " +
|
|
1379
|
+
"the registry (STRATEGY.md P-2). On a public chain it proves ONLY that an on-chain record binds " +
|
|
1380
|
+
"this exact digest at a block whose timestamp BOUNDS existence — as trustworthy as the chain + " +
|
|
1381
|
+
"YOUR pinned contract address — NOT the artifact's truth, NOT faithful recording, NOT attribution " +
|
|
1382
|
+
"beyond the anchoring key. The `chain` facts in this receipt are the anchorer's claim until " +
|
|
1383
|
+
"re-checked against the chain (`vh verify-anchored --rpc`).";
|
|
1384
|
+
|
|
1385
|
+
// The stable, named reason codes — the producer's verdict contract, byte-for-byte.
|
|
1386
|
+
const ANCHOR_REASONS = Object.freeze({
|
|
1387
|
+
NOT_AN_OBJECT: "not-an-object",
|
|
1388
|
+
UNKNOWN_KIND: "unknown-kind",
|
|
1389
|
+
EVIDENCE_SEAL_INVALID: "evidence-seal-invalid",
|
|
1390
|
+
AGENT_PACKET_INVALID: "agent-packet-invalid",
|
|
1391
|
+
JOURNAL_TREE_HEAD_INVALID: "journal-tree-head-invalid",
|
|
1392
|
+
TRUSTLEDGER_SEAL_INVALID: "trustledger-seal-invalid",
|
|
1393
|
+
DATASET_ATTESTATION_INVALID: "dataset-attestation-invalid",
|
|
1394
|
+
PARCEL_ATTESTATION_INVALID: "parcel-attestation-invalid",
|
|
1395
|
+
BAD_ARGS: "bad-args",
|
|
1396
|
+
BAD_DIGEST: "bad-digest",
|
|
1397
|
+
BAD_HOW: "bad-how",
|
|
1398
|
+
BAD_LABEL: "bad-label",
|
|
1399
|
+
BAD_CHAIN: "bad-chain",
|
|
1400
|
+
BAD_RECEIPT: "bad-receipt",
|
|
1401
|
+
DIGEST_MISMATCH: "digest-mismatch",
|
|
1402
|
+
KIND_MISMATCH: "kind-mismatch",
|
|
1403
|
+
HOW_MISMATCH: "how-mismatch",
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
// The two closed-table kinds this verifier did not already name (the other four reuse KINDS above).
|
|
1407
|
+
const ANCHOR_JOURNAL_TREE_HEAD_KIND = "vh.journal-tree-head";
|
|
1408
|
+
const ANCHOR_PARCEL_ATTESTATION_KIND = "verifyhash.parcel-attestation";
|
|
1409
|
+
|
|
1410
|
+
// The CLOSED, frozen kind table — same six kinds, same order as the producer core.
|
|
1411
|
+
const ANCHOR_ARTIFACT_KINDS = Object.freeze([
|
|
1412
|
+
KINDS.EVIDENCE_SEAL, // "vh.evidence-seal"
|
|
1413
|
+
KINDS.AGENT_PACKET, // "vh.agent-session-packet"
|
|
1414
|
+
ANCHOR_JOURNAL_TREE_HEAD_KIND, // "vh.journal-tree-head"
|
|
1415
|
+
KINDS.TRUST_SEAL, // "trustledger.reconcile-seal"
|
|
1416
|
+
KINDS.DATASET_ATTESTATION, // "verifyhash.dataset-attestation"
|
|
1417
|
+
ANCHOR_PARCEL_ATTESTATION_KIND, // "verifyhash.parcel-attestation"
|
|
1418
|
+
]);
|
|
1419
|
+
|
|
1420
|
+
// Canonical-case wire shapes (the receipt is canonical LOWERCASE; artifacts may carry mixed-case hex
|
|
1421
|
+
// exactly where the producer validators accept it).
|
|
1422
|
+
const ANCHOR_HEX32_LC_RE = /^0x[0-9a-f]{64}$/;
|
|
1423
|
+
const ANCHOR_ADDRESS_LC_RE = /^0x[0-9a-f]{40}$/;
|
|
1424
|
+
const ANCHOR_CONTROL_CHAR_RE = /[\u0000-\u001f\u007f]/;
|
|
1425
|
+
const ANCHOR_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
1426
|
+
|
|
1427
|
+
function anchorIsPlainObject(v) {
|
|
1428
|
+
return v != null && typeof v === "object" && !Array.isArray(v);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// The per-kind derivation rules (`how`) — VERBATIM the producer's HOW_FIXED table. These are WIRE
|
|
1432
|
+
// FORMAT (bound into every receipt), so they name the producer's files even though THIS verifier
|
|
1433
|
+
// re-derives the digest with its own independent code: the rule describes the derivation, and the
|
|
1434
|
+
// parity test pins these strings against the producer core byte-for-byte.
|
|
1435
|
+
const ANCHOR_HOW_FIXED = Object.freeze({
|
|
1436
|
+
[KINDS.EVIDENCE_SEAL]:
|
|
1437
|
+
"digest = the evidence packet's `root` (sorted-pair Merkle root over its path-bound file leaves), " +
|
|
1438
|
+
"re-derived by cli/evidence.js readSeal before extraction",
|
|
1439
|
+
[KINDS.AGENT_PACKET]:
|
|
1440
|
+
"digest = the agent-session packet's verified head `root` (RFC-6962 ordered Merkle root over the " +
|
|
1441
|
+
"event leaves), re-derived by cli/agent.js verifyPacket before extraction",
|
|
1442
|
+
[KINDS.TRUST_SEAL]:
|
|
1443
|
+
"digest = the TrustLedger sealfile's `root` (Merkle root over its committed input/output leaves + " +
|
|
1444
|
+
"verdict header), re-derived by trustledger/seal.js readSeal before extraction",
|
|
1445
|
+
[KINDS.DATASET_ATTESTATION]:
|
|
1446
|
+
"digest = 0x + sha256 over the canonical UNSIGNED dataset-attestation bytes, exactly as " +
|
|
1447
|
+
"`vh dataset timestamp-request` computes it (cli/core/timestamp.js sha256Hex)",
|
|
1448
|
+
[ANCHOR_PARCEL_ATTESTATION_KIND]:
|
|
1449
|
+
"digest = 0x + sha256 over the canonical UNSIGNED parcel-attestation bytes, exactly as " +
|
|
1450
|
+
"`vh parcel timestamp-request` computes it (cli/core/timestamp.js sha256Hex)",
|
|
1451
|
+
});
|
|
1452
|
+
|
|
1453
|
+
function anchorJournalHow(size) {
|
|
1454
|
+
return (
|
|
1455
|
+
`digest = the journal tree head \`root\` (RFC-6962 ordered Merkle root, cli/journal-log.js ` +
|
|
1456
|
+
`treeHead) over ${size} entries; the head size is bound into this derivation rule`
|
|
1457
|
+
);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
const ANCHOR_JOURNAL_HOW_RE =
|
|
1461
|
+
/^digest = the journal tree head `root` \(RFC-6962 ordered Merkle root, cli\/journal-log\.js treeHead\) over (0|[1-9][0-9]*) entries; the head size is bound into this derivation rule$/;
|
|
1462
|
+
|
|
1463
|
+
function anchorHowValidFor(kind, how) {
|
|
1464
|
+
if (typeof how !== "string") return false;
|
|
1465
|
+
if (kind === ANCHOR_JOURNAL_TREE_HEAD_KIND) {
|
|
1466
|
+
const m = ANCHOR_JOURNAL_HOW_RE.exec(how);
|
|
1467
|
+
return m !== null && Number.isSafeInteger(Number(m[1]));
|
|
1468
|
+
}
|
|
1469
|
+
return how === ANCHOR_HOW_FIXED[kind];
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
function anchorOk(digest, kind, how) {
|
|
1473
|
+
return { ok: true, digest, kind, how };
|
|
1474
|
+
}
|
|
1475
|
+
function anchorNo(reason, detail) {
|
|
1476
|
+
return detail === undefined ? { ok: false, reason } : { ok: false, reason, detail };
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
// ---------------------------------------------------------------------------------------------------
|
|
1480
|
+
// The per-kind STRICT validators + digest extraction — independent ports of the artifacts' shipped
|
|
1481
|
+
// validators (the messages mirror the producers' so the named verdict a counterparty reads is the
|
|
1482
|
+
// same either way). Each leg is TOTAL: a defect is a named reject, never a throw out of this section.
|
|
1483
|
+
// ---------------------------------------------------------------------------------------------------
|
|
1484
|
+
|
|
1485
|
+
// vh.evidence-seal — a strict port of cli/core/packetseal.js validateSeal under the evidence config
|
|
1486
|
+
// (kind/schemaVersion/note pinned, per-entry leaf self-consistency, NO header, and the LOAD-BEARING
|
|
1487
|
+
// root re-derivation from the seal's OWN (relPath, contentHash) leaves via the verifier's merkle lib).
|
|
1488
|
+
const ANCHOR_EVIDENCE_TRUST_NOTE =
|
|
1489
|
+
"This evidence seal is TAMPER-EVIDENT + OFFLINE-RECOMPUTABLE, NOT a trusted timestamp. Its Merkle " +
|
|
1490
|
+
"`root` commits to the full set of (relPath, content) pairs in the directory: any edit, rename, add, " +
|
|
1491
|
+
"or remove changes the root, and verify RE-DERIVES the root from the bytes you hold and LOCALIZES the " +
|
|
1492
|
+
"change to the exact file (MATCH / CHANGED / MISSING / UNEXPECTED). It does NOT prove WHEN the sealing " +
|
|
1493
|
+
'happened ("sealed at T" rides the human-owned signing/timestamp trust-root, STRATEGY.md P-3) and it ' +
|
|
1494
|
+
"is NOT a legal opinion. The packet is an UNTRUSTED transport container: verify never trusts the " +
|
|
1495
|
+
"packet's own stored hashes.";
|
|
1496
|
+
const ANCHOR_EVIDENCE_SCHEMA_VERSIONS = Object.freeze([1]);
|
|
1497
|
+
|
|
1498
|
+
// Shared strict per-entry + root checks for the two packetseal-family legs. `label` carries the
|
|
1499
|
+
// product wording; `headerLeaf` (when non-null) is folded into the root as the reserved header entry.
|
|
1500
|
+
function anchorCheckSealEntries(entries, label, where, seenRelPath, flat, headerRelPath) {
|
|
1501
|
+
entries.forEach((entry, i) => {
|
|
1502
|
+
if (!anchorIsPlainObject(entry)) {
|
|
1503
|
+
throw new Error(`${label} ${where}[${i}] must be an object`);
|
|
1504
|
+
}
|
|
1505
|
+
if (typeof entry.relPath !== "string" || entry.relPath.length === 0) {
|
|
1506
|
+
throw new Error(`${label} ${where}[${i}].relPath must be a non-empty string`);
|
|
1507
|
+
}
|
|
1508
|
+
if (headerRelPath !== null && entry.relPath === headerRelPath) {
|
|
1509
|
+
throw new Error(
|
|
1510
|
+
`${label} ${where}[${i}].relPath ${JSON.stringify(entry.relPath)} is reserved for the seal header`
|
|
1511
|
+
);
|
|
1512
|
+
}
|
|
1513
|
+
if (seenRelPath.has(entry.relPath)) {
|
|
1514
|
+
throw new Error(`${label} has a duplicate relPath across the file set: ${JSON.stringify(entry.relPath)}`);
|
|
1515
|
+
}
|
|
1516
|
+
seenRelPath.add(entry.relPath);
|
|
1517
|
+
for (const f of ["contentHash", "leaf"]) {
|
|
1518
|
+
if (typeof entry[f] !== "string" || !merkle.HEX32_RE.test(entry[f])) {
|
|
1519
|
+
throw new Error(
|
|
1520
|
+
`${label} ${where}[${i}].${f} must be a 0x-prefixed 32-byte hex string, got: ${String(entry[f])}`
|
|
1521
|
+
);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
const expectedLeaf = merkle.pathLeaf(entry.relPath, entry.contentHash);
|
|
1525
|
+
if (entry.leaf.toLowerCase() !== expectedLeaf.toLowerCase()) {
|
|
1526
|
+
throw new Error(
|
|
1527
|
+
`${label} ${where}[${i}].leaf is inconsistent with its relPath+contentHash ` +
|
|
1528
|
+
`(expected ${expectedLeaf}, got ${entry.leaf})`
|
|
1529
|
+
);
|
|
1530
|
+
}
|
|
1531
|
+
flat.push({ relPath: entry.relPath, contentHash: entry.contentHash });
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
function anchorValidateEvidenceSeal(obj) {
|
|
1536
|
+
const label = "evidence seal";
|
|
1537
|
+
if (!anchorIsPlainObject(obj)) throw new Error(`${label} must be a JSON object`);
|
|
1538
|
+
if (obj.kind !== KINDS.EVIDENCE_SEAL) {
|
|
1539
|
+
throw new Error(`not a ${label} (kind: ${JSON.stringify(obj.kind)}; expected ${JSON.stringify(KINDS.EVIDENCE_SEAL)})`);
|
|
1540
|
+
}
|
|
1541
|
+
if (!ANCHOR_EVIDENCE_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
|
|
1542
|
+
throw new Error(
|
|
1543
|
+
`unsupported ${label} schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
|
|
1544
|
+
`(this build understands ${JSON.stringify(ANCHOR_EVIDENCE_SCHEMA_VERSIONS)})`
|
|
1545
|
+
);
|
|
1546
|
+
}
|
|
1547
|
+
if (obj.note !== ANCHOR_EVIDENCE_TRUST_NOTE) {
|
|
1548
|
+
throw new Error(`${label} \`note\` must be the standing trust note (caveat must not drift)`);
|
|
1549
|
+
}
|
|
1550
|
+
if (typeof obj.root !== "string" || !merkle.HEX32_RE.test(obj.root)) {
|
|
1551
|
+
throw new Error(`${label} root must be a 0x-prefixed 32-byte hex string, got: ${String(obj.root)}`);
|
|
1552
|
+
}
|
|
1553
|
+
if (!Array.isArray(obj.files) || obj.files.length === 0) {
|
|
1554
|
+
throw new Error(`${label} \`files\` must be a non-empty array`);
|
|
1555
|
+
}
|
|
1556
|
+
const flat = [];
|
|
1557
|
+
anchorCheckSealEntries(obj.files, label, "files", new Set(), flat, null);
|
|
1558
|
+
if (obj.fileCount !== undefined && obj.fileCount !== obj.files.length) {
|
|
1559
|
+
throw new Error(`${label} fileCount (${String(obj.fileCount)}) does not match the files length (${obj.files.length})`);
|
|
1560
|
+
}
|
|
1561
|
+
if (obj.header !== undefined) {
|
|
1562
|
+
throw new Error(`${label} carries a header but its config declares none`);
|
|
1563
|
+
}
|
|
1564
|
+
const rederived = merkle.rootFromFlat(flat);
|
|
1565
|
+
if (rederived.toLowerCase() !== obj.root.toLowerCase()) {
|
|
1566
|
+
throw new Error(
|
|
1567
|
+
`${label} root does not re-derive from its listed entries ` +
|
|
1568
|
+
`(expected ${rederived}, got ${obj.root}) — the seal is internally inconsistent ` +
|
|
1569
|
+
"(a file was edited without updating the root)"
|
|
1570
|
+
);
|
|
1571
|
+
}
|
|
1572
|
+
return obj;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
function anchorEvidenceDigest(artifact) {
|
|
1576
|
+
try {
|
|
1577
|
+
anchorValidateEvidenceSeal(artifact);
|
|
1578
|
+
} catch (e) {
|
|
1579
|
+
return anchorNo(ANCHOR_REASONS.EVIDENCE_SEAL_INVALID, e && e.message ? e.message : String(e));
|
|
1580
|
+
}
|
|
1581
|
+
return anchorOk(artifact.root.toLowerCase(), KINDS.EVIDENCE_SEAL, ANCHOR_HOW_FIXED[KINDS.EVIDENCE_SEAL]);
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// vh.agent-session-packet — REUSES this verifier's OWN independent agent engine verbatim: the strict
|
|
1585
|
+
// packet-structure validation + the authoritative per-event/leaf/root/counts recompute, PLUS (when a
|
|
1586
|
+
// headAttestation is present) the head-binding and signature-genuineness checks — the exact facts the
|
|
1587
|
+
// producer's `agent.verifyPacket` gates the digest on (a vendor pin is not part of digest extraction).
|
|
1588
|
+
function anchorAgentDigest(artifact) {
|
|
1589
|
+
let structure;
|
|
1590
|
+
try {
|
|
1591
|
+
structure = validateAgentPacketStructure(artifact);
|
|
1592
|
+
} catch (e) {
|
|
1593
|
+
return anchorNo(ANCHOR_REASONS.AGENT_PACKET_INVALID, e && e.message ? e.message : String(e));
|
|
1594
|
+
}
|
|
1595
|
+
const fileResult = verifyAgentSeal(artifact);
|
|
1596
|
+
const agent = fileResult.agent;
|
|
1597
|
+
const seqOf = () => (agent.seq !== null && agent.seq !== undefined ? ` at seq ${agent.seq}` : "");
|
|
1598
|
+
if (!fileResult.filesOk) {
|
|
1599
|
+
const reason = agent.reason || fileResult.reasonKind || "REJECTED";
|
|
1600
|
+
return anchorNo(ANCHOR_REASONS.AGENT_PACKET_INVALID, `packet verify REJECTED: ${reason}${seqOf()}`);
|
|
1601
|
+
}
|
|
1602
|
+
if (artifact.headAttestation !== undefined) {
|
|
1603
|
+
const embedded = structure.signedHead.embeddedHead;
|
|
1604
|
+
const bound =
|
|
1605
|
+
embedded.size === agent.recomputedHead.size && embedded.root === agent.recomputedHead.root;
|
|
1606
|
+
if (!bound) {
|
|
1607
|
+
return anchorNo(ANCHOR_REASONS.AGENT_PACKET_INVALID, "packet verify REJECTED: HEAD_NOT_BOUND");
|
|
1608
|
+
}
|
|
1609
|
+
const claimed = artifact.headAttestation.signature.signer; // lowercase, structurally enforced
|
|
1610
|
+
const recovered = tryRecover(artifact.headAttestation.attestation, artifact.headAttestation.signature.signature);
|
|
1611
|
+
if (recovered == null || recovered !== claimed) {
|
|
1612
|
+
return anchorNo(ANCHOR_REASONS.AGENT_PACKET_INVALID, "packet verify REJECTED: SIGNATURE_FORGED");
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
return anchorOk(fileResult.recomputedRoot, KINDS.AGENT_PACKET, ANCHOR_HOW_FIXED[KINDS.AGENT_PACKET]);
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// vh.journal-tree-head — the bare { size, root } commitment or its kind-tagged twin. The empty-root
|
|
1619
|
+
// constant is re-derived HERE from the family's domain string with the verifier's own keccak (equal
|
|
1620
|
+
// to cli/journal-log.js EMPTY_ROOT — pinned by the parity test).
|
|
1621
|
+
const ANCHOR_JOURNAL_EMPTY_ROOT = merkle.hashBytes(Buffer.from(AGENT_EMPTY_ROOT_DOMAIN, "utf8"));
|
|
1622
|
+
|
|
1623
|
+
function anchorJournalHeadDigest(artifact, tagged) {
|
|
1624
|
+
const allowed = tagged ? ["kind", "size", "root"] : ["size", "root"];
|
|
1625
|
+
for (const k of Object.keys(artifact)) {
|
|
1626
|
+
if (!allowed.includes(k)) {
|
|
1627
|
+
return anchorNo(
|
|
1628
|
+
ANCHOR_REASONS.JOURNAL_TREE_HEAD_INVALID,
|
|
1629
|
+
`journal tree head has unknown field: ${JSON.stringify(k)}`
|
|
1630
|
+
);
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
if (!Number.isSafeInteger(artifact.size) || artifact.size < 0) {
|
|
1634
|
+
return anchorNo(
|
|
1635
|
+
ANCHOR_REASONS.JOURNAL_TREE_HEAD_INVALID,
|
|
1636
|
+
`journal tree head size must be a non-negative integer, got: ${String(artifact.size)}`
|
|
1637
|
+
);
|
|
1638
|
+
}
|
|
1639
|
+
if (typeof artifact.root !== "string" || !ANCHOR_HEX32_LC_RE.test(artifact.root)) {
|
|
1640
|
+
return anchorNo(
|
|
1641
|
+
ANCHOR_REASONS.JOURNAL_TREE_HEAD_INVALID,
|
|
1642
|
+
`journal tree head root must be a LOWERCASE 0x-bytes32 hex string, got: ${String(artifact.root)}`
|
|
1643
|
+
);
|
|
1644
|
+
}
|
|
1645
|
+
if (artifact.size === 0 && artifact.root !== ANCHOR_JOURNAL_EMPTY_ROOT) {
|
|
1646
|
+
return anchorNo(
|
|
1647
|
+
ANCHOR_REASONS.JOURNAL_TREE_HEAD_INVALID,
|
|
1648
|
+
`an EMPTY journal tree head (size 0) must carry the documented empty root ${ANCHOR_JOURNAL_EMPTY_ROOT}`
|
|
1649
|
+
);
|
|
1650
|
+
}
|
|
1651
|
+
if (artifact.size > 0 && artifact.root === ANCHOR_JOURNAL_EMPTY_ROOT) {
|
|
1652
|
+
return anchorNo(
|
|
1653
|
+
ANCHOR_REASONS.JOURNAL_TREE_HEAD_INVALID,
|
|
1654
|
+
"a non-empty journal tree head cannot carry the domain-separated EMPTY root"
|
|
1655
|
+
);
|
|
1656
|
+
}
|
|
1657
|
+
return anchorOk(artifact.root, ANCHOR_JOURNAL_TREE_HEAD_KIND, anchorJournalHow(artifact.size));
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
// trustledger.reconcile-seal — a strict port of trustledger/seal.js validateSeal: the verdict/role/
|
|
1661
|
+
// inputs/outputs checks, per-entry leaf self-consistency, and the LOAD-BEARING root re-derivation from
|
|
1662
|
+
// the seal's OWN leaves PLUS the synthetic verdict/role HEADER leaf (content re-derived from the
|
|
1663
|
+
// seal's recorded verdict + input role bindings via the verifier's own lib/canonical port).
|
|
1664
|
+
const ANCHOR_TRUST_SEAL_NOTE =
|
|
1665
|
+
"This reconciliation seal is TAMPER-EVIDENT, not a trusted timestamp and not a legal opinion. Its " +
|
|
1666
|
+
"Merkle `root` commits to the full set of (relPath, content) pairs across the source inputs AND " +
|
|
1667
|
+
"every emitted packet file, PLUS a reserved HEADER leaf binding the recorded verdict " +
|
|
1668
|
+
"(pass/reportDate/period) and each input's logical role: any edit, rename, add, or remove of a " +
|
|
1669
|
+
"file — or any edit of the verdict/date/period or swap of an input role — changes the root, and " +
|
|
1670
|
+
"verifySeal localizes a file change to the exact file and a verdict/role change to the header. It " +
|
|
1671
|
+
"does NOT prove WHEN the sealing actually happened (the bound reportDate cannot be edited " +
|
|
1672
|
+
"undetected, but a self-asserted date still rides the human trust-root P-3 — standing up a real " +
|
|
1673
|
+
"signing key or timestamp anchor is needs-human) and it does NOT validate the legal MEANING of " +
|
|
1674
|
+
"the reconciliation (the CPA review still governs). The seal is an UNTRUSTED transport container: " +
|
|
1675
|
+
"verifySeal RE-DERIVES the root from the bytes you supply — it never trusts the seal's own hashes.";
|
|
1676
|
+
const ANCHOR_TRUST_SEAL_SCHEMA_VERSIONS = Object.freeze([1]);
|
|
1677
|
+
const ANCHOR_TRUST_SEAL_INPUT_ROLES = Object.freeze(["bank", "book", "rentroll"]);
|
|
1678
|
+
const ANCHOR_TRUST_SEAL_CORE_LABEL = "trustledger reconciliation seal";
|
|
1679
|
+
|
|
1680
|
+
function anchorValidateTrustSeal(obj) {
|
|
1681
|
+
if (!anchorIsPlainObject(obj)) throw new Error("seal must be a JSON object");
|
|
1682
|
+
if (obj.kind !== KINDS.TRUST_SEAL) {
|
|
1683
|
+
throw new Error(
|
|
1684
|
+
`not a trustledger reconciliation seal (kind: ${JSON.stringify(obj.kind)}; expected ` +
|
|
1685
|
+
`${JSON.stringify(KINDS.TRUST_SEAL)})`
|
|
1686
|
+
);
|
|
1687
|
+
}
|
|
1688
|
+
if (!ANCHOR_TRUST_SEAL_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
|
|
1689
|
+
throw new Error(
|
|
1690
|
+
`unsupported seal schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
|
|
1691
|
+
`(this build understands ${JSON.stringify(ANCHOR_TRUST_SEAL_SCHEMA_VERSIONS)})`
|
|
1692
|
+
);
|
|
1693
|
+
}
|
|
1694
|
+
if (obj.note !== ANCHOR_TRUST_SEAL_NOTE) {
|
|
1695
|
+
throw new Error("seal `note` must be the standing SEAL_TRUST_NOTE (caveat must not drift)");
|
|
1696
|
+
}
|
|
1697
|
+
if (typeof obj.root !== "string" || !merkle.HEX32_RE.test(obj.root)) {
|
|
1698
|
+
throw new Error(`seal root must be a 0x-prefixed 32-byte hex string, got: ${String(obj.root)}`);
|
|
1699
|
+
}
|
|
1700
|
+
if (!anchorIsPlainObject(obj.verdict)) {
|
|
1701
|
+
throw new Error("seal is missing `verdict` { pass, reportDate }");
|
|
1702
|
+
}
|
|
1703
|
+
if (typeof obj.verdict.pass !== "boolean") {
|
|
1704
|
+
throw new Error("seal verdict.pass must be a boolean");
|
|
1705
|
+
}
|
|
1706
|
+
if (!ANCHOR_DATE_RE.test(String(obj.verdict.reportDate || ""))) {
|
|
1707
|
+
throw new Error('seal verdict.reportDate must be a "YYYY-MM-DD" string');
|
|
1708
|
+
}
|
|
1709
|
+
if (!("period" in obj.verdict)) {
|
|
1710
|
+
throw new Error("seal verdict is missing `period` (may be null)");
|
|
1711
|
+
}
|
|
1712
|
+
if (obj.verdict.period !== null && typeof obj.verdict.period !== "string") {
|
|
1713
|
+
throw new Error("seal verdict.period must be a string or null");
|
|
1714
|
+
}
|
|
1715
|
+
if (!Array.isArray(obj.inputs) || obj.inputs.length === 0) {
|
|
1716
|
+
throw new Error("seal `inputs` must be a non-empty array");
|
|
1717
|
+
}
|
|
1718
|
+
if (!Array.isArray(obj.outputs) || obj.outputs.length === 0) {
|
|
1719
|
+
throw new Error("seal `outputs` must be a non-empty array");
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
const seenRelPath = new Set();
|
|
1723
|
+
const seenRole = new Set();
|
|
1724
|
+
const flat = [];
|
|
1725
|
+
// Per-entry checks use the trustledger wording (`seal inputs[0]...`); the reserved-header check uses
|
|
1726
|
+
// the core-config label, exactly as the producer's core-delegated view reports it.
|
|
1727
|
+
const checkEntries = (entries, where) => {
|
|
1728
|
+
entries.forEach((entry, i) => {
|
|
1729
|
+
if (!anchorIsPlainObject(entry)) throw new Error(`seal ${where}[${i}] must be an object`);
|
|
1730
|
+
if (typeof entry.relPath !== "string" || entry.relPath.length === 0) {
|
|
1731
|
+
throw new Error(`seal ${where}[${i}].relPath must be a non-empty string`);
|
|
1732
|
+
}
|
|
1733
|
+
if (entry.relPath === canonical.TRUST_SEAL_HEADER_RELPATH) {
|
|
1734
|
+
throw new Error(
|
|
1735
|
+
`${ANCHOR_TRUST_SEAL_CORE_LABEL} files[${flat.length}].relPath ` +
|
|
1736
|
+
`${JSON.stringify(entry.relPath)} is reserved for the seal header`
|
|
1737
|
+
);
|
|
1738
|
+
}
|
|
1739
|
+
if (seenRelPath.has(entry.relPath)) {
|
|
1740
|
+
throw new Error(`seal has a duplicate relPath across the file set: ${JSON.stringify(entry.relPath)}`);
|
|
1741
|
+
}
|
|
1742
|
+
seenRelPath.add(entry.relPath);
|
|
1743
|
+
for (const f of ["contentHash", "leaf"]) {
|
|
1744
|
+
if (typeof entry[f] !== "string" || !merkle.HEX32_RE.test(entry[f])) {
|
|
1745
|
+
throw new Error(
|
|
1746
|
+
`seal ${where}[${i}].${f} must be a 0x-prefixed 32-byte hex string, got: ${String(entry[f])}`
|
|
1747
|
+
);
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
const expectedLeaf = merkle.pathLeaf(entry.relPath, entry.contentHash);
|
|
1751
|
+
if (entry.leaf.toLowerCase() !== expectedLeaf.toLowerCase()) {
|
|
1752
|
+
throw new Error(
|
|
1753
|
+
`seal ${where}[${i}].leaf is inconsistent with its relPath+contentHash ` +
|
|
1754
|
+
`(expected ${expectedLeaf}, got ${entry.leaf})`
|
|
1755
|
+
);
|
|
1756
|
+
}
|
|
1757
|
+
flat.push({ relPath: entry.relPath, contentHash: entry.contentHash });
|
|
1758
|
+
});
|
|
1759
|
+
};
|
|
1760
|
+
checkEntries(obj.inputs, "inputs");
|
|
1761
|
+
obj.inputs.forEach((entry, i) => {
|
|
1762
|
+
if (!ANCHOR_TRUST_SEAL_INPUT_ROLES.includes(entry.role)) {
|
|
1763
|
+
throw new Error(
|
|
1764
|
+
`seal inputs[${i}].role must be one of ${JSON.stringify(ANCHOR_TRUST_SEAL_INPUT_ROLES)}, got: ` +
|
|
1765
|
+
`${JSON.stringify(entry.role)}`
|
|
1766
|
+
);
|
|
1767
|
+
}
|
|
1768
|
+
if (seenRole.has(entry.role)) {
|
|
1769
|
+
throw new Error(`seal has a duplicate input role: ${JSON.stringify(entry.role)}`);
|
|
1770
|
+
}
|
|
1771
|
+
seenRole.add(entry.role);
|
|
1772
|
+
});
|
|
1773
|
+
checkEntries(obj.outputs, "outputs");
|
|
1774
|
+
obj.outputs.forEach((entry, i) => {
|
|
1775
|
+
if (entry.role !== undefined && entry.role !== null) {
|
|
1776
|
+
throw new Error(
|
|
1777
|
+
`seal outputs[${i}] must not carry a role (roles partition INPUTS only), got: ` +
|
|
1778
|
+
`${JSON.stringify(entry.role)}`
|
|
1779
|
+
);
|
|
1780
|
+
}
|
|
1781
|
+
});
|
|
1782
|
+
const total = obj.inputs.length + obj.outputs.length;
|
|
1783
|
+
if (obj.fileCount !== undefined && obj.fileCount !== total) {
|
|
1784
|
+
throw new Error(`seal fileCount (${String(obj.fileCount)}) does not match the entry total (${total})`);
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
// THE LOAD-BEARING CHECK: re-derive the root from the listed leaves PLUS the verdict/role HEADER leaf.
|
|
1788
|
+
const headerBytes = canonical.trustSealHeaderBytes(
|
|
1789
|
+
obj.verdict,
|
|
1790
|
+
obj.inputs.map((e) => ({ role: e.role, relPath: e.relPath }))
|
|
1791
|
+
);
|
|
1792
|
+
const committed = [
|
|
1793
|
+
...flat,
|
|
1794
|
+
{ relPath: canonical.TRUST_SEAL_HEADER_RELPATH, contentHash: merkle.hashBytes(headerBytes) },
|
|
1795
|
+
];
|
|
1796
|
+
const rederived = merkle.rootFromFlat(committed);
|
|
1797
|
+
if (rederived.toLowerCase() !== obj.root.toLowerCase()) {
|
|
1798
|
+
throw new Error(
|
|
1799
|
+
"seal root does not re-derive from its listed entries + verdict/role header " +
|
|
1800
|
+
"(the seal is internally inconsistent: a file, the verdict, or an input role was edited " +
|
|
1801
|
+
"without updating the root)"
|
|
1802
|
+
);
|
|
1803
|
+
}
|
|
1804
|
+
return obj;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
function anchorTrustledgerDigest(artifact) {
|
|
1808
|
+
try {
|
|
1809
|
+
anchorValidateTrustSeal(artifact);
|
|
1810
|
+
} catch (e) {
|
|
1811
|
+
return anchorNo(ANCHOR_REASONS.TRUSTLEDGER_SEAL_INVALID, e && e.message ? e.message : String(e));
|
|
1812
|
+
}
|
|
1813
|
+
return anchorOk(artifact.root.toLowerCase(), KINDS.TRUST_SEAL, ANCHOR_HOW_FIXED[KINDS.TRUST_SEAL]);
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
// verifyhash.dataset-attestation / verifyhash.parcel-attestation — strict ports of the shipped
|
|
1817
|
+
// validators (cli/dataset.js validateAttestation / cli/parcel.js validateParcelAttestation), then the
|
|
1818
|
+
// SAME canonical bytes the producers serialize (via the verifier's own lib/canonical port — the two
|
|
1819
|
+
// attestation shapes share the identical canonical key order), hashed with Node-core sha256. The
|
|
1820
|
+
// closed field set is enforced FIRST, exactly as the producer core does: an unknown key would ride
|
|
1821
|
+
// along unbound by the digest, so it is rejected rather than silently dropped.
|
|
1822
|
+
const ANCHOR_ATTESTATION_FIELDS = Object.freeze([
|
|
1823
|
+
"kind",
|
|
1824
|
+
"schemaVersion",
|
|
1825
|
+
"note",
|
|
1826
|
+
"root",
|
|
1827
|
+
"fileCount",
|
|
1828
|
+
"manifestDigest",
|
|
1829
|
+
"signed",
|
|
1830
|
+
"signature",
|
|
1831
|
+
]);
|
|
1832
|
+
const ANCHOR_ATTESTATION_SCHEMA_VERSIONS = Object.freeze([1]);
|
|
1833
|
+
|
|
1834
|
+
function anchorValidateAttestation(obj, kind, noun) {
|
|
1835
|
+
if (!anchorIsPlainObject(obj)) throw new Error(`${noun} attestation must be a JSON object`);
|
|
1836
|
+
if (obj.kind !== kind) {
|
|
1837
|
+
throw new Error(
|
|
1838
|
+
`not a verifyhash ${noun} attestation (kind: ${JSON.stringify(obj.kind)}; expected ${JSON.stringify(kind)})`
|
|
1839
|
+
);
|
|
1840
|
+
}
|
|
1841
|
+
if (!ANCHOR_ATTESTATION_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
|
|
1842
|
+
throw new Error(
|
|
1843
|
+
`unsupported ${noun} attestation schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
|
|
1844
|
+
`(this build understands ${JSON.stringify(ANCHOR_ATTESTATION_SCHEMA_VERSIONS)})`
|
|
1845
|
+
);
|
|
1846
|
+
}
|
|
1847
|
+
for (const f of ["root", "manifestDigest"]) {
|
|
1848
|
+
if (typeof obj[f] !== "string" || !merkle.HEX32_RE.test(obj[f])) {
|
|
1849
|
+
throw new Error(`${noun} attestation ${f} must be a 0x-prefixed 32-byte hex string, got: ${String(obj[f])}`);
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
if (!Number.isInteger(obj.fileCount) || obj.fileCount < 1) {
|
|
1853
|
+
throw new Error(`${noun} attestation fileCount must be a positive integer, got: ${String(obj.fileCount)}`);
|
|
1854
|
+
}
|
|
1855
|
+
if (obj.signed !== false) {
|
|
1856
|
+
throw new Error(
|
|
1857
|
+
`${noun} attestation signed must be false (this build emits/reads only the UNSIGNED payload; ` +
|
|
1858
|
+
`attaching a real signature is the human-owned trust-root, P-3), got: ${String(obj.signed)}`
|
|
1859
|
+
);
|
|
1860
|
+
}
|
|
1861
|
+
if (obj.signature !== null) {
|
|
1862
|
+
throw new Error(`${noun} attestation signature must be null in the UNSIGNED payload, got: ${String(obj.signature)}`);
|
|
1863
|
+
}
|
|
1864
|
+
return obj;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
function anchorAttestationDigest(artifact, kind, noun, reason) {
|
|
1868
|
+
for (const k of Object.keys(artifact)) {
|
|
1869
|
+
if (!ANCHOR_ATTESTATION_FIELDS.includes(k)) {
|
|
1870
|
+
return anchorNo(reason, `attestation has unknown field ${JSON.stringify(k)} (the canonical bytes would not bind it)`);
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
let canonicalBytes;
|
|
1874
|
+
try {
|
|
1875
|
+
anchorValidateAttestation(artifact, kind, noun);
|
|
1876
|
+
// The verifier's own canonical serializer: the SAME fixed key order + trailing newline the
|
|
1877
|
+
// producer emits (dataset and parcel attestations share the identical canonical shape).
|
|
1878
|
+
canonicalBytes = canonical.serializeUnsignedDatasetAttestation(artifact);
|
|
1879
|
+
} catch (e) {
|
|
1880
|
+
return anchorNo(reason, e && e.message ? e.message : String(e));
|
|
1881
|
+
}
|
|
1882
|
+
const digest = "0x" + nodeCrypto.createHash("sha256").update(canonicalBytes, "utf8").digest("hex");
|
|
1883
|
+
return anchorOk(digest, kind, ANCHOR_HOW_FIXED[kind]);
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
/**
|
|
1887
|
+
* Extract the ONE canonical 32-byte digest a chain record binds for `artifact` — the standalone port
|
|
1888
|
+
* of the producer core's artifactDigest, dispatching over the SAME closed kind table. TOTAL.
|
|
1889
|
+
*/
|
|
1890
|
+
function anchorArtifactDigest(artifact) {
|
|
1891
|
+
try {
|
|
1892
|
+
if (!anchorIsPlainObject(artifact)) {
|
|
1893
|
+
return anchorNo(ANCHOR_REASONS.NOT_AN_OBJECT, "artifact must be a parsed JSON object");
|
|
1894
|
+
}
|
|
1895
|
+
const kind = artifact.kind;
|
|
1896
|
+
if (kind === undefined) {
|
|
1897
|
+
if ("size" in artifact || "root" in artifact) {
|
|
1898
|
+
return anchorJournalHeadDigest(artifact, false);
|
|
1899
|
+
}
|
|
1900
|
+
return anchorNo(
|
|
1901
|
+
ANCHOR_REASONS.UNKNOWN_KIND,
|
|
1902
|
+
"artifact carries no `kind` and is not a { size, root } journal tree head"
|
|
1903
|
+
);
|
|
1904
|
+
}
|
|
1905
|
+
if (typeof kind !== "string") {
|
|
1906
|
+
return anchorNo(ANCHOR_REASONS.UNKNOWN_KIND, "artifact `kind` must be a string");
|
|
1907
|
+
}
|
|
1908
|
+
switch (kind) {
|
|
1909
|
+
case KINDS.EVIDENCE_SEAL:
|
|
1910
|
+
return anchorEvidenceDigest(artifact);
|
|
1911
|
+
case KINDS.AGENT_PACKET:
|
|
1912
|
+
return anchorAgentDigest(artifact);
|
|
1913
|
+
case ANCHOR_JOURNAL_TREE_HEAD_KIND:
|
|
1914
|
+
return anchorJournalHeadDigest(artifact, true);
|
|
1915
|
+
case KINDS.TRUST_SEAL:
|
|
1916
|
+
return anchorTrustledgerDigest(artifact);
|
|
1917
|
+
case KINDS.DATASET_ATTESTATION:
|
|
1918
|
+
return anchorAttestationDigest(
|
|
1919
|
+
artifact,
|
|
1920
|
+
KINDS.DATASET_ATTESTATION,
|
|
1921
|
+
"dataset",
|
|
1922
|
+
ANCHOR_REASONS.DATASET_ATTESTATION_INVALID
|
|
1923
|
+
);
|
|
1924
|
+
case ANCHOR_PARCEL_ATTESTATION_KIND:
|
|
1925
|
+
return anchorAttestationDigest(
|
|
1926
|
+
artifact,
|
|
1927
|
+
ANCHOR_PARCEL_ATTESTATION_KIND,
|
|
1928
|
+
"parcel",
|
|
1929
|
+
ANCHOR_REASONS.PARCEL_ATTESTATION_INVALID
|
|
1930
|
+
);
|
|
1931
|
+
default:
|
|
1932
|
+
return anchorNo(
|
|
1933
|
+
ANCHOR_REASONS.UNKNOWN_KIND,
|
|
1934
|
+
`unknown artifact kind ${JSON.stringify(kind)} (the closed table: ${ANCHOR_ARTIFACT_KINDS.join(", ")})`
|
|
1935
|
+
);
|
|
1936
|
+
}
|
|
1937
|
+
} catch (e) {
|
|
1938
|
+
return anchorNo(ANCHOR_REASONS.NOT_AN_OBJECT, e && e.message ? e.message : String(e));
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
// ---------------------------------------------------------------------------------------------------
|
|
1943
|
+
// Receipt validation + the binding verdict — verbatim ports of the producer core's _validateReceipt /
|
|
1944
|
+
// verifyAnchoredReceipt (strict form checks; every deviation a named `bad-receipt` naming the field).
|
|
1945
|
+
// ---------------------------------------------------------------------------------------------------
|
|
1946
|
+
|
|
1947
|
+
const ANCHOR_CHAIN_FIELDS = Object.freeze([
|
|
1948
|
+
"authorBound",
|
|
1949
|
+
"blockNumber",
|
|
1950
|
+
"blockTime",
|
|
1951
|
+
"chainId",
|
|
1952
|
+
"contract",
|
|
1953
|
+
"contributor",
|
|
1954
|
+
"txHash",
|
|
1955
|
+
]);
|
|
1956
|
+
const ANCHOR_RECEIPT_FIELDS = Object.freeze(["artifactKind", "artifactLabel", "chain", "digest", "how", "kind", "note"]);
|
|
1957
|
+
const ANCHOR_RECEIPT_REQUIRED = Object.freeze(["artifactKind", "chain", "digest", "how", "kind", "note"]);
|
|
1958
|
+
|
|
1959
|
+
function anchorBadReceipt(field, detail) {
|
|
1960
|
+
return { ok: false, reason: ANCHOR_REASONS.BAD_RECEIPT, field, detail };
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
function anchorCheckChain(chain) {
|
|
1964
|
+
if (!anchorIsPlainObject(chain)) {
|
|
1965
|
+
return { ok: false, field: "chain", detail: "chain must be an object of the seven recorded chain facts" };
|
|
1966
|
+
}
|
|
1967
|
+
for (const k of Object.keys(chain)) {
|
|
1968
|
+
if (!ANCHOR_CHAIN_FIELDS.includes(k)) {
|
|
1969
|
+
return { ok: false, field: `chain.${k}`, detail: `chain has unknown field: ${JSON.stringify(k)}` };
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
for (const k of ANCHOR_CHAIN_FIELDS) {
|
|
1973
|
+
if (!(k in chain)) {
|
|
1974
|
+
return { ok: false, field: `chain.${k}`, detail: `chain is missing required field: ${JSON.stringify(k)}` };
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
if (typeof chain.authorBound !== "boolean") {
|
|
1978
|
+
return { ok: false, field: "chain.authorBound", detail: "authorBound must be a boolean" };
|
|
1979
|
+
}
|
|
1980
|
+
for (const k of ["blockNumber", "blockTime"]) {
|
|
1981
|
+
if (!Number.isSafeInteger(chain[k]) || chain[k] < 0) {
|
|
1982
|
+
return { ok: false, field: `chain.${k}`, detail: `${k} must be a non-negative integer, got: ${String(chain[k])}` };
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
if (!Number.isSafeInteger(chain.chainId) || chain.chainId < 1) {
|
|
1986
|
+
return { ok: false, field: "chain.chainId", detail: `chainId must be a positive integer, got: ${String(chain.chainId)}` };
|
|
1987
|
+
}
|
|
1988
|
+
for (const k of ["contract", "contributor"]) {
|
|
1989
|
+
if (typeof chain[k] !== "string" || !ANCHOR_ADDRESS_LC_RE.test(chain[k])) {
|
|
1990
|
+
return {
|
|
1991
|
+
ok: false,
|
|
1992
|
+
field: `chain.${k}`,
|
|
1993
|
+
detail: `${k} must be a LOWERCASE 0x-address (canonical case), got: ${String(chain[k])}`,
|
|
1994
|
+
};
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
if (typeof chain.txHash !== "string" || !ANCHOR_HEX32_LC_RE.test(chain.txHash)) {
|
|
1998
|
+
return {
|
|
1999
|
+
ok: false,
|
|
2000
|
+
field: "chain.txHash",
|
|
2001
|
+
detail: `txHash must be a LOWERCASE 0x-bytes32 hex string, got: ${String(chain.txHash)}`,
|
|
2002
|
+
};
|
|
2003
|
+
}
|
|
2004
|
+
return { ok: true };
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
function anchorCanonicalChain(chain) {
|
|
2008
|
+
return {
|
|
2009
|
+
authorBound: chain.authorBound,
|
|
2010
|
+
blockNumber: chain.blockNumber,
|
|
2011
|
+
blockTime: chain.blockTime,
|
|
2012
|
+
chainId: chain.chainId,
|
|
2013
|
+
contract: chain.contract,
|
|
2014
|
+
contributor: chain.contributor,
|
|
2015
|
+
txHash: chain.txHash,
|
|
2016
|
+
};
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
function anchorValidateReceipt(receipt) {
|
|
2020
|
+
if (!anchorIsPlainObject(receipt)) {
|
|
2021
|
+
return anchorBadReceipt("receipt", "receipt must be a parsed JSON object");
|
|
2022
|
+
}
|
|
2023
|
+
for (const k of Object.keys(receipt)) {
|
|
2024
|
+
if (!ANCHOR_RECEIPT_FIELDS.includes(k)) {
|
|
2025
|
+
return anchorBadReceipt(k, `receipt has unknown field: ${JSON.stringify(k)}`);
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
for (const k of ANCHOR_RECEIPT_REQUIRED) {
|
|
2029
|
+
if (!(k in receipt)) {
|
|
2030
|
+
return anchorBadReceipt(k, `receipt is missing required field: ${JSON.stringify(k)}`);
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
if (receipt.kind !== ANCHORED_RECEIPT_KIND) {
|
|
2034
|
+
return anchorBadReceipt(
|
|
2035
|
+
"kind",
|
|
2036
|
+
`not an anchored receipt this build understands (kind: ${JSON.stringify(receipt.kind)}; expected ${JSON.stringify(ANCHORED_RECEIPT_KIND)})`
|
|
2037
|
+
);
|
|
2038
|
+
}
|
|
2039
|
+
if (receipt.note !== ANCHOR_TRUST_NOTE) {
|
|
2040
|
+
return anchorBadReceipt("note", "receipt `note` must be the standing trust note VERBATIM (the caveat must not drift)");
|
|
2041
|
+
}
|
|
2042
|
+
if (typeof receipt.digest !== "string" || !ANCHOR_HEX32_LC_RE.test(receipt.digest)) {
|
|
2043
|
+
return anchorBadReceipt("digest", `receipt digest must be a LOWERCASE 0x-bytes32 hex string, got: ${String(receipt.digest)}`);
|
|
2044
|
+
}
|
|
2045
|
+
if (typeof receipt.artifactKind !== "string" || !ANCHOR_ARTIFACT_KINDS.includes(receipt.artifactKind)) {
|
|
2046
|
+
return anchorBadReceipt(
|
|
2047
|
+
"artifactKind",
|
|
2048
|
+
`receipt artifactKind ${JSON.stringify(receipt.artifactKind)} is not in the closed table (${ANCHOR_ARTIFACT_KINDS.join(", ")})`
|
|
2049
|
+
);
|
|
2050
|
+
}
|
|
2051
|
+
if (!anchorHowValidFor(receipt.artifactKind, receipt.how)) {
|
|
2052
|
+
return anchorBadReceipt("how", `receipt \`how\` is not the documented derivation rule for ${receipt.artifactKind}`);
|
|
2053
|
+
}
|
|
2054
|
+
if (receipt.artifactLabel !== undefined) {
|
|
2055
|
+
const l = receipt.artifactLabel;
|
|
2056
|
+
if (typeof l !== "string" || l.length === 0 || l.length > 200 || ANCHOR_CONTROL_CHAR_RE.test(l)) {
|
|
2057
|
+
return anchorBadReceipt(
|
|
2058
|
+
"artifactLabel",
|
|
2059
|
+
"artifactLabel, when present, must be a 1..200-char string with no control characters"
|
|
2060
|
+
);
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
const c = anchorCheckChain(receipt.chain);
|
|
2064
|
+
if (!c.ok) return anchorBadReceipt(c.field, c.detail);
|
|
2065
|
+
return { ok: true };
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
/**
|
|
2069
|
+
* Verify that `receipt` is a well-formed `vh-anchored-receipt@1` AND that it binds EXACTLY the
|
|
2070
|
+
* supplied `artifact` — the OFFLINE binding leg, standalone: the digest is RECOMPUTED from the
|
|
2071
|
+
* artifact via the closed table (never trusted from either side) and the full { kind, digest, how }
|
|
2072
|
+
* triple must match. NEVER consults a network; the receipt's chain facts are returned as the
|
|
2073
|
+
* anchorer's CLAIM. TOTAL: named rejects, no throws. Same verdicts as the producer core.
|
|
2074
|
+
*
|
|
2075
|
+
* @param {object} args { receipt, artifact } — both caller-supplied PARSED objects
|
|
2076
|
+
* @returns {{ ok:true, digest:string, chain:object } |
|
|
2077
|
+
* { ok:false, reason:string, field?:string, detail?:string }}
|
|
2078
|
+
*/
|
|
2079
|
+
function verifyAnchoredReceipt(args) {
|
|
2080
|
+
try {
|
|
2081
|
+
if (!anchorIsPlainObject(args)) {
|
|
2082
|
+
return anchorNo(ANCHOR_REASONS.BAD_ARGS, "verifyAnchoredReceipt requires { receipt, artifact }");
|
|
2083
|
+
}
|
|
2084
|
+
const r = anchorValidateReceipt(args.receipt);
|
|
2085
|
+
if (!r.ok) return r;
|
|
2086
|
+
const d = anchorArtifactDigest(args.artifact);
|
|
2087
|
+
if (!d.ok) return d; // the artifact's OWN named validation reject, propagated verbatim
|
|
2088
|
+
const receipt = args.receipt;
|
|
2089
|
+
if (d.kind !== receipt.artifactKind) {
|
|
2090
|
+
return anchorNo(
|
|
2091
|
+
ANCHOR_REASONS.KIND_MISMATCH,
|
|
2092
|
+
`receipt anchors a ${receipt.artifactKind} but the supplied artifact is a ${d.kind}`
|
|
2093
|
+
);
|
|
2094
|
+
}
|
|
2095
|
+
if (d.digest !== receipt.digest) {
|
|
2096
|
+
return anchorNo(
|
|
2097
|
+
ANCHOR_REASONS.DIGEST_MISMATCH,
|
|
2098
|
+
`recomputed digest ${d.digest} != receipt digest ${receipt.digest} — this receipt does not bind this artifact`
|
|
2099
|
+
);
|
|
2100
|
+
}
|
|
2101
|
+
if (d.how !== receipt.how) {
|
|
2102
|
+
return anchorNo(ANCHOR_REASONS.HOW_MISMATCH, `recomputed derivation rule != receipt \`how\` (recomputed: ${d.how})`);
|
|
2103
|
+
}
|
|
2104
|
+
return { ok: true, digest: d.digest, chain: anchorCanonicalChain(receipt.chain) };
|
|
2105
|
+
} catch (e) {
|
|
2106
|
+
return anchorNo(ANCHOR_REASONS.BAD_ARGS, e && e.message ? e.message : String(e));
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
// ---------------------------------------------------------------------------------------------------
|
|
2111
|
+
// The anchored-receipt CLI leg: read + parse the two files, run the pure binding verify, render the
|
|
2112
|
+
// stable human/JSON verdict. READ-ONLY (no receipt/temp/side-effect file is ever written); exit
|
|
2113
|
+
// contract 0 ACCEPTED / 3 REJECTED (named) / 2 usage / 1 IO — the family's shared verify contract.
|
|
2114
|
+
// ---------------------------------------------------------------------------------------------------
|
|
2115
|
+
|
|
2116
|
+
// The in-band honesty of the offline leg, stated once for both output shapes.
|
|
2117
|
+
const ANCHOR_OFFLINE_NOTE =
|
|
2118
|
+
"OFFLINE binding check: the receipt binds this exact artifact, but its chain facts were NOT " +
|
|
2119
|
+
"re-checked (this standalone verifier opens no network). Confirm them against the chain with the " +
|
|
2120
|
+
"producer cli: vh verify-anchored <receipt> <sealed-file> --rpc <url> --contract <addr>.";
|
|
2121
|
+
|
|
2122
|
+
// ---------------------------------------------------------------------------------------------------
|
|
2123
|
+
// CHAIN-CLASS trust guidance for the OFFLINE leg. The offline binding leg proves the receipt binds
|
|
2124
|
+
// THIS artifact; it can NEVER (offline, by definition) confirm the digest is actually anchored on any
|
|
2125
|
+
// chain. But it CAN classify the chain the receipt CLAIMS — and that classification is the single most
|
|
2126
|
+
// load-bearing thing a counterparty needs to avoid this vertical's worst overclaim: mistaking a
|
|
2127
|
+
// receipt from a worthless LOCAL DEV chain (STRATEGY.md P-2 — a local-chain anchor proves MECHANISM
|
|
2128
|
+
// only and is worth NOTHING publicly) for a public-chain proof. Surfacing it HERE puts the check in
|
|
2129
|
+
// the INDEPENDENT verifier a counterparty actually runs, not only in the producer's prose, and makes
|
|
2130
|
+
// it MACHINE-GATEABLE (`chainClass` / `publiclyMeaningful` in --json — a stable, additive contract a
|
|
2131
|
+
// future indexer/UI keys on). The id sets MIRROR the producer's cli/anchor.js KNOWN_TESTNET_CHAIN_IDS
|
|
2132
|
+
// (test/verifier.standalone.test.js pins them against it byte-for-byte so the two sides cannot drift):
|
|
2133
|
+
// the two generic dev chains are LOCAL-DEV, the remaining known ids are PUBLIC TESTNETS, and every
|
|
2134
|
+
// other id is UNKNOWN (a chain — possibly a mainnet — whose weight this offline leg cannot judge).
|
|
2135
|
+
//
|
|
2136
|
+
// This guidance is STRICTLY ADDITIVE: it never changes the accept/reject decision (a bound receipt is
|
|
2137
|
+
// still ACCEPTED at exit 0) and it never touches the pure `verifyAnchoredReceipt` verdict object,
|
|
2138
|
+
// which stays a byte-faithful port of the producer core. It is presentation-layer trust context only.
|
|
2139
|
+
const ANCHOR_LOCAL_DEV_CHAIN_IDS = Object.freeze([31337, 1337]);
|
|
2140
|
+
const ANCHOR_PUBLIC_TESTNET_CHAIN_IDS = Object.freeze([
|
|
2141
|
+
80002, 80001, 11155111, 17000, 5, 11155420, 84532, 421614,
|
|
2142
|
+
]);
|
|
2143
|
+
|
|
2144
|
+
// Classify the chainId a receipt CLAIMS into { chainClass, publiclyMeaningful, advisory }. TOTAL — a
|
|
2145
|
+
// non-integer/out-of-set id falls through to the honest "unknown" bucket (never throws). `chainId`
|
|
2146
|
+
// arrives already strict-validated (a positive safe integer) from anchorCheckChain.
|
|
2147
|
+
function anchorClassifyChainId(chainId) {
|
|
2148
|
+
if (ANCHOR_LOCAL_DEV_CHAIN_IDS.includes(chainId)) {
|
|
2149
|
+
return {
|
|
2150
|
+
chainClass: "local-dev",
|
|
2151
|
+
publiclyMeaningful: false,
|
|
2152
|
+
advisory:
|
|
2153
|
+
`this receipt's chain (chainId ${chainId}) is a LOCAL DEV chain: the anchor proves MECHANISM ` +
|
|
2154
|
+
`ONLY and is worth NOTHING publicly until a human deploys the registry to a public chain ` +
|
|
2155
|
+
`(STRATEGY.md P-2). Do NOT treat a local-dev receipt as a public proof.`,
|
|
2156
|
+
};
|
|
2157
|
+
}
|
|
2158
|
+
if (ANCHOR_PUBLIC_TESTNET_CHAIN_IDS.includes(chainId)) {
|
|
2159
|
+
return {
|
|
2160
|
+
chainClass: "public-testnet",
|
|
2161
|
+
publiclyMeaningful: false,
|
|
2162
|
+
advisory:
|
|
2163
|
+
`this receipt's chain (chainId ${chainId}) is a PUBLIC TESTNET: an anchor there demonstrates ` +
|
|
2164
|
+
`the mechanism on a public chain but carries NO economic finality — treat it as a testnet ` +
|
|
2165
|
+
`proof, never a mainnet one.`,
|
|
2166
|
+
};
|
|
2167
|
+
}
|
|
2168
|
+
return {
|
|
2169
|
+
chainClass: "unknown",
|
|
2170
|
+
publiclyMeaningful: null,
|
|
2171
|
+
advisory:
|
|
2172
|
+
`this receipt's chainId ${chainId} is outside this verifier's known local/testnet set (it may ` +
|
|
2173
|
+
`be a mainnet): the OFFLINE leg cannot weigh the chain — re-check the anchor against that chain ` +
|
|
2174
|
+
`before relying on it.`,
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
function anchorReadJson(label, filePath) {
|
|
2179
|
+
let text;
|
|
2180
|
+
try {
|
|
2181
|
+
text = fs.readFileSync(path.resolve(filePath), "utf8");
|
|
2182
|
+
} catch (e) {
|
|
2183
|
+
throw new IOError(`cannot read ${label} ${filePath}: ${e.message}`);
|
|
2184
|
+
}
|
|
2185
|
+
let obj;
|
|
2186
|
+
try {
|
|
2187
|
+
obj = JSON.parse(text);
|
|
2188
|
+
} catch (e) {
|
|
2189
|
+
throw new IOError(`${label} ${filePath} is not valid JSON: ${e.message}`);
|
|
2190
|
+
}
|
|
2191
|
+
if (obj == null || typeof obj !== "object" || Array.isArray(obj)) {
|
|
2192
|
+
throw new IOError(`${label} ${filePath} must be a JSON object`);
|
|
2193
|
+
}
|
|
2194
|
+
return obj;
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
function runVerifyAnchoredOffline(opts, write, writeErr) {
|
|
2198
|
+
let receipt;
|
|
2199
|
+
let artifact;
|
|
2200
|
+
try {
|
|
2201
|
+
receipt = anchorReadJson("receipt", opts.artifact);
|
|
2202
|
+
artifact = anchorReadJson("artifact", opts.anchoredArtifact);
|
|
2203
|
+
} catch (e) {
|
|
2204
|
+
writeErr(`error: ${e.message}\n`);
|
|
2205
|
+
return EXIT.IO;
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
const v = verifyAnchoredReceipt({ receipt, artifact });
|
|
2209
|
+
if (!v.ok) {
|
|
2210
|
+
if (opts.json) {
|
|
2211
|
+
write(
|
|
2212
|
+
JSON.stringify(
|
|
2213
|
+
{ ok: false, verdict: "REJECTED", mode: "offline", reason: v.reason, field: v.field, detail: v.detail },
|
|
2214
|
+
null,
|
|
2215
|
+
2
|
|
2216
|
+
) + "\n"
|
|
2217
|
+
);
|
|
2218
|
+
} else {
|
|
2219
|
+
writeErr(`verify-vh anchored-receipt: REJECTED (${v.reason})${v.detail ? `: ${v.detail}` : ""}\n`);
|
|
2220
|
+
}
|
|
2221
|
+
return EXIT.REJECTED;
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
// Classify the chain the receipt CLAIMS (additive trust context — never changes the ACCEPT verdict).
|
|
2225
|
+
const cls = anchorClassifyChainId(v.chain.chainId);
|
|
2226
|
+
|
|
2227
|
+
if (opts.json) {
|
|
2228
|
+
write(
|
|
2229
|
+
JSON.stringify(
|
|
2230
|
+
{
|
|
2231
|
+
ok: true,
|
|
2232
|
+
verdict: "ACCEPTED",
|
|
2233
|
+
mode: "offline",
|
|
2234
|
+
digest: v.digest,
|
|
2235
|
+
artifactKind: receipt.artifactKind,
|
|
2236
|
+
chain: v.chain,
|
|
2237
|
+
chainClass: cls.chainClass,
|
|
2238
|
+
publiclyMeaningful: cls.publiclyMeaningful,
|
|
2239
|
+
chainAdvisory: cls.advisory,
|
|
2240
|
+
registry: null,
|
|
2241
|
+
note: ANCHOR_OFFLINE_NOTE,
|
|
2242
|
+
},
|
|
2243
|
+
null,
|
|
2244
|
+
2
|
|
2245
|
+
) + "\n"
|
|
2246
|
+
);
|
|
2247
|
+
} else {
|
|
2248
|
+
const c = v.chain;
|
|
2249
|
+
write("verify-vh anchored-receipt: ACCEPTED (offline binding check)\n");
|
|
2250
|
+
write(` digest: ${v.digest}\n`);
|
|
2251
|
+
write(` kind: ${receipt.artifactKind}\n`);
|
|
2252
|
+
write(
|
|
2253
|
+
` chain CLAIM: chainId ${c.chainId}, contract ${c.contract}, tx ${c.txHash}, ` +
|
|
2254
|
+
`block ${c.blockNumber}, blockTime ${c.blockTime}, contributor ${c.contributor}, ` +
|
|
2255
|
+
`authorBound ${c.authorBound}\n`
|
|
2256
|
+
);
|
|
2257
|
+
write(` chain class: ${cls.chainClass} (publiclyMeaningful: ${cls.publiclyMeaningful})\n`);
|
|
2258
|
+
// For anything not proven publicly meaningful, lead with a WARNING so a counterparty cannot skim
|
|
2259
|
+
// past the caveat; a local-dev receipt (the committed-fixture case) is worth NOTHING publicly.
|
|
2260
|
+
write(` ${cls.publiclyMeaningful === true ? "ADVISORY" : "WARNING"}: ${cls.advisory}\n`);
|
|
2261
|
+
write(
|
|
2262
|
+
" NOTE: the OFFLINE binding leg only — the chain facts above are the anchorer's CLAIM, not " +
|
|
2263
|
+
"re-checked against any chain. Confirm them with the producer cli: " +
|
|
2264
|
+
"vh verify-anchored <receipt> <sealed-file> --rpc <url> --contract <addr>.\n"
|
|
2265
|
+
);
|
|
2266
|
+
}
|
|
2267
|
+
return EXIT.OK;
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
// ---------------------------------------------------------------------------
|
|
2271
|
+
// Argument parsing.
|
|
2272
|
+
// SINGLE-ARTIFACT (the original, byte-for-byte unchanged contract):
|
|
2273
|
+
// verify-vh <artifact> [--vendor <0xaddr>] [--dir <d>] [--json]
|
|
2274
|
+
// BATCH/MANIFEST (T-33.1 — one invocation gates EVERY release artifact, one CI exit code):
|
|
2275
|
+
// verify-vh <artifact> <artifact> ... [--vendor <0xaddr>] [--dir <d>] [--json]
|
|
2276
|
+
// verify-vh --manifest <file> [--vendor <0xaddr>] [--dir <d>] [--json]
|
|
2277
|
+
// Batch mode is a pure SUPERSET: it engages ONLY when more than one positional <artifact> is given OR
|
|
2278
|
+
// `--manifest <file>` is supplied. A lone positional with no --manifest takes the identical single path,
|
|
2279
|
+
// so existing callers/tests never shift. A top-level `--vendor`/`--dir` is a DEFAULT each entry inherits
|
|
2280
|
+
// unless the entry (a manifest line) overrides it with its own per-entry `--vendor`/`--dir`.
|
|
2281
|
+
// ---------------------------------------------------------------------------
|
|
2282
|
+
|
|
2283
|
+
function parseArgs(argv) {
|
|
2284
|
+
const opts = {
|
|
2285
|
+
artifact: undefined,
|
|
2286
|
+
vendor: undefined,
|
|
2287
|
+
dir: undefined,
|
|
2288
|
+
json: false,
|
|
2289
|
+
help: false,
|
|
2290
|
+
manifest: undefined,
|
|
2291
|
+
revocations: undefined,
|
|
2292
|
+
asOf: undefined,
|
|
2293
|
+
anchoredArtifact: undefined,
|
|
2294
|
+
_pos: [],
|
|
2295
|
+
};
|
|
2296
|
+
for (let i = 0; i < argv.length; i++) {
|
|
2297
|
+
const a = argv[i];
|
|
2298
|
+
const need = (flag) => {
|
|
2299
|
+
const v = argv[++i];
|
|
2300
|
+
if (v === undefined) throw new UsageError(`${flag} requires a value`);
|
|
2301
|
+
return v;
|
|
2302
|
+
};
|
|
2303
|
+
switch (a) {
|
|
2304
|
+
case "--vendor":
|
|
2305
|
+
opts.vendor = need("--vendor");
|
|
2306
|
+
break;
|
|
2307
|
+
case "--dir":
|
|
2308
|
+
opts.dir = need("--dir");
|
|
2309
|
+
break;
|
|
2310
|
+
case "--manifest":
|
|
2311
|
+
opts.manifest = need("--manifest");
|
|
2312
|
+
break;
|
|
2313
|
+
case "--revocations":
|
|
2314
|
+
opts.revocations = need("--revocations");
|
|
2315
|
+
break;
|
|
2316
|
+
case "--anchored-artifact":
|
|
2317
|
+
opts.anchoredArtifact = need("--anchored-artifact");
|
|
2318
|
+
break;
|
|
2319
|
+
case "--as-of":
|
|
2320
|
+
opts.asOf = need("--as-of");
|
|
2321
|
+
break;
|
|
2322
|
+
case "--json":
|
|
2323
|
+
opts.json = true;
|
|
2324
|
+
break;
|
|
2325
|
+
case "-h":
|
|
2326
|
+
case "--help":
|
|
2327
|
+
case "help":
|
|
2328
|
+
opts.help = true;
|
|
2329
|
+
break;
|
|
2330
|
+
default:
|
|
2331
|
+
if (a && a.startsWith("--")) throw new UsageError(`unknown flag: ${a}`);
|
|
2332
|
+
opts._pos.push(a);
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
// batch === any path that aggregates MULTIPLE per-artifact verdicts under ONE exit code:
|
|
2336
|
+
// either a --manifest file, or more than one repeated positional <artifact>.
|
|
2337
|
+
opts.batch = opts.manifest !== undefined || opts._pos.length > 1;
|
|
2338
|
+
if (opts.manifest !== undefined && opts._pos.length > 0) {
|
|
2339
|
+
throw new UsageError(
|
|
2340
|
+
`--manifest <file> lists the artifacts; do not also pass positional <artifact> args (got: ${opts._pos[0]})`
|
|
2341
|
+
);
|
|
2342
|
+
}
|
|
2343
|
+
// Validate the OPTIONAL recipient-side trust-decision flags (--revocations / --as-of, T-51.4) SHAPE up
|
|
2344
|
+
// front so a malformed --as-of (or --as-of without --revocations) is a usage error (2), never a runtime
|
|
2345
|
+
// throw mid-verify. Mirrors `vh evidence verify-signed`'s validateAsOfFlags so the two stacks reject the
|
|
2346
|
+
// same inputs the same way.
|
|
2347
|
+
if (opts.asOf !== undefined && !opts.revocations) {
|
|
2348
|
+
throw new UsageError(
|
|
2349
|
+
"--as-of requires --revocations (it pins the instant the revocation decision is made AS OF)"
|
|
2350
|
+
);
|
|
2351
|
+
}
|
|
2352
|
+
if (opts.asOf !== undefined) {
|
|
2353
|
+
const ms = Date.parse(opts.asOf);
|
|
2354
|
+
if (
|
|
2355
|
+
typeof opts.asOf !== "string" ||
|
|
2356
|
+
!revocation.ISO_INSTANT_RE.test(opts.asOf) ||
|
|
2357
|
+
Number.isNaN(ms) ||
|
|
2358
|
+
new Date(ms).toISOString() !== opts.asOf
|
|
2359
|
+
) {
|
|
2360
|
+
throw new UsageError(
|
|
2361
|
+
`invalid --as-of: ${opts.asOf} (expected a canonical ISO-8601 UTC instant, e.g. 2026-06-01T00:00:00.000Z)`
|
|
2362
|
+
);
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
// ANCHORED-RECEIPT leg (T-70.4): `--anchored-artifact <sealed-file>` pairs ONE receipt positional
|
|
2366
|
+
// with ONE sealed artifact. It is a dedicated two-file binding check, so the sibling-verify flags
|
|
2367
|
+
// (--vendor/--dir/--revocations/--as-of) and the batch/manifest modes do not compose with it — each
|
|
2368
|
+
// incompatible combination is a NAMED usage error up front, never a silently-ignored flag.
|
|
2369
|
+
if (opts.anchoredArtifact !== undefined) {
|
|
2370
|
+
if (opts.manifest !== undefined) {
|
|
2371
|
+
throw new UsageError("--anchored-artifact verifies ONE receipt; it cannot be combined with --manifest");
|
|
2372
|
+
}
|
|
2373
|
+
for (const [flag, val] of [
|
|
2374
|
+
["--vendor", opts.vendor],
|
|
2375
|
+
["--dir", opts.dir],
|
|
2376
|
+
["--revocations", opts.revocations],
|
|
2377
|
+
["--as-of", opts.asOf],
|
|
2378
|
+
]) {
|
|
2379
|
+
if (val !== undefined) {
|
|
2380
|
+
throw new UsageError(
|
|
2381
|
+
`${flag} does not apply to the anchored-receipt binding check (--anchored-artifact reads exactly two files: the receipt and the sealed artifact)`
|
|
2382
|
+
);
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
if (opts._pos.length !== 1) {
|
|
2386
|
+
throw new UsageError(
|
|
2387
|
+
"--anchored-artifact requires exactly ONE <receipt> positional: verify-vh <receipt> --anchored-artifact <sealed-file>"
|
|
2388
|
+
);
|
|
2389
|
+
}
|
|
2390
|
+
opts.batch = false;
|
|
2391
|
+
}
|
|
2392
|
+
// Preserve the SINGLE-artifact contract verbatim: exactly one positional and no --manifest.
|
|
2393
|
+
opts.artifact = opts._pos[0];
|
|
2394
|
+
return opts;
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
// ---------------------------------------------------------------------------
|
|
2398
|
+
// Manifest parsing. A manifest is a newline list OR a JSON array of artifact entries; each entry names an
|
|
2399
|
+
// artifact path and may carry a per-entry `--vendor`/`--dir` that overrides the top-level defaults.
|
|
2400
|
+
//
|
|
2401
|
+
// NEWLINE form — one entry per line, shell-style tokens. Blank lines and `#` comments are skipped:
|
|
2402
|
+
// releases/a.vhevidence.json
|
|
2403
|
+
// releases/b.vhseal --vendor 0xabc... --dir ./out
|
|
2404
|
+
// JSON form — an array of strings and/or objects:
|
|
2405
|
+
// ["a.vhevidence.json", {"artifact":"b.vhseal","vendor":"0xabc...","dir":"./out"}]
|
|
2406
|
+
//
|
|
2407
|
+
// Paths in the manifest resolve relative to the MANIFEST FILE's own directory (a release ships its
|
|
2408
|
+
// manifest next to its artifacts), unless the path is given a per-entry `--dir` for its SIBLINGS — note
|
|
2409
|
+
// `dir` localizes where an artifact's SIBLING files are read, exactly as the single-artifact `--dir` does;
|
|
2410
|
+
// the artifact path itself resolves against the manifest dir. The manifest is parsed in-process; NO new
|
|
2411
|
+
// crypto and NO network — it is a list, nothing more.
|
|
2412
|
+
// ---------------------------------------------------------------------------
|
|
2413
|
+
|
|
2414
|
+
// Minimal whitespace tokenizer for a newline-form manifest line. No quoting support is needed (artifact
|
|
2415
|
+
// paths and 0x addresses contain no spaces); a token is any run of non-whitespace.
|
|
2416
|
+
function tokenizeManifestLine(line) {
|
|
2417
|
+
return line.split(/\s+/).filter((t) => t.length > 0);
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
function parseManifestLine(line, lineNo) {
|
|
2421
|
+
const toks = tokenizeManifestLine(line);
|
|
2422
|
+
const entry = { artifact: undefined, vendor: undefined, dir: undefined };
|
|
2423
|
+
for (let i = 0; i < toks.length; i++) {
|
|
2424
|
+
const t = toks[i];
|
|
2425
|
+
const need = (flag) => {
|
|
2426
|
+
const v = toks[++i];
|
|
2427
|
+
if (v === undefined) throw new UsageError(`manifest line ${lineNo}: ${flag} requires a value`);
|
|
2428
|
+
return v;
|
|
2429
|
+
};
|
|
2430
|
+
if (t === "--vendor") entry.vendor = need("--vendor");
|
|
2431
|
+
else if (t === "--dir") entry.dir = need("--dir");
|
|
2432
|
+
else if (t.startsWith("--")) throw new UsageError(`manifest line ${lineNo}: unknown flag: ${t}`);
|
|
2433
|
+
else if (entry.artifact === undefined) entry.artifact = t;
|
|
2434
|
+
else throw new UsageError(`manifest line ${lineNo}: unexpected extra token: ${t}`);
|
|
2435
|
+
}
|
|
2436
|
+
if (entry.artifact === undefined) {
|
|
2437
|
+
throw new UsageError(`manifest line ${lineNo}: no artifact path`);
|
|
2438
|
+
}
|
|
2439
|
+
return entry;
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
function parseManifest(text, manifestPath) {
|
|
2443
|
+
const trimmed = text.replace(/^/, "").trim();
|
|
2444
|
+
const entries = [];
|
|
2445
|
+
if (trimmed.startsWith("[")) {
|
|
2446
|
+
// JSON array form.
|
|
2447
|
+
let arr;
|
|
2448
|
+
try {
|
|
2449
|
+
arr = JSON.parse(trimmed);
|
|
2450
|
+
} catch (e) {
|
|
2451
|
+
throw new IOError(`manifest ${manifestPath} is not valid JSON: ${e.message}`);
|
|
2452
|
+
}
|
|
2453
|
+
if (!Array.isArray(arr)) throw new IOError(`manifest ${manifestPath} JSON must be an array of entries`);
|
|
2454
|
+
arr.forEach((raw, idx) => {
|
|
2455
|
+
if (typeof raw === "string") {
|
|
2456
|
+
entries.push({ artifact: raw, vendor: undefined, dir: undefined });
|
|
2457
|
+
} else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
2458
|
+
if (typeof raw.artifact !== "string" || raw.artifact.length === 0) {
|
|
2459
|
+
throw new IOError(`manifest ${manifestPath} entry ${idx}: "artifact" must be a non-empty string`);
|
|
2460
|
+
}
|
|
2461
|
+
entries.push({
|
|
2462
|
+
artifact: raw.artifact,
|
|
2463
|
+
vendor: raw.vendor != null ? String(raw.vendor) : undefined,
|
|
2464
|
+
dir: raw.dir != null ? String(raw.dir) : undefined,
|
|
2465
|
+
});
|
|
2466
|
+
} else {
|
|
2467
|
+
throw new IOError(`manifest ${manifestPath} entry ${idx} must be a string or { artifact, vendor?, dir? }`);
|
|
2468
|
+
}
|
|
2469
|
+
});
|
|
2470
|
+
} else {
|
|
2471
|
+
// Newline form: one entry per non-blank, non-comment line.
|
|
2472
|
+
const lines = trimmed.split(/\r?\n/);
|
|
2473
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2474
|
+
const line = lines[i];
|
|
2475
|
+
const bare = line.trim();
|
|
2476
|
+
if (bare.length === 0 || bare.startsWith("#")) continue;
|
|
2477
|
+
entries.push(parseManifestLine(line, i + 1));
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
if (entries.length === 0) {
|
|
2481
|
+
throw new UsageError(`manifest ${manifestPath} lists no artifacts`);
|
|
2482
|
+
}
|
|
2483
|
+
return entries;
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
// ---------------------------------------------------------------------------
|
|
2487
|
+
// THE DISK FILE SOURCE — the CLI's `readEntry` implementation, carrying the FULL path-confinement
|
|
2488
|
+
// discipline the disk path always had (byte-identical classification):
|
|
2489
|
+
// (1) string-level confinement, BEFORE any filesystem access: an ABSOLUTE relPath, or any relPath with
|
|
2490
|
+
// a `..` path COMPONENT, is REJECTED unread;
|
|
2491
|
+
// (2) resolved-path confinement: a resolved path that ESCAPES baseDir (string-wise, against the
|
|
2492
|
+
// realpath of baseDir) is REJECTED;
|
|
2493
|
+
// (3) post-open symlink confinement: after opening a present file we realpath it and re-assert
|
|
2494
|
+
// containment, defeating a sibling that is a SYMLINK pointing out of baseDir (fs.readFileSync
|
|
2495
|
+
// follows symlinks regardless of the string check) — the just-read bytes are DROPPED, never hashed.
|
|
2496
|
+
// ---------------------------------------------------------------------------
|
|
2497
|
+
|
|
2498
|
+
// True when a resolved absolute path escapes the (already realpath'd) base directory. A path equal to the
|
|
2499
|
+
// base or under it does not escape; anything that path.relative()'s to "" / ".." / an absolute drive is out.
|
|
2500
|
+
function escapesBase(baseReal, abs) {
|
|
2501
|
+
const rel = path.relative(baseReal, abs);
|
|
2502
|
+
return rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel);
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
function makeDiskReadEntry(baseDir) {
|
|
2506
|
+
// Anchor confinement on the REALPATH of baseDir so a symlinked baseDir itself (e.g. /tmp -> /private/tmp
|
|
2507
|
+
// on macOS) does not spuriously trip the containment check on otherwise-legitimate siblings.
|
|
2508
|
+
let baseReal;
|
|
2509
|
+
try {
|
|
2510
|
+
baseReal = fs.realpathSync(baseDir);
|
|
2511
|
+
} catch (_) {
|
|
2512
|
+
baseReal = path.resolve(baseDir);
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
return function readEntry(relPath) {
|
|
2516
|
+
// (1) String-level confinement, BEFORE any filesystem access.
|
|
2517
|
+
if (
|
|
2518
|
+
typeof relPath !== "string" ||
|
|
2519
|
+
relPath.length === 0 ||
|
|
2520
|
+
path.isAbsolute(relPath) ||
|
|
2521
|
+
relPath.split(/[\\/]/).includes("..")
|
|
2522
|
+
) {
|
|
2523
|
+
return { status: "escaped" };
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
// (2) Resolved-path confinement: the resolved absolute path must stay under baseReal.
|
|
2527
|
+
const abs = path.resolve(baseDir, relPath);
|
|
2528
|
+
if (escapesBase(baseReal, abs)) {
|
|
2529
|
+
return { status: "escaped" };
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
let bytes;
|
|
2533
|
+
try {
|
|
2534
|
+
bytes = fs.readFileSync(abs);
|
|
2535
|
+
} catch (_) {
|
|
2536
|
+
return { status: "missing" };
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
// (3) Post-open symlink confinement.
|
|
2540
|
+
let real;
|
|
2541
|
+
try {
|
|
2542
|
+
real = fs.realpathSync(abs);
|
|
2543
|
+
} catch (_) {
|
|
2544
|
+
real = abs;
|
|
2545
|
+
}
|
|
2546
|
+
if (escapesBase(baseReal, real)) {
|
|
2547
|
+
return { status: "escaped" };
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
return { status: "ok", bytes };
|
|
2551
|
+
};
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
// The original disk-shaped helpers, kept with their exact signatures + behavior (thin wrappers over the
|
|
2555
|
+
// engine with a disk source). `relResolver` was always accepted-and-unused on classifyFiles; retained so
|
|
2556
|
+
// the signature does not shift.
|
|
2557
|
+
function classifyFiles(sealedEntries, baseDir, relResolver) { // eslint-disable-line no-unused-vars
|
|
2558
|
+
return classifyFilesWith(sealedEntries, makeDiskReadEntry(baseDir));
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
function verifyEvidenceSeal(seal, baseDir) {
|
|
2562
|
+
return verifyEvidenceSealWith(seal, makeDiskReadEntry(baseDir));
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
function verifyTrustSeal(seal, baseDir) {
|
|
2566
|
+
return verifyTrustSealWith(seal, makeDiskReadEntry(baseDir));
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
// ---------------------------------------------------------------------------
|
|
2570
|
+
// The DISK verify entrypoint — the original CLI contract, byte-identical: reads + JSON-parses the
|
|
2571
|
+
// artifact, then drives the SAME pure engine with the disk file source. Returns { result, code }.
|
|
2572
|
+
// ---------------------------------------------------------------------------
|
|
2573
|
+
|
|
2574
|
+
function verifyArtifact(opts) {
|
|
2575
|
+
if (!opts.artifact) throw new UsageError("verify-vh requires an <artifact>");
|
|
2576
|
+
|
|
2577
|
+
const artifactPath = path.resolve(opts.artifact);
|
|
2578
|
+
let text;
|
|
2579
|
+
try {
|
|
2580
|
+
text = fs.readFileSync(artifactPath, "utf8");
|
|
2581
|
+
} catch (e) {
|
|
2582
|
+
throw new IOError(`cannot read artifact ${opts.artifact}: ${e.message}`);
|
|
2583
|
+
}
|
|
2584
|
+
let obj;
|
|
2585
|
+
try {
|
|
2586
|
+
obj = JSON.parse(text);
|
|
2587
|
+
} catch (e) {
|
|
2588
|
+
throw new IOError(`artifact ${opts.artifact} is not valid JSON: ${e.message}`);
|
|
2589
|
+
}
|
|
2590
|
+
if (obj == null || typeof obj !== "object" || Array.isArray(obj)) {
|
|
2591
|
+
throw new IOError(`artifact ${opts.artifact} must be a JSON object`);
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
// A bare anchored receipt reached the sibling-verify path: point the caller at the two-file binding
|
|
2595
|
+
// check instead of the generic "unrecognized kind" (a receipt alone carries nothing to re-derive).
|
|
2596
|
+
if (obj.kind === ANCHORED_RECEIPT_KIND) {
|
|
2597
|
+
throw new UsageError(
|
|
2598
|
+
`${opts.artifact} is a ${ANCHORED_RECEIPT_KIND} anchored receipt — verify its OFFLINE binding ` +
|
|
2599
|
+
"leg against the sealed artifact it anchors: verify-vh <receipt> --anchored-artifact <sealed-file>"
|
|
2600
|
+
);
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
// The base directory siblings resolve against: --dir override else the artifact's own directory.
|
|
2604
|
+
const baseDir = opts.dir != null ? path.resolve(opts.dir) : path.dirname(artifactPath);
|
|
2605
|
+
|
|
2606
|
+
const { result, code } = verifyParsedArtifact({
|
|
2607
|
+
artifact: opts.artifact,
|
|
2608
|
+
obj,
|
|
2609
|
+
vendor: opts.vendor,
|
|
2610
|
+
readEntry: makeDiskReadEntry(baseDir),
|
|
2611
|
+
});
|
|
2612
|
+
|
|
2613
|
+
// OPTIONAL recipient-side TRUST-DECISION-AS-OF (EPIC-51 / T-51.4). Runs ONLY under --revocations — with no
|
|
2614
|
+
// flag the result + code are byte-identical to the pre-T-51.4 baseline (regression-pinned). A signer
|
|
2615
|
+
// revoked-before-as-of downgrades an otherwise-ACCEPTED artifact to REVOKED (exit 3); a later-dated
|
|
2616
|
+
// revocation is informational; a forged/tampered/third-party one is ignored with a warning. OFFLINE /
|
|
2617
|
+
// key-free on the read side; the revocations file/dir is the ONLY new I/O. This reaches the SAME downgrade
|
|
2618
|
+
// `vh ... verify-signed --revocations` does, byte-for-byte on identical inputs.
|
|
2619
|
+
if (opts.revocations) {
|
|
2620
|
+
let applied;
|
|
2621
|
+
try {
|
|
2622
|
+
applied = revocation.loadAndApply({
|
|
2623
|
+
result,
|
|
2624
|
+
revocationsPath: opts.revocations,
|
|
2625
|
+
asOf: opts.asOf,
|
|
2626
|
+
nowISO: opts.nowISO || new Date().toISOString(),
|
|
2627
|
+
});
|
|
2628
|
+
} catch (e) {
|
|
2629
|
+
// A malformed --as-of is caught at parse time; here the only failures are an unreadable path or a
|
|
2630
|
+
// non-JSON single revocations file — a genuine IO error (exit 1), surfaced (never a stack), never a
|
|
2631
|
+
// silently-skipped downgrade.
|
|
2632
|
+
throw new IOError(`cannot evaluate --revocations ${opts.revocations}: ${e.message}`);
|
|
2633
|
+
}
|
|
2634
|
+
// A REVOKED decision flips an otherwise-ACCEPTED verdict to REVOKED (exit 3); an already-REJECTED verdict
|
|
2635
|
+
// is left rejected (the trust-as-of never upgrades). The trustAsOf block + defaulted flag ride along for
|
|
2636
|
+
// the renderer.
|
|
2637
|
+
const downgraded = applied.result;
|
|
2638
|
+
downgraded.trustAsOfDefaulted = applied.defaulted;
|
|
2639
|
+
const newCode = downgraded.accepted ? EXIT.OK : EXIT.REJECTED;
|
|
2640
|
+
return { result: downgraded, code: newCode };
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
return { result, code };
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
// ---------------------------------------------------------------------------
|
|
2647
|
+
// BATCH / MANIFEST orchestration (T-33.1). One invocation gates EVERY artifact a release produces and
|
|
2648
|
+
// returns ONE CI exit code. Each entry is verified READ-ONLY through the SAME `verifyArtifact` core (NO
|
|
2649
|
+
// new crypto, NO new artifact kind, path-escape/no-network guarantees preserved per entry); the per-entry
|
|
2650
|
+
// `--json` body is the IDENTICAL single-artifact shape, so there is no divergence to drift.
|
|
2651
|
+
//
|
|
2652
|
+
// AGGREGATE EXIT CONTRACT:
|
|
2653
|
+
// * exit 0 (OK) — and only if — EVERY artifact verifies (each accepted).
|
|
2654
|
+
// * exit 3 (REJECTED) — if ANY artifact is rejected (CHANGED/MISSING/bad_signature/wrong_issuer/…);
|
|
2655
|
+
// the report names WHICH artifact failed and why.
|
|
2656
|
+
// * exit 2 (USAGE) — a malformed flag / per-entry --vendor (raised before any verify runs).
|
|
2657
|
+
// * exit 1 (IO) — an artifact (or the manifest itself) is unreadable / not the expected shape.
|
|
2658
|
+
// Usage/IO are evaluated PER ENTRY and SHORT-CIRCUIT the whole run with the matching code, exactly as the
|
|
2659
|
+
// single-artifact path does — a release gate must not "pass" while one of its artifacts could not even be
|
|
2660
|
+
// read or parsed. The IO/USAGE code wins over a REJECTED tally (you cannot certify a batch you could not
|
|
2661
|
+
// fully evaluate).
|
|
2662
|
+
// ---------------------------------------------------------------------------
|
|
2663
|
+
|
|
2664
|
+
function buildBatchEntries(opts) {
|
|
2665
|
+
// Returns [{ artifact, vendor, dir }] with top-level --vendor/--dir applied as DEFAULTS each entry may
|
|
2666
|
+
// override. Artifact paths from a manifest resolve against the manifest file's own directory.
|
|
2667
|
+
if (opts.manifest !== undefined) {
|
|
2668
|
+
const manifestPath = path.resolve(opts.manifest);
|
|
2669
|
+
let text;
|
|
2670
|
+
try {
|
|
2671
|
+
text = fs.readFileSync(manifestPath, "utf8");
|
|
2672
|
+
} catch (e) {
|
|
2673
|
+
throw new IOError(`cannot read manifest ${opts.manifest}: ${e.message}`);
|
|
2674
|
+
}
|
|
2675
|
+
const manifestDir = path.dirname(manifestPath);
|
|
2676
|
+
return parseManifest(text, opts.manifest).map((e) => ({
|
|
2677
|
+
// The artifact path resolves relative to the manifest's directory (a release ships them together).
|
|
2678
|
+
artifact: path.resolve(manifestDir, e.artifact),
|
|
2679
|
+
// Per-entry --vendor/--dir override the top-level defaults; a --dir resolves against the manifest dir.
|
|
2680
|
+
vendor: e.vendor != null ? e.vendor : opts.vendor,
|
|
2681
|
+
dir: e.dir != null ? path.resolve(manifestDir, e.dir) : opts.dir,
|
|
2682
|
+
}));
|
|
2683
|
+
}
|
|
2684
|
+
// Repeated positional <artifact> args: each inherits the (single) top-level --vendor/--dir.
|
|
2685
|
+
return opts._pos.map((a) => ({ artifact: a, vendor: opts.vendor, dir: opts.dir }));
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
function verifyBatch(opts) {
|
|
2689
|
+
const entries = buildBatchEntries(opts);
|
|
2690
|
+
const results = [];
|
|
2691
|
+
for (const e of entries) {
|
|
2692
|
+
// Verify each entry through the SAME core. A USAGE/IO problem with any single entry short-circuits the
|
|
2693
|
+
// whole batch with that code (the gate cannot certify a release it could not fully evaluate). The
|
|
2694
|
+
// top-level --revocations/--as-of (T-51.4) apply to EVERY entry as a default, so one revocations
|
|
2695
|
+
// file/dir gates a whole release's signed artifacts under one as-of instant.
|
|
2696
|
+
const { result } = verifyArtifact({
|
|
2697
|
+
artifact: e.artifact,
|
|
2698
|
+
vendor: e.vendor,
|
|
2699
|
+
dir: e.dir,
|
|
2700
|
+
revocations: opts.revocations,
|
|
2701
|
+
asOf: opts.asOf,
|
|
2702
|
+
nowISO: opts.nowISO,
|
|
2703
|
+
});
|
|
2704
|
+
results.push(result);
|
|
2705
|
+
}
|
|
2706
|
+
const total = results.length;
|
|
2707
|
+
const passed = results.filter((r) => r.accepted).length;
|
|
2708
|
+
const failed = total - passed;
|
|
2709
|
+
const ok = failed === 0;
|
|
2710
|
+
const aggregate = { ok, total, passed, failed, results };
|
|
2711
|
+
return { aggregate, code: ok ? EXIT.OK : EXIT.REJECTED };
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
// ---------------------------------------------------------------------------
|
|
2715
|
+
// Human + JSON rendering.
|
|
2716
|
+
// ---------------------------------------------------------------------------
|
|
2717
|
+
|
|
2718
|
+
function renderHuman(r) {
|
|
2719
|
+
const L = [];
|
|
2720
|
+
L.push(TRUST_NOTE);
|
|
2721
|
+
L.push("");
|
|
2722
|
+
L.push(`# verify-vh — ${r.artifact}`);
|
|
2723
|
+
L.push(`kind: ${r.kind}`);
|
|
2724
|
+
if (r.payloadKind !== r.kind) L.push(`embedded kind: ${r.payloadKind}`);
|
|
2725
|
+
L.push(`signed: ${r.signed ? "yes" : "no"}`);
|
|
2726
|
+
if (r.signed) {
|
|
2727
|
+
L.push(`recovered signer:${r.recoveredSigner ? " " + r.recoveredSigner : " (unrecoverable)"}`);
|
|
2728
|
+
L.push(`claimed signer: ${r.claimedSigner}`);
|
|
2729
|
+
if (r.pinnedVendor != null) {
|
|
2730
|
+
L.push(`pinned --vendor: ${r.pinnedVendor}`);
|
|
2731
|
+
L.push(`signer matches vendor: ${r.signerMatchesVendor ? "yes" : "NO"}`);
|
|
2732
|
+
} else {
|
|
2733
|
+
L.push("(no --vendor pin: the recovered signer above is reported, not pinned)");
|
|
2734
|
+
}
|
|
2735
|
+
} else if (r.recoveredSigner == null && r.pinnedVendor != null) {
|
|
2736
|
+
L.push("note: --vendor was supplied but this artifact is UNSIGNED (no signer to pin)");
|
|
2737
|
+
}
|
|
2738
|
+
if (r.sealedRoot != null) L.push(`sealed root: ${r.sealedRoot}`);
|
|
2739
|
+
if (r.recomputedRoot != null) L.push(`recomputed root: ${r.recomputedRoot}`);
|
|
2740
|
+
if (r.rootMatches != null) L.push(`root matches: ${r.rootMatches ? "yes" : "NO"}`);
|
|
2741
|
+
if (r.identityOnly) {
|
|
2742
|
+
L.push("(identity-only artifact: it commits to a dataset root/digest, not a re-walkable file set)");
|
|
2743
|
+
}
|
|
2744
|
+
L.push(
|
|
2745
|
+
`files: ${r.counts.matched} matched, ${r.counts.changed} changed, ` +
|
|
2746
|
+
`${r.counts.missing} missing, ${r.counts.escaped || 0} rejected, ${r.counts.unexpected} unexpected`
|
|
2747
|
+
);
|
|
2748
|
+
// AGENT-SESSION packet block (T-68.3) — present ONLY for r.agent results, so every other kind's
|
|
2749
|
+
// output stays byte-identical.
|
|
2750
|
+
if (r.agent) {
|
|
2751
|
+
L.push(`declared head: { size: ${r.agent.head.size}, root: ${r.agent.head.root} }`);
|
|
2752
|
+
if (r.agent.counts) {
|
|
2753
|
+
L.push(
|
|
2754
|
+
`events: ${r.agent.counts.events} (${r.agent.counts.full} full, ${r.agent.counts.redacted} redacted)`
|
|
2755
|
+
);
|
|
2756
|
+
L.push(
|
|
2757
|
+
`withheld seqs: ${r.agent.withheld.length === 0 ? "(none — every payload disclosed)" : r.agent.withheld.join(", ")}`
|
|
2758
|
+
);
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
// OPTIONAL recipient-side TRUST-DECISION-AS-OF block (T-51.4) — printed ONLY when --revocations was
|
|
2762
|
+
// supplied (r.trustAsOf is attached then). With no flag this block is absent, so the output is byte-
|
|
2763
|
+
// identical to the pre-T-51.4 baseline. The block reads the SAME way the producer's verify-signed does.
|
|
2764
|
+
if (r.trustAsOf) {
|
|
2765
|
+
L.push("");
|
|
2766
|
+
for (const line of revocation.renderTrustAsOf(r.trustAsOf, { defaulted: r.trustAsOfDefaulted })) {
|
|
2767
|
+
L.push(line);
|
|
2768
|
+
}
|
|
2769
|
+
}
|
|
2770
|
+
L.push("");
|
|
2771
|
+
if (r.accepted) {
|
|
2772
|
+
L.push("OK — the artifact verifies.");
|
|
2773
|
+
} else if (r.reason === "key_revoked_as_of") {
|
|
2774
|
+
// The signature + bytes checked out, but the signing key was revoked AT OR BEFORE the as-of instant — a
|
|
2775
|
+
// distinct REVOKED verdict (exit 3), matching the producer's verify-signed downgrade.
|
|
2776
|
+
const g = r.trustAsOf && r.trustAsOf.governing;
|
|
2777
|
+
L.push("REVOKED (key_revoked_as_of):");
|
|
2778
|
+
if (g) {
|
|
2779
|
+
L.push(
|
|
2780
|
+
` key_revoked_as_of: the signing key (${g.vendorAddress}) was REVOKED as of ${g.revokedAt} ` +
|
|
2781
|
+
`(reason: ${g.reason})${g.supersededBy ? `, superseded by ${g.supersededBy}` : ""} — at or before ` +
|
|
2782
|
+
`the as-of instant. The bytes + signature check out, but the key was no longer trustworthy then.`
|
|
2783
|
+
);
|
|
2784
|
+
}
|
|
2785
|
+
} else {
|
|
2786
|
+
L.push(`REJECTED (${r.reason}):`);
|
|
2787
|
+
for (const c of r.changed) {
|
|
2788
|
+
L.push(` CHANGED ${c.relPath}: sealed ${c.expectedContentHash} != on-disk ${c.actualContentHash}`);
|
|
2789
|
+
}
|
|
2790
|
+
for (const m of r.missing) {
|
|
2791
|
+
L.push(` MISSING ${m.relPath}: referenced but not found on disk`);
|
|
2792
|
+
}
|
|
2793
|
+
for (const x of r.escaped || []) {
|
|
2794
|
+
// SECURITY: print the attacker's relPath string ONLY — never a content hash of the out-of-tree target.
|
|
2795
|
+
L.push(` REJECTED ${x.relPath}: path escapes the artifact directory (refused to read; no hash computed)`);
|
|
2796
|
+
}
|
|
2797
|
+
for (const u of r.unexpected) {
|
|
2798
|
+
L.push(` UNEXPECTED ${u.relPath}: on disk but not referenced`);
|
|
2799
|
+
}
|
|
2800
|
+
if (r.reason === "bad_signature") {
|
|
2801
|
+
L.push(" bad_signature: the signature does not recover to the claimed signer (tampered or forged).");
|
|
2802
|
+
}
|
|
2803
|
+
if (r.reason === "wrong_issuer") {
|
|
2804
|
+
L.push(
|
|
2805
|
+
` wrong_issuer: recovered ${r.recoveredSigner} but you pinned --vendor ${r.pinnedVendor}.`
|
|
2806
|
+
);
|
|
2807
|
+
}
|
|
2808
|
+
if (r.reason === "unsigned_cannot_pin_vendor") {
|
|
2809
|
+
L.push(" --vendor was pinned but the artifact carries no signature to recover a signer from.");
|
|
2810
|
+
}
|
|
2811
|
+
if (r.reason === "root_mismatch") {
|
|
2812
|
+
L.push(" root_mismatch: the recomputed root does not equal the sealed root.");
|
|
2813
|
+
}
|
|
2814
|
+
if (r.reason === "path_escape") {
|
|
2815
|
+
L.push(
|
|
2816
|
+
" path_escape: the artifact references a file OUTSIDE its own directory (absolute path, `..` " +
|
|
2817
|
+
"traversal, or an out-of-tree symlink). A genuine artifact never does this; refused to read it."
|
|
2818
|
+
);
|
|
2819
|
+
}
|
|
2820
|
+
// AGENT-SESSION packet reject details (T-68.3): name the first offending event seq + the named fault.
|
|
2821
|
+
if (r.agent) {
|
|
2822
|
+
if (r.agent.seq !== null && r.agent.seq !== undefined) {
|
|
2823
|
+
L.push(` first offending event seq: ${r.agent.seq}${r.agent.reason ? ` (${r.agent.reason})` : ""}`);
|
|
2824
|
+
}
|
|
2825
|
+
if (r.reason === "event_invalid") {
|
|
2826
|
+
L.push(
|
|
2827
|
+
` event_invalid: an event failed strict canonical validation` +
|
|
2828
|
+
`${r.agent.field ? ` (field: ${r.agent.field})` : ""} — the packet cannot be trusted.`
|
|
2829
|
+
);
|
|
2830
|
+
}
|
|
2831
|
+
if (r.reason === "counts_mismatch") {
|
|
2832
|
+
L.push(" counts_mismatch: the packet's declared full/redacted counts do not match a recount.");
|
|
2833
|
+
}
|
|
2834
|
+
if (r.reason === "head_not_bound") {
|
|
2835
|
+
L.push(
|
|
2836
|
+
" head_not_bound: the headAttestation signs a DIFFERENT { size, root } than this packet's " +
|
|
2837
|
+
"events derive — the signature belongs to another session."
|
|
2838
|
+
);
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
}
|
|
2842
|
+
L.push("");
|
|
2843
|
+
return L.join("\n");
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
// Human rendering of a batch aggregate: a per-artifact PASS/FAIL line (FAIL names the reason), then the
|
|
2847
|
+
// one-line roll-up + the final verdict. The trust note is printed ONCE at the top.
|
|
2848
|
+
function renderBatchHuman(agg) {
|
|
2849
|
+
const L = [];
|
|
2850
|
+
L.push(TRUST_NOTE);
|
|
2851
|
+
L.push("");
|
|
2852
|
+
L.push(`# verify-vh — BATCH (${agg.total} artifact${agg.total === 1 ? "" : "s"})`);
|
|
2853
|
+
for (const r of agg.results) {
|
|
2854
|
+
if (r.accepted) {
|
|
2855
|
+
L.push(` PASS ${r.artifact}`);
|
|
2856
|
+
} else {
|
|
2857
|
+
L.push(` FAIL ${r.artifact} (${r.reason})`);
|
|
2858
|
+
// Localize the first failing detail so a CI log names exactly what moved, per artifact.
|
|
2859
|
+
for (const c of r.changed) {
|
|
2860
|
+
L.push(` CHANGED ${c.relPath}: sealed ${c.expectedContentHash} != on-disk ${c.actualContentHash}`);
|
|
2861
|
+
}
|
|
2862
|
+
for (const m of r.missing) {
|
|
2863
|
+
L.push(` MISSING ${m.relPath}`);
|
|
2864
|
+
}
|
|
2865
|
+
for (const x of r.escaped || []) {
|
|
2866
|
+
L.push(` REJECTED ${x.relPath}: path escapes the artifact directory (no hash computed)`);
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
}
|
|
2870
|
+
L.push("");
|
|
2871
|
+
L.push(`total: ${agg.total}, passed: ${agg.passed}, failed: ${agg.failed}`);
|
|
2872
|
+
L.push(agg.ok ? "OK — every artifact verifies." : `REJECTED — ${agg.failed} artifact(s) failed.`);
|
|
2873
|
+
L.push("");
|
|
2874
|
+
return L.join("\n");
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
// ---------------------------------------------------------------------------
|
|
2878
|
+
// `demo` — the ZERO-CONFIG, zero-flag, zero-key-knowledge quickstart (T-55.2).
|
|
2879
|
+
//
|
|
2880
|
+
// WHY THIS EXISTS
|
|
2881
|
+
// A cold prospect should be able to go from NOTHING to a VERIFIED packet in one command — `verify-vh demo`
|
|
2882
|
+
// (or `npx … demo`) — with NO flags, NO `--vendor` to paste, and NO key knowledge. The whole sales promise
|
|
2883
|
+
// ("don't trust us — verify it yourself, offline") is unfalsifiable until they have RUN the tool once and
|
|
2884
|
+
// watched it ACCEPT a genuine packet, name the signer, then REJECT a one-byte-tampered copy. `demo` IS that
|
|
2885
|
+
// first run: it ships a tiny, self-contained, GENUINELY-SIGNED evidence packet baked into this file, plays
|
|
2886
|
+
// it through the EXACT same `verifyArtifact` core every real verify uses, and prints the honest verdict.
|
|
2887
|
+
//
|
|
2888
|
+
// HOW IT STAYS HONEST (no special-case verify path)
|
|
2889
|
+
// The fixture below is a REAL `vh.evidence-seal-signed` container: a keccak Merkle seal over two referenced
|
|
2890
|
+
// files, signed with a FIXED, well-known TEST-ONLY key (NEVER a real key, NEVER real funds — its address is
|
|
2891
|
+
// the standard hardhat account #1, published precisely so no one mistakes it for a production signer). The
|
|
2892
|
+
// signature was produced once with the family's real EIP-191 personal-sign path; the demo RECOVERS it with
|
|
2893
|
+
// the SAME vendored secp256k1 recovery a real verify uses, so the signer address printed is genuinely
|
|
2894
|
+
// recovered from the bytes — not echoed. `demo` materializes the packet + its two files into a throwaway
|
|
2895
|
+
// temp dir, runs the real `verifyArtifact` twice (genuine -> ACCEPT pinned to the recovered signer; a
|
|
2896
|
+
// one-byte-tampered copy -> REJECT/CHANGED), then deletes the temp dir. It writes NOTHING under cwd.
|
|
2897
|
+
// ---------------------------------------------------------------------------
|
|
2898
|
+
|
|
2899
|
+
// The fixed TEST-ONLY signer (hardhat account #1). Published so it can NEVER be confused with a real key.
|
|
2900
|
+
const DEMO_SIGNER = "0x70997970c51812dc3a010c7d01b50e0d17dc79c8";
|
|
2901
|
+
|
|
2902
|
+
// The two referenced files the demo seal commits to, by relPath -> exact UTF-8 content.
|
|
2903
|
+
const DEMO_FILES = Object.freeze({
|
|
2904
|
+
"model-card.md": "# Demo model card\nThis file is sealed by the verify-vh demo.\n",
|
|
2905
|
+
"weights.txt": "0.10 0.20 0.30\n",
|
|
2906
|
+
});
|
|
2907
|
+
|
|
2908
|
+
// The GENUINELY-SIGNED evidence container. `attestation` is the EXACT bytes the signature is over (the same
|
|
2909
|
+
// plain serialization the producer's evidence path emits for the embedded seal); the signature is a real
|
|
2910
|
+
// 65-byte EIP-191 personal-sign over those bytes by DEMO_SIGNER. Re-derived from DEMO_FILES (a build-time
|
|
2911
|
+
// check would re-seal the same bytes), so the root binds the real file content above.
|
|
2912
|
+
const DEMO_CONTAINER = Object.freeze({
|
|
2913
|
+
kind: "vh.evidence-seal-signed",
|
|
2914
|
+
attestation:
|
|
2915
|
+
'{"kind":"vh.evidence-seal","files":[{"relPath":"model-card.md","contentHash":"0x1aeca0ad922f53e9c30186234c5d1a62ffda62a828988bdd266fa93240675db0","leaf":"0xbbb3052a7359188aed3f114e15b721cf5d707a8bdf09109d1d51ec5765b3c58c"},{"relPath":"weights.txt","contentHash":"0x7716d380e062d1daf7ca58897b55f6b58900ed4fd1eda79445956c5c3d336cdf","leaf":"0x34ce488c6fb49a32d356a2553196dc817a439c13a03ce9a2a2ff2710fcf9eea2"}],"root":"0x621a5eb924a9887f88d4b05ccdf19834cdae2f4ed2399921acc7b8a45d48da9b"}',
|
|
2916
|
+
signature: {
|
|
2917
|
+
scheme: "eip191-personal-sign",
|
|
2918
|
+
signer: DEMO_SIGNER,
|
|
2919
|
+
signature:
|
|
2920
|
+
"0x1aabba1530df192e87498bbf1a26f63a7e30d84d72c14bf5d08b2d872df9810b672efcf26f30ec6a38a00ffc158be53633daeff9e99f344b6c1a2e99522d61a01b",
|
|
2921
|
+
},
|
|
2922
|
+
});
|
|
2923
|
+
|
|
2924
|
+
// The packet filename the demo materializes (shared by the throwaway-temp round-trip and the `demo <dir>`
|
|
2925
|
+
// keepable scaffold) so the "NEXT" command the demo prints names the file it actually wrote.
|
|
2926
|
+
const DEMO_PACKET_NAME = "demo-packet.vhevidence.json";
|
|
2927
|
+
|
|
2928
|
+
// ---------------------------------------------------------------------------
|
|
2929
|
+
// The DEMO AGENT-SESSION packet (T-68.3): a small, GENUINE `vh.agent-session-packet` produced by the
|
|
2930
|
+
// REAL `vh agent seal` + `vh agent redact` path (never re-authored by hand) — a 4-event session
|
|
2931
|
+
// (prompt -> tool_call -> tool_result -> completion) whose tool_call payload (seq 1) is REDACTED
|
|
2932
|
+
// behind its hash commitment, so the fixture demonstrates the load-bearing property: a redacted
|
|
2933
|
+
// packet STILL VERIFIES (identical leaves + root). UNSIGNED — the whole agent verify surface is the
|
|
2934
|
+
// FREE funnel leg. The standalone HTML page inlines these constants verbatim (next to DEMO_FILES /
|
|
2935
|
+
// DEMO_CONTAINER above) for its built-in agent demo: click -> ACCEPT; tamper ONE byte of a payload in
|
|
2936
|
+
// the page -> REJECT naming event seq DEMO_AGENT_TAMPER_SEQ. The TAMPER_FROM/TO pair is a one-byte
|
|
2937
|
+
// substring edit that occurs EXACTLY once in the packet text (pinned by test/verifier.agent.test.js).
|
|
2938
|
+
// ---------------------------------------------------------------------------
|
|
2939
|
+
const DEMO_AGENT_PACKET_NAME = "demo-session.vhagent.json";
|
|
2940
|
+
const DEMO_AGENT_PACKET_TEXT = "{\"kind\":\"vh.agent-session-packet\",\"schemaVersion\":1,\"note\":\"This agent-session packet is TAMPER-EVIDENT + OFFLINE-RECOMPUTABLE, NOT a trusted timestamp and NOT a claim the agent behaved well. Its ordered Merkle `head` {size, root} (RFC-6962-style, position-bound) commits to every event: verify RE-DERIVES each event leaf — recomputing the payload hash commitment for a FULL event, checking the carried commitment for a REDACTED one — and the root from the events you hold, and a REJECT names the first offending event seq. Redaction WITHHOLDS a payload behind its hash commitment without changing any leaf or the root: it can hide, never silently alter. Event `ts` fields are SELF-ASSERTED metadata (recorded, never verified against any clock); \\\"sealed at time T\\\" rides the human-owned signing/timestamp trust-root (STRATEGY.md P-3). Garbage-in is out of scope: the head proves the LOG is intact and append-only, not that the log faithfully records what the agent actually did. The packet is an UNTRUSTED transport container: verify never trusts the packet's own stored hashes.\",\"head\":{\"size\":4,\"root\":\"0xd455ad3f8050f1d863d65003532055326629bf92574cf8919b022222abdf66d1\"},\"counts\":{\"events\":4,\"full\":3,\"redacted\":1},\"events\":[{\"seq\":0,\"ts\":\"2026-07-01T09:00:00.000Z\",\"actor\":\"user\",\"type\":\"prompt\",\"payload\":\"Summarize the vendor contract and flag any auto-renewal clause.\",\"payloadHash\":\"0x1e2d99e683d2623c77a82721f633f27206cd8051be8c848509f63bb570bd5be4\"},{\"seq\":1,\"ts\":\"2026-07-01T09:00:01.000Z\",\"actor\":\"agent:assistant\",\"type\":\"tool_call\",\"payloadHash\":\"0x32133a5998ab97eaef8850a7a47cec6e1056b964a050e6e5561f97ec22b24498\",\"redacted\":true,\"meta\":{\"tool\":\"contract_search\"}},{\"seq\":2,\"ts\":\"2026-07-01T09:00:02.000Z\",\"actor\":\"tool:contract_search\",\"type\":\"tool_result\",\"payload\":\"Section 12.3: renews automatically for successive 12-month terms unless cancelled 60 days prior.\",\"payloadHash\":\"0x57bed64393fb6ed461a5b00143cc239cf705e4a1ea5d0ee84a8f5f7ecc85bdc1\"},{\"seq\":3,\"ts\":\"2026-07-01T09:00:03.000Z\",\"actor\":\"agent:assistant\",\"type\":\"completion\",\"payload\":\"Flagged: Section 12.3 auto-renews for successive 12-month terms and requires 60 days cancellation notice.\",\"payloadHash\":\"0x43649f64cb62093be040484c6858b80f0973e6aa2bd9bc4df75c0c725dcd5bb4\"}],\"leaves\":[\"0x5a3354160c02d09a5b653227ebd35d8f0a1ade1284e402049b91c4f8acd873e3\",\"0x57ac83bf53104a1d952cf9d00e904f15e31d4cc17bc6ff0aedacd1b6ca40904a\",\"0xb3ee61a8dc496b92e05db48b990edee212bda46ca29e5480efb056a5c2cf817f\",\"0x1000b07e45f6151bcf49be6266358cec551a690654f22dc5dae279e7d6bfb7d1\"]}\n";
|
|
2941
|
+
const DEMO_AGENT_TAMPER_SEQ = 0;
|
|
2942
|
+
const DEMO_AGENT_TAMPER_FROM = "\"payload\":\"Summarize the vendor contract";
|
|
2943
|
+
const DEMO_AGENT_TAMPER_TO = "\"payload\":\"SUMMARIZE the vendor contract";
|
|
2944
|
+
|
|
2945
|
+
// Materialize the demo packet + its referenced files into `dir`. Returns the packet path.
|
|
2946
|
+
function writeDemoFixture(dir) {
|
|
2947
|
+
for (const [rel, content] of Object.entries(DEMO_FILES)) {
|
|
2948
|
+
fs.writeFileSync(path.join(dir, rel), content);
|
|
2949
|
+
}
|
|
2950
|
+
const packetPath = path.join(dir, DEMO_PACKET_NAME);
|
|
2951
|
+
fs.writeFileSync(packetPath, JSON.stringify(DEMO_CONTAINER, null, 2));
|
|
2952
|
+
return packetPath;
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
// Run the zero-config demo: seal -> ACCEPT (pinned to the recovered signer) -> tamper -> REJECT. Uses the
|
|
2956
|
+
// REAL verifyArtifact core for BOTH runs (no bespoke verify path), so the verdicts are exactly what a real
|
|
2957
|
+
// counterparty would see. Returns the EXIT-contract code (0 only when the whole demo behaved as designed).
|
|
2958
|
+
function runDemo(write, writeErr) {
|
|
2959
|
+
// A throwaway temp dir so the demo needs no input and writes NOTHING under cwd. Cleaned in finally.
|
|
2960
|
+
let tmp;
|
|
2961
|
+
try {
|
|
2962
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "verify-vh-demo-"));
|
|
2963
|
+
} catch (e) {
|
|
2964
|
+
writeErr(`error: demo could not create a temp working dir: ${e.message}\n`);
|
|
2965
|
+
return EXIT.IO;
|
|
2966
|
+
}
|
|
2967
|
+
try {
|
|
2968
|
+
const packetPath = writeDemoFixture(tmp);
|
|
2969
|
+
|
|
2970
|
+
const L = [];
|
|
2971
|
+
L.push(TRUST_NOTE);
|
|
2972
|
+
L.push("");
|
|
2973
|
+
L.push("# verify-vh demo — a self-contained, GENUINELY-SIGNED packet, verified OFFLINE with zero config.");
|
|
2974
|
+
L.push("# (No flags, no key to paste: the demo ships a real signed seal + its files and checks them for you.)");
|
|
2975
|
+
L.push(`# Working dir (throwaway, deleted on exit): ${tmp}`);
|
|
2976
|
+
L.push("");
|
|
2977
|
+
|
|
2978
|
+
// (1) GENUINE packet: recover the signer first, then PIN it (so the demo proves both recovery AND the
|
|
2979
|
+
// vendor-pin path) — exactly what a real counterparty does once they learn the producer's address.
|
|
2980
|
+
const recovered = tryRecover(DEMO_CONTAINER.attestation, DEMO_CONTAINER.signature.signature);
|
|
2981
|
+
if (recovered !== DEMO_SIGNER) {
|
|
2982
|
+
writeErr(
|
|
2983
|
+
`error: demo fixture is corrupt — embedded signature recovered ${String(recovered)} != ${DEMO_SIGNER}\n`
|
|
2984
|
+
);
|
|
2985
|
+
return EXIT.IO;
|
|
2986
|
+
}
|
|
2987
|
+
L.push("STEP 1 — verify the genuine packet (signer recovered from the bytes, then pinned):");
|
|
2988
|
+
const good = verifyArtifact({ artifact: packetPath, vendor: recovered, dir: tmp });
|
|
2989
|
+
if (!good.result.accepted || good.code !== EXIT.OK) {
|
|
2990
|
+
// Should never happen for the shipped fixture; treat as an internal fault, not a silent pass.
|
|
2991
|
+
writeErr(`error: demo genuine packet did NOT verify (reason: ${good.result.reason})\n`);
|
|
2992
|
+
write(renderHuman(good.result));
|
|
2993
|
+
return EXIT.IO;
|
|
2994
|
+
}
|
|
2995
|
+
L.push(` ACCEPT — the artifact verifies. signer: ${good.result.recoveredSigner}`);
|
|
2996
|
+
L.push(` sealed root: ${good.result.sealedRoot}`);
|
|
2997
|
+
L.push(` recomputed root: ${good.result.recomputedRoot} (re-derived from the bytes on disk)`);
|
|
2998
|
+
L.push(` files: ${good.result.counts.matched} matched, 0 changed, 0 missing.`);
|
|
2999
|
+
L.push("");
|
|
3000
|
+
|
|
3001
|
+
// (2) TAMPER one byte of a referenced file, re-verify the SAME packet -> a clean REJECT naming the file.
|
|
3002
|
+
const victim = path.join(tmp, "model-card.md");
|
|
3003
|
+
fs.writeFileSync(victim, DEMO_FILES["model-card.md"] + "X"); // one extra byte
|
|
3004
|
+
L.push("STEP 2 — tamper ONE byte of a referenced file, then re-verify the SAME packet:");
|
|
3005
|
+
const bad = verifyArtifact({ artifact: packetPath, vendor: recovered, dir: tmp });
|
|
3006
|
+
if (bad.result.accepted || bad.code !== EXIT.REJECTED) {
|
|
3007
|
+
writeErr(`error: demo tampered packet was NOT rejected (reason: ${bad.result.reason})\n`);
|
|
3008
|
+
return EXIT.IO;
|
|
3009
|
+
}
|
|
3010
|
+
L.push(` REJECT (${bad.result.reason}) — the tampered copy is caught:`);
|
|
3011
|
+
for (const c of bad.result.changed) {
|
|
3012
|
+
L.push(` CHANGED ${c.relPath}: sealed ${c.expectedContentHash} != on-disk ${c.actualContentHash}`);
|
|
3013
|
+
}
|
|
3014
|
+
L.push("");
|
|
3015
|
+
|
|
3016
|
+
L.push("That is the whole promise: a genuine packet is ACCEPTED and its signer named, while a one-byte");
|
|
3017
|
+
L.push("change is REJECTED — re-derived from the bytes you hold, offline, with no producer stack.");
|
|
3018
|
+
L.push("");
|
|
3019
|
+
// The bare demo is a closed loop in a temp dir — gone the instant it exits. Hand the user the ONE command
|
|
3020
|
+
// that turns "I watched a demo" into "I have a real packet on disk I can poke at": `demo <dir>` writes the
|
|
3021
|
+
// same genuine packet somewhere they KEEP, with copy-paste verify/tamper/restore commands. That is the
|
|
3022
|
+
// working on-ramp from the canned proof to verifying their OWN bytes (where the paid `--sign` pull begins).
|
|
3023
|
+
// NOTE: we name the command literally (NOT process.argv[1]) so the bare-demo output is byte-identical
|
|
3024
|
+
// whether run in-process, as `node verify-vh.js`, or from the standalone bundle — the demo's own
|
|
3025
|
+
// determinism is a tested invariant (the standalone must byte-match the in-tree demo).
|
|
3026
|
+
L.push("TRY IT YOURSELF: keep a copy you can tamper with by hand —");
|
|
3027
|
+
L.push(" node verify-vh.js demo ./vh-demo # writes the same signed packet + files into ./vh-demo,");
|
|
3028
|
+
L.push(" # then prints the exact verify / tamper / restore commands.");
|
|
3029
|
+
L.push("");
|
|
3030
|
+
L.push("NEXT: run it on a REAL packet you were handed:");
|
|
3031
|
+
L.push(" node verify-vh.js <packet> --vendor 0xPRODUCER_ADDRESS (exit 0 = verifies; 3 = REJECTED)");
|
|
3032
|
+
L.push("");
|
|
3033
|
+
write(L.join("\n"));
|
|
3034
|
+
return EXIT.OK;
|
|
3035
|
+
} catch (e) {
|
|
3036
|
+
writeErr(`error: demo failed unexpectedly: ${e.message}\n`);
|
|
3037
|
+
return EXIT.IO;
|
|
3038
|
+
} finally {
|
|
3039
|
+
try {
|
|
3040
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
3041
|
+
} catch (_) {
|
|
3042
|
+
/* best-effort cleanup; the OS reaps temp dirs anyway */
|
|
3043
|
+
}
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
// ---------------------------------------------------------------------------
|
|
3048
|
+
// `demo <dir>` — the KEEPABLE scaffold (T-55.2 rework). The bare `demo` proves the round-trip in a throwaway
|
|
3049
|
+
// temp dir and is GONE the instant it exits — a closed loop the prospect can WATCH but cannot TOUCH. That is
|
|
3050
|
+
// the funnel dead-end the review panel flagged: the demo's own "NEXT: run it on a REAL packet" is unactionable
|
|
3051
|
+
// because a brand-new user HAS no packet yet. `demo <dir>` closes that gap: it MATERIALIZES the same genuine
|
|
3052
|
+
// signed packet + its two referenced files into a directory the user names and KEEPS, then prints the exact,
|
|
3053
|
+
// copy-pasteable REAL commands to (a) verify it with the real (non-canned) verify path, (b) tamper one byte
|
|
3054
|
+
// and watch the real REJECT, and (c) restore and re-ACCEPT. The prospect's FIRST hands-on artifact is now one
|
|
3055
|
+
// they hold on disk and can poke at with the production code path — the working on-ramp from "watched a demo"
|
|
3056
|
+
// to "verified my own bytes", which is where the free→paid pull (sign YOUR OWN files: `vh evidence seal
|
|
3057
|
+
// --sign` / the `evidence_unlimited` upgrade) actually begins.
|
|
3058
|
+
//
|
|
3059
|
+
// It is a PURE SUPERSET of the flagless quickstart: it engages ONLY when a single <dir> token follows `demo`
|
|
3060
|
+
// (`verify-vh demo` with no token stays the byte-identical throwaway round-trip above). It WRITES — by design,
|
|
3061
|
+
// into the dir the user explicitly named — so it is never reached by the bare flagless path the "writes
|
|
3062
|
+
// nothing under cwd" contract pins. The packet it writes is byte-identical to the round-trip's, signed by the
|
|
3063
|
+
// same fixed TEST-ONLY key (hardhat #1 — never a real key / real funds).
|
|
3064
|
+
// ---------------------------------------------------------------------------
|
|
3065
|
+
|
|
3066
|
+
function runDemoEmit(targetDir, write, writeErr) {
|
|
3067
|
+
// Confirm the shipped fixture is internally sound BEFORE writing anything (recover the signer from the
|
|
3068
|
+
// embedded bytes, exactly as a real verify does) — a corrupt fixture is an internal fault, not a scaffold.
|
|
3069
|
+
const recovered = tryRecover(DEMO_CONTAINER.attestation, DEMO_CONTAINER.signature.signature);
|
|
3070
|
+
if (recovered !== DEMO_SIGNER) {
|
|
3071
|
+
writeErr(
|
|
3072
|
+
`error: demo fixture is corrupt — embedded signature recovered ${String(recovered)} != ${DEMO_SIGNER}\n`
|
|
3073
|
+
);
|
|
3074
|
+
return EXIT.IO;
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
const dir = path.resolve(targetDir);
|
|
3078
|
+
// mkdir -p the target. We create the user-named dir if absent; an existing dir is fine (we only add files).
|
|
3079
|
+
try {
|
|
3080
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
3081
|
+
} catch (e) {
|
|
3082
|
+
writeErr(`error: demo could not create ${targetDir}: ${e.message}\n`);
|
|
3083
|
+
return EXIT.IO;
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
let packetPath;
|
|
3087
|
+
try {
|
|
3088
|
+
packetPath = writeDemoFixture(dir);
|
|
3089
|
+
} catch (e) {
|
|
3090
|
+
writeErr(`error: demo could not write the scaffold into ${targetDir}: ${e.message}\n`);
|
|
3091
|
+
return EXIT.IO;
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
// Verify the just-written packet through the REAL core (no canned path), so the scaffold is proven good on
|
|
3095
|
+
// disk before we tell the user to trust it — and so the ACCEPT line the user will reproduce is the truth.
|
|
3096
|
+
const good = verifyArtifact({ artifact: packetPath, vendor: recovered, dir });
|
|
3097
|
+
if (!good.result.accepted || good.code !== EXIT.OK) {
|
|
3098
|
+
writeErr(`error: demo scaffold did NOT verify after writing (reason: ${good.result.reason})\n`);
|
|
3099
|
+
return EXIT.IO;
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
// The command name as the user invoked us (verify-vh.js in-tree, verify-vh-standalone.js as the bundle), so
|
|
3103
|
+
// the copy-paste commands below name the EXACT file they ran — not a guessed path.
|
|
3104
|
+
// Name the command the user actually ran (verify-vh.js in-tree, verify-vh-standalone.js as the bundle) so the
|
|
3105
|
+
// copy-paste lines below name the EXACT file they invoked. If argv[1] is not one of our scripts (e.g. running
|
|
3106
|
+
// in-process under a test harness), fall back to the canonical name rather than printing the harness binary.
|
|
3107
|
+
const argv1 = path.basename(process.argv[1] || "");
|
|
3108
|
+
const self = /verify-vh/.test(argv1) ? argv1 : "verify-vh.js";
|
|
3109
|
+
// Print a path that is copy-pasteable from the user's CURRENT shell: the relative path when the target sits
|
|
3110
|
+
// at/under cwd (the common `demo ./vh-demo` case -> a tidy `vh-demo/...`), else the absolute path (a `../../`
|
|
3111
|
+
// chain to a far-off dir is unreadable and brittle — the absolute path always resolves).
|
|
3112
|
+
const rel = (p) => {
|
|
3113
|
+
const r = path.relative(process.cwd(), p);
|
|
3114
|
+
return r && !r.startsWith("..") && !path.isAbsolute(r) ? r : p;
|
|
3115
|
+
};
|
|
3116
|
+
const pkt = rel(packetPath);
|
|
3117
|
+
const card = rel(path.join(dir, "model-card.md"));
|
|
3118
|
+
|
|
3119
|
+
const L = [];
|
|
3120
|
+
L.push(TRUST_NOTE);
|
|
3121
|
+
L.push("");
|
|
3122
|
+
L.push(`# verify-vh demo — wrote a real, KEEPABLE signed packet you can verify yourself, hands-on.`);
|
|
3123
|
+
L.push(`# Signed by a fixed TEST-ONLY key (hardhat #1 — never a real key / real funds).`);
|
|
3124
|
+
L.push("");
|
|
3125
|
+
L.push(`Wrote into ${dir}:`);
|
|
3126
|
+
L.push(` ${DEMO_PACKET_NAME} (a genuinely-signed evidence packet)`);
|
|
3127
|
+
for (const r of Object.keys(DEMO_FILES)) L.push(` ${r}`);
|
|
3128
|
+
L.push(` signer (recovered from the bytes): ${recovered}`);
|
|
3129
|
+
L.push("");
|
|
3130
|
+
L.push("It already VERIFIES — run it yourself (the real verify path, no canned demo):");
|
|
3131
|
+
L.push(` node ${self} ${pkt} --vendor ${recovered}`);
|
|
3132
|
+
L.push(" # exit 0 = ACCEPT (root re-derived from YOUR bytes on disk; signer pinned).");
|
|
3133
|
+
L.push("");
|
|
3134
|
+
L.push("Now PROVE tamper-evidence with your own hands — change one byte, then re-verify:");
|
|
3135
|
+
L.push(` printf 'X' >> ${card}`);
|
|
3136
|
+
L.push(` node ${self} ${pkt} --vendor ${recovered} # exit 3 = REJECT (CHANGED ${path.basename(card)})`);
|
|
3137
|
+
L.push("");
|
|
3138
|
+
L.push("Restore it and watch it ACCEPT again (the change was the ONLY reason it rejected):");
|
|
3139
|
+
L.push(` node ${self} ${pkt} --vendor ${recovered} # after restoring the byte`);
|
|
3140
|
+
L.push("");
|
|
3141
|
+
L.push("NEXT — verify a packet someone handed YOU (same command, their address):");
|
|
3142
|
+
L.push(` node ${self} <their-packet> --vendor 0xTHEIR_ADDRESS`);
|
|
3143
|
+
L.push("");
|
|
3144
|
+
L.push("Want to SIGN your OWN files so a counterparty can pin YOU? That is the paid producer side:");
|
|
3145
|
+
L.push(" vh evidence seal <your-folder> --sign (an EIP-191 signer-pin; the `evidence_unlimited`");
|
|
3146
|
+
L.push(" upgrade lifts the free 25-file cap) — see verifier/README.md §0a.");
|
|
3147
|
+
L.push("");
|
|
3148
|
+
write(L.join("\n"));
|
|
3149
|
+
return EXIT.OK;
|
|
3150
|
+
}
|
|
3151
|
+
|
|
3152
|
+
function usage() {
|
|
3153
|
+
return [
|
|
3154
|
+
"verify-vh — standalone, read-only, OFFLINE verifier for verifyhash artifacts",
|
|
3155
|
+
"",
|
|
3156
|
+
"Usage:",
|
|
3157
|
+
" verify-vh demo (zero-config quickstart)",
|
|
3158
|
+
" verify-vh demo <dir> (write a keepable signed packet you can verify yourself)",
|
|
3159
|
+
" verify-vh <artifact> [--vendor <0xaddr>] [--dir <d>] [--revocations <file-or-dir> [--as-of <ISO>]] [--json]",
|
|
3160
|
+
" verify-vh <artifact> <artifact> ... [--vendor <0xaddr>] [--dir <d>] [--revocations <file-or-dir>] [--json] (batch)",
|
|
3161
|
+
" verify-vh --manifest <file> [--vendor <0xaddr>] [--dir <d>] [--revocations <file-or-dir>] [--json] (batch)",
|
|
3162
|
+
" verify-vh <receipt> --anchored-artifact <sealed-file> [--json] (anchored-receipt binding check)",
|
|
3163
|
+
"",
|
|
3164
|
+
"DEMO: `verify-vh demo` runs a self-contained, genuinely-signed packet through the real verify path —",
|
|
3165
|
+
"NO flags, NO key, NO install state: it ACCEPTs the packet (naming the signer), then REJECTs a one-byte-",
|
|
3166
|
+
"tampered copy. The single command that takes a brand-new user from nothing to a verified packet.",
|
|
3167
|
+
"`verify-vh demo <dir>` goes one step further: it WRITES that same genuine signed packet + its files into",
|
|
3168
|
+
"<dir> (which you keep) and prints copy-paste commands so you verify, tamper, and re-verify it by hand.",
|
|
3169
|
+
"",
|
|
3170
|
+
"Auto-detects the artifact kind (evidence seal, reconciliation seal, dataset attestation, proof",
|
|
3171
|
+
"bundle — bare or signed — or an agent-session packet *.vhagent.json), RE-DERIVES the keccak root",
|
|
3172
|
+
"from the referenced bytes (siblings resolve next to the artifact, or under --dir <d>), recovers",
|
|
3173
|
+
"the signer of a signed artifact, and PINS it to --vendor <0xaddr> (or reports the recovered signer",
|
|
3174
|
+
"when no pin is given). An agent-session packet is SELF-CONTAINED: every event leaf + the ordered",
|
|
3175
|
+
"RFC-6962-style head are re-derived from the events in the packet (REDACTED payloads are checked by",
|
|
3176
|
+
"their hash commitments), and a REJECT names the first offending event seq.",
|
|
3177
|
+
"",
|
|
3178
|
+
"REVOCATIONS: --revocations <file-or-dir> [--as-of <ISO>] downgrades an otherwise-ACCEPTED signed",
|
|
3179
|
+
"artifact to REVOKED (exit 3) when its signing key was REVOKED at or before --as-of (default now). The",
|
|
3180
|
+
"file may be one signed revocation or a JSON array; a directory is read as a flat pool of revocation",
|
|
3181
|
+
"files. A revocation dated AFTER --as-of stays ACCEPTED with a later-revoked note; a forged/tampered/",
|
|
3182
|
+
"third-party revocation is IGNORED with a warning. This reaches the SAME downgrade the producer's",
|
|
3183
|
+
"`vh ... verify-signed --revocations` does, OFFLINE — no producer stack, no network, no key.",
|
|
3184
|
+
"",
|
|
3185
|
+
"ANCHORED RECEIPTS (T-70.4): a `vh-anchored-receipt@1` produced by `vh anchor-artifact` verifies",
|
|
3186
|
+
"here WITHOUT the producer stack: --anchored-artifact <sealed-file> re-derives the sealed artifact's",
|
|
3187
|
+
"digest through the SAME closed kind table (evidence seal, agent-session packet, journal tree head,",
|
|
3188
|
+
"TrustLedger seal, dataset/parcel attestation), validates the receipt strictly (a drifted trust note",
|
|
3189
|
+
"is a named bad-receipt), and confirms the receipt binds EXACTLY those bytes — ACCEPTED exit 0, or",
|
|
3190
|
+
"the specific named reject (digest-mismatch / kind-mismatch / how-mismatch / bad-receipt / the",
|
|
3191
|
+
"artifact's own named reject) exit 3. OFFLINE binding leg ONLY: the receipt's `chain` facts remain",
|
|
3192
|
+
"the anchorer's CLAIM — re-check them on chain with the producer cli (`vh verify-anchored --rpc`).",
|
|
3193
|
+
"",
|
|
3194
|
+
"BATCH/MANIFEST: pass several <artifact> args, or --manifest <file> (a newline list or JSON array of",
|
|
3195
|
+
"artifact paths, each line/object may carry its own --vendor/--dir). ALL must pass for exit 0; if ANY",
|
|
3196
|
+
"is rejected, exit is 3 and the report names which artifact failed and why. --json emits a stable",
|
|
3197
|
+
"aggregate { ok, total, passed, failed, results:[...] } whose entries are the single-artifact shape.",
|
|
3198
|
+
"Top-level --vendor/--dir are inherited as defaults a manifest entry may override; --revocations/--as-of",
|
|
3199
|
+
"apply to every entry.",
|
|
3200
|
+
"",
|
|
3201
|
+
"READ-ONLY: holds no key, writes nothing. Exit: 0 ok / 3 rejected|revoked / 2 usage / 1 IO.",
|
|
3202
|
+
"",
|
|
3203
|
+
].join("\n");
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3206
|
+
// ---------------------------------------------------------------------------
|
|
3207
|
+
// run(argv, io) — the testable entrypoint. Returns the EXIT-contract integer. Injectable stdout/stderr.
|
|
3208
|
+
// ---------------------------------------------------------------------------
|
|
3209
|
+
|
|
3210
|
+
function run(argv, io = {}) {
|
|
3211
|
+
const write = io.write || ((s) => process.stdout.write(s));
|
|
3212
|
+
const writeErr = io.writeErr || ((s) => process.stderr.write(s));
|
|
3213
|
+
|
|
3214
|
+
let opts;
|
|
3215
|
+
try {
|
|
3216
|
+
opts = parseArgs(argv);
|
|
3217
|
+
} catch (e) {
|
|
3218
|
+
writeErr(`error: ${e.message}\n`);
|
|
3219
|
+
return EXIT.USAGE;
|
|
3220
|
+
}
|
|
3221
|
+
if (opts.help) {
|
|
3222
|
+
write(usage());
|
|
3223
|
+
return EXIT.OK;
|
|
3224
|
+
}
|
|
3225
|
+
// DEMO: the zero-config quickstart (T-55.2). `verify-vh demo` — a SINGLE bare positional `demo`, with NO
|
|
3226
|
+
// other args at all (no flags, no second positional, no manifest) — runs the self-contained signed packet
|
|
3227
|
+
// through the real verify path. We require the LONE argument to be exactly `demo` so the quickstart contract
|
|
3228
|
+
// is unambiguous: `demo` with any extra token falls through to the normal path (where it is a clean error),
|
|
3229
|
+
// never a silently-flag-ignoring run. It is a pure SUPERSET of the existing contract: `demo` was never a
|
|
3230
|
+
// valid artifact path before (there is no file named `demo`, so a lone `demo` was a clean IO error), so
|
|
3231
|
+
// intercepting it here shifts no existing caller.
|
|
3232
|
+
if (argv.length === 1 && opts.artifact === "demo") {
|
|
3233
|
+
return runDemo(write, writeErr);
|
|
3234
|
+
}
|
|
3235
|
+
// DEMO SCAFFOLD: `verify-vh demo <dir>` — a pure SUPERSET (T-55.2 rework). When `demo` is followed by exactly
|
|
3236
|
+
// ONE more bare token (a target directory) and NO flags, write the same genuine signed packet + its files
|
|
3237
|
+
// into that dir the user KEEPS, and print copy-paste verify/tamper/restore commands. This is the actionable
|
|
3238
|
+
// on-ramp the bare demo (a throwaway temp dir, gone on exit) cannot give. We require EXACTLY two bare
|
|
3239
|
+
// positionals and no flags so the contract stays unambiguous; `demo <dir> --anything` falls through to the
|
|
3240
|
+
// normal path (where a file literally named `demo` is a clean IO error, byte-identically to before).
|
|
3241
|
+
if (
|
|
3242
|
+
argv.length === 2 &&
|
|
3243
|
+
argv[0] === "demo" &&
|
|
3244
|
+
opts._pos.length === 2 &&
|
|
3245
|
+
opts._pos[0] === "demo" &&
|
|
3246
|
+
!opts.json &&
|
|
3247
|
+
opts.manifest === undefined &&
|
|
3248
|
+
opts.vendor === undefined &&
|
|
3249
|
+
opts.dir === undefined
|
|
3250
|
+
) {
|
|
3251
|
+
return runDemoEmit(opts._pos[1], write, writeErr);
|
|
3252
|
+
}
|
|
3253
|
+
// No artifact AND no manifest → the same usage error as before (the batch additions are a pure superset).
|
|
3254
|
+
if (opts.artifact === undefined && opts.manifest === undefined) {
|
|
3255
|
+
writeErr("error: verify-vh requires an <artifact>\n\n");
|
|
3256
|
+
writeErr(usage());
|
|
3257
|
+
return EXIT.USAGE;
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3260
|
+
// ANCHORED-RECEIPT binding check (T-70.4): a dedicated two-file leg — parseArgs already guaranteed
|
|
3261
|
+
// exactly one <receipt> positional and no incompatible flag. READ-ONLY; exit 0/3/2/1 as everywhere.
|
|
3262
|
+
if (opts.anchoredArtifact !== undefined) {
|
|
3263
|
+
return runVerifyAnchoredOffline(opts, write, writeErr);
|
|
3264
|
+
}
|
|
3265
|
+
|
|
3266
|
+
// The recipient's current decision instant (the default --as-of). Injectable via io.nowISO so a test can
|
|
3267
|
+
// pin the clock; otherwise the wall clock. Threaded onto opts for the (optional) revocation evaluation.
|
|
3268
|
+
opts.nowISO = io.nowISO || new Date().toISOString();
|
|
3269
|
+
|
|
3270
|
+
// BATCH path: a --manifest file or more than one positional <artifact>. Aggregates per-artifact verdicts
|
|
3271
|
+
// under one CI exit code. The single-artifact path below is byte-for-byte the original behavior.
|
|
3272
|
+
if (opts.batch) {
|
|
3273
|
+
let out;
|
|
3274
|
+
try {
|
|
3275
|
+
out = verifyBatch(opts);
|
|
3276
|
+
} catch (e) {
|
|
3277
|
+
if (e instanceof UsageError) {
|
|
3278
|
+
writeErr(`error: ${e.message}\n`);
|
|
3279
|
+
return EXIT.USAGE;
|
|
3280
|
+
}
|
|
3281
|
+
if (e instanceof IOError) {
|
|
3282
|
+
writeErr(`error: ${e.message}\n`);
|
|
3283
|
+
return EXIT.IO;
|
|
3284
|
+
}
|
|
3285
|
+
writeErr(`error: ${e.message}\n`);
|
|
3286
|
+
return EXIT.IO;
|
|
3287
|
+
}
|
|
3288
|
+
if (opts.json) {
|
|
3289
|
+
write(JSON.stringify(out.aggregate, null, 2) + "\n");
|
|
3290
|
+
} else {
|
|
3291
|
+
write(renderBatchHuman(out.aggregate));
|
|
3292
|
+
}
|
|
3293
|
+
return out.code;
|
|
3294
|
+
}
|
|
3295
|
+
|
|
3296
|
+
let out;
|
|
3297
|
+
try {
|
|
3298
|
+
out = verifyArtifact(opts);
|
|
3299
|
+
} catch (e) {
|
|
3300
|
+
if (e instanceof UsageError) {
|
|
3301
|
+
writeErr(`error: ${e.message}\n`);
|
|
3302
|
+
return EXIT.USAGE;
|
|
3303
|
+
}
|
|
3304
|
+
if (e instanceof IOError) {
|
|
3305
|
+
writeErr(`error: ${e.message}\n`);
|
|
3306
|
+
return EXIT.IO;
|
|
3307
|
+
}
|
|
3308
|
+
// Any other error is an unexpected internal fault — surface it as an IO error (never a stack to a
|
|
3309
|
+
// counterparty), exit 1.
|
|
3310
|
+
writeErr(`error: ${e.message}\n`);
|
|
3311
|
+
return EXIT.IO;
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
if (opts.json) {
|
|
3315
|
+
write(JSON.stringify(out.result, null, 2) + "\n");
|
|
3316
|
+
} else {
|
|
3317
|
+
write(renderHuman(out.result));
|
|
3318
|
+
}
|
|
3319
|
+
return out.code;
|
|
3320
|
+
}
|
|
3321
|
+
|
|
3322
|
+
// CLI shim: only run when invoked directly (so the module is importable in tests without side effects).
|
|
3323
|
+
if (require.main === module) {
|
|
3324
|
+
process.exit(run(process.argv.slice(2)));
|
|
3325
|
+
}
|
|
3326
|
+
|
|
3327
|
+
module.exports = {
|
|
3328
|
+
EXIT,
|
|
3329
|
+
KINDS,
|
|
3330
|
+
TRUST_NOTE,
|
|
3331
|
+
UsageError,
|
|
3332
|
+
IOError,
|
|
3333
|
+
parseArgs,
|
|
3334
|
+
parseManifest,
|
|
3335
|
+
verifyArtifact,
|
|
3336
|
+
verifyArtifactFromBytes,
|
|
3337
|
+
verifyBatch,
|
|
3338
|
+
buildBatchEntries,
|
|
3339
|
+
renderBatchHuman,
|
|
3340
|
+
verifyEvidenceSeal,
|
|
3341
|
+
verifyTrustSeal,
|
|
3342
|
+
verifyDatasetAttestation,
|
|
3343
|
+
verifyProofBundle,
|
|
3344
|
+
verifyAgentSeal,
|
|
3345
|
+
AGENT_TRUST_NOTE,
|
|
3346
|
+
// ANCHORED-RECEIPT surface (T-70.4) — wire-format constants + the pure binding verify, exported so
|
|
3347
|
+
// the parity test can pin them against the producer core (cli/core/anchor-binding.js) byte-for-byte.
|
|
3348
|
+
ANCHORED_RECEIPT_KIND,
|
|
3349
|
+
ANCHOR_TRUST_NOTE,
|
|
3350
|
+
ANCHOR_REASONS,
|
|
3351
|
+
ANCHOR_ARTIFACT_KINDS,
|
|
3352
|
+
ANCHOR_JOURNAL_TREE_HEAD_KIND,
|
|
3353
|
+
ANCHOR_JOURNAL_EMPTY_ROOT,
|
|
3354
|
+
ANCHOR_LOCAL_DEV_CHAIN_IDS,
|
|
3355
|
+
ANCHOR_PUBLIC_TESTNET_CHAIN_IDS,
|
|
3356
|
+
anchorClassifyChainId,
|
|
3357
|
+
anchorArtifactDigest,
|
|
3358
|
+
verifyAnchoredReceipt,
|
|
3359
|
+
runVerifyAnchoredOffline,
|
|
3360
|
+
renderHuman,
|
|
3361
|
+
revocation,
|
|
3362
|
+
usage,
|
|
3363
|
+
run,
|
|
3364
|
+
runDemo,
|
|
3365
|
+
runDemoEmit,
|
|
3366
|
+
DEMO_SIGNER,
|
|
3367
|
+
DEMO_FILES,
|
|
3368
|
+
DEMO_CONTAINER,
|
|
3369
|
+
DEMO_PACKET_NAME,
|
|
3370
|
+
DEMO_AGENT_PACKET_NAME,
|
|
3371
|
+
DEMO_AGENT_PACKET_TEXT,
|
|
3372
|
+
DEMO_AGENT_TAMPER_SEQ,
|
|
3373
|
+
DEMO_AGENT_TAMPER_FROM,
|
|
3374
|
+
DEMO_AGENT_TAMPER_TO,
|
|
3375
|
+
MAX_RELPATH_CHARS,
|
|
3376
|
+
};
|