diff --git a/src/main/runtime/device-entry.ts b/src/main/runtime/device-entry.ts index 41804c1163d..ac05b74f120 100644 --- a/src/main/runtime/device-entry.ts +++ b/src/main/runtime/device-entry.ts @@ -35,6 +35,8 @@ export type DeviceEntry = { devicePublicKeyB64?: string // The token row this installation migrated from; that row stays for its other holders. forkedFrom?: string + // Session-only browser key (memory only); the row is dropped on host restart and after idling. + ephemeral?: true } /** A row that authenticates by bearer token: every pairing offer and delegated child. */ diff --git a/src/main/runtime/device-key-fork.ts b/src/main/runtime/device-key-fork.ts index 00099a31bdb..cfa95d464f0 100644 --- a/src/main/runtime/device-key-fork.ts +++ b/src/main/runtime/device-key-fork.ts @@ -29,7 +29,8 @@ export function planDeviceKeyFork( legacyDeviceId: string, devicePublicKeyB64: string, label: string, - now: number + now: number, + options: { ephemeral?: boolean } = {} ): DeviceKeyFork { const legacy = devices.find((device) => device.deviceId === legacyDeviceId) if (!legacy) { @@ -70,6 +71,7 @@ export function planDeviceKeyFork( credentialKind: 'device-key', devicePublicKeyB64, forkedFrom: legacy.deviceId, + ...(options.ephemeral ? { ephemeral: true as const } : {}), // Why: orchestration work owned under the token keeps its owner after migration. callerFingerprint: devicePrincipalOf(legacy).callerFingerprint, pairedAt: now, diff --git a/src/main/runtime/device-registry-file-read.ts b/src/main/runtime/device-registry-file-read.ts new file mode 100644 index 00000000000..30bab126e83 --- /dev/null +++ b/src/main/runtime/device-registry-file-read.ts @@ -0,0 +1,41 @@ +import { readFileSync } from 'node:fs' +import { hardenExistingSecureFile, isUnreadableError } from '../../shared/secure-file' +import { corruptRegistryCopyExists, isMissingFileError } from './device-registry-corruption' +import type { DeviceEntry } from './device-entry' +import { normalizeLoadedDeviceRow } from './device-registry-row-validation' + +export type DeviceRegistryFileRead = { + devices: DeviceEntry[] + /** The registry exists but could not be read, which makes `devices` a lie to save from. */ + unreadable: boolean + /** A parse failure, now or in an earlier run, means an unlisted device may still exist. */ + doubtful: boolean + /** A corrupt file must be set aside before the first rewrite. */ + corruptFilePending: boolean +} + +export function readDeviceRegistryFile(registryPath: string): DeviceRegistryFileRead { + // Why: a set-aside corrupt copy keeps revocation verdicts off until the owner removes it, even + // when the rewrite after quarantine never landed and the registry file itself is missing. + const doubtful = corruptRegistryCopyExists(registryPath) + try { + hardenExistingSecureFile(registryPath) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: normalizeLoadedDeviceRow re-validates every field; a non-array throws below and reads as corrupt. + const parsed = JSON.parse(readFileSync(registryPath, 'utf-8')) as DeviceEntry[] + return { + devices: parsed.map(normalizeLoadedDeviceRow), + unreadable: false, + doubtful, + corruptFilePending: false + } + } catch (error) { + if (isMissingFileError(error)) { + return { devices: [], unreadable: false, doubtful, corruptFilePending: false } + } + // "Cannot read" is not "is empty". Saving an empty list over a registry we were merely + // denied would erase every paired device's bearer token, and the write would succeed. + const unreadable = isUnreadableError(error) + // Why: a corrupt file is not evidence any device was revoked; never tell a keyed client so. + return { devices: [], unreadable, doubtful: true, corruptFilePending: !unreadable } + } +} diff --git a/src/main/runtime/device-registry-row-validation.ts b/src/main/runtime/device-registry-row-validation.ts index 591732b98ca..04966feec63 100644 --- a/src/main/runtime/device-registry-row-validation.ts +++ b/src/main/runtime/device-registry-row-validation.ts @@ -76,15 +76,21 @@ export function normalizeLoadedDeviceRow(device: DeviceEntry): DeviceEntry { // Why: a malformed keyed row stays keyed (so it never falls back to a token) but cannot authenticate. function validDeviceKeyFields( device: DeviceEntry -): Pick { +): Pick { if (device.credentialKind !== 'device-key') { - return { credentialKind: undefined, devicePublicKeyB64: undefined, forkedFrom: undefined } + return { + credentialKind: undefined, + devicePublicKeyB64: undefined, + forkedFrom: undefined, + ephemeral: undefined + } } return { credentialKind: 'device-key', devicePublicKeyB64: isDevicePublicKeyB64(device.devicePublicKeyB64) ? device.devicePublicKeyB64 : undefined, - forkedFrom: typeof device.forkedFrom === 'string' ? device.forkedFrom : undefined + forkedFrom: typeof device.forkedFrom === 'string' ? device.forkedFrom : undefined, + ephemeral: device.ephemeral === true ? true : undefined } } diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index 677d5b189b7..cf312bc79d7 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -2,24 +2,16 @@ // (mobile) connections. Each paired device gets its own revocable token so // compromising one device doesn't expose others. The registry is a simple // JSON file with hardened permissions matching the runtime metadata pattern. -import { readFileSync } from 'node:fs' import { join } from 'node:path' -import { - hardenExistingSecureFile, - isUnreadableError, - writeDurableSecureJsonFile, - writeSecureJsonFile -} from '../../shared/secure-file' +import { writeDurableSecureJsonFile, writeSecureJsonFile } from '../../shared/secure-file' import type { DeviceScope } from '../../shared/runtime-types' import { removeStaleDurableWriteTempFiles } from '../durable-file-write' import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files' import { mintDeviceCredential } from './device-credential' import { planDeviceKeyFork } from './device-key-fork' -import { - corruptRegistryCopyExists, - isMissingFileError, - quarantineCorruptRegistry -} from './device-registry-corruption' +import { planIdleEphemeralPrune, withoutEphemeralRows } from './ephemeral-device-rows' +import { quarantineCorruptRegistry } from './device-registry-corruption' +import { readDeviceRegistryFile } from './device-registry-file-read' import type { RelayDeviceBinding } from './relay/relay-revoke-outbox' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' @@ -29,7 +21,7 @@ import { type DelegatedMobileDeviceEntry } from './delegated-mobile-devices' import type { RuntimeDeviceGrant } from './rpc/rpc-method-permission' -import { normalizeLoadedDeviceRow, validGrants } from './device-registry-row-validation' +import { validGrants } from './device-registry-row-validation' import { isPendingTokenRow, mobilePairingConnectionModeOf, @@ -247,13 +239,19 @@ export class DeviceRegistry { } /** Forks `legacyDeviceId` into a keyed row; durable before it returns (security transition). */ - forkDeviceKey(legacyDeviceId: string, devicePublicKeyB64: string, label: string): DeviceEntry { + forkDeviceKey( + legacyDeviceId: string, + devicePublicKeyB64: string, + label: string, + options: { ephemeral?: boolean } = {} + ): DeviceEntry { const fork = planDeviceKeyFork( this.devices, legacyDeviceId, devicePublicKeyB64, label, - Date.now() + Date.now(), + options ) if (fork.created) { this.save(fork.nextDevices, { durable: true }) @@ -262,6 +260,16 @@ export class DeviceRegistry { return fork.entry } + /** Drops idle session-only rows; durable, since a removed row is a revocation. */ + pruneIdleEphemeralDevices(now: number, isLive: (deviceId: string) => boolean): string[] { + const plan = planIdleEphemeralPrune(this.devices, now, isLive) + if (plan.removed.length > 0) { + this.save(plan.nextDevices, { durable: true }) + this.devices = plan.nextDevices + } + return plan.removed + } + /** The row an authenticated socket is still entitled to, or null once revoked or re-credentialed. */ resolveAuthenticatedPrincipal(principal: AuthenticatedPrincipalBinding): DeviceEntry | null { const device = this.getDevice(principal.deviceId) @@ -306,11 +314,15 @@ export class DeviceRegistry { return } this.cancelPendingLastSeenFlush() + // Why: matches the async hardening path — a failed bookkeeping write must not take down the runtime. + this.saveOrLog({}, 'persist device lastSeen') + } + + private saveOrLog(options: { durable?: boolean }, action: string): void { try { - this.save(this.devices) + this.save(this.devices, options) } catch (error) { - // Why: matches the async hardening path — a failed bookkeeping write must not take down the runtime. - console.error('[mobile] Failed to persist device lastSeen:', error) + console.error(`[runtime] Failed to ${action}:`, error) } } @@ -322,25 +334,14 @@ export class DeviceRegistry { } private load(): void { - // Why: a set-aside corrupt copy keeps revocation verdicts off until the owner removes it, even - // when the rewrite after quarantine never landed and the registry file itself is missing. - this.registryDoubtful = corruptRegistryCopyExists(this.registryPath) - try { - hardenExistingSecureFile(this.registryPath) - const parsed = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as DeviceEntry[] - this.devices = parsed.map(normalizeLoadedDeviceRow) - this.registryUnreadable = false - } catch (error) { - this.devices = [] - if (isMissingFileError(error)) { - return - } - // "Cannot read" is not "is empty". Saving an empty list over a registry we were merely - // denied would erase every paired device's bearer token, and the write would succeed. - this.registryUnreadable = isUnreadableError(error) - // Why: a corrupt file is not evidence any device was revoked; never tell a keyed client so. - this.registryDoubtful = true - this.corruptFilePending = !this.registryUnreadable + const read = readDeviceRegistryFile(this.registryPath) + this.registryUnreadable = read.unreadable + this.registryDoubtful = read.doubtful + this.corruptFilePending = read.corruptFilePending + this.devices = withoutEphemeralRows(read.devices) + // Why: a session-only browser's key died with the last host process; its row must not outlive it. + if (this.devices.length !== read.devices.length) { + this.saveOrLog({ durable: true }, 'drop session-only devices after restart') } } diff --git a/src/main/runtime/ephemeral-device-rows.test.ts b/src/main/runtime/ephemeral-device-rows.test.ts new file mode 100644 index 00000000000..d213a73aba0 --- /dev/null +++ b/src/main/runtime/ephemeral-device-rows.test.ts @@ -0,0 +1,107 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { planDeviceKeyFork } from './device-key-fork' +import { DeviceRegistry, type DeviceEntry } from './device-registry' +import { EPHEMERAL_DEVICE_IDLE_MS, planIdleEphemeralPrune } from './ephemeral-device-rows' +import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files' + +const KEY_A = Buffer.alloc(32, 1).toString('base64') +const dirs: string[] = [] + +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function tokenRow(overrides: Partial = {}): DeviceEntry { + return { + deviceId: 'legacy', + name: 'Browser', + token: 'token', + scope: 'runtime', + pairedAt: 1, + lastSeenAt: 2, + ...overrides + } +} + +function keyedRow(overrides: Partial = {}): DeviceEntry { + return { + deviceId: 'keyed', + name: 'Browser', + scope: 'runtime', + pairedAt: 1, + lastSeenAt: 1_000, + credentialKind: 'device-key', + devicePublicKeyB64: KEY_A, + forkedFrom: 'legacy', + ephemeral: true, + ...overrides + } +} + +function registryWith(rows: DeviceEntry[]): { dir: string; registry: DeviceRegistry } { + const dir = mkdtempSync(join(tmpdir(), 'orca-ephemeral-rows-')) + dirs.push(dir) + writeFileSync(join(dir, DEVICE_REGISTRY_FILENAME), JSON.stringify(rows)) + return { dir, registry: new DeviceRegistry(dir) } +} + +describe('ephemeral device rows', () => { + it('marks a session-only fork ephemeral and keeps persistent forks unmarked', () => { + const session = planDeviceKeyFork([tokenRow()], 'legacy', KEY_A, 'Tab', 9, { + ephemeral: true + }) + expect(session.entry.ephemeral).toBe(true) + expect(planDeviceKeyFork([tokenRow()], 'legacy', KEY_A, 'Tab', 9).entry.ephemeral).toBe( + undefined + ) + }) + + it('drops ephemeral rows when the host restarts and persists that', () => { + const { dir, registry } = registryWith([tokenRow(), keyedRow()]) + expect(registry.getDevice('keyed')).toBeNull() + expect(registry.lookupDeviceKey('keyed')).toEqual({ kind: 'unknown' }) + expect(registry.getDevice('legacy')).not.toBeNull() + const onDisk = JSON.parse(readFileSync(join(dir, DEVICE_REGISTRY_FILENAME), 'utf8')) + expect(onDisk.map((row: DeviceEntry) => row.deviceId)).toEqual(['legacy']) + }) + + it('ignores an ephemeral flag on a token row', () => { + const { registry } = registryWith([tokenRow({ ephemeral: true })]) + expect(registry.getDevice('legacy')?.ephemeral).toBeUndefined() + }) + + it('prunes only idle, disconnected ephemeral rows', () => { + const rows = [ + tokenRow(), + keyedRow(), + keyedRow({ deviceId: 'live', lastSeenAt: 1_000 }), + keyedRow({ deviceId: 'recent', lastSeenAt: 1_000 + EPHEMERAL_DEVICE_IDLE_MS }), + keyedRow({ deviceId: 'persistent', ephemeral: undefined }) + ] + const now = 2_000 + EPHEMERAL_DEVICE_IDLE_MS + const plan = planIdleEphemeralPrune(rows, now, (deviceId) => deviceId === 'live') + expect(plan.removed).toEqual(['keyed']) + expect(plan.nextDevices.map((row) => row.deviceId)).toEqual([ + 'legacy', + 'live', + 'recent', + 'persistent' + ]) + }) + + it('removes idle rows from the registry durably', () => { + const { registry } = registryWith([tokenRow()]) + const fork = registry.forkDeviceKey('legacy', KEY_A, 'Tab', { ephemeral: true }) + const removed = registry.pruneIdleEphemeralDevices( + fork.lastSeenAt + EPHEMERAL_DEVICE_IDLE_MS + 1, + () => false + ) + expect(removed).toEqual([fork.deviceId]) + expect(registry.lookupDeviceKey(fork.deviceId)).toEqual({ kind: 'unknown' }) + }) +}) diff --git a/src/main/runtime/ephemeral-device-rows.ts b/src/main/runtime/ephemeral-device-rows.ts new file mode 100644 index 00000000000..4249a44493c --- /dev/null +++ b/src/main/runtime/ephemeral-device-rows.ts @@ -0,0 +1,30 @@ +import type { DeviceEntry } from './device-entry' + +// Why: a session-only browser keeps its key in memory; an idle row is an abandoned tab, not a device. +export const EPHEMERAL_DEVICE_IDLE_MS = 12 * 60 * 60 * 1000 +export const EPHEMERAL_DEVICE_SWEEP_INTERVAL_MS = 5 * 60 * 1000 + +/** Rows that survive a host restart: a session-only browser's key did not. */ +export function withoutEphemeralRows(devices: readonly DeviceEntry[]): DeviceEntry[] { + return devices.filter((device) => device.ephemeral !== true) +} + +/** Idle is measured from the last connect or disconnect; a row with a live socket never expires. */ +export function planIdleEphemeralPrune( + devices: readonly DeviceEntry[], + now: number, + isLive: (deviceId: string) => boolean +): { nextDevices: DeviceEntry[]; removed: string[] } { + const removed = devices + .filter( + (device) => + device.ephemeral === true && + now - device.lastSeenAt > EPHEMERAL_DEVICE_IDLE_MS && + !isLive(device.deviceId) + ) + .map((device) => device.deviceId) + return { + nextDevices: devices.filter((device) => !removed.includes(device.deviceId)), + removed + } +} diff --git a/src/main/runtime/rpc/e2ee-channel.ts b/src/main/runtime/rpc/e2ee-channel.ts index b7c3506d1f9..4bf7c1d0e7d 100644 --- a/src/main/runtime/rpc/e2ee-channel.ts +++ b/src/main/runtime/rpc/e2ee-channel.ts @@ -11,7 +11,8 @@ import { authenticateMobileE2EE, decodeMobileE2EEPublicKey, describeMobileE2EEAuthFailure, - type DeviceKeyResolution + type DeviceKeyResolution, + type DeviceTokenRefusal } from './mobile-e2ee-auth-validation' import { isMobileE2EEOutboundItemWithinLimit } from './mobile-e2ee-outbound-admission' import { parseRemoteRuntimeJsonText } from '../../../shared/remote-runtime-request-frames' @@ -36,7 +37,7 @@ const MAX_CONSECUTIVE_DECRYPT_FAILURES = 5 export type E2EEChannelOptions = { serverSecretKey: Uint8Array - resolveAuthenticatedDevice: (token: string) => E2EEAuthenticatedDevice | null + resolveAuthenticatedDevice: (token: string) => E2EEAuthenticatedDevice | DeviceTokenRefusal | null // Absent: key auth is refused as bad_auth, the same as before device keys existed. resolveAuthenticatedDeviceKey?: (deviceId: string) => DeviceKeyResolution onReady: (channel: E2EEChannel, device: E2EEAuthenticatedDevice) => void diff --git a/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts b/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts index 18e15fa6281..cb2c4b2bb28 100644 --- a/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts +++ b/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts @@ -2,6 +2,7 @@ import type { DesktopMobileE2EEV2Session } from './mobile-e2ee-v2-desktop-sessio import { publicKeyFromBase64 } from './e2ee-crypto' import { parseRemoteRuntimeJsonText } from '../../../shared/remote-runtime-request-frames' import { isDeviceProofB64, verifyDeviceProof } from '../../../shared/device-key-proof' +import { INSECURE_WEB_ORIGIN_AUTH_CODE } from '../../../shared/web-origin-security' export type ClassifiedE2EEAuth = | { kind: 'token'; deviceToken: string; clientCapabilities?: unknown } @@ -80,6 +81,10 @@ export type MobileE2EEAuthFailureCode = | 'device_revoked' | 'registry_unavailable' | 'bad_proof' + | typeof INSECURE_WEB_ORIGIN_AUTH_CODE + +// The token matched, but this transport may not claim it (e.g. a first pairing from an insecure page). +export type DeviceTokenRefusal = { refused: typeof INSECURE_WEB_ORIGIN_AUTH_CODE } export type DeviceKeyResolution = | { kind: 'found'; device: TDevice; devicePublicKeyB64: string } @@ -92,7 +97,7 @@ export type DeviceKeyResolution = export function authenticateMobileE2EE(args: { plaintext: string v2Session: DesktopMobileE2EEV2Session | null - resolveDevice: (token: string) => TDevice | null + resolveDevice: (token: string) => TDevice | DeviceTokenRefusal | null // Absent on a host that does not accept device keys: key auth is then bad_auth, as before. resolveDeviceKey?: (deviceId: string) => DeviceKeyResolution }): @@ -101,6 +106,9 @@ export function authenticateMobileE2EE const auth = classifyE2EEAuth(args.plaintext, args.v2Session) if (auth.kind === 'token') { const device = args.resolveDevice(auth.deviceToken) + if (device && 'refused' in device) { + return { ok: false, code: device.refused } + } // Why: a keyed row has no token, so presenting one can never match it. return device?.deviceToken === auth.deviceToken ? { ok: true, device, clientCapabilities: auth.clientCapabilities } @@ -144,7 +152,9 @@ export function describeMobileE2EEAuthFailure(failure: { ? 'Unauthorized' : failure.code === 'registry_unavailable' ? 'Device registry unavailable' - : 'Invalid e2ee_auth' + : failure.code === INSECURE_WEB_ORIGIN_AUTH_CODE + ? 'Insecure web origin' + : 'Invalid e2ee_auth' return { sealedError: { type: 'e2ee_error', diff --git a/src/main/runtime/rpc/mobile-socket-wiring.ts b/src/main/runtime/rpc/mobile-socket-wiring.ts index 009a35c9363..4305a73c89b 100644 --- a/src/main/runtime/rpc/mobile-socket-wiring.ts +++ b/src/main/runtime/rpc/mobile-socket-wiring.ts @@ -1,6 +1,12 @@ import { randomBytes } from 'node:crypto' import type { WebSocket } from 'ws' import type { DeviceEntry, DeviceRegistry } from '../device-registry' +import { isPendingTokenRow } from '../device-entry' +import { EPHEMERAL_DEVICE_SWEEP_INTERVAL_MS } from '../ephemeral-device-rows' +import { + INSECURE_WEB_ORIGIN_AUTH_CODE, + isInsecureRemoteWebOrigin +} from '../../../shared/web-origin-security' import { devicePrincipalOf } from '../device-principal' import type { E2EEKeypair } from '../e2ee-keypair' import { E2EEChannel, type E2EEAuthenticatedDevice } from './e2ee-channel' @@ -11,7 +17,8 @@ import type { RpcBinarySender } from './rpc-binary-sender' type MobileSocketPayload = string | Uint8Array export type MobileSocketTransportMetadata = - | { transport: 'direct' } + // webClient: the Orca web bundle marked its upgrade; webOrigin: that page's origin. + | { transport: 'direct'; webOrigin?: string; webClient?: true } | { transport: 'relay' relayHostId: string @@ -59,6 +66,8 @@ type MobileSocketWiringOptions = { onReady?: (socket: AuthenticatedMobileSocket) => void // Why: stale keys and missing registry entries both fail before RPC can explain the re-pair action. onUnpairedDeviceAuthFailure?: (metadata: MobileSocketTransportMetadata) => void + // Operator opt-in for first pairings from a plain-http page on another machine. + allowInsecureWebPairing?: () => boolean } function toAuthenticatedDevice(device: DeviceEntry): E2EEAuthenticatedDevice { @@ -76,6 +85,8 @@ export class MobileSocketWiring { private readonly onClose: MobileSocketWiringOptions['onClose'] private readonly onReady: MobileSocketWiringOptions['onReady'] private readonly onUnpairedDeviceAuthFailure: MobileSocketWiringOptions['onUnpairedDeviceAuthFailure'] + private readonly allowInsecureWebPairing: () => boolean + private readonly ephemeralSweep: ReturnType private readonly channels = new Map() private readonly connectionIds = new Map() private readonly authenticatedSockets = new Map() @@ -90,6 +101,39 @@ export class MobileSocketWiring { this.onClose = options.onClose this.onReady = options.onReady this.onUnpairedDeviceAuthFailure = options.onUnpairedDeviceAuthFailure + this.allowInsecureWebPairing = options.allowInsecureWebPairing ?? (() => false) + this.ephemeralSweep = setInterval( + () => this.sweepIdleEphemeralDevices(Date.now()), + EPHEMERAL_DEVICE_SWEEP_INTERVAL_MS + ) + // Why: bookkeeping must never hold the process open. + this.ephemeralSweep.unref?.() + } + + dispose(): void { + clearInterval(this.ephemeralSweep) + } + + /** Removes idle session-only browser rows and cuts any socket still bound to them. */ + sweepIdleEphemeralDevices(now: number): string[] { + let removed: string[] = [] + try { + removed = this.deviceRegistry.pruneIdleEphemeralDevices(now, (deviceId) => + this.hasAuthenticatedSocket(deviceId) + ) + } catch (error) { + console.error('[mobile] Failed to prune idle browser-session devices:', error) + } + for (const deviceId of removed) { + this.terminateDeviceConnections(deviceId) + } + return removed + } + + private hasAuthenticatedSocket(deviceId: string): boolean { + return Array.from(this.authenticatedSockets.values()).some( + (socket) => socket.device.deviceId === deviceId + ) } attachTransport( @@ -156,6 +200,16 @@ export class MobileSocketWiring { if (!device) { return null } + if ( + isPendingTokenRow(device) && + metadata.transport === 'direct' && + // Why the marker too: phones send an http Origin as well, and must never be gated. + metadata.webClient === true && + isInsecureRemoteWebOrigin(metadata.webOrigin) && + !this.allowInsecureWebPairing() + ) { + return { refused: INSECURE_WEB_ORIGIN_AUTH_CODE } + } // Why: outer relay authorization cannot choose the local Orca // identity; E2EE must resolve the same device before readiness. if (metadata.transport === 'relay' && metadata.relayDeviceId !== device.deviceId) { @@ -251,6 +305,10 @@ export class MobileSocketWiring { Array.from(this.authenticatedSockets.values()).some( (candidate) => candidate.device.deviceId === socket.device.deviceId ) + // Why: a session-only row idles from its last disconnect, not from when it connected. + if (socket && !hasOtherConnections && socket.device.deviceToken === undefined) { + this.deviceRegistry.updateLastSeenDeferred(socket.device.deviceId) + } this.onClose(socket, hasOtherConnections) } } diff --git a/src/main/runtime/rpc/web-session-pairing.test.ts b/src/main/runtime/rpc/web-session-pairing.test.ts new file mode 100644 index 00000000000..0965e4971f3 --- /dev/null +++ b/src/main/runtime/rpc/web-session-pairing.test.ts @@ -0,0 +1,239 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import nacl from 'tweetnacl' +import type { WebSocket } from 'ws' +import { describe, expect, it, vi } from 'vitest' +import { encodeDeviceProofMessage } from '../../../shared/device-key-proof' +import { DeviceRegistry } from '../device-registry' +import { EPHEMERAL_DEVICE_IDLE_MS } from '../ephemeral-device-rows' +import { SimulatedMobileE2EEV2Peer } from '../relay/simulated-mobile-e2ee-v2-peer' +import { registerSocketDeviceKey } from '../runtime-rpc/runtime-rpc-device-key-registration' +import { generateKeyPair } from './e2ee-crypto' +import { + MobileSocketWiring, + type AuthenticatedMobileSocket, + type MobileSocketTransport, + type MobileSocketTransportMetadata +} from './mobile-socket-wiring' + +class FakeSocket { + readonly OPEN = 1 + readyState = this.OPEN + bufferedAmount = 0 + readonly sent: (string | Buffer)[] = [] + readonly send = vi.fn((data: string | Buffer) => this.sent.push(data)) + readonly close = vi.fn() +} + +class FakeTransport implements MobileSocketTransport { + private messageHandler: Parameters[0] | null = null + readonly setClientId = vi.fn() + readonly terminateClientConnections = vi.fn(() => 0) + onMessage(handler: Parameters[0]): void { + this.messageHandler = handler + } + onConnectionClose(): void {} + receive(ws: FakeSocket, message: string): void { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the wiring only sends, closes and reads OPEN/readyState/bufferedAmount. + this.messageHandler?.(message, vi.fn(), ws as unknown as WebSocket) + } +} + +const desktop = generateKeyPair() +const LAN_PAGE = 'http://192.168.1.20:6768' +const RELAY_HOST_ID = 'AbCdEf0123_-xyZ9' + +// The new web bundle marks its upgrade; phones (React Native) send an http Origin but no marker. +const WEB_CLIENT = { webOrigin: LAN_PAGE, webClient: true as const } + +function harness( + page: { webOrigin?: string; webClient?: true } | undefined, + allowInsecureWebPairing = false, + relay?: (deviceId: string) => MobileSocketTransportMetadata +) { + const registry = new DeviceRegistry(mkdtempSync(join(tmpdir(), 'orca-web-session-'))) + const transport = new FakeTransport() + const ready: AuthenticatedMobileSocket[] = [] + const wiring = new MobileSocketWiring({ + deviceRegistry: registry, + e2eeKeypair: { + publicKey: desktop.publicKey, + secretKey: desktop.secretKey, + publicKeyB64: Buffer.from(desktop.publicKey).toString('base64') + }, + onText: vi.fn(), + onBinary: vi.fn(), + onClose: vi.fn(), + onReady: (socket) => ready.push(socket), + allowInsecureWebPairing: () => allowInsecureWebPairing + }) + let relayDeviceId = '' + wiring.attachTransport(transport, () => + relay ? relay(relayDeviceId) : { transport: 'direct', ...page } + ) + + function connect(auth: (transcriptHashB64: string) => Record) { + const ws = new FakeSocket() + const peer = new SimulatedMobileE2EEV2Peer( + nacl.box.keyPair(), + desktop.publicKey, + relay ? RELAY_HOST_ID : null + ) + transport.receive(ws, JSON.stringify(peer.hello)) + expect(peer.acceptReady(JSON.parse(String(ws.sent[0])))).toBe(true) + const transcriptHashB64 = peer.transcriptHashB64 + const before = ready.length + transport.receive( + ws, + peer.sealText( + JSON.stringify({ type: 'e2ee_auth', v: 2, transcriptHashB64, ...auth(transcriptHashB64) }) + ) + ) + const reply = ws.sent[1] ? JSON.parse(peer.openText(String(ws.sent[1]))!) : null + return { ws, transcriptHashB64, reply, socket: ready.length > before ? ready.at(-1) : null } + } + return { + registry, + wiring, + transport, + connect, + bindRelay: (deviceId: string) => (relayDeviceId = deviceId) + } +} + +function sign(key: nacl.SignKeyPair, purpose: 'auth' | 'register', transcriptHashB64: string) { + const message = encodeDeviceProofMessage(purpose, Buffer.from(transcriptHashB64, 'base64')) + return Buffer.from(nacl.sign.detached(message, key.secretKey)).toString('base64') +} + +describe('pairing from a plain-http page on another machine', () => { + it('refuses a first pairing without operator opt-in, with a sealed reason', () => { + const { registry, connect } = harness(WEB_CLIENT) + const pending = registry.getOrCreatePendingDevice('Browser', 'runtime') + const attempt = connect(() => ({ deviceToken: pending.token })) + expect(attempt.reply).toEqual({ type: 'e2ee_error', error: { code: 'insecure_origin' } }) + expect(attempt.socket).toBeNull() + expect(attempt.ws.close).toHaveBeenCalledWith(4001, 'Insecure web origin') + expect(registry.getDevice(pending.deviceId)?.lastSeenAt).toBe(0) + }) + + it('pairs when the operator opted in', () => { + const { registry, connect } = harness(WEB_CLIENT, true) + const pending = registry.getOrCreatePendingDevice('Browser', 'runtime') + expect(connect(() => ({ deviceToken: pending.token })).reply).toMatchObject({ + type: 'e2ee_authenticated' + }) + }) + + it('never cuts a browser that was already paired', () => { + const { registry, connect } = harness(WEB_CLIENT) + const paired = registry.addDevice('Browser', 'runtime') + registry.updateLastSeen(paired.deviceId) + expect(connect(() => ({ deviceToken: paired.token })).reply).toMatchObject({ + type: 'e2ee_authenticated' + }) + }) + + it('lets https, loopback and native clients pair as before', () => { + for (const page of [ + { webOrigin: 'https://desk.example.com', webClient: true as const }, + { webOrigin: 'http://localhost:6768', webClient: true as const }, + undefined + ]) { + const { registry, connect } = harness(page) + const pending = registry.getOrCreatePendingDevice('Client', 'runtime') + expect(connect(() => ({ deviceToken: pending.token })).reply).toMatchObject({ + type: 'e2ee_authenticated' + }) + } + }) +}) + +describe('phones over direct LAN are never gated by their Origin', () => { + it('pairs a released phone whose socket sends an http LAN Origin but no web marker', () => { + for (const scope of ['mobile', 'runtime'] as const) { + const { registry, connect } = harness({ webOrigin: LAN_PAGE }) + const pending = registry.getOrCreatePendingDevice('Phone', scope) + expect(connect(() => ({ deviceToken: pending.token })).reply).toMatchObject({ + type: 'e2ee_authenticated' + }) + } + }) +}) + +describe('relayed phones are never gated', () => { + it('pairs over Relay whatever the page marker says', () => { + const { registry, connect, bindRelay } = harness(WEB_CLIENT, false, (relayDeviceId) => ({ + transport: 'relay', + relayHostId: RELAY_HOST_ID, + relayDeviceId, + basisConnId: 'conn', + credentialKind: 'invite' + })) + const pending = registry.getOrCreatePendingDevice('Phone', 'mobile') + bindRelay(pending.deviceId) + expect(connect(() => ({ deviceToken: pending.token })).reply).toMatchObject({ + type: 'e2ee_authenticated' + }) + }) +}) + +describe('session-only browser keys', () => { + it('registers an ephemeral row, sweeps it once idle and disconnected, and then revokes it', () => { + const { registry, wiring, transport, connect } = harness({ + webOrigin: 'https://desk.example.com', + webClient: true as const + }) + const paired = registry.addDevice('Browser', 'runtime') + registry.updateLastSeen(paired.deviceId) + const migrating = connect(() => ({ deviceToken: paired.token })) + const key = nacl.sign.keyPair() + const fork = registerSocketDeviceKey(registry, migrating.socket!, { + devicePublicKeyB64: Buffer.from(key.publicKey).toString('base64'), + deviceProofB64: sign(key, 'register', migrating.transcriptHashB64), + ephemeral: true + }) + expect(registry.getDevice(fork.deviceId)?.ephemeral).toBe(true) + + const keyed = connect((hash) => ({ + deviceId: fork.deviceId, + deviceProofB64: sign(key, 'auth', hash) + })) + expect(keyed.reply).toMatchObject({ type: 'e2ee_authenticated' }) + const idle = registry.getDevice(fork.deviceId)!.lastSeenAt + EPHEMERAL_DEVICE_IDLE_MS + 1 + // A live socket keeps the row however long the tab stays open. + expect(wiring.sweepIdleEphemeralDevices(idle)).toEqual([]) + + transport.receive(keyed.ws, 'ignored') + wiring.dispose() + expect(registry.pruneIdleEphemeralDevices(idle, () => false)).toEqual([fork.deviceId]) + const after = connect((hash) => ({ + deviceId: fork.deviceId, + deviceProofB64: sign(key, 'auth', hash) + })) + expect(after.reply).toEqual({ + type: 'e2ee_error', + error: { code: 'device_revoked', deviceId: fork.deviceId } + }) + }) + + it('cuts sockets bound to a swept row', () => { + const { registry, wiring, transport } = harness(undefined) + const paired = registry.addDevice('Browser', 'runtime') + registry.updateLastSeen(paired.deviceId) + const key = nacl.sign.keyPair() + const fork = registry.forkDeviceKey( + paired.deviceId, + Buffer.from(key.publicKey).toString('base64'), + 'Tab', + { ephemeral: true } + ) + expect( + wiring.sweepIdleEphemeralDevices(fork.lastSeenAt + EPHEMERAL_DEVICE_IDLE_MS + 1) + ).toEqual([fork.deviceId]) + expect(transport.terminateClientConnections).toHaveBeenCalledWith(fork.deviceId) + expect(registry.getDevice(paired.deviceId)).not.toBeNull() + wiring.dispose() + }) +}) diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index 29a6aa4b854..06d943faddf 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -4,6 +4,7 @@ import { createServer as createHttpServer, type Server as HttpServer } from 'nod import { WebSocketServer, type WebSocket } from 'ws' import type { RpcTransport } from './transport' import { createStaticWebClientHandler } from './static-web-client-handler' +import { hasWebClientUpgradeMarker } from '../../../shared/web-origin-security' import { attachNodeWebSocketLifecycle, clearNodeWebSocketPreAuthTimer, @@ -59,6 +60,7 @@ export class WebSocketTransport implements RpcTransport { private readonly fallbackLadder: readonly number[] private persistedFallbackBindFailed = false private httpServer: HttpsServer | HttpServer | null = null + private readonly webPages = new WeakMap() private wss: WebSocketServer | null = null private messageHandler: WebSocketMessageHandler | null = null private connectionCloseHandler: WebSocketConnectionCloseHandler | null = null @@ -110,6 +112,11 @@ export class WebSocketTransport implements RpcTransport { this.connectionCloseHandler = handler } + /** The Orca web bundle that opened `ws` (it marks its upgrade), with its page origin. */ + webPageOf(ws: WebSocket): { webOrigin?: string; webClient: true } | undefined { + return this.webPages.get(ws) + } + setClientId(ws: WebSocket, clientId: string): void { this.wsClientIds.set(ws, clientId) clearNodeWebSocketPreAuthTimer(ws, this.preAuthTimers) @@ -213,11 +220,18 @@ export class WebSocketTransport implements RpcTransport { maxPayload: WEBSOCKET_TRANSPORT_MAX_MESSAGE_BYTES }) - wss.on('connection', (ws) => { + wss.on('connection', (ws, request) => { if (wss.clients.size > WEBSOCKET_TRANSPORT_MAX_CONNECTIONS) { rejectNodeWebSocketOverCapacity(ws) return } + if (hasWebClientUpgradeMarker(request.url)) { + const origin = request.headers.origin + this.webPages.set(ws, { + ...(typeof origin === 'string' ? { webOrigin: origin } : {}), + webClient: true + }) + } this.handleConnection(ws) }) diff --git a/src/main/runtime/runtime-rpc-browser-host-admission.test.ts b/src/main/runtime/runtime-rpc-browser-host-admission.test.ts index b9187315478..24b94ee4fe4 100644 --- a/src/main/runtime/runtime-rpc-browser-host-admission.test.ts +++ b/src/main/runtime/runtime-rpc-browser-host-admission.test.ts @@ -55,7 +55,9 @@ describe('runtime RPC browser-host admission', () => { }) server['deviceRegistry'] = new DeviceRegistry(userDataPath) const device = server['deviceRegistry'].addDevice('runtime-test', 'runtime') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the server reads only these wiring members in this test. server['mobileSocketWiring'] = { + dispose: () => {}, getConnectionId: () => 'connection-a' } as unknown as NonNullable<(typeof server)['mobileSocketWiring']> const socket = new FakeWebSocket() @@ -125,7 +127,9 @@ describe('runtime RPC browser-host admission', () => { [socketB, 'connection-b'], [socketAReplacement, 'connection-a-replacement'] ]) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the server reads only these wiring members in this test. server['mobileSocketWiring'] = { + dispose: () => {}, getConnectionId: (socket) => connectionIds.get(socket as unknown as FakeWebSocket) } as unknown as NonNullable<(typeof server)['mobileSocketWiring']> const repliesA: Record[] = [] diff --git a/src/main/runtime/runtime-rpc-websocket-long-poll-caps.test.ts b/src/main/runtime/runtime-rpc-websocket-long-poll-caps.test.ts index ea8837c01c9..2054fa23de8 100644 --- a/src/main/runtime/runtime-rpc-websocket-long-poll-caps.test.ts +++ b/src/main/runtime/runtime-rpc-websocket-long-poll-caps.test.ts @@ -54,7 +54,9 @@ describe('OrcaRuntimeRpcServer', () => { server['deviceRegistry'] = new DeviceRegistry(userDataPath) const entry = server['deviceRegistry']!.addDevice('runtime-test', 'runtime') const ws = new FakeWebSocket() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the server reads only these wiring members in this test. server['mobileSocketWiring'] = { + dispose: () => {}, getConnectionId: () => 'conn-test' } as unknown as NonNullable<(typeof server)['mobileSocketWiring']> const replies: Record[] = [] @@ -132,7 +134,9 @@ describe('OrcaRuntimeRpcServer', () => { // Why: 'runtime' scope, not 'mobile' — orchestration.ask is absent from the mobile allowlist. const entry = server['deviceRegistry']!.addDevice('runtime-test', 'runtime') const ws = new FakeWebSocket() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the server reads only these wiring members in this test. server['mobileSocketWiring'] = { + dispose: () => {}, getConnectionId: () => 'conn-test' } as unknown as NonNullable<(typeof server)['mobileSocketWiring']> const replies: Record[] = [] @@ -214,7 +218,9 @@ describe('OrcaRuntimeRpcServer', () => { server['deviceRegistry'] = new DeviceRegistry(userDataPath) const entry = server['deviceRegistry']!.addDevice('runtime-test', 'runtime') const ws = new FakeWebSocket() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the server reads only these wiring members in this test. server['mobileSocketWiring'] = { + dispose: () => {}, getConnectionId: () => 'conn-test' } as unknown as NonNullable<(typeof server)['mobileSocketWiring']> let activeDispatches = 0 diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-device-key-registration.ts b/src/main/runtime/runtime-rpc/runtime-rpc-device-key-registration.ts index e63b2d7f38c..7c566e7c407 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-device-key-registration.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-device-key-registration.ts @@ -35,7 +35,8 @@ export function registerSocketDeviceKey( const entry = registry.forkDeviceKey( legacy.deviceId, params.devicePublicKeyB64, - sanitizeDeviceLabel(params.deviceLabel, legacy.name) + sanitizeDeviceLabel(params.deviceLabel, legacy.name), + { ephemeral: params.ephemeral === true } ) return { deviceId: entry.deviceId, credentialRevision: entry.credentialRevision ?? 0 } } diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts index 3159f9f6286..188ce1318e4 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts @@ -1,3 +1,4 @@ +import { ALLOW_INSECURE_WEB_PAIRING_ENV } from '../../../shared/web-origin-security' import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../shared/agent-launch-runtime-capability' import type { RuntimeTransportMetadata } from '../../../shared/runtime-bootstrap' import { watchRuntimeMetadataOwnership } from '../runtime-metadata-ownership-watch' @@ -112,6 +113,7 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { } catch (error) { // Why: WebSocket transport is supplementary; on failure (e.g. port in use) continue with Unix socket only. console.error('[runtime] Failed to start WebSocket transport:', error) + this.mobileSocketWiring?.dispose() this.mobileSocketWiring = null } } @@ -191,7 +193,10 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { fallbackLadder: wsFallbackPortLadder(this.wsPort) }) const mobileSocketWiring = this.ensureMobileSocketWiring(deviceRegistry, e2eeKeypair) - this.detachWebSocketWiring = mobileSocketWiring.attachTransport(wsTransport) + this.detachWebSocketWiring = mobileSocketWiring.attachTransport(wsTransport, (ws) => ({ + transport: 'direct', + ...wsTransport.webPageOf(ws) + })) try { await wsTransport.start() @@ -270,6 +275,7 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { this.runtime.onClientDisconnected(socket.device.clientId) } }, + allowInsecureWebPairing: () => process.env[ALLOW_INSECURE_WEB_PAIRING_ENV] === '1', // Why: relay attempts are authorized upstream; only direct failures should prompt local re-pairing. onUnpairedDeviceAuthFailure: (metadata) => { if (metadata.transport === 'direct') { diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts b/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts index 2efd9bc7678..d5e5b8610dd 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts @@ -41,6 +41,7 @@ export class RuntimeRpcShutdown extends RuntimeRpcMobilePairing { if (this.hostDescriptor) { publishHostDescriptor(this.runtime.getRuntimeId(), null) } + this.mobileSocketWiring?.dispose() this.mobileSocketWiring = null this.detachWebSocketWiring = null const stopResults = await Promise.allSettled( diff --git a/src/shared/device-key-capability.ts b/src/shared/device-key-capability.ts index 62bc1a8810a..48a8d4b8a9f 100644 --- a/src/shared/device-key-capability.ts +++ b/src/shared/device-key-capability.ts @@ -5,4 +5,12 @@ // pairing.registerDeviceKey; clients migrate or present a key only when this is advertised. export const DEVICE_KEY_RUNTIME_CAPABILITY = 'pairing.device-key.v1' as const +// Why: registerDeviceKey's params are strict on older hosts, so `ephemeral` is sent only when advertised. +export const DEVICE_KEY_EPHEMERAL_RUNTIME_CAPABILITY = 'pairing.device-key-ephemeral.v1' as const + +export const DEVICE_KEY_RUNTIME_CAPABILITIES = [ + DEVICE_KEY_RUNTIME_CAPABILITY, + DEVICE_KEY_EPHEMERAL_RUNTIME_CAPABILITY +] as const + export const REGISTER_DEVICE_KEY_METHOD = 'pairing.registerDeviceKey' as const diff --git a/src/shared/device-key-registration-contract.ts b/src/shared/device-key-registration-contract.ts index 8655cc67acb..d02e326bc4e 100644 --- a/src/shared/device-key-registration-contract.ts +++ b/src/shared/device-key-registration-contract.ts @@ -6,7 +6,10 @@ export const RegisterDeviceKeyParamsSchema = z devicePublicKeyB64: z.string().refine(isDevicePublicKeyB64, 'expected a 32-byte Ed25519 key'), // Ed25519 over encodeDeviceProofMessage('register', this connection's v2 transcript hash). deviceProofB64: z.string().refine(isDeviceProofB64, 'expected a 64-byte signature'), - deviceLabel: z.string().max(256).optional() + deviceLabel: z.string().max(256).optional(), + // Session-only browser key: the host drops the row on restart and after idling. + // Sent only to hosts advertising DEVICE_KEY_EPHEMERAL_RUNTIME_CAPABILITY (older hosts are strict). + ephemeral: z.literal(true).optional() }) .strict() diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index a24fd1ef1be..b71bdce3726 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -45,7 +45,7 @@ import { AGENT_SESSION_RESUME_RUNTIME_CAPABILITIES } from './agent-session-resum import { QODER_OWNED_TERMINAL_CREATE_CAPABILITY } from './qoder-terminal-create-capability' import { AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY } from './agent-session-continue-interrupted-capability' import { ORCAD_RUNTIME_CAPABILITIES } from './orcad-runtime-capabilities' -import { DEVICE_KEY_RUNTIME_CAPABILITY } from './device-key-capability' +import { DEVICE_KEY_RUNTIME_CAPABILITIES } from './device-key-capability' // Why a star: every resume capability is part of the runtime contract this module re-exports. export * from './agent-session-resume-runtime-capabilities' import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from './agent-session-background-task-child-views-capability' @@ -356,7 +356,7 @@ export const ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY = export const RUNTIME_CAPABILITIES = [ ...ORCAD_RUNTIME_CAPABILITIES, - DEVICE_KEY_RUNTIME_CAPABILITY, + ...DEVICE_KEY_RUNTIME_CAPABILITIES, QODER_OWNED_TERMINAL_CREATE_CAPABILITY, ...AGENT_SESSION_STOP_RUNTIME_CAPABILITIES, ...AGENT_SESSION_CREATE_RUNTIME_CAPABILITIES, diff --git a/src/shared/web-origin-security.test.ts b/src/shared/web-origin-security.test.ts new file mode 100644 index 00000000000..a7fdeb6821d --- /dev/null +++ b/src/shared/web-origin-security.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + hasWebClientUpgradeMarker, + isInsecureRemoteWebOrigin, + withWebClientUpgradeMarker +} from './web-origin-security' + +describe('isInsecureRemoteWebOrigin', () => { + it('flags plain http pages served to other machines', () => { + expect(isInsecureRemoteWebOrigin('http://192.168.1.20:6768')).toBe(true) + expect(isInsecureRemoteWebOrigin('http://desk.local')).toBe(true) + expect(isInsecureRemoteWebOrigin('http://[fe80::1]:6768')).toBe(true) + }) + + it('treats https and loopback pages as secure', () => { + expect(isInsecureRemoteWebOrigin('https://desk.example.com')).toBe(false) + expect(isInsecureRemoteWebOrigin('http://localhost:6768')).toBe(false) + expect(isInsecureRemoteWebOrigin('http://app.localhost')).toBe(false) + expect(isInsecureRemoteWebOrigin('http://127.0.0.1:6768')).toBe(false) + expect(isInsecureRemoteWebOrigin('http://127.8.0.1')).toBe(false) + expect(isInsecureRemoteWebOrigin('http://[::1]:6768')).toBe(false) + }) + + it('leaves non-browser and unparseable origins to token auth', () => { + expect(isInsecureRemoteWebOrigin(undefined)).toBe(false) + expect(isInsecureRemoteWebOrigin('null')).toBe(false) + expect(isInsecureRemoteWebOrigin('file://')).toBe(false) + expect(isInsecureRemoteWebOrigin('not a url')).toBe(false) + }) +}) + +describe('web client upgrade marker', () => { + it('round-trips through the endpoint the web bundle dials', () => { + const marked = withWebClientUpgradeMarker('ws://192.168.1.20:6768') + expect(marked).toBe('ws://192.168.1.20:6768/?orca-client=web') + expect(hasWebClientUpgradeMarker(new URL(marked).pathname + new URL(marked).search)).toBe(true) + expect(withWebClientUpgradeMarker('wss://desk.example.com/orca?x=1')).toBe( + 'wss://desk.example.com/orca?x=1&orca-client=web' + ) + }) + + it('is absent for phones, CLIs and other paths', () => { + expect(hasWebClientUpgradeMarker('/')).toBe(false) + expect(hasWebClientUpgradeMarker(undefined)).toBe(false) + expect(hasWebClientUpgradeMarker('/?orca-client=phone')).toBe(false) + }) +}) diff --git a/src/shared/web-origin-security.ts b/src/shared/web-origin-security.ts new file mode 100644 index 00000000000..a6a6bae1f12 --- /dev/null +++ b/src/shared/web-origin-security.ts @@ -0,0 +1,61 @@ +// Why: a plain-http page on another machine can be swapped by anyone on the LAN path, so a +// pairing code typed into it is exposed. Browsers treat loopback http as a secure context. +export function isInsecureRemoteWebOrigin(origin: string | undefined): boolean { + if (!origin) { + return false + } + let url: URL + try { + url = new URL(origin) + } catch { + return false + } + return url.protocol === 'http:' && !isLoopbackHostname(url.hostname) +} + +function isLoopbackHostname(hostname: string): boolean { + const host = hostname.toLowerCase() + return ( + host === 'localhost' || + host.endsWith('.localhost') || + host === '[::1]' || + /^127(?:\.\d{1,3}){3}$/.test(host) + ) +} + +/** Sealed auth error for a first pairing from such a page without operator opt-in. */ +export const INSECURE_WEB_ORIGIN_AUTH_CODE = 'insecure_origin' as const + +// Operator opt-in, read by the host serving the page (desktop or `orca serve`). +export const ALLOW_INSECURE_WEB_PAIRING_ENV = 'ORCA_ALLOW_INSECURE_WEB_PAIRING' as const + +// Why a marker: React Native phones also send an http Origin on LAN sockets, so the Origin alone +// cannot say "browser". Only the web bundle adds this query to its upgrade URL; hosts that predate +// it ignore the query string, so the marked upgrade still connects to them. +export const WEB_CLIENT_UPGRADE_PARAM = 'orca-client' as const +export const WEB_CLIENT_UPGRADE_VALUE = 'web' as const + +export function withWebClientUpgradeMarker(endpoint: string): string { + try { + const url = new URL(endpoint) + url.searchParams.set(WEB_CLIENT_UPGRADE_PARAM, WEB_CLIENT_UPGRADE_VALUE) + return url.toString() + } catch { + return endpoint + } +} + +/** True when an upgrade request path (`/path?query`) carries the web bundle's marker. */ +export function hasWebClientUpgradeMarker(requestUrl: string | undefined): boolean { + if (!requestUrl) { + return false + } + try { + return ( + new URL(requestUrl, 'http://upgrade.invalid').searchParams.get(WEB_CLIENT_UPGRADE_PARAM) === + WEB_CLIENT_UPGRADE_VALUE + ) + } catch { + return false + } +} diff --git a/tests/e2e/cross-version-wire/cross-version-web-session-pairing.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-web-session-pairing.unit.test.ts new file mode 100644 index 00000000000..508efdcb94b --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-web-session-pairing.unit.test.ts @@ -0,0 +1,71 @@ +import { mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import nacl from 'tweetnacl' +import { beforeAll, describe, expect, it } from 'vitest' +import { DeviceRegistry, type DeviceEntry } from '../../../src/main/runtime/device-registry' +import { DEVICE_REGISTRY_FILENAME } from '../../../src/main/runtime/mobile-pairing-files' +import { DEVICE_KEY_EPHEMERAL_RUNTIME_CAPABILITY } from '../../../src/shared/device-key-capability' +import { + importReleaseCheckoutModule, + materializeReleaseCheckout, + resolveBaselineReleaseRef +} from './release-checkout' + +/** + * Session-only browser keys add an `ephemeral` flag to keyed rows. A released host (desktop + * downgrade) must keep such rows, keep token rows authenticating, and never advertise the + * capability that makes a browser send `ephemeral` to it. + */ +const SUITE_TIMEOUT_MS = 180_000 + +type RegistryLike = { + validateToken(token: string): { deviceId: string } | null + updateLastSeen(deviceId: string): void +} + +let ReleasedRegistry: new (userDataPath: string) => RegistryLike +let releasedCapabilities: readonly string[] + +beforeAll(async () => { + const checkout = await materializeReleaseCheckout(resolveBaselineReleaseRef()) + const [registryModule, protocolModule] = await Promise.all([ + importReleaseCheckoutModule(checkout, 'src/main/runtime/device-registry.ts'), + importReleaseCheckoutModule(checkout, 'src/shared/protocol-version.ts') + ]) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned release's DeviceRegistry class; a changed shape fails the assertions below. + ReleasedRegistry = registryModule.DeviceRegistry as typeof ReleasedRegistry + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned release's capability list; a changed shape fails the assertion below. + releasedCapabilities = protocolModule.RUNTIME_CAPABILITIES as readonly string[] +}, SUITE_TIMEOUT_MS) + +describe('cross-version: session-only browser rows', () => { + it('a released host never invites a browser to send ephemeral', () => { + expect(releasedCapabilities).not.toContain(DEVICE_KEY_EPHEMERAL_RUNTIME_CAPABILITY) + }) + + it('a released registry keeps the session row and token rows; this host drops it on restart', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-xv-web-session-')) + const current = new DeviceRegistry(userDataPath) + const legacy = current.addDevice('Browser', 'runtime') + current.updateLastSeen(legacy.deviceId) + const keyB64 = Buffer.from(nacl.sign.keyPair().publicKey).toString('base64') + const session = current.forkDeviceKey(legacy.deviceId, keyB64, 'Tab', { ephemeral: true }) + + const released = new ReleasedRegistry(userDataPath) + expect(released.validateToken(legacy.token)?.deviceId).toBe(legacy.deviceId) + released.updateLastSeen(legacy.deviceId) + const rows: DeviceEntry[] = JSON.parse( + readFileSync(join(userDataPath, DEVICE_REGISTRY_FILENAME), 'utf8') + ) + expect(rows.find((row) => row.deviceId === session.deviceId)).toMatchObject({ + ephemeral: true, + devicePublicKeyB64: keyB64 + }) + expect(rows.find((row) => row.deviceId === session.deviceId)?.token).toBeUndefined() + + const restarted = new DeviceRegistry(userDataPath) + expect(restarted.getDevice(session.deviceId)).toBeNull() + expect(restarted.validateToken(legacy.token)?.deviceId).toBe(legacy.deviceId) + }) +}) diff --git a/tests/e2e/cross-version-wire/cross-version-web-upgrade-marker.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-web-upgrade-marker.unit.test.ts new file mode 100644 index 00000000000..7d148b3df2f --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-web-upgrade-marker.unit.test.ts @@ -0,0 +1,85 @@ +import WebSocket from 'ws' +import { afterEach, beforeAll, describe, expect, it } from 'vitest' +import { WebSocketTransport } from '../../../src/main/runtime/rpc/ws-transport' +import { withWebClientUpgradeMarker } from '../../../src/shared/web-origin-security' +import { + importReleaseCheckoutModule, + materializeReleaseCheckout, + resolveBaselineReleaseRef +} from './release-checkout' + +/** + * The updated web client marks its WebSocket upgrade with `?orca-client=web` so a host can tell + * it from a phone (React Native also sends an http Origin). A released host must still accept the + * marked upgrade, and this host must record the marker only when it is present. + */ +const SUITE_TIMEOUT_MS = 180_000 +const LAN_PAGE = 'http://192.168.1.20:6768' + +type TransportLike = { + start(): Promise + stop(): Promise + readonly resolvedPort: number + onMessage(handler: (message: unknown, reply: unknown, ws: WebSocket) => void): void +} +type TransportConstructor = new (options: Record) => TransportLike + +let ReleasedTransport: TransportConstructor +const running: TransportLike[] = [] + +beforeAll(async () => { + const checkout = await materializeReleaseCheckout(resolveBaselineReleaseRef()) + const module = await importReleaseCheckoutModule(checkout, 'src/main/runtime/rpc/ws-transport.ts') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned release's WebSocketTransport; a changed shape fails the assertions below. + ReleasedTransport = module.WebSocketTransport as TransportConstructor +}, SUITE_TIMEOUT_MS) + +afterEach(async () => { + await Promise.all(running.splice(0).map((transport) => transport.stop())) +}) + +async function dial(transport: TransportLike, marked: boolean): Promise { + const received = new Promise((resolve) => + transport.onMessage((_message, _reply, ws) => resolve(ws)) + ) + const base = `ws://127.0.0.1:${transport.resolvedPort}` + const client = new WebSocket(marked ? withWebClientUpgradeMarker(base) : base, { + headers: { Origin: LAN_PAGE } + }) + await new Promise((resolve, reject) => { + client.once('open', resolve) + client.once('error', reject) + }) + client.send('{"type":"e2ee_hello"}') + const serverSide = await received + client.close() + return serverSide +} + +async function started(Transport: TransportConstructor): Promise { + const transport = new Transport({ host: '127.0.0.1', port: 0, preAuthTimeoutMs: 60_000 }) + await transport.start() + running.push(transport) + return transport +} + +describe('cross-version: web client upgrade marker', () => { + it('a released host accepts the marked upgrade and receives its frames', async () => { + const transport = await started(ReleasedTransport) + await expect(dial(transport, true)).resolves.toBeDefined() + }) + + it('this host records the marker for the web bundle only, never for a phone', async () => { + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: 0, + preAuthTimeoutMs: 60_000 + }) + await transport.start() + running.push(transport) + const web = await dial(transport, true) + expect(transport.webPageOf(web)).toEqual({ webOrigin: LAN_PAGE, webClient: true }) + const phone = await dial(transport, false) + expect(transport.webPageOf(phone)).toBeUndefined() + }) +})