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,539 @@
1
+ "use strict";
2
+
3
+ // cli/core/revocation.js — the PURE producer-key REVOCATION core for verifyhash (EPIC-51 / T-51.1).
4
+ //
5
+ // WHY THIS EXISTS
6
+ // Every sealed/signed artifact this family mints (an evidence seal, a signed license, a dataset/parcel
7
+ // attestation, an identity card) is trusted because a vendor's signing KEY backs it. But a key can be
8
+ // compromised, leave with a contractor, or simply be rotated. Today there is NO first-class, OFFLINE-
9
+ // verifiable way for that vendor to SAY "this key is revoked as of D" — so every artifact the key ever
10
+ // signed keeps verifying as ACCEPTED forever, and a recipient has no way to ask "was this key still good
11
+ // when THIS exhibit was sealed?". The producer REVOCATION statement closes that first gap: a vendor
12
+ // SIGNS, with the SAME key that signed their evidence/licenses/cards, a small self-describing container
13
+ // that marks that key's own `vendorAddress` revoked as of a point in time, for a bounded reason, and
14
+ // OPTIONALLY names a `supersededBy` successor key. A recipient who holds the revocation can recover the
15
+ // signer and confirm it equals the revocation's OWN `vendorAddress` — all OFFLINE, no network, no key,
16
+ // no I/O.
17
+ //
18
+ // IT IS JUST ONE MORE PRODUCT ON THE SHARED SIGNED-ATTESTATION ENVELOPE.
19
+ // Exactly like the seal/license/dataset/identity-card, the revocation defines an UNSIGNED PAYLOAD (a
20
+ // versioned object: vendorAddress + a CLOSED reason + revokedAt + optional supersededBy + note), a
21
+ // canonical serializer, and a strict validator, then hands those to `cli/core/attestation.js` as the
22
+ // product framing. The attestation core does ALL the crypto: it embeds the EXACT canonical payload
23
+ // bytes as the `attestation`, attaches the detached EIP-191 signature, and later RE-DERIVES the signer
24
+ // from those bytes. There is NO new crypto here, NO new dependency, NO new scheme — `buildRevocation`
25
+ // wraps via `signAttestation`, `verifyRevocation` recovers via `verifySignedAttestation`, byte-for-byte
26
+ // the SAME shared paths the identity card uses. We REUSE core/attestation.js VERBATIM — we never fork
27
+ // the signing/recovery path.
28
+ //
29
+ // THE LOAD-BEARING SELF-CONTROL INVARIANT — a key REVOKES ITSELF.
30
+ // A revocation carries its OWN `vendorAddress` INSIDE the signed payload, so it asserts "the holder of
31
+ // THIS key declares THIS address revoked". `verifyRevocation` therefore REQUIRES recovered === the
32
+ // embedded `vendorAddress`: a revocation whose signature recovers to any OTHER key is REJECTED
33
+ // (`vendorAddressMatchesSigner` fails), never a silent accept. This is the whole point — a THIRD PARTY
34
+ // cannot revoke a key it does not control (otherwise anyone could grief any vendor by "revoking" their
35
+ // key). `buildRevocation` enforces the SAME invariant at mint time — it refuses to produce a revocation
36
+ // for an address the provisioned signer does not control — so a revocation can never round-trip into a
37
+ // false ACCEPT. This mirrors the EPIC-49 identity-card mint invariant exactly.
38
+ //
39
+ // PURE + I/O-FREE + KEY-AGNOSTIC.
40
+ // Every function here is pure: no filesystem, no clock, no network. (readRevocation parses a JSON
41
+ // STRING/object — it does NOT read a file; the file read is the CLI layer's job in T-51.3.) The only key
42
+ // handling is a passed-in ethers signer-like object (an ephemeral `Wallet.createRandom()` in tests; the
43
+ // loop NEVER holds a real key) whose private key lives ONLY inside that object — never read, persisted,
44
+ // or logged here. The `revokedAt` instant is a CALLER-supplied argument; this core never reads the system
45
+ // clock, so the same inputs always yield byte-identical bytes + verdict. PRODUCT-AGNOSTIC: this module
46
+ // requires the GENERIC attestation core, never the reverse — no back-edge.
47
+
48
+ const coreAttestation = require("./attestation");
49
+ const { getAddress } = require("ethers");
50
+
51
+ // On-disk schema discriminators. The revocation carries its OWN kind + version (distinct from every
52
+ // seal/license/manifest/identity-card kind) so a random JSON file, a license, a seal, or a card is never
53
+ // misread as a revocation.
54
+ const REVOCATION_KIND = "vh-key-revocation";
55
+ const REVOCATION_SCHEMA_VERSION = 1;
56
+ const SUPPORTED_REVOCATION_SCHEMA_VERSIONS = Object.freeze([1]);
57
+
58
+ // The SIGNED-container framing (the detached-signature envelope kind) — its OWN discriminator.
59
+ const SIGNED_REVOCATION_KIND = "vh-key-revocation-signed";
60
+ const SIGNED_REVOCATION_SCHEMA_VERSION = 1;
61
+ const SUPPORTED_SIGNED_REVOCATION_SCHEMA_VERSIONS = Object.freeze([1]);
62
+
63
+ // The CLOSED reason set. A revocation declares WHY the key is being revoked; an out-of-set value is a HARD
64
+ // build/validate error (never silently honored), exactly like the identity card's closed productLine and
65
+ // the license core's closed entitlement table. Frozen + a derived sorted list so error messages are
66
+ // deterministic. These are the lifecycle events that make a key's past signatures suspect (compromised /
67
+ // retired) or simply rotated (superseded / rotated) — a small, fixed vocabulary a recipient can reason about.
68
+ const REVOCATION_REASONS = Object.freeze(["compromised", "rotated", "superseded", "retired"]);
69
+ const REVOCATION_REASON_SET = Object.freeze(REVOCATION_REASONS.slice().sort());
70
+
71
+ // A claimed 0x-address INSIDE the payload: 0x + 40 LOWERCASE hex chars. Lowercase-only for the SAME
72
+ // byte-determinism reason the attestation core lowercases the signer — an EIP-55-checksummed address is the
73
+ // canonical address in a DIFFERENT encoding, so accepting it verbatim would let one vendor serialize two
74
+ // ways. A caller holding a checksummed address lowercases it before building the revocation (buildRevocation
75
+ // normalizes for them via getAddress, so a checksummed input is accepted and canonicalized).
76
+ const ADDRESS_RE = /^0x[0-9a-f]{40}$/;
77
+
78
+ // A strict ISO-8601 UTC instant ("YYYY-MM-DDTHH:MM:SS(.mmm)Z"). Same canonical instant grammar the identity
79
+ // card pins publishedAt to, so two logically-identical revocations serialize to identical bytes. We pin the
80
+ // SHAPE here and require the canonical millis round-trip below.
81
+ const ISO_INSTANT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
82
+
83
+ // The in-band trust caveat — stated ONCE so the human + JSON paths agree and the boundary can never drift.
84
+ // It is the load-bearing honesty of the artifact: a revocation proves the KEY-HOLDER SAID "revoked as of D";
85
+ // it is NOT a trusted wall-clock timestamp (the `revokedAt` instant rides the human-owned timestamp trust-
86
+ // root, STRATEGY.md P-3), and it is NOT a legal opinion.
87
+ const REVOCATION_TRUST_NOTE =
88
+ "This is a verifyhash producer KEY REVOCATION: the holder of `vendorAddress`'s key SIGNED it, declaring " +
89
+ "that address REVOKED as of `revokedAt` for `reason` (optionally superseded by `supersededBy`). verify " +
90
+ "RE-DERIVES the signer from these exact bytes and REQUIRES it to equal `vendorAddress` — a key revokes " +
91
+ "ITSELF; a third party cannot revoke a key it does not control. It proves the KEY-HOLDER's SIGNED CLAIM " +
92
+ 'ONLY: `revokedAt` is the holder\'s self-asserted instant, NOT a trusted TIMESTAMP (it rides the human-' +
93
+ "owned timestamp trust-root, STRATEGY.md P-3), and this is NOT a legal opinion.";
94
+
95
+ const SIGNED_REVOCATION_TRUST_NOTE =
96
+ "This is a SIGNED verifyhash key-revocation container: it WRAPS (never edits) the EXACT canonical " +
97
+ "revocation bytes in `attestation` and attaches a detached EIP-191 signature. verifyRevocation " +
98
+ "RE-DERIVES the signer from those bytes and pins it to the embedded `vendorAddress` — it never trusts " +
99
+ "the file's own claims. Every caveat of the embedded revocation applies. " +
100
+ REVOCATION_TRUST_NOTE;
101
+
102
+ // A dedicated error type so callers/tests catch ONE revocation error for the HARD validation failures
103
+ // (a closed-field/closed-reason/malformed-address/non-canonical-date violation, an out-of-control mint). An
104
+ // ordinary verify REJECT is NOT thrown — it is a clean verdict (verdict: "REJECTED"), exactly like the rest
105
+ // of the family.
106
+ class RevocationError extends Error {
107
+ constructor(message) {
108
+ super(message);
109
+ this.name = "RevocationError";
110
+ }
111
+ }
112
+
113
+ function isPlainObject(v) {
114
+ return v != null && typeof v === "object" && !Array.isArray(v);
115
+ }
116
+
117
+ // The CLOSED field set of an UNSIGNED revocation payload. An UNKNOWN/extraneous key is a HARD error (never
118
+ // silently dropped) so the revocation's shape ossifies with exactly these fields. `kind`/`schemaVersion`/
119
+ // `note` are the framing the core fixes; the rest is the producer-supplied revocation. `supersededBy` is
120
+ // OPTIONAL — it is a member of the closed set (so a present-but-malformed value HARD-errors) but absence is
121
+ // allowed (validated below).
122
+ const REVOCATION_FIELDS = Object.freeze([
123
+ "kind",
124
+ "schemaVersion",
125
+ "note",
126
+ "vendorAddress",
127
+ "reason",
128
+ "revokedAt",
129
+ "supersededBy",
130
+ ]);
131
+
132
+ /**
133
+ * STRICT structural validation of an UNSIGNED revocation payload. Throws a RevocationError on the FIRST
134
+ * problem (named + localized); returns the object unchanged on success. NEVER half-accepts and NEVER fills
135
+ * defaults. This is the `validateUnsigned` the attestation core re-runs on the embedded payload (the
136
+ * wrap-don't-edit invariant), so a signed container can never smuggle a malformed/edited revocation.
137
+ *
138
+ * REJECTS (HARD): a non-object; a wrong kind/schemaVersion/note; an UNKNOWN/extraneous field; a missing/
139
+ * malformed (not lowercase-0x) vendorAddress; an out-of-set reason; a non-canonical-ISO revokedAt; a
140
+ * present-but-malformed (not lowercase-0x) supersededBy. `supersededBy` may be ABSENT (optional); when
141
+ * present it must be a valid lowercase-0x address.
142
+ *
143
+ * @param {any} obj
144
+ * @returns {object} the same object, if valid
145
+ */
146
+ function validateRevocation(obj) {
147
+ if (!isPlainObject(obj)) {
148
+ throw new RevocationError("revocation payload must be a JSON object");
149
+ }
150
+
151
+ // CLOSED FIELD SET: every key must be one of REVOCATION_FIELDS. An unknown/extraneous key HARD-errors
152
+ // (never silently kept) so the revocation can never carry a smuggled, unvalidated field.
153
+ for (const key of Object.keys(obj)) {
154
+ if (!REVOCATION_FIELDS.includes(key)) {
155
+ throw new RevocationError(
156
+ `revocation has an unknown field: ${JSON.stringify(key)} ` +
157
+ `(the closed field set is ${JSON.stringify(REVOCATION_FIELDS)})`
158
+ );
159
+ }
160
+ }
161
+
162
+ if (obj.kind !== REVOCATION_KIND) {
163
+ throw new RevocationError(
164
+ `not a verifyhash revocation (kind: ${JSON.stringify(obj.kind)}; expected ${JSON.stringify(REVOCATION_KIND)})`
165
+ );
166
+ }
167
+ if (!SUPPORTED_REVOCATION_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
168
+ throw new RevocationError(
169
+ `unsupported revocation schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
170
+ `(this build understands ${JSON.stringify(SUPPORTED_REVOCATION_SCHEMA_VERSIONS)})`
171
+ );
172
+ }
173
+ if (obj.note !== REVOCATION_TRUST_NOTE) {
174
+ throw new RevocationError(
175
+ "revocation `note` must be the standing REVOCATION_TRUST_NOTE (caveat must not drift)"
176
+ );
177
+ }
178
+
179
+ // vendorAddress — the address the revocation BINDS to the signing key (the key that revokes ITSELF). A
180
+ // lowercase 0x-address (checksummed/mixed-case is rejected here for byte-determinism; buildRevocation
181
+ // lowercases a checksummed input).
182
+ if (typeof obj.vendorAddress !== "string" || !ADDRESS_RE.test(obj.vendorAddress)) {
183
+ throw new RevocationError(
184
+ "revocation vendorAddress must be a 0x-prefixed 20-byte LOWERCASE-hex address " +
185
+ `(checksummed/mixed-case rejected for byte-determinism — lowercase it first), got: ${String(obj.vendorAddress)}`
186
+ );
187
+ }
188
+
189
+ // reason — a single value drawn from the CLOSED REVOCATION_REASONS set. An out-of-set value HARD-errors.
190
+ if (typeof obj.reason !== "string" || !REVOCATION_REASON_SET.includes(obj.reason)) {
191
+ throw new RevocationError(
192
+ `revocation reason must be one of the closed set ${JSON.stringify(REVOCATION_REASON_SET)}, ` +
193
+ `got: ${JSON.stringify(obj.reason)}`
194
+ );
195
+ }
196
+
197
+ // revokedAt — a strict, CANONICAL ISO-8601 UTC instant. The regex pins the SHAPE; the millis round-trip
198
+ // FORCES the `.mmm` form and REJECTS every rolled-over/impossible instant (e.g. Feb-29 in a non-leap year),
199
+ // exactly like the identity card, so a self-asserted date can never silently coerce.
200
+ if (typeof obj.revokedAt !== "string" || !ISO_INSTANT_RE.test(obj.revokedAt)) {
201
+ throw new RevocationError(
202
+ `revocation revokedAt must be an ISO-8601 UTC instant ("YYYY-MM-DDTHH:MM:SS(.mmm)Z"), got: ${String(obj.revokedAt)}`
203
+ );
204
+ }
205
+ const ms = Date.parse(obj.revokedAt);
206
+ if (Number.isNaN(ms) || new Date(ms).toISOString() !== obj.revokedAt) {
207
+ throw new RevocationError(
208
+ `revocation revokedAt must be a canonical ISO-8601 UTC instant ("YYYY-MM-DDTHH:MM:SS.mmmZ", ` +
209
+ `millis required, no rolled-over/impossible fields), got: ${String(obj.revokedAt)}`
210
+ );
211
+ }
212
+
213
+ // supersededBy — OPTIONAL. When ABSENT (the key is undefined OR not present), that is allowed: a plain
214
+ // revocation supersedes the key with nothing. When PRESENT it must be a valid lowercase-0x address (a
215
+ // present-but-malformed / checksummed / non-string value HARD-errors — never silently dropped). We do NOT
216
+ // permit `null` to mean "absent": a null value is a present-but-invalid address, rejected, so the wire
217
+ // form has exactly one encoding of "no successor" (the key simply omitted).
218
+ if (Object.prototype.hasOwnProperty.call(obj, "supersededBy") && obj.supersededBy !== undefined) {
219
+ if (typeof obj.supersededBy !== "string" || !ADDRESS_RE.test(obj.supersededBy)) {
220
+ throw new RevocationError(
221
+ "revocation supersededBy, when present, must be a 0x-prefixed 20-byte LOWERCASE-hex address " +
222
+ `(checksummed/mixed-case rejected for byte-determinism — lowercase it first), got: ${String(obj.supersededBy)}`
223
+ );
224
+ }
225
+ }
226
+
227
+ return obj;
228
+ }
229
+
230
+ /**
231
+ * Serialize a validated UNSIGNED revocation payload to its canonical, byte-deterministic bytes: a FIXED key
232
+ * order, NO insignificant whitespace, a single trailing newline. `supersededBy` is emitted ONLY when present
233
+ * (an absent successor is OMITTED, never written as null) so the wire form has exactly one encoding of "no
234
+ * successor". This is the EXACT byte sequence the envelope signs over and verifyRevocation re-derives the
235
+ * signer from, so two logically-identical revocations sign identically.
236
+ * @param {object} payload a validated revocation payload
237
+ * @returns {string} the canonical serialization (newline-terminated)
238
+ */
239
+ function serializeRevocation(payload) {
240
+ validateRevocation(payload);
241
+ const canonical = {
242
+ kind: payload.kind,
243
+ schemaVersion: payload.schemaVersion,
244
+ note: payload.note,
245
+ vendorAddress: payload.vendorAddress,
246
+ reason: payload.reason,
247
+ revokedAt: payload.revokedAt,
248
+ };
249
+ // OPTIONAL `supersededBy` is appended LAST and ONLY when present — so the canonical bytes of a revocation
250
+ // without a successor never carry a null/empty slot, and one with a successor always carries it in the
251
+ // SAME fixed position.
252
+ if (Object.prototype.hasOwnProperty.call(payload, "supersededBy") && payload.supersededBy !== undefined) {
253
+ canonical.supersededBy = payload.supersededBy;
254
+ }
255
+ return JSON.stringify(canonical) + "\n";
256
+ }
257
+
258
+ /**
259
+ * Assemble + strictly validate an UNSIGNED revocation payload from caller fields. PURE. This is the payload
260
+ * `buildRevocation` then wraps in the signed envelope. Splitting it out lets a caller hold/inspect the
261
+ * unsigned revocation before signing (and validates it the SAME way the embedded payload is re-validated on
262
+ * read). A checksummed/mixed-case vendorAddress/supersededBy is normalized to lowercase here (a syntactically
263
+ * invalid address HARD-errors); every other field passes through to validateRevocation.
264
+ *
265
+ * @param {object} params { vendorAddress, reason, revokedAt, [supersededBy] }
266
+ * @returns {object} a validated, canonicalized revocation payload
267
+ */
268
+ function buildRevocationPayload(params) {
269
+ if (!isPlainObject(params)) {
270
+ throw new RevocationError(
271
+ "buildRevocationPayload requires a { vendorAddress, reason, revokedAt, [supersededBy] } object"
272
+ );
273
+ }
274
+ // Normalize a checksummed/mixed-case vendorAddress to canonical lowercase (so a caller may paste an EIP-55
275
+ // address). A syntactically invalid address is a HARD error (named, no surprise).
276
+ let vendorAddress;
277
+ try {
278
+ vendorAddress = getAddress(params.vendorAddress).toLowerCase();
279
+ } catch (_e) {
280
+ throw new RevocationError(
281
+ `revocation vendorAddress must be a valid 0x-address, got: ${String(params.vendorAddress)}`
282
+ );
283
+ }
284
+
285
+ const payload = {
286
+ kind: REVOCATION_KIND,
287
+ schemaVersion: REVOCATION_SCHEMA_VERSION,
288
+ note: REVOCATION_TRUST_NOTE,
289
+ vendorAddress,
290
+ reason: params.reason,
291
+ revokedAt: params.revokedAt,
292
+ };
293
+
294
+ // OPTIONAL supersededBy — only set it when the caller supplied a non-undefined value, and normalize a
295
+ // checksummed/mixed-case successor to lowercase the same way (a syntactically invalid successor HARD-errors
296
+ // with a named, supersededBy-specific message; a null/empty/wrong-type value flows to validateRevocation
297
+ // which rejects it as a present-but-malformed successor).
298
+ if (params.supersededBy !== undefined) {
299
+ if (typeof params.supersededBy === "string") {
300
+ try {
301
+ payload.supersededBy = getAddress(params.supersededBy).toLowerCase();
302
+ } catch (_e) {
303
+ throw new RevocationError(
304
+ `revocation supersededBy, when present, must be a valid 0x-address, got: ${String(params.supersededBy)}`
305
+ );
306
+ }
307
+ } else {
308
+ // A non-string, non-undefined supersededBy (e.g. null, a number) is a present-but-malformed successor.
309
+ payload.supersededBy = params.supersededBy;
310
+ }
311
+ }
312
+
313
+ // validateRevocation throws a named error on any malformed/unknown/missing field — never silently accepts.
314
+ // Return the canonicalized payload (re-parsed from serializeRevocation) so the in-memory object's field
315
+ // order matches the signed bytes exactly.
316
+ validateRevocation(payload);
317
+ return JSON.parse(serializeRevocation(payload));
318
+ }
319
+
320
+ // The SIGNED-attestation framing passed to the GENERIC attestation core. The core does ALL the crypto + the
321
+ // wrap-don't-edit invariant; this supplies ONLY the revocation framing + the unsigned codec. SAME pattern
322
+ // the seal/license/dataset/identity-card use — frozen so it can never be mutated mid-flight.
323
+ const SIGNED_REVOCATION_CFG = Object.freeze({
324
+ kind: SIGNED_REVOCATION_KIND,
325
+ schemaVersion: SIGNED_REVOCATION_SCHEMA_VERSION,
326
+ supportedSchemaVersions: SUPPORTED_SIGNED_REVOCATION_SCHEMA_VERSIONS,
327
+ note: SIGNED_REVOCATION_TRUST_NOTE,
328
+ label: "signed key revocation",
329
+ validateUnsigned: validateRevocation,
330
+ serializeUnsigned: serializeRevocation,
331
+ });
332
+
333
+ /**
334
+ * Mint a SIGNED revocation container. Builds + validates the unsigned payload (canonicalizing vendorAddress/
335
+ * supersededBy), then routes it + the caller's signer through the SHARED `signAttestation` core, which signs
336
+ * the EXACT canonical bytes (EIP-191 personal_sign) and wraps + validates the container.
337
+ *
338
+ * THE LOAD-BEARING SELF-CONTROL INVARIANT — the key MUST control the address it revokes. After signing, the
339
+ * recovered signer is required to EQUAL the embedded `vendorAddress`. A provisioned key that does NOT control
340
+ * the claimed address HARD-errors (RevocationError) — the loop refuses to mint a revocation for an address
341
+ * the signer cannot back (a third party cannot revoke a key it does not control). So a built revocation
342
+ * ALWAYS round-trips to ACCEPT by construction.
343
+ *
344
+ * NO key handling here — the key lives only inside the signer object (an ephemeral Wallet in tests).
345
+ *
346
+ * @param {object} params { vendorAddress, reason, revokedAt, [supersededBy] }
347
+ * @param {object} signer an ethers signer-like object: async getAddress() + signMessage()
348
+ * @returns {Promise<object>} the validated signed-revocation container
349
+ */
350
+ async function buildRevocation(params, signer) {
351
+ const payload = buildRevocationPayload(params);
352
+ const container = await coreAttestation.signAttestation(
353
+ { attestation: payload, signer },
354
+ SIGNED_REVOCATION_CFG
355
+ );
356
+ // Enforce the self-control invariant: recover the signer from the just-signed bytes and require it to
357
+ // EQUAL the embedded vendorAddress. signAttestation already pinned the container's CLAIMED signer to the
358
+ // signer's own address, so this catches the genuine "revoking an address this key does not control" case —
359
+ // never a silent mint of an unbacked revocation.
360
+ const recovered = coreAttestation.recoverSigner(container); // lowercase 0x-address
361
+ if (recovered !== payload.vendorAddress) {
362
+ throw new RevocationError(
363
+ "refusing to mint a revocation the signing key does not control: the recovered signer " +
364
+ `(${recovered}) does NOT equal the revocation's vendorAddress (${payload.vendorAddress}). A key ` +
365
+ "revokes ITSELF — sign with the key that controls vendorAddress."
366
+ );
367
+ }
368
+ return container;
369
+ }
370
+
371
+ /** Strictly validate a parsed SIGNED revocation container — thin wrapper over the shared core. */
372
+ function validateSignedRevocation(obj) {
373
+ return coreAttestation.validateSignedAttestation(obj, SIGNED_REVOCATION_CFG);
374
+ }
375
+
376
+ /** Serialize a SIGNED revocation container to its canonical bytes — thin wrapper over the shared core. */
377
+ function serializeSignedRevocation(container) {
378
+ return coreAttestation.serializeSignedAttestation(container, SIGNED_REVOCATION_CFG);
379
+ }
380
+
381
+ /**
382
+ * Read + strictly validate a SIGNED revocation container (JSON STRING or object). PURE — it parses bytes
383
+ * already in hand; it does NOT read a file (the file read is the CLI layer's job, T-51.3). A parse error is
384
+ * a RevocationError (never a raw SyntaxError); a malformed/corrupt container is rejected by the shared
385
+ * validator, never half-accepted.
386
+ * @param {string|object} input
387
+ * @returns {object} the validated container
388
+ */
389
+ function readRevocation(input) {
390
+ let obj;
391
+ if (typeof input === "string") {
392
+ try {
393
+ obj = JSON.parse(input);
394
+ } catch (e) {
395
+ throw new RevocationError(`revocation container is not valid JSON: ${e.message}`);
396
+ }
397
+ } else if (isPlainObject(input)) {
398
+ obj = input;
399
+ } else {
400
+ throw new RevocationError(
401
+ "readRevocation requires a JSON string or a signed-revocation container object"
402
+ );
403
+ }
404
+ try {
405
+ coreAttestation.validateSignedAttestation(obj, SIGNED_REVOCATION_CFG);
406
+ } catch (e) {
407
+ throw new RevocationError(e.message);
408
+ }
409
+ return obj;
410
+ }
411
+
412
+ /**
413
+ * Verify (purely, OFFLINE) a SIGNED revocation container — the STRICT, PURE verify path. It recovers the
414
+ * signer from the embedded canonical revocation bytes + signature and:
415
+ * (1) confirms it equals the container's CLAIMED `signer` (signatureMatchesSigner — ALWAYS run);
416
+ * (2) confirms it equals the revocation's OWN embedded `vendorAddress` (vendorAddressMatchesSigner —
417
+ * ALWAYS run; this is the load-bearing SELF-CONTROL check: a key revokes ITSELF);
418
+ * (3) OPTIONALLY pins it to an EXPECTED signer (`expectedSigner` — signerMatchesExpected, run ONLY when
419
+ * present).
420
+ * The verdict is ACCEPTED only when EVERY requested check passes; a forged/mismatched/tampered revocation
421
+ * is a clean REJECTED — NEVER a silent pass, NEVER a thrown error for an ordinary rejection.
422
+ *
423
+ * It is OFFLINE / key-free / network-free / I/O-free: it recovers a PUBLIC address from a signature, holds
424
+ * no private key, contacts nothing, writes nothing, and mutates the container NOT at all. The returned shape
425
+ * EXTENDS the FAMILY verdict shape (the byte-for-byte fields `verifySignedAttestation` returns, including
426
+ * checks/failedChecks/recoveredSigner/claimedSigner so a future indexer/UI depends on ONE stable shape) with
427
+ * the revocation-specific vendorAddress/reason/revokedAt/supersededBy + checks.vendorAddressMatchesSigner.
428
+ *
429
+ * STRUCTURAL SAFETY: the container is validated FIRST (validateSignedRevocation); a structurally invalid
430
+ * container HARD-errors (RevocationError) before any recovery, so an ordinary REJECTED verdict only ever
431
+ * describes a SOUND revocation whose signature simply doesn't back its claims.
432
+ *
433
+ * @param {object} params
434
+ * @param {object} params.container a signed-revocation container (from buildRevocation/readRevocation)
435
+ * @param {string} [params.expectedSigner] OPTIONAL expected signer 0x-address; checked when present
436
+ * @returns {{
437
+ * verdict: "ACCEPTED"|"REJECTED",
438
+ * accepted: boolean,
439
+ * recoveredSigner: string,
440
+ * claimedSigner: string,
441
+ * vendorAddress: string,
442
+ * reason: string,
443
+ * revokedAt: string,
444
+ * supersededBy: string|null,
445
+ * scheme: string,
446
+ * checks: {
447
+ * signatureMatchesSigner: boolean,
448
+ * vendorAddressMatchesSigner: boolean,
449
+ * signerMatchesExpected: boolean|null,
450
+ * },
451
+ * expectedSigner: string|null,
452
+ * failedChecks: string[],
453
+ * }}
454
+ */
455
+ function verifyRevocation(params) {
456
+ if (!isPlainObject(params)) {
457
+ throw new RevocationError("verifyRevocation requires { container, [expectedSigner] }");
458
+ }
459
+ // Validate the container FIRST (and re-validate the embedded revocation) so an ordinary REJECTED verdict
460
+ // only ever describes a STRUCTURALLY SOUND revocation. A corrupt/foreign container is a HARD error, never
461
+ // a verdict.
462
+ const container = validateSignedRevocation(params.container);
463
+ const revocation = JSON.parse(container.attestation); // validated above (lowercase vendorAddress etc.)
464
+ const vendorAddress = revocation.vendorAddress;
465
+
466
+ // Route the signature recovery + the OPTIONAL expected-signer pin through the SHARED generic core (the
467
+ // SAME path the identity card uses). We do NOT pass expectedCanonical — the revocation-specific binding is
468
+ // the vendorAddress self-control check below, computed from the embedded revocation.
469
+ const att = coreAttestation.verifySignedAttestation({
470
+ container,
471
+ expectedSigner: params.expectedSigner,
472
+ });
473
+
474
+ // (2) The load-bearing SELF-CONTROL check: the RECOVERED signer must equal the revocation's OWN
475
+ // vendorAddress. We pin against the RECOVERED signer (not the merely-CLAIMED one), so a revocation that
476
+ // claims a vendorAddress its signature does not back is REJECTED — a third party cannot revoke a key it
477
+ // does not control. When the signature is unrecoverable, recoveredSigner is the "(unrecoverable)"
478
+ // sentinel, which can never equal a lowercase 0x-address — so this is false (REJECT).
479
+ const vendorAddressMatchesSigner = att.recoveredSigner === vendorAddress;
480
+
481
+ // The verdict is ACCEPTED only when EVERY requested check passes. signatureMatchesSigner +
482
+ // vendorAddressMatchesSigner are ALWAYS required; signerMatchesExpected only when expectedSigner was given
483
+ // (null = not requested, never fails the gate). We REBUILD failedChecks (the core's list does not know
484
+ // about vendorAddressMatchesSigner) so the verdict, the checks, and failedChecks can never disagree.
485
+ const failedChecks = [];
486
+ if (!att.checks.signatureMatchesSigner) failedChecks.push("signatureMatchesSigner");
487
+ if (!vendorAddressMatchesSigner) failedChecks.push("vendorAddressMatchesSigner");
488
+ if (att.checks.signerMatchesExpected === false) failedChecks.push("signerMatchesExpected");
489
+ const accepted = failedChecks.length === 0;
490
+
491
+ return {
492
+ verdict: accepted ? "ACCEPTED" : "REJECTED",
493
+ accepted,
494
+ recoveredSigner: att.recoveredSigner,
495
+ claimedSigner: att.claimedSigner,
496
+ vendorAddress,
497
+ reason: revocation.reason,
498
+ revokedAt: revocation.revokedAt,
499
+ // Surface the OPTIONAL successor explicitly as null when absent (so a machine reader gets a stable field
500
+ // rather than an undefined that JSON drops).
501
+ supersededBy: Object.prototype.hasOwnProperty.call(revocation, "supersededBy")
502
+ ? revocation.supersededBy
503
+ : null,
504
+ scheme: att.scheme,
505
+ checks: {
506
+ signatureMatchesSigner: att.checks.signatureMatchesSigner,
507
+ vendorAddressMatchesSigner,
508
+ signerMatchesExpected: att.checks.signerMatchesExpected,
509
+ },
510
+ expectedSigner: att.expectedSigner,
511
+ failedChecks,
512
+ };
513
+ }
514
+
515
+ module.exports = {
516
+ // kinds + closed sets
517
+ REVOCATION_KIND,
518
+ REVOCATION_SCHEMA_VERSION,
519
+ SUPPORTED_REVOCATION_SCHEMA_VERSIONS,
520
+ SIGNED_REVOCATION_KIND,
521
+ SIGNED_REVOCATION_SCHEMA_VERSION,
522
+ SUPPORTED_SIGNED_REVOCATION_SCHEMA_VERSIONS,
523
+ REVOCATION_REASONS,
524
+ REVOCATION_REASON_SET,
525
+ REVOCATION_FIELDS,
526
+ REVOCATION_TRUST_NOTE,
527
+ SIGNED_REVOCATION_TRUST_NOTE,
528
+ RevocationError,
529
+ // unsigned-payload codec
530
+ validateRevocation,
531
+ serializeRevocation,
532
+ buildRevocationPayload,
533
+ // signed container
534
+ buildRevocation,
535
+ validateSignedRevocation,
536
+ serializeSignedRevocation,
537
+ readRevocation,
538
+ verifyRevocation,
539
+ };