refactor(hub): enforce conversation-scoped device authorization
This commit is contained in:
parent
3123506657
commit
a0bb88e7b7
21 changed files with 528 additions and 99 deletions
30
apps/desktop/src/main/electron-env.d.ts
vendored
30
apps/desktop/src/main/electron-env.d.ts
vendored
|
|
@ -70,18 +70,20 @@ interface SkillInfo {
|
|||
triggers: string[]
|
||||
}
|
||||
|
||||
interface DeviceMeta {
|
||||
userAgent?: string
|
||||
platform?: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
interface DeviceEntryInfo {
|
||||
deviceId: string
|
||||
agentId: string
|
||||
addedAt: number
|
||||
meta?: DeviceMeta
|
||||
}
|
||||
interface DeviceMeta {
|
||||
userAgent?: string
|
||||
platform?: string
|
||||
language?: string
|
||||
clientName?: string
|
||||
}
|
||||
|
||||
interface DeviceEntryInfo {
|
||||
deviceId: string
|
||||
agentId: string
|
||||
conversationIds: string[]
|
||||
addedAt: number
|
||||
meta?: DeviceMeta
|
||||
}
|
||||
|
||||
interface SkillAddResult {
|
||||
ok: boolean
|
||||
|
|
@ -188,8 +190,8 @@ interface ElectronAPI {
|
|||
closeAgent: (id: string) => Promise<unknown>
|
||||
closeConversation: (id: string) => Promise<unknown>
|
||||
sendMessage: (agentId: string, content: string, conversationId?: string) => Promise<unknown>
|
||||
registerToken: (token: string, agentId: string, expiresAt: number) => Promise<unknown>
|
||||
onDeviceConfirmRequest: (callback: (deviceId: string, meta?: DeviceMeta) => void) => void
|
||||
registerToken: (token: string, agentId: string, conversationId: string, expiresAt: number) => Promise<unknown>
|
||||
onDeviceConfirmRequest: (callback: (deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => void) => void
|
||||
offDeviceConfirmRequest: () => void
|
||||
deviceConfirmResponse: (deviceId: string, allowed: boolean) => void
|
||||
listDevices: () => Promise<DeviceEntryInfo[]>
|
||||
|
|
|
|||
|
|
@ -501,11 +501,14 @@ export function registerHubIpcHandlers(): void {
|
|||
* Register a one-time token for device verification.
|
||||
* Called by the QR code component when a token is generated or refreshed.
|
||||
*/
|
||||
ipcMain.handle('hub:registerToken', async (_event, token: string, agentId: string, expiresAt: number) => {
|
||||
ipcMain.handle(
|
||||
'hub:registerToken',
|
||||
async (_event, token: string, agentId: string, conversationId: string, expiresAt: number) => {
|
||||
const h = getHub()
|
||||
h.registerToken(token, agentId, expiresAt)
|
||||
h.registerToken(token, agentId, conversationId, expiresAt)
|
||||
return { ok: true }
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* List all verified (whitelisted) devices.
|
||||
|
|
@ -551,7 +554,7 @@ export function setupDeviceConfirmation(mainWindow: Electron.BrowserWindow): voi
|
|||
})
|
||||
|
||||
// Register confirm handler on Hub — sends request to renderer, awaits response
|
||||
h.setConfirmHandler((deviceId: string, _agentId: string, meta) => {
|
||||
h.setConfirmHandler((deviceId: string, agentId: string, conversationId: string, meta) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
// Auto-reject if user doesn't respond within 60 seconds
|
||||
const timeout = setTimeout(() => {
|
||||
|
|
@ -566,7 +569,7 @@ export function setupDeviceConfirmation(mainWindow: Electron.BrowserWindow): voi
|
|||
mainWindow.webContents.send('hub:devices-changed')
|
||||
}
|
||||
})
|
||||
mainWindow.webContents.send('hub:device-confirm-request', deviceId, meta)
|
||||
mainWindow.webContents.send('hub:device-confirm-request', deviceId, agentId, conversationId, meta)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -170,11 +170,27 @@ const electronAPI = {
|
|||
closeConversation: (id: string) => ipcRenderer.invoke('hub:closeConversation', id),
|
||||
sendMessage: (agentId: string, content: string, conversationId?: string) =>
|
||||
ipcRenderer.invoke('hub:sendMessage', agentId, content, conversationId),
|
||||
registerToken: (token: string, agentId: string, expiresAt: number) =>
|
||||
ipcRenderer.invoke('hub:registerToken', token, agentId, expiresAt),
|
||||
onDeviceConfirmRequest: (callback: (deviceId: string, meta?: { userAgent?: string; platform?: string; language?: string }) => void) => {
|
||||
ipcRenderer.on('hub:device-confirm-request', (_event, deviceId: string, meta?: { userAgent?: string; platform?: string; language?: string }) => callback(deviceId, meta))
|
||||
},
|
||||
registerToken: (token: string, agentId: string, conversationId: string, expiresAt: number) =>
|
||||
ipcRenderer.invoke('hub:registerToken', token, agentId, conversationId, expiresAt),
|
||||
onDeviceConfirmRequest: (
|
||||
callback: (
|
||||
deviceId: string,
|
||||
agentId: string,
|
||||
conversationId: string,
|
||||
meta?: { userAgent?: string; platform?: string; language?: string; clientName?: string },
|
||||
) => void,
|
||||
) => {
|
||||
ipcRenderer.on(
|
||||
'hub:device-confirm-request',
|
||||
(
|
||||
_event,
|
||||
deviceId: string,
|
||||
agentId: string,
|
||||
conversationId: string,
|
||||
meta?: { userAgent?: string; platform?: string; language?: string; clientName?: string },
|
||||
) => callback(deviceId, agentId, conversationId, meta),
|
||||
)
|
||||
},
|
||||
offDeviceConfirmRequest: () => {
|
||||
ipcRenderer.removeAllListeners('hub:device-confirm-request')
|
||||
},
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ interface DeviceMeta {
|
|||
|
||||
interface PendingConfirm {
|
||||
deviceId: string
|
||||
agentId: string
|
||||
conversationId: string
|
||||
meta?: DeviceMeta
|
||||
}
|
||||
|
||||
|
|
@ -32,9 +34,11 @@ export function DeviceConfirmDialog() {
|
|||
const [pending, setPending] = useState<PendingConfirm | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI?.hub.onDeviceConfirmRequest((deviceId: string, meta?: DeviceMeta) => {
|
||||
setPending({ deviceId, meta })
|
||||
})
|
||||
window.electronAPI?.hub.onDeviceConfirmRequest(
|
||||
(deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => {
|
||||
setPending({ deviceId, agentId, conversationId, meta })
|
||||
},
|
||||
)
|
||||
return () => {
|
||||
window.electronAPI?.hub.offDeviceConfirmRequest()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,7 +131,8 @@ export function ConnectionQRCode({
|
|||
expirySeconds = 30,
|
||||
size = 200,
|
||||
}: ConnectionQRCodeProps) {
|
||||
const { token, expiresAt, refresh } = useQRToken(agentId, expirySeconds)
|
||||
const resolvedConversationId = conversationId ?? agentId
|
||||
const { token, expiresAt, refresh } = useQRToken(agentId, resolvedConversationId, expirySeconds)
|
||||
const remaining = useCountdown(expiresAt, refresh)
|
||||
|
||||
// Derive QR data and URL from current token (computed during render)
|
||||
|
|
@ -141,11 +142,11 @@ export function ConnectionQRCode({
|
|||
gateway,
|
||||
hubId,
|
||||
agentId,
|
||||
conversationId: conversationId ?? agentId,
|
||||
conversationId: resolvedConversationId,
|
||||
token,
|
||||
expires: expiresAt,
|
||||
}),
|
||||
[gateway, hubId, agentId, conversationId, token, expiresAt]
|
||||
[gateway, hubId, agentId, resolvedConversationId, token, expiresAt]
|
||||
)
|
||||
|
||||
const connectionUrl = useMemo(() => {
|
||||
|
|
@ -153,12 +154,12 @@ export function ConnectionQRCode({
|
|||
gateway,
|
||||
hub: hubId,
|
||||
agent: agentId,
|
||||
conversation: conversationId ?? agentId,
|
||||
conversation: resolvedConversationId,
|
||||
token,
|
||||
exp: expiresAt.toString(),
|
||||
})
|
||||
return `multica://connect?${params.toString()}`
|
||||
}, [gateway, hubId, agentId, conversationId, token, expiresAt])
|
||||
}, [gateway, hubId, agentId, resolvedConversationId, token, expiresAt])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ function generateToken(): string {
|
|||
* - Auto-refreshes when expired
|
||||
* - Registers token with Hub
|
||||
*/
|
||||
export function useQRToken(agentId: string, expirySeconds: number) {
|
||||
export function useQRToken(agentId: string, conversationId: string, expirySeconds: number) {
|
||||
const [token, setToken] = useState(generateToken)
|
||||
const [expiresAt, setExpiresAt] = useState(() => Date.now() + expirySeconds * 1000)
|
||||
|
||||
|
|
@ -20,12 +20,12 @@ export function useQRToken(agentId: string, expirySeconds: number) {
|
|||
const newExpiry = Date.now() + expirySeconds * 1000
|
||||
setToken(newToken)
|
||||
setExpiresAt(newExpiry)
|
||||
window.electronAPI?.hub.registerToken(newToken, agentId, newExpiry)
|
||||
}, [agentId, expirySeconds])
|
||||
window.electronAPI?.hub.registerToken(newToken, agentId, conversationId, newExpiry)
|
||||
}, [agentId, conversationId, expirySeconds])
|
||||
|
||||
// Register initial token
|
||||
useEffect(() => {
|
||||
window.electronAPI?.hub.registerToken(token, agentId, expiresAt)
|
||||
window.electronAPI?.hub.registerToken(token, agentId, conversationId, expiresAt)
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return { token, expiresAt, refresh }
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ export function TelegramConnectQR({
|
|||
expirySeconds = 30,
|
||||
size = 200,
|
||||
}: TelegramConnectQRProps) {
|
||||
const { token, expiresAt, refresh } = useQRToken(agentId, expirySeconds)
|
||||
const resolvedConversationId = conversationId ?? agentId
|
||||
const { token, expiresAt, refresh } = useQRToken(agentId, resolvedConversationId, expirySeconds)
|
||||
const remaining = useCountdown(expiresAt, refresh)
|
||||
|
||||
const [deepLink, setDeepLink] = useState<string | null>(null)
|
||||
|
|
@ -50,7 +51,7 @@ export function TelegramConnectQR({
|
|||
gateway,
|
||||
hubId,
|
||||
agentId,
|
||||
conversationId: conversationId ?? agentId,
|
||||
conversationId: resolvedConversationId,
|
||||
token,
|
||||
expires: expiresAt,
|
||||
}),
|
||||
|
|
@ -81,7 +82,7 @@ export function TelegramConnectQR({
|
|||
|
||||
fetchCode()
|
||||
return () => { cancelled = true }
|
||||
}, [token, expiresAt, gateway, hubId, agentId, conversationId])
|
||||
}, [token, expiresAt, gateway, hubId, agentId, resolvedConversationId])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export interface DeviceMeta {
|
|||
export interface DeviceEntry {
|
||||
deviceId: string
|
||||
agentId: string
|
||||
conversationIds: string[]
|
||||
addedAt: number
|
||||
meta?: DeviceMeta
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ interface DeviceMeta {
|
|||
|
||||
interface PendingConfirm {
|
||||
deviceId: string
|
||||
agentId: string
|
||||
conversationId: string
|
||||
meta?: DeviceMeta
|
||||
}
|
||||
|
||||
|
|
@ -42,9 +44,11 @@ export default function ConnectStep({ onNext, onBack }: ConnectStepProps) {
|
|||
|
||||
// Listen for device confirm requests during onboarding
|
||||
useEffect(() => {
|
||||
window.electronAPI?.hub.onDeviceConfirmRequest((deviceId: string, meta?: DeviceMeta) => {
|
||||
setPending({ deviceId, meta })
|
||||
})
|
||||
window.electronAPI?.hub.onDeviceConfirmRequest(
|
||||
(deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => {
|
||||
setPending({ deviceId, agentId, conversationId, meta })
|
||||
},
|
||||
)
|
||||
return () => {
|
||||
window.electronAPI?.hub.offDeviceConfirmRequest()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1319,7 +1319,11 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
|
|||
? `Telegram @${msg.from.username}`
|
||||
: `Telegram ${msg?.from?.first_name ?? telegramUserId}`,
|
||||
});
|
||||
const mainConversationId = connectionInfo.conversationId ?? result.mainConversationId ?? result.agentId;
|
||||
const mainConversationId =
|
||||
connectionInfo.conversationId
|
||||
?? result.conversationId
|
||||
?? result.mainConversationId
|
||||
?? result.agentId;
|
||||
|
||||
// 5. Save to DB
|
||||
await this.userStore.upsert({
|
||||
|
|
|
|||
|
|
@ -177,6 +177,8 @@ export interface VerifyParams {
|
|||
export interface VerifyResult {
|
||||
hubId: string;
|
||||
agentId: string;
|
||||
/** Main conversation for this agent. Defaults to agentId in legacy mode. */
|
||||
/** Authorized conversation scope for this device. */
|
||||
conversationId?: string;
|
||||
/** Backward-compatible alias for conversationId. */
|
||||
mainConversationId?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -234,7 +234,17 @@ export class GatewayClient {
|
|||
}
|
||||
|
||||
/** Hub 验证成功回调 */
|
||||
onVerified(callback: (result: { hubId: string; agentId: string; mainConversationId?: string; isNewDevice?: boolean }) => void): this {
|
||||
onVerified(
|
||||
callback: (
|
||||
result: {
|
||||
hubId: string;
|
||||
agentId: string;
|
||||
conversationId?: string;
|
||||
mainConversationId?: string;
|
||||
isNewDevice?: boolean;
|
||||
}
|
||||
) => void,
|
||||
): this {
|
||||
this.callbacks.onVerified = callback;
|
||||
return this;
|
||||
}
|
||||
|
|
@ -318,7 +328,13 @@ export class GatewayClient {
|
|||
platform: navigator.platform,
|
||||
language: navigator.language,
|
||||
} : undefined;
|
||||
this.request<{ hubId: string; agentId: string; mainConversationId?: string; isNewDevice?: boolean }>(
|
||||
this.request<{
|
||||
hubId: string;
|
||||
agentId: string;
|
||||
conversationId?: string;
|
||||
mainConversationId?: string;
|
||||
isNewDevice?: boolean;
|
||||
}>(
|
||||
this.options.hubId,
|
||||
"verify",
|
||||
{ token: this.options.token, meta },
|
||||
|
|
|
|||
|
|
@ -110,7 +110,15 @@ export interface GatewayClientCallbacks {
|
|||
onConnect?: (socketId: string) => void;
|
||||
onDisconnect?: (reason: string) => void;
|
||||
onRegistered?: (deviceId: string) => void;
|
||||
onVerified?: (result: { hubId: string; agentId: string; mainConversationId?: string; isNewDevice?: boolean }) => void;
|
||||
onVerified?: (
|
||||
result: {
|
||||
hubId: string;
|
||||
agentId: string;
|
||||
conversationId?: string;
|
||||
mainConversationId?: string;
|
||||
isNewDevice?: boolean;
|
||||
}
|
||||
) => void;
|
||||
onMessage?: (message: RoutedMessage) => void;
|
||||
onSendError?: (error: SendErrorResponse) => void;
|
||||
onPong?: (data: string) => void;
|
||||
|
|
|
|||
87
packages/core/src/hub/device-store.test.ts
Normal file
87
packages/core/src/hub/device-store.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { DeviceStore } from "./device-store.js";
|
||||
|
||||
describe("DeviceStore", () => {
|
||||
const testDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of testDirs) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
testDirs.length = 0;
|
||||
});
|
||||
|
||||
it("stores token with conversation scope and enforces one-time consumption", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "device-store-test-"));
|
||||
testDirs.push(dir);
|
||||
const store = new DeviceStore({ devicesFile: join(dir, "whitelist.json") });
|
||||
|
||||
const expiresAt = Date.now() + 60_000;
|
||||
store.registerToken("token-1", "agent-1", "conv-1", expiresAt);
|
||||
|
||||
expect(store.consumeToken("token-1")).toEqual({
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
});
|
||||
expect(store.consumeToken("token-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("enforces conversation-level authorization and supports adding scopes", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "device-store-test-"));
|
||||
testDirs.push(dir);
|
||||
const devicesFile = join(dir, "whitelist.json");
|
||||
const store = new DeviceStore({ devicesFile });
|
||||
|
||||
store.allowDevice("dev-1", "agent-1", "conv-1");
|
||||
expect(store.isAllowed("dev-1")).toEqual({
|
||||
agentId: "agent-1",
|
||||
conversationIds: ["conv-1"],
|
||||
});
|
||||
expect(store.isAllowed("dev-1", "conv-1")).toEqual({
|
||||
agentId: "agent-1",
|
||||
conversationIds: ["conv-1"],
|
||||
});
|
||||
expect(store.isAllowed("dev-1", "conv-2")).toBeNull();
|
||||
|
||||
expect(store.allowConversation("dev-1", "conv-2")).toBe(true);
|
||||
expect(store.isAllowed("dev-1", "conv-2")).toEqual({
|
||||
agentId: "agent-1",
|
||||
conversationIds: ["conv-1", "conv-2"],
|
||||
});
|
||||
|
||||
const restored = new DeviceStore({ devicesFile });
|
||||
expect(restored.isAllowed("dev-1", "conv-1")).not.toBeNull();
|
||||
expect(restored.isAllowed("dev-1", "conv-2")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("migrates legacy entries without conversationIds using agentId as fallback scope", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "device-store-test-"));
|
||||
testDirs.push(dir);
|
||||
const devicesFile = join(dir, "whitelist.json");
|
||||
writeFileSync(
|
||||
devicesFile,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
devices: [
|
||||
{
|
||||
deviceId: "legacy-dev",
|
||||
agentId: "legacy-agent",
|
||||
addedAt: 123,
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const store = new DeviceStore({ devicesFile });
|
||||
expect(store.isAllowed("legacy-dev")).toEqual({
|
||||
agentId: "legacy-agent",
|
||||
conversationIds: ["legacy-agent"],
|
||||
});
|
||||
expect(store.isAllowed("legacy-dev", "legacy-agent")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { DATA_DIR } from "@multica/utils";
|
||||
|
||||
// ============ Types ============
|
||||
|
|
@ -7,6 +7,7 @@ import { DATA_DIR } from "@multica/utils";
|
|||
interface TokenEntry {
|
||||
token: string;
|
||||
agentId: string;
|
||||
conversationId: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
|
|
@ -20,6 +21,7 @@ export interface DeviceMeta {
|
|||
export interface DeviceEntry {
|
||||
deviceId: string;
|
||||
agentId: string;
|
||||
conversationIds: string[];
|
||||
addedAt: number;
|
||||
meta?: DeviceMeta | undefined;
|
||||
}
|
||||
|
|
@ -34,41 +36,44 @@ interface WhitelistFile {
|
|||
const DEVICES_DIR = join(DATA_DIR, "client-devices");
|
||||
const DEVICES_FILE = join(DEVICES_DIR, "whitelist.json");
|
||||
|
||||
function ensureDir(): void {
|
||||
if (!existsSync(DEVICES_DIR)) {
|
||||
mkdirSync(DEVICES_DIR, { recursive: true });
|
||||
}
|
||||
interface DeviceStoreOptions {
|
||||
devicesFile?: string;
|
||||
}
|
||||
|
||||
function loadDevices(): DeviceEntry[] {
|
||||
if (!existsSync(DEVICES_FILE)) return [];
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(DEVICES_FILE, "utf-8"));
|
||||
// Migrate legacy array format
|
||||
if (Array.isArray(raw)) return raw as DeviceEntry[];
|
||||
return (raw as WhitelistFile).devices ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
function normalizeConversationIds(
|
||||
input: unknown,
|
||||
fallbackConversationId: string,
|
||||
): string[] {
|
||||
const ids = new Set<string>();
|
||||
if (Array.isArray(input)) {
|
||||
for (const item of input) {
|
||||
if (typeof item !== "string") continue;
|
||||
const id = item.trim();
|
||||
if (id) ids.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function saveDevices(devices: DeviceEntry[]): void {
|
||||
ensureDir();
|
||||
const data: WhitelistFile = { version: 1, devices };
|
||||
writeFileSync(DEVICES_FILE, JSON.stringify(data, null, 2), "utf-8");
|
||||
const fallback = fallbackConversationId.trim();
|
||||
if (ids.size === 0 && fallback) {
|
||||
ids.add(fallback);
|
||||
}
|
||||
return Array.from(ids);
|
||||
}
|
||||
|
||||
// ============ DeviceStore ============
|
||||
|
||||
export class DeviceStore {
|
||||
private readonly devicesDir: string;
|
||||
private readonly devicesFile: string;
|
||||
/** 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() {
|
||||
constructor(options?: DeviceStoreOptions) {
|
||||
this.devicesFile = options?.devicesFile ?? DEVICES_FILE;
|
||||
this.devicesDir = options?.devicesFile ? dirname(options.devicesFile) : DEVICES_DIR;
|
||||
// Restore from persistent storage
|
||||
for (const entry of loadDevices()) {
|
||||
for (const entry of this.loadDevices()) {
|
||||
this.allowedDevices.set(entry.deviceId, entry);
|
||||
}
|
||||
}
|
||||
|
|
@ -76,38 +81,75 @@ export class DeviceStore {
|
|||
// ---- Token management ----
|
||||
|
||||
/** Register a one-time token (called when QR code is generated) */
|
||||
registerToken(token: string, agentId: string, expiresAt: number): void {
|
||||
registerToken(token: string, agentId: string, conversationId: string, expiresAt: number): void {
|
||||
// Clean up expired tokens to prevent accumulation
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this.tokens) {
|
||||
if (now > entry.expiresAt) this.tokens.delete(key);
|
||||
}
|
||||
this.tokens.set(token, { token, agentId, expiresAt });
|
||||
this.tokens.set(token, { token, agentId, conversationId, expiresAt });
|
||||
}
|
||||
|
||||
/** Validate and consume a token (one-time use). Returns agentId if valid, null otherwise. */
|
||||
consumeToken(token: string): { agentId: string } | null {
|
||||
consumeToken(token: string): { agentId: string; conversationId: 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 };
|
||||
return { agentId: entry.agentId, conversationId: entry.conversationId };
|
||||
}
|
||||
|
||||
// ---- Device whitelist ----
|
||||
|
||||
/** Add a device to the whitelist (called after token verification + user confirmation) */
|
||||
allowDevice(deviceId: string, agentId: string, meta?: DeviceMeta): void {
|
||||
const entry: DeviceEntry = { deviceId, agentId, addedAt: Date.now(), meta };
|
||||
allowDevice(deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta): void {
|
||||
const existing = this.allowedDevices.get(deviceId);
|
||||
const conversationIds = existing && existing.agentId === agentId
|
||||
? normalizeConversationIds(existing.conversationIds, conversationId)
|
||||
: normalizeConversationIds([], conversationId);
|
||||
if (!conversationIds.includes(conversationId)) {
|
||||
conversationIds.push(conversationId);
|
||||
}
|
||||
|
||||
const entry: DeviceEntry = {
|
||||
deviceId,
|
||||
agentId,
|
||||
conversationIds,
|
||||
addedAt: existing?.addedAt ?? Date.now(),
|
||||
meta,
|
||||
};
|
||||
this.allowedDevices.set(deviceId, entry);
|
||||
this.persist();
|
||||
}
|
||||
|
||||
/** Check if a device is in the whitelist */
|
||||
isAllowed(deviceId: string): { agentId: string } | null {
|
||||
isAllowed(
|
||||
deviceId: string,
|
||||
conversationId?: string,
|
||||
): { agentId: string; conversationIds: string[] } | null {
|
||||
const entry = this.allowedDevices.get(deviceId);
|
||||
return entry ? { agentId: entry.agentId } : null;
|
||||
if (!entry) return null;
|
||||
|
||||
if (conversationId !== undefined && !entry.conversationIds.includes(conversationId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
agentId: entry.agentId,
|
||||
conversationIds: [...entry.conversationIds],
|
||||
};
|
||||
}
|
||||
|
||||
/** Grant an additional conversation scope to an existing device. */
|
||||
allowConversation(deviceId: string, conversationId: string): boolean {
|
||||
const entry = this.allowedDevices.get(deviceId);
|
||||
if (!entry) return false;
|
||||
if (entry.conversationIds.includes(conversationId)) return true;
|
||||
entry.conversationIds.push(conversationId);
|
||||
this.allowedDevices.set(deviceId, entry);
|
||||
this.persist();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Remove a device from the whitelist */
|
||||
|
|
@ -123,6 +165,56 @@ export class DeviceStore {
|
|||
}
|
||||
|
||||
private persist(): void {
|
||||
saveDevices(Array.from(this.allowedDevices.values()));
|
||||
this.saveDevices(Array.from(this.allowedDevices.values()));
|
||||
}
|
||||
|
||||
private ensureDir(): void {
|
||||
if (!existsSync(this.devicesDir)) {
|
||||
mkdirSync(this.devicesDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
private loadDevices(): DeviceEntry[] {
|
||||
if (!existsSync(this.devicesFile)) return [];
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(this.devicesFile, "utf-8"));
|
||||
const devices = Array.isArray(raw) ? raw : (raw as WhitelistFile).devices ?? [];
|
||||
if (!Array.isArray(devices)) return [];
|
||||
|
||||
const normalized: DeviceEntry[] = [];
|
||||
for (const item of devices) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const rawDeviceId = (item as { deviceId?: unknown }).deviceId;
|
||||
const rawAgentId = (item as { agentId?: unknown }).agentId;
|
||||
if (typeof rawDeviceId !== "string" || typeof rawAgentId !== "string") continue;
|
||||
const deviceId = rawDeviceId.trim();
|
||||
const agentId = rawAgentId.trim();
|
||||
if (!deviceId || !agentId) continue;
|
||||
const fallbackConversationId = typeof (item as { conversationId?: unknown }).conversationId === "string"
|
||||
? (item as { conversationId: string }).conversationId
|
||||
: agentId;
|
||||
normalized.push({
|
||||
deviceId,
|
||||
agentId,
|
||||
conversationIds: normalizeConversationIds(
|
||||
(item as { conversationIds?: unknown }).conversationIds,
|
||||
fallbackConversationId,
|
||||
),
|
||||
addedAt: typeof (item as { addedAt?: unknown }).addedAt === "number"
|
||||
? (item as { addedAt: number }).addedAt
|
||||
: Date.now(),
|
||||
meta: (item as { meta?: DeviceMeta }).meta,
|
||||
});
|
||||
}
|
||||
return normalized;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private saveDevices(devices: DeviceEntry[]): void {
|
||||
this.ensureDir();
|
||||
const data: WhitelistFile = { version: 2, devices };
|
||||
writeFileSync(this.devicesFile, JSON.stringify(data, null, 2), "utf-8");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,7 +103,9 @@ export class Hub {
|
|||
private heartbeatUnsubscribe: (() => void) | null = null;
|
||||
private client: GatewayClient;
|
||||
readonly deviceStore: DeviceStore;
|
||||
private _onConfirmDevice: ((deviceId: string, agentId: string, meta?: DeviceMeta) => Promise<boolean>) | null = null;
|
||||
private _onConfirmDevice: (
|
||||
(deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => Promise<boolean>
|
||||
) | null = null;
|
||||
private _stateChangeListeners: ((state: ConnectionState) => void)[] = [];
|
||||
readonly channelManager: ChannelManager;
|
||||
url: string;
|
||||
|
|
@ -126,12 +128,12 @@ export class Hub {
|
|||
hubId: this.hubId,
|
||||
deviceStore: this.deviceStore,
|
||||
resolveMainConversationId: (agentId) => this.getAgentMainConversationId(agentId),
|
||||
onConfirmDevice: (deviceId, agentId, meta) => {
|
||||
onConfirmDevice: (deviceId, agentId, conversationId, meta) => {
|
||||
if (!this._onConfirmDevice) {
|
||||
// No UI confirm handler registered (CLI mode etc.) — auto-approve
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return this._onConfirmDevice(deviceId, agentId, meta);
|
||||
return this._onConfirmDevice(deviceId, agentId, conversationId, meta);
|
||||
},
|
||||
}));
|
||||
this.rpc.register("generateChannelWelcome", createGenerateChannelWelcomeHandler(this));
|
||||
|
|
@ -413,6 +415,7 @@ export class Hub {
|
|||
}
|
||||
|
||||
// Non-RPC messages also require verified device
|
||||
const payload = msg.payload as { agentId?: string; conversationId?: string; content?: string } | undefined;
|
||||
if (!this.deviceStore.isAllowed(msg.from)) {
|
||||
console.warn(`[Hub] Rejected message from unverified device: ${msg.from}`);
|
||||
this.client.send(msg.from, "error", {
|
||||
|
|
@ -422,9 +425,6 @@ export class Hub {
|
|||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular chat message
|
||||
const payload = msg.payload as { agentId?: string; conversationId?: string; content?: string } | undefined;
|
||||
const incomingAgentId = payload?.agentId;
|
||||
const conversationId = this.resolveConversationId(incomingAgentId, payload?.conversationId);
|
||||
const agentId = this.resolveAgentId(incomingAgentId, conversationId);
|
||||
|
|
@ -433,6 +433,30 @@ export class Hub {
|
|||
console.warn(`[Hub] Invalid payload, missing agentId or content`);
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedScope = this.deviceStore.isAllowed(msg.from, conversationId);
|
||||
if (!allowedScope) {
|
||||
console.warn(`[Hub] Rejected message outside authorized conversation scope: ${msg.from} -> ${conversationId}`);
|
||||
this.client.send(msg.from, "error", {
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Device is not authorized for this conversation.",
|
||||
messageId: msg.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (allowedScope.agentId !== agentId) {
|
||||
console.warn(
|
||||
`[Hub] Rejected message due to agent mismatch: device=${msg.from}, allowedAgent=${allowedScope.agentId}, targetAgent=${agentId}`,
|
||||
);
|
||||
this.client.send(msg.from, "error", {
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Device is not authorized for this agent.",
|
||||
messageId: msg.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const agent = this.agents.get(conversationId);
|
||||
if (agent && !agent.closed) {
|
||||
this.agentSenders.set(conversationId, msg.from);
|
||||
|
|
@ -459,7 +483,11 @@ export class Hub {
|
|||
}
|
||||
|
||||
/** Register a confirmation handler for new device connections (called by Desktop UI) */
|
||||
setConfirmHandler(handler: ((deviceId: string, agentId: string, meta?: DeviceMeta) => Promise<boolean>) | null): void {
|
||||
setConfirmHandler(
|
||||
handler: (
|
||||
(deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => Promise<boolean>
|
||||
) | null,
|
||||
): void {
|
||||
this._onConfirmDevice = handler;
|
||||
}
|
||||
|
||||
|
|
@ -488,11 +516,21 @@ export class Hub {
|
|||
}
|
||||
|
||||
/** Register a one-time token for device verification (called when QR code is generated) */
|
||||
registerToken(token: string, agentId: string, expiresAt: number): void {
|
||||
registerToken(token: string, agentId: string, conversationId: string, expiresAt: number): void {
|
||||
const normalizedAgentId = this.normalizeId(agentId);
|
||||
if (!normalizedAgentId) return;
|
||||
const resolvedAgentId = this.conversationAgents.get(normalizedAgentId) ?? normalizedAgentId;
|
||||
this.deviceStore.registerToken(token, resolvedAgentId, expiresAt);
|
||||
const normalizedConversationId = this.normalizeId(conversationId);
|
||||
if (!normalizedAgentId || !normalizedConversationId) return;
|
||||
|
||||
const resolvedConversationId = this.resolveConversationId(normalizedAgentId, normalizedConversationId);
|
||||
const ownerAgentId = this.conversationAgents.get(resolvedConversationId);
|
||||
if (ownerAgentId && ownerAgentId !== normalizedAgentId) {
|
||||
console.warn(
|
||||
`[Hub] registerToken rejected due to agent/conversation mismatch: agent=${normalizedAgentId}, conversation=${resolvedConversationId}, owner=${ownerAgentId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const resolvedAgentId = ownerAgentId ?? normalizedAgentId;
|
||||
this.deviceStore.registerToken(token, resolvedAgentId, resolvedConversationId, expiresAt);
|
||||
}
|
||||
|
||||
/** 重连到新的 Gateway 地址 */
|
||||
|
|
@ -777,6 +815,12 @@ export class Hub {
|
|||
const { requestId, method } = request;
|
||||
try {
|
||||
const result = await this.rpc.dispatch(method, request.params, from);
|
||||
if (method === "createConversation") {
|
||||
const createdConversationId = (result as { id?: unknown }).id;
|
||||
if (typeof createdConversationId === "string" && createdConversationId) {
|
||||
this.deviceStore.allowConversation(from, createdConversationId);
|
||||
}
|
||||
}
|
||||
this.client.send<ResponseSuccessPayload>(from, ResponseAction, {
|
||||
requestId,
|
||||
ok: true,
|
||||
|
|
|
|||
105
packages/core/src/hub/rpc/handlers/verify.test.ts
Normal file
105
packages/core/src/hub/rpc/handlers/verify.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createVerifyHandler } from "./verify.js";
|
||||
import { RpcError } from "../dispatcher.js";
|
||||
import type { DeviceStore } from "../../device-store.js";
|
||||
|
||||
function createDeviceStoreStub() {
|
||||
return {
|
||||
isAllowed: vi.fn(),
|
||||
consumeToken: vi.fn(),
|
||||
allowDevice: vi.fn(),
|
||||
} as unknown as DeviceStore;
|
||||
}
|
||||
|
||||
describe("createVerifyHandler", () => {
|
||||
it("returns existing authorized conversation scope without consuming token", async () => {
|
||||
const deviceStore = createDeviceStoreStub();
|
||||
const storeApi = deviceStore as unknown as {
|
||||
isAllowed: ReturnType<typeof vi.fn>;
|
||||
consumeToken: ReturnType<typeof vi.fn>;
|
||||
allowDevice: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
storeApi.isAllowed.mockReturnValue({
|
||||
agentId: "agent-1",
|
||||
conversationIds: ["conv-1"],
|
||||
});
|
||||
|
||||
const onConfirmDevice = vi.fn(async () => true);
|
||||
const handler = createVerifyHandler({
|
||||
hubId: "hub-1",
|
||||
deviceStore,
|
||||
resolveMainConversationId: () => "conv-1",
|
||||
onConfirmDevice,
|
||||
});
|
||||
|
||||
const result = await handler({}, "dev-1");
|
||||
expect(result).toEqual({
|
||||
hubId: "hub-1",
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
mainConversationId: "conv-1",
|
||||
isNewDevice: false,
|
||||
});
|
||||
expect(storeApi.consumeToken).not.toHaveBeenCalled();
|
||||
expect(onConfirmDevice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("consumes token, confirms device, and stores conversation scope", async () => {
|
||||
const deviceStore = createDeviceStoreStub();
|
||||
const storeApi = deviceStore as unknown as {
|
||||
isAllowed: ReturnType<typeof vi.fn>;
|
||||
consumeToken: ReturnType<typeof vi.fn>;
|
||||
allowDevice: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
storeApi.isAllowed.mockReturnValue(null);
|
||||
storeApi.consumeToken.mockReturnValue({
|
||||
agentId: "agent-2",
|
||||
conversationId: "conv-2",
|
||||
});
|
||||
|
||||
const onConfirmDevice = vi.fn(async () => true);
|
||||
const handler = createVerifyHandler({
|
||||
hubId: "hub-2",
|
||||
deviceStore,
|
||||
resolveMainConversationId: () => "conv-2",
|
||||
onConfirmDevice,
|
||||
});
|
||||
|
||||
const result = await handler({ token: "token-2" }, "dev-2");
|
||||
expect(result).toEqual({
|
||||
hubId: "hub-2",
|
||||
agentId: "agent-2",
|
||||
conversationId: "conv-2",
|
||||
mainConversationId: "conv-2",
|
||||
isNewDevice: true,
|
||||
});
|
||||
expect(onConfirmDevice).toHaveBeenCalledWith("dev-2", "agent-2", "conv-2", undefined);
|
||||
expect(storeApi.allowDevice).toHaveBeenCalledWith("dev-2", "agent-2", "conv-2", undefined);
|
||||
});
|
||||
|
||||
it("throws REJECTED when user denies device confirmation", async () => {
|
||||
const deviceStore = createDeviceStoreStub();
|
||||
const storeApi = deviceStore as unknown as {
|
||||
isAllowed: ReturnType<typeof vi.fn>;
|
||||
consumeToken: ReturnType<typeof vi.fn>;
|
||||
allowDevice: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
storeApi.isAllowed.mockReturnValue(null);
|
||||
storeApi.consumeToken.mockReturnValue({
|
||||
agentId: "agent-3",
|
||||
conversationId: "conv-3",
|
||||
});
|
||||
|
||||
const handler = createVerifyHandler({
|
||||
hubId: "hub-3",
|
||||
deviceStore,
|
||||
onConfirmDevice: async () => false,
|
||||
});
|
||||
|
||||
await expect(handler({ token: "token-3" }, "dev-3")).rejects.toMatchObject({
|
||||
code: "REJECTED",
|
||||
} satisfies Partial<RpcError>);
|
||||
expect(storeApi.allowDevice).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -7,7 +7,12 @@ interface VerifyContext {
|
|||
deviceStore: DeviceStore;
|
||||
resolveMainConversationId?: (agentId: string) => string | undefined;
|
||||
/** Called for first-time connections. Returns true if user approves, false if rejected. */
|
||||
onConfirmDevice: (deviceId: string, agentId: string, meta?: DeviceMeta) => Promise<boolean>;
|
||||
onConfirmDevice: (
|
||||
deviceId: string,
|
||||
agentId: string,
|
||||
conversationId: string,
|
||||
meta?: DeviceMeta,
|
||||
) => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface VerifyParams {
|
||||
|
|
@ -22,11 +27,18 @@ export function createVerifyHandler(ctx: VerifyContext): RpcHandler {
|
|||
// 1. Already in whitelist → pass through (reconnection, no confirmation needed)
|
||||
const allowed = ctx.deviceStore.isAllowed(from);
|
||||
if (allowed) {
|
||||
const mainConversationId = ctx.resolveMainConversationId?.(allowed.agentId) ?? allowed.agentId;
|
||||
const preferredConversationId = allowed.conversationIds[0];
|
||||
const mainConversationId = ctx.resolveMainConversationId?.(allowed.agentId)
|
||||
?? preferredConversationId
|
||||
?? allowed.agentId;
|
||||
const conversationId = allowed.conversationIds.includes(mainConversationId)
|
||||
? mainConversationId
|
||||
: preferredConversationId ?? mainConversationId;
|
||||
return {
|
||||
hubId: ctx.hubId,
|
||||
agentId: allowed.agentId,
|
||||
mainConversationId,
|
||||
conversationId,
|
||||
mainConversationId: conversationId,
|
||||
isNewDevice: false,
|
||||
};
|
||||
}
|
||||
|
|
@ -42,17 +54,18 @@ export function createVerifyHandler(ctx: VerifyContext): RpcHandler {
|
|||
}
|
||||
|
||||
// 3. Token valid → await Desktop user confirmation
|
||||
const confirmed = await ctx.onConfirmDevice(from, result.agentId, meta);
|
||||
const confirmed = await ctx.onConfirmDevice(from, result.agentId, result.conversationId, meta);
|
||||
if (!confirmed) {
|
||||
throw new RpcError("REJECTED", "Connection rejected by user");
|
||||
}
|
||||
|
||||
// 4. User confirmed → add to whitelist (with device metadata)
|
||||
ctx.deviceStore.allowDevice(from, result.agentId, meta);
|
||||
const mainConversationId = ctx.resolveMainConversationId?.(result.agentId) ?? result.agentId;
|
||||
ctx.deviceStore.allowDevice(from, result.agentId, result.conversationId, meta);
|
||||
const mainConversationId = ctx.resolveMainConversationId?.(result.agentId) ?? result.conversationId;
|
||||
return {
|
||||
hubId: ctx.hubId,
|
||||
agentId: result.agentId,
|
||||
conversationId: mainConversationId,
|
||||
mainConversationId,
|
||||
isNewDevice: true,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -187,6 +187,8 @@ export interface VerifyParams {
|
|||
export interface VerifyResult {
|
||||
hubId: string;
|
||||
agentId: string;
|
||||
/** Main conversation for this agent. Defaults to agentId in legacy mode. */
|
||||
/** Authorized conversation scope for this device. */
|
||||
conversationId?: string;
|
||||
/** Backward-compatible alias for conversationId. */
|
||||
mainConversationId?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -234,7 +234,17 @@ export class GatewayClient {
|
|||
}
|
||||
|
||||
/** Hub 验证成功回调 */
|
||||
onVerified(callback: (result: { hubId: string; agentId: string; mainConversationId?: string; isNewDevice?: boolean }) => void): this {
|
||||
onVerified(
|
||||
callback: (
|
||||
result: {
|
||||
hubId: string;
|
||||
agentId: string;
|
||||
conversationId?: string;
|
||||
mainConversationId?: string;
|
||||
isNewDevice?: boolean;
|
||||
}
|
||||
) => void,
|
||||
): this {
|
||||
this.callbacks.onVerified = callback;
|
||||
return this;
|
||||
}
|
||||
|
|
@ -318,7 +328,13 @@ export class GatewayClient {
|
|||
platform: navigator.platform,
|
||||
language: navigator.language,
|
||||
} : undefined;
|
||||
this.request<{ hubId: string; agentId: string; mainConversationId?: string; isNewDevice?: boolean }>(
|
||||
this.request<{
|
||||
hubId: string;
|
||||
agentId: string;
|
||||
conversationId?: string;
|
||||
mainConversationId?: string;
|
||||
isNewDevice?: boolean;
|
||||
}>(
|
||||
this.options.hubId,
|
||||
"verify",
|
||||
{ token: this.options.token, meta },
|
||||
|
|
|
|||
|
|
@ -110,7 +110,15 @@ export interface GatewayClientCallbacks {
|
|||
onConnect?: (socketId: string) => void;
|
||||
onDisconnect?: (reason: string) => void;
|
||||
onRegistered?: (deviceId: string) => void;
|
||||
onVerified?: (result: { hubId: string; agentId: string; mainConversationId?: string; isNewDevice?: boolean }) => void;
|
||||
onVerified?: (
|
||||
result: {
|
||||
hubId: string;
|
||||
agentId: string;
|
||||
conversationId?: string;
|
||||
mainConversationId?: string;
|
||||
isNewDevice?: boolean;
|
||||
}
|
||||
) => void;
|
||||
onMessage?: (message: RoutedMessage) => void;
|
||||
onSendError?: (error: SendErrorResponse) => void;
|
||||
onPong?: (data: string) => void;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue