feat(runtime): session-only browser keys and opt-in for pairing from plain-http pages (host) (#27540)

* feat(runtime): session-only browser keys and an opt-in for pairing from plain-http pages (host)

- pairing.registerDeviceKey accepts ephemeral: true (advertised as
  pairing.device-key-ephemeral.v1). Such keyed rows are dropped when the host
  restarts and after 12 hours with no live socket; a sweep removes them and cuts
  any socket still bound to them, so later key auth gets device_revoked.
- The WebSocket upgrade Origin is recorded per socket. A first pairing (a
  never-used pairing code) from an http:// page on another machine is refused
  with a sealed insecure_origin error unless the operator sets
  ORCA_ALLOW_INSECURE_WEB_PAIRING=1. Already-paired browsers, https and loopback
  pages, and native clients are unchanged.
- Registry file reading moved to device-registry-file-read.ts to keep the
  registry under the size cap.

* fix(runtime): gate first pairings only for the marked web client, never phones

React Native sends an http Origin on every LAN WebSocket, so the Origin alone
refused first-time QR pairing from released phones. The web bundle now marks
its upgrade with ?orca-client=web; only a marked socket from an http page on
another machine is gated. Released hosts ignore the query string.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-11 14:40:36 -07:00
committed by GitHub
co-authored by m4air
parent 79a597375b
commit fee083fa47
24 changed files with 856 additions and 52 deletions
+2
View File
@@ -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. */
+3 -1
View File
@@ -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,
@@ -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 }
}
}
@@ -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<DeviceEntry, 'credentialKind' | 'devicePublicKeyB64' | 'forkedFrom'> {
): Pick<DeviceEntry, 'credentialKind' | 'devicePublicKeyB64' | 'forkedFrom' | 'ephemeral'> {
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
}
}
+38 -37
View File
@@ -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')
}
}
@@ -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> = {}): DeviceEntry {
return {
deviceId: 'legacy',
name: 'Browser',
token: 'token',
scope: 'runtime',
pairedAt: 1,
lastSeenAt: 2,
...overrides
}
}
function keyedRow(overrides: Partial<DeviceEntry> = {}): 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' })
})
})
+30
View File
@@ -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
}
}
+3 -2
View File
@@ -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<E2EEAuthenticatedDevice>
onReady: (channel: E2EEChannel, device: E2EEAuthenticatedDevice) => void
@@ -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<TDevice> =
| { kind: 'found'; device: TDevice; devicePublicKeyB64: string }
@@ -92,7 +97,7 @@ export type DeviceKeyResolution<TDevice> =
export function authenticateMobileE2EE<TDevice extends { deviceToken?: string }>(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<TDevice>
}):
@@ -101,6 +106,9 @@ export function authenticateMobileE2EE<TDevice extends { deviceToken?: string }>
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',
+59 -1
View File
@@ -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<ArrayBufferLike>
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<typeof setInterval>
private readonly channels = new Map<WebSocket, E2EEChannel>()
private readonly connectionIds = new Map<WebSocket, string>()
private readonly authenticatedSockets = new Map<WebSocket, AuthenticatedMobileSocket>()
@@ -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)
}
}
@@ -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<MobileSocketTransport['onMessage']>[0] | null = null
readonly setClientId = vi.fn()
readonly terminateClientConnections = vi.fn(() => 0)
onMessage(handler: Parameters<MobileSocketTransport['onMessage']>[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<string, unknown>) {
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()
})
})
+15 -1
View File
@@ -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<WebSocket, { webOrigin?: string; webClient: true }>()
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)
})
@@ -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<string, unknown>[] = []
@@ -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<string, unknown>[] = []
@@ -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<string, unknown>[] = []
@@ -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
@@ -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 }
}
@@ -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') {
@@ -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(
+8
View File
@@ -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
@@ -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()
+2 -2
View File
@@ -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,
+47
View File
@@ -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)
})
})
+61
View File
@@ -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
}
}
@@ -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)
})
})
@@ -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<void>
stop(): Promise<void>
readonly resolvedPort: number
onMessage(handler: (message: unknown, reply: unknown, ws: WebSocket) => void): void
}
type TransportConstructor = new (options: Record<string, unknown>) => 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<WebSocket> {
const received = new Promise<WebSocket>((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<void>((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<TransportLike> {
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()
})
})