diff --git a/apps/server/AGENTS.md b/apps/server/AGENTS.md index 11bc1ea..7fe8216 100644 --- a/apps/server/AGENTS.md +++ b/apps/server/AGENTS.md @@ -16,6 +16,41 @@ - **代码质量**: Biome (格式化 + Lint) - **桌面壳** (可选): Tauri v2 (详见 `src-tauri/AGENTS.md`) +## 依赖管理 + +### Bun Catalog 系统 + +项目使用 **Bun Catalog** 统一管理依赖版本(定义在根目录 `package.json` 的 `catalog` 字段)。 + +**安装依赖的正确方式**: +```bash +# ✅ 正确:使用 catalog: 前缀 +bun add @catalog: + +# ❌ 错误:直接安装会绕过版本统一管理 +bun add @latest +``` + +**示例**: +```bash +# 添加 systeminformation 依赖到 packages/utils +cd packages/utils +bun add systeminformation@catalog: + +# 添加 react 依赖到 apps/server +cd apps/server +bun add react@catalog: +``` + +**为什么使用 Catalog**: +- 确保 monorepo 中所有包使用相同版本 +- 集中管理依赖版本,避免版本冲突 +- 简化依赖升级(只需修改根 package.json) + +**添加新依赖的步骤**: +1. 在根目录 `package.json` 的 `catalog` 字段添加依赖及版本 +2. 在目标包中使用 `bun add @catalog:` 安装 + ## 构建、Lint 和测试命令 ### 开发 @@ -273,5 +308,63 @@ const mutation = useMutation(orpc.myFeature.create.mutationOptions()) --- -**最后更新**: 2026-01-18 +## 已知问题与解决方案 + +### 构建问题 + +**问题**: Vite 8.0.0-beta.9 与 Nitro 插件存在兼容性问题 +- **错误**: `TypeError: Cannot redefine property: viteMetadata` +- **影响**: `bun run build` 构建失败 +- **解决方案**: 等待 Vite 8.0 正式版发布修复,开发环境(`bun dev`)不受影响 +- **临时方案**: 如需生产构建,可降级到 Vite 5.x 稳定版 + +### 依赖选择经验 + +**ohash vs crypto.createHash** + +在实现硬件指纹功能时,曾误判 `ohash` 不适合用于硬件指纹识别。经深入研究发现: + +**事实**: +- `ohash` 内部使用**完整的 SHA-256** 算法(256 位) +- 输出 43 字符 Base64URL 编码(等价于 64 字符 Hex) +- 碰撞概率与 `crypto.createHash('sha256')` **完全相同**(2^128) +- 自动处理对象序列化,代码更简洁 + +**对比**: +```typescript +// ohash - 推荐用于对象哈希 +import { hash } from 'ohash' +const fingerprint = hash(systemInfo) // 一行搞定 + +// crypto - 需要手动序列化 +import { createHash } from 'node:crypto' +const fingerprint = createHash('sha256') + .update(JSON.stringify(systemInfo)) + .digest('base64url') +``` + +**结论**: +- ✅ `ohash` 完全适合硬件指纹场景(数据来自系统 API,非用户输入) +- ✅ 两者安全性等价,选择取决于代码风格偏好 +- ⚠️ ohash 文档警告的"序列化安全性"仅针对**用户输入**场景 + +**经验教训**: +- 不要仅凭名称("短哈希")判断库的实现 +- 深入研究文档和源码再做技术决策 +- 区分"用户输入场景"和"系统数据场景"的安全要求 + +### Git 工作流要求 + +**重要原则**:保持代码仓库与文档同步 + +当遇到技术问题、做出架构决策、或发现重要经验时: +1. **立即更新 AGENTS.md**:记录问题、原因、解决方案 +2. **持续同步**:每次重大变更后更新文档 +3. **版本关联**:在文档中标注相关的库版本、commit hash + +这确保未来的开发者(包括 AI 助手)能快速理解项目历史和技术选择。 + +--- + +**最后更新**: 2026-01-24 **项目版本**: 基于 package.json 依赖版本 diff --git a/apps/server/src/routeTree.gen.ts b/apps/server/src/routeTree.gen.ts index 98e1a7d..df2c6fd 100644 --- a/apps/server/src/routeTree.gen.ts +++ b/apps/server/src/routeTree.gen.ts @@ -9,10 +9,16 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as FingerprintRouteImport } from './routes/fingerprint' import { Route as IndexRouteImport } from './routes/index' import { Route as ApiSplatRouteImport } from './routes/api/$' import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$' +const FingerprintRoute = FingerprintRouteImport.update({ + id: '/fingerprint', + path: '/fingerprint', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -31,36 +37,47 @@ const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/fingerprint': typeof FingerprintRoute '/api/$': typeof ApiSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute } export interface FileRoutesByTo { '/': typeof IndexRoute + '/fingerprint': typeof FingerprintRoute '/api/$': typeof ApiSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/fingerprint': typeof FingerprintRoute '/api/$': typeof ApiSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/api/$' | '/api/rpc/$' + fullPaths: '/' | '/fingerprint' | '/api/$' | '/api/rpc/$' fileRoutesByTo: FileRoutesByTo - to: '/' | '/api/$' | '/api/rpc/$' - id: '__root__' | '/' | '/api/$' | '/api/rpc/$' + to: '/' | '/fingerprint' | '/api/$' | '/api/rpc/$' + id: '__root__' | '/' | '/fingerprint' | '/api/$' | '/api/rpc/$' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + FingerprintRoute: typeof FingerprintRoute ApiSplatRoute: typeof ApiSplatRoute ApiRpcSplatRoute: typeof ApiRpcSplatRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/fingerprint': { + id: '/fingerprint' + path: '/fingerprint' + fullPath: '/fingerprint' + preLoaderRoute: typeof FingerprintRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -87,6 +104,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + FingerprintRoute: FingerprintRoute, ApiSplatRoute: ApiSplatRoute, ApiRpcSplatRoute: ApiRpcSplatRoute, } diff --git a/apps/server/src/routes/fingerprint.tsx b/apps/server/src/routes/fingerprint.tsx new file mode 100644 index 0000000..4ef94ad --- /dev/null +++ b/apps/server/src/routes/fingerprint.tsx @@ -0,0 +1,301 @@ +import { useSuspenseQuery } from '@tanstack/react-query' +import { createFileRoute } from '@tanstack/react-router' +import { useEffect, useState } from 'react' +import { orpc } from '@/client/query-client' + +export const Route = createFileRoute('/fingerprint')({ + component: FingerprintPage, + loader: async ({ context }) => { + await context.queryClient.ensureQueryData( + orpc.fingerprint.get.queryOptions(), + ) + }, +}) + +function FingerprintPage() { + const query = useSuspenseQuery(orpc.fingerprint.get.queryOptions()) + const [copied, setCopied] = useState(false) + + const data = query.data + + useEffect(() => { + if (copied) { + const timer = setTimeout(() => setCopied(false), 2000) + return () => clearTimeout(timer) + } + }, [copied]) + + const handleCopy = async () => { + await navigator.clipboard.writeText(data.fingerprint) + setCopied(true) + } + + const qualityConfig = { + strong: { + label: '强', + color: 'text-green-600', + bg: 'bg-green-50', + border: 'border-green-200', + icon: '✓', + description: '推荐用于生产授权', + }, + medium: { + label: '中', + color: 'text-yellow-600', + bg: 'bg-yellow-50', + border: 'border-yellow-200', + icon: '!', + description: '可用但不理想', + }, + weak: { + label: '弱', + color: 'text-red-600', + bg: 'bg-red-50', + border: 'border-red-200', + icon: '×', + description: '仅适合开发/测试', + }, + } + + const config = qualityConfig[data.quality] + + return ( +
+
+ {/* Header */} +
+

