diff --git a/app/main/index.ts b/app/main/index.ts index 281df81..e315831 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -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, () => { diff --git a/app/main/license/licenseService.ts b/app/main/license/licenseService.ts index f55712e..6db46db 100644 --- a/app/main/license/licenseService.ts +++ b/app/main/license/licenseService.ts @@ -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 { + 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 { 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 { 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) { diff --git a/app/main/license/verifyLicenseToken.test.ts b/app/main/license/verifyLicenseToken.test.ts index f44bcba..db53b6f 100644 --- a/app/main/license/verifyLicenseToken.test.ts +++ b/app/main/license/verifyLicenseToken.test.ts @@ -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'); +}); diff --git a/app/renderer/editor/license/EditorLicenseModals.tsx b/app/renderer/editor/license/EditorLicenseModals.tsx index ea0c447..b2fe684 100644 --- a/app/renderer/editor/license/EditorLicenseModals.tsx +++ b/app/renderer/editor/license/EditorLicenseModals.tsx @@ -49,12 +49,12 @@ export function LicenseTokenModal({ open, onClose, onSaved }: LicenseTokenModalP
-
ЛИЦЕНЗИОННЫЙ ТОКЕН
+
КЛЮЧ