mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user