1ba29b9a09
Scope desktop media accounts by the authenticated desktop client_id so the same user logging in from Mac and Windows no longer sees a shared account list. The list, identity lookup, upsert, patch, tombstone and async health sink paths now all require matching client_id, and the active uniqueness index moves from (workspace, platform, uid) to (workspace, client, platform, uid). Also strengthen the desktop client_id seed: when the OS machine id is unavailable, fall back to a hashed fingerprint of stable hardware MAC addresses before the random installation id, so the same hardware keeps the same client across reinstalls. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
238 lines
6.4 KiB
TypeScript
238 lines
6.4 KiB
TypeScript
import { execFileSync } from 'node:child_process'
|
|
import { createHash, randomUUID } from 'node:crypto'
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
import { hostname, networkInterfaces } from 'node:os'
|
|
import type { NetworkInterfaceInfo } from 'node:os'
|
|
import { join } from 'node:path'
|
|
|
|
import { app } from 'electron/main'
|
|
|
|
const STABLE_CLIENT_NAMESPACE = 'geo-rankly.desktop-client.v1'
|
|
const FALLBACK_INSTALLATION_ID_FILE = 'machine-id-fallback.json'
|
|
|
|
export interface DesktopDeviceInfo {
|
|
device_name: string
|
|
os: string
|
|
cpu_arch: string
|
|
}
|
|
|
|
export interface DesktopClientIDScope {
|
|
tenant_id: number
|
|
workspace_id: number
|
|
user_id: number
|
|
}
|
|
|
|
export function resolveDesktopDeviceInfo(): DesktopDeviceInfo {
|
|
return {
|
|
device_name: hostname() || `Desktop ${process.platform}`,
|
|
os: process.platform,
|
|
cpu_arch: process.arch,
|
|
}
|
|
}
|
|
|
|
export function resolveDesktopClientID(scope: DesktopClientIDScope): string {
|
|
const tenantID = normalizePositiveInteger(scope.tenant_id)
|
|
const workspaceID = normalizePositiveInteger(scope.workspace_id)
|
|
const userID = normalizePositiveInteger(scope.user_id)
|
|
if (tenantID === null || workspaceID === null || userID === null) {
|
|
throw new Error('desktop_client_id_scope_invalid')
|
|
}
|
|
|
|
return uuidFromSeed(
|
|
`${STABLE_CLIENT_NAMESPACE}:${stableMachineIdentitySeed()}:account:${tenantID}:${workspaceID}:${userID}`,
|
|
)
|
|
}
|
|
|
|
function stableMachineIdentitySeed(): string {
|
|
const machineID = resolveOSMachineID()
|
|
if (machineID) {
|
|
return `machine:${machineID}`
|
|
}
|
|
|
|
const networkFingerprint = resolveNetworkHardwareFingerprint()
|
|
if (networkFingerprint) {
|
|
return `network:${networkFingerprint}`
|
|
}
|
|
|
|
return `installation:${resolvePersistedInstallationID()}`
|
|
}
|
|
|
|
function resolveOSMachineID(): string | null {
|
|
if (process.platform === 'darwin') {
|
|
return normalizeMachineID(readMacOSPlatformUUID())
|
|
}
|
|
if (process.platform === 'win32') {
|
|
return normalizeMachineID(readWindowsMachineGuid())
|
|
}
|
|
if (process.platform === 'linux') {
|
|
return normalizeMachineID(readLinuxMachineID())
|
|
}
|
|
return null
|
|
}
|
|
|
|
function readMacOSPlatformUUID(): string | null {
|
|
try {
|
|
const output = execFileSync('ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice'], {
|
|
encoding: 'utf8',
|
|
timeout: 1500,
|
|
})
|
|
return output.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/)?.[1] ?? null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function readWindowsMachineGuid(): string | null {
|
|
try {
|
|
const output = execFileSync(
|
|
'reg',
|
|
['query', 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid'],
|
|
{
|
|
encoding: 'utf8',
|
|
timeout: 1500,
|
|
windowsHide: true,
|
|
},
|
|
)
|
|
return output.match(/MachineGuid\s+REG_SZ\s+([^\r\n]+)/i)?.[1] ?? null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function readLinuxMachineID(): string | null {
|
|
for (const path of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
|
|
try {
|
|
if (existsSync(path)) {
|
|
const value = readFileSync(path, 'utf8').trim()
|
|
if (value) {
|
|
return value
|
|
}
|
|
}
|
|
} catch {
|
|
// Try the next known location.
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function normalizeMachineID(value: string | null): string | null {
|
|
const normalized = (value ?? '').trim().toLowerCase()
|
|
if (!normalized || normalized === 'unknown') {
|
|
return null
|
|
}
|
|
return createHash('sha256').update(normalized).digest('hex')
|
|
}
|
|
|
|
function resolveNetworkHardwareFingerprint(): string | null {
|
|
const macs = Object.values(networkInterfaces())
|
|
.flatMap((items) => items ?? [])
|
|
.filter(isStableHardwareNetworkInterface)
|
|
.map((item) => normalizeMacAddress(item.mac))
|
|
.filter((mac): mac is string => Boolean(mac))
|
|
|
|
const uniqueMacs = [...new Set(macs)].sort()
|
|
if (!uniqueMacs.length) {
|
|
return null
|
|
}
|
|
|
|
return createHash('sha256').update(uniqueMacs.join('|')).digest('hex')
|
|
}
|
|
|
|
function isStableHardwareNetworkInterface(item: NetworkInterfaceInfo): boolean {
|
|
const mac = normalizeMacAddress(item.mac)
|
|
if (!mac || item.internal) {
|
|
return false
|
|
}
|
|
|
|
const bytes = mac.split(':').map((part) => Number.parseInt(part, 16))
|
|
if (bytes.length !== 6 || bytes.some((byte) => !Number.isFinite(byte))) {
|
|
return false
|
|
}
|
|
|
|
const firstByte = bytes[0] ?? 0
|
|
const isMulticast = (firstByte & 0x01) === 0x01
|
|
const isLocallyAdministered = (firstByte & 0x02) === 0x02
|
|
return !isMulticast && !isLocallyAdministered
|
|
}
|
|
|
|
function normalizeMacAddress(value: string | undefined): string | null {
|
|
const normalized = (value ?? '').trim().toLowerCase()
|
|
if (!normalized || normalized === '00:00:00:00:00:00') {
|
|
return null
|
|
}
|
|
if (!/^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/.test(normalized)) {
|
|
return null
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
function resolvePersistedInstallationID(): string {
|
|
const existing = readPersistedInstallationID()
|
|
if (existing) {
|
|
return existing
|
|
}
|
|
|
|
const generated = randomUUID()
|
|
persistInstallationID(generated)
|
|
return generated
|
|
}
|
|
|
|
function readPersistedInstallationID(): string | null {
|
|
try {
|
|
const data = JSON.parse(readFileSync(fallbackInstallationIDPath(), 'utf8')) as {
|
|
installation_id?: unknown
|
|
}
|
|
return normalizeInstallationID(data.installation_id)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function persistInstallationID(installationID: string): void {
|
|
const dir = app.getPath('userData')
|
|
mkdirSync(dir, { recursive: true })
|
|
writeFileSync(
|
|
fallbackInstallationIDPath(),
|
|
JSON.stringify({ installation_id: installationID }),
|
|
'utf8',
|
|
)
|
|
}
|
|
|
|
function fallbackInstallationIDPath(): string {
|
|
return join(app.getPath('userData'), FALLBACK_INSTALLATION_ID_FILE)
|
|
}
|
|
|
|
function normalizeInstallationID(value: unknown): string | null {
|
|
if (typeof value !== 'string') {
|
|
return null
|
|
}
|
|
const normalized = value.trim().toLowerCase()
|
|
if (
|
|
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(normalized)
|
|
) {
|
|
return null
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
function normalizePositiveInteger(value: unknown): number | null {
|
|
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
|
|
return null
|
|
}
|
|
return value
|
|
}
|
|
|
|
function uuidFromSeed(seed: string): string {
|
|
const chars = createHash('sha256').update(seed).digest('hex').slice(0, 32).split('')
|
|
chars[12] = '5'
|
|
chars[16] = (((Number.parseInt(chars[16] ?? '0', 16) || 0) & 0x3) | 0x8).toString(16)
|
|
|
|
return [
|
|
chars.slice(0, 8).join(''),
|
|
chars.slice(8, 12).join(''),
|
|
chars.slice(12, 16).join(''),
|
|
chars.slice(16, 20).join(''),
|
|
chars.slice(20, 32).join(''),
|
|
].join('-')
|
|
}
|