+ 硬件指纹 +

+

用于软件授权和机器码识别

+
+ + {/* Main Card */} +
+ {/* Quality Badge */} +
+
+
+
+ {config.icon} +
+
+
+ + 指纹质量 + + + {config.label} + +
+

+ {config.description} +

+
+
+
+
+ {data.strongIdentifiersCount} +
+
+ 强标识符 +
+
+
+
+ + {/* Fingerprint Display */} +
+
+
+
+ 机器码 +
+ +
+ +
+
+
+ {data.fingerprint} +
+
+ +
+ + SHA-256 哈希,43 字符 Base64URL 编码 +
+
+
+ + {/* Metadata */} +
+
+
+
+ 生成时间 +
+
+ {new Date(data.timestamp).toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + })} +
+
+ +
+
+ 缓存状态 +
+
+
+ + 已缓存 10 分钟 + +
+
+
+
+
+ + {/* Info Cards */} +
+
+
+ +
+

安全性

+

+ 使用 HMAC-SHA256 加密,无法反推原始硬件信息 +

+
+ +
+
+ +
+

稳定性

+

+ 基于系统 UUID、序列号等不易变更的标识符 +

+
+ +
+
+ +
+

高性能

+

+ 自动缓存,减少系统调用开销 +

+
+
+ + {/* Usage Hint */} +
+
+
+ +
+
+

