feat(hub): add device verification with token + whitelist + user confirmation

Add DeviceStore for managing one-time tokens and persistent device
whitelist. Create async verify RPC handler that validates tokens and
awaits Desktop user confirmation for first-time connections. Whitelisted
devices (reconnections) pass through instantly. Add message guard to
reject unverified device traffic.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
yushen
2026-02-04 13:27:29 +08:00
parent 0eac2b2a23
commit b2c0711676
4 changed files with 201 additions and 4 deletions

106
src/hub/device-store.ts Normal file
View File

@@ -0,0 +1,106 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { DATA_DIR } from "../shared/index.js";
// ============ Types ============
interface TokenEntry {
token: string;
agentId: string;
expiresAt: number;
}
export interface DeviceEntry {
deviceId: string;
agentId: string;
addedAt: number;
}
// ============ Persistence ============
const DEVICES_DIR = join(DATA_DIR, "devices");
const DEVICES_FILE = join(DEVICES_DIR, "whitelist.json");
function ensureDir(): void {
if (!existsSync(DEVICES_DIR)) {
mkdirSync(DEVICES_DIR, { recursive: true });
}
}
function loadDevices(): DeviceEntry[] {
if (!existsSync(DEVICES_FILE)) return [];
try {
return JSON.parse(readFileSync(DEVICES_FILE, "utf-8")) as DeviceEntry[];
} catch {
return [];
}
}
function saveDevices(devices: DeviceEntry[]): void {
ensureDir();
writeFileSync(DEVICES_FILE, JSON.stringify(devices, null, 2), "utf-8");
}
// ============ DeviceStore ============
export class DeviceStore {
/** One-time tokens (in-memory only, not persisted) */
private readonly tokens = new Map<string, TokenEntry>();
/** Allowed device IDs (persisted to disk) */
private readonly allowedDevices = new Map<string, DeviceEntry>();
constructor() {
// Restore from persistent storage
for (const entry of loadDevices()) {
this.allowedDevices.set(entry.deviceId, entry);
}
}
// ---- Token management ----
/** Register a one-time token (called when QR code is generated) */
registerToken(token: string, agentId: string, expiresAt: number): void {
this.tokens.set(token, { token, agentId, expiresAt });
}
/** Validate and consume a token (one-time use). Returns agentId if valid, null otherwise. */
consumeToken(token: string): { agentId: string } | null {
const entry = this.tokens.get(token);
if (!entry) return null;
// Always delete — consumed or expired
this.tokens.delete(token);
if (Date.now() > entry.expiresAt) return null;
return { agentId: entry.agentId };
}
// ---- Device whitelist ----
/** Add a device to the whitelist (called after token verification + user confirmation) */
allowDevice(deviceId: string, agentId: string): void {
const entry: DeviceEntry = { deviceId, agentId, addedAt: Date.now() };
this.allowedDevices.set(deviceId, entry);
this.persist();
}
/** Check if a device is in the whitelist */
isAllowed(deviceId: string): { agentId: string } | null {
const entry = this.allowedDevices.get(deviceId);
return entry ? { agentId: entry.agentId } : null;
}
/** Remove a device from the whitelist */
revokeDevice(deviceId: string): boolean {
const deleted = this.allowedDevices.delete(deviceId);
if (deleted) this.persist();
return deleted;
}
/** List all whitelisted devices */
listDevices(): DeviceEntry[] {
return Array.from(this.allowedDevices.values());
}
private persist(): void {
saveDevices(Array.from(this.allowedDevices.values()));
}
}

View File

