zklicensing 0.1.0-beta.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 +104 -0
- package/README.md +603 -0
- package/build/archiveBootstrap.d.ts +60 -0
- package/build/archiveBootstrap.js +234 -0
- package/build/buyClient.d.ts +58 -0
- package/build/buyClient.js +162 -0
- package/build/contractInterface.d.ts +55 -0
- package/build/contractInterface.js +213 -0
- package/build/countryStats.d.ts +37 -0
- package/build/countryStats.js +89 -0
- package/build/deployClient.d.ts +168 -0
- package/build/deployClient.js +172 -0
- package/build/freezeClient.d.ts +44 -0
- package/build/freezeClient.js +59 -0
- package/build/generationClient.d.ts +54 -0
- package/build/generationClient.js +113 -0
- package/build/index.d.ts +72 -0
- package/build/index.js +258 -0
- package/build/licenseProof.d.ts +26 -0
- package/build/licenseProof.js +41 -0
- package/build/licenseRecord.d.ts +39 -0
- package/build/licenseRecord.js +6 -0
- package/build/licenseStore.d.ts +49 -0
- package/build/licenseStore.js +303 -0
- package/build/manifestVerify.d.ts +30 -0
- package/build/manifestVerify.js +95 -0
- package/build/migrateClient.d.ts +43 -0
- package/build/migrateClient.js +47 -0
- package/build/ownership.d.ts +34 -0
- package/build/ownership.js +147 -0
- package/build/refundClient.d.ts +52 -0
- package/build/refundClient.js +92 -0
- package/build/renewClient.d.ts +49 -0
- package/build/renewClient.js +135 -0
- package/build/statsClient.d.ts +96 -0
- package/build/statsClient.js +28 -0
- package/build/verifyCore.d.ts +26 -0
- package/build/verifyCore.js +57 -0
- package/build/verifyService.d.ts +3 -0
- package/build/verifyService.js +690 -0
- package/package.json +118 -0
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
3
|
+
// All rights reserved.
|
|
4
|
+
/**
|
|
5
|
+
* verifyService.ts
|
|
6
|
+
*
|
|
7
|
+
* Lightweight, standalone license verification service.
|
|
8
|
+
*
|
|
9
|
+
* Exposes the same state endpoints as the keeper but contains NO ZK prover —
|
|
10
|
+
* no circuit compilation, no o1js native backend. Starts in ~2 seconds and
|
|
11
|
+
* runs on any small VPS or container.
|
|
12
|
+
*
|
|
13
|
+
* No write endpoints — chain events are the source of truth. License state
|
|
14
|
+
* is rebuilt from the archive node in three ways:
|
|
15
|
+
* - auto-bootstrap on startup when licenses.json is empty,
|
|
16
|
+
* - lazy bootstrap inside GET / when the computed Merkle root
|
|
17
|
+
* disagrees with the on-chain root for the requested app (the canonical
|
|
18
|
+
* "we're stale" signal). One replay + one retry is enough — root is
|
|
19
|
+
* consensus. This catches licenses issued, renewed, or refunded after
|
|
20
|
+
* boot on the very next verify call, with zero work done when no one is
|
|
21
|
+
* asking. Concurrent verifies for the same app share one in-flight
|
|
22
|
+
* bootstrap so a popular license under load does not stampede the
|
|
23
|
+
* archive node.
|
|
24
|
+
* - POST /bootstrap (admin) for ad-hoc reasons: a newly registered zkApp
|
|
25
|
+
* (verify won't trigger a sync for an app no client knows about yet), a
|
|
26
|
+
* corrupted licenses.json, or repopulating the offline /license/...
|
|
27
|
+
* debug endpoint without first hitting /verify.
|
|
28
|
+
*
|
|
29
|
+
* Deployment modes
|
|
30
|
+
* ----------------
|
|
31
|
+
* 1. Same-machine as the keeper
|
|
32
|
+
* - Run from the keeper's working directory so apps.json and licenses.json
|
|
33
|
+
* are shared transparently. Optionally set LICENSE_STORE_PATH to point at
|
|
34
|
+
* a shared file.
|
|
35
|
+
*
|
|
36
|
+
* 2. Separate machine (replica)
|
|
37
|
+
* - Copy the keeper's apps.json into the verifyService working directory.
|
|
38
|
+
* apps.json tells the service which zkApp addresses to replay events for.
|
|
39
|
+
* - Start the service. On boot it auto-bootstraps every app in apps.json
|
|
40
|
+
* from the archive node. MINA_ARCHIVE_URL defaults to the Minascan public
|
|
41
|
+
* archive matching MINA_NETWORK_ID:
|
|
42
|
+
* mainnet → https://api.minascan.io/archive/mainnet/v1/graphql
|
|
43
|
+
* else → https://api.minascan.io/archive/devnet/v1/graphql
|
|
44
|
+
* Override by setting MINA_ARCHIVE_URL in the environment.
|
|
45
|
+
* - To register a NEW app on the replica afterwards:
|
|
46
|
+
* a) Re-copy apps.json from the keeper (it owns the registry), then
|
|
47
|
+
* b) POST /bootstrap with the new zkAppAddress to ingest its events.
|
|
48
|
+
* - Re-copying apps.json on a cron (or pulling it via GET /apps from the
|
|
49
|
+
* keeper) is the recommended way to keep the registry in sync.
|
|
50
|
+
*
|
|
51
|
+
* Usage:
|
|
52
|
+
* node build/src/verifyService.js
|
|
53
|
+
*
|
|
54
|
+
* Configuration is read from environment variables (see README §Configuration).
|
|
55
|
+
* Required: MINA_GRAPHQL_URL, PLATFORM_ADDRESS.
|
|
56
|
+
* Optional: MINA_NETWORK_ID (default testnet), MINA_ARCHIVE_URL (defaults by
|
|
57
|
+
* network), VERIFY_PORT (default 8081), VERIFY_ADMIN_TOKEN.
|
|
58
|
+
*
|
|
59
|
+
* Endpoints:
|
|
60
|
+
* GET /health
|
|
61
|
+
* GET /?licenseHash=...&zkAppAddress=... ← primary endpoint for apps
|
|
62
|
+
* GET /challenge?licenseHash=... ← ownership nonce
|
|
63
|
+
* POST /respond ← ownership proof + token
|
|
64
|
+
* POST /refresh ← extend token TTL (no re-prove)
|
|
65
|
+
* POST /releaseSeat ← release this device's seat
|
|
66
|
+
* POST /reset-sessions ← drop all sessions (re-prove)
|
|
67
|
+
* GET /root?zkApp=...
|
|
68
|
+
* GET /witness/:zkAppAddress/:licenseHash
|
|
69
|
+
* GET /license/:zkAppAddress/:licenseHash
|
|
70
|
+
* GET /apps
|
|
71
|
+
* POST /bootstrap ← replay events from archive
|
|
72
|
+
*/
|
|
73
|
+
import fs from 'fs/promises';
|
|
74
|
+
import { mkdirSync } from 'fs';
|
|
75
|
+
import { homedir } from 'os';
|
|
76
|
+
import { join } from 'path';
|
|
77
|
+
import express from 'express';
|
|
78
|
+
import { Field, Mina, fetchLastBlock, fetchAccount, PublicKey, Cache, setBackend, verify, } from 'o1js';
|
|
79
|
+
import { buildMap, getLicense, licenseStatus, readRecords, serializeWitness, } from './licenseStore.js';
|
|
80
|
+
import { verifyLicenseCore } from './verifyCore.js';
|
|
81
|
+
import { bootstrapFromArchive, applyEventsToStore, lazyBootstrap } from './archiveBootstrap.js';
|
|
82
|
+
import { LicenseProof, LicenseProofProof } from './licenseProof.js';
|
|
83
|
+
import { CANONICAL_VK_HASHES, CANONICAL_LICENSE_PROOF_VK_HASH, SUPPORTED_LICENSING_APP_VERSIONS, fetchLicensingAppVersion, } from './contractInterface.js';
|
|
84
|
+
import { createChallengeStore, createSessionStore, newJti, signOwnershipToken, verifyOwnershipToken, newOwnershipKey, OWNERSHIP_TOKEN_TTL_MS, } from './ownership.js';
|
|
85
|
+
setBackend('native');
|
|
86
|
+
const CIRCUIT_CACHE_DIR = join(homedir(), '.cache', 'zklic-verifier');
|
|
87
|
+
mkdirSync(CIRCUIT_CACHE_DIR, { recursive: true });
|
|
88
|
+
// ------------------------------------------------------------
|
|
89
|
+
// CONFIG (env vars) & NETWORK
|
|
90
|
+
// ------------------------------------------------------------
|
|
91
|
+
function requireEnv(name, hint) {
|
|
92
|
+
const v = process.env[name];
|
|
93
|
+
if (!v) {
|
|
94
|
+
console.error(`❌ ${name} env var is required. ${hint}`);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
return v;
|
|
98
|
+
}
|
|
99
|
+
const MINA_NETWORK_ID = process.env.MINA_NETWORK_ID ?? 'devnet';
|
|
100
|
+
const MINA_GRAPHQL_URL = requireEnv('MINA_GRAPHQL_URL', 'e.g. https://api.minascan.io/node/devnet/v1/graphql');
|
|
101
|
+
const PORT = parseInt(process.env.VERIFY_PORT ?? '8081', 10);
|
|
102
|
+
function defaultArchiveUrl(networkId) {
|
|
103
|
+
switch (networkId) {
|
|
104
|
+
case 'mainnet':
|
|
105
|
+
return 'https://api.minascan.io/archive/mainnet/v1/graphql';
|
|
106
|
+
case 'devnet':
|
|
107
|
+
return 'https://api.minascan.io/archive/devnet/v1/graphql';
|
|
108
|
+
default:
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const archiveUrl = process.env.MINA_ARCHIVE_URL ?? defaultArchiveUrl(MINA_NETWORK_ID);
|
|
113
|
+
if (!archiveUrl) {
|
|
114
|
+
console.error(`❌ No archive URL for MINA_NETWORK_ID="${MINA_NETWORK_ID}" and no built-in default for this network.\n` +
|
|
115
|
+
` Set MINA_ARCHIVE_URL to the archive node GraphQL endpoint.`);
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
console.log(`🔗 Mina node: ${MINA_GRAPHQL_URL}`);
|
|
119
|
+
console.log(`📚 Archive node: ${archiveUrl}${process.env.MINA_ARCHIVE_URL ? ' (from MINA_ARCHIVE_URL)' : ' (default — set MINA_ARCHIVE_URL to override)'}`);
|
|
120
|
+
Mina.setActiveInstance(Mina.Network({
|
|
121
|
+
networkId: MINA_NETWORK_ID,
|
|
122
|
+
mina: MINA_GRAPHQL_URL,
|
|
123
|
+
archive: archiveUrl,
|
|
124
|
+
}));
|
|
125
|
+
// ------------------------------------------------------------
|
|
126
|
+
// CIRCUIT — LicenseProof (ownership challenge/response)
|
|
127
|
+
// ------------------------------------------------------------
|
|
128
|
+
let licenseProofVk = null;
|
|
129
|
+
let licenseProofCompile = null;
|
|
130
|
+
async function ensureCompiled() {
|
|
131
|
+
if (licenseProofVk)
|
|
132
|
+
return;
|
|
133
|
+
licenseProofCompile ??= (async () => {
|
|
134
|
+
const cached = Cache.FileSystem(CIRCUIT_CACHE_DIR);
|
|
135
|
+
const t = Date.now();
|
|
136
|
+
console.log(`[verify-prover] Compiling LicenseProof (cache: ${CIRCUIT_CACHE_DIR})…`);
|
|
137
|
+
const lpVk = await LicenseProof.compile({ cache: cached });
|
|
138
|
+
const vk = lpVk.verificationKey;
|
|
139
|
+
if (CANONICAL_LICENSE_PROOF_VK_HASH && vk.hash.toString() !== CANONICAL_LICENSE_PROOF_VK_HASH) {
|
|
140
|
+
throw new Error(`LicenseProof VK hash ${vk.hash.toString()} does not match CANONICAL_LICENSE_PROOF_VK_HASH ${CANONICAL_LICENSE_PROOF_VK_HASH}`);
|
|
141
|
+
}
|
|
142
|
+
licenseProofVk = vk;
|
|
143
|
+
console.log(`[verify-prover] Ready in ${((Date.now() - t) / 1000).toFixed(1)}s — vk ${vk.hash.toString().slice(0, 16)}…`);
|
|
144
|
+
})();
|
|
145
|
+
await licenseProofCompile;
|
|
146
|
+
}
|
|
147
|
+
ensureCompiled().catch((e) => console.error('[verify-prover] Compile failed:', e));
|
|
148
|
+
// ------------------------------------------------------------
|
|
149
|
+
// OWNERSHIP — challenge + activation token (helpers live in ownership.ts)
|
|
150
|
+
// ------------------------------------------------------------
|
|
151
|
+
const ownershipChallenges = createChallengeStore();
|
|
152
|
+
setInterval(() => { /* opportunistic GC */ ownershipChallenges.size(); }, 30_000).unref();
|
|
153
|
+
// Per-license active-device session store. Backs the concurrent-device cap
|
|
154
|
+
// (AppRecord.maxConcurrentDevices), /releaseSeat, and /reset-sessions. In-memory
|
|
155
|
+
// by design — a keeper restart drops every session, forcing devices to
|
|
156
|
+
// re-prove ownership. That is a security feature: persisted sessions would
|
|
157
|
+
// let a stolen token bypass concurrency caps across restarts.
|
|
158
|
+
const ownershipSessions = createSessionStore();
|
|
159
|
+
setInterval(() => { /* opportunistic GC — activeCount touches prune */ ownershipSessions.size(); }, 30_000).unref();
|
|
160
|
+
async function loadAppByAddress(zkAppAddress) {
|
|
161
|
+
const apps = await readApps();
|
|
162
|
+
return apps.find((a) => a.zkAppAddress === zkAppAddress) ?? null;
|
|
163
|
+
}
|
|
164
|
+
// Resolve which generation a zkAppAddress claims to belong to by walking the
|
|
165
|
+
// keeper's AppRecord.deploymentHistory[]. The top-level address is the current
|
|
166
|
+
// generation (history.length + 1); older addresses live in history at
|
|
167
|
+
// increasing index. Returns null when the address is not present in any
|
|
168
|
+
// app's history — which is exactly what stops a "genuine v1 circuit
|
|
169
|
+
// redeployed at a fresh address" impersonation: unlisted address → no
|
|
170
|
+
// generation → verify rejects. See CANONICAL_VK_HASHES in contractInterface.ts.
|
|
171
|
+
async function resolveGenerationByAddress(zkAppAddress) {
|
|
172
|
+
const apps = await readApps();
|
|
173
|
+
for (const a of apps) {
|
|
174
|
+
const historyLen = a.deploymentHistory?.length ?? 0;
|
|
175
|
+
if (a.zkAppAddress === zkAppAddress)
|
|
176
|
+
return historyLen + 1;
|
|
177
|
+
if (a.deploymentHistory) {
|
|
178
|
+
for (let i = 0; i < a.deploymentHistory.length; i++) {
|
|
179
|
+
if (a.deploymentHistory[i].zkAppAddress === zkAppAddress)
|
|
180
|
+
return i + 1;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
const OWNERSHIP_KEY_PATH = process.env.OWNERSHIP_KEY_PATH ?? join(CIRCUIT_CACHE_DIR, 'ownership-hmac.key');
|
|
187
|
+
let ownershipKey;
|
|
188
|
+
try {
|
|
189
|
+
ownershipKey = await fs.readFile(OWNERSHIP_KEY_PATH);
|
|
190
|
+
console.log(`🔑 Ownership HMAC key loaded from ${OWNERSHIP_KEY_PATH}`);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
ownershipKey = newOwnershipKey();
|
|
194
|
+
await fs.writeFile(OWNERSHIP_KEY_PATH, ownershipKey, { mode: 0o600 });
|
|
195
|
+
console.log(`🔑 Ownership HMAC key generated → ${OWNERSHIP_KEY_PATH}`);
|
|
196
|
+
}
|
|
197
|
+
// ------------------------------------------------------------
|
|
198
|
+
// HELPERS
|
|
199
|
+
// ------------------------------------------------------------
|
|
200
|
+
async function currentSlot() {
|
|
201
|
+
try {
|
|
202
|
+
const block = await fetchLastBlock();
|
|
203
|
+
return Number(block.globalSlotSinceGenesis.toString());
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return 0;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const APPS_FILE = 'apps.json';
|
|
210
|
+
async function readApps() {
|
|
211
|
+
try {
|
|
212
|
+
return JSON.parse(await fs.readFile(APPS_FILE, 'utf8'));
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return [];
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// ------------------------------------------------------------
|
|
219
|
+
// EXPRESS SERVER
|
|
220
|
+
// ------------------------------------------------------------
|
|
221
|
+
const app = express();
|
|
222
|
+
app.use(express.json());
|
|
223
|
+
app.use((_req, res, next) => {
|
|
224
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
225
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
226
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS');
|
|
227
|
+
if (_req.method === 'OPTIONS') {
|
|
228
|
+
res.status(204).end();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
next();
|
|
232
|
+
});
|
|
233
|
+
const VERIFY_ADMIN_TOKEN = process.env.VERIFY_ADMIN_TOKEN ?? '';
|
|
234
|
+
function requireAdmin(req, res, next) {
|
|
235
|
+
if (!VERIFY_ADMIN_TOKEN) {
|
|
236
|
+
res.status(503).json({ error: 'Admin endpoint disabled (VERIFY_ADMIN_TOKEN env var not set)' });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const auth = req.headers.authorization ?? '';
|
|
240
|
+
const match = auth.match(/^Bearer\s+(.+)$/);
|
|
241
|
+
if (!match || match[1] !== VERIFY_ADMIN_TOKEN) {
|
|
242
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
next();
|
|
246
|
+
}
|
|
247
|
+
// Health
|
|
248
|
+
app.get('/health', (_req, res) => {
|
|
249
|
+
res.json({ status: 'ok', service: 'verify', network: MINA_GRAPHQL_URL });
|
|
250
|
+
});
|
|
251
|
+
// Current MerkleMap root for a specific zkApp.
|
|
252
|
+
app.get('/root', async (req, res) => {
|
|
253
|
+
const zkAppAddress = req.query.zkApp ?? '';
|
|
254
|
+
if (!zkAppAddress) {
|
|
255
|
+
return res.status(400).json({ error: 'Missing zkApp query param' });
|
|
256
|
+
}
|
|
257
|
+
const records = await readRecords();
|
|
258
|
+
const map = buildMap(records, zkAppAddress);
|
|
259
|
+
res.json({ root: map.getRoot().toString() });
|
|
260
|
+
});
|
|
261
|
+
// GET /challenge?licenseHash=...
|
|
262
|
+
// Issues a nonce for the ownership challenge-response flow.
|
|
263
|
+
app.get('/challenge', (req, res) => {
|
|
264
|
+
const licenseHash = String(req.query.licenseHash ?? '');
|
|
265
|
+
if (!licenseHash) {
|
|
266
|
+
return res.status(400).json({ error: 'Missing licenseHash' });
|
|
267
|
+
}
|
|
268
|
+
res.json(ownershipChallenges.issue(licenseHash));
|
|
269
|
+
});
|
|
270
|
+
// POST /respond — body: { zkAppAddress, proof }
|
|
271
|
+
// Verifies a LicenseProof against the issued nonce, consumes the nonce,
|
|
272
|
+
// enforces the vendor's concurrent-device cap (if any), and returns
|
|
273
|
+
// { ownership: 'verified', token, expiresAt, jti, activeSessions, deviceLimit }
|
|
274
|
+
// on success. On cap overflow returns 429 without minting a token — clients
|
|
275
|
+
// must release a seat (POST /releaseSeat), reset all sessions (POST
|
|
276
|
+
// /reset-sessions), or wait for the oldest to hit TTL.
|
|
277
|
+
app.post('/respond', async (req, res) => {
|
|
278
|
+
await ensureCompiled();
|
|
279
|
+
if (!licenseProofVk) {
|
|
280
|
+
return res.status(503).json({ error: 'Verify service not ready — circuits compiling' });
|
|
281
|
+
}
|
|
282
|
+
const { zkAppAddress, proof: proofJson } = req.body;
|
|
283
|
+
if (!zkAppAddress || !proofJson) {
|
|
284
|
+
return res.status(400).json({ error: 'Missing zkAppAddress or proof' });
|
|
285
|
+
}
|
|
286
|
+
let proof;
|
|
287
|
+
try {
|
|
288
|
+
proof = await LicenseProofProof.fromJSON(proofJson);
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
return res.status(400).json({ error: 'Invalid proof format' });
|
|
292
|
+
}
|
|
293
|
+
let proofValid = false;
|
|
294
|
+
try {
|
|
295
|
+
proofValid = await verify(proof, licenseProofVk);
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
return res.status(400).json({ error: 'Proof verification threw' });
|
|
299
|
+
}
|
|
300
|
+
if (!proofValid) {
|
|
301
|
+
return res.status(400).json({ error: 'Proof is cryptographically invalid' });
|
|
302
|
+
}
|
|
303
|
+
const licenseHash = proof.publicInput.licenseHash.toString();
|
|
304
|
+
const nonce = proof.publicInput.nonce.toString();
|
|
305
|
+
const nonceState = ownershipChallenges.consume(licenseHash, nonce);
|
|
306
|
+
if (nonceState !== 'ok') {
|
|
307
|
+
return res.status(400).json({ error: `Nonce ${nonceState} — request a fresh /challenge` });
|
|
308
|
+
}
|
|
309
|
+
// Concurrent-device cap enforcement. Read the vendor's per-app limit
|
|
310
|
+
// (0/undefined = unlimited) and try to reserve a session slot BEFORE
|
|
311
|
+
// minting the HMAC — a token without a matching session-store entry is
|
|
312
|
+
// dead on arrival at GET / anyway, so refusing here avoids handing the
|
|
313
|
+
// caller a bearer that they can only use to be rejected.
|
|
314
|
+
const app_record = await loadAppByAddress(zkAppAddress);
|
|
315
|
+
const cap = Math.max(0, Math.floor(app_record?.maxConcurrentDevices ?? 0));
|
|
316
|
+
const jti = newJti();
|
|
317
|
+
const exp = Date.now() + OWNERSHIP_TOKEN_TTL_MS;
|
|
318
|
+
const reserved = ownershipSessions.insert(licenseHash, jti, exp, cap);
|
|
319
|
+
if (reserved.state === 'at-cap') {
|
|
320
|
+
return res.status(429).json({
|
|
321
|
+
error: 'Concurrent-device limit reached — release a seat, run POST /reset-sessions, or wait for the oldest session to expire',
|
|
322
|
+
activeSessions: reserved.active,
|
|
323
|
+
deviceLimit: cap,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
const token = signOwnershipToken(ownershipKey, { z: zkAppAddress, l: licenseHash, e: exp, jti });
|
|
327
|
+
res.json({
|
|
328
|
+
ownership: 'verified',
|
|
329
|
+
token,
|
|
330
|
+
expiresAt: exp,
|
|
331
|
+
jti,
|
|
332
|
+
activeSessions: reserved.active,
|
|
333
|
+
deviceLimit: cap,
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
// POST /releaseSeat — body: { token }
|
|
337
|
+
// Releases the seat identified by the token's jti. Possession of the token
|
|
338
|
+
// is authorization to release it (same threat model as any bearer token);
|
|
339
|
+
// no proof-of-ownership is required. Enables an in-app "log out this device"
|
|
340
|
+
// button. Idempotent — a second call with the same token silently no-ops.
|
|
341
|
+
app.post('/releaseSeat', (req, res) => {
|
|
342
|
+
const { token } = req.body;
|
|
343
|
+
if (!token)
|
|
344
|
+
return res.status(400).json({ error: 'Missing token' });
|
|
345
|
+
const payload = verifyOwnershipToken(ownershipKey, token);
|
|
346
|
+
if (!payload)
|
|
347
|
+
return res.status(401).json({ error: 'Invalid or expired token' });
|
|
348
|
+
if (!payload.jti) {
|
|
349
|
+
// Pre-jti tokens aren't tracked in the session store, so there's no seat
|
|
350
|
+
// to release. Return 200 for idempotency — the caller's intent (log this
|
|
351
|
+
// device out) is satisfied by the local token delete.
|
|
352
|
+
return res.json({ ok: true, released: false, reason: 'token has no jti (pre-jti issue)' });
|
|
353
|
+
}
|
|
354
|
+
const released = ownershipSessions.releaseSeat(payload.l, payload.jti);
|
|
355
|
+
res.json({ ok: true, released, jti: payload.jti });
|
|
356
|
+
});
|
|
357
|
+
// POST /refresh — body: { token }
|
|
358
|
+
// Extends the lifetime of the current session without a fresh ownership proof.
|
|
359
|
+
// The caller sends the token they already hold; the server verifies the HMAC,
|
|
360
|
+
// checks the jti is still active in the session store (i.e. not released or
|
|
361
|
+
// evicted), and issues a new token with the same jti and a fresh exp. The
|
|
362
|
+
// session store's insert is idempotent per jti, so no cap check is needed —
|
|
363
|
+
// the slot was already reserved at /respond time.
|
|
364
|
+
//
|
|
365
|
+
// Intended for background renewal from a running app: the client polls this
|
|
366
|
+
// endpoint before the token expires so a user who keeps the app open never
|
|
367
|
+
// has to redo the passphrase-gated challenge/response. A stolen token can be
|
|
368
|
+
// refreshed too, which is why /releaseSeat exists — losing a device means the
|
|
369
|
+
// owner explicitly logs it out, and subsequent refreshes fail.
|
|
370
|
+
//
|
|
371
|
+
// Pre-jti tokens (legacy /respond without session tracking) can also refresh:
|
|
372
|
+
// they're valid HMAC, they carry no jti, and there's no session-store row to
|
|
373
|
+
// gate against. The reissued token is identically pre-jti. Callers that want
|
|
374
|
+
// concurrency enforcement must reissue via /respond so a jti gets minted.
|
|
375
|
+
app.post('/refresh', (req, res) => {
|
|
376
|
+
const { token } = req.body;
|
|
377
|
+
if (!token)
|
|
378
|
+
return res.status(400).json({ error: 'Missing token' });
|
|
379
|
+
const payload = verifyOwnershipToken(ownershipKey, token);
|
|
380
|
+
if (!payload)
|
|
381
|
+
return res.status(401).json({ error: 'Invalid or expired token' });
|
|
382
|
+
const newExp = Date.now() + OWNERSHIP_TOKEN_TTL_MS;
|
|
383
|
+
if (payload.jti) {
|
|
384
|
+
if (!ownershipSessions.has(payload.l, payload.jti)) {
|
|
385
|
+
return res.status(401).json({ error: 'Seat released' });
|
|
386
|
+
}
|
|
387
|
+
// Idempotent for existing jti — updates the expiry, never trips the cap.
|
|
388
|
+
ownershipSessions.insert(payload.l, payload.jti, newExp, 0);
|
|
389
|
+
}
|
|
390
|
+
const next = {
|
|
391
|
+
z: payload.z,
|
|
392
|
+
l: payload.l,
|
|
393
|
+
e: newExp,
|
|
394
|
+
...(payload.jti ? { jti: payload.jti } : {}),
|
|
395
|
+
};
|
|
396
|
+
const nextToken = signOwnershipToken(ownershipKey, next);
|
|
397
|
+
res.json({ token: nextToken, expiresAt: newExp, jti: payload.jti });
|
|
398
|
+
});
|
|
399
|
+
// POST /reset-sessions — body: { zkAppAddress, proof }
|
|
400
|
+
// Drops ALL active sessions for the licenseHash proved by the LicenseProof.
|
|
401
|
+
// Requires the same challenge/response as /respond — only the passphrase
|
|
402
|
+
// holder can invoke it. Intended for "reinstall unsticks a device that
|
|
403
|
+
// burned a slot without logging out" recovery. After this returns, the
|
|
404
|
+
// caller runs a fresh /respond to re-provision a session under a zero
|
|
405
|
+
// active count.
|
|
406
|
+
app.post('/reset-sessions', async (req, res) => {
|
|
407
|
+
await ensureCompiled();
|
|
408
|
+
if (!licenseProofVk) {
|
|
409
|
+
return res.status(503).json({ error: 'Verify service not ready — circuits compiling' });
|
|
410
|
+
}
|
|
411
|
+
const { zkAppAddress, proof: proofJson } = req.body;
|
|
412
|
+
if (!zkAppAddress || !proofJson) {
|
|
413
|
+
return res.status(400).json({ error: 'Missing zkAppAddress or proof' });
|
|
414
|
+
}
|
|
415
|
+
let proof;
|
|
416
|
+
try {
|
|
417
|
+
proof = await LicenseProofProof.fromJSON(proofJson);
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
return res.status(400).json({ error: 'Invalid proof format' });
|
|
421
|
+
}
|
|
422
|
+
let proofValid = false;
|
|
423
|
+
try {
|
|
424
|
+
proofValid = await verify(proof, licenseProofVk);
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
return res.status(400).json({ error: 'Proof verification threw' });
|
|
428
|
+
}
|
|
429
|
+
if (!proofValid) {
|
|
430
|
+
return res.status(400).json({ error: 'Proof is cryptographically invalid' });
|
|
431
|
+
}
|
|
432
|
+
const licenseHash = proof.publicInput.licenseHash.toString();
|
|
433
|
+
const nonce = proof.publicInput.nonce.toString();
|
|
434
|
+
const nonceState = ownershipChallenges.consume(licenseHash, nonce);
|
|
435
|
+
if (nonceState !== 'ok') {
|
|
436
|
+
return res.status(400).json({ error: `Nonce ${nonceState} — request a fresh /challenge` });
|
|
437
|
+
}
|
|
438
|
+
const dropped = ownershipSessions.reset(licenseHash);
|
|
439
|
+
res.json({ ok: true, dropped });
|
|
440
|
+
});
|
|
441
|
+
// GET /?licenseHash=...&zkAppAddress=...[&token=...]
|
|
442
|
+
// Primary endpoint for Android, iOS, PWA, and Node SDK license verification.
|
|
443
|
+
//
|
|
444
|
+
// Ownership semantics:
|
|
445
|
+
// - Without `token`, the response describes the state of the on-chain leaf
|
|
446
|
+
// only — it does NOT prove the caller owns the license. This is fine for
|
|
447
|
+
// status displays; gate access behind /respond or pass a fresh
|
|
448
|
+
// activation `token` in the query string.
|
|
449
|
+
// - With `token`, the service checks the HMAC and refuses if the token isn't
|
|
450
|
+
// for this (zkAppAddress, licenseHash) pair.
|
|
451
|
+
app.get('/', async (req, res) => {
|
|
452
|
+
const { licenseHash: licenseHashStr, zkAppAddress, token } = req.query;
|
|
453
|
+
if (!licenseHashStr || !zkAppAddress) {
|
|
454
|
+
return res.status(400).json({ error: 'Missing required query params: licenseHash, zkAppAddress' });
|
|
455
|
+
}
|
|
456
|
+
let ownershipVerified = false;
|
|
457
|
+
if (token) {
|
|
458
|
+
const payload = verifyOwnershipToken(ownershipKey, token);
|
|
459
|
+
if (!payload || payload.z !== zkAppAddress || payload.l !== licenseHashStr) {
|
|
460
|
+
return res.status(401).json({ error: 'Invalid or expired ownership token' });
|
|
461
|
+
}
|
|
462
|
+
// Session-store gate: a jti-bearing token must still be in the active-
|
|
463
|
+
// session set for its license. A token whose jti was released (via
|
|
464
|
+
// /releaseSeat, /reset-sessions, or evicted by TTL prune) is refused even if
|
|
465
|
+
// it's still HMAC-valid and inside its exp. Pre-jti tokens (issued
|
|
466
|
+
// before the session-tracking rollout) skip this check and remain
|
|
467
|
+
// usable until their exp — no forced logout across the migration.
|
|
468
|
+
if (payload.jti && !ownershipSessions.has(payload.l, payload.jti)) {
|
|
469
|
+
return res.status(401).json({ error: 'Seat released — call POST /respond to acquire a fresh token' });
|
|
470
|
+
}
|
|
471
|
+
ownershipVerified = true;
|
|
472
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
const [records, { account }, lastBlock] = await Promise.all([
|
|
475
|
+
readRecords(),
|
|
476
|
+
fetchAccount({ publicKey: PublicKey.fromBase58(zkAppAddress) }),
|
|
477
|
+
fetchLastBlock(),
|
|
478
|
+
]);
|
|
479
|
+
const onChainRoot = account?.zkapp?.appState?.[0]?.toString();
|
|
480
|
+
if (!onChainRoot) {
|
|
481
|
+
return res.status(404).json({ error: 'zkApp not found on chain' });
|
|
482
|
+
}
|
|
483
|
+
// Source-version tag from the already-fetched account state — free byte
|
|
484
|
+
// for the SDK / dashboard to label which protocol revision a license lives
|
|
485
|
+
// on. Not a security gate (that's the boot + bootstrap check); purely
|
|
486
|
+
// informational.
|
|
487
|
+
const zkAppVersion = Number(account?.zkapp?.appState?.[7]?.toString() ?? '0');
|
|
488
|
+
// Defense in depth against impersonation. The contract locks
|
|
489
|
+
// `setVerificationKey` via `Permissions.VerificationKey
|
|
490
|
+
// .impossibleDuringCurrentVersion()` plus deploy-key disposal, so VK
|
|
491
|
+
// rotation post-deploy is impossible for any *listed* address. The check
|
|
492
|
+
// below resolves the address's generation via keeper deploymentHistory
|
|
493
|
+
// and asserts its on-chain VK equals that generation's canonical hash.
|
|
494
|
+
// Two failure modes: (a) the address is unknown (not the current live
|
|
495
|
+
// deployment of any registered app, not in any deploymentHistory[]) —
|
|
496
|
+
// reject; that stops a genuine old circuit deployed at a fresh address.
|
|
497
|
+
// (b) the address is listed but its VK doesn't equal its generation's
|
|
498
|
+
// hash — reject; that's a loud index-corruption tripwire.
|
|
499
|
+
const onChainVk = account?.zkapp?.verificationKey?.hash?.toString();
|
|
500
|
+
const generation = await resolveGenerationByAddress(zkAppAddress);
|
|
501
|
+
if (generation === null) {
|
|
502
|
+
return res.json({
|
|
503
|
+
valid: false,
|
|
504
|
+
expiresAt: null,
|
|
505
|
+
expirySlot: 0,
|
|
506
|
+
inGracePeriod: false,
|
|
507
|
+
remainingDays: 0,
|
|
508
|
+
reason: 'zkAppAddress is not in any registered app deployment history',
|
|
509
|
+
ownership: ownershipVerified ? 'verified' : 'unverified',
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
const expectedVk = CANONICAL_VK_HASHES[generation];
|
|
513
|
+
if (expectedVk && onChainVk !== expectedVk) {
|
|
514
|
+
return res.json({
|
|
515
|
+
valid: false,
|
|
516
|
+
expiresAt: null,
|
|
517
|
+
expirySlot: 0,
|
|
518
|
+
inGracePeriod: false,
|
|
519
|
+
remainingDays: 0,
|
|
520
|
+
reason: `zkApp verification key does not match generation ${generation}'s canonical LicensingApp circuit`,
|
|
521
|
+
ownership: ownershipVerified ? 'verified' : 'unverified',
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
const currentSlot = Number(lastBlock.globalSlotSinceGenesis.toString());
|
|
525
|
+
let liveRecords = records;
|
|
526
|
+
let result = verifyLicenseCore({
|
|
527
|
+
zkAppAddress,
|
|
528
|
+
licenseHash: licenseHashStr,
|
|
529
|
+
records: liveRecords,
|
|
530
|
+
onChainRoot,
|
|
531
|
+
currentSlot,
|
|
532
|
+
});
|
|
533
|
+
// Lazy self-heal: a root mismatch means the local store is stale (a
|
|
534
|
+
// license has been bought, renewed, or refunded since we last replayed).
|
|
535
|
+
// One replay + one retry is enough — the on-chain root is the source of
|
|
536
|
+
// truth and the archive node will reflect it.
|
|
537
|
+
if (!result.valid && result.reason === 'License state does not match on-chain root') {
|
|
538
|
+
await lazyBootstrap(zkAppAddress);
|
|
539
|
+
liveRecords = await readRecords();
|
|
540
|
+
result = verifyLicenseCore({
|
|
541
|
+
zkAppAddress,
|
|
542
|
+
licenseHash: licenseHashStr,
|
|
543
|
+
records: liveRecords,
|
|
544
|
+
onChainRoot,
|
|
545
|
+
currentSlot,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
const record = liveRecords.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHashStr);
|
|
549
|
+
return res.json({
|
|
550
|
+
...result,
|
|
551
|
+
currentSlot,
|
|
552
|
+
purchaseSlot: record?.purchaseSlot ?? 0,
|
|
553
|
+
ownership: ownershipVerified ? 'verified' : 'unverified',
|
|
554
|
+
zkAppVersion,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
catch (err) {
|
|
558
|
+
console.error('[verify]', err?.message ?? err);
|
|
559
|
+
res.status(500).json({ error: err?.message ?? 'Verification failed' });
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
// License map witness — composite (zkAppAddress, licenseHash) key.
|
|
563
|
+
app.get('/witness/:zkAppAddress/:licenseHash', async (req, res) => {
|
|
564
|
+
const { zkAppAddress, licenseHash } = req.params;
|
|
565
|
+
try {
|
|
566
|
+
const records = await readRecords();
|
|
567
|
+
const map = buildMap(records, zkAppAddress);
|
|
568
|
+
const key = Field(licenseHash);
|
|
569
|
+
const witness = map.getWitness(key);
|
|
570
|
+
res.json({
|
|
571
|
+
root: map.getRoot().toString(),
|
|
572
|
+
currentValue: map.get(key).toString(),
|
|
573
|
+
...serializeWitness(witness),
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
catch (err) {
|
|
577
|
+
res.status(400).json({ error: err.message });
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
// License status — composite (zkAppAddress, licenseHash) key.
|
|
581
|
+
app.get('/license/:zkAppAddress/:licenseHash', async (req, res) => {
|
|
582
|
+
const { zkAppAddress, licenseHash } = req.params;
|
|
583
|
+
const record = await getLicense(zkAppAddress, licenseHash);
|
|
584
|
+
const slot = await currentSlot();
|
|
585
|
+
const status = licenseStatus(record, slot);
|
|
586
|
+
res.json({
|
|
587
|
+
found: record !== null,
|
|
588
|
+
expirySlot: record?.expirySlot ?? 0,
|
|
589
|
+
status,
|
|
590
|
+
txHash: record?.txHash ?? null,
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
// List registered apps
|
|
594
|
+
app.get('/apps', async (_req, res) => {
|
|
595
|
+
const apps = await readApps();
|
|
596
|
+
res.json({ apps });
|
|
597
|
+
});
|
|
598
|
+
// POST /bootstrap — admin
|
|
599
|
+
// Body: { zkAppAddress: string }
|
|
600
|
+
// Replays all canonical events from the archive node and rebuilds licenses.json.
|
|
601
|
+
// Gated because an open archive-replay endpoint is a DoS vector (each call hits
|
|
602
|
+
// the archive node and rewrites the local store).
|
|
603
|
+
app.post('/bootstrap', requireAdmin, async (req, res) => {
|
|
604
|
+
const { zkAppAddress } = req.body;
|
|
605
|
+
if (!zkAppAddress) {
|
|
606
|
+
return res.status(400).json({ error: 'Missing zkAppAddress' });
|
|
607
|
+
}
|
|
608
|
+
try {
|
|
609
|
+
// Refuse to replay a zkApp whose immutable source-version tag doesn't match
|
|
610
|
+
// one this verifier understands. Otherwise a v2-deployed contract's events
|
|
611
|
+
// could be decoded with a v1 schema and yield subtly wrong state.
|
|
612
|
+
const onChainVersion = await fetchLicensingAppVersion(zkAppAddress);
|
|
613
|
+
if (onChainVersion === null) {
|
|
614
|
+
return res.status(400).json({ error: `zkApp ${zkAppAddress.slice(0, 16)}… not visible on chain` });
|
|
615
|
+
}
|
|
616
|
+
if (!SUPPORTED_LICENSING_APP_VERSIONS.includes(onChainVersion)) {
|
|
617
|
+
return res.status(400).json({
|
|
618
|
+
error: `unsupported LicensingApp version — on-chain v${onChainVersion}, this verifier supports v${SUPPORTED_LICENSING_APP_VERSIONS.join(', v')}`,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
console.log(`[bootstrap] Replaying events for ${zkAppAddress.slice(0, 16)}… (v${onChainVersion}) from ${archiveUrl}`);
|
|
622
|
+
const { events } = await bootstrapFromArchive(zkAppAddress);
|
|
623
|
+
const { count, warnings } = await applyEventsToStore(zkAppAddress, events);
|
|
624
|
+
for (const w of warnings)
|
|
625
|
+
console.warn('[bootstrap]', w);
|
|
626
|
+
console.log(`[bootstrap] Done — ${count} license records written`);
|
|
627
|
+
res.json({ ok: true, count, warnings, onChainVersion });
|
|
628
|
+
}
|
|
629
|
+
catch (err) {
|
|
630
|
+
console.error('[bootstrap]', err?.message ?? err);
|
|
631
|
+
res.status(500).json({ error: err?.message ?? 'Bootstrap failed' });
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
// ------------------------------------------------------------
|
|
635
|
+
// START
|
|
636
|
+
// ------------------------------------------------------------
|
|
637
|
+
let records = await readRecords();
|
|
638
|
+
// Auto-bootstrap from archive if store is empty.
|
|
639
|
+
if (records.length === 0) {
|
|
640
|
+
const apps = await readApps();
|
|
641
|
+
if (apps.length > 0) {
|
|
642
|
+
console.log(`📦 licenses.json is empty — bootstrapping ${apps.length} app(s) from archive node…`);
|
|
643
|
+
for (const appRecord of apps) {
|
|
644
|
+
try {
|
|
645
|
+
// Same version guard as POST /bootstrap — refuse to replay any address
|
|
646
|
+
// whose on-chain source-version tag this verifier doesn't understand.
|
|
647
|
+
const onChainVersion = await fetchLicensingAppVersion(appRecord.zkAppAddress);
|
|
648
|
+
if (onChainVersion === null) {
|
|
649
|
+
console.warn(` ⚠️ ${appRecord.name}: zkApp not visible on chain — skipping`);
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
if (!SUPPORTED_LICENSING_APP_VERSIONS.includes(onChainVersion)) {
|
|
653
|
+
console.warn(` ⚠️ ${appRecord.name}: on-chain v${onChainVersion} not supported (supports v${SUPPORTED_LICENSING_APP_VERSIONS.join(', v')}) — skipping`);
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
const { events } = await bootstrapFromArchive(appRecord.zkAppAddress);
|
|
657
|
+
const { count, warnings } = await applyEventsToStore(appRecord.zkAppAddress, events);
|
|
658
|
+
for (const w of warnings)
|
|
659
|
+
console.warn('⚠️', w);
|
|
660
|
+
console.log(` ✅ ${appRecord.name} (v${onChainVersion}): ${count} records`);
|
|
661
|
+
}
|
|
662
|
+
catch (err) {
|
|
663
|
+
console.warn(` ⚠️ ${appRecord.name}: bootstrap failed — ${err?.message ?? err}`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
records = await readRecords();
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
console.log(`📋 Loaded ${records.length} license records across ${new Set(records.map(r => r.zkAppAddress)).size} zkApp(s)`);
|
|
670
|
+
app.listen(PORT, () => {
|
|
671
|
+
console.log(`🔍 Verify service listening on http://localhost:${PORT}`);
|
|
672
|
+
console.log(` GET /?licenseHash=...&zkAppAddress=...[&token=...]`);
|
|
673
|
+
console.log(` GET /challenge?licenseHash=...`);
|
|
674
|
+
console.log(` POST /respond`);
|
|
675
|
+
console.log(` POST /refresh`);
|
|
676
|
+
console.log(` POST /releaseSeat`);
|
|
677
|
+
console.log(` POST /reset-sessions`);
|
|
678
|
+
console.log(` GET /witness/:zkAppAddress/:licenseHash`);
|
|
679
|
+
console.log(` GET /license/:zkAppAddress/:licenseHash`);
|
|
680
|
+
console.log(` GET /apps`);
|
|
681
|
+
console.log(` POST /bootstrap`);
|
|
682
|
+
if (VERIFY_ADMIN_TOKEN) {
|
|
683
|
+
console.log(`🔒 Admin endpoints (bootstrap) enabled`);
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
console.log(`⚠️ Admin endpoints disabled (set VERIFY_ADMIN_TOKEN to enable)`);
|
|
687
|
+
}
|
|
688
|
+
console.log(`🔁 Lazy bootstrap on root mismatch (no periodic poller)`);
|
|
689
|
+
});
|
|
690
|
+
//# sourceMappingURL=verifyService.js.map
|