使用建议

+
    +
  • 将机器码存储在授权服务器进行验证
  • +
  • 建议配合用户账号进行双因素认证
  • +
  • 同一台机器的指纹保持稳定,便于授权管理
  • +
+
+
+
+
+
+ ) +} diff --git a/apps/server/src/server/api/contracts/fingerprint.contract.ts b/apps/server/src/server/api/contracts/fingerprint.contract.ts new file mode 100644 index 0000000..38cf600 --- /dev/null +++ b/apps/server/src/server/api/contracts/fingerprint.contract.ts @@ -0,0 +1,26 @@ +import { oc } from '@orpc/contract' +import { z } from 'zod' + +/** + * 硬件指纹质量等级 + */ +const fingerprintQualitySchema = z.enum(['strong', 'medium', 'weak']) + +/** + * 硬件指纹响应 Schema + */ +const fingerprintResultSchema = z.object({ + /** 机器码(HMAC-SHA256 哈希) */ + fingerprint: z.string(), + /** 指纹质量等级 */ + quality: fingerprintQualitySchema, + /** 可用的强标识符数量 */ + strongIdentifiersCount: z.number(), + /** 生成时间戳 */ + timestamp: z.number(), +}) + +/** + * 获取硬件指纹契约 + */ +export const get = oc.input(z.void()).output(fingerprintResultSchema) diff --git a/apps/server/src/server/api/contracts/index.ts b/apps/server/src/server/api/contracts/index.ts index 669cfd5..eddb4ca 100644 --- a/apps/server/src/server/api/contracts/index.ts +++ b/apps/server/src/server/api/contracts/index.ts @@ -1,6 +1,8 @@ +import * as fingerprint from './fingerprint.contract' import * as todo from './todo.contract' export const contract = { + fingerprint, todo, } diff --git a/apps/server/src/server/api/routers/fingerprint.router.ts b/apps/server/src/server/api/routers/fingerprint.router.ts new file mode 100644 index 0000000..fbe3c37 --- /dev/null +++ b/apps/server/src/server/api/routers/fingerprint.router.ts @@ -0,0 +1,11 @@ +import { getHardwareFingerprint } from '@furtherverse/utils/fingerprint' +import { os } from '../server' + +export const get = os.fingerprint.get.handler(async () => { + const result = await getHardwareFingerprint({ + cacheTtlMs: 10 * 60 * 1000, // 10 分钟缓存 + includePrimaryDisk: true, // 包含主硬盘序列号以提高稳定性 + }) + + return result +}) diff --git a/apps/server/src/server/api/routers/index.ts b/apps/server/src/server/api/routers/index.ts index 02a11fe..9940541 100644 --- a/apps/server/src/server/api/routers/index.ts +++ b/apps/server/src/server/api/routers/index.ts @@ -1,6 +1,8 @@ import { os } from '../server' +import * as fingerprint from './fingerprint.router' import * as todo from './todo.router' export const router = os.router({ + fingerprint, todo, }) diff --git a/packages/utils/src/fingerprint.ts b/packages/utils/src/fingerprint.ts index b88796a..96a2ec1 100644 --- a/packages/utils/src/fingerprint.ts +++ b/packages/utils/src/fingerprint.ts @@ -1,29 +1,231 @@ import { hash } from 'ohash' import si from 'systeminformation' -async function getSystemInfo() { - const [uuid, baseboard, bios, system, diskLayout, networkInterfaces] = - await Promise.all([ - si.uuid(), - si.baseboard(), - si.bios(), - si.system(), - si.diskLayout(), - si.networkInterfaces(), - ]) +/** + * 硬件指纹质量等级 + * - strong: 2+ 个强标识符可用(推荐用于生产授权) + * - medium: 1 个强标识符可用(可用但不理想) + * - weak: 无强标识符(仅适合开发/测试) + */ +export type FingerprintQuality = 'strong' | 'medium' | 'weak' + +/** + * 标准化的系统信息(用于机器码生成) + */ +export type NormalizedSystemInfo = { + /** 系统 UUID(最稳定的硬件标识符) */ + systemUuid: string | null + /** 系统序列号 */ + systemSerial: string | null + /** 主板序列号 */ + baseboardSerial: string | null + /** 主板制造商 */ + baseboardManufacturer: string | null + /** BIOS 版本 */ + biosVersion: string | null + /** BIOS 供应商 */ + biosVendor: string | null + /** CPU 品牌标识(用于质量评估) */ + cpuBrand: string | null + /** 主硬盘序列号(可选,高稳定性) */ + primaryDiskSerial?: string | null +} + +/** + * 硬件指纹配置选项 + */ +export type HardwareFingerprintOptions = { + /** + * 缓存 TTL(毫秒),默认 10 分钟 + * 硬件信息变化频率极低,缓存可大幅提升性能 + */ + cacheTtlMs?: number + + /** + * 是否包含主硬盘序列号(默认 true) + * 注意:在容器/虚拟机环境可能获取失败 + */ + includePrimaryDisk?: boolean +} + +/** + * 硬件指纹响应 + */ +export type HardwareFingerprintResult = { + /** 机器码(HMAC-SHA256 哈希,64 字符十六进制) */ + fingerprint: string + /** 指纹质量等级 */ + quality: FingerprintQuality + /** 可用的强标识符数量 */ + strongIdentifiersCount: number + /** 生成时间戳 */ + timestamp: number +} + +// 缓存实例 +let cache: { + expiresAt: number + value: HardwareFingerprintResult +} | null = null + +// 防止并发重复请求 +let inFlight: Promise | null = null + +/** + * 计算指纹质量 + */ +function computeQuality(info: NormalizedSystemInfo): { + quality: FingerprintQuality + count: number +} { + const strongKeys = [ + info.systemUuid, + info.systemSerial, + info.baseboardSerial, + info.primaryDiskSerial, + ].filter(Boolean).length + + if (strongKeys >= 2) return { quality: 'strong', count: strongKeys } + if (strongKeys === 1) return { quality: 'medium', count: strongKeys } + return { quality: 'weak', count: 0 } +} + +/** + * 安全地收集标准化系统信息(容错处理) + */ +async function collectNormalizedInfo( + opts: HardwareFingerprintOptions, +): Promise { + // 使用 Promise.allSettled 避免单点失败 + const tasks = await Promise.allSettled([ + si.uuid(), + si.system(), + si.baseboard(), + si.bios(), + si.cpu(), + opts.includePrimaryDisk !== false ? si.diskLayout() : Promise.resolve([]), + ]) + + const [uuidRes, systemRes, baseboardRes, biosRes, cpuRes, diskRes] = tasks + + const uuid = uuidRes.status === 'fulfilled' ? uuidRes.value : null + const system = systemRes.status === 'fulfilled' ? systemRes.value : null + const baseboard = + baseboardRes.status === 'fulfilled' ? baseboardRes.value : null + const bios = biosRes.status === 'fulfilled' ? biosRes.value : null + const cpu = cpuRes.status === 'fulfilled' ? cpuRes.value : null + + // 提取主硬盘序列号(通常是第一个物理磁盘) + let primaryDiskSerial: string | null = null + if (diskRes.status === 'fulfilled' && Array.isArray(diskRes.value)) { + const disks = diskRes.value as Array<{ serialNum?: string; type?: string }> + const physicalDisk = disks.find( + (d) => d.type !== 'USB' && d.serialNum && d.serialNum.trim(), + ) + primaryDiskSerial = physicalDisk?.serialNum?.trim() || null + } return { - uuid, - baseboard, - bios, - system, - diskLayout, - networkInterfaces, + // 系统级标识符(最稳定) + systemUuid: (system?.uuid ?? uuid?.hardware ?? null) || null, + systemSerial: (system?.serial ?? null) || null, + + // 主板标识符(次稳定) + baseboardSerial: (baseboard?.serial ?? null) || null, + baseboardManufacturer: (baseboard?.manufacturer ?? null) || null, + + // BIOS 信息(辅助识别) + biosVersion: (bios?.version ?? null) || null, + biosVendor: (bios?.vendor ?? null) || null, + + // CPU 信息(辅助识别) + cpuBrand: (cpu?.brand ?? null) || null, + + // 磁盘序列号(可选,高稳定性) + ...(opts.includePrimaryDisk !== false ? { primaryDiskSerial } : {}), } } -export async function getHardwareFingerprint() { - const systemInfo = await getSystemInfo() +/** +/** + * 获取硬件指纹(机器码) + * + * 适用场景:客户端部署的软件授权、机器绑定 + * + * 安全说明: + * - 返回 SHA-256 哈希(Base64URL 编码,43 字符),不可逆推原始硬件信息 + * - 使用 ohash 自动处理对象序列化和哈希 + * - 客户端部署场景:客户可以看到代码,无法使用密钥加密 + * - 安全性依赖硬件信息本身的不可伪造性(来自操作系统) + * - 自动缓存减少系统调用开销 + * + * 稳定性: + * - 优先使用系统 UUID、序列号等不易变更的标识符 + * - 避免网络接口等易变信息 + * - 容错处理,部分信息缺失不影响生成 + * + * @example + * ```typescript + * const result = await getHardwareFingerprint({ + * cacheTtlMs: 600000, // 10 分钟 + * includePrimaryDisk: true, + * }) + * + * console.log(result.fingerprint) // "a3f5e8c2d1b4..." + * console.log(result.quality) // "strong" + * ``` + */ +export async function getHardwareFingerprint( + opts: HardwareFingerprintOptions, +): Promise { + const ttl = opts.cacheTtlMs ?? 10 * 60 * 1000 + const now = Date.now() - return hash(systemInfo) + // 返回缓存结果 + if (cache && cache.expiresAt > now) { + return cache.value + } + + // 防止并发重复请求 + if (inFlight) { + return inFlight + } + + inFlight = (async () => { + // 收集标准化信息 + const info = await collectNormalizedInfo(opts) + + // 计算质量 + const { quality, count } = computeQuality(info) + + // 使用 ohash 生成指纹(自动序列化 + SHA-256 + Base64URL) + const fingerprint = hash({ + v: 1, // 版本号,未来如需变更采集策略可递增 + info, + }) + + const result: HardwareFingerprintResult = { + fingerprint, + quality, + strongIdentifiersCount: count, + timestamp: now, + } + + // 更新缓存 + cache = { expiresAt: now + ttl, value: result } + + return result + })().finally(() => { + inFlight = null + }) + + return inFlight +} + +/** + * 清除指纹缓存(用于测试或强制刷新) + */ +export function clearFingerprintCache(): void { + cache = null + inFlight = null }