import crypto from 'node:crypto'; import fs from 'node:fs'; import { BrowserWindow, safeStorage } from 'electron'; 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, licenseFallbackSealedPath, preferencesPath } from './paths'; import { verifyLicenseToken } from './verifyLicenseToken'; type Preferences = { eulaAcceptedVersion?: number; }; type LicenseChangeListener = () => void; const FALLBACK_MAGIC = Buffer.from('DNDLF1', 'ascii'); const licenseChangeListeners = new Set(); /** Слушатели вызываются после смены состояния лицензии (сохранённый токен, EULA, отзыв). */ export function addLicenseChangeListener(fn: LicenseChangeListener): () => void { licenseChangeListeners.add(fn); return () => { licenseChangeListeners.delete(fn); }; } function notifyLicenseChangeListeners(): void { for (const fn of licenseChangeListeners) { try { fn(); } catch (err) { console.error('[license] change listener failed', err); } } } function readPreferences(userData: string): Preferences { try { const raw = fs.readFileSync(preferencesPath(userData), 'utf8'); return JSON.parse(raw) as Preferences; } catch { return {}; } } function writePreferences(userData: string, prefs: Preferences): void { fs.mkdirSync(userData, { recursive: true }); fs.writeFileSync(preferencesPath(userData), `${JSON.stringify(prefs, null, 2)}\n`, 'utf8'); } function emitLicenseStatusChanged(): void { for (const win of BrowserWindow.getAllWindows()) { win.webContents.send(ipcChannels.license.statusChanged, {}); } notifyLicenseChangeListeners(); } export class LicenseService { private readonly userData: string; private readonly deviceId: string; private lastRemoteRevokeCheckMs = 0; private lastRemoteRevoked = false; constructor(userData: string) { this.userData = userData; this.deviceId = getOrCreateDeviceId(userData); } private isSkipLicense(): boolean { return process.env.DND_SKIP_LICENSE === '1' || process.env.DND_SKIP_LICENSE === 'true'; } /** Только для окружений без OS keychain (WSL и т.п.); слабее safeStorage — см. licensing-spec. */ private isInsecureFileStorageAllowed(): boolean { const v = process.env.DND_LICENSE_INSECURE_FILE_STORAGE?.trim().toLowerCase(); return v === '1' || v === 'true' || v === 'yes'; } private deriveFallbackKey(): Buffer { return crypto .createHash('sha256') .update('DNDGamePlayer.license.fallback.v1\0', 'utf8') .update(this.deviceId, 'utf8') .digest(); } private readFallbackSealedToken(): string { const p = licenseFallbackSealedPath(this.userData); const buf = fs.readFileSync(p); if (buf.length < FALLBACK_MAGIC.length + 12 + 16 + 1) { throw new Error('license.fallback: файл повреждён или слишком короткий'); } if (!buf.subarray(0, FALLBACK_MAGIC.length).equals(FALLBACK_MAGIC)) { throw new Error('license.fallback: неверный формат'); } const iv = buf.subarray(FALLBACK_MAGIC.length, FALLBACK_MAGIC.length + 12); const tag = buf.subarray(FALLBACK_MAGIC.length + 12, FALLBACK_MAGIC.length + 12 + 16); const data = buf.subarray(FALLBACK_MAGIC.length + 12 + 16); const key = this.deriveFallbackKey(); const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8'); } private writeFallbackSealedToken(token: string): void { const key = this.deriveFallbackKey(); const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); const enc = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()]); const tag = cipher.getAuthTag(); const payload = Buffer.concat([FALLBACK_MAGIC, iv, tag, enc]); fs.writeFileSync(licenseFallbackSealedPath(this.userData), payload, { mode: 0o600 }); } private readSealedToken(): string | null { const sealedPath = licenseEncryptedPath(this.userData); const fallbackPath = licenseFallbackSealedPath(this.userData); if (fs.existsSync(sealedPath)) { if (!safeStorage.isEncryptionAvailable()) { throw new Error( 'safeStorage недоступен: есть license.sealed, но расшифровать нельзя (часто перенос профиля или WSL без keyring). Удалите файл лицензии в настройках приложения или используйте DND_LICENSE_INSECURE_FILE_STORAGE=1 и активируйте заново.', ); } const buf = fs.readFileSync(sealedPath); return safeStorage.decryptString(buf); } if (fs.existsSync(fallbackPath)) { return this.readFallbackSealedToken(); } return null; } private writeSealedToken(token: string): void { fs.mkdirSync(this.userData, { recursive: true }); if (safeStorage.isEncryptionAvailable()) { const enc = safeStorage.encryptString(token); fs.writeFileSync(licenseEncryptedPath(this.userData), enc); try { fs.unlinkSync(licenseFallbackSealedPath(this.userData)); } catch { /* ok */ } return; } if (this.isInsecureFileStorageAllowed()) { this.writeFallbackSealedToken(token); try { fs.unlinkSync(licenseEncryptedPath(this.userData)); } catch { /* ok */ } return; } throw new Error( 'safeStorage недоступен: нельзя сохранить лицензию на этой системе (типично WSL без gnome-keyring). Запустите с переменной DND_LICENSE_INSECURE_FILE_STORAGE=1 — токен будет сохранён в зашифрованном файле (слабее OS-хранилища); либо настройте Secret Service / gnome-keyring.', ); } private clearSealedTokenFile(): void { try { fs.unlinkSync(licenseEncryptedPath(this.userData)); } catch { /* ok */ } try { fs.unlinkSync(licenseFallbackSealedPath(this.userData)); } catch { /* ok */ } } /** База для `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); } /** * Онлайн-проверка отзыва. Не вызывать через `await` из UI-пути: без VPN/DNS до сервера * лицензий TCP может висеть до таймаута (см. fetch), из‑за чего главное окно долго «чёрное». */ private async maybeRefreshRemoteRevocation(payload: LicensePayloadV1): Promise { const base = process.env.DND_LICENSE_STATUS_URL?.trim(); if (!base) return; const now = Date.now(); if (now - this.lastRemoteRevokeCheckMs < 60_000) return; this.lastRemoteRevokeCheckMs = now; const wasRevoked = this.lastRemoteRevoked; try { const u = new URL('v1/status', base.endsWith('/') ? base : `${base}/`); u.searchParams.set('sub', payload.sub); const res = await fetch(u, { method: 'GET', signal: AbortSignal.timeout(8000) }); if (!res.ok) return; const j = (await res.json()) as { revoked?: boolean }; this.lastRemoteRevoked = Boolean(j.revoked); } catch { /* offline: не блокируем */ } if (wasRevoked !== this.lastRemoteRevoked) { emitLicenseStatusChanged(); } } getStatusSync(): LicenseSnapshot { if (this.isSkipLicense()) { return { active: true, devSkip: true, reason: 'ok', summary: null, eulaAcceptedVersion: readPreferences(this.userData).eulaAcceptedVersion ?? null, deviceId: this.deviceId, }; } const eulaAcceptedVersion = readPreferences(this.userData).eulaAcceptedVersion ?? null; const nowSec = Math.floor(Date.now() / 1000); let token: string | null = null; try { token = this.readSealedToken(); } catch { return { active: false, devSkip: false, reason: 'none', summary: null, eulaAcceptedVersion, deviceId: this.deviceId, }; } if (!token?.trim()) { return { active: false, devSkip: false, reason: 'none', summary: null, eulaAcceptedVersion, deviceId: this.deviceId, }; } const v = verifyLicenseToken(token, { nowSec, deviceId: this.deviceId }); if (!v.ok) { return { active: false, devSkip: false, reason: v.reason, summary: null, eulaAcceptedVersion, deviceId: this.deviceId, }; } if (this.lastRemoteRevoked) { return { active: false, devSkip: false, reason: 'revoked_remote', summary: { sub: v.payload.sub, pid: v.payload.pid, exp: v.payload.exp, did: v.payload.did, }, eulaAcceptedVersion, deviceId: this.deviceId, }; } return { active: true, devSkip: false, reason: 'ok', summary: { sub: v.payload.sub, pid: v.payload.pid, exp: v.payload.exp, did: v.payload.did, }, eulaAcceptedVersion, deviceId: this.deviceId, }; } /** Снимок для UI/IPC: без ожидания сети (проверка отзыва уходит в фон). */ getStatus(): LicenseSnapshot { if (this.isSkipLicense()) return this.getStatusSync(); const base = this.getStatusSync(); if (!base.active || !base.summary) return base; const token = this.readSealedToken(); if (!token?.trim()) return base; const v = verifyLicenseToken(token, { nowSec: Math.floor(Date.now() / 1000), deviceId: this.deviceId, }); if (!v.ok) return this.getStatusSync(); void this.maybeRefreshRemoteRevocation(v.payload); return this.getStatusSync(); } async setToken(token: string): Promise { if (this.isSkipLicense()) { return this.getStatusSync(); } 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) { throw new Error(`LICENSE_INVALID:${v.reason}`); } this.writeSealedToken(trimmed); this.lastRemoteRevoked = false; emitLicenseStatusChanged(); return this.getStatusSync(); } clearToken(): LicenseSnapshot { this.clearSealedTokenFile(); this.lastRemoteRevoked = false; emitLicenseStatusChanged(); return this.getStatusSync(); } acceptEula(version: number): { ok: true } { if (version !== EULA_CURRENT_VERSION) { throw new Error('EULA_BAD_VERSION'); } const prefs = readPreferences(this.userData); prefs.eulaAcceptedVersion = version; writePreferences(this.userData, prefs); emitLicenseStatusChanged(); return { ok: true }; } assertForIpc(): void { if (this.isSkipLicense()) return; const s = this.getStatusSync(); if (!s.active) { throw new Error(`LICENSE_REQUIRED:${s.reason}`); } } }