chore(device): add @lobechat/device-identity (#15321)

 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 <noreply@anthropic.com>
This commit is contained in:
Arvin Xu
2026-05-29 13:28:10 +08:00
committed by GitHub
parent 2461709de4
commit 94c7fa4d76
5 changed files with 154 additions and 0 deletions
+19
View File
@@ -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"
}
}
@@ -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}$/);
});
});
+68
View File
@@ -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' };
}
};
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src/"]
}
@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
all: false,
reporter: ['text', 'json', 'lcov', 'text-summary'],
},
environment: 'node',
},
});