From 242be23876d60cdf8cdc471439f3356af741b6ad Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:34:23 +0800 Subject: [PATCH] feat(utils): unify encrypted Device ID across all platforms - Add common generateEncryptedId() utility in @multica/utils - All Device IDs now use same encryption algorithm (40 hex chars) - Web: store encrypted format directly in localStorage - Desktop: use shared utility, accept encrypted ID from Web - Hub: use shared utility for hub-id generation - Telegram: use shared utility for device ID generation - Gateway hook: use encrypted format for client connections Algorithm: sha256(sha256(uuid).slice(0,32)).slice(0,8) + sha256(uuid).slice(0,32) Co-Authored-By: Claude Opus 4.5 --- apps/desktop/src/main/ipc/auth.ts | 52 +++----------------- apps/gateway/telegram/telegram-user.store.ts | 4 +- apps/gateway/telegram/telegram.service.ts | 3 +- apps/web/app/login/login-form.tsx | 7 ++- apps/web/lib/device.ts | 51 ++++++++++++------- apps/web/service/request.ts | 11 ++--- packages/core/src/hub/hub-identity.ts | 23 +++++---- packages/hooks/src/use-gateway-connection.ts | 47 ++++++++++++++---- packages/utils/src/device-id.ts | 50 +++++++++++++++++++ packages/utils/src/index.ts | 1 + 10 files changed, 154 insertions(+), 95 deletions(-) create mode 100644 packages/utils/src/device-id.ts diff --git a/apps/desktop/src/main/ipc/auth.ts b/apps/desktop/src/main/ipc/auth.ts index 200e6edd..8449295d 100644 --- a/apps/desktop/src/main/ipc/auth.ts +++ b/apps/desktop/src/main/ipc/auth.ts @@ -9,7 +9,6 @@ */ import http from "node:http"; -import crypto from "node:crypto"; import { ipcMain, shell, BrowserWindow } from "electron"; import { existsSync, @@ -18,7 +17,7 @@ import { mkdirSync, } from "node:fs"; import { join, dirname } from "node:path"; -import { DATA_DIR } from "@multica/utils"; +import { DATA_DIR, generateEncryptedId, isValidEncryptedId } from "@multica/utils"; import type { AuthUser } from "@multica/types"; // ============================================================================ @@ -46,37 +45,6 @@ interface AuthFileData { const AUTH_FILE_PATH = join(DATA_DIR, "auth.json"); -/** - * SHA-256 hash function. - */ -function sha256(text: string): string { - return crypto.createHash("sha256").update(text, "utf8").digest("hex"); -} - -/** - * Generate encrypted Device ID. - * Algorithm (consistent with devv-sdk and Web): - * 1. Generate UUID - * 2. SHA-256 hash of UUID, take first 32 chars - * 3. SHA-256 hash of step 2 result, take first 8 chars - * 4. Return: step3[0:8] + step2[0:32] = 40 chars - * - * This encrypted format is stored directly (not the raw UUID). - */ -function generateEncryptedDeviceId(): string { - const uuid = crypto.randomUUID(); - const firstHash = sha256(uuid).slice(0, 32); - const finalId = sha256(firstHash).slice(0, 8) + firstHash; - return finalId; -} - -/** - * Validate device ID format (40 hex characters). - */ -function isValidDeviceId(deviceId: string): boolean { - return typeof deviceId === "string" && /^[a-f0-9]{40}$/i.test(deviceId); -} - /** * Read raw auth file data, handling all edge cases. * Returns null if file doesn't exist or is invalid. @@ -123,32 +91,26 @@ function writeAuthFile(data: Partial): boolean { /** * Get or create a persistent Device ID. * Device ID persists across logins/logouts - it represents the device, not the user. - * The stored value is already encrypted (40 hex chars), not the raw UUID. + * The stored value is encrypted (40 hex chars). */ export function getOrCreateDeviceId(): string { const existing = readAuthFile(); - // If we have a valid encrypted deviceId (40 hex chars), return it - if (existing?.deviceId && isValidDeviceId(existing.deviceId)) { + // If we have a valid encrypted deviceId, return it + if (existing?.deviceId && isValidEncryptedId(existing.deviceId)) { return existing.deviceId; } // Generate new encrypted deviceId - const newDeviceId = generateEncryptedDeviceId(); + const newDeviceId = generateEncryptedId(); console.log("[Auth] Generated new Device ID:", newDeviceId.slice(0, 8) + "..."); - // If there was an old-format deviceId (UUID), we'll replace it - if (existing?.deviceId && !isValidDeviceId(existing.deviceId)) { - console.log("[Auth] Migrating old-format Device ID to encrypted format"); - } - - // Preserve any existing auth data while adding/updating deviceId + // Preserve any existing auth data while adding deviceId const dataToSave: Partial = existing ? { ...existing, deviceId: newDeviceId } : { deviceId: newDeviceId }; if (!writeAuthFile(dataToSave)) { - // Write failed, but we can still return the generated ID for this session console.error("[Auth] Failed to persist new Device ID"); } @@ -189,7 +151,7 @@ function saveAuthData(sid: string, user: AuthUser, passedDeviceId?: string): boo try { // Use passed deviceId from Web if valid, otherwise use local one let deviceId: string; - if (passedDeviceId && isValidDeviceId(passedDeviceId)) { + if (passedDeviceId && isValidEncryptedId(passedDeviceId)) { deviceId = passedDeviceId; console.log("[Auth] Using Device ID from Web browser:", deviceId.slice(0, 8) + "..."); } else { diff --git a/apps/gateway/telegram/telegram-user.store.ts b/apps/gateway/telegram/telegram-user.store.ts index ba2e807f..c65afb83 100644 --- a/apps/gateway/telegram/telegram-user.store.ts +++ b/apps/gateway/telegram/telegram-user.store.ts @@ -3,7 +3,7 @@ */ import { Inject, Injectable, Logger } from "@nestjs/common"; -import { v7 as uuidv7 } from "uuid"; +import { generateEncryptedId } from "@multica/utils"; import type { RowDataPacket } from "mysql2/promise"; import { DatabaseService } from "../database/database.service.js"; import type { TelegramUser, TelegramUserCreate } from "./types.js"; @@ -88,7 +88,7 @@ export class TelegramUserStore { } // Create new user with provided or generated device ID - const deviceId = data.deviceId ?? `tg-${uuidv7()}`; + const deviceId = data.deviceId ?? `tg-${generateEncryptedId()}`; await this.db.execute( `INSERT INTO telegram_users ( diff --git a/apps/gateway/telegram/telegram.service.ts b/apps/gateway/telegram/telegram.service.ts index eace14ea..987da5f8 100644 --- a/apps/gateway/telegram/telegram.service.ts +++ b/apps/gateway/telegram/telegram.service.ts @@ -12,6 +12,7 @@ import type { OnModuleInit } from "@nestjs/common"; import { Bot, InputFile, webhookCallback } from "grammy"; import type { Context } from "grammy"; import { v7 as uuidv7 } from "uuid"; +import { generateEncryptedId } from "@multica/utils"; import { parseConnectionCode } from "@multica/store/connection"; import type { ConnectionInfo } from "@multica/store/connection"; import { @@ -233,7 +234,7 @@ export class TelegramService implements OnModuleInit { } // 4. Generate device ID and register virtual device - const deviceId = `tg-${uuidv7()}`; + const deviceId = `tg-${generateEncryptedId()}`; this.registerVirtualDeviceForUser(deviceId, telegramUserId); // 5. Send verify RPC diff --git a/apps/web/app/login/login-form.tsx b/apps/web/app/login/login-form.tsx index e8a016f6..80c94e69 100644 --- a/apps/web/app/login/login-form.tsx +++ b/apps/web/app/login/login-form.tsx @@ -10,7 +10,7 @@ import { MulticaIcon } from '@multica/ui/components/multica-icon' import { LoginAuthType, UserInfo } from '@/lib/interface' import { saveSession, isAuthenticated } from '@/lib/auth' import { userLogin } from '@/service/user' -import { getOrCreateDeviceId, generateDeviceIdHeader } from '@/lib/device' +import { getOrCreateDeviceId } from '@/lib/device' type LoginStep = 'email' | 'code' @@ -115,9 +115,8 @@ export function LoginForm() { const port = nextUrl.searchParams.get('port') const platform = nextUrl.searchParams.get('platform') || 'web' - // Get Device ID and encrypt for Desktop - const rawDeviceId = getOrCreateDeviceId() - const deviceId = await generateDeviceIdHeader(rawDeviceId) + // Get Device ID (already encrypted 40-char format) + const deviceId = await getOrCreateDeviceId() const params = new URLSearchParams({ sid, diff --git a/apps/web/lib/device.ts b/apps/web/lib/device.ts index 299179a0..575cef58 100644 --- a/apps/web/lib/device.ts +++ b/apps/web/lib/device.ts @@ -1,6 +1,6 @@ /** * Device ID management for Multica Web - * Consistent with copilot-search: stores raw UUID, encrypts when transmitting + * Stores encrypted format directly (40 hex chars) */ const DEVICE_ID_KEY = 'MULTICA_DEVICE_ID' @@ -13,30 +13,43 @@ async function sha256(text: string): Promise { return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') } +// Generate encrypted device ID (40 hex chars) +async function generateEncryptedDeviceId(): Promise { + const uuid = crypto.randomUUID() + const firstHash = (await sha256(uuid)).slice(0, 32) + return (await sha256(firstHash)).slice(0, 8) + firstHash +} + +// Validate encrypted ID format (40 hex characters) +function isValidEncryptedId(id: string): boolean { + return typeof id === 'string' && /^[a-f0-9]{40}$/i.test(id) +} + +// Cached promise for async generation +let deviceIdPromise: Promise | null = null + /** - * Get or create Device ID (raw UUID format) - * Stored in localStorage, encrypted only when transmitting + * Get or create Device ID (encrypted 40-char format) + * Stored in localStorage, ready to use directly */ -export function getOrCreateDeviceId(): string { +export async function getOrCreateDeviceId(): Promise { if (typeof window === 'undefined') return '' - let deviceId = localStorage.getItem(DEVICE_ID_KEY) + const existing = localStorage.getItem(DEVICE_ID_KEY) - if (!deviceId) { - deviceId = crypto.randomUUID() - localStorage.setItem(DEVICE_ID_KEY, deviceId) + // If already encrypted format, return as-is + if (existing && isValidEncryptedId(existing)) { + return existing } - return deviceId + // Generate new encrypted ID + if (!deviceIdPromise) { + deviceIdPromise = generateEncryptedDeviceId().then((id) => { + localStorage.setItem(DEVICE_ID_KEY, id) + return id + }) + } + + return deviceIdPromise } -/** - * Generate encrypted Device-Id header value - * Algorithm (consistent with copilot-search): - * 1. sha256(uuid).slice(0, 32) = hashedDeviceId - * 2. sha256(hashedDeviceId).slice(0, 8) + hashedDeviceId = 40 chars - */ -export async function generateDeviceIdHeader(deviceId: string): Promise { - const hashedDeviceId = (await sha256(deviceId)).slice(0, 32) - return (await sha256(hashedDeviceId)).slice(0, 8) + hashedDeviceId -} diff --git a/apps/web/service/request.ts b/apps/web/service/request.ts index 9c97b5c1..3f731870 100644 --- a/apps/web/service/request.ts +++ b/apps/web/service/request.ts @@ -1,15 +1,14 @@ import { API_HOST } from '@/lib/constant'; -import { getOrCreateDeviceId, generateDeviceIdHeader } from '@/lib/device'; +import { getOrCreateDeviceId } from '@/lib/device'; import { getSid } from '@/lib/auth'; // Fetch request wrapper export async function request(url: string, options: RequestInit = {}): Promise { - // Get or generate Device ID, encrypt for header - let deviceIdHeader = ''; + // Get or generate Device ID (already encrypted 40-char format) + let deviceId = ''; let sid: string | null = null; if (typeof window !== 'undefined') { - const deviceId = getOrCreateDeviceId(); - deviceIdHeader = await generateDeviceIdHeader(deviceId); + deviceId = await getOrCreateDeviceId(); sid = getSid(); } @@ -18,7 +17,7 @@ export async function request(url: string, options: RequestInit = { headers: { 'Content-Type': 'application/json', 'os-type': '3', - ...(deviceIdHeader && { 'Device-Id': deviceIdHeader }), + ...(deviceId && { 'Device-Id': deviceId }), ...(sid && { 'Authorization': `Bearer ${sid}` }), ...options.headers, }, diff --git a/packages/core/src/hub/hub-identity.ts b/packages/core/src/hub/hub-identity.ts index 8c4986cb..b42ff592 100644 --- a/packages/core/src/hub/hub-identity.ts +++ b/packages/core/src/hub/hub-identity.ts @@ -1,22 +1,27 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; -import { v7 as uuidv7 } from "uuid"; -import { DATA_DIR } from "@multica/utils"; +import { DATA_DIR, generateEncryptedId, isValidEncryptedId } from "@multica/utils"; const HUB_ID_FILE = join(DATA_DIR, "hub-id"); /** - * 获取当前 Hub 的 ID。 - * 首次调用时生成 UUIDv7 并持久化到 ~/.super-multica/hub-id, + * 获取当前 Hub 的 ID(加密后的 40 字符格式)。 + * 首次调用时生成加密 ID 并持久化到 ~/.super-multica/hub-id, * 后续调用直接读取。 */ export function getHubId(): string { try { - return readFileSync(HUB_ID_FILE, "utf-8").trim(); + const existing = readFileSync(HUB_ID_FILE, "utf-8").trim(); + if (isValidEncryptedId(existing)) { + return existing; + } } catch { - const id = uuidv7(); - mkdirSync(DATA_DIR, { recursive: true }); - writeFileSync(HUB_ID_FILE, id, "utf-8"); - return id; + // File doesn't exist or read error } + + // Generate new encrypted ID + const id = generateEncryptedId(); + mkdirSync(DATA_DIR, { recursive: true }); + writeFileSync(HUB_ID_FILE, id, "utf-8"); + return id; } diff --git a/packages/hooks/src/use-gateway-connection.ts b/packages/hooks/src/use-gateway-connection.ts index f77e043d..525b512b 100644 --- a/packages/hooks/src/use-gateway-connection.ts +++ b/packages/hooks/src/use-gateway-connection.ts @@ -1,7 +1,6 @@ "use client"; import { useState, useEffect, useCallback, useRef } from "react"; -import { v7 as uuidv7 } from "uuid"; import { GatewayClient, type ConnectionState, @@ -37,13 +36,43 @@ function clearIdentity(): void { localStorage.removeItem(STORAGE_KEY); } -function getDeviceId(): string { - let id = localStorage.getItem(DEVICE_KEY); - if (!id) { - id = uuidv7(); - localStorage.setItem(DEVICE_KEY, id); +// SHA-256 hash (Web Crypto API) +async function sha256(text: string): Promise { + const buffer = new TextEncoder().encode(text); + const hashBuffer = await crypto.subtle.digest("SHA-256", buffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +// Generate encrypted device ID (40 hex chars, consistent with copilot-search) +async function generateEncryptedDeviceId(): Promise { + const uuid = crypto.randomUUID(); + const firstHash = (await sha256(uuid)).slice(0, 32); + return (await sha256(firstHash)).slice(0, 8) + firstHash; +} + +// Validate encrypted ID format (40 hex characters) +function isValidEncryptedId(id: string): boolean { + return typeof id === "string" && /^[a-f0-9]{40}$/i.test(id); +} + +// Cached promise for device ID generation +let deviceIdPromise: Promise | null = null; + +async function getDeviceId(): Promise { + const existing = localStorage.getItem(DEVICE_KEY); + // If already encrypted format, return as-is + if (existing && isValidEncryptedId(existing)) { + return existing; } - return id; + // Generate new encrypted ID (or migrate old UUID) + if (!deviceIdPromise) { + deviceIdPromise = generateEncryptedDeviceId().then((id) => { + localStorage.setItem(DEVICE_KEY, id); + return id; + }); + } + return deviceIdPromise; } export type PageState = "loading" | "not-connected" | "connecting" | "connected"; @@ -72,12 +101,12 @@ export function useGatewayConnection(): UseGatewayConnectionReturn { const connectToGateway = useCallback( (id: ConnectionIdentity, token?: string) => { - const doConnect = () => { + const doConnect = async () => { disconnectingRef.current = false; setPageState("connecting"); setError(null); - const deviceId = getDeviceId(); + const deviceId = await getDeviceId(); const client = new GatewayClient({ url: id.gateway, diff --git a/packages/utils/src/device-id.ts b/packages/utils/src/device-id.ts new file mode 100644 index 00000000..17161f51 --- /dev/null +++ b/packages/utils/src/device-id.ts @@ -0,0 +1,50 @@ +/** + * Encrypted Device/Hub ID generation utilities + * + * All device identifiers (Device ID, Hub ID, etc.) use the same encryption format: + * 1. Generate UUID + * 2. sha256(uuid).slice(0, 32) = firstHash + * 3. sha256(firstHash).slice(0, 8) + firstHash = 40 hex chars + * + * This is consistent with copilot-search/devv-sdk. + */ + +import { createHash } from "node:crypto"; +import { v7 as uuidv7 } from "uuid"; + +/** + * SHA-256 hash function (Node.js) + */ +function sha256(text: string): string { + return createHash("sha256").update(text, "utf8").digest("hex"); +} + +/** + * Generate an encrypted device/hub ID (40 hex characters) + * + * Algorithm: + * 1. Generate UUIDv7 + * 2. sha256(uuid).slice(0, 32) = firstHash + * 3. sha256(firstHash).slice(0, 8) + firstHash = 40 chars + */ +export function generateEncryptedId(): string { + const uuid = uuidv7(); + const firstHash = sha256(uuid).slice(0, 32); + return sha256(firstHash).slice(0, 8) + firstHash; +} + +/** + * Validate encrypted ID format (40 hex characters) + */ +export function isValidEncryptedId(id: string): boolean { + return typeof id === "string" && /^[a-f0-9]{40}$/i.test(id); +} + +/** + * Encrypt a raw UUID to the 40-char format + * Used when migrating old UUIDs to encrypted format + */ +export function encryptRawId(rawId: string): string { + const firstHash = sha256(rawId).slice(0, 32); + return sha256(firstHash).slice(0, 8) + firstHash; +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index cd47d49f..09feed32 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -3,3 +3,4 @@ export * from "./paths.js"; export * from "./errors.js"; export * from "./retry.js"; export * from "./cancellation.js"; +export * from "./device-id.js";