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'; import { machineFingerprintCachePath } from './paths'; type ExecFile = ( file: string, args: readonly string[], options: { encoding: 'utf8'; windowsHide?: boolean; timeout?: number }, ) => string; export type MachineFingerprintDeps = { platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; /** Electron userData — для дискового кэша hashed fingerprint. */ userData?: string; 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; }; /** Process-level кэш: повторные вызовы в том же процессе без I/O. */ let memoryFingerprint: string | null = null; /** Только для тестов. */ export function clearMachineFingerprintMemoryCache(): void { memoryFingerprint = null; } function isPlausiblyFingerprint(id: string): boolean { return id.length >= 8 && id.length <= 128 && !/\s/.test(id); } 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 >, ): 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; } } function readDiskFingerprintCache( cacheFile: string, readFile: (p: string, encoding: 'utf8') => string, existsSync: (p: string) => boolean, ): string | null { try { if (!existsSync(cacheFile)) return null; const cached = readFile(cacheFile, 'utf8').trim(); return isPlausiblyFingerprint(cached) ? cached : null; } catch { return null; } } function writeDiskFingerprintCache( cacheFile: string, fingerprint: string, mkdirSync: (p: string, opts: { recursive: boolean }) => void, writeFileSync: (p: string, data: string, opts?: { mode?: number }) => void, ): void { try { mkdirSync(path.dirname(cacheFile), { recursive: true }); writeFileSync(cacheFile, `${fingerprint}\n`, { mode: 0o644 }); } catch { /* кэш необязателен */ } } /** * Стабильный идентификатор физической машины (одинаковый для всех пользователей ОС на одном ПК). * Источники: Windows MachineGuid, macOS IOPlatformUUID, Linux /etc/machine-id. * Кэш: память процесса → userData/machine.fingerprint → sync probe ОС только при miss. */ 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) { memoryFingerprint = override; return override; } if (memoryFingerprint && isPlausiblyFingerprint(memoryFingerprint)) { return memoryFingerprint; } const userData = deps.userData?.trim(); const cacheFile = userData ? machineFingerprintCachePath(userData) : null; if (cacheFile) { const fromDisk = readDiskFingerprintCache(cacheFile, readFile, existsSync); if (fromDisk) { memoryFingerprint = fromDisk; return fromDisk; } } 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: не удалось получить идентификатор физической машины', ); } const fingerprint = hashMachineRawId(platform, raw); memoryFingerprint = fingerprint; if (cacheFile) { writeDiskFingerprintCache(cacheFile, fingerprint, mkdirSync, writeFileSync); } return fingerprint; }