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.
Files changed (154) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +883 -0
  3. package/cli/abi/ContributionRegistry.json +881 -0
  4. package/cli/agent.js +2173 -0
  5. package/cli/anchor-artifact.js +853 -0
  6. package/cli/anchor.js +400 -0
  7. package/cli/claim.js +881 -0
  8. package/cli/core/agent-commit.js +448 -0
  9. package/cli/core/agent-session.js +598 -0
  10. package/cli/core/anchor-binding.js +663 -0
  11. package/cli/core/attestation.js +580 -0
  12. package/cli/core/evidence-plans.js +495 -0
  13. package/cli/core/fixtures/evidence-plans/baseline.json +19 -0
  14. package/cli/core/fulfill-intake.js +1082 -0
  15. package/cli/core/go-live-preflight.js +481 -0
  16. package/cli/core/license.js +534 -0
  17. package/cli/core/manifest.js +243 -0
  18. package/cli/core/packetseal.js +591 -0
  19. package/cli/core/registryArtifact.js +49 -0
  20. package/cli/core/revocation.js +539 -0
  21. package/cli/core/rfc3161.js +389 -0
  22. package/cli/core/timestamp.js +482 -0
  23. package/cli/core/trust-asof.js +479 -0
  24. package/cli/dataset.js +2950 -0
  25. package/cli/evidence.js +2227 -0
  26. package/cli/fulfill-webhook-http.js +438 -0
  27. package/cli/git.js +220 -0
  28. package/cli/hash.js +550 -0
  29. package/cli/identity.js +1072 -0
  30. package/cli/journal-cli.js +1110 -0
  31. package/cli/journal-log.js +454 -0
  32. package/cli/journal.js +334 -0
  33. package/cli/lineage.js +447 -0
  34. package/cli/list.js +287 -0
  35. package/cli/parcel.js +1509 -0
  36. package/cli/proof.js +578 -0
  37. package/cli/prove.js +300 -0
  38. package/cli/receipt.js +631 -0
  39. package/cli/registry.js +331 -0
  40. package/cli/reputation.js +344 -0
  41. package/cli/revocation.js +495 -0
  42. package/cli/serve-verify-http.js +298 -0
  43. package/cli/serve-verify.js +333 -0
  44. package/cli/show.js +339 -0
  45. package/cli/verify.js +383 -0
  46. package/cli/vh.js +3927 -0
  47. package/docs/ADOPT.md +183 -0
  48. package/docs/ADOPTION.json +11 -0
  49. package/docs/AGENTTRACE.md +247 -0
  50. package/docs/ANCHORING.md +167 -0
  51. package/docs/AUDIT.md +55 -0
  52. package/docs/CONFORMANCE.md +107 -0
  53. package/docs/DATALEDGER.md +638 -0
  54. package/docs/DECIDE.md +47 -0
  55. package/docs/DECISIONS-PENDING.md +27 -0
  56. package/docs/DEPLOY-PUBLIC-SITE.md +301 -0
  57. package/docs/ENGINE-LEDGER.json +12 -0
  58. package/docs/EVIDENCE.md +519 -0
  59. package/docs/GO-LIVE.md +66 -0
  60. package/docs/IDENTITY.md +123 -0
  61. package/docs/INDEPENDENT-VERIFICATION.md +377 -0
  62. package/docs/INTEGRITY-JOURNAL.md +337 -0
  63. package/docs/KEY-LIFECYCLE.md +179 -0
  64. package/docs/LICENSING.md +46 -0
  65. package/docs/LINEAGE.md +307 -0
  66. package/docs/LOOP-AUDIT-2026-07-03.json +580 -0
  67. package/docs/LOOP-HARDENING-PLAN.md +44 -0
  68. package/docs/MERKLE-LEAVES.md +113 -0
  69. package/docs/METRICS.jsonl +31 -0
  70. package/docs/MORNING.md +204 -0
  71. package/docs/PILOT.md +444 -0
  72. package/docs/PROOFPARCEL.md +227 -0
  73. package/docs/PROOFS.md +262 -0
  74. package/docs/RECEIPTS.md +341 -0
  75. package/docs/REPUTATION.md +158 -0
  76. package/docs/SDK.md +301 -0
  77. package/docs/STRATEGY-ARCHIVE.md +5055 -0
  78. package/docs/SUPERVISOR-RUNBOOK.md +52 -0
  79. package/docs/TRUST-BOUNDARIES.md +335 -0
  80. package/docs/TRUSTLEDGER.md +1976 -0
  81. package/docs/USAGE-BUDGET.json +121 -0
  82. package/docs/VERIFY-SERVICE.md +168 -0
  83. package/index.js +160 -0
  84. package/package.json +41 -0
  85. package/trustledger/build-standalone.js +796 -0
  86. package/trustledger/cli.js +3179 -0
  87. package/trustledger/close.js +391 -0
  88. package/trustledger/corpus.js +159 -0
  89. package/trustledger/dist/BUILD-PROVENANCE.json +99 -0
  90. package/trustledger/dist/trustledger-standalone.html +6197 -0
  91. package/trustledger/dist/trustledger-standalone.html.sha256 +1 -0
  92. package/trustledger/door-core.js +442 -0
  93. package/trustledger/fixtures/bank.csv +7 -0
  94. package/trustledger/fixtures/bank.malformed.csv +3 -0
  95. package/trustledger/fixtures/bank.noalias.csv +5 -0
  96. package/trustledger/fixtures/bank.ofx +34 -0
  97. package/trustledger/fixtures/bank.real.csv +5 -0
  98. package/trustledger/fixtures/corpus/_shared/prior-close.json +22 -0
  99. package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/inputs.json +14 -0
  100. package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/meta.json +7 -0
  101. package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/inputs.json +14 -0
  102. package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/meta.json +7 -0
  103. package/trustledger/fixtures/corpus/continuity-break--benign-twin/inputs.json +15 -0
  104. package/trustledger/fixtures/corpus/continuity-break--benign-twin/meta.json +7 -0
  105. package/trustledger/fixtures/corpus/continuity-break--out-of-trust/inputs.json +15 -0
  106. package/trustledger/fixtures/corpus/continuity-break--out-of-trust/meta.json +7 -0
  107. package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/inputs.json +13 -0
  108. package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/meta.json +7 -0
  109. package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/inputs.json +13 -0
  110. package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/meta.json +7 -0
  111. package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/inputs.json +15 -0
  112. package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/meta.json +7 -0
  113. package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/inputs.json +15 -0
  114. package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/meta.json +7 -0
  115. package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/inputs.json +16 -0
  116. package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/meta.json +7 -0
  117. package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/inputs.json +13 -0
  118. package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/meta.json +7 -0
  119. package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/inputs.json +13 -0
  120. package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/meta.json +7 -0
  121. package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/inputs.json +13 -0
  122. package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/meta.json +7 -0
  123. package/trustledger/fixtures/e2e/bank.aliased.csv +4 -0
  124. package/trustledger/fixtures/e2e/bank.csv +4 -0
  125. package/trustledger/fixtures/e2e/bank.nsf.csv +4 -0
  126. package/trustledger/fixtures/e2e/quickbooks.csv +6 -0
  127. package/trustledger/fixtures/e2e/quickbooks.nsf.csv +8 -0
  128. package/trustledger/fixtures/e2e/rentroll.csv +6 -0
  129. package/trustledger/fixtures/e2e/rentroll.nsf.csv +8 -0
  130. package/trustledger/fixtures/e2e/rentroll.short.csv +5 -0
  131. package/trustledger/fixtures/plans/baseline.json +25 -0
  132. package/trustledger/fixtures/plans/price-binding.example.json +27 -0
  133. package/trustledger/fixtures/policy/ambiguous-deposit-example.json +12 -0
  134. package/trustledger/fixtures/policy/baseline.json +19 -0
  135. package/trustledger/fixtures/policy/ca-example.json +12 -0
  136. package/trustledger/fixtures/policy/negative-tenant-ledger-example.json +12 -0
  137. package/trustledger/fixtures/policy/owner-overdraw-example.json +12 -0
  138. package/trustledger/fixtures/quickbooks.csv +7 -0
  139. package/trustledger/fixtures/quickbooks.real.csv +5 -0
  140. package/trustledger/fixtures/rentroll.csv +6 -0
  141. package/trustledger/fixtures/rentroll.real.csv +4 -0
  142. package/trustledger/ingest.js +1163 -0
  143. package/trustledger/lib/policy-bundled-loader.js +44 -0
  144. package/trustledger/lib/sha256-vendored.js +227 -0
  145. package/trustledger/license.js +563 -0
  146. package/trustledger/match.js +551 -0
  147. package/trustledger/plans.js +551 -0
  148. package/trustledger/policy.js +398 -0
  149. package/trustledger/public/index.html +512 -0
  150. package/trustledger/reconcile.js +1486 -0
  151. package/trustledger/report.js +887 -0
  152. package/trustledger/seal.js +854 -0
  153. package/trustledger/server.js +391 -0
  154. 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
+ };