Initial commit: license activation server (Ed25519)
Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
data.json
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -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`).
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"productKeys": [
|
||||
{
|
||||
"key": "DND-DEMO-PRODUCT-KEY",
|
||||
"sub": "lic_demo_default",
|
||||
"pid": "dnd_player",
|
||||
"maxDevices": 3,
|
||||
"expiresAtSec": 1893456000
|
||||
}
|
||||
],
|
||||
"revokedSubs": [],
|
||||
"activations": {}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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
@@ -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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user