HAPI + 设备指纹认证:打造更安全的远程编程体验

最近在用 HAPI 这个去中心化 AI 代理平台,体验了"Vibe Coding"的自由感——在咖啡馆、徒步时也能远程控制本地开发环境。但在实际使用中发现了安全隐患:原生的 JWT Token 认证在多设备场景下缺少设备级管控。于是我 fork 了项目,新增了设备指纹认证功能,实现"设备 + Token"的双因素验证。
这篇文章分享从使用者到贡献者的完整实践过程,包括 HAPI 项目介绍、安全问题分析、设备指纹认证的前后端实现,以及实际部署经验。
HAPI 是什么
HAPI 是一个去中心化的 AI 代理平台,支持"Vibe Coding"理念——随时随地自由编程,AI 代理在后台为你工作。
核心特性
Vibe Coding 理念
去徒步、去喝咖啡,或者只是放松一下。你的 AI 代理在后台为你工作,只有在需要你确认时,才会通过即时通讯应用通知你。
去中心化架构
每个用户运行自己的 hub,数据留在本地。不像其他云服务把你的代码和会话存储在中心化服务器上,HAPI 让你完全掌控数据主权。
远程控制
通过 PWA 或即时通讯应用(如 Telegram)访问本地开发环境。会话驻留在你的电脑上,手机只是一个窗口。本地就是原生 Claude Code 或 Codex,外出时切换到手机,双空格键瞬间切回本地。
即时通讯集成
距离与控制的完美平衡。通过 Telegram 或其他应用,只在真正需要你输入时才通知你。
技术栈
TypeScript + Hono 框架 + SQLite,单二进制部署,零配置启动。
架构设计
graph TB
subgraph remote["远程访问"]
phone["📱 手机端
PWA应用"]
im["💬 即时通讯
Telegram等"]
end
subgraph local["本地环境(用户电脑)"]
hub["🔧 HAPI Hub
核心服务"]
sqlite["💾 SQLite
本地数据库"]
dev["💻 开发环境
代码/项目"]
end
phone -->|HTTPS/WireGuard| hub
im -->|即时通知| hub
hub -->|存储会话| sqlite
hub -->|控制| dev
hub -.->|通知| im
style remote fill:#e7f5ff,stroke:#1971c2,stroke-width:2px
style local fill:#d3f9d8,stroke:#2f9e44,stroke-width:2px
style hub fill:#ffe8cc,stroke:#d9480f,stroke-width:3px
style sqlite fill:#fff4e6,stroke:#e67700,stroke-width:2px数据主权完全掌握在用户手中,没有中心化服务器存储你的代码和会话。
原生认证方案的不足
原生方案是什么
HAPI 原生使用 JWT Token 认证。用户登录后获得 Token,后续请求携带 Token 验证身份。这是标准的 Web 应用认证方式。
实际使用中的问题
Token 泄露风险
如果 Token 被窃取(网络抓包、XSS 攻击),攻击者可以在任何设备上使用。
多设备管控缺失
无法区分是"我的手机"还是"别人的设备"在访问。
无法远程撤销
发现异常访问时,只能重置 Token,但所有设备都会失效。
缺少审计能力
无法追踪"哪个设备在什么时间访问了什么"。
为什么需要设备级管控
远程编程场景下,设备是"信任边界"。即使 Token 泄露,攻击者也无法在未授权设备上使用。可以实现"设备白名单"机制,只允许信任的设备访问。
对比:纯 Token 认证 vs 双因素认证
| 维度 | 纯 Token 认证 | 设备指纹 + Token |
|---|---|---|
| Token 泄露 | 攻击者可在任何设备使用 | 攻击者无法在未授权设备使用 |
| 设备管控 | 无法区分设备 | 设备白名单机制 |
| 撤销粒度 | 重置 Token 影响所有设备 | 可单独撤销某个设备 |
| 审计能力 | 无法追踪设备 | 可追踪设备访问记录 |
设备指纹认证方案设计
核心思路
双因素认证:设备指纹(你的设备)+ JWT Token(你的身份)。类比银行 U 盾,既要密码(Token),又要 U 盾(设备)。
技术选型
为什么选择浏览器指纹
- 纯前端实现,无需用户操作
- 同一设备生成的指纹高度一致
- 无需额外硬件支持
为什么不用其他方案
| 方案 | 问题 |
|---|---|
| Cookie/LocalStorage | 用户清除后失效 |
| 设备 ID(如 IMEI) | 隐私问题,浏览器无法访问 |
| 硬件密钥(如 YubiKey) | 成本高,用户体验差 |
整体架构
前端:采集设备特征(屏幕、Canvas、WebGL 等)→ SHA-256 哈希 → 32 位指纹
后端:白名单管理(JSON 配置文件)→ 认证中间件(先验证设备,再验证 Token)
配置:~/.hapi/settings.json 存储设备白名单
验证流程
graph TB
start["客户端请求"] --> extract["提取设备指纹
Header或Query"]
extract --> format["格式校验
32位十六进制"]
format --> whitelist["白名单检查
PreRegisteredDeviceManager"]
whitelist -->|设备未授权| reject1["❌ 403
DEVICE_NOT_AUTHORIZED"]
whitelist -->|设备已授权| token["JWT Token验证"]
token -->|Token无效| reject2["❌ 401
INVALID_ACCESS_TOKEN"]
token -->|Token有效| success["✅ 双因素认证通过
继续处理请求"]
style start fill:#e7f5ff,stroke:#1971c2,stroke-width:2px
style extract fill:#d3f9d8,stroke:#2f9e44,stroke-width:2px
style format fill:#d3f9d8,stroke:#2f9e44,stroke-width:2px
style whitelist fill:#ffe8cc,stroke:#d9480f,stroke-width:2px
style token fill:#e5dbff,stroke:#5f3dc4,stroke-width:2px
style success fill:#d3f9d8,stroke:#2f9e44,stroke-width:3px
style reject1 fill:#ffe3e3,stroke:#c92a2a,stroke-width:2px
style reject2 fill:#ffe3e3,stroke:#c92a2a,stroke-width:2px验证顺序很关键:先验证设备,再验证 Token。设备验证更快(内存查询),可以快速拒绝未授权设备,减少 JWT 验证的计算开销。
前端实现 - 设备指纹生成器
核心思路
收集浏览器和硬件的各种特征,组合后生成唯一标识。即使用户清除 Cookie,只要设备不变,指纹就不会变。
设备特征采集
基础特征
- 屏幕分辨率、颜色深度
- User Agent、平台信息
- CPU 核心数(hardwareConcurrency)
- 设备内存(deviceMemory)
- 时区、语言
- 触摸点数量(maxTouchPoints)
Canvas Fingerprinting(重点)
原理:不同设备的 GPU、字体渲染引擎、抗锯齿算法有细微差异。
实现:绘制特定图形(矩形 + 文字 + emoji),toDataURL() 返回 base64 图像数据。
实践心得:
- 文字要包含 emoji(不同系统渲染差异大)
- 颜色和字体多样化,增加渲染复杂度
- 隐私模式下可能有变化
WebGL Fingerprinting
原理:直接读取 GPU 的 RENDERER 和 VENDOR 信息。
实现:gl.getParameter(gl.RENDERER)
输出:如 “Apple GPU - Apple Inc.”
实践心得:某些浏览器会屏蔽 WebGL 信息,返回空字符串不影响整体指纹。
SHA-256 哈希
所有特征 JSON 序列化,使用浏览器原生 crypto.subtle.digest,取前 32 位作为最终指纹。既保证唯一性,又避免暴露设备信息。
完整代码
1export class DeviceFingerprintGenerator {
2 static async generateFingerprint(): Promise<string> {
3 const features = {
4 screenResolution: `${screen.width}x${screen.height}`,
5 colorDepth: screen.colorDepth,
6 userAgent: navigator.userAgent,
7 platform: navigator.platform,
8 hardwareConcurrency: navigator.hardwareConcurrency || 0,
9 deviceMemory: (navigator as any).deviceMemory || 0,
10 canvas: this.getCanvasFingerprint(),
11 webgl: this.getWebGLFingerprint(),
12 timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
13 language: navigator.language,
14 maxTouchPoints: navigator.maxTouchPoints || 0,
15 };
16
17 const featuresString = JSON.stringify(features);
18 return this.sha256Hash(featuresString);
19 }
20
21 private static getCanvasFingerprint(): string {
22 const canvas = document.createElement('canvas');
23 const ctx = canvas.getContext('2d');
24 if (!ctx) return '';
25
26 ctx.textBaseline = 'top';
27 ctx.font = '14px Arial';
28 ctx.fillStyle = '#f60';
29 ctx.fillRect(125, 1, 62, 20);
30 ctx.fillStyle = '#069';
31 ctx.fillText('Hello, world! 🌍', 2, 15);
32
33 return canvas.toDataURL();
34 }
35
36 private static getWebGLFingerprint(): string {
37 const canvas = document.createElement('canvas');
38 const gl = canvas.getContext('webgl');
39 if (!gl) return '';
40
41 const renderer = gl.getParameter(gl.RENDERER);
42 const vendor = gl.getParameter(gl.VENDOR);
43 return `${renderer}-${vendor}`;
44 }
45
46 private static async sha256Hash(message: string): Promise<string> {
47 const msgBuffer = new TextEncoder().encode(message);
48 const hashBuffer = await crypto.subtle.digest('SHA-256'ffer);
49 const hashArray = Array.from(new Uint8Array(hashBuffer));
50 return hashArray.map(b => b.toString(16).padStart(2, '0'))
51 .join('')
52 .substring(0, 32);
53 }
54}
使用示例
1// 在应用初始化时生成指纹
2const fingerprint = await DeviceFingerprintGenerator.generateFingerprint();
3
4// 在 API 请求中携带指纹
5fetch('/api/data', {
6 headers: {
7 'X-Device-Fingerprint': fingerprint,
8 'Authorization': `Bearer ${token}`
9 }
10});
后端实现 - 认证中间件与配置管理
核心思路
验证设备指纹是否在白名单中,然后再验证 JWT Token。双因素认证流程。基于 Hono 框架,但思路适用于任何 Node.js 框架。
设备管理器实现
PreRegisteredDeviceManager 设计
使用 Set
配置格式:
1{
2 "fingerprint": [
3 "a3f5e8c9d2b1...",
4 "b4e6f9c0d3a2..."
5 ]
6}
实践心得:
- 支持 HAPI_HOME 环境变量,方便 Docker 部署
- 启动时加载配置到内存,避免每次请求都读文件
完整代码
1export class PreRegisteredDeviceManager {
2 private static deviceRecords: Set<string> = new Set();
3
4 private static getSettingsPath(): string {
5 const dataDir = process.env.HAPI_HOME
6 ? process.env.HAPI_HOME.replace(/^~/, homedir())
7 : join(homedir(), ".hapi");
8 return joi "settings.json");
9 }
10
11 static isDeviceAllowed(deviceFingerprint: string): boolean {
12 return this.deviceRecords.has(deviceFingerprint);
13 }
14
15 static async loadDeviceConfig(): Promise<void> {
16 try {
17 const settingsPath = this.getSettingsPath();
18 const { readFileSync, existsSync } = await import("fs");
19 if (existsSync(settingsPath)) {
20 const config = JSON.parse(readFileSync(settingsPath, "utf8"));
21 if (Array.isArray(config.fingerprint)) {
22 config.fingerprint.forEach((record: string) => {
23 this.deviceRecords.add(record);
24 });
25 }
26 }
27 } catch (error) {
28 console.error("Failed to load device config:", error);
29 throw error;
30 }
31 }
32}
33
34// 启动时加载配置
35PreRegisteredDeviceManager.loadDeviceConfig();
认证中间件实现
createDeviceBasedAuthMiddleware 设计
验证流程(按顺序):
- 提取设备指纹(支持 Header 和 Query 两种方式)
- 格式校验(32 位十六进制)
- 白名单检查
- JWT Token 验证
为什么先验证设备,再验证 Token
- 设备验证更快(内存查询)
- 快速拒绝未授权设备
- 减少 JWT 验证的计算开销
- 从安全角度,设备级别的拦截更早
双路径提取设计(重点)
- 普通 API 请求:从 Header 读取
- SSE 连接(/api/events):从 Query 参数读取
- 原因:SSE 无法自定义 Header,只能通过 URL 参数传递
- 实践心得:这个设计让认证逻辑统一,不需要为 SSE 单独写认证代码
错误处理设计
- DEVICE_FINGERPRINT_MISSING:设备指纹缺失
- INVALID_DEVICE_FINGERPRINT_FORMAT:格式错误
- DEVICE_NOT_AUTHORIZED:设备未授权
- AUTH_TOKEN_MISSING:Token 缺失
- INVALID_ACCESS_TOKEN:Token 无效
完整代码
1export function createDeviceBasedAuthMiddleware(jwtSecret: Uint8Array) {
2 return async (c, next) => {
3 try {
4 // 1. 提取设备指纹(支持 Header 和 Query 两种方式)
5 let deviceFingerprint = c.req.header('X-Device-Fingerprint');
6 if (!deviceFingerprint) {
7 deviceFingerprint = c.req.query().deviceFingerprint;
8 }
9
10 if (!deviceFingerprint) {
11 return c.json({
12 error: 'Device fingerprint required',
13 code: 'DEVICE_FINGERPRINT_MISSING'
14 }, 401);
15 }
16
17 // 2. 格式校验(32 位十六进制)
18 if (!/^[a-f0-9]{32}$/.test(deviceFingerprint)) {
19 return c.json({
20 error: 'Invalid device fingerprint format',
21 code: 'INVALID_DEVICE_FINGERPRINT_FORMAT'
22 }, 400);
23 }
24
25 // 3. 白名单检查
26 if (!PreRegisteredDeviceManager.isDeviceAllowed(deviceFingerprint)) {
27 return c.json({
28 error: 'Device not authorized',
29 code: 'DEVICE_NOT_AUTHORIZED'
30 }, 403);
31 }
32
33 // 4. JWT Token 验证
34 const authHeader = c.req.header('Authorization');
35 const tokenFromHeader = authHeader?.startsWith('Bearer ')
36 ? authHeader.slice('Bearer '.length)
37 : undefined;
38 const tokenFromQuery = c.req.path === '/api/events'
39 ? c.req.query().token
40 : undefined;
41 const accessToken = tokenFromHeader ?? tokenFromQuery;
42
43 if (!accessToken) {
44 return c.json({
45 error: 'Authorization token required',
46 code: 'AUTH_TOKEN_MISSING'
47 }, 401);
48 }
49
50 const verified = await jwtVerify(accessToken, jwtSecret);
51 const parsed = jwtPayloadSchema.safeParse(verified.payload);
52
53 if (!parsed.success) {
54 return c.json({ error: 'Invalid token payload' }, 401);
55 }
56
57 c.set('userId', parsed.data.uid);
58 c.set('namespace', parsed.data.ns);
59
60 await next();
61 } catch (error) {
62 return c.json({ error: 'Authentication failed' }, 500);
63 }
64 };
65}
使用示例
1import { Hono } from 'hono';
2
3const app = new Hono();
4const jwtSecret = new TextEncoder().encode(process.env.JWT_SECRET);
5
6// 应用中间件到需要认证的路由
7app.use('/api/*', createDeviceBasedAuthMiddleware(jwtSecret));
8
9app.get('/api/data', (c) => {
10 const userId = c.get('userId');
11 return c.json({ message: 'Authenticated!', userId });
12});
实践经验与踩坑记录
兼容性:SSE 连接的特殊处理
SSE(Server-Sent Events)是个坑。SSE 连接无法自定义 Header,只能通过 URL 参数传递认证信息。一开始只支持 Header 方式,导致 SSE 连接全部失败。
解决方案:做双路径兼容,优先从 Header 读取,如果是 SSE 路径(/api/events)就从 Query 读取。
1// 双路径提取设计
2let deviceFingerprint = c.req.header('X-Device-Fingerprint');
3if (!deviceFingerprint) {
4 deviceFingerprint = c.req.query().deviceFingerprint;
5}
6
7// Token 也是同样的逻辑
8const tokenFromHeader = authHeader?.startsWith('Bearer ')
9 ? authHeader.slice('Bearer '.length)
10 : undefined;
11const tokenFromQuery = c.req.path === '/api/events'
12 ? c.req.query().token
13 : undefined;
14const accessToken = tokenFromHeader ?? tokenFromQuery;
安全提醒
无论是 Header 还是 Query 参数,都必须基于 HTTPS 传输。HTTP 明文传输会导致设备指纹和 Token 被中间人窃取。
我的部署方案:
- 家用电信宽带配置公网 IP
- 使用阿里云申请的 HTTPS 证书
- 域名解析到家庭公网 IP
- HAPI hub 配置 HTTPS 证书路径
HAPI 还支持其他安全模式:
- 公共中继:通过 WireGuard + TLS 端到端加密
- Cloudflare Tunnel:零配置 HTTPS
教训:安全传输是双因素认证的前提。
总结与展望
功能价值
这套设备指纹认证方案在 HAPI fork 版本中运行良好,显著提升了安全性。
核心优势:
- 零侵入:纯前端采集,无需用户操作
- 高性能:内存查询,响应时间 < 1ms
- 易维护:JSON 配置,无需数据库
- 兼容性好:支持 SSE 长连接场景
适用场景与局限性
适用场景:内部管理系统、企业应用、需要设备级管控的场景
局限性:
- 隐私模式下指纹可能变化
- 用户更换设备需要重新授权
- 不适合公开的 C 端产品(用户体验问题)
未来优化方向
- 动态白名单管理:实现 Web 管理界面,支持设备的添加、删除、查看
- 指纹相似度匹配:容忍设备指纹的微小变化
- 多因素认证:结合 IP 地址、地理位置、使用时间等维度
- 审计日志:记录所有认证尝试,方便安全分析
项目链接
- Fork 仓库:https://github.com/ciphermagic/hapi/tree/feature
- HAPI 仓库:https://github.com/tiann/hapi
- HAPI 官网:https://hapi.run
完整代码已在生产环境运行,如果你也在做类似的安全加固,希望这篇文章能给你一些启发。
