Initial commit: license activation server (Ed25519)

Made-with: Cursor
This commit is contained in:
Ivan Fontosh
2026-04-19 17:58:51 +08:00
commit 4a0523f309
6 changed files with 262 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
data.json
.env
*.log
.DS_Store
+53
View File
@@ -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 <LICENSE_ADMIN_TOKEN>`, тело `{ "sub": "..." }`.
- `POST /v1/admin/issue` — админская выдача (`sub`, `pid`, `iat`, `exp`, `did`).
- `GET /health``{ ok: true }`.
## Клиент
`DND_LICENSE_STATUS_URL=http://localhost:3847/` (база URL для сборки пути `v1/status`).
+13
View File
@@ -0,0 +1,13 @@
{
"productKeys": [
{
"key": "DND-DEMO-PRODUCT-KEY",
"sub": "lic_demo_default",
"pid": "dnd_player",
"maxDevices": 3,
"expiresAtSec": 1893456000
}
],
"revokedSubs": [],
"activations": {}
}
+18
View File
@@ -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}`);
}
+13
View File
@@ -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"
}
}
+160
View File
@@ -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}`);
});