mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
✨ 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:
@@ -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}$/);
|
||||
});
|
||||
});
|
||||
@@ -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' };
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user