Лицензия, редактор, пульт и сборка
- Main: license service, IPC, router; закрытие окон; yauzl закрытие zip (EMFILE), zipRead тест - Editor: стабильный projectState без мигания, логотип и меню, строки UI, LayoutShell overlay - Control: ластик для всех типов эффектов, затухание/нарастание музыки при смене сцены - Сборка: vite, build/dev scripts, obfuscate-main и build-env скрипты с тестами; package.json Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Публичный ключ Ed25519 (SPKI DER, base64). Должен соответствовать приватному ключу на сервере лицензий.
|
||||
* Репозиторий сервера: https://git.mailib.ru/ifontosh/DndGamePlayerLicenseServer.git
|
||||
*/
|
||||
export const LICENSE_ED25519_SPKI_DER_B64 = 'MCowBQYDK2VwAyEAd7zvdjqeYW/fUvG5RX1/L1SCTZL1xzh+kr4rlNLQJbY=';
|
||||
@@ -0,0 +1,11 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { canonicalJson } from './canonicalJson';
|
||||
|
||||
void test('canonicalJson: стабильный порядок ключей', () => {
|
||||
const a = canonicalJson({ b: 2, a: 1 });
|
||||
const b = canonicalJson({ a: 1, b: 2 });
|
||||
assert.equal(a, b);
|
||||
assert.equal(a, '{"a":1,"b":2}');
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Детерминированная JSON-сериализация для подписи (совпадает с `lib/canonicalJson.mjs` в DndGamePlayerLicenseServer). */
|
||||
export function canonicalJson(value: unknown): string {
|
||||
if (value === null) return 'null';
|
||||
const t = typeof value;
|
||||
if (t === 'number') {
|
||||
if (!Number.isFinite(value as number)) 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 o = value as Record<string, unknown>;
|
||||
const keys = Object.keys(o).sort();
|
||||
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`).join(',')}}`;
|
||||
}
|
||||
throw new TypeError(`unsupported type in license payload: ${t}`);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Версия текста EULA; при изменении текста увеличить и запросить повторное принятие. */
|
||||
export const EULA_CURRENT_VERSION = 1;
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { LicenseVerifyFailure } from './verifyReasons';
|
||||
|
||||
export type LicenseSnapshotReason = 'ok' | LicenseVerifyFailure | 'revoked_remote' | 'none';
|
||||
|
||||
export type LicenseSnapshot = {
|
||||
active: boolean;
|
||||
devSkip: boolean;
|
||||
reason: LicenseSnapshotReason;
|
||||
summary: {
|
||||
sub: string;
|
||||
pid: string;
|
||||
exp: number;
|
||||
did: string | null;
|
||||
} | null;
|
||||
eulaAcceptedVersion: number | null;
|
||||
deviceId: string;
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
/** Полезная нагрузка лицензии v1 (подписывается Ed25519 на сервере). */
|
||||
export type LicensePayloadV1 = {
|
||||
v: 1;
|
||||
/** Стабильный id лицензии (для отзыва и учёта). */
|
||||
sub: string;
|
||||
/** Идентификатор продукта. */
|
||||
pid: string;
|
||||
/** Unix-время выдачи (сек). */
|
||||
iat: number;
|
||||
/** Unix-время окончания (сек). */
|
||||
exp: number;
|
||||
/** Не раньше этого времени (сек), опционально. */
|
||||
nbf?: number;
|
||||
/** Привязка к устройству: null — любое устройство. */
|
||||
did: string | null;
|
||||
};
|
||||
|
||||
export function isLicensePayloadV1(x: unknown): x is LicensePayloadV1 {
|
||||
if (!x || typeof x !== 'object') return false;
|
||||
const o = x as Record<string, unknown>;
|
||||
return (
|
||||
o.v === 1 &&
|
||||
typeof o.sub === 'string' &&
|
||||
o.sub.length > 0 &&
|
||||
typeof o.pid === 'string' &&
|
||||
o.pid.length > 0 &&
|
||||
typeof o.iat === 'number' &&
|
||||
Number.isFinite(o.iat) &&
|
||||
typeof o.exp === 'number' &&
|
||||
Number.isFinite(o.exp) &&
|
||||
(o.did === null || typeof o.did === 'string')
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
const B64URL = {
|
||||
encode(bytes: Uint8Array): string {
|
||||
let bin = '';
|
||||
for (const byte of bytes) {
|
||||
bin += String.fromCharCode(byte);
|
||||
}
|
||||
const b64 = btoa(bin);
|
||||
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/u, '');
|
||||
},
|
||||
decode(s: string): Uint8Array {
|
||||
const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4));
|
||||
const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + pad;
|
||||
const bin = atob(b64);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i) & 0xff;
|
||||
return out;
|
||||
},
|
||||
};
|
||||
|
||||
/** Тело UTF-8 + подпись Ed25519 (64 байта), разделитель «.». */
|
||||
export function splitSignedLicenseToken(token: string): { bodyUtf8: string; signature: Uint8Array } | null {
|
||||
const t = token.trim();
|
||||
const dot = t.indexOf('.');
|
||||
if (dot <= 0) return null;
|
||||
const a = t.slice(0, dot);
|
||||
const b = t.slice(dot + 1);
|
||||
if (!a || !b) return null;
|
||||
try {
|
||||
const bodyBytes = B64URL.decode(a);
|
||||
const sigBytes = B64URL.decode(b);
|
||||
const bodyUtf8 = new TextDecoder().decode(bodyBytes);
|
||||
return { bodyUtf8, signature: sigBytes };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function joinSignedLicenseToken(bodyUtf8: string, signature: Uint8Array): string {
|
||||
const bodyBytes = new TextEncoder().encode(bodyUtf8);
|
||||
return `${B64URL.encode(bodyBytes)}.${B64URL.encode(signature)}`;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type LicenseVerifyFailure =
|
||||
| 'malformed'
|
||||
| 'bad_signature'
|
||||
| 'bad_payload'
|
||||
| 'not_yet_valid'
|
||||
| 'expired'
|
||||
| 'wrong_device';
|
||||
Reference in New Issue
Block a user