diff --git a/src/main/index.ts b/src/main/index.ts index d024ac7aea4..a169ca4fe48 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2163,7 +2163,9 @@ app.whenReady().then(async () => { desktopRelayService = relayService runtimeRpc.setMobileRelayPairingProvider({ createPairingRelay: (relayDeviceId) => relayService.createPairingRelay(relayDeviceId), - onDeviceRevokeQueued: (item) => relayService.onDeviceRevokeQueued(item) + onDeviceRevokeQueued: (item) => relayService.onDeviceRevokeQueued(item), + getEndpoints: (context, params) => relayService.getEndpoints(context, params), + provisionRelay: (context, params) => relayService.provisionRelay(context, params) }) relayService.start() } catch (error) { diff --git a/src/main/runtime/relay/desktop-relay-service.test.ts b/src/main/runtime/relay/desktop-relay-service.test.ts new file mode 100644 index 00000000000..c5c9ac4c5e2 --- /dev/null +++ b/src/main/runtime/relay/desktop-relay-service.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import type { MobilePairingConnectionContext } from '../runtime-rpc' +import { pairingAuthorizationForContext } from './desktop-relay-service' + +const relayHostId = 'AbCdEf0123_-xyZ9' + +function context( + transport: MobilePairingConnectionContext['transport'] +): MobilePairingConnectionContext { + return { deviceId: 'device-1', connectionId: 'e2ee-connection-1', transport } +} + +describe('pairingAuthorizationForContext', () => { + it('derives direct authorization only from the authenticated connection', () => { + expect(pairingAuthorizationForContext(context({ transport: 'direct' }), relayHostId)).toEqual({ + mode: 'authenticated-direct', + directAuthId: 'e2ee-connection-1' + }) + }) + + it('derives invite authorization only from immutable relay metadata', () => { + expect( + pairingAuthorizationForContext( + context({ + transport: 'relay', + relayHostId, + relayDeviceId: 'device-1', + basisConnId: 'relay-basis-1', + credentialKind: 'invite' + }), + relayHostId + ) + ).toEqual({ mode: 'relay-basis', basisConnId: 'relay-basis-1' }) + }) + + it('reserves resume metadata for confirmation and rejects stale hosts', () => { + expect( + pairingAuthorizationForContext( + context({ + transport: 'relay', + relayHostId, + relayDeviceId: 'device-1', + basisConnId: 'resume-basis-1', + credentialKind: 'resume' + }), + relayHostId + ) + ).toBeNull() + expect(() => + pairingAuthorizationForContext( + context({ + transport: 'relay', + relayHostId: 'stale-host-id-1', + relayDeviceId: 'device-1', + basisConnId: 'relay-basis-1', + credentialKind: 'invite' + }), + relayHostId + ) + ).toThrow('stale_relay_connection') + }) +}) diff --git a/src/main/runtime/relay/desktop-relay-service.ts b/src/main/runtime/relay/desktop-relay-service.ts index 94ca8eb4665..a08121ac8c1 100644 --- a/src/main/runtime/relay/desktop-relay-service.ts +++ b/src/main/runtime/relay/desktop-relay-service.ts @@ -1,5 +1,11 @@ import type { OrcaCloudAuthConfig } from '../../orca-profiles/profile-cloud-auth-config' -import type { OrcaRuntimeRpcServer } from '../runtime-rpc' +import type { MobilePairingConnectionContext, OrcaRuntimeRpcServer } from '../runtime-rpc' +import type { + DeviceCredentialInstalled, + PairingGetEndpointsParams, + PairingGetEndpointsResult, + PairingProvisionRelayParams +} from '../../../shared/mobile-relay-credential-contract' import { readRelayAuthContext } from './relay-auth-context' import { RelayAuthCoordinator } from './relay-auth-coordinator' import { RelaySessionBroker, type RelayBrokerStatus } from './relay-session-broker' @@ -9,6 +15,7 @@ import type { RelayDeviceBinding, RelayRevokeOutboxItem } from './relay-revoke-outbox' +import type { DeviceCredentialInstallAuthorization } from './relay-control-requests' type DesktopRelayServiceOptions = { authConfig: OrcaCloudAuthConfig @@ -18,9 +25,25 @@ type DesktopRelayServiceOptions = { onStatus: (status: RelayBrokerStatus) => void } +export function pairingAuthorizationForContext( + context: MobilePairingConnectionContext, + relayHostId: string +): DeviceCredentialInstallAuthorization | null { + if (context.transport.transport === 'direct') { + return { mode: 'authenticated-direct', directAuthId: context.connectionId } + } + if (context.transport.relayHostId !== relayHostId) { + throw new Error('stale_relay_connection') + } + return context.transport.credentialKind === 'invite' + ? { mode: 'relay-basis', basisConnId: context.transport.basisConnId } + : null +} + export class DesktopRelayService { private readonly coordinator: RelayAuthCoordinator private readonly revokeOutbox: RelayRevokeOutbox + private readonly runtimeRpc: OrcaRuntimeRpcServer constructor(options: DesktopRelayServiceOptions) { const keypair = options.runtimeRpc.getE2EEKeypair() @@ -28,6 +51,7 @@ export class DesktopRelayService { if (!keypair || !mobileSocketWiring) { throw new Error('mobile_runtime_not_ready') } + this.runtimeRpc = options.runtimeRpc this.revokeOutbox = options.runtimeRpc.getRelayRevokeOutbox() this.coordinator = new RelayAuthCoordinator({ readContext: () => readRelayAuthContext(options.authConfig, options.userDataPath), @@ -91,6 +115,66 @@ export class DesktopRelayService { } } + async getEndpoints( + context: MobilePairingConnectionContext, + params: PairingGetEndpointsParams + ): Promise { + this.requireMobileDevice(context.deviceId) + const broker = this.coordinator.getActiveBroker() + if (!(broker instanceof RelaySessionBroker) || !broker.endpoint) { + return { v: 1, relay: null } + } + this.assertRelayHost(context, broker) + const result: PairingGetEndpointsResult = { v: 1, relay: broker.endpoint } + if (params.installReqId) { + result.installStatus = await broker.credentialInstallStatus( + context.deviceId, + params.installReqId + ) + } + if (params.resumeConfirmReqId) { + if ( + context.transport.transport !== 'relay' || + context.transport.credentialKind !== 'resume' + ) { + throw new Error('resume_confirmation_unavailable') + } + result.resumeConfirmation = await broker.confirmResume( + context.transport.basisConnId, + params.resumeConfirmReqId + ) + } + return result + } + + async provisionRelay( + context: MobilePairingConnectionContext, + params: PairingProvisionRelayParams + ): Promise { + this.requireMobileDevice(context.deviceId) + const broker = this.coordinator.getActiveBroker() + if (!(broker instanceof RelaySessionBroker) || !broker.endpoint) { + throw new Error('relay_control_not_active') + } + this.assertRelayHost(context, broker) + const authorization = pairingAuthorizationForContext(context, broker.hostId) + if (!authorization) { + // Why: a resume splice proves renewal through confirmation; it cannot be + // repurposed as either of the two initial-install authorization modes. + throw new Error('relay_provision_authorization_unavailable') + } + if ( + !this.runtimeRpc.setMobileRelayBinding(context.deviceId, { + relayHostId: broker.hostId, + relayDeviceId: context.deviceId, + ownerIdentityKey: broker.ownerIdentityKey + }) + ) { + throw new Error('mobile_device_not_found') + } + return await broker.installCredential(context.deviceId, params, authorization) + } + stop(): void { this.coordinator.stop() } @@ -101,6 +185,24 @@ export class DesktopRelayService { } } + private requireMobileDevice(deviceId: string): void { + if (this.runtimeRpc.getDeviceRegistry()?.getDevice(deviceId)?.scope !== 'mobile') { + throw new Error('mobile_device_not_found') + } + } + + private assertRelayHost( + context: MobilePairingConnectionContext, + broker: RelaySessionBroker + ): void { + if ( + context.transport.transport === 'relay' && + context.transport.relayHostId !== broker.hostId + ) { + throw new Error('stale_relay_connection') + } + } + private async flushRevoke( broker: RelaySessionBroker, item: RelayRevokeOutboxItem diff --git a/src/main/runtime/relay/relay-control-client.test.ts b/src/main/runtime/relay/relay-control-client.test.ts index 275945768de..415b4583d46 100644 --- a/src/main/runtime/relay/relay-control-client.test.ts +++ b/src/main/runtime/relay/relay-control-client.test.ts @@ -242,6 +242,72 @@ describe('RelayControlClient', () => { ) await expect(invitePromise).resolves.toMatchObject({ reqId: 'invite-req' }) + const installRequest = nextJson(socket) + const installPromise = client.installCredential({ + reqId: 'install-req', + relayDeviceId: 'device-1', + newResumeTokenHash: 'A'.repeat(43), + authorization: { mode: 'relay-basis', basisConnId: 'conn-1' } + }) + await expect(installRequest).resolves.toEqual({ + type: 'device-credential-install', + v: 1, + reqId: 'install-req', + relayDeviceId: 'device-1', + newResumeTokenHash: 'A'.repeat(43), + authorization: { mode: 'relay-basis', basisConnId: 'conn-1' } + }) + socket.send( + JSON.stringify({ + type: 'device-credential-installed', + v: 1, + reqId: 'install-req', + authorizationMode: 'relay-basis', + currentVersion: 1, + resumeExpiresAt: Date.now() + 60_000 + }) + ) + await expect(installPromise).resolves.toMatchObject({ currentVersion: 1 }) + + const statusRequest = nextJson(socket) + const statusPromise = client.credentialInstallStatus('device-1', 'install-req') + await expect(statusRequest).resolves.toEqual({ + type: 'device-credential-install-status', + v: 1, + reqId: 'install-req', + relayDeviceId: 'device-1' + }) + socket.send( + JSON.stringify({ + type: 'device-credential-install-status-result', + v: 1, + reqId: 'install-req', + state: 'not-found' + }) + ) + await expect(statusPromise).resolves.toMatchObject({ state: 'not-found' }) + + const confirmationRequest = nextJson(socket) + const confirmationPromise = client.confirmResume('conn-2', 'confirm-req') + await expect(confirmationRequest).resolves.toEqual({ + type: 'device-resume-confirm', + v: 1, + reqId: 'confirm-req', + basisConnId: 'conn-2' + }) + socket.send( + JSON.stringify({ + type: 'device-resume-confirmed', + v: 1, + reqId: 'confirm-req', + currentVersion: 1, + acceptedAs: 'current', + renewed: true, + resumeExpiresAt: Date.now() + 60_000 + }) + ) + await expect(confirmationPromise).resolves.toMatchObject({ renewed: true }) + socket.send(JSON.stringify({ type: 'drain', graceMs: 5_000, recovery: 'resolve-director' })) await vi.waitFor(() => expect(onDrain).toHaveBeenCalledOnce()) }) diff --git a/src/main/runtime/relay/relay-control-client.ts b/src/main/runtime/relay/relay-control-client.ts index 7d1f3c5f0da..d431c13d54a 100644 --- a/src/main/runtime/relay/relay-control-client.ts +++ b/src/main/runtime/relay/relay-control-client.ts @@ -15,6 +15,7 @@ import { type RelayInviteCreatedMessage } from './relay-control-protocol' import { RelayControlRequests } from './relay-control-requests' +import type { DeviceCredentialInstallAuthorization } from './relay-control-requests' import { answerRelayHostChallenge } from './relay-host-proof' type RelayControlState = 'idle' | 'opening' | 'proving' | 'active' | 'draining' | 'closed' @@ -120,6 +121,33 @@ export class RelayControlClient { return this.requests.revokeDevice(reqId, relayDeviceId, (payload) => this.sendActive(payload)) } + installCredential(input: { + reqId: string + relayDeviceId: string + newResumeTokenHash: string + expectedCurrentHash?: string + authorization: DeviceCredentialInstallAuthorization + }): ReturnType { + const { reqId, ...request } = input + return this.requests.installCredential(reqId, request, (payload) => this.sendActive(payload)) + } + + credentialInstallStatus( + relayDeviceId: string, + reqId: string + ): ReturnType { + return this.requests.credentialInstallStatus(reqId, relayDeviceId, (payload) => + this.sendActive(payload) + ) + } + + confirmResume( + basisConnId: string, + reqId: string + ): ReturnType { + return this.requests.confirmResume(reqId, basisConnId, (payload) => this.sendActive(payload)) + } + closeNow(): void { this.state = 'closed' this.requests.rejectAll(new Error('relay_control_closed')) diff --git a/src/main/runtime/relay/relay-control-protocol.ts b/src/main/runtime/relay/relay-control-protocol.ts index 24f3d7c7851..06cf61a95fd 100644 --- a/src/main/runtime/relay/relay-control-protocol.ts +++ b/src/main/runtime/relay/relay-control-protocol.ts @@ -1,5 +1,9 @@ import { z } from 'zod' import type { RawData } from 'ws' +import { + DeviceCredentialInstalledSchema, + DeviceResumeConfirmedSchema +} from '../../../shared/mobile-relay-credential-contract' const OpaqueIdSchema = z.string().min(1).max(128) const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) @@ -79,6 +83,34 @@ export const RelayDeviceRevokedMessageSchema = z .object({ type: z.literal('device-revoked'), reqId: OpaqueIdSchema }) .strict() +export const RelayDeviceCredentialInstalledMessageSchema = DeviceCredentialInstalledSchema.extend({ + type: z.literal('device-credential-installed') +}).strict() + +export const RelayDeviceCredentialInstallStatusResultMessageSchema = z.union([ + z + .object({ + type: z.literal('device-credential-install-status-result'), + v: z.literal(1), + reqId: OpaqueIdSchema, + state: z.literal('not-found') + }) + .strict(), + z + .object({ + type: z.literal('device-credential-install-status-result'), + v: z.literal(1), + reqId: OpaqueIdSchema, + state: z.literal('committed'), + result: DeviceCredentialInstalledSchema + }) + .strict() +]) + +export const RelayDeviceResumeConfirmedMessageSchema = DeviceResumeConfirmedSchema.extend({ + type: z.literal('device-resume-confirmed') +}).strict() + export const RelayControlErrorMessageSchema = z .object({ type: z.literal('control-error'), @@ -92,6 +124,15 @@ export type RelayHostHelloAckMessage = z.infer export type RelayDrainMessage = z.infer export type RelayInviteCreatedMessage = z.infer +export type RelayDeviceCredentialInstalledMessage = z.infer< + typeof RelayDeviceCredentialInstalledMessageSchema +> +export type RelayDeviceCredentialInstallStatusResultMessage = z.infer< + typeof RelayDeviceCredentialInstallStatusResultMessageSchema +> +export type RelayDeviceResumeConfirmedMessage = z.infer< + typeof RelayDeviceResumeConfirmedMessageSchema +> export function parseRelayControlMessage(raw: RawData): Record | null { try { diff --git a/src/main/runtime/relay/relay-control-requests.ts b/src/main/runtime/relay/relay-control-requests.ts index 8a56c7151d1..e67b7b090bf 100644 --- a/src/main/runtime/relay/relay-control-requests.ts +++ b/src/main/runtime/relay/relay-control-requests.ts @@ -1,17 +1,27 @@ import { RelayControlErrorMessageSchema, + RelayDeviceCredentialInstalledMessageSchema, + RelayDeviceCredentialInstallStatusResultMessageSchema, RelayDeviceRevokedMessageSchema, + RelayDeviceResumeConfirmedMessageSchema, RelayInviteCreatedMessageSchema, + type RelayDeviceCredentialInstalledMessage, + type RelayDeviceCredentialInstallStatusResultMessage, + type RelayDeviceResumeConfirmedMessage, type RelayInviteCreatedMessage } from './relay-control-protocol' type PendingRequest = { - kind: 'invite' | 'revoke' + kind: 'invite' | 'revoke' | 'install' | 'install-status' | 'confirm' resolve: (value: unknown) => void reject: (error: Error) => void timer: ReturnType } +export type DeviceCredentialInstallAuthorization = + | { mode: 'relay-basis'; basisConnId: string } + | { mode: 'authenticated-direct'; directAuthId: string } + export class RelayControlRequests { private readonly pending = new Map() @@ -41,6 +51,50 @@ export class RelayControlRequests { ) as Promise } + installCredential( + reqId: string, + input: { + relayDeviceId: string + newResumeTokenHash: string + expectedCurrentHash?: string + authorization: DeviceCredentialInstallAuthorization + }, + send: (payload: object) => void + ): Promise { + return this.request( + reqId, + 'install', + { type: 'device-credential-install', v: 1, reqId, ...input }, + send + ) as Promise + } + + credentialInstallStatus( + reqId: string, + relayDeviceId: string, + send: (payload: object) => void + ): Promise { + return this.request( + reqId, + 'install-status', + { type: 'device-credential-install-status', v: 1, reqId, relayDeviceId }, + send + ) as Promise + } + + confirmResume( + reqId: string, + basisConnId: string, + send: (payload: object) => void + ): Promise { + return this.request( + reqId, + 'confirm', + { type: 'device-resume-confirm', v: 1, reqId, basisConnId }, + send + ) as Promise + } + resolveMessage(message: Record): boolean { const reqId = typeof message.reqId === 'string' ? message.reqId : null const pending = reqId ? this.pending.get(reqId) : null @@ -62,12 +116,27 @@ export class RelayControlRequests { pending.resolve(invite.data) return true } - const revoked = RelayDeviceRevokedMessageSchema.safeParse(message) - if (!revoked.success) { + if (pending.kind === 'revoke') { + const revoked = RelayDeviceRevokedMessageSchema.safeParse(message) + if (!revoked.success) { + return false + } + this.finish(reqId) + pending.resolve(undefined) + return true + } + const schema = + pending.kind === 'install' + ? RelayDeviceCredentialInstalledMessageSchema + : pending.kind === 'install-status' + ? RelayDeviceCredentialInstallStatusResultMessageSchema + : RelayDeviceResumeConfirmedMessageSchema + const result = schema.safeParse(message) + if (!result.success) { return false } this.finish(reqId) - pending.resolve(undefined) + pending.resolve(result.data) return true } diff --git a/src/main/runtime/relay/relay-session-broker.ts b/src/main/runtime/relay/relay-session-broker.ts index 1e9f21cd312..a4b7034d6fa 100644 --- a/src/main/runtime/relay/relay-session-broker.ts +++ b/src/main/runtime/relay/relay-session-broker.ts @@ -1,10 +1,18 @@ import type WebSocket from 'ws' import type { OrcaCloudAuthConfig } from '../../orca-profiles/profile-cloud-auth-config' import type { PairingRelay } from '../../../shared/mobile-relay-pairing-offer' +import type { + DeviceCredentialInstalled, + DeviceCredentialInstallStatusResult, + DeviceResumeConfirmed, + MobileRelayEndpoint, + PairingProvisionRelayParams +} from '../../../shared/mobile-relay-credential-contract' import type { E2EEKeypair } from '../e2ee-keypair' import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring' import { CloudRelayTransport } from '../rpc/relay-transport' import { RelayControlClient } from './relay-control-client' +import type { DeviceCredentialInstallAuthorization } from './relay-control-requests' import { deriveRelayHostId, exchangeRelayAuthorization, @@ -84,6 +92,21 @@ export class RelaySessionBroker { return `${identity.userId}\0${identity.profileId}\0${identity.organizationId}` } + get endpoint(): MobileRelayEndpoint | null { + const assignment = this.assignment + if (!assignment) { + return null + } + return { + v: 1, + directorUrl: this.options.authConfig.relayDirectorUrl, + cellUrl: assignment.cellUrl, + assignmentEpoch: assignment.assignmentEpoch, + relayHostId: this.relayHostId, + e2eeFraming: 2 + } + } + createInvite(relayDeviceId: string): ReturnType { if (!this.control) { return Promise.reject(new Error('relay_control_not_active')) @@ -117,6 +140,47 @@ export class RelaySessionBroker { return this.control.revokeDevice(relayDeviceId, reqId) } + async installCredential( + relayDeviceId: string, + params: PairingProvisionRelayParams, + authorization: DeviceCredentialInstallAuthorization + ): Promise { + if (!this.control) { + throw new Error('relay_control_not_active') + } + const message = await this.control.installCredential({ + relayDeviceId, + authorization, + ...params + }) + this.assertCurrent() + const { type: _type, ...result } = message + return result + } + + async credentialInstallStatus( + relayDeviceId: string, + reqId: string + ): Promise { + if (!this.control) { + throw new Error('relay_control_not_active') + } + const message = await this.control.credentialInstallStatus(relayDeviceId, reqId) + this.assertCurrent() + const { type: _type, ...result } = message + return result + } + + async confirmResume(basisConnId: string, reqId: string): Promise { + if (!this.control) { + throw new Error('relay_control_not_active') + } + const message = await this.control.confirmResume(basisConnId, reqId) + this.assertCurrent() + const { type: _type, ...result } = message + return result + } + closeNow(): void { if (this.closed) { return diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 1ef817bf552..3552b680caf 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -5,6 +5,17 @@ import { ZodError, type ZodType } from 'zod' import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol' import type { OrcaRuntimeService } from '../orca-runtime' +import type { + DeviceCredentialInstalled, + PairingGetEndpointsParams, + PairingGetEndpointsResult, + PairingProvisionRelayParams +} from '../../../shared/mobile-relay-credential-contract' + +export type PairingRpcContext = { + getEndpoints(params: PairingGetEndpointsParams): Promise + provisionRelay(params: PairingProvisionRelayParams): Promise +} export type RpcEnvelopeMeta = { runtimeId: string @@ -64,6 +75,7 @@ export type RpcContext = { // clients. Carries the paired device's scope so handlers can gate the diet to // phones only. Undefined for in-process callers → treat as full-class (no clip). clientKind?: 'mobile' | 'runtime' + pairing?: PairingRpcContext // Why: mobile terminal traffic is byte-oriented and bypasses JSON streaming // responses after the binary terminal cutover. Undefined on Unix/socket // transports and non-E2EE WebSocket paths. diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 24ff44597b0..bf7e26fb461 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -10,6 +10,7 @@ import { isStreamingMethod, type RpcAnyMethod, type RpcEnvelopeMeta, + type PairingRpcContext, type RpcRegistry, type RpcRequest, type RpcResponse @@ -102,6 +103,7 @@ export class RpcDispatcher { signal?: AbortSignal clientId?: string clientKind?: 'mobile' | 'runtime' + pairing?: PairingRpcContext sendBinary?: (bytes: Uint8Array) => boolean | void registerBinaryStreamHandler?: ( streamId: number, @@ -135,6 +137,7 @@ export class RpcDispatcher { connectionId: options?.connectionId, clientId: options?.clientId, clientKind: options?.clientKind, + pairing: options?.pairing, sendBinary: options?.sendBinary, registerBinaryStreamHandler: options?.registerBinaryStreamHandler }) diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index bc0a73985f9..00d2fd974be 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -34,6 +34,7 @@ import { SKILL_METHODS } from './skills' import { CLIPBOARD_METHODS } from './clipboard' import { HOST_CAPABILITY_METHODS } from './host-capabilities' import { EMULATOR_METHODS } from './emulator' +import { PAIRING_METHODS } from './pairing' // Why: a flat manifest keeps registration order explicit and provides one // grep-point for "what methods does the RPC server expose?" — useful when @@ -73,5 +74,6 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...HOST_CAPABILITY_METHODS, ...CLIENT_EVENT_METHODS, ...CLIENT_UI_METHODS, - ...EMULATOR_METHODS + ...EMULATOR_METHODS, + ...PAIRING_METHODS ] diff --git a/src/main/runtime/rpc/methods/pairing.test.ts b/src/main/runtime/rpc/methods/pairing.test.ts new file mode 100644 index 00000000000..58a12a1f058 --- /dev/null +++ b/src/main/runtime/rpc/methods/pairing.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../../orca-runtime' +import { RpcDispatcher } from '../dispatcher' +import { PAIRING_METHODS } from './pairing' + +function dispatchPairing( + method: string, + params: unknown, + pairing: NonNullable[2]>['pairing'] +): Promise> { + return new Promise((resolve) => { + const dispatcher = new RpcDispatcher({ + runtime: new OrcaRuntimeService(), + methods: PAIRING_METHODS + }) + void dispatcher.dispatchStreaming( + { id: 'request-1', authToken: '', method, params }, + (response) => resolve(JSON.parse(response) as Record), + { pairing } + ) + }) +} + +describe('pairing RPC methods', () => { + it('passes only phone-owned credential material to the server-bound provider', async () => { + const provisionRelay = vi.fn().mockResolvedValue({ + v: 1, + reqId: 'install-1', + authorizationMode: 'authenticated-direct', + currentVersion: 1, + resumeExpiresAt: Date.now() + 60_000 + }) + const pairing = { getEndpoints: vi.fn(), provisionRelay } + + await expect( + dispatchPairing( + 'pairing.provisionRelay', + { reqId: 'install-1', newResumeTokenHash: 'A'.repeat(43) }, + pairing + ) + ).resolves.toMatchObject({ ok: true }) + expect(provisionRelay).toHaveBeenCalledWith({ + reqId: 'install-1', + newResumeTokenHash: 'A'.repeat(43) + }) + }) + + it('rejects caller-selected identity and authorization metadata', async () => { + const pairing = { getEndpoints: vi.fn(), provisionRelay: vi.fn() } + + for (const injected of [ + { relayDeviceId: 'attacker-device' }, + { authorization: { mode: 'relay-basis', basisConnId: 'attacker-basis' } }, + { directAuthId: 'attacker-direct' }, + { acceptedCredentialVersion: 99 } + ]) { + await expect( + dispatchPairing( + 'pairing.provisionRelay', + { reqId: 'install-1', newResumeTokenHash: 'A'.repeat(43), ...injected }, + pairing + ) + ).resolves.toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + } + await expect( + dispatchPairing( + 'pairing.getEndpoints', + { installReqId: 'status-1', basisConnId: 'injected' }, + pairing + ) + ).resolves.toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + expect(pairing.provisionRelay).not.toHaveBeenCalled() + expect(pairing.getEndpoints).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/pairing.ts b/src/main/runtime/rpc/methods/pairing.ts new file mode 100644 index 00000000000..7762881ba37 --- /dev/null +++ b/src/main/runtime/rpc/methods/pairing.ts @@ -0,0 +1,28 @@ +import { defineMethod, type RpcAnyMethod } from '../core' +import { + PairingGetEndpointsParamsSchema, + PairingProvisionRelayParamsSchema +} from '../../../../shared/mobile-relay-credential-contract' + +export const PAIRING_METHODS: readonly RpcAnyMethod[] = [ + defineMethod({ + name: 'pairing.getEndpoints', + params: PairingGetEndpointsParamsSchema, + handler: async (params, ctx) => { + if (!ctx.pairing) { + throw new Error('pairing_context_unavailable') + } + return await ctx.pairing.getEndpoints(params) + } + }), + defineMethod({ + name: 'pairing.provisionRelay', + params: PairingProvisionRelayParamsSchema, + handler: async (params, ctx) => { + if (!ctx.pairing) { + throw new Error('pairing_context_unavailable') + } + return await ctx.pairing.provisionRelay(params) + } + }) +] diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 039bc29c4b8..2a86c175168 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -528,7 +528,9 @@ describe('OrcaRuntimeRpcServer', () => { ownerIdentityKey: 'user\0profile\0org' } }), - onDeviceRevokeQueued: vi.fn() + onDeviceRevokeQueued: vi.fn(), + getEndpoints: vi.fn(), + provisionRelay: vi.fn() }) await server.start() @@ -566,7 +568,9 @@ describe('OrcaRuntimeRpcServer', () => { }) server.setMobileRelayPairingProvider({ createPairingRelay: vi.fn().mockRejectedValue(new Error('relay offline')), - onDeviceRevokeQueued: vi.fn() + onDeviceRevokeQueued: vi.fn(), + getEndpoints: vi.fn(), + provisionRelay: vi.fn() }) await server.start() @@ -615,7 +619,9 @@ describe('OrcaRuntimeRpcServer', () => { }), onDeviceRevokeQueued: (item) => { registryPresence.push(server.getDeviceRegistry()?.getDevice(item.relayDeviceId) !== null) - } + }, + getEndpoints: vi.fn(), + provisionRelay: vi.fn() }) await server.start() @@ -642,6 +648,76 @@ describe('OrcaRuntimeRpcServer', () => { } }) + it('binds pairing RPC providers to the immutable authenticated socket context', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + const getEndpoints = vi.fn().mockResolvedValue({ v: 1, relay: null }) + const provisionRelay = vi.fn().mockResolvedValue({ + v: 1, + reqId: 'install-1', + authorizationMode: 'authenticated-direct', + currentVersion: 1, + resumeExpiresAt: Date.now() + 60_000 + }) + server.setMobileRelayPairingProvider({ + createPairingRelay: vi.fn(), + onDeviceRevokeQueued: vi.fn(), + getEndpoints, + provisionRelay + }) + + await server.start() + try { + const offer = server.createPairingOffer({ + address: '127.0.0.1', + scope: 'mobile' + }) + expect(offer.available).toBe(true) + if (!offer.available) { + throw new Error('WebSocket pairing unavailable') + } + const session = await authenticateMobileWsSession(offer.pairingUrl) + const responses = createEncryptedWsResponseReader(session) + sendEncryptedWsRequest(session, { + id: 'endpoints-1', + method: 'pairing.getEndpoints', + params: { installReqId: 'status-1' } + }) + await expect(responses.next('endpoints-1')).resolves.toMatchObject({ + ok: true, + result: { v: 1, relay: null } + }) + sendEncryptedWsRequest(session, { + id: 'provision-1', + method: 'pairing.provisionRelay', + params: { reqId: 'install-1', newResumeTokenHash: 'A'.repeat(43) } + }) + await expect(responses.next('provision-1')).resolves.toMatchObject({ ok: true }) + + const [endpointContext, endpointParams] = getEndpoints.mock.calls[0]! + expect(endpointContext).toEqual({ + deviceId: offer.deviceId, + connectionId: expect.any(String), + transport: { transport: 'direct' } + }) + expect(endpointParams).toEqual({ installReqId: 'status-1' }) + expect(provisionRelay).toHaveBeenCalledWith(endpointContext, { + reqId: 'install-1', + newResumeTokenHash: 'A'.repeat(43) + }) + responses.dispose() + session.ws.close() + await waitForWsClose(session.ws) + } finally { + await server.stop() + } + }) + it('cleans up pre-auth E2EE WebSocket state when the socket closes', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const runtime = new OrcaRuntimeService() diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index e225cb3cff1..6aec53e908a 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -20,13 +20,23 @@ import { readWsFallbackPort, writeWsFallbackPort } from './rpc/ws-fallback-port- import type { WebSocket } from 'ws' import { DeviceRegistry, type DeviceScope } from './device-registry' import { loadOrCreateE2EEKeypair, type E2EEKeypair } from './e2ee-keypair' -import { MobileSocketWiring } from './rpc/mobile-socket-wiring' +import { + MobileSocketWiring, + type AuthenticatedMobileSocket, + type MobileSocketTransportMetadata +} from './rpc/mobile-socket-wiring' import type { PairingRelay } from '../../shared/mobile-relay-pairing-offer' import { RelayRevokeOutbox, type RelayDeviceBinding, type RelayRevokeOutboxItem } from './relay/relay-revoke-outbox' +import type { + DeviceCredentialInstalled, + PairingGetEndpointsParams, + PairingGetEndpointsResult, + PairingProvisionRelayParams +} from '../../shared/mobile-relay-credential-contract' import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' import { decodeTerminalStreamFrame, @@ -56,8 +66,22 @@ type MobileRelayPairingProvider = { relayDeviceId: string ): Promise<{ relay: PairingRelay; binding: RelayDeviceBinding }> onDeviceRevokeQueued(item: RelayRevokeOutboxItem): void + getEndpoints( + context: MobilePairingConnectionContext, + params: PairingGetEndpointsParams + ): Promise + provisionRelay( + context: MobilePairingConnectionContext, + params: PairingProvisionRelayParams + ): Promise } +export type MobilePairingConnectionContext = Readonly<{ + deviceId: string + connectionId: string + transport: MobileSocketTransportMetadata +}> + // Why: after 10 s of a pending dispatch we emit a tiny `{"_keepalive":true}` // frame every 10 s until the handler resolves. Each write resets both the // server's own socket idle timer (30 s) and — once §3.1 ships on the client — @@ -294,6 +318,8 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'markdown.saveTab', 'notifications.subscribe', 'notifications.unsubscribe', + 'pairing.getEndpoints', + 'pairing.provisionRelay', 'preflight.check', 'preflight.detectAgents', 'preflight.detectRemoteAgents', @@ -486,6 +512,23 @@ export class OrcaRuntimeRpcServer { return this.relayRevokeOutbox } + setMobileRelayBinding(deviceId: string, binding: RelayDeviceBinding): boolean { + const current = this.deviceRegistry?.getDevice(deviceId) + if (current?.scope !== 'mobile') { + return false + } + if ( + current.relayBinding && + (current.relayBinding.relayHostId !== binding.relayHostId || + current.relayBinding.ownerIdentityKey !== binding.ownerIdentityKey) + ) { + // Why: switching the account/host that owns this local pairing cannot + // strand the old cloud credential family even if that account is offline. + this.queueRelayDeviceRevoke(current.relayBinding) + } + return this.deviceRegistry?.setRelayBinding(deviceId, binding) ?? false + } + setMobileRelayPairingProvider(provider: MobileRelayPairingProvider | null): void { this.mobileRelayPairingProvider = provider } @@ -801,7 +844,8 @@ export class OrcaRuntimeRpcServer { sendBinary, undefined, socket.ws, - socket.device.deviceToken + socket.device.deviceToken, + socket ) }, onBinary: (socket, bytes) => this.handleWebSocketBinaryMessage(bytes, socket.ws), @@ -958,7 +1002,8 @@ export class OrcaRuntimeRpcServer { sendBinary: (response: Uint8Array) => boolean | void, wsTransport?: WebSocketTransport, ws?: WebSocket, - authenticatedDeviceToken?: string | null + authenticatedDeviceToken?: string | null, + authenticatedSocket?: AuthenticatedMobileSocket ): Promise { let request: RpcRequest try { @@ -1043,6 +1088,30 @@ export class OrcaRuntimeRpcServer { : reply const connectionId = ws ? this.mobileSocketWiring?.getConnectionId(ws) : undefined + const pairingProvider = this.mobileRelayPairingProvider + const pairingContext = + pairingProvider && authenticatedSocket + ? { + getEndpoints: (params: PairingGetEndpointsParams) => + pairingProvider.getEndpoints( + { + deviceId: authenticatedSocket.device.deviceId, + connectionId: authenticatedSocket.connectionId, + transport: authenticatedSocket.transport + }, + params + ), + provisionRelay: (params: PairingProvisionRelayParams) => + pairingProvider.provisionRelay( + { + deviceId: authenticatedSocket.device.deviceId, + connectionId: authenticatedSocket.connectionId, + transport: authenticatedSocket.transport + }, + params + ) + } + : undefined try { await this.dispatcher.dispatchStreaming(request, replyForRequest, { connectionId, @@ -1050,6 +1119,7 @@ export class OrcaRuntimeRpcServer { // Why: gates the mobile-only payload diet (native-chat char clipping) so // full-screen web/desktop runtime clients aren't truncated. clientKind: device.scope, + pairing: pairingContext, signal: abortRegistration?.signal, sendBinary, registerBinaryStreamHandler: (streamId, handler) => diff --git a/src/shared/mobile-relay-credential-contract.ts b/src/shared/mobile-relay-credential-contract.ts new file mode 100644 index 00000000000..c5c04c632a7 --- /dev/null +++ b/src/shared/mobile-relay-credential-contract.ts @@ -0,0 +1,95 @@ +import { z } from 'zod' + +const OpaqueIdSchema = z.string().min(1).max(128) +const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) +const EpochMsSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) +const RelayHostIdSchema = z.string().regex(/^[A-Za-z0-9_-]{16}$/) + +function isCanonicalHttpsOrigin(value: string): boolean { + try { + const parsed = new URL(value) + return parsed.protocol === 'https:' && value === parsed.origin + } catch { + return false + } +} + +export const PairingProvisionRelayParamsSchema = z + .object({ + reqId: OpaqueIdSchema, + newResumeTokenHash: Base64Url32ByteSchema, + expectedCurrentHash: Base64Url32ByteSchema.optional() + }) + .strict() + +export const PairingGetEndpointsParamsSchema = z + .object({ + installReqId: OpaqueIdSchema.optional(), + resumeConfirmReqId: OpaqueIdSchema.optional() + }) + .strict() + +export const DeviceCredentialInstalledSchema = z + .object({ + v: z.literal(1), + reqId: OpaqueIdSchema, + authorizationMode: z.enum(['relay-basis', 'authenticated-direct']), + currentVersion: z.number().int().positive(), + resumeExpiresAt: EpochMsSchema, + graceExpiresAt: EpochMsSchema.optional() + }) + .strict() + +export const DeviceCredentialInstallStatusResultSchema = z.union([ + z.object({ v: z.literal(1), reqId: OpaqueIdSchema, state: z.literal('not-found') }).strict(), + z + .object({ + v: z.literal(1), + reqId: OpaqueIdSchema, + state: z.literal('committed'), + result: DeviceCredentialInstalledSchema + }) + .strict() +]) + +export const DeviceResumeConfirmedSchema = z + .object({ + v: z.literal(1), + reqId: OpaqueIdSchema, + currentVersion: z.number().int().positive(), + acceptedAs: z.enum(['current', 'grace']), + renewed: z.boolean(), + resumeExpiresAt: EpochMsSchema, + graceExpiresAt: EpochMsSchema.optional() + }) + .strict() + +export const MobileRelayEndpointSchema = z + .object({ + v: z.literal(1), + directorUrl: z.string().refine(isCanonicalHttpsOrigin), + cellUrl: z.string().refine(isCanonicalHttpsOrigin), + assignmentEpoch: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + relayHostId: RelayHostIdSchema, + e2eeFraming: z.literal(2) + }) + .strict() + +export const PairingGetEndpointsResultSchema = z + .object({ + v: z.literal(1), + relay: MobileRelayEndpointSchema.nullable(), + installStatus: DeviceCredentialInstallStatusResultSchema.optional(), + resumeConfirmation: DeviceResumeConfirmedSchema.optional() + }) + .strict() + +export type PairingProvisionRelayParams = z.infer +export type PairingGetEndpointsParams = z.infer +export type DeviceCredentialInstalled = z.infer +export type DeviceCredentialInstallStatusResult = z.infer< + typeof DeviceCredentialInstallStatusResultSchema +> +export type DeviceResumeConfirmed = z.infer +export type MobileRelayEndpoint = z.infer +export type PairingGetEndpointsResult = z.infer