verifyhash 0.1.0
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/LICENSE +201 -0
- package/README.md +883 -0
- package/cli/abi/ContributionRegistry.json +881 -0
- package/cli/agent.js +2173 -0
- package/cli/anchor-artifact.js +853 -0
- package/cli/anchor.js +400 -0
- package/cli/claim.js +881 -0
- package/cli/core/agent-commit.js +448 -0
- package/cli/core/agent-session.js +598 -0
- package/cli/core/anchor-binding.js +663 -0
- package/cli/core/attestation.js +580 -0
- package/cli/core/evidence-plans.js +495 -0
- package/cli/core/fixtures/evidence-plans/baseline.json +19 -0
- package/cli/core/fulfill-intake.js +1082 -0
- package/cli/core/go-live-preflight.js +481 -0
- package/cli/core/license.js +534 -0
- package/cli/core/manifest.js +243 -0
- package/cli/core/packetseal.js +591 -0
- package/cli/core/registryArtifact.js +49 -0
- package/cli/core/revocation.js +539 -0
- package/cli/core/rfc3161.js +389 -0
- package/cli/core/timestamp.js +482 -0
- package/cli/core/trust-asof.js +479 -0
- package/cli/dataset.js +2950 -0
- package/cli/evidence.js +2227 -0
- package/cli/fulfill-webhook-http.js +438 -0
- package/cli/git.js +220 -0
- package/cli/hash.js +550 -0
- package/cli/identity.js +1072 -0
- package/cli/journal-cli.js +1110 -0
- package/cli/journal-log.js +454 -0
- package/cli/journal.js +334 -0
- package/cli/lineage.js +447 -0
- package/cli/list.js +287 -0
- package/cli/parcel.js +1509 -0
- package/cli/proof.js +578 -0
- package/cli/prove.js +300 -0
- package/cli/receipt.js +631 -0
- package/cli/registry.js +331 -0
- package/cli/reputation.js +344 -0
- package/cli/revocation.js +495 -0
- package/cli/serve-verify-http.js +298 -0
- package/cli/serve-verify.js +333 -0
- package/cli/show.js +339 -0
- package/cli/verify.js +383 -0
- package/cli/vh.js +3927 -0
- package/docs/ADOPT.md +183 -0
- package/docs/ADOPTION.json +11 -0
- package/docs/AGENTTRACE.md +247 -0
- package/docs/ANCHORING.md +167 -0
- package/docs/AUDIT.md +55 -0
- package/docs/CONFORMANCE.md +107 -0
- package/docs/DATALEDGER.md +638 -0
- package/docs/DECIDE.md +47 -0
- package/docs/DECISIONS-PENDING.md +27 -0
- package/docs/DEPLOY-PUBLIC-SITE.md +301 -0
- package/docs/ENGINE-LEDGER.json +12 -0
- package/docs/EVIDENCE.md +519 -0
- package/docs/GO-LIVE.md +66 -0
- package/docs/IDENTITY.md +123 -0
- package/docs/INDEPENDENT-VERIFICATION.md +377 -0
- package/docs/INTEGRITY-JOURNAL.md +337 -0
- package/docs/KEY-LIFECYCLE.md +179 -0
- package/docs/LICENSING.md +46 -0
- package/docs/LINEAGE.md +307 -0
- package/docs/LOOP-AUDIT-2026-07-03.json +580 -0
- package/docs/LOOP-HARDENING-PLAN.md +44 -0
- package/docs/MERKLE-LEAVES.md +113 -0
- package/docs/METRICS.jsonl +31 -0
- package/docs/MORNING.md +204 -0
- package/docs/PILOT.md +444 -0
- package/docs/PROOFPARCEL.md +227 -0
- package/docs/PROOFS.md +262 -0
- package/docs/RECEIPTS.md +341 -0
- package/docs/REPUTATION.md +158 -0
- package/docs/SDK.md +301 -0
- package/docs/STRATEGY-ARCHIVE.md +5055 -0
- package/docs/SUPERVISOR-RUNBOOK.md +52 -0
- package/docs/TRUST-BOUNDARIES.md +335 -0
- package/docs/TRUSTLEDGER.md +1976 -0
- package/docs/USAGE-BUDGET.json +121 -0
- package/docs/VERIFY-SERVICE.md +168 -0
- package/index.js +160 -0
- package/package.json +41 -0
- package/trustledger/build-standalone.js +796 -0
- package/trustledger/cli.js +3179 -0
- package/trustledger/close.js +391 -0
- package/trustledger/corpus.js +159 -0
- package/trustledger/dist/BUILD-PROVENANCE.json +99 -0
- package/trustledger/dist/trustledger-standalone.html +6197 -0
- package/trustledger/dist/trustledger-standalone.html.sha256 +1 -0
- package/trustledger/door-core.js +442 -0
- package/trustledger/fixtures/bank.csv +7 -0
- package/trustledger/fixtures/bank.malformed.csv +3 -0
- package/trustledger/fixtures/bank.noalias.csv +5 -0
- package/trustledger/fixtures/bank.ofx +34 -0
- package/trustledger/fixtures/bank.real.csv +5 -0
- package/trustledger/fixtures/corpus/_shared/prior-close.json +22 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/inputs.json +14 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/inputs.json +14 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/continuity-break--benign-twin/inputs.json +15 -0
- package/trustledger/fixtures/corpus/continuity-break--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/continuity-break--out-of-trust/inputs.json +15 -0
- package/trustledger/fixtures/corpus/continuity-break--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/inputs.json +13 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/inputs.json +13 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/inputs.json +15 -0
- package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/inputs.json +15 -0
- package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/inputs.json +16 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/inputs.json +13 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/inputs.json +13 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/inputs.json +13 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/e2e/bank.aliased.csv +4 -0
- package/trustledger/fixtures/e2e/bank.csv +4 -0
- package/trustledger/fixtures/e2e/bank.nsf.csv +4 -0
- package/trustledger/fixtures/e2e/quickbooks.csv +6 -0
- package/trustledger/fixtures/e2e/quickbooks.nsf.csv +8 -0
- package/trustledger/fixtures/e2e/rentroll.csv +6 -0
- package/trustledger/fixtures/e2e/rentroll.nsf.csv +8 -0
- package/trustledger/fixtures/e2e/rentroll.short.csv +5 -0
- package/trustledger/fixtures/plans/baseline.json +25 -0
- package/trustledger/fixtures/plans/price-binding.example.json +27 -0
- package/trustledger/fixtures/policy/ambiguous-deposit-example.json +12 -0
- package/trustledger/fixtures/policy/baseline.json +19 -0
- package/trustledger/fixtures/policy/ca-example.json +12 -0
- package/trustledger/fixtures/policy/negative-tenant-ledger-example.json +12 -0
- package/trustledger/fixtures/policy/owner-overdraw-example.json +12 -0
- package/trustledger/fixtures/quickbooks.csv +7 -0
- package/trustledger/fixtures/quickbooks.real.csv +5 -0
- package/trustledger/fixtures/rentroll.csv +6 -0
- package/trustledger/fixtures/rentroll.real.csv +4 -0
- package/trustledger/ingest.js +1163 -0
- package/trustledger/lib/policy-bundled-loader.js +44 -0
- package/trustledger/lib/sha256-vendored.js +227 -0
- package/trustledger/license.js +563 -0
- package/trustledger/match.js +551 -0
- package/trustledger/plans.js +551 -0
- package/trustledger/policy.js +398 -0
- package/trustledger/public/index.html +512 -0
- package/trustledger/reconcile.js +1486 -0
- package/trustledger/report.js +887 -0
- package/trustledger/seal.js +854 -0
- package/trustledger/server.js +391 -0
- package/trustledger/valueproof.js +350 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// cli/core/trust-asof.js — the recipient-side TRUST-DECISION-AS-OF helper for verifyhash (EPIC-51 / T-51.2).
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS EXISTS
|
|
6
|
+
// T-51.1 gave a PRODUCER a way to SIGN "this key is revoked as of D" (cli/core/revocation.js). This module
|
|
7
|
+
// is the RECIPIENT's other half: given the signer a signed artifact RECOVERS to, a set of producer
|
|
8
|
+
// revocation statements, and a point in time the recipient cares about ("the moment this exhibit was
|
|
9
|
+
// sealed", or "now"), it answers the only question that matters — "was that key trustworthy AS OF that
|
|
10
|
+
// instant?". A key that was revoked BEFORE the as-of instant means the artifact was signed (or is being
|
|
11
|
+
// relied upon) under a key its own holder had already declared dead: that downgrades the verdict to
|
|
12
|
+
// REVOKED. A revocation dated AFTER the as-of leaves the verdict ACCEPTED but carries an INFORMATIONAL
|
|
13
|
+
// "later-revoked" note (the key was fine then, but is revoked now — useful context, not a downgrade).
|
|
14
|
+
//
|
|
15
|
+
// THE LOAD-BEARING SAFETY INVARIANT — A REVOCATION CAN ONLY EVER REMOVE TRUST, NEVER ADD IT.
|
|
16
|
+
// Every revocation statement is run through the EXISTING `verifyRevocation` core VERBATIM (no new crypto):
|
|
17
|
+
// it must (1) recover to its own claimed signer AND (2) recover to its own embedded `vendorAddress` (the
|
|
18
|
+
// self-control invariant — a key revokes ITSELF). A revocation that fails EITHER check — forged, tampered,
|
|
19
|
+
// third-party, structurally malformed, or simply not parseable — is IGNORED with a WARNING and can NEVER
|
|
20
|
+
// downgrade the verdict. So an attacker who plants a bogus "revocation" for a victim's key cannot grief a
|
|
21
|
+
// recipient into rejecting a perfectly good artifact: a revocation only bites when it genuinely recovers to
|
|
22
|
+
// the SAME key it claims to revoke, and only for the subject that key controls.
|
|
23
|
+
//
|
|
24
|
+
// SUBJECT-SCOPING — A REVOCATION ONLY BITES THE KEY IT NAMES.
|
|
25
|
+
// The `subject` is the artifact's RECOVERED signer (the address `verify-signed`/`verify-attest`/`verify`
|
|
26
|
+
// actually derived from the bytes, NOT the merely-claimed one). A revocation only affects the verdict when
|
|
27
|
+
// its `vendorAddress` EQUALS that subject. A revocation for some OTHER key is simply not relevant — counted
|
|
28
|
+
// as `irrelevant`, never as a downgrade. So a recipient can carry a whole pile of a vendor's revocations
|
|
29
|
+
// and only the one(s) for the key that actually signed THIS artifact can change the verdict.
|
|
30
|
+
//
|
|
31
|
+
// PURE + I/O-FREE + KEY-FREE + CLOCK-FREE.
|
|
32
|
+
// Every function here is pure: no filesystem (the file read is the CLI layer's job — this takes parsed JSON
|
|
33
|
+
// text or already-parsed containers), no network, no key, no system clock (the `asOf` instant is a CALLER-
|
|
34
|
+
// supplied argument; the CLI defaults it sanely, but the core never reads the wall clock, so the same
|
|
35
|
+
// inputs always yield the same verdict). It REUSES `cli/core/revocation.js` (which reuses the shared
|
|
36
|
+
// attestation core) VERBATIM — there is NO new signing/recovery path here.
|
|
37
|
+
//
|
|
38
|
+
// STRICTLY ADDITIVE / OPT-IN.
|
|
39
|
+
// This helper runs ONLY when a caller passes `--revocations`. With NO revocations input the helper is never
|
|
40
|
+
// invoked, so the four signed-verify commands produce byte-identical verdicts + exit codes to their
|
|
41
|
+
// pre-EPIC baseline. That regression-safety is the whole point of keeping this OUT of the verify cores and
|
|
42
|
+
// layering it at the edge.
|
|
43
|
+
|
|
44
|
+
const coreRevocation = require("./revocation");
|
|
45
|
+
|
|
46
|
+
// A strict ISO-8601 UTC instant ("YYYY-MM-DDTHH:MM:SS(.mmm)Z") — the SAME canonical instant grammar the
|
|
47
|
+
// revocation core pins `revokedAt` to, so the `asOf` the recipient supplies is compared on the same footing.
|
|
48
|
+
const ISO_INSTANT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
|
|
49
|
+
|
|
50
|
+
// A recovered-signer address: 0x + 40 LOWERCASE hex. The verify cores return the recovered signer in this
|
|
51
|
+
// form (or the "(unrecoverable)" sentinel, which can never match this and so is never a valid subject).
|
|
52
|
+
const ADDRESS_RE = /^0x[0-9a-f]{40}$/;
|
|
53
|
+
|
|
54
|
+
// A dedicated error type for the HARD input errors of THIS helper (a malformed asOf, a non-array revocations
|
|
55
|
+
// input, a bad subject). An individual BOGUS revocation is NEVER thrown — it is collected as an ignored
|
|
56
|
+
// warning so one bad entry can never abort the evaluation of the good ones.
|
|
57
|
+
class TrustAsOfError extends Error {
|
|
58
|
+
constructor(message) {
|
|
59
|
+
super(message);
|
|
60
|
+
this.name = "TrustAsOfError";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isPlainObject(v) {
|
|
65
|
+
return v != null && typeof v === "object" && !Array.isArray(v);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse + strictly validate a recipient-supplied `asOf` instant into epoch-millis. PURE. A malformed/
|
|
70
|
+
* non-canonical instant is a HARD TrustAsOfError (named, no silent coercion) — the as-of is the pivot of the
|
|
71
|
+
* whole decision, so it must be exactly one canonical instant. Mirrors the revocation core's revokedAt grammar.
|
|
72
|
+
* @param {string} asOf an ISO-8601 UTC instant
|
|
73
|
+
* @returns {number} epoch milliseconds
|
|
74
|
+
*/
|
|
75
|
+
function parseAsOf(asOf) {
|
|
76
|
+
if (typeof asOf !== "string" || !ISO_INSTANT_RE.test(asOf)) {
|
|
77
|
+
throw new TrustAsOfError(
|
|
78
|
+
`--as-of must be an ISO-8601 UTC instant ("YYYY-MM-DDTHH:MM:SS(.mmm)Z"), got: ${String(asOf)}`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const ms = Date.parse(asOf);
|
|
82
|
+
if (Number.isNaN(ms) || new Date(ms).toISOString() !== asOf) {
|
|
83
|
+
throw new TrustAsOfError(
|
|
84
|
+
`--as-of must be a canonical ISO-8601 UTC instant (no rolled-over/impossible fields), got: ${String(asOf)}`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return ms;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Normalize the `revocations` input into a flat array of items to evaluate. PURE. Accepts:
|
|
92
|
+
* - an ARRAY of already-parsed signed-revocation container objects (or JSON strings), OR
|
|
93
|
+
* - a single signed-revocation container object, OR
|
|
94
|
+
* - a JSON STRING that parses to either of the above (a bundle file is a JSON ARRAY of containers, or a
|
|
95
|
+
* single container object).
|
|
96
|
+
* Each element is normalized to a parsed object (a JSON string is parsed; a parse failure becomes a bogus
|
|
97
|
+
* entry marked with `_parseError` so the caller IGNORES it with a warning, never throws). This keeps the
|
|
98
|
+
* file-format flexible (one revocation, or a bundle) while the file READ stays the CLI layer's job.
|
|
99
|
+
* @param {any} revocations
|
|
100
|
+
* @returns {Array<object|{_parseError:string,_raw:any}>}
|
|
101
|
+
*/
|
|
102
|
+
function normalizeRevocationsInput(revocations) {
|
|
103
|
+
// A JSON string: parse it, then recurse on the parsed value.
|
|
104
|
+
if (typeof revocations === "string") {
|
|
105
|
+
let parsed;
|
|
106
|
+
try {
|
|
107
|
+
parsed = JSON.parse(revocations);
|
|
108
|
+
} catch (e) {
|
|
109
|
+
// A whole-file parse failure is a HARD input error (the caller handed us bytes that aren't JSON at
|
|
110
|
+
// all) — distinct from a single bad entry inside a valid array.
|
|
111
|
+
throw new TrustAsOfError(`revocations input is not valid JSON: ${e.message}`);
|
|
112
|
+
}
|
|
113
|
+
return normalizeRevocationsInput(parsed);
|
|
114
|
+
}
|
|
115
|
+
if (Array.isArray(revocations)) {
|
|
116
|
+
return revocations.map((el) => {
|
|
117
|
+
if (typeof el === "string") {
|
|
118
|
+
try {
|
|
119
|
+
return JSON.parse(el);
|
|
120
|
+
} catch (e) {
|
|
121
|
+
return { _parseError: `entry is not valid JSON: ${e.message}`, _raw: el };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return el;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
if (isPlainObject(revocations)) {
|
|
128
|
+
return [revocations];
|
|
129
|
+
}
|
|
130
|
+
throw new TrustAsOfError(
|
|
131
|
+
"revocations input must be a signed-revocation container, an array of them, or JSON text of either"
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Evaluate one already-parsed revocation entry against the subject. PURE. Returns a small classification:
|
|
137
|
+
* { kind: "applies"|"later"|"irrelevant"|"ignored", ... }
|
|
138
|
+
* - "ignored": the entry is forged/tampered/third-party/structurally-bogus — verifyRevocation REJECTED
|
|
139
|
+
* it (or it threw on a malformed container, or it failed to parse). Carries a `warning`.
|
|
140
|
+
* NEVER downgrades the verdict.
|
|
141
|
+
* - "irrelevant":a SOUND revocation, but for a DIFFERENT key than the subject. Counted, never a downgrade.
|
|
142
|
+
* - "later": a SOUND, subject-matching revocation whose revokedAt is AFTER asOf — the key was still
|
|
143
|
+
* good as of the instant; this is INFORMATIONAL context, not a downgrade.
|
|
144
|
+
* - "applies": a SOUND, subject-matching revocation whose revokedAt is AT OR BEFORE asOf — the key was
|
|
145
|
+
* ALREADY revoked as of the instant; this DOWNGRADES the verdict to REVOKED.
|
|
146
|
+
* The revokedAt comparison is `revokedAtMs <= asOfMs` (a revocation effective exactly at the as-of instant
|
|
147
|
+
* counts as revoked — the boundary is inclusive on the revoked side, matching "revoked AS OF D").
|
|
148
|
+
*/
|
|
149
|
+
function classifyRevocation(entry, subject, asOfMs) {
|
|
150
|
+
// A failed-to-parse entry: ignore with a warning (never throw, never downgrade).
|
|
151
|
+
if (entry && entry._parseError) {
|
|
152
|
+
return { kind: "ignored", warning: `ignored an unparseable revocation entry (${entry._parseError})` };
|
|
153
|
+
}
|
|
154
|
+
// Run the SOUNDNESS check through the EXISTING revocation core VERBATIM. A structurally-malformed/foreign
|
|
155
|
+
// container throws inside verifyRevocation (RevocationError) — we CATCH it and treat it as ignored, so a
|
|
156
|
+
// single bad entry can never abort the whole evaluation or downgrade the verdict.
|
|
157
|
+
let v;
|
|
158
|
+
try {
|
|
159
|
+
v = coreRevocation.verifyRevocation({ container: entry });
|
|
160
|
+
} catch (e) {
|
|
161
|
+
return { kind: "ignored", warning: `ignored a malformed/foreign revocation (${e.message})` };
|
|
162
|
+
}
|
|
163
|
+
// A SOUND container whose signature does NOT back its claims (forged/tampered/third-party) is a clean
|
|
164
|
+
// REJECTED verdict — IGNORE it with a warning. This is the load-bearing anti-grief invariant: a revocation
|
|
165
|
+
// only ever bites when it genuinely recovers to the key it claims to revoke.
|
|
166
|
+
if (!v.accepted) {
|
|
167
|
+
return {
|
|
168
|
+
kind: "ignored",
|
|
169
|
+
warning:
|
|
170
|
+
`ignored a revocation that does not verify (failed: ${v.failedChecks.join(", ")}; ` +
|
|
171
|
+
`vendorAddress ${v.vendorAddress}) — a forged/tampered/third-party revocation never downgrades trust`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
// SOUND. Is it for THIS subject? A revocation for some other key is simply irrelevant to this artifact.
|
|
175
|
+
if (v.vendorAddress !== subject) {
|
|
176
|
+
return { kind: "irrelevant", vendorAddress: v.vendorAddress };
|
|
177
|
+
}
|
|
178
|
+
// SOUND + subject-matching. Compare its self-asserted revokedAt to the as-of pivot.
|
|
179
|
+
const revokedAtMs = Date.parse(v.revokedAt); // validated canonical inside verifyRevocation
|
|
180
|
+
const detail = {
|
|
181
|
+
vendorAddress: v.vendorAddress,
|
|
182
|
+
reason: v.reason,
|
|
183
|
+
revokedAt: v.revokedAt,
|
|
184
|
+
supersededBy: v.supersededBy, // null when absent (the revocation core normalizes this)
|
|
185
|
+
};
|
|
186
|
+
if (revokedAtMs <= asOfMs) {
|
|
187
|
+
return { kind: "applies", ...detail };
|
|
188
|
+
}
|
|
189
|
+
return { kind: "later", ...detail };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* THE RECIPIENT-SIDE TRUST-DECISION-AS-OF. PURE / OFFLINE / KEY-FREE / I/O-FREE / CLOCK-FREE.
|
|
194
|
+
*
|
|
195
|
+
* Given the artifact's RECOVERED signer (`subject`), a set of producer revocation statements (`revocations`),
|
|
196
|
+
* and the instant the recipient cares about (`asOf`), decide whether the subject key was trustworthy AS OF
|
|
197
|
+
* that instant. It NEVER signs, reads a file, or touches the clock — it only re-runs the existing
|
|
198
|
+
* `verifyRevocation` core over already-in-hand bytes and compares dates.
|
|
199
|
+
*
|
|
200
|
+
* VERDICT (the `status` field):
|
|
201
|
+
* - "REVOKED": at least one SOUND, subject-matching revocation has `revokedAt <= asOf` — the key was
|
|
202
|
+
* ALREADY revoked as of the instant. This is the ONLY downgrading outcome. The verdict
|
|
203
|
+
* names the GOVERNING revocation (the EARLIEST applicable one) — its reason + revokedAt
|
|
204
|
+
* (+ supersededBy when set).
|
|
205
|
+
* - "OK": no SOUND, subject-matching revocation is effective at-or-before the as-of instant. If a
|
|
206
|
+
* SOUND, subject-matching revocation exists but is dated AFTER the as-of, `laterRevoked`
|
|
207
|
+
* is populated (an INFORMATIONAL "this key is revoked NOW, but was fine then" note) — the
|
|
208
|
+
* status STAYS "OK".
|
|
209
|
+
* - "UNEVALUABLE": the subject is the "(unrecoverable)" sentinel (or otherwise not a real address) — there
|
|
210
|
+
* is no key to evaluate revocations against. This is NEVER a downgrade by itself (the
|
|
211
|
+
* artifact's own verify verdict already handles an unrecoverable signature); it just
|
|
212
|
+
* reports that revocation evaluation could not bind to a subject.
|
|
213
|
+
*
|
|
214
|
+
* `revoked` is a convenience boolean (status === "REVOKED"). `ignored` carries the warnings for every entry
|
|
215
|
+
* that did not verify (forged/tampered/third-party/malformed/unparseable) — surfaced so a recipient SEES that
|
|
216
|
+
* a planted revocation was discarded, rather than it silently vanishing.
|
|
217
|
+
*
|
|
218
|
+
* @param {object} params
|
|
219
|
+
* @param {string} params.subject the artifact's RECOVERED signer (lowercase 0x-address, or the
|
|
220
|
+
* "(unrecoverable)" sentinel)
|
|
221
|
+
* @param {string} params.asOf the recipient's decision instant (ISO-8601 UTC)
|
|
222
|
+
* @param {any} params.revocations a signed-revocation container, an array of them, or JSON text of either
|
|
223
|
+
* @returns {{
|
|
224
|
+
* status: "OK"|"REVOKED"|"UNEVALUABLE",
|
|
225
|
+
* revoked: boolean,
|
|
226
|
+
* subject: string,
|
|
227
|
+
* asOf: string,
|
|
228
|
+
* governing: null | { vendorAddress, reason, revokedAt, supersededBy },
|
|
229
|
+
* laterRevoked: null | { vendorAddress, reason, revokedAt, supersededBy },
|
|
230
|
+
* counts: { total, applicable, later, irrelevant, ignored },
|
|
231
|
+
* ignored: string[],
|
|
232
|
+
* }}
|
|
233
|
+
*/
|
|
234
|
+
function evaluateTrustAsOf(params) {
|
|
235
|
+
if (!isPlainObject(params)) {
|
|
236
|
+
throw new TrustAsOfError("evaluateTrustAsOf requires { subject, asOf, revocations }");
|
|
237
|
+
}
|
|
238
|
+
const { subject, asOf, revocations } = params;
|
|
239
|
+
|
|
240
|
+
if (typeof subject !== "string" || subject.length === 0) {
|
|
241
|
+
throw new TrustAsOfError("evaluateTrustAsOf requires a string `subject` (the artifact's recovered signer)");
|
|
242
|
+
}
|
|
243
|
+
const asOfMs = parseAsOf(asOf); // HARD-errors on a malformed asOf
|
|
244
|
+
|
|
245
|
+
const entries = normalizeRevocationsInput(revocations); // HARD-errors on a non-JSON / wrong-type input
|
|
246
|
+
|
|
247
|
+
// A non-address subject (the "(unrecoverable)" sentinel, or any non-0x value) cannot be matched by any
|
|
248
|
+
// revocation's vendorAddress. We still evaluate every entry (so forged ones are still reported as ignored),
|
|
249
|
+
// but no SOUND revocation can ever apply, so the status is UNEVALUABLE — a clear "no key to bind to", never
|
|
250
|
+
// a silent OK that hides the fact that revocation evaluation could not run.
|
|
251
|
+
const subjectIsAddress = ADDRESS_RE.test(subject);
|
|
252
|
+
|
|
253
|
+
const applicable = []; // SOUND, subject-matching, revokedAt <= asOf
|
|
254
|
+
const later = []; // SOUND, subject-matching, revokedAt > asOf
|
|
255
|
+
let irrelevant = 0; // SOUND, different key
|
|
256
|
+
const ignored = []; // warnings for every entry that did not verify
|
|
257
|
+
|
|
258
|
+
for (const entry of entries) {
|
|
259
|
+
const c = classifyRevocation(entry, subject, asOfMs);
|
|
260
|
+
if (c.kind === "ignored") {
|
|
261
|
+
ignored.push(c.warning);
|
|
262
|
+
} else if (c.kind === "irrelevant") {
|
|
263
|
+
irrelevant += 1;
|
|
264
|
+
} else if (c.kind === "later") {
|
|
265
|
+
later.push(c);
|
|
266
|
+
} else if (c.kind === "applies") {
|
|
267
|
+
applicable.push(c);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// The GOVERNING revocation is the EARLIEST applicable one (smallest revokedAt) — the instant from which the
|
|
272
|
+
// key was no longer trustworthy. Tie-break deterministically on vendorAddress then reason so the chosen
|
|
273
|
+
// record is stable. (For a single subject every applicable revocation shares the vendorAddress, but a
|
|
274
|
+
// recipient may carry several revocations for the same key with different dates/reasons; the earliest one
|
|
275
|
+
// is the one that actually downgraded trust as of the instant.)
|
|
276
|
+
const sortByEffective = (a, b) =>
|
|
277
|
+
Date.parse(a.revokedAt) - Date.parse(b.revokedAt) ||
|
|
278
|
+
(a.vendorAddress < b.vendorAddress ? -1 : a.vendorAddress > b.vendorAddress ? 1 : 0) ||
|
|
279
|
+
(a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0);
|
|
280
|
+
|
|
281
|
+
const govern = (arr) => {
|
|
282
|
+
if (arr.length === 0) return null;
|
|
283
|
+
const [g] = arr.slice().sort(sortByEffective);
|
|
284
|
+
return {
|
|
285
|
+
vendorAddress: g.vendorAddress,
|
|
286
|
+
reason: g.reason,
|
|
287
|
+
revokedAt: g.revokedAt,
|
|
288
|
+
supersededBy: g.supersededBy,
|
|
289
|
+
};
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const governing = govern(applicable);
|
|
293
|
+
// The earliest LATER revocation (the soonest the key WILL be / IS now considered revoked) — informational.
|
|
294
|
+
const laterRevoked = governing ? null : govern(later);
|
|
295
|
+
|
|
296
|
+
let status;
|
|
297
|
+
if (governing) {
|
|
298
|
+
status = "REVOKED";
|
|
299
|
+
} else if (!subjectIsAddress) {
|
|
300
|
+
status = "UNEVALUABLE";
|
|
301
|
+
} else {
|
|
302
|
+
status = "OK";
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
status,
|
|
307
|
+
revoked: status === "REVOKED",
|
|
308
|
+
subject,
|
|
309
|
+
asOf,
|
|
310
|
+
governing,
|
|
311
|
+
laterRevoked,
|
|
312
|
+
counts: {
|
|
313
|
+
total: entries.length,
|
|
314
|
+
applicable: applicable.length,
|
|
315
|
+
later: later.length,
|
|
316
|
+
irrelevant,
|
|
317
|
+
ignored: ignored.length,
|
|
318
|
+
},
|
|
319
|
+
ignored,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Resolve the effective `--as-of` instant. PURE. When the recipient supplied one, use it (validated); when
|
|
325
|
+
* they did not, default sanely to the recipient's CURRENT decision time (`nowISO`, injected so tests are
|
|
326
|
+
* deterministic). The default answers the most common question — "is this key trustworthy RIGHT NOW?" — while
|
|
327
|
+
* the explicit `--as-of` answers the stronger "was it trustworthy when this exhibit was sealed?". A
|
|
328
|
+
* malformed explicit `--as-of` is a HARD TrustAsOfError (never silently coerced to now).
|
|
329
|
+
* @param {string|undefined|null} asOf the caller's --as-of value, if any
|
|
330
|
+
* @param {string} nowISO the recipient's current instant (ISO-8601 UTC) — injected; defaults to the wall clock
|
|
331
|
+
* @returns {{ asOf: string, defaulted: boolean }}
|
|
332
|
+
*/
|
|
333
|
+
function resolveAsOf(asOf, nowISO) {
|
|
334
|
+
if (asOf !== undefined && asOf !== null && asOf !== "") {
|
|
335
|
+
parseAsOf(asOf); // validate shape; throws on malformed
|
|
336
|
+
return { asOf, defaulted: false };
|
|
337
|
+
}
|
|
338
|
+
if (typeof nowISO !== "string") {
|
|
339
|
+
throw new TrustAsOfError("resolveAsOf requires a nowISO instant when --as-of is not given");
|
|
340
|
+
}
|
|
341
|
+
parseAsOf(nowISO); // the injected/default now must itself be canonical
|
|
342
|
+
return { asOf: nowISO, defaulted: true };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Fold a TRUST-DECISION-AS-OF onto an existing signed-verify result, OFFLINE. PURE. This is the single shared
|
|
347
|
+
* integration the FOUR verify commands call so the downgrade rule (a key revoked-before-as-of REVOKES the
|
|
348
|
+
* artifact) and the informational later-revoked note are computed ONE way. It NEVER upgrades a verdict: if
|
|
349
|
+
* the artifact's own verify already REJECTED, it stays rejected; the trust-as-of only ever ADDS a REVOKED
|
|
350
|
+
* downgrade on top of an otherwise-ACCEPTED artifact.
|
|
351
|
+
*
|
|
352
|
+
* The `subject` is the artifact's RECOVERED signer. When the artifact's signature did not even recover (the
|
|
353
|
+
* "(unrecoverable)" sentinel), no revocation can bind — the decision is UNEVALUABLE and never changes the
|
|
354
|
+
* (already-REJECTED) verdict.
|
|
355
|
+
*
|
|
356
|
+
* @param {object} params
|
|
357
|
+
* @param {object} params.result the verify result (must carry `recoveredSigner` + `accepted`)
|
|
358
|
+
* @param {any} params.revocations a signed-revocation container / array / JSON text (already in hand)
|
|
359
|
+
* @param {string} params.asOf the resolved decision instant (ISO-8601 UTC)
|
|
360
|
+
* @returns {object} a NEW result object: the original fields PLUS `trustAsOf` (the evaluateTrustAsOf block),
|
|
361
|
+
* with `accepted`/`verdict`/`failedChecks` updated when the decision is REVOKED. The original is not mutated.
|
|
362
|
+
*/
|
|
363
|
+
function applyToVerifyResult(params) {
|
|
364
|
+
if (!isPlainObject(params) || !isPlainObject(params.result)) {
|
|
365
|
+
throw new TrustAsOfError("applyToVerifyResult requires { result, revocations, asOf }");
|
|
366
|
+
}
|
|
367
|
+
const { result, revocations, asOf } = params;
|
|
368
|
+
const subject = result.recoveredSigner;
|
|
369
|
+
if (typeof subject !== "string") {
|
|
370
|
+
throw new TrustAsOfError("applyToVerifyResult: result.recoveredSigner must be a string");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const decision = evaluateTrustAsOf({ subject, asOf, revocations });
|
|
374
|
+
|
|
375
|
+
// Build a NEW result (never mutate the caller's). The trustAsOf block is ALWAYS attached when revocations
|
|
376
|
+
// were supplied (so a recipient sees the evaluation even when it changed nothing).
|
|
377
|
+
const out = { ...result, trustAsOf: decision };
|
|
378
|
+
|
|
379
|
+
if (decision.revoked) {
|
|
380
|
+
// The ONLY downgrading path: an otherwise-ACCEPTED artifact whose signer was revoked-before-as-of becomes
|
|
381
|
+
// REVOKED. We do NOT touch an already-REJECTED verdict's accepted=false; we DO flip an accepted one. The
|
|
382
|
+
// headline verdict becomes "REVOKED" (distinct from the signature-failure "REJECTED") and a named pseudo-
|
|
383
|
+
// check records WHY in failedChecks, so the existing `accepted ? 0 : 3` exit mapping yields exit 3.
|
|
384
|
+
out.accepted = false;
|
|
385
|
+
out.verdict = "REVOKED";
|
|
386
|
+
out.failedChecks = Array.isArray(result.failedChecks) ? result.failedChecks.slice() : [];
|
|
387
|
+
if (!out.failedChecks.includes("keyRevokedAsOf")) out.failedChecks.push("keyRevokedAsOf");
|
|
388
|
+
}
|
|
389
|
+
return out;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Render the human-readable TRUST-DECISION-AS-OF lines a verify command appends to its report. PURE. Returns
|
|
394
|
+
* an array of lines (no trailing blank) the caller joins/prints. Mirrors the family's per-check PASS/FAIL
|
|
395
|
+
* idiom. Surfaces: the as-of instant, the verdict (REVOKED / OK / could-not-evaluate), the GOVERNING
|
|
396
|
+
* revocation's reason + revokedAt (+ supersededBy), the informational later-revoked note, and a line PER
|
|
397
|
+
* ignored (forged/tampered/malformed) revocation so a planted one is visibly discarded, never silent.
|
|
398
|
+
* @param {object} decision the object evaluateTrustAsOf returns
|
|
399
|
+
* @param {{ defaulted?: boolean, indent?: string }} [ctx]
|
|
400
|
+
* @returns {string[]} lines
|
|
401
|
+
*/
|
|
402
|
+
function renderTrustAsOf(decision, ctx = {}) {
|
|
403
|
+
const I = ctx.indent || "";
|
|
404
|
+
const L = [];
|
|
405
|
+
const asOfNote = ctx.defaulted ? " (defaulted to now; pass --as-of <ISO> to pin the decision instant)" : "";
|
|
406
|
+
L.push(`${I}revocation check (as of ${decision.asOf})${asOfNote}:`);
|
|
407
|
+
if (decision.status === "REVOKED") {
|
|
408
|
+
const g = decision.governing;
|
|
409
|
+
L.push(
|
|
410
|
+
`${I} [REVOKED] the signing key (${g.vendorAddress}) was REVOKED as of ${g.revokedAt} ` +
|
|
411
|
+
`(reason: ${g.reason})${g.supersededBy ? `, superseded by ${g.supersededBy}` : ""} — at or before ` +
|
|
412
|
+
`the as-of instant. This artifact is NOT trustworthy as of ${decision.asOf}.`
|
|
413
|
+
);
|
|
414
|
+
} else if (decision.status === "UNEVALUABLE") {
|
|
415
|
+
L.push(
|
|
416
|
+
`${I} [skip] the signature did not recover to a key — no subject to evaluate revocations against.`
|
|
417
|
+
);
|
|
418
|
+
} else {
|
|
419
|
+
L.push(`${I} [OK] no applicable revocation: the signing key was not revoked as of ${decision.asOf}.`);
|
|
420
|
+
if (decision.laterRevoked) {
|
|
421
|
+
const lr = decision.laterRevoked;
|
|
422
|
+
L.push(
|
|
423
|
+
`${I} [note] this key (${lr.vendorAddress}) IS revoked as of ${lr.revokedAt} ` +
|
|
424
|
+
`(reason: ${lr.reason})${lr.supersededBy ? `, superseded by ${lr.supersededBy}` : ""} — AFTER your ` +
|
|
425
|
+
`as-of instant, so it does NOT downgrade THIS decision (informational).`
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
for (const w of decision.ignored) {
|
|
430
|
+
L.push(`${I} [warning] ${w}`);
|
|
431
|
+
}
|
|
432
|
+
return L;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The ONE shared CLI integration the four signed-verify commands call. It is the single place the
|
|
437
|
+
* --revocations file is read, the --as-of is resolved (defaulting to `nowISO`), the decision is computed, and
|
|
438
|
+
* folded onto the verify result. Keeping the file READ here (behind an injectable `readFile`) — rather than
|
|
439
|
+
* in each command — means the four commands stay byte-identical in behavior.
|
|
440
|
+
*
|
|
441
|
+
* It runs ONLY when `revocationsPath` is truthy; with no path it returns the result UNCHANGED and a null
|
|
442
|
+
* decision (the regression-safety contract: with no --revocations the verify commands are pre-EPIC identical).
|
|
443
|
+
*
|
|
444
|
+
* @param {object} params
|
|
445
|
+
* @param {object} params.result the verify result (carries recoveredSigner + accepted)
|
|
446
|
+
* @param {string|undefined} params.revocationsPath the --revocations file path (or falsy to skip entirely)
|
|
447
|
+
* @param {string|undefined} params.asOf the --as-of instant (or falsy to default to nowISO)
|
|
448
|
+
* @param {string} params.nowISO the recipient's current instant (injectable; default wall clock)
|
|
449
|
+
* @param {(p:string)=>string} params.readFile reads the revocations file to text (injectable for tests)
|
|
450
|
+
* @returns {{ result: object, decision: object|null, defaulted: boolean }}
|
|
451
|
+
* @throws {TrustAsOfError} on a malformed --as-of or a non-JSON revocations file
|
|
452
|
+
* @throws the underlying read error when the revocations file cannot be read
|
|
453
|
+
*/
|
|
454
|
+
function loadAndApply(params) {
|
|
455
|
+
if (!isPlainObject(params) || !isPlainObject(params.result)) {
|
|
456
|
+
throw new TrustAsOfError("loadAndApply requires { result, revocationsPath, asOf, nowISO, readFile }");
|
|
457
|
+
}
|
|
458
|
+
const { result, revocationsPath, asOf, nowISO, readFile } = params;
|
|
459
|
+
if (!revocationsPath) {
|
|
460
|
+
return { result, decision: null, defaulted: false };
|
|
461
|
+
}
|
|
462
|
+
const { asOf: effectiveAsOf, defaulted } = resolveAsOf(asOf, nowISO);
|
|
463
|
+
const text = readFile(revocationsPath); // the ONLY I/O; the caller injects fs.readFileSync
|
|
464
|
+
const out = applyToVerifyResult({ result, revocations: text, asOf: effectiveAsOf });
|
|
465
|
+
return { result: out, decision: out.trustAsOf, defaulted };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
module.exports = {
|
|
469
|
+
TrustAsOfError,
|
|
470
|
+
ISO_INSTANT_RE,
|
|
471
|
+
parseAsOf,
|
|
472
|
+
resolveAsOf,
|
|
473
|
+
normalizeRevocationsInput,
|
|
474
|
+
classifyRevocation,
|
|
475
|
+
evaluateTrustAsOf,
|
|
476
|
+
applyToVerifyResult,
|
|
477
|
+
renderTrustAsOf,
|
|
478
|
+
loadAndApply,
|
|
479
|
+
};
|