@@ -21,6 +21,8 @@ import { createListAgentsHandler } from "./rpc/handlers/list-agents.js";
import { createCreateAgentHandler } from "./rpc/handlers/create-agent.js";
import { createDeleteAgentHandler } from "./rpc/handlers/delete-agent.js";
import { createUpdateGatewayHandler } from "./rpc/handlers/update-gateway.js";
import { DeviceStore } from "./device-store.js";
import { createVerifyHandler } from "./rpc/handlers/verify.js";
export class Hub {
private readonly agents = new Map<string, AsyncAgent>();
@@ -29,6 +31,8 @@ export class Hub {
private readonly agentStreamCounters = new Map<string, number>();
private readonly rpc: RpcDispatcher;
private client: GatewayClient;
readonly deviceStore: DeviceStore;
private _onConfirmDevice: ((deviceId: string, agentId: string) => Promise<boolean>) | null = null;
url: string;
readonly path: string;
readonly hubId: string;
@@ -42,8 +46,20 @@ export class Hub {
this.url = url;
this.path = path ?? "/ws";
this.hubId = getHubId();
this.deviceStore = new DeviceStore();
this.rpc = new RpcDispatcher();
this.rpc.register("verify", createVerifyHandler({
hubId: this.hubId,
deviceStore: this.deviceStore,
onConfirmDevice: (deviceId, agentId) => {
if (!this._onConfirmDevice) {
// No UI confirm handler registered (CLI mode etc.) — auto-approve
return Promise.resolve(true);
}
return this._onConfirmDevice(deviceId, agentId);
},
}));
this.rpc.register("getAgentMessages", createGetAgentMessagesHandler());
this.rpc.register("getHubInfo", createGetHubInfoHandler(this));
this.rpc.register("listAgents", createListAgentsHandler(this));
@@ -101,10 +117,30 @@ export class Hub {
// RPC request
if (msg.action === RequestAction) {
const payload = msg.payload as RequestPayload;
// verify RPC is always allowed (it IS the verification step)
if (payload.method === "verify") {
void this.handleRpc(msg.from, payload);
return;
}
// Other RPCs require verified device
if (!this.deviceStore.isAllowed(msg.from)) {
this.client.send<ResponseErrorPayload>(msg.from, ResponseAction, {
requestId: payload.requestId,
ok: false,
error: { code: "UNAUTHORIZED", message: "Device not verified" },
});
return;
}
void this.handleRpc(msg.from, payload);
return;
}
// Non-RPC messages also require verified device
if (!this.deviceStore.isAllowed(msg.from)) {
console.warn(`[Hub] Rejected message from unverified device: ${msg.from}`);
return;
}
// Regular chat message
const payload = msg.payload as { agentId?: string; content?: string } | undefined;
const agentId = payload?.agentId;
@@ -129,6 +165,16 @@ export class Hub {
return client;
}
/** Register a confirmation handler for new device connections (called by Desktop UI) */
setConfirmHandler(handler: ((deviceId: string, agentId: string) => Promise<boolean>) | null): void {
this._onConfirmDevice = handler;
}
/** Register a one-time token for device verification (called when QR code is generated) */
registerToken(token: string, agentId: string, expiresAt: number): void {
this.deviceStore.registerToken(token, agentId, expiresAt);
}
/** 重连到新的 Gateway 地址 */
reconnect(url: string): void {
console.log(`[Hub] Reconnecting to ${url}`);
@@ -234,7 +280,7 @@ export class Hub {
private async handleRpc(from: string, request: RequestPayload): Promise<void> {
const { requestId, method } = request;
try {
const result = await this.rpc.dispatch(method, request.params);
const result = await this.rpc.dispatch(method, request.params, from);
this.client.send<ResponseSuccessPayload>(from, ResponseAction, {
requestId,
ok: true,

View File

@@ -1,4 +1,4 @@
export type RpcHandler = (params: unknown) => unknown | Promise<unknown>;
export type RpcHandler = (params: unknown, from: string) => unknown | Promise<unknown>;
export class RpcError extends Error {
constructor(
@@ -22,11 +22,11 @@ export class RpcDispatcher {
}
/** Dispatch an RPC request to its handler */
async dispatch(method: string, params: unknown): Promise<unknown> {
async dispatch(method: string, params: unknown, from: string): Promise<unknown> {
const handler = this.handlers.get(method);
if (!handler) {
throw new RpcError("METHOD_NOT_FOUND", `Unknown RPC method: ${method}`);
}
return handler(params);
return handler(params, from);
}
}

View File

@@ -0,0 +1,45 @@
import type { RpcHandler } from "../dispatcher.js";
import { RpcError } from "../dispatcher.js";
import type { DeviceStore } from "../../device-store.js";
interface VerifyContext {
hubId: string;
deviceStore: DeviceStore;
/** Called for first-time connections. Returns true if user approves, false if rejected. */
onConfirmDevice: (deviceId: string, agentId: string) => Promise<boolean>;
}
interface VerifyParams {
token?: string;
}
export function createVerifyHandler(ctx: VerifyContext): RpcHandler {
return async (params: unknown, from: string) => {
// 1. Already in whitelist → pass through (reconnection, no confirmation needed)
const allowed = ctx.deviceStore.isAllowed(from);
if (allowed) {
return { hubId: ctx.hubId, agentId: allowed.agentId };
}
// 2. Validate token
const { token } = (params ?? {}) as VerifyParams;
if (!token) {
throw new RpcError("UNAUTHORIZED", "Device not authorized");
}
const result = ctx.deviceStore.consumeToken(token);
if (!result) {
throw new RpcError("UNAUTHORIZED", "Invalid or expired token");
}
// 3. Token valid → await Desktop user confirmation
const confirmed = await ctx.onConfirmDevice(from, result.agentId);
if (!confirmed) {
throw new RpcError("REJECTED", "Connection rejected by user");
}
// 4. User confirmed → add to whitelist
ctx.deviceStore.allowDevice(from, result.agentId);
return { hubId: ctx.hubId, agentId: result.agentId };
};
}