diff --git a/assistant/src/daemon/approved-devices-store.ts b/assistant/src/daemon/approved-devices-store.ts new file mode 100644 index 00000000000..141bb086568 --- /dev/null +++ b/assistant/src/daemon/approved-devices-store.ts @@ -0,0 +1,140 @@ +/** + * Persistent store for always-allowed paired devices. + * + * Persisted to ~/.vellum/protected/approved-devices.json using the + * atomic-write pattern from trust-store.ts (write .tmp → rename → chmod). + */ + +import { existsSync, readFileSync, writeFileSync, renameSync, chmodSync, mkdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { createHash } from 'node:crypto'; +import { getRootDir } from '../util/platform.js'; +import { getLogger } from '../util/logger.js'; + +const log = getLogger('approved-devices-store'); + +export interface ApprovedDevice { + hashedDeviceId: string; + deviceName: string; + lastPairedAt: number; +} + +interface ApprovedDevicesFile { + version: 1; + devices: ApprovedDevice[]; +} + +function getStorePath(): string { + return join(getRootDir(), 'protected', 'approved-devices.json'); +} + +/** Hash a raw deviceId for storage. */ +export function hashDeviceId(deviceId: string): string { + return createHash('sha256').update(deviceId).digest('hex'); +} + +let cachedDevices: Map | null = null; + +function loadFromDisk(): Map { + const path = getStorePath(); + if (!existsSync(path)) { + return new Map(); + } + try { + const raw = readFileSync(path, 'utf-8'); + const data = JSON.parse(raw) as ApprovedDevicesFile; + if (data.version !== 1 || !Array.isArray(data.devices)) { + log.warn('Invalid approved-devices.json format, starting fresh'); + return new Map(); + } + const map = new Map(); + for (const device of data.devices) { + map.set(device.hashedDeviceId, device); + } + return map; + } catch (err) { + log.error({ err }, 'Failed to load approved-devices.json'); + return new Map(); + } +} + +function saveToDisk(devices: Map): void { + const path = getStorePath(); + const dir = dirname(path); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + const data: ApprovedDevicesFile = { + version: 1, + devices: Array.from(devices.values()), + }; + const tmpPath = path + '.tmp.' + process.pid; + writeFileSync(tmpPath, JSON.stringify(data, null, 2), { mode: 0o600 }); + renameSync(tmpPath, path); + chmodSync(path, 0o600); +} + +function getDevices(): Map { + if (cachedDevices == null) { + cachedDevices = loadFromDisk(); + } + return cachedDevices; +} + +/** Check if a hashed device ID is in the allowlist. */ +export function isDeviceApproved(hashedDeviceId: string): boolean { + return getDevices().has(hashedDeviceId); +} + +/** Add or update a device in the allowlist. */ +export function approveDevice(hashedDeviceId: string, deviceName: string): void { + const devices = getDevices(); + devices.set(hashedDeviceId, { + hashedDeviceId, + deviceName, + lastPairedAt: Date.now(), + }); + saveToDisk(devices); + log.info({ hashedDeviceId }, 'Device approved and saved to allowlist'); +} + +/** Update lastPairedAt and deviceName for an existing device (auto-approve refresh). */ +export function refreshDevice(hashedDeviceId: string, deviceName: string): void { + const devices = getDevices(); + const existing = devices.get(hashedDeviceId); + if (existing) { + existing.deviceName = deviceName; + existing.lastPairedAt = Date.now(); + saveToDisk(devices); + log.info({ hashedDeviceId }, 'Device metadata refreshed'); + } +} + +/** Remove a device from the allowlist. Returns true if removed. */ +export function removeDevice(hashedDeviceId: string): boolean { + const devices = getDevices(); + const removed = devices.delete(hashedDeviceId); + if (removed) { + saveToDisk(devices); + log.info({ hashedDeviceId }, 'Device removed from allowlist'); + } + return removed; +} + +/** Clear all approved devices. */ +export function clearAllDevices(): void { + const devices = getDevices(); + devices.clear(); + saveToDisk(devices); + log.info('All approved devices cleared'); +} + +/** List all approved devices. */ +export function listDevices(): ApprovedDevice[] { + return Array.from(getDevices().values()); +} + +/** Reset the in-memory cache (for testing). */ +export function resetCache(): void { + cachedDevices = null; +} diff --git a/assistant/src/daemon/handlers/index.ts b/assistant/src/daemon/handlers/index.ts index 674f8a4baf0..3a458781a92 100644 --- a/assistant/src/daemon/handlers/index.ts +++ b/assistant/src/daemon/handlers/index.ts @@ -24,6 +24,7 @@ import { workspaceFileHandlers } from './workspace-files.js'; import { identityHandlers } from './identity.js'; import { dictationHandlers } from './dictation.js'; import { inboxInviteHandlers } from './config-inbox.js'; +import { pairingHandlers } from './pairing.js'; // Re-export types and utilities for backwards compatibility export type { @@ -116,6 +117,7 @@ const handlers = { ...identityHandlers, ...dictationHandlers, ...inboxInviteHandlers, + ...pairingHandlers, ...inlineHandlers, } satisfies DispatchMap; diff --git a/assistant/src/daemon/handlers/pairing.ts b/assistant/src/daemon/handlers/pairing.ts new file mode 100644 index 00000000000..4d5dfd7c876 --- /dev/null +++ b/assistant/src/daemon/handlers/pairing.ts @@ -0,0 +1,100 @@ +import * as net from 'node:net'; +import type { + PairingApprovalResponse, + ApprovedDeviceRemove, +} from '../ipc-protocol.js'; +import { log, defineHandlers, type HandlerContext } from './shared.js'; +import { + isDeviceApproved, + approveDevice, + refreshDevice, + removeDevice, + clearAllDevices, + listDevices, +} from '../approved-devices-store.js'; +import type { PairingStore } from '../pairing-store.js'; + +/** Module-level reference set by the daemon server at startup. */ +let pairingStoreRef: PairingStore | null = null; +let bearerTokenRef: string | undefined; + +export function initPairingHandlers(store: PairingStore, bearerToken: string | undefined): void { + pairingStoreRef = store; + bearerTokenRef = bearerToken; +} + +function handlePairingApprovalResponse( + msg: PairingApprovalResponse, + _socket: net.Socket, + ctx: HandlerContext, +): void { + if (!pairingStoreRef) { + log.warn('Pairing store not initialized'); + return; + } + + const entry = pairingStoreRef.get(msg.pairingRequestId); + if (!entry) { + log.warn({ pairingRequestId: msg.pairingRequestId }, 'Pairing request not found for approval response'); + return; + } + + // Idempotent: if already approved/denied, just re-broadcast the current status + if (entry.status === 'approved' || entry.status === 'denied') { + log.info({ pairingRequestId: msg.pairingRequestId, status: entry.status }, 'Duplicate approval response, no-op'); + return; + } + + if (msg.decision === 'deny') { + pairingStoreRef.deny(msg.pairingRequestId); + log.info({ pairingRequestId: msg.pairingRequestId }, 'Pairing request denied'); + return; + } + + // approve_once or always_allow + if (!bearerTokenRef) { + log.error('Cannot approve pairing: no bearer token configured'); + return; + } + + pairingStoreRef.approve(msg.pairingRequestId, bearerTokenRef); + log.info({ pairingRequestId: msg.pairingRequestId, decision: msg.decision }, 'Pairing request approved'); + + // If always_allow, persist the device to the allowlist + if (msg.decision === 'always_allow' && entry.hashedDeviceId) { + approveDevice(entry.hashedDeviceId, entry.deviceName ?? 'Unknown Device'); + } +} + +function handleApprovedDevicesList(socket: net.Socket, ctx: HandlerContext): void { + const devices = listDevices(); + ctx.send(socket, { + type: 'approved_devices_list_response', + devices, + }); +} + +function handleApprovedDeviceRemove( + msg: ApprovedDeviceRemove, + socket: net.Socket, + ctx: HandlerContext, +): void { + const success = removeDevice(msg.hashedDeviceId); + ctx.send(socket, { + type: 'approved_device_remove_response', + success, + }); + log.info({ hashedDeviceId: msg.hashedDeviceId, success }, 'Device removal requested via IPC'); +} + +function handleApprovedDevicesClear(_socket: net.Socket, _ctx: HandlerContext): void { + clearAllDevices(); + log.info('All approved devices cleared via IPC'); +} + +export const pairingHandlers = defineHandlers({ + pairing_approval_response: handlePairingApprovalResponse, + approved_devices_list: (_msg, socket, ctx) => handleApprovedDevicesList(socket, ctx), + approved_device_remove: handleApprovedDeviceRemove, + approved_devices_clear: (_msg, socket, ctx) => handleApprovedDevicesClear(socket, ctx), +}); diff --git a/assistant/src/daemon/pairing-store.ts b/assistant/src/daemon/pairing-store.ts new file mode 100644 index 00000000000..dd83cb718e6 --- /dev/null +++ b/assistant/src/daemon/pairing-store.ts @@ -0,0 +1,177 @@ +/** + * In-memory pairing request store with TTL. + * + * Each pairing request lives for at most TTL_MS (5 minutes) before + * being swept as expired. Status transitions: + * registered → pending → approved | denied | expired + */ + +import { createHash, timingSafeEqual } from 'node:crypto'; +import { getLogger } from '../util/logger.js'; + +const log = getLogger('pairing-store'); + +const TTL_MS = 5 * 60 * 1000; // 5 minutes +const SWEEP_INTERVAL_MS = 30_000; // 30 seconds + +export type PairingStatus = 'registered' | 'pending' | 'approved' | 'denied' | 'expired'; + +export interface PairingRequest { + pairingRequestId: string; + hashedPairingSecret: string; + hashedDeviceId?: string; + deviceName?: string; + status: PairingStatus; + gatewayUrl: string; + localLanUrl: string | null; + bearerToken?: string; + createdAt: number; +} + +function hashValue(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function timingSafeCompare(a: string, b: string): boolean { + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + if (bufA.length !== bufB.length) return false; + return timingSafeEqual(bufA, bufB); +} + +export class PairingStore { + private requests = new Map(); + private sweepTimer: ReturnType | null = null; + + start(): void { + this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS); + } + + stop(): void { + if (this.sweepTimer) { + clearInterval(this.sweepTimer); + this.sweepTimer = null; + } + this.requests.clear(); + } + + /** + * Pre-register a pairing request (called when QR is displayed). + * Idempotent: if the same ID exists and secret matches, overwrite. + * Returns false with 'conflict' if ID exists but secret doesn't match. + */ + register(params: { + pairingRequestId: string; + pairingSecret: string; + gatewayUrl: string; + localLanUrl?: string | null; + }): { ok: true } | { ok: false; reason: 'conflict' } { + const hashedSecret = hashValue(params.pairingSecret); + const existing = this.requests.get(params.pairingRequestId); + + if (existing) { + if (!timingSafeCompare(existing.hashedPairingSecret, hashedSecret)) { + return { ok: false, reason: 'conflict' }; + } + } + + this.requests.set(params.pairingRequestId, { + pairingRequestId: params.pairingRequestId, + hashedPairingSecret: hashedSecret, + status: 'registered', + gatewayUrl: params.gatewayUrl, + localLanUrl: params.localLanUrl ?? null, + createdAt: Date.now(), + }); + + log.info({ pairingRequestId: params.pairingRequestId }, 'Pairing request registered'); + return { ok: true }; + } + + /** + * iOS initiates a pairing request. Validates the secret and transitions + * the entry to "pending" (or "approved" if auto-approved). + */ + beginRequest(params: { + pairingRequestId: string; + pairingSecret: string; + deviceId: string; + deviceName: string; + }): { ok: true; entry: PairingRequest } | { ok: false; reason: 'not_found' | 'invalid_secret' | 'expired' } { + const entry = this.requests.get(params.pairingRequestId); + if (!entry) { + return { ok: false, reason: 'not_found' }; + } + + if (entry.status === 'expired' || entry.status === 'denied') { + return { ok: false, reason: 'expired' }; + } + + const hashedSecret = hashValue(params.pairingSecret); + if (!timingSafeCompare(entry.hashedPairingSecret, hashedSecret)) { + return { ok: false, reason: 'invalid_secret' }; + } + + entry.hashedDeviceId = hashValue(params.deviceId); + entry.deviceName = params.deviceName; + if (entry.status === 'registered') { + entry.status = 'pending'; + } + + return { ok: true, entry }; + } + + /** + * Approve a pairing request. Sets the bearer token for iOS to retrieve. + */ + approve(pairingRequestId: string, bearerToken: string): PairingRequest | null { + const entry = this.requests.get(pairingRequestId); + if (!entry) return null; + entry.status = 'approved'; + entry.bearerToken = bearerToken; + return entry; + } + + /** + * Deny a pairing request. + */ + deny(pairingRequestId: string): PairingRequest | null { + const entry = this.requests.get(pairingRequestId); + if (!entry) return null; + entry.status = 'denied'; + return entry; + } + + /** + * Get a pairing request by ID. + */ + get(pairingRequestId: string): PairingRequest | null { + return this.requests.get(pairingRequestId) ?? null; + } + + /** + * Validate the secret for a status poll request (timing-safe). + */ + validateSecret(pairingRequestId: string, secret: string): boolean { + const entry = this.requests.get(pairingRequestId); + if (!entry) return false; + const hashedSecret = hashValue(secret); + return timingSafeCompare(entry.hashedPairingSecret, hashedSecret); + } + + private sweep(): void { + const now = Date.now(); + for (const [id, entry] of this.requests) { + if (now - entry.createdAt > TTL_MS) { + if (entry.status !== 'approved') { + entry.status = 'expired'; + } + // Remove entries older than 2x TTL regardless of status + if (now - entry.createdAt > TTL_MS * 2) { + this.requests.delete(id); + log.debug({ pairingRequestId: id }, 'Pairing request swept'); + } + } + } + } +} diff --git a/assistant/src/runtime/http-server.ts b/assistant/src/runtime/http-server.ts index 6f43da77651..148f3b7f630 100644 --- a/assistant/src/runtime/http-server.ts +++ b/assistant/src/runtime/http-server.ts @@ -89,6 +89,12 @@ import type { BrowserRelayWebSocketData } from '../browser-extension-relay/serve import { handleSubscribeAssistantEvents } from './routes/events-routes.js'; import { consumeCallback, consumeCallbackError } from '../security/oauth-callback-registry.js'; import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js'; +import { PairingStore } from '../daemon/pairing-store.js'; +import { + isDeviceApproved, + refreshDevice, + hashDeviceId, +} from '../daemon/approved-devices-store.js'; // Re-export shared types so existing consumers don't need to update imports export type { @@ -98,7 +104,6 @@ export type { RuntimeHttpServerOptions, RuntimeAttachmentMetadata, ApprovalCopyGenerator, - ApprovalConversationGenerator, } from './http-types.js'; import type { @@ -106,7 +111,6 @@ import type { NonBlockingMessageProcessor, RuntimeHttpServerOptions, ApprovalCopyGenerator, - ApprovalConversationGenerator, } from './http-types.js'; const log = getLogger('runtime-http'); @@ -396,12 +400,13 @@ export class RuntimeHttpServer { private persistAndProcessMessage?: NonBlockingMessageProcessor; private runOrchestrator?: RunOrchestrator; private approvalCopyGenerator?: ApprovalCopyGenerator; - private approvalConversationGenerator?: ApprovalConversationGenerator; private interfacesDir: string | null; private suggestionCache = new Map(); private suggestionInFlight = new Map>(); private retrySweepTimer: ReturnType | null = null; private sweepInProgress = false; + private pairingStore = new PairingStore(); + private pairingBroadcast?: (msg: { type: string; [key: string]: unknown }) => void; constructor(options: RuntimeHttpServerOptions = {}) { this.port = options.port ?? DEFAULT_PORT; @@ -411,7 +416,6 @@ export class RuntimeHttpServer { this.persistAndProcessMessage = options.persistAndProcessMessage; this.runOrchestrator = options.runOrchestrator; this.approvalCopyGenerator = options.approvalCopyGenerator; - this.approvalConversationGenerator = options.approvalConversationGenerator; this.interfacesDir = options.interfacesDir ?? null; } @@ -420,6 +424,16 @@ export class RuntimeHttpServer { return this.server?.port ?? this.port; } + /** Expose the pairing store so the daemon server can wire IPC handlers. */ + getPairingStore(): PairingStore { + return this.pairingStore; + } + + /** Set a callback for broadcasting IPC messages (wired by daemon server). */ + setPairingBroadcast(fn: (msg: { type: string; [key: string]: unknown }) => void): void { + this.pairingBroadcast = fn; + } + async start(): Promise { type AllWebSocketData = RelayWebSocketData | BrowserRelayWebSocketData; this.server = Bun.serve({ @@ -506,10 +520,13 @@ export class RuntimeHttpServer { log.warn('RUNTIME_HTTP_HOST is not bound to loopback. This may expose the runtime to direct public access.'); } + this.pairingStore.start(); + log.info({ port: this.actualPort, hostname: this.hostname, auth: !!this.bearerToken }, 'Runtime HTTP server listening'); } async stop(): Promise { + this.pairingStore.stop(); stopGuardianExpirySweep(); stopGuardianActionSweep(); if (this.retrySweepTimer) { @@ -641,6 +658,14 @@ export class RuntimeHttpServer { } } + // ── Pairing endpoints (unauthenticated, secret-gated) ────────── + if (path === '/v1/pairing/request' && req.method === 'POST') { + return await this.handlePairingRequest(req); + } + if (path === '/v1/pairing/status' && req.method === 'GET') { + return this.handlePairingStatus(url); + } + // Require bearer token when configured if (!isHttpAuthDisabled() && this.bearerToken) { const authHeader = req.headers.get('authorization'); @@ -650,6 +675,11 @@ export class RuntimeHttpServer { } } + // ── Pairing registration (bearer-authenticated) ────────────── + if (path === '/v1/pairing/register' && req.method === 'POST') { + return await this.handlePairingRegister(req); + } + // Serve shareable app pages const pagesMatch = path.match(/^\/pages\/([^/]+)$/); if (pagesMatch && req.method === 'GET') { @@ -874,7 +904,7 @@ export class RuntimeHttpServer { if (endpoint === 'channels/inbound' && req.method === 'POST') { const gatewayOriginSecret = getRuntimeGatewayOriginSecret(); - return await handleChannelInbound(req, this.processMessage, this.bearerToken, this.runOrchestrator, assistantId, gatewayOriginSecret, this.approvalCopyGenerator, this.approvalConversationGenerator); + return await handleChannelInbound(req, this.processMessage, this.bearerToken, this.runOrchestrator, assistantId, gatewayOriginSecret, this.approvalCopyGenerator); } if (endpoint === 'channels/delivery-ack' && req.method === 'POST') { @@ -1224,6 +1254,132 @@ export class RuntimeHttpServer { }); } + // ── Pairing HTTP handlers ───────────────────────────────────────── + + /** + * POST /v1/pairing/register — Bearer-authenticated. + * macOS pre-registers a pairing request when the QR is displayed. + */ + private async handlePairingRegister(req: Request): Promise { + try { + const body = await req.json() as Record; + const pairingRequestId = typeof body.pairingRequestId === 'string' ? body.pairingRequestId : ''; + const pairingSecret = typeof body.pairingSecret === 'string' ? body.pairingSecret : ''; + const gatewayUrl = typeof body.gatewayUrl === 'string' ? body.gatewayUrl : ''; + const localLanUrl = typeof body.localLanUrl === 'string' ? body.localLanUrl : null; + + if (!pairingRequestId || !pairingSecret || !gatewayUrl) { + return Response.json({ error: 'Missing required fields: pairingRequestId, pairingSecret, gatewayUrl' }, { status: 400 }); + } + + const result = this.pairingStore.register({ pairingRequestId, pairingSecret, gatewayUrl, localLanUrl }); + if (!result.ok) { + return Response.json({ error: 'Conflict: pairingRequestId exists with different secret' }, { status: 409 }); + } + + return Response.json({ ok: true }); + } catch (err) { + log.error({ err }, 'Failed to register pairing request'); + return Response.json({ error: 'Internal server error' }, { status: 500 }); + } + } + + /** + * POST /v1/pairing/request — Unauthenticated (secret-gated). + * iOS initiates a pairing handshake. + */ + private async handlePairingRequest(req: Request): Promise { + try { + const body = await req.json() as Record; + const pairingRequestId = typeof body.pairingRequestId === 'string' ? body.pairingRequestId : ''; + const pairingSecret = typeof body.pairingSecret === 'string' ? body.pairingSecret : ''; + const deviceId = typeof body.deviceId === 'string' ? body.deviceId.trim() : ''; + const deviceName = typeof body.deviceName === 'string' ? body.deviceName.trim() : ''; + + // Redact secret from any potential logging of body + log.info({ pairingRequestId, deviceName, hasDeviceId: !!deviceId }, 'Pairing request received'); + + if (!deviceId || !deviceName) { + return Response.json({ error: 'Missing required fields: deviceId, deviceName' }, { status: 400 }); + } + + if (!pairingRequestId || !pairingSecret) { + return Response.json({ error: 'Missing required fields: pairingRequestId, pairingSecret' }, { status: 400 }); + } + + const result = this.pairingStore.beginRequest({ pairingRequestId, pairingSecret, deviceId, deviceName }); + if (!result.ok) { + const statusCode = result.reason === 'invalid_secret' ? 403 : result.reason === 'not_found' ? 403 : 410; + return Response.json({ error: 'Forbidden' }, { status: statusCode }); + } + + const entry = result.entry; + const hashedDeviceId = hashDeviceId(deviceId); + + // Auto-approve if device is in the allowlist + if (isDeviceApproved(hashedDeviceId) && this.bearerToken) { + refreshDevice(hashedDeviceId, deviceName); + this.pairingStore.approve(pairingRequestId, this.bearerToken); + log.info({ pairingRequestId, hashedDeviceId }, 'Auto-approved allowlisted device'); + return Response.json({ + status: 'approved', + bearerToken: this.bearerToken, + gatewayUrl: entry.gatewayUrl, + localLanUrl: entry.localLanUrl, + }); + } + + // Send IPC to macOS to show approval prompt + if (this.pairingBroadcast) { + this.pairingBroadcast({ + type: 'pairing_approval_request', + pairingRequestId, + deviceId: hashedDeviceId, + deviceName, + }); + } + + return Response.json({ status: 'pending' }); + } catch (err) { + log.error({ err }, 'Failed to process pairing request'); + return Response.json({ error: 'Internal server error' }, { status: 500 }); + } + } + + /** + * GET /v1/pairing/status?id=&secret= — Unauthenticated (secret-gated). + * iOS polls for approval status. + */ + private handlePairingStatus(url: URL): Response { + const id = url.searchParams.get('id') ?? ''; + // Note: secret is redacted from logs + const secret = url.searchParams.get('secret') ?? ''; + + if (!id || !secret) { + return Response.json({ error: 'Missing required params: id, secret' }, { status: 400 }); + } + + if (!this.pairingStore.validateSecret(id, secret)) { + return Response.json({ error: 'Forbidden' }, { status: 403 }); + } + + const entry = this.pairingStore.get(id); + if (!entry) { + return Response.json({ error: 'Not found' }, { status: 404 }); + } + + if (entry.status === 'approved') { + return Response.json({ + status: 'approved', + bearerToken: entry.bearerToken, + gatewayUrl: entry.gatewayUrl, + localLanUrl: entry.localLanUrl, + }); + } + + return Response.json({ status: entry.status }); + } + private handleGetInterface(interfacePath: string): Response { if (!this.interfacesDir) { return Response.json({ error: 'Interface not found' }, { status: 404 });