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:
Ivan Fontosh
2026-07-23 10:39:46 +08:00
parent c1c332364c
commit 32a5479086
11 changed files with 399 additions and 24 deletions
+21 -8
View File
@@ -1,18 +1,31 @@
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import { resolveMachineFingerprint } from './machineFingerprint';
import { deviceIdPath } from './paths';
export function getOrCreateDeviceId(userData: string): string {
const p = deviceIdPath(userData);
/** Старый per-user UUID из userData/device.id (до привязки к железу). */
export function readLegacyDeviceId(userData: string): string | null {
try {
const existing = fs.readFileSync(p, 'utf8').trim();
const existing = fs.readFileSync(deviceIdPath(userData), 'utf8').trim();
if (existing.length >= 8) return existing;
} catch {
/* empty */
}
const id = randomUUID();
fs.mkdirSync(userData, { recursive: true });
fs.writeFileSync(p, `${id}\n`, 'utf8');
return id;
return null;
}
export function clearLegacyDeviceId(userData: string): void {
try {
fs.unlinkSync(deviceIdPath(userData));
} catch {
/* empty */
}
}
/**
* Идентификатор устройства для лицензии: отпечаток физической машины.
* Одинаков для всех пользователей ОС на одном ПК (Windows/macOS/Linux).
*/
export function getOrCreateDeviceId(_userData?: string): string {
return resolveMachineFingerprint();
}
+32 -8
View File
@@ -10,7 +10,7 @@ import type { LicensePayloadV1 } from '../../shared/license/payloadV1';
import { isProductKey } from '../../shared/license/productKey';
import { normalizeLicenseTokenInput } from '../../shared/license/tokenFormat';
import { getOrCreateDeviceId } from './deviceId';
import { clearLegacyDeviceId, getOrCreateDeviceId, readLegacyDeviceId } from './deviceId';
import { licenseEncryptedPath, licenseFallbackSealedPath, preferencesPath } from './paths';
import { verifyLicenseToken } from './verifyLicenseToken';
@@ -66,12 +66,28 @@ function emitLicenseStatusChanged(): void {
export class LicenseService {
private readonly userData: string;
private readonly deviceId: string;
/** UUID из старого userData/device.id — только для совместимости до повторной активации. */
private legacyDeviceId: string | null;
private lastRemoteRevokeCheckMs = 0;
private lastRemoteRevoked = false;
constructor(userData: string) {
this.userData = 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 {
@@ -190,10 +206,17 @@ export class LicenseService {
private async activateWithProductKey(productKey: string): Promise<string> {
const base = this.resolveLicenseActivateBaseUrl();
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, {
method: 'POST',
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),
});
const text = await res.text();
@@ -211,6 +234,10 @@ export class LicenseService {
if (!token || typeof token !== 'string') {
throw new Error('LICENSE_ACTIVATE_FAILED:token_missing');
}
if (this.legacyDeviceId) {
clearLegacyDeviceId(this.userData);
this.legacyDeviceId = null;
}
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) {
return {
active: false,
@@ -328,10 +355,7 @@ export class LicenseService {
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,
});
const v = verifyLicenseToken(token, this.verifyOpts(Math.floor(Date.now() / 1000)));
if (!v.ok) return this.getStatusSync();
void this.maybeRefreshRemoteRevocation(v.payload);
return this.getStatusSync();
@@ -346,7 +370,7 @@ export class LicenseService {
trimmed = await this.activateWithProductKey(trimmed);
}
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) {
throw new Error(`LICENSE_INVALID:${v.reason}`);
}
+109
View File
@@ -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 });
});
+193
View File
@@ -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);
}
+1
View File
@@ -9,6 +9,7 @@ export function licenseFallbackSealedPath(userData: string): string {
return path.join(userData, 'license.sealed.fallback');
}
/** Устаревший per-user UUID; актуальный deviceId — fingerprint машины (см. machineFingerprint.ts). */
export function deviceIdPath(userData: string): string {
return path.join(userData, 'device.id');
}
@@ -55,6 +55,30 @@ void test('verifyLicenseToken: неверное устройство', () => {
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: токен с переносами строк после копирования', () => {
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
const pubB64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
+12 -3
View File
@@ -23,7 +23,13 @@ function getBundledPublicKey() {
export function verifyLicenseToken(
token: string,
opts: { nowSec: number; deviceId: string; publicKeyOverrideSpkiDerB64?: string },
opts: {
nowSec: number;
deviceId: string;
/** Старые deviceId (например UUID из userData) — принимаются до повторной активации. */
alsoAcceptDeviceIds?: readonly string[];
publicKeyOverrideSpkiDerB64?: string;
},
): LicenseVerifyResult {
const parts = splitSignedLicenseToken(token);
if (!parts) return { ok: false, reason: 'malformed' };
@@ -53,8 +59,11 @@ export function verifyLicenseToken(
return { ok: false, reason: 'not_yet_valid' };
}
if (opts.nowSec >= payload.exp) return { ok: false, reason: 'expired' };
if (payload.did !== null && payload.did !== opts.deviceId) {
return { ok: false, reason: 'wrong_device' };
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: true, payload };