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,534 @@
1
+ "use strict";
2
+
3
+ // cli/core/license.js — the GENERIC, product-agnostic SIGNED-ENTITLEMENT (license) engine.
4
+ //
5
+ // WHY THIS EXISTS
6
+ // verifyhash is growing into a FAMILY of provenance products. Each one that sells a paid surface needs
7
+ // the SAME thing: a way for a VENDOR to mint a `*.vhlicense.json` that the CLI/server can verify
8
+ // OFFLINE — no license server, no network call, no key on the customer's machine — strictly answering
9
+ // "did OUR vendor key sign this?" and "is it in-window, and what does it entitle?". The license PAYLOAD
10
+ // shape (a versioned object: id/customer/plan + a CLOSED set of entitlement flags + an [issuedAt,
11
+ // expiresAt] window), the canonical serializer, the strict validator, and the OFFLINE verify
12
+ // (re-derive the signer, PIN it to the vendor, check the window, localize the reject reason) are
13
+ // IDENTICAL across products; only the product `kind`, its `schemaVersion`, and its CLOSED entitlement
14
+ // TABLE differ. This module is the SINGLE, tested implementation of that machinery; each product is a
15
+ // THIN adapter that supplies its OWN `kind`/`schemaVersion`/entitlement table as an explicit `cfg`
16
+ // (mirroring how cli/core/manifest.js takes a `cfg`).
17
+ //
18
+ // REUSE — the proven signed-attestation envelope, VERBATIM.
19
+ // A license is just one more product on the shared signed-attestation envelope (cli/core/attestation.js),
20
+ // exactly like the seal/dataset/parcel. We define an UNSIGNED license PAYLOAD, a canonical serializer,
21
+ // and a strict validator, then hand those to `cli/core/attestation.js` as the product framing. The
22
+ // attestation core does ALL the crypto: it embeds the EXACT canonical payload bytes as the attestation,
23
+ // attaches the detached EIP-191 signature, and later RE-DERIVES the signer from those bytes. There is NO
24
+ // new crypto here, NO new dependency — `buildLicense` wraps via `signAttestation`, `verifyLicense`
25
+ // recovers via `verifySignedAttestation`, exactly as the seal does.
26
+ //
27
+ // PURE + I/O-FREE.
28
+ // Every function here is pure: no filesystem, no clock, no network, no key handling (the key lives only
29
+ // inside the caller's signer object). `verifyLicense` takes `now` as an explicit argument — it never
30
+ // reads the system clock — so the same container + same `now` + same `vendorAddress` + same `cfg`
31
+ // always yield a byte-identical verdict. (The product adapter's `read` may do I/O via the attestation
32
+ // core; this core itself never touches disk.)
33
+ //
34
+ // TRUST-BOUNDARIES — the license is an UNTRUSTED transport container.
35
+ // `verifyLicense` RE-DERIVES the signer from the supplied bytes and PINS it to the caller's
36
+ // `vendorAddress`. It NEVER trusts the file's own claims: a license that merely SAYS it was signed by
37
+ // the vendor, but recovers to a different key, is `wrong_issuer`, not trusted. An unknown entitlement
38
+ // flag (one not in the supplied `cfg` table) is a hard build REJECT, never silently honored.
39
+ // Entitlements only mean anything once the verdict is `valid`.
40
+ //
41
+ // PRODUCT-AGNOSTIC: this module NEVER requires a product module, so the dependency points product → core,
42
+ // never the reverse — no back-edge.
43
+
44
+ const coreAttestation = require("./attestation");
45
+ const { getAddress } = require("ethers");
46
+
47
+ // A strict ISO-8601 UTC instant, e.g. "2026-05-31T00:00:00.000Z". We require the canonical form ethers/
48
+ // JS emit via `new Date(...).toISOString()` so two logically-identical licenses serialize to identical
49
+ // bytes. We REJECT a date-only ("YYYY-MM-DD") or an offset-bearing form (over-loose) — the license dates
50
+ // are machine-compared instants, so they must be a single, unambiguous UTC encoding. Shared across the
51
+ // product family so the instant grammar can never diverge between products.
52
+ const ISO_INSTANT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Errors — STRICT. A malformed/ambiguous license raises a NAMED error rather than being silently
56
+ // dropped, coerced, or partially accepted. The error CLASS is product-agnostic; a product adapter that
57
+ // wants its OWN error name (e.g. TrustLedger's historical `LicenseError`) supplies it via `cfg.ErrorClass`
58
+ // so its byte-for-byte messages + thrown type stay UNCHANGED.
59
+ // ---------------------------------------------------------------------------
60
+
61
+ class LicenseError extends Error {
62
+ constructor(message) {
63
+ super(message);
64
+ this.name = "LicenseError";
65
+ }
66
+ }
67
+
68
+ function isPlainObject(v) {
69
+ return v != null && typeof v === "object" && !Array.isArray(v);
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Internal: assert a product passed a structurally complete license config. The injected
74
+ // `kind`/`schemaVersion`/entitlement table is how the core stays product-agnostic without knowing
75
+ // anything product-specific (so there is no `require("../../trustledger/license")` back-edge). Defends the
76
+ // core against an adapter that forgot a field — a clear programmer error HERE, not a confusing downstream
77
+ // symptom.
78
+ // ---------------------------------------------------------------------------
79
+
80
+ function _requireCfg(cfg) {
81
+ if (!isPlainObject(cfg)) {
82
+ throw new Error(
83
+ "license core requires a { kind, schemaVersion, supportedSchemaVersions, note, entitlements, signedKind, signedSchemaVersion, supportedSignedSchemaVersions, signedNote } config"
84
+ );
85
+ }
86
+ if (typeof cfg.kind !== "string" || cfg.kind.length === 0) {
87
+ throw new Error("license core config requires a non-empty string `kind`");
88
+ }
89
+ if (!Array.isArray(cfg.supportedSchemaVersions) || cfg.supportedSchemaVersions.length === 0) {
90
+ throw new Error("license core config requires a non-empty `supportedSchemaVersions` array");
91
+ }
92
+ if (!cfg.supportedSchemaVersions.includes(cfg.schemaVersion)) {
93
+ throw new Error("license core config `schemaVersion` must be one of `supportedSchemaVersions`");
94
+ }
95
+ if (typeof cfg.note !== "string") {
96
+ throw new Error("license core config requires a string `note` (the in-band trust caveat)");
97
+ }
98
+ if (!isPlainObject(cfg.entitlements) || Object.keys(cfg.entitlements).length === 0) {
99
+ throw new Error(
100
+ "license core config requires a non-empty `entitlements` table { flag -> human meaning } (the CLOSED entitlement set)"
101
+ );
102
+ }
103
+ if (typeof cfg.signedKind !== "string" || cfg.signedKind.length === 0) {
104
+ throw new Error("license core config requires a non-empty string `signedKind`");
105
+ }
106
+ if (!Array.isArray(cfg.supportedSignedSchemaVersions) || cfg.supportedSignedSchemaVersions.length === 0) {
107
+ throw new Error("license core config requires a non-empty `supportedSignedSchemaVersions` array");
108
+ }
109
+ if (typeof cfg.signedNote !== "string") {
110
+ throw new Error("license core config requires a string `signedNote`");
111
+ }
112
+ }
113
+
114
+ // The product's named error class, or the core's default. So an adapter's historical error TYPE + name is
115
+ // preserved byte-for-byte.
116
+ function _errClass(cfg) {
117
+ return (cfg && typeof cfg.ErrorClass === "function") ? cfg.ErrorClass : LicenseError;
118
+ }
119
+
120
+ // The frozen, SORTED list of valid entitlement flags for a cfg — derived from the table so the two can
121
+ // never drift. Sorted so error messages + any iteration are deterministic.
122
+ function entitlementFlags(cfg) {
123
+ _requireCfg(cfg);
124
+ return Object.freeze(Object.keys(cfg.entitlements).sort());
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // validateLicense(obj, cfg) — STRICT structural validation of an UNSIGNED license PAYLOAD against a
129
+ // product's framing. Throws a named error (cfg.ErrorClass) on the FIRST problem; returns the object
130
+ // unchanged on success. This is the `validateUnsigned` the attestation core re-runs on the embedded
131
+ // payload (the wrap-don't-edit invariant), so a signed container can never smuggle a malformed/edited
132
+ // license.
133
+ //
134
+ // REJECTS: a wrong kind / schemaVersion; a wrong note; a missing/non-string licenseId/customer/plan; a
135
+ // non-array or empty entitlements; a duplicate or unknown (not-in-cfg) entitlement flag; a non-ISO
136
+ // issuedAt/expiresAt; expiresAt <= issuedAt. It NEVER half-accepts or fills defaults.
137
+ // ---------------------------------------------------------------------------
138
+
139
+ function validateLicense(obj, cfg) {
140
+ _requireCfg(cfg);
141
+ const Err = _errClass(cfg);
142
+ const FLAGS = entitlementFlags(cfg);
143
+
144
+ if (!isPlainObject(obj)) {
145
+ throw new Err("license payload must be a JSON object");
146
+ }
147
+ if (obj.kind !== cfg.kind) {
148
+ throw new Err(
149
+ `not a trustledger license (kind: ${JSON.stringify(obj.kind)}; expected ${JSON.stringify(cfg.kind)})`
150
+ );
151
+ }
152
+ if (!cfg.supportedSchemaVersions.includes(obj.schemaVersion)) {
153
+ throw new Err(
154
+ `unsupported license schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
155
+ `(this build understands ${JSON.stringify(cfg.supportedSchemaVersions)})`
156
+ );
157
+ }
158
+ if (obj.note !== cfg.note) {
159
+ throw new Err("license `note` must be the standing LICENSE_TRUST_NOTE (caveat must not drift)");
160
+ }
161
+
162
+ for (const f of ["licenseId", "customer", "plan"]) {
163
+ if (typeof obj[f] !== "string" || obj[f].length === 0) {
164
+ throw new Err(`license ${f} must be a non-empty string`);
165
+ }
166
+ }
167
+
168
+ // entitlements — a closed set of KNOWN flags (drawn ONLY from cfg.entitlements), each used at most once.
169
+ if (!Array.isArray(obj.entitlements) || obj.entitlements.length === 0) {
170
+ throw new Err("license `entitlements` must be a non-empty array of known flags");
171
+ }
172
+ const seen = new Set();
173
+ for (const flag of obj.entitlements) {
174
+ if (typeof flag !== "string") {
175
+ throw new Err(`license entitlement must be a string flag, got: ${JSON.stringify(flag)}`);
176
+ }
177
+ if (!Object.prototype.hasOwnProperty.call(cfg.entitlements, flag)) {
178
+ throw new Err(
179
+ `unknown license entitlement: ${JSON.stringify(flag)} ` +
180
+ `(this build understands ${JSON.stringify(FLAGS)})`
181
+ );
182
+ }
183
+ if (seen.has(flag)) {
184
+ throw new Err(`license has a duplicate entitlement: ${JSON.stringify(flag)}`);
185
+ }
186
+ seen.add(flag);
187
+ }
188
+
189
+ // issuedAt / expiresAt — strict ISO instants, expiresAt strictly after issuedAt.
190
+ for (const f of ["issuedAt", "expiresAt"]) {
191
+ if (typeof obj[f] !== "string" || !ISO_INSTANT_RE.test(obj[f])) {
192
+ throw new Err(
193
+ `license ${f} must be an ISO-8601 UTC instant ("YYYY-MM-DDTHH:MM:SS(.mmm)Z"), got: ${String(obj[f])}`
194
+ );
195
+ }
196
+ // The regex pins the SHAPE; require it to be a real, CANONICAL calendar instant too. Date.parse on a
197
+ // strict-Z form is UTC, but it (1) does NOT reject out-of-range fields — it ROLLS THEM OVER (e.g.
198
+ // "2026-02-29T00:00:00.000Z" in non-leap 2026 silently becomes 2026-03-01; "...T24:00:00.000Z"
199
+ // becomes the next day), and (2) accepts a missing-millis form ("...:00Z") that is a different BYTE
200
+ // string than the canonical "...:00.000Z". Either case would let two logically-distinct (or
201
+ // logically-identical) inputs sign differently / silently coerce a self-asserted date — breaking the
202
+ // byte-determinism the whole product rests on. So after parsing we require the round-trip to be
203
+ // BYTE-IDENTICAL: `new Date(ms).toISOString() === obj[f]`. toISOString always emits the canonical,
204
+ // normalized "YYYY-MM-DDTHH:MM:SS.mmmZ", so this single equality both FORCES the `.mmm` millis form
205
+ // and REJECTS every rolled-over/impossible instant — never silently coerced.
206
+ const ms = Date.parse(obj[f]);
207
+ if (Number.isNaN(ms) || new Date(ms).toISOString() !== obj[f]) {
208
+ throw new Err(
209
+ `license ${f} must be a canonical ISO-8601 UTC instant ("YYYY-MM-DDTHH:MM:SS.mmmZ", millis required, ` +
210
+ `no rolled-over/impossible fields), got: ${String(obj[f])}`
211
+ );
212
+ }
213
+ }
214
+ const issuedMs = Date.parse(obj.issuedAt);
215
+ const expiresMs = Date.parse(obj.expiresAt);
216
+ if (expiresMs <= issuedMs) {
217
+ throw new Err(
218
+ `license expiresAt (${obj.expiresAt}) must be strictly AFTER issuedAt (${obj.issuedAt})`
219
+ );
220
+ }
221
+
222
+ return obj;
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // serializeLicense(payload, cfg) — canonical, byte-deterministic serialization of an UNSIGNED license
227
+ // payload: a FIXED key order, NO insignificant whitespace, a single trailing newline. Entitlements are
228
+ // emitted in the FROZEN entitlementFlags(cfg) order (filtered to those present) so the bytes are
229
+ // independent of the caller's array order. This is the EXACT byte sequence the envelope signs over and
230
+ // `verifyLicense` re-derives the signer from — so two logically-identical licenses sign identically.
231
+ // ---------------------------------------------------------------------------
232
+
233
+ function serializeLicense(payload, cfg) {
234
+ validateLicense(payload, cfg);
235
+ const FLAGS = entitlementFlags(cfg);
236
+ const present = new Set(payload.entitlements);
237
+ const canonical = {
238
+ kind: payload.kind,
239
+ schemaVersion: payload.schemaVersion,
240
+ note: payload.note,
241
+ licenseId: payload.licenseId,
242
+ customer: payload.customer,
243
+ plan: payload.plan,
244
+ // Emit in the fixed table order, filtered to those present — order-independent canonical form.
245
+ entitlements: FLAGS.filter((f) => present.has(f)),
246
+ issuedAt: payload.issuedAt,
247
+ expiresAt: payload.expiresAt,
248
+ };
249
+ return JSON.stringify(canonical) + "\n";
250
+ }
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // buildLicensePayload(params, cfg) — assemble + strictly validate an UNSIGNED license payload from caller
254
+ // fields. PURE. This is the payload that `buildLicense` then wraps in the signed envelope. Splitting it
255
+ // out lets a caller hold/inspect the unsigned payload before signing (and lets the build path validate
256
+ // the SAME way the embedded payload is re-validated on read).
257
+ // ---------------------------------------------------------------------------
258
+
259
+ function buildLicensePayload(params, cfg) {
260
+ _requireCfg(cfg);
261
+ const Err = _errClass(cfg);
262
+ if (!isPlainObject(params)) {
263
+ throw new Err(
264
+ "buildLicensePayload requires a { licenseId, customer, plan, entitlements, issuedAt, expiresAt } object"
265
+ );
266
+ }
267
+ const payload = {
268
+ kind: cfg.kind,
269
+ schemaVersion: cfg.schemaVersion,
270
+ note: cfg.note,
271
+ licenseId: params.licenseId,
272
+ customer: params.customer,
273
+ plan: params.plan,
274
+ entitlements: params.entitlements,
275
+ issuedAt: params.issuedAt,
276
+ expiresAt: params.expiresAt,
277
+ };
278
+ // validateLicense throws a named error on any malformed/unknown/missing field — never silently
279
+ // accepts. We return the canonicalized payload (re-parsed from serializeLicense) so the in-memory
280
+ // object's entitlement order matches the signed bytes exactly.
281
+ validateLicense(payload, cfg);
282
+ return JSON.parse(serializeLicense(payload, cfg));
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // _signedCfg(cfg) — the SIGNED-attestation framing passed to the GENERIC attestation core, derived from
287
+ // the product's license `cfg`. The attestation core does ALL the crypto + the wrap-don't-edit invariant;
288
+ // this supplies ONLY the product-specific framing (signedKind/schema/note/label) + the unsigned payload
289
+ // codec (bound to THIS cfg). This is the SAME pattern the seal/dataset use.
290
+ // ---------------------------------------------------------------------------
291
+
292
+ function _signedCfg(cfg) {
293
+ _requireCfg(cfg);
294
+ return {
295
+ kind: cfg.signedKind,
296
+ schemaVersion: cfg.signedSchemaVersion,
297
+ supportedSchemaVersions: cfg.supportedSignedSchemaVersions,
298
+ note: cfg.signedNote,
299
+ label: cfg.signedLabel || "signed license",
300
+ validateUnsigned: (obj) => validateLicense(obj, cfg),
301
+ serializeUnsigned: (obj) => serializeLicense(obj, cfg),
302
+ };
303
+ }
304
+
305
+ // ---------------------------------------------------------------------------
306
+ // buildLicense(params, signer, cfg) — mint a SIGNED license container. Builds + validates the unsigned
307
+ // payload, then routes it + the caller's signer through the SHARED `signAttestation` core, which signs the
308
+ // EXACT canonical bytes (EIP-191 personal_sign) and wraps + validates the container. NO key handling here —
309
+ // the key lives only inside the signer object. The container ROUND-TRIPS by construction: verifyLicense
310
+ // recovers exactly this signer over exactly serializeLicense(payload, cfg).
311
+ //
312
+ // @param {object} params { licenseId, customer, plan, entitlements, issuedAt, expiresAt }
313
+ // @param {object} signer an ethers signer-like object: async getAddress() + signMessage()
314
+ // @param {object} cfg the product's license framing
315
+ // @returns {Promise<object>} the validated signed-license container
316
+ // ---------------------------------------------------------------------------
317
+
318
+ async function buildLicense(params, signer, cfg) {
319
+ const payload = buildLicensePayload(params, cfg);
320
+ return coreAttestation.signAttestation({ attestation: payload, signer }, _signedCfg(cfg));
321
+ }
322
+
323
+ /** Strictly validate a parsed SIGNED-license container — thin wrapper over the shared core. */
324
+ function validateSignedLicense(obj, cfg) {
325
+ return coreAttestation.validateSignedAttestation(obj, _signedCfg(cfg));
326
+ }
327
+
328
+ /** Serialize a SIGNED-license container to its canonical bytes — thin wrapper over the shared core. */
329
+ function serializeSignedLicense(container, cfg) {
330
+ return coreAttestation.serializeSignedAttestation(container, _signedCfg(cfg));
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // readLicense(text|obj, cfg) — parse + strictly validate a SIGNED-license container (JSON string or
335
+ // object). A parse error is a cfg.ErrorClass (never a raw SyntaxError); a malformed/corrupt container is
336
+ // rejected by the shared validator, never half-accepted.
337
+ // ---------------------------------------------------------------------------
338
+
339
+ function readLicense(input, cfg) {
340
+ _requireCfg(cfg);
341
+ const Err = _errClass(cfg);
342
+ let obj;
343
+ if (typeof input === "string") {
344
+ try {
345
+ obj = JSON.parse(input);
346
+ } catch (e) {
347
+ throw new Err(`license container is not valid JSON: ${e.message}`);
348
+ }
349
+ } else if (isPlainObject(input)) {
350
+ obj = input;
351
+ } else {
352
+ throw new Err("readLicense requires a JSON string or a signed-license container object");
353
+ }
354
+ // Surface the core's structural rejection as a cfg.ErrorClass so callers catch ONE error type. The core
355
+ // throws plain Errors; we re-tag the message.
356
+ try {
357
+ coreAttestation.validateSignedAttestation(obj, _signedCfg(cfg));
358
+ } catch (e) {
359
+ throw new Err(e.message);
360
+ }
361
+ return obj;
362
+ }
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // verifyLicense(container, { now, vendorAddress, cfg }) — the AUTHORITATIVE, PURE, OFFLINE verify.
366
+ //
367
+ // Re-derive the canonical payload from the container's embedded bytes, recover the signer via the
368
+ // EXISTING core recovery, and return a STRUCTURED verdict. `valid` is true ONLY when ALL hold:
369
+ // (a) the envelope signature verifies (recovers to the CLAIMED signer);
370
+ // (b) the recovered signer EQUALS the pinned `vendorAddress` (any other key => wrong_issuer);
371
+ // (c) `now` is within [issuedAt, expiresAt] inclusive.
372
+ // Otherwise a LOCALIZED reason is returned (never thrown for an ordinary rejection):
373
+ // * malformed — the container is structurally invalid (not a sound signed license)
374
+ // * bad_signature — the signature does not recover to the claimed signer (tamper / corrupt)
375
+ // * wrong_issuer — recovered, but NOT the pinned vendor key
376
+ // * not_yet_valid — now < issuedAt
377
+ // * expired — now > expiresAt
378
+ //
379
+ // `now` is an EXPLICIT argument (a Date, an ISO string, or epoch-ms number) — verifyLicense NEVER reads
380
+ // the system clock, so it stays pure/deterministic. `vendorAddress` is REQUIRED: a license is worthless
381
+ // without a key to pin it to (we never "trust whoever signed it"). `cfg` is REQUIRED: it is the product's
382
+ // license framing (kind/schema/note/entitlement table). NO I/O, NO network, NO key.
383
+ //
384
+ // @param {object} container a signed-license container (from buildLicense/readLicense)
385
+ // @param {object} opts { now: Date|string|number, vendorAddress: string, cfg: object }
386
+ // @returns {{
387
+ // valid: boolean,
388
+ // reason: null|"malformed"|"bad_signature"|"wrong_issuer"|"not_yet_valid"|"expired",
389
+ // recoveredSigner: string|null,
390
+ // vendorAddress: string,
391
+ // payload: object|null,
392
+ // entitlements: string[],
393
+ // now: string,
394
+ // }}
395
+ // ---------------------------------------------------------------------------
396
+
397
+ function verifyLicense(container, opts) {
398
+ if (!isPlainObject(opts)) {
399
+ throw new LicenseError("verifyLicense requires an options object { now, vendorAddress, cfg }");
400
+ }
401
+ const cfg = opts.cfg;
402
+ _requireCfg(cfg);
403
+ const Err = _errClass(cfg);
404
+ const signedCfg = _signedCfg(cfg);
405
+
406
+ // vendorAddress is REQUIRED + must be a syntactically valid address. We normalize via the core's
407
+ // ethers getAddress (accepts checksummed/mixed-case) and lowercase it for comparison. A garbage
408
+ // vendorAddress is a CALLER error (thrown), distinct from an ordinary license rejection.
409
+ let normalizedVendor;
410
+ try {
411
+ normalizedVendor = getAddress(opts.vendorAddress);
412
+ } catch (_e) {
413
+ throw new Err(
414
+ `verifyLicense requires a valid vendorAddress (0x-address to pin the issuer to), got: ${String(opts.vendorAddress)}`
415
+ );
416
+ }
417
+ const vendorLc = normalizedVendor.toLowerCase();
418
+
419
+ // `now` — accept a Date, an ISO string, or epoch-ms; resolve to epoch-ms. A garbage `now` is a CALLER
420
+ // error (thrown). We record the resolved instant as an ISO string for transparency. NOTE: `now` is
421
+ // INTENTIONALLY lenient (it is the caller's explicit clock arg, not a self-asserted payload date) — a
422
+ // date-only "2026-06-23" is accepted as UTC midnight, unlike the strict, canonical ISO instants the
423
+ // payload's issuedAt/expiresAt are held to. The window check only compares epoch-ms.
424
+ let nowMs;
425
+ if (opts.now instanceof Date) {
426
+ nowMs = opts.now.getTime();
427
+ } else if (typeof opts.now === "number" && Number.isFinite(opts.now)) {
428
+ nowMs = opts.now;
429
+ } else if (typeof opts.now === "string") {
430
+ nowMs = Date.parse(opts.now);
431
+ } else {
432
+ nowMs = NaN;
433
+ }
434
+ if (Number.isNaN(nowMs)) {
435
+ throw new Err(
436
+ `verifyLicense requires a valid \`now\` (a Date, ISO string, or epoch-ms number), got: ${String(opts.now)}`
437
+ );
438
+ }
439
+ const nowIso = new Date(nowMs).toISOString();
440
+
441
+ function reject(reason, recoveredSigner, payload) {
442
+ return {
443
+ valid: false,
444
+ reason,
445
+ recoveredSigner: recoveredSigner == null ? null : recoveredSigner,
446
+ vendorAddress: vendorLc,
447
+ payload: payload == null ? null : payload,
448
+ entitlements: [],
449
+ now: nowIso,
450
+ };
451
+ }
452
+
453
+ // (0) STRUCTURAL: the container must be a sound signed license, with an embedded payload that
454
+ // re-validates (the core enforces the wrap-don't-edit invariant). A malformed/hand-corrupted
455
+ // container is `malformed`, never trusted. The embedded payload must ALSO parse to a sound
456
+ // license so we can read its dates/entitlements.
457
+ let payload;
458
+ try {
459
+ coreAttestation.validateSignedAttestation(container, signedCfg);
460
+ payload = JSON.parse(container.attestation);
461
+ validateLicense(payload, cfg);
462
+ } catch (_e) {
463
+ return reject("malformed", null, null);
464
+ }
465
+
466
+ // (a) SIGNATURE: recover the signer from the embedded bytes and confirm it matches the CLAIMED signer.
467
+ // A tampered payload byte (the embedded bytes no longer match what was signed) recovers to the
468
+ // wrong address — signatureMatchesSigner is false — so this is `bad_signature`. A structurally
469
+ // unrecoverable signature is also caught (the core returns "(unrecoverable)").
470
+ const att = coreAttestation.verifySignedAttestation({ container });
471
+ if (!att.checks.signatureMatchesSigner) {
472
+ return reject("bad_signature", att.recoveredSigner === "(unrecoverable)" ? null : att.recoveredSigner, payload);
473
+ }
474
+ const recovered = att.recoveredSigner; // lowercase 0x-address
475
+
476
+ // (b) ISSUER PIN: the recovered signer must EQUAL the pinned vendor key. A license signed by any other
477
+ // key is REJECTED (wrong_issuer), never trusted — this is the TRUST-BOUNDARIES re-derivation.
478
+ if (recovered !== vendorLc) {
479
+ return reject("wrong_issuer", recovered, payload);
480
+ }
481
+
482
+ // (c) WINDOW: `now` must be within [issuedAt, expiresAt] inclusive. validateLicense already proved
483
+ // expiresAt > issuedAt, so the window is non-empty.
484
+ const issuedMs = Date.parse(payload.issuedAt);
485
+ const expiresMs = Date.parse(payload.expiresAt);
486
+ if (nowMs < issuedMs) {
487
+ return reject("not_yet_valid", recovered, payload);
488
+ }
489
+ if (nowMs > expiresMs) {
490
+ return reject("expired", recovered, payload);
491
+ }
492
+
493
+ // VALID — signature verifies, issuer is the vendor, and now is in-window.
494
+ return {
495
+ valid: true,
496
+ reason: null,
497
+ recoveredSigner: recovered,
498
+ vendorAddress: vendorLc,
499
+ payload,
500
+ entitlements: payload.entitlements.slice(),
501
+ now: nowIso,
502
+ };
503
+ }
504
+
505
+ // ---------------------------------------------------------------------------
506
+ // hasEntitlement(verdict, flag) — PURE. True ONLY when the verdict is `valid` AND `flag` is present in
507
+ // its entitlements. False for ANY non-valid verdict (a rejected/expired/wrong-issuer license entitles
508
+ // NOTHING) and for an unknown/absent flag. This is product-AGNOSTIC (it reads only the verdict), so it
509
+ // needs no cfg. The single gate product code should call — it can never accidentally honor an entitlement
510
+ // from an untrusted verdict.
511
+ // ---------------------------------------------------------------------------
512
+
513
+ function hasEntitlement(verdict, flag) {
514
+ if (!isPlainObject(verdict) || verdict.valid !== true) return false;
515
+ if (typeof flag !== "string") return false;
516
+ return Array.isArray(verdict.entitlements) && verdict.entitlements.includes(flag);
517
+ }
518
+
519
+ module.exports = {
520
+ ISO_INSTANT_RE,
521
+ LicenseError,
522
+ entitlementFlags,
523
+ // unsigned payload
524
+ validateLicense,
525
+ serializeLicense,
526
+ buildLicensePayload,
527
+ // signed container (shared attestation core)
528
+ buildLicense,
529
+ validateSignedLicense,
530
+ serializeSignedLicense,
531
+ readLicense,
532
+ verifyLicense,
533
+ hasEntitlement,
534
+ };