device-id.jsjavascript
/**
* @file The machine's identity for the per-seat device cap: a random UUID, created once
* and reused forever. Main-process only, like everything auth-adjacent.
*
* On macOS it lives in the Keychain (via /usr/bin/security), which survives app deletion
* and app-data wipes — a reinstall keeps its slot. Anywhere the Keychain isn't available
* (other platforms, locked keychain) it falls back to a plain JSON file in userData; the
* ID is random and grants nothing by itself, so the file needs no encryption — the only
* cost of the fallback is that wiping app data makes the machine look new (one
* self-service deactivation fixes that).
* @module device-id
*/
const { execFileSync } = require('child_process');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
/** Keychain service name the device ID is stored under. @type {string} */
const SERVICE = 'com.ezlaybacks.device-id';
/**
* Read the device ID from the macOS Keychain.
* @returns {?string} The stored ID, or null when absent or the Keychain is unavailable.
*/
function keychainRead() {
try {
const out = execFileSync('/usr/bin/security',
['find-generic-password', '-s', SERVICE, '-w'],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
return out || null;
} catch {
return null;
}
}
/**
* Write the device ID to the macOS Keychain.
* @param {string} id Device UUID to store.
* @returns {boolean} True on success, false when the Keychain refused.
*/
function keychainWrite(id) {
try {
execFileSync('/usr/bin/security',
['add-generic-password', '-s', SERVICE, '-a', 'device', '-w', id, '-U'],
{ stdio: 'ignore' });
return true;
} catch {
return false;
}
}
/**
* Get-or-create the device ID, stable across calls. Prefers the Keychain on macOS and
* falls back to userData/device-id.json when the Keychain refuses, on other platforms,
* or when EZL_PASSWORD_STORE=basic. That env var is the existing test hook (e2e +
* headless CI): it opts safeStorage out of the OS keychain, and device identity follows
* — the throwaway test profile gets a file-backed ID, so every e2e launch is a fresh
* machine and tests never write to the developer's real Keychain. An unreadable or
* first-run file mints a fresh UUID and writes it.
* @param {string} userDataDir Electron userData directory (app.getPath('userData')).
* @returns {string} The device UUID.
*/
function getDeviceId(userDataDir) {
const useKeychain = process.platform === 'darwin' && process.env.EZL_PASSWORD_STORE !== 'basic';
if (useKeychain) {
const existing = keychainRead();
if (existing) return existing;
const id = crypto.randomUUID();
if (keychainWrite(id)) return id;
// keychain refused — fall through to the file
}
const file = path.join(userDataDir, 'device-id.json');
try {
const saved = JSON.parse(fs.readFileSync(file, 'utf8')).device_id;
if (saved) return saved;
} catch { /* first run or unreadable — mint below */ }
const id = crypto.randomUUID();
fs.mkdirSync(userDataDir, { recursive: true });
fs.writeFileSync(file, JSON.stringify({ device_id: id }));
return id;
}
module.exports = { getDeviceId };