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 { getOrCreateDeviceId } from './deviceId'; import { licenseEncryptedPath, preferencesPath } from './paths'; import { verifyLicenseToken } from './verifyLicenseToken'; type Preferences = { eulaAcceptedVersion?: number; }; 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, {}); } } 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'; } private readSealedToken(): string | null { const p = licenseEncryptedPath(this.userData); if (!fs.existsSync(p)) return null; if (!safeStorage.isEncryptionAvailable()) { throw new Error('safeStorage недоступен: нельзя расшифровать лицензию на этой системе'); } const buf = fs.readFileSync(p); return safeStorage.decryptString(buf); } private writeSealedToken(token: string): void { if (!safeStorage.isEncryptionAvailable()) { throw new Error('safeStorage недоступен: нельзя сохранить лицензию на этой системе'); } fs.mkdirSync(this.userData, { recursive: true }); const enc = safeStorage.encryptString(token); fs.writeFileSync(licenseEncryptedPath(this.userData), enc); } private clearSealedTokenFile(): void { try { fs.unlinkSync(licenseEncryptedPath(this.userData)); } catch { /* ok */ } } 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; 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: не блокируем */ } } 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, }; } async getStatus(): Promise { 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(); await this.maybeRefreshRemoteRevocation(v.payload); return this.getStatusSync(); } setToken(token: string): LicenseSnapshot { if (this.isSkipLicense()) { return this.getStatusSync(); } const trimmed = token.trim(); 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}`); } } }