From 94c7fa4d769033c7acafebb65d268e87921936d2 Mon Sep 17 00:00:00 2001 From: Arvin Xu Date: Fri, 29 May 2026 13:28:10 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20chore(device):=20add=20`@lobechat/d?= =?UTF-8?q?evice-identity`=20(#15321)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✨ feat(device): add @lobechat/device-identity (stable machine-derived deviceId) New shared package: `deriveDeviceId` hashes the OS machine id with the userId (+ salt) so one machine + one user → one stable, user-scoped deviceId that survives LobeHub reinstalls. Falls back to a caller-supplied random UUID (flagged via `identitySource: 'fallback'`) when the machine id is unavailable. Foundational layer — no consumers yet; desktop/CLI wire it up in a later PR. Part of LOBE-9572. Closes LOBE-9574. Co-authored-by: Claude Opus 4.8 --- packages/device-identity/package.json | 19 ++++++ packages/device-identity/src/index.test.ts | 52 +++++++++++++++++ packages/device-identity/src/index.ts | 68 ++++++++++++++++++++++ packages/device-identity/tsconfig.json | 4 ++ packages/device-identity/vitest.config.mts | 11 ++++ 5 files changed, 154 insertions(+) create mode 100644 packages/device-identity/package.json create mode 100644 packages/device-identity/src/index.test.ts create mode 100644 packages/device-identity/src/index.ts create mode 100644 packages/device-identity/tsconfig.json create mode 100644 packages/device-identity/vitest.config.mts diff --git a/packages/device-identity/package.json b/packages/device-identity/package.json new file mode 100644 index 0000000000..319075ea94 --- /dev/null +++ b/packages/device-identity/package.json @@ -0,0 +1,19 @@ +{ + "name": "@lobechat/device-identity", + "version": "1.0.0", + "private": true, + "exports": { + ".": "./src/index.ts" + }, + "main": "./src/index.ts", + "scripts": { + "test": "bunx vitest run --silent='passed-only'", + "test:coverage": "bunx vitest run --coverage" + }, + "dependencies": { + "node-machine-id": "^1.1.12" + }, + "devDependencies": { + "vitest": "^3.0.0" + } +} diff --git a/packages/device-identity/src/index.test.ts b/packages/device-identity/src/index.test.ts new file mode 100644 index 0000000000..67f469ef33 --- /dev/null +++ b/packages/device-identity/src/index.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; + +import { deriveDeviceId } from './index'; + +const FIXED_MACHINE = 'AAAA-BBBB-CCCC-DDDD'; +const readMachineId = () => FIXED_MACHINE; + +describe('deriveDeviceId', () => { + it('is deterministic for the same machine + user', () => { + const a = deriveDeviceId('user-1', { readMachineId }); + const b = deriveDeviceId('user-1', { readMachineId }); + + expect(a.identitySource).toBe('machine-id'); + expect(a.deviceId).toBe(b.deviceId); + expect(a.deviceId).toHaveLength(32); + }); + + it('differs for different users on the same machine', () => { + const a = deriveDeviceId('user-1', { readMachineId }); + const b = deriveDeviceId('user-2', { readMachineId }); + + expect(a.deviceId).not.toBe(b.deviceId); + }); + + it('falls back to the provided id when machine id is unavailable', () => { + const result = deriveDeviceId('user-1', { + fallbackId: 'stored-uuid', + readMachineId: () => { + throw new Error('no /etc/machine-id'); + }, + }); + + expect(result).toEqual({ deviceId: 'stored-uuid', identitySource: 'fallback' }); + }); + + it('falls back when machine id is empty', () => { + const result = deriveDeviceId('user-1', { fallbackId: 'stored-uuid', readMachineId: () => '' }); + + expect(result).toEqual({ deviceId: 'stored-uuid', identitySource: 'fallback' }); + }); + + it('generates a random uuid fallback when no fallbackId is given', () => { + const result = deriveDeviceId('user-1', { + readMachineId: () => { + throw new Error('unavailable'); + }, + }); + + expect(result.identitySource).toBe('fallback'); + expect(result.deviceId).toMatch(/^[\da-f-]{36}$/); + }); +}); diff --git a/packages/device-identity/src/index.ts b/packages/device-identity/src/index.ts new file mode 100644 index 0000000000..a6c6bf4739 --- /dev/null +++ b/packages/device-identity/src/index.ts @@ -0,0 +1,68 @@ +import { createHash, randomUUID } from 'node:crypto'; + +import { machineIdSync } from 'node-machine-id'; + +/** + * Constant mixed into the deviceId hash. Not a secret — it only ensures the + * hash input is namespaced to LobeHub so the same machine id used elsewhere + * can't produce a colliding value. + */ +const SALT = 'lobehub-device-salt'; + +export type IdentitySource = 'fallback' | 'machine-id'; + +export interface DeviceIdentity { + deviceId: string; + identitySource: IdentitySource; +} + +export interface DeriveDeviceIdOptions { + /** + * Reuse an existing id when the machine identifier is unavailable + * (e.g. the desktop's previously stored Electron Store UUID, or a CLI + * `--device-id` override). Keeps a device stable across the fallback path. + */ + fallbackId?: string; + /** + * Override the raw machine-id reader. Defaults to `node-machine-id`. Exists + * so callers in restricted environments and tests can inject a value without + * mocking the module. + */ + readMachineId?: () => string; +} + +/** + * Derive a stable deviceId for `(machine, user)`. + * + * Same machine + same user → same id (survives LobeHub reinstall, since the + * machine id is OS-level). Same machine + different user → different id, so the + * server can't correlate accounts on one machine. When the machine id can't be + * read, falls back to `fallbackId` (or a fresh random UUID) and flags the + * source so callers/UI can surface that this device may not survive a reinstall. + */ +export const deriveDeviceId = ( + userId: string, + options: DeriveDeviceIdOptions = {}, +): DeviceIdentity => { + const readMachineId = options.readMachineId ?? (() => machineIdSync(true)); + + try { + const machineId = readMachineId(); + if (!machineId) throw new Error('empty machine id'); + + // Fast sha256 is deliberate: this derives an opaque, stable device + // identifier from a high-entropy machine UUID — it is NOT password storage. + // A slow KDF (bcrypt/scrypt) only helps for low-entropy secrets; here the + // input space is infeasible to brute-force, so it would add no security. + // userId is mixed in solely for cross-account isolation (same machine + + // different user → different deviceId), not as a hashed credential. + const deviceId = createHash('sha256') + .update(`${machineId}|${userId}|${SALT}`) + .digest('hex') + .slice(0, 32); + + return { deviceId, identitySource: 'machine-id' }; + } catch { + return { deviceId: options.fallbackId ?? randomUUID(), identitySource: 'fallback' }; + } +}; diff --git a/packages/device-identity/tsconfig.json b/packages/device-identity/tsconfig.json new file mode 100644 index 0000000000..58e72733d0 --- /dev/null +++ b/packages/device-identity/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/"] +} diff --git a/packages/device-identity/vitest.config.mts b/packages/device-identity/vitest.config.mts new file mode 100644 index 0000000000..e996656b66 --- /dev/null +++ b/packages/device-identity/vitest.config.mts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + coverage: { + all: false, + reporter: ['text', 'json', 'lcov', 'text-summary'], + }, + environment: 'node', + }, +});