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 <noreply@anthropic.com>
This commit is contained in:
parent
8fcc14ceb1
commit
242be23876
10 changed files with 154 additions and 95 deletions
|
|
@ -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<AuthFileData>): 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<AuthFileData> = 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 {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
|||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
// Generate encrypted device ID (40 hex chars)
|
||||
async function generateEncryptedDeviceId(): Promise<string> {
|
||||
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<string> | 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<string> {
|
||||
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<string> {
|
||||
const hashedDeviceId = (await sha256(deviceId)).slice(0, 32)
|
||||
return (await sha256(hashedDeviceId)).slice(0, 8) + hashedDeviceId
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<T = unknown>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
// 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<T = unknown>(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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
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<string> {
|
||||
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<string> | null = null;
|
||||
|
||||
async function getDeviceId(): Promise<string> {
|
||||
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,
|
||||
|
|
|
|||
50
packages/utils/src/device-id.ts
Normal file
50
packages/utils/src/device-id.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue