подготовка к билду

This commit is contained in:
Ivan Fontosh
2026-04-19 23:22:05 +08:00
parent 2fa20da94d
commit 726c89e104
13 changed files with 180 additions and 13 deletions
+1 -1
View File
@@ -107,7 +107,7 @@ async function main() {
buildNumber: getOptionalBuildNumber(),
}));
registerHandler(ipcChannels.license.getStatus, () => licenseService.getStatus());
registerHandler(ipcChannels.license.setToken, ({ token }) => licenseService.setToken(token));
registerHandler(ipcChannels.license.setToken, async ({ token }) => licenseService.setToken(token));
registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken());
registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version));
registerHandler(ipcChannels.windows.openMultiWindow, () => {
+41 -2
View File
@@ -6,6 +6,8 @@ import { ipcChannels } from '../../shared/ipc/contracts';
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
import type { LicensePayloadV1 } from '../../shared/license/payloadV1';
import { isDndProductKey } from '../../shared/license/productKey';
import { normalizeLicenseTokenInput } from '../../shared/license/tokenFormat';
import { getOrCreateDeviceId } from './deviceId';
import { licenseEncryptedPath, preferencesPath } from './paths';
@@ -77,6 +79,40 @@ export class LicenseService {
}
}
/** База для `POST /v1/activate` (и при желании совпадает с сервером отзыва). */
private resolveLicenseActivateBaseUrl(): string {
const raw = process.env.DND_LICENSE_STATUS_URL?.trim();
if (raw) return raw.endsWith('/') ? raw : `${raw}/`;
return 'https://license.mailib.ru/';
}
private async activateWithProductKey(productKey: string): Promise<string> {
const base = this.resolveLicenseActivateBaseUrl();
const url = new URL('v1/activate', base);
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ productKey: productKey.trim(), deviceId: this.deviceId }),
signal: AbortSignal.timeout(20_000),
});
const text = await res.text();
let parsed: unknown;
try {
parsed = JSON.parse(text) as unknown;
} catch {
throw new Error(`LICENSE_ACTIVATE_FAILED:${text.slice(0, 200)}`);
}
const obj = parsed as { token?: string; error?: string; message?: string };
if (!res.ok) {
throw new Error(`LICENSE_ACTIVATE_FAILED:${obj.error ?? obj.message ?? String(res.status)}`);
}
const token = obj.token;
if (!token || typeof token !== 'string') {
throw new Error('LICENSE_ACTIVATE_FAILED:token_missing');
}
return normalizeLicenseTokenInput(token);
}
private async maybeRefreshRemoteRevocation(payload: LicensePayloadV1): Promise<void> {
const base = process.env.DND_LICENSE_STATUS_URL?.trim();
if (!base) return;
@@ -191,11 +227,14 @@ export class LicenseService {
return this.getStatusSync();
}
setToken(token: string): LicenseSnapshot {
async setToken(token: string): Promise<LicenseSnapshot> {
if (this.isSkipLicense()) {
return this.getStatusSync();
}
const trimmed = token.trim();
let trimmed = normalizeLicenseTokenInput(token);
if (isDndProductKey(trimmed)) {
trimmed = await this.activateWithProductKey(trimmed);
}
const nowSec = Math.floor(Date.now() / 1000);
const v = verifyLicenseToken(trimmed, { nowSec, deviceId: this.deviceId });
if (!v.ok) {
@@ -54,3 +54,29 @@ void test('verifyLicenseToken: неверное устройство', () => {
if (bad.ok) assert.fail('expected failure');
assert.equal(bad.reason, 'wrong_device');
});
void test('verifyLicenseToken: токен с переносами строк после копирования', () => {
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
const pubB64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
const licensePayload = {
v: 1 as const,
sub: 'lic_wrap',
pid: 'dnd_player',
iat: 100,
exp: 2_000_000_000,
did: null as string | null,
};
const body = canonicalJson(licensePayload);
const sig = sign(null, Buffer.from(body, 'utf8'), privateKey);
const token = joinSignedLicenseToken(body, new Uint8Array(sig.buffer, sig.byteOffset, sig.byteLength));
const mid = Math.max(1, Math.floor(token.length / 2));
const messy = `${token.slice(0, mid)}\n${token.slice(mid, mid + 3)} \r\n ${token.slice(mid + 3)}`;
const ok = verifyLicenseToken(messy, {
nowSec: 1_700_000_000,
deviceId: 'any',
publicKeyOverrideSpkiDerB64: pubB64,
});
if (!ok.ok) assert.fail(`expected ok, got ${ok.reason}`);
assert.equal(ok.payload.sub, 'lic_wrap');
});
@@ -49,12 +49,12 @@ export function LicenseTokenModal({ open, onClose, onSaved }: LicenseTokenModalP
</button>
</div>
<div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>ЛИЦЕНЗИОННЫЙ ТОКЕН</div>
<div className={styles.fieldLabel}>КЛЮЧ</div>
<textarea
className={styles.licenseTextarea}
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="Вставьте токен, выданный сервером лицензий…"
placeholder="Продуктовый ключ DND-..."
spellCheck={false}
/>
</div>
+1 -1
View File
@@ -2,4 +2,4 @@
* Публичный ключ Ed25519 (SPKI DER, base64). Должен соответствовать приватному ключу на сервере лицензий.
* Репозиторий сервера: https://git.mailib.ru/ifontosh/DndGamePlayerLicenseServer.git
*/
export const LICENSE_ED25519_SPKI_DER_B64 = 'MCowBQYDK2VwAyEAd7zvdjqeYW/fUvG5RX1/L1SCTZL1xzh+kr4rlNLQJbY=';
export const LICENSE_ED25519_SPKI_DER_B64 = 'MCowBQYDK2VwAyEA0KNHmwh7cjUtHh0V5XApTav9z/mee9iWLSS4MFbVDq8=';
+16
View File
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { isDndProductKey } from './productKey';
void test('isDndProductKey: пример пользователя', () => {
assert.equal(isDndProductKey('DND-CEBEC1BF-AD0B-4312-BFDD-675AFF5955FD'), true);
});
void test('isDndProductKey: токен с точкой — нет', () => {
assert.equal(isDndProductKey('eyJ.xxx'), false);
});
void test('isDndProductKey: пробелы по краям', () => {
assert.equal(isDndProductKey(' DND-CEBEC1BF-AD0B-4312-BFDD-675AFF5955FD '), true);
});
+6
View File
@@ -0,0 +1,6 @@
/** Продуктовый ключ активации (не путать с лицензионным токеном `base64.base64`). */
const DND_PRODUCT_KEY_RE = /^DND-[0-9A-F]{8}-(?:[0-9A-F]{4}-){3}[0-9A-F]{12}$/iu;
export function isDndProductKey(s: string): boolean {
return DND_PRODUCT_KEY_RE.test(s.trim());
}
+16 -1
View File
@@ -1,5 +1,13 @@
/** Убирает переносы/неразрывные пробелы из вставки из почты и мессенджеров (иначе `malformed`). */
export function normalizeLicenseTokenInput(token: string): string {
return token.replace(/[\s\u00a0\u200b-\u200d\ufeff\u2028\u2029]+/gu, '').trim();
}
const B64URL = {
encode(bytes: Uint8Array): string {
if (typeof Buffer !== 'undefined') {
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64url');
}
let bin = '';
for (const byte of bytes) {
bin += String.fromCharCode(byte);
@@ -8,6 +16,13 @@ const B64URL = {
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/u, '');
},
decode(s: string): Uint8Array {
if (typeof Buffer !== 'undefined') {
try {
return new Uint8Array(Buffer.from(s, 'base64url'));
} catch {
/* fall through */
}
}
const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4));
const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + pad;
const bin = atob(b64);
@@ -19,7 +34,7 @@ const B64URL = {
/** Тело UTF-8 + подпись Ed25519 (64 байта), разделитель «.». */
export function splitSignedLicenseToken(token: string): { bodyUtf8: string; signature: Uint8Array } | null {
const t = token.trim();
const t = normalizeLicenseTokenInput(token);
const dot = t.indexOf('.');
if (dot <= 0) return null;
const a = t.slice(0, dot);