fix(license): bind deviceId to physical machine, not OS user
Use OS machine identifiers (Windows MachineGuid, macOS IOPlatformUUID, Linux machine-id) hashed as deviceId so all accounts on one PC share one license slot. Keep legacy userData/device.id for migration and retire it on re-activation. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,18 +1,31 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
import { resolveMachineFingerprint } from './machineFingerprint';
|
||||||
import { deviceIdPath } from './paths';
|
import { deviceIdPath } from './paths';
|
||||||
|
|
||||||
export function getOrCreateDeviceId(userData: string): string {
|
/** Старый per-user UUID из userData/device.id (до привязки к железу). */
|
||||||
const p = deviceIdPath(userData);
|
export function readLegacyDeviceId(userData: string): string | null {
|
||||||
try {
|
try {
|
||||||
const existing = fs.readFileSync(p, 'utf8').trim();
|
const existing = fs.readFileSync(deviceIdPath(userData), 'utf8').trim();
|
||||||
if (existing.length >= 8) return existing;
|
if (existing.length >= 8) return existing;
|
||||||
} catch {
|
} catch {
|
||||||
/* empty */
|
/* empty */
|
||||||
}
|
}
|
||||||
const id = randomUUID();
|
return null;
|
||||||
fs.mkdirSync(userData, { recursive: true });
|
}
|
||||||
fs.writeFileSync(p, `${id}\n`, 'utf8');
|
|
||||||
return id;
|
export function clearLegacyDeviceId(userData: string): void {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(deviceIdPath(userData));
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Идентификатор устройства для лицензии: отпечаток физической машины.
|
||||||
|
* Одинаков для всех пользователей ОС на одном ПК (Windows/macOS/Linux).
|
||||||
|
*/
|
||||||
|
export function getOrCreateDeviceId(_userData?: string): string {
|
||||||
|
return resolveMachineFingerprint();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { LicensePayloadV1 } from '../../shared/license/payloadV1';
|
|||||||
import { isProductKey } from '../../shared/license/productKey';
|
import { isProductKey } from '../../shared/license/productKey';
|
||||||
import { normalizeLicenseTokenInput } from '../../shared/license/tokenFormat';
|
import { normalizeLicenseTokenInput } from '../../shared/license/tokenFormat';
|
||||||
|
|
||||||
import { getOrCreateDeviceId } from './deviceId';
|
import { clearLegacyDeviceId, getOrCreateDeviceId, readLegacyDeviceId } from './deviceId';
|
||||||
import { licenseEncryptedPath, licenseFallbackSealedPath, preferencesPath } from './paths';
|
import { licenseEncryptedPath, licenseFallbackSealedPath, preferencesPath } from './paths';
|
||||||
import { verifyLicenseToken } from './verifyLicenseToken';
|
import { verifyLicenseToken } from './verifyLicenseToken';
|
||||||
|
|
||||||
@@ -66,12 +66,28 @@ function emitLicenseStatusChanged(): void {
|
|||||||
export class LicenseService {
|
export class LicenseService {
|
||||||
private readonly userData: string;
|
private readonly userData: string;
|
||||||
private readonly deviceId: string;
|
private readonly deviceId: string;
|
||||||
|
/** UUID из старого userData/device.id — только для совместимости до повторной активации. */
|
||||||
|
private legacyDeviceId: string | null;
|
||||||
private lastRemoteRevokeCheckMs = 0;
|
private lastRemoteRevokeCheckMs = 0;
|
||||||
private lastRemoteRevoked = false;
|
private lastRemoteRevoked = false;
|
||||||
|
|
||||||
constructor(userData: string) {
|
constructor(userData: string) {
|
||||||
this.userData = userData;
|
this.userData = userData;
|
||||||
this.deviceId = getOrCreateDeviceId(userData);
|
this.deviceId = getOrCreateDeviceId(userData);
|
||||||
|
const legacy = readLegacyDeviceId(userData);
|
||||||
|
this.legacyDeviceId = legacy && legacy !== this.deviceId ? legacy : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private verifyOpts(nowSec: number): {
|
||||||
|
nowSec: number;
|
||||||
|
deviceId: string;
|
||||||
|
alsoAcceptDeviceIds?: readonly string[];
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
nowSec,
|
||||||
|
deviceId: this.deviceId,
|
||||||
|
...(this.legacyDeviceId ? { alsoAcceptDeviceIds: [this.legacyDeviceId] } : {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private isSkipLicense(): boolean {
|
private isSkipLicense(): boolean {
|
||||||
@@ -190,10 +206,17 @@ export class LicenseService {
|
|||||||
private async activateWithProductKey(productKey: string): Promise<string> {
|
private async activateWithProductKey(productKey: string): Promise<string> {
|
||||||
const base = this.resolveLicenseActivateBaseUrl();
|
const base = this.resolveLicenseActivateBaseUrl();
|
||||||
const url = new URL('v1/activate', base);
|
const url = new URL('v1/activate', base);
|
||||||
|
const body: { productKey: string; deviceId: string; retireDeviceId?: string } = {
|
||||||
|
productKey: productKey.trim(),
|
||||||
|
deviceId: this.deviceId,
|
||||||
|
};
|
||||||
|
if (this.legacyDeviceId) {
|
||||||
|
body.retireDeviceId = this.legacyDeviceId;
|
||||||
|
}
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
body: JSON.stringify({ productKey: productKey.trim(), deviceId: this.deviceId }),
|
body: JSON.stringify(body),
|
||||||
signal: AbortSignal.timeout(20_000),
|
signal: AbortSignal.timeout(20_000),
|
||||||
});
|
});
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
@@ -211,6 +234,10 @@ export class LicenseService {
|
|||||||
if (!token || typeof token !== 'string') {
|
if (!token || typeof token !== 'string') {
|
||||||
throw new Error('LICENSE_ACTIVATE_FAILED:token_missing');
|
throw new Error('LICENSE_ACTIVATE_FAILED:token_missing');
|
||||||
}
|
}
|
||||||
|
if (this.legacyDeviceId) {
|
||||||
|
clearLegacyDeviceId(this.userData);
|
||||||
|
this.legacyDeviceId = null;
|
||||||
|
}
|
||||||
return normalizeLicenseTokenInput(token);
|
return normalizeLicenseTokenInput(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +305,7 @@ export class LicenseService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const v = verifyLicenseToken(token, { nowSec, deviceId: this.deviceId });
|
const v = verifyLicenseToken(token, this.verifyOpts(nowSec));
|
||||||
if (!v.ok) {
|
if (!v.ok) {
|
||||||
return {
|
return {
|
||||||
active: false,
|
active: false,
|
||||||
@@ -328,10 +355,7 @@ export class LicenseService {
|
|||||||
if (!base.active || !base.summary) return base;
|
if (!base.active || !base.summary) return base;
|
||||||
const token = this.readSealedToken();
|
const token = this.readSealedToken();
|
||||||
if (!token?.trim()) return base;
|
if (!token?.trim()) return base;
|
||||||
const v = verifyLicenseToken(token, {
|
const v = verifyLicenseToken(token, this.verifyOpts(Math.floor(Date.now() / 1000)));
|
||||||
nowSec: Math.floor(Date.now() / 1000),
|
|
||||||
deviceId: this.deviceId,
|
|
||||||
});
|
|
||||||
if (!v.ok) return this.getStatusSync();
|
if (!v.ok) return this.getStatusSync();
|
||||||
void this.maybeRefreshRemoteRevocation(v.payload);
|
void this.maybeRefreshRemoteRevocation(v.payload);
|
||||||
return this.getStatusSync();
|
return this.getStatusSync();
|
||||||
@@ -346,7 +370,7 @@ export class LicenseService {
|
|||||||
trimmed = await this.activateWithProductKey(trimmed);
|
trimmed = await this.activateWithProductKey(trimmed);
|
||||||
}
|
}
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
const v = verifyLicenseToken(trimmed, { nowSec, deviceId: this.deviceId });
|
const v = verifyLicenseToken(trimmed, this.verifyOpts(nowSec));
|
||||||
if (!v.ok) {
|
if (!v.ok) {
|
||||||
throw new Error(`LICENSE_INVALID:${v.reason}`);
|
throw new Error(`LICENSE_INVALID:${v.reason}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
hashMachineRawId,
|
||||||
|
machineWideIdPath,
|
||||||
|
parseMacIOPlatformUUID,
|
||||||
|
parseWindowsMachineGuid,
|
||||||
|
parseWmicUuid,
|
||||||
|
resolveMachineFingerprint,
|
||||||
|
} from './machineFingerprint';
|
||||||
|
|
||||||
|
void test('hashMachineRawId: стабилен и не зависит от регистра GUID', () => {
|
||||||
|
const a = hashMachineRawId('win32', 'ABCDEF00-1111-2222-3333-444455556666');
|
||||||
|
const b = hashMachineRawId('win32', 'abcdef00-1111-2222-3333-444455556666');
|
||||||
|
assert.equal(a, b);
|
||||||
|
assert.equal(a.length, 64);
|
||||||
|
assert.notEqual(a, hashMachineRawId('linux', 'abcdef00-1111-2222-3333-444455556666'));
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('parseWindowsMachineGuid', () => {
|
||||||
|
const out = `
|
||||||
|
HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography
|
||||||
|
MachineGuid REG_SZ A1B2C3D4-E5F6-7890-ABCD-EF1234567890
|
||||||
|
`;
|
||||||
|
assert.equal(parseWindowsMachineGuid(out), 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890');
|
||||||
|
assert.equal(parseWindowsMachineGuid('nope'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('parseMacIOPlatformUUID', () => {
|
||||||
|
const out = `
|
||||||
|
+-o IOPlatformExpertDevice <class IOPlatformExpertDevice, id 0x1000001ea, registered, matched>
|
||||||
|
{
|
||||||
|
"IOPlatformUUID" = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890"
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
assert.equal(parseMacIOPlatformUUID(out), 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890');
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('parseWmicUuid', () => {
|
||||||
|
assert.equal(parseWmicUuid('UUID\nA1B2C3D4-E5F6-7890-ABCD-EF1234567890\n'), 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890');
|
||||||
|
assert.equal(parseWmicUuid('UUID\n00000000-0000-0000-0000-000000000000\n'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('resolveMachineFingerprint: override через DND_LICENSE_DEVICE_ID', () => {
|
||||||
|
const id = resolveMachineFingerprint({
|
||||||
|
platform: 'linux',
|
||||||
|
env: { DND_LICENSE_DEVICE_ID: 'override-device-id-12345' },
|
||||||
|
});
|
||||||
|
assert.equal(id, 'override-device-id-12345');
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('resolveMachineFingerprint: Windows MachineGuid → одинаковый hash', () => {
|
||||||
|
const exec = () =>
|
||||||
|
`
|
||||||
|
HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography
|
||||||
|
MachineGuid REG_SZ A1B2C3D4-E5F6-7890-ABCD-EF1234567890
|
||||||
|
`;
|
||||||
|
const a = resolveMachineFingerprint({
|
||||||
|
platform: 'win32',
|
||||||
|
env: {},
|
||||||
|
execFileSync: exec as never,
|
||||||
|
});
|
||||||
|
const b = resolveMachineFingerprint({
|
||||||
|
platform: 'win32',
|
||||||
|
env: {},
|
||||||
|
execFileSync: exec as never,
|
||||||
|
});
|
||||||
|
assert.equal(a, b);
|
||||||
|
assert.equal(a, hashMachineRawId('win32', 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890'));
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('resolveMachineFingerprint: Linux /etc/machine-id', () => {
|
||||||
|
const id = resolveMachineFingerprint({
|
||||||
|
platform: 'linux',
|
||||||
|
env: {},
|
||||||
|
readFileSync: (p) => {
|
||||||
|
if (p === '/etc/machine-id') return '0123456789abcdef0123456789abcdef\n';
|
||||||
|
throw new Error('enoent');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(id, hashMachineRawId('linux', '0123456789abcdef0123456789abcdef'));
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('resolveMachineFingerprint: fallback в machine-wide путь', () => {
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'machine-fp-'));
|
||||||
|
const env = { PROGRAMDATA: tmp };
|
||||||
|
const p = machineWideIdPath('win32', env);
|
||||||
|
const id1 = resolveMachineFingerprint({
|
||||||
|
platform: 'win32',
|
||||||
|
env,
|
||||||
|
execFileSync: () => {
|
||||||
|
throw new Error('no reg');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const id2 = resolveMachineFingerprint({
|
||||||
|
platform: 'win32',
|
||||||
|
env,
|
||||||
|
execFileSync: () => {
|
||||||
|
throw new Error('no reg');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(id1, id2);
|
||||||
|
assert.ok(fs.existsSync(p));
|
||||||
|
fs.rmSync(tmp, { recursive: true, force: true });
|
||||||
|
});
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
type ExecFile = (
|
||||||
|
file: string,
|
||||||
|
args: readonly string[],
|
||||||
|
options: { encoding: 'utf8'; windowsHide?: boolean; timeout?: number },
|
||||||
|
) => string;
|
||||||
|
|
||||||
|
export type MachineFingerprintDeps = {
|
||||||
|
platform?: NodeJS.Platform;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
execFileSync?: ExecFile;
|
||||||
|
readFileSync?: (p: string, encoding: 'utf8') => string;
|
||||||
|
existsSync?: (p: string) => boolean;
|
||||||
|
mkdirSync?: (p: string, opts: { recursive: boolean }) => void;
|
||||||
|
writeFileSync?: (p: string, data: string, opts?: { mode?: number }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const HASH_PREFIX = 'TTRPGPlayer.machine.v1\0';
|
||||||
|
|
||||||
|
/** Стабильный opaque id из сырого машинного идентификатора ОС. */
|
||||||
|
export function hashMachineRawId(platform: string, rawId: string): string {
|
||||||
|
return createHash('sha256')
|
||||||
|
.update(HASH_PREFIX, 'utf8')
|
||||||
|
.update(platform, 'utf8')
|
||||||
|
.update('\0', 'utf8')
|
||||||
|
.update(rawId.trim().toLowerCase(), 'utf8')
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWindowsMachineGuid(regOutput: string): string | null {
|
||||||
|
const m = /MachineGuid\s+REG_SZ\s+([0-9a-fA-F-]{8,})/.exec(regOutput);
|
||||||
|
const id = m?.[1]?.trim();
|
||||||
|
return id && id.length >= 8 ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMacIOPlatformUUID(ioregOutput: string): string | null {
|
||||||
|
const m = /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(ioregOutput);
|
||||||
|
const id = m?.[1]?.trim();
|
||||||
|
return id && id.length >= 8 ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWmicUuid(wmicOutput: string): string | null {
|
||||||
|
const lines = wmicOutput
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
for (const line of lines) {
|
||||||
|
if (/^uuid$/i.test(line)) continue;
|
||||||
|
if (/^[0-9a-fA-F-]{8,}$/.test(line) && !/^0+-?0+-?0+-?0+-?0+$/.test(line)) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryExec(exec: ExecFile, file: string, args: readonly string[]): string | null {
|
||||||
|
try {
|
||||||
|
return exec(file, args, { encoding: 'utf8', windowsHide: true, timeout: 8_000 });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readWindowsRawId(exec: ExecFile): string | null {
|
||||||
|
const regOut = tryExec(exec, 'reg', [
|
||||||
|
'query',
|
||||||
|
'HKLM\\SOFTWARE\\Microsoft\\Cryptography',
|
||||||
|
'/v',
|
||||||
|
'MachineGuid',
|
||||||
|
]);
|
||||||
|
if (regOut) {
|
||||||
|
const guid = parseWindowsMachineGuid(regOut);
|
||||||
|
if (guid) return guid;
|
||||||
|
}
|
||||||
|
const wmicOut = tryExec(exec, 'wmic', ['csproduct', 'get', 'uuid']);
|
||||||
|
if (wmicOut) {
|
||||||
|
const uuid = parseWmicUuid(wmicOut);
|
||||||
|
if (uuid) return uuid;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDarwinRawId(exec: ExecFile): string | null {
|
||||||
|
const out = tryExec(exec, 'ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice']);
|
||||||
|
if (!out) return null;
|
||||||
|
return parseMacIOPlatformUUID(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLinuxRawId(readFile: (p: string, encoding: 'utf8') => string): string | null {
|
||||||
|
for (const p of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
|
||||||
|
try {
|
||||||
|
const id = readFile(p, 'utf8').trim();
|
||||||
|
if (id.length >= 8 && !/^0+$/.test(id)) return id;
|
||||||
|
} catch {
|
||||||
|
/* try next */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const id = readFile('/sys/class/dmi/id/product_uuid', 'utf8').trim();
|
||||||
|
if (id.length >= 8 && !/^0+-?0+-?0+-?0+-?0+$/i.test(id)) return id;
|
||||||
|
} catch {
|
||||||
|
/* optional, often root-only */
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Каталог на уровне машины (не профиль пользователя), для редкого fallback UUID. */
|
||||||
|
export function machineWideIdPath(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string {
|
||||||
|
if (platform === 'win32') {
|
||||||
|
const base = env.PROGRAMDATA?.trim() || path.join(env.SystemDrive || 'C:', 'ProgramData');
|
||||||
|
return path.join(base, 'TTRPGPlayer', 'machine.id');
|
||||||
|
}
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
return path.join('/Library/Application Support', 'TTRPGPlayer', 'machine.id');
|
||||||
|
}
|
||||||
|
return path.join('/var/lib', 'ttrpg-player', 'machine.id');
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOrCreateMachineWideFallback(
|
||||||
|
platform: NodeJS.Platform,
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
deps: Required<
|
||||||
|
Pick<MachineFingerprintDeps, 'existsSync' | 'readFileSync' | 'mkdirSync' | 'writeFileSync'>
|
||||||
|
>,
|
||||||
|
): string | null {
|
||||||
|
const p = machineWideIdPath(platform, env);
|
||||||
|
try {
|
||||||
|
if (deps.existsSync(p)) {
|
||||||
|
const existing = deps.readFileSync(p, 'utf8').trim();
|
||||||
|
if (existing.length >= 8) return existing;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* create below */
|
||||||
|
}
|
||||||
|
const id = createHash('sha256')
|
||||||
|
.update(`TTRPGPlayer.machine.fallback\0${platform}\0${os.hostname()}\0${Date.now()}\0${Math.random()}`, 'utf8')
|
||||||
|
.digest('hex');
|
||||||
|
try {
|
||||||
|
deps.mkdirSync(path.dirname(p), { recursive: true });
|
||||||
|
deps.writeFileSync(p, `${id}\n`, { mode: 0o644 });
|
||||||
|
return id;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Стабильный идентификатор физической машины (одинаковый для всех пользователей ОС на одном ПК).
|
||||||
|
* Источники: Windows MachineGuid, macOS IOPlatformUUID, Linux /etc/machine-id.
|
||||||
|
*/
|
||||||
|
export function resolveMachineFingerprint(deps: MachineFingerprintDeps = {}): string {
|
||||||
|
const platform = deps.platform ?? process.platform;
|
||||||
|
const env = deps.env ?? process.env;
|
||||||
|
const exec = deps.execFileSync ?? (execFileSync as ExecFile);
|
||||||
|
const readFile = deps.readFileSync ?? ((p, enc) => fs.readFileSync(p, enc));
|
||||||
|
const existsSync = deps.existsSync ?? ((p) => fs.existsSync(p));
|
||||||
|
const mkdirSync = deps.mkdirSync ?? ((p, opts) => {
|
||||||
|
fs.mkdirSync(p, opts);
|
||||||
|
});
|
||||||
|
const writeFileSync = deps.writeFileSync ?? ((p, data, opts) => {
|
||||||
|
fs.writeFileSync(p, data, opts);
|
||||||
|
});
|
||||||
|
|
||||||
|
const override = env.DND_LICENSE_DEVICE_ID?.trim();
|
||||||
|
if (override && override.length >= 8) return override;
|
||||||
|
|
||||||
|
let raw: string | null = null;
|
||||||
|
if (platform === 'win32') raw = readWindowsRawId(exec);
|
||||||
|
else if (platform === 'darwin') raw = readDarwinRawId(exec);
|
||||||
|
else if (platform === 'linux') raw = readLinuxRawId(readFile);
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
raw = readOrCreateMachineWideFallback(platform, env, {
|
||||||
|
existsSync,
|
||||||
|
readFileSync: readFile,
|
||||||
|
mkdirSync,
|
||||||
|
writeFileSync,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error(
|
||||||
|
'LICENSE_MACHINE_ID_UNAVAILABLE: не удалось получить идентификатор физической машины',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hashMachineRawId(platform, raw);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export function licenseFallbackSealedPath(userData: string): string {
|
|||||||
return path.join(userData, 'license.sealed.fallback');
|
return path.join(userData, 'license.sealed.fallback');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Устаревший per-user UUID; актуальный deviceId — fingerprint машины (см. machineFingerprint.ts). */
|
||||||
export function deviceIdPath(userData: string): string {
|
export function deviceIdPath(userData: string): string {
|
||||||
return path.join(userData, 'device.id');
|
return path.join(userData, 'device.id');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,30 @@ void test('verifyLicenseToken: неверное устройство', () => {
|
|||||||
assert.equal(bad.reason, 'wrong_device');
|
assert.equal(bad.reason, 'wrong_device');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('verifyLicenseToken: принимает legacy deviceId при миграции', () => {
|
||||||
|
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
||||||
|
const pubB64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
|
||||||
|
const payload = {
|
||||||
|
v: 1 as const,
|
||||||
|
sub: 'lic_legacy',
|
||||||
|
pid: 'dnd_player',
|
||||||
|
iat: 100,
|
||||||
|
exp: 2_000_000_000,
|
||||||
|
did: 'legacy-uuid-from-userdata',
|
||||||
|
};
|
||||||
|
const body = canonicalJson(payload);
|
||||||
|
const sig = sign(null, Buffer.from(body, 'utf8'), privateKey);
|
||||||
|
const token = joinSignedLicenseToken(body, new Uint8Array(sig.buffer, sig.byteOffset, sig.byteLength));
|
||||||
|
|
||||||
|
const ok = verifyLicenseToken(token, {
|
||||||
|
nowSec: 1_700_000_000,
|
||||||
|
deviceId: 'machine-fingerprint-hash',
|
||||||
|
alsoAcceptDeviceIds: ['legacy-uuid-from-userdata'],
|
||||||
|
publicKeyOverrideSpkiDerB64: pubB64,
|
||||||
|
});
|
||||||
|
if (!ok.ok) assert.fail(`expected ok, got ${ok.reason}`);
|
||||||
|
});
|
||||||
|
|
||||||
void test('verifyLicenseToken: токен с переносами строк после копирования', () => {
|
void test('verifyLicenseToken: токен с переносами строк после копирования', () => {
|
||||||
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
||||||
const pubB64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
|
const pubB64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ function getBundledPublicKey() {
|
|||||||
|
|
||||||
export function verifyLicenseToken(
|
export function verifyLicenseToken(
|
||||||
token: string,
|
token: string,
|
||||||
opts: { nowSec: number; deviceId: string; publicKeyOverrideSpkiDerB64?: string },
|
opts: {
|
||||||
|
nowSec: number;
|
||||||
|
deviceId: string;
|
||||||
|
/** Старые deviceId (например UUID из userData) — принимаются до повторной активации. */
|
||||||
|
alsoAcceptDeviceIds?: readonly string[];
|
||||||
|
publicKeyOverrideSpkiDerB64?: string;
|
||||||
|
},
|
||||||
): LicenseVerifyResult {
|
): LicenseVerifyResult {
|
||||||
const parts = splitSignedLicenseToken(token);
|
const parts = splitSignedLicenseToken(token);
|
||||||
if (!parts) return { ok: false, reason: 'malformed' };
|
if (!parts) return { ok: false, reason: 'malformed' };
|
||||||
@@ -53,9 +59,12 @@ export function verifyLicenseToken(
|
|||||||
return { ok: false, reason: 'not_yet_valid' };
|
return { ok: false, reason: 'not_yet_valid' };
|
||||||
}
|
}
|
||||||
if (opts.nowSec >= payload.exp) return { ok: false, reason: 'expired' };
|
if (opts.nowSec >= payload.exp) return { ok: false, reason: 'expired' };
|
||||||
if (payload.did !== null && payload.did !== opts.deviceId) {
|
if (payload.did !== null) {
|
||||||
|
const accepted = new Set<string>([opts.deviceId, ...(opts.alsoAcceptDeviceIds ?? [])]);
|
||||||
|
if (!accepted.has(payload.did)) {
|
||||||
return { ok: false, reason: 'wrong_device' };
|
return { ok: false, reason: 'wrong_device' };
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { ok: true, payload };
|
return { ok: true, payload };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export const EULA_RU_MARKDOWN = `
|
|||||||
|
|
||||||
## 4. Активация и проверка лицензии
|
## 4. Активация и проверка лицензии
|
||||||
|
|
||||||
Для активации Программа отправляет на сервер лицензирования лицензионный ключ и технический идентификатор устройства (deviceId). Для проверки отзыва лицензии Программа может отправлять идентификатор лицензии (sub).
|
Для активации Программа отправляет на сервер лицензирования лицензионный ключ и технический идентификатор физической машины (deviceId). Для проверки отзыва лицензии Программа может отправлять идентификатор лицензии (sub).
|
||||||
|
|
||||||
Программа не отправляет на сервер имя пользователя, адрес электронной почты, содержимое проектов, сцены, изображения, музыку, заметки, кампании или иные пользовательские материалы.
|
Программа не отправляет на сервер имя пользователя, адрес электронной почты, содержимое проектов, сцены, изображения, музыку, заметки, кампании или иные пользовательские материалы.
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
/** Версия текста EULA; при изменении текста увеличить и запросить повторное принятие. */
|
/** Версия текста EULA; при изменении текста увеличить и запросить повторное принятие. */
|
||||||
export const EULA_CURRENT_VERSION = 2;
|
export const EULA_CURRENT_VERSION = 3;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
3. **Срок** — поле `exp` (unix секунды) в выданном токене. Клиент отклоняет истёкший токен без сети. На сервере два формата продуктового ключа:
|
3. **Срок** — поле `exp` (unix секунды) в выданном токене. Клиент отклоняет истёкший токен без сети. На сервере два формата продуктового ключа:
|
||||||
- **fixed** — в записи ключа задан `expiresAtSec`; при активации `exp` копируется из него (старый формат).
|
- **fixed** — в записи ключа задан `expiresAtSec`; при активации `exp` копируется из него (старый формат).
|
||||||
- **period** — задан `validDays`; при **первой** активации лицензии (`sub`) сервер фиксирует `activatedAtSec` и выставляет `exp = activatedAtSec + validDays×86400` для всех устройств. Клиенту тип ключа неизвестен — только итоговый `exp` в токене.
|
- **period** — задан `validDays`; при **первой** активации лицензии (`sub`) сервер фиксирует `activatedAtSec` и выставляет `exp = activatedAtSec + validDays×86400` для всех устройств. Клиенту тип ключа неизвестен — только итоговый `exp` в токене.
|
||||||
4. **Устройства** — поле `did` в токене: при активации сервер привязывает токен к `deviceId` клиента и ведёт учёт списка устройств на `sub` в `data.json` (`maxDevices`).
|
4. **Устройства** — поле `did` в токене: при активации сервер привязывает токен к `deviceId` клиента и ведёт учёт списка устройств на `sub` в `data.json` (`maxDevices`). `deviceId` — **отпечаток физической машины** (не пользователь ОС): Windows `MachineGuid`, macOS `IOPlatformUUID`, Linux `/etc/machine-id`; клиент хеширует значение (SHA-256) и шлёт opaque-строку. На одном ПК разные учётки Windows/macOS/Linux получают один и тот же `deviceId`. Опционально `retireDeviceId` в `POST /v1/activate` снимает старый слот (миграция с per-user UUID).
|
||||||
5. **Отзыв** — сервер помечает `sub` в `revokedSubs`. Клиент при наличии `DND_LICENSE_STATUS_URL` запрашивает `GET /v1/status?sub=…`; при `revoked: true` лицензия считается недействительной **без обновления** приложения. Офлайн до истечения `exp` отозванный токен формально криптографически валиден — это осознанный компромисс; при необходимости сокращайте срок жизни токена или добавляйте принудительную онлайн-проверку перед критичными действиями.
|
5. **Отзыв** — сервер помечает `sub` в `revokedSubs`. Клиент при наличии `DND_LICENSE_STATUS_URL` запрашивает `GET /v1/status?sub=…`; при `revoked: true` лицензия считается недействительной **без обновления** приложения. Офлайн до истечения `exp` отозванный токен формально криптографически валиден — это осознанный компромисс; при необходимости сокращайте срок жизни токена или добавляйте принудительную онлайн-проверку перед критичными действиями.
|
||||||
|
|
||||||
## Продакшен-сборка
|
## Продакшен-сборка
|
||||||
@@ -20,7 +20,9 @@
|
|||||||
|
|
||||||
## Хранение на клиенте
|
## Хранение на клиенте
|
||||||
|
|
||||||
Токен не хранится открытым текстом в JSON userData: используется **Electron `safeStorage`** (на macOS — связка с Keychain, на Windows — DPAPI). Идентификатор устройства — отдельный файл `device.id` (не секрет). Принятие EULA — `preferences.json` (версия текста).
|
Токен не хранится открытым текстом в JSON userData: используется **Electron `safeStorage`** (на macOS — связка с Keychain, на Windows — DPAPI). Идентификатор устройства вычисляется из ID машины ОС (см. выше); файл `device.id` в userData — устаревший per-user UUID, читается только для совместимости до повторной активации. Принятие EULA — `preferences.json` (версия текста).
|
||||||
|
|
||||||
|
Для тестов/отладки можно задать `DND_LICENSE_DEVICE_ID` (готовая строка deviceId, без fingerprint).
|
||||||
|
|
||||||
### Linux / WSL без keyring
|
### Linux / WSL без keyring
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
||||||
"lint": "eslint . --max-warnings 0",
|
"lint": "eslint . --max-warnings 0",
|
||||||
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
||||||
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs",
|
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs",
|
||||||
"format": "prettier . --check",
|
"format": "prettier . --check",
|
||||||
"format:write": "prettier . --write",
|
"format:write": "prettier . --write",
|
||||||
"postinstall": "patch-package",
|
"postinstall": "patch-package",
|
||||||
|
|||||||
Reference in New Issue
Block a user