Compare commits
3 Commits
8fa8467db7
...
v1.0.14
| Author | SHA1 | Date | |
|---|---|---|---|
| 2dc7015f53 | |||
| 0eadfdce30 | |||
| 7e7827224d |
@@ -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<LicenseChangeListener>();
|
||||
|
||||
/** Слушатели вызываются после смены состояния лицензии (сохранённый токен, 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);
|
||||
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);
|
||||
}
|
||||
|
||||
private writeSealedToken(token: string): void {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
throw new Error('safeStorage недоступен: нельзя сохранить лицензию на этой системе');
|
||||
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 {
|
||||
@@ -100,6 +173,11 @@ export class LicenseService {
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
try {
|
||||
fs.unlinkSync(licenseFallbackSealedPath(this.userData));
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
}
|
||||
|
||||
/** База для `POST /v1/activate` (и при желании совпадает с сервером отзыва). */
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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`).
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "DndGamePlayer",
|
||||
"version": "1.0.12",
|
||||
"version": "1.0.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "DndGamePlayer",
|
||||
"version": "1.0.12",
|
||||
"version": "1.0.14",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "DndGamePlayer",
|
||||
"version": "1.0.12",
|
||||
"version": "1.0.14",
|
||||
"description": "DNDGamePlayer — редактор и проигрыватель игр",
|
||||
"main": "dist/main/index.cjs",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user