diff --git a/src/renderer/src/web/preload-api/web-runtime-session.ts b/src/renderer/src/web/preload-api/web-runtime-session.ts index a50f182e421..64f4f28e8e1 100644 --- a/src/renderer/src/web/preload-api/web-runtime-session.ts +++ b/src/renderer/src/web/preload-api/web-runtime-session.ts @@ -91,6 +91,10 @@ export function getClientForEnvironment( if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { throw new Error('runtime_manually_disconnected') } + // Why: a request captured before a re-pair must not reopen a client with the replaced token. + if (!isActivePairing(environment)) { + throw new Error(PAIRING_CHANGED_MESSAGE) + } if ( !webRuntimeState.activeClient || webRuntimeState.activeClientEnvironmentId !== environment.id @@ -173,17 +177,32 @@ export function requireActiveEnvironmentOrNull(): StoredWebRuntimeEnvironment | return webRuntimeState.activeEnvironment } +const PAIRING_CHANGED_MESSAGE = 'The paired Orca server changed while the request was in progress.' + export function assertActiveEnvironment(environmentId: string): void { if (requireActiveEnvironment().id !== environmentId) { - throw new Error('The paired Orca server changed while the request was in progress.') + throw new Error(PAIRING_CHANGED_MESSAGE) } } +function pairingRevisionOf(environment: StoredWebRuntimeEnvironment): number { + return environment.pairingRevision ?? environment.createdAt +} + +/** Re-pairing the same server keeps its id, so only the revision tells the pairings apart. */ +function isActivePairing(environment: StoredWebRuntimeEnvironment): boolean { + const active = webRuntimeState.activeEnvironment + return ( + active?.id === environment.id && pairingRevisionOf(active) === pairingRevisionOf(environment) + ) +} + export function updateEnvironmentFromResponse( environment: StoredWebRuntimeEnvironment, response: RuntimeRpcResponse ): void { - if (webRuntimeState.activeEnvironment?.id !== environment.id) { + const active = webRuntimeState.activeEnvironment + if (!active || !isActivePairing(environment)) { return } const runtimeId = response.ok ? response._meta.runtimeId : (response._meta?.runtimeId ?? null) @@ -194,8 +213,9 @@ export function updateEnvironmentFromResponse( typeof (response.result as { pairedDeviceId?: unknown }).pairedDeviceId === 'string' ? (response.result as { pairedDeviceId: string }).pairedDeviceId : undefined + // Why the active record: the captured one may predate another reply's update of this pairing. webRuntimeState.activeEnvironment = updateStoredEnvironmentRuntimeId( - environment, + active, runtimeId, pairedDeviceId ) diff --git a/src/renderer/src/web/web-pairing.test.ts b/src/renderer/src/web/web-pairing.test.ts index 42721c3c821..367b2af949e 100644 --- a/src/renderer/src/web/web-pairing.test.ts +++ b/src/renderer/src/web/web-pairing.test.ts @@ -49,6 +49,17 @@ describe('web pairing input', () => { expect(parseWebPairingInput(encodeOffer({ endpoint }))).toMatchObject({ endpoint: expected }) }) + it('keeps a readable host descriptor and drops an unreadable one without refusing', () => { + const hostDescriptor = { installationId: '11111111-1111-4111-8111-111111111111' } + expect(parseWebPairingInput(encodeOffer({ hostDescriptor }))).toEqual({ + ...offer, + hostDescriptor + }) + expect(parseWebPairingInput(encodeOffer({ hostDescriptor: { installationId: 7 } }))).toEqual( + offer + ) + }) + it('treats invalid device scope metadata as unknown', () => { expect(parseWebPairingInput(`orca://pair?code=${encodeOffer({ scope: 'admin' })}`)).toEqual( offer diff --git a/src/renderer/src/web/web-pairing.ts b/src/renderer/src/web/web-pairing.ts index fc58bf9ea08..54a0312c702 100644 --- a/src/renderer/src/web/web-pairing.ts +++ b/src/renderer/src/web/web-pairing.ts @@ -1,3 +1,7 @@ +import { + RuntimeHostDescriptorSchema, + type RuntimeHostDescriptor +} from '../../../shared/runtime-host-descriptor' import type { DeviceScope } from '../../../shared/runtime-types' const PAIRING_OFFER_VERSION = 2 @@ -9,6 +13,7 @@ export type WebPairingOffer = { publicKeyB64: string pairedDeviceId?: string scope?: DeviceScope + hostDescriptor?: RuntimeHostDescriptor } export type WebPairingStartupDecision = @@ -104,13 +109,16 @@ function decodePairingPayload(base64url: string): WebPairingOffer | null { typeof parsed.pairedDeviceId === 'string' && parsed.pairedDeviceId.length > 0 ? parsed.pairedDeviceId : null + // Why safeParse: a descriptor this build cannot read is dropped, never a reason to refuse pairing. + const hostDescriptor = RuntimeHostDescriptorSchema.safeParse(parsed.hostDescriptor).data return { v: PAIRING_OFFER_VERSION, endpoint: normalizeWebSocketEndpoint(parsed.endpoint), deviceToken: parsed.deviceToken, publicKeyB64: parsed.publicKeyB64, ...(pairedDeviceId ? { pairedDeviceId } : {}), - ...(scope ? { scope } : {}) + ...(scope ? { scope } : {}), + ...(hostDescriptor ? { hostDescriptor } : {}) } } diff --git a/src/renderer/src/web/web-runtime-environment-repair.test.ts b/src/renderer/src/web/web-runtime-environment-repair.test.ts new file mode 100644 index 00000000000..abcbd68d5c5 --- /dev/null +++ b/src/renderer/src/web/web-runtime-environment-repair.test.ts @@ -0,0 +1,161 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import { + encodePairingCode, + installBrowserGlobals, + writeStoredRuntimeEnvironment +} from './web-preload-api-test-harness' + +const ENVIRONMENT_KEY = 'orca.web.runtimeEnvironment.v1' +const INSTALL_A = '11111111-1111-4111-8111-111111111111' +const INSTALL_B = '22222222-2222-4222-8222-222222222222' + +function readStored(storage: Storage): Record { + const stored: unknown = JSON.parse(storage.getItem(ENVIRONMENT_KEY) ?? '{}') + return typeof stored === 'object' && stored !== null + ? Object.fromEntries(Object.entries(stored)) + : {} +} + +async function installWithStoredServer( + pin?: Record +): Promise> { + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + if (pin) { + globals.storage.setItem( + ENVIRONMENT_KEY, + JSON.stringify({ ...readStored(globals.storage), hostDescriptor: pin }) + ) + } + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + return globals +} + +describe('web re-pairing the same server', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.doUnmock('./web-runtime-client') + }) + + it('never lets a reply from the replaced pairing overwrite the new one', async () => { + const tokens: string[] = [] + const pending: { answer?: () => void } = {} + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + constructor(offer: { deviceToken: string }) { + tokens.push(offer.deviceToken) + } + + call(method: string): Promise> { + const reply = { id: method, ok: true as const, result: {}, _meta: { runtimeId: 'r-1' } } + return new Promise((resolve) => { + pending.answer = () => resolve(reply) + }) + } + + close(): void {} + } + })) + const globals = await installWithStoredServer() + const staleCall = globals.window.api.runtimeEnvironments.call({ + selector: 'web-server-a', + method: 'repos.list' + }) + await vi.waitFor(() => expect(pending.answer).toBeDefined()) + const answerStale = pending.answer + + await globals.window.api.runtimeEnvironments.addFromPairingCode({ + name: 'Server A', + pairingCode: encodePairingCode({ publicKeyB64: 'public-key', deviceToken: 'fresh-token' }) + }) + answerStale?.() + await staleCall + + expect(readStored(globals.storage)).toMatchObject({ + id: 'web-server-a', + endpoints: [{ deviceToken: 'fresh-token' }] + }) + void globals.window.api.runtimeEnvironments.call({ + selector: 'web-server-a', + method: 'repos.list' + }) + await vi.waitFor(() => expect(tokens).toEqual(['token', 'fresh-token'])) + }) + + it('keeps the execution-host id and refreshes the credentials (#11574)', async () => { + const globals = await installWithStoredServer() + + const paired = await globals.window.api.runtimeEnvironments.addFromPairingCode({ + name: 'Server A again', + pairingCode: encodePairingCode({ + publicKeyB64: 'public-key', + deviceToken: 'fresh-token', + hostDescriptor: { installationId: INSTALL_A } + }) + }) + + expect(paired.environment).toMatchObject({ id: 'web-server-a', createdAt: 1 }) + expect(paired.environment.pairingRevision).toBeGreaterThan(1) + expect(paired.environment).not.toHaveProperty('hostDescriptor') + expect(readStored(globals.storage)).toMatchObject({ + id: 'web-server-a', + hostDescriptor: { installationId: INSTALL_A }, + endpoints: [{ id: 'ws-web-server-a', deviceToken: 'fresh-token' }] + }) + expect(readStored(globals.storage)).not.toHaveProperty('compatibleEnvironmentIds') + }) + + it('mints a new id for a same-key server whose pinned installation changed', async () => { + const globals = await installWithStoredServer({ installationId: INSTALL_A }) + + const paired = await globals.window.api.runtimeEnvironments.addFromPairingCode({ + name: 'Clone', + pairingCode: encodePairingCode({ + publicKeyB64: 'public-key', + hostDescriptor: { installationId: INSTALL_B } + }) + }) + + expect(paired.environment.id).not.toBe('web-server-a') + expect(readStored(globals.storage)).toMatchObject({ + hostDescriptor: { installationId: INSTALL_B } + }) + }) + + it('keeps the old pin when a downgraded server sends no descriptor', async () => { + const globals = await installWithStoredServer({ installationId: INSTALL_A }) + + const paired = await globals.window.api.runtimeEnvironments.addFromPairingCode({ + name: 'Server A', + pairingCode: encodePairingCode({ publicKeyB64: 'public-key' }) + }) + + expect(paired.environment.id).toBe('web-server-a') + expect(readStored(globals.storage)).toMatchObject({ + hostDescriptor: { installationId: INSTALL_A } + }) + }) + + it('drops a malformed pinned descriptor instead of trusting it', async () => { + const globals = await installWithStoredServer({ installationId: 'not-a-uuid' }) + + const paired = await globals.window.api.runtimeEnvironments.addFromPairingCode({ + name: 'Server A', + pairingCode: encodePairingCode({ + publicKeyB64: 'public-key', + hostDescriptor: { installationId: INSTALL_B } + }) + }) + + expect(paired.environment.id).toBe('web-server-a') + expect(readStored(globals.storage)).toMatchObject({ + hostDescriptor: { installationId: INSTALL_B } + }) + }) +}) diff --git a/src/renderer/src/web/web-runtime-environment.ts b/src/renderer/src/web/web-runtime-environment.ts index 6093a88aed3..1dcc123c00b 100644 --- a/src/renderer/src/web/web-runtime-environment.ts +++ b/src/renderer/src/web/web-runtime-environment.ts @@ -1,10 +1,18 @@ import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments' +import { + RuntimeHostDescriptorSchema, + type RuntimeHostDescriptor +} from '../../../shared/runtime-host-descriptor' +import { classifyRuntimeHostRePair } from '../../../shared/runtime-host-pairing-identity' import type { WebPairingOffer } from './web-pairing' import { createBrowserUuid } from '@/lib/browser-uuid' import { translate } from '@/i18n/i18n' export type StoredWebRuntimeEnvironment = Omit & { + /** Ids minted by re-pairs before re-pairing kept the id; read-only, never extended. */ compatibleEnvironmentIds?: string[] + /** The server's descriptor pinned at pairing; see classifyRuntimeHostRePair. */ + hostDescriptor?: RuntimeHostDescriptor endpoints: { id: string kind: 'websocket' @@ -41,15 +49,18 @@ export function readStoredWebRuntimeEnvironment(): StoredWebRuntimeEnvironment | typeof parsed.pairedDeviceId === 'string' && parsed.pairedDeviceId.trim().length > 0 ? parsed.pairedDeviceId.trim() : null + const hostDescriptor = RuntimeHostDescriptorSchema.safeParse(parsed.hostDescriptor).data const { compatibleEnvironmentIds: _unvalidatedIds, pairedDeviceId: _unvalidatedDeviceId, + hostDescriptor: _unvalidatedDescriptor, ...environment } = parsed return { ...environment, ...(pairedDeviceId ? { pairedDeviceId } : {}), - ...(compatibleEnvironmentIds.length > 0 ? { compatibleEnvironmentIds } : {}) + ...(compatibleEnvironmentIds.length > 0 ? { compatibleEnvironmentIds } : {}), + ...(hostDescriptor ? { hostDescriptor } : {}) } } catch { return null @@ -70,19 +81,37 @@ export function createStoredWebRuntimeEnvironment(args: { previousEnvironment?: StoredWebRuntimeEnvironment | null connectionDependency?: 'ssh-tunnel' }): StoredWebRuntimeEnvironment { - const id = `web-${createBrowserUuid()}` + const previous = args.previousEnvironment + const match = previous + ? classifyRuntimeHostRePair( + { + publicKeys: previous.endpoints.map((endpoint) => endpoint.publicKeyB64), + pin: previous.hostDescriptor + }, + args.offer + ) + : { kind: 'different-host' as const } + // Why keep the id: it is this server's execution-host identity; a new one strands its sessions (#11574). + const sameHost = match.kind === 'same-host' ? previous : null + const id = sameHost?.id ?? `web-${createBrowserUuid()}` const now = Date.now() - const compatibleEnvironmentIds = getCompatibleEnvironmentIds(args.previousEnvironment, args.offer) + const pin = match.kind === 'same-host' ? match.pin : args.offer.hostDescriptor + const compatibleEnvironmentIds = sameHost?.compatibleEnvironmentIds ?? [] return { id, name: args.name.trim() || 'Orca Server', - createdAt: now, + createdAt: sameHost?.createdAt ?? now, updatedAt: now, + // Why bump: request fences compare revisions, and the device token they captured is gone. + ...(sameHost + ? { pairingRevision: Math.max(now, (sameHost.pairingRevision ?? sameHost.createdAt) + 1) } + : {}), lastUsedAt: null, runtimeId: null, ...(args.offer.pairedDeviceId ? { pairedDeviceId: args.offer.pairedDeviceId } : {}), ...(args.connectionDependency ? { connectionDependency: args.connectionDependency } : {}), ...(compatibleEnvironmentIds.length > 0 ? { compatibleEnvironmentIds } : {}), + ...(pin ? { hostDescriptor: pin } : {}), preferredEndpointId: `ws-${id}`, endpoints: [ { @@ -97,20 +126,14 @@ export function createStoredWebRuntimeEnvironment(args: { } } -function getCompatibleEnvironmentIds( - previous: StoredWebRuntimeEnvironment | null | undefined, - offer: WebPairingOffer -): string[] { - if (!previous?.endpoints.some((endpoint) => endpoint.publicKeyB64 === offer.publicKeyB64)) { - return [] - } - return [...new Set([...(previous.compatibleEnvironmentIds ?? []), previous.id])] -} - export function redactStoredWebRuntimeEnvironment( environment: StoredWebRuntimeEnvironment ): PublicKnownRuntimeEnvironment { - const { compatibleEnvironmentIds: _compatibleEnvironmentIds, ...publicEnvironment } = environment + const { + compatibleEnvironmentIds: _compatibleEnvironmentIds, + hostDescriptor: _hostDescriptor, + ...publicEnvironment + } = environment return { ...publicEnvironment, endpoints: environment.endpoints.map( diff --git a/src/shared/runtime-host-pairing-identity.test.ts b/src/shared/runtime-host-pairing-identity.test.ts new file mode 100644 index 00000000000..d91c289a331 --- /dev/null +++ b/src/shared/runtime-host-pairing-identity.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { classifyRuntimeHostRePair } from './runtime-host-pairing-identity' + +const INSTALL_A = '11111111-1111-4111-8111-111111111111' +const INSTALL_B = '22222222-2222-4222-8222-222222222222' +const BIND_1 = 'a'.repeat(43) +const BIND_2 = 'b'.repeat(43) + +describe('classifyRuntimeHostRePair', () => { + it('treats a different host key as a different server, whatever the descriptor says', () => { + expect( + classifyRuntimeHostRePair( + { publicKeys: ['key-a'], pin: { installationId: INSTALL_A } }, + { publicKeyB64: 'key-b', hostDescriptor: { installationId: INSTALL_A } } + ) + ).toEqual({ kind: 'different-host' }) + }) + + it('keeps an unpinned server matched by key and pins its first descriptor', () => { + expect(classifyRuntimeHostRePair({ publicKeys: ['key-a'] }, { publicKeyB64: 'key-a' })).toEqual( + { kind: 'same-host', pin: undefined, descriptor: 'unpinned' } + ) + expect( + classifyRuntimeHostRePair( + { publicKeys: ['key-a'] }, + { publicKeyB64: 'key-a', hostDescriptor: { installationId: INSTALL_A } } + ) + ).toEqual({ kind: 'same-host', pin: { installationId: INSTALL_A }, descriptor: 'pinned' }) + }) + + it('separates a same-key server whose installation changed', () => { + expect( + classifyRuntimeHostRePair( + { publicKeys: ['key-a'], pin: { installationId: INSTALL_A } }, + { publicKeyB64: 'key-a', hostDescriptor: { installationId: INSTALL_B } } + ) + ).toEqual({ kind: 'different-host' }) + }) + + it('keeps a moved or reinstalled server and re-pins its new binding', () => { + const next = { installationId: INSTALL_A, machineBinding: BIND_2 } + expect( + classifyRuntimeHostRePair( + { publicKeys: ['key-a'], pin: { installationId: INSTALL_A, machineBinding: BIND_1 } }, + { publicKeyB64: 'key-a', hostDescriptor: next } + ) + ).toEqual({ kind: 'same-host', pin: next, descriptor: 'moved' }) + }) + + it('never learns from a downgraded server that stopped sending a descriptor', () => { + const pin = { installationId: INSTALL_A, machineBinding: BIND_1 } + expect( + classifyRuntimeHostRePair({ publicKeys: ['key-a'], pin }, { publicKeyB64: 'key-a' }) + ).toEqual({ kind: 'same-host', pin, descriptor: 'unverifiable' }) + }) +}) diff --git a/src/shared/runtime-host-pairing-identity.ts b/src/shared/runtime-host-pairing-identity.ts new file mode 100644 index 00000000000..bd0075f7ac7 --- /dev/null +++ b/src/shared/runtime-host-pairing-identity.ts @@ -0,0 +1,47 @@ +import type { RuntimeHostDescriptor } from './runtime-host-descriptor' + +/** + * How a new pairing offer relates to a server this client already pairs with. + * `pin` is the descriptor to keep pinned. `descriptor`: + * - `pinned`: the offer's descriptor matches (or is now pinned for the first time). + * - `moved`: same installation, different machine binding: the profile moved or was reinstalled. + * - `unverifiable`: a pinned server now sends no descriptor (downgraded); the old pin is kept. + * - `unpinned`: neither side has a descriptor yet. + */ +export type RuntimeHostRePairMatch = + | { + kind: 'same-host' + pin: RuntimeHostDescriptor | undefined + descriptor: 'pinned' | 'moved' | 'unverifiable' | 'unpinned' + } + | { kind: 'different-host' } + +/** + * The host key, which the pairing handshake proves, is the trust root: a different key is a + * different server. The descriptor only separates a reinstall or a deliberate clone that kept the key. + */ +export function classifyRuntimeHostRePair( + previous: { publicKeys: readonly string[]; pin?: RuntimeHostDescriptor }, + offer: { publicKeyB64: string; hostDescriptor?: RuntimeHostDescriptor } +): RuntimeHostRePairMatch { + if (!previous.publicKeys.includes(offer.publicKeyB64)) { + return { kind: 'different-host' } + } + const { pin } = previous + const next = offer.hostDescriptor + if (!next) { + // Why keep the old pin: an older server must never let this client learn a replacement. + return { kind: 'same-host', pin, descriptor: pin ? 'unverifiable' : 'unpinned' } + } + if (!pin) { + return { kind: 'same-host', pin: next, descriptor: 'pinned' } + } + if (pin.installationId !== next.installationId) { + return { kind: 'different-host' } + } + const moved = + pin.machineBinding !== undefined && + next.machineBinding !== undefined && + pin.machineBinding !== next.machineBinding + return { kind: 'same-host', pin: next, descriptor: moved ? 'moved' : 'pinned' } +}