From 4a0523f309ce2dc78ec82bd75104f635730041ef Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Sun, 19 Apr 2026 17:58:51 +0800 Subject: [PATCH] Initial commit: license activation server (Ed25519) Made-with: Cursor --- .gitignore | 5 ++ README.md | 53 ++++++++++++++ data.example.json | 13 ++++ lib/canonicalJson.mjs | 18 +++++ package.json | 13 ++++ src/server.mjs | 160 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 262 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 data.example.json create mode 100644 lib/canonicalJson.mjs create mode 100644 package.json create mode 100644 src/server.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f173cc0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +data.json +.env +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..8851e5c --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# DndGamePlayerLicenseServer + +Сервис лицензирования для **DNDGamePlayer**: активация по продуктовому ключу, выдача подписанного токена Ed25519, отзыв по `sub` (клиент опрашивает `GET /v1/status`, если задан `DND_LICENSE_STATUS_URL` в Electron main). + +Репозиторий клиента: отдельный проект `dnd_player` — публичный ключ вшит в `app/shared/license/bundledPublicKey.ts` и должен соответствовать `LICENSE_PRIVATE_KEY_PEM` здесь. + +## Переменные окружения + +| Переменная | Описание | +|------------|----------| +| `LICENSE_PRIVATE_KEY_PEM` | Приватный ключ Ed25519 в PEM (PKCS#8), **обязательно** | +| `LICENSE_ADMIN_TOKEN` | Bearer-токен для `/v1/admin/*` (по умолчанию `change-me-admin`) | +| `DND_LICENSE_DATA_PATH` | Путь к `data.json` (по умолчанию `./data.json` в корне репозитория) | +| `PORT` | Порт (по умолчанию `3847`) | + +## Подготовка + +1. Скопируйте `data.example.json` → `data.json`. +2. Сгенерируйте пару Ed25519; **публичный** ключ (SPKI DER base64) вставьте в клиент в `bundledPublicKey.ts`. + +```bash +node -e "const c=require('crypto');const kp=c.generateKeyPairSync('ed25519');console.log(kp.privateKey.export({type:'pkcs8',format:'pem'}));" +``` + +### Демо-ключ (только для разработки) + +Публичный ключ по умолчанию в клиенте соответствует этому приватному PEM: + +``` +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIDNxA9U1VSG9zoOvcJ5uB+JUe25UD5m9UwMi6slXzW44 +-----END PRIVATE KEY----- +``` + +## Запуск + +```bash +npm start +``` + +На Windows перед запуском задайте переменную окружения `LICENSE_PRIVATE_KEY_PEM` (многострочное значение в кавычках или через `.env` и загрузчик по желанию). + +## API + +- `POST /v1/activate` — `{ "productKey": "...", "deviceId": "..." }` → `{ token, sub }`. +- `GET /v1/status?sub=...` → `{ revoked: boolean }`. +- `POST /v1/admin/revoke` — `Authorization: Bearer `, тело `{ "sub": "..." }`. +- `POST /v1/admin/issue` — админская выдача (`sub`, `pid`, `iat`, `exp`, `did`). +- `GET /health` — `{ ok: true }`. + +## Клиент + +`DND_LICENSE_STATUS_URL=http://localhost:3847/` (база URL для сборки пути `v1/status`). diff --git a/data.example.json b/data.example.json new file mode 100644 index 0000000..3e68684 --- /dev/null +++ b/data.example.json @@ -0,0 +1,13 @@ +{ + "productKeys": [ + { + "key": "DND-DEMO-PRODUCT-KEY", + "sub": "lic_demo_default", + "pid": "dnd_player", + "maxDevices": 3, + "expiresAtSec": 1893456000 + } + ], + "revokedSubs": [], + "activations": {} +} diff --git a/lib/canonicalJson.mjs b/lib/canonicalJson.mjs new file mode 100644 index 0000000..8279a77 --- /dev/null +++ b/lib/canonicalJson.mjs @@ -0,0 +1,18 @@ +/** Детерминированная JSON-сериализация для подписи (должна совпадать с `app/shared/license/canonicalJson.ts` в клиенте DNDGamePlayer). */ +export function canonicalJson(value) { + if (value === null) return 'null'; + const t = typeof value; + if (t === 'number') { + if (!Number.isFinite(value)) throw new TypeError('non-finite number in license payload'); + return JSON.stringify(value); + } + if (t === 'string' || t === 'boolean') return JSON.stringify(value); + if (Array.isArray(value)) { + return `[${value.map((x) => canonicalJson(x)).join(',')}]`; + } + if (t === 'object') { + const keys = Object.keys(value).sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`; + } + throw new TypeError(`unsupported type: ${t}`); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..894b31d --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "dndgameplayer-license-server", + "private": true, + "version": "1.0.0", + "type": "module", + "description": "Сервис выдачи и отзыва лицензий DNDGamePlayer (Ed25519)", + "scripts": { + "start": "node src/server.mjs" + }, + "engines": { + "node": ">=20" + } +} diff --git a/src/server.mjs b/src/server.mjs new file mode 100644 index 0000000..933488f --- /dev/null +++ b/src/server.mjs @@ -0,0 +1,160 @@ +import { createPrivateKey, randomUUID, sign } from 'node:crypto'; +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { canonicalJson } from '../lib/canonicalJson.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '..'); + +function readData() { + const p = process.env.DND_LICENSE_DATA_PATH ?? path.join(root, 'data.json'); + const raw = fs.readFileSync(p, 'utf8'); + return JSON.parse(raw); +} + +function writeData(data) { + const p = process.env.DND_LICENSE_DATA_PATH ?? path.join(root, 'data.json'); + fs.writeFileSync(p, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); +} + +function b64url(buf) { + return Buffer.from(buf) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/u, ''); +} + +function signPayload(payload, privateKeyPem) { + const key = createPrivateKey(privateKeyPem); + const body = canonicalJson(payload); + const sig = sign(null, Buffer.from(body, 'utf8'), key); + return `${b64url(Buffer.from(body, 'utf8'))}.${b64url(sig)}`; +} + +function json(res, code, obj) { + const body = JSON.stringify(obj); + res.writeHead(code, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + }); + res.end(body); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} + +const privateKeyPem = process.env.LICENSE_PRIVATE_KEY_PEM; +if (!privateKeyPem) { + console.error('Задайте LICENSE_PRIVATE_KEY_PEM (PKCS#8 PEM, Ed25519)'); + process.exit(1); +} + +const adminToken = process.env.LICENSE_ADMIN_TOKEN ?? 'change-me-admin'; + +const port = Number(process.env.PORT ?? 3847); + +const server = http.createServer(async (req, res) => { + try { + const url = new URL(req.url ?? '/', `http://localhost`); + if (req.method === 'GET' && url.pathname === '/v1/status') { + const sub = url.searchParams.get('sub'); + if (!sub) return json(res, 400, { error: 'missing_sub' }); + const data = readData(); + const revoked = Array.isArray(data.revokedSubs) && data.revokedSubs.includes(sub); + return json(res, 200, { revoked }); + } + + if (req.method === 'POST' && url.pathname === '/v1/activate') { + const raw = await readBody(req); + const body = JSON.parse(raw || '{}'); + const productKey = body.productKey; + const deviceId = body.deviceId; + if (!productKey || !deviceId) return json(res, 400, { error: 'productKey_and_deviceId_required' }); + + const data = readData(); + const pk = data.productKeys?.find((x) => x.key === productKey); + if (!pk) return json(res, 403, { error: 'unknown_product_key' }); + if (data.revokedSubs?.includes(pk.sub)) return json(res, 403, { error: 'license_revoked' }); + + data.activations ??= {}; + const list = data.activations[pk.sub] ?? []; + const already = list.includes(deviceId); + if (!already && list.length >= pk.maxDevices) { + return json(res, 403, { error: 'too_many_devices' }); + } + if (!already) { + list.push(deviceId); + data.activations[pk.sub] = list; + writeData(data); + } + + const now = Math.floor(Date.now() / 1000); + const payload = { + v: 1, + sub: pk.sub, + pid: pk.pid, + iat: now, + exp: pk.expiresAtSec, + did: deviceId, + }; + const token = signPayload(payload, privateKeyPem); + return json(res, 200, { token, sub: pk.sub }); + } + + if (req.method === 'POST' && url.pathname === '/v1/admin/revoke') { + const auth = req.headers.authorization ?? ''; + const tok = auth.startsWith('Bearer ') ? auth.slice(7) : ''; + if (tok !== adminToken) return json(res, 401, { error: 'unauthorized' }); + const raw = await readBody(req); + const body = JSON.parse(raw || '{}'); + const sub = body.sub; + if (!sub) return json(res, 400, { error: 'missing_sub' }); + const data = readData(); + data.revokedSubs ??= []; + if (!data.revokedSubs.includes(sub)) data.revokedSubs.push(sub); + writeData(data); + return json(res, 200, { ok: true }); + } + + if (req.method === 'POST' && url.pathname === '/v1/admin/issue') { + const auth = req.headers.authorization ?? ''; + const tok = auth.startsWith('Bearer ') ? auth.slice(7) : ''; + if (tok !== adminToken) return json(res, 401, { error: 'unauthorized' }); + const raw = await readBody(req); + const body = JSON.parse(raw || '{}'); + const now = Math.floor(Date.now() / 1000); + const payload = { + v: 1, + sub: body.sub ?? `lic_${randomUUID()}`, + pid: body.pid ?? 'dnd_player', + iat: body.iat ?? now, + exp: body.exp ?? now + 86400 * 365, + did: body.did === undefined ? null : body.did, + }; + const token = signPayload(payload, privateKeyPem); + return json(res, 200, { token, payload }); + } + + if (req.method === 'GET' && url.pathname === '/health') { + return json(res, 200, { ok: true }); + } + + return json(res, 404, { error: 'not_found' }); + } catch (e) { + json(res, 500, { error: e instanceof Error ? e.message : String(e) }); + } +}); + +server.listen(port, () => { + console.log(`DndGamePlayerLicenseServer listening on http://localhost:${port}`); +});