feat(runtime): add relay credential pairing RPCs

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-13 01:02:30 -04:00
co-authored by Orca
parent 4213abcc7d
commit 86f933368e
16 changed files with 808 additions and 13 deletions
+3 -1
View File
@@ -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) {
@@ -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')
})
})
+103 -1
View File
@@ -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<PairingGetEndpointsResult> {
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<DeviceCredentialInstalled> {
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
@@ -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())
})
@@ -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<RelayControlRequests['installCredential']> {
const { reqId, ...request } = input
return this.requests.installCredential(reqId, request, (payload) => this.sendActive(payload))
}
credentialInstallStatus(
relayDeviceId: string,
reqId: string
): ReturnType<RelayControlRequests['credentialInstallStatus']> {
return this.requests.credentialInstallStatus(reqId, relayDeviceId, (payload) =>
this.sendActive(payload)
)
}
confirmResume(
basisConnId: string,
reqId: string
): ReturnType<RelayControlRequests['confirmResume']> {
return this.requests.confirmResume(reqId, basisConnId, (payload) => this.sendActive(payload))
}
closeNow(): void {
this.state = 'closed'
this.requests.rejectAll(new Error('relay_control_closed'))
@@ -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<typeof RelayHostHelloAckMessageSc
export type RelayConnectionOpenMessage = z.infer<typeof RelayConnectionOpenMessageSchema>
export type RelayDrainMessage = z.infer<typeof RelayDrainMessageSchema>
export type RelayInviteCreatedMessage = z.infer<typeof RelayInviteCreatedMessageSchema>
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<string, unknown> | null {
try {
@@ -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<typeof setTimeout>
}
export type DeviceCredentialInstallAuthorization =
| { mode: 'relay-basis'; basisConnId: string }
| { mode: 'authenticated-direct'; directAuthId: string }
export class RelayControlRequests {
private readonly pending = new Map<string, PendingRequest>()
@@ -41,6 +51,50 @@ export class RelayControlRequests {
) as Promise<void>
}
installCredential(
reqId: string,
input: {
relayDeviceId: string
newResumeTokenHash: string
expectedCurrentHash?: string
authorization: DeviceCredentialInstallAuthorization
},
send: (payload: object) => void
): Promise<RelayDeviceCredentialInstalledMessage> {
return this.request(
reqId,
'install',
{ type: 'device-credential-install', v: 1, reqId, ...input },
send
) as Promise<RelayDeviceCredentialInstalledMessage>
}
credentialInstallStatus(
reqId: string,
relayDeviceId: string,
send: (payload: object) => void
): Promise<RelayDeviceCredentialInstallStatusResultMessage> {
return this.request(
reqId,
'install-status',
{ type: 'device-credential-install-status', v: 1, reqId, relayDeviceId },
send
) as Promise<RelayDeviceCredentialInstallStatusResultMessage>
}
confirmResume(
reqId: string,
basisConnId: string,
send: (payload: object) => void
): Promise<RelayDeviceResumeConfirmedMessage> {
return this.request(
reqId,
'confirm',
{ type: 'device-resume-confirm', v: 1, reqId, basisConnId },
send
) as Promise<RelayDeviceResumeConfirmedMessage>
}
resolveMessage(message: Record<string, unknown>): 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
}
@@ -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<RelayControlClient['createInvite']> {
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<DeviceCredentialInstalled> {
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<DeviceCredentialInstallStatusResult> {
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<DeviceResumeConfirmed> {
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
+12
View File
@@ -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<PairingGetEndpointsResult>
provisionRelay(params: PairingProvisionRelayParams): Promise<DeviceCredentialInstalled>
}
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.
+3
View File
@@ -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<ArrayBufferLike>) => 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
})
+3 -1
View File
@@ -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
]
@@ -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<Parameters<RpcDispatcher['dispatchStreaming']>[2]>['pairing']
): Promise<Record<string, unknown>> {
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<string, unknown>),
{ 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()
})
})
+28
View File
@@ -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)
}
})
]
+79 -3
View File
@@ -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()
+73 -3
View File
@@ -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<PairingGetEndpointsResult>
provisionRelay(
context: MobilePairingConnectionContext,
params: PairingProvisionRelayParams
): Promise<DeviceCredentialInstalled>
}
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<ArrayBufferLike>) => boolean | void,
wsTransport?: WebSocketTransport,
ws?: WebSocket,
authenticatedDeviceToken?: string | null
authenticatedDeviceToken?: string | null,
authenticatedSocket?: AuthenticatedMobileSocket
): Promise<void> {
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) =>
@@ -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<typeof PairingProvisionRelayParamsSchema>
export type PairingGetEndpointsParams = z.infer<typeof PairingGetEndpointsParamsSchema>
export type DeviceCredentialInstalled = z.infer<typeof DeviceCredentialInstalledSchema>
export type DeviceCredentialInstallStatusResult = z.infer<
typeof DeviceCredentialInstallStatusResultSchema
>
export type DeviceResumeConfirmed = z.infer<typeof DeviceResumeConfirmedSchema>
export type MobileRelayEndpoint = z.infer<typeof MobileRelayEndpointSchema>
export type PairingGetEndpointsResult = z.infer<typeof PairingGetEndpointsResultSchema>