verifyhash 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/cli/agent-hook.js +431 -0
- package/docs/ADOPT.md +15 -5
- package/docs/AGENT-HOOK.md +111 -0
- package/docs/PUBLISH-VERIFY-VH.md +45 -0
- package/examples/README.md +185 -0
- package/examples/policy.lenient.json +5 -0
- package/examples/policy.strict.json +6 -0
- package/examples/run.js +366 -0
- package/examples/sample-dataset/README.txt +10 -0
- package/examples/sample-dataset/corpus/cc-by-poem.txt +8 -0
- package/examples/sample-dataset/corpus/mit-notes.txt +4 -0
- package/examples/sample-dataset/data/unlabeled.txt +5 -0
- package/examples/sample-dataset/vendored/gpl-snippet.txt +5 -0
- package/examples/sample-dataset.hints.json +7 -0
- package/examples/sample-parcel/data/manifest-of-contents.txt +7 -0
- package/examples/sample-parcel/data/records.csv +4 -0
- package/examples/sample-parcel/delivery-note.txt +9 -0
- package/package.json +25 -3
- package/verifier/README.md +555 -0
- package/verifier/action/README.md +87 -0
- package/verifier/action/action.yml +146 -0
- package/verifier/build-standalone-html.js +1287 -0
- package/verifier/build-standalone.js +989 -0
- package/verifier/ci/journal.generic.sh +96 -0
- package/verifier/ci/journal.github-actions.yml +99 -0
- package/verifier/ci/reproduce-vh.generic.sh +59 -0
- package/verifier/ci/reproduce-vh.github-actions.yml +49 -0
- package/verifier/ci/verify-service.generic.sh +96 -0
- package/verifier/ci/verify-service.github-actions.yml +88 -0
- package/verifier/ci/verify-vh.generic.sh +75 -0
- package/verifier/ci/verify-vh.github-actions.yml +56 -0
- package/verifier/dist/BUILD-PROVENANCE.json +210 -0
- package/verifier/dist/seal-vh-standalone.js +876 -0
- package/verifier/dist/seal-vh-standalone.js.sha256 +1 -0
- package/verifier/dist/verify-vh-standalone.html +3373 -0
- package/verifier/dist/verify-vh-standalone.html.sha256 +1 -0
- package/verifier/dist/verify-vh-standalone.js +4121 -0
- package/verifier/dist/verify-vh-standalone.js.sha256 +1 -0
- package/verifier/lib/canonical.js +141 -0
- package/verifier/lib/keccak.js +30 -0
- package/verifier/lib/keccak256-vendored.js +206 -0
- package/verifier/lib/merkle.js +145 -0
- package/verifier/lib/revocation-core.js +606 -0
- package/verifier/lib/revocation.js +200 -0
- package/verifier/lib/seal-cli.js +374 -0
- package/verifier/lib/seal-evidence.js +237 -0
- package/verifier/lib/secp256k1-recover.js +249 -0
- package/verifier/package.json +39 -0
- package/verifier/verify-vh.js +2374 -0
- package/docs/ADOPTION.json +0 -11
- package/docs/AUDIT.md +0 -55
- package/docs/DECIDE.md +0 -47
- package/docs/DECISIONS-PENDING.md +0 -27
- package/docs/DEPLOY-PUBLIC-SITE.md +0 -301
- package/docs/ENGINE-LEDGER.json +0 -12
- package/docs/LOOP-AUDIT-2026-07-03.json +0 -580
- package/docs/LOOP-HARDENING-PLAN.md +0 -44
- package/docs/METRICS.jsonl +0 -31
- package/docs/MORNING.md +0 -204
- package/docs/STRATEGY-ARCHIVE.md +0 -5055
- package/docs/SUPERVISOR-RUNBOOK.md +0 -52
- package/docs/USAGE-BUDGET.json +0 -121
|
@@ -0,0 +1,2374 @@
|
|
|
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
|
+
|
|
43
|
+
const merkle = require("./lib/merkle");
|
|
44
|
+
const canonical = require("./lib/canonical");
|
|
45
|
+
const { recoverPersonalSignAddress } = require("./lib/secp256k1-recover");
|
|
46
|
+
const revocation = require("./lib/revocation");
|
|
47
|
+
|
|
48
|
+
// ============================ BEGIN VERIFY-VH PURE ENGINE (T-66.1) ============================
|
|
49
|
+
// EVERYTHING between this marker and the matching END marker is the PURE verify engine: it performs NO
|
|
50
|
+
// I/O of its own and never touches fs / os / path / process / child_process — every byte it verifies
|
|
51
|
+
// arrives through the injected `readEntry` seam (or as an argument). Its only outside references are the
|
|
52
|
+
// four module bindings above, all of which resolve to PURE modules for the functions used here:
|
|
53
|
+
// `merkle`, `canonical`, `recoverPersonalSignAddress`, and the PURE decision half of `revocation`
|
|
54
|
+
// (./lib/revocation-core.js re-exports — never the fs-backed readRevocationsFromPath/loadAndApply).
|
|
55
|
+
// test/verifier.browser-core.test.js enforces all of this mechanically; the markers also make the block
|
|
56
|
+
// mechanically extractable (vm / browser bundling, EPIC-66).
|
|
57
|
+
|
|
58
|
+
// CI-gateable exit contract, mirroring the producer family (vh verify-seal / vh evidence verify):
|
|
59
|
+
// 0 ok / 3 rejected / 2 usage / 1 IO. Stable; a future CI/indexer keys on these.
|
|
60
|
+
const EXIT = Object.freeze({ OK: 0, IO: 1, USAGE: 2, REJECTED: 3 });
|
|
61
|
+
|
|
62
|
+
// A usage error the CLI maps to exit 2 (vs an IO error -> 1, vs a clean REJECTED verdict -> 3).
|
|
63
|
+
class UsageError extends Error {}
|
|
64
|
+
class IOError extends Error {}
|
|
65
|
+
|
|
66
|
+
// The on-disk `kind` discriminators of every artifact family this verifier understands. Bare and signed
|
|
67
|
+
// variants are listed so auto-detect routes correctly. Disjoint, versioned strings — a foreign/random
|
|
68
|
+
// JSON file falls through to a clear "unrecognized artifact" usage error rather than a misread.
|
|
69
|
+
const KINDS = Object.freeze({
|
|
70
|
+
EVIDENCE_SEAL: "vh.evidence-seal",
|
|
71
|
+
EVIDENCE_SEAL_SIGNED: "vh.evidence-seal-signed",
|
|
72
|
+
TRUST_SEAL: "trustledger.reconcile-seal",
|
|
73
|
+
TRUST_SEAL_SIGNED: "trustledger.reconcile-seal-signed",
|
|
74
|
+
DATASET_ATTESTATION: "verifyhash.dataset-attestation",
|
|
75
|
+
DATASET_ATTESTATION_SIGNED: "verifyhash.dataset-attestation-signed",
|
|
76
|
+
DATASET_ATTESTATION_TIMESTAMPED: "verifyhash.dataset-attestation-timestamped",
|
|
77
|
+
PROOF: "verifyhash.merkle-proof",
|
|
78
|
+
AGENT_PACKET: "vh.agent-session-packet",
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const TRUST_NOTE =
|
|
82
|
+
"verify-vh is an INDEPENDENT, read-only, OFFLINE verifier. It RE-DERIVES the keccak root from the " +
|
|
83
|
+
"bytes you hold and recovers the signer with no producer stack. It proves TAMPER-EVIDENCE + WHO " +
|
|
84
|
+
"vouched — NOT a trusted timestamp and NOT a legal opinion.";
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Address normalization + recovery helpers. The verifier compares addresses as LOWERCASE 0x-hex (the
|
|
88
|
+
// canonical byte-deterministic form the producer records); a caller may paste an EIP-55-checksummed
|
|
89
|
+
// --vendor and we lowercase it (a checksum mismatch is not our concern — we compare 20 raw bytes).
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
93
|
+
|
|
94
|
+
function normalizeAddress(addr, label) {
|
|
95
|
+
if (typeof addr !== "string" || !ADDRESS_RE.test(addr)) {
|
|
96
|
+
throw new UsageError(`${label} must be a 0x-prefixed 20-byte hex address, got: ${String(addr)}`);
|
|
97
|
+
}
|
|
98
|
+
return addr.toLowerCase();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Recover the EIP-191 signer over the embedded canonical bytes. A tampered/corrupt signature can be
|
|
102
|
+
// UNRECOVERABLE (no valid curve point) — that throws, which the caller turns into a `bad_signature`
|
|
103
|
+
// REJECTED verdict, never a crash. Returns lowercase 0x-hex, or null if recovery failed.
|
|
104
|
+
function tryRecover(message, signature) {
|
|
105
|
+
try {
|
|
106
|
+
return recoverPersonalSignAddress(message, signature);
|
|
107
|
+
} catch (_) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Signed-container decoding. A signed artifact carries the embedded UNSIGNED payload as the EXACT
|
|
114
|
+
// canonical bytes (a STRING) in `attestation`, plus a { scheme, signer, signature } block. The signed
|
|
115
|
+
// MESSAGE is that embedded string verbatim, so signer recovery runs over `container.attestation`.
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
function decodeSigned(container) {
|
|
119
|
+
const sig = container && container.signature;
|
|
120
|
+
if (sig == null || typeof sig !== "object" || Array.isArray(sig)) {
|
|
121
|
+
throw new IOError("signed artifact is missing a { scheme, signer, signature } signature block");
|
|
122
|
+
}
|
|
123
|
+
if (sig.scheme !== "eip191-personal-sign") {
|
|
124
|
+
throw new IOError(
|
|
125
|
+
`unsupported signature scheme: ${JSON.stringify(sig.scheme)} ` +
|
|
126
|
+
"(this verifier understands eip191-personal-sign)"
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (typeof container.attestation !== "string") {
|
|
130
|
+
throw new IOError("signed artifact must embed the canonical UNSIGNED bytes as a string `attestation`");
|
|
131
|
+
}
|
|
132
|
+
if (typeof sig.signature !== "string" || !/^0x[0-9a-fA-F]{130}$/.test(sig.signature)) {
|
|
133
|
+
throw new IOError("signed artifact signature must be a 65-byte (r||s||v) 0x-hex string");
|
|
134
|
+
}
|
|
135
|
+
if (typeof sig.signer !== "string" || !ADDRESS_RE.test(sig.signer)) {
|
|
136
|
+
throw new IOError("signed artifact signer must be a 0x-prefixed 20-byte hex address");
|
|
137
|
+
}
|
|
138
|
+
let embedded;
|
|
139
|
+
try {
|
|
140
|
+
embedded = JSON.parse(container.attestation);
|
|
141
|
+
} catch (e) {
|
|
142
|
+
throw new IOError(`embedded attestation is not valid JSON: ${e.message}`);
|
|
143
|
+
}
|
|
144
|
+
return { embedded, message: container.attestation, claimedSigner: sig.signer.toLowerCase(), signature: sig.signature };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Per-file re-derivation, shared by every seal kind AND by both file sources. Given the sealed
|
|
149
|
+
// { relPath, contentHash } entries and a `readEntry` source, fetch each referenced file's bytes through
|
|
150
|
+
// the source, recompute its contentHash, and localize the outcome to MATCH / CHANGED / MISSING /
|
|
151
|
+
// ESCAPED; a file present under a sealed relPath that is NOT in the seal cannot occur here (we only read
|
|
152
|
+
// sealed relPaths) — UNEXPECTED is reported only for seals where the producer enumerates a directory
|
|
153
|
+
// (evidence seal verify re-walks the dir). For artifact verification we follow the producer's read
|
|
154
|
+
// model: read exactly the relPaths the artifact names from the source.
|
|
155
|
+
//
|
|
156
|
+
// SECURITY — CONFINEMENT LIVES IN THE SOURCE. `relPath` values come straight from the attacker-controlled
|
|
157
|
+
// artifact JSON (the threat model is attacker-controls-the-input, victim-runs-on-their-own-machine: a
|
|
158
|
+
// malicious producer hands a counterparty a "verify me" artifact, hoping its relPaths probe the
|
|
159
|
+
// counterparty's filesystem). Each source therefore CONFINES every read BEFORE touching its backing
|
|
160
|
+
// store and answers `{ status: "escaped" }` for a hostile relPath (absolute, a `..` traversal component,
|
|
161
|
+
// or — for the disk source — a resolved/realpath escape of baseDir). An escaped entry is recorded ONLY by
|
|
162
|
+
// relPath (the attacker's string) — we NEVER hash it and NEVER emit an actualContentHash for it, so the
|
|
163
|
+
// verdict can never become a content-confirmation / hash-disclosure oracle over a file outside the
|
|
164
|
+
// source. A `path_escape` entry is a hard REJECTED verdict.
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
function classifyFilesWith(sealedEntries, readEntry) {
|
|
168
|
+
const changed = [];
|
|
169
|
+
const missing = [];
|
|
170
|
+
const matched = [];
|
|
171
|
+
const escaped = []; // { relPath } only — NEVER a hash; a confinement reject, read nothing
|
|
172
|
+
const flat = []; // { relPath, contentHash } actually-present, for the root re-derivation
|
|
173
|
+
|
|
174
|
+
for (const e of sealedEntries) {
|
|
175
|
+
const relPath = e.relPath;
|
|
176
|
+
const r = readEntry(relPath);
|
|
177
|
+
if (r.status === "escaped") {
|
|
178
|
+
escaped.push({ relPath: String(relPath) });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (r.status === "missing") {
|
|
182
|
+
missing.push({ relPath });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const actual = merkle.hashBytes(r.bytes);
|
|
186
|
+
flat.push({ relPath, contentHash: actual });
|
|
187
|
+
if (actual.toLowerCase() === String(e.contentHash).toLowerCase()) {
|
|
188
|
+
matched.push({ relPath, contentHash: actual });
|
|
189
|
+
} else {
|
|
190
|
+
changed.push({ relPath, expectedContentHash: e.contentHash, actualContentHash: actual });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { matched, changed, missing, escaped, flat };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Verify an EVIDENCE seal (bare or the embedded seal of a signed container). The seal lists `files`
|
|
198
|
+
// [{ relPath, contentHash, leaf }] + `root`. We re-derive the root from the bytes the source holds and
|
|
199
|
+
// localize any tamper. NO header (evidence seals bind only the file set). UNEXPECTED files (present
|
|
200
|
+
// under a sealed-sibling tree but not named) are NOT scanned here — the artifact names exactly what it
|
|
201
|
+
// commits to; the producer's `vh evidence verify` re-walks the dir, but the standalone verifier verifies
|
|
202
|
+
// what the artifact REFERENCES (read-only, no directory walk). NOTE an "extra" file is still caught
|
|
203
|
+
// structurally: the sealed root commits to the FULL file set, so a seal doctored to omit an entry can
|
|
204
|
+
// never keep its root (root_mismatch), and a signed seal edited that way breaks its signature.
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
function verifyEvidenceSealWith(seal, readEntry) {
|
|
208
|
+
if (!Array.isArray(seal.files) || seal.files.length === 0) {
|
|
209
|
+
throw new IOError("evidence seal `files` must be a non-empty array");
|
|
210
|
+
}
|
|
211
|
+
if (typeof seal.root !== "string" || !merkle.HEX32_RE.test(seal.root)) {
|
|
212
|
+
throw new IOError("evidence seal `root` must be a 0x-prefixed 32-byte hex string");
|
|
213
|
+
}
|
|
214
|
+
const { matched, changed, missing, escaped, flat } = classifyFilesWith(seal.files, readEntry);
|
|
215
|
+
|
|
216
|
+
// The AUTHORITATIVE root is re-derived from the bytes actually held — never the seal's stored root.
|
|
217
|
+
// A partial/changed set yields a different root; rootMatches goes false.
|
|
218
|
+
let recomputedRoot = null;
|
|
219
|
+
if (flat.length > 0) {
|
|
220
|
+
try {
|
|
221
|
+
recomputedRoot = merkle.rootFromFlat(flat);
|
|
222
|
+
} catch (_) {
|
|
223
|
+
recomputedRoot = null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const rootMatches =
|
|
227
|
+
missing.length === 0 &&
|
|
228
|
+
changed.length === 0 &&
|
|
229
|
+
escaped.length === 0 &&
|
|
230
|
+
recomputedRoot != null &&
|
|
231
|
+
recomputedRoot.toLowerCase() === seal.root.toLowerCase();
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
matched,
|
|
235
|
+
changed,
|
|
236
|
+
missing,
|
|
237
|
+
escaped,
|
|
238
|
+
unexpected: [],
|
|
239
|
+
sealedRoot: seal.root,
|
|
240
|
+
recomputedRoot,
|
|
241
|
+
rootMatches,
|
|
242
|
+
filesOk: changed.length === 0 && missing.length === 0 && escaped.length === 0 && rootMatches,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// Verify a TRUST (reconciliation) seal (bare or embedded). The seal lists `inputs` (role+relPath+
|
|
248
|
+
// contentHash+leaf) and `outputs` (relPath+contentHash+leaf), plus a `verdict` + `root`. The root commits
|
|
249
|
+
// to all inputs + outputs PLUS a synthetic verdict/role HEADER leaf. We re-derive the root from the held
|
|
250
|
+
// bytes AND the header content recomputed from the seal's OWN verdict + input role bindings — so a
|
|
251
|
+
// verdict/role edit (which lives in the seal, not a file) still changes the recomputed root. Inputs are
|
|
252
|
+
// sealed by basename and resolve through the source (the portable handoff ships sources next to the seal).
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
function verifyTrustSealWith(seal, readEntry) {
|
|
256
|
+
if (!Array.isArray(seal.inputs) || seal.inputs.length === 0) {
|
|
257
|
+
throw new IOError("trust seal `inputs` must be a non-empty array");
|
|
258
|
+
}
|
|
259
|
+
if (!Array.isArray(seal.outputs) || seal.outputs.length === 0) {
|
|
260
|
+
throw new IOError("trust seal `outputs` must be a non-empty array");
|
|
261
|
+
}
|
|
262
|
+
if (typeof seal.root !== "string" || !merkle.HEX32_RE.test(seal.root)) {
|
|
263
|
+
throw new IOError("trust seal `root` must be a 0x-prefixed 32-byte hex string");
|
|
264
|
+
}
|
|
265
|
+
if (seal.verdict == null || typeof seal.verdict !== "object") {
|
|
266
|
+
throw new IOError("trust seal is missing its `verdict` block");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const sealedEntries = [
|
|
270
|
+
...seal.inputs.map((e) => ({ relPath: e.relPath, contentHash: e.contentHash, role: e.role })),
|
|
271
|
+
...seal.outputs.map((e) => ({ relPath: e.relPath, contentHash: e.contentHash, role: null })),
|
|
272
|
+
];
|
|
273
|
+
const { matched, changed, missing, escaped, flat } = classifyFilesWith(sealedEntries, readEntry);
|
|
274
|
+
|
|
275
|
+
// Re-derive the root: the held file leaves PLUS the verdict/role HEADER leaf (content recomputed
|
|
276
|
+
// from the seal's own verdict + input role bindings). The header is folded in as one more (relPath,
|
|
277
|
+
// content) pair under the reserved header relPath — exactly the producer's binding.
|
|
278
|
+
let recomputedRoot = null;
|
|
279
|
+
// Only attempt the root re-derivation when no file is MISSING or ESCAPED (a partial set can never
|
|
280
|
+
// re-derive the sealed root anyway, and the header binds the FULL committed structure).
|
|
281
|
+
if (missing.length === 0 && escaped.length === 0 && flat.length === seal.inputs.length + seal.outputs.length) {
|
|
282
|
+
try {
|
|
283
|
+
const headerBytes = canonical.trustSealHeaderBytes(
|
|
284
|
+
seal.verdict,
|
|
285
|
+
seal.inputs.map((e) => ({ role: e.role, relPath: e.relPath }))
|
|
286
|
+
);
|
|
287
|
+
const committed = [
|
|
288
|
+
...flat,
|
|
289
|
+
{ relPath: canonical.TRUST_SEAL_HEADER_RELPATH, contentHash: merkle.hashBytes(headerBytes) },
|
|
290
|
+
];
|
|
291
|
+
recomputedRoot = merkle.rootFromFlat(committed);
|
|
292
|
+
} catch (_) {
|
|
293
|
+
recomputedRoot = null;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const rootMatches =
|
|
297
|
+
escaped.length === 0 &&
|
|
298
|
+
recomputedRoot != null &&
|
|
299
|
+
recomputedRoot.toLowerCase() === seal.root.toLowerCase();
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
matched,
|
|
303
|
+
changed,
|
|
304
|
+
missing,
|
|
305
|
+
escaped,
|
|
306
|
+
unexpected: [],
|
|
307
|
+
sealedRoot: seal.root,
|
|
308
|
+
recomputedRoot,
|
|
309
|
+
rootMatches,
|
|
310
|
+
filesOk: changed.length === 0 && missing.length === 0 && escaped.length === 0 && rootMatches,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// Verify a DATASET attestation (bare/signed/timestamped). A dataset attestation commits to the dataset
|
|
316
|
+
// IDENTITY (root, fileCount, manifestDigest) — it does NOT carry the per-file list, so there are no
|
|
317
|
+
// sibling bytes to re-derive a Merkle root from without the original manifest. The independent verifier
|
|
318
|
+
// therefore confirms the embedded identity is well-formed + (for signed) recovers/pins the signer; the
|
|
319
|
+
// `root` is the dataset's, carried as-is. (`vh dataset verify <dir> --manifest` is the path that
|
|
320
|
+
// re-derives a root from a live tree; the attestation alone has no tree to re-walk.)
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
function verifyDatasetAttestation(att) {
|
|
324
|
+
for (const f of ["root", "manifestDigest"]) {
|
|
325
|
+
if (typeof att[f] !== "string" || !merkle.HEX32_RE.test(att[f])) {
|
|
326
|
+
throw new IOError(`dataset attestation ${f} must be a 0x-prefixed 32-byte hex string`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (!Number.isInteger(att.fileCount) || att.fileCount < 1) {
|
|
330
|
+
throw new IOError("dataset attestation fileCount must be a positive integer");
|
|
331
|
+
}
|
|
332
|
+
return {
|
|
333
|
+
matched: [],
|
|
334
|
+
changed: [],
|
|
335
|
+
missing: [],
|
|
336
|
+
escaped: [],
|
|
337
|
+
unexpected: [],
|
|
338
|
+
sealedRoot: att.root,
|
|
339
|
+
recomputedRoot: null,
|
|
340
|
+
rootMatches: null, // no sibling bytes to re-derive a root from (identity-only artifact)
|
|
341
|
+
filesOk: true, // structural identity is sound; the binding is via the signature for signed variants
|
|
342
|
+
identityOnly: true,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
// Verify a PROOF bundle. A proof artifact carries { root, leaf, contentHash, relPath, proof[] }. We
|
|
348
|
+
// RE-DERIVE the leaf from relPath + contentHash, then fold leafHash(leaf) up through the proof siblings
|
|
349
|
+
// with nodeHash and confirm it reproduces `root` — byte-identically to the on-chain verifyLeaf, but
|
|
350
|
+
// fully OFFLINE. (The on-chain "is this root anchored" check is out of scope for the offline verifier.)
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
function verifyProofBundle(art) {
|
|
354
|
+
for (const f of ["root", "leaf", "contentHash"]) {
|
|
355
|
+
if (typeof art[f] !== "string" || !merkle.HEX32_RE.test(art[f])) {
|
|
356
|
+
throw new IOError(`proof artifact ${f} must be a 0x-prefixed 32-byte hex string`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (typeof art.relPath !== "string" || art.relPath.length === 0) {
|
|
360
|
+
throw new IOError("proof artifact relPath must be a non-empty string");
|
|
361
|
+
}
|
|
362
|
+
if (!Array.isArray(art.proof)) {
|
|
363
|
+
throw new IOError("proof artifact `proof` must be an array of 0x 32-byte hex siblings");
|
|
364
|
+
}
|
|
365
|
+
const derivedLeaf = merkle.pathLeaf(art.relPath, art.contentHash);
|
|
366
|
+
const leafMatches = derivedLeaf.toLowerCase() === art.leaf.toLowerCase();
|
|
367
|
+
let computed = merkle.leafHash(art.leaf);
|
|
368
|
+
for (const sib of art.proof) {
|
|
369
|
+
computed = merkle.nodeHash(computed, sib);
|
|
370
|
+
}
|
|
371
|
+
const foldsToRoot = computed.toLowerCase() === art.root.toLowerCase();
|
|
372
|
+
return {
|
|
373
|
+
matched: leafMatches && foldsToRoot ? [{ relPath: art.relPath, contentHash: art.contentHash }] : [],
|
|
374
|
+
changed:
|
|
375
|
+
leafMatches && foldsToRoot ? [] : [{ relPath: art.relPath, expectedContentHash: art.root, actualContentHash: computed }],
|
|
376
|
+
missing: [],
|
|
377
|
+
escaped: [],
|
|
378
|
+
unexpected: [],
|
|
379
|
+
sealedRoot: art.root,
|
|
380
|
+
recomputedRoot: computed,
|
|
381
|
+
rootMatches: leafMatches && foldsToRoot,
|
|
382
|
+
filesOk: leafMatches && foldsToRoot,
|
|
383
|
+
proof: { derivedLeaf, leafMatches, foldsToRoot },
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// Verify an AGENT-SESSION packet (T-68.3 — the AgentTrace funnel leg, FREE surface only).
|
|
389
|
+
//
|
|
390
|
+
// A `*.vhagent.json` packet is SELF-CONTAINED: it carries its ordered event list (full and/or
|
|
391
|
+
// REDACTED), a per-event leaf expectation list, and an RFC-6962-style ordered Merkle head
|
|
392
|
+
// { size, root } — there are NO sibling files to read, so `readEntry` is never consulted. This block
|
|
393
|
+
// RE-DERIVES every event leaf and the root from the events the packet holds, exactly as the producer's
|
|
394
|
+
// `vh agent verify` does, but from an INDEPENDENT implementation surface: everything below is written
|
|
395
|
+
// against the verifier's OWN dependency-free keccak (merkle.hashBytes) — it imports NOTHING from cli/.
|
|
396
|
+
//
|
|
397
|
+
// THE CONVENTION (must match cli/core/agent-session.js + cli/journal-log.js VERBATIM):
|
|
398
|
+
// * payloadHash = keccak256(utf8(payload)) (the payload COMMITMENT)
|
|
399
|
+
// * event leaf = keccak256(utf8(JSON.stringify([
|
|
400
|
+
// LEAF_DOMAIN, seq, ts, actor, type, payloadHash, canonicalMetaJson|null ])))
|
|
401
|
+
// — the payload participates ONLY via its commitment, so a FULL event and its REDACTED twin
|
|
402
|
+
// (payload dropped, commitment carried, `redacted: true`) derive the IDENTICAL leaf: redaction
|
|
403
|
+
// changes neither the leaves nor the root (it can WITHHOLD, never silently ALTER).
|
|
404
|
+
// * the ordered tree (RFC 6962, position-bound, NO sorting — the OPPOSITE of the evidence tree):
|
|
405
|
+
// leaf node = keccak256(0x00 || leaf) interior = keccak256(0x01 || left || right)
|
|
406
|
+
// MTH(D[0:n]) = interior(MTH(D[0:k]), MTH(D[k:n])), k = largest power of two < n
|
|
407
|
+
// empty log root = keccak256(utf8("vh.journal-log/v1:empty-root"))
|
|
408
|
+
// * a SIGNED packet carries `headAttestation`: a detached EIP-191 personal-sign over the EXACT
|
|
409
|
+
// canonical head-payload bytes (the embedded `attestation` string). The signature wraps the HEAD,
|
|
410
|
+
// so ONE signature stays valid for every redacted copy of the same sealed session.
|
|
411
|
+
//
|
|
412
|
+
// VERDICTS: event-level tamper (a payload that no longer matches its carried commitment — including a
|
|
413
|
+
// REDACTED event whose commitment was forged — or a leaf that no longer matches its expectation) is a
|
|
414
|
+
// REJECT NAMING THE SEQ; a tampered head is `root_mismatch`; a forged signature is `bad_signature`; a
|
|
415
|
+
// sound signature by the wrong signer under a --vendor pin is `wrong_issuer`; a --vendor pin on an
|
|
416
|
+
// UNSIGNED packet is `unsigned_cannot_pin_vendor` (a stripped signature never passes a pinned verify).
|
|
417
|
+
// The recompute is AUTHORITATIVE: the packet is an untrusted container and its stored hashes are only
|
|
418
|
+
// EXPECTATIONS checked against.
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
// The producer's in-band trust note, REQUIRED verbatim (the packetseal discipline: the caveat may not
|
|
422
|
+
// drift; a packet whose note was edited is structurally invalid, exactly as `vh agent verify` treats it).
|
|
423
|
+
const AGENT_TRUST_NOTE =
|
|
424
|
+
"This agent-session packet is TAMPER-EVIDENT + OFFLINE-RECOMPUTABLE, NOT a trusted timestamp and " +
|
|
425
|
+
"NOT a claim the agent behaved well. Its ordered Merkle `head` {size, root} (RFC-6962-style, " +
|
|
426
|
+
"position-bound) commits to every event: verify RE-DERIVES each event leaf — recomputing the " +
|
|
427
|
+
"payload hash commitment for a FULL event, checking the carried commitment for a REDACTED one — " +
|
|
428
|
+
"and the root from the events you hold, and a REJECT names the first offending event seq. " +
|
|
429
|
+
"Redaction WITHHOLDS a payload behind its hash commitment without changing any leaf or the root: " +
|
|
430
|
+
"it can hide, never silently alter. Event `ts` fields are SELF-ASSERTED metadata (recorded, never " +
|
|
431
|
+
'verified against any clock); "sealed at time T" rides the human-owned signing/timestamp ' +
|
|
432
|
+
"trust-root (STRATEGY.md P-3). Garbage-in is out of scope: the head proves the LOG is intact and " +
|
|
433
|
+
"append-only, not that the log faithfully records what the agent actually did. The packet is an " +
|
|
434
|
+
"UNTRUSTED transport container: verify never trusts the packet's own stored hashes.";
|
|
435
|
+
|
|
436
|
+
const AGENT_SIGNED_HEAD_TRUST_NOTE =
|
|
437
|
+
"This is a SIGNED agent-session HEAD attestation: it WRAPS (never edits) the EXACT canonical head " +
|
|
438
|
+
"bytes in `attestation` and attaches a detached EIP-191 signature. It asserts the holder of the " +
|
|
439
|
+
"`signer` key vouched for THIS session head {size, root} at signing time. Because event leaves " +
|
|
440
|
+
"are redaction-safe, the SAME signature stays valid for every redacted copy of the sealed session " +
|
|
441
|
+
"(redaction changes neither leaves nor root). It does NOT prove a timestamp (no \"sealed since " +
|
|
442
|
+
"T\" — still the human trust-root P-3) and is NOT a legal opinion. Every caveat of the packet " +
|
|
443
|
+
"applies. " +
|
|
444
|
+
AGENT_TRUST_NOTE;
|
|
445
|
+
|
|
446
|
+
const AGENT_HEAD_KIND = "vh.agent-head";
|
|
447
|
+
const AGENT_SIGNED_HEAD_KIND = "vh.agent-head-signed";
|
|
448
|
+
const AGENT_PACKET_SCHEMA_VERSIONS = Object.freeze([1]);
|
|
449
|
+
const AGENT_EVENT_TYPES = Object.freeze(["prompt", "completion", "tool_call", "tool_result", "note"]);
|
|
450
|
+
const AGENT_EVENT_FIELDS = Object.freeze([
|
|
451
|
+
"seq",
|
|
452
|
+
"ts",
|
|
453
|
+
"actor",
|
|
454
|
+
"type",
|
|
455
|
+
"payload",
|
|
456
|
+
"payloadHash",
|
|
457
|
+
"redacted",
|
|
458
|
+
"meta",
|
|
459
|
+
]);
|
|
460
|
+
const AGENT_LEAF_DOMAIN = "vh.agent-session/v1:event-leaf";
|
|
461
|
+
const AGENT_EMPTY_ROOT_DOMAIN = "vh.journal-log/v1:empty-root";
|
|
462
|
+
const AGENT_META_MAX_DEPTH = 32;
|
|
463
|
+
const AGENT_META_MAX_NODES = 100000;
|
|
464
|
+
|
|
465
|
+
// Canonical-case wire shapes (the producer emits lowercase-only hex; mixed case is a foreign artifact).
|
|
466
|
+
const AGENT_HEX32_LC_RE = /^0x[0-9a-f]{64}$/;
|
|
467
|
+
const AGENT_ADDRESS_LC_RE = /^0x[0-9a-f]{40}$/;
|
|
468
|
+
const AGENT_SIG_LC_RE = /^0x[0-9a-f]{130}$/;
|
|
469
|
+
|
|
470
|
+
// STRICT UTF-8 encoder that MIRRORS the producer's ethers `toUtf8Bytes` byte-for-byte (verified over
|
|
471
|
+
// the whole 0x0000..0xFFFF code-unit space + surrogate edge cases). ethers' default error mode THROWS
|
|
472
|
+
// only on a lone HIGH surrogate (an unfinished pair, no code point) — so this returns null there — but
|
|
473
|
+
// it ENCODES a lone LOW surrogate as its literal 3-byte sequence (U+DC00 -> ed b0 80), NOT an error;
|
|
474
|
+
// so a lone low surrogate falls straight through to the c<0x10000 branch below (matching the producer,
|
|
475
|
+
// whose commitment over such a payload is well-defined). Pure JS; no TextEncoder (which would silently
|
|
476
|
+
// substitute U+FFFD and DIVERGE from the producer). null => the event's commitment is undefined here
|
|
477
|
+
// exactly as it is for the producer, so both sides reject in lockstep (fail-closed, never a mismatch).
|
|
478
|
+
function agentUtf8Bytes(str) {
|
|
479
|
+
const out = [];
|
|
480
|
+
for (let i = 0; i < str.length; i++) {
|
|
481
|
+
let c = str.charCodeAt(i);
|
|
482
|
+
if (c >= 0xd800 && c <= 0xdbff) {
|
|
483
|
+
const lo = i + 1 < str.length ? str.charCodeAt(i + 1) : -1;
|
|
484
|
+
if (lo < 0xdc00 || lo > 0xdfff) return null; // lone HIGH surrogate (ethers THROWS; no code point)
|
|
485
|
+
c = (c - 0xd800) * 0x400 + (lo - 0xdc00) + 0x10000;
|
|
486
|
+
i++;
|
|
487
|
+
}
|
|
488
|
+
// A lone LOW surrogate (0xdc00..0xdfff) is NOT special-cased: ethers encodes it as its 3-byte form
|
|
489
|
+
// via the c<0x10000 branch, so we do too — deleting the old lone-low `return null` that FALSELY
|
|
490
|
+
// rejected genuine packets carrying truncated-UTF-16 / arbitrary-tool-result bytes.
|
|
491
|
+
if (c < 0x80) out.push(c);
|
|
492
|
+
else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 63));
|
|
493
|
+
else if (c < 0x10000) out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
|
|
494
|
+
else out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 63), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
|
|
495
|
+
}
|
|
496
|
+
return new Uint8Array(out);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// 0x-hex -> bytes, and a tiny concat — the only byte plumbing the ordered tree needs.
|
|
500
|
+
function agentHexToBytes(hex) {
|
|
501
|
+
const s = hex.slice(2);
|
|
502
|
+
const out = new Uint8Array(s.length / 2);
|
|
503
|
+
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);
|
|
504
|
+
return out;
|
|
505
|
+
}
|
|
506
|
+
function agentConcatBytes(list) {
|
|
507
|
+
let total = 0;
|
|
508
|
+
for (const b of list) total += b.length;
|
|
509
|
+
const out = new Uint8Array(total);
|
|
510
|
+
let off = 0;
|
|
511
|
+
for (const b of list) {
|
|
512
|
+
out.set(b, off);
|
|
513
|
+
off += b.length;
|
|
514
|
+
}
|
|
515
|
+
return out;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// RFC-6962 domain-separated hashing over the verifier's OWN keccak (merkle.hashBytes — the same
|
|
519
|
+
// independent primitive every other artifact family here is re-derived with). Children fold in TREE
|
|
520
|
+
// ORDER (never sorted): position IS meaning in an ordered session log.
|
|
521
|
+
function agentLeafNodeHash(leafHex) {
|
|
522
|
+
return merkle.hashBytes(agentConcatBytes([Uint8Array.of(0x00), agentHexToBytes(leafHex)]));
|
|
523
|
+
}
|
|
524
|
+
function agentInteriorHash(leftHex, rightHex) {
|
|
525
|
+
return merkle.hashBytes(
|
|
526
|
+
agentConcatBytes([Uint8Array.of(0x01), agentHexToBytes(leftHex), agentHexToBytes(rightHex)])
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// MTH (RFC 6962 §2.1) over the ORDERED leaf values; the empty log has a domain-separated constant root.
|
|
531
|
+
function agentTreeRoot(leaves) {
|
|
532
|
+
if (leaves.length === 0) return merkle.hashBytes(agentUtf8Bytes(AGENT_EMPTY_ROOT_DOMAIN));
|
|
533
|
+
function mth(lo, hi) {
|
|
534
|
+
const n = hi - lo;
|
|
535
|
+
if (n === 1) return agentLeafNodeHash(leaves[lo]);
|
|
536
|
+
let k = 1;
|
|
537
|
+
while (k * 2 < n) k *= 2;
|
|
538
|
+
return agentInteriorHash(mth(lo, lo + k), mth(lo + k, hi));
|
|
539
|
+
}
|
|
540
|
+
return mth(0, leaves.length);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// A "plain" JSON-shaped object (prototype Object.prototype or null) — the same strictness the producer
|
|
544
|
+
// applies, so what is hashed is exactly what could be written to disk and read back.
|
|
545
|
+
function agentIsPlainObject(v) {
|
|
546
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
|
|
547
|
+
const proto = Object.getPrototypeOf(v);
|
|
548
|
+
return proto === Object.prototype || proto === null;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Canonical JSON for `meta`: keys SORTED, only JSON-representable values, depth capped, and a TOTAL
|
|
552
|
+
// work budget so a shared-reference DAG can never hang the verifier. Returns the canonical text or
|
|
553
|
+
// null (reject) — byte-identical to the producer's canonicalization for every accepted value.
|
|
554
|
+
function agentCanonicalJson(value, depth, budget) {
|
|
555
|
+
if (depth > AGENT_META_MAX_DEPTH) return null;
|
|
556
|
+
if (++budget.n > AGENT_META_MAX_NODES) return null;
|
|
557
|
+
if (value === null) return "null";
|
|
558
|
+
const t = typeof value;
|
|
559
|
+
if (t === "boolean") return value ? "true" : "false";
|
|
560
|
+
if (t === "number") return Number.isFinite(value) ? JSON.stringify(value) : null;
|
|
561
|
+
if (t === "string") return JSON.stringify(value);
|
|
562
|
+
if (Array.isArray(value)) {
|
|
563
|
+
const parts = [];
|
|
564
|
+
for (const item of value) {
|
|
565
|
+
const p = agentCanonicalJson(item, depth + 1, budget);
|
|
566
|
+
if (p === null) return null;
|
|
567
|
+
parts.push(p);
|
|
568
|
+
}
|
|
569
|
+
return "[" + parts.join(",") + "]";
|
|
570
|
+
}
|
|
571
|
+
if (agentIsPlainObject(value)) {
|
|
572
|
+
const keys = Object.keys(value).sort();
|
|
573
|
+
const parts = [];
|
|
574
|
+
for (const k of keys) {
|
|
575
|
+
const p = agentCanonicalJson(value[k], depth + 1, budget);
|
|
576
|
+
if (p === null) return null;
|
|
577
|
+
parts.push(JSON.stringify(k) + ":" + p);
|
|
578
|
+
}
|
|
579
|
+
return "{" + parts.join(",") + "}";
|
|
580
|
+
}
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// The payload COMMITMENT: keccak256 over the payload's UTF-8 bytes. null on a non-string or a string
|
|
585
|
+
// with no UTF-8 encoding (a lone HIGH surrogate — where ethers throws) — TOTAL, mirrors the producer
|
|
586
|
+
// exactly (a lone LOW surrogate IS encodable, so it commits rather than rejecting).
|
|
587
|
+
function agentPayloadHash(payload) {
|
|
588
|
+
if (typeof payload !== "string") return null;
|
|
589
|
+
const bytes = agentUtf8Bytes(payload);
|
|
590
|
+
return bytes === null ? null : merkle.hashBytes(bytes);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// STRICT validation of one canonical event — an INDEPENDENT re-implementation of the producer's rules
|
|
594
|
+
// (closed field set; exactly the FULL or REDACTED shape; a carried commitment on a full event must
|
|
595
|
+
// equal the recomputed one). Never throws; every failure is a named { ok:false, reason, field? } (the
|
|
596
|
+
// commitment-mismatch reject also carries carried/recomputed so the caller can localize the change).
|
|
597
|
+
function agentValidateEvent(event) {
|
|
598
|
+
try {
|
|
599
|
+
if (!agentIsPlainObject(event)) return { ok: false, reason: "EVENT_NOT_OBJECT" };
|
|
600
|
+
for (const k of Object.keys(event)) {
|
|
601
|
+
if (!AGENT_EVENT_FIELDS.includes(k)) return { ok: false, reason: "EVENT_UNKNOWN_FIELD", field: k };
|
|
602
|
+
}
|
|
603
|
+
if (!Number.isSafeInteger(event.seq) || event.seq < 0) {
|
|
604
|
+
return { ok: false, reason: "EVENT_BAD_SEQ", field: "seq" };
|
|
605
|
+
}
|
|
606
|
+
if (typeof event.ts !== "string") return { ok: false, reason: "EVENT_BAD_TS", field: "ts" };
|
|
607
|
+
if (typeof event.actor !== "string" || event.actor.length === 0) {
|
|
608
|
+
return { ok: false, reason: "EVENT_BAD_ACTOR", field: "actor" };
|
|
609
|
+
}
|
|
610
|
+
if (!AGENT_EVENT_TYPES.includes(event.type)) return { ok: false, reason: "EVENT_BAD_TYPE", field: "type" };
|
|
611
|
+
const hasPayload = "payload" in event;
|
|
612
|
+
const hasHash = "payloadHash" in event;
|
|
613
|
+
if (hasPayload && typeof event.payload !== "string") {
|
|
614
|
+
return { ok: false, reason: "EVENT_BAD_PAYLOAD", field: "payload" };
|
|
615
|
+
}
|
|
616
|
+
if (hasHash && !(typeof event.payloadHash === "string" && merkle.HEX32_RE.test(event.payloadHash))) {
|
|
617
|
+
return { ok: false, reason: "EVENT_BAD_PAYLOAD_HASH", field: "payloadHash" };
|
|
618
|
+
}
|
|
619
|
+
if ("redacted" in event && typeof event.redacted !== "boolean") {
|
|
620
|
+
return { ok: false, reason: "EVENT_BAD_REDACTED_FLAG", field: "redacted" };
|
|
621
|
+
}
|
|
622
|
+
if (!hasPayload && !hasHash) return { ok: false, reason: "EVENT_MISSING_PAYLOAD", field: "payload" };
|
|
623
|
+
if (event.redacted === true && hasPayload) {
|
|
624
|
+
return { ok: false, reason: "EVENT_REDACTED_WITH_PAYLOAD", field: "redacted" };
|
|
625
|
+
}
|
|
626
|
+
if (event.redacted === true && !hasHash) {
|
|
627
|
+
return { ok: false, reason: "EVENT_BAD_PAYLOAD_HASH", field: "payloadHash" };
|
|
628
|
+
}
|
|
629
|
+
if (!hasPayload && event.redacted !== true) {
|
|
630
|
+
return { ok: false, reason: "EVENT_UNFLAGGED_REDACTION", field: "redacted" };
|
|
631
|
+
}
|
|
632
|
+
let commitment;
|
|
633
|
+
if (hasPayload) {
|
|
634
|
+
commitment = agentPayloadHash(event.payload);
|
|
635
|
+
if (commitment === null) return { ok: false, reason: "EVENT_BAD_PAYLOAD", field: "payload" };
|
|
636
|
+
if (hasHash && commitment !== event.payloadHash.toLowerCase()) {
|
|
637
|
+
return {
|
|
638
|
+
ok: false,
|
|
639
|
+
reason: "EVENT_PAYLOAD_HASH_MISMATCH",
|
|
640
|
+
field: "payloadHash",
|
|
641
|
+
carried: event.payloadHash.toLowerCase(),
|
|
642
|
+
recomputed: commitment,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
} else {
|
|
646
|
+
commitment = event.payloadHash.toLowerCase();
|
|
647
|
+
}
|
|
648
|
+
let metaJson = null;
|
|
649
|
+
if ("meta" in event) {
|
|
650
|
+
metaJson = agentCanonicalJson(event.meta, 0, { n: 0 });
|
|
651
|
+
if (metaJson === null) return { ok: false, reason: "EVENT_BAD_META", field: "meta" };
|
|
652
|
+
}
|
|
653
|
+
return { ok: true, redacted: !hasPayload, payloadHash: commitment, metaJson };
|
|
654
|
+
} catch (_) {
|
|
655
|
+
return { ok: false, reason: "HOSTILE_INPUT" };
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// The redaction-safe LEAF VALUE of one validated event: the fixed-position JSON array preimage with
|
|
660
|
+
// the payload represented ONLY by its commitment (so a full event and its redacted twin derive the
|
|
661
|
+
// identical leaf). Returns null only for an encoding fault (kept total).
|
|
662
|
+
function agentEventLeaf(event, validated) {
|
|
663
|
+
const encoded = JSON.stringify([
|
|
664
|
+
AGENT_LEAF_DOMAIN,
|
|
665
|
+
event.seq,
|
|
666
|
+
event.ts,
|
|
667
|
+
event.actor,
|
|
668
|
+
event.type,
|
|
669
|
+
validated.payloadHash,
|
|
670
|
+
validated.metaJson,
|
|
671
|
+
]);
|
|
672
|
+
const bytes = agentUtf8Bytes(encoded);
|
|
673
|
+
return bytes === null ? null : merkle.hashBytes(bytes);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// The shared { size, root } head shape. Throws IOError (a malformed/foreign artifact, exit 1 — the same
|
|
677
|
+
// class `vh agent verify` gives a structurally invalid packet).
|
|
678
|
+
function validateAgentHeadShape(head, label) {
|
|
679
|
+
if (head == null || typeof head !== "object" || Array.isArray(head)) {
|
|
680
|
+
throw new IOError(`${label} \`head\` must be a { size, root } object`);
|
|
681
|
+
}
|
|
682
|
+
for (const k of Object.keys(head)) {
|
|
683
|
+
if (k !== "size" && k !== "root") {
|
|
684
|
+
throw new IOError(`${label} head has unknown field: ${JSON.stringify(k)}`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
if (!Number.isSafeInteger(head.size) || head.size < 0) {
|
|
688
|
+
throw new IOError(`${label} head.size must be a non-negative integer, got: ${String(head.size)}`);
|
|
689
|
+
}
|
|
690
|
+
if (typeof head.root !== "string" || !AGENT_HEX32_LC_RE.test(head.root)) {
|
|
691
|
+
throw new IOError(
|
|
692
|
+
`${label} head.root must be a LOWERCASE 0x-bytes32 hex string, got: ${String(head.root)}`
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// STRICT structural validation of the OPTIONAL signed-head container: the exact canonical embedded
|
|
698
|
+
// bytes, a known scheme, lowercase signer/signature, and an embedded head payload in canonical form.
|
|
699
|
+
// Returns { embeddedHead } for the binding check. Throws IOError on any structural defect.
|
|
700
|
+
function validateAgentSignedHead(container) {
|
|
701
|
+
const label = "agent-session packet headAttestation";
|
|
702
|
+
if (container == null || typeof container !== "object" || Array.isArray(container)) {
|
|
703
|
+
throw new IOError(`${label} must be a JSON object`);
|
|
704
|
+
}
|
|
705
|
+
const KNOWN = ["kind", "schemaVersion", "note", "attestation", "signature"];
|
|
706
|
+
for (const k of Object.keys(container)) {
|
|
707
|
+
if (!KNOWN.includes(k)) throw new IOError(`${label} has unknown field: ${JSON.stringify(k)}`);
|
|
708
|
+
}
|
|
709
|
+
if (container.kind !== AGENT_SIGNED_HEAD_KIND) {
|
|
710
|
+
throw new IOError(
|
|
711
|
+
`${label} kind must be ${JSON.stringify(AGENT_SIGNED_HEAD_KIND)}, got: ${JSON.stringify(container.kind)}`
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
if (container.schemaVersion !== 1) {
|
|
715
|
+
throw new IOError(`${label} has unsupported schemaVersion: ${JSON.stringify(container.schemaVersion)}`);
|
|
716
|
+
}
|
|
717
|
+
if (container.note !== AGENT_SIGNED_HEAD_TRUST_NOTE) {
|
|
718
|
+
throw new IOError(`${label} note must be the standing signed-head trust note (caveat must not drift)`);
|
|
719
|
+
}
|
|
720
|
+
if (typeof container.attestation !== "string") {
|
|
721
|
+
throw new IOError(`${label} must embed the canonical UNSIGNED head bytes as a string \`attestation\``);
|
|
722
|
+
}
|
|
723
|
+
let embedded;
|
|
724
|
+
try {
|
|
725
|
+
embedded = JSON.parse(container.attestation);
|
|
726
|
+
} catch (e) {
|
|
727
|
+
throw new IOError(`${label} embedded attestation is not valid JSON: ${e.message}`);
|
|
728
|
+
}
|
|
729
|
+
if (
|
|
730
|
+
embedded == null ||
|
|
731
|
+
typeof embedded !== "object" ||
|
|
732
|
+
Array.isArray(embedded) ||
|
|
733
|
+
embedded.kind !== AGENT_HEAD_KIND ||
|
|
734
|
+
embedded.schemaVersion !== 1 ||
|
|
735
|
+
embedded.note !== AGENT_TRUST_NOTE
|
|
736
|
+
) {
|
|
737
|
+
throw new IOError(`${label} embedded payload is not a canonical ${JSON.stringify(AGENT_HEAD_KIND)} payload`);
|
|
738
|
+
}
|
|
739
|
+
validateAgentHeadShape(embedded.head, `${label} embedded payload`);
|
|
740
|
+
// The embedded string must be the EXACT canonical serialization (the byte-unambiguous signed message);
|
|
741
|
+
// an insignificant-whitespace/reordered variant is a foreign artifact.
|
|
742
|
+
const canonicalText =
|
|
743
|
+
JSON.stringify({
|
|
744
|
+
kind: embedded.kind,
|
|
745
|
+
schemaVersion: embedded.schemaVersion,
|
|
746
|
+
note: embedded.note,
|
|
747
|
+
head: { size: embedded.head.size, root: embedded.head.root },
|
|
748
|
+
}) + "\n";
|
|
749
|
+
if (container.attestation !== canonicalText) {
|
|
750
|
+
throw new IOError(`${label} embedded attestation is not in canonical form (the signed-over bytes are ambiguous)`);
|
|
751
|
+
}
|
|
752
|
+
const sig = container.signature;
|
|
753
|
+
if (sig == null || typeof sig !== "object" || Array.isArray(sig)) {
|
|
754
|
+
throw new IOError(`${label} signature must be a { scheme, signer, signature } object`);
|
|
755
|
+
}
|
|
756
|
+
if (sig.scheme !== "eip191-personal-sign") {
|
|
757
|
+
throw new IOError(
|
|
758
|
+
`${label} has unsupported signature scheme: ${JSON.stringify(sig.scheme)} (this verifier understands eip191-personal-sign)`
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
if (typeof sig.signer !== "string" || !AGENT_ADDRESS_LC_RE.test(sig.signer)) {
|
|
762
|
+
throw new IOError(`${label} signer must be a LOWERCASE 0x-prefixed 20-byte hex address`);
|
|
763
|
+
}
|
|
764
|
+
if (typeof sig.signature !== "string" || !AGENT_SIG_LC_RE.test(sig.signature)) {
|
|
765
|
+
throw new IOError(`${label} signature must be a 65-byte (r||s||v) LOWERCASE 0x-hex string`);
|
|
766
|
+
}
|
|
767
|
+
return { embeddedHead: { size: embedded.head.size, root: embedded.head.root } };
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// STRICT structural validation of a parsed packet (SHAPE only — the per-event/leaf/root RECOMPUTE is
|
|
771
|
+
// verifyAgentSeal's job, so event-level tamper stays a NAMED verdict naming the seq, never a throw).
|
|
772
|
+
// Mirrors the producer's validatePacketShape defect-for-defect. Throws IOError.
|
|
773
|
+
function validateAgentPacketStructure(obj) {
|
|
774
|
+
const label = "agent-session packet";
|
|
775
|
+
const KNOWN = ["kind", "schemaVersion", "note", "head", "counts", "events", "leaves", "headAttestation"];
|
|
776
|
+
for (const k of Object.keys(obj)) {
|
|
777
|
+
if (!KNOWN.includes(k)) throw new IOError(`${label} has unknown field: ${JSON.stringify(k)}`);
|
|
778
|
+
}
|
|
779
|
+
if (!AGENT_PACKET_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
|
|
780
|
+
throw new IOError(
|
|
781
|
+
`unsupported ${label} schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
|
|
782
|
+
`(this verifier understands ${JSON.stringify(AGENT_PACKET_SCHEMA_VERSIONS)})`
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
if (obj.note !== AGENT_TRUST_NOTE) {
|
|
786
|
+
throw new IOError(`${label} \`note\` must be the standing trust note (caveat must not drift)`);
|
|
787
|
+
}
|
|
788
|
+
validateAgentHeadShape(obj.head, label);
|
|
789
|
+
if (obj.counts == null || typeof obj.counts !== "object" || Array.isArray(obj.counts)) {
|
|
790
|
+
throw new IOError(`${label} \`counts\` must be a { events, full, redacted } object`);
|
|
791
|
+
}
|
|
792
|
+
for (const k of Object.keys(obj.counts)) {
|
|
793
|
+
if (!["events", "full", "redacted"].includes(k)) {
|
|
794
|
+
throw new IOError(`${label} counts has unknown field: ${JSON.stringify(k)}`);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
for (const k of ["events", "full", "redacted"]) {
|
|
798
|
+
if (!Number.isSafeInteger(obj.counts[k]) || obj.counts[k] < 0) {
|
|
799
|
+
throw new IOError(`${label} counts.${k} must be a non-negative integer, got: ${String(obj.counts[k])}`);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
if (!Array.isArray(obj.events)) throw new IOError(`${label} \`events\` must be an array`);
|
|
803
|
+
if (!Array.isArray(obj.leaves) || obj.leaves.length !== obj.events.length) {
|
|
804
|
+
throw new IOError(`${label} \`leaves\` must be an array with EXACTLY one leaf expectation per event`);
|
|
805
|
+
}
|
|
806
|
+
obj.leaves.forEach((l, i) => {
|
|
807
|
+
if (typeof l !== "string" || !AGENT_HEX32_LC_RE.test(l)) {
|
|
808
|
+
throw new IOError(`${label} leaves[${i}] must be a LOWERCASE 0x-bytes32 hex string, got: ${String(l)}`);
|
|
809
|
+
}
|
|
810
|
+
});
|
|
811
|
+
if (obj.head.size !== obj.events.length) {
|
|
812
|
+
throw new IOError(
|
|
813
|
+
`${label} head.size (${obj.head.size}) does not match the events length (${obj.events.length})`
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
if (obj.counts.events !== obj.events.length || obj.counts.full + obj.counts.redacted !== obj.counts.events) {
|
|
817
|
+
throw new IOError(
|
|
818
|
+
`${label} \`counts\` is internally inconsistent (events must equal the events length; full + redacted must equal events)`
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
let signedHead = null;
|
|
822
|
+
if (obj.headAttestation !== undefined) signedHead = validateAgentSignedHead(obj.headAttestation);
|
|
823
|
+
return { packet: obj, signedHead };
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// The AUTHORITATIVE per-event/leaf/root/counts RECOMPUTE over a shape-validated packet. Returns the
|
|
827
|
+
// engine's standard fileResult shape (matched/changed/... + roots) PLUS an `agent` sub-verdict block
|
|
828
|
+
// and a `reasonKind` in the verifier's reason vocabulary. Event faults are localized to the FIRST
|
|
829
|
+
// offending seq, exactly as the producer's verify names it. Never throws.
|
|
830
|
+
function verifyAgentSeal(packet) {
|
|
831
|
+
const matched = [];
|
|
832
|
+
const changed = [];
|
|
833
|
+
const withheld = [];
|
|
834
|
+
const agent = {
|
|
835
|
+
head: { size: packet.head.size, root: packet.head.root },
|
|
836
|
+
recomputedHead: null,
|
|
837
|
+
counts: null,
|
|
838
|
+
withheld: null,
|
|
839
|
+
seq: null,
|
|
840
|
+
reason: null,
|
|
841
|
+
};
|
|
842
|
+
const base = {
|
|
843
|
+
matched,
|
|
844
|
+
changed,
|
|
845
|
+
missing: [],
|
|
846
|
+
escaped: [],
|
|
847
|
+
unexpected: [],
|
|
848
|
+
sealedRoot: packet.head.root,
|
|
849
|
+
recomputedRoot: null,
|
|
850
|
+
rootMatches: null,
|
|
851
|
+
filesOk: false,
|
|
852
|
+
reasonKind: null,
|
|
853
|
+
agent,
|
|
854
|
+
};
|
|
855
|
+
const events = packet.events;
|
|
856
|
+
const leaves = [];
|
|
857
|
+
for (let i = 0; i < events.length; i++) {
|
|
858
|
+
const v = agentValidateEvent(events[i]);
|
|
859
|
+
if (!v.ok) {
|
|
860
|
+
agent.seq = i;
|
|
861
|
+
agent.reason = v.reason;
|
|
862
|
+
if (v.field !== undefined) agent.field = v.field;
|
|
863
|
+
if (v.reason === "EVENT_PAYLOAD_HASH_MISMATCH") {
|
|
864
|
+
// The payload no longer matches its carried commitment: a CONTENT change localized to its seq
|
|
865
|
+
// (this is also how a REDACTED event's FORGED commitment surfaces once its leaf is checked).
|
|
866
|
+
changed.push({ relPath: `events[${i}]`, expectedContentHash: v.carried, actualContentHash: v.recomputed });
|
|
867
|
+
base.reasonKind = "CHANGED";
|
|
868
|
+
} else {
|
|
869
|
+
base.reasonKind = "event_invalid";
|
|
870
|
+
}
|
|
871
|
+
return base;
|
|
872
|
+
}
|
|
873
|
+
if (events[i].seq !== i) {
|
|
874
|
+
agent.seq = i;
|
|
875
|
+
agent.reason = "SESSION_SEQ_NOT_CONTIGUOUS";
|
|
876
|
+
base.reasonKind = "event_invalid";
|
|
877
|
+
return base;
|
|
878
|
+
}
|
|
879
|
+
const leaf = agentEventLeaf(events[i], v);
|
|
880
|
+
if (leaf === null || leaf !== packet.leaves[i]) {
|
|
881
|
+
// A bound-field edit (ts/actor/type/meta) or a forged redacted commitment: the re-derived leaf no
|
|
882
|
+
// longer matches the packet's own expectation — named by seq, recompute authoritative.
|
|
883
|
+
agent.seq = i;
|
|
884
|
+
agent.reason = "EVENT_LEAF_MISMATCH";
|
|
885
|
+
changed.push({ relPath: `events[${i}]`, expectedContentHash: packet.leaves[i], actualContentHash: leaf });
|
|
886
|
+
base.reasonKind = "CHANGED";
|
|
887
|
+
return base;
|
|
888
|
+
}
|
|
889
|
+
leaves.push(leaf);
|
|
890
|
+
matched.push({ relPath: `events[${i}]`, contentHash: leaf });
|
|
891
|
+
if (v.redacted) withheld.push(i);
|
|
892
|
+
}
|
|
893
|
+
const recomputedRoot = agentTreeRoot(leaves);
|
|
894
|
+
base.recomputedRoot = recomputedRoot;
|
|
895
|
+
agent.recomputedHead = { size: leaves.length, root: recomputedRoot };
|
|
896
|
+
base.rootMatches = leaves.length === packet.head.size && recomputedRoot === packet.head.root;
|
|
897
|
+
if (!base.rootMatches) {
|
|
898
|
+
agent.reason = "HEAD_MISMATCH";
|
|
899
|
+
base.reasonKind = "root_mismatch";
|
|
900
|
+
return base;
|
|
901
|
+
}
|
|
902
|
+
const full = events.length - withheld.length;
|
|
903
|
+
agent.counts = { events: events.length, full, redacted: withheld.length };
|
|
904
|
+
agent.withheld = withheld;
|
|
905
|
+
if (packet.counts.full !== full || packet.counts.redacted !== withheld.length) {
|
|
906
|
+
agent.reason = "COUNTS_MISMATCH";
|
|
907
|
+
base.reasonKind = "counts_mismatch";
|
|
908
|
+
return base;
|
|
909
|
+
}
|
|
910
|
+
base.filesOk = true;
|
|
911
|
+
return base;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// The artifact-level orchestrator for KINDS.AGENT_PACKET — both entrypoints (disk + bytes) route here
|
|
915
|
+
// through verifyParsedArtifact, so the two paths' verdicts are one code path (deep-equal by
|
|
916
|
+
// construction). Precedence mirrors the producer's `vh agent verify`: event/leaf/head/counts faults
|
|
917
|
+
// (naming the seq) dominate; then head binding, signature genuineness, and the vendor pin.
|
|
918
|
+
function verifyAgentPacketArtifact({ artifact, obj, pinned }) {
|
|
919
|
+
const { signedHead } = validateAgentPacketStructure(obj); // throws IOError on a malformed/foreign packet
|
|
920
|
+
const fileResult = verifyAgentSeal(obj);
|
|
921
|
+
const agent = fileResult.agent;
|
|
922
|
+
|
|
923
|
+
const signed = obj.headAttestation !== undefined;
|
|
924
|
+
let recoveredSigner = null;
|
|
925
|
+
let claimedSigner = null;
|
|
926
|
+
let signatureOk = null;
|
|
927
|
+
let signerMatchesVendor = null;
|
|
928
|
+
let headBound = null;
|
|
929
|
+
if (signed) {
|
|
930
|
+
claimedSigner = obj.headAttestation.signature.signer; // lowercase, structurally enforced
|
|
931
|
+
recoveredSigner = tryRecover(obj.headAttestation.attestation, obj.headAttestation.signature.signature);
|
|
932
|
+
signatureOk = recoveredSigner != null && recoveredSigner === claimedSigner;
|
|
933
|
+
if (agent.recomputedHead != null) {
|
|
934
|
+
// The signature must vouch for THIS session's RECOMPUTED head — a signature pasted from a
|
|
935
|
+
// different session recovers fine but binds a different { size, root }.
|
|
936
|
+
headBound =
|
|
937
|
+
signedHead.embeddedHead.size === agent.recomputedHead.size &&
|
|
938
|
+
signedHead.embeddedHead.root === agent.recomputedHead.root;
|
|
939
|
+
}
|
|
940
|
+
if (signatureOk && pinned != null) signerMatchesVendor = recoveredSigner === pinned;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
let accepted = true;
|
|
944
|
+
let reason = "OK";
|
|
945
|
+
if (!fileResult.filesOk) {
|
|
946
|
+
accepted = false;
|
|
947
|
+
reason = fileResult.reasonKind;
|
|
948
|
+
} else if (signed && headBound === false) {
|
|
949
|
+
accepted = false;
|
|
950
|
+
reason = "head_not_bound";
|
|
951
|
+
agent.reason = "HEAD_NOT_BOUND";
|
|
952
|
+
} else if (signed && !signatureOk) {
|
|
953
|
+
accepted = false;
|
|
954
|
+
reason = "bad_signature";
|
|
955
|
+
agent.reason = "SIGNATURE_FORGED";
|
|
956
|
+
} else if (signed && pinned != null && signerMatchesVendor !== true) {
|
|
957
|
+
accepted = false;
|
|
958
|
+
reason = "wrong_issuer";
|
|
959
|
+
agent.reason = "WRONG_VENDOR";
|
|
960
|
+
} else if (!signed && pinned != null) {
|
|
961
|
+
// Fail-closed pin: a stripped signature can never pass a pinned verify.
|
|
962
|
+
accepted = false;
|
|
963
|
+
reason = "unsigned_cannot_pin_vendor";
|
|
964
|
+
agent.reason = "NOT_SIGNED";
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const result = {
|
|
968
|
+
artifact,
|
|
969
|
+
kind: KINDS.AGENT_PACKET,
|
|
970
|
+
payloadKind: KINDS.AGENT_PACKET,
|
|
971
|
+
signed,
|
|
972
|
+
verdict: accepted ? "OK" : "REJECTED",
|
|
973
|
+
reason,
|
|
974
|
+
accepted,
|
|
975
|
+
recoveredSigner,
|
|
976
|
+
claimedSigner,
|
|
977
|
+
pinnedVendor: pinned,
|
|
978
|
+
signatureOk,
|
|
979
|
+
signerMatchesVendor,
|
|
980
|
+
sealedRoot: fileResult.sealedRoot,
|
|
981
|
+
recomputedRoot: fileResult.recomputedRoot,
|
|
982
|
+
rootMatches: fileResult.rootMatches,
|
|
983
|
+
counts: {
|
|
984
|
+
matched: fileResult.matched.length,
|
|
985
|
+
changed: fileResult.changed.length,
|
|
986
|
+
missing: 0,
|
|
987
|
+
escaped: 0,
|
|
988
|
+
unexpected: 0,
|
|
989
|
+
},
|
|
990
|
+
matched: fileResult.matched,
|
|
991
|
+
changed: fileResult.changed,
|
|
992
|
+
missing: [],
|
|
993
|
+
escaped: [],
|
|
994
|
+
unexpected: [],
|
|
995
|
+
agent,
|
|
996
|
+
note: TRUST_NOTE,
|
|
997
|
+
};
|
|
998
|
+
return { result, code: accepted ? EXIT.OK : EXIT.REJECTED };
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// ---------------------------------------------------------------------------
|
|
1002
|
+
// The core verify orchestration over an ALREADY-PARSED artifact object + an injected file source. This
|
|
1003
|
+
// is the ONE engine BOTH entrypoints drive — `verifyArtifact` (disk: the CLI contract, byte-identical to
|
|
1004
|
+
// before this seam existed) and `verifyArtifactFromBytes` (in-memory map). It auto-detects the artifact
|
|
1005
|
+
// kind, decodes a signed container (recovering + pinning the signer), re-derives the root from
|
|
1006
|
+
// referenced bytes, and assembles a deterministic verdict. PURE: every read goes through `readEntry`.
|
|
1007
|
+
// Returns { result, code } — code is the EXIT-contract integer.
|
|
1008
|
+
// ---------------------------------------------------------------------------
|
|
1009
|
+
|
|
1010
|
+
function verifyParsedArtifact({ artifact, obj, vendor, readEntry }) {
|
|
1011
|
+
const kind = obj.kind;
|
|
1012
|
+
const pinned = vendor != null ? normalizeAddress(vendor, "--vendor") : null;
|
|
1013
|
+
|
|
1014
|
+
// AGENT-SESSION packet (T-68.3): SELF-CONTAINED — no sibling bytes, its own leaf/root convention and
|
|
1015
|
+
// its own in-packet signed head. Routed to the dedicated orchestrator above (`readEntry` unused).
|
|
1016
|
+
if (kind === KINDS.AGENT_PACKET) {
|
|
1017
|
+
return verifyAgentPacketArtifact({ artifact, obj, pinned });
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Detect signed vs bare and the underlying payload kind. A signed container wraps the embedded payload.
|
|
1021
|
+
let signed = false;
|
|
1022
|
+
let recoveredSigner = null;
|
|
1023
|
+
let claimedSigner = null;
|
|
1024
|
+
let signatureOk = null; // null = no signature on this artifact
|
|
1025
|
+
let payload = obj; // the (possibly embedded) thing whose root we re-derive
|
|
1026
|
+
let payloadKind = kind;
|
|
1027
|
+
|
|
1028
|
+
if (
|
|
1029
|
+
kind === KINDS.EVIDENCE_SEAL_SIGNED ||
|
|
1030
|
+
kind === KINDS.TRUST_SEAL_SIGNED ||
|
|
1031
|
+
kind === KINDS.DATASET_ATTESTATION_SIGNED ||
|
|
1032
|
+
kind === KINDS.DATASET_ATTESTATION_TIMESTAMPED
|
|
1033
|
+
) {
|
|
1034
|
+
signed = true;
|
|
1035
|
+
const dec = decodeSigned(obj);
|
|
1036
|
+
payload = dec.embedded;
|
|
1037
|
+
payloadKind = dec.embedded.kind;
|
|
1038
|
+
claimedSigner = dec.claimedSigner;
|
|
1039
|
+
recoveredSigner = tryRecover(dec.message, dec.signature);
|
|
1040
|
+
// signatureOk: the signature recovers AND matches the CLAIMED signer recorded in the container.
|
|
1041
|
+
signatureOk = recoveredSigner != null && recoveredSigner === claimedSigner;
|
|
1042
|
+
} else if (!Object.values(KINDS).includes(kind)) {
|
|
1043
|
+
throw new UsageError(
|
|
1044
|
+
`unrecognized artifact kind: ${JSON.stringify(kind)} ` +
|
|
1045
|
+
"(verify-vh understands evidence seals, reconciliation seals, dataset attestations, and proof bundles)"
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// Re-derive the root from the referenced bytes per the (underlying) kind.
|
|
1050
|
+
let fileResult;
|
|
1051
|
+
if (payloadKind === KINDS.EVIDENCE_SEAL) {
|
|
1052
|
+
fileResult = verifyEvidenceSealWith(payload, readEntry);
|
|
1053
|
+
} else if (payloadKind === KINDS.TRUST_SEAL) {
|
|
1054
|
+
fileResult = verifyTrustSealWith(payload, readEntry);
|
|
1055
|
+
} else if (payloadKind === KINDS.DATASET_ATTESTATION) {
|
|
1056
|
+
fileResult = verifyDatasetAttestation(payload);
|
|
1057
|
+
} else if (payloadKind === KINDS.PROOF) {
|
|
1058
|
+
fileResult = verifyProofBundle(payload);
|
|
1059
|
+
} else {
|
|
1060
|
+
throw new UsageError(
|
|
1061
|
+
`unrecognized embedded artifact kind: ${JSON.stringify(payloadKind)}`
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// --- Decide the verdict + the deterministic reason. ---
|
|
1066
|
+
// Precedence: a structural file tamper (CHANGED/MISSING/root mismatch) is a clean REJECTED. For a
|
|
1067
|
+
// SIGNED artifact, a broken signature is `bad_signature`; a recovered signer that does not equal the
|
|
1068
|
+
// pinned --vendor is `wrong_issuer`. Both are clean REJECTED verdicts (exit 3), never a crash.
|
|
1069
|
+
let reason = "OK";
|
|
1070
|
+
let accepted = true;
|
|
1071
|
+
|
|
1072
|
+
const escaped = fileResult.escaped || [];
|
|
1073
|
+
if (!fileResult.filesOk) {
|
|
1074
|
+
accepted = false;
|
|
1075
|
+
// path_escape DOMINATES: an artifact that tries to read outside its source is malicious by
|
|
1076
|
+
// construction (the threat model is a hostile producer probing the counterparty's filesystem), so it
|
|
1077
|
+
// is reported FIRST — never as a benign CHANGED/MISSING, and never with a leaked out-of-tree content
|
|
1078
|
+
// hash.
|
|
1079
|
+
if (escaped.length > 0) reason = "path_escape";
|
|
1080
|
+
else if (fileResult.changed.length > 0) reason = "CHANGED";
|
|
1081
|
+
else if (fileResult.missing.length > 0) reason = "MISSING";
|
|
1082
|
+
else if (fileResult.unexpected.length > 0) reason = "UNEXPECTED";
|
|
1083
|
+
else reason = "root_mismatch";
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// Signature checks (only for signed artifacts). A bad signature dominates the "issuer" check (you
|
|
1087
|
+
// cannot trust an issuer you cannot recover).
|
|
1088
|
+
let signerMatchesVendor = null;
|
|
1089
|
+
if (signed) {
|
|
1090
|
+
if (!signatureOk) {
|
|
1091
|
+
accepted = false;
|
|
1092
|
+
// bad_signature is the dominant reason ONLY if files were otherwise OK; if a file also changed we
|
|
1093
|
+
// still surface bad_signature because the signature is the trust root of a signed artifact.
|
|
1094
|
+
reason = "bad_signature";
|
|
1095
|
+
} else if (pinned != null) {
|
|
1096
|
+
signerMatchesVendor = recoveredSigner === pinned;
|
|
1097
|
+
if (!signerMatchesVendor) {
|
|
1098
|
+
accepted = false;
|
|
1099
|
+
// wrong_issuer only when the signature itself is sound but the signer is not the pinned vendor.
|
|
1100
|
+
if (fileResult.filesOk) reason = "wrong_issuer";
|
|
1101
|
+
else if (reason === "OK") reason = "wrong_issuer";
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
} else if (pinned != null) {
|
|
1105
|
+
// A --vendor pin on an UNSIGNED artifact cannot be satisfied (there is no signer to recover); this is
|
|
1106
|
+
// a clean REJECTED wrong_issuer-style verdict so a CI gate expecting a signed-by-vendor artifact fails.
|
|
1107
|
+
accepted = false;
|
|
1108
|
+
reason = "unsigned_cannot_pin_vendor";
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
const verdict = accepted ? "OK" : "REJECTED";
|
|
1112
|
+
const code = accepted ? EXIT.OK : EXIT.REJECTED;
|
|
1113
|
+
|
|
1114
|
+
const result = {
|
|
1115
|
+
artifact,
|
|
1116
|
+
kind,
|
|
1117
|
+
payloadKind,
|
|
1118
|
+
signed,
|
|
1119
|
+
verdict,
|
|
1120
|
+
reason,
|
|
1121
|
+
accepted,
|
|
1122
|
+
recoveredSigner,
|
|
1123
|
+
claimedSigner,
|
|
1124
|
+
pinnedVendor: pinned,
|
|
1125
|
+
signatureOk,
|
|
1126
|
+
signerMatchesVendor,
|
|
1127
|
+
sealedRoot: fileResult.sealedRoot,
|
|
1128
|
+
recomputedRoot: fileResult.recomputedRoot,
|
|
1129
|
+
rootMatches: fileResult.rootMatches,
|
|
1130
|
+
counts: {
|
|
1131
|
+
matched: fileResult.matched.length,
|
|
1132
|
+
changed: fileResult.changed.length,
|
|
1133
|
+
missing: fileResult.missing.length,
|
|
1134
|
+
escaped: escaped.length,
|
|
1135
|
+
unexpected: fileResult.unexpected.length,
|
|
1136
|
+
},
|
|
1137
|
+
matched: fileResult.matched,
|
|
1138
|
+
changed: fileResult.changed,
|
|
1139
|
+
missing: fileResult.missing,
|
|
1140
|
+
escaped,
|
|
1141
|
+
unexpected: fileResult.unexpected,
|
|
1142
|
+
note: TRUST_NOTE,
|
|
1143
|
+
};
|
|
1144
|
+
if (fileResult.identityOnly) result.identityOnly = true;
|
|
1145
|
+
if (fileResult.proof) result.proof = fileResult.proof;
|
|
1146
|
+
|
|
1147
|
+
return { result, code };
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// ---------------------------------------------------------------------------
|
|
1151
|
+
// The PURE revocation fold for the bytes path. Semantically identical to revocation.loadAndApply (the
|
|
1152
|
+
// disk integration) once the entries are in hand: resolve the as-of instant (defaulting to nowISO),
|
|
1153
|
+
// normalize the caller-supplied revocations input (a JSON string, a container object, or an array of
|
|
1154
|
+
// either), fold the decision onto the result, and recompute the exit code. Uses ONLY the pure decision
|
|
1155
|
+
// functions (./lib/revocation-core.js via the revocation re-exports) — never the fs-backed reader.
|
|
1156
|
+
// ---------------------------------------------------------------------------
|
|
1157
|
+
|
|
1158
|
+
function applyRevocationsDecision(result, revocationsInput, asOf, nowISO) {
|
|
1159
|
+
const resolved = revocation.resolveAsOf(asOf, nowISO);
|
|
1160
|
+
const entries = revocation.normalizeRevocationsInput(revocationsInput);
|
|
1161
|
+
const downgraded = revocation.applyToVerifyResult({ result, revocations: entries, asOf: resolved.asOf });
|
|
1162
|
+
downgraded.trustAsOfDefaulted = resolved.defaulted;
|
|
1163
|
+
return { result: downgraded, code: downgraded.accepted ? EXIT.OK : EXIT.REJECTED };
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// ---------------------------------------------------------------------------
|
|
1167
|
+
// THE IN-MEMORY FILE SOURCE + BYTES ENTRYPOINT (T-66.1).
|
|
1168
|
+
//
|
|
1169
|
+
// `verifyArtifactFromBytes({ artifactText, files, vendor, revocationsText, asOf, nowISO, artifactName })`
|
|
1170
|
+
// drives the EXACT engine above over caller-supplied bytes:
|
|
1171
|
+
// * `artifactText` — the artifact JSON as a STRING (what a browser read out of a dropped file);
|
|
1172
|
+
// * `files` — a plain `{ relPath: Uint8Array|Buffer }` map of the packet's referenced bytes;
|
|
1173
|
+
// * `vendor` — optional 0x-address pin (same semantics as `--vendor`);
|
|
1174
|
+
// * `revocationsText` — optional revocations input (JSON text / container / array; same semantics as
|
|
1175
|
+
// the CONTENT of a `--revocations` file), with optional `asOf` (canonical ISO instant) + `nowISO`;
|
|
1176
|
+
// * `artifactName` — optional label used verbatim as `result.artifact` (defaults below).
|
|
1177
|
+
//
|
|
1178
|
+
// CONTRACT — NEVER THROWS. Hostile input (non-JSON artifact text, an oversized / absolute / `..` map
|
|
1179
|
+
// key, a non-bytes map value, a malformed vendor or asOf) is NAMED-rejected: the return value is
|
|
1180
|
+
// { ok, code, result, error }
|
|
1181
|
+
// where a computed verdict carries `result` (the SAME structured shape `verifyArtifact` returns — the
|
|
1182
|
+
// two are DEEP-EQUAL on identical inputs) + `error: null`, and an input problem carries `result: null` +
|
|
1183
|
+
// `error: { name: "UsageError"|"IOError", code, message }` with the exact defect named. The verdict
|
|
1184
|
+
// classes (missing / extra / content-mismatch / wrong-vendor / tampered-signature / path_escape /
|
|
1185
|
+
// revoked) derive from the MAP exactly as the disk path derives them from the directory.
|
|
1186
|
+
// ---------------------------------------------------------------------------
|
|
1187
|
+
|
|
1188
|
+
// The largest relPath key the in-memory map accepts. Sealed relPaths are short; a multi-kilobyte "key"
|
|
1189
|
+
// is hostile input (an attempted resource-exhaustion / log-flooding vector), rejected by NAME up front.
|
|
1190
|
+
const MAX_RELPATH_CHARS = 4096;
|
|
1191
|
+
|
|
1192
|
+
// PURE string-level confinement for an in-memory relPath — the map-source mirror of the disk source's
|
|
1193
|
+
// string checks (absolute anywhere, or any `..` traversal component, is hostile). Windows-style drive
|
|
1194
|
+
// and UNC prefixes are treated as absolute here too: an in-memory map NEVER has a legitimate absolute
|
|
1195
|
+
// key, whatever platform authored the artifact.
|
|
1196
|
+
function isTraversalOrAbsoluteRelPath(relPath) {
|
|
1197
|
+
if (typeof relPath !== "string" || relPath.length === 0) return true;
|
|
1198
|
+
if (relPath.charAt(0) === "/" || relPath.charAt(0) === "\\") return true;
|
|
1199
|
+
if (/^[A-Za-z]:[\\/]/.test(relPath)) return true;
|
|
1200
|
+
if (relPath.split(/[\\/]/).includes("..")) return true;
|
|
1201
|
+
return false;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// Validate the caller's `{ relPath: bytes }` map SHAPE up front so a hostile map is NAMED-rejected
|
|
1205
|
+
// before any verification work (and before any key is dereferenced). Throws UsageError; the entrypoint
|
|
1206
|
+
// converts that into the structured `{ error }` return — never an uncaught throw.
|
|
1207
|
+
function validateFilesMap(files) {
|
|
1208
|
+
if (files == null || typeof files !== "object" || Array.isArray(files)) {
|
|
1209
|
+
throw new UsageError(
|
|
1210
|
+
"verifyArtifactFromBytes requires `files` as a plain { relPath: Uint8Array|Buffer } object map"
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
for (const key of Object.keys(files)) {
|
|
1214
|
+
if (key.length === 0) {
|
|
1215
|
+
throw new UsageError("files map contains an empty relPath key");
|
|
1216
|
+
}
|
|
1217
|
+
if (key.length > MAX_RELPATH_CHARS) {
|
|
1218
|
+
throw new UsageError(
|
|
1219
|
+
`files map key exceeds ${MAX_RELPATH_CHARS} characters (oversized relPath, starts: ` +
|
|
1220
|
+
`${JSON.stringify(key.slice(0, 64))})`
|
|
1221
|
+
);
|
|
1222
|
+
}
|
|
1223
|
+
if (isTraversalOrAbsoluteRelPath(key)) {
|
|
1224
|
+
throw new UsageError(
|
|
1225
|
+
`files map key is not a confined relative path: ${JSON.stringify(key.slice(0, 256))}`
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
const v = files[key];
|
|
1229
|
+
if (!(v instanceof Uint8Array)) {
|
|
1230
|
+
throw new UsageError(
|
|
1231
|
+
`files map value for ${JSON.stringify(key.slice(0, 256))} must be a Uint8Array/Buffer of the file's bytes`
|
|
1232
|
+
);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// The in-memory `readEntry` source over an (already-validated) map: a hostile relPath from the ARTIFACT
|
|
1238
|
+
// is `escaped` (the same string-level rules as the disk source — so absolute/`..` seal entries produce
|
|
1239
|
+
// the identical path_escape verdict), an absent key is `missing`, and a present key answers its bytes.
|
|
1240
|
+
// Lookups use an own-property check so `__proto__`/`constructor` style keys can never smuggle
|
|
1241
|
+
// prototype-chain values in as file bytes.
|
|
1242
|
+
function makeMapReadEntry(files) {
|
|
1243
|
+
return function readEntry(relPath) {
|
|
1244
|
+
if (isTraversalOrAbsoluteRelPath(relPath)) return { status: "escaped" };
|
|
1245
|
+
if (!Object.prototype.hasOwnProperty.call(files, relPath)) return { status: "missing" };
|
|
1246
|
+
return { status: "ok", bytes: files[relPath] };
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function verifyArtifactFromBytes(params) {
|
|
1251
|
+
try {
|
|
1252
|
+
if (params == null || typeof params !== "object" || Array.isArray(params)) {
|
|
1253
|
+
throw new UsageError(
|
|
1254
|
+
"verifyArtifactFromBytes requires a params object: " +
|
|
1255
|
+
"{ artifactText, files, vendor?, revocationsText?, asOf?, nowISO?, artifactName? }"
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1258
|
+
const { artifactText, files, vendor, revocationsText, asOf, nowISO, artifactName } = params;
|
|
1259
|
+
if (typeof artifactText !== "string") {
|
|
1260
|
+
throw new UsageError("verifyArtifactFromBytes requires `artifactText` (the artifact JSON as a string)");
|
|
1261
|
+
}
|
|
1262
|
+
validateFilesMap(files);
|
|
1263
|
+
|
|
1264
|
+
// Mirror the CLI's flag-shape gate (parseArgs): asOf only means something alongside revocations, and
|
|
1265
|
+
// must be a canonical ISO-8601 UTC instant — a malformed one is a NAMED usage rejection up front,
|
|
1266
|
+
// never a mid-verify throw.
|
|
1267
|
+
if (asOf !== undefined && asOf !== null && (revocationsText === undefined || revocationsText === null)) {
|
|
1268
|
+
throw new UsageError(
|
|
1269
|
+
"asOf requires revocationsText (it pins the instant the revocation decision is made AS OF)"
|
|
1270
|
+
);
|
|
1271
|
+
}
|
|
1272
|
+
if (asOf !== undefined && asOf !== null) {
|
|
1273
|
+
const ms = Date.parse(asOf);
|
|
1274
|
+
if (
|
|
1275
|
+
typeof asOf !== "string" ||
|
|
1276
|
+
!revocation.ISO_INSTANT_RE.test(asOf) ||
|
|
1277
|
+
Number.isNaN(ms) ||
|
|
1278
|
+
new Date(ms).toISOString() !== asOf
|
|
1279
|
+
) {
|
|
1280
|
+
throw new UsageError(
|
|
1281
|
+
`invalid asOf: ${String(asOf)} (expected a canonical ISO-8601 UTC instant, e.g. 2026-06-01T00:00:00.000Z)`
|
|
1282
|
+
);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
const label = artifactName != null ? String(artifactName) : "(in-memory artifact)";
|
|
1287
|
+
let obj;
|
|
1288
|
+
try {
|
|
1289
|
+
obj = JSON.parse(artifactText);
|
|
1290
|
+
} catch (e) {
|
|
1291
|
+
throw new IOError(`artifact ${label} is not valid JSON: ${e.message}`);
|
|
1292
|
+
}
|
|
1293
|
+
if (obj == null || typeof obj !== "object" || Array.isArray(obj)) {
|
|
1294
|
+
throw new IOError(`artifact ${label} must be a JSON object`);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
const { result, code } = verifyParsedArtifact({
|
|
1298
|
+
artifact: label,
|
|
1299
|
+
obj,
|
|
1300
|
+
vendor,
|
|
1301
|
+
readEntry: makeMapReadEntry(files),
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
// OPTIONAL recipient-side TRUST-DECISION-AS-OF, from caller-supplied revocations INPUT (never a
|
|
1305
|
+
// filesystem read). Same downgrade math as the disk path's revocation.loadAndApply, so the two
|
|
1306
|
+
// paths' results stay deep-equal on identical inputs.
|
|
1307
|
+
if (revocationsText !== undefined && revocationsText !== null) {
|
|
1308
|
+
let applied;
|
|
1309
|
+
try {
|
|
1310
|
+
applied = applyRevocationsDecision(result, revocationsText, asOf, nowISO || new Date().toISOString());
|
|
1311
|
+
} catch (e) {
|
|
1312
|
+
// A non-JSON / wrong-shape revocations input is the bytes-path analogue of an unreadable
|
|
1313
|
+
// --revocations file: a NAMED IO-class rejection, never a silently-skipped downgrade.
|
|
1314
|
+
throw new IOError(`cannot evaluate revocations: ${e.message}`);
|
|
1315
|
+
}
|
|
1316
|
+
return { ok: applied.result.accepted, code: applied.code, result: applied.result, error: null };
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
return { ok: result.accepted, code, result, error: null };
|
|
1320
|
+
} catch (e) {
|
|
1321
|
+
const isUsage = e instanceof UsageError;
|
|
1322
|
+
const code = isUsage ? EXIT.USAGE : EXIT.IO;
|
|
1323
|
+
return {
|
|
1324
|
+
ok: false,
|
|
1325
|
+
code,
|
|
1326
|
+
result: null,
|
|
1327
|
+
error: {
|
|
1328
|
+
name: isUsage ? "UsageError" : "IOError",
|
|
1329
|
+
code,
|
|
1330
|
+
message: String(e && e.message ? e.message : e),
|
|
1331
|
+
},
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// ============================= END VERIFY-VH PURE ENGINE (T-66.1) =============================
|
|
1337
|
+
|
|
1338
|
+
// ---------------------------------------------------------------------------
|
|
1339
|
+
// Argument parsing.
|
|
1340
|
+
// SINGLE-ARTIFACT (the original, byte-for-byte unchanged contract):
|
|
1341
|
+
// verify-vh <artifact> [--vendor <0xaddr>] [--dir <d>] [--json]
|
|
1342
|
+
// BATCH/MANIFEST (T-33.1 — one invocation gates EVERY release artifact, one CI exit code):
|
|
1343
|
+
// verify-vh <artifact> <artifact> ... [--vendor <0xaddr>] [--dir <d>] [--json]
|
|
1344
|
+
// verify-vh --manifest <file> [--vendor <0xaddr>] [--dir <d>] [--json]
|
|
1345
|
+
// Batch mode is a pure SUPERSET: it engages ONLY when more than one positional <artifact> is given OR
|
|
1346
|
+
// `--manifest <file>` is supplied. A lone positional with no --manifest takes the identical single path,
|
|
1347
|
+
// so existing callers/tests never shift. A top-level `--vendor`/`--dir` is a DEFAULT each entry inherits
|
|
1348
|
+
// unless the entry (a manifest line) overrides it with its own per-entry `--vendor`/`--dir`.
|
|
1349
|
+
// ---------------------------------------------------------------------------
|
|
1350
|
+
|
|
1351
|
+
function parseArgs(argv) {
|
|
1352
|
+
const opts = {
|
|
1353
|
+
artifact: undefined,
|
|
1354
|
+
vendor: undefined,
|
|
1355
|
+
dir: undefined,
|
|
1356
|
+
json: false,
|
|
1357
|
+
help: false,
|
|
1358
|
+
manifest: undefined,
|
|
1359
|
+
revocations: undefined,
|
|
1360
|
+
asOf: undefined,
|
|
1361
|
+
_pos: [],
|
|
1362
|
+
};
|
|
1363
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1364
|
+
const a = argv[i];
|
|
1365
|
+
const need = (flag) => {
|
|
1366
|
+
const v = argv[++i];
|
|
1367
|
+
if (v === undefined) throw new UsageError(`${flag} requires a value`);
|
|
1368
|
+
return v;
|
|
1369
|
+
};
|
|
1370
|
+
switch (a) {
|
|
1371
|
+
case "--vendor":
|
|
1372
|
+
opts.vendor = need("--vendor");
|
|
1373
|
+
break;
|
|
1374
|
+
case "--dir":
|
|
1375
|
+
opts.dir = need("--dir");
|
|
1376
|
+
break;
|
|
1377
|
+
case "--manifest":
|
|
1378
|
+
opts.manifest = need("--manifest");
|
|
1379
|
+
break;
|
|
1380
|
+
case "--revocations":
|
|
1381
|
+
opts.revocations = need("--revocations");
|
|
1382
|
+
break;
|
|
1383
|
+
case "--as-of":
|
|
1384
|
+
opts.asOf = need("--as-of");
|
|
1385
|
+
break;
|
|
1386
|
+
case "--json":
|
|
1387
|
+
opts.json = true;
|
|
1388
|
+
break;
|
|
1389
|
+
case "-h":
|
|
1390
|
+
case "--help":
|
|
1391
|
+
case "help":
|
|
1392
|
+
opts.help = true;
|
|
1393
|
+
break;
|
|
1394
|
+
default:
|
|
1395
|
+
if (a && a.startsWith("--")) throw new UsageError(`unknown flag: ${a}`);
|
|
1396
|
+
opts._pos.push(a);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
// batch === any path that aggregates MULTIPLE per-artifact verdicts under ONE exit code:
|
|
1400
|
+
// either a --manifest file, or more than one repeated positional <artifact>.
|
|
1401
|
+
opts.batch = opts.manifest !== undefined || opts._pos.length > 1;
|
|
1402
|
+
if (opts.manifest !== undefined && opts._pos.length > 0) {
|
|
1403
|
+
throw new UsageError(
|
|
1404
|
+
`--manifest <file> lists the artifacts; do not also pass positional <artifact> args (got: ${opts._pos[0]})`
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
1407
|
+
// Validate the OPTIONAL recipient-side trust-decision flags (--revocations / --as-of, T-51.4) SHAPE up
|
|
1408
|
+
// front so a malformed --as-of (or --as-of without --revocations) is a usage error (2), never a runtime
|
|
1409
|
+
// throw mid-verify. Mirrors `vh evidence verify-signed`'s validateAsOfFlags so the two stacks reject the
|
|
1410
|
+
// same inputs the same way.
|
|
1411
|
+
if (opts.asOf !== undefined && !opts.revocations) {
|
|
1412
|
+
throw new UsageError(
|
|
1413
|
+
"--as-of requires --revocations (it pins the instant the revocation decision is made AS OF)"
|
|
1414
|
+
);
|
|
1415
|
+
}
|
|
1416
|
+
if (opts.asOf !== undefined) {
|
|
1417
|
+
const ms = Date.parse(opts.asOf);
|
|
1418
|
+
if (
|
|
1419
|
+
typeof opts.asOf !== "string" ||
|
|
1420
|
+
!revocation.ISO_INSTANT_RE.test(opts.asOf) ||
|
|
1421
|
+
Number.isNaN(ms) ||
|
|
1422
|
+
new Date(ms).toISOString() !== opts.asOf
|
|
1423
|
+
) {
|
|
1424
|
+
throw new UsageError(
|
|
1425
|
+
`invalid --as-of: ${opts.asOf} (expected a canonical ISO-8601 UTC instant, e.g. 2026-06-01T00:00:00.000Z)`
|
|
1426
|
+
);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
// Preserve the SINGLE-artifact contract verbatim: exactly one positional and no --manifest.
|
|
1430
|
+
opts.artifact = opts._pos[0];
|
|
1431
|
+
return opts;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// ---------------------------------------------------------------------------
|
|
1435
|
+
// Manifest parsing. A manifest is a newline list OR a JSON array of artifact entries; each entry names an
|
|
1436
|
+
// artifact path and may carry a per-entry `--vendor`/`--dir` that overrides the top-level defaults.
|
|
1437
|
+
//
|
|
1438
|
+
// NEWLINE form — one entry per line, shell-style tokens. Blank lines and `#` comments are skipped:
|
|
1439
|
+
// releases/a.vhevidence.json
|
|
1440
|
+
// releases/b.vhseal --vendor 0xabc... --dir ./out
|
|
1441
|
+
// JSON form — an array of strings and/or objects:
|
|
1442
|
+
// ["a.vhevidence.json", {"artifact":"b.vhseal","vendor":"0xabc...","dir":"./out"}]
|
|
1443
|
+
//
|
|
1444
|
+
// Paths in the manifest resolve relative to the MANIFEST FILE's own directory (a release ships its
|
|
1445
|
+
// manifest next to its artifacts), unless the path is given a per-entry `--dir` for its SIBLINGS — note
|
|
1446
|
+
// `dir` localizes where an artifact's SIBLING files are read, exactly as the single-artifact `--dir` does;
|
|
1447
|
+
// the artifact path itself resolves against the manifest dir. The manifest is parsed in-process; NO new
|
|
1448
|
+
// crypto and NO network — it is a list, nothing more.
|
|
1449
|
+
// ---------------------------------------------------------------------------
|
|
1450
|
+
|
|
1451
|
+
// Minimal whitespace tokenizer for a newline-form manifest line. No quoting support is needed (artifact
|
|
1452
|
+
// paths and 0x addresses contain no spaces); a token is any run of non-whitespace.
|
|
1453
|
+
function tokenizeManifestLine(line) {
|
|
1454
|
+
return line.split(/\s+/).filter((t) => t.length > 0);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
function parseManifestLine(line, lineNo) {
|
|
1458
|
+
const toks = tokenizeManifestLine(line);
|
|
1459
|
+
const entry = { artifact: undefined, vendor: undefined, dir: undefined };
|
|
1460
|
+
for (let i = 0; i < toks.length; i++) {
|
|
1461
|
+
const t = toks[i];
|
|
1462
|
+
const need = (flag) => {
|
|
1463
|
+
const v = toks[++i];
|
|
1464
|
+
if (v === undefined) throw new UsageError(`manifest line ${lineNo}: ${flag} requires a value`);
|
|
1465
|
+
return v;
|
|
1466
|
+
};
|
|
1467
|
+
if (t === "--vendor") entry.vendor = need("--vendor");
|
|
1468
|
+
else if (t === "--dir") entry.dir = need("--dir");
|
|
1469
|
+
else if (t.startsWith("--")) throw new UsageError(`manifest line ${lineNo}: unknown flag: ${t}`);
|
|
1470
|
+
else if (entry.artifact === undefined) entry.artifact = t;
|
|
1471
|
+
else throw new UsageError(`manifest line ${lineNo}: unexpected extra token: ${t}`);
|
|
1472
|
+
}
|
|
1473
|
+
if (entry.artifact === undefined) {
|
|
1474
|
+
throw new UsageError(`manifest line ${lineNo}: no artifact path`);
|
|
1475
|
+
}
|
|
1476
|
+
return entry;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
function parseManifest(text, manifestPath) {
|
|
1480
|
+
const trimmed = text.replace(/^/, "").trim();
|
|
1481
|
+
const entries = [];
|
|
1482
|
+
if (trimmed.startsWith("[")) {
|
|
1483
|
+
// JSON array form.
|
|
1484
|
+
let arr;
|
|
1485
|
+
try {
|
|
1486
|
+
arr = JSON.parse(trimmed);
|
|
1487
|
+
} catch (e) {
|
|
1488
|
+
throw new IOError(`manifest ${manifestPath} is not valid JSON: ${e.message}`);
|
|
1489
|
+
}
|
|
1490
|
+
if (!Array.isArray(arr)) throw new IOError(`manifest ${manifestPath} JSON must be an array of entries`);
|
|
1491
|
+
arr.forEach((raw, idx) => {
|
|
1492
|
+
if (typeof raw === "string") {
|
|
1493
|
+
entries.push({ artifact: raw, vendor: undefined, dir: undefined });
|
|
1494
|
+
} else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
1495
|
+
if (typeof raw.artifact !== "string" || raw.artifact.length === 0) {
|
|
1496
|
+
throw new IOError(`manifest ${manifestPath} entry ${idx}: "artifact" must be a non-empty string`);
|
|
1497
|
+
}
|
|
1498
|
+
entries.push({
|
|
1499
|
+
artifact: raw.artifact,
|
|
1500
|
+
vendor: raw.vendor != null ? String(raw.vendor) : undefined,
|
|
1501
|
+
dir: raw.dir != null ? String(raw.dir) : undefined,
|
|
1502
|
+
});
|
|
1503
|
+
} else {
|
|
1504
|
+
throw new IOError(`manifest ${manifestPath} entry ${idx} must be a string or { artifact, vendor?, dir? }`);
|
|
1505
|
+
}
|
|
1506
|
+
});
|
|
1507
|
+
} else {
|
|
1508
|
+
// Newline form: one entry per non-blank, non-comment line.
|
|
1509
|
+
const lines = trimmed.split(/\r?\n/);
|
|
1510
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1511
|
+
const line = lines[i];
|
|
1512
|
+
const bare = line.trim();
|
|
1513
|
+
if (bare.length === 0 || bare.startsWith("#")) continue;
|
|
1514
|
+
entries.push(parseManifestLine(line, i + 1));
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
if (entries.length === 0) {
|
|
1518
|
+
throw new UsageError(`manifest ${manifestPath} lists no artifacts`);
|
|
1519
|
+
}
|
|
1520
|
+
return entries;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
// ---------------------------------------------------------------------------
|
|
1524
|
+
// THE DISK FILE SOURCE — the CLI's `readEntry` implementation, carrying the FULL path-confinement
|
|
1525
|
+
// discipline the disk path always had (byte-identical classification):
|
|
1526
|
+
// (1) string-level confinement, BEFORE any filesystem access: an ABSOLUTE relPath, or any relPath with
|
|
1527
|
+
// a `..` path COMPONENT, is REJECTED unread;
|
|
1528
|
+
// (2) resolved-path confinement: a resolved path that ESCAPES baseDir (string-wise, against the
|
|
1529
|
+
// realpath of baseDir) is REJECTED;
|
|
1530
|
+
// (3) post-open symlink confinement: after opening a present file we realpath it and re-assert
|
|
1531
|
+
// containment, defeating a sibling that is a SYMLINK pointing out of baseDir (fs.readFileSync
|
|
1532
|
+
// follows symlinks regardless of the string check) — the just-read bytes are DROPPED, never hashed.
|
|
1533
|
+
// ---------------------------------------------------------------------------
|
|
1534
|
+
|
|
1535
|
+
// True when a resolved absolute path escapes the (already realpath'd) base directory. A path equal to the
|
|
1536
|
+
// base or under it does not escape; anything that path.relative()'s to "" / ".." / an absolute drive is out.
|
|
1537
|
+
function escapesBase(baseReal, abs) {
|
|
1538
|
+
const rel = path.relative(baseReal, abs);
|
|
1539
|
+
return rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel);
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
function makeDiskReadEntry(baseDir) {
|
|
1543
|
+
// Anchor confinement on the REALPATH of baseDir so a symlinked baseDir itself (e.g. /tmp -> /private/tmp
|
|
1544
|
+
// on macOS) does not spuriously trip the containment check on otherwise-legitimate siblings.
|
|
1545
|
+
let baseReal;
|
|
1546
|
+
try {
|
|
1547
|
+
baseReal = fs.realpathSync(baseDir);
|
|
1548
|
+
} catch (_) {
|
|
1549
|
+
baseReal = path.resolve(baseDir);
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
return function readEntry(relPath) {
|
|
1553
|
+
// (1) String-level confinement, BEFORE any filesystem access.
|
|
1554
|
+
if (
|
|
1555
|
+
typeof relPath !== "string" ||
|
|
1556
|
+
relPath.length === 0 ||
|
|
1557
|
+
path.isAbsolute(relPath) ||
|
|
1558
|
+
relPath.split(/[\\/]/).includes("..")
|
|
1559
|
+
) {
|
|
1560
|
+
return { status: "escaped" };
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// (2) Resolved-path confinement: the resolved absolute path must stay under baseReal.
|
|
1564
|
+
const abs = path.resolve(baseDir, relPath);
|
|
1565
|
+
if (escapesBase(baseReal, abs)) {
|
|
1566
|
+
return { status: "escaped" };
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
let bytes;
|
|
1570
|
+
try {
|
|
1571
|
+
bytes = fs.readFileSync(abs);
|
|
1572
|
+
} catch (_) {
|
|
1573
|
+
return { status: "missing" };
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// (3) Post-open symlink confinement.
|
|
1577
|
+
let real;
|
|
1578
|
+
try {
|
|
1579
|
+
real = fs.realpathSync(abs);
|
|
1580
|
+
} catch (_) {
|
|
1581
|
+
real = abs;
|
|
1582
|
+
}
|
|
1583
|
+
if (escapesBase(baseReal, real)) {
|
|
1584
|
+
return { status: "escaped" };
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
return { status: "ok", bytes };
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
// The original disk-shaped helpers, kept with their exact signatures + behavior (thin wrappers over the
|
|
1592
|
+
// engine with a disk source). `relResolver` was always accepted-and-unused on classifyFiles; retained so
|
|
1593
|
+
// the signature does not shift.
|
|
1594
|
+
function classifyFiles(sealedEntries, baseDir, relResolver) { // eslint-disable-line no-unused-vars
|
|
1595
|
+
return classifyFilesWith(sealedEntries, makeDiskReadEntry(baseDir));
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
function verifyEvidenceSeal(seal, baseDir) {
|
|
1599
|
+
return verifyEvidenceSealWith(seal, makeDiskReadEntry(baseDir));
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
function verifyTrustSeal(seal, baseDir) {
|
|
1603
|
+
return verifyTrustSealWith(seal, makeDiskReadEntry(baseDir));
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// ---------------------------------------------------------------------------
|
|
1607
|
+
// The DISK verify entrypoint — the original CLI contract, byte-identical: reads + JSON-parses the
|
|
1608
|
+
// artifact, then drives the SAME pure engine with the disk file source. Returns { result, code }.
|
|
1609
|
+
// ---------------------------------------------------------------------------
|
|
1610
|
+
|
|
1611
|
+
function verifyArtifact(opts) {
|
|
1612
|
+
if (!opts.artifact) throw new UsageError("verify-vh requires an <artifact>");
|
|
1613
|
+
|
|
1614
|
+
const artifactPath = path.resolve(opts.artifact);
|
|
1615
|
+
let text;
|
|
1616
|
+
try {
|
|
1617
|
+
text = fs.readFileSync(artifactPath, "utf8");
|
|
1618
|
+
} catch (e) {
|
|
1619
|
+
throw new IOError(`cannot read artifact ${opts.artifact}: ${e.message}`);
|
|
1620
|
+
}
|
|
1621
|
+
let obj;
|
|
1622
|
+
try {
|
|
1623
|
+
obj = JSON.parse(text);
|
|
1624
|
+
} catch (e) {
|
|
1625
|
+
throw new IOError(`artifact ${opts.artifact} is not valid JSON: ${e.message}`);
|
|
1626
|
+
}
|
|
1627
|
+
if (obj == null || typeof obj !== "object" || Array.isArray(obj)) {
|
|
1628
|
+
throw new IOError(`artifact ${opts.artifact} must be a JSON object`);
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
// The base directory siblings resolve against: --dir override else the artifact's own directory.
|
|
1632
|
+
const baseDir = opts.dir != null ? path.resolve(opts.dir) : path.dirname(artifactPath);
|
|
1633
|
+
|
|
1634
|
+
const { result, code } = verifyParsedArtifact({
|
|
1635
|
+
artifact: opts.artifact,
|
|
1636
|
+
obj,
|
|
1637
|
+
vendor: opts.vendor,
|
|
1638
|
+
readEntry: makeDiskReadEntry(baseDir),
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
// OPTIONAL recipient-side TRUST-DECISION-AS-OF (EPIC-51 / T-51.4). Runs ONLY under --revocations — with no
|
|
1642
|
+
// flag the result + code are byte-identical to the pre-T-51.4 baseline (regression-pinned). A signer
|
|
1643
|
+
// revoked-before-as-of downgrades an otherwise-ACCEPTED artifact to REVOKED (exit 3); a later-dated
|
|
1644
|
+
// revocation is informational; a forged/tampered/third-party one is ignored with a warning. OFFLINE /
|
|
1645
|
+
// key-free on the read side; the revocations file/dir is the ONLY new I/O. This reaches the SAME downgrade
|
|
1646
|
+
// `vh ... verify-signed --revocations` does, byte-for-byte on identical inputs.
|
|
1647
|
+
if (opts.revocations) {
|
|
1648
|
+
let applied;
|
|
1649
|
+
try {
|
|
1650
|
+
applied = revocation.loadAndApply({
|
|
1651
|
+
result,
|
|
1652
|
+
revocationsPath: opts.revocations,
|
|
1653
|
+
asOf: opts.asOf,
|
|
1654
|
+
nowISO: opts.nowISO || new Date().toISOString(),
|
|
1655
|
+
});
|
|
1656
|
+
} catch (e) {
|
|
1657
|
+
// A malformed --as-of is caught at parse time; here the only failures are an unreadable path or a
|
|
1658
|
+
// non-JSON single revocations file — a genuine IO error (exit 1), surfaced (never a stack), never a
|
|
1659
|
+
// silently-skipped downgrade.
|
|
1660
|
+
throw new IOError(`cannot evaluate --revocations ${opts.revocations}: ${e.message}`);
|
|
1661
|
+
}
|
|
1662
|
+
// A REVOKED decision flips an otherwise-ACCEPTED verdict to REVOKED (exit 3); an already-REJECTED verdict
|
|
1663
|
+
// is left rejected (the trust-as-of never upgrades). The trustAsOf block + defaulted flag ride along for
|
|
1664
|
+
// the renderer.
|
|
1665
|
+
const downgraded = applied.result;
|
|
1666
|
+
downgraded.trustAsOfDefaulted = applied.defaulted;
|
|
1667
|
+
const newCode = downgraded.accepted ? EXIT.OK : EXIT.REJECTED;
|
|
1668
|
+
return { result: downgraded, code: newCode };
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
return { result, code };
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
// ---------------------------------------------------------------------------
|
|
1675
|
+
// BATCH / MANIFEST orchestration (T-33.1). One invocation gates EVERY artifact a release produces and
|
|
1676
|
+
// returns ONE CI exit code. Each entry is verified READ-ONLY through the SAME `verifyArtifact` core (NO
|
|
1677
|
+
// new crypto, NO new artifact kind, path-escape/no-network guarantees preserved per entry); the per-entry
|
|
1678
|
+
// `--json` body is the IDENTICAL single-artifact shape, so there is no divergence to drift.
|
|
1679
|
+
//
|
|
1680
|
+
// AGGREGATE EXIT CONTRACT:
|
|
1681
|
+
// * exit 0 (OK) — and only if — EVERY artifact verifies (each accepted).
|
|
1682
|
+
// * exit 3 (REJECTED) — if ANY artifact is rejected (CHANGED/MISSING/bad_signature/wrong_issuer/…);
|
|
1683
|
+
// the report names WHICH artifact failed and why.
|
|
1684
|
+
// * exit 2 (USAGE) — a malformed flag / per-entry --vendor (raised before any verify runs).
|
|
1685
|
+
// * exit 1 (IO) — an artifact (or the manifest itself) is unreadable / not the expected shape.
|
|
1686
|
+
// Usage/IO are evaluated PER ENTRY and SHORT-CIRCUIT the whole run with the matching code, exactly as the
|
|
1687
|
+
// single-artifact path does — a release gate must not "pass" while one of its artifacts could not even be
|
|
1688
|
+
// read or parsed. The IO/USAGE code wins over a REJECTED tally (you cannot certify a batch you could not
|
|
1689
|
+
// fully evaluate).
|
|
1690
|
+
// ---------------------------------------------------------------------------
|
|
1691
|
+
|
|
1692
|
+
function buildBatchEntries(opts) {
|
|
1693
|
+
// Returns [{ artifact, vendor, dir }] with top-level --vendor/--dir applied as DEFAULTS each entry may
|
|
1694
|
+
// override. Artifact paths from a manifest resolve against the manifest file's own directory.
|
|
1695
|
+
if (opts.manifest !== undefined) {
|
|
1696
|
+
const manifestPath = path.resolve(opts.manifest);
|
|
1697
|
+
let text;
|
|
1698
|
+
try {
|
|
1699
|
+
text = fs.readFileSync(manifestPath, "utf8");
|
|
1700
|
+
} catch (e) {
|
|
1701
|
+
throw new IOError(`cannot read manifest ${opts.manifest}: ${e.message}`);
|
|
1702
|
+
}
|
|
1703
|
+
const manifestDir = path.dirname(manifestPath);
|
|
1704
|
+
return parseManifest(text, opts.manifest).map((e) => ({
|
|
1705
|
+
// The artifact path resolves relative to the manifest's directory (a release ships them together).
|
|
1706
|
+
artifact: path.resolve(manifestDir, e.artifact),
|
|
1707
|
+
// Per-entry --vendor/--dir override the top-level defaults; a --dir resolves against the manifest dir.
|
|
1708
|
+
vendor: e.vendor != null ? e.vendor : opts.vendor,
|
|
1709
|
+
dir: e.dir != null ? path.resolve(manifestDir, e.dir) : opts.dir,
|
|
1710
|
+
}));
|
|
1711
|
+
}
|
|
1712
|
+
// Repeated positional <artifact> args: each inherits the (single) top-level --vendor/--dir.
|
|
1713
|
+
return opts._pos.map((a) => ({ artifact: a, vendor: opts.vendor, dir: opts.dir }));
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
function verifyBatch(opts) {
|
|
1717
|
+
const entries = buildBatchEntries(opts);
|
|
1718
|
+
const results = [];
|
|
1719
|
+
for (const e of entries) {
|
|
1720
|
+
// Verify each entry through the SAME core. A USAGE/IO problem with any single entry short-circuits the
|
|
1721
|
+
// whole batch with that code (the gate cannot certify a release it could not fully evaluate). The
|
|
1722
|
+
// top-level --revocations/--as-of (T-51.4) apply to EVERY entry as a default, so one revocations
|
|
1723
|
+
// file/dir gates a whole release's signed artifacts under one as-of instant.
|
|
1724
|
+
const { result } = verifyArtifact({
|
|
1725
|
+
artifact: e.artifact,
|
|
1726
|
+
vendor: e.vendor,
|
|
1727
|
+
dir: e.dir,
|
|
1728
|
+
revocations: opts.revocations,
|
|
1729
|
+
asOf: opts.asOf,
|
|
1730
|
+
nowISO: opts.nowISO,
|
|
1731
|
+
});
|
|
1732
|
+
results.push(result);
|
|
1733
|
+
}
|
|
1734
|
+
const total = results.length;
|
|
1735
|
+
const passed = results.filter((r) => r.accepted).length;
|
|
1736
|
+
const failed = total - passed;
|
|
1737
|
+
const ok = failed === 0;
|
|
1738
|
+
const aggregate = { ok, total, passed, failed, results };
|
|
1739
|
+
return { aggregate, code: ok ? EXIT.OK : EXIT.REJECTED };
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
// ---------------------------------------------------------------------------
|
|
1743
|
+
// Human + JSON rendering.
|
|
1744
|
+
// ---------------------------------------------------------------------------
|
|
1745
|
+
|
|
1746
|
+
function renderHuman(r) {
|
|
1747
|
+
const L = [];
|
|
1748
|
+
L.push(TRUST_NOTE);
|
|
1749
|
+
L.push("");
|
|
1750
|
+
L.push(`# verify-vh — ${r.artifact}`);
|
|
1751
|
+
L.push(`kind: ${r.kind}`);
|
|
1752
|
+
if (r.payloadKind !== r.kind) L.push(`embedded kind: ${r.payloadKind}`);
|
|
1753
|
+
L.push(`signed: ${r.signed ? "yes" : "no"}`);
|
|
1754
|
+
if (r.signed) {
|
|
1755
|
+
L.push(`recovered signer:${r.recoveredSigner ? " " + r.recoveredSigner : " (unrecoverable)"}`);
|
|
1756
|
+
L.push(`claimed signer: ${r.claimedSigner}`);
|
|
1757
|
+
if (r.pinnedVendor != null) {
|
|
1758
|
+
L.push(`pinned --vendor: ${r.pinnedVendor}`);
|
|
1759
|
+
L.push(`signer matches vendor: ${r.signerMatchesVendor ? "yes" : "NO"}`);
|
|
1760
|
+
} else {
|
|
1761
|
+
L.push("(no --vendor pin: the recovered signer above is reported, not pinned)");
|
|
1762
|
+
}
|
|
1763
|
+
} else if (r.recoveredSigner == null && r.pinnedVendor != null) {
|
|
1764
|
+
L.push("note: --vendor was supplied but this artifact is UNSIGNED (no signer to pin)");
|
|
1765
|
+
}
|
|
1766
|
+
if (r.sealedRoot != null) L.push(`sealed root: ${r.sealedRoot}`);
|
|
1767
|
+
if (r.recomputedRoot != null) L.push(`recomputed root: ${r.recomputedRoot}`);
|
|
1768
|
+
if (r.rootMatches != null) L.push(`root matches: ${r.rootMatches ? "yes" : "NO"}`);
|
|
1769
|
+
if (r.identityOnly) {
|
|
1770
|
+
L.push("(identity-only artifact: it commits to a dataset root/digest, not a re-walkable file set)");
|
|
1771
|
+
}
|
|
1772
|
+
L.push(
|
|
1773
|
+
`files: ${r.counts.matched} matched, ${r.counts.changed} changed, ` +
|
|
1774
|
+
`${r.counts.missing} missing, ${r.counts.escaped || 0} rejected, ${r.counts.unexpected} unexpected`
|
|
1775
|
+
);
|
|
1776
|
+
// AGENT-SESSION packet block (T-68.3) — present ONLY for r.agent results, so every other kind's
|
|
1777
|
+
// output stays byte-identical.
|
|
1778
|
+
if (r.agent) {
|
|
1779
|
+
L.push(`declared head: { size: ${r.agent.head.size}, root: ${r.agent.head.root} }`);
|
|
1780
|
+
if (r.agent.counts) {
|
|
1781
|
+
L.push(
|
|
1782
|
+
`events: ${r.agent.counts.events} (${r.agent.counts.full} full, ${r.agent.counts.redacted} redacted)`
|
|
1783
|
+
);
|
|
1784
|
+
L.push(
|
|
1785
|
+
`withheld seqs: ${r.agent.withheld.length === 0 ? "(none — every payload disclosed)" : r.agent.withheld.join(", ")}`
|
|
1786
|
+
);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
// OPTIONAL recipient-side TRUST-DECISION-AS-OF block (T-51.4) — printed ONLY when --revocations was
|
|
1790
|
+
// supplied (r.trustAsOf is attached then). With no flag this block is absent, so the output is byte-
|
|
1791
|
+
// identical to the pre-T-51.4 baseline. The block reads the SAME way the producer's verify-signed does.
|
|
1792
|
+
if (r.trustAsOf) {
|
|
1793
|
+
L.push("");
|
|
1794
|
+
for (const line of revocation.renderTrustAsOf(r.trustAsOf, { defaulted: r.trustAsOfDefaulted })) {
|
|
1795
|
+
L.push(line);
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
L.push("");
|
|
1799
|
+
if (r.accepted) {
|
|
1800
|
+
L.push("OK — the artifact verifies.");
|
|
1801
|
+
} else if (r.reason === "key_revoked_as_of") {
|
|
1802
|
+
// The signature + bytes checked out, but the signing key was revoked AT OR BEFORE the as-of instant — a
|
|
1803
|
+
// distinct REVOKED verdict (exit 3), matching the producer's verify-signed downgrade.
|
|
1804
|
+
const g = r.trustAsOf && r.trustAsOf.governing;
|
|
1805
|
+
L.push("REVOKED (key_revoked_as_of):");
|
|
1806
|
+
if (g) {
|
|
1807
|
+
L.push(
|
|
1808
|
+
` key_revoked_as_of: the signing key (${g.vendorAddress}) was REVOKED as of ${g.revokedAt} ` +
|
|
1809
|
+
`(reason: ${g.reason})${g.supersededBy ? `, superseded by ${g.supersededBy}` : ""} — at or before ` +
|
|
1810
|
+
`the as-of instant. The bytes + signature check out, but the key was no longer trustworthy then.`
|
|
1811
|
+
);
|
|
1812
|
+
}
|
|
1813
|
+
} else {
|
|
1814
|
+
L.push(`REJECTED (${r.reason}):`);
|
|
1815
|
+
for (const c of r.changed) {
|
|
1816
|
+
L.push(` CHANGED ${c.relPath}: sealed ${c.expectedContentHash} != on-disk ${c.actualContentHash}`);
|
|
1817
|
+
}
|
|
1818
|
+
for (const m of r.missing) {
|
|
1819
|
+
L.push(` MISSING ${m.relPath}: referenced but not found on disk`);
|
|
1820
|
+
}
|
|
1821
|
+
for (const x of r.escaped || []) {
|
|
1822
|
+
// SECURITY: print the attacker's relPath string ONLY — never a content hash of the out-of-tree target.
|
|
1823
|
+
L.push(` REJECTED ${x.relPath}: path escapes the artifact directory (refused to read; no hash computed)`);
|
|
1824
|
+
}
|
|
1825
|
+
for (const u of r.unexpected) {
|
|
1826
|
+
L.push(` UNEXPECTED ${u.relPath}: on disk but not referenced`);
|
|
1827
|
+
}
|
|
1828
|
+
if (r.reason === "bad_signature") {
|
|
1829
|
+
L.push(" bad_signature: the signature does not recover to the claimed signer (tampered or forged).");
|
|
1830
|
+
}
|
|
1831
|
+
if (r.reason === "wrong_issuer") {
|
|
1832
|
+
L.push(
|
|
1833
|
+
` wrong_issuer: recovered ${r.recoveredSigner} but you pinned --vendor ${r.pinnedVendor}.`
|
|
1834
|
+
);
|
|
1835
|
+
}
|
|
1836
|
+
if (r.reason === "unsigned_cannot_pin_vendor") {
|
|
1837
|
+
L.push(" --vendor was pinned but the artifact carries no signature to recover a signer from.");
|
|
1838
|
+
}
|
|
1839
|
+
if (r.reason === "root_mismatch") {
|
|
1840
|
+
L.push(" root_mismatch: the recomputed root does not equal the sealed root.");
|
|
1841
|
+
}
|
|
1842
|
+
if (r.reason === "path_escape") {
|
|
1843
|
+
L.push(
|
|
1844
|
+
" path_escape: the artifact references a file OUTSIDE its own directory (absolute path, `..` " +
|
|
1845
|
+
"traversal, or an out-of-tree symlink). A genuine artifact never does this; refused to read it."
|
|
1846
|
+
);
|
|
1847
|
+
}
|
|
1848
|
+
// AGENT-SESSION packet reject details (T-68.3): name the first offending event seq + the named fault.
|
|
1849
|
+
if (r.agent) {
|
|
1850
|
+
if (r.agent.seq !== null && r.agent.seq !== undefined) {
|
|
1851
|
+
L.push(` first offending event seq: ${r.agent.seq}${r.agent.reason ? ` (${r.agent.reason})` : ""}`);
|
|
1852
|
+
}
|
|
1853
|
+
if (r.reason === "event_invalid") {
|
|
1854
|
+
L.push(
|
|
1855
|
+
` event_invalid: an event failed strict canonical validation` +
|
|
1856
|
+
`${r.agent.field ? ` (field: ${r.agent.field})` : ""} — the packet cannot be trusted.`
|
|
1857
|
+
);
|
|
1858
|
+
}
|
|
1859
|
+
if (r.reason === "counts_mismatch") {
|
|
1860
|
+
L.push(" counts_mismatch: the packet's declared full/redacted counts do not match a recount.");
|
|
1861
|
+
}
|
|
1862
|
+
if (r.reason === "head_not_bound") {
|
|
1863
|
+
L.push(
|
|
1864
|
+
" head_not_bound: the headAttestation signs a DIFFERENT { size, root } than this packet's " +
|
|
1865
|
+
"events derive — the signature belongs to another session."
|
|
1866
|
+
);
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
L.push("");
|
|
1871
|
+
return L.join("\n");
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
// Human rendering of a batch aggregate: a per-artifact PASS/FAIL line (FAIL names the reason), then the
|
|
1875
|
+
// one-line roll-up + the final verdict. The trust note is printed ONCE at the top.
|
|
1876
|
+
function renderBatchHuman(agg) {
|
|
1877
|
+
const L = [];
|
|
1878
|
+
L.push(TRUST_NOTE);
|
|
1879
|
+
L.push("");
|
|
1880
|
+
L.push(`# verify-vh — BATCH (${agg.total} artifact${agg.total === 1 ? "" : "s"})`);
|
|
1881
|
+
for (const r of agg.results) {
|
|
1882
|
+
if (r.accepted) {
|
|
1883
|
+
L.push(` PASS ${r.artifact}`);
|
|
1884
|
+
} else {
|
|
1885
|
+
L.push(` FAIL ${r.artifact} (${r.reason})`);
|
|
1886
|
+
// Localize the first failing detail so a CI log names exactly what moved, per artifact.
|
|
1887
|
+
for (const c of r.changed) {
|
|
1888
|
+
L.push(` CHANGED ${c.relPath}: sealed ${c.expectedContentHash} != on-disk ${c.actualContentHash}`);
|
|
1889
|
+
}
|
|
1890
|
+
for (const m of r.missing) {
|
|
1891
|
+
L.push(` MISSING ${m.relPath}`);
|
|
1892
|
+
}
|
|
1893
|
+
for (const x of r.escaped || []) {
|
|
1894
|
+
L.push(` REJECTED ${x.relPath}: path escapes the artifact directory (no hash computed)`);
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
L.push("");
|
|
1899
|
+
L.push(`total: ${agg.total}, passed: ${agg.passed}, failed: ${agg.failed}`);
|
|
1900
|
+
L.push(agg.ok ? "OK — every artifact verifies." : `REJECTED — ${agg.failed} artifact(s) failed.`);
|
|
1901
|
+
L.push("");
|
|
1902
|
+
return L.join("\n");
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
// ---------------------------------------------------------------------------
|
|
1906
|
+
// `demo` — the ZERO-CONFIG, zero-flag, zero-key-knowledge quickstart (T-55.2).
|
|
1907
|
+
//
|
|
1908
|
+
// WHY THIS EXISTS
|
|
1909
|
+
// A cold prospect should be able to go from NOTHING to a VERIFIED packet in one command — `verify-vh demo`
|
|
1910
|
+
// (or `npx … demo`) — with NO flags, NO `--vendor` to paste, and NO key knowledge. The whole sales promise
|
|
1911
|
+
// ("don't trust us — verify it yourself, offline") is unfalsifiable until they have RUN the tool once and
|
|
1912
|
+
// watched it ACCEPT a genuine packet, name the signer, then REJECT a one-byte-tampered copy. `demo` IS that
|
|
1913
|
+
// first run: it ships a tiny, self-contained, GENUINELY-SIGNED evidence packet baked into this file, plays
|
|
1914
|
+
// it through the EXACT same `verifyArtifact` core every real verify uses, and prints the honest verdict.
|
|
1915
|
+
//
|
|
1916
|
+
// HOW IT STAYS HONEST (no special-case verify path)
|
|
1917
|
+
// The fixture below is a REAL `vh.evidence-seal-signed` container: a keccak Merkle seal over two referenced
|
|
1918
|
+
// files, signed with a FIXED, well-known TEST-ONLY key (NEVER a real key, NEVER real funds — its address is
|
|
1919
|
+
// the standard hardhat account #1, published precisely so no one mistakes it for a production signer). The
|
|
1920
|
+
// signature was produced once with the family's real EIP-191 personal-sign path; the demo RECOVERS it with
|
|
1921
|
+
// the SAME vendored secp256k1 recovery a real verify uses, so the signer address printed is genuinely
|
|
1922
|
+
// recovered from the bytes — not echoed. `demo` materializes the packet + its two files into a throwaway
|
|
1923
|
+
// temp dir, runs the real `verifyArtifact` twice (genuine -> ACCEPT pinned to the recovered signer; a
|
|
1924
|
+
// one-byte-tampered copy -> REJECT/CHANGED), then deletes the temp dir. It writes NOTHING under cwd.
|
|
1925
|
+
// ---------------------------------------------------------------------------
|
|
1926
|
+
|
|
1927
|
+
// The fixed TEST-ONLY signer (hardhat account #1). Published so it can NEVER be confused with a real key.
|
|
1928
|
+
const DEMO_SIGNER = "0x70997970c51812dc3a010c7d01b50e0d17dc79c8";
|
|
1929
|
+
|
|
1930
|
+
// The two referenced files the demo seal commits to, by relPath -> exact UTF-8 content.
|
|
1931
|
+
const DEMO_FILES = Object.freeze({
|
|
1932
|
+
"model-card.md": "# Demo model card\nThis file is sealed by the verify-vh demo.\n",
|
|
1933
|
+
"weights.txt": "0.10 0.20 0.30\n",
|
|
1934
|
+
});
|
|
1935
|
+
|
|
1936
|
+
// The GENUINELY-SIGNED evidence container. `attestation` is the EXACT bytes the signature is over (the same
|
|
1937
|
+
// plain serialization the producer's evidence path emits for the embedded seal); the signature is a real
|
|
1938
|
+
// 65-byte EIP-191 personal-sign over those bytes by DEMO_SIGNER. Re-derived from DEMO_FILES (a build-time
|
|
1939
|
+
// check would re-seal the same bytes), so the root binds the real file content above.
|
|
1940
|
+
const DEMO_CONTAINER = Object.freeze({
|
|
1941
|
+
kind: "vh.evidence-seal-signed",
|
|
1942
|
+
attestation:
|
|
1943
|
+
'{"kind":"vh.evidence-seal","files":[{"relPath":"model-card.md","contentHash":"0x1aeca0ad922f53e9c30186234c5d1a62ffda62a828988bdd266fa93240675db0","leaf":"0xbbb3052a7359188aed3f114e15b721cf5d707a8bdf09109d1d51ec5765b3c58c"},{"relPath":"weights.txt","contentHash":"0x7716d380e062d1daf7ca58897b55f6b58900ed4fd1eda79445956c5c3d336cdf","leaf":"0x34ce488c6fb49a32d356a2553196dc817a439c13a03ce9a2a2ff2710fcf9eea2"}],"root":"0x621a5eb924a9887f88d4b05ccdf19834cdae2f4ed2399921acc7b8a45d48da9b"}',
|
|
1944
|
+
signature: {
|
|
1945
|
+
scheme: "eip191-personal-sign",
|
|
1946
|
+
signer: DEMO_SIGNER,
|
|
1947
|
+
signature:
|
|
1948
|
+
"0x1aabba1530df192e87498bbf1a26f63a7e30d84d72c14bf5d08b2d872df9810b672efcf26f30ec6a38a00ffc158be53633daeff9e99f344b6c1a2e99522d61a01b",
|
|
1949
|
+
},
|
|
1950
|
+
});
|
|
1951
|
+
|
|
1952
|
+
// The packet filename the demo materializes (shared by the throwaway-temp round-trip and the `demo <dir>`
|
|
1953
|
+
// keepable scaffold) so the "NEXT" command the demo prints names the file it actually wrote.
|
|
1954
|
+
const DEMO_PACKET_NAME = "demo-packet.vhevidence.json";
|
|
1955
|
+
|
|
1956
|
+
// ---------------------------------------------------------------------------
|
|
1957
|
+
// The DEMO AGENT-SESSION packet (T-68.3): a small, GENUINE `vh.agent-session-packet` produced by the
|
|
1958
|
+
// REAL `vh agent seal` + `vh agent redact` path (never re-authored by hand) — a 4-event session
|
|
1959
|
+
// (prompt -> tool_call -> tool_result -> completion) whose tool_call payload (seq 1) is REDACTED
|
|
1960
|
+
// behind its hash commitment, so the fixture demonstrates the load-bearing property: a redacted
|
|
1961
|
+
// packet STILL VERIFIES (identical leaves + root). UNSIGNED — the whole agent verify surface is the
|
|
1962
|
+
// FREE funnel leg. The standalone HTML page inlines these constants verbatim (next to DEMO_FILES /
|
|
1963
|
+
// DEMO_CONTAINER above) for its built-in agent demo: click -> ACCEPT; tamper ONE byte of a payload in
|
|
1964
|
+
// the page -> REJECT naming event seq DEMO_AGENT_TAMPER_SEQ. The TAMPER_FROM/TO pair is a one-byte
|
|
1965
|
+
// substring edit that occurs EXACTLY once in the packet text (pinned by test/verifier.agent.test.js).
|
|
1966
|
+
// ---------------------------------------------------------------------------
|
|
1967
|
+
const DEMO_AGENT_PACKET_NAME = "demo-session.vhagent.json";
|
|
1968
|
+
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";
|
|
1969
|
+
const DEMO_AGENT_TAMPER_SEQ = 0;
|
|
1970
|
+
const DEMO_AGENT_TAMPER_FROM = "\"payload\":\"Summarize the vendor contract";
|
|
1971
|
+
const DEMO_AGENT_TAMPER_TO = "\"payload\":\"SUMMARIZE the vendor contract";
|
|
1972
|
+
|
|
1973
|
+
// Materialize the demo packet + its referenced files into `dir`. Returns the packet path.
|
|
1974
|
+
function writeDemoFixture(dir) {
|
|
1975
|
+
for (const [rel, content] of Object.entries(DEMO_FILES)) {
|
|
1976
|
+
fs.writeFileSync(path.join(dir, rel), content);
|
|
1977
|
+
}
|
|
1978
|
+
const packetPath = path.join(dir, DEMO_PACKET_NAME);
|
|
1979
|
+
fs.writeFileSync(packetPath, JSON.stringify(DEMO_CONTAINER, null, 2));
|
|
1980
|
+
return packetPath;
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
// Run the zero-config demo: seal -> ACCEPT (pinned to the recovered signer) -> tamper -> REJECT. Uses the
|
|
1984
|
+
// REAL verifyArtifact core for BOTH runs (no bespoke verify path), so the verdicts are exactly what a real
|
|
1985
|
+
// counterparty would see. Returns the EXIT-contract code (0 only when the whole demo behaved as designed).
|
|
1986
|
+
function runDemo(write, writeErr) {
|
|
1987
|
+
// A throwaway temp dir so the demo needs no input and writes NOTHING under cwd. Cleaned in finally.
|
|
1988
|
+
let tmp;
|
|
1989
|
+
try {
|
|
1990
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "verify-vh-demo-"));
|
|
1991
|
+
} catch (e) {
|
|
1992
|
+
writeErr(`error: demo could not create a temp working dir: ${e.message}\n`);
|
|
1993
|
+
return EXIT.IO;
|
|
1994
|
+
}
|
|
1995
|
+
try {
|
|
1996
|
+
const packetPath = writeDemoFixture(tmp);
|
|
1997
|
+
|
|
1998
|
+
const L = [];
|
|
1999
|
+
L.push(TRUST_NOTE);
|
|
2000
|
+
L.push("");
|
|
2001
|
+
L.push("# verify-vh demo — a self-contained, GENUINELY-SIGNED packet, verified OFFLINE with zero config.");
|
|
2002
|
+
L.push("# (No flags, no key to paste: the demo ships a real signed seal + its files and checks them for you.)");
|
|
2003
|
+
L.push(`# Working dir (throwaway, deleted on exit): ${tmp}`);
|
|
2004
|
+
L.push("");
|
|
2005
|
+
|
|
2006
|
+
// (1) GENUINE packet: recover the signer first, then PIN it (so the demo proves both recovery AND the
|
|
2007
|
+
// vendor-pin path) — exactly what a real counterparty does once they learn the producer's address.
|
|
2008
|
+
const recovered = tryRecover(DEMO_CONTAINER.attestation, DEMO_CONTAINER.signature.signature);
|
|
2009
|
+
if (recovered !== DEMO_SIGNER) {
|
|
2010
|
+
writeErr(
|
|
2011
|
+
`error: demo fixture is corrupt — embedded signature recovered ${String(recovered)} != ${DEMO_SIGNER}\n`
|
|
2012
|
+
);
|
|
2013
|
+
return EXIT.IO;
|
|
2014
|
+
}
|
|
2015
|
+
L.push("STEP 1 — verify the genuine packet (signer recovered from the bytes, then pinned):");
|
|
2016
|
+
const good = verifyArtifact({ artifact: packetPath, vendor: recovered, dir: tmp });
|
|
2017
|
+
if (!good.result.accepted || good.code !== EXIT.OK) {
|
|
2018
|
+
// Should never happen for the shipped fixture; treat as an internal fault, not a silent pass.
|
|
2019
|
+
writeErr(`error: demo genuine packet did NOT verify (reason: ${good.result.reason})\n`);
|
|
2020
|
+
write(renderHuman(good.result));
|
|
2021
|
+
return EXIT.IO;
|
|
2022
|
+
}
|
|
2023
|
+
L.push(` ACCEPT — the artifact verifies. signer: ${good.result.recoveredSigner}`);
|
|
2024
|
+
L.push(` sealed root: ${good.result.sealedRoot}`);
|
|
2025
|
+
L.push(` recomputed root: ${good.result.recomputedRoot} (re-derived from the bytes on disk)`);
|
|
2026
|
+
L.push(` files: ${good.result.counts.matched} matched, 0 changed, 0 missing.`);
|
|
2027
|
+
L.push("");
|
|
2028
|
+
|
|
2029
|
+
// (2) TAMPER one byte of a referenced file, re-verify the SAME packet -> a clean REJECT naming the file.
|
|
2030
|
+
const victim = path.join(tmp, "model-card.md");
|
|
2031
|
+
fs.writeFileSync(victim, DEMO_FILES["model-card.md"] + "X"); // one extra byte
|
|
2032
|
+
L.push("STEP 2 — tamper ONE byte of a referenced file, then re-verify the SAME packet:");
|
|
2033
|
+
const bad = verifyArtifact({ artifact: packetPath, vendor: recovered, dir: tmp });
|
|
2034
|
+
if (bad.result.accepted || bad.code !== EXIT.REJECTED) {
|
|
2035
|
+
writeErr(`error: demo tampered packet was NOT rejected (reason: ${bad.result.reason})\n`);
|
|
2036
|
+
return EXIT.IO;
|
|
2037
|
+
}
|
|
2038
|
+
L.push(` REJECT (${bad.result.reason}) — the tampered copy is caught:`);
|
|
2039
|
+
for (const c of bad.result.changed) {
|
|
2040
|
+
L.push(` CHANGED ${c.relPath}: sealed ${c.expectedContentHash} != on-disk ${c.actualContentHash}`);
|
|
2041
|
+
}
|
|
2042
|
+
L.push("");
|
|
2043
|
+
|
|
2044
|
+
L.push("That is the whole promise: a genuine packet is ACCEPTED and its signer named, while a one-byte");
|
|
2045
|
+
L.push("change is REJECTED — re-derived from the bytes you hold, offline, with no producer stack.");
|
|
2046
|
+
L.push("");
|
|
2047
|
+
// The bare demo is a closed loop in a temp dir — gone the instant it exits. Hand the user the ONE command
|
|
2048
|
+
// that turns "I watched a demo" into "I have a real packet on disk I can poke at": `demo <dir>` writes the
|
|
2049
|
+
// same genuine packet somewhere they KEEP, with copy-paste verify/tamper/restore commands. That is the
|
|
2050
|
+
// working on-ramp from the canned proof to verifying their OWN bytes (where the paid `--sign` pull begins).
|
|
2051
|
+
// NOTE: we name the command literally (NOT process.argv[1]) so the bare-demo output is byte-identical
|
|
2052
|
+
// whether run in-process, as `node verify-vh.js`, or from the standalone bundle — the demo's own
|
|
2053
|
+
// determinism is a tested invariant (the standalone must byte-match the in-tree demo).
|
|
2054
|
+
L.push("TRY IT YOURSELF: keep a copy you can tamper with by hand —");
|
|
2055
|
+
L.push(" node verify-vh.js demo ./vh-demo # writes the same signed packet + files into ./vh-demo,");
|
|
2056
|
+
L.push(" # then prints the exact verify / tamper / restore commands.");
|
|
2057
|
+
L.push("");
|
|
2058
|
+
L.push("NEXT: run it on a REAL packet you were handed:");
|
|
2059
|
+
L.push(" node verify-vh.js <packet> --vendor 0xPRODUCER_ADDRESS (exit 0 = verifies; 3 = REJECTED)");
|
|
2060
|
+
L.push("");
|
|
2061
|
+
write(L.join("\n"));
|
|
2062
|
+
return EXIT.OK;
|
|
2063
|
+
} catch (e) {
|
|
2064
|
+
writeErr(`error: demo failed unexpectedly: ${e.message}\n`);
|
|
2065
|
+
return EXIT.IO;
|
|
2066
|
+
} finally {
|
|
2067
|
+
try {
|
|
2068
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
2069
|
+
} catch (_) {
|
|
2070
|
+
/* best-effort cleanup; the OS reaps temp dirs anyway */
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
// ---------------------------------------------------------------------------
|
|
2076
|
+
// `demo <dir>` — the KEEPABLE scaffold (T-55.2 rework). The bare `demo` proves the round-trip in a throwaway
|
|
2077
|
+
// temp dir and is GONE the instant it exits — a closed loop the prospect can WATCH but cannot TOUCH. That is
|
|
2078
|
+
// the funnel dead-end the review panel flagged: the demo's own "NEXT: run it on a REAL packet" is unactionable
|
|
2079
|
+
// because a brand-new user HAS no packet yet. `demo <dir>` closes that gap: it MATERIALIZES the same genuine
|
|
2080
|
+
// signed packet + its two referenced files into a directory the user names and KEEPS, then prints the exact,
|
|
2081
|
+
// copy-pasteable REAL commands to (a) verify it with the real (non-canned) verify path, (b) tamper one byte
|
|
2082
|
+
// and watch the real REJECT, and (c) restore and re-ACCEPT. The prospect's FIRST hands-on artifact is now one
|
|
2083
|
+
// they hold on disk and can poke at with the production code path — the working on-ramp from "watched a demo"
|
|
2084
|
+
// to "verified my own bytes", which is where the free→paid pull (sign YOUR OWN files: `vh evidence seal
|
|
2085
|
+
// --sign` / the `evidence_unlimited` upgrade) actually begins.
|
|
2086
|
+
//
|
|
2087
|
+
// It is a PURE SUPERSET of the flagless quickstart: it engages ONLY when a single <dir> token follows `demo`
|
|
2088
|
+
// (`verify-vh demo` with no token stays the byte-identical throwaway round-trip above). It WRITES — by design,
|
|
2089
|
+
// into the dir the user explicitly named — so it is never reached by the bare flagless path the "writes
|
|
2090
|
+
// nothing under cwd" contract pins. The packet it writes is byte-identical to the round-trip's, signed by the
|
|
2091
|
+
// same fixed TEST-ONLY key (hardhat #1 — never a real key / real funds).
|
|
2092
|
+
// ---------------------------------------------------------------------------
|
|
2093
|
+
|
|
2094
|
+
function runDemoEmit(targetDir, write, writeErr) {
|
|
2095
|
+
// Confirm the shipped fixture is internally sound BEFORE writing anything (recover the signer from the
|
|
2096
|
+
// embedded bytes, exactly as a real verify does) — a corrupt fixture is an internal fault, not a scaffold.
|
|
2097
|
+
const recovered = tryRecover(DEMO_CONTAINER.attestation, DEMO_CONTAINER.signature.signature);
|
|
2098
|
+
if (recovered !== DEMO_SIGNER) {
|
|
2099
|
+
writeErr(
|
|
2100
|
+
`error: demo fixture is corrupt — embedded signature recovered ${String(recovered)} != ${DEMO_SIGNER}\n`
|
|
2101
|
+
);
|
|
2102
|
+
return EXIT.IO;
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
const dir = path.resolve(targetDir);
|
|
2106
|
+
// mkdir -p the target. We create the user-named dir if absent; an existing dir is fine (we only add files).
|
|
2107
|
+
try {
|
|
2108
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
2109
|
+
} catch (e) {
|
|
2110
|
+
writeErr(`error: demo could not create ${targetDir}: ${e.message}\n`);
|
|
2111
|
+
return EXIT.IO;
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
let packetPath;
|
|
2115
|
+
try {
|
|
2116
|
+
packetPath = writeDemoFixture(dir);
|
|
2117
|
+
} catch (e) {
|
|
2118
|
+
writeErr(`error: demo could not write the scaffold into ${targetDir}: ${e.message}\n`);
|
|
2119
|
+
return EXIT.IO;
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
// Verify the just-written packet through the REAL core (no canned path), so the scaffold is proven good on
|
|
2123
|
+
// disk before we tell the user to trust it — and so the ACCEPT line the user will reproduce is the truth.
|
|
2124
|
+
const good = verifyArtifact({ artifact: packetPath, vendor: recovered, dir });
|
|
2125
|
+
if (!good.result.accepted || good.code !== EXIT.OK) {
|
|
2126
|
+
writeErr(`error: demo scaffold did NOT verify after writing (reason: ${good.result.reason})\n`);
|
|
2127
|
+
return EXIT.IO;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
// The command name as the user invoked us (verify-vh.js in-tree, verify-vh-standalone.js as the bundle), so
|
|
2131
|
+
// the copy-paste commands below name the EXACT file they ran — not a guessed path.
|
|
2132
|
+
// Name the command the user actually ran (verify-vh.js in-tree, verify-vh-standalone.js as the bundle) so the
|
|
2133
|
+
// copy-paste lines below name the EXACT file they invoked. If argv[1] is not one of our scripts (e.g. running
|
|
2134
|
+
// in-process under a test harness), fall back to the canonical name rather than printing the harness binary.
|
|
2135
|
+
const argv1 = path.basename(process.argv[1] || "");
|
|
2136
|
+
const self = /verify-vh/.test(argv1) ? argv1 : "verify-vh.js";
|
|
2137
|
+
// Print a path that is copy-pasteable from the user's CURRENT shell: the relative path when the target sits
|
|
2138
|
+
// at/under cwd (the common `demo ./vh-demo` case -> a tidy `vh-demo/...`), else the absolute path (a `../../`
|
|
2139
|
+
// chain to a far-off dir is unreadable and brittle — the absolute path always resolves).
|
|
2140
|
+
const rel = (p) => {
|
|
2141
|
+
const r = path.relative(process.cwd(), p);
|
|
2142
|
+
return r && !r.startsWith("..") && !path.isAbsolute(r) ? r : p;
|
|
2143
|
+
};
|
|
2144
|
+
const pkt = rel(packetPath);
|
|
2145
|
+
const card = rel(path.join(dir, "model-card.md"));
|
|
2146
|
+
|
|
2147
|
+
const L = [];
|
|
2148
|
+
L.push(TRUST_NOTE);
|
|
2149
|
+
L.push("");
|
|
2150
|
+
L.push(`# verify-vh demo — wrote a real, KEEPABLE signed packet you can verify yourself, hands-on.`);
|
|
2151
|
+
L.push(`# Signed by a fixed TEST-ONLY key (hardhat #1 — never a real key / real funds).`);
|
|
2152
|
+
L.push("");
|
|
2153
|
+
L.push(`Wrote into ${dir}:`);
|
|
2154
|
+
L.push(` ${DEMO_PACKET_NAME} (a genuinely-signed evidence packet)`);
|
|
2155
|
+
for (const r of Object.keys(DEMO_FILES)) L.push(` ${r}`);
|
|
2156
|
+
L.push(` signer (recovered from the bytes): ${recovered}`);
|
|
2157
|
+
L.push("");
|
|
2158
|
+
L.push("It already VERIFIES — run it yourself (the real verify path, no canned demo):");
|
|
2159
|
+
L.push(` node ${self} ${pkt} --vendor ${recovered}`);
|
|
2160
|
+
L.push(" # exit 0 = ACCEPT (root re-derived from YOUR bytes on disk; signer pinned).");
|
|
2161
|
+
L.push("");
|
|
2162
|
+
L.push("Now PROVE tamper-evidence with your own hands — change one byte, then re-verify:");
|
|
2163
|
+
L.push(` printf 'X' >> ${card}`);
|
|
2164
|
+
L.push(` node ${self} ${pkt} --vendor ${recovered} # exit 3 = REJECT (CHANGED ${path.basename(card)})`);
|
|
2165
|
+
L.push("");
|
|
2166
|
+
L.push("Restore it and watch it ACCEPT again (the change was the ONLY reason it rejected):");
|
|
2167
|
+
L.push(` node ${self} ${pkt} --vendor ${recovered} # after restoring the byte`);
|
|
2168
|
+
L.push("");
|
|
2169
|
+
L.push("NEXT — verify a packet someone handed YOU (same command, their address):");
|
|
2170
|
+
L.push(` node ${self} <their-packet> --vendor 0xTHEIR_ADDRESS`);
|
|
2171
|
+
L.push("");
|
|
2172
|
+
L.push("Want to SIGN your OWN files so a counterparty can pin YOU? That is the paid producer side:");
|
|
2173
|
+
L.push(" vh evidence seal <your-folder> --sign (an EIP-191 signer-pin; the `evidence_unlimited`");
|
|
2174
|
+
L.push(" upgrade lifts the free 25-file cap) — see verifier/README.md §0a.");
|
|
2175
|
+
L.push("");
|
|
2176
|
+
write(L.join("\n"));
|
|
2177
|
+
return EXIT.OK;
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
function usage() {
|
|
2181
|
+
return [
|
|
2182
|
+
"verify-vh — standalone, read-only, OFFLINE verifier for verifyhash artifacts",
|
|
2183
|
+
"",
|
|
2184
|
+
"Usage:",
|
|
2185
|
+
" verify-vh demo (zero-config quickstart)",
|
|
2186
|
+
" verify-vh demo <dir> (write a keepable signed packet you can verify yourself)",
|
|
2187
|
+
" verify-vh <artifact> [--vendor <0xaddr>] [--dir <d>] [--revocations <file-or-dir> [--as-of <ISO>]] [--json]",
|
|
2188
|
+
" verify-vh <artifact> <artifact> ... [--vendor <0xaddr>] [--dir <d>] [--revocations <file-or-dir>] [--json] (batch)",
|
|
2189
|
+
" verify-vh --manifest <file> [--vendor <0xaddr>] [--dir <d>] [--revocations <file-or-dir>] [--json] (batch)",
|
|
2190
|
+
"",
|
|
2191
|
+
"DEMO: `verify-vh demo` runs a self-contained, genuinely-signed packet through the real verify path —",
|
|
2192
|
+
"NO flags, NO key, NO install state: it ACCEPTs the packet (naming the signer), then REJECTs a one-byte-",
|
|
2193
|
+
"tampered copy. The single command that takes a brand-new user from nothing to a verified packet.",
|
|
2194
|
+
"`verify-vh demo <dir>` goes one step further: it WRITES that same genuine signed packet + its files into",
|
|
2195
|
+
"<dir> (which you keep) and prints copy-paste commands so you verify, tamper, and re-verify it by hand.",
|
|
2196
|
+
"",
|
|
2197
|
+
"Auto-detects the artifact kind (evidence seal, reconciliation seal, dataset attestation, proof",
|
|
2198
|
+
"bundle — bare or signed — or an agent-session packet *.vhagent.json), RE-DERIVES the keccak root",
|
|
2199
|
+
"from the referenced bytes (siblings resolve next to the artifact, or under --dir <d>), recovers",
|
|
2200
|
+
"the signer of a signed artifact, and PINS it to --vendor <0xaddr> (or reports the recovered signer",
|
|
2201
|
+
"when no pin is given). An agent-session packet is SELF-CONTAINED: every event leaf + the ordered",
|
|
2202
|
+
"RFC-6962-style head are re-derived from the events in the packet (REDACTED payloads are checked by",
|
|
2203
|
+
"their hash commitments), and a REJECT names the first offending event seq.",
|
|
2204
|
+
"",
|
|
2205
|
+
"REVOCATIONS: --revocations <file-or-dir> [--as-of <ISO>] downgrades an otherwise-ACCEPTED signed",
|
|
2206
|
+
"artifact to REVOKED (exit 3) when its signing key was REVOKED at or before --as-of (default now). The",
|
|
2207
|
+
"file may be one signed revocation or a JSON array; a directory is read as a flat pool of revocation",
|
|
2208
|
+
"files. A revocation dated AFTER --as-of stays ACCEPTED with a later-revoked note; a forged/tampered/",
|
|
2209
|
+
"third-party revocation is IGNORED with a warning. This reaches the SAME downgrade the producer's",
|
|
2210
|
+
"`vh ... verify-signed --revocations` does, OFFLINE — no producer stack, no network, no key.",
|
|
2211
|
+
"",
|
|
2212
|
+
"BATCH/MANIFEST: pass several <artifact> args, or --manifest <file> (a newline list or JSON array of",
|
|
2213
|
+
"artifact paths, each line/object may carry its own --vendor/--dir). ALL must pass for exit 0; if ANY",
|
|
2214
|
+
"is rejected, exit is 3 and the report names which artifact failed and why. --json emits a stable",
|
|
2215
|
+
"aggregate { ok, total, passed, failed, results:[...] } whose entries are the single-artifact shape.",
|
|
2216
|
+
"Top-level --vendor/--dir are inherited as defaults a manifest entry may override; --revocations/--as-of",
|
|
2217
|
+
"apply to every entry.",
|
|
2218
|
+
"",
|
|
2219
|
+
"READ-ONLY: holds no key, writes nothing. Exit: 0 ok / 3 rejected|revoked / 2 usage / 1 IO.",
|
|
2220
|
+
"",
|
|
2221
|
+
].join("\n");
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
// ---------------------------------------------------------------------------
|
|
2225
|
+
// run(argv, io) — the testable entrypoint. Returns the EXIT-contract integer. Injectable stdout/stderr.
|
|
2226
|
+
// ---------------------------------------------------------------------------
|
|
2227
|
+
|
|
2228
|
+
function run(argv, io = {}) {
|
|
2229
|
+
const write = io.write || ((s) => process.stdout.write(s));
|
|
2230
|
+
const writeErr = io.writeErr || ((s) => process.stderr.write(s));
|
|
2231
|
+
|
|
2232
|
+
let opts;
|
|
2233
|
+
try {
|
|
2234
|
+
opts = parseArgs(argv);
|
|
2235
|
+
} catch (e) {
|
|
2236
|
+
writeErr(`error: ${e.message}\n`);
|
|
2237
|
+
return EXIT.USAGE;
|
|
2238
|
+
}
|
|
2239
|
+
if (opts.help) {
|
|
2240
|
+
write(usage());
|
|
2241
|
+
return EXIT.OK;
|
|
2242
|
+
}
|
|
2243
|
+
// DEMO: the zero-config quickstart (T-55.2). `verify-vh demo` — a SINGLE bare positional `demo`, with NO
|
|
2244
|
+
// other args at all (no flags, no second positional, no manifest) — runs the self-contained signed packet
|
|
2245
|
+
// through the real verify path. We require the LONE argument to be exactly `demo` so the quickstart contract
|
|
2246
|
+
// is unambiguous: `demo` with any extra token falls through to the normal path (where it is a clean error),
|
|
2247
|
+
// never a silently-flag-ignoring run. It is a pure SUPERSET of the existing contract: `demo` was never a
|
|
2248
|
+
// valid artifact path before (there is no file named `demo`, so a lone `demo` was a clean IO error), so
|
|
2249
|
+
// intercepting it here shifts no existing caller.
|
|
2250
|
+
if (argv.length === 1 && opts.artifact === "demo") {
|
|
2251
|
+
return runDemo(write, writeErr);
|
|
2252
|
+
}
|
|
2253
|
+
// DEMO SCAFFOLD: `verify-vh demo <dir>` — a pure SUPERSET (T-55.2 rework). When `demo` is followed by exactly
|
|
2254
|
+
// ONE more bare token (a target directory) and NO flags, write the same genuine signed packet + its files
|
|
2255
|
+
// into that dir the user KEEPS, and print copy-paste verify/tamper/restore commands. This is the actionable
|
|
2256
|
+
// on-ramp the bare demo (a throwaway temp dir, gone on exit) cannot give. We require EXACTLY two bare
|
|
2257
|
+
// positionals and no flags so the contract stays unambiguous; `demo <dir> --anything` falls through to the
|
|
2258
|
+
// normal path (where a file literally named `demo` is a clean IO error, byte-identically to before).
|
|
2259
|
+
if (
|
|
2260
|
+
argv.length === 2 &&
|
|
2261
|
+
argv[0] === "demo" &&
|
|
2262
|
+
opts._pos.length === 2 &&
|
|
2263
|
+
opts._pos[0] === "demo" &&
|
|
2264
|
+
!opts.json &&
|
|
2265
|
+
opts.manifest === undefined &&
|
|
2266
|
+
opts.vendor === undefined &&
|
|
2267
|
+
opts.dir === undefined
|
|
2268
|
+
) {
|
|
2269
|
+
return runDemoEmit(opts._pos[1], write, writeErr);
|
|
2270
|
+
}
|
|
2271
|
+
// No artifact AND no manifest → the same usage error as before (the batch additions are a pure superset).
|
|
2272
|
+
if (opts.artifact === undefined && opts.manifest === undefined) {
|
|
2273
|
+
writeErr("error: verify-vh requires an <artifact>\n\n");
|
|
2274
|
+
writeErr(usage());
|
|
2275
|
+
return EXIT.USAGE;
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
// The recipient's current decision instant (the default --as-of). Injectable via io.nowISO so a test can
|
|
2279
|
+
// pin the clock; otherwise the wall clock. Threaded onto opts for the (optional) revocation evaluation.
|
|
2280
|
+
opts.nowISO = io.nowISO || new Date().toISOString();
|
|
2281
|
+
|
|
2282
|
+
// BATCH path: a --manifest file or more than one positional <artifact>. Aggregates per-artifact verdicts
|
|
2283
|
+
// under one CI exit code. The single-artifact path below is byte-for-byte the original behavior.
|
|
2284
|
+
if (opts.batch) {
|
|
2285
|
+
let out;
|
|
2286
|
+
try {
|
|
2287
|
+
out = verifyBatch(opts);
|
|
2288
|
+
} catch (e) {
|
|
2289
|
+
if (e instanceof UsageError) {
|
|
2290
|
+
writeErr(`error: ${e.message}\n`);
|
|
2291
|
+
return EXIT.USAGE;
|
|
2292
|
+
}
|
|
2293
|
+
if (e instanceof IOError) {
|
|
2294
|
+
writeErr(`error: ${e.message}\n`);
|
|
2295
|
+
return EXIT.IO;
|
|
2296
|
+
}
|
|
2297
|
+
writeErr(`error: ${e.message}\n`);
|
|
2298
|
+
return EXIT.IO;
|
|
2299
|
+
}
|
|
2300
|
+
if (opts.json) {
|
|
2301
|
+
write(JSON.stringify(out.aggregate, null, 2) + "\n");
|
|
2302
|
+
} else {
|
|
2303
|
+
write(renderBatchHuman(out.aggregate));
|
|
2304
|
+
}
|
|
2305
|
+
return out.code;
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
let out;
|
|
2309
|
+
try {
|
|
2310
|
+
out = verifyArtifact(opts);
|
|
2311
|
+
} catch (e) {
|
|
2312
|
+
if (e instanceof UsageError) {
|
|
2313
|
+
writeErr(`error: ${e.message}\n`);
|
|
2314
|
+
return EXIT.USAGE;
|
|
2315
|
+
}
|
|
2316
|
+
if (e instanceof IOError) {
|
|
2317
|
+
writeErr(`error: ${e.message}\n`);
|
|
2318
|
+
return EXIT.IO;
|
|
2319
|
+
}
|
|
2320
|
+
// Any other error is an unexpected internal fault — surface it as an IO error (never a stack to a
|
|
2321
|
+
// counterparty), exit 1.
|
|
2322
|
+
writeErr(`error: ${e.message}\n`);
|
|
2323
|
+
return EXIT.IO;
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
if (opts.json) {
|
|
2327
|
+
write(JSON.stringify(out.result, null, 2) + "\n");
|
|
2328
|
+
} else {
|
|
2329
|
+
write(renderHuman(out.result));
|
|
2330
|
+
}
|
|
2331
|
+
return out.code;
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
// CLI shim: only run when invoked directly (so the module is importable in tests without side effects).
|
|
2335
|
+
if (require.main === module) {
|
|
2336
|
+
process.exit(run(process.argv.slice(2)));
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
module.exports = {
|
|
2340
|
+
EXIT,
|
|
2341
|
+
KINDS,
|
|
2342
|
+
TRUST_NOTE,
|
|
2343
|
+
UsageError,
|
|
2344
|
+
IOError,
|
|
2345
|
+
parseArgs,
|
|
2346
|
+
parseManifest,
|
|
2347
|
+
verifyArtifact,
|
|
2348
|
+
verifyArtifactFromBytes,
|
|
2349
|
+
verifyBatch,
|
|
2350
|
+
buildBatchEntries,
|
|
2351
|
+
renderBatchHuman,
|
|
2352
|
+
verifyEvidenceSeal,
|
|
2353
|
+
verifyTrustSeal,
|
|
2354
|
+
verifyDatasetAttestation,
|
|
2355
|
+
verifyProofBundle,
|
|
2356
|
+
verifyAgentSeal,
|
|
2357
|
+
AGENT_TRUST_NOTE,
|
|
2358
|
+
renderHuman,
|
|
2359
|
+
revocation,
|
|
2360
|
+
usage,
|
|
2361
|
+
run,
|
|
2362
|
+
runDemo,
|
|
2363
|
+
runDemoEmit,
|
|
2364
|
+
DEMO_SIGNER,
|
|
2365
|
+
DEMO_FILES,
|
|
2366
|
+
DEMO_CONTAINER,
|
|
2367
|
+
DEMO_PACKET_NAME,
|
|
2368
|
+
DEMO_AGENT_PACKET_NAME,
|
|
2369
|
+
DEMO_AGENT_PACKET_TEXT,
|
|
2370
|
+
DEMO_AGENT_TAMPER_SEQ,
|
|
2371
|
+
DEMO_AGENT_TAMPER_FROM,
|
|
2372
|
+
DEMO_AGENT_TAMPER_TO,
|
|
2373
|
+
MAX_RELPATH_CHARS,
|
|
2374
|
+
};
|