diff --git a/app/main/license/licenseService.ts b/app/main/license/licenseService.ts index a8eef90..50801fa 100644 --- a/app/main/license/licenseService.ts +++ b/app/main/license/licenseService.ts @@ -1,3 +1,4 @@ +import crypto from 'node:crypto'; import fs from 'node:fs'; import { BrowserWindow, safeStorage } from 'electron'; @@ -10,7 +11,7 @@ import { isDndProductKey } from '../../shared/license/productKey'; import { normalizeLicenseTokenInput } from '../../shared/license/tokenFormat'; import { getOrCreateDeviceId } from './deviceId'; -import { licenseEncryptedPath, preferencesPath } from './paths'; +import { licenseEncryptedPath, licenseFallbackSealedPath, preferencesPath } from './paths'; import { verifyLicenseToken } from './verifyLicenseToken'; type Preferences = { @@ -19,6 +20,8 @@ type Preferences = { type LicenseChangeListener = () => void; +const FALLBACK_MAGIC = Buffer.from('DNDLF1', 'ascii'); + const licenseChangeListeners = new Set(); /** Слушатели вызываются после смены состояния лицензии (сохранённый токен, EULA, отзыв). */ @@ -75,23 +78,93 @@ export class LicenseService { 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 недоступен: нельзя расшифровать лицензию на этой системе'); - } + /** Только для окружений без 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); - return safeStorage.decryptString(buf); + 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 { - if (!safeStorage.isEncryptionAvailable()) { - throw new Error('safeStorage недоступен: нельзя сохранить лицензию на этой системе'); - } fs.mkdirSync(this.userData, { recursive: true }); - const enc = safeStorage.encryptString(token); - fs.writeFileSync(licenseEncryptedPath(this.userData), enc); + 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 { @@ -100,6 +173,11 @@ export class LicenseService { } catch { /* ok */ } + try { + fs.unlinkSync(licenseFallbackSealedPath(this.userData)); + } catch { + /* ok */ + } } /** База для `POST /v1/activate` (и при желании совпадает с сервером отзыва). */ diff --git a/app/main/license/paths.ts b/app/main/license/paths.ts index 47e0fc2..89f3262 100644 --- a/app/main/license/paths.ts +++ b/app/main/license/paths.ts @@ -4,6 +4,11 @@ export function licenseEncryptedPath(userData: string): string { return path.join(userData, 'license.sealed'); } +/** Fallback, если нет OS keychain (WSL без gnome-keyring и т.п.); только при DND_LICENSE_INSECURE_FILE_STORAGE=1. */ +export function licenseFallbackSealedPath(userData: string): string { + return path.join(userData, 'license.sealed.fallback'); +} + export function deviceIdPath(userData: string): string { return path.join(userData, 'device.id'); } diff --git a/docs/licensing-spec.md b/docs/licensing-spec.md index c76598d..ae38bd4 100644 --- a/docs/licensing-spec.md +++ b/docs/licensing-spec.md @@ -20,6 +20,18 @@ Токен не хранится открытым текстом в JSON userData: используется **Electron `safeStorage`** (на macOS — связка с Keychain, на Windows — DPAPI). Идентификатор устройства — отдельный файл `device.id` (не секрет). Принятие EULA — `preferences.json` (версия текста). +### Linux / WSL без keyring + +На Linux `safeStorage` обычно требует **Secret Service** (например `gnome-keyring` + D-Bus). В **WSL** без keyring `safeStorage.isEncryptionAvailable()` часто **false**, и сохранить токен нельзя. + +Явный обход (только если осознанно нужен запуск без OS-хранилища): переменная окружения **`DND_LICENSE_INSECURE_FILE_STORAGE=1`**. Тогда токен пишется в файл **`license.sealed.fallback`** (AES-256-GCM, ключ от `deviceId` + константа приложения). Это **слабее**, чем связка с ОС: при копировании `userData` + знании формата теоретически проще атаковать офлайн. Для обычного десктопа Linux с рабочим сеансом переменную не задавайте. + +Пример запуска AppImage: + +```bash +DND_LICENSE_INSECURE_FILE_STORAGE=1 ./DNDGamePlayer-1.0.12-x64.AppImage --no-sandbox --appimage-extract-and-run +``` + ## Юридическое (D9) Текст EULA в приложении (`app/renderer/legal/eulaRu.ts`) и формулировки про активацию/отзыв/устройства. Перед первым вводом ключа пользователь принимает EULA (версия `EULA_CURRENT_VERSION` в `app/shared/license/eulaVersion.ts`).