verifyhash 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/cli/agent-hook.js +431 -0
- package/docs/ADOPT.md +15 -5
- package/docs/AGENT-HOOK.md +111 -0
- package/docs/ANCHORING.md +43 -22
- package/docs/PUBLISH-VERIFY-VH.md +45 -0
- package/examples/README.md +185 -0
- package/examples/policy.lenient.json +5 -0
- package/examples/policy.strict.json +6 -0
- package/examples/run.js +366 -0
- package/examples/sample-dataset/README.txt +10 -0
- package/examples/sample-dataset/corpus/cc-by-poem.txt +8 -0
- package/examples/sample-dataset/corpus/mit-notes.txt +4 -0
- package/examples/sample-dataset/data/unlabeled.txt +5 -0
- package/examples/sample-dataset/vendored/gpl-snippet.txt +5 -0
- package/examples/sample-dataset.hints.json +7 -0
- package/examples/sample-parcel/data/manifest-of-contents.txt +7 -0
- package/examples/sample-parcel/data/records.csv +4 -0
- package/examples/sample-parcel/delivery-note.txt +9 -0
- package/package.json +26 -3
- package/verifier/README.md +584 -0
- package/verifier/action/README.md +87 -0
- package/verifier/action/action.yml +146 -0
- package/verifier/build-standalone-html.js +1287 -0
- package/verifier/build-standalone.js +989 -0
- package/verifier/ci/journal.generic.sh +96 -0
- package/verifier/ci/journal.github-actions.yml +99 -0
- package/verifier/ci/reproduce-vh.generic.sh +59 -0
- package/verifier/ci/reproduce-vh.github-actions.yml +49 -0
- package/verifier/ci/verify-service.generic.sh +96 -0
- package/verifier/ci/verify-service.github-actions.yml +88 -0
- package/verifier/ci/verify-vh.generic.sh +75 -0
- package/verifier/ci/verify-vh.github-actions.yml +56 -0
- package/verifier/dist/BUILD-PROVENANCE.json +210 -0
- package/verifier/dist/seal-vh-standalone.js +876 -0
- package/verifier/dist/seal-vh-standalone.js.sha256 +1 -0
- package/verifier/dist/verify-vh-standalone.html +3373 -0
- package/verifier/dist/verify-vh-standalone.html.sha256 +1 -0
- package/verifier/dist/verify-vh-standalone.js +5123 -0
- package/verifier/dist/verify-vh-standalone.js.sha256 +1 -0
- package/verifier/lib/canonical.js +141 -0
- package/verifier/lib/keccak.js +30 -0
- package/verifier/lib/keccak256-vendored.js +206 -0
- package/verifier/lib/merkle.js +145 -0
- package/verifier/lib/revocation-core.js +606 -0
- package/verifier/lib/revocation.js +200 -0
- package/verifier/lib/seal-cli.js +374 -0
- package/verifier/lib/seal-evidence.js +237 -0
- package/verifier/lib/secp256k1-recover.js +249 -0
- package/verifier/package.json +39 -0
- package/verifier/verify-vh.js +3376 -0
- package/docs/ADOPTION.json +0 -11
- package/docs/AUDIT.md +0 -55
- package/docs/DECIDE.md +0 -47
- package/docs/DECISIONS-PENDING.md +0 -27
- package/docs/DEPLOY-PUBLIC-SITE.md +0 -301
- package/docs/ENGINE-LEDGER.json +0 -12
- package/docs/LOOP-AUDIT-2026-07-03.json +0 -580
- package/docs/LOOP-HARDENING-PLAN.md +0 -44
- package/docs/METRICS.jsonl +0 -31
- package/docs/MORNING.md +0 -204
- package/docs/STRATEGY-ARCHIVE.md +0 -5055
- package/docs/SUPERVISOR-RUNBOOK.md +0 -52
- package/docs/USAGE-BUDGET.json +0 -121
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// verifier/lib/revocation-core.js — the PURE (I/O-FREE, fs-FREE, path-FREE, clock-FREE) half of the
|
|
4
|
+
// stack-free recipient-side KEY-REVOCATION reader + as-of decision (EPIC-51 / T-51.4, split out by T-66.1).
|
|
5
|
+
//
|
|
6
|
+
// WHY THIS FILE IS SEPARATE
|
|
7
|
+
// T-66.1 gives the independent verifier an IN-MEMORY file-source seam (`verifyArtifactFromBytes`) whose
|
|
8
|
+
// whole code path must be portable off Node (a browser page, a vm sandbox): NO `fs`, NO `path`, NO `os`,
|
|
9
|
+
// NO `process` may be reachable from it — statically, at module scope (the same discipline
|
|
10
|
+
// trustledger/lib/policy-bundled-loader.js established for the TrustLedger browser core). The revocation
|
|
11
|
+
// DECISION (validate + recover + classify + as-of fold) was always pure, but it lived in the same module
|
|
12
|
+
// as the FILE/DIR reader, which requires `fs` + `path` at module scope. This file is the decision, alone:
|
|
13
|
+
// every function here is a pure function of its arguments; requiring this module touches NOTHING impure.
|
|
14
|
+
// verifier/lib/revocation.js re-exports everything from here VERBATIM (the same function objects) and adds
|
|
15
|
+
// the two — and only two — fs-backed conveniences (`readRevocationsFromPath`, `loadAndApply`), so every
|
|
16
|
+
// existing caller keeps its exact surface and byte-identical behavior.
|
|
17
|
+
//
|
|
18
|
+
// EVERYTHING BELOW IS MOVED VERBATIM from verifier/lib/revocation.js (no semantic edit) — see that file's
|
|
19
|
+
// header for the full design rationale (the load-bearing self-control invariant, subject scoping, and the
|
|
20
|
+
// gate-for-gate parity with the producer stack's cli/core/trust-asof.js + cli/core/revocation.js).
|
|
21
|
+
|
|
22
|
+
const { recoverPersonalSignAddress } = require("./secp256k1-recover");
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// On-disk discriminators + grammars — byte-identical to cli/core/revocation.js so a producer-minted
|
|
26
|
+
// revocation reads here verbatim.
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
const SIGNED_REVOCATION_KIND = "vh-key-revocation-signed";
|
|
30
|
+
const REVOCATION_KIND = "vh-key-revocation";
|
|
31
|
+
|
|
32
|
+
// The SCHEMA versions this build understands — byte-identical to cli/core/revocation.js's
|
|
33
|
+
// SUPPORTED_*_SCHEMA_VERSIONS. The producer REJECTS (IGNORES) a revocation whose container OR embedded
|
|
34
|
+
// payload carries an unsupported schemaVersion, so the verifier must too (parity).
|
|
35
|
+
const REVOCATION_SCHEMA_VERSION = 1;
|
|
36
|
+
const SUPPORTED_REVOCATION_SCHEMA_VERSIONS = Object.freeze([1]);
|
|
37
|
+
const SUPPORTED_SIGNED_REVOCATION_SCHEMA_VERSIONS = Object.freeze([1]);
|
|
38
|
+
|
|
39
|
+
// The CLOSED reason set (the producer's REVOCATION_REASON_SET, sorted). An out-of-set reason marks the
|
|
40
|
+
// embedded revocation structurally malformed — the entry is IGNORED (never silently honored).
|
|
41
|
+
const REVOCATION_REASON_SET = Object.freeze(["compromised", "retired", "rotated", "superseded"]);
|
|
42
|
+
|
|
43
|
+
// The CLOSED field set of an UNSIGNED revocation payload — byte-identical to cli/core/revocation.js's
|
|
44
|
+
// REVOCATION_FIELDS. The producer HARD-rejects any extraneous/unknown key (validateRevocation), IGNORING
|
|
45
|
+
// the revocation; the verifier must enforce the SAME closed set so a smuggled extra field can never make
|
|
46
|
+
// the two stacks disagree (a self-signed-but-non-canonical revocation the producer ignores must be ignored
|
|
47
|
+
// here too). `supersededBy` is OPTIONAL but a member of the set.
|
|
48
|
+
const REVOCATION_FIELDS = Object.freeze([
|
|
49
|
+
"kind",
|
|
50
|
+
"schemaVersion",
|
|
51
|
+
"note",
|
|
52
|
+
"vendorAddress",
|
|
53
|
+
"reason",
|
|
54
|
+
"revokedAt",
|
|
55
|
+
"supersededBy",
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
// The standing in-band trust NOTES — copied VERBATIM from cli/core/revocation.js so the verifier pins the
|
|
59
|
+
// EXACT same `note` text the producer requires. The producer's validateRevocation requires the embedded
|
|
60
|
+
// payload's `note` to equal REVOCATION_TRUST_NOTE, and validateSignedAttestation requires the container's
|
|
61
|
+
// `note` to equal SIGNED_REVOCATION_TRUST_NOTE; a revocation with a wrong/absent note is IGNORED by the
|
|
62
|
+
// producer, so the verifier must ignore it too (parity). These strings are LOAD-BEARING for parity, not
|
|
63
|
+
// for security (the signature binds the bytes regardless) — they must never drift from the producer's.
|
|
64
|
+
const REVOCATION_TRUST_NOTE =
|
|
65
|
+
"This is a verifyhash producer KEY REVOCATION: the holder of `vendorAddress`'s key SIGNED it, declaring " +
|
|
66
|
+
"that address REVOKED as of `revokedAt` for `reason` (optionally superseded by `supersededBy`). verify " +
|
|
67
|
+
"RE-DERIVES the signer from these exact bytes and REQUIRES it to equal `vendorAddress` — a key revokes " +
|
|
68
|
+
"ITSELF; a third party cannot revoke a key it does not control. It proves the KEY-HOLDER's SIGNED CLAIM " +
|
|
69
|
+
'ONLY: `revokedAt` is the holder\'s self-asserted instant, NOT a trusted TIMESTAMP (it rides the human-' +
|
|
70
|
+
"owned timestamp trust-root, STRATEGY.md P-3), and this is NOT a legal opinion.";
|
|
71
|
+
|
|
72
|
+
const SIGNED_REVOCATION_TRUST_NOTE =
|
|
73
|
+
"This is a SIGNED verifyhash key-revocation container: it WRAPS (never edits) the EXACT canonical " +
|
|
74
|
+
"revocation bytes in `attestation` and attaches a detached EIP-191 signature. verifyRevocation " +
|
|
75
|
+
"RE-DERIVES the signer from those bytes and pins it to the embedded `vendorAddress` — it never trusts " +
|
|
76
|
+
"the file's own claims. Every caveat of the embedded revocation applies. " +
|
|
77
|
+
REVOCATION_TRUST_NOTE;
|
|
78
|
+
|
|
79
|
+
// A claimed 0x-address INSIDE the payload: 0x + 40 LOWERCASE hex (byte-determinism — mixed-case rejected).
|
|
80
|
+
const PAYLOAD_ADDRESS_RE = /^0x[0-9a-f]{40}$/;
|
|
81
|
+
// A recovered/expected address (the verifier lowercases everything it compares).
|
|
82
|
+
const ADDRESS_RE = /^0x[0-9a-f]{40}$/;
|
|
83
|
+
// A 65-byte (r||s||v) signature as 0x-hex — LOWERCASE-only, byte-for-byte the producer's EIP191_SIG_RE
|
|
84
|
+
// (cli/core/attestation.js). The producer REJECTS mixed/upper-case hex for byte-determinism and IGNORES a
|
|
85
|
+
// revocation carrying it; accepting mixed case here would let a third party re-encode a holder's genuine
|
|
86
|
+
// revocation into one the producer drops but the verifier honors — a parity split with NO key required. So
|
|
87
|
+
// the verifier pins the SAME lowercase grammar.
|
|
88
|
+
const SIGNATURE_RE = /^0x[0-9a-f]{130}$/;
|
|
89
|
+
// A strict, CANONICAL ISO-8601 UTC instant — the SAME grammar revokedAt / asOf are pinned to.
|
|
90
|
+
const ISO_INSTANT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
|
|
91
|
+
|
|
92
|
+
// The recovered-signer sentinel the producer core returns for an unrecoverable signature.
|
|
93
|
+
const UNRECOVERABLE = "(unrecoverable)";
|
|
94
|
+
|
|
95
|
+
// A dedicated error type for the HARD input errors of THIS helper (a malformed asOf, a non-JSON/wrong-type
|
|
96
|
+
// revocations input, an unreadable path). An individual BOGUS revocation is NEVER thrown — it is collected as
|
|
97
|
+
// an ignored warning so one bad entry can never abort the evaluation of the good ones.
|
|
98
|
+
class RevocationReadError extends Error {
|
|
99
|
+
constructor(message) {
|
|
100
|
+
super(message);
|
|
101
|
+
this.name = "RevocationReadError";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isPlainObject(v) {
|
|
106
|
+
return v != null && typeof v === "object" && !Array.isArray(v);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Canonical-instant parsing (asOf + revokedAt share this).
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
function parseCanonicalInstant(value, label) {
|
|
114
|
+
if (typeof value !== "string" || !ISO_INSTANT_RE.test(value)) {
|
|
115
|
+
throw new RevocationReadError(
|
|
116
|
+
`${label} must be an ISO-8601 UTC instant ("YYYY-MM-DDTHH:MM:SS(.mmm)Z"), got: ${String(value)}`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const ms = Date.parse(value);
|
|
120
|
+
if (Number.isNaN(ms) || new Date(ms).toISOString() !== value) {
|
|
121
|
+
throw new RevocationReadError(
|
|
122
|
+
`${label} must be a canonical ISO-8601 UTC instant (no rolled-over/impossible fields), got: ${String(value)}`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return ms;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Resolve the effective `--as-of` instant. PURE. When the recipient supplied one, validate + use it; when
|
|
130
|
+
* they did not, default to the recipient's CURRENT decision time (`nowISO`, injectable for tests). A
|
|
131
|
+
* malformed explicit `--as-of` is a HARD RevocationReadError (never silently coerced to now). Mirrors
|
|
132
|
+
* cli/core/trust-asof.js resolveAsOf.
|
|
133
|
+
* @param {string|undefined|null} asOf
|
|
134
|
+
* @param {string} nowISO the recipient's current instant (ISO-8601 UTC)
|
|
135
|
+
* @returns {{ asOf: string, defaulted: boolean }}
|
|
136
|
+
*/
|
|
137
|
+
function resolveAsOf(asOf, nowISO) {
|
|
138
|
+
if (asOf !== undefined && asOf !== null && asOf !== "") {
|
|
139
|
+
parseCanonicalInstant(asOf, "--as-of"); // validate shape; throws on malformed
|
|
140
|
+
return { asOf, defaulted: false };
|
|
141
|
+
}
|
|
142
|
+
if (typeof nowISO !== "string") {
|
|
143
|
+
throw new RevocationReadError("resolveAsOf requires a nowISO instant when --as-of is not given");
|
|
144
|
+
}
|
|
145
|
+
parseCanonicalInstant(nowISO, "nowISO"); // the injected/default now must itself be canonical
|
|
146
|
+
return { asOf: nowISO, defaulted: true };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Serialize a validated UNSIGNED revocation payload to its CANONICAL, byte-deterministic bytes — a FIXED key
|
|
151
|
+
* order, NO insignificant whitespace, a single trailing newline. This is a LINE-FOR-LINE port of
|
|
152
|
+
* cli/core/revocation.js serializeRevocation: the FIXED field order (kind, schemaVersion, note,
|
|
153
|
+
* vendorAddress, reason, revokedAt) with `supersededBy` appended LAST and ONLY when present. It lets the
|
|
154
|
+
* verifier perform the producer's canonical-bytes BINDING check (`attestation === serializeRevocation(...)`)
|
|
155
|
+
* with NO ethers — the producer's whole point that two logically-identical revocations serialize identically.
|
|
156
|
+
* @param {object} payload an already-structurally-validated revocation payload
|
|
157
|
+
* @returns {string} the canonical serialization (newline-terminated)
|
|
158
|
+
*/
|
|
159
|
+
function serializeRevocation(payload) {
|
|
160
|
+
const canonical = {
|
|
161
|
+
kind: payload.kind,
|
|
162
|
+
schemaVersion: payload.schemaVersion,
|
|
163
|
+
note: payload.note,
|
|
164
|
+
vendorAddress: payload.vendorAddress,
|
|
165
|
+
reason: payload.reason,
|
|
166
|
+
revokedAt: payload.revokedAt,
|
|
167
|
+
};
|
|
168
|
+
if (Object.prototype.hasOwnProperty.call(payload, "supersededBy") && payload.supersededBy !== undefined) {
|
|
169
|
+
canonical.supersededBy = payload.supersededBy;
|
|
170
|
+
}
|
|
171
|
+
return JSON.stringify(canonical) + "\n";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Structural validation of a parsed SIGNED revocation container + its embedded payload. A structurally
|
|
176
|
+
// invalid container is REJECTED by THROWING (the caller catches + IGNORES it with a warning) — never
|
|
177
|
+
// half-accepted.
|
|
178
|
+
//
|
|
179
|
+
// PARITY-CRITICAL: this MIRRORS the producer's cli/core/attestation.js validateSignedAttestation +
|
|
180
|
+
// cli/core/revocation.js validateRevocation so the two stacks IGNORE the EXACT same malformed-but-self-signed
|
|
181
|
+
// revocations. The producer's verdict-gating structural checks the verifier MUST replicate (or the offline
|
|
182
|
+
// path reaches REVOKED where the producer reaches OK on identical inputs) are:
|
|
183
|
+
// - the CONTAINER carries the right kind, a SUPPORTED schemaVersion, and the standing SIGNED note;
|
|
184
|
+
// - the signature block has a known scheme, a 65-byte LOWERCASE-hex signature, and a lowercase signer;
|
|
185
|
+
// - the EMBEDDED payload re-validates as a sound UNSIGNED revocation: a CLOSED field set (no extra/unknown
|
|
186
|
+
// key), the right kind, a SUPPORTED schemaVersion, the standing UNSIGNED note, a lowercase vendorAddress,
|
|
187
|
+
// a closed-set reason, a canonical revokedAt, an optional lowercase supersededBy; AND
|
|
188
|
+
// - the WRAP-DON'T-EDIT binding: the embedded `attestation` STRING is byte-for-byte the canonical
|
|
189
|
+
// re-serialization of the embedded payload (so a non-canonical / reordered / whitespace variant is
|
|
190
|
+
// IGNORED, exactly as the producer ignores it).
|
|
191
|
+
// The signature recovery (verifyRevocation) is what makes a revocation SAFE against forgers; these structural
|
|
192
|
+
// checks are what make the verifier's VERDICT EQUAL the producer's on every malformed input it sees.
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
function validateSignedRevocation(obj) {
|
|
196
|
+
if (!isPlainObject(obj)) {
|
|
197
|
+
throw new RevocationReadError("revocation container must be a JSON object");
|
|
198
|
+
}
|
|
199
|
+
if (obj.kind !== SIGNED_REVOCATION_KIND) {
|
|
200
|
+
throw new RevocationReadError(
|
|
201
|
+
`not a signed key-revocation (kind ${JSON.stringify(obj.kind)}; expected ${JSON.stringify(SIGNED_REVOCATION_KIND)})`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
// The CONTAINER schemaVersion must be supported (the producer's validateSignedAttestation rejects an
|
|
205
|
+
// unsupported one before any recovery — so the verifier must too).
|
|
206
|
+
if (!SUPPORTED_SIGNED_REVOCATION_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
|
|
207
|
+
throw new RevocationReadError(
|
|
208
|
+
`unsupported signed revocation schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
|
|
209
|
+
`(this build understands ${JSON.stringify(SUPPORTED_SIGNED_REVOCATION_SCHEMA_VERSIONS)})`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
// The CONTAINER note must be the standing SIGNED note (the producer pins it; a drifted note is IGNORED).
|
|
213
|
+
if (obj.note !== SIGNED_REVOCATION_TRUST_NOTE) {
|
|
214
|
+
throw new RevocationReadError("signed revocation `note` must be the standing SIGNED_REVOCATION_TRUST_NOTE");
|
|
215
|
+
}
|
|
216
|
+
if (typeof obj.attestation !== "string") {
|
|
217
|
+
throw new RevocationReadError("signed revocation must embed the canonical UNSIGNED bytes as a string `attestation`");
|
|
218
|
+
}
|
|
219
|
+
const sig = obj.signature;
|
|
220
|
+
if (!isPlainObject(sig)) {
|
|
221
|
+
throw new RevocationReadError("signed revocation is missing a { scheme, signer, signature } signature block");
|
|
222
|
+
}
|
|
223
|
+
if (sig.scheme !== "eip191-personal-sign") {
|
|
224
|
+
throw new RevocationReadError(
|
|
225
|
+
`unsupported signature scheme: ${JSON.stringify(sig.scheme)} (this verifier understands eip191-personal-sign)`
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
// LOWERCASE-only signer + signature, byte-for-byte the producer's ADDRESS_RE / EIP191_SIG_RE — a
|
|
229
|
+
// mixed/upper-case re-encoding is IGNORED by the producer, so it must be ignored here too.
|
|
230
|
+
if (typeof sig.signer !== "string" || !PAYLOAD_ADDRESS_RE.test(sig.signer)) {
|
|
231
|
+
throw new RevocationReadError(
|
|
232
|
+
"signed revocation signer must be a 0x-prefixed 20-byte LOWERCASE-hex address (mixed/upper case rejected for byte-determinism)"
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
if (typeof sig.signature !== "string" || !SIGNATURE_RE.test(sig.signature)) {
|
|
236
|
+
throw new RevocationReadError(
|
|
237
|
+
"signed revocation signature must be a 65-byte (r||s||v) 0x-prefixed LOWERCASE-hex string (mixed/upper case rejected for byte-determinism)"
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Parse + strictly validate the embedded revocation payload (the producer's validateRevocation, mirrored).
|
|
242
|
+
let rev;
|
|
243
|
+
try {
|
|
244
|
+
rev = JSON.parse(obj.attestation);
|
|
245
|
+
} catch (e) {
|
|
246
|
+
throw new RevocationReadError(`embedded revocation is not valid JSON: ${e.message}`);
|
|
247
|
+
}
|
|
248
|
+
if (!isPlainObject(rev)) {
|
|
249
|
+
throw new RevocationReadError("embedded revocation payload must be a JSON object");
|
|
250
|
+
}
|
|
251
|
+
// CLOSED FIELD SET: an unknown/extraneous key is a HARD reject (the producer IGNORES such a revocation).
|
|
252
|
+
for (const key of Object.keys(rev)) {
|
|
253
|
+
if (!REVOCATION_FIELDS.includes(key)) {
|
|
254
|
+
throw new RevocationReadError(
|
|
255
|
+
`revocation has an unknown field: ${JSON.stringify(key)} (the closed field set is ${JSON.stringify(REVOCATION_FIELDS)})`
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (rev.kind !== REVOCATION_KIND) {
|
|
260
|
+
throw new RevocationReadError(
|
|
261
|
+
`embedded payload is not a key revocation (kind ${JSON.stringify(rev.kind)}; expected ${JSON.stringify(REVOCATION_KIND)})`
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
if (!SUPPORTED_REVOCATION_SCHEMA_VERSIONS.includes(rev.schemaVersion)) {
|
|
265
|
+
throw new RevocationReadError(
|
|
266
|
+
`unsupported revocation schemaVersion: ${JSON.stringify(rev.schemaVersion)} ` +
|
|
267
|
+
`(this build understands ${JSON.stringify(SUPPORTED_REVOCATION_SCHEMA_VERSIONS)})`
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
if (rev.note !== REVOCATION_TRUST_NOTE) {
|
|
271
|
+
throw new RevocationReadError("revocation `note` must be the standing REVOCATION_TRUST_NOTE (caveat must not drift)");
|
|
272
|
+
}
|
|
273
|
+
if (typeof rev.vendorAddress !== "string" || !PAYLOAD_ADDRESS_RE.test(rev.vendorAddress)) {
|
|
274
|
+
throw new RevocationReadError(
|
|
275
|
+
`revocation vendorAddress must be a 0x-prefixed 20-byte LOWERCASE-hex address, got: ${String(rev.vendorAddress)}`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
if (typeof rev.reason !== "string" || !REVOCATION_REASON_SET.includes(rev.reason)) {
|
|
279
|
+
throw new RevocationReadError(
|
|
280
|
+
`revocation reason must be one of ${JSON.stringify(REVOCATION_REASON_SET)}, got: ${JSON.stringify(rev.reason)}`
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
parseCanonicalInstant(rev.revokedAt, "revocation revokedAt"); // throws on a non-canonical instant
|
|
284
|
+
if (
|
|
285
|
+
Object.prototype.hasOwnProperty.call(rev, "supersededBy") &&
|
|
286
|
+
rev.supersededBy !== undefined &&
|
|
287
|
+
(typeof rev.supersededBy !== "string" || !PAYLOAD_ADDRESS_RE.test(rev.supersededBy))
|
|
288
|
+
) {
|
|
289
|
+
throw new RevocationReadError(
|
|
290
|
+
`revocation supersededBy, when present, must be a 0x-prefixed 20-byte LOWERCASE-hex address, got: ${String(rev.supersededBy)}`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
// WRAP-DON'T-EDIT BINDING (the producer's `obj.attestation !== cfg.serializeUnsigned(embedded)` gate). The
|
|
294
|
+
// embedded STRING must be byte-for-byte the canonical re-serialization of the embedded payload — so a
|
|
295
|
+
// reordered-keys / extra-whitespace / otherwise non-canonical (but genuinely self-signed) variant is
|
|
296
|
+
// IGNORED here exactly as the producer ignores it. THIS is the check that closes the headline parity gap.
|
|
297
|
+
if (obj.attestation !== serializeRevocation(rev)) {
|
|
298
|
+
throw new RevocationReadError(
|
|
299
|
+
"embedded revocation is not in canonical form (the signed-over bytes must be byte-for-byte the canonical serialization)"
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
return { container: obj, revocation: rev };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Verify (purely, OFFLINE) a parsed SIGNED revocation container — the STACK-FREE mirror of the producer's
|
|
307
|
+
* verifyRevocation. It recovers the signer from the embedded canonical bytes + signature and:
|
|
308
|
+
* (1) confirms it equals the container's CLAIMED `signer` (signatureMatchesSigner — ALWAYS run);
|
|
309
|
+
* (2) confirms it equals the revocation's OWN embedded `vendorAddress` (vendorAddressMatchesSigner — the
|
|
310
|
+
* load-bearing SELF-CONTROL check: a key revokes ITSELF).
|
|
311
|
+
* The verdict is ACCEPTED only when BOTH pass; a forged/tampered/third-party revocation is a clean REJECTED.
|
|
312
|
+
* A structurally invalid container THROWS (RevocationReadError) before any recovery, so an ordinary REJECTED
|
|
313
|
+
* verdict only ever describes a STRUCTURALLY SOUND revocation whose signature simply doesn't back its claims.
|
|
314
|
+
*
|
|
315
|
+
* @param {object} container a parsed signed-revocation container object
|
|
316
|
+
* @returns {{ accepted, recoveredSigner, claimedSigner, vendorAddress, reason, revokedAt, supersededBy, failedChecks }}
|
|
317
|
+
*/
|
|
318
|
+
function verifyRevocation(container) {
|
|
319
|
+
const { revocation } = validateSignedRevocation(container);
|
|
320
|
+
const claimedSigner = container.signature.signer.toLowerCase();
|
|
321
|
+
const vendorAddress = revocation.vendorAddress;
|
|
322
|
+
|
|
323
|
+
// Recover the signer from the EXACT embedded bytes. A tampered/corrupt signature can be UNRECOVERABLE (no
|
|
324
|
+
// valid curve point) — that throws; we map it to the "(unrecoverable)" sentinel, never a crash, mirroring
|
|
325
|
+
// the producer core's catch.
|
|
326
|
+
let recoveredSigner;
|
|
327
|
+
try {
|
|
328
|
+
recoveredSigner = recoverPersonalSignAddress(container.attestation, container.signature.signature);
|
|
329
|
+
} catch (_) {
|
|
330
|
+
recoveredSigner = UNRECOVERABLE;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const signatureMatchesSigner = recoveredSigner === claimedSigner;
|
|
334
|
+
const vendorAddressMatchesSigner = recoveredSigner === vendorAddress;
|
|
335
|
+
|
|
336
|
+
const failedChecks = [];
|
|
337
|
+
if (!signatureMatchesSigner) failedChecks.push("signatureMatchesSigner");
|
|
338
|
+
if (!vendorAddressMatchesSigner) failedChecks.push("vendorAddressMatchesSigner");
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
accepted: failedChecks.length === 0,
|
|
342
|
+
recoveredSigner,
|
|
343
|
+
claimedSigner,
|
|
344
|
+
vendorAddress,
|
|
345
|
+
reason: revocation.reason,
|
|
346
|
+
revokedAt: revocation.revokedAt,
|
|
347
|
+
supersededBy: Object.prototype.hasOwnProperty.call(revocation, "supersededBy")
|
|
348
|
+
? revocation.supersededBy
|
|
349
|
+
: null,
|
|
350
|
+
failedChecks,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// Normalize the `revocations` input into a flat array of entries to evaluate. PURE. Accepts an ARRAY of
|
|
356
|
+
// already-parsed containers (or JSON strings), a single container object, or a JSON STRING of either (a
|
|
357
|
+
// bundle file is a JSON ARRAY of containers, or a single container object). A per-entry parse failure becomes
|
|
358
|
+
// a `_parseError` marker (IGNORED with a warning); a WHOLE-input parse failure HARD-errors. Mirrors
|
|
359
|
+
// cli/core/trust-asof.js normalizeRevocationsInput.
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
function normalizeRevocationsInput(revocations) {
|
|
363
|
+
if (typeof revocations === "string") {
|
|
364
|
+
let parsed;
|
|
365
|
+
try {
|
|
366
|
+
parsed = JSON.parse(revocations);
|
|
367
|
+
} catch (e) {
|
|
368
|
+
throw new RevocationReadError(`revocations input is not valid JSON: ${e.message}`);
|
|
369
|
+
}
|
|
370
|
+
return normalizeRevocationsInput(parsed);
|
|
371
|
+
}
|
|
372
|
+
if (Array.isArray(revocations)) {
|
|
373
|
+
return revocations.map((el) => {
|
|
374
|
+
if (typeof el === "string") {
|
|
375
|
+
try {
|
|
376
|
+
return JSON.parse(el);
|
|
377
|
+
} catch (e) {
|
|
378
|
+
return { _parseError: `entry is not valid JSON: ${e.message}`, _raw: el };
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return el;
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
if (isPlainObject(revocations)) {
|
|
385
|
+
return [revocations];
|
|
386
|
+
}
|
|
387
|
+
throw new RevocationReadError(
|
|
388
|
+
"revocations input must be a signed-revocation container, an array of them, or JSON text of either"
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Classify ONE already-parsed revocation entry against the subject + as-of pivot. PURE. Mirrors
|
|
393
|
+
// cli/core/trust-asof.js classifyRevocation exactly (the same `applies`/`later`/`irrelevant`/`ignored`
|
|
394
|
+
// outcomes + the inclusive `revokedAt <= asOf` boundary).
|
|
395
|
+
function classifyRevocation(entry, subject, asOfMs) {
|
|
396
|
+
if (entry && entry._parseError) {
|
|
397
|
+
return { kind: "ignored", warning: `ignored an unparseable revocation entry (${entry._parseError})` };
|
|
398
|
+
}
|
|
399
|
+
let v;
|
|
400
|
+
try {
|
|
401
|
+
v = verifyRevocation(entry);
|
|
402
|
+
} catch (e) {
|
|
403
|
+
return { kind: "ignored", warning: `ignored a malformed/foreign revocation (${e.message})` };
|
|
404
|
+
}
|
|
405
|
+
if (!v.accepted) {
|
|
406
|
+
return {
|
|
407
|
+
kind: "ignored",
|
|
408
|
+
warning:
|
|
409
|
+
`ignored a revocation that does not verify (failed: ${v.failedChecks.join(", ")}; ` +
|
|
410
|
+
`vendorAddress ${v.vendorAddress}) — a forged/tampered/third-party revocation never downgrades trust`,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
if (v.vendorAddress !== subject) {
|
|
414
|
+
return { kind: "irrelevant", vendorAddress: v.vendorAddress };
|
|
415
|
+
}
|
|
416
|
+
const revokedAtMs = Date.parse(v.revokedAt);
|
|
417
|
+
const detail = {
|
|
418
|
+
vendorAddress: v.vendorAddress,
|
|
419
|
+
reason: v.reason,
|
|
420
|
+
revokedAt: v.revokedAt,
|
|
421
|
+
supersededBy: v.supersededBy,
|
|
422
|
+
};
|
|
423
|
+
// Inclusive on the revoked side: a revocation effective EXACTLY at the as-of instant counts as revoked.
|
|
424
|
+
if (revokedAtMs <= asOfMs) {
|
|
425
|
+
return { kind: "applies", ...detail };
|
|
426
|
+
}
|
|
427
|
+
return { kind: "later", ...detail };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* THE RECIPIENT-SIDE TRUST-DECISION-AS-OF. PURE / OFFLINE / KEY-FREE / I/O-FREE / CLOCK-FREE. Identical
|
|
432
|
+
* semantics to cli/core/trust-asof.js evaluateTrustAsOf (so verify-vh's downgrade matches the producer's
|
|
433
|
+
* byte-for-byte). Returns a stable decision block.
|
|
434
|
+
* @param {object} params { subject, asOf, revocations }
|
|
435
|
+
* @returns {{ status, revoked, subject, asOf, governing, laterRevoked, counts, ignored }}
|
|
436
|
+
*/
|
|
437
|
+
function evaluateTrustAsOf(params) {
|
|
438
|
+
if (!isPlainObject(params)) {
|
|
439
|
+
throw new RevocationReadError("evaluateTrustAsOf requires { subject, asOf, revocations }");
|
|
440
|
+
}
|
|
441
|
+
const { subject, asOf, revocations } = params;
|
|
442
|
+
if (typeof subject !== "string" || subject.length === 0) {
|
|
443
|
+
throw new RevocationReadError("evaluateTrustAsOf requires a string `subject` (the artifact's recovered signer)");
|
|
444
|
+
}
|
|
445
|
+
const asOfMs = parseCanonicalInstant(asOf, "--as-of");
|
|
446
|
+
const entries = normalizeRevocationsInput(revocations);
|
|
447
|
+
|
|
448
|
+
// A non-address subject (the "(unrecoverable)" sentinel) cannot be matched by any revocation — still
|
|
449
|
+
// evaluate every entry (so forged ones are reported as ignored), but no SOUND revocation can apply.
|
|
450
|
+
const subjectIsAddress = ADDRESS_RE.test(subject);
|
|
451
|
+
|
|
452
|
+
const applicable = [];
|
|
453
|
+
const later = [];
|
|
454
|
+
let irrelevant = 0;
|
|
455
|
+
const ignored = [];
|
|
456
|
+
|
|
457
|
+
for (const entry of entries) {
|
|
458
|
+
const c = classifyRevocation(entry, subject, asOfMs);
|
|
459
|
+
if (c.kind === "ignored") ignored.push(c.warning);
|
|
460
|
+
else if (c.kind === "irrelevant") irrelevant += 1;
|
|
461
|
+
else if (c.kind === "later") later.push(c);
|
|
462
|
+
else if (c.kind === "applies") applicable.push(c);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// The GOVERNING revocation is the EARLIEST applicable one (smallest revokedAt), tie-broken deterministically
|
|
466
|
+
// on vendorAddress then reason — the instant from which the key was no longer trustworthy.
|
|
467
|
+
const sortByEffective = (a, b) =>
|
|
468
|
+
Date.parse(a.revokedAt) - Date.parse(b.revokedAt) ||
|
|
469
|
+
(a.vendorAddress < b.vendorAddress ? -1 : a.vendorAddress > b.vendorAddress ? 1 : 0) ||
|
|
470
|
+
(a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0);
|
|
471
|
+
|
|
472
|
+
const govern = (arr) => {
|
|
473
|
+
if (arr.length === 0) return null;
|
|
474
|
+
const [g] = arr.slice().sort(sortByEffective);
|
|
475
|
+
return { vendorAddress: g.vendorAddress, reason: g.reason, revokedAt: g.revokedAt, supersededBy: g.supersededBy };
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
const governing = govern(applicable);
|
|
479
|
+
const laterRevoked = governing ? null : govern(later);
|
|
480
|
+
|
|
481
|
+
let status;
|
|
482
|
+
if (governing) status = "REVOKED";
|
|
483
|
+
else if (!subjectIsAddress) status = "UNEVALUABLE";
|
|
484
|
+
else status = "OK";
|
|
485
|
+
|
|
486
|
+
return {
|
|
487
|
+
status,
|
|
488
|
+
revoked: status === "REVOKED",
|
|
489
|
+
subject,
|
|
490
|
+
asOf,
|
|
491
|
+
governing,
|
|
492
|
+
laterRevoked,
|
|
493
|
+
counts: {
|
|
494
|
+
total: entries.length,
|
|
495
|
+
applicable: applicable.length,
|
|
496
|
+
later: later.length,
|
|
497
|
+
irrelevant,
|
|
498
|
+
ignored: ignored.length,
|
|
499
|
+
},
|
|
500
|
+
ignored,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Fold a TRUST-DECISION-AS-OF onto an existing verify-vh result, OFFLINE. PURE. Mirrors cli/core/trust-asof.js
|
|
506
|
+
* applyToVerifyResult: it NEVER upgrades a verdict — an already-REJECTED artifact stays rejected; the
|
|
507
|
+
* trust-as-of only ever ADDS a REVOKED downgrade on top of an otherwise-ACCEPTED artifact. Returns a NEW
|
|
508
|
+
* result object (the original is not mutated): the original fields PLUS `trustAsOf`, with accepted/verdict/
|
|
509
|
+
* reason updated when REVOKED.
|
|
510
|
+
*
|
|
511
|
+
* The `subject` is the artifact's RECOVERED signer. When the signature did not even recover (the
|
|
512
|
+
* "(unrecoverable)" sentinel / a null), no revocation can bind — the decision is UNEVALUABLE and never
|
|
513
|
+
* changes the (already-rejected) verdict.
|
|
514
|
+
*
|
|
515
|
+
* @param {object} params { result, revocations, asOf }
|
|
516
|
+
* @returns {object} a new result with `trustAsOf` attached
|
|
517
|
+
*/
|
|
518
|
+
function applyToVerifyResult(params) {
|
|
519
|
+
if (!isPlainObject(params) || !isPlainObject(params.result)) {
|
|
520
|
+
throw new RevocationReadError("applyToVerifyResult requires { result, revocations, asOf }");
|
|
521
|
+
}
|
|
522
|
+
const { result, revocations, asOf } = params;
|
|
523
|
+
// The subject is the recovered signer. verify-vh leaves recoveredSigner null for an UNSIGNED artifact and
|
|
524
|
+
// sets the "(unrecoverable)" sentinel for a broken signature — both are non-addresses, so neither binds.
|
|
525
|
+
const subject =
|
|
526
|
+
typeof result.recoveredSigner === "string" && result.recoveredSigner.length > 0
|
|
527
|
+
? result.recoveredSigner
|
|
528
|
+
: UNRECOVERABLE;
|
|
529
|
+
|
|
530
|
+
const decision = evaluateTrustAsOf({ subject, asOf, revocations });
|
|
531
|
+
const out = { ...result, trustAsOf: decision };
|
|
532
|
+
|
|
533
|
+
if (decision.revoked) {
|
|
534
|
+
// The ONLY downgrading path: an otherwise-ACCEPTED artifact whose signer was revoked-before-as-of becomes
|
|
535
|
+
// REVOKED (exit 3). We flip accepted=false + set a distinct REVOKED verdict + a named reason so the
|
|
536
|
+
// existing `accepted ? 0 : 3` exit mapping yields exit 3, byte-for-byte with the producer.
|
|
537
|
+
out.accepted = false;
|
|
538
|
+
out.verdict = "REVOKED";
|
|
539
|
+
out.reason = "key_revoked_as_of";
|
|
540
|
+
}
|
|
541
|
+
return out;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Render the human-readable TRUST-DECISION-AS-OF lines verify-vh appends to its report. PURE. Returns an
|
|
546
|
+
* array of lines. Mirrors cli/core/trust-asof.js renderTrustAsOf's content so the two stacks read the same.
|
|
547
|
+
* @param {object} decision the object evaluateTrustAsOf returns
|
|
548
|
+
* @param {{ defaulted?: boolean, indent?: string }} [ctx]
|
|
549
|
+
* @returns {string[]} lines
|
|
550
|
+
*/
|
|
551
|
+
function renderTrustAsOf(decision, ctx = {}) {
|
|
552
|
+
const I = ctx.indent || "";
|
|
553
|
+
const L = [];
|
|
554
|
+
const asOfNote = ctx.defaulted ? " (defaulted to now; pass --as-of <ISO> to pin the decision instant)" : "";
|
|
555
|
+
L.push(`${I}revocation check (as of ${decision.asOf})${asOfNote}:`);
|
|
556
|
+
if (decision.status === "REVOKED") {
|
|
557
|
+
const g = decision.governing;
|
|
558
|
+
L.push(
|
|
559
|
+
`${I} [REVOKED] the signing key (${g.vendorAddress}) was REVOKED as of ${g.revokedAt} ` +
|
|
560
|
+
`(reason: ${g.reason})${g.supersededBy ? `, superseded by ${g.supersededBy}` : ""} — at or before ` +
|
|
561
|
+
`the as-of instant. This artifact is NOT trustworthy as of ${decision.asOf}.`
|
|
562
|
+
);
|
|
563
|
+
} else if (decision.status === "UNEVALUABLE") {
|
|
564
|
+
L.push(`${I} [skip] the signature did not recover to a key — no subject to evaluate revocations against.`);
|
|
565
|
+
} else {
|
|
566
|
+
L.push(`${I} [OK] no applicable revocation: the signing key was not revoked as of ${decision.asOf}.`);
|
|
567
|
+
if (decision.laterRevoked) {
|
|
568
|
+
const lr = decision.laterRevoked;
|
|
569
|
+
L.push(
|
|
570
|
+
`${I} [note] this key (${lr.vendorAddress}) IS revoked as of ${lr.revokedAt} ` +
|
|
571
|
+
`(reason: ${lr.reason})${lr.supersededBy ? `, superseded by ${lr.supersededBy}` : ""} — AFTER your ` +
|
|
572
|
+
`as-of instant, so it does NOT downgrade THIS decision (informational).`
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
for (const w of decision.ignored) {
|
|
577
|
+
L.push(`${I} [warning] ${w}`);
|
|
578
|
+
}
|
|
579
|
+
return L;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
module.exports = {
|
|
583
|
+
RevocationReadError,
|
|
584
|
+
SIGNED_REVOCATION_KIND,
|
|
585
|
+
REVOCATION_KIND,
|
|
586
|
+
REVOCATION_SCHEMA_VERSION,
|
|
587
|
+
SUPPORTED_REVOCATION_SCHEMA_VERSIONS,
|
|
588
|
+
SUPPORTED_SIGNED_REVOCATION_SCHEMA_VERSIONS,
|
|
589
|
+
REVOCATION_REASON_SET,
|
|
590
|
+
REVOCATION_FIELDS,
|
|
591
|
+
REVOCATION_TRUST_NOTE,
|
|
592
|
+
SIGNED_REVOCATION_TRUST_NOTE,
|
|
593
|
+
ISO_INSTANT_RE,
|
|
594
|
+
UNRECOVERABLE,
|
|
595
|
+
isPlainObject,
|
|
596
|
+
parseCanonicalInstant,
|
|
597
|
+
resolveAsOf,
|
|
598
|
+
serializeRevocation,
|
|
599
|
+
validateSignedRevocation,
|
|
600
|
+
verifyRevocation,
|
|
601
|
+
normalizeRevocationsInput,
|
|
602
|
+
classifyRevocation,
|
|
603
|
+
evaluateTrustAsOf,
|
|
604
|
+
applyToVerifyResult,
|
|
605
|
+
renderTrustAsOf,
|
|
606
|
+
};
|