fix(license): optional file fallback when safeStorage unavailable (WSL)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-05-13 23:14:08 +08:00
parent 8fa8467db7
commit 7e7827224d
3 changed files with 108 additions and 13 deletions
+87 -9
View File
@@ -1,3 +1,4 @@
import crypto from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
import { BrowserWindow, safeStorage } from 'electron'; import { BrowserWindow, safeStorage } from 'electron';
@@ -10,7 +11,7 @@ import { isDndProductKey } from '../../shared/license/productKey';
import { normalizeLicenseTokenInput } from '../../shared/license/tokenFormat'; import { normalizeLicenseTokenInput } from '../../shared/license/tokenFormat';
import { getOrCreateDeviceId } from './deviceId'; import { getOrCreateDeviceId } from './deviceId';
import { licenseEncryptedPath, preferencesPath } from './paths'; import { licenseEncryptedPath, licenseFallbackSealedPath, preferencesPath } from './paths';
import { verifyLicenseToken } from './verifyLicenseToken'; import { verifyLicenseToken } from './verifyLicenseToken';
type Preferences = { type Preferences = {
@@ -19,6 +20,8 @@ type Preferences = {
type LicenseChangeListener = () => void; type LicenseChangeListener = () => void;
const FALLBACK_MAGIC = Buffer.from('DNDLF1', 'ascii');
const licenseChangeListeners = new Set<LicenseChangeListener>(); const licenseChangeListeners = new Set<LicenseChangeListener>();
/** Слушатели вызываются после смены состояния лицензии (сохранённый токен, EULA, отзыв). */ /** Слушатели вызываются после смены состояния лицензии (сохранённый токен, EULA, отзыв). */
@@ -75,23 +78,93 @@ export class LicenseService {
return process.env.DND_SKIP_LICENSE === '1' || process.env.DND_SKIP_LICENSE === 'true'; return process.env.DND_SKIP_LICENSE === '1' || process.env.DND_SKIP_LICENSE === 'true';
} }
private readSealedToken(): string | null { /** Только для окружений без OS keychain (WSL и т.п.); слабее safeStorage — см. licensing-spec. */
const p = licenseEncryptedPath(this.userData); private isInsecureFileStorageAllowed(): boolean {
if (!fs.existsSync(p)) return null; const v = process.env.DND_LICENSE_INSECURE_FILE_STORAGE?.trim().toLowerCase();
if (!safeStorage.isEncryptionAvailable()) { return v === '1' || v === 'true' || v === 'yes';
throw new Error('safeStorage недоступен: нельзя расшифровать лицензию на этой системе');
} }
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); 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); return safeStorage.decryptString(buf);
} }
private writeSealedToken(token: string): void { if (fs.existsSync(fallbackPath)) {
if (!safeStorage.isEncryptionAvailable()) { return this.readFallbackSealedToken();
throw new Error('safeStorage недоступен: нельзя сохранить лицензию на этой системе');
} }
return null;
}
private writeSealedToken(token: string): void {
fs.mkdirSync(this.userData, { recursive: true }); fs.mkdirSync(this.userData, { recursive: true });
if (safeStorage.isEncryptionAvailable()) {
const enc = safeStorage.encryptString(token); const enc = safeStorage.encryptString(token);
fs.writeFileSync(licenseEncryptedPath(this.userData), enc); 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 { private clearSealedTokenFile(): void {
@@ -100,6 +173,11 @@ export class LicenseService {
} catch { } catch {
/* ok */ /* ok */
} }
try {
fs.unlinkSync(licenseFallbackSealedPath(this.userData));
} catch {
/* ok */
}
} }
/** База для `POST /v1/activate` (и при желании совпадает с сервером отзыва). */ /** База для `POST /v1/activate` (и при желании совпадает с сервером отзыва). */
+5
View File
@@ -4,6 +4,11 @@ export function licenseEncryptedPath(userData: string): string {
return path.join(userData, 'license.sealed'); 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 { export function deviceIdPath(userData: string): string {
return path.join(userData, 'device.id'); return path.join(userData, 'device.id');
} }
+12
View File
@@ -20,6 +20,18 @@
Токен не хранится открытым текстом в JSON userData: используется **Electron `safeStorage`** (на macOS — связка с Keychain, на Windows — DPAPI). Идентификатор устройства — отдельный файл `device.id` (не секрет). Принятие EULA — `preferences.json` (версия текста). Токен не хранится открытым текстом в 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) ## Юридическое (D9)
Текст EULA в приложении (`app/renderer/legal/eulaRu.ts`) и формулировки про активацию/отзыв/устройства. Перед первым вводом ключа пользователь принимает EULA (версия `EULA_CURRENT_VERSION` в `app/shared/license/eulaVersion.ts`). Текст EULA в приложении (`app/renderer/legal/eulaRu.ts`) и формулировки про активацию/отзыв/устройства. Перед первым вводом ключа пользователь принимает EULA (версия `EULA_CURRENT_VERSION` в `app/shared/license/eulaVersion.ts`).