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,580 @@
1
+ "use strict";
2
+
3
+ // cli/core/attestation.js — the GENERIC signed-attestation ENVELOPE engine for the product family.
4
+ //
5
+ // WHY THIS EXISTS
6
+ // Every verifyhash provenance product (DataLedger, ProofParcel, AttestKit later) needs the SAME thing
7
+ // once it has a canonical UNSIGNED identity payload: a way to WRAP that payload in a detached
8
+ // signature WITHOUT editing it, then re-read and cryptographically verify the wrap. The container
9
+ // builder/reader, the supported `scheme` list, the signer-recovery, and the wrap-don't-edit invariant
10
+ // are IDENTICAL across products; only the container `kind` and the in-band trust `note` differ. This
11
+ // module is the SINGLE, tested implementation of that envelope machinery; each product is a THIN
12
+ // adapter that supplies its OWN container `kind`/`note` plus its OWN unsigned payload validator +
13
+ // serializer (so the core stays product-agnostic and never requires a product module — no back-edge).
14
+ //
15
+ // THE SCHEME (detached, NOT EIP-712)
16
+ // `eip191-personal-sign` means: the signer ran `personal_sign` (EIP-191) over the EXACT canonical
17
+ // unsigned bytes (the UTF-8 of the embedded `attestation` string, including its single trailing
18
+ // newline). A detached signature — NOT EIP-712 typed data — so the signed message IS the canonical
19
+ // payload bytes verbatim, with no separate domain/struct encoding to drift from them.
20
+ //
21
+ // WRAP-DON'T-EDIT INVARIANT
22
+ // The embedded UNSIGNED payload is re-parsed and re-validated by the PRODUCT's own unsigned validator
23
+ // (injected as `cfg.validateUnsigned`), and the embedded string is required byte-for-byte equal to
24
+ // `cfg.serializeUnsigned(embedded)` — so wrapping adds a vouch, it NEVER edits the thing vouched for,
25
+ // and the bytes that were signed are unambiguous.
26
+
27
+ const { verifyMessage, getAddress, Wallet } = require("ethers");
28
+ const fs = require("fs");
29
+
30
+ // The detached signature schemes this build understands. Each is an EXPLICIT, documented value so a
31
+ // reader knows EXACTLY what bytes were signed and how. `eip191-personal-sign` = EIP-191 personal_sign
32
+ // over the canonical UNSIGNED attestation bytes (a 65-byte r||s||v secp256k1 signature). Shared across
33
+ // the whole product family so the supported-scheme set can never diverge between products.
34
+ const SIGNED_ATTESTATION_SCHEMES = Object.freeze(["eip191-personal-sign"]);
35
+
36
+ // A 0x-prefixed, 0x-only, EVEN-length, non-empty hex string for the signature. eip191-personal-sign is
37
+ // specifically a 65-byte (r||s||v) secp256k1 signature -> exactly 130 hex chars. Strict by scheme below.
38
+ //
39
+ // CANONICAL CASE (byte-determinism). These accept ONLY lowercase hex. The signature block is the
40
+ // HUMAN-supplied part of the container and the part most likely to arrive EIP-55-checksummed (mixed
41
+ // case) or upper-cased. If we accepted mixed case and round-tripped it verbatim, two structurally
42
+ // identical containers over the SAME logical signature would serialize to DIFFERENT bytes — breaking the
43
+ // byte-determinism a future indexer/UI keys on. We REJECT non-canonical case on read/validate (rather
44
+ // than silently normalizing) so the wire format ossifies with one — and only one — byte encoding.
45
+ const HEXSTR_RE = /^0x([0-9a-f]{2})+$/;
46
+ const EIP191_SIG_RE = /^0x[0-9a-f]{130}$/; // 65 bytes: r(32) || s(32) || v(1)
47
+
48
+ // A claimed 0x-address: 0x + 40 LOWERCASE hex chars. The container records the CLAIMED signer; the
49
+ // recovery step (recoverSigner below) derives the actual signer from the signature. Lowercase-only for
50
+ // the same byte-determinism reason as the signature value: an EIP-55-checksummed (mixed-case) signer is
51
+ // the canonical address in a DIFFERENT encoding, so accepting it verbatim would let the same signer
52
+ // serialize two ways. A caller holding a checksummed address lowercases it before building the container.
53
+ const ADDRESS_RE = /^0x[0-9a-f]{40}$/;
54
+
55
+ /**
56
+ * Internal: assert a product passed a structurally complete signed-container config. The injected
57
+ * `validateUnsigned`/`serializeUnsigned` are how the core enforces the wrap-don't-edit invariant WITHOUT
58
+ * knowing anything product-specific (so there is no `require("../dataset")` back-edge).
59
+ */
60
+ function _requireCfg(cfg) {
61
+ if (!cfg || typeof cfg !== "object") {
62
+ throw new Error("attestation core requires a { kind, schemaVersion, supportedSchemaVersions, note, validateUnsigned, serializeUnsigned } config");
63
+ }
64
+ if (typeof cfg.kind !== "string" || cfg.kind.length === 0) {
65
+ throw new Error("attestation core config requires a non-empty string `kind`");
66
+ }
67
+ if (!Array.isArray(cfg.supportedSchemaVersions) || cfg.supportedSchemaVersions.length === 0) {
68
+ throw new Error("attestation core config requires a non-empty `supportedSchemaVersions` array");
69
+ }
70
+ if (typeof cfg.note !== "string") {
71
+ throw new Error("attestation core config requires a string `note` (the in-band trust caveat)");
72
+ }
73
+ if (typeof cfg.validateUnsigned !== "function" || typeof cfg.serializeUnsigned !== "function") {
74
+ throw new Error(
75
+ "attestation core config requires `validateUnsigned` and `serializeUnsigned` functions (the product's UNSIGNED payload codec)"
76
+ );
77
+ }
78
+ if (typeof cfg.label !== "string" && cfg.label !== undefined) {
79
+ throw new Error("attestation core config `label`, when present, must be a string");
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Strictly validate a parsed SIGNED-attestation container against a product's framing. Throws an Error
85
+ * describing the FIRST problem; never mutates and never fills defaults. REJECTS: a wrong kind/
86
+ * schemaVersion, a wrong `note`, a non-string embedded `attestation`, a missing/non-object `signature`
87
+ * block, an unknown `scheme`, a malformed `signer` address, a missing/!hex `signature` value, or an
88
+ * embedded `attestation` that does not re-validate as a sound UNSIGNED payload (i.e. it must STILL be
89
+ * signed:false/signature:null — wrapping never edits). It NEVER half-accepts.
90
+ *
91
+ * @param {any} obj
92
+ * @param {object} cfg the product's signed-container framing (see buildSignedAttestation)
93
+ * @returns {object} the same object, if valid
94
+ */
95
+ function validateSignedAttestation(obj, cfg) {
96
+ _requireCfg(cfg);
97
+ const label = cfg.label || "signed dataset attestation";
98
+ if (obj == null || typeof obj !== "object" || Array.isArray(obj)) {
99
+ throw new Error(`${label} must be a JSON object`);
100
+ }
101
+ if (obj.kind !== cfg.kind) {
102
+ throw new Error(
103
+ `not a verifyhash ${label} (kind: ${JSON.stringify(obj.kind)}; expected ` +
104
+ `${JSON.stringify(cfg.kind)})`
105
+ );
106
+ }
107
+ if (!cfg.supportedSchemaVersions.includes(obj.schemaVersion)) {
108
+ throw new Error(
109
+ `unsupported ${label} schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
110
+ `(this build understands ${JSON.stringify(cfg.supportedSchemaVersions)})`
111
+ );
112
+ }
113
+ if (obj.note !== cfg.note) {
114
+ throw new Error(`${label} note must be the standing SIGNED_ATTESTATION_TRUST_NOTE`);
115
+ }
116
+ // The embedded UNSIGNED payload is carried as the EXACT canonical bytes serializeUnsigned emits — a
117
+ // STRING, so the signed-over bytes are unambiguous. Re-parse and re-validate it with the PRODUCT's
118
+ // strict unsigned validator: it must STILL be signed:false/signature:null. This is the wrap-don't-edit
119
+ // invariant — a signed container can never smuggle an edited or already-"signed" payload.
120
+ if (typeof obj.attestation !== "string") {
121
+ throw new Error(
122
+ `${label} must embed the canonical UNSIGNED attestation as a string \`attestation\``
123
+ );
124
+ }
125
+ let embedded;
126
+ try {
127
+ embedded = JSON.parse(obj.attestation);
128
+ } catch (e) {
129
+ throw new Error(`embedded attestation is not valid JSON: ${e.message}`);
130
+ }
131
+ // Re-validate the embedded payload by the PRODUCT's unsigned validator (throws on signed:true etc.).
132
+ cfg.validateUnsigned(embedded);
133
+ // Re-serialize the embedded payload and require the embedded STRING to be byte-identical to the
134
+ // canonical form. This pins the embedded bytes to EXACTLY what serializeUnsigned emits — the bytes
135
+ // that were signed over — so no insignificant-whitespace / reordered variant can sneak in.
136
+ if (obj.attestation !== cfg.serializeUnsigned(embedded)) {
137
+ throw new Error(
138
+ "embedded attestation is not in canonical form (the signed-over bytes must be byte-for-byte " +
139
+ "serializeAttestation's output)"
140
+ );
141
+ }
142
+
143
+ const sig = obj.signature;
144
+ if (sig == null || typeof sig !== "object" || Array.isArray(sig)) {
145
+ throw new Error(`${label} signature must be a { scheme, signer, signature } object`);
146
+ }
147
+ if (!SIGNED_ATTESTATION_SCHEMES.includes(sig.scheme)) {
148
+ throw new Error(
149
+ `unknown signature scheme: ${JSON.stringify(sig.scheme)} ` +
150
+ `(this build understands ${JSON.stringify(SIGNED_ATTESTATION_SCHEMES)})`
151
+ );
152
+ }
153
+ if (typeof sig.signer !== "string" || !ADDRESS_RE.test(sig.signer)) {
154
+ throw new Error(
155
+ `signature signer must be a 0x-prefixed 20-byte LOWERCASE-hex address ` +
156
+ `(checksummed/mixed-case rejected for byte-determinism — lowercase it first), got: ${String(sig.signer)}`
157
+ );
158
+ }
159
+ if (typeof sig.signature !== "string" || !HEXSTR_RE.test(sig.signature)) {
160
+ throw new Error(
161
+ `signature value must be a 0x-prefixed LOWERCASE-hex string ` +
162
+ `(mixed/upper case rejected for byte-determinism), got: ${String(sig.signature)}`
163
+ );
164
+ }
165
+ // Per-scheme shape: eip191-personal-sign is a 65-byte r||s||v secp256k1 signature.
166
+ if (sig.scheme === "eip191-personal-sign" && !EIP191_SIG_RE.test(sig.signature)) {
167
+ throw new Error(
168
+ `eip191-personal-sign signature must be a 65-byte (r||s||v) 0x-hex string, got length ${sig.signature.length}`
169
+ );
170
+ }
171
+ return obj;
172
+ }
173
+
174
+ /**
175
+ * Assemble + validate a SIGNED-attestation container from a validated UNSIGNED payload and a detached
176
+ * signature triple, PARAMETERIZED by the product's container framing. PURE: it performs NO signing and
177
+ * NO key handling — the loop never holds a key. It embeds the EXACT canonical unsigned bytes
178
+ * (cfg.serializeUnsigned(attestation)) as a string so the signed-over bytes are unambiguous, then
179
+ * attaches { scheme, signer, signature } and strictly validates the whole container.
180
+ *
181
+ * @param {object} params
182
+ * @param {object} params.attestation a validated UNSIGNED payload (re-validated via cfg.validateUnsigned)
183
+ * @param {string} params.scheme one of SIGNED_ATTESTATION_SCHEMES (e.g. "eip191-personal-sign")
184
+ * @param {string} params.signer the claimed 0x-address of the signer
185
+ * @param {string} params.signature the 0x-hex detached signature over cfg.serializeUnsigned(attestation)
186
+ * @param {object} cfg the product's signed-container framing
187
+ * @returns {object} a validated signed-attestation container
188
+ */
189
+ function buildSignedAttestation(params, cfg) {
190
+ _requireCfg(cfg);
191
+ if (!params || typeof params !== "object") {
192
+ throw new Error("buildSignedAttestation requires { attestation, scheme, signer, signature }");
193
+ }
194
+ const { attestation, scheme, signer, signature } = params;
195
+ // The embedded payload must itself be a sound UNSIGNED payload before we wrap it (re-validate so a
196
+ // programmatic caller that hand-built one is checked too). validateUnsigned rejects signed:true.
197
+ cfg.validateUnsigned(attestation);
198
+ // Embed the EXACT canonical bytes — the string serializeUnsigned emits — so the signed-over bytes are
199
+ // byte-for-byte unambiguous.
200
+ const container = {
201
+ kind: cfg.kind,
202
+ schemaVersion: cfg.schemaVersion,
203
+ note: cfg.note,
204
+ attestation: cfg.serializeUnsigned(attestation),
205
+ signature: { scheme, signer, signature },
206
+ };
207
+ validateSignedAttestation(container, cfg);
208
+ return container;
209
+ }
210
+
211
+ /**
212
+ * Sign a validated UNSIGNED payload with a caller-supplied signer and WRAP it into a validated signed
213
+ * container — the single, tested place that turns a payload + key-holder into the detached-signature
214
+ * envelope, parameterized by the product's framing.
215
+ *
216
+ * KEY HYGIENE (why a signer OBJECT, never a raw key). This helper takes an ethers signer-like object
217
+ * (exposing async `getAddress()` + `signMessage(bytes|string)` — e.g. an ethers `Wallet`). It NEVER
218
+ * accepts a raw private-key string, NEVER persists a key, and NEVER logs one: the key lives only inside
219
+ * the caller's signer object. Loading a key from a keystore/env/HSM and constructing that signer is the
220
+ * CLI layer's job (T-19.2); this core stays key-agnostic.
221
+ *
222
+ * WHAT IS SIGNED (byte-for-byte). It re-validates the unsigned payload via `cfg.validateUnsigned` and
223
+ * serializes it to the EXACT canonical bytes with `cfg.serializeUnsigned` — the SAME string
224
+ * `recoverSigner` later runs `verifyMessage` over (including the trailing newline). It then runs
225
+ * `signer.signMessage(canonicalBytes)` (EIP-191 personal_sign), reads `signer.getAddress()`, lowercases
226
+ * it, and routes the triple through the EXISTING `buildSignedAttestation` so the container is assembled
227
+ * AND strictly validated by the one shared path (no new container assembly here). The result therefore
228
+ * ROUND-TRIPS by construction: verifySignedAttestation recovers exactly this signer over exactly these
229
+ * bytes, and binding against `cfg.serializeUnsigned(attestation)` passes.
230
+ *
231
+ * The embedded UNSIGNED payload is WRAPPED, never edited — it stays signed:false/signature:null (the
232
+ * wrap-don't-edit invariant, enforced by buildSignedAttestation re-validating it).
233
+ *
234
+ * @param {object} params
235
+ * @param {object} params.attestation a validated UNSIGNED payload (re-validated via cfg.validateUnsigned)
236
+ * @param {object} params.signer an ethers signer-like object: async getAddress() + signMessage(bytes|string)
237
+ * @param {object} cfg the product's signed-container framing
238
+ * @returns {Promise<object>} the validated signed-attestation container
239
+ */
240
+ async function signAttestation(params, cfg) {
241
+ _requireCfg(cfg);
242
+ if (!params || typeof params !== "object") {
243
+ throw new Error("signAttestation requires { attestation, signer }");
244
+ }
245
+ const { attestation, signer } = params;
246
+ if (!signer || (typeof signer !== "object" && typeof signer !== "function")) {
247
+ throw new Error(
248
+ "signAttestation requires a `signer` object exposing getAddress() + signMessage() (e.g. an ethers Wallet); a raw private-key string is NOT accepted"
249
+ );
250
+ }
251
+ if (typeof signer.getAddress !== "function" || typeof signer.signMessage !== "function") {
252
+ throw new Error(
253
+ "signAttestation `signer` must expose getAddress() and signMessage() (an ethers signer-like object)"
254
+ );
255
+ }
256
+ // (a) Re-validate the unsigned payload and serialize it to the EXACT canonical bytes — the same string
257
+ // recoverSigner runs verifyMessage over (byte-for-byte, including the trailing newline). We validate
258
+ // FIRST so we never ask the signer to sign a malformed/already-"signed" payload.
259
+ cfg.validateUnsigned(attestation);
260
+ const canonicalBytes = cfg.serializeUnsigned(attestation);
261
+ // (b) EIP-191 personal_sign over exactly those bytes. The key never leaves the signer object.
262
+ const signature = await signer.signMessage(canonicalBytes);
263
+ // (c) Read the signer's address, lowercase it (the container records the CLAIMED signer in canonical
264
+ // lowercase), and route through the EXISTING builder so the container is assembled AND strictly
265
+ // validated by the one shared path — no separate container assembly here.
266
+ const signerAddress = (await signer.getAddress()).toLowerCase();
267
+ return buildSignedAttestation(
268
+ { attestation, scheme: "eip191-personal-sign", signer: signerAddress, signature },
269
+ cfg
270
+ );
271
+ }
272
+
273
+ /**
274
+ * Resolve a HUMAN-supplied private key from EXACTLY ONE source — an env var (`keyEnv`, read via
275
+ * `process.env[keyEnv]`) or a file the human created (`keyFile`, read with fs) — and construct an
276
+ * in-process ethers `Wallet` from it. This is the ONE place the CLI sign-path turns a caller-provisioned
277
+ * key into a signer object; the key is read, used to build the Wallet, and then exists ONLY inside that
278
+ * Wallet (the raw string is never returned, persisted, or logged).
279
+ *
280
+ * KEY HYGIENE (load-bearing). EXACTLY ONE of `keyEnv`/`keyFile` must be supplied: neither, both, a
281
+ * missing env var, an unreadable file, or a malformed/zero key HARD-ERRORS with a clear, actionable
282
+ * message — and the message NEVER includes the key material (only the SOURCE: the var name or the path).
283
+ * The key is trimmed of surrounding whitespace/newlines (so a key file written by `echo`/an editor works),
284
+ * a bare 64-hex key is accepted (0x is prefixed for it), and an all-zero key is rejected (it is not a
285
+ * usable signer and is a common "empty placeholder" mistake). All validation happens BEFORE any signing.
286
+ *
287
+ * The Wallet is NOT given a provider — signing an attestation is purely offline (EIP-191 personal_sign),
288
+ * needs no network, and must never be able to touch a chain.
289
+ *
290
+ * @param {object} params
291
+ * @param {string} [params.keyEnv] name of an env var holding the private key (read via process.env)
292
+ * @param {string} [params.keyFile] path to a file the human created holding the private key
293
+ * @returns {{ wallet: object, source: string }} the in-process Wallet + a human SOURCE label (no key)
294
+ */
295
+ function loadSigningWallet(params) {
296
+ if (!params || typeof params !== "object") {
297
+ throw new Error("loadSigningWallet requires { keyEnv } or { keyFile }");
298
+ }
299
+ const { keyEnv, keyFile } = params;
300
+ const hasEnv = keyEnv !== undefined && keyEnv !== null;
301
+ const hasFile = keyFile !== undefined && keyFile !== null;
302
+
303
+ // EXACTLY ONE key source. Neither and both are BOTH hard errors (a clear, actionable message), so the
304
+ // human is never surprised about WHICH key signed (or that nothing was provided).
305
+ if (!hasEnv && !hasFile) {
306
+ throw new Error(
307
+ "no signing key: pass EXACTLY ONE of --key-env <VAR> (read process.env[VAR]) or " +
308
+ "--key-file <path> (a key file YOU created). The key must be one you provisioned outside this tool."
309
+ );
310
+ }
311
+ if (hasEnv && hasFile) {
312
+ throw new Error(
313
+ "--key-env and --key-file are mutually exclusive; pass EXACTLY ONE signing-key source"
314
+ );
315
+ }
316
+
317
+ // Read the raw key from the single chosen source. The error messages name only the SOURCE (the env var
318
+ // name or the file path) — NEVER the key material.
319
+ let raw;
320
+ let source;
321
+ if (hasEnv) {
322
+ if (typeof keyEnv !== "string" || keyEnv.length === 0) {
323
+ throw new Error("--key-env requires a non-empty environment-variable NAME");
324
+ }
325
+ source = `env:${keyEnv}`;
326
+ const fromEnv = process.env[keyEnv];
327
+ if (fromEnv === undefined || fromEnv === "") {
328
+ throw new Error(
329
+ `environment variable ${keyEnv} is not set (or empty); it must hold the signing private key`
330
+ );
331
+ }
332
+ raw = fromEnv;
333
+ } else {
334
+ if (typeof keyFile !== "string" || keyFile.length === 0) {
335
+ throw new Error("--key-file requires a non-empty file PATH");
336
+ }
337
+ source = `file:${keyFile}`;
338
+ try {
339
+ raw = fs.readFileSync(keyFile, "utf8");
340
+ } catch (e) {
341
+ // Surface the OS error (ENOENT/EACCES…) but never the key — the file was unreadable, so there is no
342
+ // key to leak here anyway.
343
+ throw new Error(`cannot read --key-file ${keyFile}: ${e.message}`);
344
+ }
345
+ }
346
+
347
+ // Normalize: trim surrounding whitespace/newlines (a key file from `echo`/an editor has a trailing \n),
348
+ // and accept a bare 64-hex key by prefixing 0x. Then validate WITHOUT echoing the key on failure.
349
+ let key = String(raw).trim();
350
+ if (key.length === 0) {
351
+ throw new Error(`signing key from ${source} is empty after trimming whitespace`);
352
+ }
353
+ if (/^[0-9a-fA-F]{64}$/.test(key)) key = "0x" + key;
354
+
355
+ // Reject an all-zero key explicitly (a common empty-placeholder mistake; ethers would also reject it,
356
+ // but we give a clearer, key-free message). Compare case-insensitively, with or without 0x.
357
+ const stripped = key.toLowerCase().startsWith("0x") ? key.slice(2) : key;
358
+ if (/^0{64}$/.test(stripped)) {
359
+ throw new Error(
360
+ `signing key from ${source} is the all-zero key, which is not a usable signer ` +
361
+ "(provision a real key outside this tool)"
362
+ );
363
+ }
364
+
365
+ let wallet;
366
+ try {
367
+ wallet = new Wallet(key);
368
+ } catch (e) {
369
+ // ethers' message can be verbose; it does NOT echo the key, but we replace it with a fixed, key-free
370
+ // message naming only the SOURCE so nothing about the key material can ever reach stderr/logs.
371
+ throw new Error(
372
+ `signing key from ${source} is not a valid private key (expected a 32-byte 0x-hex secp256k1 key)`
373
+ );
374
+ }
375
+ // The key now lives ONLY inside `wallet`; `key`/`raw` go out of scope when this function returns.
376
+ return { wallet, source };
377
+ }
378
+
379
+ /**
380
+ * Serialize a signed-attestation container to its canonical, byte-deterministic bytes: a FIXED top-level
381
+ * (and signature-block) key order, NO insignificant whitespace, a single trailing newline. Two runs over
382
+ * the same inputs produce an identical string.
383
+ * @param {object} container a validated signed-attestation container
384
+ * @param {object} cfg the product's signed-container framing
385
+ * @returns {string} the canonical serialization (newline-terminated)
386
+ */
387
+ function serializeSignedAttestation(container, cfg) {
388
+ validateSignedAttestation(container, cfg);
389
+ const canonical = {
390
+ kind: container.kind,
391
+ schemaVersion: container.schemaVersion,
392
+ note: container.note,
393
+ // The embedded canonical UNSIGNED bytes (a string) — JSON.stringify escapes it, preserving the exact
394
+ // bytes including the embedded trailing newline.
395
+ attestation: container.attestation,
396
+ signature: {
397
+ scheme: container.signature.scheme,
398
+ signer: container.signature.signer,
399
+ signature: container.signature.signature,
400
+ },
401
+ };
402
+ return JSON.stringify(canonical) + "\n";
403
+ }
404
+
405
+ /**
406
+ * Read, parse, and STRICTLY validate the signed-attestation container at `signedPath` against a
407
+ * product's framing. Round-trips with serializeSignedAttestation. Throws on a missing file or invalid
408
+ * JSON too. The `label` (default "signed dataset attestation") parameterizes only the human noun in the
409
+ * I/O error messages so DataLedger's strings stay byte-identical.
410
+ *
411
+ * @param {string} signedPath
412
+ * @param {object} cfg the product's signed-container framing
413
+ * @returns {object} the validated container
414
+ */
415
+ function readSignedAttestation(signedPath, cfg) {
416
+ _requireCfg(cfg);
417
+ const fs = require("fs");
418
+ const label = cfg.label || "signed dataset attestation";
419
+ if (!signedPath || typeof signedPath !== "string") {
420
+ throw new Error("readSignedAttestation requires a signed attestation file path");
421
+ }
422
+ let raw;
423
+ try {
424
+ raw = fs.readFileSync(signedPath, "utf8");
425
+ } catch (e) {
426
+ throw new Error(`cannot read ${label} at ${signedPath}: ${e.message}`);
427
+ }
428
+ let obj;
429
+ try {
430
+ obj = JSON.parse(raw);
431
+ } catch (e) {
432
+ throw new Error(`${label} at ${signedPath} is not valid JSON: ${e.message}`);
433
+ }
434
+ return validateSignedAttestation(obj, cfg);
435
+ }
436
+
437
+ /**
438
+ * Recover the signing address from a signed-attestation container's embedded canonical bytes + signature
439
+ * per the declared `scheme`. PURE: no I/O, no key, no network. For `eip191-personal-sign` this is ethers'
440
+ * `verifyMessage(<embedded canonical bytes>, signature)` — EIP-191 personal_sign recovery over the EXACT
441
+ * bytes that were signed. Returns the recovered address as a LOWERCASE 0x-hex string. Throws on an
442
+ * unknown scheme (defense-in-depth: validateSignedAttestation already rejects one) or an unrecoverable
443
+ * signature. Product-agnostic — the embedded bytes are whatever the container carries.
444
+ *
445
+ * @param {object} container a validated signed-attestation container
446
+ * @returns {string} the recovered signer address, 0x-prefixed lowercase
447
+ */
448
+ function recoverSigner(container) {
449
+ const { scheme, signature } = container.signature;
450
+ if (scheme === "eip191-personal-sign") {
451
+ // The signed message IS the embedded canonical UNSIGNED bytes verbatim (the string, including its
452
+ // single trailing newline). verifyMessage runs EIP-191 personal_sign recovery over exactly those bytes.
453
+ const recovered = verifyMessage(container.attestation, signature);
454
+ return recovered.toLowerCase();
455
+ }
456
+ throw new Error(
457
+ `cannot recover signer for unknown signature scheme: ${JSON.stringify(scheme)} ` +
458
+ `(this build understands ${JSON.stringify(SIGNED_ATTESTATION_SCHEMES)})`
459
+ );
460
+ }
461
+
462
+ /**
463
+ * Verify (purely, OFFLINE) a signed-attestation container: recover the signer from the embedded canonical
464
+ * bytes + signature and confirm it equals the container's CLAIMED `signer`; OPTIONALLY pin it to an
465
+ * EXPECTED signer (`expectedSigner`); OPTIONALLY confirm the signature binds a caller's own item
466
+ * (`expectedCanonical` — the canonical UNSIGNED bytes the caller recomputed from their own data) by
467
+ * requiring them byte-identical to the embedded payload. The verdict is ACCEPTED only when EVERY
468
+ * requested check passes.
469
+ *
470
+ * No I/O, no provider, no key, no network. Throws only on an unrecoverable signature when the scheme is
471
+ * unknown; a recovered address that simply doesn't match is a clean REJECTED (a normal verdict).
472
+ *
473
+ * This is the GENERIC verify core. Products supply the canonical bytes to bind against (computed from
474
+ * THEIR own item) rather than the core knowing how to build them.
475
+ *
476
+ * @param {object} params
477
+ * @param {object} params.container a validated signed-attestation container
478
+ * @param {string} [params.expectedSigner] OPTIONAL expected signer 0x-address; checked when present
479
+ * @param {string} [params.expectedCanonical] OPTIONAL canonical UNSIGNED bytes to bind; checked when present
480
+ * @returns {{
481
+ * verdict: "ACCEPTED"|"REJECTED",
482
+ * accepted: boolean,
483
+ * recoveredSigner: string,
484
+ * claimedSigner: string,
485
+ * scheme: string,
486
+ * checks: {
487
+ * signatureMatchesSigner: boolean,
488
+ * signerMatchesExpected: boolean|null,
489
+ * manifestBindsAttestation: boolean|null,
490
+ * },
491
+ * expectedSigner: string|null,
492
+ * manifestChecked: boolean,
493
+ * failedChecks: string[],
494
+ * }}
495
+ */
496
+ function verifySignedAttestation(params) {
497
+ if (!params || typeof params !== "object") {
498
+ throw new Error("verifySignedAttestation requires { container, [expectedSigner], [expectedCanonical] }");
499
+ }
500
+ const { container, expectedSigner, expectedCanonical } = params;
501
+
502
+ const claimedSigner = container.signature.signer; // validated lowercase 0x-address
503
+ const scheme = container.signature.scheme;
504
+
505
+ // (b) Recover the signer from the embedded canonical bytes + signature, and confirm it equals the
506
+ // container's CLAIMED `signer`. A signature that does not recover to the claimed signer means the
507
+ // `signer` label is unbacked — a clean check failure (REJECTED), not an error.
508
+ //
509
+ // A TAMPERED signature can be not merely WRONG but UNRECOVERABLE: a corrupted (r,s,v) may have no
510
+ // valid secp256k1 point, in which case ethers' verifyMessage throws. That is still a caller-facing
511
+ // REJECTED verdict, NOT a crash — so we catch it and treat it as a failed signature check (the
512
+ // recovered signer is the explicit "(unrecoverable)" sentinel, never a real address). An unknown
513
+ // scheme is a different (structural) failure and is re-thrown — validateSignedAttestation already
514
+ // rejects it, so this is defense-in-depth that should never fire for a read container.
515
+ let recoveredSigner;
516
+ let signatureMatchesSigner;
517
+ try {
518
+ recoveredSigner = recoverSigner(container);
519
+ signatureMatchesSigner = recoveredSigner === claimedSigner.toLowerCase();
520
+ } catch (e) {
521
+ if (/unknown signature scheme/.test(e.message)) throw e;
522
+ recoveredSigner = "(unrecoverable)";
523
+ signatureMatchesSigner = false;
524
+ }
525
+
526
+ // (c) OPTIONAL pin: confirm the recovered signer equals the EXPECTED address the caller pinned.
527
+ // Normalize the expected address (accept checksummed/mixed-case via getAddress, then lowercase) so a
528
+ // caller can paste an EIP-55 address. null = not requested.
529
+ let signerMatchesExpected = null;
530
+ let normalizedExpected = null;
531
+ if (expectedSigner !== undefined && expectedSigner !== null) {
532
+ normalizedExpected = getAddress(expectedSigner).toLowerCase();
533
+ // Pin against the RECOVERED signer (not the merely-claimed one): the caller pins WHO actually signed.
534
+ signerMatchesExpected = recoveredSigner === normalizedExpected;
535
+ }
536
+
537
+ // (d) OPTIONAL binding: require the caller-supplied canonical UNSIGNED bytes byte-identical to the
538
+ // embedded (signed-over) payload. This proves the signature binds the item the caller actually
539
+ // holds, not some other one. null = not requested.
540
+ let manifestBindsAttestation = null;
541
+ if (expectedCanonical !== undefined && expectedCanonical !== null) {
542
+ manifestBindsAttestation = expectedCanonical === container.attestation;
543
+ }
544
+
545
+ // Verdict: ACCEPTED only when EVERY REQUESTED check passes. The signature-vs-signer check is ALWAYS
546
+ // requested; the other two only when their flag was given (null = not requested, never fails the gate).
547
+ const failedChecks = [];
548
+ if (!signatureMatchesSigner) failedChecks.push("signatureMatchesSigner");
549
+ if (signerMatchesExpected === false) failedChecks.push("signerMatchesExpected");
550
+ if (manifestBindsAttestation === false) failedChecks.push("manifestBindsAttestation");
551
+ const accepted = failedChecks.length === 0;
552
+
553
+ return {
554
+ verdict: accepted ? "ACCEPTED" : "REJECTED",
555
+ accepted,
556
+ recoveredSigner,
557
+ claimedSigner: claimedSigner.toLowerCase(),
558
+ scheme,
559
+ checks: {
560
+ signatureMatchesSigner,
561
+ signerMatchesExpected,
562
+ manifestBindsAttestation,
563
+ },
564
+ expectedSigner: normalizedExpected,
565
+ manifestChecked: manifestBindsAttestation !== null,
566
+ failedChecks,
567
+ };
568
+ }
569
+
570
+ module.exports = {
571
+ SIGNED_ATTESTATION_SCHEMES,
572
+ validateSignedAttestation,
573
+ buildSignedAttestation,
574
+ signAttestation,
575
+ loadSigningWallet,
576
+ serializeSignedAttestation,
577
+ readSignedAttestation,
578
+ recoverSigner,
579
+ verifySignedAttestation,
580
+ };