verifyhash 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +883 -0
- package/cli/abi/ContributionRegistry.json +881 -0
- package/cli/agent.js +2173 -0
- package/cli/anchor-artifact.js +853 -0
- package/cli/anchor.js +400 -0
- package/cli/claim.js +881 -0
- package/cli/core/agent-commit.js +448 -0
- package/cli/core/agent-session.js +598 -0
- package/cli/core/anchor-binding.js +663 -0
- package/cli/core/attestation.js +580 -0
- package/cli/core/evidence-plans.js +495 -0
- package/cli/core/fixtures/evidence-plans/baseline.json +19 -0
- package/cli/core/fulfill-intake.js +1082 -0
- package/cli/core/go-live-preflight.js +481 -0
- package/cli/core/license.js +534 -0
- package/cli/core/manifest.js +243 -0
- package/cli/core/packetseal.js +591 -0
- package/cli/core/registryArtifact.js +49 -0
- package/cli/core/revocation.js +539 -0
- package/cli/core/rfc3161.js +389 -0
- package/cli/core/timestamp.js +482 -0
- package/cli/core/trust-asof.js +479 -0
- package/cli/dataset.js +2950 -0
- package/cli/evidence.js +2227 -0
- package/cli/fulfill-webhook-http.js +438 -0
- package/cli/git.js +220 -0
- package/cli/hash.js +550 -0
- package/cli/identity.js +1072 -0
- package/cli/journal-cli.js +1110 -0
- package/cli/journal-log.js +454 -0
- package/cli/journal.js +334 -0
- package/cli/lineage.js +447 -0
- package/cli/list.js +287 -0
- package/cli/parcel.js +1509 -0
- package/cli/proof.js +578 -0
- package/cli/prove.js +300 -0
- package/cli/receipt.js +631 -0
- package/cli/registry.js +331 -0
- package/cli/reputation.js +344 -0
- package/cli/revocation.js +495 -0
- package/cli/serve-verify-http.js +298 -0
- package/cli/serve-verify.js +333 -0
- package/cli/show.js +339 -0
- package/cli/verify.js +383 -0
- package/cli/vh.js +3927 -0
- package/docs/ADOPT.md +183 -0
- package/docs/ADOPTION.json +11 -0
- package/docs/AGENTTRACE.md +247 -0
- package/docs/ANCHORING.md +167 -0
- package/docs/AUDIT.md +55 -0
- package/docs/CONFORMANCE.md +107 -0
- package/docs/DATALEDGER.md +638 -0
- package/docs/DECIDE.md +47 -0
- package/docs/DECISIONS-PENDING.md +27 -0
- package/docs/DEPLOY-PUBLIC-SITE.md +301 -0
- package/docs/ENGINE-LEDGER.json +12 -0
- package/docs/EVIDENCE.md +519 -0
- package/docs/GO-LIVE.md +66 -0
- package/docs/IDENTITY.md +123 -0
- package/docs/INDEPENDENT-VERIFICATION.md +377 -0
- package/docs/INTEGRITY-JOURNAL.md +337 -0
- package/docs/KEY-LIFECYCLE.md +179 -0
- package/docs/LICENSING.md +46 -0
- package/docs/LINEAGE.md +307 -0
- package/docs/LOOP-AUDIT-2026-07-03.json +580 -0
- package/docs/LOOP-HARDENING-PLAN.md +44 -0
- package/docs/MERKLE-LEAVES.md +113 -0
- package/docs/METRICS.jsonl +31 -0
- package/docs/MORNING.md +204 -0
- package/docs/PILOT.md +444 -0
- package/docs/PROOFPARCEL.md +227 -0
- package/docs/PROOFS.md +262 -0
- package/docs/RECEIPTS.md +341 -0
- package/docs/REPUTATION.md +158 -0
- package/docs/SDK.md +301 -0
- package/docs/STRATEGY-ARCHIVE.md +5055 -0
- package/docs/SUPERVISOR-RUNBOOK.md +52 -0
- package/docs/TRUST-BOUNDARIES.md +335 -0
- package/docs/TRUSTLEDGER.md +1976 -0
- package/docs/USAGE-BUDGET.json +121 -0
- package/docs/VERIFY-SERVICE.md +168 -0
- package/index.js +160 -0
- package/package.json +41 -0
- package/trustledger/build-standalone.js +796 -0
- package/trustledger/cli.js +3179 -0
- package/trustledger/close.js +391 -0
- package/trustledger/corpus.js +159 -0
- package/trustledger/dist/BUILD-PROVENANCE.json +99 -0
- package/trustledger/dist/trustledger-standalone.html +6197 -0
- package/trustledger/dist/trustledger-standalone.html.sha256 +1 -0
- package/trustledger/door-core.js +442 -0
- package/trustledger/fixtures/bank.csv +7 -0
- package/trustledger/fixtures/bank.malformed.csv +3 -0
- package/trustledger/fixtures/bank.noalias.csv +5 -0
- package/trustledger/fixtures/bank.ofx +34 -0
- package/trustledger/fixtures/bank.real.csv +5 -0
- package/trustledger/fixtures/corpus/_shared/prior-close.json +22 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/inputs.json +14 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/inputs.json +14 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/continuity-break--benign-twin/inputs.json +15 -0
- package/trustledger/fixtures/corpus/continuity-break--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/continuity-break--out-of-trust/inputs.json +15 -0
- package/trustledger/fixtures/corpus/continuity-break--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/inputs.json +13 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/inputs.json +13 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/inputs.json +15 -0
- package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/inputs.json +15 -0
- package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/inputs.json +16 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/inputs.json +13 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/inputs.json +13 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/inputs.json +13 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/e2e/bank.aliased.csv +4 -0
- package/trustledger/fixtures/e2e/bank.csv +4 -0
- package/trustledger/fixtures/e2e/bank.nsf.csv +4 -0
- package/trustledger/fixtures/e2e/quickbooks.csv +6 -0
- package/trustledger/fixtures/e2e/quickbooks.nsf.csv +8 -0
- package/trustledger/fixtures/e2e/rentroll.csv +6 -0
- package/trustledger/fixtures/e2e/rentroll.nsf.csv +8 -0
- package/trustledger/fixtures/e2e/rentroll.short.csv +5 -0
- package/trustledger/fixtures/plans/baseline.json +25 -0
- package/trustledger/fixtures/plans/price-binding.example.json +27 -0
- package/trustledger/fixtures/policy/ambiguous-deposit-example.json +12 -0
- package/trustledger/fixtures/policy/baseline.json +19 -0
- package/trustledger/fixtures/policy/ca-example.json +12 -0
- package/trustledger/fixtures/policy/negative-tenant-ledger-example.json +12 -0
- package/trustledger/fixtures/policy/owner-overdraw-example.json +12 -0
- package/trustledger/fixtures/quickbooks.csv +7 -0
- package/trustledger/fixtures/quickbooks.real.csv +5 -0
- package/trustledger/fixtures/rentroll.csv +6 -0
- package/trustledger/fixtures/rentroll.real.csv +4 -0
- package/trustledger/ingest.js +1163 -0
- package/trustledger/lib/policy-bundled-loader.js +44 -0
- package/trustledger/lib/sha256-vendored.js +227 -0
- package/trustledger/license.js +563 -0
- package/trustledger/match.js +551 -0
- package/trustledger/plans.js +551 -0
- package/trustledger/policy.js +398 -0
- package/trustledger/public/index.html +512 -0
- package/trustledger/reconcile.js +1486 -0
- package/trustledger/report.js +887 -0
- package/trustledger/seal.js +854 -0
- package/trustledger/server.js +391 -0
- package/trustledger/valueproof.js +350 -0
|
@@ -0,0 +1,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
|
+
};
|