diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 0d31113a0e0..2998d1dcce7 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -51,7 +51,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Typecheck - run: npx tsc --noEmit + run: pnpm typecheck - name: Test run: pnpm test diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index d1c5ebff14a..b3bd5c85b14 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -11,6 +11,7 @@ import { RpcClientProvider } from '../src/transport/client-context' import { getNotificationNavigationPath } from '../src/notifications/notification-routing' import { loadHosts } from '../src/transport/host-store' import { extractPairingCodeFromUrl } from '../src/transport/pairing' +import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery' // Why: keeps the native splash screen visible until the React tree is mounted // and ready to render. Without this the user sees a blank white/black frame @@ -35,6 +36,12 @@ export default function RootLayout() { const router = useRouter() const handledNotificationIdsRef = useRef>(new Set()) + useEffect(() => { + // Why: pairing publication is journaled across process death; startup must + // reconcile the server result before another scan can replace that journal. + void recoverMobileRelayPairing() + }, []) + // Why: route `orca://pair?...` deep links to the confirm screen so // the same pairing flow runs whether the link arrived via QR scan, // paste, AirDrop, Messages, or `xcrun simctl openurl`. getInitialURL diff --git a/mobile/app/connection-log.tsx b/mobile/app/connection-log.tsx index cc19497e080..e2a9b73ff7f 100644 --- a/mobile/app/connection-log.tsx +++ b/mobile/app/connection-log.tsx @@ -9,11 +9,11 @@ import { colors, spacing, typography } from '../src/theme/mobile-theme' import { ConnectionLog } from '../src/components/ConnectionLog' import { loadHosts } from '../src/transport/host-store' import { connectionLogStore } from '../src/transport/connection-log-buffer' +import { useHostClient } from '../src/transport/client-context' import { - useHostClient, useLastConnectedAt, useReconnectAttempt -} from '../src/transport/client-context' +} from '../src/transport/client-context-connection-metrics' import { buildConnectionDiagnosticsReport } from '../src/diagnostics/connection-diagnostics-report' import type { ConnectionLogEntry, HostProfile } from '../src/transport/types' diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 1b2ac212d2f..442066ca962 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -34,11 +34,13 @@ import { removeHostAndCloseClient } from '../../../src/transport/host-removal-li import { useHostClient, useCloseHost, - useForceReconnect, - useReconnectAttempt, - useLastConnectedAt + useForceReconnect } from '../../../src/transport/client-context' import { useWorktreeResync } from '../../../src/transport/use-worktree-resync' +import { + useLastConnectedAt, + useReconnectAttempt +} from '../../../src/transport/client-context-connection-metrics' import { classifyConnection, type ConnectionVerdict diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index a1446d9d307..b4e7207d6a5 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -56,12 +56,11 @@ import { saveTerminalTextScale, type MobileTerminalLinkOpenMode } from '../../../../src/storage/preferences' +import { useHostClient, useForceReconnect } from '../../../../src/transport/client-context' import { - useHostClient, - useForceReconnect, - useReconnectAttempt, - useLastConnectedAt -} from '../../../../src/transport/client-context' + useLastConnectedAt, + useReconnectAttempt +} from '../../../../src/transport/client-context-connection-metrics' import { classifyConnection, verdictDisplayLabel diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index c61d01559cd..191a47038f2 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -34,11 +34,11 @@ import { } from 'lucide-react-native' import type { RpcClient } from '../../../src/transport/rpc-client' import type { RpcSuccess } from '../../../src/transport/types' +import { useHostClient } from '../../../src/transport/client-context' import { - useHostClient, useLastConnectedAt, useReconnectAttempt -} from '../../../src/transport/client-context' +} from '../../../src/transport/client-context-connection-metrics' import { classifyConnection } from '../../../src/transport/connection-health' import { StatusDot } from '../../../src/components/StatusDot' import { ActionSheetModal } from '../../../src/components/ActionSheetModal' diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index d92dffb0a3e..45821de712b 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -3,7 +3,6 @@ import { View, Text, StyleSheet, Pressable, FlatList, Alert } from 'react-native import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import { useRouter, useFocusEffect } from 'expo-router' import { - Monitor, QrCode, Settings, ChevronRight, @@ -35,12 +34,12 @@ import { useForceReconnect, usePrimeHosts } from '../src/transport/client-context' -import { classifyConnection, verdictDisplayLabel } from '../src/transport/connection-health' +import { classifyConnection } from '../src/transport/connection-health' import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications' import type { ConnectionState, HostProfile } from '../src/transport/types' import { triggerMediumImpact } from '../src/platform/haptics' import { OrcaLogo } from '../src/components/OrcaLogo' -import { StatusDot } from '../src/components/StatusDot' +import { MobileHostCard } from '../src/components/MobileHostCard' import { TaskProviderLogo } from '../src/components/TaskProviderLogo' import { TextInputModal } from '../src/components/TextInputModal' import { ActionSheetModal, type ActionSheetAction } from '../src/components/ActionSheetModal' @@ -324,6 +323,10 @@ export default function HomeScreen() { // docs/mobile-shared-client-per-host.md. const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) const allClients = useAllHostClients(hostIds) + const hostPaths = useMemo( + () => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])), + [allClients] + ) const closeHostClient = useCloseHost() const forceReconnectHost = useForceReconnect() const primeHosts = usePrimeHosts() @@ -824,7 +827,6 @@ export default function HomeScreen() { const state = hostStates[item.id] ?? 'connecting' const attempts = hostAttempts[item.id] ?? 0 const lastConnectedAt = hostLastConnected[item.id] ?? null - const connected = state === 'connected' const info = worktreeInfo[item.id] const verdict = classifyConnection({ state, @@ -832,45 +834,21 @@ export default function HomeScreen() { lastConnectedAt, endpoint: item.endpoint }) - const isError = - verdict.kind === 'warning' || - verdict.kind === 'unreachable' || - verdict.kind === 'auth-failed' return ( - [styles.hostCard, pressed && styles.hostCardPressed]} + router.push(`/h/${item.id}`)} onLongPress={() => { triggerMediumImpact() setActionTarget(item) }} - delayLongPress={400} - > - - - - - - {item.name} - - - - - {verdictDisplayLabel(verdict)} - {connected && info - ? ` · ${info.totalWorktrees} worktree${info.totalWorktrees !== 1 ? 's' : ''}${info.activeCount > 0 ? ` · ${info.activeCount} active` : ''}` - : ''} - - - - - + /> ) }} ListFooterComponent={ @@ -1081,16 +1059,16 @@ export default function HomeScreen() { items.push({ label: 'Rename', icon: Edit3, + closeBeforePress: true, onPress: () => { - setActionTarget(null) setRenameTarget(host) } }) items.push({ label: 'Remove', destructive: true, + closeBeforePress: true, onPress: () => { - setActionTarget(null) setConfirmRemove(host) } }) @@ -1244,63 +1222,9 @@ const styles = StyleSheet.create({ }, /* ─── Host cards ─── */ - hostCard: { - flexDirection: 'row', - alignItems: 'center', - paddingLeft: spacing.md, - paddingRight: spacing.md, - paddingVertical: 12, - borderRadius: radii.card, - backgroundColor: colors.bgPanel, - borderWidth: 1, - borderColor: colors.borderSubtle - }, hostCardPressed: { backgroundColor: colors.bgRaised }, - hostIcon: { - width: 46, - height: 46, - borderRadius: 13, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.bgRaised, - marginRight: 14, - position: 'relative' - }, - hostMain: { - flex: 1, - minWidth: 0, - marginRight: spacing.sm - }, - hostName: { - color: colors.textPrimary, - fontSize: 15, - fontWeight: '600', - lineHeight: 20 - }, - hostMeta: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - marginTop: 3 - }, - hostMetaItem: { - fontSize: 12, - color: colors.textSecondary - }, - hostMetaDot: { - width: 3, - height: 3, - borderRadius: 1.5, - backgroundColor: colors.textMuted, - marginHorizontal: 8 - }, - statusDot: { - width: 7, - height: 7, - borderRadius: 3.5 - }, /* ─── Resume card ─── */ resumeCard: { diff --git a/mobile/app/pair-confirm.tsx b/mobile/app/pair-confirm.tsx index 6fff509d820..1db947b10b0 100644 --- a/mobile/app/pair-confirm.tsx +++ b/mobile/app/pair-confirm.tsx @@ -5,12 +5,10 @@ import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router' import { ChevronLeft } from 'lucide-react-native' import { resolvePairConfirmRouteState } from '../src/transport/pair-confirm-state' import { - startPairingConnectionAttempt, - type PairingConnectionAttempt -} from '../src/transport/pairing-connection-attempt' -import { connect } from '../src/transport/rpc-client' -import { saveHost, getNextHostName } from '../src/transport/host-store' -import type { ConnectionLogEntry, RpcResponse } from '../src/transport/types' + startPreProfilePairing, + type PreProfilePairingAttempt +} from '../src/transport/pre-profile-pairing-coordinator' +import type { ConnectionLogEntry } from '../src/transport/types' import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' import { ConnectionLog } from '../src/components/ConnectionLog' @@ -35,7 +33,7 @@ export default function PairConfirmScreen() { // batch fewer setState calls when entries arrive in bursts. const logsRef = useRef([]) const mountedRef = useRef(true) - const activePairingAttemptRef = useRef(null) + const activePairingAttemptRef = useRef(null) const routeState = resolvePairConfirmRouteState(params.code) const offer = routeState.offer @@ -79,20 +77,12 @@ export default function PairConfirmScreen() { setStatus('connecting') logsRef.current = [] setLogs([]) - let client: ReturnType | null = null activePairingAttemptRef.current?.dispose() - // Why: split the try/catch around the network call vs the local save - // so a Keychain or AsyncStorage failure doesn't masquerade as a - // "Cannot connect" error. - let response: RpcResponse - const attempt = startPairingConnectionAttempt({ + const attempt = startPreProfilePairing({ + offer, timeoutMs: PAIRING_OVERALL_TIMEOUT_MS, - closeClient: () => client?.close() - }) - activePairingAttemptRef.current = attempt - try { - client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, { + connectOptions: { onLog: (entry) => { if (!mountedRef.current || activePairingAttemptRef.current !== attempt) { return @@ -100,8 +90,11 @@ export default function PairConfirmScreen() { logsRef.current = [...logsRef.current, entry] setLogs(logsRef.current) } - }) - response = await client.sendRequest('status.get') + } + }) + activePairingAttemptRef.current = attempt + try { + const { hostId } = await attempt.result const attemptIsCurrent = activePairingAttemptRef.current === attempt attempt.dispose() if (activePairingAttemptRef.current === attempt) { @@ -110,6 +103,7 @@ export default function PairConfirmScreen() { if (!mountedRef.current || !attemptIsCurrent) { return } + router.replace(`/h/${hostId}`) } catch (err) { const timedOut = attempt.timedOut const attemptIsCurrent = activePairingAttemptRef.current === attempt @@ -125,47 +119,7 @@ export default function PairConfirmScreen() { setErrorMessage( timedOut ? `Couldn't connect within ${PAIRING_OVERALL_TIMEOUT_MS / 1000}s — see log below for where it stalled` - : 'Cannot connect — check that your computer is on the same network' - ) - return - } - - if (!response.ok) { - if (!mountedRef.current) { - return - } - setStatus('error') - setErrorMessage( - response.error.code === 'unauthorized' - ? 'Authentication failed — token may be expired' - : `Server error: ${response.error.message}` - ) - return - } - - try { - const hostId = `host-${Date.now()}` - const hostName = await getNextHostName() - await saveHost({ - id: hostId, - name: hostName, - endpoint: offer.endpoint, - deviceToken: offer.deviceToken, - publicKeyB64: offer.publicKeyB64, - lastConnected: Date.now() - }) - if (!mountedRef.current) { - return - } - router.replace(`/h/${hostId}`) - } catch (err) { - if (!mountedRef.current) { - return - } - console.warn('[pair-confirm] save failed', err) - setStatus('error') - setErrorMessage( - `Pairing succeeded but couldn't save the host: ${err instanceof Error ? err.message : String(err)}` + : `Pairing failed: ${err instanceof Error ? err.message : String(err)}` ) } } diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx index 599aba41ad8..926812cffb1 100644 --- a/mobile/app/pair-scan.tsx +++ b/mobile/app/pair-scan.tsx @@ -14,12 +14,10 @@ import { useRouter } from 'expo-router' import { ChevronLeft, Clipboard as ClipboardIcon, QrCode } from 'lucide-react-native' import { decodePairingUrl, parsePairingCode } from '../src/transport/pairing' import { - startPairingConnectionAttempt, - type PairingConnectionAttempt -} from '../src/transport/pairing-connection-attempt' -import { connect } from '../src/transport/rpc-client' -import { saveHost, getNextHostName } from '../src/transport/host-store' -import type { ConnectionLogEntry, PairingOffer, RpcResponse } from '../src/transport/types' + startPreProfilePairing, + type PreProfilePairingAttempt +} from '../src/transport/pre-profile-pairing-coordinator' +import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types' import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' import { TextInputModal } from '../src/components/TextInputModal' import { ConnectionLog } from '../src/components/ConnectionLog' @@ -54,7 +52,7 @@ export default function PairScanScreen() { const logsRef = useRef([]) const processingRef = useRef(false) const mountedRef = useRef(true) - const activePairingAttemptRef = useRef(null) + const activePairingAttemptRef = useRef(null) const setPairScanRootRef = useCallback((node: View | null): void => { if (node !== null) { @@ -123,21 +121,12 @@ export default function PairScanScreen() { setStatus('connecting') logsRef.current = [] setLogs([]) - let client: ReturnType | null = null activePairingAttemptRef.current?.dispose() - // Why: split the try/catch around the network call vs the local save - // so a Keychain or AsyncStorage failure doesn't masquerade as a - // "Cannot connect — same network?" error. Pairing reached the - // desktop fine; the failure is local persistence. - let response: RpcResponse - const attempt = startPairingConnectionAttempt({ + const attempt = startPreProfilePairing({ + offer, timeoutMs: PAIRING_OVERALL_TIMEOUT_MS, - closeClient: () => client?.close() - }) - activePairingAttemptRef.current = attempt - try { - client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, { + connectOptions: { onLog: (entry) => { if (!mountedRef.current || activePairingAttemptRef.current !== attempt) { return @@ -145,8 +134,11 @@ export default function PairScanScreen() { logsRef.current = [...logsRef.current, entry] setLogs(logsRef.current) } - }) - response = await client.sendRequest('status.get') + } + }) + activePairingAttemptRef.current = attempt + try { + const { hostId } = await attempt.result const attemptIsCurrent = activePairingAttemptRef.current === attempt attempt.dispose() if (activePairingAttemptRef.current === attempt) { @@ -155,6 +147,7 @@ export default function PairScanScreen() { if (!mountedRef.current || !attemptIsCurrent) { return } + router.replace(`/h/${hostId}`) } catch (err) { const timedOut = attempt.timedOut const attemptIsCurrent = activePairingAttemptRef.current === attempt @@ -170,51 +163,7 @@ export default function PairScanScreen() { setErrorMessage( timedOut ? `Couldn't connect within ${PAIRING_OVERALL_TIMEOUT_MS / 1000}s — see log below for where it stalled` - : 'Cannot connect — check that your computer is on the same network' - ) - processingRef.current = false - return - } - - if (!response.ok) { - if (!mountedRef.current) { - return - } - if (response.error.code === 'unauthorized') { - setStatus('error') - setErrorMessage('Authentication failed — token may be expired') - processingRef.current = false - return - } - setStatus('error') - setErrorMessage(`Server error: ${response.error.message}`) - processingRef.current = false - return - } - - try { - const hostId = `host-${Date.now()}` - const hostName = await getNextHostName() - await saveHost({ - id: hostId, - name: hostName, - endpoint: offer.endpoint, - deviceToken: offer.deviceToken, - publicKeyB64: offer.publicKeyB64, - lastConnected: Date.now() - }) - if (!mountedRef.current) { - return - } - router.replace(`/h/${hostId}`) - } catch (err) { - if (!mountedRef.current) { - return - } - console.warn('[pair] save failed', err) - setStatus('error') - setErrorMessage( - `Pairing succeeded but couldn't save the host: ${err instanceof Error ? err.message : String(err)}` + : `Pairing failed: ${err instanceof Error ? err.message : String(err)}` ) processingRef.current = false } diff --git a/mobile/package.json b/mobile/package.json index 66abfe67e5f..41ddf3f2f3b 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -9,6 +9,7 @@ "ios": "expo run:ios", "postinstall": "node scripts/build-terminal-webview-engine.mjs", "test": "vitest run", + "typecheck": "tsc --noEmit", "lint": "oxlint", "format": "oxfmt --write .", "format:check": "oxfmt --check .", @@ -17,6 +18,7 @@ "start:emulator": "node scripts/start-emulator.mjs" }, "dependencies": { + "@noble/hashes": "1.8.0", "@orca/expo-two-way-audio": "file:./packages/expo-two-way-audio", "@react-native-async-storage/async-storage": "^2.2.0", "@xterm/addon-unicode11": "0.10.0-beta.285", diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index d28e9ac1d40..d5b49ba03f3 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: .: dependencies: + '@noble/hashes': + specifier: 1.8.0 + version: 1.8.0 '@orca/expo-two-way-audio': specifier: file:./packages/expo-two-way-audio version: file:packages/expo-two-way-audio(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) @@ -1878,6 +1881,10 @@ packages: '@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3': resolution: {integrity: sha512-s88O1aVtXftvp5bCPB7WnmXc5IwOZZ7YPuwNPt+GtOOXpPvad1LfbmjYv+qII7zP6RU2QGnqve27dnLycEnyEQ==} + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@orca/expo-two-way-audio@file:packages/expo-two-way-audio': resolution: {directory: packages/expo-two-way-audio, type: directory} peerDependencies: @@ -8835,6 +8842,8 @@ snapshots: '@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3': optional: true + '@noble/hashes@1.8.0': {} + '@orca/expo-two-way-audio@file:packages/expo-two-way-audio(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': dependencies: expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) diff --git a/mobile/src/components/ActionSheetModal.tsx b/mobile/src/components/ActionSheetModal.tsx index 0d8aef34aba..44ec0ce03f7 100644 --- a/mobile/src/components/ActionSheetModal.tsx +++ b/mobile/src/components/ActionSheetModal.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react' +import { useRef, type ReactNode } from 'react' import { ActivityIndicator, View, Text, Pressable, StyleSheet } from 'react-native' import { Edit3, Trash2, type LucideIcon } from 'lucide-react-native' import { colors, spacing, typography } from '../theme/mobile-theme' @@ -13,6 +13,7 @@ export type ActionSheetAction = { hint?: string loading?: boolean skipAutoClose?: boolean + closeBeforePress?: boolean onPress: () => void } @@ -107,9 +108,37 @@ export function ActionSheetContent({ title, message, actions, onClose }: Content } export function ActionSheetModal({ visible, title, message, actions, onClose }: Props) { + const pendingActionRef = useRef<(() => void) | null>(null) + const sequencedActions = actions.map((action) => + action.closeBeforePress + ? { + ...action, + onPress: () => { + pendingActionRef.current = action.onPress + } + } + : action + ) + return ( - - + { + // Why: iOS cannot present a second native modal until the action + // sheet's native window has fully unmounted. + const pendingAction = pendingActionRef.current + pendingActionRef.current = null + pendingAction?.() + }} + dragContentToDismiss + > + ) } diff --git a/mobile/src/components/BottomDrawer.tsx b/mobile/src/components/BottomDrawer.tsx index eb23e53a554..138e39ddc0e 100644 --- a/mobile/src/components/BottomDrawer.tsx +++ b/mobile/src/components/BottomDrawer.tsx @@ -40,6 +40,7 @@ const TOP_SCROLL_EPSILON = 1 type Props = { visible: boolean onClose: () => void + onAfterClose?: () => void children: ReactNode dragContentToDismiss?: boolean contentScrollable?: boolean @@ -49,6 +50,7 @@ type Props = { export function BottomDrawer({ visible, onClose, + onAfterClose, children, dragContentToDismiss = true, contentScrollable = true, @@ -73,7 +75,10 @@ export function BottomDrawer({ setMounted(false)} + onHidden={() => { + setMounted(false) + onAfterClose?.() + }} dragContentToDismiss={dragContentToDismiss} contentScrollable={contentScrollable} zIndex={zIndex} diff --git a/mobile/src/components/MobileHostCard.tsx b/mobile/src/components/MobileHostCard.tsx new file mode 100644 index 00000000000..1787b958d7c --- /dev/null +++ b/mobile/src/components/MobileHostCard.tsx @@ -0,0 +1,91 @@ +import { ChevronRight, Monitor } from 'lucide-react-native' +import { Pressable, StyleSheet, Text, View } from 'react-native' +import type { ConnectionVerdict } from '../transport/connection-health' +import { verdictDisplayLabel } from '../transport/connection-health' +import { mobileConnectionPathLabel } from '../transport/mobile-connection-path-label' +import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client' +import type { ConnectionState, HostProfile } from '../transport/types' +import { colors, radii, spacing } from '../theme/mobile-theme' +import { StatusDot } from './StatusDot' + +export function MobileHostCard(props: { + host: HostProfile + state: ConnectionState + verdict: ConnectionVerdict + path: MobileConnectionPath + worktreeCounts?: { total: number; active: number } + onPress: () => void + onLongPress: () => void +}) { + const connected = props.state === 'connected' + const isError = ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind) + return ( + [styles.card, pressed && styles.cardPressed]} + onPress={props.onPress} + onLongPress={props.onLongPress} + delayLongPress={400} + > + + + + + + {props.host.name} + + + + + {verdictDisplayLabel(props.verdict)} + {connected ? ` · ${mobileConnectionPathLabel(props.path)}` : ''} + {connected && props.worktreeCounts + ? ` · ${props.worktreeCounts.total} worktree${props.worktreeCounts.total !== 1 ? 's' : ''}${props.worktreeCounts.active > 0 ? ` · ${props.worktreeCounts.active} active` : ''}` + : ''} + + + {props.verdict.kind === 'unreachable' && !props.host.relay ? ( + + Update desktop Orca and sign in to connect from anywhere + + ) : null} + + + + ) +} + +const styles = StyleSheet.create({ + card: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingVertical: 12, + borderRadius: radii.card, + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + cardPressed: { backgroundColor: colors.bgRaised }, + icon: { + width: 46, + height: 46, + borderRadius: 13, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgRaised, + marginRight: 14 + }, + main: { flex: 1, minWidth: 0, marginRight: spacing.sm }, + name: { color: colors.textPrimary, fontSize: 15, fontWeight: '600', lineHeight: 20 }, + meta: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 3 }, + metaText: { fontSize: 12, color: colors.textSecondary }, + discoveryHint: { + marginTop: spacing.xs, + fontSize: 11, + lineHeight: 15, + color: colors.textMuted + } +}) diff --git a/mobile/src/dictation/mobile-dictation-setup.test.ts b/mobile/src/dictation/mobile-dictation-setup.test.ts index d72854afc82..f84e545af49 100644 --- a/mobile/src/dictation/mobile-dictation-setup.test.ts +++ b/mobile/src/dictation/mobile-dictation-setup.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import type { RpcClient } from '../transport/rpc-client' import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types' import { @@ -67,6 +68,26 @@ describe('rpc wrappers', () => { expect(client.calls[0]).toEqual({ method: 'speech.models.list', params: null }) }) + it('retries the idempotent setup read once after logical-client cutover', async () => { + const setup: MobileSpeechSetup = { enabled: false, selectedModelId: '', models: [] } + const sendRequest = vi + .fn() + .mockRejectedValueOnce(new LogicalClientCutoverError()) + .mockResolvedValueOnce(ok(setup)) + + await expect(fetchDictationSetup({ sendRequest })).resolves.toEqual(setup) + expect(sendRequest).toHaveBeenCalledTimes(2) + expect(sendRequest).toHaveBeenNthCalledWith(1, 'speech.models.list', null) + expect(sendRequest).toHaveBeenNthCalledWith(2, 'speech.models.list', null) + }) + + it('does not retry unrelated setup-read failures', async () => { + const sendRequest = vi.fn().mockRejectedValue(new Error('offline')) + + await expect(fetchDictationSetup({ sendRequest })).rejects.toThrow('offline') + expect(sendRequest).toHaveBeenCalledOnce() + }) + it('starts a download', async () => { const client = clientWith([ok({ started: true })]) await downloadDictationModel(client, 'm1') diff --git a/mobile/src/dictation/mobile-dictation-setup.ts b/mobile/src/dictation/mobile-dictation-setup.ts index ad4d4b96881..ca510ee4a31 100644 --- a/mobile/src/dictation/mobile-dictation-setup.ts +++ b/mobile/src/dictation/mobile-dictation-setup.ts @@ -1,5 +1,6 @@ import type { RuntimeSpeechSetupState } from '../../../src/shared/runtime-types' import type { RpcClient } from '../transport/rpc-client' +import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import type { RpcSuccess } from '../transport/types' export type MobileSpeechSetup = RuntimeSpeechSetupState @@ -31,7 +32,7 @@ export function isDictationSetupRequiredError(message: string): boolean { export async function fetchDictationSetup( client: Pick ): Promise { - const response = await client.sendRequest('speech.models.list', null) + const response = await fetchDictationSetupResponse(client) if (!response.ok) { if (isLegacyDesktopSpeechSetupError(response.error)) { throw new Error(LEGACY_DESKTOP_SPEECH_SETUP_MESSAGE) @@ -41,6 +42,19 @@ export async function fetchDictationSetup( return (response as RpcSuccess).result as MobileSpeechSetup } +async function fetchDictationSetupResponse(client: Pick) { + try { + return await client.sendRequest('speech.models.list', null) + } catch (error) { + if (!(error instanceof LogicalClientCutoverError)) { + throw error + } + // Why: this read can safely repeat on the authenticated replacement; mutation + // RPCs must still surface cutover so callers never replay unknown commits. + return client.sendRequest('speech.models.list', null) + } +} + export async function downloadDictationModel( client: Pick, modelId: string diff --git a/mobile/src/source-control/mobile-pr-create.test.ts b/mobile/src/source-control/mobile-pr-create.test.ts index ce877dc3c41..28f2e4b691f 100644 --- a/mobile/src/source-control/mobile-pr-create.test.ts +++ b/mobile/src/source-control/mobile-pr-create.test.ts @@ -8,7 +8,8 @@ import { getMobilePrCreateBlockMessage, mobileRepoSelectorFromWorktreeId, resolveMobilePrPrefill, - shouldPushBeforeMobilePrCreate + shouldPushBeforeMobilePrCreate, + type MobilePrPrefill } from './mobile-pr-create' function ok(result: unknown): RpcSuccess { @@ -122,6 +123,19 @@ describe('mobile create form gating parity', () => { expect(mobileAllowsComposer).toBe(desktopAllowsComposer) }) + + it('stays safely blocked for a reason added by a newer desktop contract', () => { + expect( + getMobilePrCreateBlockMessage({ + provider: 'github', + base: 'main', + title: 'Add feature', + body: '', + canCreate: false, + blockedReason: 'future_desktop_reason' as unknown as MobilePrPrefill['blockedReason'] + }) + ).toBe('This branch is not ready for a pull request yet.') + }) }) describe('resolveMobilePrPrefill', () => { diff --git a/mobile/src/source-control/mobile-pr-create.ts b/mobile/src/source-control/mobile-pr-create.ts index b9a3817f4d2..b584b86e202 100644 --- a/mobile/src/source-control/mobile-pr-create.ts +++ b/mobile/src/source-control/mobile-pr-create.ts @@ -72,5 +72,9 @@ export function getMobilePrCreateBlockMessage(prefill: MobilePrPrefill): string case null: case undefined: return `This branch is not ready for a ${copy.reviewLabel} yet.` + default: + // Why: desktop can add blocked reasons before a long-lived mobile branch + // catches up; remain safely blocked while preserving merge-ref typechecks. + return `This branch is not ready for a ${copy.reviewLabel} yet.` } } diff --git a/mobile/src/transport/client-context-connection-metrics.ts b/mobile/src/transport/client-context-connection-metrics.ts new file mode 100644 index 00000000000..391c0cb0568 --- /dev/null +++ b/mobile/src/transport/client-context-connection-metrics.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react' +import { useRpcClientContext, type RpcClientContextValue } from './client-context' + +export function useReconnectAttempt(hostId: string | undefined): number { + return useHostMetric(hostId, (context, id) => context.getReconnectAttempt(id), 0) +} + +export function useLastConnectedAt(hostId: string | undefined): number | null { + return useHostMetric(hostId, (context, id) => context.getLastConnectedAt(id), null) +} + +function useHostMetric( + hostId: string | undefined, + read: (context: RpcClientContextValue, hostId: string) => T, + fallback: T +): T { + const context = useRpcClientContext() + const [, force] = useState(0) + useEffect(() => { + if (!hostId) { + return + } + return context.subscribeHostState(hostId, () => force((count) => count + 1)) + }, [context, hostId]) + return hostId ? read(context, hostId) : fallback +} diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index b2ed60e74e4..121d0e42661 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -10,6 +10,9 @@ const loadHostsMock = vi.fn() vi.mock('./rpc-client', () => ({ connect: (...args: unknown[]) => connectMock(...args) })) +vi.mock('./host-logical-client', () => ({ + openHostLogicalClient: (...args: unknown[]) => connectMock(...args) +})) vi.mock('./host-store', () => ({ loadHosts: () => loadHostsMock() })) @@ -131,7 +134,7 @@ describe('useHostClient', () => { loadHostsMock.mockResolvedValue([HOST]) const harness = await renderHarness(HOST.id) - expect(harness.hook.client).toBe(fake) + expect(harness.hook.client).not.toBeNull() expect(harness.hook.state).toBe('connected') // Regression (STA-1511): closeHost deletes the entry; before the fix the diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index df54f143528..d9e07be4033 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -19,11 +19,13 @@ import { useState, type ReactNode } from 'react' -import { connect, type RpcClient } from './rpc-client' +import type { RpcClient } from './rpc-client' import { connectionLogStore } from './connection-log-buffer' import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers' import { HostClientOpenRegistry } from './host-client-open-registry' import { loadHosts } from './host-store' +import { openHostLogicalClient } from './host-logical-client' +import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client' import type { ConnectionState, HostProfile } from './types' type StoreEntry = { @@ -33,7 +35,7 @@ type StoreEntry = { unsubState: () => void } -type ContextValue = { +export type RpcClientContextValue = { acquire: (hostId: string, host?: HostProfile) => RpcClient | null release: (hostId: string) => void forceReconnect: (hostId: string) => Promise @@ -45,6 +47,7 @@ type ContextValue = { // Used by the UI to escalate "Reconnecting…" into a "host appears // unreachable, re-pair?" prompt. getLastConnectedAt: (hostId: string) => number | null + getActivePath: (hostId: string) => MobileConnectionPath subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void getAllClients: () => Array<{ hostId: string; client: RpcClient }> subscribeAllHosts: (listener: () => void) => () => void @@ -54,7 +57,7 @@ type ContextValue = { primeHosts: (hosts: HostProfile[]) => void } -const Ctx = createContext(null) +const Ctx = createContext(null) export function RpcClientProvider({ children }: { children: ReactNode }) { // Why: entries live in a ref so updates don't force re-renders of the @@ -155,12 +158,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { let client: RpcClient try { - client = connect(host.endpoint, host.deviceToken, host.publicKeyB64, { - // Why: retain reconnect lifecycle events for the Connection Log - // screen — without this the reasons a host is stuck live only in - // console.log, which users can't see or share. - onLog: (entry) => connectionLogStore.append(hostId, entry) - }) + client = openHostLogicalClient(host, (entry) => connectionLogStore.append(hostId, entry)) } catch { // Why: connect() can throw synchronously if the public key is // malformed or the endpoint URL is invalid. Notify so the UI @@ -279,6 +277,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { return storeRef.current.get(hostId)?.client.getLastConnectedAt() ?? null }, []) + const getActivePath = useCallback((hostId: string): MobileConnectionPath => { + return clientActivePath(storeRef.current.get(hostId)?.client) + }, []) + const subscribeHostState = useCallback( (hostId: string, listener: (state: ConnectionState) => void) => { let set = stateListenersRef.current.get(hostId) @@ -348,7 +350,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { }) }, []) - const value = useMemo( + const value = useMemo( () => ({ acquire, release, @@ -357,6 +359,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { getState, getReconnectAttempt, getLastConnectedAt, + getActivePath, subscribeHostState, getAllClients, subscribeAllHosts, @@ -370,6 +373,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { getState, getReconnectAttempt, getLastConnectedAt, + getActivePath, subscribeHostState, getAllClients, subscribeAllHosts, @@ -380,7 +384,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { return {children} } -function useCtx(): ContextValue { +export function useRpcClientContext(): RpcClientContextValue { const ctx = useContext(Ctx) if (!ctx) { throw new Error('useHostClient must be used inside ') @@ -395,7 +399,7 @@ export function useHostClient(hostId: string | undefined): { client: RpcClient | null state: ConnectionState } { - const ctx = useCtx() + const ctx = useRpcClientContext() const [, force] = useState(0) const [state, setState] = useState(() => hostId ? ctx.getState(hostId) : 'disconnected' @@ -453,8 +457,9 @@ export function useAllHostClients(hostIds: string[]): Array<{ hostId: string client: RpcClient state: ConnectionState + path: MobileConnectionPath }> { - const ctx = useCtx() + const ctx = useRpcClientContext() // Stable key so we don't tear down on every render of the array. const key = useMemo(() => [...hostIds].sort().join(','), [hostIds]) const [tick, setTick] = useState(0) @@ -483,11 +488,21 @@ export function useAllHostClients(hostIds: string[]): Array<{ }, [key]) return useMemo(() => { - const out: Array<{ hostId: string; client: RpcClient; state: ConnectionState }> = [] + const out: Array<{ + hostId: string + client: RpcClient + state: ConnectionState + path: MobileConnectionPath + }> = [] for (const id of hostIds) { const all = ctx.getAllClients().find((entry) => entry.hostId === id) if (all) { - out.push({ hostId: id, client: all.client, state: ctx.getState(id) }) + out.push({ + hostId: id, + client: all.client, + state: ctx.getState(id), + path: ctx.getActivePath(id) + }) } } return out @@ -499,13 +514,13 @@ export function useAllHostClients(hostIds: string[]): Array<{ // host-store has no React-side handle. Expose a hook that lets callers // close a host after removal. export function useCloseHost(): (hostId: string) => void { - const ctx = useCtx() + const ctx = useRpcClientContext() return ctx.closeHost } // Why: future-proof "Connection issues — try again" affordance. export function useForceReconnect(): (hostId: string) => Promise { - const ctx = useCtx() + const ctx = useRpcClientContext() return ctx.forceReconnect } @@ -513,41 +528,11 @@ export function useForceReconnect(): (hostId: string) => Promise { // provider can skip its own loadHosts() pass when it eventually opens // each host — collapses two serial Keychain reads on cold-start into one. export function usePrimeHosts(): (hosts: HostProfile[]) => void { - const ctx = useCtx() + const ctx = useRpcClientContext() return ctx.primeHosts } -// Why: lets the home/host-detail UI escalate "Reconnecting…" to a more -// alarming "Can't connect" once the rpc-client has cycled enough times to -// indicate something's actually wrong (wrong port, server down, network -// loss). Reads through the context so it stays in sync with the live -// rpc-client instance even after forceReconnect swaps the underlying -// client. -export function useReconnectAttempt(hostId: string | undefined): number { - const ctx = useCtx() - const [, force] = useState(0) - useEffect(() => { - if (!hostId) { - return - } - return ctx.subscribeHostState(hostId, () => force((n) => n + 1)) - }, [ctx, hostId]) - return hostId ? ctx.getReconnectAttempt(hostId) : 0 -} - -// Why: timestamp of last successful connect for this host, or null if -// the client has never connected. Combined with reconnectAttempt this -// distinguishes "transient blip" (recently connected) from "host -// appears unreachable" (never connected, or hasn't connected in N -// seconds despite many retry attempts). -export function useLastConnectedAt(hostId: string | undefined): number | null { - const ctx = useCtx() - const [, force] = useState(0) - useEffect(() => { - if (!hostId) { - return - } - return ctx.subscribeHostState(hostId, () => force((n) => n + 1)) - }, [ctx, hostId]) - return hostId ? ctx.getLastConnectedAt(hostId) : null +function clientActivePath(client: RpcClient | undefined): MobileConnectionPath { + const logical = client as Partial | undefined + return typeof logical?.getActivePath === 'function' ? logical.getActivePath() : 'lan' } diff --git a/mobile/src/transport/host-logical-client.ts b/mobile/src/transport/host-logical-client.ts new file mode 100644 index 00000000000..7d90b32c8f2 --- /dev/null +++ b/mobile/src/transport/host-logical-client.ts @@ -0,0 +1,36 @@ +import { AppState, Platform } from 'react-native' +import { connect, type RpcClient } from './rpc-client' +import { createStableLogicalRpcClient } from './stable-logical-rpc-client' +import type { ConnectionLogSink, HostProfile } from './types' +import { directPathForEndpoint } from './mobile-direct-endpoint-probe' +import { startMobileEndpointLifecycle } from './mobile-endpoint-lifecycle' + +export function openHostLogicalClient(host: HostProfile, onLog: ConnectionLogSink): RpcClient { + // Why: the stable facade owns app-visible RPC/subscription state while the + // direct socket remains a replaceable first physical generation. + const logical = createStableLogicalRpcClient( + connect(host.endpoint, host.deviceToken, host.publicKeyB64, { onLog }), + directPathForEndpoint(host, host.endpoint) + ) + if (Platform.OS === 'web') { + return logical + } + + const endpointLifecycle = startMobileEndpointLifecycle(logical, host, onLog) + endpointLifecycle.setForeground(AppState.currentState === 'active') + const appStateSubscription = AppState.addEventListener('change', (state) => { + endpointLifecycle.setForeground(state === 'active') + }) + const closeLogical = logical.close + logical.close = () => { + appStateSubscription.remove() + endpointLifecycle.stop() + closeLogical() + } + const notifyLogicalForeground = logical.notifyForeground + logical.notifyForeground = () => { + endpointLifecycle.setForeground(true) + notifyLogicalForeground() + } + return logical +} diff --git a/mobile/src/transport/host-store.test.ts b/mobile/src/transport/host-store.test.ts index 1f2c878271a..a1aa3d677a1 100644 --- a/mobile/src/transport/host-store.test.ts +++ b/mobile/src/transport/host-store.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { MobileRelayHostOverlay } from './mobile-relay-host-overlay' const asyncStorageMock = vi.hoisted(() => ({ getItem: vi.fn(), @@ -32,9 +33,19 @@ vi.mock('./host-credential-cleanup', () => ({ retryPendingHostCredentialCleanups: vi.fn() })) -import { removeHost, renameHost, resetHostStoreForTests, updateLastConnected } from './host-store' +import { + loadHosts, + MobileRelayUpgradeHostRemovedError, + removeHost, + renameHost, + resetHostStoreForTests, + saveExistingHostRelayUpgrade, + updateLastConnected +} from './host-store' +import { resetMobileRelayHostOverlayStoreForTests } from './mobile-relay-host-overlay-store' const HOSTS_STORAGE_KEY = 'orca:hosts' +const OVERLAY_STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2' const HOST_ONE = { id: 'host-1', name: 'Host 1', @@ -52,17 +63,23 @@ const HOST_TWO = { describe('host-store list mutations', () => { let storedHostsRaw: string + let storedOverlayRaw: string | null beforeEach(() => { vi.clearAllMocks() resetHostStoreForTests() + resetMobileRelayHostOverlayStoreForTests() scheduleCleanupMock.mockReset() scheduleCleanupMock.mockResolvedValue(undefined) storedHostsRaw = JSON.stringify([HOST_ONE, HOST_TWO]) + storedOverlayRaw = null asyncStorageMock.getItem.mockImplementation(async (key: string) => { if (key === HOSTS_STORAGE_KEY) { return storedHostsRaw } + if (key === OVERLAY_STORAGE_KEY) { + return storedOverlayRaw + } return null }) asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => { @@ -70,6 +87,9 @@ describe('host-store list mutations', () => { storedHostsRaw = raw } }) + secureStoreMock.getItemAsync.mockImplementation(async (key: string) => + key.endsWith(HOST_ONE.id) || key.endsWith(HOST_TWO.id) ? `token-${key.at(-1)}` : null + ) }) it('commits the removal when credential cleanup scheduling rejects', async () => { @@ -81,6 +101,51 @@ describe('host-store list mutations', () => { expect(scheduleCleanupMock).toHaveBeenCalledWith(HOST_ONE.id, expect.any(Function)) }) + it('merges v2 endpoints only onto an existing legacy base host', async () => { + const overlay: MobileRelayHostOverlay = { + v: 2, + hostId: HOST_ONE.id, + endpoints: [ + { id: 'direct-primary', kind: 'lan', url: HOST_ONE.endpoint }, + { + id: 'relay-primary', + kind: 'relay', + url: 'wss://relay-c1.onorca.dev/v1/connect/AbCdEf0123_-xyZ9' + } + ], + relayHostId: 'AbCdEf0123_-xyZ9', + relay: { + v: 1, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + e2eeFraming: 2 + } + } + storedOverlayRaw = JSON.stringify([overlay, { ...overlay, hostId: 'removed-by-old-build' }]) + + const hosts = await loadHosts() + + expect(hosts.find(({ id }) => id === HOST_ONE.id)).toMatchObject({ + endpoints: overlay.endpoints, + relayHostId: overlay.relayHostId, + relay: overlay.relay + }) + expect(hosts.some(({ id }) => id === 'removed-by-old-build')).toBe(false) + }) + + it('refuses to resurrect a removed host during relay upgrade publication', async () => { + storedHostsRaw = JSON.stringify([HOST_TWO]) + + await expect( + saveExistingHostRelayUpgrade({ ...HOST_ONE, deviceToken: 'token-1' }) + ).rejects.toBeInstanceOf(MobileRelayUpgradeHostRemovedError) + + expect(JSON.parse(storedHostsRaw)).toEqual([HOST_TWO]) + expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled() + }) + it('awaits cleanup scheduling after metadata commit', async () => { let resolveSchedule: (() => void) | null = null scheduleCleanupMock.mockReturnValue( diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index e37b0427d8c..a17e6f57a1b 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -12,6 +12,14 @@ import { retryPendingHostCredentialCleanups, scheduleHostCredentialCleanup } from './host-credential-cleanup' +import { + loadMobileRelayHostOverlayState, + removeMobileRelayHostOverlay, + saveMobileRelayHostOverlay +} from './mobile-relay-host-overlay-store' +import { deleteMobileRelayCredentialBundle } from './mobile-relay-credential-bundle' +import { deleteMobileRelayDirectUpgradeJournal } from './mobile-relay-direct-upgrade-journal' +import { scheduleOrphanedMobileRelayCleanup } from './mobile-relay-orphan-cleanup' const STORAGE_KEY = 'orca:hosts' // Why: SecureStore keys must match [A-Za-z0-9._-]; colons are rejected. @@ -61,6 +69,12 @@ async function deleteDeviceToken(hostId: string): Promise { await SecureStore.deleteItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS) } +async function deleteHostCredentials(hostId: string): Promise { + await deleteDeviceToken(hostId) + await deleteMobileRelayCredentialBundle(hostId) + await deleteMobileRelayDirectUpgradeJournal(hostId) +} + // Why: SecureStore reads on Android Keystore can take 50-200ms each, and // loadHosts() is called from every screen mount + every useFocusEffect. // Stack with N hosts and you get N*200ms blocking every navigation, which @@ -120,6 +134,14 @@ async function doLoadHosts(): Promise { if (!storedHosts) { return [] } + const overlayState = await loadMobileRelayHostOverlayState( + new Set(storedHosts.map(({ id }) => id)) + ) + await scheduleOrphanedMobileRelayCleanup({ + hostIds: overlayState.orphanHostIds, + deleteCredential: deleteHostCredentials + }) + const overlays = overlayState.overlays const out: HostProfile[] = [] for (const stored of storedHosts) { @@ -144,7 +166,18 @@ async function doLoadHosts(): Promise { token = fetched tokenCache.set(stored.id, token) } - out.push({ ...stored, deviceToken: token }) + const overlay = overlays.get(stored.id) + out.push({ + ...stored, + deviceToken: token, + ...(overlay + ? { + endpoints: overlay.endpoints, + relayHostId: overlay.relayHostId, + relay: overlay.relay + } + : {}) + }) } return out } @@ -196,7 +229,17 @@ function toStored(host: HostProfile): StoredHostProfile { } } +export class MobileRelayUpgradeHostRemovedError extends Error {} + export async function saveHost(host: HostProfile): Promise { + await persistHost(host, false) +} + +export async function saveExistingHostRelayUpgrade(host: HostProfile): Promise { + await persistHost(host, true) +} + +async function persistHost(host: HostProfile, requireExisting: boolean): Promise { const validated = HostProfileSchema.parse(host) const stored = toStored(validated) await mutateStoredHosts((hosts) => { @@ -206,6 +249,10 @@ export async function saveHost(host: HostProfile): Promise { next[index] = stored return next } + if (requireExisting) { + // Why: an in-flight relay upgrade must not resurrect a host the user removed. + throw new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed') + } return [...hosts, stored] }) // Why: write metadata BEFORE the keychain token so a crash between the two @@ -215,15 +262,30 @@ export async function saveHost(host: HostProfile): Promise { // from current metadata. await writeDeviceToken(stored.id, validated.deviceToken) tokenCache.set(stored.id, validated.deviceToken) + if (validated.endpoints) { + await saveMobileRelayHostOverlay({ + v: 2, + hostId: stored.id, + endpoints: validated.endpoints, + relayHostId: validated.relayHostId, + relay: validated.relay + }) + } } export async function removeHost(hostId: string): Promise { await mutateStoredHosts((hosts) => hosts.filter((h) => h.id !== hostId)) tokenCache.delete(hostId) + try { + await removeMobileRelayHostOverlay(hostId) + } catch { + // The missing legacy base is authoritative, so a retained overlay cannot + // resurrect this host and can be cleaned on a later explicit retry. + } // Why: await only the durable cleanup intent (AsyncStorage). Native keychain // delete can reject or stall and must not freeze removeHost / the UI. try { - await scheduleHostCredentialCleanup(hostId, deleteDeviceToken) + await scheduleHostCredentialCleanup(hostId, deleteHostCredentials) } catch { // Metadata is already committed; orphan-token recovery is best-effort. } @@ -234,7 +296,7 @@ export async function retryPendingHostCredentialCleanup(): Promise<{ remainingIds: string[] storageUnreadable: boolean }> { - return retryPendingHostCredentialCleanups(deleteDeviceToken) + return retryPendingHostCredentialCleanups(deleteHostCredentials) } export async function renameHost(hostId: string, newName: string): Promise { diff --git a/mobile/src/transport/mobile-connection-path-label.test.ts b/mobile/src/transport/mobile-connection-path-label.test.ts new file mode 100644 index 00000000000..e65842a8869 --- /dev/null +++ b/mobile/src/transport/mobile-connection-path-label.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest' +import { mobileConnectionPathLabel } from './mobile-connection-path-label' + +describe('mobile connection path label', () => { + it('distinguishes LAN, Tailscale, and the relay without exposing transport errors', () => { + expect(mobileConnectionPathLabel('lan')).toBe('Direct · LAN') + expect(mobileConnectionPathLabel('tailscale')).toBe('Direct · Tailscale') + expect(mobileConnectionPathLabel('relay')).toBe('Orca Relay') + }) +}) diff --git a/mobile/src/transport/mobile-connection-path-label.ts b/mobile/src/transport/mobile-connection-path-label.ts new file mode 100644 index 00000000000..0df114e85a7 --- /dev/null +++ b/mobile/src/transport/mobile-connection-path-label.ts @@ -0,0 +1,8 @@ +import type { MobileConnectionPath } from './stable-logical-rpc-client' + +export function mobileConnectionPathLabel(path: MobileConnectionPath): string { + if (path === 'relay') { + return 'Orca Relay' + } + return path === 'tailscale' ? 'Direct · Tailscale' : 'Direct · LAN' +} diff --git a/mobile/src/transport/mobile-direct-endpoint-probe.ts b/mobile/src/transport/mobile-direct-endpoint-probe.ts new file mode 100644 index 00000000000..99bd4e858f6 --- /dev/null +++ b/mobile/src/transport/mobile-direct-endpoint-probe.ts @@ -0,0 +1,71 @@ +import type { RpcClient } from './rpc-client' +import type { MobileConnectionPath } from './stable-logical-rpc-client' +import type { HostProfile } from './types' + +function directEndpointUrls(host: HostProfile): string[] { + const endpoints = + host.endpoints?.filter(({ kind }) => kind !== 'relay').map(({ url }) => url) ?? [] + return [...new Set([host.endpoint, ...endpoints])] +} + +export function directPathForEndpoint( + host: HostProfile, + endpoint: string +): Exclude { + const configured = host.endpoints?.find((candidate) => candidate.url === endpoint) + if (configured?.kind === 'tailscale') { + return 'tailscale' + } + try { + const hostname = new URL(endpoint).hostname + if (hostname.endsWith('.ts.net') || /^100\.(?:\d{1,3}\.){2}\d{1,3}$/.test(hostname)) { + return 'tailscale' + } + } catch {} + return 'lan' +} + +function waitForAuthenticatedSession(session: RpcClient, timeoutMs: number): Promise { + if (session.getState() === 'connected') { + return Promise.resolve() + } + return new Promise((resolve, reject) => { + let timer: ReturnType | null = null + const unsubscribe = session.onStateChange((state) => { + if (state === 'connected') { + finish() + resolve() + } else if (state === 'disconnected' || state === 'auth-failed') { + finish() + reject(new Error(`probe session ${state}`)) + } + }) + timer = setTimeout(() => { + finish() + reject(new Error('probe session authentication timed out')) + }, timeoutMs) + function finish(): void { + if (timer) { + clearTimeout(timer) + } + unsubscribe() + } + }) +} + +export async function openAuthenticatedDirectEndpoint( + host: HostProfile, + openDirect: (endpoint: string) => RpcClient, + timeoutMs: number +): Promise<{ client: RpcClient; path: Exclude } | null> { + for (const endpoint of directEndpointUrls(host)) { + const client = openDirect(endpoint) + try { + await waitForAuthenticatedSession(client, timeoutMs) + return { client, path: directPathForEndpoint(host, endpoint) } + } catch { + client.close() + } + } + return null +} diff --git a/mobile/src/transport/mobile-e2ee-legacy-fixtures.test.ts b/mobile/src/transport/mobile-e2ee-legacy-fixtures.test.ts new file mode 100644 index 00000000000..7e0628960e1 --- /dev/null +++ b/mobile/src/transport/mobile-e2ee-legacy-fixtures.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest' +import nacl from 'tweetnacl' +import { MOBILE_E2EE_LEGACY_FIXTURE } from '../../../src/shared/mobile-e2ee-legacy-fixtures' + +vi.mock('expo-crypto', () => ({ + getRandomBytes: (length: number) => new Uint8Array(length).fill(9) +})) + +import { decrypt, decryptBytes, deriveSharedKey } from './e2ee' + +describe('mobile legacy E2EE fixtures', () => { + it('matches the captured desktop key and text/binary frames', () => { + const fixture = MOBILE_E2EE_LEGACY_FIXTURE + const server = nacl.box.keyPair.fromSecretKey(fixture.serverSecretKey) + const client = nacl.box.keyPair.fromSecretKey(fixture.clientSecretKey) + const shared = deriveSharedKey(client.secretKey, server.publicKey) + + expect(hex(shared)).toBe(fixture.sharedKeyHex) + expect(decrypt(fixture.authFrameB64, shared)).toBe(fixture.authPlaintext) + expect(decryptBytes(fromHex(fixture.binaryFrameHex), shared)).toEqual(fixture.binaryPlaintext) + }) +}) + +function hex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') +} + +function fromHex(value: string): Uint8Array { + return Uint8Array.from(value.match(/../g) ?? [], (byte) => Number.parseInt(byte, 16)) +} diff --git a/mobile/src/transport/mobile-e2ee-v2-client-session.test.ts b/mobile/src/transport/mobile-e2ee-v2-client-session.test.ts new file mode 100644 index 00000000000..6cff0ef3523 --- /dev/null +++ b/mobile/src/transport/mobile-e2ee-v2-client-session.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from 'vitest' +import nacl from 'tweetnacl' +import { + encodeMobileE2EEV2Transcript, + validateMobileE2EEV2Handshake, + type MobileE2EEV2Ready +} from '../../../src/shared/mobile-e2ee-v2-contract' +import { sealMobileE2EEV2Frame } from '../../../src/shared/mobile-e2ee-v2-framing' + +vi.mock('expo-crypto', () => ({ + getRandomBytes: (length: number) => new Uint8Array(length).fill(9) +})) + +import { deriveSharedKey } from './e2ee' +import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session' +import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule' + +const desktop = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1)) +const client = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2)) + +function setup() { + const session = MobileE2EEV2ClientSession.create({ + desktopPublicKeyB64: Buffer.from(desktop.publicKey).toString('base64'), + transport: 'relay', + relayHostId: 'AbCdEf0123_-xyZ9', + clientNonce: new Uint8Array(32).fill(3), + clientKeyPair: client + }) + const ready: MobileE2EEV2Ready = { + type: 'e2ee_ready', + v: 2, + desktopPublicKeyB64: Buffer.from(desktop.publicKey).toString('base64'), + clientNonceB64: session.hello.clientNonceB64, + desktopNonceB64: Buffer.from(new Uint8Array(32).fill(4)).toString('base64'), + selection: { framing: 2, payloadKinds: ['text', 'binary'] }, + context: session.hello.context + } + return { session, ready } +} + +describe('mobile E2EE v2 client session', () => { + it('pins the desktop key and accepts the exact transcript', () => { + const { session, ready } = setup() + expect(session.acceptReady(ready)).toBe(true) + expect(session.transcriptHashB64).toHaveLength(44) + expect( + session.acceptReady({ + ...ready, + desktopPublicKeyB64: Buffer.from(new Uint8Array(32).fill(8)).toString('base64') + }) + ).toBe(false) + }) + + it('seals auth at counter zero and rejects replayed desktop frames', () => { + const { session, ready } = setup() + expect(session.acceptReady(ready)).toBe(true) + const auth = JSON.stringify({ + type: 'e2ee_auth', + v: 2, + transcriptHashB64: session.transcriptHashB64, + deviceToken: 'token' + }) + const authFrame = Buffer.from(session.sealText(auth), 'base64') + expect(authFrame.subarray(16, 24)).toEqual(Buffer.alloc(8, 0)) + + const handshake = validateMobileE2EEV2Handshake(session.hello, ready)! + const schedule = deriveMobileE2EEV2KeySchedule({ + sharedSecret: deriveSharedKey(desktop.secretKey, client.publicKey), + transcript: encodeMobileE2EEV2Transcript(handshake), + clientNonce: handshake.clientNonce, + desktopNonce: handshake.desktopNonce + }) + const response = sealMobileE2EEV2Frame({ + payload: new TextEncoder().encode('authenticated'), + key: schedule.desktopToMobileKey, + sessionId: schedule.sessionId, + direction: 'desktop-to-mobile', + payloadKind: 'text', + counter: 0n + }) + const encoded = Buffer.from(response).toString('base64') + expect(session.openText(encoded)).toBe('authenticated') + expect(session.openText(encoded)).toBeNull() + }) +}) diff --git a/mobile/src/transport/mobile-e2ee-v2-client-session.ts b/mobile/src/transport/mobile-e2ee-v2-client-session.ts new file mode 100644 index 00000000000..1cce2946860 --- /dev/null +++ b/mobile/src/transport/mobile-e2ee-v2-client-session.ts @@ -0,0 +1,167 @@ +import * as ExpoCrypto from 'expo-crypto' +import { + encodeMobileE2EEV2Transcript, + validateMobileE2EEV2Handshake, + type MobileE2EETransport, + type MobileE2EEV2Hello +} from '../../../src/shared/mobile-e2ee-v2-contract' +import { + openMobileE2EEV2Frame, + sealMobileE2EEV2Frame +} from '../../../src/shared/mobile-e2ee-v2-framing' +import { deriveSharedKey, generateKeyPair, publicKeyFromBase64, publicKeyToBase64 } from './e2ee' +import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule' + +export class MobileE2EEV2ClientSession { + readonly hello: MobileE2EEV2Hello + private inboundCounter = 0n + private outboundCounter = 0n + private schedule: ReturnType | null = null + private transcriptHashB64Value: string | null = null + + private constructor( + private readonly clientSecretKey: Uint8Array, + private readonly pinnedDesktopPublicKey: Uint8Array, + hello: MobileE2EEV2Hello + ) { + this.hello = hello + } + + static create(args: { + desktopPublicKeyB64: string + transport: MobileE2EETransport + relayHostId?: string + clientNonce?: Uint8Array + clientKeyPair?: { publicKey: Uint8Array; secretKey: Uint8Array } + }): MobileE2EEV2ClientSession { + const keyPair = args.clientKeyPair ?? generateKeyPair() + const clientNonce = args.clientNonce ?? ExpoCrypto.getRandomBytes(32) + if (clientNonce.length !== 32) { + throw new Error(`Invalid client nonce length: ${clientNonce.length}`) + } + return new MobileE2EEV2ClientSession( + keyPair.secretKey, + publicKeyFromBase64(args.desktopPublicKeyB64), + { + type: 'e2ee_hello', + v: 2, + clientPublicKeyB64: publicKeyToBase64(keyPair.publicKey), + clientNonceB64: encodeBase64(clientNonce), + capabilities: { framing: [2], payloadKinds: ['text', 'binary'] }, + context: { + protocol: 'orca-mobile-e2ee', + initiator: 'mobile', + responder: 'desktop', + transport: args.transport, + ...(args.relayHostId ? { relayHostId: args.relayHostId } : {}) + } + } + ) + } + + acceptReady(ready: unknown): boolean { + const handshake = validateMobileE2EEV2Handshake(this.hello, ready) + if (!handshake || !equalBytes(handshake.desktopPublicKey, this.pinnedDesktopPublicKey)) { + return false + } + this.schedule = deriveMobileE2EEV2KeySchedule({ + sharedSecret: deriveSharedKey(this.clientSecretKey, this.pinnedDesktopPublicKey), + transcript: encodeMobileE2EEV2Transcript(handshake), + clientNonce: handshake.clientNonce, + desktopNonce: handshake.desktopNonce + }) + this.transcriptHashB64Value = encodeBase64(this.schedule.transcriptHash) + return true + } + + get transcriptHashB64(): string { + if (!this.transcriptHashB64Value) { + throw new Error('E2EE v2 ready has not been accepted') + } + return this.transcriptHashB64Value + } + + openText(frameB64: string): string | null { + const frame = decodeCanonicalBase64(frameB64) + if (!frame) { + return null + } + const plaintext = this.open(frame, 'text') + return plaintext ? new TextDecoder().decode(plaintext) : null + } + + openBinary(frame: Uint8Array): Uint8Array | null { + return this.open(frame, 'binary') + } + + sealText(plaintext: string): string { + return encodeBase64(this.seal(new TextEncoder().encode(plaintext), 'text')) + } + + sealBinary(plaintext: Uint8Array): Uint8Array { + return this.seal(plaintext, 'binary') + } + + private open(frame: Uint8Array, payloadKind: 'text' | 'binary'): Uint8Array | null { + if (!this.schedule) { + return null + } + const plaintext = openMobileE2EEV2Frame({ + frame, + key: this.schedule.desktopToMobileKey, + sessionId: this.schedule.sessionId, + direction: 'desktop-to-mobile', + payloadKind, + expectedCounter: this.inboundCounter + }) + if (plaintext) { + this.inboundCounter++ + } + return plaintext + } + + private seal(plaintext: Uint8Array, payloadKind: 'text' | 'binary'): Uint8Array { + if (!this.schedule) { + throw new Error('E2EE v2 ready has not been accepted') + } + const frame = sealMobileE2EEV2Frame({ + payload: plaintext, + key: this.schedule.mobileToDesktopKey, + sessionId: this.schedule.sessionId, + direction: 'mobile-to-desktop', + payloadKind, + counter: this.outboundCounter + }) + this.outboundCounter++ + return frame + } +} + +function encodeBase64(bytes: Uint8Array): string { + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return btoa(binary) +} + +function decodeCanonicalBase64(value: string): Uint8Array | null { + try { + const binary = atob(value) + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)) + return encodeBase64(bytes) === value ? bytes : null + } catch { + return null + } +} + +function equalBytes(left: Uint8Array, right: Uint8Array): boolean { + if (left.length !== right.length) { + return false + } + let difference = 0 + for (let index = 0; index < left.length; index++) { + difference |= left[index]! ^ right[index]! + } + return difference === 0 +} diff --git a/mobile/src/transport/mobile-e2ee-v2-key-schedule.test.ts b/mobile/src/transport/mobile-e2ee-v2-key-schedule.test.ts new file mode 100644 index 00000000000..7a3dcb903ef --- /dev/null +++ b/mobile/src/transport/mobile-e2ee-v2-key-schedule.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { + encodeMobileE2EEV2Transcript, + validateMobileE2EEV2Handshake +} from '../../../src/shared/mobile-e2ee-v2-contract' +import { + createMobileE2EEV2Fixture, + MOBILE_E2EE_V2_VECTOR +} from '../../../src/shared/mobile-e2ee-v2-fixtures' +import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule' + +function hex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') +} + +describe('mobile E2EE v2 key schedule', () => { + it('matches the desktop normative HKDF vector', () => { + const { hello, ready, sharedSecret } = createMobileE2EEV2Fixture() + const handshake = validateMobileE2EEV2Handshake(hello, ready)! + const schedule = deriveMobileE2EEV2KeySchedule({ + sharedSecret, + transcript: encodeMobileE2EEV2Transcript(handshake), + clientNonce: handshake.clientNonce, + desktopNonce: handshake.desktopNonce + }) + + expect(hex(schedule.mobileToDesktopKey)).toBe(MOBILE_E2EE_V2_VECTOR.mobileToDesktopKeyHex) + expect(hex(schedule.desktopToMobileKey)).toBe(MOBILE_E2EE_V2_VECTOR.desktopToMobileKeyHex) + expect(hex(schedule.sessionId)).toBe(MOBILE_E2EE_V2_VECTOR.sessionIdHex) + expect(hex(schedule.transcriptHash)).toBe(MOBILE_E2EE_V2_VECTOR.transcriptHashHex) + }) + + it('derives unique direction keys and session IDs across fresh client nonces', () => { + const { hello, ready, sharedSecret } = createMobileE2EEV2Fixture() + const fingerprints = new Set() + for (let index = 0; index < 128; index++) { + const nonce = new Uint8Array(32) + new DataView(nonce.buffer).setUint32(28, index, false) + const clientNonceB64 = Buffer.from(nonce).toString('base64') + const handshake = validateMobileE2EEV2Handshake( + { ...hello, clientNonceB64 }, + { ...ready, clientNonceB64 } + )! + const schedule = deriveMobileE2EEV2KeySchedule({ + sharedSecret, + transcript: encodeMobileE2EEV2Transcript(handshake), + clientNonce: handshake.clientNonce, + desktopNonce: handshake.desktopNonce + }) + fingerprints.add( + [schedule.mobileToDesktopKey, schedule.desktopToMobileKey, schedule.sessionId] + .map(hex) + .join(':') + ) + } + expect(fingerprints.size).toBe(128) + }) +}) diff --git a/mobile/src/transport/mobile-e2ee-v2-key-schedule.ts b/mobile/src/transport/mobile-e2ee-v2-key-schedule.ts new file mode 100644 index 00000000000..d7a0101693e --- /dev/null +++ b/mobile/src/transport/mobile-e2ee-v2-key-schedule.ts @@ -0,0 +1,48 @@ +import { hkdf } from '@noble/hashes/hkdf' +import { sha256 } from '@noble/hashes/sha256' + +const SALT_LABEL = new TextEncoder().encode('orca-mobile-e2ee/v2/salt\0') +const INFO_LABEL = new TextEncoder().encode('orca-mobile-e2ee/v2/session\0') + +export function deriveMobileE2EEV2KeySchedule(args: { + sharedSecret: Uint8Array + transcript: Uint8Array + clientNonce: Uint8Array + desktopNonce: Uint8Array +}): { + mobileToDesktopKey: Uint8Array + desktopToMobileKey: Uint8Array + sessionId: Uint8Array + transcriptHash: Uint8Array +} { + requireLength(args.sharedSecret, 32, 'shared secret') + requireLength(args.clientNonce, 32, 'client nonce') + requireLength(args.desktopNonce, 32, 'desktop nonce') + + const transcriptHash = sha256(args.transcript) + const salt = sha256(concatBytes([SALT_LABEL, args.clientNonce, args.desktopNonce])) + const info = concatBytes([INFO_LABEL, transcriptHash]) + const expanded = hkdf(sha256, args.sharedSecret, salt, info, 96) + return { + mobileToDesktopKey: expanded.slice(0, 32), + desktopToMobileKey: expanded.slice(32, 64), + sessionId: expanded.slice(64, 96), + transcriptHash + } +} + +function concatBytes(parts: readonly Uint8Array[]): Uint8Array { + const result = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)) + let offset = 0 + for (const part of parts) { + result.set(part, offset) + offset += part.length + } + return result +} + +function requireLength(bytes: Uint8Array, expected: number, label: string): void { + if (bytes.length !== expected) { + throw new Error(`Invalid ${label}: expected ${expected} bytes, got ${bytes.length}`) + } +} diff --git a/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts b/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts new file mode 100644 index 00000000000..a7aae9e6d0f --- /dev/null +++ b/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts @@ -0,0 +1,208 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import nacl from 'tweetnacl' +import { + encodeMobileE2EEV2Transcript, + validateMobileE2EEV2Handshake, + type MobileE2EEV2Ready +} from '../../../src/shared/mobile-e2ee-v2-contract' +import { + openMobileE2EEV2Frame, + sealMobileE2EEV2Frame +} from '../../../src/shared/mobile-e2ee-v2-framing' + +vi.mock('expo-crypto', () => ({ + getRandomBytes: (length: number) => new Uint8Array(length).fill(9) +})) + +import { deriveSharedKey } from './e2ee' +import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session' +import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule' +import { + MobileE2EEAuthenticationError, + MobileE2EEV2PhysicalChannel, + type MobileE2EEV2Socket +} from './mobile-e2ee-v2-physical-channel' + +const desktop = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1)) +const client = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2)) + +function setup(decodeBinary: (raw: unknown) => Promise) { + const session = MobileE2EEV2ClientSession.create({ + desktopPublicKeyB64: Buffer.from(desktop.publicKey).toString('base64'), + transport: 'relay', + relayHostId: 'AbCdEf0123_-xyZ9', + clientNonce: new Uint8Array(32).fill(3), + clientKeyPair: client + }) + const sent: (string | Uint8Array)[] = [] + const socket = { + OPEN: 1, + readyState: 1, + bufferedAmount: 0, + send: (frame: string | Uint8Array) => sent.push(frame) + } satisfies MobileE2EEV2Socket + const events: string[] = [] + const onAuthenticated = vi.fn(() => events.push('authenticated')) + const onError = vi.fn() + const channel = new MobileE2EEV2PhysicalChannel({ + session, + socket, + deviceToken: 'valid-token', + decodeBinary, + onAuthenticated, + onText: (plaintext) => events.push(`text:${plaintext}`), + onBinary: (plaintext) => events.push(`binary:${plaintext[0]}`), + onError + }) + channel.start() + + const ready: MobileE2EEV2Ready = { + type: 'e2ee_ready', + v: 2, + desktopPublicKeyB64: Buffer.from(desktop.publicKey).toString('base64'), + clientNonceB64: session.hello.clientNonceB64, + desktopNonceB64: Buffer.from(new Uint8Array(32).fill(4)).toString('base64'), + selection: { framing: 2, payloadKinds: ['text', 'binary'] }, + context: session.hello.context + } + const handshake = validateMobileE2EEV2Handshake(session.hello, ready)! + const schedule = deriveMobileE2EEV2KeySchedule({ + sharedSecret: deriveSharedKey(desktop.secretKey, client.publicKey), + transcript: encodeMobileE2EEV2Transcript(handshake), + clientNonce: handshake.clientNonce, + desktopNonce: handshake.desktopNonce + }) + return { channel, session, socket, sent, events, onAuthenticated, onError, ready, schedule } +} + +function serverFrame( + payload: Uint8Array, + kind: 'text' | 'binary', + counter: bigint, + schedule: ReturnType['schedule'] +): Uint8Array { + return sealMobileE2EEV2Frame({ + payload, + key: schedule.desktopToMobileKey, + sessionId: schedule.sessionId, + direction: 'desktop-to-mobile', + payloadKind: kind, + counter + }) +} + +async function authenticate(ctx: ReturnType): Promise { + await ctx.channel.handleMessage(JSON.stringify(ctx.ready)) + const response = serverFrame( + new TextEncoder().encode( + JSON.stringify({ + type: 'e2ee_authenticated', + v: 2, + transcriptHashB64: ctx.session.transcriptHashB64 + }) + ), + 'text', + 0n, + ctx.schedule + ) + await ctx.channel.handleMessage(Buffer.from(response).toString('base64')) +} + +describe('mobile E2EE v2 physical channel', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('sends hello/auth and confirms the exact transcript', async () => { + const ctx = setup(async () => null) + await authenticate(ctx) + + expect(JSON.parse(ctx.sent[0] as string)).toEqual(ctx.session.hello) + expect(typeof ctx.sent[1]).toBe('string') + expect(ctx.onAuthenticated).toHaveBeenCalledOnce() + expect(ctx.onError).not.toHaveBeenCalled() + }) + + it('classifies the encrypted desktop device-token rejection as global auth failure', async () => { + const ctx = setup(async () => null) + await ctx.channel.handleMessage(JSON.stringify(ctx.ready)) + const rejection = serverFrame( + new TextEncoder().encode( + JSON.stringify({ type: 'e2ee_error', error: { code: 'unauthorized' } }) + ), + 'text', + 0n, + ctx.schedule + ) + + await ctx.channel.handleMessage(Buffer.from(rejection).toString('base64')) + + expect(ctx.onError.mock.calls[0]![0]).toBeInstanceOf(MobileE2EEAuthenticationError) + expect(ctx.onAuthenticated).not.toHaveBeenCalled() + }) + + it('serializes delayed binary conversion before a later text counter', async () => { + let releaseBinary!: (bytes: Uint8Array) => void + const pendingBinary = new Promise((resolve) => (releaseBinary = resolve)) + const ctx = setup(async () => pendingBinary) + await authenticate(ctx) + ctx.events.length = 0 + + const binary = serverFrame(new Uint8Array([7]), 'binary', 1n, ctx.schedule) + const text = serverFrame(new TextEncoder().encode('later'), 'text', 2n, ctx.schedule) + const first = ctx.channel.handleMessage({ delayedBlob: true }) + const second = ctx.channel.handleMessage(Buffer.from(text).toString('base64')) + await Promise.resolve() + expect(ctx.events).toEqual([]) + + releaseBinary(binary) + await Promise.all([first, second]) + expect(ctx.events).toEqual(['binary:7', 'text:later']) + expect(ctx.onError).not.toHaveBeenCalled() + }) + + it('queues outbound text and binary in one counter order', async () => { + const ctx = setup(async () => null) + await authenticate(ctx) + ctx.socket.bufferedAmount = 9 * 1024 * 1024 + expect(ctx.channel.sendText('one')).toBe(true) + expect(ctx.channel.sendBinary(new Uint8Array([2]))).toBe(true) + expect(ctx.sent).toHaveLength(2) + + ctx.socket.bufferedAmount = 0 + vi.runOnlyPendingTimers() + expect(typeof ctx.sent[2]).toBe('string') + expect(ctx.sent[3]).toBeInstanceOf(Uint8Array) + expect( + openMobileE2EEV2Frame({ + frame: Buffer.from(ctx.sent[2] as string, 'base64'), + key: ctx.schedule.mobileToDesktopKey, + sessionId: ctx.schedule.sessionId, + direction: 'mobile-to-desktop', + payloadKind: 'text', + expectedCounter: 1n + }) + ).toEqual(new TextEncoder().encode('one')) + expect( + openMobileE2EEV2Frame({ + frame: ctx.sent[3] as Uint8Array, + key: ctx.schedule.mobileToDesktopKey, + sessionId: ctx.schedule.sessionId, + direction: 'mobile-to-desktop', + payloadKind: 'binary', + expectedCounter: 2n + }) + ).toEqual(new Uint8Array([2])) + }) + + it('bounds the unified outbound queue and reports a wedged link', async () => { + const ctx = setup(async () => null) + await authenticate(ctx) + ctx.socket.bufferedAmount = 9 * 1024 * 1024 + const megabyte = new Uint8Array(1024 * 1024) + for (let index = 0; index < 65; index++) { + expect(ctx.channel.sendBinary(megabyte)).toBe(true) + } + expect(ctx.onError).toHaveBeenCalledOnce() + expect(ctx.onError.mock.calls[0]![0].message).toBe('E2EE v2 outbound buffer overflow') + }) +}) diff --git a/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts b/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts new file mode 100644 index 00000000000..ec3b8088915 --- /dev/null +++ b/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts @@ -0,0 +1,185 @@ +import { + createWsOutboundBackpressureQueue, + type WsOutboundBackpressureQueue +} from '../../../src/shared/ws-outbound-backpressure-queue' +import type { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session' + +type ChannelState = 'awaiting-ready' | 'awaiting-authenticated' | 'ready' +type OutboundItem = { kind: 'text'; plaintext: string } | { kind: 'binary'; plaintext: Uint8Array } + +export class MobileE2EEAuthenticationError extends Error { + constructor() { + super('E2EE device authentication rejected') + } +} + +export type MobileE2EEV2Socket = { + readonly OPEN: number + readonly readyState: number + readonly bufferedAmount: number + send: (frame: string | Uint8Array) => void +} + +export class MobileE2EEV2PhysicalChannel { + private state: ChannelState = 'awaiting-ready' + private generation = 0 + private inboundChain: Promise = Promise.resolve() + private readonly outboundQueue: WsOutboundBackpressureQueue + + constructor( + private readonly args: { + session: MobileE2EEV2ClientSession + socket: MobileE2EEV2Socket + deviceToken: string + decodeBinary: (raw: unknown) => Promise + onAuthenticated: () => void + onText: (plaintext: string) => void + onBinary: (plaintext: Uint8Array) => void + onError: (error: Error) => void + } + ) { + this.outboundQueue = createWsOutboundBackpressureQueue({ + // Why: encryption happens only when an admitted item reaches the wire, + // so a bounded-queue rejection cannot burn an ordered v2 counter. + send: (item) => { + args.socket.send( + item.kind === 'text' + ? args.session.sealText(item.plaintext) + : args.session.sealBinary(item.plaintext) + ) + }, + byteLengthOf: (item) => + (item.kind === 'text' + ? new TextEncoder().encode(item.plaintext).length + : item.plaintext.length) + 82, + getBufferedAmount: () => args.socket.bufferedAmount, + isWritable: () => args.socket.readyState === args.socket.OPEN, + onOverflow: () => args.onError(new Error('E2EE v2 outbound buffer overflow')) + }) + } + + start(): void { + this.args.socket.send(JSON.stringify(this.args.session.hello)) + } + + handleMessage(raw: unknown): Promise { + const generation = this.generation + this.inboundChain = this.inboundChain + .then(() => this.processMessage(raw, generation)) + .catch((error: unknown) => { + if (generation === this.generation) { + this.args.onError(error instanceof Error ? error : new Error(String(error))) + } + }) + return this.inboundChain + } + + sendText(plaintext: string): boolean { + return this.enqueueReady({ kind: 'text', plaintext }) + } + + sendBinary(plaintext: Uint8Array): boolean { + return this.enqueueReady({ kind: 'binary', plaintext }) + } + + dispose(): void { + this.generation++ + this.outboundQueue.dispose() + } + + private async processMessage(raw: unknown, generation: number): Promise { + if (generation !== this.generation) { + return + } + if (this.state === 'awaiting-ready') { + this.acceptReady(raw) + return + } + + const plaintext = + typeof raw === 'string' + ? this.args.session.openText(raw) + : await this.openBinary(raw, generation) + if (generation !== this.generation || plaintext === null) { + return + } + if (this.state === 'awaiting-authenticated') { + if (typeof plaintext === 'string' && isAuthenticationRejection(plaintext)) { + throw new MobileE2EEAuthenticationError() + } + if (typeof plaintext !== 'string' || !this.isAuthenticated(plaintext)) { + throw new Error('Invalid E2EE v2 authenticated response') + } + this.state = 'ready' + this.args.onAuthenticated() + } else if (typeof plaintext === 'string') { + this.args.onText(plaintext) + } else { + this.args.onBinary(plaintext) + } + } + + private acceptReady(raw: unknown): void { + if (typeof raw !== 'string') { + throw new Error('Expected plaintext E2EE v2 ready') + } + let ready: unknown + try { + ready = JSON.parse(raw) + } catch { + throw new Error('Invalid E2EE v2 ready JSON') + } + if (!this.args.session.acceptReady(ready)) { + throw new Error('Invalid E2EE v2 ready') + } + this.state = 'awaiting-authenticated' + this.outboundQueue.enqueue({ + kind: 'text', + plaintext: JSON.stringify({ + type: 'e2ee_auth', + v: 2, + transcriptHashB64: this.args.session.transcriptHashB64, + deviceToken: this.args.deviceToken + }) + }) + } + + private async openBinary(raw: unknown, generation: number): Promise { + const bytes = await this.args.decodeBinary(raw) + if (!bytes || generation !== this.generation) { + return null + } + return this.args.session.openBinary(bytes) + } + + private isAuthenticated(plaintext: string): boolean { + try { + const message = JSON.parse(plaintext) as Record + return ( + Object.keys(message).sort().join(',') === 'transcriptHashB64,type,v' && + message.type === 'e2ee_authenticated' && + message.v === 2 && + message.transcriptHashB64 === this.args.session.transcriptHashB64 + ) + } catch { + return false + } + } + + private enqueueReady(item: OutboundItem): boolean { + if (this.state !== 'ready') { + return false + } + this.outboundQueue.enqueue(item) + return true + } +} + +function isAuthenticationRejection(plaintext: string): boolean { + try { + const message = JSON.parse(plaintext) as Record + return message.type === 'e2ee_error' + } catch { + return false + } +} diff --git a/mobile/src/transport/mobile-endpoint-hysteresis.test.ts b/mobile/src/transport/mobile-endpoint-hysteresis.test.ts new file mode 100644 index 00000000000..560c801979b --- /dev/null +++ b/mobile/src/transport/mobile-endpoint-hysteresis.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis' + +const options = { + directSuccessesRequired: 3, + directObservationMs: 30_000, + failureCooldownMs: 60_000, + minimumDwellMs: 60_000 +} + +describe('mobile endpoint hysteresis', () => { + it('requires three authenticated direct successes across the observation and dwell windows', () => { + const policy = new MobileEndpointHysteresis(0, options) + + expect(policy.recordDirectSuccess(60_000)).toBe(false) + expect(policy.recordDirectSuccess(75_000)).toBe(false) + expect(policy.recordDirectSuccess(90_000)).toBe(true) + }) + + it('resets progress and observes cooldown after a failure', () => { + const policy = new MobileEndpointHysteresis(0, options) + policy.recordDirectSuccess(60_000) + policy.recordDirectFailure(61_000) + + expect(policy.canProbe(120_999)).toBe(false) + expect(policy.canProbe(121_000)).toBe(true) + expect(policy.recordDirectSuccess(121_000)).toBe(false) + expect(policy.recordDirectSuccess(136_000)).toBe(false) + expect(policy.recordDirectSuccess(151_000)).toBe(true) + }) +}) diff --git a/mobile/src/transport/mobile-endpoint-hysteresis.ts b/mobile/src/transport/mobile-endpoint-hysteresis.ts new file mode 100644 index 00000000000..2ce80d84691 --- /dev/null +++ b/mobile/src/transport/mobile-endpoint-hysteresis.ts @@ -0,0 +1,52 @@ +export type EndpointHysteresisOptions = { + directSuccessesRequired: number + directObservationMs: number + failureCooldownMs: number + minimumDwellMs: number +} + +export class MobileEndpointHysteresis { + private consecutiveDirectSuccesses = 0 + private directObservationStartedAt: number | null = null + private cooldownUntil = 0 + private lastMigrationAt: number + + constructor( + startedAt: number, + private readonly options: EndpointHysteresisOptions + ) { + this.lastMigrationAt = startedAt + } + + recordDirectSuccess(now: number): boolean { + if (now < this.cooldownUntil) { + return false + } + if (this.consecutiveDirectSuccesses === 0) { + this.directObservationStartedAt = now + } + this.consecutiveDirectSuccesses += 1 + return ( + this.consecutiveDirectSuccesses >= this.options.directSuccessesRequired && + this.directObservationStartedAt !== null && + now - this.directObservationStartedAt >= this.options.directObservationMs && + now - this.lastMigrationAt >= this.options.minimumDwellMs + ) + } + + recordDirectFailure(now: number): void { + this.consecutiveDirectSuccesses = 0 + this.directObservationStartedAt = null + this.cooldownUntil = now + this.options.failureCooldownMs + } + + recordMigration(now: number): void { + this.lastMigrationAt = now + this.consecutiveDirectSuccesses = 0 + this.directObservationStartedAt = null + } + + canProbe(now: number): boolean { + return now >= this.cooldownUntil + } +} diff --git a/mobile/src/transport/mobile-endpoint-lifecycle.ts b/mobile/src/transport/mobile-endpoint-lifecycle.ts new file mode 100644 index 00000000000..57881022a56 --- /dev/null +++ b/mobile/src/transport/mobile-endpoint-lifecycle.ts @@ -0,0 +1,98 @@ +import * as ExpoCrypto from 'expo-crypto' +import type { ConnectionLogSink, HostProfile } from './types' +import { connect } from './rpc-client' +import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor' +import { connectMobileRelayRpcSession } from './mobile-relay-rpc-session' +import { resolveMobileRelayEndpoint } from './mobile-relay-resume-director' +import { + readMobileRelayCredentialBundle, + writeMobileRelayCredentialBundle +} from './mobile-relay-credential-bundle' +import { saveHost } from './host-store' +import { upgradeDirectMobileRelay } from './mobile-relay-direct-upgrade' +import { MobileRelayDirectUpgradeController } from './mobile-relay-direct-upgrade-controller' +import type { StableLogicalRpcClient } from './stable-logical-rpc-client' + +type EndpointLifecycle = { + setForeground(foreground: boolean): void + stop(): void +} + +type EndpointOwner = EndpointLifecycle & { + start(): Promise +} + +export function startMobileEndpointLifecycle( + logical: StableLogicalRpcClient, + initialHost: HostProfile, + onLog: ConnectionLogSink +): EndpointLifecycle { + let stopped = false + let foreground = true + let owner: EndpointOwner + + const startSupervisor = async (host: HostProfile): Promise => { + if (stopped) { + return + } + const supervisor = createSupervisor(logical, host, onLog) + owner.stop() + owner = supervisor + supervisor.setForeground(foreground) + await supervisor.start() + } + + if (initialHost.relay) { + owner = createSupervisor(logical, initialHost, onLog) + void owner.start() + } else { + owner = new MobileRelayDirectUpgradeController(logical, initialHost, { + upgrade: (client, host) => + upgradeDirectMobileRelay({ + client, + host, + dependencies: { randomBytes: ExpoCrypto.getRandomBytes } + }), + onUpgraded: ({ host }) => startSupervisor(host) + }) + void owner.start() + } + + return { + setForeground(next) { + foreground = next + owner.setForeground(next) + }, + stop() { + stopped = true + owner.stop() + } + } +} + +function createSupervisor( + logical: StableLogicalRpcClient, + host: HostProfile, + onLog: ConnectionLogSink +): MobileEndpointSupervisor { + return new MobileEndpointSupervisor(logical, host, { + openDirect: (endpoint) => connect(endpoint, host.deviceToken, host.publicKeyB64, { onLog }), + openRelay: (relay, credential, confirmReqId) => + connectMobileRelayRpcSession({ + relay, + resumeToken: credential.token, + resumeCredentialVersion: credential.version, + resumeConfirmReqId: confirmReqId, + deviceToken: host.deviceToken, + desktopPublicKeyB64: host.publicKeyB64 + }), + resolveRelay: resolveMobileRelayEndpoint, + readBundle: readMobileRelayCredentialBundle, + writeBundle: writeMobileRelayCredentialBundle, + saveHost, + now: Date.now, + randomBytes: ExpoCrypto.getRandomBytes, + setTimer: setTimeout, + clearTimer: clearTimeout + }) +} diff --git a/mobile/src/transport/mobile-endpoint-supervisor-support.ts b/mobile/src/transport/mobile-endpoint-supervisor-support.ts new file mode 100644 index 00000000000..28318ffd4a8 --- /dev/null +++ b/mobile/src/transport/mobile-endpoint-supervisor-support.ts @@ -0,0 +1,44 @@ +import { RelayOuterError } from './mobile-relay-e2ee-link' +import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel' +import type { HostProfile } from './types' +import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract' + +export function isDirectorResolutionFailure(error: Error): boolean { + return ( + !(error instanceof MobileE2EEAuthenticationError) && + (!(error instanceof RelayOuterError) || [4409, 4503, 1006].includes(error.code)) + ) +} + +export function relayWebSocketUrl(relay: { cellUrl: string; relayHostId: string }): string { + const url = new URL(relay.cellUrl) + url.protocol = 'wss:' + url.pathname = `/v1/connect/${encodeURIComponent(relay.relayHostId)}` + return url.toString() +} + +export async function persistRelayHost( + host: HostProfile, + relay: MobileRelayEndpoint, + saveHost: (host: HostProfile) => Promise +): Promise { + const endpoints = [ + ...(host.endpoints ?? [{ id: 'direct-primary', kind: 'lan' as const, url: host.endpoint }]) + ].filter(({ kind }) => kind !== 'relay') + endpoints.push({ id: 'relay-primary', kind: 'relay', url: relayWebSocketUrl(relay) }) + const updated = { ...host, endpoints, relayHostId: relay.relayHostId, relay } + await saveHost(updated) + return updated +} + +export function encodeBase64Url(value: Uint8Array): string { + let binary = '' + for (const byte of value) { + binary += String.fromCharCode(byte) + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +export function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} diff --git a/mobile/src/transport/mobile-endpoint-supervisor.test.ts b/mobile/src/transport/mobile-endpoint-supervisor.test.ts new file mode 100644 index 00000000000..f7b62584168 --- /dev/null +++ b/mobile/src/transport/mobile-endpoint-supervisor.test.ts @@ -0,0 +1,246 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' +import { RelayOuterError } from './mobile-relay-e2ee-link' +import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' +import { + MobileEndpointSupervisor, + type MobileEndpointSupervisorDependencies +} from './mobile-endpoint-supervisor' +import type { RpcClient } from './rpc-client' +import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client' +import type { ConnectionState, HostProfile, RpcResponse } from './types' + +vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) +vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' })) +vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) })) + +class FakeSession implements RpcClient { + readonly sendRequest = vi.fn( + async (): Promise => ({ + id: 'rpc-1', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-1' } + }) + ) + readonly subscribe = vi.fn(() => () => {}) + readonly updateTerminalSubscriptionViewport = vi.fn() + readonly notifyForeground = vi.fn() + readonly close = vi.fn() + private readonly listeners = new Set<(state: ConnectionState) => void>() + + constructor(private state: ConnectionState) {} + + getState = () => this.state + getReconnectAttempt = () => 0 + getLastConnectedAt = () => null + onStateChange = (listener: (state: ConnectionState) => void) => { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + publishState(state: ConnectionState): void { + this.state = state + for (const listener of this.listeners) { + listener(state) + } + } +} + +class FakeRelaySession extends FakeSession implements MobileRelayRpcSession { + constructor( + state: ConnectionState, + private readonly failure: Error | null = null, + private readonly lease = Date.now() + 120_000 + ) { + super(state) + } + getLeaseExpiresAt = () => this.lease + getResumeConfirmation = () => ({ + v: 1 as const, + reqId: 'confirm-1', + currentVersion: 2, + acceptedAs: 'current' as const, + renewed: true, + resumeExpiresAt: Date.now() + 300_000 + }) + getFailure = () => this.failure +} + +class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient { + private path: MobileConnectionPath + private generation = 1 + + constructor(state: ConnectionState, path: MobileConnectionPath) { + super(state) + this.path = path + } + + migrateTo = vi.fn(async (session: RpcClient, path: MobileConnectionPath) => { + if (session.getState() !== 'connected') { + session.close() + throw new Error(`replacement session ${session.getState()}`) + } + this.path = path + this.generation += 1 + }) + suspendActiveSession = vi.fn(() => this.publishState('disconnected')) + getActivePath = () => this.path + getGeneration = () => this.generation +} + +const relay = { + v: 1 as const, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + e2eeFraming: 2 as const +} +const host: HostProfile = { + id: 'host-1', + name: 'Blue Whale', + endpoint: 'ws://192.168.1.10:6768', + deviceToken: 'device-token', + publicKeyB64: 'A'.repeat(44), + lastConnected: 1, + endpoints: [ + { id: 'direct-primary', kind: 'lan', url: 'ws://192.168.1.10:6768' }, + { id: 'relay-primary', kind: 'relay', url: 'wss://relay-c1.onorca.dev/v1/connect/id' } + ], + relayHostId: relay.relayHostId, + relay +} +const bundle: MobileRelayCredentialBundle = { + v: 1, + hostId: host.id, + deviceToken: host.deviceToken, + current: { + token: 'A'.repeat(43), + hash: 'B'.repeat(43), + version: 2, + expiresAt: Number.MAX_SAFE_INTEGER + } +} + +function dependencies( + overrides: Partial = {} +): MobileEndpointSupervisorDependencies { + return { + openDirect: vi.fn(() => new FakeSession('connected')), + openRelay: vi.fn(() => new FakeRelaySession('connected')), + resolveRelay: vi.fn(async ({ relay }) => relay), + readBundle: vi.fn(async () => bundle), + writeBundle: vi.fn(async () => {}), + saveHost: vi.fn(async () => {}), + now: Date.now, + randomBytes: (length) => new Uint8Array(length).fill(1), + setTimer: setTimeout, + clearTimer: clearTimeout, + ...overrides + } +} + +describe('mobile endpoint supervisor', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-13T12:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('fails over to a confirmed relay session and persists its renewed expiry', async () => { + const logical = new FakeLogicalClient('disconnected', 'lan') + const deps = dependencies() + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + + expect(logical.migrateTo).toHaveBeenCalledWith(expect.any(FakeRelaySession), 'relay') + expect(logical.getActivePath()).toBe('relay') + expect(deps.writeBundle).toHaveBeenCalledWith( + expect.objectContaining({ current: expect.objectContaining({ version: 2 }) }) + ) + supervisor.stop() + }) + + it('fails over when the direct retry loop publishes reconnecting', async () => { + const logical = new FakeLogicalClient('connecting', 'lan') + const deps = dependencies() + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + await supervisor.start() + + logical.publishState('reconnecting') + await vi.waitFor(() => expect(logical.getActivePath()).toBe('relay')) + + expect(logical.migrateTo).toHaveBeenCalledWith(expect.any(FakeRelaySession), 'relay') + supervisor.stop() + }) + + it('fails over when direct is already reconnecting before startup completes', async () => { + const logical = new FakeLogicalClient('reconnecting', 'lan') + const deps = dependencies() + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + + expect(logical.migrateTo).toHaveBeenCalledWith(expect.any(FakeRelaySession), 'relay') + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + }) + + it('uses POST resolve for wrong-cell recovery and persists the authoritative target', async () => { + const logical = new FakeLogicalClient('disconnected', 'lan') + const openRelay = vi + .fn() + .mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4409))) + .mockReturnValueOnce(new FakeRelaySession('connected')) + const resolved = { ...relay, cellUrl: 'https://relay-c2.onorca.dev', assignmentEpoch: 8 } + const deps = dependencies({ + openRelay, + resolveRelay: vi.fn(async () => resolved) + }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + + expect(deps.resolveRelay).toHaveBeenCalledOnce() + expect(openRelay).toHaveBeenLastCalledWith(resolved, expect.any(Object), expect.any(String)) + expect(deps.saveHost).toHaveBeenCalledWith( + expect.objectContaining({ relay: resolved, endpoint: host.endpoint }) + ) + supervisor.stop() + }) + + it('promotes direct only after repeated foreground authenticated probes and dwell', async () => { + const logical = new FakeLogicalClient('connected', 'relay') + const deps = dependencies() + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + await supervisor.start() + + await vi.advanceTimersByTimeAsync(45_000) + expect(logical.getActivePath()).toBe('relay') + await vi.advanceTimersByTimeAsync(15_000) + expect(logical.getActivePath()).toBe('lan') + expect(deps.openDirect).toHaveBeenCalledTimes(4) + supervisor.stop() + }) + + it('releases a background relay session and reconnects it on foreground', async () => { + const logical = new FakeLogicalClient('connected', 'relay') + const deps = dependencies() + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + await supervisor.start() + + supervisor.setForeground(false) + expect(logical.suspendActiveSession).toHaveBeenCalledOnce() + expect(logical.getState()).toBe('disconnected') + + supervisor.setForeground(true) + await vi.waitFor(() => expect(logical.migrateTo).toHaveBeenCalled()) + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + }) +}) diff --git a/mobile/src/transport/mobile-endpoint-supervisor.ts b/mobile/src/transport/mobile-endpoint-supervisor.ts new file mode 100644 index 00000000000..9815a1a3ed4 --- /dev/null +++ b/mobile/src/transport/mobile-endpoint-supervisor.ts @@ -0,0 +1,322 @@ +import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract' +import { openAuthenticatedDirectEndpoint } from './mobile-direct-endpoint-probe' +import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis' +import { + encodeBase64Url, + isDirectorResolutionFailure, + persistRelayHost, + toError +} from './mobile-endpoint-supervisor-support' +import { + applyResumeConfirmation, + mobileRelayCredentialNeedsRotation, + rotateMobileRelayCredential +} from './mobile-relay-credential-rotation' +import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' +import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' +import { resolveMobileRelayEndpoint } from './mobile-relay-resume-director' +import type { RpcClient } from './rpc-client' +import type { StableLogicalRpcClient } from './stable-logical-rpc-client' +import type { HostProfile } from './types' + +const DIRECT_PROBE_INTERVAL_MS = 15_000 +const DIRECT_OBSERVATION_MS = 30_000 +const MINIMUM_DWELL_MS = 60_000 +const FAILURE_COOLDOWN_MS = 60_000 +const LEASE_ROTATION_MARGIN_MS = 30_000 + +export type MobileEndpointSupervisorDependencies = { + openDirect: (endpoint: string) => RpcClient + openRelay: ( + relay: MobileRelayEndpoint, + credential: { token: string; version: number }, + confirmReqId: string + ) => MobileRelayRpcSession + resolveRelay: typeof resolveMobileRelayEndpoint + readBundle: (hostId: string) => Promise + writeBundle: (bundle: MobileRelayCredentialBundle) => Promise + saveHost: (host: HostProfile) => Promise + now: () => number + randomBytes: (length: number) => Uint8Array + setTimer: typeof setTimeout + clearTimer: typeof clearTimeout +} + +export class MobileEndpointSupervisor { + private host: HostProfile + private bundle: MobileRelayCredentialBundle | null = null + private stopped = false + private foreground = true + private operationInFlight = false + private credentialRotationInFlight = false + private relayRotationPending = false + private probeTimer: ReturnType | null = null + private leaseTimer: ReturnType | null = null + private unsubscribeState: (() => void) | null = null + private readonly hysteresis: MobileEndpointHysteresis + + constructor( + private readonly logical: StableLogicalRpcClient, + host: HostProfile, + private readonly dependencies: MobileEndpointSupervisorDependencies + ) { + this.host = host + this.hysteresis = new MobileEndpointHysteresis(dependencies.now(), { + directSuccessesRequired: 3, + directObservationMs: DIRECT_OBSERVATION_MS, + failureCooldownMs: FAILURE_COOLDOWN_MS, + minimumDwellMs: MINIMUM_DWELL_MS + }) + } + + async start(): Promise { + this.bundle = await this.dependencies.readBundle(this.host.id).catch(() => null) + if (this.stopped || !this.bundle || !this.host.relay) { + return + } + this.unsubscribeState = this.logical.onStateChange((state) => { + if (state === 'connected') { + if (this.logical.getActivePath() !== 'relay') { + void this.rotateCredentialIfNeeded() + } + this.scheduleDirectProbe() + } else if (state === 'reconnecting' || state === 'disconnected' || state === 'auth-failed') { + // Why: the direct client enters reconnecting after its first failed + // dial and may never publish disconnected while its retry loop lives. + void this.recoverRelay() + } + }) + const initialState = this.logical.getState() + if ( + initialState === 'reconnecting' || + initialState === 'disconnected' || + initialState === 'auth-failed' + ) { + // Why: the first direct dial can fail while encrypted relay credentials + // are still loading, before the supervisor subscribes to state changes. + await this.recoverRelay() + } else { + this.scheduleDirectProbe() + } + } + + setForeground(foreground: boolean): void { + this.foreground = foreground + if (foreground) { + void this.recoverRelay(this.relayRotationPending) + this.scheduleDirectProbe(0) + } else { + if (this.logical.getActivePath() === 'relay') { + // Why: background phones must not hold billed relay data splices; the + // stable client keeps subscriptions for authenticated foreground replay. + this.logical.suspendActiveSession() + } + if (this.probeTimer) { + this.dependencies.clearTimer(this.probeTimer) + this.probeTimer = null + } + } + } + + stop(): void { + this.stopped = true + this.unsubscribeState?.() + this.unsubscribeState = null + if (this.probeTimer) { + this.dependencies.clearTimer(this.probeTimer) + this.probeTimer = null + } + this.clearLeaseTimer() + } + + private async recoverRelay(forceReplacement = false): Promise { + if ( + this.stopped || + !this.foreground || + this.operationInFlight || + !this.bundle || + !this.host.relay || + (!forceReplacement && this.logical.getState() === 'connected') + ) { + return + } + this.operationInFlight = true + try { + const credentials = [this.bundle.current, this.bundle.grace].filter( + (credential): credential is NonNullable => + Boolean(credential && credential.expiresAt > this.dependencies.now()) + ) + for (const credential of credentials) { + if (await this.tryRelayCredential(credential)) { + return + } + } + } finally { + this.operationInFlight = false + if (forceReplacement && this.relayRotationPending && !this.stopped && !this.leaseTimer) { + this.leaseTimer = this.dependencies.setTimer(() => { + this.leaseTimer = null + void this.recoverRelay(true) + }, 5000) + } + } + } + + private async tryRelayCredential(credential: { + token: string + version: number + }): Promise { + const first = await this.openAndMigrateRelay(credential) + if (first.ok) { + return true + } + if (!isDirectorResolutionFailure(first.error) || !this.host.relay) { + return false + } + try { + const resolved = await this.dependencies.resolveRelay({ + relay: this.host.relay, + resumeToken: credential.token + }) + this.host = await persistRelayHost(this.host, resolved, this.dependencies.saveHost) + return (await this.openAndMigrateRelay(credential)).ok + } catch { + return false + } + } + + private async openAndMigrateRelay(credential: { + token: string + version: number + }): Promise<{ ok: true } | { ok: false; error: Error }> { + if (!this.host.relay || !this.bundle) { + return { ok: false, error: new Error('relay state missing') } + } + const session = this.dependencies.openRelay( + this.host.relay, + credential, + `confirm-${encodeBase64Url(this.dependencies.randomBytes(16))}` + ) + try { + await this.logical.migrateTo(session, 'relay') + if (!this.foreground) { + this.logical.suspendActiveSession() + } + this.relayRotationPending = false + this.hysteresis.recordMigration(this.dependencies.now()) + const confirmation = session.getResumeConfirmation() + if (confirmation) { + this.bundle = applyResumeConfirmation(this.bundle, credential.version, confirmation) + await this.dependencies.writeBundle(this.bundle) + } + this.scheduleLeaseRotation(session) + this.scheduleDirectProbe() + return { ok: true } + } catch (error) { + return { ok: false, error: session.getFailure() ?? toError(error) } + } + } + + private scheduleDirectProbe(delayMs = DIRECT_PROBE_INTERVAL_MS): void { + if ( + this.stopped || + !this.foreground || + this.logical.getActivePath() !== 'relay' || + this.probeTimer + ) { + return + } + this.probeTimer = this.dependencies.setTimer(() => { + this.probeTimer = null + void this.probeDirect() + }, delayMs) + } + + private async probeDirect(): Promise { + if ( + this.stopped || + !this.foreground || + this.operationInFlight || + !this.hysteresis.canProbe(this.dependencies.now()) + ) { + this.scheduleDirectProbe() + return + } + this.operationInFlight = true + let successful: Awaited> = null + try { + const openDirect = this.dependencies.openDirect + successful = await openAuthenticatedDirectEndpoint(this.host, openDirect, 12_000) + if (!successful) { + this.hysteresis.recordDirectFailure(this.dependencies.now()) + return + } + if (!this.hysteresis.recordDirectSuccess(this.dependencies.now())) { + successful.client.close() + return + } + await this.logical.migrateTo(successful.client, successful.path) + successful = null + this.hysteresis.recordMigration(this.dependencies.now()) + this.clearLeaseTimer() + this.relayRotationPending = false + await this.rotateCredentialIfNeeded() + } finally { + successful?.client.close() + this.operationInFlight = false + if (this.relayRotationPending) { + void this.recoverRelay(true) + } + this.scheduleDirectProbe() + } + } + + private async rotateCredentialIfNeeded(): Promise { + if ( + this.stopped || + this.credentialRotationInFlight || + !this.bundle || + this.logical.getActivePath() === 'relay' || + !mobileRelayCredentialNeedsRotation(this.bundle, this.dependencies.now()) + ) { + return + } + this.credentialRotationInFlight = true + try { + const result = await rotateMobileRelayCredential({ + client: this.logical, + bundle: this.bundle, + writeBundle: this.dependencies.writeBundle, + randomBytes: this.dependencies.randomBytes + }) + this.bundle = result.bundle + this.host = await persistRelayHost(this.host, result.relay, this.dependencies.saveHost) + } catch { + // Why: pending material remains durable; the next authenticated direct + // opportunity must reconcile it before creating another install key. + } finally { + this.credentialRotationInFlight = false + } + } + + private scheduleLeaseRotation(session: MobileRelayRpcSession): void { + this.clearLeaseTimer() + const deadline = session.getLeaseExpiresAt() + if (!deadline) { + return + } + const delay = Math.max(1000, deadline - this.dependencies.now() - LEASE_ROTATION_MARGIN_MS) + this.leaseTimer = this.dependencies.setTimer(() => { + this.leaseTimer = null + this.relayRotationPending = true + void this.recoverRelay(true) + }, delay) + } + + private clearLeaseTimer(): void { + if (this.leaseTimer) { + this.dependencies.clearTimer(this.leaseTimer) + this.leaseTimer = null + } + } +} diff --git a/mobile/src/transport/mobile-relay-credential-bundle.test.ts b/mobile/src/transport/mobile-relay-credential-bundle.test.ts new file mode 100644 index 00000000000..4df9d44430b --- /dev/null +++ b/mobile/src/transport/mobile-relay-credential-bundle.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const secureStore = vi.hoisted(() => ({ + getItemAsync: vi.fn(), + setItemAsync: vi.fn(), + deleteItemAsync: vi.fn() +})) +const platform = vi.hoisted(() => ({ OS: 'ios' })) + +vi.mock('expo-secure-store', () => ({ + WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY', + ...secureStore +})) +vi.mock('react-native', () => ({ Platform: platform })) + +import { + deleteMobileRelayCredentialBundle, + promotePairingJournalCredential, + readMobileRelayCredentialBundle, + writeMobileRelayCredentialBundle +} from './mobile-relay-credential-bundle' +import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' + +const journal = { + metadata: { + v: 1, + journalId: 'pair-1', + offerFingerprint: 'A'.repeat(43), + host: { + id: 'host-1', + name: 'Blue Whale', + endpoint: 'ws://192.168.1.10:6768', + publicKeyB64: 'A'.repeat(44), + lastConnected: 1 + }, + relay: { + v: 1, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteExpiresAt: 10_000, + e2eeFraming: 2 + }, + installReqId: 'install-1', + resumeConfirmReqId: 'confirm-1', + pendingResumeTokenHash: 'B'.repeat(43), + winner: 'direct', + authorizationMode: 'authenticated-direct' + }, + secrets: { + v: 1, + journalId: 'pair-1', + deviceToken: 'device-token', + inviteToken: 'C'.repeat(43), + pendingResumeToken: 'D'.repeat(43) + } +} satisfies MobileRelayPairingJournal + +describe('mobile relay credential bundle', () => { + let stored: string | null + + beforeEach(() => { + vi.clearAllMocks() + platform.OS = 'ios' + stored = null + secureStore.getItemAsync.mockImplementation(async () => stored) + secureStore.setItemAsync.mockImplementation(async (_key: string, value: string) => { + stored = value + }) + secureStore.deleteItemAsync.mockImplementation(async () => { + stored = null + }) + }) + + it('promotes only a matching committed install result to current', async () => { + const bundle = promotePairingJournalCredential({ + journal, + installed: { + v: 1, + reqId: 'install-1', + authorizationMode: 'authenticated-direct', + currentVersion: 3, + resumeExpiresAt: 50_000 + } + }) + await writeMobileRelayCredentialBundle(bundle) + + await expect(readMobileRelayCredentialBundle('host-1')).resolves.toEqual(bundle) + expect(stored).toContain(journal.secrets.pendingResumeToken) + expect(stored).not.toContain(journal.secrets.inviteToken) + }) + + it('rejects a result from another request or authorization mode', () => { + expect(() => + promotePairingJournalCredential({ + journal, + installed: { + v: 1, + reqId: 'other-request', + authorizationMode: 'relay-basis', + currentVersion: 1, + resumeExpiresAt: 50_000 + } + }) + ).toThrow(/does not match/) + }) + + it('deletes the namespaced bundle and never enables it on web', async () => { + await deleteMobileRelayCredentialBundle('host-1') + expect(secureStore.deleteItemAsync).toHaveBeenCalledWith( + 'orca.mobile-relay.credentials.host-1', + expect.any(Object) + ) + platform.OS = 'web' + await expect(readMobileRelayCredentialBundle('host-1')).rejects.toThrow(/native secret store/) + }) +}) diff --git a/mobile/src/transport/mobile-relay-credential-bundle.ts b/mobile/src/transport/mobile-relay-credential-bundle.ts new file mode 100644 index 00000000000..87f6f3826f4 --- /dev/null +++ b/mobile/src/transport/mobile-relay-credential-bundle.ts @@ -0,0 +1,112 @@ +import * as SecureStore from 'expo-secure-store' +import { Platform } from 'react-native' +import { z } from 'zod' +import type { DeviceCredentialInstalled } from '../../../src/shared/mobile-relay-credential-contract' +import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' + +const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) +const ResumeCredentialSchema = z + .object({ + token: Base64Url32ByteSchema, + hash: Base64Url32ByteSchema, + version: z.number().int().positive(), + expiresAt: z.number().int().nonnegative() + }) + .strict() + +export const MobileRelayCredentialBundleSchema = z + .object({ + v: z.literal(1), + hostId: z.string().min(1), + deviceToken: z.string().min(1), + current: ResumeCredentialSchema, + grace: ResumeCredentialSchema.optional(), + pending: z + .object({ + token: Base64Url32ByteSchema, + hash: Base64Url32ByteSchema, + reqId: z.string().min(1) + }) + .strict() + .optional(), + invite: z + .object({ token: Base64Url32ByteSchema, expiresAt: z.number().int().positive() }) + .strict() + .optional() + }) + .strict() + +export type MobileRelayCredentialBundle = z.infer + +const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = { + keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY +} + +function credentialKey(hostId: string): string { + return `orca.mobile-relay.credentials.${hostId}` +} + +export function promotePairingJournalCredential(args: { + journal: MobileRelayPairingJournal + installed: DeviceCredentialInstalled +}): MobileRelayCredentialBundle { + const { journal, installed } = args + if ( + installed.reqId !== journal.metadata.installReqId || + installed.authorizationMode !== journal.metadata.authorizationMode + ) { + throw new Error('relay credential install result does not match pairing journal') + } + return MobileRelayCredentialBundleSchema.parse({ + v: 1, + hostId: journal.metadata.host.id, + deviceToken: journal.secrets.deviceToken, + current: { + token: journal.secrets.pendingResumeToken, + hash: journal.metadata.pendingResumeTokenHash, + version: installed.currentVersion, + expiresAt: installed.resumeExpiresAt + } + }) +} + +export async function readMobileRelayCredentialBundle( + hostId: string +): Promise { + requireNativeSecretStore() + const raw = await SecureStore.getItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS) + if (raw === null) { + return null + } + try { + const result = MobileRelayCredentialBundleSchema.safeParse(JSON.parse(raw)) + return result.success && result.data.hostId === hostId ? result.data : null + } catch { + return null + } +} + +export async function writeMobileRelayCredentialBundle( + bundle: MobileRelayCredentialBundle +): Promise { + requireNativeSecretStore() + const validated = MobileRelayCredentialBundleSchema.parse(bundle) + await SecureStore.setItemAsync( + credentialKey(validated.hostId), + JSON.stringify(validated), + KEYCHAIN_OPTIONS + ) +} + +export async function deleteMobileRelayCredentialBundle(hostId: string): Promise { + if (Platform.OS === 'web') { + return + } + await SecureStore.deleteItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS) +} + +function requireNativeSecretStore(): void { + if (Platform.OS === 'web') { + throw new Error('Orca Relay credentials require a native secret store') + } +} diff --git a/mobile/src/transport/mobile-relay-credential-hash.test.ts b/mobile/src/transport/mobile-relay-credential-hash.test.ts new file mode 100644 index 00000000000..7057bda113e --- /dev/null +++ b/mobile/src/transport/mobile-relay-credential-hash.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest' +import { hashMobileRelayCredential } from './mobile-relay-credential-hash' + +describe('mobile relay credential hash', () => { + it('hashes the base64url wire token text rather than its decoded bytes', () => { + expect(hashMobileRelayCredential('BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc')).toBe( + '3Ev4DHdHPRMPoN6GukAY_pi7IUAF5qWJHRK6kURvnoE' + ) + }) +}) diff --git a/mobile/src/transport/mobile-relay-credential-hash.ts b/mobile/src/transport/mobile-relay-credential-hash.ts new file mode 100644 index 00000000000..bc54b2817a1 --- /dev/null +++ b/mobile/src/transport/mobile-relay-credential-hash.ts @@ -0,0 +1,15 @@ +import { sha256 } from '@noble/hashes/sha256' + +// Why: the relay stores a digest of the serialized base64url bearer, not the +// random bytes it encodes, so every installer must hash the wire token text. +export function hashMobileRelayCredential(token: string): string { + return encodeBase64Url(sha256(new TextEncoder().encode(token))) +} + +function encodeBase64Url(value: Uint8Array): string { + let binary = '' + for (const byte of value) { + binary += String.fromCharCode(byte) + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} diff --git a/mobile/src/transport/mobile-relay-credential-rotation.test.ts b/mobile/src/transport/mobile-relay-credential-rotation.test.ts new file mode 100644 index 00000000000..2870a3fe50f --- /dev/null +++ b/mobile/src/transport/mobile-relay-credential-rotation.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from './rpc-client' +import { + applyResumeConfirmation, + mobileRelayCredentialNeedsRotation, + rotateMobileRelayCredential +} from './mobile-relay-credential-rotation' +import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' +import { hashMobileRelayCredential } from './mobile-relay-credential-hash' +import type { RpcResponse } from './types' + +vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) +vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' })) +vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) })) + +const relay = { + v: 1 as const, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + e2eeFraming: 2 as const +} + +const bundle: MobileRelayCredentialBundle = { + v: 1, + hostId: 'host-1', + deviceToken: 'device-token', + current: { + token: 'A'.repeat(43), + hash: 'B'.repeat(43), + version: 2, + expiresAt: 50_000 + } +} + +function success(result: unknown): RpcResponse { + return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +function install(reqId: string) { + return { + v: 1 as const, + reqId, + authorizationMode: 'authenticated-direct' as const, + currentVersion: 3, + resumeExpiresAt: 100_000, + graceExpiresAt: 70_000 + } +} + +function fakeClient(responses: RpcResponse[]): RpcClient { + return { + sendRequest: vi.fn(async () => responses.shift()!), + subscribe: vi.fn(() => () => {}), + updateTerminalSubscriptionViewport: vi.fn(), + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => 1, + onStateChange: () => () => {}, + notifyForeground: vi.fn(), + close: vi.fn() + } +} + +describe('mobile relay credential rotation', () => { + it('persists pending material before install and promotes only committed status', async () => { + const installed = install('rotate-CAgICAgICAgICAgICAgICA') + const client = fakeClient([ + success({ v: 1, relay, installStatus: { v: 1, reqId: installed.reqId, state: 'not-found' } }), + success(installed), + success({ + v: 1, + relay, + installStatus: { v: 1, reqId: installed.reqId, state: 'committed', result: installed } + }) + ]) + const writes: MobileRelayCredentialBundle[] = [] + + const result = await rotateMobileRelayCredential({ + client, + bundle, + writeBundle: async (value) => { + writes.push(value) + }, + randomBytes: (length) => new Uint8Array(length).fill(length === 32 ? 7 : 8) + }) + + expect(writes).toHaveLength(2) + expect(writes[0]!.pending).toMatchObject({ reqId: installed.reqId }) + expect(writes[0]!.pending!.hash).toBe('3Ev4DHdHPRMPoN6GukAY_pi7IUAF5qWJHRK6kURvnoE') + expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'pairing.provisionRelay', { + reqId: installed.reqId, + newResumeTokenHash: writes[0]!.pending!.hash, + expectedCurrentHash: bundle.current.hash + }) + expect(result.bundle).toMatchObject({ + current: { version: 3, expiresAt: 100_000 }, + grace: { version: 2, expiresAt: 70_000 } + }) + expect(result.bundle.pending).toBeUndefined() + }) + + it('repairs legacy decoded-byte hashes before the normal rotation window', () => { + const now = 1_000 + const valid = { + ...bundle, + current: { + ...bundle.current, + hash: hashMobileRelayCredential(bundle.current.token), + expiresAt: now + 30 * 24 * 60 * 60 * 1000 + } + } + + expect(mobileRelayCredentialNeedsRotation(valid, now)).toBe(false) + expect(mobileRelayCredentialNeedsRotation(bundle, now)).toBe(true) + expect( + mobileRelayCredentialNeedsRotation( + { ...valid, pending: { token: 'C'.repeat(43), hash: 'D'.repeat(43), reqId: 'pending' } }, + now + ) + ).toBe(true) + }) + + it('reconciles a committed lost response without issuing a second install', async () => { + const pendingBundle: MobileRelayCredentialBundle = { + ...bundle, + pending: { token: 'C'.repeat(43), hash: 'D'.repeat(43), reqId: 'rotate-existing' } + } + const installed = install('rotate-existing') + const client = fakeClient([ + success({ + v: 1, + relay, + installStatus: { v: 1, reqId: installed.reqId, state: 'committed', result: installed } + }) + ]) + const writeBundle = vi.fn(async () => {}) + + await rotateMobileRelayCredential({ client, bundle: pendingBundle, writeBundle }) + + expect(client.sendRequest).toHaveBeenCalledOnce() + expect(client.sendRequest).toHaveBeenCalledWith('pairing.getEndpoints', { + installReqId: 'rotate-existing' + }) + expect(writeBundle).toHaveBeenCalledOnce() + }) + + it('applies only authoritative current or grace confirmation expiries', () => { + const withGrace: MobileRelayCredentialBundle = { + ...bundle, + grace: { token: 'C'.repeat(43), hash: 'D'.repeat(43), version: 1, expiresAt: 40_000 } + } + const renewed = applyResumeConfirmation(withGrace, 2, { + v: 1, + reqId: 'confirm-current', + currentVersion: 2, + acceptedAs: 'current', + renewed: true, + resumeExpiresAt: 120_000, + graceExpiresAt: 40_000 + }) + const grace = applyResumeConfirmation(renewed, 1, { + v: 1, + reqId: 'confirm-grace', + currentVersion: 2, + acceptedAs: 'grace', + renewed: false, + resumeExpiresAt: 120_000, + graceExpiresAt: 45_000 + }) + + expect(renewed.current.expiresAt).toBe(120_000) + expect(grace.grace?.expiresAt).toBe(45_000) + expect(applyResumeConfirmation(grace, 99, { ...graceConfirmation(), reqId: 'other' })).toBe( + grace + ) + }) +}) + +function graceConfirmation() { + return { + v: 1 as const, + reqId: 'confirm', + currentVersion: 2, + acceptedAs: 'grace' as const, + renewed: false, + resumeExpiresAt: 120_000, + graceExpiresAt: 45_000 + } +} diff --git a/mobile/src/transport/mobile-relay-credential-rotation.ts b/mobile/src/transport/mobile-relay-credential-rotation.ts new file mode 100644 index 00000000000..1905247255b --- /dev/null +++ b/mobile/src/transport/mobile-relay-credential-rotation.ts @@ -0,0 +1,147 @@ +import * as ExpoCrypto from 'expo-crypto' +import { + DeviceCredentialInstalledSchema, + PairingGetEndpointsResultSchema, + type DeviceResumeConfirmed, + type MobileRelayEndpoint +} from '../../../src/shared/mobile-relay-credential-contract' +import { + MobileRelayCredentialBundleSchema, + type MobileRelayCredentialBundle +} from './mobile-relay-credential-bundle' +import { hashMobileRelayCredential } from './mobile-relay-credential-hash' +import type { RpcClient } from './rpc-client' + +const CREDENTIAL_ROTATION_WINDOW_MS = 7 * 24 * 60 * 60 * 1000 + +type RotationResult = { + bundle: MobileRelayCredentialBundle + relay: MobileRelayEndpoint +} + +export async function rotateMobileRelayCredential(args: { + client: RpcClient + bundle: MobileRelayCredentialBundle + writeBundle: (bundle: MobileRelayCredentialBundle) => Promise + randomBytes?: (length: number) => Uint8Array +}): Promise { + let bundle = args.bundle + if (!bundle.pending) { + const randomBytes = args.randomBytes ?? ExpoCrypto.getRandomBytes + const token = encodeBase64Url(randomBytes(32)) + bundle = MobileRelayCredentialBundleSchema.parse({ + ...bundle, + pending: { + token, + hash: hashMobileRelayCredential(token), + reqId: `rotate-${encodeBase64Url(randomBytes(16))}` + } + }) + // Why: a crash or lost response must leave enough material to query the + // one global install key before any second authorization attempt. + await args.writeBundle(bundle) + } + + const pending = bundle.pending + if (!pending) { + throw new Error('relay credential rotation pending state missing') + } + let endpoints = await getEndpoints(args.client, pending.reqId) + if (endpoints.installStatus?.state !== 'committed') { + const response = await args.client.sendRequest('pairing.provisionRelay', { + reqId: pending.reqId, + newResumeTokenHash: pending.hash, + expectedCurrentHash: bundle.current.hash + }) + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + const installed = DeviceCredentialInstalledSchema.parse(response.result) + endpoints = await getEndpoints(args.client, pending.reqId) + if ( + endpoints.installStatus?.state !== 'committed' || + JSON.stringify(endpoints.installStatus.result) !== JSON.stringify(installed) + ) { + throw new Error('relay credential rotation was not authoritatively committed') + } + } + if (!endpoints.relay || endpoints.installStatus?.state !== 'committed') { + throw new Error('relay credential rotation endpoint state missing') + } + const installed = endpoints.installStatus.result + const next = MobileRelayCredentialBundleSchema.parse({ + ...bundle, + current: { + token: pending.token, + hash: pending.hash, + version: installed.currentVersion, + expiresAt: installed.resumeExpiresAt + }, + ...(installed.graceExpiresAt + ? { grace: { ...bundle.current, expiresAt: installed.graceExpiresAt } } + : { grace: undefined }), + pending: undefined + }) + await args.writeBundle(next) + return { bundle: next, relay: endpoints.relay } +} + +export function mobileRelayCredentialNeedsRotation( + bundle: MobileRelayCredentialBundle, + now: number +): boolean { + // Why: pre-release clients hashed decoded random bytes. Direct connectivity + // can safely replace that unusable cloud credential using its stored hash. + const malformedCurrentHash = + bundle.current.hash !== hashMobileRelayCredential(bundle.current.token) + return ( + Boolean(bundle.pending) || + malformedCurrentHash || + bundle.current.expiresAt - now <= CREDENTIAL_ROTATION_WINDOW_MS + ) +} + +export function applyResumeConfirmation( + bundle: MobileRelayCredentialBundle, + usedCredentialVersion: number, + confirmation: DeviceResumeConfirmed +): MobileRelayCredentialBundle { + if ( + confirmation.acceptedAs === 'current' && + confirmation.renewed && + bundle.current.version === usedCredentialVersion && + confirmation.currentVersion === usedCredentialVersion + ) { + return MobileRelayCredentialBundleSchema.parse({ + ...bundle, + current: { ...bundle.current, expiresAt: confirmation.resumeExpiresAt } + }) + } + if ( + confirmation.acceptedAs === 'grace' && + bundle.grace?.version === usedCredentialVersion && + confirmation.graceExpiresAt + ) { + return MobileRelayCredentialBundleSchema.parse({ + ...bundle, + grace: { ...bundle.grace, expiresAt: confirmation.graceExpiresAt } + }) + } + return bundle +} + +async function getEndpoints(client: RpcClient, installReqId: string) { + const response = await client.sendRequest('pairing.getEndpoints', { installReqId }) + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + return PairingGetEndpointsResultSchema.parse(response.result) +} + +function encodeBase64Url(value: Uint8Array): string { + let binary = '' + for (const byte of value) { + binary += String.fromCharCode(byte) + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} diff --git a/mobile/src/transport/mobile-relay-direct-upgrade-controller.test.ts b/mobile/src/transport/mobile-relay-direct-upgrade-controller.test.ts new file mode 100644 index 00000000000..28fb7c307f5 --- /dev/null +++ b/mobile/src/transport/mobile-relay-direct-upgrade-controller.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from 'vitest' +import { MobileRelayDirectUpgradeController } from './mobile-relay-direct-upgrade-controller' +import type { MobileRelayDirectUpgradeResult } from './mobile-relay-direct-upgrade' +import type { StableLogicalRpcClient } from './stable-logical-rpc-client' +import type { ConnectionState, HostProfile } from './types' + +const directHost: HostProfile = { + id: 'host-direct', + name: 'Host 4', + endpoint: 'ws://192.168.1.2:6768', + deviceToken: 'device-token', + publicKeyB64: 'A'.repeat(44), + lastConnected: 1 +} + +const upgraded = { + host: { + ...directHost, + relayHostId: 'AbCdEf0123_-xyZ9', + relay: { + v: 1 as const, + directorUrl: 'https://relay-staging.onorca.dev', + cellUrl: 'https://c1.relay-staging.onorca.dev', + assignmentEpoch: 4, + relayHostId: 'AbCdEf0123_-xyZ9', + e2eeFraming: 2 as const + } + }, + bundle: { + v: 1 as const, + hostId: directHost.id, + deviceToken: directHost.deviceToken, + current: { + token: 'A'.repeat(43), + hash: 'B'.repeat(43), + version: 1, + expiresAt: 99_999_999 + } + } +} satisfies MobileRelayDirectUpgradeResult + +function logicalClient(initial: ConnectionState) { + let state = initial + const listeners = new Set<(state: ConnectionState) => void>() + return { + client: { + getState: () => state, + onStateChange: (listener: (next: ConnectionState) => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + } + } as unknown as StableLogicalRpcClient, + setState(next: ConnectionState) { + state = next + for (const listener of listeners) { + listener(next) + } + } + } +} + +describe('direct pairing upgrade controller', () => { + it('upgrades immediately after an authenticated direct connection', async () => { + const logical = logicalClient('connected') + const upgrade = vi.fn(async () => upgraded) + const onUpgraded = vi.fn(async () => {}) + const controller = new MobileRelayDirectUpgradeController(logical.client, directHost, { + upgrade, + onUpgraded + }) + + await controller.start() + + expect(upgrade).toHaveBeenCalledWith(logical.client, directHost) + expect(onUpgraded).toHaveBeenCalledWith(upgraded) + }) + + it('retries a deferred upgrade on the next foreground transition', async () => { + const logical = logicalClient('connected') + const upgrade = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(upgraded) + const onUpgraded = vi.fn(async () => {}) + const controller = new MobileRelayDirectUpgradeController(logical.client, directHost, { + upgrade, + onUpgraded + }) + + await controller.start() + controller.setForeground(false) + controller.setForeground(true) + await vi.waitFor(() => expect(onUpgraded).toHaveBeenCalledOnce()) + + expect(upgrade).toHaveBeenCalledTimes(2) + }) + + it('fences a completed request after the host client closes', async () => { + const logical = logicalClient('connecting') + let resolveUpgrade!: (result: MobileRelayDirectUpgradeResult) => void + const upgrade = vi.fn( + () => + new Promise((resolve) => { + resolveUpgrade = resolve + }) + ) + const onUpgraded = vi.fn(async () => {}) + const controller = new MobileRelayDirectUpgradeController(logical.client, directHost, { + upgrade, + onUpgraded + }) + await controller.start() + + logical.setState('connected') + controller.stop() + resolveUpgrade(upgraded) + await Promise.resolve() + + expect(onUpgraded).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/transport/mobile-relay-direct-upgrade-controller.ts b/mobile/src/transport/mobile-relay-direct-upgrade-controller.ts new file mode 100644 index 00000000000..1cd57cfcb72 --- /dev/null +++ b/mobile/src/transport/mobile-relay-direct-upgrade-controller.ts @@ -0,0 +1,69 @@ +import type { MobileRelayDirectUpgradeResult } from './mobile-relay-direct-upgrade' +import type { StableLogicalRpcClient } from './stable-logical-rpc-client' +import type { HostProfile } from './types' + +type Dependencies = { + upgrade: ( + client: StableLogicalRpcClient, + host: HostProfile + ) => Promise + onUpgraded: (result: MobileRelayDirectUpgradeResult) => Promise +} + +export class MobileRelayDirectUpgradeController { + private foreground = true + private stopped = false + private inFlight = false + private unsubscribe: (() => void) | null = null + + constructor( + private readonly logical: StableLogicalRpcClient, + private readonly host: HostProfile, + private readonly dependencies: Dependencies + ) {} + + async start(): Promise { + this.unsubscribe = this.logical.onStateChange((state) => { + if (state === 'connected') { + void this.tryUpgrade() + } + }) + if (this.logical.getState() === 'connected') { + await this.tryUpgrade() + } + } + + setForeground(foreground: boolean): void { + this.foreground = foreground + if (foreground && this.logical.getState() === 'connected') { + void this.tryUpgrade() + } + } + + stop(): void { + this.stopped = true + this.unsubscribe?.() + this.unsubscribe = null + } + + private async tryUpgrade(): Promise { + if (this.stopped || !this.foreground || this.inFlight) { + return + } + this.inFlight = true + try { + const result = await this.dependencies.upgrade(this.logical, this.host) + if (!result || this.stopped) { + return + } + this.unsubscribe?.() + this.unsubscribe = null + await this.dependencies.onUpgraded(result) + } catch { + // Why: the journal survives transient auth/control failure; retry only on + // the next authenticated reconnect or foreground transition. + } finally { + this.inFlight = false + } + } +} diff --git a/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts b/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts new file mode 100644 index 00000000000..19775270b45 --- /dev/null +++ b/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts @@ -0,0 +1,89 @@ +import * as SecureStore from 'expo-secure-store' +import { Platform } from 'react-native' +import { z } from 'zod' +import { hashMobileRelayCredential } from './mobile-relay-credential-hash' + +const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) + +export const MobileRelayDirectUpgradeJournalSchema = z + .object({ + v: z.literal(1), + hostId: z.string().min(1), + reqId: z.string().min(1).max(128), + pendingResumeToken: Base64Url32ByteSchema, + pendingResumeTokenHash: Base64Url32ByteSchema + }) + .strict() + +export type MobileRelayDirectUpgradeJournal = z.infer + +const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = { + keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY +} + +function journalKey(hostId: string): string { + return `orca.mobile-relay.direct-upgrade.${hostId}` +} + +export function createMobileRelayDirectUpgradeJournal( + hostId: string, + randomBytes: (length: number) => Uint8Array +): MobileRelayDirectUpgradeJournal { + const pendingResumeToken = encodeBase64Url(randomBytes(32)) + return MobileRelayDirectUpgradeJournalSchema.parse({ + v: 1, + hostId, + reqId: `upgrade-${encodeBase64Url(randomBytes(16))}`, + pendingResumeToken, + pendingResumeTokenHash: hashMobileRelayCredential(pendingResumeToken) + }) +} + +export async function readMobileRelayDirectUpgradeJournal( + hostId: string +): Promise { + requireNativeSecretStore() + const raw = await SecureStore.getItemAsync(journalKey(hostId), KEYCHAIN_OPTIONS) + if (!raw) { + return null + } + try { + const parsed = MobileRelayDirectUpgradeJournalSchema.safeParse(JSON.parse(raw)) + return parsed.success && parsed.data.hostId === hostId ? parsed.data : null + } catch { + return null + } +} + +export async function writeMobileRelayDirectUpgradeJournal( + journal: MobileRelayDirectUpgradeJournal +): Promise { + requireNativeSecretStore() + const parsed = MobileRelayDirectUpgradeJournalSchema.parse(journal) + await SecureStore.setItemAsync( + journalKey(parsed.hostId), + JSON.stringify(parsed), + KEYCHAIN_OPTIONS + ) +} + +export async function deleteMobileRelayDirectUpgradeJournal(hostId: string): Promise { + if (Platform.OS === 'web') { + return + } + await SecureStore.deleteItemAsync(journalKey(hostId), KEYCHAIN_OPTIONS) +} + +function encodeBase64Url(value: Uint8Array): string { + let binary = '' + for (const byte of value) { + binary += String.fromCharCode(byte) + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function requireNativeSecretStore(): void { + if (Platform.OS === 'web') { + throw new Error('Orca Relay upgrade state requires a native secret store') + } +} diff --git a/mobile/src/transport/mobile-relay-direct-upgrade.test.ts b/mobile/src/transport/mobile-relay-direct-upgrade.test.ts new file mode 100644 index 00000000000..d77848b6e17 --- /dev/null +++ b/mobile/src/transport/mobile-relay-direct-upgrade.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it, vi } from 'vitest' +import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract' +import { MobileRelayUpgradeHostRemovedError } from './host-store' +import { + createMobileRelayDirectUpgradeJournal, + type MobileRelayDirectUpgradeJournal +} from './mobile-relay-direct-upgrade-journal' +import { upgradeDirectMobileRelay } from './mobile-relay-direct-upgrade' +import type { RpcClient } from './rpc-client' +import type { HostProfile, RpcResponse } from './types' + +vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) +vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' })) +vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) })) + +const relay: MobileRelayEndpoint = { + v: 1, + directorUrl: 'https://relay-staging.onorca.dev', + cellUrl: 'https://c1.relay-staging.onorca.dev', + assignmentEpoch: 4, + relayHostId: 'AbCdEf0123_-xyZ9', + e2eeFraming: 2 +} + +const host: HostProfile = { + id: 'host-direct', + name: 'Host 4', + endpoint: 'ws://192.168.1.2:6768', + deviceToken: 'device-token', + publicKeyB64: 'A'.repeat(44), + lastConnected: 1 +} + +function success(result: unknown): RpcResponse { + return { id: 'rpc', ok: true, result, _meta: { runtimeId: 'runtime' } } +} + +function clientWith(responses: RpcResponse[]) { + return { + sendRequest: vi.fn(async () => responses.shift()!), + getState: () => 'connected' + } as unknown as RpcClient +} + +function installed(journal: MobileRelayDirectUpgradeJournal) { + return { + v: 1 as const, + reqId: journal.reqId, + authorizationMode: 'authenticated-direct' as const, + currentVersion: 1, + resumeExpiresAt: 9_999_999 + } +} + +function dependencies(journal: MobileRelayDirectUpgradeJournal | null = null) { + let stored = journal + return { + readJournal: vi.fn(async () => stored), + writeJournal: vi.fn(async (next: MobileRelayDirectUpgradeJournal) => { + stored = next + }), + clearJournal: vi.fn(async () => { + stored = null + }), + writeBundle: vi.fn(async () => {}), + deleteBundle: vi.fn(async () => {}), + saveHost: vi.fn(async () => {}), + randomBytes: (length: number) => new Uint8Array(length).fill(7) + } +} + +describe('existing direct pairing relay upgrade', () => { + it('persists pending material before install and publishes only after committed status', async () => { + const deps = dependencies() + let journal: MobileRelayDirectUpgradeJournal | null = null + deps.writeJournal.mockImplementation(async (next) => { + journal = next + }) + const client = clientWith([ + success({ v: 1, relay }), + success({ + v: 1, + reqId: 'upgrade-BwcHBwcHBwcHBwcHBwcHBw', + authorizationMode: 'authenticated-direct', + currentVersion: 1, + resumeExpiresAt: 9_999_999 + }), + success({ + v: 1, + relay, + installStatus: { + v: 1, + reqId: 'upgrade-BwcHBwcHBwcHBwcHBwcHBw', + state: 'committed', + result: { + v: 1, + reqId: 'upgrade-BwcHBwcHBwcHBwcHBwcHBw', + authorizationMode: 'authenticated-direct', + currentVersion: 1, + resumeExpiresAt: 9_999_999 + } + } + }) + ]) + + const result = await upgradeDirectMobileRelay({ client, host, dependencies: deps }) + + expect(journal).not.toBeNull() + expect(deps.writeJournal.mock.invocationCallOrder[0]).toBeLessThan( + client.sendRequest.mock.invocationCallOrder[0]! + ) + expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'pairing.provisionRelay', { + reqId: journal!.reqId, + newResumeTokenHash: journal!.pendingResumeTokenHash + }) + expect(deps.writeBundle).toHaveBeenCalledBefore(deps.saveHost) + expect(result?.host.relay).toEqual(relay) + expect(deps.clearJournal).toHaveBeenCalledWith(host.id) + }) + + it('recovers an already committed install without authorizing a second one', async () => { + const journal = createMobileRelayDirectUpgradeJournal(host.id, (length) => + new Uint8Array(length).fill(3) + ) + const committed = installed(journal) + const deps = dependencies(journal) + const client = clientWith([ + success({ + v: 1, + relay, + installStatus: { v: 1, reqId: journal.reqId, state: 'committed', result: committed } + }) + ]) + + const result = await upgradeDirectMobileRelay({ client, host, dependencies: deps }) + + expect(client.sendRequest).toHaveBeenCalledOnce() + expect(result?.bundle.current.version).toBe(1) + expect(deps.writeBundle).toHaveBeenCalledOnce() + }) + + it('cleans pending state and leaves direct access unchanged for an old desktop', async () => { + const deps = dependencies() + const client = clientWith([ + { + id: 'rpc', + ok: false, + error: { code: 'method_not_found', message: 'unsupported' }, + _meta: { runtimeId: 'runtime' } + } + ]) + + await expect(upgradeDirectMobileRelay({ client, host, dependencies: deps })).resolves.toBeNull() + expect(deps.clearJournal).toHaveBeenCalledWith(host.id) + expect(deps.writeBundle).not.toHaveBeenCalled() + expect(deps.saveHost).not.toHaveBeenCalled() + }) + + it('retains the durable journal when relay registration is temporarily unavailable', async () => { + const deps = dependencies() + const client = clientWith([success({ v: 1, relay: null })]) + + await expect(upgradeDirectMobileRelay({ client, host, dependencies: deps })).rejects.toThrow( + 'relay endpoint unavailable' + ) + expect(deps.writeJournal).toHaveBeenCalledOnce() + expect(deps.clearJournal).not.toHaveBeenCalled() + }) + + it('cleans newly installed secrets instead of resurrecting a removed host', async () => { + const journal = createMobileRelayDirectUpgradeJournal(host.id, (length) => + new Uint8Array(length).fill(5) + ) + const committed = installed(journal) + const deps = dependencies(journal) + deps.saveHost.mockRejectedValue( + new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed') + ) + const client = clientWith([ + success({ + v: 1, + relay, + installStatus: { v: 1, reqId: journal.reqId, state: 'committed', result: committed } + }) + ]) + + await expect( + upgradeDirectMobileRelay({ client, host, dependencies: deps }) + ).rejects.toBeInstanceOf(MobileRelayUpgradeHostRemovedError) + expect(deps.deleteBundle).toHaveBeenCalledWith(host.id) + expect(deps.clearJournal).toHaveBeenCalledWith(host.id) + }) +}) diff --git a/mobile/src/transport/mobile-relay-direct-upgrade.ts b/mobile/src/transport/mobile-relay-direct-upgrade.ts new file mode 100644 index 00000000000..b804d6fb610 --- /dev/null +++ b/mobile/src/transport/mobile-relay-direct-upgrade.ts @@ -0,0 +1,175 @@ +import * as ExpoCrypto from 'expo-crypto' +import { + DeviceCredentialInstalledSchema, + PairingGetEndpointsResultSchema, + type DeviceCredentialInstalled, + type PairingGetEndpointsResult +} from '../../../src/shared/mobile-relay-credential-contract' +import { MobileRelayUpgradeHostRemovedError, saveExistingHostRelayUpgrade } from './host-store' +import { persistRelayHost } from './mobile-endpoint-supervisor-support' +import { + MobileRelayCredentialBundleSchema, + deleteMobileRelayCredentialBundle, + writeMobileRelayCredentialBundle, + type MobileRelayCredentialBundle +} from './mobile-relay-credential-bundle' +import { + createMobileRelayDirectUpgradeJournal, + deleteMobileRelayDirectUpgradeJournal, + readMobileRelayDirectUpgradeJournal, + writeMobileRelayDirectUpgradeJournal, + type MobileRelayDirectUpgradeJournal +} from './mobile-relay-direct-upgrade-journal' +import type { RpcClient } from './rpc-client' +import type { HostProfile, RpcResponse } from './types' + +export type MobileRelayDirectUpgradeResult = { + host: HostProfile + bundle: MobileRelayCredentialBundle +} + +type Dependencies = { + readJournal: typeof readMobileRelayDirectUpgradeJournal + writeJournal: typeof writeMobileRelayDirectUpgradeJournal + clearJournal: typeof deleteMobileRelayDirectUpgradeJournal + writeBundle: typeof writeMobileRelayCredentialBundle + saveHost: typeof saveExistingHostRelayUpgrade + deleteBundle: typeof deleteMobileRelayCredentialBundle + randomBytes: (length: number) => Uint8Array +} + +export async function upgradeDirectMobileRelay(args: { + client: RpcClient + host: HostProfile + dependencies?: Partial +}): Promise { + if (args.host.relay) { + return null + } + const dependencies: Dependencies = { + readJournal: readMobileRelayDirectUpgradeJournal, + writeJournal: writeMobileRelayDirectUpgradeJournal, + clearJournal: deleteMobileRelayDirectUpgradeJournal, + writeBundle: writeMobileRelayCredentialBundle, + saveHost: saveExistingHostRelayUpgrade, + deleteBundle: deleteMobileRelayCredentialBundle, + randomBytes: ExpoCrypto.getRandomBytes, + ...args.dependencies + } + let journal = await dependencies.readJournal(args.host.id) + if (!journal) { + journal = createMobileRelayDirectUpgradeJournal(args.host.id, dependencies.randomBytes) + // Why: the stable reqId and pending secret must survive a lost install response. + await dependencies.writeJournal(journal) + } + + const initial = await getEndpoints(args.client, journal.reqId) + if (initial === 'method-not-found') { + await dependencies.clearJournal(args.host.id) + return null + } + if (initial.installStatus?.state === 'committed') { + return publishCommitted(args.host, journal, initial, dependencies) + } + if (!initial.relay) { + throw new Error('relay endpoint unavailable for direct pairing upgrade') + } + + const provisionResponse = await args.client.sendRequest('pairing.provisionRelay', { + reqId: journal.reqId, + newResumeTokenHash: journal.pendingResumeTokenHash + }) + if (isMethodNotFound(provisionResponse)) { + await dependencies.clearJournal(args.host.id) + return null + } + const installed = DeviceCredentialInstalledSchema.parse(requireSuccess(provisionResponse)) + assertDirectInstall(journal, installed) + const reconciled = await getEndpoints(args.client, journal.reqId) + if (reconciled === 'method-not-found') { + throw new Error('relay endpoint reconciliation became unavailable') + } + assertCommitted(reconciled, installed) + return publishCommitted(args.host, journal, reconciled, dependencies) +} + +async function publishCommitted( + host: HostProfile, + journal: MobileRelayDirectUpgradeJournal, + endpoints: PairingGetEndpointsResult, + dependencies: Dependencies +): Promise { + if (endpoints.installStatus?.state !== 'committed' || !endpoints.relay) { + throw new Error('direct pairing upgrade was not authoritatively committed') + } + const installed = endpoints.installStatus.result + assertDirectInstall(journal, installed) + const bundle = MobileRelayCredentialBundleSchema.parse({ + v: 1, + hostId: host.id, + deviceToken: host.deviceToken, + current: { + token: journal.pendingResumeToken, + hash: journal.pendingResumeTokenHash, + version: installed.currentVersion, + expiresAt: installed.resumeExpiresAt + } + }) + // Why: the overlay must never advertise relay without its matching credential. + await dependencies.writeBundle(bundle) + let updatedHost: HostProfile + try { + updatedHost = await persistRelayHost(host, endpoints.relay, dependencies.saveHost) + } catch (error) { + if (error instanceof MobileRelayUpgradeHostRemovedError) { + await dependencies.deleteBundle(host.id) + await dependencies.clearJournal(host.id) + } + throw error + } + await dependencies.clearJournal(host.id) + return { host: updatedHost, bundle } +} + +async function getEndpoints( + client: RpcClient, + installReqId: string +): Promise { + const response = await client.sendRequest('pairing.getEndpoints', { installReqId }) + if (isMethodNotFound(response)) { + return 'method-not-found' + } + return PairingGetEndpointsResultSchema.parse(requireSuccess(response)) +} + +function assertDirectInstall( + journal: MobileRelayDirectUpgradeJournal, + installed: DeviceCredentialInstalled +): void { + if (installed.reqId !== journal.reqId || installed.authorizationMode !== 'authenticated-direct') { + throw new Error('relay credential install does not match direct upgrade journal') + } +} + +function assertCommitted( + endpoints: PairingGetEndpointsResult, + installed: DeviceCredentialInstalled +): void { + if ( + endpoints.installStatus?.state !== 'committed' || + JSON.stringify(endpoints.installStatus.result) !== JSON.stringify(installed) + ) { + throw new Error('relay credential install was not authoritatively reconciled') + } +} + +function requireSuccess(response: RpcResponse): unknown { + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + return response.result +} + +function isMethodNotFound(response: RpcResponse): boolean { + return !response.ok && response.error.code === 'method_not_found' +} diff --git a/mobile/src/transport/mobile-relay-e2ee-link.ts b/mobile/src/transport/mobile-relay-e2ee-link.ts new file mode 100644 index 00000000000..183a8deea6d --- /dev/null +++ b/mobile/src/transport/mobile-relay-e2ee-link.ts @@ -0,0 +1,151 @@ +import { + RelayPhoneHelloSchema, + type RelayPhoneHello +} from '../../../src/shared/mobile-relay-phone-protocol' +import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session' +import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel' +import { websocketPayloadToUint8 } from './websocket-payload-bytes' + +export class RelayOuterError extends Error { + constructor(readonly code: number) { + super(`relay_outer_${code}`) + } +} + +type MobileRelayE2eeLinkOptions = { + endpoint: { cellUrl: string; relayHostId: string } + credential: string + expectedCredentialKind: 'invite' | 'resume' + deviceToken: string + desktopPublicKeyB64: string + onAuthenticated: () => void + onText: (plaintext: string) => void + onBinary: (plaintext: Uint8Array) => void + onHello?: (hello: Extract) => void + onError: (error: Error) => void + createSocket?: (url: string) => WebSocket +} + +export class MobileRelayE2eeLink { + private readonly options: MobileRelayE2eeLinkOptions + private readonly socket: WebSocket + private readonly channel: MobileE2EEV2PhysicalChannel + private outerReady = false + private closed = false + private inboundChain: Promise = Promise.resolve() + + constructor(options: MobileRelayE2eeLinkOptions) { + this.options = options + this.socket = (options.createSocket ?? ((url) => new WebSocket(url)))( + relaySocketUrl(options.endpoint) + ) + const session = MobileE2EEV2ClientSession.create({ + desktopPublicKeyB64: options.desktopPublicKeyB64, + transport: 'relay', + relayHostId: options.endpoint.relayHostId + }) + this.channel = new MobileE2EEV2PhysicalChannel({ + session, + socket: this.socket, + deviceToken: options.deviceToken, + decodeBinary: websocketPayloadToUint8, + onAuthenticated: options.onAuthenticated, + onText: options.onText, + onBinary: options.onBinary, + onError: (error) => this.fail(error) + }) + this.bindSocket() + } + + sendText(plaintext: string): boolean { + return !this.closed && this.channel.sendText(plaintext) + } + + sendBinary(plaintext: Uint8Array): boolean { + return !this.closed && this.channel.sendBinary(plaintext) + } + + close(): void { + if (this.closed) { + return + } + this.closed = true + this.channel.dispose() + this.socket.close() + } + + private bindSocket(): void { + this.socket.onopen = () => { + this.socket.send( + JSON.stringify({ + type: 'relay-auth', + v: 1, + mode: 'connect', + credential: this.options.credential + }) + ) + } + this.socket.onmessage = (event) => { + this.inboundChain = this.inboundChain + .then(async () => { + if (this.closed) { + return + } + if (!this.outerReady) { + this.acceptHello(event.data) + } else { + await this.channel.handleMessage(event.data) + } + }) + .catch((error: unknown) => this.fail(asError(error))) + } + this.socket.onerror = () => this.fail(new Error('relay transport error')) + this.socket.onclose = (event) => this.fail(new RelayOuterError(event.code || 1006)) + } + + private acceptHello(raw: unknown): void { + if (typeof raw !== 'string') { + throw new Error('expected plaintext relay hello') + } + let value: unknown + try { + value = JSON.parse(raw) + } catch { + throw new Error('invalid relay hello JSON') + } + const parsed = RelayPhoneHelloSchema.safeParse(value) + if (!parsed.success) { + throw new Error('invalid relay hello') + } + if (!parsed.data.ok) { + throw new RelayOuterError(parsed.data.code) + } + if (parsed.data.credentialKind !== this.options.expectedCredentialKind) { + throw new Error('relay credential resolved as an unexpected credential kind') + } + this.outerReady = true + this.options.onHello?.(parsed.data) + this.channel.start() + } + + private fail(error: Error): void { + if (this.closed) { + return + } + this.closed = true + this.channel.dispose() + this.options.onError(error) + this.socket.close() + } +} + +function relaySocketUrl(endpoint: { cellUrl: string; relayHostId: string }): string { + const url = new URL(endpoint.cellUrl) + url.protocol = 'wss:' + url.pathname = `/v1/connect/${encodeURIComponent(endpoint.relayHostId)}` + return url.toString() +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} diff --git a/mobile/src/transport/mobile-relay-host-overlay-store.test.ts b/mobile/src/transport/mobile-relay-host-overlay-store.test.ts new file mode 100644 index 00000000000..8286fe67f5b --- /dev/null +++ b/mobile/src/transport/mobile-relay-host-overlay-store.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn() +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) + +import { + loadMobileRelayHostOverlays, + resetMobileRelayHostOverlayStoreForTests, + saveMobileRelayHostOverlay +} from './mobile-relay-host-overlay-store' +import type { MobileRelayHostOverlay } from './mobile-relay-host-overlay' + +const STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2' +const OVERLAY: MobileRelayHostOverlay = { + v: 2, + hostId: 'host-1', + endpoints: [ + { id: 'direct-primary', kind: 'lan', url: 'ws://192.168.1.10:6768' }, + { + id: 'relay-primary', + kind: 'relay', + url: 'wss://relay-c1.onorca.dev/v1/connect/AbCdEf0123_-xyZ9' + } + ], + relayHostId: 'AbCdEf0123_-xyZ9', + relay: { + v: 1, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + e2eeFraming: 2 + } +} + +describe('mobile relay host overlay store', () => { + let stored: string | null + + beforeEach(() => { + vi.clearAllMocks() + resetMobileRelayHostOverlayStoreForTests() + stored = null + asyncStorage.getItem.mockImplementation(async (key: string) => + key === STORAGE_KEY ? stored : null + ) + asyncStorage.setItem.mockImplementation(async (key: string, value: string) => { + if (key === STORAGE_KEY) { + stored = value + } + }) + }) + + it('round-trips v2 metadata in a namespace legacy builds do not rewrite', async () => { + await saveMobileRelayHostOverlay(OVERLAY) + + await expect(loadMobileRelayHostOverlays(new Set(['host-1']))).resolves.toEqual( + new Map([['host-1', OVERLAY]]) + ) + expect(asyncStorage.setItem).toHaveBeenCalledWith(STORAGE_KEY, expect.any(String)) + }) + + it('never overlays or resurrects a host whose legacy base was removed', async () => { + stored = JSON.stringify([OVERLAY]) + + await expect(loadMobileRelayHostOverlays(new Set())).resolves.toEqual(new Map()) + expect(asyncStorage.setItem).not.toHaveBeenCalled() + expect(JSON.parse(stored)).toEqual([OVERLAY]) + }) + + it('refuses to overwrite unreadable overlay storage', async () => { + stored = '{' + + await expect(saveMobileRelayHostOverlay(OVERLAY)).rejects.toThrow(/unreadable/) + expect(asyncStorage.setItem).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/transport/mobile-relay-host-overlay-store.ts b/mobile/src/transport/mobile-relay-host-overlay-store.ts new file mode 100644 index 00000000000..4dbb50d9f46 --- /dev/null +++ b/mobile/src/transport/mobile-relay-host-overlay-store.ts @@ -0,0 +1,94 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { + MobileRelayHostOverlaySchema, + type MobileRelayHostOverlay +} from './mobile-relay-host-overlay' + +const OVERLAY_STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2' +let overlayMutation: Promise = Promise.resolve() + +function parseOverlays(raw: string | null): MobileRelayHostOverlay[] | null { + if (raw === null) { + return [] + } + try { + const value = JSON.parse(raw) as unknown + if (!Array.isArray(value)) { + return null + } + return value.flatMap((item) => { + const result = MobileRelayHostOverlaySchema.safeParse(item) + return result.success ? [result.data] : [] + }) + } catch { + return null + } +} + +async function readOverlaysForMutation(): Promise { + const overlays = parseOverlays(await AsyncStorage.getItem(OVERLAY_STORAGE_KEY)) + if (!overlays) { + // Why: never rewrite an unreadable v2 namespace as an empty list; doing so + // would destroy relay recovery data during an unrelated host mutation. + throw new Error('mobile relay host overlay storage unreadable') + } + return overlays +} + +async function mutateOverlays( + update: (overlays: MobileRelayHostOverlay[]) => MobileRelayHostOverlay[] +): Promise { + const mutation = overlayMutation.then(async () => { + const current = await readOverlaysForMutation() + await AsyncStorage.setItem(OVERLAY_STORAGE_KEY, JSON.stringify(update(current))) + }) + overlayMutation = mutation.catch(() => {}) + return mutation +} + +export async function loadMobileRelayHostOverlays( + existingHostIds: ReadonlySet +): Promise> { + return (await loadMobileRelayHostOverlayState(existingHostIds)).overlays +} + +export async function loadMobileRelayHostOverlayState( + existingHostIds: ReadonlySet +): Promise<{ overlays: Map; orphanHostIds: string[] }> { + await overlayMutation + const overlays = parseOverlays(await AsyncStorage.getItem(OVERLAY_STORAGE_KEY)) ?? [] + const active = new Map() + const orphanHostIds: string[] = [] + for (const overlay of overlays) { + // Why: an older app can remove the legacy base without knowing this + // namespace; never let the retained overlay resurrect that host later. + if (existingHostIds.has(overlay.hostId)) { + active.set(overlay.hostId, overlay) + } else { + orphanHostIds.push(overlay.hostId) + } + } + return { overlays: active, orphanHostIds } +} + +export async function saveMobileRelayHostOverlay(overlay: MobileRelayHostOverlay): Promise { + const validated = MobileRelayHostOverlaySchema.parse(overlay) + return mutateOverlays((overlays) => { + const index = overlays.findIndex(({ hostId }) => hostId === validated.hostId) + if (index < 0) { + return [...overlays, validated] + } + const next = overlays.slice() + next[index] = validated + return next + }) +} + +export function removeMobileRelayHostOverlay(hostId: string): Promise { + return mutateOverlays((overlays) => overlays.filter((overlay) => overlay.hostId !== hostId)) +} + +/** Test-only: drain the module mutation chain between cases. */ +export function resetMobileRelayHostOverlayStoreForTests(): void { + overlayMutation = Promise.resolve() +} diff --git a/mobile/src/transport/mobile-relay-host-overlay.ts b/mobile/src/transport/mobile-relay-host-overlay.ts new file mode 100644 index 00000000000..59c407c9ee3 --- /dev/null +++ b/mobile/src/transport/mobile-relay-host-overlay.ts @@ -0,0 +1,47 @@ +import { z } from 'zod' +import { MobileRelayEndpointSchema } from '../../../src/shared/mobile-relay-credential-contract' + +export const MobileAccessEndpointSchema = z + .object({ + id: z.string().min(1).max(128), + kind: z.enum(['lan', 'tailscale', 'relay']), + url: z.string().min(1).max(2048) + }) + .strict() + +export const MobileRelayHostOverlaySchema = z + .object({ + v: z.literal(2), + hostId: z.string().min(1), + endpoints: z.array(MobileAccessEndpointSchema).min(1).max(16), + relayHostId: z + .string() + .regex(/^[A-Za-z0-9_-]{16}$/) + .optional(), + relay: MobileRelayEndpointSchema.optional() + }) + .strict() + .superRefine((overlay, context) => { + if ((overlay.relayHostId === undefined) !== (overlay.relay === undefined)) { + context.addIssue({ code: 'custom', message: 'Relay identity and endpoint must coexist' }) + return + } + if (overlay.relay && overlay.relay.relayHostId !== overlay.relayHostId) { + context.addIssue({ + code: 'custom', + path: ['relayHostId'], + message: 'Relay host identity mismatch' + }) + } + const relayEndpointCount = overlay.endpoints.filter(({ kind }) => kind === 'relay').length + if (relayEndpointCount !== (overlay.relay ? 1 : 0)) { + context.addIssue({ + code: 'custom', + path: ['endpoints'], + message: 'Expected exactly one endpoint for configured relay metadata' + }) + } + }) + +export type MobileAccessEndpoint = z.infer +export type MobileRelayHostOverlay = z.infer diff --git a/mobile/src/transport/mobile-relay-invite-director.test.ts b/mobile/src/transport/mobile-relay-invite-director.test.ts new file mode 100644 index 00000000000..04508024d07 --- /dev/null +++ b/mobile/src/transport/mobile-relay-invite-director.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { resolvePairingInviteThroughDirector } from './mobile-relay-invite-director' + +class FakeSocket { + sent: string[] = [] + onopen: (() => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + onerror: (() => void) | null = null + onclose: ((event: { code: number }) => void) | null = null + send(value: string): void { + this.sent.push(value) + } + close(): void {} +} + +const relay = { + v: 1 as const, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: 'abcdefghijklmnopqrstuvwxyzABCDEFGH012345678', + inviteExpiresAt: Date.now() + 300_000, + e2eeFraming: 2 as const +} + +describe('pairing invite director resolution', () => { + it('authenticates only to the configured director and accepts a strictly newer move', async () => { + const socket = new FakeSocket() + let url = '' + const resolving = resolvePairingInviteThroughDirector({ + relay, + createSocket: (value) => { + url = value + return socket as unknown as WebSocket + } + }) + socket.onopen?.() + expect(url).toBe('wss://relay.onorca.dev/v1/connect/AbCdEf0123_-xyZ9') + expect(url).not.toContain('?') + expect(JSON.parse(socket.sent[0]!)).toEqual({ + type: 'relay-auth', + v: 1, + mode: 'connect', + credential: relay.inviteToken + }) + socket.onmessage?.({ + data: JSON.stringify({ + type: 'relay-moved', + v: 1, + cellUrl: 'https://relay-c2.onorca.dev', + assignmentEpoch: 8 + }) + }) + + await expect(resolving).resolves.toMatchObject({ + cellUrl: 'https://relay-c2.onorca.dev', + assignmentEpoch: 8 + }) + }) + + it('rejects same/older epochs and untrusted extra fields', async () => { + const socket = new FakeSocket() + const resolving = resolvePairingInviteThroughDirector({ + relay, + createSocket: () => socket as unknown as WebSocket + }) + socket.onmessage?.({ + data: JSON.stringify({ + type: 'relay-moved', + v: 1, + cellUrl: 'https://relay-c2.onorca.dev', + assignmentEpoch: 7, + targetFromCell: true + }) + }) + + await expect(resolving).rejects.toThrow(/not strictly newer/) + }) +}) diff --git a/mobile/src/transport/mobile-relay-invite-director.ts b/mobile/src/transport/mobile-relay-invite-director.ts new file mode 100644 index 00000000000..5dc83f526b6 --- /dev/null +++ b/mobile/src/transport/mobile-relay-invite-director.ts @@ -0,0 +1,78 @@ +import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer' +import { RelayMovedSchema } from '../../../src/shared/mobile-relay-phone-protocol' + +export function resolvePairingInviteThroughDirector(args: { + relay: PairingRelay + timeoutMs?: number + createSocket?: (url: string) => WebSocket +}): Promise { + const socket = (args.createSocket ?? ((url) => new WebSocket(url)))( + directorWebSocketUrl(args.relay) + ) + return new Promise((resolve, reject) => { + let settled = false + const timeout = setTimeout( + () => finish(new Error('relay director resolution timed out')), + args.timeoutMs ?? 5_000 + ) + socket.onopen = () => { + socket.send( + JSON.stringify({ + type: 'relay-auth', + v: 1, + mode: 'connect', + credential: args.relay.inviteToken + }) + ) + } + socket.onmessage = (event) => { + if (typeof event.data !== 'string') { + finish(new Error('invalid relay director move')) + return + } + let value: unknown + try { + value = JSON.parse(event.data) + } catch { + finish(new Error('invalid relay director move')) + return + } + const moved = RelayMovedSchema.safeParse(value) + if (!moved.success || moved.data.assignmentEpoch <= args.relay.assignmentEpoch) { + finish(new Error('relay director move was not strictly newer')) + return + } + settled = true + clearTimeout(timeout) + socket.close() + resolve({ + ...args.relay, + cellUrl: moved.data.cellUrl, + assignmentEpoch: moved.data.assignmentEpoch + }) + } + socket.onerror = () => finish(new Error('relay director transport error')) + socket.onclose = (event) => { + if (!settled) { + finish(new Error(`relay director closed before move: ${event.code || 1006}`)) + } + } + + function finish(error: Error): void { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + socket.close() + reject(error) + } + }) +} + +export function directorWebSocketUrl(relay: PairingRelay): string { + const url = new URL(relay.directorUrl) + url.protocol = 'wss:' + url.pathname = `/v1/connect/${encodeURIComponent(relay.relayHostId)}` + return url.toString() +} diff --git a/mobile/src/transport/mobile-relay-orphan-cleanup.test.ts b/mobile/src/transport/mobile-relay-orphan-cleanup.test.ts new file mode 100644 index 00000000000..ceab7e6b412 --- /dev/null +++ b/mobile/src/transport/mobile-relay-orphan-cleanup.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest' +import { scheduleOrphanedMobileRelayCleanup } from './mobile-relay-orphan-cleanup' + +describe('mobile relay orphan cleanup', () => { + it('durably schedules credential deletion before removing an orphan overlay pointer', async () => { + const order: string[] = [] + const deleteCredential = vi.fn(async () => {}) + const scheduleCleanup = vi.fn(async (hostId: string) => { + order.push(`schedule:${hostId}`) + }) + const removeOverlay = vi.fn(async (hostId: string) => { + order.push(`overlay:${hostId}`) + }) + + await scheduleOrphanedMobileRelayCleanup({ + hostIds: ['host-1', 'host-1'], + deleteCredential, + scheduleCleanup, + removeOverlay + }) + + expect(order).toEqual(['schedule:host-1', 'overlay:host-1']) + expect(scheduleCleanup).toHaveBeenCalledWith('host-1', deleteCredential) + }) + + it('retains the overlay pointer when durable cleanup scheduling fails', async () => { + const removeOverlay = vi.fn(async () => {}) + await scheduleOrphanedMobileRelayCleanup({ + hostIds: ['host-1'], + deleteCredential: vi.fn(async () => {}), + scheduleCleanup: vi.fn(async () => { + throw new Error('storage unavailable') + }), + removeOverlay + }) + + expect(removeOverlay).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/transport/mobile-relay-orphan-cleanup.ts b/mobile/src/transport/mobile-relay-orphan-cleanup.ts new file mode 100644 index 00000000000..fd57f01f9c9 --- /dev/null +++ b/mobile/src/transport/mobile-relay-orphan-cleanup.ts @@ -0,0 +1,22 @@ +import { scheduleHostCredentialCleanup } from './host-credential-cleanup' +import { removeMobileRelayHostOverlay } from './mobile-relay-host-overlay-store' + +export async function scheduleOrphanedMobileRelayCleanup(args: { + hostIds: string[] + deleteCredential: (hostId: string) => Promise + scheduleCleanup?: typeof scheduleHostCredentialCleanup + removeOverlay?: typeof removeMobileRelayHostOverlay +}): Promise { + const scheduleCleanup = args.scheduleCleanup ?? scheduleHostCredentialCleanup + const removeOverlay = args.removeOverlay ?? removeMobileRelayHostOverlay + for (const hostId of new Set(args.hostIds)) { + try { + // Why: an older build may remove the legacy host while retaining the v2 + // namespace; persist keychain cleanup intent before dropping that pointer. + await scheduleCleanup(hostId, args.deleteCredential) + await removeOverlay(hostId) + } catch { + // Retain the overlay pointer if durable cleanup intent could not be recorded. + } + } +} diff --git a/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts b/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts new file mode 100644 index 00000000000..bc65bc2e6eb --- /dev/null +++ b/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts @@ -0,0 +1,173 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn() +})) +const secureStore = vi.hoisted(() => ({ + getItemAsync: vi.fn(), + setItemAsync: vi.fn(), + deleteItemAsync: vi.fn() +})) +const platform = vi.hoisted(() => ({ OS: 'ios' })) + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) +vi.mock('expo-secure-store', () => ({ + WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY', + ...secureStore +})) +vi.mock('expo-crypto', () => ({ getRandomBytes: vi.fn() })) +vi.mock('react-native', () => ({ Platform: platform })) + +import { createMobileRelayPairingJournal } from './mobile-relay-pairing-journal' +import { + clearMobileRelayPairingJournal, + loadMobileRelayPairingJournal, + resetMobileRelayPairingJournalStoreForTests, + saveMobileRelayPairingJournal, + updateMobileRelayPairingJournal +} from './mobile-relay-pairing-journal-store' +import type { PairingOffer } from './types' + +const now = Date.UTC(2026, 6, 13) +const offer = { + v: 2, + endpoint: 'ws://192.168.1.10:6768', + deviceToken: 'device-token-secret', + publicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + relay: { + v: 1, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: 'abcdefghijklmnopqrstuvwxyzABCDEFGH012345678', + inviteExpiresAt: now + 300_000, + e2eeFraming: 2 + } +} satisfies PairingOffer + +describe('mobile relay pairing journal store', () => { + let metadataRaw: string | null + let secretRaw: string | null + + beforeEach(() => { + vi.clearAllMocks() + resetMobileRelayPairingJournalStoreForTests() + platform.OS = 'ios' + metadataRaw = null + secretRaw = null + asyncStorage.getItem.mockImplementation(async () => metadataRaw) + asyncStorage.setItem.mockImplementation(async (_key: string, value: string) => { + metadataRaw = value + }) + asyncStorage.removeItem.mockImplementation(async () => { + metadataRaw = null + }) + secureStore.getItemAsync.mockImplementation(async () => secretRaw) + secureStore.setItemAsync.mockImplementation(async (_key: string, value: string) => { + secretRaw = value + }) + secureStore.deleteItemAsync.mockImplementation(async () => { + secretRaw = null + }) + }) + + it('persists metadata before secrets and never places bearers in AsyncStorage', async () => { + const journal = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-1', + hostName: 'Blue Whale', + now, + randomBytes: (length) => new Uint8Array(length).fill(length) + }) + + await saveMobileRelayPairingJournal(journal) + + expect(asyncStorage.setItem.mock.invocationCallOrder[0]).toBeLessThan( + secureStore.setItemAsync.mock.invocationCallOrder[0]! + ) + expect(metadataRaw).not.toContain(offer.deviceToken) + expect(metadataRaw).not.toContain(offer.relay.inviteToken) + expect(metadataRaw).not.toContain(journal.secrets.pendingResumeToken) + await expect(loadMobileRelayPairingJournal()).resolves.toEqual(journal) + }) + + it('records a provisional winner only for the active journal identity', async () => { + const journal = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-1', + hostName: 'Blue Whale', + randomBytes: (length) => new Uint8Array(length).fill(7) + }) + await saveMobileRelayPairingJournal(journal) + + await updateMobileRelayPairingJournal(journal.metadata.journalId, (metadata) => ({ + ...metadata, + winner: 'direct', + authorizationMode: 'authenticated-direct' + })) + await expect( + updateMobileRelayPairingJournal('stale-journal', (metadata) => metadata) + ).rejects.toThrow(/stale/) + expect(JSON.parse(metadataRaw!)).toMatchObject({ + winner: 'direct', + authorizationMode: 'authenticated-direct' + }) + }) + + it('treats metadata without its secret record as an incomplete crash', async () => { + const journal = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-1', + hostName: 'Blue Whale', + randomBytes: (length) => new Uint8Array(length).fill(9) + }) + metadataRaw = JSON.stringify(journal.metadata) + + await expect(loadMobileRelayPairingJournal()).resolves.toBeNull() + expect(metadataRaw).toBeNull() + }) + + it('cleans mismatched secret records and refuses to replace a recoverable journal', async () => { + const journal = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-1', + hostName: 'Blue Whale', + randomBytes: (length) => new Uint8Array(length).fill(10) + }) + metadataRaw = JSON.stringify(journal.metadata) + secretRaw = JSON.stringify({ ...journal.secrets, journalId: 'different-journal' }) + await expect(loadMobileRelayPairingJournal()).resolves.toBeNull() + expect(metadataRaw).toBeNull() + expect(secretRaw).toBeNull() + + await saveMobileRelayPairingJournal(journal) + const replacement = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-2', + hostName: 'Red Panda', + randomBytes: (length) => new Uint8Array(length).fill(12) + }) + await expect(saveMobileRelayPairingJournal(replacement)).rejects.toThrow(/recovery pending/) + await expect(loadMobileRelayPairingJournal()).resolves.toEqual(journal) + }) + + it('clears metadata before deleting its secret and keeps relay unavailable on web', async () => { + const journal = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-1', + hostName: 'Blue Whale', + randomBytes: (length) => new Uint8Array(length).fill(11) + }) + await saveMobileRelayPairingJournal(journal) + await clearMobileRelayPairingJournal(journal.metadata.journalId) + expect(asyncStorage.removeItem.mock.invocationCallOrder[0]).toBeLessThan( + secureStore.deleteItemAsync.mock.invocationCallOrder[0]! + ) + + platform.OS = 'web' + await expect(saveMobileRelayPairingJournal(journal)).rejects.toThrow(/native secret store/) + }) +}) diff --git a/mobile/src/transport/mobile-relay-pairing-journal-store.ts b/mobile/src/transport/mobile-relay-pairing-journal-store.ts new file mode 100644 index 00000000000..6e82bf38b9f --- /dev/null +++ b/mobile/src/transport/mobile-relay-pairing-journal-store.ts @@ -0,0 +1,136 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import * as SecureStore from 'expo-secure-store' +import { Platform } from 'react-native' +import { + MobileRelayPairingJournalMetadataSchema, + MobileRelayPairingJournalSecretsSchema, + type MobileRelayPairingJournal, + type MobileRelayPairingJournalMetadata +} from './mobile-relay-pairing-journal' + +const JOURNAL_STORAGE_KEY = 'orca:mobile-relay:pairing-journal:v1' +const JOURNAL_SECRET_KEY = 'orca.mobile-relay.pairing-journal.v1' +const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = { + keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY +} +let journalMutation: Promise = Promise.resolve() + +export async function saveMobileRelayPairingJournal( + journal: MobileRelayPairingJournal +): Promise { + requireNativeSecretStore() + const metadata = MobileRelayPairingJournalMetadataSchema.parse(journal.metadata) + const secrets = MobileRelayPairingJournalSecretsSchema.parse(journal.secrets) + if (metadata.journalId !== secrets.journalId) { + throw new Error('mobile relay pairing journal identity mismatch') + } + const mutation = journalMutation.then(async () => { + const existingRaw = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY) + const existing = existingRaw ? parseMetadata(existingRaw) : null + if (existing && existing.journalId !== metadata.journalId) { + throw new Error('mobile relay pairing recovery pending') + } + // Why: metadata-first makes a crash before the keychain write recover as + // an incomplete journal, never as an untracked bearer secret. + await AsyncStorage.setItem(JOURNAL_STORAGE_KEY, JSON.stringify(metadata)) + await SecureStore.setItemAsync(JOURNAL_SECRET_KEY, JSON.stringify(secrets), KEYCHAIN_OPTIONS) + }) + journalMutation = mutation.catch(() => {}) + return mutation +} + +export async function loadMobileRelayPairingJournal(): Promise { + requireNativeSecretStore() + await journalMutation + const rawMetadata = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY) + if (rawMetadata === null) { + await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS).catch(() => {}) + return null + } + const metadata = parseMetadata(rawMetadata) + if (!metadata) { + await removeIncompleteJournal() + return null + } + const rawSecrets = await SecureStore.getItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS) + if (rawSecrets === null) { + await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY) + return null + } + const secrets = parseSecrets(rawSecrets) + if (!secrets || secrets.journalId !== metadata.journalId) { + await removeIncompleteJournal() + return null + } + return { metadata, secrets } +} + +async function removeIncompleteJournal(): Promise { + // Why: metadata is the discoverable cleanup pointer; remove it before the + // native secret so a second crash can only leave a self-cleaning orphan. + await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY) + await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS).catch(() => {}) +} + +export async function updateMobileRelayPairingJournal( + journalId: string, + update: (metadata: MobileRelayPairingJournalMetadata) => MobileRelayPairingJournalMetadata +): Promise { + const mutation = journalMutation.then(async () => { + const raw = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY) + const current = raw ? parseMetadata(raw) : null + if (!current || current.journalId !== journalId) { + throw new Error('stale mobile relay pairing journal') + } + const next = MobileRelayPairingJournalMetadataSchema.parse(update(current)) + if (next.journalId !== journalId) { + throw new Error('mobile relay pairing journal identity mismatch') + } + await AsyncStorage.setItem(JOURNAL_STORAGE_KEY, JSON.stringify(next)) + }) + journalMutation = mutation.catch(() => {}) + return mutation +} + +export async function clearMobileRelayPairingJournal(journalId: string): Promise { + const mutation = journalMutation.then(async () => { + const raw = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY) + const current = raw ? parseMetadata(raw) : null + if (current && current.journalId !== journalId) { + throw new Error('stale mobile relay pairing journal') + } + await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY) + await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS) + }) + journalMutation = mutation.catch(() => {}) + return mutation +} + +function parseMetadata(raw: string): MobileRelayPairingJournalMetadata | null { + try { + const result = MobileRelayPairingJournalMetadataSchema.safeParse(JSON.parse(raw)) + return result.success ? result.data : null + } catch { + return null + } +} + +function parseSecrets(raw: string) { + try { + const result = MobileRelayPairingJournalSecretsSchema.safeParse(JSON.parse(raw)) + return result.success ? result.data : null + } catch { + return null + } +} + +function requireNativeSecretStore(): void { + if (Platform.OS === 'web') { + throw new Error('Orca Relay pairing requires a native secret store') + } +} + +/** Test-only: drain the module mutation chain between cases. */ +export function resetMobileRelayPairingJournalStoreForTests(): void { + journalMutation = Promise.resolve() +} diff --git a/mobile/src/transport/mobile-relay-pairing-journal.ts b/mobile/src/transport/mobile-relay-pairing-journal.ts new file mode 100644 index 00000000000..08b27ad7009 --- /dev/null +++ b/mobile/src/transport/mobile-relay-pairing-journal.ts @@ -0,0 +1,111 @@ +import * as ExpoCrypto from 'expo-crypto' +import { sha256 } from '@noble/hashes/sha256' +import { z } from 'zod' +import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer' +import { hashMobileRelayCredential } from './mobile-relay-credential-hash' +import type { PairingOffer } from './types' + +const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) + +export const MobileRelayPairingJournalMetadataSchema = z + .object({ + v: z.literal(1), + journalId: z.string().min(1).max(128), + offerFingerprint: Base64Url32ByteSchema, + host: z + .object({ + id: z.string().min(1), + name: z.string().min(1), + endpoint: z.string().min(1), + publicKeyB64: z.string().min(1), + lastConnected: z.number().int().nonnegative() + }) + .strict(), + relay: z + .object({ + v: z.literal(1), + directorUrl: z.string().min(1), + cellUrl: z.string().min(1), + assignmentEpoch: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + relayHostId: z.string().regex(/^[A-Za-z0-9_-]{16}$/), + inviteExpiresAt: z.number().int().positive(), + e2eeFraming: z.literal(2) + }) + .strict(), + installReqId: z.string().min(1).max(128), + resumeConfirmReqId: z.string().min(1).max(128), + pendingResumeTokenHash: Base64Url32ByteSchema, + winner: z.enum(['direct', 'relay']).optional(), + authorizationMode: z.enum(['authenticated-direct', 'relay-basis']).optional() + }) + .strict() + +export const MobileRelayPairingJournalSecretsSchema = z + .object({ + v: z.literal(1), + journalId: z.string().min(1).max(128), + deviceToken: z.string().min(1), + inviteToken: Base64Url32ByteSchema, + pendingResumeToken: Base64Url32ByteSchema + }) + .strict() + +export type MobileRelayPairingJournalMetadata = z.infer< + typeof MobileRelayPairingJournalMetadataSchema +> +export type MobileRelayPairingJournalSecrets = z.infer< + typeof MobileRelayPairingJournalSecretsSchema +> +export type MobileRelayPairingJournal = { + metadata: MobileRelayPairingJournalMetadata + secrets: MobileRelayPairingJournalSecrets +} + +export function createMobileRelayPairingJournal(args: { + offer: PairingOffer & { relay: PairingRelay } + hostId: string + hostName: string + now?: number + randomBytes?: (length: number) => Uint8Array +}): MobileRelayPairingJournal { + const randomBytes = args.randomBytes ?? ExpoCrypto.getRandomBytes + const pendingResumeToken = encodeBase64Url(randomBytes(32)) + const journalId = `pair-${encodeBase64Url(randomBytes(16))}` + const installReqId = `install-${encodeBase64Url(randomBytes(16))}` + const resumeConfirmReqId = `confirm-${encodeBase64Url(randomBytes(16))}` + const { inviteToken, ...relayMetadata } = args.offer.relay + return { + metadata: MobileRelayPairingJournalMetadataSchema.parse({ + v: 1, + journalId, + offerFingerprint: encodeBase64Url(sha256(JSON.stringify(args.offer))), + host: { + id: args.hostId, + name: args.hostName, + endpoint: args.offer.endpoint, + publicKeyB64: args.offer.publicKeyB64, + lastConnected: args.now ?? Date.now() + }, + relay: relayMetadata, + installReqId, + resumeConfirmReqId, + pendingResumeTokenHash: hashMobileRelayCredential(pendingResumeToken) + }), + secrets: { + v: 1, + journalId, + deviceToken: args.offer.deviceToken, + inviteToken, + pendingResumeToken + } + } +} + +function encodeBase64Url(value: Uint8Array | string): string { + const bytes = typeof value === 'string' ? new TextEncoder().encode(value) : value + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} diff --git a/mobile/src/transport/mobile-relay-pairing-offer.test.ts b/mobile/src/transport/mobile-relay-pairing-offer.test.ts new file mode 100644 index 00000000000..d042896f469 --- /dev/null +++ b/mobile/src/transport/mobile-relay-pairing-offer.test.ts @@ -0,0 +1,27 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + createMobileRelayPairingFixtures, + encodePairingFixturePayload +} from '../../../src/shared/mobile-relay-pairing-fixtures' +import { decodePairingUrl } from './pairing' + +describe('mobile relay pairing contract', () => { + const now = Date.UTC(2026, 6, 12, 16) + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(now) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + for (const fixture of createMobileRelayPairingFixtures(now)) { + it(fixture.name, () => { + expect(decodePairingUrl(encodePairingFixturePayload(fixture.payload))).toEqual( + fixture.expected + ) + }) + } +}) diff --git a/mobile/src/transport/mobile-relay-pairing-recovery.test.ts b/mobile/src/transport/mobile-relay-pairing-recovery.test.ts new file mode 100644 index 00000000000..41d53189880 --- /dev/null +++ b/mobile/src/transport/mobile-relay-pairing-recovery.test.ts @@ -0,0 +1,223 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' +import { createMobileRelayPairingJournal } from './mobile-relay-pairing-journal' +import { + recoverMobileRelayPairing, + resetMobileRelayPairingRecoveryForTests +} from './mobile-relay-pairing-recovery' +import type { PairingCandidateClient } from './mobile-relay-physical-client' +import type { PairingOffer, RpcResponse } from './types' + +vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) +vi.mock('expo-crypto', () => ({ getRandomBytes: vi.fn() })) +vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED' })) +vi.mock('@react-native-async-storage/async-storage', () => ({ default: {} })) + +const now = Date.UTC(2026, 6, 13) +const offer = { + v: 2, + endpoint: 'ws://192.168.1.10:6768', + deviceToken: 'device-token', + publicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + relay: { + v: 1, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: 'abcdefghijklmnopqrstuvwxyzABCDEFGH012345678', + inviteExpiresAt: now + 300_000, + e2eeFraming: 2 + } +} satisfies PairingOffer + +function response(result: unknown): RpcResponse { + return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +function installed( + journal: ReturnType, + mode: 'authenticated-direct' | 'relay-basis' +) { + return { + v: 1 as const, + reqId: journal.metadata.installReqId, + authorizationMode: mode, + currentVersion: 1, + resumeExpiresAt: now + 86_400_000 + } +} + +function endpoints( + journal: ReturnType, + state: { state: 'not-found' } | { state: 'committed'; result: ReturnType } +) { + return { + v: 1 as const, + relay: { + v: 1 as const, + directorUrl: offer.relay.directorUrl, + cellUrl: offer.relay.cellUrl, + assignmentEpoch: offer.relay.assignmentEpoch, + relayHostId: offer.relay.relayHostId, + e2eeFraming: 2 as const + }, + installStatus: { v: 1 as const, reqId: journal.metadata.installReqId, ...state } + } +} + +function journal(mode: 'authenticated-direct' | 'relay-basis' = 'authenticated-direct') { + const value = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-1', + hostName: 'Blue Whale', + now, + randomBytes: (length) => new Uint8Array(length).fill(length) + }) + return { + ...value, + metadata: { + ...value.metadata, + winner: mode === 'authenticated-direct' ? ('direct' as const) : ('relay' as const), + authorizationMode: mode + } + } +} + +function client(handler: (method: string, params: unknown) => Promise) { + return { sendRequest: vi.fn(handler), close: vi.fn() } satisfies PairingCandidateClient +} + +function dependencies(args: { + journal: ReturnType + connectRelay: ReturnType + bundle?: MobileRelayCredentialBundle | null +}) { + return { + loadJournal: vi.fn(async () => args.journal), + updateJournal: vi.fn(async (_id, update) => { + Object.assign(args.journal.metadata, update(args.journal.metadata)) + }), + clearJournal: vi.fn(async () => {}), + readCredentialBundle: vi.fn(async () => args.bundle ?? null), + writeCredentialBundle: vi.fn(async () => {}), + loadHosts: vi.fn(async () => []), + saveHost: vi.fn(async () => {}), + connectRelay: args.connectRelay, + resolveInviteDirector: vi.fn(async () => { + throw new Error('director not needed') + }), + now: () => now, + platform: 'ios' + } +} + +describe('mobile relay pairing recovery', () => { + beforeEach(() => { + resetMobileRelayPairingRecoveryForTests() + }) + + it('recovers a lost direct-install response with the pending credential first', async () => { + const saved = journal() + const committed = installed(saved, 'authenticated-direct') + const pending = client(async (method, params) => { + expect(method).toBe('pairing.getEndpoints') + expect(params).toEqual({ + installReqId: saved.metadata.installReqId, + resumeConfirmReqId: saved.metadata.resumeConfirmReqId + }) + return response(endpoints(saved, { state: 'committed', result: committed })) + }) + const connectRelay = vi.fn(() => pending) + const deps = dependencies({ journal: saved, connectRelay }) + + await expect(recoverMobileRelayPairing(deps)).resolves.toBe('recovered') + expect(connectRelay).toHaveBeenCalledWith( + expect.objectContaining({ + credential: saved.secrets.pendingResumeToken, + expectedCredentialKind: 'resume' + }) + ) + expect(deps.writeCredentialBundle).toHaveBeenCalledOnce() + expect(deps.saveHost).toHaveBeenCalledOnce() + expect(deps.clearJournal).toHaveBeenCalledOnce() + }) + + it('tries pending then current before an unexpired invite and transitions after not-found', async () => { + const saved = journal() + const currentToken = 'C'.repeat(43) + const bundle: MobileRelayCredentialBundle = { + v: 1, + hostId: 'host-1', + deviceToken: offer.deviceToken, + current: { + token: currentToken, + hash: 'D'.repeat(43), + version: 1, + expiresAt: now + 60_000 + } + } + const failed = () => + client(async () => { + throw new Error('resume rejected') + }) + const relayInstalled = installed(saved, 'relay-basis') + let statusCalls = 0 + const invite = client(async (method) => { + if (method === 'pairing.getEndpoints') { + statusCalls += 1 + return response( + statusCalls === 1 + ? endpoints(saved, { state: 'not-found' }) + : endpoints(saved, { state: 'committed', result: relayInstalled }) + ) + } + expect(saved.metadata.authorizationMode).toBe('relay-basis') + return response(relayInstalled) + }) + const seenCredentials: (string | undefined)[] = [] + const connectRelay = vi.fn((args) => { + seenCredentials.push(args.credential) + return args.credential ? failed() : invite + }) + const deps = dependencies({ journal: saved, connectRelay, bundle }) + + await expect(recoverMobileRelayPairing(deps)).resolves.toBe('recovered') + expect(seenCredentials).toEqual([saved.secrets.pendingResumeToken, currentToken, undefined]) + expect(deps.updateJournal).toHaveBeenCalledWith(saved.metadata.journalId, expect.any(Function)) + expect(invite.sendRequest).toHaveBeenCalledWith('pairing.provisionRelay', { + reqId: saved.metadata.installReqId, + newResumeTokenHash: saved.metadata.pendingResumeTokenHash + }) + }) + + it('accepts the one late direct result after invite fallback observed not-found', async () => { + const saved = journal() + const directInstalled = installed(saved, 'authenticated-direct') + const failedPending = client(async () => { + throw new Error('pending unavailable') + }) + let statusCalls = 0 + const invite = client(async (method) => { + if (method === 'pairing.getEndpoints') { + statusCalls += 1 + return response( + statusCalls === 1 + ? endpoints(saved, { state: 'not-found' }) + : endpoints(saved, { state: 'committed', result: directInstalled }) + ) + } + return response(directInstalled) + }) + const deps = dependencies({ + journal: saved, + connectRelay: vi.fn((args) => (args.credential ? failedPending : invite)) + }) + + await expect(recoverMobileRelayPairing(deps)).resolves.toBe('recovered') + const written = deps.writeCredentialBundle.mock.calls[0]![0] + expect(written.current.token).toBe(saved.secrets.pendingResumeToken) + expect(saved.metadata.authorizationMode).toBe('authenticated-direct') + expect(deps.updateJournal).toHaveBeenCalledTimes(2) + }) +}) diff --git a/mobile/src/transport/mobile-relay-pairing-recovery.ts b/mobile/src/transport/mobile-relay-pairing-recovery.ts new file mode 100644 index 00000000000..0d5ba7e5e07 --- /dev/null +++ b/mobile/src/transport/mobile-relay-pairing-recovery.ts @@ -0,0 +1,296 @@ +import { Platform } from 'react-native' +import { + DeviceCredentialInstalledSchema, + PairingGetEndpointsResultSchema, + type DeviceCredentialInstalled, + type MobileRelayEndpoint +} from '../../../src/shared/mobile-relay-credential-contract' +import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer' +import { loadHosts, saveHost } from './host-store' +import { + promotePairingJournalCredential, + readMobileRelayCredentialBundle, + writeMobileRelayCredentialBundle, + type MobileRelayCredentialBundle +} from './mobile-relay-credential-bundle' +import { resolvePairingInviteThroughDirector } from './mobile-relay-invite-director' +import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' +import { + clearMobileRelayPairingJournal, + loadMobileRelayPairingJournal, + updateMobileRelayPairingJournal +} from './mobile-relay-pairing-journal-store' +import { + connectMobileRelayForPairing, + type PairingCandidateClient +} from './mobile-relay-physical-client' +import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate' +import type { HostProfile, RpcResponse } from './types' + +export type MobileRelayPairingRecoveryResult = 'none' | 'recovered' | 'deferred' + +type RecoveryDependencies = { + loadJournal: typeof loadMobileRelayPairingJournal + updateJournal: typeof updateMobileRelayPairingJournal + clearJournal: typeof clearMobileRelayPairingJournal + readCredentialBundle: typeof readMobileRelayCredentialBundle + writeCredentialBundle: typeof writeMobileRelayCredentialBundle + loadHosts: typeof loadHosts + saveHost: typeof saveHost + connectRelay: typeof connectMobileRelayForPairing + resolveInviteDirector: typeof resolvePairingInviteThroughDirector + now: () => number + platform: string +} + +const defaultDependencies: RecoveryDependencies = { + loadJournal: loadMobileRelayPairingJournal, + updateJournal: updateMobileRelayPairingJournal, + clearJournal: clearMobileRelayPairingJournal, + readCredentialBundle: readMobileRelayCredentialBundle, + writeCredentialBundle: writeMobileRelayCredentialBundle, + loadHosts, + saveHost, + connectRelay: connectMobileRelayForPairing, + resolveInviteDirector: resolvePairingInviteThroughDirector, + now: Date.now, + platform: Platform.OS +} + +let recoveryPromise: Promise | null = null + +export function recoverMobileRelayPairing( + overrides: Partial = {} +): Promise { + if (recoveryPromise) { + return recoveryPromise + } + const dependencies = { ...defaultDependencies, ...overrides } + recoveryPromise = runRecovery(dependencies).finally(() => { + recoveryPromise = null + }) + return recoveryPromise +} + +async function runRecovery( + dependencies: RecoveryDependencies +): Promise { + if (dependencies.platform === 'web') { + return 'none' + } + let journal: MobileRelayPairingJournal | null + try { + journal = await dependencies.loadJournal() + } catch { + return 'deferred' + } + if (!journal) { + return 'none' + } + const bundle = await dependencies.readCredentialBundle(journal.metadata.host.id).catch(() => null) + const hosts = await dependencies.loadHosts().catch(() => []) + const existing = hosts.find(({ id }) => id === journal!.metadata.host.id) + if (existing?.relayHostId === journal.metadata.relay.relayHostId && bundle) { + await dependencies.clearJournal(journal.metadata.journalId) + return 'recovered' + } + + const credentials = recoveryCredentials(journal, bundle, dependencies.now()) + for (const credential of credentials) { + let client: PairingCandidateClient | null = null + try { + client = + credential.kind === 'invite' + ? createInviteClient(journal, dependencies, (next) => { + journal = next + }) + : dependencies.connectRelay({ + relay: pairingRelay(journal), + deviceToken: journal.secrets.deviceToken, + desktopPublicKeyB64: journal.metadata.host.publicKeyB64, + credential: credential.token, + expectedCredentialKind: 'resume' + }) + const endpoints = await getRecoveryStatus(client, journal, credential.kind) + if (endpoints.installStatus?.state === 'committed') { + await publishCommitted(journal, endpoints, dependencies) + return 'recovered' + } + if (credential.kind === 'invite' && endpoints.installStatus?.state === 'not-found') { + journal = await transitionToInviteAuthorization(journal, dependencies) + const installed = DeviceCredentialInstalledSchema.parse( + requireSuccess( + await client.sendRequest('pairing.provisionRelay', { + reqId: journal.metadata.installReqId, + newResumeTokenHash: journal.metadata.pendingResumeTokenHash + }) + ) + ) + const reconciled = await getRecoveryStatus(client, journal, 'invite') + assertCommitted(reconciled, installed) + await publishCommitted(journal, reconciled, dependencies) + return 'recovered' + } + } catch { + // Why: ambiguous pairing state advances only by credential priority and + // authoritative status; a transport failure never rewrites the journal. + } finally { + client?.close() + } + } + return 'deferred' +} + +function recoveryCredentials( + journal: MobileRelayPairingJournal, + bundle: MobileRelayCredentialBundle | null, + now: number +): { kind: 'resume' | 'invite'; token: string }[] { + const credentials: { kind: 'resume' | 'invite'; token: string }[] = [ + { kind: 'resume', token: journal.secrets.pendingResumeToken } + ] + if (bundle?.current.token && bundle.current.token !== journal.secrets.pendingResumeToken) { + credentials.push({ kind: 'resume', token: bundle.current.token }) + } + if (journal.metadata.relay.inviteExpiresAt > now) { + credentials.push({ kind: 'invite', token: journal.secrets.inviteToken }) + } + return credentials +} + +function createInviteClient( + journal: MobileRelayPairingJournal, + dependencies: RecoveryDependencies, + replaceJournal: (journal: MobileRelayPairingJournal) => void +): PairingCandidateClient { + return createRecoveringPairingRelayCandidate({ + journal, + connect: (relay) => + dependencies.connectRelay({ + relay, + deviceToken: journal.secrets.deviceToken, + desktopPublicKeyB64: journal.metadata.host.publicKeyB64 + }), + resolveDirector: (relay) => dependencies.resolveInviteDirector({ relay }), + persistMove: async (relay) => { + const next = { + ...journal, + metadata: { + ...journal.metadata, + relay: { + ...journal.metadata.relay, + cellUrl: relay.cellUrl, + assignmentEpoch: relay.assignmentEpoch + } + } + } + await dependencies.updateJournal(journal.metadata.journalId, () => next.metadata) + replaceJournal(next) + }, + now: dependencies.now + }) +} + +async function getRecoveryStatus( + client: PairingCandidateClient, + journal: MobileRelayPairingJournal, + kind: 'resume' | 'invite' +) { + return PairingGetEndpointsResultSchema.parse( + requireSuccess( + await client.sendRequest('pairing.getEndpoints', { + installReqId: journal.metadata.installReqId, + ...(kind === 'resume' ? { resumeConfirmReqId: journal.metadata.resumeConfirmReqId } : {}) + }) + ) + ) +} + +async function transitionToInviteAuthorization( + journal: MobileRelayPairingJournal, + dependencies: RecoveryDependencies +): Promise { + const next: MobileRelayPairingJournal = { + ...journal, + metadata: { + ...journal.metadata, + winner: 'relay', + authorizationMode: 'relay-basis' + } + } + // Why: the branch change becomes durable only after authoritative not-found. + await dependencies.updateJournal(journal.metadata.journalId, () => next.metadata) + return next +} + +async function publishCommitted( + journal: MobileRelayPairingJournal, + endpoints: ReturnType, + dependencies: RecoveryDependencies +): Promise { + if (endpoints.installStatus?.state !== 'committed' || !endpoints.relay) { + throw new Error('relay pairing recovery was not committed') + } + const installed = endpoints.installStatus.result + const reconciledJournal: MobileRelayPairingJournal = { + ...journal, + metadata: { + ...journal.metadata, + winner: installed.authorizationMode === 'authenticated-direct' ? 'direct' : 'relay', + authorizationMode: installed.authorizationMode + } + } + if (journal.metadata.authorizationMode !== installed.authorizationMode) { + await dependencies.updateJournal(journal.metadata.journalId, () => reconciledJournal.metadata) + } + await dependencies.writeCredentialBundle( + promotePairingJournalCredential({ journal: reconciledJournal, installed }) + ) + await dependencies.saveHost(relayHost(reconciledJournal, endpoints.relay)) + await dependencies.clearJournal(journal.metadata.journalId) +} + +function relayHost(journal: MobileRelayPairingJournal, relay: MobileRelayEndpoint): HostProfile { + const host = journal.metadata.host + const url = new URL(relay.cellUrl) + url.protocol = 'wss:' + url.pathname = `/v1/connect/${encodeURIComponent(relay.relayHostId)}` + return { + ...host, + deviceToken: journal.secrets.deviceToken, + endpoints: [ + { id: 'direct-primary', kind: 'lan', url: host.endpoint }, + { id: 'relay-primary', kind: 'relay', url: url.toString() } + ], + relayHostId: relay.relayHostId, + relay + } +} + +function pairingRelay(journal: MobileRelayPairingJournal): PairingRelay { + return { ...journal.metadata.relay, inviteToken: journal.secrets.inviteToken } +} + +function requireSuccess(response: RpcResponse): unknown { + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + return response.result +} + +function assertCommitted( + endpoints: ReturnType, + installed: DeviceCredentialInstalled +): void { + if ( + endpoints.installStatus?.state !== 'committed' || + JSON.stringify(endpoints.installStatus.result) !== JSON.stringify(installed) + ) { + throw new Error('relay pairing recovery install was not authoritatively committed') + } +} + +/** Test-only: clear the startup single-flight between cases. */ +export function resetMobileRelayPairingRecoveryForTests(): void { + recoveryPromise = null +} diff --git a/mobile/src/transport/mobile-relay-physical-client.test.ts b/mobile/src/transport/mobile-relay-physical-client.test.ts new file mode 100644 index 00000000000..e4aaa0fe4f8 --- /dev/null +++ b/mobile/src/transport/mobile-relay-physical-client.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const fakes = vi.hoisted(() => ({ + channelOptions: null as null | { + onAuthenticated(): void + onText(value: string): void + }, + start: vi.fn(), + handleMessage: vi.fn(), + sendText: vi.fn(() => true), + dispose: vi.fn() +})) + +vi.mock('./mobile-e2ee-v2-client-session', () => ({ + MobileE2EEV2ClientSession: { create: vi.fn(() => ({ hello: {} })) } +})) +vi.mock('./mobile-e2ee-v2-physical-channel', () => ({ + MobileE2EEV2PhysicalChannel: class { + constructor(options: NonNullable) { + fakes.channelOptions = options + } + start = fakes.start + handleMessage = fakes.handleMessage + sendText = fakes.sendText + dispose = fakes.dispose + } +})) + +import { connectMobileRelayForPairing, RelayOuterError } from './mobile-relay-physical-client' + +class FakeSocket { + readonly OPEN = 1 + readyState = 1 + bufferedAmount = 0 + sent: unknown[] = [] + onopen: (() => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + onerror: (() => void) | null = null + onclose: ((event: { code: number }) => void) | null = null + + send(value: unknown): void { + this.sent.push(value) + } + + close(): void {} + + receive(data: unknown): void { + this.onmessage?.({ data }) + } +} + +const relay = { + v: 1 as const, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: 'abcdefghijklmnopqrstuvwxyzABCDEFGH012345678', + inviteExpiresAt: Date.now() + 300_000, + e2eeFraming: 2 as const +} + +describe('mobile relay physical pairing client', () => { + beforeEach(() => { + vi.clearAllMocks() + fakes.channelOptions = null + fakes.sendText.mockReturnValue(true) + }) + + it('uses first-frame outer auth, waits for host attach, then carries RPC over E2EE v2', async () => { + const socket = new FakeSocket() + let openedUrl = '' + const client = connectMobileRelayForPairing({ + relay, + deviceToken: 'device-token', + desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + createSocket: (url) => { + openedUrl = url + return socket as unknown as WebSocket + } + }) + socket.onopen?.() + expect(openedUrl).toBe('wss://relay-c1.onorca.dev/v1/connect/AbCdEf0123_-xyZ9') + expect(openedUrl).not.toContain('?') + expect(JSON.parse(socket.sent[0] as string)).toEqual({ + type: 'relay-auth', + v: 1, + mode: 'connect', + credential: relay.inviteToken + }) + + socket.receive( + JSON.stringify({ + type: 'relay-hello', + ok: true, + credentialKind: 'invite', + leaseExpiresAt: Date.now() + 60_000 + }) + ) + await vi.waitFor(() => expect(fakes.start).toHaveBeenCalledOnce()) + fakes.channelOptions!.onAuthenticated() + const responsePromise = client.sendRequest('status.get') + await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce()) + const request = JSON.parse(fakes.sendText.mock.calls[0]![0] as string) + expect(request).toEqual({ + id: 'relay-pair-1', + deviceToken: 'device-token', + method: 'status.get' + }) + fakes.channelOptions!.onText( + JSON.stringify({ + id: request.id, + ok: true, + result: { path: 'relay' }, + _meta: { runtimeId: 'runtime-1' } + }) + ) + await expect(responsePromise).resolves.toMatchObject({ ok: true, result: { path: 'relay' } }) + }) + + it('surfaces a typed endpoint-scoped outer rejection before E2EE', async () => { + const socket = new FakeSocket() + const client = connectMobileRelayForPairing({ + relay, + deviceToken: 'device-token', + desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + createSocket: () => socket as unknown as WebSocket + }) + const status = client.sendRequest('status.get') + socket.receive(JSON.stringify({ type: 'relay-hello', ok: false, code: 4404 })) + + await expect(status).rejects.toEqual(new RelayOuterError(4404)) + expect(fakes.start).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/transport/mobile-relay-physical-client.ts b/mobile/src/transport/mobile-relay-physical-client.ts new file mode 100644 index 00000000000..acf264b8c73 --- /dev/null +++ b/mobile/src/transport/mobile-relay-physical-client.ts @@ -0,0 +1,182 @@ +import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer' +import { RelayPhoneHelloSchema } from '../../../src/shared/mobile-relay-phone-protocol' +import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session' +import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel' +import { isRpcResponse } from './rpc-response-shape' +import type { RpcResponse } from './types' +import { websocketPayloadToUint8 } from './websocket-payload-bytes' +export { RelayOuterError } from './mobile-relay-e2ee-link' +import { RelayOuterError } from './mobile-relay-e2ee-link' + +type PendingRequest = { + resolve: (response: RpcResponse) => void + reject: (error: Error) => void + timer: ReturnType +} + +export type PairingCandidateClient = { + sendRequest(method: string, params?: unknown): Promise + close(): void +} + +export function connectMobileRelayForPairing(args: { + relay: PairingRelay + deviceToken: string + desktopPublicKeyB64: string + credential?: string + expectedCredentialKind?: 'invite' | 'resume' + requestTimeoutMs?: number + createSocket?: (url: string) => WebSocket +}): PairingCandidateClient { + const requestTimeoutMs = args.requestTimeoutMs ?? 30_000 + const socketUrl = relayPhoneWebSocketUrl(args.relay) + const socket = (args.createSocket ?? ((url) => new WebSocket(url)))(socketUrl) + const session = MobileE2EEV2ClientSession.create({ + desktopPublicKeyB64: args.desktopPublicKeyB64, + transport: 'relay', + relayHostId: args.relay.relayHostId + }) + const pending = new Map() + let requestCounter = 0 + let closed = false + let outerReady = false + let authenticated = false + let resolveAuthenticated!: () => void + let rejectAuthenticated!: (error: Error) => void + const authenticatedPromise = new Promise((resolve, reject) => { + resolveAuthenticated = resolve + rejectAuthenticated = reject + }) + const channel = new MobileE2EEV2PhysicalChannel({ + session, + socket, + deviceToken: args.deviceToken, + decodeBinary: websocketPayloadToUint8, + onAuthenticated: () => { + authenticated = true + resolveAuthenticated() + }, + onText: (plaintext) => { + let value: unknown + try { + value = JSON.parse(plaintext) + } catch { + return + } + if (!isRpcResponse(value)) { + return + } + const request = pending.get(value.id) + if (request) { + clearTimeout(request.timer) + pending.delete(value.id) + request.resolve(value) + } + }, + onBinary: () => {}, + onError: fail + }) + + socket.onopen = () => { + socket.send( + JSON.stringify({ + type: 'relay-auth', + v: 1, + mode: 'connect', + credential: args.credential ?? args.relay.inviteToken + }) + ) + } + let inboundChain: Promise = Promise.resolve() + socket.onmessage = (event) => { + inboundChain = inboundChain + .then(async () => { + if (closed) { + return + } + if (!outerReady) { + acceptRelayHello(event.data) + return + } + await channel.handleMessage(event.data) + }) + .catch((error: unknown) => fail(asError(error))) + } + socket.onerror = () => fail(new Error('relay transport error')) + socket.onclose = (event) => fail(new RelayOuterError(event.code || 1006)) + + function acceptRelayHello(raw: unknown): void { + if (typeof raw !== 'string') { + throw new Error('expected plaintext relay hello') + } + let value: unknown + try { + value = JSON.parse(raw) + } catch { + throw new Error('invalid relay hello JSON') + } + const parsed = RelayPhoneHelloSchema.safeParse(value) + if (!parsed.success) { + throw new Error('invalid relay hello') + } + if (!parsed.data.ok) { + throw new RelayOuterError(parsed.data.code) + } + if (parsed.data.credentialKind !== (args.expectedCredentialKind ?? 'invite')) { + throw new Error('relay credential resolved as an unexpected credential kind') + } + outerReady = true + channel.start() + } + + function fail(error: Error): void { + if (closed) { + return + } + closed = true + channel.dispose() + rejectAuthenticated(error) + for (const request of pending.values()) { + clearTimeout(request.timer) + request.reject(error) + } + pending.clear() + socket.close() + } + + return { + async sendRequest(method, params) { + await authenticatedPromise + if (closed || !authenticated) { + throw new Error('relay pairing client closed') + } + const id = `relay-pair-${++requestCounter}` + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id) + reject(new Error(`relay pairing RPC timed out: ${method}`)) + }, requestTimeoutMs) + pending.set(id, { resolve, reject, timer }) + if ( + !channel.sendText(JSON.stringify({ id, deviceToken: args.deviceToken, method, params })) + ) { + clearTimeout(timer) + pending.delete(id) + reject(new Error('relay E2EE channel not ready')) + } + }) + }, + close: () => fail(new Error('relay pairing client closed')) + } +} + +export function relayPhoneWebSocketUrl(relay: PairingRelay): string { + const url = new URL(relay.cellUrl) + url.protocol = 'wss:' + url.pathname = `/v1/connect/${encodeURIComponent(relay.relayHostId)}` + return url.toString() +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} diff --git a/mobile/src/transport/mobile-relay-resume-director.test.ts b/mobile/src/transport/mobile-relay-resume-director.test.ts new file mode 100644 index 00000000000..426ff89ba07 --- /dev/null +++ b/mobile/src/transport/mobile-relay-resume-director.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest' +import { resolveMobileRelayEndpoint } from './mobile-relay-resume-director' + +const relay = { + v: 1 as const, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-old.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + e2eeFraming: 2 as const +} + +describe('mobile relay resume director', () => { + it('uses a bounded POST body and never puts the bearer in the URL', async () => { + const fetchImpl = vi.fn( + async () => + new Response( + JSON.stringify({ + v: 1, + cellUrl: 'https://relay-c2.onorca.dev', + assignmentEpoch: 8, + leaseExpiresAt: Date.now() + 60_000 + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + ) + + await expect( + resolveMobileRelayEndpoint({ relay, resumeToken: 'A'.repeat(43), fetchImpl }) + ).resolves.toMatchObject({ cellUrl: 'https://relay-c2.onorca.dev', assignmentEpoch: 8 }) + const [url, init] = fetchImpl.mock.calls[0]! + expect(url).toBe('https://relay.onorca.dev/v1/resolve') + expect(url).not.toContain('A'.repeat(43)) + expect(init).toMatchObject({ method: 'POST' }) + expect(JSON.parse(init!.body as string)).toEqual({ + v: 1, + relayHostId: relay.relayHostId, + resumeToken: 'A'.repeat(43) + }) + }) + + it('rejects non-canonical targets and oversized bodies', async () => { + const badTarget = vi.fn( + async () => + new Response( + JSON.stringify({ + v: 1, + cellUrl: 'http://relay-c2.onorca.dev', + assignmentEpoch: 8, + leaseExpiresAt: 1 + }) + ) + ) + await expect( + resolveMobileRelayEndpoint({ relay, resumeToken: 'A'.repeat(43), fetchImpl: badTarget }) + ).rejects.toThrow() + + const oversized = vi.fn( + async () => + new Response('x'.repeat(16 * 1024 + 1), { headers: { 'content-length': '16385' } }) + ) + await expect( + resolveMobileRelayEndpoint({ relay, resumeToken: 'A'.repeat(43), fetchImpl: oversized }) + ).rejects.toThrow(/too large/) + }) +}) diff --git a/mobile/src/transport/mobile-relay-resume-director.ts b/mobile/src/transport/mobile-relay-resume-director.ts new file mode 100644 index 00000000000..86fd283b9a5 --- /dev/null +++ b/mobile/src/transport/mobile-relay-resume-director.ts @@ -0,0 +1,63 @@ +import { z } from 'zod' +import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract' + +const MAX_RESPONSE_BYTES = 16 * 1024 +const ResolveResponseSchema = z + .object({ + v: z.literal(1), + cellUrl: z.string().refine(isCanonicalHttpsOrigin), + assignmentEpoch: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + leaseExpiresAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) + }) + .strict() + +export async function resolveMobileRelayEndpoint(args: { + relay: MobileRelayEndpoint + resumeToken: string + fetchImpl?: typeof fetch + timeoutMs?: number +}): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), args.timeoutMs ?? 5000) + try { + const url = new URL('/v1/resolve', args.relay.directorUrl) + const response = await (args.fetchImpl ?? fetch)(url.toString(), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + v: 1, + relayHostId: args.relay.relayHostId, + resumeToken: args.resumeToken + }), + signal: controller.signal + }) + if (!response.ok) { + throw new Error(`relay director resolve failed (${response.status})`) + } + const declaredLength = Number(response.headers.get('content-length') ?? 0) + if (declaredLength > MAX_RESPONSE_BYTES) { + throw new Error('relay director resolve response too large') + } + const raw = await response.text() + if (new TextEncoder().encode(raw).byteLength > MAX_RESPONSE_BYTES) { + throw new Error('relay director resolve response too large') + } + const resolved = ResolveResponseSchema.parse(JSON.parse(raw) as unknown) + return { + ...args.relay, + cellUrl: resolved.cellUrl, + assignmentEpoch: resolved.assignmentEpoch + } + } finally { + clearTimeout(timer) + } +} + +function isCanonicalHttpsOrigin(value: string): boolean { + try { + const parsed = new URL(value) + return parsed.protocol === 'https:' && parsed.origin === value + } catch { + return false + } +} diff --git a/mobile/src/transport/mobile-relay-rpc-session.test.ts b/mobile/src/transport/mobile-relay-rpc-session.test.ts new file mode 100644 index 00000000000..9fa88dca9b8 --- /dev/null +++ b/mobile/src/transport/mobile-relay-rpc-session.test.ts @@ -0,0 +1,207 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + BrowserScreencastOpcode, + encodeBrowserScreencastFrame +} from '../../../src/shared/browser-screencast-protocol' +import { encodeTerminalStreamFrame, TerminalStreamOpcode } from './terminal-stream-protocol' + +const fakes = vi.hoisted(() => ({ + linkOptions: null as null | { + endpoint: { cellUrl: string; relayHostId: string } + credential: string + expectedCredentialKind: string + onHello(value: unknown): void + onAuthenticated(): void + onText(value: string): void + onBinary(value: Uint8Array): void + onError(error: Error): void + }, + sendText: vi.fn(() => true), + close: vi.fn() +})) + +vi.mock('./mobile-relay-e2ee-link', () => ({ + MobileRelayE2eeLink: class { + constructor(options: NonNullable) { + fakes.linkOptions = options + } + sendText = fakes.sendText + close = fakes.close + } +})) + +import { connectMobileRelayRpcSession } from './mobile-relay-rpc-session' + +const relay = { + v: 1 as const, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + e2eeFraming: 2 as const +} + +function openSession() { + return connectMobileRelayRpcSession({ + relay, + resumeToken: 'resume-secret', + resumeCredentialVersion: 3, + resumeConfirmReqId: 'confirm-1', + deviceToken: 'device-token', + desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + requestTimeoutMs: 1000 + }) +} + +async function authenticateSession() { + const session = openSession() + fakes.linkOptions!.onHello({ + type: 'relay-hello', + ok: true, + credentialKind: 'resume', + leaseExpiresAt: Date.now() + 60_000, + acceptedCredentialVersion: 3, + acceptedAs: 'current', + resumeExpiresAt: Date.now() + 300_000 + }) + expect(session.getState()).toBe('handshaking') + fakes.linkOptions!.onAuthenticated() + await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce()) + const request = JSON.parse(fakes.sendText.mock.calls[0]![0] as string) as { + id: string + method: string + params: unknown + } + fakes.linkOptions!.onText( + JSON.stringify({ + id: request.id, + ok: true, + result: { + v: 1, + relay, + resumeConfirmation: { + v: 1, + reqId: 'confirm-1', + currentVersion: 3, + acceptedAs: 'current', + renewed: true, + resumeExpiresAt: Date.now() + 300_000 + } + }, + _meta: { runtimeId: 'runtime-1' } + }) + ) + await vi.waitFor(() => expect(session.getState()).toBe('connected')) + fakes.sendText.mockClear() + return { session, confirmationRequest: request } +} + +describe('mobile relay RPC session', () => { + beforeEach(() => { + vi.clearAllMocks() + fakes.linkOptions = null + fakes.sendText.mockReturnValue(true) + }) + + it('requires exact resume observations and confirms by request ID before becoming connected', async () => { + const { session, confirmationRequest } = await authenticateSession() + + expect(fakes.linkOptions).toMatchObject({ + endpoint: relay, + credential: 'resume-secret', + expectedCredentialKind: 'resume' + }) + expect(confirmationRequest).toMatchObject({ + method: 'pairing.getEndpoints', + params: { resumeConfirmReqId: 'confirm-1' }, + deviceToken: 'device-token' + }) + expect(confirmationRequest.params).not.toHaveProperty('relayDeviceId') + expect(confirmationRequest.params).not.toHaveProperty('acceptedCredentialVersion') + expect(session.getLeaseExpiresAt()).toEqual(expect.any(Number)) + }) + + it('rejects a mismatched outer credential version and closes the physical link', () => { + const session = openSession() + fakes.linkOptions!.onHello({ + type: 'relay-hello', + ok: true, + credentialKind: 'resume', + leaseExpiresAt: Date.now() + 60_000, + acceptedCredentialVersion: 2, + acceptedAs: 'grace', + resumeExpiresAt: Date.now() + 300_000 + }) + + expect(session.getState()).toBe('disconnected') + expect(fakes.close).toHaveBeenCalledOnce() + expect(fakes.sendText).not.toHaveBeenCalled() + }) + + it('routes terminal and browser binary streams after confirmation', async () => { + const { session } = await authenticateSession() + const terminalListener = vi.fn() + session.subscribe('terminal.subscribe', { terminal: 'term-1' }, terminalListener) + await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce()) + const terminalRequest = JSON.parse(fakes.sendText.mock.calls[0]![0] as string) as { + id: string + } + fakes.linkOptions!.onText( + JSON.stringify({ + id: terminalRequest.id, + ok: true, + result: { streamId: 42 }, + _meta: { runtimeId: 'runtime-1' } + }) + ) + fakes.linkOptions!.onBinary( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId: 42, + seq: 1, + payload: new TextEncoder().encode('hello') + }) + ) + expect(terminalListener).toHaveBeenLastCalledWith({ + type: 'data', + streamId: 42, + chunk: 'hello' + }) + + fakes.sendText.mockClear() + const onBinaryFrame = vi.fn() + session.subscribe('browser.screencast', {}, vi.fn(), { onBinaryFrame }) + await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce()) + const browserRequest = JSON.parse(fakes.sendText.mock.calls[0]![0] as string) as { id: string } + fakes.linkOptions!.onText( + JSON.stringify({ + id: browserRequest.id, + ok: true, + result: { subscriptionId: 'browser-1' }, + _meta: { runtimeId: 'runtime-1' } + }) + ) + fakes.linkOptions!.onBinary( + encodeBrowserScreencastFrame({ + opcode: BrowserScreencastOpcode.Frame, + seq: 9, + format: 'jpeg', + metadata: { imageWidth: 800 }, + image: new Uint8Array([1, 2, 3]) + }) + ) + expect(onBinaryFrame).toHaveBeenCalledWith( + expect.objectContaining({ seq: 9, format: 'jpeg', image: new Uint8Array([1, 2, 3]) }) + ) + }) + + it('rejects pending RPC work when the physical link fails', async () => { + const { session } = await authenticateSession() + const pending = session.sendRequest('status.get') + await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce()) + fakes.linkOptions!.onError(new Error('relay transport error')) + + await expect(pending).rejects.toThrow('relay transport error') + expect(session.getState()).toBe('disconnected') + }) +}) diff --git a/mobile/src/transport/mobile-relay-rpc-session.ts b/mobile/src/transport/mobile-relay-rpc-session.ts new file mode 100644 index 00000000000..5fe435ae0e5 --- /dev/null +++ b/mobile/src/transport/mobile-relay-rpc-session.ts @@ -0,0 +1,254 @@ +import { + PairingGetEndpointsResultSchema, + type DeviceResumeConfirmed, + type MobileRelayEndpoint +} from '../../../src/shared/mobile-relay-credential-contract' +import { MobileRelayE2eeLink } from './mobile-relay-e2ee-link' +import { MobileRelayRpcStreams } from './mobile-relay-rpc-streams' +import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel' +import { isRpcResponse } from './rpc-response-shape' +import type { RpcClient } from './rpc-client' +import type { ConnectionState, RpcResponse } from './types' + +type PendingRequest = { + resolve: (response: RpcResponse) => void + reject: (error: Error) => void + timer: ReturnType +} + +export type MobileRelayRpcSession = RpcClient & { + getLeaseExpiresAt(): number | null + getResumeConfirmation(): DeviceResumeConfirmed | null + getFailure(): Error | null +} + +export function connectMobileRelayRpcSession(args: { + relay: MobileRelayEndpoint + resumeToken: string + resumeCredentialVersion: number + resumeConfirmReqId: string + deviceToken: string + desktopPublicKeyB64: string + requestTimeoutMs?: number + createSocket?: (url: string) => WebSocket +}): MobileRelayRpcSession { + const requestTimeoutMs = args.requestTimeoutMs ?? 30_000 + const pending = new Map() + const stateListeners = new Set<(state: ConnectionState) => void>() + let state: ConnectionState = 'connecting' + let requestCounter = 0 + let lastConnectedAt: number | null = null + let leaseExpiresAt: number | null = null + let resumeConfirmation: DeviceResumeConfirmed | null = null + let failure: Error | null = null + let closed = false + const streams = new MobileRelayRpcStreams({ + nextId, + sendFrame, + waitForConnected: () => waitForConnected() + }) + + const link = new MobileRelayE2eeLink({ + endpoint: args.relay, + credential: args.resumeToken, + expectedCredentialKind: 'resume', + deviceToken: args.deviceToken, + desktopPublicKeyB64: args.desktopPublicKeyB64, + createSocket: args.createSocket, + onHello: (hello) => { + if ( + hello.credentialKind !== 'resume' || + hello.acceptedCredentialVersion !== args.resumeCredentialVersion + ) { + fail(new Error('relay resume credential version mismatch')) + return + } + leaseExpiresAt = hello.leaseExpiresAt + publishState('handshaking') + }, + onAuthenticated: () => void confirmResume(), + onText: handleText, + onBinary: handleBinary, + onError: fail + }) + + const client: MobileRelayRpcSession = { + async sendRequest(method, params, options) { + await waitForConnected(options?.timeoutMs) + return sendRpc(method, params, options?.timeoutMs) + }, + + subscribe(method, params, listener, options) { + if (closed) { + return () => {} + } + return streams.subscribe(method, params, listener, options) + }, + + updateTerminalSubscriptionViewport(terminal, viewport) { + streams.updateTerminalViewport(terminal, viewport) + }, + getState: () => state, + getReconnectAttempt: () => 0, + getLastConnectedAt: () => lastConnectedAt, + onStateChange(listener) { + stateListeners.add(listener) + return () => stateListeners.delete(listener) + }, + notifyForeground: () => {}, + close() { + if (closed) { + return + } + closed = true + link.close() + rejectPending(new Error('Client closed')) + streams.clear() + publishState('disconnected') + }, + getLeaseExpiresAt: () => leaseExpiresAt, + getResumeConfirmation: () => resumeConfirmation, + getFailure: () => failure + } + return client + + async function confirmResume(): Promise { + try { + const response = await sendRpc( + 'pairing.getEndpoints', + { resumeConfirmReqId: args.resumeConfirmReqId }, + requestTimeoutMs, + true + ) + if (!response.ok) { + throw new Error(response.error.code) + } + const result = PairingGetEndpointsResultSchema.parse(response.result) + if (!result.resumeConfirmation || result.relay?.relayHostId !== args.relay.relayHostId) { + throw new Error('relay resume confirmation missing') + } + resumeConfirmation = result.resumeConfirmation + lastConnectedAt = Date.now() + publishState('connected') + } catch (error) { + fail(asError(error)) + } + } + + function sendRpc( + method: string, + params: unknown, + timeoutMs = requestTimeoutMs, + beforeConnected = false + ): Promise { + if (closed || (!beforeConnected && state !== 'connected')) { + return Promise.reject(new Error('relay session not connected')) + } + const id = nextId() + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id) + reject(new Error(`relay RPC timed out: ${method}`)) + }, timeoutMs) + pending.set(id, { resolve, reject, timer }) + if (!sendFrame({ id, method, params })) { + clearTimeout(timer) + pending.delete(id) + reject(new Error('relay E2EE channel not ready')) + } + }) + } + + function sendFrame(request: { id: string; method: string; params?: unknown }): boolean { + return link.sendText(JSON.stringify({ ...request, deviceToken: args.deviceToken })) + } + + function handleText(plaintext: string): void { + let value: unknown + try { + value = JSON.parse(plaintext) + } catch { + return + } + if (!isRpcResponse(value)) { + return + } + const request = pending.get(value.id) + if (request) { + clearTimeout(request.timer) + pending.delete(value.id) + request.resolve(value) + return + } + streams.handleResponse(value) + } + + function handleBinary(bytes: Uint8Array): void { + streams.handleBinary(bytes) + } + + function waitForConnected(timeoutMs = requestTimeoutMs): Promise { + if (state === 'connected') { + return Promise.resolve() + } + return new Promise((resolve, reject) => { + let timer: ReturnType | null = null + const unsubscribe = client.onStateChange((next) => { + if (next === 'connected') { + finish() + resolve() + } else if (next === 'disconnected' || next === 'auth-failed') { + finish() + reject(new Error(`relay session ${next}`)) + } + }) + timer = setTimeout(() => { + finish() + reject(new Error('relay session connection timed out')) + }, timeoutMs) + function finish(): void { + if (timer) { + clearTimeout(timer) + } + unsubscribe() + } + }) + } + + function publishState(next: ConnectionState): void { + if (state === next) { + return + } + state = next + for (const listener of stateListeners) { + listener(next) + } + } + + function fail(error: Error): void { + if (closed) { + return + } + closed = true + failure = error + link.close() + rejectPending(error) + publishState(error instanceof MobileE2EEAuthenticationError ? 'auth-failed' : 'disconnected') + } + + function rejectPending(error: Error): void { + for (const request of pending.values()) { + clearTimeout(request.timer) + request.reject(error) + } + pending.clear() + } + + function nextId(): string { + return `relay-rpc-${++requestCounter}-${Date.now()}` + } +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} diff --git a/mobile/src/transport/mobile-relay-rpc-streams.ts b/mobile/src/transport/mobile-relay-rpc-streams.ts new file mode 100644 index 00000000000..552880e163a --- /dev/null +++ b/mobile/src/transport/mobile-relay-rpc-streams.ts @@ -0,0 +1,165 @@ +import { decodeBrowserScreencastFrame } from './browser-screencast-protocol' +import { + handleTerminalBinaryFrame, + type TerminalSnapshotState +} from './rpc-client-terminal-binary-frame' +import { + buildTerminalUnsubscribeParams, + updateTerminalSubscriptionViewport +} from './rpc-client-terminal-subscription' +import type { RpcClient } from './rpc-client' +import type { RpcResponse, RpcSuccess } from './types' + +type StreamRecord = { + method: string + params: unknown + listener: (result: unknown) => void + onBinaryFrame?: Parameters[3] extends + | { onBinaryFrame?: infer Listener } + | undefined + ? Listener + : never + streamIds: Set + subscriptionId?: string + cancelled: boolean +} + +type StreamManagerOptions = { + nextId: () => string + sendFrame: (request: { id: string; method: string; params?: unknown }) => boolean + waitForConnected: () => Promise +} + +export class MobileRelayRpcStreams { + private readonly streams = new Map() + private readonly terminalListeners = new Map void>() + private readonly terminalSnapshots = new Map() + private activeBrowserStream: StreamRecord | null = null + + constructor(private readonly options: StreamManagerOptions) {} + + subscribe( + method: string, + params: unknown, + listener: (result: unknown) => void, + subscribeOptions?: Parameters[3] + ): () => void { + const id = this.options.nextId() + const stream: StreamRecord = { + method, + params, + listener, + onBinaryFrame: subscribeOptions?.onBinaryFrame, + streamIds: new Set(), + cancelled: false + } + this.streams.set(id, stream) + void this.options + .waitForConnected() + .then(() => { + if (!stream.cancelled && !this.options.sendFrame({ id, method, params: stream.params })) { + this.remove(id) + } + }) + .catch(() => this.remove(id)) + return () => this.cancel(id) + } + + updateTerminalViewport(terminal: string, viewport: { cols: number; rows: number }): void { + updateTerminalSubscriptionViewport(this.streams.values(), terminal, viewport) + } + + handleResponse(response: RpcResponse): boolean { + const stream = this.streams.get(response.id) + if (!stream) { + return false + } + if (!response.ok) { + this.remove(response.id) + return true + } + const result = (response as RpcSuccess).result + if (result && typeof result === 'object') { + const metadata = result as { subscriptionId?: unknown; streamId?: unknown; type?: unknown } + if (typeof metadata.subscriptionId === 'string') { + stream.subscriptionId = metadata.subscriptionId + } + if (typeof metadata.streamId === 'number') { + stream.streamIds.add(metadata.streamId) + this.terminalListeners.set(metadata.streamId, stream.listener) + } + if (stream.method === 'browser.screencast') { + this.activeBrowserStream = stream + } + if (metadata.type === 'end') { + stream.listener(result) + this.remove(response.id) + return true + } + } + if (!stream.cancelled) { + stream.listener(result) + } + return true + } + + handleBinary(bytes: Uint8Array): void { + const browserFrame = decodeBrowserScreencastFrame(bytes) + if (browserFrame && this.activeBrowserStream?.onBinaryFrame) { + this.activeBrowserStream.onBinaryFrame(browserFrame) + return + } + handleTerminalBinaryFrame(bytes, { + terminalSnapshots: this.terminalSnapshots, + getListener: (streamId) => this.terminalListeners.get(streamId), + recordValidatedInboundTraffic: () => {} + }) + } + + clear(): void { + this.streams.clear() + this.terminalListeners.clear() + this.terminalSnapshots.clear() + this.activeBrowserStream = null + } + + private cancel(id: string): void { + const stream = this.streams.get(id) + if (!stream || stream.cancelled) { + return + } + stream.cancelled = true + if (stream.method === 'terminal.subscribe') { + const params = buildTerminalUnsubscribeParams(stream.params) + if (params) { + this.options.sendFrame({ + id: this.options.nextId(), + method: 'terminal.unsubscribe', + params + }) + } + } else if (stream.subscriptionId) { + this.options.sendFrame({ + id: this.options.nextId(), + method: stream.method.replace(/\.subscribe$/, '.unsubscribe'), + params: { subscriptionId: stream.subscriptionId } + }) + } + this.remove(id) + } + + private remove(id: string): void { + const stream = this.streams.get(id) + if (!stream) { + return + } + for (const streamId of stream.streamIds) { + this.terminalListeners.delete(streamId) + this.terminalSnapshots.delete(streamId) + } + if (this.activeBrowserStream === stream) { + this.activeBrowserStream = null + } + this.streams.delete(id) + } +} diff --git a/mobile/src/transport/pairing-candidate-race.ts b/mobile/src/transport/pairing-candidate-race.ts new file mode 100644 index 00000000000..3b754b6ea49 --- /dev/null +++ b/mobile/src/transport/pairing-candidate-race.ts @@ -0,0 +1,59 @@ +import type { PairingCandidateClient } from './mobile-relay-physical-client' + +export type PairingCandidate = { + path: 'direct' | 'relay' + client: PairingCandidateClient +} + +export function racePairingCandidates( + candidates: readonly PairingCandidate[] +): Promise { + return new Promise((resolve, reject) => { + const successes: PairingCandidate[] = [] + let failures = 0 + let settled = false + let selectionQueued = false + for (const candidate of candidates) { + void candidate.client.sendRequest('status.get').then( + (response) => { + if (!response.ok) { + failures++ + rejectIfFinished() + return + } + successes.push(candidate) + if (selectionQueued) { + return + } + selectionQueued = true + // Why: defer one microtask so simultaneous successes are visible and + // direct deterministically wins the exact tie regardless of callback order. + queueMicrotask(() => { + if (settled) { + return + } + settled = true + const winner = successes.find(({ path }) => path === 'direct') ?? successes[0]! + for (const loser of candidates) { + if (loser !== winner) { + loser.client.close() + } + } + resolve(winner) + }) + }, + () => { + failures++ + rejectIfFinished() + } + ) + } + + function rejectIfFinished(): void { + if (!settled && failures === candidates.length && successes.length === 0) { + settled = true + reject(new Error('direct and relay pairing paths both failed')) + } + } + }) +} diff --git a/mobile/src/transport/pairing-relay-candidate.test.ts b/mobile/src/transport/pairing-relay-candidate.test.ts new file mode 100644 index 00000000000..742af991c7e --- /dev/null +++ b/mobile/src/transport/pairing-relay-candidate.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it, vi } from 'vitest' +import type { PairingCandidateClient } from './mobile-relay-physical-client' +import { RelayOuterError } from './mobile-relay-physical-client' +import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate' +import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' + +vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) +vi.mock('expo-crypto', () => ({ + getRandomBytes: (length: number) => new Uint8Array(length).fill(length) +})) + +const journal = { + metadata: { + v: 1, + journalId: 'pair-1', + offerFingerprint: 'A'.repeat(43), + host: { + id: 'host-1', + name: 'Blue Whale', + endpoint: 'ws://192.168.1.10:6768', + publicKeyB64: 'A'.repeat(44), + lastConnected: 1 + }, + relay: { + v: 1, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteExpiresAt: 10_000, + e2eeFraming: 2 + }, + installReqId: 'install-1', + resumeConfirmReqId: 'confirm-1', + pendingResumeTokenHash: 'B'.repeat(43) + }, + secrets: { + v: 1, + journalId: 'pair-1', + deviceToken: 'device-token', + inviteToken: 'C'.repeat(43), + pendingResumeToken: 'D'.repeat(43) + } +} satisfies MobileRelayPairingJournal + +function client( + result: Promise | Promise> +): PairingCandidateClient { + return { sendRequest: vi.fn(() => result), close: vi.fn() } +} + +function success() { + return { + id: 'rpc-1', + ok: true as const, + result: { path: 'relay' }, + _meta: { runtimeId: 'runtime-1' } + } +} + +describe('recovering pairing relay candidate', () => { + it('persists a strictly-newer director move before retrying the target', async () => { + const events: string[] = [] + const stale = client(Promise.reject(new RelayOuterError(4409))) + const target = client(Promise.resolve(success())) + let connects = 0 + const candidate = createRecoveringPairingRelayCandidate({ + journal, + connect: (relay) => { + events.push(`connect:${relay.assignmentEpoch}`) + return connects++ === 0 ? stale : target + }, + resolveDirector: async (relay) => ({ + ...relay, + cellUrl: 'https://relay-c2.onorca.dev', + assignmentEpoch: 8 + }), + persistMove: async (relay) => { + events.push(`persist:${relay.assignmentEpoch}`) + }, + now: () => 1, + random: () => 0, + sleep: async () => {} + }) + + await expect(candidate.sendRequest('status.get')).resolves.toEqual(success()) + expect(events).toEqual(['connect:7', 'persist:8', 'connect:8']) + expect(stale.close).toHaveBeenCalledOnce() + }) + + it('does not ask the director to reinterpret endpoint-scoped host-offline', async () => { + const offline = client(Promise.reject(new RelayOuterError(4404))) + const resolveDirector = vi.fn() + const candidate = createRecoveringPairingRelayCandidate({ + journal, + connect: () => offline, + resolveDirector, + persistMove: vi.fn(), + now: () => 1 + }) + + await expect(candidate.sendRequest('status.get')).rejects.toEqual(new RelayOuterError(4404)) + expect(resolveDirector).not.toHaveBeenCalled() + }) + + it.each([ + ['wrong cell', new RelayOuterError(4409)], + ['planned drain', new RelayOuterError(4503)], + ['opaque close', new RelayOuterError(1006)], + ['HTTP 502', new Error('HTTP 502')], + ['HTTP 503', new Error('HTTP 503')], + ['HTTP 504', new Error('HTTP 504')], + ['transport failure', new Error('relay transport error')] + ])('uses the configured director after %s before E2EE', async (_name, failure) => { + const stale = client(Promise.reject(failure)) + const target = client(Promise.resolve(success())) + const resolveDirector = vi.fn(async (relay) => ({ + ...relay, + cellUrl: 'https://relay-c2.onorca.dev', + assignmentEpoch: 8 + })) + let connects = 0 + const candidate = createRecoveringPairingRelayCandidate({ + journal, + connect: () => (connects++ === 0 ? stale : target), + resolveDirector, + persistMove: vi.fn(async () => {}), + now: () => 1, + random: () => 0, + sleep: async () => {} + }) + + await expect(candidate.sendRequest('status.get')).resolves.toEqual(success()) + expect(resolveDirector).toHaveBeenCalledOnce() + }) + + it('bounds director recovery and applies full jitter to failures and target retries', async () => { + const stale = client(Promise.reject(new Error('HTTP 503'))) + const target = client(Promise.resolve(success())) + const resolveDirector = vi + .fn() + .mockRejectedValueOnce(new Error('HTTP 504')) + .mockRejectedValueOnce(new RelayOuterError(1006)) + .mockImplementationOnce(async (relay) => ({ + ...relay, + cellUrl: 'https://relay-c2.onorca.dev', + assignmentEpoch: 8 + })) + const sleep = vi.fn(async () => {}) + let connects = 0 + const candidate = createRecoveringPairingRelayCandidate({ + journal, + connect: () => (connects++ === 0 ? stale : target), + resolveDirector, + persistMove: vi.fn(async () => {}), + now: () => 1, + random: () => 0.5, + sleep, + maxRecoveryAttempts: 3 + }) + + await expect(candidate.sendRequest('status.get')).resolves.toEqual(success()) + expect(resolveDirector).toHaveBeenCalledTimes(3) + expect(sleep.mock.calls.map(([delay]) => delay)).toEqual([50, 100, 200]) + }) +}) diff --git a/mobile/src/transport/pairing-relay-candidate.ts b/mobile/src/transport/pairing-relay-candidate.ts new file mode 100644 index 00000000000..50a7108b5f8 --- /dev/null +++ b/mobile/src/transport/pairing-relay-candidate.ts @@ -0,0 +1,90 @@ +import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer' +import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' +import { RelayOuterError, type PairingCandidateClient } from './mobile-relay-physical-client' + +export function createRecoveringPairingRelayCandidate(args: { + journal: MobileRelayPairingJournal + connect: (relay: PairingRelay) => PairingCandidateClient + resolveDirector: (relay: PairingRelay) => Promise + persistMove: (relay: PairingRelay) => Promise + now: () => number + random?: () => number + sleep?: (delayMs: number) => Promise + maxRecoveryAttempts?: number +}): PairingCandidateClient { + let relay = pairingRelayFromJournal(args.journal) + let client = args.connect(relay) + let closed = false + + return { + async sendRequest(method, params) { + try { + return await client.sendRequest(method, params) + } catch (error) { + if ( + method !== 'status.get' || + closed || + relay.inviteExpiresAt <= args.now() || + !isDirectorRecoverable(error) + ) { + throw error + } + return recoverThroughDirector(method, params, error) + } + }, + close() { + closed = true + client.close() + } + } + + async function recoverThroughDirector(method: string, params: unknown, initialError: unknown) { + const maxAttempts = args.maxRecoveryAttempts ?? 3 + const random = args.random ?? Math.random + const sleep = + args.sleep ?? ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))) + let lastError = initialError + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + if (closed || relay.inviteExpiresAt <= args.now()) { + throw lastError + } + try { + const moved = await args.resolveDirector(relay) + // Why: the authenticated newer assignment must be durable before a + // target dial so a crash cannot revert to the known-stale cell. + await args.persistMove(moved) + client.close() + relay = moved + const capMs = Math.min(2_000, 100 * 2 ** attempt) + await sleep(Math.floor(random() * (capMs + 1))) + if (closed) { + throw new Error('relay pairing client closed') + } + client = args.connect(relay) + return await client.sendRequest(method, params) + } catch (error) { + lastError = error + if (!isDirectorRecoverable(error) || attempt + 1 >= maxAttempts) { + throw error + } + const capMs = Math.min(2_000, 100 * 2 ** attempt) + await sleep(Math.floor(random() * (capMs + 1))) + } + } + throw lastError + } +} + +function pairingRelayFromJournal(journal: MobileRelayPairingJournal): PairingRelay { + return { + ...journal.metadata.relay, + inviteToken: journal.secrets.inviteToken + } +} + +function isDirectorRecoverable(error: unknown): boolean { + if (!(error instanceof RelayOuterError)) { + return true + } + return error.code === 4409 || error.code === 4503 || error.code === 1006 +} diff --git a/mobile/src/transport/pairing-relay-served-recovery.test.ts b/mobile/src/transport/pairing-relay-served-recovery.test.ts new file mode 100644 index 00000000000..d83faf37a83 --- /dev/null +++ b/mobile/src/transport/pairing-relay-served-recovery.test.ts @@ -0,0 +1,159 @@ +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { WebSocket as NodeWebSocket, WebSocketServer } from 'ws' +import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer' +import { + connectMobileRelayForPairing, + type PairingCandidateClient +} from './mobile-relay-physical-client' +import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' +import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate' + +vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) +vi.mock('expo-crypto', () => ({ + getRandomBytes: (length: number) => new Uint8Array(length).fill(length) +})) + +const servers: Server[] = [] + +afterEach(async () => { + await Promise.all( + servers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve()))) + ) +}) + +describe('served relay pairing recovery', () => { + it('uses the configured director after an HTTP 502 WebSocket upgrade response', async () => { + const cellUrl = await serveUpgradeFailure(502) + const journal = createJournal(cellUrl) + const resolvedRelay = { + ...relayFromJournal(journal), + cellUrl: 'https://c2.relay-staging.onorca.dev', + assignmentEpoch: 8 + } + const resolveDirector = vi.fn(async () => resolvedRelay) + const persistMove = vi.fn(async () => {}) + const target = successfulClient() + + const candidate = createRecoveringPairingRelayCandidate({ + journal, + connect: (relay) => (relay.assignmentEpoch === 7 ? servedPhysicalClient(relay) : target), + resolveDirector, + persistMove, + now: () => 1, + random: () => 0, + sleep: async () => {} + }) + + await expect(candidate.sendRequest('status.get')).resolves.toMatchObject({ ok: true }) + expect(resolveDirector).toHaveBeenCalledOnce() + expect(persistMove).toHaveBeenCalledWith(resolvedRelay) + }) + + it('keeps a served 4404 host-offline close scoped to the failed endpoint', async () => { + const cellUrl = await serveCloseCode(4404) + const journal = createJournal(cellUrl) + const resolveDirector = vi.fn() + const candidate = createRecoveringPairingRelayCandidate({ + journal, + connect: servedPhysicalClient, + resolveDirector, + persistMove: vi.fn(async () => {}), + now: () => 1 + }) + + await expect(candidate.sendRequest('status.get')).rejects.toMatchObject({ code: 4404 }) + expect(resolveDirector).not.toHaveBeenCalled() + }) +}) + +function servedPhysicalClient(relay: PairingRelay): PairingCandidateClient { + return connectMobileRelayForPairing({ + relay, + deviceToken: 'device-token', + desktopPublicKeyB64: Buffer.alloc(32, 7).toString('base64'), + createSocket: (url) => { + // Why: production cells require TLS; the black-box test only substitutes + // a loopback plaintext transport while preserving the served upgrade path. + return new NodeWebSocket(url.replace('wss:', 'ws:')) as unknown as WebSocket + } + }) +} + +function successfulClient(): PairingCandidateClient { + return { + sendRequest: async () => ({ + id: 'rpc-1', + ok: true, + result: { path: 'relay' }, + _meta: { runtimeId: 'runtime-1' } + }), + close: vi.fn() + } +} + +async function serveUpgradeFailure(status: number): Promise { + const server = createServer() + server.on('upgrade', (_request, socket) => { + socket.end(`HTTP/1.1 ${status} Bad Gateway\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`) + }) + return listen(server) +} + +async function serveCloseCode(code: number): Promise { + const server = createServer() + const sockets = new WebSocketServer({ server }) + sockets.on('connection', (socket) => socket.close(code, 'host offline')) + return listen(server) +} + +async function listen(server: Server): Promise { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + servers.push(server) + const address = server.address() as AddressInfo + return `http://127.0.0.1:${address.port}` +} + +function createJournal(cellUrl: string): MobileRelayPairingJournal { + return { + metadata: { + v: 1, + journalId: 'pair-1', + offerFingerprint: 'A'.repeat(43), + host: { + id: 'host-1', + name: 'Blue Whale', + endpoint: 'ws://192.168.1.10:6768', + publicKeyB64: Buffer.alloc(32, 7).toString('base64'), + lastConnected: 1 + }, + relay: { + v: 1, + directorUrl: 'https://relay-staging.onorca.dev', + cellUrl, + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteExpiresAt: 10_000, + e2eeFraming: 2 + }, + installReqId: 'install-1', + resumeConfirmReqId: 'confirm-1', + pendingResumeTokenHash: 'B'.repeat(43) + }, + secrets: { + v: 1, + journalId: 'pair-1', + deviceToken: 'device-token', + inviteToken: 'C'.repeat(43), + pendingResumeToken: 'D'.repeat(43) + } + } +} + +function relayFromJournal(journal: MobileRelayPairingJournal): PairingRelay { + return { + ...journal.metadata.relay, + inviteToken: journal.secrets.inviteToken + } +} diff --git a/mobile/src/transport/pre-profile-pairing-coordinator.test.ts b/mobile/src/transport/pre-profile-pairing-coordinator.test.ts new file mode 100644 index 00000000000..16c833bf4f2 --- /dev/null +++ b/mobile/src/transport/pre-profile-pairing-coordinator.test.ts @@ -0,0 +1,330 @@ +import { describe, expect, it, vi } from 'vitest' +import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' +import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' +import { racePairingCandidates } from './pairing-candidate-race' +import { startPreProfilePairing } from './pre-profile-pairing-coordinator' +import type { HostProfile, PairingOffer, RpcResponse } from './types' +import type { RpcClient } from './rpc-client' + +vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) +vi.mock('expo-crypto', () => ({ + getRandomBytes: (length: number) => new Uint8Array(length).fill(length) +})) +vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED' })) + +const now = Date.UTC(2026, 6, 13) +const directOffer: PairingOffer = { + v: 2, + endpoint: 'ws://192.168.1.10:6768', + deviceToken: 'device-token', + publicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' +} +const relayOffer: PairingOffer = { + ...directOffer, + relay: { + v: 1, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: 'abcdefghijklmnopqrstuvwxyzABCDEFGH012345678', + inviteExpiresAt: now + 300_000, + e2eeFraming: 2 + } +} + +function success(result: unknown): RpcResponse { + return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +function failure(code: string): RpcResponse { + return { + id: 'rpc-1', + ok: false, + error: { code, message: code }, + _meta: { runtimeId: 'runtime-1' } + } +} + +function fakeClient(responses: RpcResponse[]) { + return { + sendRequest: vi.fn().mockImplementation(async () => responses.shift()!), + close: vi.fn() + } as unknown as RpcClient +} + +function dependencies(client: RpcClient, events: string[]) { + const unavailableRelay = fakeClient([]) + ;(unavailableRelay.sendRequest as ReturnType).mockRejectedValue( + new Error('relay unavailable') + ) + return { + connectDirect: vi.fn(() => (events.push('connect'), client)), + connectRelay: vi.fn(() => unavailableRelay), + resolveInviteDirector: vi.fn(async () => { + throw new Error('director unavailable') + }), + getNextHostName: vi.fn(async () => 'Blue Whale'), + saveHost: vi.fn(async (_host: HostProfile) => { + events.push('save-host') + }), + saveJournal: vi.fn(async (_journal: MobileRelayPairingJournal) => { + events.push('save-journal') + }), + updateJournal: vi.fn(async () => { + events.push('update-journal') + }), + clearJournal: vi.fn(async () => { + events.push('clear-journal') + }), + writeCredentialBundle: vi.fn(async (_bundle: MobileRelayCredentialBundle) => { + events.push('write-credential') + }), + now: () => now, + platform: 'ios' + } +} + +describe('pre-profile pairing coordinator', () => { + it('chooses direct when both post-E2EE status successes settle in the same turn', async () => { + let resolveDirect!: (response: RpcResponse) => void + let resolveRelay!: (response: RpcResponse) => void + const direct = fakeClient([]) + const relay = fakeClient([]) + ;(direct.sendRequest as ReturnType).mockReturnValue( + new Promise((resolve) => { + resolveDirect = resolve + }) + ) + ;(relay.sendRequest as ReturnType).mockReturnValue( + new Promise((resolve) => { + resolveRelay = resolve + }) + ) + const racing = racePairingCandidates([ + { path: 'direct', client: direct }, + { path: 'relay', client: relay } + ]) + resolveRelay(success({ path: 'relay' })) + resolveDirect(success({ path: 'direct' })) + + await expect(racing).resolves.toMatchObject({ path: 'direct' }) + expect(relay.close).toHaveBeenCalledOnce() + expect(direct.close).not.toHaveBeenCalled() + }) + + it('keeps a legacy offer direct-only through the shared path', async () => { + const events: string[] = [] + const client = fakeClient([success({ version: '1.0.0' })]) + const deps = dependencies(client, events) + + const attempt = startPreProfilePairing({ + offer: directOffer, + timeoutMs: 5_000, + dependencies: deps + }) + + await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` }) + expect(deps.saveHost).toHaveBeenCalledWith({ + id: `host-${now}`, + name: 'Blue Whale', + endpoint: directOffer.endpoint, + deviceToken: directOffer.deviceToken, + publicKeyB64: directOffer.publicKeyB64, + lastConnected: now + }) + expect(events).toEqual(['connect', 'save-host']) + }) + + it('journals before connecting and publishes only after authoritative direct install', async () => { + const events: string[] = [] + let journal: MobileRelayPairingJournal | null = null + const client = { + sendRequest: vi.fn(async (method: string) => { + if (method === 'status.get') { + return success({ version: '1.0.0' }) + } + if (!journal) { + throw new Error('journal was not saved before RPC') + } + const installed = { + v: 1 as const, + reqId: journal.metadata.installReqId, + authorizationMode: 'authenticated-direct' as const, + currentVersion: 1, + resumeExpiresAt: now + 86_400_000 + } + if (method === 'pairing.provisionRelay') { + return success(installed) + } + return success({ + v: 1, + relay: { + v: 1, + directorUrl: relayOffer.relay!.directorUrl, + cellUrl: relayOffer.relay!.cellUrl, + assignmentEpoch: 7, + relayHostId: relayOffer.relay!.relayHostId, + e2eeFraming: 2 + }, + installStatus: { + v: 1, + reqId: journal.metadata.installReqId, + state: 'committed', + result: installed + } + }) + }), + close: vi.fn() + } as unknown as RpcClient + const deps = dependencies(client, events) + deps.saveJournal.mockImplementation(async (value) => { + journal = value + events.push('save-journal') + }) + + const attempt = startPreProfilePairing({ + offer: relayOffer, + timeoutMs: 5_000, + dependencies: deps + }) + await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` }) + + expect(journal).not.toBeNull() + expect(events).toEqual([ + 'save-journal', + 'connect', + 'update-journal', + 'write-credential', + 'save-host', + 'clear-journal' + ]) + expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'pairing.provisionRelay', { + reqId: journal!.metadata.installReqId, + newResumeTokenHash: journal!.metadata.pendingResumeTokenHash + }) + expect(deps.saveHost).toHaveBeenCalledWith( + expect.objectContaining({ + id: `host-${now}`, + endpoint: directOffer.endpoint, + relayHostId: relayOffer.relay!.relayHostId, + endpoints: [ + { id: 'direct-primary', kind: 'lan', url: directOffer.endpoint }, + { + id: 'relay-primary', + kind: 'relay', + url: `wss://relay-c1.onorca.dev/v1/connect/${relayOffer.relay!.relayHostId}` + } + ] + }) + ) + }) + + it('tolerates an old desktop method_not_found and commits a direct-only host', async () => { + const events: string[] = [] + const client = fakeClient([success({ version: '1.0.0' }), failure('method_not_found')]) + const deps = dependencies(client, events) + + const attempt = startPreProfilePairing({ + offer: relayOffer, + timeoutMs: 5_000, + dependencies: deps + }) + await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` }) + + expect(deps.saveHost).toHaveBeenCalledWith( + expect.not.objectContaining({ endpoints: expect.anything() }) + ) + expect(events).toEqual([ + 'save-journal', + 'connect', + 'update-journal', + 'save-host', + 'clear-journal' + ]) + }) + + it('uses relay-basis provisioning when only the relay reaches post-E2EE status', async () => { + const direct = fakeClient([]) + ;(direct.sendRequest as ReturnType).mockRejectedValue(new Error('LAN down')) + let journal: MobileRelayPairingJournal | null = null + const relay = { + sendRequest: vi.fn(async (method: string) => { + if (method === 'status.get') { + return success({ path: 'relay' }) + } + const installed = { + v: 1 as const, + reqId: journal!.metadata.installReqId, + authorizationMode: 'relay-basis' as const, + currentVersion: 1, + resumeExpiresAt: now + 86_400_000 + } + if (method === 'pairing.provisionRelay') { + return success(installed) + } + return success({ + v: 1, + relay: { + v: 1, + directorUrl: relayOffer.relay!.directorUrl, + cellUrl: relayOffer.relay!.cellUrl, + assignmentEpoch: 7, + relayHostId: relayOffer.relay!.relayHostId, + e2eeFraming: 2 + }, + installStatus: { + v: 1, + reqId: journal!.metadata.installReqId, + state: 'committed', + result: installed + } + }) + }), + close: vi.fn() + } as unknown as RpcClient + const deps = dependencies(direct, []) + deps.connectRelay.mockReturnValue(relay) + deps.saveJournal.mockImplementation(async (value) => { + journal = value + }) + + const attempt = startPreProfilePairing({ + offer: relayOffer, + timeoutMs: 5_000, + dependencies: deps + }) + await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` }) + + expect(direct.close).toHaveBeenCalled() + expect(relay.sendRequest).toHaveBeenNthCalledWith(2, 'pairing.provisionRelay', { + reqId: journal!.metadata.installReqId, + newResumeTokenHash: journal!.metadata.pendingResumeTokenHash + }) + expect(deps.writeCredentialBundle).toHaveBeenCalledWith( + expect.objectContaining({ current: expect.objectContaining({ version: 1 }) }) + ) + }) + + it('cancels the disposable physical client without publishing a host', async () => { + let resolveStatus!: (response: RpcResponse) => void + const status = new Promise((resolve) => { + resolveStatus = resolve + }) + const client = fakeClient([]) + ;(client.sendRequest as ReturnType).mockReturnValue(status) + const deps = dependencies(client, []) + const attempt = startPreProfilePairing({ + offer: directOffer, + timeoutMs: 5_000, + dependencies: deps + }) + await vi.waitFor(() => expect(client.sendRequest).toHaveBeenCalledWith('status.get')) + attempt.dispose() + resolveStatus(success({ version: '1.0.0' })) + + await expect(attempt.result).rejects.toThrow(/cancelled/) + expect(client.close).toHaveBeenCalledOnce() + expect(deps.saveHost).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/transport/pre-profile-pairing-coordinator.ts b/mobile/src/transport/pre-profile-pairing-coordinator.ts new file mode 100644 index 00000000000..9d2a3b48ddb --- /dev/null +++ b/mobile/src/transport/pre-profile-pairing-coordinator.ts @@ -0,0 +1,301 @@ +import { Platform } from 'react-native' +import { + DeviceCredentialInstalledSchema, + PairingGetEndpointsResultSchema, + type DeviceCredentialInstalled, + type MobileRelayEndpoint +} from '../../../src/shared/mobile-relay-credential-contract' +import { connect, type ConnectOptions } from './rpc-client' +import { getNextHostName, saveHost } from './host-store' +import type { HostProfile, PairingOffer, RpcResponse } from './types' +import { + createMobileRelayPairingJournal, + type MobileRelayPairingJournal +} from './mobile-relay-pairing-journal' +import { + clearMobileRelayPairingJournal, + saveMobileRelayPairingJournal, + updateMobileRelayPairingJournal +} from './mobile-relay-pairing-journal-store' +import { + promotePairingJournalCredential, + writeMobileRelayCredentialBundle +} from './mobile-relay-credential-bundle' +import { + connectMobileRelayForPairing, + type PairingCandidateClient +} from './mobile-relay-physical-client' +import { racePairingCandidates, type PairingCandidate } from './pairing-candidate-race' +import { resolvePairingInviteThroughDirector } from './mobile-relay-invite-director' +import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate' + +export type PreProfilePairingAttempt = { + readonly result: Promise<{ hostId: string }> + readonly timedOut: boolean + dispose(): void +} + +type Dependencies = { + connectDirect: typeof connect + connectRelay: typeof connectMobileRelayForPairing + resolveInviteDirector: typeof resolvePairingInviteThroughDirector + getNextHostName: typeof getNextHostName + saveHost: typeof saveHost + saveJournal: typeof saveMobileRelayPairingJournal + updateJournal: typeof updateMobileRelayPairingJournal + clearJournal: typeof clearMobileRelayPairingJournal + writeCredentialBundle: typeof writeMobileRelayCredentialBundle + now: () => number + platform: string +} + +const defaultDependencies: Dependencies = { + connectDirect: connect, + connectRelay: connectMobileRelayForPairing, + resolveInviteDirector: resolvePairingInviteThroughDirector, + getNextHostName, + saveHost, + saveJournal: saveMobileRelayPairingJournal, + updateJournal: updateMobileRelayPairingJournal, + clearJournal: clearMobileRelayPairingJournal, + writeCredentialBundle: writeMobileRelayCredentialBundle, + now: Date.now, + platform: Platform.OS +} + +export function startPreProfilePairing(args: { + offer: PairingOffer + timeoutMs: number + connectOptions?: ConnectOptions + dependencies?: Partial +}): PreProfilePairingAttempt { + const dependencies = { ...defaultDependencies, ...args.dependencies } + const clients = new Set() + let disposed = false + let timedOut = false + let timer: ReturnType | null = null + + const dispose = (): void => { + if (disposed) { + return + } + disposed = true + if (timer) { + clearTimeout(timer) + timer = null + } + for (const client of clients) { + client.close() + } + clients.clear() + } + + timer = setTimeout(() => { + timedOut = true + dispose() + }, args.timeoutMs) + + const result = runPairing(args.offer, args.connectOptions, dependencies, clients, () => disposed) + .catch((error: unknown) => { + if (timedOut) { + throw new Error('mobile pairing timed out') + } + throw error + }) + .finally(() => { + if (timer) { + clearTimeout(timer) + timer = null + } + for (const client of clients) { + client.close() + } + clients.clear() + }) + + return { + result, + get timedOut() { + return timedOut + }, + dispose + } +} + +async function runPairing( + offer: PairingOffer, + connectOptions: ConnectOptions | undefined, + dependencies: Dependencies, + clients: Set, + isDisposed: () => boolean +): Promise<{ hostId: string }> { + const now = dependencies.now() + const hostId = `host-${now}` + const hostName = await dependencies.getNextHostName() + assertActive(isDisposed) + let journal: MobileRelayPairingJournal | null = null + if (offer.relay && dependencies.platform !== 'web') { + journal = createMobileRelayPairingJournal({ + offer: { ...offer, relay: offer.relay }, + hostId, + hostName, + now + }) + await dependencies.saveJournal(journal) + assertActive(isDisposed) + } + + const directClient = dependencies.connectDirect( + offer.endpoint, + offer.deviceToken, + offer.publicKeyB64, + connectOptions + ) + clients.add(directClient) + const candidates: PairingCandidate[] = [{ path: 'direct', client: directClient }] + if (journal) { + const relayClient = createRecoveringPairingRelayCandidate({ + journal, + connect: (relay) => + dependencies.connectRelay({ + relay, + deviceToken: offer.deviceToken, + desktopPublicKeyB64: offer.publicKeyB64 + }), + resolveDirector: (relay) => dependencies.resolveInviteDirector({ relay }), + persistMove: async (relay) => { + journal = { + ...journal!, + metadata: { + ...journal!.metadata, + relay: { + ...journal!.metadata.relay, + cellUrl: relay.cellUrl, + assignmentEpoch: relay.assignmentEpoch + } + } + } + await dependencies.updateJournal(journal.metadata.journalId, () => journal!.metadata) + }, + now: dependencies.now + }) + clients.add(relayClient) + candidates.push({ path: 'relay', client: relayClient }) + } + const winner = await racePairingCandidates(candidates) + assertActive(isDisposed) + + if (!journal) { + await dependencies.saveHost(baseHost(offer, hostId, hostName, now)) + return { hostId } + } + + journal = { + ...journal, + metadata: { + ...journal.metadata, + winner: winner.path, + authorizationMode: winner.path === 'direct' ? 'authenticated-direct' : 'relay-basis' + } + } + await dependencies.updateJournal(journal.metadata.journalId, () => journal!.metadata) + const provision = await winner.client.sendRequest('pairing.provisionRelay', { + reqId: journal.metadata.installReqId, + newResumeTokenHash: journal.metadata.pendingResumeTokenHash + }) + if (isMethodNotFound(provision)) { + if (winner.path !== 'direct') { + throw new Error('relay pairing RPC unavailable after relay path authentication') + } + await dependencies.saveHost(baseHost(offer, hostId, hostName, now)) + await dependencies.clearJournal(journal.metadata.journalId) + return { hostId } + } + const installed = DeviceCredentialInstalledSchema.parse(requireSuccess(provision)) + const endpoints = PairingGetEndpointsResultSchema.parse( + requireSuccess( + await winner.client.sendRequest('pairing.getEndpoints', { + installReqId: journal.metadata.installReqId + }) + ) + ) + assertCommittedInstall(endpoints.installStatus, installed) + if (!endpoints.relay) { + throw new Error('desktop returned no relay endpoint after credential install') + } + assertActive(isDisposed) + await dependencies.writeCredentialBundle(promotePairingJournalCredential({ journal, installed })) + await dependencies.saveHost(relayHost(journal, endpoints.relay)) + await dependencies.clearJournal(journal.metadata.journalId) + return { hostId } +} + +function baseHost( + offer: PairingOffer, + hostId: string, + name: string, + lastConnected: number +): HostProfile { + return { + id: hostId, + name, + endpoint: offer.endpoint, + deviceToken: offer.deviceToken, + publicKeyB64: offer.publicKeyB64, + lastConnected + } +} + +function relayHost(journal: MobileRelayPairingJournal, relay: MobileRelayEndpoint): HostProfile { + const host = journal.metadata.host + return { + ...host, + deviceToken: journal.secrets.deviceToken, + endpoints: [ + { id: 'direct-primary', kind: 'lan', url: host.endpoint }, + { id: 'relay-primary', kind: 'relay', url: relayWebSocketUrl(relay) } + ], + relayHostId: relay.relayHostId, + relay + } +} + +function relayWebSocketUrl(relay: MobileRelayEndpoint): string { + const url = new URL(relay.cellUrl) + url.protocol = 'wss:' + url.pathname = `/v1/connect/${encodeURIComponent(relay.relayHostId)}` + return url.toString() +} + +function requireSuccess(response: RpcResponse): unknown { + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + return response.result +} + +function isMethodNotFound(response: RpcResponse): boolean { + return !response.ok && response.error.code === 'method_not_found' +} + +function assertCommittedInstall( + status: + | { state: 'not-found' } + | { state: 'committed'; result: DeviceCredentialInstalled } + | undefined, + installed: DeviceCredentialInstalled +): void { + if ( + !status || + status.state !== 'committed' || + JSON.stringify(status.result) !== JSON.stringify(installed) + ) { + throw new Error('relay credential install was not authoritatively reconciled') + } +} + +function assertActive(isDisposed: () => boolean): void { + if (isDisposed()) { + throw new Error('mobile pairing cancelled') + } +} diff --git a/mobile/src/transport/stable-logical-rpc-client.test.ts b/mobile/src/transport/stable-logical-rpc-client.test.ts new file mode 100644 index 00000000000..8fe6dc25837 --- /dev/null +++ b/mobile/src/transport/stable-logical-rpc-client.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ConnectionState, RpcResponse } from './types' +import type { RpcClient } from './rpc-client' +import { + createStableLogicalRpcClient, + LogicalClientCutoverError +} from './stable-logical-rpc-client' + +class FakeSession implements RpcClient { + readonly sendRequest = + vi.fn< + (method: string, params?: unknown, options?: { timeoutMs?: number }) => Promise + >() + readonly subscribe = vi.fn() + readonly updateTerminalSubscriptionViewport = + vi.fn() + readonly notifyForeground = vi.fn() + readonly close = vi.fn() + private state: ConnectionState + private readonly stateListeners = new Set<(state: ConnectionState) => void>() + private readonly streamListeners = new Set<(result: unknown) => void>() + + constructor(state: ConnectionState) { + this.state = state + this.subscribe.mockImplementation((_method, _params, listener) => { + this.streamListeners.add(listener) + return () => this.streamListeners.delete(listener) + }) + } + + getState = (): ConnectionState => this.state + getReconnectAttempt = (): number => 0 + getLastConnectedAt = (): number | null => null + onStateChange = (listener: (state: ConnectionState) => void): (() => void) => { + this.stateListeners.add(listener) + return () => this.stateListeners.delete(listener) + } + + setState(state: ConnectionState): void { + this.state = state + for (const listener of this.stateListeners) { + listener(state) + } + } + + emitStream(value: unknown): void { + for (const listener of this.streamListeners) { + listener(value) + } + } +} + +function success(value: unknown): RpcResponse { + return { id: 'rpc-1', ok: true, result: value, _meta: { runtimeId: 'runtime-1' } } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +describe('stable logical RPC client', () => { + it('makes before break, rejects in-flight work, and replays subscriptions', async () => { + const oldSession = new FakeSession('connected') + const nextSession = new FakeSession('connecting') + const pending = deferred() + oldSession.sendRequest.mockReturnValue(pending.promise) + nextSession.sendRequest.mockResolvedValue(success('next')) + const client = createStableLogicalRpcClient(oldSession, 'lan') + const stream = vi.fn() + client.subscribe('terminal.subscribe', { terminal: 'term-1' }, stream) + const request = client.sendRequest('worktree.create', { name: 'new' }) + + const migrating = client.migrateTo(nextSession, 'relay') + expect(oldSession.close).not.toHaveBeenCalled() + expect(nextSession.subscribe).not.toHaveBeenCalled() + nextSession.setState('connected') + await migrating + + await expect(request).rejects.toBeInstanceOf(LogicalClientCutoverError) + expect(nextSession.subscribe).toHaveBeenCalledWith( + 'terminal.subscribe', + { terminal: 'term-1' }, + expect.any(Function), + undefined + ) + expect(oldSession.close).toHaveBeenCalledOnce() + expect(client.getActivePath()).toBe('relay') + expect(client.getGeneration()).toBe(2) + oldSession.emitStream('stale') + nextSession.emitStream('current') + expect(stream).toHaveBeenCalledOnce() + expect(stream).toHaveBeenCalledWith('current') + pending.resolve(success('late')) + }) + + it('keeps replies that commit before cutover and carries viewport state into replay', async () => { + const oldSession = new FakeSession('connected') + const nextSession = new FakeSession('connected') + oldSession.sendRequest.mockResolvedValue(success('old')) + const client = createStableLogicalRpcClient(oldSession, 'lan') + client.subscribe( + 'terminal.subscribe', + { terminal: 'term-1', viewport: { cols: 80, rows: 24 } }, + vi.fn() + ) + client.updateTerminalSubscriptionViewport('term-1', { cols: 120, rows: 40 }) + + await expect(client.sendRequest('status.get')).resolves.toEqual(success('old')) + await client.migrateTo(nextSession, 'relay') + expect(nextSession.subscribe).toHaveBeenCalledWith( + 'terminal.subscribe', + { terminal: 'term-1', viewport: { cols: 120, rows: 40 } }, + expect.any(Function), + undefined + ) + }) + + it('suspends one physical session and replays subscriptions on foreground replacement', async () => { + const oldSession = new FakeSession('connected') + const nextSession = new FakeSession('connected') + nextSession.sendRequest.mockResolvedValue(success('next')) + const client = createStableLogicalRpcClient(oldSession, 'relay') + client.subscribe('session.tabs.subscribe', { worktree: 'id:wt-1' }, vi.fn()) + + client.suspendActiveSession() + + expect(oldSession.close).toHaveBeenCalledOnce() + expect(client.getState()).toBe('disconnected') + await expect(client.sendRequest('status.get')).rejects.toThrow('Client suspended') + + await client.migrateTo(nextSession, 'relay') + + expect(nextSession.subscribe).toHaveBeenCalledWith( + 'session.tabs.subscribe', + { worktree: 'id:wt-1' }, + expect.any(Function), + undefined + ) + await expect(client.sendRequest('status.get')).resolves.toEqual(success('next')) + }) + + it('closes a replacement that fails authentication and preserves the active session', async () => { + const oldSession = new FakeSession('connected') + const replacement = new FakeSession('connecting') + const client = createStableLogicalRpcClient(oldSession, 'lan') + const migrating = client.migrateTo(replacement, 'relay') + replacement.setState('auth-failed') + + await expect(migrating).rejects.toThrow(/auth-failed/) + expect(replacement.close).toHaveBeenCalledOnce() + expect(oldSession.close).not.toHaveBeenCalled() + expect(client.getActivePath()).toBe('lan') + expect(client.getGeneration()).toBe(1) + }) +}) diff --git a/mobile/src/transport/stable-logical-rpc-client.ts b/mobile/src/transport/stable-logical-rpc-client.ts new file mode 100644 index 00000000000..0931a663689 --- /dev/null +++ b/mobile/src/transport/stable-logical-rpc-client.ts @@ -0,0 +1,293 @@ +import type { ConnectionState, RpcResponse } from './types' +import type { RpcClient } from './rpc-client' + +export type MobileConnectionPath = 'lan' | 'tailscale' | 'relay' + +export class LogicalClientCutoverError extends Error { + constructor() { + super('RPC interrupted by connection migration') + } +} + +type SubscriptionRecord = { + method: string + params: unknown + listener: (result: unknown) => void + options?: Parameters[3] + disposePhysical: (() => void) | null + cancelled: boolean +} + +type PendingRequest = { + reject: (error: Error) => void +} + +export type StableLogicalRpcClient = RpcClient & { + migrateTo(session: RpcClient, path: MobileConnectionPath, timeoutMs?: number): Promise + suspendActiveSession(): void + getActivePath(): MobileConnectionPath + getGeneration(): number +} + +export function createStableLogicalRpcClient( + initialSession: RpcClient, + initialPath: MobileConnectionPath +): StableLogicalRpcClient { + let activeSession = initialSession + let activePath = initialPath + let generation = 1 + let closed = false + let suspended = false + let nextSubscriptionId = 0 + let activeStateUnsubscribe: (() => void) | null = null + const subscriptions = new Map() + const pendingRequests = new Set() + const stateListeners = new Set<(state: ConnectionState) => void>() + let state = initialSession.getState() + + bindActiveState(initialSession, generation) + + const logical: StableLogicalRpcClient = { + sendRequest(method, params, options) { + if (closed) { + return Promise.reject(new Error('Client closed')) + } + if (suspended) { + return Promise.reject(new Error('Client suspended')) + } + const requestGeneration = generation + const session = activeSession + return new Promise((resolve, reject) => { + const pending = { reject } + pendingRequests.add(pending) + void session.sendRequest(method, params, options).then( + (response) => { + pendingRequests.delete(pending) + if (closed) { + reject(new Error('Client closed')) + } else if (requestGeneration !== generation) { + reject(new LogicalClientCutoverError()) + } else { + resolve(response) + } + }, + (error: unknown) => { + pendingRequests.delete(pending) + reject(error) + } + ) + }) + }, + + subscribe(method, params, listener, options) { + if (closed) { + return () => {} + } + const id = ++nextSubscriptionId + const record: SubscriptionRecord = { + method, + params, + listener, + options, + disposePhysical: null, + cancelled: false + } + subscriptions.set(id, record) + if (!suspended) { + attachSubscription(record, activeSession, generation) + } + return () => { + if (record.cancelled) { + return + } + record.cancelled = true + record.disposePhysical?.() + record.disposePhysical = null + subscriptions.delete(id) + } + }, + + updateTerminalSubscriptionViewport(terminal, viewport) { + for (const record of subscriptions.values()) { + if ( + record.params && + typeof record.params === 'object' && + 'terminal' in record.params && + record.params.terminal === terminal + ) { + record.params = { ...record.params, viewport } + } + } + if (!suspended) { + activeSession.updateTerminalSubscriptionViewport(terminal, viewport) + } + }, + + getState: () => state, + getReconnectAttempt: () => activeSession.getReconnectAttempt(), + getLastConnectedAt: () => activeSession.getLastConnectedAt(), + onStateChange(listener) { + stateListeners.add(listener) + return () => stateListeners.delete(listener) + }, + notifyForeground: () => { + if (!suspended) { + activeSession.notifyForeground() + } + }, + close() { + if (closed) { + return + } + closed = true + activeStateUnsubscribe?.() + activeStateUnsubscribe = null + for (const pending of pendingRequests) { + pending.reject(new Error('Client closed')) + } + pendingRequests.clear() + for (const record of subscriptions.values()) { + record.disposePhysical?.() + } + subscriptions.clear() + activeSession.close() + publishState('disconnected') + }, + + suspendActiveSession() { + if (closed || suspended) { + return + } + suspended = true + activeStateUnsubscribe?.() + activeStateUnsubscribe = null + for (const pending of pendingRequests) { + pending.reject(new Error('Client suspended')) + } + pendingRequests.clear() + for (const record of subscriptions.values()) { + record.disposePhysical?.() + record.disposePhysical = null + } + activeSession.close() + publishState('disconnected') + }, + + async migrateTo(nextSession, path, timeoutMs = 12_000) { + if (closed) { + nextSession.close() + throw new Error('Client closed') + } + try { + await waitForAuthenticated(nextSession, timeoutMs) + } catch (error) { + nextSession.close() + throw error + } + if (closed) { + nextSession.close() + throw new Error('Client closed') + } + const previous = activeSession + const previousStateUnsubscribe = activeStateUnsubscribe + const nextGeneration = generation + 1 + + // Why: replay on the authenticated replacement before closing the old + // session, but fence callbacks until the generation becomes current. + for (const record of subscriptions.values()) { + const disposePrevious = record.disposePhysical + attachSubscription(record, nextSession, nextGeneration) + disposePrevious?.() + } + generation = nextGeneration + activeSession = nextSession + activePath = path + suspended = false + previousStateUnsubscribe?.() + bindActiveState(nextSession, nextGeneration) + for (const pending of pendingRequests) { + pending.reject(new LogicalClientCutoverError()) + } + pendingRequests.clear() + state = nextSession.getState() + for (const listener of stateListeners) { + listener(state) + } + previous.close() + }, + + getActivePath: () => activePath, + getGeneration: () => generation + } + + return logical + + function attachSubscription( + record: SubscriptionRecord, + session: RpcClient, + subscriptionGeneration: number + ): void { + record.disposePhysical = session.subscribe( + record.method, + record.params, + (result) => { + if (!closed && !record.cancelled && generation === subscriptionGeneration) { + record.listener(result) + } + }, + record.options + ) + } + + function bindActiveState(session: RpcClient, sessionGeneration: number): void { + activeStateUnsubscribe = session.onStateChange((next) => { + if (!closed && generation === sessionGeneration && session === activeSession) { + publishState(next) + } + }) + } + + function publishState(next: ConnectionState): void { + if (state === next) { + return + } + state = next + for (const listener of stateListeners) { + listener(next) + } + } +} + +function waitForAuthenticated(session: RpcClient, timeoutMs: number): Promise { + if (session.getState() === 'connected') { + return Promise.resolve() + } + return new Promise((resolve, reject) => { + let settled = false + let timer: ReturnType | null = null + const unsubscribe = session.onStateChange((state) => { + if (state === 'connected') { + finish() + resolve() + } else if (state === 'auth-failed' || state === 'disconnected') { + finish() + reject(new Error(`replacement session ${state}`)) + } + }) + timer = setTimeout(() => { + finish() + reject(new Error('replacement session authentication timed out')) + }, timeoutMs) + + function finish(): void { + if (settled) { + return + } + settled = true + if (timer) { + clearTimeout(timer) + } + unsubscribe() + } + }) +} diff --git a/mobile/src/transport/types.ts b/mobile/src/transport/types.ts index 1140c692ecb..d031d11ff08 100644 --- a/mobile/src/transport/types.ts +++ b/mobile/src/transport/types.ts @@ -1,4 +1,17 @@ import { z } from 'zod' +import { + PairingOfferSchema, + type PairingOffer +} from '../../../src/shared/mobile-relay-pairing-offer' +import { + MobileAccessEndpointSchema, + type MobileAccessEndpoint, + type MobileRelayHostOverlay +} from './mobile-relay-host-overlay' +import { MobileRelayEndpointSchema } from '../../../src/shared/mobile-relay-credential-contract' + +export { PairingOfferSchema } +export type { PairingOffer } export type RpcRequest = { id: string @@ -24,17 +37,6 @@ export type RpcFailure = { export type RpcResponse = RpcSuccess | RpcFailure -const PAIRING_OFFER_VERSION = 2 - -export const PairingOfferSchema = z.object({ - v: z.literal(PAIRING_OFFER_VERSION), - endpoint: z.string().min(1), - deviceToken: z.string().min(1), - publicKeyB64: z.string().min(1) -}) - -export type PairingOffer = z.infer - export type ConnectionLogLevel = 'info' | 'success' | 'warn' | 'error' export type ConnectionLogEntry = { @@ -64,6 +66,9 @@ export type HostProfile = { deviceToken: string publicKeyB64: string lastConnected: number + endpoints?: MobileAccessEndpoint[] + relayHostId?: MobileRelayHostOverlay['relayHostId'] + relay?: MobileRelayHostOverlay['relay'] } export const HostProfileSchema = z.object({ @@ -72,7 +77,13 @@ export const HostProfileSchema = z.object({ endpoint: z.string().min(1), deviceToken: z.string().min(1), publicKeyB64: z.string().min(1), - lastConnected: z.number().finite() + lastConnected: z.number().finite(), + endpoints: z.array(MobileAccessEndpointSchema).min(1).max(16).optional(), + relayHostId: z + .string() + .regex(/^[A-Za-z0-9_-]{16}$/) + .optional(), + relay: MobileRelayEndpointSchema.optional() }) // Why: persisted host record after the v0.0.3 keychain split. The diff --git a/src/main/index.ts b/src/main/index.ts index eb2ac4a7668..8bda7328eda 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -15,6 +15,8 @@ import { migrateMobilePairingDataToCanonicalUserDataPath } from './persistence' import { ensureActiveOrcaProfile, initOrcaProfilePaths } from './orca-profiles/profile-index-store' +import { getOrcaCloudAuthConfig } from './orca-profiles/profile-cloud-auth-config' +import { getProfileUserDataPath } from './orca-profiles/profile-storage-paths' import { applyAppIcon } from './app-icon' import { StatsCollector, initStatsPath } from './stats/collector' import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store' @@ -41,6 +43,8 @@ import { resolveConsent } from './telemetry/consent' import { triggerStartupNotificationRegistration } from './ipc/notifications' import { OrcaRuntimeService } from './runtime/orca-runtime' import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc' +import { DesktopRelayService } from './runtime/relay/desktop-relay-service' +import type { RelayBrokerStatus } from './runtime/relay/relay-session-broker' import { awaitRuntimeFileWatcherUnsubscribes } from './runtime/orca-runtime-files' import { clearRuntimeMetadataIfOwned } from './runtime/runtime-metadata' import { ensureMainI18n, setMainUiLanguage } from './i18n/main-i18n' @@ -215,6 +219,8 @@ let claudeRuntimeAuth: ClaudeRuntimeAuthService | null = null let runtime: OrcaRuntimeService | null = null let rateLimits: RateLimitService | null = null let runtimeRpc: OrcaRuntimeRpcServer | null = null +let desktopRelayService: DesktopRelayService | null = null +let desktopRelayStatus: RelayBrokerStatus = 'offline' // Why: set during early startup; gates whether headless serve installs the // offscreen browser backend (and thus advertises browser pane support). let headlessBrowserDisplayAvailable = false @@ -977,8 +983,11 @@ function openMainWindow(): BrowserWindow { codexRuntimeHome ? [codexRuntimeHome.getHostRuntimeHomePath()] : [], onBeforeRelaunch: async () => { isQuitting = true + desktopRelayService?.fenceAndCloseNow() await preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }) - } + }, + onOrcaProfileAuthMutation: () => desktopRelayService?.authMutated(), + onBeforeOrcaProfileSignOut: () => desktopRelayService?.fenceAndCloseNow() } ) automations.setWebContents(window.webContents) @@ -2109,7 +2118,7 @@ app.whenReady().then(async () => { ...(serveOptions?.wsPort !== undefined ? { wsPort: serveOptions.wsPort } : {}), webClientRoot: getBundledWebClientRoot() }) - registerMobileHandlers(runtimeRpc) + registerMobileHandlers(runtimeRpc, { getRelayStatus: () => desktopRelayStatus }) startTerminalRuntimeStartupServices() app.on('activate', requestDesktopActivation) @@ -2215,6 +2224,36 @@ app.whenReady().then(async () => { }) ]) + const cloudAuth = getOrcaCloudAuthConfig() + if (cloudAuth.configured) { + try { + const relayService = new DesktopRelayService({ + authConfig: cloudAuth.config, + userDataPath: getProfileUserDataPath(), + appVersion: app.getVersion(), + runtimeRpc, + onStatus: (status) => { + desktopRelayStatus = status + mainWindow?.webContents.send('mobile:relayStatusChanged', status) + } + }) + desktopRelayService = relayService + runtimeRpc.setMobileRelayPairingProvider({ + createPairingRelay: (relayDeviceId) => relayService.createPairingRelay(relayDeviceId), + onDeviceRevokeQueued: (item) => relayService.onDeviceRevokeQueued(item), + onDemandStateChanged: () => relayService.demandStateChanged(), + getEndpoints: (context, params) => relayService.getEndpoints(context, params), + provisionRelay: (context, params) => relayService.provisionRelay(context, params) + }) + relayService.start() + } catch (error) { + console.warn( + '[relay] Desktop relay startup unavailable:', + error instanceof Error ? error.message : String(error) + ) + } + } + // Why: the macOS notification permission dialog must fire after the window // is visible and focused. If it fires before the window exists, the system // dialog either doesn't appear or gets immediately covered by the maximized @@ -2239,6 +2278,8 @@ app.on('before-quit', () => { }) } isQuitting = true + desktopRelayService?.fenceAndCloseNow() + runtimeRpc?.setMobileRelayPairingProvider(null) unsubscribeSystemResumeBroadcast?.() unsubscribeSystemResumeBroadcast = null unsubscribeAgentAwakeStatusChanges?.() diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts index 4091a298446..49a39686a28 100644 --- a/src/main/ipc/mobile.test.ts +++ b/src/main/ipc/mobile.test.ts @@ -64,13 +64,13 @@ describe('registerMobileHandlers', () => { en0: [{ family: 'IPv4', internal: false, address: '192.168.1.24' }], utun4: [{ family: 'IPv4', internal: false, address: '100.102.47.57' }] }) - const createPairingOffer = vi.fn().mockReturnValue({ + const createMobilePairingOffer = vi.fn().mockResolvedValue({ available: true, pairingUrl: 'orca://pair#mobile', endpoint: 'ws://100.102.47.57:6768', deviceId: 'mobile-1' }) - const rpcServer = { createPairingOffer } + const rpcServer = { createMobilePairingOffer } registerMobileHandlers(rpcServer as never) @@ -81,14 +81,33 @@ describe('registerMobileHandlers', () => { deviceId: 'mobile-1' }) - expect(createPairingOffer).toHaveBeenCalledWith({ + expect(createMobilePairingOffer).toHaveBeenCalledWith({ address: '100.102.47.57', + connectionMode: undefined, rotate: undefined, - name: expect.stringMatching(/^Mobile /), - scope: 'mobile' + name: expect.stringMatching(/^Mobile /) }) }) + it('forwards an explicit local-only pairing choice', async () => { + networkInterfacesMock.mockReturnValue({ + en0: [{ family: 'IPv4', internal: false, address: '192.168.1.24' }] + }) + const createMobilePairingOffer = vi.fn().mockResolvedValue({ + available: true, + pairingUrl: 'orca://pair#local', + endpoint: 'ws://192.168.1.24:6768', + deviceId: 'mobile-local' + }) + + registerMobileHandlers({ createMobilePairingOffer } as never) + await handlers.get('mobile:getPairingQR')?.(null, { connectionMode: 'local-only' }) + + expect(createMobilePairingOffer).toHaveBeenCalledWith( + expect.objectContaining({ connectionMode: 'local-only' }) + ) + }) + it('lists only paired mobile-scoped devices', () => { const rpcServer = { getDeviceRegistry: () => ({ @@ -229,6 +248,27 @@ describe('registerMobileHandlers', () => { expect(revokeRuntimeAccess).toHaveBeenCalledWith('runtime-1') }) + it('awaits mobile device revocation before replying', async () => { + const revokeMobileDevice = vi.fn().mockResolvedValue(true) + const rpcServer = { + getDeviceRegistry: () => ({}), + revokeMobileDevice + } + + registerMobileHandlers(rpcServer as never) + + await expect( + handlers.get('mobile:revokeDevice')?.(null, { deviceId: 'mobile-1' }) + ).resolves.toEqual({ revoked: true }) + expect(revokeMobileDevice).toHaveBeenCalledWith('mobile-1') + }) + + it('reports the current relay broker status without exposing a toggle', () => { + registerMobileHandlers({} as never, { getRelayStatus: () => 'registered' }) + + expect(handlers.get('mobile:getRelayStatus')?.()).toEqual({ status: 'registered' }) + }) + it('inspects and repairs the current packaged Windows websocket port', async () => { const runPowerShell = vi .fn() diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index 548835801ce..4cc09bc0b51 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -2,9 +2,11 @@ import { app, ipcMain, shell, type IpcMainInvokeEvent } from 'electron' import { networkInterfaces } from 'node:os' import QRCode from 'qrcode' import type { RuntimeAccessGrant } from '../../shared/runtime-access-grants' +import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' import { isTailnetIPv4Address } from '../../shared/tailnet-address' import type { DeviceEntry } from '../runtime/device-registry' import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' +import type { RelayBrokerStatus } from '../runtime/relay/relay-session-broker' import { getWebSocketPort, inspectWindowsMobileFirewall, @@ -60,6 +62,7 @@ function toRuntimeAccessGrant(device: DeviceEntry): RuntimeAccessGrant { export type MobileHandlerDependencies = { firewallEnvironment?: WindowsMobileFirewallEnvironment openWindowsNetworkSettings?: () => Promise + getRelayStatus?: () => RelayBrokerStatus } export function registerMobileHandlers( @@ -78,7 +81,14 @@ export function registerMobileHandlers( ipcMain.handle( 'mobile:getPairingQR', - async (_event, args?: { address?: string; rotate?: boolean }) => { + async ( + _event, + args?: { + address?: string + connectionMode?: MobilePairingConnectionMode + rotate?: boolean + } + ) => { // Why: allow the caller to specify which network interface address to // embed in the QR code. This supports overlay networks (Tailscale, // ZeroTier) where the default LAN IP isn't reachable from the phone. @@ -94,11 +104,11 @@ export function registerMobileHandlers( // `rotate: true` (explicit "Regenerate" intent because the prior token // may have been exposed), we discard any pending token and mint a fresh // one so the new QR carries a different credential. - const offer = rpcServer.createPairingOffer({ + const offer = await rpcServer.createMobilePairingOffer({ address: ip, + connectionMode: args?.connectionMode, rotate: args?.rotate, - name: `Mobile ${new Date().toLocaleDateString()}`, - scope: 'mobile' + name: `Mobile ${new Date().toLocaleDateString()}` }) if (!offer.available) { return { available: false as const } @@ -187,12 +197,12 @@ export function registerMobileHandlers( } }) - ipcMain.handle('mobile:revokeDevice', (_event, args: { deviceId: string }) => { + ipcMain.handle('mobile:revokeDevice', async (_event, args: { deviceId: string }) => { const registry = rpcServer.getDeviceRegistry() if (!registry) { return { revoked: false } } - return { revoked: rpcServer.revokeMobileDevice(args.deviceId) } + return { revoked: await rpcServer.revokeMobileDevice(args.deviceId) } }) ipcMain.handle('mobile:revokeRuntimeAccess', (_event, args: { deviceId: string }) => { @@ -234,6 +244,10 @@ export function registerMobileHandlers( await openSettings() return true }) + + ipcMain.handle('mobile:getRelayStatus', () => ({ + status: dependencies.getRelayStatus?.() ?? 'offline' + })) } function isWindowRenderer(event: IpcMainInvokeEvent): boolean { diff --git a/src/main/ipc/orca-profiles.ts b/src/main/ipc/orca-profiles.ts index fa6a57859e9..6fbd877552f 100644 --- a/src/main/ipc/orca-profiles.ts +++ b/src/main/ipc/orca-profiles.ts @@ -25,6 +25,10 @@ import { seedNewOrcaProfileTelemetryConsent, setActiveOrcaProfile } from '../orca-profiles/profile-index-store' +import { + cloudSessionIdentity, + recordCloudSessionIdentityMutation +} from '../orca-profiles/profile-cloud-session-mutation' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { isMultiProfileUiEnabled } from '../orca-profiles/profile-ui-scope' import { transferOrcaProfileProject } from '../orca-profiles/profile-project-transfer' @@ -42,6 +46,8 @@ import { registerOrcaProfileOrgMemberHandlers } from './orca-profile-org-members type RegisterOrcaProfileHandlersOptions = { onBeforeRelaunch?: () => void | Promise + onAuthMutation?: () => void + onBeforeSignOut?: () => void } function profileIdFromArgs(args: unknown): string { @@ -192,6 +198,17 @@ export function registerOrcaProfileHandlers( return { status: 'already-active' } } + const activeProfile = current.profiles.find( + (profile) => profile.id === current.activeProfileId + ) + if (activeProfile?.cloud) { + // Why: profile selection changes the expected identity synchronously; + // stale refresh saves must fail even before relaunch teardown finishes. + recordCloudSessionIdentityMutation( + cloudSessionIdentity(activeProfile.id, activeProfile.cloud), + getProfileUserDataPath() + ) + } // Why: the current profile must be persisted before the global index // points startup at the target profile. await runBeforeProfileRelaunch(options.onBeforeRelaunch) @@ -248,8 +265,13 @@ export function registerOrcaProfileHandlers( ipcMain.handle( 'orcaProfiles:connectCurrent', - async (): Promise => - connectCurrentOrcaProfile(getProfileUserDataPath()) + async (): Promise => { + const result = await connectCurrentOrcaProfile(getProfileUserDataPath()) + if (result.status === 'connected') { + options.onAuthMutation?.() + } + return result + } ) ipcMain.handle( @@ -264,6 +286,7 @@ export function registerOrcaProfileHandlers( ) if (result.status === 'created') { seedNewOrcaProfileTelemetryConsent(result.profile.id, store.getSettings().telemetry) + options.onAuthMutation?.() } return result } @@ -271,20 +294,35 @@ export function registerOrcaProfileHandlers( ipcMain.handle( 'orcaProfiles:refreshAuth', - async (): Promise => - refreshCurrentOrcaProfileAuth(getProfileUserDataPath()) + async (): Promise => { + const result = await refreshCurrentOrcaProfileAuth(getProfileUserDataPath()) + if (result.status === 'refreshed') { + options.onAuthMutation?.() + } + return result + } ) ipcMain.handle( 'orcaProfiles:signOutCurrent', - async (): Promise => - signOutCurrentOrcaProfile(getProfileUserDataPath()) + async (): Promise => { + options.onBeforeSignOut?.() + return signOutCurrentOrcaProfile(getProfileUserDataPath()) + } ) ipcMain.handle( 'orcaProfiles:selectOrg', - async (_event, rawArgs: SelectOrcaProfileOrgArgs): Promise => - selectCurrentOrcaProfileOrg(getProfileUserDataPath(), orgIdFromUnknown(rawArgs)) + async (_event, rawArgs: SelectOrcaProfileOrgArgs): Promise => { + const result = await selectCurrentOrcaProfileOrg( + getProfileUserDataPath(), + orgIdFromUnknown(rawArgs) + ) + if (result.status === 'selected') { + options.onAuthMutation?.() + } + return result + } ) registerOrcaProfileOrgMemberHandlers() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index c827ba561df..cd2dab472b7 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -82,6 +82,8 @@ let registered = false type CoreHandlerLifecycleOptions = { onBeforeRelaunch?: () => void | Promise + onOrcaProfileAuthMutation?: () => void + onBeforeOrcaProfileSignOut?: () => void getAdditionalAiVaultCodexHomePaths?: () => readonly string[] } @@ -161,7 +163,9 @@ export function registerCoreHandlers( } registerTelemetryHandlers(store) registerOrcaProfileHandlers(store, { - onBeforeRelaunch: lifecycleOptions.onBeforeRelaunch + onBeforeRelaunch: lifecycleOptions.onBeforeRelaunch, + onAuthMutation: lifecycleOptions.onOrcaProfileAuthMutation, + onBeforeSignOut: lifecycleOptions.onBeforeOrcaProfileSignOut }) registerBrowserHandlers() registerShellHandlers() diff --git a/src/main/orca-profiles/profile-cloud-auth-config.test.ts b/src/main/orca-profiles/profile-cloud-auth-config.test.ts index d18ff159fd5..cf2c77b0072 100644 --- a/src/main/orca-profiles/profile-cloud-auth-config.test.ts +++ b/src/main/orca-profiles/profile-cloud-auth-config.test.ts @@ -36,12 +36,34 @@ describe('Orca cloud auth config', () => { profileEndpoint: 'https://orca-cloud.example/v1/desktop/auth/profile', orgEndpoint: 'https://orca-cloud.example/v1/desktop/auth/org', logoutEndpoint: 'https://orca-cloud.example/v1/desktop/auth/logout', + relayTokenEndpoint: 'https://orca-cloud.example/v1/desktop/auth/relay-token', + relayDirectorUrl: 'https://relay.onorca.dev', clientId: 'desktop-client', scope: 'openid profile email offline_access' } }) }) + it('uses first-party production endpoints without runtime env in packaged builds', () => { + expect(getOrcaCloudAuthConfig({}, true)).toEqual({ + configured: true, + config: { + apiBaseUrl: 'https://login.onorca.dev', + authorizeEndpoint: 'https://login.onorca.dev/v1/desktop/auth/authorize', + sessionEndpoint: 'https://login.onorca.dev/v1/desktop/auth/session', + refreshEndpoint: 'https://login.onorca.dev/v1/desktop/auth/refresh', + capabilitiesEndpoint: 'https://login.onorca.dev/v1/desktop/auth/capabilities', + profileEndpoint: 'https://login.onorca.dev/v1/desktop/auth/profile', + orgEndpoint: 'https://login.onorca.dev/v1/desktop/auth/org', + logoutEndpoint: 'https://login.onorca.dev/v1/desktop/auth/logout', + relayTokenEndpoint: 'https://login.onorca.dev/v1/desktop/auth/relay-token', + relayDirectorUrl: 'https://relay.onorca.dev', + clientId: 'orca-desktop', + scope: 'openid profile email offline_access' + } + }) + }) + it('allows loopback HTTP endpoints for local desktop auth development', () => { const state = getOrcaCloudAuthConfig({ ORCA_CLOUD_API_URL: 'http://localhost:4100', diff --git a/src/main/orca-profiles/profile-cloud-auth-config.ts b/src/main/orca-profiles/profile-cloud-auth-config.ts index 15f9c2e6d24..09cfd8dfc6b 100644 --- a/src/main/orca-profiles/profile-cloud-auth-config.ts +++ b/src/main/orca-profiles/profile-cloud-auth-config.ts @@ -9,11 +9,16 @@ export type OrcaCloudAuthConfig = { profileEndpoint: string orgEndpoint: string logoutEndpoint: string + relayTokenEndpoint: string + relayDirectorUrl: string clientId: string scope: string } const DEFAULT_SCOPE = 'openid profile email offline_access' +const PRODUCTION_API_BASE_URL = 'https://login.onorca.dev' +const PRODUCTION_CLIENT_ID = 'orca-desktop' +const PRODUCTION_RELAY_DIRECTOR_URL = 'https://relay.onorca.dev' // Why: packaged main bundles never define NODE_ENV, so packaged-ness is the // only reliable production signal for gating dev-only auth escape hatches. @@ -49,6 +54,15 @@ function endpoint(baseUrl: string, path: string): string { return new URL(path, `${baseUrl}/`).toString() } +function cleanOrigin(value: string | undefined, allowLoopbackHttp: boolean): string | null { + const cleaned = cleanUrl(value, allowLoopbackHttp) + if (!cleaned) { + return null + } + const parsed = new URL(cleaned) + return parsed.pathname === '/' && !parsed.search && !parsed.hash ? parsed.origin : null +} + export function getOrcaCloudAuthConfig( env: NodeJS.ProcessEnv = process.env, packaged: boolean = isPackagedOrcaBuild() @@ -58,8 +72,15 @@ export function getOrcaCloudAuthConfig( const allowLoopbackHttp = !packaged const cleanEndpointUrl = (value: string | undefined): string | null => cleanUrl(value, allowLoopbackHttp) - const apiBaseUrl = cleanEndpointUrl(env.ORCA_CLOUD_API_URL) - const clientId = env.ORCA_CLOUD_CLIENT_ID?.trim() + const configuredApiBaseUrl = env.ORCA_CLOUD_API_URL?.trim() + // Why: packaged releases cannot depend on launch-time environment injection; + // these first-party endpoints and the public OAuth client ID are not secrets. + const apiBaseUrl = configuredApiBaseUrl + ? cleanEndpointUrl(configuredApiBaseUrl) + : packaged + ? PRODUCTION_API_BASE_URL + : null + const clientId = env.ORCA_CLOUD_CLIENT_ID?.trim() || (packaged ? PRODUCTION_CLIENT_ID : undefined) if (!apiBaseUrl || !clientId) { return { configured: false, @@ -92,6 +113,11 @@ export function getOrcaCloudAuthConfig( logoutEndpoint: cleanEndpointUrl(env.ORCA_CLOUD_LOGOUT_URL) ?? endpoint(apiBaseUrl, '/v1/desktop/auth/logout'), + relayTokenEndpoint: + cleanEndpointUrl(env.ORCA_CLOUD_RELAY_TOKEN_URL) ?? + endpoint(apiBaseUrl, '/v1/desktop/auth/relay-token'), + relayDirectorUrl: + cleanOrigin(env.ORCA_RELAY_URL, allowLoopbackHttp) ?? PRODUCTION_RELAY_DIRECTOR_URL, clientId, scope: env.ORCA_CLOUD_AUTH_SCOPE?.trim() || DEFAULT_SCOPE } diff --git a/src/main/orca-profiles/profile-cloud-callback-page.ts b/src/main/orca-profiles/profile-cloud-callback-page.ts new file mode 100644 index 00000000000..1439010b438 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-callback-page.ts @@ -0,0 +1,89 @@ +export const ORCA_CLOUD_CALLBACK_RESPONSE_HEADERS = { + 'cache-control': 'no-store', + 'content-security-policy': + "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'", + 'content-type': 'text/html; charset=utf-8', + 'referrer-policy': 'no-referrer', + 'x-content-type-options': 'nosniff' +} as const + +// Why: the loopback callback cannot load Orca's renderer bundle, so this +// standalone page mirrors its canonical light/dark tokens without external assets. +export const ORCA_CLOUD_CALLBACK_SUCCESS_PAGE = ` + + + + + + Signed in to Orca + + + +
+ +

Signed in to Orca

+

You can close this tab and return to the app.

+
+ +` diff --git a/src/main/orca-profiles/profile-cloud-capability-refresh.ts b/src/main/orca-profiles/profile-cloud-capability-refresh.ts new file mode 100644 index 00000000000..c66eee70952 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-capability-refresh.ts @@ -0,0 +1,110 @@ +import type { RefreshCurrentOrcaProfileAuthResult } from '../../shared/orca-profiles' +import { getOrcaCloudAuthConfig, isOrcaCloudDevAuthEnabled } from './profile-cloud-auth-config' +import { getOrcaProfileAuthStatusFromProfile } from './profile-cloud-auth-status' +import { refreshOrcaCloudCapabilities } from './profile-cloud-client' +import { linkOrcaProfileToCloud } from './profile-cloud-index' +import { ensureActiveOrcaProfile, getOrcaProfileListState } from './profile-index-store' +import { refreshDevOrcaCloudProfile } from './profile-cloud-dev-service' +import { + captureCloudSessionMutation, + cloudSessionIdentity, + recordCloudSessionIdentityMutationIfCurrent +} from './profile-cloud-session-mutation' +import { runWithFreshOrcaCloudSession } from './profile-cloud-session-refresh' +import { readOrcaCloudSession, saveOrcaCloudSessionIfCurrent } from './profile-cloud-session-store' + +export async function refreshCurrentOrcaProfileAuth( + userDataPath: string +): Promise { + const active = ensureActiveOrcaProfile(userDataPath) + const auth = () => getOrcaProfileAuthStatusFromProfile(active, userDataPath) + if (!active.profile.cloud) { + return { status: 'local', auth: auth() } + } + if (isOrcaCloudDevAuthEnabled()) { + const result = refreshDevOrcaCloudProfile(active, userDataPath) + if (result.status !== 'updated') { + return { status: 'reconnect-required', auth: auth() } + } + return { + status: 'refreshed', + auth: auth(), + activeProfileId: result.list.activeProfileId, + profiles: result.list.profiles + } + } + const configState = getOrcaCloudAuthConfig() + if (!configState.configured) { + return { status: 'unconfigured', auth: auth() } + } + try { + const identity = cloudSessionIdentity(active.profile.id, active.profile.cloud) + let mutationSnapshot = captureCloudSessionMutation(identity, userDataPath) + const operation = await runWithFreshOrcaCloudSession( + configState.config, + active, + userDataPath, + (session) => refreshOrcaCloudCapabilities(configState.config, session) + ) + if (operation.status !== 'ok') { + return { status: 'reconnect-required', auth: auth() } + } + const refresh = operation.value + if (refresh.cloud) { + const refreshedIdentity = cloudSessionIdentity(active.profile.id, refresh.cloud) + if ( + refreshedIdentity.cloudUserId !== identity.cloudUserId || + refreshedIdentity.cloudProfileId !== identity.cloudProfileId + ) { + throw new Error('orca_cloud_identity_changed_during_capability_refresh') + } + if (refreshedIdentity.organizationId !== identity.organizationId) { + const advanced = recordCloudSessionIdentityMutationIfCurrent( + refreshedIdentity, + userDataPath, + mutationSnapshot + ) + if (!advanced) { + return { status: 'reconnect-required', auth: auth() } + } + mutationSnapshot = advanced + } + } + const session = readOrcaCloudSession(active.profile.id, userDataPath) + if (session.status !== 'found') { + return { status: 'reconnect-required', auth: auth() } + } + if ( + saveOrcaCloudSessionIfCurrent( + active.profile.id, + userDataPath, + { + ...session.session, + organizations: refresh.organizations ?? session.session.organizations, + capabilities: refresh.capabilities + }, + mutationSnapshot + ) === null + ) { + return { status: 'reconnect-required', auth: auth() } + } + const list = refresh.cloud + ? linkOrcaProfileToCloud(active.profile.id, refresh.cloud, userDataPath) + : getOrcaProfileListState(userDataPath) + return { + status: 'refreshed', + auth: getOrcaProfileAuthStatusFromProfile( + ensureActiveOrcaProfile(userDataPath), + userDataPath + ), + activeProfileId: list.activeProfileId, + profiles: list.profiles + } + } catch (error) { + return { + status: 'failed', + auth: auth(), + error: error instanceof Error ? error.message : String(error) + } + } +} diff --git a/src/main/orca-profiles/profile-cloud-client.test.ts b/src/main/orca-profiles/profile-cloud-client.test.ts index f24ba0bb5bf..d57f7a041ca 100644 --- a/src/main/orca-profiles/profile-cloud-client.test.ts +++ b/src/main/orca-profiles/profile-cloud-client.test.ts @@ -20,6 +20,8 @@ const config: OrcaCloudAuthConfig = { profileEndpoint: 'https://orca-cloud.example/v1/desktop/auth/profile', orgEndpoint: 'https://orca-cloud.example/v1/desktop/auth/org', logoutEndpoint: 'https://orca-cloud.example/v1/desktop/auth/logout', + relayTokenEndpoint: 'https://orca-cloud.example/v1/desktop/auth/relay-token', + relayDirectorUrl: 'https://relay.example', clientId: 'desktop-client', scope: 'openid profile email offline_access' } diff --git a/src/main/orca-profiles/profile-cloud-org-members-client.test.ts b/src/main/orca-profiles/profile-cloud-org-members-client.test.ts index bc3792742d2..637d5f308bf 100644 --- a/src/main/orca-profiles/profile-cloud-org-members-client.test.ts +++ b/src/main/orca-profiles/profile-cloud-org-members-client.test.ts @@ -21,6 +21,8 @@ const config: OrcaCloudAuthConfig = { profileEndpoint: 'https://orca-cloud.example/v1/desktop/auth/profile', orgEndpoint: 'https://orca-cloud.example/v1/desktop/auth/org', logoutEndpoint: 'https://orca-cloud.example/v1/desktop/auth/logout', + relayTokenEndpoint: 'https://orca-cloud.example/v1/desktop/auth/relay-token', + relayDirectorUrl: 'https://relay.example', clientId: 'desktop-client', scope: 'openid profile email offline_access' } diff --git a/src/main/orca-profiles/profile-cloud-org-selection.ts b/src/main/orca-profiles/profile-cloud-org-selection.ts new file mode 100644 index 00000000000..2a55623ee88 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-org-selection.ts @@ -0,0 +1,92 @@ +import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config' +import { + OrcaCloudRequestError, + refreshOrcaCloudSession, + selectOrcaCloudOrg +} from './profile-cloud-client' +import { linkOrcaProfileToCloud } from './profile-cloud-index' +import type { ActiveOrcaProfileState } from './profile-index-store' +import { + cloudSessionIdentity, + recordCloudSessionIdentityMutation, + recordCloudSessionIdentityMutationIfCurrent +} from './profile-cloud-session-mutation' +import { + readOrcaCloudSession, + saveOrcaCloudSessionIfCurrent, + type OrcaCloudSession +} from './profile-cloud-session-store' + +export async function selectCloudOrgWithMutationFence(input: { + config: OrcaCloudAuthConfig + active: ActiveOrcaProfileState + userDataPath: string + orgId: string +}): Promise | null> { + const cloud = input.active.profile.cloud + const stored = readOrcaCloudSession(input.active.profile.id, input.userDataPath) + if (!cloud || stored.status !== 'found') { + return null + } + const oldIdentity = cloudSessionIdentity(input.active.profile.id, cloud) + const targetIdentity = { + ...oldIdentity, + organizationId: input.orgId + } + // Why: advance the durable identity fence before the first request. An old + // refresh may finish, but its compare-and-save can no longer publish. + const snapshot = recordCloudSessionIdentityMutation(targetIdentity, input.userDataPath) + let workingSession: OrcaCloudSession = stored.session + try { + let selected + try { + selected = await selectOrcaCloudOrg(input.config, workingSession, input.orgId) + } catch (error) { + if (!(error instanceof OrcaCloudRequestError) || error.statusCode !== 401) { + throw error + } + const refreshed = await refreshOrcaCloudSession(input.config, workingSession) + if ( + refreshed.cloud.userId !== cloud.userId || + refreshed.cloud.cloudProfileId !== cloud.cloudProfileId + ) { + throw new Error('orca_cloud_identity_changed_during_org_selection') + } + workingSession = { + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken, + expiresAt: refreshed.expiresAt, + organizations: refreshed.organizations, + capabilities: refreshed.capabilities + } + selected = await selectOrcaCloudOrg(input.config, workingSession, input.orgId) + } + if ( + selected.cloud.userId !== cloud.userId || + selected.cloud.cloudProfileId !== cloud.cloudProfileId || + selected.cloud.activeOrgId !== input.orgId + ) { + throw new Error('orca_cloud_org_selection_identity_mismatch') + } + const nextSession: OrcaCloudSession = { + ...workingSession, + organizations: selected.organizations ?? workingSession.organizations, + capabilities: selected.capabilities + } + if ( + saveOrcaCloudSessionIfCurrent( + input.active.profile.id, + input.userDataPath, + nextSession, + snapshot + ) === null + ) { + throw new Error('stale_cloud_session_mutation') + } + const list = linkOrcaProfileToCloud(input.active.profile.id, selected.cloud, input.userDataPath) + return list + } catch (error) { + recordCloudSessionIdentityMutationIfCurrent(oldIdentity, input.userDataPath, snapshot) + throw error + } +} diff --git a/src/main/orca-profiles/profile-cloud-pkce.test.ts b/src/main/orca-profiles/profile-cloud-pkce.test.ts index 03f6e5894b3..357a5389465 100644 --- a/src/main/orca-profiles/profile-cloud-pkce.test.ts +++ b/src/main/orca-profiles/profile-cloud-pkce.test.ts @@ -1,4 +1,5 @@ import { get } from 'node:http' +import type { IncomingHttpHeaders } from 'node:http' import { describe, expect, it, beforeEach, vi } from 'vitest' import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config' @@ -16,6 +17,7 @@ import { beginOrcaCloudPkceFlow } from './profile-cloud-pkce' type HttpResponse = { body: string + headers: IncomingHttpHeaders statusCode: number | undefined } @@ -28,6 +30,8 @@ const config: OrcaCloudAuthConfig = { profileEndpoint: 'https://orca-cloud.example/v1/desktop/auth/profile', orgEndpoint: 'https://orca-cloud.example/v1/desktop/auth/org', logoutEndpoint: 'https://orca-cloud.example/v1/desktop/auth/logout', + relayTokenEndpoint: 'https://orca-cloud.example/v1/desktop/auth/relay-token', + relayDirectorUrl: 'https://relay.example', clientId: 'desktop-client', scope: 'openid profile email offline_access' } @@ -41,7 +45,7 @@ function readHttp(url: string): Promise { body += chunk }) response.on('end', () => { - resolve({ body, statusCode: response.statusCode }) + resolve({ body, headers: response.headers, statusCode: response.statusCode }) }) }) request.on('error', reject) @@ -91,6 +95,11 @@ describe('Orca cloud PKCE flow', () => { const validResponse = await readHttp(callbackUrl(redirectUri, { code: 'real-code', state })) expect(validResponse.statusCode).toBe(200) + expect(validResponse.headers['cache-control']).toBe('no-store') + expect(validResponse.headers['content-security-policy']).toContain("default-src 'none'") + expect(validResponse.body).toContain('

Signed in to Orca

') + expect(validResponse.body).toContain('You can close this tab and return to the app.') + expect(validResponse.body).not.toContain('class="brand"') await expect(flow).resolves.toMatchObject({ code: 'real-code', redirectUri, diff --git a/src/main/orca-profiles/profile-cloud-pkce.ts b/src/main/orca-profiles/profile-cloud-pkce.ts index 8adf3f3e587..79e6819cfd8 100644 --- a/src/main/orca-profiles/profile-cloud-pkce.ts +++ b/src/main/orca-profiles/profile-cloud-pkce.ts @@ -2,6 +2,10 @@ import { createHash, randomBytes } from 'node:crypto' import { createServer, type Server, type ServerResponse } from 'node:http' import { shell } from 'electron' import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config' +import { + ORCA_CLOUD_CALLBACK_RESPONSE_HEADERS, + ORCA_CLOUD_CALLBACK_SUCCESS_PAGE +} from './profile-cloud-callback-page' export type OrcaCloudAuthorizationCode = { code: string @@ -102,8 +106,8 @@ export function beginOrcaCloudPkceFlow( writeInvalidCallback(response) return } - response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) - response.end('Orca

You can return to Orca.

') + response.writeHead(200, ORCA_CLOUD_CALLBACK_RESPONSE_HEADERS) + response.end(ORCA_CLOUD_CALLBACK_SUCCESS_PAGE) resolveFlow(code) } catch (error) { rejectFlow(error instanceof Error ? error : new Error('orca_cloud_auth_callback_failed')) diff --git a/src/main/orca-profiles/profile-cloud-service.ts b/src/main/orca-profiles/profile-cloud-service.ts index df735a81220..e17b3d2c6f1 100644 --- a/src/main/orca-profiles/profile-cloud-service.ts +++ b/src/main/orca-profiles/profile-cloud-service.ts @@ -3,24 +3,21 @@ import type { CreateCloudLinkedOrcaProfileArgs, CreateCloudLinkedOrcaProfileResult, OrcaProfileAuthStatus, - RefreshCurrentOrcaProfileAuthResult, SelectOrcaProfileOrgResult, SignOutCurrentOrcaProfileResult } from '../../shared/orca-profiles' -import { ensureActiveOrcaProfile, getOrcaProfileListState } from './profile-index-store' +import { ensureActiveOrcaProfile } from './profile-index-store' import { getOrcaCloudAuthConfig, isOrcaCloudDevAuthEnabled } from './profile-cloud-auth-config' import { clearOrcaCloudSession, readOrcaCloudSession, - saveOrcaCloudSession, saveOrcaCloudSessionExchange } from './profile-cloud-session-store' +import { cloudSessionIdentity, tombstoneCloudSession } from './profile-cloud-session-mutation' import { createOrcaCloudProfile, exchangeOrcaCloudAuthCode, - refreshOrcaCloudCapabilities, - revokeOrcaCloudSession, - selectOrcaCloudOrg + revokeOrcaCloudSession } from './profile-cloud-client' import { beginOrcaCloudPkceFlow } from './profile-cloud-pkce' import { @@ -32,10 +29,12 @@ import { runWithFreshOrcaCloudSession } from './profile-cloud-session-refresh' import { connectDevOrcaCloudProfile, createDevCloudLinkedOrcaProfile, - refreshDevOrcaCloudProfile, selectDevOrcaCloudOrg } from './profile-cloud-dev-service' import { getOrcaProfileAuthStatusFromProfile } from './profile-cloud-auth-status' +import { selectCloudOrgWithMutationFence } from './profile-cloud-org-selection' + +export { refreshCurrentOrcaProfileAuth } from './profile-cloud-capability-refresh' function isUserCancelledAuthError(message: string): boolean { return message === 'orca_cloud_auth_timeout' || message === 'orca_cloud_auth_denied' @@ -110,6 +109,14 @@ export async function signOutCurrentOrcaProfile( const active = ensureActiveOrcaProfile(userDataPath) const configState = getOrcaCloudAuthConfig() const session = readOrcaCloudSession(active.profile.id, userDataPath) + if (active.profile.cloud) { + // Why: persist the destructive fence before logout network I/O so a + // refresh already in flight cannot save after explicit sign-out. + tombstoneCloudSession( + cloudSessionIdentity(active.profile.id, active.profile.cloud), + userDataPath + ) + } if (!isOrcaCloudDevAuthEnabled() && configState.configured && session.status === 'found') { await revokeOrcaCloudSession(configState.config, session.session).catch(() => undefined) } @@ -179,68 +186,6 @@ export async function createCloudLinkedOrcaProfile( } } -export async function refreshCurrentOrcaProfileAuth( - userDataPath: string -): Promise { - const active = ensureActiveOrcaProfile(userDataPath) - if (!active.profile.cloud) { - return { status: 'local', auth: activeAuth(active, userDataPath) } - } - if (isOrcaCloudDevAuthEnabled()) { - const result = refreshDevOrcaCloudProfile(active, userDataPath) - if (result.status !== 'updated') { - return { status: 'reconnect-required', auth: getCurrentOrcaProfileAuthStatus(userDataPath) } - } - return { - status: 'refreshed', - auth: getCurrentOrcaProfileAuthStatus(userDataPath), - activeProfileId: result.list.activeProfileId, - profiles: result.list.profiles - } - } - - const configState = getOrcaCloudAuthConfig() - if (!configState.configured) { - return { status: 'unconfigured', auth: activeAuth(active, userDataPath) } - } - try { - const operation = await runWithFreshOrcaCloudSession( - configState.config, - active, - userDataPath, - (session) => refreshOrcaCloudCapabilities(configState.config, session) - ) - if (operation.status !== 'ok') { - return { status: 'reconnect-required', auth: getCurrentOrcaProfileAuthStatus(userDataPath) } - } - const refresh = operation.value - const session = readOrcaCloudSession(active.profile.id, userDataPath) - if (session.status !== 'found') { - return { status: 'reconnect-required', auth: getCurrentOrcaProfileAuthStatus(userDataPath) } - } - saveOrcaCloudSession(active.profile.id, userDataPath, { - ...session.session, - organizations: refresh.organizations ?? session.session.organizations, - capabilities: refresh.capabilities - }) - const list = refresh.cloud - ? linkOrcaProfileToCloud(active.profile.id, refresh.cloud, userDataPath) - : getOrcaProfileListState(userDataPath) - return { - status: 'refreshed', - auth: getCurrentOrcaProfileAuthStatus(userDataPath), - activeProfileId: list.activeProfileId, - profiles: list.profiles - } - } catch (error) { - return { - status: 'failed', - auth: getCurrentOrcaProfileAuthStatus(userDataPath), - error: error instanceof Error ? error.message : String(error) - } - } -} - export async function selectCurrentOrcaProfileOrg( userDataPath: string, orgId: string @@ -264,26 +209,15 @@ export async function selectCurrentOrcaProfileOrg( return { status: 'unconfigured', auth: activeAuth(active, userDataPath) } } try { - const operation = await runWithFreshOrcaCloudSession( - configState.config, + const list = await selectCloudOrgWithMutationFence({ + config: configState.config, active, userDataPath, - (session) => selectOrcaCloudOrg(configState.config, session, orgId) - ) - if (operation.status !== 'ok') { - return { status: 'reconnect-required', auth: activeAuth(active, userDataPath) } - } - const selected = operation.value - const session = readOrcaCloudSession(active.profile.id, userDataPath) - if (session.status !== 'found') { - return { status: 'reconnect-required', auth: activeAuth(active, userDataPath) } - } - saveOrcaCloudSession(active.profile.id, userDataPath, { - ...session.session, - organizations: selected.organizations ?? session.session.organizations, - capabilities: selected.capabilities + orgId }) - const list = linkOrcaProfileToCloud(active.profile.id, selected.cloud, userDataPath) + if (!list) { + return { status: 'reconnect-required', auth: activeAuth(active, userDataPath) } + } return { status: 'selected', auth: getCurrentOrcaProfileAuthStatus(userDataPath), diff --git a/src/main/orca-profiles/profile-cloud-session-mutation.test.ts b/src/main/orca-profiles/profile-cloud-session-mutation.test.ts new file mode 100644 index 00000000000..a8196a7f1a3 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-session-mutation.test.ts @@ -0,0 +1,62 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + captureCloudSessionMutation, + isCloudSessionMutationCurrent, + recordCloudSessionIdentityMutation, + recordSuccessfulCloudSessionLogin, + tombstoneCloudSession, + type CloudSessionIdentity +} from './profile-cloud-session-mutation' + +describe('cloud session mutation fence', () => { + let userDataPath: string + const identity: CloudSessionIdentity = { + localProfileId: 'local-1', + cloudUserId: 'user-1', + cloudProfileId: 'profile-1', + organizationId: 'org-1' + } + + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-cloud-session-mutation-')) + }) + + afterEach(() => rmSync(userDataPath, { recursive: true, force: true })) + + it('invalidates a captured refresh before destructive sign-out', () => { + const snapshot = captureCloudSessionMutation(identity, userDataPath) + expect(isCloudSessionMutationCurrent(identity.localProfileId, userDataPath, snapshot)).toBe( + true + ) + tombstoneCloudSession(identity, userDataPath) + expect(isCloudSessionMutationCurrent(identity.localProfileId, userDataPath, snapshot)).toBe( + false + ) + }) + + it('clears only the matching tombstone after explicit successful login', () => { + tombstoneCloudSession(identity, userDataPath) + const login = recordSuccessfulCloudSessionLogin(identity, userDataPath) + expect(isCloudSessionMutationCurrent(identity.localProfileId, userDataPath, login)).toBe(true) + }) + + it('invalidates old work when the expected org changes without tombstoning either identity', () => { + const old = captureCloudSessionMutation(identity, userDataPath) + const next = recordCloudSessionIdentityMutation( + { ...identity, organizationId: 'org-2' }, + userDataPath + ) + expect(isCloudSessionMutationCurrent(identity.localProfileId, userDataPath, old)).toBe(false) + expect(isCloudSessionMutationCurrent(identity.localProfileId, userDataPath, next)).toBe(true) + }) + + it('persists the fence across module-independent reads', () => { + const snapshot = recordSuccessfulCloudSessionLogin(identity, userDataPath) + expect(isCloudSessionMutationCurrent(identity.localProfileId, userDataPath, snapshot)).toBe( + true + ) + }) +}) diff --git a/src/main/orca-profiles/profile-cloud-session-mutation.ts b/src/main/orca-profiles/profile-cloud-session-mutation.ts new file mode 100644 index 00000000000..d360cb72ab9 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-session-mutation.ts @@ -0,0 +1,175 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { writeSecureJsonFile } from '../../shared/secure-file' +import type { OrcaProfileCloudSummary } from '../../shared/orca-profiles' +import { getOrcaProfileDirectory } from './profile-storage-paths' + +const MUTATION_STATE_VERSION = 1 + +export type CloudSessionIdentity = { + localProfileId: string + cloudUserId: string + cloudProfileId: string + organizationId: string +} + +export type CloudSessionMutationSnapshot = { + epoch: number + identityKey: string +} + +type CloudSessionMutationState = { + version: 1 + epoch: number + expectedIdentityKey: string + tombstonedIdentityKeys: string[] +} + +function identityKey(identity: CloudSessionIdentity): string { + return `${identity.localProfileId}\0${identity.cloudUserId}\0${identity.cloudProfileId}\0${identity.organizationId}` +} + +function statePath(profileId: string, userDataPath: string): string { + return join(getOrcaProfileDirectory(profileId, userDataPath), 'account-session-mutation.json') +} + +function isState(value: unknown): value is CloudSessionMutationState { + if (!value || typeof value !== 'object') { + return false + } + const candidate = value as Partial + return ( + candidate.version === MUTATION_STATE_VERSION && + Number.isSafeInteger(candidate.epoch) && + Number(candidate.epoch) >= 0 && + typeof candidate.expectedIdentityKey === 'string' && + Array.isArray(candidate.tombstonedIdentityKeys) && + candidate.tombstonedIdentityKeys.every((key) => typeof key === 'string') + ) +} + +function readState(profileId: string, userDataPath: string): CloudSessionMutationState | null { + const path = statePath(profileId, userDataPath) + if (!existsSync(path)) { + return null + } + try { + const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8')) + if (!isState(parsed)) { + throw new Error('invalid_cloud_session_mutation_state') + } + return parsed + } catch { + throw new Error('invalid_cloud_session_mutation_state') + } +} + +function saveState( + profileId: string, + userDataPath: string, + state: CloudSessionMutationState +): void { + writeSecureJsonFile(statePath(profileId, userDataPath), state) +} + +export function cloudSessionIdentity( + localProfileId: string, + cloud: OrcaProfileCloudSummary +): CloudSessionIdentity { + return { + localProfileId, + cloudUserId: cloud.userId, + cloudProfileId: cloud.cloudProfileId, + organizationId: cloud.activeOrgId ?? '' + } +} + +export function captureCloudSessionMutation( + identity: CloudSessionIdentity, + userDataPath: string +): CloudSessionMutationSnapshot { + const key = identityKey(identity) + let state = readState(identity.localProfileId, userDataPath) + if (!state) { + state = { + version: MUTATION_STATE_VERSION, + epoch: 0, + expectedIdentityKey: key, + tombstonedIdentityKeys: [] + } + saveState(identity.localProfileId, userDataPath, state) + } + return { epoch: state.epoch, identityKey: key } +} + +export function recordSuccessfulCloudSessionLogin( + identity: CloudSessionIdentity, + userDataPath: string +): CloudSessionMutationSnapshot { + const key = identityKey(identity) + const previous = readState(identity.localProfileId, userDataPath) + const state: CloudSessionMutationState = { + version: MUTATION_STATE_VERSION, + epoch: (previous?.epoch ?? -1) + 1, + expectedIdentityKey: key, + tombstonedIdentityKeys: (previous?.tombstonedIdentityKeys ?? []).filter( + (candidate) => candidate !== key + ) + } + saveState(identity.localProfileId, userDataPath, state) + return { epoch: state.epoch, identityKey: key } +} + +export function recordCloudSessionIdentityMutation( + identity: CloudSessionIdentity, + userDataPath: string +): CloudSessionMutationSnapshot { + const key = identityKey(identity) + const previous = readState(identity.localProfileId, userDataPath) + const state: CloudSessionMutationState = { + version: MUTATION_STATE_VERSION, + epoch: (previous?.epoch ?? -1) + 1, + expectedIdentityKey: key, + tombstonedIdentityKeys: previous?.tombstonedIdentityKeys ?? [] + } + saveState(identity.localProfileId, userDataPath, state) + return { epoch: state.epoch, identityKey: key } +} + +export function tombstoneCloudSession(identity: CloudSessionIdentity, userDataPath: string): void { + const key = identityKey(identity) + const previous = readState(identity.localProfileId, userDataPath) + const tombstones = new Set(previous?.tombstonedIdentityKeys ?? []) + tombstones.add(key) + saveState(identity.localProfileId, userDataPath, { + version: MUTATION_STATE_VERSION, + epoch: (previous?.epoch ?? -1) + 1, + expectedIdentityKey: key, + tombstonedIdentityKeys: [...tombstones] + }) +} + +export function isCloudSessionMutationCurrent( + profileId: string, + userDataPath: string, + snapshot: CloudSessionMutationSnapshot +): boolean { + const state = readState(profileId, userDataPath) + return Boolean( + state && + state.epoch === snapshot.epoch && + state.expectedIdentityKey === snapshot.identityKey && + !state.tombstonedIdentityKeys.includes(snapshot.identityKey) + ) +} + +export function recordCloudSessionIdentityMutationIfCurrent( + identity: CloudSessionIdentity, + userDataPath: string, + snapshot: CloudSessionMutationSnapshot +): CloudSessionMutationSnapshot | null { + if (!isCloudSessionMutationCurrent(identity.localProfileId, userDataPath, snapshot)) { + return null + } + return recordCloudSessionIdentityMutation(identity, userDataPath) +} diff --git a/src/main/orca-profiles/profile-cloud-session-refresh.test.ts b/src/main/orca-profiles/profile-cloud-session-refresh.test.ts new file mode 100644 index 00000000000..42b665d4e35 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-session-refresh.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config' +import type * as ProfileCloudClient from './profile-cloud-client' +import type { ActiveOrcaProfileState } from './profile-index-store' + +const { readMock, saveIfCurrentMock, clearMock, refreshMock, linkMock } = vi.hoisted(() => ({ + readMock: vi.fn(), + saveIfCurrentMock: vi.fn((): string | null => 'memory-only'), + clearMock: vi.fn(), + refreshMock: vi.fn(), + linkMock: vi.fn() +})) + +vi.mock('./profile-cloud-session-store', () => ({ + readOrcaCloudSession: readMock, + saveOrcaCloudSessionIfCurrent: saveIfCurrentMock, + clearOrcaCloudSession: clearMock +})) + +vi.mock('./profile-cloud-session-mutation', () => ({ + captureCloudSessionMutation: vi.fn(() => ({ epoch: 1, identityKey: 'identity' })), + cloudSessionIdentity: vi.fn((localProfileId, cloud) => ({ + localProfileId, + cloudUserId: cloud.userId, + cloudProfileId: cloud.cloudProfileId, + organizationId: cloud.activeOrgId ?? '' + })), + tombstoneCloudSession: vi.fn() +})) + +vi.mock('./profile-cloud-client', async (importOriginal) => { + const original = await importOriginal() + return { ...original, refreshOrcaCloudSession: refreshMock } +}) + +vi.mock('./profile-cloud-index', () => ({ linkOrcaProfileToCloud: linkMock })) + +import { readFreshOrcaCloudSession } from './profile-cloud-session-refresh' + +const config = {} as OrcaCloudAuthConfig +const active = { + profile: { + id: 'profile-1', + cloud: { + userId: 'user-1', + cloudProfileId: 'cloud-profile-1', + activeOrgId: 'org-1' + } + } +} as ActiveOrcaProfileState +const staleSession = { + accessToken: 'old-access', + refreshToken: 'one-use-refresh', + expiresAt: 1, + organizations: [], + capabilities: { flags: {}, refreshedAt: 1 } +} + +describe('profile cloud session refresh', () => { + beforeEach(() => { + vi.clearAllMocks() + saveIfCurrentMock.mockReturnValue('memory-only') + readMock.mockReturnValue({ status: 'found', session: staleSession, persistence: 'memory-only' }) + }) + + it('does not publish a refresh whose persistent mutation snapshot became stale', async () => { + let resolveRefresh!: (value: Record) => void + refreshMock.mockReturnValue(new Promise((resolve) => (resolveRefresh = resolve))) + const refreshing = readFreshOrcaCloudSession(config, active, '/data') + saveIfCurrentMock.mockReturnValue(null) + resolveRefresh({ + accessToken: 'stale-access', + refreshToken: 'stale-refresh', + expiresAt: Date.now() + 600_000, + organizations: [], + capabilities: { flags: { 'relay.use': true }, refreshedAt: 2 }, + cloud: { + userId: 'user-1', + cloudProfileId: 'cloud-profile-1', + activeOrgId: 'org-1' + } + }) + await expect(refreshing).rejects.toThrow('stale_cloud_session_mutation') + expect(linkMock).not.toHaveBeenCalled() + }) + + it('single-flights concurrent rotating refresh-token use per profile and store', async () => { + let resolveRefresh!: (value: Record) => void + refreshMock.mockReturnValue(new Promise((resolve) => (resolveRefresh = resolve))) + + const first = readFreshOrcaCloudSession(config, active, '/data') + const second = readFreshOrcaCloudSession(config, active, '/data') + expect(refreshMock).toHaveBeenCalledTimes(1) + + resolveRefresh({ + accessToken: 'new-access', + refreshToken: 'new-refresh', + expiresAt: Date.now() + 600_000, + organizations: [], + capabilities: { flags: { 'relay.use': true }, refreshedAt: 2 }, + cloud: { + userId: 'user-1', + cloudProfileId: 'cloud-profile-1', + activeOrgId: 'org-1' + } + }) + + const [firstResult, secondResult] = await Promise.all([first, second]) + expect(firstResult).toEqual(secondResult) + expect(saveIfCurrentMock).toHaveBeenCalledTimes(1) + expect(linkMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/orca-profiles/profile-cloud-session-refresh.ts b/src/main/orca-profiles/profile-cloud-session-refresh.ts index 404cba47bd4..ced48a16e7b 100644 --- a/src/main/orca-profiles/profile-cloud-session-refresh.ts +++ b/src/main/orca-profiles/profile-cloud-session-refresh.ts @@ -4,10 +4,15 @@ import { clearOrcaCloudSession, type OrcaCloudSession, readOrcaCloudSession, - saveOrcaCloudSession + saveOrcaCloudSessionIfCurrent } from './profile-cloud-session-store' import { OrcaCloudRequestError, refreshOrcaCloudSession } from './profile-cloud-client' import { linkOrcaProfileToCloud } from './profile-cloud-index' +import { + captureCloudSessionMutation, + cloudSessionIdentity, + tombstoneCloudSession +} from './profile-cloud-session-mutation' const CLOUD_SESSION_REFRESH_SKEW_MS = 60_000 @@ -31,6 +36,12 @@ export function isOrcaCloudAuthFailure(error: unknown): boolean { const inflightCloudSessionRefreshes = new Map>() +class StaleCloudSessionMutationError extends Error { + constructor() { + super('stale_cloud_session_mutation') + } +} + function cloudSessionRefreshKey(profileId: string, userDataPath: string): string { return `${userDataPath}\0${profileId}` } @@ -41,12 +52,19 @@ function cloudSessionRefreshKey(profileId: string, userDataPath: string): string function clearCloudSessionIfUnchanged( profileId: string, userDataPath: string, - failed: OrcaCloudSession + failed: OrcaCloudSession, + active: ActiveOrcaProfileState ): void { const current = readOrcaCloudSession(profileId, userDataPath) if (current.status === 'found' && current.session.refreshToken !== failed.refreshToken) { return } + if (active.profile.cloud) { + tombstoneCloudSession( + cloudSessionIdentity(active.profile.id, active.profile.cloud), + userDataPath + ) + } clearOrcaCloudSession(profileId, userDataPath) } @@ -70,7 +88,20 @@ async function refreshStoredCloudSession( // Another caller already rotated this session; reuse its result. return current.session } + if (!active.profile.cloud) { + throw new StaleCloudSessionMutationError() + } + const expectedIdentity = cloudSessionIdentity(active.profile.id, active.profile.cloud) + const snapshot = captureCloudSessionMutation(expectedIdentity, userDataPath) const refreshed = await refreshOrcaCloudSession(config, session) + const refreshedIdentity = cloudSessionIdentity(active.profile.id, refreshed.cloud) + if ( + refreshedIdentity.cloudUserId !== expectedIdentity.cloudUserId || + refreshedIdentity.cloudProfileId !== expectedIdentity.cloudProfileId || + refreshedIdentity.organizationId !== expectedIdentity.organizationId + ) { + throw new StaleCloudSessionMutationError() + } const nextSession = { accessToken: refreshed.accessToken, refreshToken: refreshed.refreshToken, @@ -78,7 +109,11 @@ async function refreshStoredCloudSession( organizations: refreshed.organizations, capabilities: refreshed.capabilities } - saveOrcaCloudSession(active.profile.id, userDataPath, nextSession) + if ( + saveOrcaCloudSessionIfCurrent(active.profile.id, userDataPath, nextSession, snapshot) === null + ) { + throw new StaleCloudSessionMutationError() + } linkOrcaProfileToCloud(active.profile.id, refreshed.cloud, userDataPath) return nextSession })() @@ -109,7 +144,7 @@ export async function readFreshOrcaCloudSession( } } catch (error) { if (isOrcaCloudAuthFailure(error)) { - clearCloudSessionIfUnchanged(active.profile.id, userDataPath, session.session) + clearCloudSessionIfUnchanged(active.profile.id, userDataPath, session.session, active) return { status: 'reconnect-required' } } throw error @@ -129,7 +164,7 @@ export async function forceRefreshOrcaCloudSession( } } catch (error) { if (isOrcaCloudAuthFailure(error)) { - clearCloudSessionIfUnchanged(active.profile.id, userDataPath, session) + clearCloudSessionIfUnchanged(active.profile.id, userDataPath, session, active) return { status: 'reconnect-required' } } throw error @@ -169,7 +204,7 @@ export async function runWithFreshOrcaCloudSession( // the user out for it would destroy a valid session, so let it surface // as a failed operation instead. if (retryError instanceof OrcaCloudRequestError && retryError.statusCode === 401) { - clearCloudSessionIfUnchanged(active.profile.id, userDataPath, refreshed.session) + clearCloudSessionIfUnchanged(active.profile.id, userDataPath, refreshed.session, active) return { status: 'reconnect-required' } } throw retryError diff --git a/src/main/orca-profiles/profile-cloud-session-store.ts b/src/main/orca-profiles/profile-cloud-session-store.ts index a8914b8f7a3..62d779772c1 100644 --- a/src/main/orca-profiles/profile-cloud-session-store.ts +++ b/src/main/orca-profiles/profile-cloud-session-store.ts @@ -10,6 +10,12 @@ import type { import { getOrcaProfileDirectory } from './profile-storage-paths' import { allowsPlaintextOrcaCloudSession } from './profile-cloud-auth-config' import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange' +import { + cloudSessionIdentity, + isCloudSessionMutationCurrent, + recordSuccessfulCloudSessionLogin, + type CloudSessionMutationSnapshot +} from './profile-cloud-session-mutation' export type OrcaCloudSession = { accessToken: string @@ -135,6 +141,7 @@ export function saveOrcaCloudSessionExchange( userDataPath: string, exchange: OrcaCloudSessionExchangeResponse ): OrcaCloudSessionPersistence { + recordSuccessfulCloudSessionLogin(cloudSessionIdentity(profileId, exchange.cloud), userDataPath) return saveOrcaCloudSession(profileId, userDataPath, { accessToken: exchange.accessToken, refreshToken: exchange.refreshToken, @@ -144,6 +151,20 @@ export function saveOrcaCloudSessionExchange( }) } +export function saveOrcaCloudSessionIfCurrent( + profileId: string, + userDataPath: string, + session: OrcaCloudSession, + snapshot: CloudSessionMutationSnapshot +): OrcaCloudSessionPersistence | null { + // Why: the check and sync save share one main-process turn, so an async + // refresh captured before sign-out/org-switch cannot resurrect the session. + if (!isCloudSessionMutationCurrent(profileId, userDataPath, snapshot)) { + return null + } + return saveOrcaCloudSession(profileId, userDataPath, session) +} + export function readOrcaCloudSession( profileId: string, userDataPath: string diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index e2144d87c16..df4d58274ea 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -8,6 +8,8 @@ import { join } from 'node:path' import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file' import type { DeviceScope } from '../../shared/runtime-types' import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files' +import type { RelayDeviceBinding } from './relay/relay-revoke-outbox' +import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' export type { DeviceScope } @@ -18,6 +20,27 @@ export type DeviceEntry = { scope: DeviceScope pairedAt: number lastSeenAt: number + relayBinding?: RelayDeviceBinding + mobilePairingConnectionMode?: MobilePairingConnectionMode +} + +function validRelayBinding(value: unknown, deviceId: string): RelayDeviceBinding | undefined { + if (!value || typeof value !== 'object') { + return undefined + } + const binding = value as Partial + return binding.relayDeviceId === deviceId && + typeof binding.relayHostId === 'string' && + typeof binding.ownerIdentityKey === 'string' + ? { + relayHostId: binding.relayHostId, + relayDeviceId: binding.relayDeviceId, + ownerIdentityKey: binding.ownerIdentityKey, + ...(typeof binding.inviteExpiresAt === 'number' && Number.isFinite(binding.inviteExpiresAt) + ? { inviteExpiresAt: binding.inviteExpiresAt } + : {}) + } + : undefined } export class DeviceRegistry { @@ -82,6 +105,40 @@ export class DeviceRegistry { return this.devices.find((d) => d.deviceId === deviceId) ?? null } + getPendingDevice(scope: DeviceScope = 'mobile'): DeviceEntry | null { + return this.devices.find((device) => device.lastSeenAt === 0 && device.scope === scope) ?? null + } + + setRelayBinding(deviceId: string, binding: RelayDeviceBinding): boolean { + const device = this.devices.find((candidate) => candidate.deviceId === deviceId) + if (!device || binding.relayDeviceId !== deviceId) { + return false + } + device.relayBinding = binding + this.save() + return true + } + + setMobilePairingConnectionMode(deviceId: string, mode: MobilePairingConnectionMode): boolean { + const device = this.devices.find((candidate) => candidate.deviceId === deviceId) + if (!device || device.scope !== 'mobile') { + return false + } + device.mobilePairingConnectionMode = mode + this.save() + return true + } + + getMobilePairingConnectionMode(deviceId: string): MobilePairingConnectionMode | null { + const device = this.devices.find((candidate) => candidate.deviceId === deviceId) + if (!device || device.scope !== 'mobile') { + return null + } + // Why: pairings created before this preference existed used automatic + // direct-first Relay fallback, so missing state must preserve that behavior. + return device.mobilePairingConnectionMode === 'local-only' ? 'local-only' : 'automatic' + } + listDevices(): readonly DeviceEntry[] { return this.devices } @@ -110,7 +167,10 @@ export class DeviceRegistry { ...device, // Why: older registries only existed for phone pairing. Treat missing // scope as mobile so legacy device tokens do not gain new CLI powers. - scope: device.scope === 'runtime' ? 'runtime' : 'mobile' + scope: device.scope === 'runtime' ? 'runtime' : 'mobile', + relayBinding: validRelayBinding(device.relayBinding, device.deviceId), + mobilePairingConnectionMode: + device.mobilePairingConnectionMode === 'local-only' ? 'local-only' : 'automatic' })) } catch { this.devices = [] diff --git a/src/main/runtime/relay/desktop-relay-service.test.ts b/src/main/runtime/relay/desktop-relay-service.test.ts new file mode 100644 index 00000000000..a69c5a74fa6 --- /dev/null +++ b/src/main/runtime/relay/desktop-relay-service.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import type { MobilePairingConnectionContext } from '../runtime-rpc' +import { DesktopRelayService, pairingAuthorizationForContext } from './desktop-relay-service' + +const relayHostId = 'AbCdEf0123_-xyZ9' + +function context( + transport: MobilePairingConnectionContext['transport'] +): MobilePairingConnectionContext { + return { deviceId: 'device-1', connectionId: 'e2ee-connection-1', transport } +} + +describe('pairingAuthorizationForContext', () => { + it('derives direct authorization only from the authenticated connection', () => { + expect(pairingAuthorizationForContext(context({ transport: 'direct' }), relayHostId)).toEqual({ + mode: 'authenticated-direct', + directAuthId: 'e2ee-connection-1' + }) + }) + + it('derives invite authorization only from immutable relay metadata', () => { + expect( + pairingAuthorizationForContext( + context({ + transport: 'relay', + relayHostId, + relayDeviceId: 'device-1', + basisConnId: 'relay-basis-1', + credentialKind: 'invite' + }), + relayHostId + ) + ).toEqual({ mode: 'relay-basis', basisConnId: 'relay-basis-1' }) + }) + + it('reserves resume metadata for confirmation and rejects stale hosts', () => { + expect( + pairingAuthorizationForContext( + context({ + transport: 'relay', + relayHostId, + relayDeviceId: 'device-1', + basisConnId: 'resume-basis-1', + credentialKind: 'resume' + }), + relayHostId + ) + ).toBeNull() + expect(() => + pairingAuthorizationForContext( + context({ + transport: 'relay', + relayHostId: 'stale-host-id-1', + relayDeviceId: 'device-1', + basisConnId: 'relay-basis-1', + credentialKind: 'invite' + }), + relayHostId + ) + ).toThrow('stale_relay_connection') + }) +}) + +describe('local-only mobile pairing', () => { + it('refuses endpoint discovery and provisioning without opening Relay demand', async () => { + const registry = { + getDevice: () => ({ deviceId: 'device-1', scope: 'mobile' }), + getMobilePairingConnectionMode: () => 'local-only' + } + const service = Object.create(DesktopRelayService.prototype) as DesktopRelayService + Object.defineProperty(service, 'runtimeRpc', { + value: { getDeviceRegistry: () => registry } + }) + + await expect(service.getEndpoints(context({ transport: 'direct' }), {})).resolves.toEqual({ + v: 1, + relay: null + }) + await expect( + service.provisionRelay(context({ transport: 'direct' }), { + reqId: 'install-1', + newResumeTokenHash: 'A'.repeat(43) + }) + ).rejects.toThrow('relay_disabled_for_device') + }) +}) diff --git a/src/main/runtime/relay/desktop-relay-service.ts b/src/main/runtime/relay/desktop-relay-service.ts new file mode 100644 index 00000000000..c52d80a8fe4 --- /dev/null +++ b/src/main/runtime/relay/desktop-relay-service.ts @@ -0,0 +1,305 @@ +import type { OrcaCloudAuthConfig } from '../../orca-profiles/profile-cloud-auth-config' +import type { MobilePairingConnectionContext, OrcaRuntimeRpcServer } from '../runtime-rpc' +import type { + DeviceCredentialInstalled, + PairingGetEndpointsParams, + PairingGetEndpointsResult, + PairingProvisionRelayParams +} from '../../../shared/mobile-relay-credential-contract' +import { readRelayAuthContext } from './relay-auth-context' +import { RelayAuthCoordinator } from './relay-auth-coordinator' +import { RelaySessionBroker, type RelayBrokerStatus } from './relay-session-broker' +import type { PairingRelay } from '../../../shared/mobile-relay-pairing-offer' +import type { + RelayRevokeOutbox, + RelayDeviceBinding, + RelayRevokeOutboxItem +} from './relay-revoke-outbox' +import type { DeviceCredentialInstallAuthorization } from './relay-control-requests' +import { deriveRelayHostId } from './relay-http-client' +import { RelayDemandLedger } from './relay-demand-ledger' + +type DesktopRelayServiceOptions = { + authConfig: OrcaCloudAuthConfig + userDataPath: string + appVersion: string + runtimeRpc: OrcaRuntimeRpcServer + onStatus: (status: RelayBrokerStatus) => void +} + +export function pairingAuthorizationForContext( + context: MobilePairingConnectionContext, + relayHostId: string +): DeviceCredentialInstallAuthorization | null { + if (context.transport.transport === 'direct') { + return { mode: 'authenticated-direct', directAuthId: context.connectionId } + } + if (context.transport.relayHostId !== relayHostId) { + throw new Error('stale_relay_connection') + } + return context.transport.credentialKind === 'invite' + ? { mode: 'relay-basis', basisConnId: context.transport.basisConnId } + : null +} + +export class DesktopRelayService { + private readonly coordinator: RelayAuthCoordinator + private readonly revokeOutbox: RelayRevokeOutbox + private readonly runtimeRpc: OrcaRuntimeRpcServer + private readonly demandLedger: RelayDemandLedger + private demandExpiryTimer: ReturnType | null = null + private stopped = false + + constructor(options: DesktopRelayServiceOptions) { + const keypair = options.runtimeRpc.getE2EEKeypair() + const mobileSocketWiring = options.runtimeRpc.getMobileSocketWiring() + if (!keypair || !mobileSocketWiring) { + throw new Error('mobile_runtime_not_ready') + } + this.runtimeRpc = options.runtimeRpc + this.revokeOutbox = options.runtimeRpc.getRelayRevokeOutbox() + this.demandLedger = new RelayDemandLedger({ + deviceRegistry: options.runtimeRpc.getDeviceRegistry()!, + revokeOutbox: this.revokeOutbox, + relayHostId: deriveRelayHostId(keypair.publicKey) + }) + this.coordinator = new RelayAuthCoordinator({ + readContext: () => readRelayAuthContext(options.authConfig, options.userDataPath), + hasDemand: ({ identity }) => + this.demandLedger.hasDemand( + `${identity.userId}\0${identity.profileId}\0${identity.organizationId}` + ), + openBroker: async ({ context, isCurrent, refreshAccessToken }) => { + const broker = await RelaySessionBroker.connect({ + authConfig: options.authConfig, + accessToken: context.accessToken, + identity: context.identity, + keypair, + appVersion: options.appVersion, + mobileSocketWiring, + isCurrent, + refreshAccessToken, + onStatus: options.onStatus + }) + void this.flushRevokeOutbox(broker) + return broker + }, + onStatus: options.onStatus + }) + } + + start(): void { + this.refreshDemand() + } + + authMutated(): void { + this.refreshDemand() + } + + fenceAndCloseNow(): void { + this.coordinator.fenceAndCloseNow() + } + + async createPairingRelay( + relayDeviceId: string + ): Promise<{ relay: PairingRelay; binding: RelayDeviceBinding }> { + return await this.withTransientDemand(`pairing:${relayDeviceId}`, async () => { + const broker = await this.requireActiveBroker() + const relay = await broker.createPairingRelay(relayDeviceId) + return { + relay, + binding: { + relayHostId: broker.hostId, + relayDeviceId, + ownerIdentityKey: broker.ownerIdentityKey, + inviteExpiresAt: relay.inviteExpiresAt + } + } + }) + } + + onDeviceRevokeQueued(item: RelayRevokeOutboxItem): void { + this.refreshDemand() + const broker = this.coordinator.getActiveBroker() + if ( + broker instanceof RelaySessionBroker && + broker.hostId === item.relayHostId && + broker.ownerIdentityKey === item.ownerIdentityKey + ) { + void this.flushRevoke(broker, item) + } + } + + async getEndpoints( + context: MobilePairingConnectionContext, + params: PairingGetEndpointsParams + ): Promise { + this.requireMobileDevice(context.deviceId) + if ( + this.runtimeRpc.getDeviceRegistry()?.getMobilePairingConnectionMode(context.deviceId) === + 'local-only' + ) { + return { v: 1, relay: null } + } + return await this.withTransientDemand(`endpoints:${context.deviceId}`, async () => { + const broker = await this.activeBrokerForDemand() + if (!broker?.endpoint) { + return { v: 1, relay: null } + } + this.assertRelayHost(context, broker) + const result: PairingGetEndpointsResult = { v: 1, relay: broker.endpoint } + if (params.installReqId) { + result.installStatus = await broker.credentialInstallStatus( + context.deviceId, + params.installReqId + ) + } + if (params.resumeConfirmReqId) { + if ( + context.transport.transport !== 'relay' || + context.transport.credentialKind !== 'resume' + ) { + throw new Error('resume_confirmation_unavailable') + } + result.resumeConfirmation = await broker.confirmResume( + context.transport.basisConnId, + params.resumeConfirmReqId + ) + } + return result + }) + } + + async provisionRelay( + context: MobilePairingConnectionContext, + params: PairingProvisionRelayParams + ): Promise { + this.requireMobileDevice(context.deviceId) + if ( + this.runtimeRpc.getDeviceRegistry()?.getMobilePairingConnectionMode(context.deviceId) === + 'local-only' + ) { + throw new Error('relay_disabled_for_device') + } + return await this.withTransientDemand(`provision:${context.deviceId}`, async () => { + const broker = await this.requireActiveBroker() + if (!broker.endpoint) { + throw new Error('relay_control_not_active') + } + this.assertRelayHost(context, broker) + const authorization = pairingAuthorizationForContext(context, broker.hostId) + if (!authorization) { + // Why: a resume splice proves renewal through confirmation; it cannot be + // repurposed as either of the two initial-install authorization modes. + throw new Error('relay_provision_authorization_unavailable') + } + if ( + !this.runtimeRpc.setMobileRelayBinding(context.deviceId, { + relayHostId: broker.hostId, + relayDeviceId: context.deviceId, + ownerIdentityKey: broker.ownerIdentityKey + }) + ) { + throw new Error('mobile_device_not_found') + } + this.refreshDemand() + return await broker.installCredential(context.deviceId, params, authorization) + }) + } + + demandStateChanged(): void { + this.refreshDemand() + } + + stop(): void { + this.stopped = true + if (this.demandExpiryTimer) { + clearTimeout(this.demandExpiryTimer) + this.demandExpiryTimer = null + } + this.coordinator.stop() + } + + private async flushRevokeOutbox(broker: RelaySessionBroker): Promise { + for (const item of this.revokeOutbox.pendingFor(broker.ownerIdentityKey, broker.hostId)) { + await this.flushRevoke(broker, item) + } + } + + private requireMobileDevice(deviceId: string): void { + if (this.runtimeRpc.getDeviceRegistry()?.getDevice(deviceId)?.scope !== 'mobile') { + throw new Error('mobile_device_not_found') + } + } + + private assertRelayHost( + context: MobilePairingConnectionContext, + broker: RelaySessionBroker + ): void { + if ( + context.transport.transport === 'relay' && + context.transport.relayHostId !== broker.hostId + ) { + throw new Error('stale_relay_connection') + } + } + + private async flushRevoke( + broker: RelaySessionBroker, + item: RelayRevokeOutboxItem + ): Promise { + try { + await broker.revokeDevice(item.relayDeviceId, item.reqId) + this.revokeOutbox.remove(item.reqId) + this.refreshDemand() + } catch { + // Why: the durable item is the source of truth; reconnecting the same + // account/control retries this stable reqId without delaying local revoke. + } + } + + private async withTransientDemand(key: string, operation: () => Promise): Promise { + const release = this.demandLedger.acquireTransient(key) + this.refreshDemand() + try { + return await operation() + } finally { + release() + this.refreshDemand() + } + } + + private async activeBrokerForDemand(): Promise { + const broker = + this.coordinator.getActiveBroker() ?? (await this.coordinator.waitForActiveBroker()) + return broker instanceof RelaySessionBroker ? broker : null + } + + private async requireActiveBroker(): Promise { + const broker = await this.activeBrokerForDemand() + if (!broker) { + throw new Error('relay_control_not_active') + } + return broker + } + + private refreshDemand(): void { + if (this.stopped) { + return + } + if (this.demandExpiryTimer) { + clearTimeout(this.demandExpiryTimer) + this.demandExpiryTimer = null + } + this.coordinator.reconcile() + const expiresAt = this.demandLedger.nextPendingExpiry() + if (expiresAt !== null) { + // Why: an unscanned QR must stop holding a standing control when its + // server invite expires, even if no renderer survives to report closure. + this.demandExpiryTimer = setTimeout( + () => this.refreshDemand(), + Math.max(1, expiresAt - Date.now() + 1) + ) + } + } +} diff --git a/src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts b/src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts new file mode 100644 index 00000000000..1f2b50e0648 --- /dev/null +++ b/src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts @@ -0,0 +1,242 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import nacl from 'tweetnacl' +import WebSocketClient, { WebSocketServer, type RawData, type WebSocket } from 'ws' +import { DeviceRegistry } from '../device-registry' +import { MobileSocketWiring } from '../rpc/mobile-socket-wiring' +import { CloudRelayTransport } from '../rpc/relay-transport' +import { deriveRelayHostId } from './relay-http-client' +import { SimulatedMobileE2EEV2Peer } from './simulated-mobile-e2ee-v2-peer' + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +function waitForOpen(socket: WebSocketClient): Promise { + return new Promise((resolve, reject) => { + socket.once('open', resolve) + socket.once('error', reject) + }) +} + +function nextText(socket: WebSocketClient): Promise { + return new Promise((resolve) => { + socket.once('message', (raw) => resolve(raw.toString())) + }) +} + +function forward(socket: WebSocket, raw: RawData, isBinary: boolean): void { + if (socket.readyState === socket.OPEN) { + socket.send(raw, { binary: isBinary }) + } +} + +describe('desktop relay E2EE integration', () => { + const servers: WebSocketServer[] = [] + const transports: CloudRelayTransport[] = [] + const userDataPaths: string[] = [] + + afterEach(async () => { + await Promise.all(transports.splice(0).map((transport) => transport.stop())) + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((resolve) => { + for (const client of server.clients) { + client.terminate() + } + server.close(() => resolve()) + }) + ) + ) + for (const path of userDataPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } + }) + + it('splices a simulated phone through CloudRelayTransport with real NaCl E2EE v2', async () => { + const relay = new WebSocketServer({ port: 0, perMessageDeflate: false }) + servers.push(relay) + await new Promise((resolve) => relay.once('listening', resolve)) + const address = relay.address() + if (!address || typeof address === 'string') { + throw new Error('expected local relay TCP address') + } + let hostSocket: WebSocket | null = null + let phoneSocket: WebSocket | null = null + let phoneAuthorized = false + const maybeSplice = (): void => { + if (!hostSocket || !phoneSocket || !phoneAuthorized) { + return + } + const host = hostSocket + const phone = phoneSocket + host.on('message', (raw, isBinary) => forward(phone, raw, isBinary)) + phone.on('message', (raw, isBinary) => forward(host, raw, isBinary)) + phone.send( + JSON.stringify({ + type: 'relay-hello', + ok: true, + credentialKind: 'invite', + leaseExpiresAt: Date.now() + 60_000 + }) + ) + } + relay.on('connection', (socket, request) => { + expect(request.url).not.toContain('?') + if (request.url === '/v1/host/data/connection-1') { + socket.once('message', (raw) => { + expect(JSON.parse(raw.toString())).toEqual({ + type: 'host-data-auth', + v: 1, + connTicket: 'A'.repeat(43), + generation: 1 + }) + hostSocket = socket + maybeSplice() + }) + return + } + if (request.url?.startsWith('/v1/connect/')) { + socket.once('message', (raw) => { + expect(JSON.parse(raw.toString())).toEqual({ + type: 'relay-auth', + v: 1, + mode: 'connect', + credential: 'B'.repeat(43) + }) + phoneSocket = socket + phoneAuthorized = true + maybeSplice() + }) + } + }) + + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-relay-e2ee-')) + userDataPaths.push(userDataPath) + const registry = new DeviceRegistry(userDataPath) + const device = registry.addDevice('Phone', 'mobile') + const desktopKeys = nacl.box.keyPair() + const relayHostId = deriveRelayHostId(desktopKeys.publicKey) + const receivedText = deferred() + const receivedBinary = deferred() + const phoneBinary = deferred() + const wiring = new MobileSocketWiring({ + deviceRegistry: registry, + e2eeKeypair: { + publicKey: desktopKeys.publicKey, + secretKey: desktopKeys.secretKey, + publicKeyB64: Buffer.from(desktopKeys.publicKey).toString('base64') + }, + onText: (socket, plaintext, reply, sendBinary) => { + expect(socket.transport).toEqual({ + transport: 'relay', + relayHostId, + relayDeviceId: device.deviceId, + basisConnId: 'connection-1', + credentialKind: 'invite' + }) + receivedText.resolve(plaintext) + reply(JSON.stringify({ id: 'rpc-1', ok: true, result: { path: 'relay' } })) + sendBinary(new Uint8Array([4, 5, 6])) + }, + onBinary: (_socket, bytes) => receivedBinary.resolve(new Uint8Array(bytes)), + onClose: vi.fn() + }) + const transport = new CloudRelayTransport({ + cellUrl: `http://127.0.0.1:${address.port}`, + relayHostId, + generation: 1 + }) + transports.push(transport) + wiring.attachTransport(transport, (socket) => transport.metadataFor(socket)) + await transport.start() + await transport.openConnection({ + connId: 'connection-1', + connTicket: 'A'.repeat(43), + kind: 'invite', + relayDeviceId: device.deviceId, + attachDeadlineMs: 5_000 + }) + + const phone = new WebSocketClient(`ws://127.0.0.1:${address.port}/v1/connect/${relayHostId}`, { + perMessageDeflate: false + }) + await waitForOpen(phone) + const relayHello = nextText(phone) + phone.send( + JSON.stringify({ + type: 'relay-auth', + v: 1, + mode: 'connect', + credential: 'B'.repeat(43) + }) + ) + await expect(relayHello).resolves.toMatch('"ok":true') + + const authenticated = deferred() + const phoneText = deferred() + const phoneSession = new SimulatedMobileE2EEV2Peer( + nacl.box.keyPair(), + desktopKeys.publicKey, + relayHostId + ) + let phoneState: 'awaiting-ready' | 'awaiting-authenticated' | 'ready' = 'awaiting-ready' + phone.on('message', (raw, isBinary) => { + if (phoneState === 'awaiting-ready') { + expect(isBinary).toBe(false) + expect(phoneSession.acceptReady(JSON.parse(raw.toString()))).toBe(true) + phoneState = 'awaiting-authenticated' + phone.send( + phoneSession.sealText( + JSON.stringify({ + type: 'e2ee_auth', + v: 2, + transcriptHashB64: phoneSession.transcriptHashB64, + deviceToken: device.token + }) + ) + ) + return + } + const plaintext = isBinary + ? phoneSession.openBinary(new Uint8Array(raw as Buffer)) + : phoneSession.openText(raw.toString()) + expect(plaintext).not.toBeNull() + if (phoneState === 'awaiting-authenticated') { + expect(JSON.parse(plaintext as string)).toEqual({ + type: 'e2ee_authenticated', + v: 2, + transcriptHashB64: phoneSession.transcriptHashB64 + }) + phoneState = 'ready' + authenticated.resolve() + } else if (typeof plaintext === 'string') { + phoneText.resolve(plaintext) + } else { + phoneBinary.resolve(plaintext!) + } + }) + phone.send(JSON.stringify(phoneSession.hello)) + await authenticated.promise + phone.send(phoneSession.sealText(JSON.stringify({ id: 'rpc-1', method: 'status.get' }))) + phone.send(phoneSession.sealBinary(new Uint8Array([1, 2, 3]))) + + await expect(receivedText.promise).resolves.toBe( + JSON.stringify({ id: 'rpc-1', method: 'status.get' }) + ) + await expect(receivedBinary.promise).resolves.toEqual(new Uint8Array([1, 2, 3])) + await expect(phoneText.promise).resolves.toBe( + JSON.stringify({ id: 'rpc-1', ok: true, result: { path: 'relay' } }) + ) + await expect(phoneBinary.promise).resolves.toEqual(new Uint8Array([4, 5, 6])) + + phone.terminate() + }, 15_000) +}) diff --git a/src/main/runtime/relay/relay-auth-context.ts b/src/main/runtime/relay/relay-auth-context.ts new file mode 100644 index 00000000000..117f3f78057 --- /dev/null +++ b/src/main/runtime/relay/relay-auth-context.ts @@ -0,0 +1,34 @@ +import type { OrcaCloudAuthConfig } from '../../orca-profiles/profile-cloud-auth-config' +import { ensureActiveOrcaProfile } from '../../orca-profiles/profile-index-store' +import { readFreshOrcaCloudSession } from '../../orca-profiles/profile-cloud-session-refresh' +import type { RelayAuthContext } from './relay-auth-coordinator' + +export async function readRelayAuthContext( + authConfig: OrcaCloudAuthConfig, + userDataPath: string +): Promise { + const active = ensureActiveOrcaProfile(userDataPath) + if (!active.profile.cloud) { + return null + } + const session = await readFreshOrcaCloudSession(authConfig, active, userDataPath) + if (session.status !== 'found') { + return null + } + // Why: refresh and org-selection can rewrite cloud linkage while the request + // is in flight; identity must come from the post-refresh profile state. + const refreshed = ensureActiveOrcaProfile(userDataPath) + const cloud = refreshed.profile.cloud + if (!cloud || refreshed.profile.id !== active.profile.id) { + return null + } + return { + identity: { + userId: cloud.userId, + profileId: cloud.cloudProfileId, + organizationId: cloud.activeOrgId ?? '' + }, + accessToken: session.session.accessToken, + relayEntitled: session.session.capabilities.flags['relay.use'] === true + } +} diff --git a/src/main/runtime/relay/relay-auth-coordinator.test.ts b/src/main/runtime/relay/relay-auth-coordinator.test.ts new file mode 100644 index 00000000000..9911d837e7e --- /dev/null +++ b/src/main/runtime/relay/relay-auth-coordinator.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, it, vi } from 'vitest' +import { RelayAuthCoordinator, type RelayAuthContext } from './relay-auth-coordinator' + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +const context: RelayAuthContext = { + identity: { userId: 'user-1', profileId: 'profile-1', organizationId: 'org-1' }, + accessToken: 'access-1', + relayEntitled: true +} + +describe('RelayAuthCoordinator', () => { + it('stays signed-in but does not open a broker without relay demand', async () => { + const openBroker = vi.fn() + const statuses: string[] = [] + const coordinator = new RelayAuthCoordinator({ + readContext: async () => context, + hasDemand: () => false, + openBroker, + onStatus: (status) => statuses.push(status) + }) + coordinator.reconcile() + await coordinator.waitForActiveBroker() + expect(openBroker).not.toHaveBeenCalled() + expect(statuses.at(-1)).toBe('standby') + }) + + it('opens on demand and lingers before closing the last control', async () => { + let demanded = false + const broker = { closeNow: vi.fn() } + const statuses: string[] = [] + const coordinator = new RelayAuthCoordinator({ + readContext: async () => context, + hasDemand: () => demanded, + openBroker: async () => broker, + onStatus: (status) => statuses.push(status), + lingerMs: 250 + }) + coordinator.reconcile() + await coordinator.waitForActiveBroker() + demanded = true + coordinator.reconcile() + await expect(coordinator.waitForActiveBroker()).resolves.toBe(broker) + demanded = false + coordinator.reconcile() + await vi.waitFor(() => expect(statuses.at(-1)).toBe('standby')) + expect(broker.closeNow).not.toHaveBeenCalled() + await vi.waitFor(() => expect(broker.closeNow).toHaveBeenCalledOnce()) + }) + + it('cancels linger when demand returns', async () => { + let demanded = true + const broker = { closeNow: vi.fn() } + const coordinator = new RelayAuthCoordinator({ + readContext: async () => context, + hasDemand: () => demanded, + openBroker: async () => broker, + onStatus: vi.fn(), + lingerMs: 20 + }) + coordinator.reconcile() + await expect(coordinator.waitForActiveBroker()).resolves.toBe(broker) + demanded = false + coordinator.reconcile() + demanded = true + coordinator.reconcile() + await new Promise((resolve) => setTimeout(resolve, 30)) + expect(broker.closeNow).not.toHaveBeenCalled() + }) + + it('does not carry old-profile demand through an identity switch', async () => { + let current = context + const broker = { closeNow: vi.fn() } + const coordinator = new RelayAuthCoordinator({ + readContext: async () => current, + hasDemand: ({ identity }) => identity.profileId === 'profile-1', + openBroker: async () => broker, + onStatus: vi.fn(), + lingerMs: 10_000 + }) + coordinator.reconcile() + await expect(coordinator.waitForActiveBroker()).resolves.toBe(broker) + current = { ...context, identity: { ...context.identity, profileId: 'profile-2' } } + coordinator.reconcile() + await coordinator.waitForActiveBroker() + expect(broker.closeNow).toHaveBeenCalledOnce() + }) + + it('fences a session read that finishes after sign-out', async () => { + const read = deferred() + const openBroker = vi.fn() + const statuses: string[] = [] + const coordinator = new RelayAuthCoordinator({ + readContext: () => read.promise, + openBroker, + onStatus: (status) => statuses.push(status) + }) + coordinator.reconcile() + coordinator.fenceAndCloseNow() + read.resolve(context) + await vi.waitFor(() => expect(openBroker).not.toHaveBeenCalled()) + expect(statuses.at(-1)).toBe('offline') + }) + + it('closes a broker whose open finishes after an identity mutation', async () => { + const opened = deferred<{ closeNow(): void }>() + const staleClose = vi.fn() + const readContext = vi + .fn<() => Promise>() + .mockResolvedValueOnce(context) + .mockResolvedValueOnce({ + ...context, + identity: { ...context.identity, organizationId: 'org-2' } + }) + const openBroker = vi + .fn() + .mockImplementationOnce(() => opened.promise) + .mockResolvedValueOnce({ closeNow: vi.fn() }) + const coordinator = new RelayAuthCoordinator({ + readContext, + openBroker, + onStatus: vi.fn() + }) + coordinator.reconcile() + await vi.waitFor(() => expect(openBroker).toHaveBeenCalledOnce()) + coordinator.reconcile() + await vi.waitFor(() => expect(openBroker).toHaveBeenCalledTimes(2)) + opened.resolve({ closeNow: staleClose }) + await vi.waitFor(() => expect(staleClose).toHaveBeenCalledOnce()) + }) + + it('keeps one broker for duplicate events with unchanged identity', async () => { + const broker = { closeNow: vi.fn() } + const openBroker = vi.fn(async () => broker) + const coordinator = new RelayAuthCoordinator({ + readContext: async () => context, + openBroker, + onStatus: vi.fn() + }) + coordinator.reconcile() + await vi.waitFor(() => expect(openBroker).toHaveBeenCalledOnce()) + coordinator.reconcile() + await vi.waitFor(() => expect(openBroker).toHaveBeenCalledOnce()) + expect(broker.closeNow).not.toHaveBeenCalled() + }) + + it('rejects a refresh result after capability removal', async () => { + let current: RelayAuthContext | null = context + let refreshAccessToken: (() => Promise) | null = null + const coordinator = new RelayAuthCoordinator({ + readContext: async () => current, + openBroker: async (input) => { + refreshAccessToken = input.refreshAccessToken + return { closeNow: vi.fn() } + }, + onStatus: vi.fn() + }) + coordinator.reconcile() + await vi.waitFor(() => expect(refreshAccessToken).not.toBeNull()) + current = { ...context, relayEntitled: false } + coordinator.reconcile() + await expect(refreshAccessToken!()).resolves.toBeNull() + }) + + it('invalidates pending ownership immediately while broker opening is paused', async () => { + const firstOpen = deferred<{ closeNow(): void }>() + const firstClose = vi.fn() + let firstIsCurrent: (() => boolean) | null = null + const openBroker = vi + .fn() + .mockImplementationOnce((input) => { + firstIsCurrent = input.isCurrent + return firstOpen.promise + }) + .mockResolvedValueOnce({ closeNow: vi.fn() }) + const coordinator = new RelayAuthCoordinator({ + readContext: async () => context, + openBroker, + onStatus: vi.fn() + }) + + coordinator.reconcile() + await vi.waitFor(() => expect(firstIsCurrent).not.toBeNull()) + coordinator.reconcile() + expect(firstIsCurrent!()).toBe(false) + await vi.waitFor(() => expect(openBroker).toHaveBeenCalledTimes(2)) + firstOpen.resolve({ closeNow: firstClose }) + await vi.waitFor(() => expect(firstClose).toHaveBeenCalledOnce()) + }) + + it('rejects a token refresh whose session read crosses an auth mutation', async () => { + const refreshRead = deferred() + let readCount = 0 + let current = context + let refreshAccessToken: (() => Promise) | null = null + const coordinator = new RelayAuthCoordinator({ + readContext: () => { + readCount += 1 + return readCount === 2 ? refreshRead.promise : Promise.resolve(current) + }, + openBroker: async (input) => { + refreshAccessToken = input.refreshAccessToken + return { closeNow: vi.fn() } + }, + onStatus: vi.fn() + }) + coordinator.reconcile() + await vi.waitFor(() => expect(refreshAccessToken).not.toBeNull()) + const refreshing = refreshAccessToken!() + current = { ...context, identity: { ...context.identity, organizationId: 'org-2' } } + coordinator.reconcile() + refreshRead.resolve(context) + + await expect(refreshing).resolves.toBeNull() + await vi.waitFor(() => expect(readCount).toBeGreaterThanOrEqual(3)) + }) + + it('reconnects automatically after a signed-in process restart or relaunch fence', async () => { + const firstBroker = { closeNow: vi.fn() } + const firstCoordinator = new RelayAuthCoordinator({ + readContext: async () => context, + openBroker: async () => firstBroker, + onStatus: vi.fn() + }) + firstCoordinator.reconcile() + await vi.waitFor(() => expect(firstCoordinator.getActiveBroker()).toBe(firstBroker)) + firstCoordinator.fenceAndCloseNow() + expect(firstBroker.closeNow).toHaveBeenCalledOnce() + + const reopenedBroker = { closeNow: vi.fn() } + const reopenedCoordinator = new RelayAuthCoordinator({ + // Why: normal quit/relaunch preserves the session store, so a fresh + // process reads the same entitled identity and opens without new login. + readContext: async () => context, + openBroker: async () => reopenedBroker, + onStatus: vi.fn() + }) + reopenedCoordinator.reconcile() + await vi.waitFor(() => expect(reopenedCoordinator.getActiveBroker()).toBe(reopenedBroker)) + }) + + it('closes and reopens for valid profile and organization identity switches', async () => { + let current = context + const brokers = Array.from({ length: 3 }, () => ({ closeNow: vi.fn() })) + const openBroker = vi + .fn() + .mockResolvedValueOnce(brokers[0]) + .mockResolvedValueOnce(brokers[1]) + .mockResolvedValueOnce(brokers[2]) + const coordinator = new RelayAuthCoordinator({ + readContext: async () => current, + openBroker, + onStatus: vi.fn() + }) + coordinator.reconcile() + await vi.waitFor(() => expect(coordinator.getActiveBroker()).toBe(brokers[0])) + + current = { ...context, identity: { ...context.identity, profileId: 'profile-2' } } + coordinator.reconcile() + await vi.waitFor(() => expect(coordinator.getActiveBroker()).toBe(brokers[1])) + expect(brokers[0]!.closeNow).toHaveBeenCalledOnce() + + current = { + ...context, + identity: { ...context.identity, profileId: 'profile-2', organizationId: 'org-2' } + } + coordinator.reconcile() + await vi.waitFor(() => expect(coordinator.getActiveBroker()).toBe(brokers[2])) + expect(brokers[1]!.closeNow).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/runtime/relay/relay-auth-coordinator.ts b/src/main/runtime/relay/relay-auth-coordinator.ts new file mode 100644 index 00000000000..f197edcc2b1 --- /dev/null +++ b/src/main/runtime/relay/relay-auth-coordinator.ts @@ -0,0 +1,225 @@ +import type { RelayBrokerStatus } from './relay-session-broker' + +export type RelayAuthIdentity = { + userId: string + profileId: string + organizationId: string +} + +export type RelayAuthContext = { + identity: RelayAuthIdentity + accessToken: string + relayEntitled: boolean +} + +export type CoordinatedRelayBroker = { + closeNow(): void +} + +type RelayAuthCoordinatorOptions = { + readContext: () => Promise + hasDemand?: (context: RelayAuthContext) => boolean + openBroker: (input: { + context: RelayAuthContext + isCurrent: () => boolean + refreshAccessToken: () => Promise + }) => Promise + onStatus: (status: RelayBrokerStatus) => void + lingerMs?: number +} + +type BrokerOwnership = { + identityKey: string + broker: CoordinatedRelayBroker | null + valid: boolean +} + +function identityKey(identity: RelayAuthIdentity): string { + return `${identity.userId}\0${identity.profileId}\0${identity.organizationId}` +} + +export class RelayAuthCoordinator { + private readonly options: RelayAuthCoordinatorOptions + private authEpoch = 0 + private ownership: BrokerOwnership | null = null + private readonly pendingOwnerships = new Set() + private latestReconcile: Promise = Promise.resolve() + private lingerTimer: ReturnType | null = null + private stopped = false + + constructor(options: RelayAuthCoordinatorOptions) { + this.options = options + } + + reconcile(): void { + if (this.stopped) { + return + } + const epoch = ++this.authEpoch + this.invalidatePendingOwnerships() + const reconcile = this.reconcileEpoch(epoch) + this.latestReconcile = reconcile + void reconcile + } + + fenceAndCloseNow(): void { + ++this.authEpoch + this.cancelLinger() + this.invalidatePendingOwnerships() + this.invalidateOwnership() + this.options.onStatus('offline') + } + + getActiveBroker(): CoordinatedRelayBroker | null { + return this.ownership?.valid ? this.ownership.broker : null + } + + async waitForActiveBroker(): Promise { + while (!this.stopped) { + const broker = this.getActiveBroker() + if (broker) { + return broker + } + const pending = this.latestReconcile + await pending + if (pending === this.latestReconcile) { + return this.getActiveBroker() + } + } + return null + } + + stop(): void { + this.stopped = true + this.fenceAndCloseNow() + } + + private async reconcileEpoch(epoch: number): Promise { + try { + const context = await this.options.readContext() + if (!this.isEpochCurrent(epoch)) { + return + } + if (!context || !context.relayEntitled) { + this.cancelLinger() + this.invalidateOwnership() + this.options.onStatus('offline') + return + } + const nextIdentityKey = identityKey(context.identity) + if (!(this.options.hasDemand?.(context) ?? true)) { + if (this.ownership?.valid && this.ownership.identityKey !== nextIdentityKey) { + this.cancelLinger() + this.invalidateOwnership() + } else if (this.ownership?.valid) { + this.scheduleLinger(context, this.ownership) + } + this.options.onStatus('standby') + return + } + this.cancelLinger() + if (this.ownership?.valid && this.ownership.identityKey === nextIdentityKey) { + this.options.onStatus('registered') + return + } + this.invalidateOwnership() + this.options.onStatus('connecting') + const ownership: BrokerOwnership = { + identityKey: nextIdentityKey, + broker: null, + valid: true + } + this.pendingOwnerships.add(ownership) + const isCurrent = (): boolean => + ownership.valid && + !this.stopped && + (ownership.broker ? this.ownership === ownership : this.isEpochCurrent(epoch)) + let broker: CoordinatedRelayBroker + try { + broker = await this.options.openBroker({ + context, + isCurrent, + refreshAccessToken: () => this.refreshAccessToken(ownership, nextIdentityKey) + }) + } finally { + this.pendingOwnerships.delete(ownership) + } + ownership.broker = broker + if (!this.isEpochCurrent(epoch) || !ownership.valid) { + broker.closeNow() + return + } + this.ownership = ownership + this.options.onStatus('registered') + } catch { + if (this.isEpochCurrent(epoch)) { + this.options.onStatus('offline') + } + } + } + + private async refreshAccessToken( + ownership: { valid: boolean }, + expectedIdentityKey: string + ): Promise { + if (!ownership.valid || this.stopped) { + return null + } + const epoch = this.authEpoch + const context = await this.options.readContext() + if ( + !ownership.valid || + !this.isEpochCurrent(epoch) || + !context?.relayEntitled || + identityKey(context.identity) !== expectedIdentityKey + ) { + return null + } + return context.accessToken + } + + private invalidateOwnership(): void { + const ownership = this.ownership + this.ownership = null + if (ownership) { + ownership.valid = false + ownership.broker?.closeNow() + } + } + + private scheduleLinger(context: RelayAuthContext, ownership: BrokerOwnership): void { + if (this.lingerTimer) { + return + } + const lingerMs = this.options.lingerMs ?? 10 * 60_000 + this.lingerTimer = setTimeout(() => { + this.lingerTimer = null + if ( + this.ownership === ownership && + ownership.valid && + !(this.options.hasDemand?.(context) ?? true) + ) { + this.invalidateOwnership() + this.options.onStatus('standby') + } + }, lingerMs) + } + + private cancelLinger(): void { + if (this.lingerTimer) { + clearTimeout(this.lingerTimer) + this.lingerTimer = null + } + } + + private invalidatePendingOwnerships(): void { + for (const ownership of this.pendingOwnerships) { + ownership.valid = false + } + this.pendingOwnerships.clear() + } + + private isEpochCurrent(epoch: number): boolean { + return !this.stopped && this.authEpoch === epoch + } +} diff --git a/src/main/runtime/relay/relay-control-client.test.ts b/src/main/runtime/relay/relay-control-client.test.ts new file mode 100644 index 00000000000..415b4583d46 --- /dev/null +++ b/src/main/runtime/relay/relay-control-client.test.ts @@ -0,0 +1,314 @@ +import { createHash, createHmac, randomBytes } from 'node:crypto' +import { afterEach, describe, expect, it, vi } from 'vitest' +import nacl from 'tweetnacl' +import { WebSocketServer, type WebSocket } from 'ws' +import type { E2EEKeypair } from '../e2ee-keypair' +import { RelayControlClient } from './relay-control-client' + +const encoder = new TextEncoder() +const HOST_PROOF_DOMAIN = 'orca-relay-host-proof/v1' +const CHALLENGE_DOMAIN = 'orca-relay-host-challenge/v1' + +function concat(parts: readonly Uint8Array[]): Uint8Array { + const output = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0)) + let offset = 0 + for (const part of parts) { + output.set(part, offset) + offset += part.byteLength + } + return output +} + +function uint32(value: number): Uint8Array { + const bytes = new Uint8Array(4) + new DataView(bytes.buffer).setUint32(0, value, false) + return bytes +} + +function uint64(value: number): Uint8Array { + const bytes = new Uint8Array(8) + new DataView(bytes.buffer).setBigUint64(0, BigInt(value), false) + return bytes +} + +function field(name: string, value: Uint8Array): Uint8Array { + const encodedName = encoder.encode(name) + return concat([uint32(encodedName.byteLength), encodedName, uint32(value.byteLength), value]) +} + +function text(value: string): Uint8Array { + return encoder.encode(value) +} + +function buildTranscript(input: { + origin: string + relayKey: Uint8Array + nonce: Uint8Array + challengeId: string + issuedAt: number + expiresAt: number + relayHostId: string + hostKey: Uint8Array +}): Uint8Array { + return concat([ + field('protocol', text(HOST_PROOF_DOMAIN)), + field('version', new Uint8Array([1])), + field('relayOrigin', text(input.origin)), + field('relayEphemeralPublicKey', input.relayKey), + field('challengeNonce', input.nonce), + field('challengeId', text(input.challengeId)), + field('issuedAt', uint64(input.issuedAt)), + field('expiresAt', uint64(input.expiresAt)), + field('userId', text('user-1')), + field('profileId', text('profile-1')), + field('organizationId', text('org-1')), + field('relayHostId', text(input.relayHostId)), + field('hostPublicKey', input.hostKey), + field('assignmentEpoch', uint64(3)), + field('previousGeneration', new Uint8Array()), + field('resumeRequested', new Uint8Array([0])) + ]) +} + +function nextJson(ws: WebSocket): Promise> { + return new Promise((resolve) => { + ws.once('message', (raw) => resolve(JSON.parse(raw.toString()) as Record)) + }) +} + +describe('RelayControlClient', () => { + const servers: WebSocketServer[] = [] + const clients: RelayControlClient[] = [] + + afterEach(async () => { + for (const client of clients.splice(0)) { + client.closeNow() + } + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((resolve) => { + for (const socket of server.clients) { + socket.terminate() + } + server.close(() => resolve()) + }) + ) + ) + }) + + it('proves the host key and drives control/data commands without URL credentials', async () => { + const server = new WebSocketServer({ port: 0, perMessageDeflate: false }) + servers.push(server) + await new Promise((resolve) => server.once('listening', resolve)) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('expected TCP relay test server') + } + const origin = `http://127.0.0.1:${address.port}` + const hostKeys = nacl.box.keyPair() + const keypair: E2EEKeypair = { + publicKey: hostKeys.publicKey, + secretKey: hostKeys.secretKey, + publicKeyB64: Buffer.from(hostKeys.publicKey).toString('base64') + } + const relayHostId = createHash('sha256') + .update(hostKeys.publicKey) + .digest('base64url') + .slice(0, 16) + const accepted = new Promise<{ socket: WebSocket; authorization: string; path: string }>( + (resolve) => { + server.once('connection', (socket, request) => + resolve({ + socket, + authorization: String(request.headers.authorization), + path: request.url ?? '' + }) + ) + } + ) + const onConnectionOpen = vi.fn() + const onDrain = vi.fn() + const onClose = vi.fn() + const client = new RelayControlClient({ + cellUrl: origin, + relayJwt: 'scoped-token', + relayHostId, + assignmentEpoch: 3, + identity: { userId: 'user-1', profileId: 'profile-1', organizationId: 'org-1' }, + keypair, + appVersion: '1.2.3', + onConnectionOpen, + onDrain, + onClose + }) + clients.push(client) + const connecting = client.connect() + const { socket, authorization, path } = await accepted + expect(authorization).toBe('Bearer scoped-token') + expect(path).toBe('/v1/host/control') + const hello = await nextJson(socket) + expect(hello).toMatchObject({ + type: 'host-hello', + relayHostId, + assignmentEpoch: 3, + hostPublicKeyB64: keypair.publicKeyB64 + }) + + const relayKeys = nacl.box.keyPair() + const nonce = randomBytes(24) + const secret = randomBytes(32) + const issuedAt = Date.now() + const expiresAt = issuedAt + 10_000 + const transcript = buildTranscript({ + origin, + relayKey: relayKeys.publicKey, + nonce, + challengeId: 'challenge-1', + issuedAt, + expiresAt, + relayHostId, + hostKey: hostKeys.publicKey + }) + const plaintext = concat([ + text(`${CHALLENGE_DOMAIN}\0`), + uint32(transcript.byteLength), + transcript, + secret + ]) + const proofMessage = nextJson(socket) + socket.send( + JSON.stringify({ + type: 'host-challenge', + challengeId: 'challenge-1', + relayEphemeralPublicKeyB64: Buffer.from(relayKeys.publicKey).toString('base64'), + nonceB64: nonce.toString('base64'), + ciphertextB64: Buffer.from( + nacl.box(plaintext, nonce, hostKeys.publicKey, relayKeys.secretKey) + ).toString('base64'), + expiresAt + }) + ) + const proof = await proofMessage + expect(proof).toMatchObject({ type: 'host-challenge-ack', challengeId: 'challenge-1' }) + const expectedProof = createHmac('sha256', secret) + .update(text(`${HOST_PROOF_DOMAIN}\0ack\0`)) + .update(transcript) + .digest('base64') + expect(proof.proofB64).toBe(expectedProof) + + socket.send( + JSON.stringify({ + type: 'host-hello-ack', + v: 1, + generation: 4, + controlResumeSecret: randomBytes(32).toString('base64url'), + leaseExpiresAt: Date.now() + 60_000, + activeConnIds: [], + pendingConns: [] + }) + ) + await expect(connecting).resolves.toMatchObject({ generation: 4 }) + + socket.send(JSON.stringify({ type: 'ping', t: Date.now() })) + await expect(nextJson(socket)).resolves.toMatchObject({ type: 'pong' }) + socket.send( + JSON.stringify({ + type: 'conn-open', + connId: 'conn-1', + connTicket: randomBytes(32).toString('base64url'), + kind: 'invite', + relayDeviceId: 'device-1', + attachDeadlineMs: 10_000 + }) + ) + await vi.waitFor(() => expect(onConnectionOpen).toHaveBeenCalledOnce()) + + const inviteRequest = nextJson(socket) + const invitePromise = client.createInvite('device-1', 'invite-req') + await expect(inviteRequest).resolves.toEqual({ + type: 'invite-create', + reqId: 'invite-req', + relayDeviceId: 'device-1' + }) + socket.send( + JSON.stringify({ + type: 'invite-created', + reqId: 'invite-req', + inviteToken: randomBytes(32).toString('base64url'), + expiresAt: Date.now() + 60_000, + maxAttempts: 3 + }) + ) + await expect(invitePromise).resolves.toMatchObject({ reqId: 'invite-req' }) + + const installRequest = nextJson(socket) + const installPromise = client.installCredential({ + reqId: 'install-req', + relayDeviceId: 'device-1', + newResumeTokenHash: 'A'.repeat(43), + authorization: { mode: 'relay-basis', basisConnId: 'conn-1' } + }) + await expect(installRequest).resolves.toEqual({ + type: 'device-credential-install', + v: 1, + reqId: 'install-req', + relayDeviceId: 'device-1', + newResumeTokenHash: 'A'.repeat(43), + authorization: { mode: 'relay-basis', basisConnId: 'conn-1' } + }) + socket.send( + JSON.stringify({ + type: 'device-credential-installed', + v: 1, + reqId: 'install-req', + authorizationMode: 'relay-basis', + currentVersion: 1, + resumeExpiresAt: Date.now() + 60_000 + }) + ) + await expect(installPromise).resolves.toMatchObject({ currentVersion: 1 }) + + const statusRequest = nextJson(socket) + const statusPromise = client.credentialInstallStatus('device-1', 'install-req') + await expect(statusRequest).resolves.toEqual({ + type: 'device-credential-install-status', + v: 1, + reqId: 'install-req', + relayDeviceId: 'device-1' + }) + socket.send( + JSON.stringify({ + type: 'device-credential-install-status-result', + v: 1, + reqId: 'install-req', + state: 'not-found' + }) + ) + await expect(statusPromise).resolves.toMatchObject({ state: 'not-found' }) + + const confirmationRequest = nextJson(socket) + const confirmationPromise = client.confirmResume('conn-2', 'confirm-req') + await expect(confirmationRequest).resolves.toEqual({ + type: 'device-resume-confirm', + v: 1, + reqId: 'confirm-req', + basisConnId: 'conn-2' + }) + socket.send( + JSON.stringify({ + type: 'device-resume-confirmed', + v: 1, + reqId: 'confirm-req', + currentVersion: 1, + acceptedAs: 'current', + renewed: true, + resumeExpiresAt: Date.now() + 60_000 + }) + ) + await expect(confirmationPromise).resolves.toMatchObject({ renewed: true }) + + socket.send(JSON.stringify({ type: 'drain', graceMs: 5_000, recovery: 'resolve-director' })) + await vi.waitFor(() => expect(onDrain).toHaveBeenCalledOnce()) + }) +}) diff --git a/src/main/runtime/relay/relay-control-client.ts b/src/main/runtime/relay/relay-control-client.ts new file mode 100644 index 00000000000..44f3d65798c --- /dev/null +++ b/src/main/runtime/relay/relay-control-client.ts @@ -0,0 +1,284 @@ +import { randomUUID } from 'node:crypto' +import WebSocket, { type RawData } from 'ws' +import { MOBILE_RELAY_CLOSE_CODE } from '../../../shared/mobile-relay-close-codes' +import type { E2EEKeypair } from '../e2ee-keypair' +import { + RelayConnectionOpenMessageSchema, + RelayDrainMessageSchema, + RelayHostChallengeMessageSchema, + RelayHostHelloAckMessageSchema, + RelayPingMessageSchema, + parseRelayControlMessage, + type RelayConnectionOpenMessage, + type RelayDrainMessage, + type RelayHostHelloAckMessage, + type RelayInviteCreatedMessage +} from './relay-control-protocol' +import { RelayControlRequests } from './relay-control-requests' +import type { DeviceCredentialInstallAuthorization } from './relay-control-requests' +import { answerRelayHostChallenge } from './relay-host-proof' + +type RelayControlState = 'idle' | 'opening' | 'proving' | 'active' | 'draining' | 'closed' + +type RelayControlClientOptions = { + cellUrl: string + relayJwt: string + relayHostId: string + assignmentEpoch: number + identity: { userId: string; profileId: string; organizationId: string } + keypair: E2EEKeypair + appVersion: string + previousGeneration?: number + controlResumeSecret?: string + onConnectionOpen: (message: RelayConnectionOpenMessage) => void + onDrain: (message: RelayDrainMessage) => void + onClose: (code: number) => void + createSocket?: (url: string, relayJwt: string) => WebSocket +} + +function controlWebSocketUrl(cellUrl: string): { origin: string; url: string } { + const parsed = new URL(cellUrl) + if (parsed.pathname !== '/' || parsed.search || parsed.hash) { + throw new Error('relay_cell_url_must_be_an_origin') + } + const origin = parsed.origin + if (parsed.protocol === 'https:') { + parsed.protocol = 'wss:' + } else if (parsed.protocol === 'http:') { + parsed.protocol = 'ws:' + } else { + throw new Error('relay_cell_url_must_use_http') + } + return { origin, url: `${parsed.origin}/v1/host/control` } +} + +export class RelayControlClient { + private readonly options: RelayControlClientOptions + private readonly relayOrigin: string + private readonly controlUrl: string + private readonly createSocket: NonNullable + private readonly requests = new RelayControlRequests() + private socket: WebSocket | null = null + private state: RelayControlState = 'idle' + private connectResolve: ((ack: RelayHostHelloAckMessage) => void) | null = null + private connectReject: ((error: Error) => void) | null = null + + constructor(options: RelayControlClientOptions) { + this.options = options + const endpoint = controlWebSocketUrl(options.cellUrl) + this.relayOrigin = endpoint.origin + this.controlUrl = endpoint.url + this.createSocket = + options.createSocket ?? + ((url, token) => + new WebSocket(url, { + headers: { authorization: `Bearer ${token}` }, + perMessageDeflate: false, + maxPayload: 64 * 1024 + })) + } + + connect(): Promise { + if (this.state !== 'idle') { + return Promise.reject(new Error('relay_control_already_started')) + } + this.state = 'opening' + const socket = this.createSocket(this.controlUrl, this.options.relayJwt) + this.socket = socket + socket.once('open', () => this.sendHostHello()) + socket.on('message', (raw, isBinary) => { + if (isBinary) { + this.failProtocol('binary control message') + return + } + this.handleMessage(raw) + }) + socket.once('error', (error) => { + if (this.state === 'opening' || this.state === 'proving') { + this.connectReject?.(error) + this.clearConnectPromise() + } + }) + socket.once('close', (code) => this.handleClose(code)) + return new Promise((resolve, reject) => { + this.connectResolve = resolve + this.connectReject = reject + }) + } + + get pendingRequestCount(): number { + return this.requests.size + } + + refreshAuthorization(relayJwt: string): void { + this.sendActive({ type: 'auth-refresh', relayJwt }) + } + + createInvite( + relayDeviceId: string, + reqId: string = randomUUID() + ): Promise { + return this.requests.createInvite(reqId, relayDeviceId, (payload) => this.sendActive(payload)) + } + + revokeDevice(relayDeviceId: string, reqId: string = randomUUID()): Promise { + return this.requests.revokeDevice(reqId, relayDeviceId, (payload) => this.sendActive(payload)) + } + + installCredential(input: { + reqId: string + relayDeviceId: string + newResumeTokenHash: string + expectedCurrentHash?: string + authorization: DeviceCredentialInstallAuthorization + }): ReturnType { + const { reqId, ...request } = input + return this.requests.installCredential(reqId, request, (payload) => this.sendActive(payload)) + } + + credentialInstallStatus( + relayDeviceId: string, + reqId: string + ): ReturnType { + return this.requests.credentialInstallStatus(reqId, relayDeviceId, (payload) => + this.sendActive(payload) + ) + } + + confirmResume( + basisConnId: string, + reqId: string + ): ReturnType { + return this.requests.confirmResume(reqId, basisConnId, (payload) => this.sendActive(payload)) + } + + closeNow(): void { + this.state = 'closed' + this.requests.rejectAll(new Error('relay_control_closed')) + this.socket?.terminate() + this.socket = null + } + + private sendHostHello(): void { + if (!this.socket || this.state !== 'opening') { + return + } + this.state = 'proving' + this.socket.send( + JSON.stringify({ + type: 'host-hello', + v: 1, + relayHostId: this.options.relayHostId, + assignmentEpoch: this.options.assignmentEpoch, + hostPublicKeyB64: this.options.keypair.publicKeyB64, + appVersion: this.options.appVersion, + ...(this.options.previousGeneration === undefined + ? {} + : { previousGeneration: this.options.previousGeneration }), + ...(this.options.controlResumeSecret + ? { controlResumeSecret: this.options.controlResumeSecret } + : {}) + }) + ) + } + + private handleMessage(raw: RawData): void { + const message = parseRelayControlMessage(raw) + if (!message) { + this.failProtocol('invalid control JSON') + return + } + if (this.state === 'proving') { + this.handleProofMessage(message) + return + } + if (this.state !== 'active' && this.state !== 'draining') { + this.failProtocol('control message before activation') + return + } + if (RelayPingMessageSchema.safeParse(message).success) { + this.socket?.send(JSON.stringify({ type: 'pong', t: message.t })) + return + } + const connection = RelayConnectionOpenMessageSchema.safeParse(message) + if (connection.success && this.state === 'active') { + this.options.onConnectionOpen(connection.data) + return + } + const drain = RelayDrainMessageSchema.safeParse(message) + if (drain.success) { + this.state = 'draining' + this.options.onDrain(drain.data) + return + } + if (this.requests.resolveMessage(message)) { + return + } + this.failProtocol('unknown control message') + } + + private handleProofMessage(message: Record): void { + const challenge = RelayHostChallengeMessageSchema.safeParse(message) + if (challenge.success) { + const proofB64 = answerRelayHostChallenge(challenge.data, { + relayOrigin: this.relayOrigin, + ...this.options.identity, + relayHostId: this.options.relayHostId, + hostPublicKey: this.options.keypair.publicKey, + hostSecretKey: this.options.keypair.secretKey, + assignmentEpoch: this.options.assignmentEpoch, + previousGeneration: this.options.previousGeneration, + resumeRequested: Boolean(this.options.controlResumeSecret) + }) + if (!proofB64) { + this.failProtocol('invalid host challenge') + return + } + this.socket?.send( + JSON.stringify({ + type: 'host-challenge-ack', + challengeId: challenge.data.challengeId, + proofB64 + }) + ) + return + } + const ack = RelayHostHelloAckMessageSchema.safeParse(message) + if (!ack.success) { + this.failProtocol('invalid host proof message') + return + } + this.state = 'active' + this.connectResolve?.(ack.data) + this.clearConnectPromise() + } + + private sendActive(payload: object): void { + if (!this.socket || (this.state !== 'active' && this.state !== 'draining')) { + throw new Error('relay_control_not_active') + } + this.socket.send(JSON.stringify(payload)) + } + + private failProtocol(reason: string): void { + this.connectReject?.(new Error(reason)) + this.clearConnectPromise() + this.socket?.close(MOBILE_RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, reason) + } + + private handleClose(code: number): void { + const wasConnecting = this.state === 'opening' || this.state === 'proving' + this.state = 'closed' + if (wasConnecting) { + this.connectReject?.(new Error(`relay_control_closed_${code}`)) + this.clearConnectPromise() + } + this.requests.rejectAll(new Error(`relay_control_closed_${code}`)) + this.options.onClose(code) + } + + private clearConnectPromise(): void { + this.connectResolve = null + this.connectReject = null + } +} diff --git a/src/main/runtime/relay/relay-control-origin.ts b/src/main/runtime/relay/relay-control-origin.ts new file mode 100644 index 00000000000..ef607c9862b --- /dev/null +++ b/src/main/runtime/relay/relay-control-origin.ts @@ -0,0 +1,243 @@ +import type WebSocket from 'ws' +import type { E2EEKeypair } from '../e2ee-keypair' +import { CloudRelayTransport } from '../rpc/relay-transport' +import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring' +import { RelayControlClient } from './relay-control-client' +import type { + RelayConnectionOpenMessage, + RelayDrainMessage, + RelayHostHelloAckMessage +} from './relay-control-protocol' +import type { RelayIdentity } from './relay-session-broker-contract' +import type { RelayAssignment } from './relay-http-client' + +type RelayControlOriginOptions = { + assignment: RelayAssignment + relayJwt: string + relayHostId: string + identity: RelayIdentity + keypair: E2EEKeypair + appVersion: string + mobileSocketWiring: MobileSocketWiring + createControlSocket?: (url: string, relayJwt: string) => WebSocket + createDataSocket?: (url: string) => WebSocket + onConnectionOwned: (connectionId: string, origin: RelayControlOrigin) => void + onConnectionReleased: (connectionId: string, origin: RelayControlOrigin) => void + onDrain: (origin: RelayControlOrigin, message: RelayDrainMessage) => void + onClose: (origin: RelayControlOrigin, code: number) => void +} + +export class RelayControlOrigin { + readonly assignment: RelayAssignment + readonly transport: CloudRelayTransport + private readonly options: RelayControlOriginOptions + private readonly controls = new Set() + private readonly retiredControlTimers = new Map< + RelayControlClient, + ReturnType + >() + private activeControl: RelayControlClient | null = null + private generation = 0 + private controlResumeSecret: string | null = null + private leaseExpiresAt = 0 + private acceptingConnections = true + private closed = false + + constructor(options: RelayControlOriginOptions) { + this.options = options + this.assignment = options.assignment + this.transport = new CloudRelayTransport({ + cellUrl: options.assignment.cellUrl, + relayHostId: options.relayHostId, + generation: 0, + createSocket: options.createDataSocket, + onConnectionClosed: (connectionId) => options.onConnectionReleased(connectionId, this) + }) + options.mobileSocketWiring.attachTransport(this.transport, (ws) => + this.transport.metadataFor(ws) + ) + } + + get control(): RelayControlClient { + if (!this.activeControl) { + throw new Error('relay_control_not_active') + } + return this.activeControl + } + + get availableControl(): RelayControlClient | null { + return this.activeControl + } + + get cellUrl(): string { + return this.assignment.cellUrl + } + + get assignmentEpoch(): number { + return this.assignment.assignmentEpoch + } + + get controlLeaseExpiresAt(): number { + return this.leaseExpiresAt + } + + get pendingRequestCount(): number { + let count = 0 + for (const control of this.controls) { + count += control.pendingRequestCount + } + return count + } + + async open(): Promise { + await this.transport.start() + const { control, ack } = await this.openControl() + this.activate(control, ack) + } + + async rebind(relayJwt: string, assignment: RelayAssignment): Promise { + if (assignment.cellUrl !== this.cellUrl || !this.controlResumeSecret || this.generation <= 0) { + throw new Error('relay_control_rebind_origin_mismatch') + } + const previous = this.activeControl + const { control, ack } = await this.openControl({ + relayJwt, + assignmentEpoch: assignment.assignmentEpoch, + previousGeneration: this.generation, + controlResumeSecret: this.controlResumeSecret + }) + this.activate(control, ack) + this.acceptingConnections = true + // Why: the resumed control owns the same server generation and splices; + // the predecessor remains only long enough for any idempotent reply in flight. + if (previous && previous.pendingRequestCount === 0) { + this.closeRetiredControl(previous) + } else if (previous) { + // Why: basis-bound requests keep their original control through its + // bounded request deadline; afterward the resumed control is sole owner. + this.retiredControlTimers.set( + previous, + setTimeout(() => this.closeRetiredControl(previous), 10_100) + ) + } + } + + markDraining(): void { + // The relay changes the control's protocol state when it sends drain. This + // marker exists for the broker's ownership policy, not a second wire event. + this.acceptingConnections = false + } + + refreshAuthorization(relayJwt: string): void { + for (const control of this.controls) { + try { + control.refreshAuthorization(relayJwt) + } catch { + // A closing drain-only origin cannot block refresh on the active target. + } + } + } + + async close(): Promise { + if (this.closed) { + return + } + this.closed = true + for (const timer of this.retiredControlTimers.values()) { + clearTimeout(timer) + } + this.retiredControlTimers.clear() + for (const control of this.controls) { + control.closeNow() + } + this.controls.clear() + this.activeControl = null + await this.transport.stop() + } + + closeNow(): void { + void this.close() + } + + private async openControl(overrides?: { + relayJwt: string + assignmentEpoch: number + previousGeneration: number + controlResumeSecret: string + }): Promise<{ control: RelayControlClient; ack: RelayHostHelloAckMessage }> { + let control!: RelayControlClient + control = new RelayControlClient({ + cellUrl: this.cellUrl, + relayJwt: overrides?.relayJwt ?? this.options.relayJwt, + relayHostId: this.options.relayHostId, + assignmentEpoch: overrides?.assignmentEpoch ?? this.assignmentEpoch, + identity: this.options.identity, + keypair: this.options.keypair, + appVersion: this.options.appVersion, + ...(overrides + ? { + previousGeneration: overrides.previousGeneration, + controlResumeSecret: overrides.controlResumeSecret + } + : {}), + onConnectionOpen: (message) => this.openConnection(message), + onDrain: (message) => this.options.onDrain(this, message), + onClose: (code) => { + this.controls.delete(control) + const timer = this.retiredControlTimers.get(control) + if (timer) { + clearTimeout(timer) + this.retiredControlTimers.delete(control) + } + if (this.activeControl === control) { + this.activeControl = null + this.options.onClose(this, code) + } + }, + createSocket: this.options.createControlSocket + }) + this.controls.add(control) + try { + return { control, ack: await control.connect() } + } catch (error) { + this.controls.delete(control) + control.closeNow() + throw error + } + } + + private closeRetiredControl(control: RelayControlClient): void { + const timer = this.retiredControlTimers.get(control) + if (timer) { + clearTimeout(timer) + this.retiredControlTimers.delete(control) + } + if (this.activeControl !== control && this.controls.delete(control)) { + control.closeNow() + } + } + + private activate(control: RelayControlClient, ack: RelayHostHelloAckMessage): void { + if (ack.generation <= 0) { + throw new Error('invalid_relay_generation') + } + this.transport.setGeneration(ack.generation) + this.generation = ack.generation + this.controlResumeSecret = ack.controlResumeSecret + this.leaseExpiresAt = ack.leaseExpiresAt + this.activeControl = control + for (const connectionId of ack.activeConnIds) { + this.options.onConnectionOwned(connectionId, this) + } + } + + private openConnection(message: RelayConnectionOpenMessage): void { + if (!this.acceptingConnections) { + return + } + this.options.onConnectionOwned(message.connId, this) + void this.transport.openConnection(message).catch(() => { + this.options.onConnectionReleased(message.connId, this) + }) + } +} diff --git a/src/main/runtime/relay/relay-control-protocol.ts b/src/main/runtime/relay/relay-control-protocol.ts new file mode 100644 index 00000000000..06cf61a95fd --- /dev/null +++ b/src/main/runtime/relay/relay-control-protocol.ts @@ -0,0 +1,146 @@ +import { z } from 'zod' +import type { RawData } from 'ws' +import { + DeviceCredentialInstalledSchema, + DeviceResumeConfirmedSchema +} from '../../../shared/mobile-relay-credential-contract' + +const OpaqueIdSchema = z.string().min(1).max(128) +const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) +const Base6432ByteSchema = z.string().regex(/^[A-Za-z0-9+/]{43}=$/) +const Base64Raw24ByteSchema = z.string().regex(/^[A-Za-z0-9+/]{32}$/) +const EpochMsSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) +const GenerationSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) + +export const RelayHostChallengeMessageSchema = z + .object({ + type: z.literal('host-challenge'), + challengeId: OpaqueIdSchema, + relayEphemeralPublicKeyB64: Base6432ByteSchema, + nonceB64: Base64Raw24ByteSchema, + ciphertextB64: z + .string() + .min(1) + .max(16 * 1024), + expiresAt: EpochMsSchema + }) + .strict() + +const PendingConnectionSchema = z + .object({ connId: OpaqueIdSchema, connTicket: Base64Url32ByteSchema }) + .strict() + +export const RelayHostHelloAckMessageSchema = z + .object({ + type: z.literal('host-hello-ack'), + v: z.literal(1), + generation: GenerationSchema, + controlResumeSecret: Base64Url32ByteSchema, + leaseExpiresAt: EpochMsSchema, + activeConnIds: z.array(OpaqueIdSchema).max(8), + pendingConns: z.array(PendingConnectionSchema).max(8) + }) + .strict() + +export const RelayConnectionOpenMessageSchema = z + .object({ + type: z.literal('conn-open'), + connId: OpaqueIdSchema, + connTicket: Base64Url32ByteSchema, + kind: z.enum(['invite', 'resume']), + relayDeviceId: OpaqueIdSchema, + attachDeadlineMs: z.number().int().positive().max(60_000) + }) + .strict() + +export const RelayDrainMessageSchema = z + .object({ + type: z.literal('drain'), + graceMs: z + .number() + .int() + .nonnegative() + .max(60 * 60 * 1000), + recovery: z.literal('resolve-director') + }) + .strict() + +export const RelayPingMessageSchema = z + .object({ type: z.literal('ping'), t: EpochMsSchema }) + .strict() + +export const RelayInviteCreatedMessageSchema = z + .object({ + type: z.literal('invite-created'), + reqId: OpaqueIdSchema, + inviteToken: Base64Url32ByteSchema, + expiresAt: EpochMsSchema, + maxAttempts: z.number().int().positive().max(16) + }) + .strict() + +export const RelayDeviceRevokedMessageSchema = z + .object({ type: z.literal('device-revoked'), reqId: OpaqueIdSchema }) + .strict() + +export const RelayDeviceCredentialInstalledMessageSchema = DeviceCredentialInstalledSchema.extend({ + type: z.literal('device-credential-installed') +}).strict() + +export const RelayDeviceCredentialInstallStatusResultMessageSchema = z.union([ + z + .object({ + type: z.literal('device-credential-install-status-result'), + v: z.literal(1), + reqId: OpaqueIdSchema, + state: z.literal('not-found') + }) + .strict(), + z + .object({ + type: z.literal('device-credential-install-status-result'), + v: z.literal(1), + reqId: OpaqueIdSchema, + state: z.literal('committed'), + result: DeviceCredentialInstalledSchema + }) + .strict() +]) + +export const RelayDeviceResumeConfirmedMessageSchema = DeviceResumeConfirmedSchema.extend({ + type: z.literal('device-resume-confirmed') +}).strict() + +export const RelayControlErrorMessageSchema = z + .object({ + type: z.literal('control-error'), + reqId: OpaqueIdSchema.optional(), + code: z.string().min(1).max(128) + }) + .strict() + +export type RelayHostChallengeMessage = z.infer +export type RelayHostHelloAckMessage = z.infer +export type RelayConnectionOpenMessage = z.infer +export type RelayDrainMessage = z.infer +export type RelayInviteCreatedMessage = z.infer +export type RelayDeviceCredentialInstalledMessage = z.infer< + typeof RelayDeviceCredentialInstalledMessageSchema +> +export type RelayDeviceCredentialInstallStatusResultMessage = z.infer< + typeof RelayDeviceCredentialInstallStatusResultMessageSchema +> +export type RelayDeviceResumeConfirmedMessage = z.infer< + typeof RelayDeviceResumeConfirmedMessageSchema +> + +export function parseRelayControlMessage(raw: RawData): Record | null { + try { + const parsed = JSON.parse(raw.toString()) as unknown + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : null + } catch { + return null + } +} diff --git a/src/main/runtime/relay/relay-control-requests.ts b/src/main/runtime/relay/relay-control-requests.ts new file mode 100644 index 00000000000..2a96b94e3ec --- /dev/null +++ b/src/main/runtime/relay/relay-control-requests.ts @@ -0,0 +1,185 @@ +import { + RelayControlErrorMessageSchema, + RelayDeviceCredentialInstalledMessageSchema, + RelayDeviceCredentialInstallStatusResultMessageSchema, + RelayDeviceRevokedMessageSchema, + RelayDeviceResumeConfirmedMessageSchema, + RelayInviteCreatedMessageSchema, + type RelayDeviceCredentialInstalledMessage, + type RelayDeviceCredentialInstallStatusResultMessage, + type RelayDeviceResumeConfirmedMessage, + type RelayInviteCreatedMessage +} from './relay-control-protocol' + +type PendingRequest = { + kind: 'invite' | 'revoke' | 'install' | 'install-status' | 'confirm' + resolve: (value: unknown) => void + reject: (error: Error) => void + timer: ReturnType +} + +export type DeviceCredentialInstallAuthorization = + | { mode: 'relay-basis'; basisConnId: string } + | { mode: 'authenticated-direct'; directAuthId: string } + +export class RelayControlRequests { + private readonly pending = new Map() + + get size(): number { + return this.pending.size + } + + createInvite( + reqId: string, + relayDeviceId: string, + send: (payload: object) => void + ): Promise { + return this.request( + reqId, + 'invite', + { type: 'invite-create', reqId, relayDeviceId }, + send + ) as Promise + } + + revokeDevice( + reqId: string, + relayDeviceId: string, + send: (payload: object) => void + ): Promise { + return this.request( + reqId, + 'revoke', + { type: 'device-revoke', reqId, relayDeviceId }, + send + ) as Promise + } + + installCredential( + reqId: string, + input: { + relayDeviceId: string + newResumeTokenHash: string + expectedCurrentHash?: string + authorization: DeviceCredentialInstallAuthorization + }, + send: (payload: object) => void + ): Promise { + return this.request( + reqId, + 'install', + { type: 'device-credential-install', v: 1, reqId, ...input }, + send + ) as Promise + } + + credentialInstallStatus( + reqId: string, + relayDeviceId: string, + send: (payload: object) => void + ): Promise { + return this.request( + reqId, + 'install-status', + { type: 'device-credential-install-status', v: 1, reqId, relayDeviceId }, + send + ) as Promise + } + + confirmResume( + reqId: string, + basisConnId: string, + send: (payload: object) => void + ): Promise { + return this.request( + reqId, + 'confirm', + { type: 'device-resume-confirm', v: 1, reqId, basisConnId }, + send + ) as Promise + } + + resolveMessage(message: Record): boolean { + const reqId = typeof message.reqId === 'string' ? message.reqId : null + const pending = reqId ? this.pending.get(reqId) : null + if (!pending || !reqId) { + return false + } + const error = RelayControlErrorMessageSchema.safeParse(message) + if (error.success) { + this.finish(reqId) + pending.reject(new Error(error.data.code)) + return true + } + if (pending.kind === 'invite') { + const invite = RelayInviteCreatedMessageSchema.safeParse(message) + if (!invite.success) { + return false + } + this.finish(reqId) + pending.resolve(invite.data) + return true + } + if (pending.kind === 'revoke') { + const revoked = RelayDeviceRevokedMessageSchema.safeParse(message) + if (!revoked.success) { + return false + } + this.finish(reqId) + pending.resolve(undefined) + return true + } + const schema = + pending.kind === 'install' + ? RelayDeviceCredentialInstalledMessageSchema + : pending.kind === 'install-status' + ? RelayDeviceCredentialInstallStatusResultMessageSchema + : RelayDeviceResumeConfirmedMessageSchema + const result = schema.safeParse(message) + if (!result.success) { + return false + } + this.finish(reqId) + pending.resolve(result.data) + return true + } + + rejectAll(error: Error): void { + for (const [reqId, pending] of this.pending) { + this.finish(reqId) + pending.reject(error) + } + } + + private request( + reqId: string, + kind: PendingRequest['kind'], + payload: object, + send: (payload: object) => void + ): Promise { + if (this.pending.has(reqId)) { + return Promise.reject(new Error('duplicate_relay_request_id')) + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(reqId) + reject(new Error('relay_control_request_timeout')) + }, 10_000) + this.pending.set(reqId, { kind, resolve, reject, timer }) + try { + send(payload) + } catch (error) { + this.finish(reqId) + reject(error) + } + }) + } + + private finish(reqId: string): void { + const pending = this.pending.get(reqId) + if (pending) { + clearTimeout(pending.timer) + this.pending.delete(reqId) + } + } +} diff --git a/src/main/runtime/relay/relay-demand-ledger.test.ts b/src/main/runtime/relay/relay-demand-ledger.test.ts new file mode 100644 index 00000000000..b8227e7d42d --- /dev/null +++ b/src/main/runtime/relay/relay-demand-ledger.test.ts @@ -0,0 +1,95 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { DeviceRegistry } from '../device-registry' +import { RelayDemandLedger } from './relay-demand-ledger' +import { RelayRevokeOutbox, type RelayDeviceBinding } from './relay-revoke-outbox' + +const ownerIdentityKey = 'user-1\0profile-1\0org-1' +const relayHostId = 'relay-host-1' + +function fixture(now: number) { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-relay-demand-')) + const deviceRegistry = new DeviceRegistry(userDataPath) + const revokeOutbox = new RelayRevokeOutbox(userDataPath) + const ledger = new RelayDemandLedger({ + deviceRegistry, + revokeOutbox, + relayHostId, + now: () => now + }) + return { userDataPath, deviceRegistry, revokeOutbox, ledger } +} + +function binding(relayDeviceId: string, inviteExpiresAt?: number): RelayDeviceBinding { + return { relayDeviceId, relayHostId, ownerIdentityKey, inviteExpiresAt } +} + +describe('RelayDemandLedger', () => { + it('reference-counts concurrent main-process work', () => { + const { ledger } = fixture(1_000) + const releaseFirst = ledger.acquireTransient('pairing:device-1') + const releaseSecond = ledger.acquireTransient('pairing:device-1') + expect(ledger.hasDemand(ownerIdentityKey)).toBe(true) + releaseFirst() + releaseFirst() + expect(ledger.hasDemand(ownerIdentityKey)).toBe(true) + releaseSecond() + expect(ledger.hasDemand(ownerIdentityKey)).toBe(false) + }) + + it('holds pending QR demand only through invite expiry', () => { + const { userDataPath, deviceRegistry, ledger } = fixture(1_000) + const pending = deviceRegistry.addDevice('Pending phone') + deviceRegistry.setRelayBinding(pending.deviceId, binding(pending.deviceId, 2_000)) + expect(ledger.hasDemand(ownerIdentityKey)).toBe(true) + expect(ledger.nextPendingExpiry()).toBe(2_000) + const restarted = new RelayDemandLedger({ + deviceRegistry: new DeviceRegistry(userDataPath), + revokeOutbox: new RelayRevokeOutbox(userDataPath), + relayHostId, + now: () => 1_500 + }) + expect(restarted.hasDemand(ownerIdentityKey)).toBe(true) + + const expiredFixture = fixture(3_000) + const expired = expiredFixture.deviceRegistry.addDevice('Expired phone') + expiredFixture.deviceRegistry.setRelayBinding( + expired.deviceId, + binding(expired.deviceId, 2_000) + ) + expect(expiredFixture.ledger.hasDemand(ownerIdentityKey)).toBe(false) + }) + + it('does not promote a scanned invite to provisioned demand before install', () => { + const { deviceRegistry, ledger } = fixture(3_000) + const scanned = deviceRegistry.addDevice('Scanned phone') + deviceRegistry.setRelayBinding(scanned.deviceId, binding(scanned.deviceId, 2_000)) + deviceRegistry.updateLastSeen(scanned.deviceId) + expect(ledger.hasDemand(ownerIdentityKey)).toBe(false) + }) + + it('keeps provisioned devices and revoke outbox work authoritative', () => { + const { deviceRegistry, revokeOutbox, ledger } = fixture(5_000) + const paired = deviceRegistry.addDevice('Paired phone') + deviceRegistry.setRelayBinding(paired.deviceId, binding(paired.deviceId)) + deviceRegistry.updateLastSeen(paired.deviceId) + expect(ledger.hasDemand(ownerIdentityKey)).toBe(true) + + deviceRegistry.removeDevice(paired.deviceId) + expect(ledger.hasDemand(ownerIdentityKey)).toBe(false) + revokeOutbox.enqueue(binding(paired.deviceId)) + expect(ledger.hasDemand(ownerIdentityKey)).toBe(true) + }) + + it('does not activate another signed-in identity or relay host', () => { + const { deviceRegistry, ledger } = fixture(1_000) + const pending = deviceRegistry.addDevice('Other phone') + deviceRegistry.setRelayBinding(pending.deviceId, { + ...binding(pending.deviceId, 2_000), + ownerIdentityKey: 'other-user\0profile\0org' + }) + expect(ledger.hasDemand(ownerIdentityKey)).toBe(false) + }) +}) diff --git a/src/main/runtime/relay/relay-demand-ledger.ts b/src/main/runtime/relay/relay-demand-ledger.ts new file mode 100644 index 00000000000..8bcc1a6a2df --- /dev/null +++ b/src/main/runtime/relay/relay-demand-ledger.ts @@ -0,0 +1,72 @@ +import type { DeviceRegistry } from '../device-registry' +import type { RelayRevokeOutbox } from './relay-revoke-outbox' + +type RelayDemandLedgerOptions = { + deviceRegistry: DeviceRegistry + revokeOutbox: RelayRevokeOutbox + relayHostId: string + now?: () => number +} + +export class RelayDemandLedger { + private readonly options: RelayDemandLedgerOptions + private readonly transientRefs = new Map() + + constructor(options: RelayDemandLedgerOptions) { + this.options = options + } + + acquireTransient(key: string): () => void { + this.transientRefs.set(key, (this.transientRefs.get(key) ?? 0) + 1) + let released = false + return () => { + if (released) { + return + } + released = true + const count = this.transientRefs.get(key) ?? 0 + if (count <= 1) { + this.transientRefs.delete(key) + } else { + this.transientRefs.set(key, count - 1) + } + } + } + + hasDemand(ownerIdentityKey: string): boolean { + if (this.transientRefs.size > 0) { + return true + } + if (this.options.revokeOutbox.pendingFor(ownerIdentityKey, this.options.relayHostId).length) { + return true + } + const now = (this.options.now ?? Date.now)() + return this.options.deviceRegistry.listDevices().some((device) => { + const binding = device.relayBinding + if ( + device.scope !== 'mobile' || + !binding || + binding.ownerIdentityKey !== ownerIdentityKey || + binding.relayHostId !== this.options.relayHostId + ) { + return false + } + // Why: E2EE authentication marks a scanned DeviceEntry as seen before + // relay credential install commits. Only removing the invite expiry at + // the durable install boundary promotes it to standing device demand. + return binding.inviteExpiresAt === undefined || binding.inviteExpiresAt > now + }) + } + + nextPendingExpiry(): number | null { + const now = (this.options.now ?? Date.now)() + let next: number | null = null + for (const device of this.options.deviceRegistry.listDevices()) { + const expiresAt = device.relayBinding?.inviteExpiresAt + if (expiresAt && expiresAt > now && (next === null || expiresAt < next)) { + next = expiresAt + } + } + return next + } +} diff --git a/src/main/runtime/relay/relay-host-proof.ts b/src/main/runtime/relay/relay-host-proof.ts new file mode 100644 index 00000000000..94db5b2f1b2 --- /dev/null +++ b/src/main/runtime/relay/relay-host-proof.ts @@ -0,0 +1,166 @@ +import { createHmac, timingSafeEqual } from 'node:crypto' +import nacl from 'tweetnacl' + +const HOST_PROOF_TRANSCRIPT_DOMAIN = 'orca-relay-host-proof/v1' +const HOST_CHALLENGE_PLAINTEXT_DOMAIN = 'orca-relay-host-challenge/v1' +const textEncoder = new TextEncoder() +const textDecoder = new TextDecoder() + +export type RelayHostChallenge = { + challengeId: string + relayEphemeralPublicKeyB64: string + nonceB64: string + ciphertextB64: string + expiresAt: number +} + +export type RelayHostProofContext = { + relayOrigin: string + userId: string + profileId: string + organizationId: string + relayHostId: string + hostPublicKey: Uint8Array + hostSecretKey: Uint8Array + assignmentEpoch: number + previousGeneration?: number + resumeRequested: boolean + now?: () => number +} + +function decodeCanonicalBase64(value: string, expectedBytes: number): Uint8Array | null { + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { + return null + } + const decoded = Buffer.from(value, 'base64') + return decoded.byteLength === expectedBytes && decoded.toString('base64') === value + ? decoded + : null +} + +function uint64(value: number): Uint8Array { + const bytes = new Uint8Array(8) + new DataView(bytes.buffer).setBigUint64(0, BigInt(value), false) + return bytes +} + +function equal(left: Uint8Array | undefined, right: Uint8Array): boolean { + return Boolean(left && left.byteLength === right.byteLength && timingSafeEqual(left, right)) +} + +function parseTranscript(transcript: Uint8Array): Map | null { + const fields = new Map() + const view = new DataView(transcript.buffer, transcript.byteOffset, transcript.byteLength) + let offset = 0 + try { + while (offset < transcript.byteLength) { + const nameLength = view.getUint32(offset, false) + offset += 4 + const name = textDecoder.decode(transcript.slice(offset, offset + nameLength)) + offset += nameLength + const valueLength = view.getUint32(offset, false) + offset += 4 + if (fields.has(name) || offset + valueLength > transcript.byteLength) { + return null + } + fields.set(name, transcript.slice(offset, offset + valueLength)) + offset += valueLength + } + } catch { + return null + } + return offset === transcript.byteLength ? fields : null +} + +function readUint64(value: Uint8Array | undefined): number | null { + if (!value || value.byteLength !== 8) { + return null + } + const parsed = new DataView(value.buffer, value.byteOffset, value.byteLength).getBigUint64( + 0, + false + ) + return parsed <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(parsed) : null +} + +function validateTranscript( + transcript: Uint8Array, + challenge: RelayHostChallenge, + context: RelayHostProofContext, + relayKey: Uint8Array, + nonce: Uint8Array +): boolean { + const fields = parseTranscript(transcript) + if (!fields || fields.size !== 16) { + return false + } + const now = (context.now ?? Date.now)() + const issuedAt = readUint64(fields.get('issuedAt')) + const expiresAt = readUint64(fields.get('expiresAt')) + const previousGeneration = fields.get('previousGeneration') + const expectedPrevious = + context.previousGeneration === undefined ? new Uint8Array() : uint64(context.previousGeneration) + return ( + issuedAt !== null && + issuedAt <= now && + now <= challenge.expiresAt && + challenge.expiresAt - issuedAt <= 10_000 && + expiresAt === challenge.expiresAt && + equal(fields.get('protocol'), textEncoder.encode(HOST_PROOF_TRANSCRIPT_DOMAIN)) && + equal(fields.get('version'), new Uint8Array([1])) && + equal(fields.get('relayOrigin'), textEncoder.encode(context.relayOrigin)) && + equal(fields.get('relayEphemeralPublicKey'), relayKey) && + equal(fields.get('challengeNonce'), nonce) && + equal(fields.get('challengeId'), textEncoder.encode(challenge.challengeId)) && + equal(fields.get('userId'), textEncoder.encode(context.userId)) && + equal(fields.get('profileId'), textEncoder.encode(context.profileId)) && + equal(fields.get('organizationId'), textEncoder.encode(context.organizationId)) && + equal(fields.get('relayHostId'), textEncoder.encode(context.relayHostId)) && + equal(fields.get('hostPublicKey'), context.hostPublicKey) && + equal(fields.get('assignmentEpoch'), uint64(context.assignmentEpoch)) && + equal(previousGeneration, expectedPrevious) && + equal(fields.get('resumeRequested'), new Uint8Array([context.resumeRequested ? 1 : 0])) + ) +} + +export function answerRelayHostChallenge( + challenge: RelayHostChallenge, + context: RelayHostProofContext +): string | null { + const relayKey = decodeCanonicalBase64(challenge.relayEphemeralPublicKeyB64, 32) + const nonce = decodeCanonicalBase64(challenge.nonceB64, 24) + const ciphertext = Buffer.from(challenge.ciphertextB64, 'base64') + if (!relayKey || !nonce || ciphertext.toString('base64') !== challenge.ciphertextB64) { + return null + } + const plaintext = nacl.box.open(ciphertext, nonce, relayKey, context.hostSecretKey) + if (!plaintext) { + return null + } + const domain = textEncoder.encode(`${HOST_CHALLENGE_PLAINTEXT_DOMAIN}\0`) + if ( + !equal(plaintext.slice(0, domain.byteLength), domain) || + plaintext.byteLength < domain.byteLength + 36 + ) { + return null + } + const transcriptLength = new DataView( + plaintext.buffer, + plaintext.byteOffset + domain.byteLength, + 4 + ).getUint32(0, false) + const transcriptStart = domain.byteLength + 4 + const secretStart = transcriptStart + transcriptLength + if (secretStart + 32 !== plaintext.byteLength) { + return null + } + const transcript = plaintext.slice(transcriptStart, secretStart) + if (!validateTranscript(transcript, challenge, context, relayKey, nonce)) { + return null + } + const secret = plaintext.slice(secretStart) + return createHmac('sha256', secret) + .update(textEncoder.encode(`${HOST_PROOF_TRANSCRIPT_DOMAIN}\0ack\0`)) + .update(transcript) + .digest('base64') +} diff --git a/src/main/runtime/relay/relay-http-client.test.ts b/src/main/runtime/relay/relay-http-client.test.ts new file mode 100644 index 00000000000..3a786210ba1 --- /dev/null +++ b/src/main/runtime/relay/relay-http-client.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest' +import nacl from 'tweetnacl' +import { exchangeRelayAuthorization, requestRelayAssignment } from './relay-http-client' + +describe('relay HTTP client', () => { + it('exchanges only the ordinary bearer for a host-bound relay token', async () => { + const keypair = nacl.box.keyPair() + const fetch = vi.fn(async () => + Response.json({ relayToken: 'scoped-relay-token', expiresAt: Date.now() + 300_000 }) + ) + await expect( + exchangeRelayAuthorization({ + endpoint: 'https://auth.example/v1/desktop/auth/relay-token', + accessToken: 'ordinary-access-token', + keypair: { + ...keypair, + publicKeyB64: Buffer.from(keypair.publicKey).toString('base64') + }, + fetch + }) + ).resolves.toMatchObject({ relayToken: 'scoped-relay-token' }) + const request = fetch.mock.calls[0]! + expect(request[0]).toBe('https://auth.example/v1/desktop/auth/relay-token') + expect(request[1]?.headers).toEqual({ + authorization: 'Bearer ordinary-access-token', + 'content-type': 'application/json' + }) + expect(JSON.parse(String(request[1]?.body))).toEqual({ + relayHostId: expect.stringMatching(/^[A-Za-z0-9_-]{16}$/), + hostPublicKeyB64: Buffer.from(keypair.publicKey).toString('base64') + }) + }) + + it('requests assignment without putting credentials in the URL', async () => { + const fetch = vi.fn(async () => + Response.json({ + v: 1, + cellUrl: 'https://relay-c1.example', + assignmentEpoch: 4, + lease: 'signed-assignment' + }) + ) + await expect( + requestRelayAssignment({ + directorUrl: 'https://relay.example', + relayToken: 'scoped-token', + relayHostId: 'AbCdEf0123_-xyZ9', + fetch + }) + ).resolves.toMatchObject({ assignmentEpoch: 4 }) + expect(fetch.mock.calls[0]?.[0]).toBe('https://relay.example/v1/assign') + expect(fetch.mock.calls[0]?.[1]?.headers).toMatchObject({ + authorization: 'Bearer scoped-token' + }) + }) + + it('rejects data-plane supplied non-origin URLs', async () => { + const fetch = vi.fn(async () => + Response.json({ + v: 1, + cellUrl: 'https://relay-c1.example/path?token=bad', + assignmentEpoch: 4, + lease: 'signed-assignment' + }) + ) + await expect( + requestRelayAssignment({ + directorUrl: 'https://relay.example', + relayToken: 'scoped-token', + relayHostId: 'AbCdEf0123_-xyZ9', + fetch + }) + ).rejects.toThrow('relay_assignment_failed_502') + }) +}) diff --git a/src/main/runtime/relay/relay-http-client.ts b/src/main/runtime/relay/relay-http-client.ts new file mode 100644 index 00000000000..ddda205d12b --- /dev/null +++ b/src/main/runtime/relay/relay-http-client.ts @@ -0,0 +1,106 @@ +import { createHash } from 'node:crypto' +import { z } from 'zod' +import type { E2EEKeypair } from '../e2ee-keypair' + +const RelayTokenResponseSchema = z + .object({ + relayToken: z + .string() + .min(1) + .max(8 * 1024), + expiresAt: z.number().int().positive().max(Number.MAX_SAFE_INTEGER) + }) + .strict() + +const AssignmentResponseSchema = z + .object({ + v: z.literal(1), + cellUrl: z.string().min(1).max(2048), + assignmentEpoch: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + lease: z + .string() + .min(1) + .max(8 * 1024) + }) + .strict() + +export type RelayAuthorization = z.infer +export type RelayAssignment = z.infer + +export class RelayHttpError extends Error { + constructor( + readonly operation: 'token-exchange' | 'assignment', + readonly statusCode: number + ) { + super(`relay_${operation}_failed_${statusCode}`) + } +} + +export function deriveRelayHostId(publicKey: Uint8Array): string { + return createHash('sha256').update(publicKey).digest('base64url').slice(0, 16) +} + +function isAllowedRelayOrigin(value: string): boolean { + try { + const url = new URL(value) + const loopback = + url.hostname === '127.0.0.1' || url.hostname === 'localhost' || url.hostname === '[::1]' + return ( + url.origin === value && (url.protocol === 'https:' || (url.protocol === 'http:' && loopback)) + ) + } catch { + return false + } +} + +export async function exchangeRelayAuthorization(input: { + endpoint: string + accessToken: string + keypair: E2EEKeypair + fetch?: typeof globalThis.fetch +}): Promise { + const relayHostId = deriveRelayHostId(input.keypair.publicKey) + const response = await (input.fetch ?? globalThis.fetch)(input.endpoint, { + method: 'POST', + headers: { + authorization: `Bearer ${input.accessToken}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ relayHostId, hostPublicKeyB64: input.keypair.publicKeyB64 }) + }) + if (!response.ok) { + throw new RelayHttpError('token-exchange', response.status) + } + const parsed = RelayTokenResponseSchema.safeParse(await response.json()) + if (!parsed.success) { + throw new RelayHttpError('token-exchange', 502) + } + return parsed.data +} + +export async function requestRelayAssignment(input: { + directorUrl: string + relayToken: string + relayHostId: string + fetch?: typeof globalThis.fetch +}): Promise { + if (!isAllowedRelayOrigin(input.directorUrl)) { + throw new RelayHttpError('assignment', 400) + } + const response = await (input.fetch ?? globalThis.fetch)(`${input.directorUrl}/v1/assign`, { + method: 'POST', + headers: { + authorization: `Bearer ${input.relayToken}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ v: 1, relayHostId: input.relayHostId }) + }) + if (!response.ok) { + throw new RelayHttpError('assignment', response.status) + } + const parsed = AssignmentResponseSchema.safeParse(await response.json()) + if (!parsed.success || !isAllowedRelayOrigin(parsed.data.cellUrl)) { + throw new RelayHttpError('assignment', 502) + } + return parsed.data +} diff --git a/src/main/runtime/relay/relay-origin-pool.ts b/src/main/runtime/relay/relay-origin-pool.ts new file mode 100644 index 00000000000..6f0f8715243 --- /dev/null +++ b/src/main/runtime/relay/relay-origin-pool.ts @@ -0,0 +1,301 @@ +import type WebSocket from 'ws' +import type { E2EEKeypair } from '../e2ee-keypair' +import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring' +import { RelayControlOrigin } from './relay-control-origin' +import type { RelayControlClient } from './relay-control-client' +import type { RelayDrainMessage } from './relay-control-protocol' +import { requestRelayAssignment, type RelayAssignment } from './relay-http-client' +import type { RelayBrokerStatus, RelayIdentity } from './relay-session-broker-contract' + +type RelayOriginPoolOptions = { + directorUrl: string + relayHostId: string + identity: RelayIdentity + keypair: E2EEKeypair + appVersion: string + mobileSocketWiring: MobileSocketWiring + isCurrent: () => boolean + onStatus: (status: RelayBrokerStatus) => void + fetch?: typeof globalThis.fetch + createControlSocket?: (url: string, relayJwt: string) => WebSocket + createDataSocket?: (url: string) => WebSocket + random?: () => number + now?: () => number +} + +export class RelayOriginPool { + private readonly options: RelayOriginPoolOptions + private activeOrigin: RelayControlOrigin | null = null + private readonly origins = new Set() + private readonly drainingOrigins = new Set() + private readonly basisOrigins = new Map() + private readonly drainTimers = new Map>() + private assignment: RelayAssignment | null = null + private relayJwt: string | null = null + private rotationTimer: ReturnType | null = null + private rotationPromise: Promise | null = null + private closed = false + + constructor(options: RelayOriginPoolOptions) { + this.options = options + } + + get activeAssignment(): RelayAssignment | null { + return this.assignment + } + + get activeControl(): RelayControlClient | null { + return this.activeOrigin?.availableControl ?? null + } + + controlForBasis(basisConnId: string): RelayControlClient | null { + return this.basisOrigins.get(basisConnId)?.availableControl ?? null + } + + async openInitial(assignment: RelayAssignment, relayJwt: string): Promise { + this.assignment = assignment + this.relayJwt = relayJwt + const origin = this.createOrigin(assignment, relayJwt) + this.origins.add(origin) + await origin.open() + this.assertCurrent() + this.activeOrigin = origin + this.scheduleControlRotation() + } + + refreshAuthorization(relayJwt: string): void { + this.relayJwt = relayJwt + for (const origin of this.origins) { + origin.refreshAuthorization(relayJwt) + } + } + + closeNow(): void { + if (this.closed) { + return + } + this.closed = true + if (this.rotationTimer) { + clearTimeout(this.rotationTimer) + this.rotationTimer = null + } + for (const timer of this.drainTimers.values()) { + clearTimeout(timer) + } + this.drainTimers.clear() + for (const origin of this.origins) { + origin.closeNow() + } + this.origins.clear() + this.drainingOrigins.clear() + this.basisOrigins.clear() + this.activeOrigin = null + } + + private createOrigin(assignment: RelayAssignment, relayJwt: string): RelayControlOrigin { + return new RelayControlOrigin({ + assignment, + relayJwt, + relayHostId: this.options.relayHostId, + identity: this.options.identity, + keypair: this.options.keypair, + appVersion: this.options.appVersion, + mobileSocketWiring: this.options.mobileSocketWiring, + createControlSocket: this.options.createControlSocket, + createDataSocket: this.options.createDataSocket, + onConnectionOwned: (connectionId, origin) => { + if (this.isCurrent() && this.origins.has(origin)) { + this.basisOrigins.set(connectionId, origin) + } + }, + onConnectionReleased: (connectionId, origin) => { + if (this.basisOrigins.get(connectionId) === origin) { + this.basisOrigins.delete(connectionId) + } + this.maybeCloseDrainedOrigin(origin) + }, + onDrain: (origin, message) => this.handleDrain(origin, message), + onClose: (origin) => { + if (origin === this.activeOrigin && this.isCurrent()) { + this.options.onStatus('offline') + this.handleDrain(origin, { + type: 'drain', + graceMs: 0, + recovery: 'resolve-director' + }) + } + } + }) + } + + private handleDrain(origin: RelayControlOrigin, message: RelayDrainMessage): void { + if (!this.isCurrent() || origin !== this.activeOrigin) { + return + } + origin.markDraining() + this.drainingOrigins.add(origin) + this.options.onStatus('draining') + if (!this.rotationPromise) { + this.rotationPromise = this.resolveDrainTarget(origin, message).finally(() => { + this.rotationPromise = null + }) + } + } + + private async resolveDrainTarget( + origin: RelayControlOrigin, + message: RelayDrainMessage + ): Promise { + try { + if (!this.relayJwt) { + throw new Error('relay_authorization_unavailable') + } + // Why: only the configured director can choose a migration target. + const assignment = await requestRelayAssignment({ + directorUrl: this.options.directorUrl, + relayToken: this.relayJwt, + relayHostId: this.options.relayHostId, + fetch: this.options.fetch + }) + this.assertCurrent() + if (assignment.cellUrl === origin.cellUrl) { + let rebound = false + try { + await origin.rebind(this.relayJwt, assignment) + rebound = true + } catch { + // Why: a restarted cell cannot know the prior process's resume secret; + // after rebind fails, a fresh generation is the only recoverable path. + await this.activateTarget(origin, assignment, this.relayJwt, message.graceMs) + } + if (rebound) { + this.assertCurrent() + this.activeOrigin = origin + this.assignment = assignment + this.drainingOrigins.delete(origin) + } + } else { + await this.activateTarget(origin, assignment, this.relayJwt, message.graceMs) + } + this.options.onStatus('registered') + this.scheduleControlRotation() + } catch { + if (this.isCurrent()) { + const random = this.options.random ?? Math.random + setTimeout(() => this.handleDrain(origin, message), 250 + Math.floor(random() * 751)) + } + } + } + + private async activateTarget( + origin: RelayControlOrigin, + assignment: RelayAssignment, + relayJwt: string, + graceMs: number + ): Promise { + const target = this.createOrigin(assignment, relayJwt) + this.origins.add(target) + try { + await target.open() + this.assertCurrent() + } catch (error) { + this.origins.delete(target) + target.closeNow() + throw error + } + this.activeOrigin = target + this.assignment = assignment + this.scheduleDrainDeadline(origin, graceMs) + this.maybeCloseDrainedOrigin(origin) + } + + private scheduleControlRotation(): void { + if (this.rotationTimer) { + clearTimeout(this.rotationTimer) + } + const origin = this.activeOrigin + if (!origin || this.closed) { + this.rotationTimer = null + return + } + const now = (this.options.now ?? Date.now)() + const random = this.options.random ?? Math.random + const earlyMs = 60_000 + Math.floor(random() * 60_001) + const delay = Math.max(0, origin.controlLeaseExpiresAt - earlyMs - now) + this.rotationTimer = setTimeout(() => void this.rebindActiveControl(origin), delay) + } + + private async rebindActiveControl(origin: RelayControlOrigin): Promise { + this.rotationTimer = null + if (!this.isCurrent() || origin !== this.activeOrigin || this.rotationPromise) { + return + } + if (!this.relayJwt || !this.assignment) { + return + } + try { + await origin.rebind(this.relayJwt, this.assignment) + this.assertCurrent() + this.scheduleControlRotation() + } catch { + if (this.isCurrent() && origin === this.activeOrigin) { + const random = this.options.random ?? Math.random + this.rotationTimer = setTimeout( + () => void this.rebindActiveControl(origin), + 5_000 + Math.floor(random() * 10_001) + ) + } + } + } + + private scheduleDrainDeadline(origin: RelayControlOrigin, graceMs: number): void { + const existing = this.drainTimers.get(origin) + if (existing) { + clearTimeout(existing) + } + this.drainTimers.set( + origin, + setTimeout(() => this.closeOrigin(origin), graceMs) + ) + } + + private maybeCloseDrainedOrigin(origin: RelayControlOrigin): void { + if ( + !this.drainingOrigins.has(origin) || + origin.pendingRequestCount > 0 || + [...this.basisOrigins.values()].includes(origin) + ) { + return + } + this.closeOrigin(origin) + } + + private closeOrigin(origin: RelayControlOrigin): void { + if (origin === this.activeOrigin) { + return + } + const timer = this.drainTimers.get(origin) + if (timer) { + clearTimeout(timer) + this.drainTimers.delete(origin) + } + for (const [connectionId, owner] of this.basisOrigins) { + if (owner === origin) { + this.basisOrigins.delete(connectionId) + } + } + this.drainingOrigins.delete(origin) + this.origins.delete(origin) + origin.closeNow() + } + + private assertCurrent(): void { + if (!this.isCurrent()) { + throw new Error('stale_relay_origin_pool') + } + } + + private isCurrent(): boolean { + return !this.closed && this.options.isCurrent() + } +} diff --git a/src/main/runtime/relay/relay-revoke-outbox.test.ts b/src/main/runtime/relay/relay-revoke-outbox.test.ts new file mode 100644 index 00000000000..54ab0473049 --- /dev/null +++ b/src/main/runtime/relay/relay-revoke-outbox.test.ts @@ -0,0 +1,32 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { RelayRevokeOutbox } from './relay-revoke-outbox' + +describe('RelayRevokeOutbox', () => { + const paths: string[] = [] + afterEach(() => { + for (const path of paths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } + }) + + it('durably retains an idempotent account-scoped revoke after local deletion', () => { + const path = mkdtempSync(join(tmpdir(), 'orca-relay-revoke-')) + paths.push(path) + const binding = { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId: 'device-1', + ownerIdentityKey: 'user-1\0profile-1\0org-1' + } + const first = new RelayRevokeOutbox(path).enqueue(binding) + const reloaded = new RelayRevokeOutbox(path) + expect(reloaded.enqueue(binding).reqId).toBe(first.reqId) + expect(reloaded.pendingFor(binding.ownerIdentityKey, binding.relayHostId)).toEqual([first]) + reloaded.remove(first.reqId) + expect( + new RelayRevokeOutbox(path).pendingFor(binding.ownerIdentityKey, binding.relayHostId) + ).toEqual([]) + }) +}) diff --git a/src/main/runtime/relay/relay-revoke-outbox.ts b/src/main/runtime/relay/relay-revoke-outbox.ts new file mode 100644 index 00000000000..8a7e8cc19bb --- /dev/null +++ b/src/main/runtime/relay/relay-revoke-outbox.ts @@ -0,0 +1,93 @@ +import { randomUUID } from 'node:crypto' +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { hardenExistingSecureFile, writeSecureJsonFile } from '../../../shared/secure-file' + +export type RelayDeviceBinding = { + relayHostId: string + relayDeviceId: string + ownerIdentityKey: string + inviteExpiresAt?: number +} + +export type RelayRevokeOutboxItem = RelayDeviceBinding & { + reqId: string + createdAt: number +} + +const OUTBOX_FILENAME = 'mobile-relay-revoke-outbox.json' + +function isItem(value: unknown): value is RelayRevokeOutboxItem { + if (!value || typeof value !== 'object') { + return false + } + const item = value as Partial + return ( + typeof item.reqId === 'string' && + typeof item.relayHostId === 'string' && + typeof item.relayDeviceId === 'string' && + typeof item.ownerIdentityKey === 'string' && + (item.inviteExpiresAt === undefined || + (typeof item.inviteExpiresAt === 'number' && Number.isFinite(item.inviteExpiresAt))) && + typeof item.createdAt === 'number' && + Number.isFinite(item.createdAt) + ) +} + +export class RelayRevokeOutbox { + private readonly path: string + private items: RelayRevokeOutboxItem[] + + constructor(userDataPath: string) { + this.path = join(userDataPath, OUTBOX_FILENAME) + this.items = this.load() + } + + enqueue(binding: RelayDeviceBinding): RelayRevokeOutboxItem { + const existing = this.items.find( + (item) => + item.relayHostId === binding.relayHostId && + item.relayDeviceId === binding.relayDeviceId && + item.ownerIdentityKey === binding.ownerIdentityKey + ) + if (existing) { + return existing + } + const item = { ...binding, reqId: randomUUID(), createdAt: Date.now() } + this.items.push(item) + this.save() + return item + } + + pendingFor(ownerIdentityKey: string, relayHostId: string): readonly RelayRevokeOutboxItem[] { + return this.items.filter( + (item) => item.ownerIdentityKey === ownerIdentityKey && item.relayHostId === relayHostId + ) + } + + remove(reqId: string): void { + const next = this.items.filter((item) => item.reqId !== reqId) + if (next.length === this.items.length) { + return + } + this.items = next + this.save() + } + + private load(): RelayRevokeOutboxItem[] { + if (!existsSync(this.path)) { + return [] + } + try { + hardenExistingSecureFile(this.path) + const parsed: unknown = JSON.parse(readFileSync(this.path, 'utf-8')) + return Array.isArray(parsed) ? parsed.filter(isItem) : [] + } catch { + return [] + } + } + + private save(): void { + writeSecureJsonFile(this.path, this.items) + } +} diff --git a/src/main/runtime/relay/relay-session-broker-contract.ts b/src/main/runtime/relay/relay-session-broker-contract.ts new file mode 100644 index 00000000000..3c7a707e5bd --- /dev/null +++ b/src/main/runtime/relay/relay-session-broker-contract.ts @@ -0,0 +1,30 @@ +import type WebSocket from 'ws' +import type { OrcaCloudAuthConfig } from '../../orca-profiles/profile-cloud-auth-config' +import type { MobileRelayStatus } from '../../../shared/mobile-relay-status' +import type { E2EEKeypair } from '../e2ee-keypair' +import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring' + +export type RelayBrokerStatus = MobileRelayStatus + +export type RelayIdentity = { + userId: string + profileId: string + organizationId: string +} + +export type RelaySessionBrokerOptions = { + authConfig: OrcaCloudAuthConfig + accessToken: string + identity: RelayIdentity + keypair: E2EEKeypair + appVersion: string + mobileSocketWiring: MobileSocketWiring + isCurrent: () => boolean + refreshAccessToken: () => Promise + onStatus: (status: RelayBrokerStatus) => void + fetch?: typeof globalThis.fetch + createControlSocket?: (url: string, relayJwt: string) => WebSocket + createDataSocket?: (url: string) => WebSocket + random?: () => number + now?: () => number +} diff --git a/src/main/runtime/relay/relay-session-broker.test.ts b/src/main/runtime/relay/relay-session-broker.test.ts new file mode 100644 index 00000000000..d2e0ff0021d --- /dev/null +++ b/src/main/runtime/relay/relay-session-broker.test.ts @@ -0,0 +1,327 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import nacl from 'tweetnacl' +import type { OrcaCloudAuthConfig } from '../../orca-profiles/profile-cloud-auth-config' +import type { RelayHostHelloAckMessage } from './relay-control-protocol' +import type * as RelayHttpClientModule from './relay-http-client' + +const fakes = vi.hoisted(() => ({ + controls: [] as { + options: { + onConnectionOpen(message: { + connId: string + connTicket: string + kind: 'invite' | 'resume' + relayDeviceId: string + attachDeadlineMs: number + }): void + onDrain(message: { type: 'drain'; graceMs: number; recovery: 'resolve-director' }): void + onClose(code: number): void + previousGeneration?: number + controlResumeSecret?: string + } + connect: ReturnType + closeNow: ReturnType + confirmResume: ReturnType + installCredential: ReturnType + pendingRequestCount: number + }[], + transports: [] as { + start: ReturnType + stop: ReturnType + setGeneration: ReturnType + metadataFor: ReturnType + openConnection: ReturnType + }[], + controlConnect: vi.fn(), + exchange: vi.fn(), + assign: vi.fn() +})) + +vi.mock('./relay-http-client', async (importOriginal) => ({ + ...(await importOriginal()), + exchangeRelayAuthorization: fakes.exchange, + requestRelayAssignment: fakes.assign +})) + +vi.mock('./relay-control-client', () => ({ + RelayControlClient: class { + connect = fakes.controlConnect + closeNow = vi.fn() + confirmResume = vi.fn().mockResolvedValue({ + type: 'device-resume-confirmed', + v: 1, + reqId: 'confirm-1', + currentVersion: 1, + acceptedAs: 'current', + renewed: true, + resumeExpiresAt: 100_000 + }) + installCredential = vi.fn().mockResolvedValue({ + type: 'device-credential-installed', + v: 1, + reqId: 'install-1', + authorizationMode: 'relay-basis', + currentVersion: 1, + resumeExpiresAt: 100_000 + }) + pendingRequestCount = 0 + + constructor(readonly options: (typeof fakes.controls)[number]['options']) { + fakes.controls.push(this) + } + } +})) + +vi.mock('../rpc/relay-transport', () => ({ + CloudRelayTransport: class { + start = vi.fn().mockResolvedValue(undefined) + stop = vi.fn().mockResolvedValue(undefined) + setGeneration = vi.fn() + metadataFor = vi.fn() + openConnection = vi.fn().mockResolvedValue(undefined) + + constructor() { + fakes.transports.push(this) + } + } +})) + +import { RelaySessionBroker, StaleRelayBrokerError } from './relay-session-broker' + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +describe('RelaySessionBroker lifecycle ownership', () => { + beforeEach(() => { + fakes.controls.length = 0 + fakes.transports.length = 0 + fakes.controlConnect.mockReset() + fakes.exchange.mockReset().mockResolvedValue({ relayToken: 'relay-jwt', expiresAt: 1_000_000 }) + fakes.assign.mockReset().mockResolvedValue({ + cellUrl: 'https://relay.example.test', + assignmentEpoch: 1, + leaseExpiresAt: 60_000 + }) + }) + + it('closes partially opened resources without publishing stale state', async () => { + const controlAck = deferred() + fakes.controlConnect.mockReturnValue(controlAck.promise) + let current = true + const statuses: string[] = [] + const keypair = nacl.box.keyPair() + const connecting = RelaySessionBroker.connect({ + authConfig: { + relayTokenEndpoint: 'https://auth.example.test/v1/relay-token', + relayDirectorUrl: 'https://relay.example.test' + } as OrcaCloudAuthConfig, + accessToken: 'access-token', + identity: { userId: 'user-1', profileId: 'profile-1', organizationId: 'org-1' }, + keypair: { + ...keypair, + publicKeyB64: Buffer.from(keypair.publicKey).toString('base64') + }, + appVersion: '1.0.0', + mobileSocketWiring: { attachTransport: vi.fn() } as never, + isCurrent: () => current, + refreshAccessToken: async () => null, + onStatus: (status) => statuses.push(status) + }) + await vi.waitFor(() => expect(fakes.controls).toHaveLength(1)) + current = false + controlAck.resolve({ + type: 'host-hello-ack', + v: 1, + generation: 1, + controlResumeSecret: 'A'.repeat(43), + leaseExpiresAt: 1_000_000, + activeConnIds: [], + pendingConns: [] + }) + + await expect(connecting).rejects.toBeInstanceOf(StaleRelayBrokerError) + expect(fakes.controls[0]!.closeNow).toHaveBeenCalledOnce() + expect(fakes.transports[0]!.stop).toHaveBeenCalledOnce() + expect(statuses).toEqual(['connecting']) + }) + + it('activates a new origin while keeping basis-bound work on the drained origin', async () => { + const firstAck: RelayHostHelloAckMessage = { + type: 'host-hello-ack', + v: 1, + generation: 1, + controlResumeSecret: 'A'.repeat(43), + leaseExpiresAt: 1_000_000, + activeConnIds: [], + pendingConns: [] + } + fakes.controlConnect.mockResolvedValueOnce(firstAck).mockResolvedValueOnce({ + ...firstAck, + generation: 2, + controlResumeSecret: 'B'.repeat(43) + }) + fakes.assign + .mockResolvedValueOnce({ + cellUrl: 'https://relay-c1.example.test', + assignmentEpoch: 1, + leaseExpiresAt: 1_000_000 + }) + .mockResolvedValueOnce({ + cellUrl: 'https://relay-c2.example.test', + assignmentEpoch: 2, + leaseExpiresAt: 2_000_000 + }) + const broker = await RelaySessionBroker.connect(brokerOptions({ onStatus: vi.fn() })) + fakes.controls[0]!.options.onConnectionOpen({ + connId: 'old-basis', + connTicket: 'T'.repeat(43), + kind: 'resume', + relayDeviceId: 'device-1', + attachDeadlineMs: 1_000 + }) + expect(brokerBasisIds(broker)).toEqual(['old-basis']) + fakes.controls[0]!.options.onDrain({ + type: 'drain', + graceMs: 30_000, + recovery: 'resolve-director' + }) + await vi.waitFor(() => expect(fakes.controls).toHaveLength(2)) + + expect(broker.endpoint?.cellUrl).toBe('https://relay-c2.example.test') + expect(fakes.transports[0]!.openConnection).toHaveBeenCalledOnce() + expect(brokerBasisIds(broker)).toEqual(['old-basis']) + expect(fakes.controls[0]!.closeNow).not.toHaveBeenCalled() + expect(fakes.transports[0]!.stop).not.toHaveBeenCalled() + await broker.confirmResume('old-basis', 'confirm-1') + expect(fakes.controls[0]!.confirmResume).toHaveBeenCalledWith('old-basis', 'confirm-1') + await broker.installCredential( + 'device-1', + { reqId: 'install-1', newResumeTokenHash: 'H'.repeat(43) }, + { mode: 'relay-basis', basisConnId: 'old-basis' } + ) + expect(fakes.controls[0]!.installCredential).toHaveBeenCalledOnce() + }) + + it('rebinds the same process generation with its control resume secret', async () => { + const ack: RelayHostHelloAckMessage = { + type: 'host-hello-ack', + v: 1, + generation: 7, + controlResumeSecret: 'R'.repeat(43), + leaseExpiresAt: 1_000_000, + activeConnIds: ['existing-basis'], + pendingConns: [] + } + fakes.controlConnect.mockResolvedValueOnce(ack).mockResolvedValueOnce({ + ...ack, + leaseExpiresAt: 2_000_000 + }) + fakes.assign + .mockResolvedValueOnce({ + cellUrl: 'https://relay.example.test', + assignmentEpoch: 1, + leaseExpiresAt: 1_000_000 + }) + .mockResolvedValueOnce({ + cellUrl: 'https://relay.example.test', + assignmentEpoch: 1, + leaseExpiresAt: 2_000_000 + }) + const broker = await RelaySessionBroker.connect(brokerOptions()) + expect(brokerBasisIds(broker)).toEqual(['existing-basis']) + fakes.controls[0]!.options.onDrain({ + type: 'drain', + graceMs: 5_000, + recovery: 'resolve-director' + }) + await vi.waitFor(() => expect(fakes.controls).toHaveLength(2)) + + expect(fakes.transports).toHaveLength(1) + expect(fakes.controls[1]!.options.previousGeneration).toBe(7) + expect(fakes.controls[1]!.options.controlResumeSecret).toBe('R'.repeat(43)) + await vi.waitFor(() => expect(brokerBasisIds(broker)).toEqual(['existing-basis'])) + await broker.confirmResume('existing-basis', 'confirm-1') + expect(fakes.controls[1]!.confirmResume).toHaveBeenCalledOnce() + }) + + it('opens a fresh same-cell generation when process-local rebind state is lost', async () => { + const ack: RelayHostHelloAckMessage = { + type: 'host-hello-ack', + v: 1, + generation: 7, + controlResumeSecret: 'R'.repeat(43), + leaseExpiresAt: 1_000_000, + activeConnIds: [], + pendingConns: [] + } + fakes.controlConnect + .mockResolvedValueOnce(ack) + .mockRejectedValueOnce(new Error('relay_control_closed_4401')) + .mockResolvedValueOnce({ + ...ack, + generation: 1, + controlResumeSecret: 'N'.repeat(43), + leaseExpiresAt: 2_000_000 + }) + fakes.assign + .mockResolvedValueOnce({ + cellUrl: 'https://relay.example.test', + assignmentEpoch: 1, + leaseExpiresAt: 1_000_000 + }) + .mockResolvedValueOnce({ + cellUrl: 'https://relay.example.test', + assignmentEpoch: 1, + leaseExpiresAt: 2_000_000 + }) + const onStatus = vi.fn() + const broker = await RelaySessionBroker.connect(brokerOptions({ onStatus })) + + fakes.controls[0]!.options.onClose(1006) + await vi.waitFor(() => expect(fakes.controls).toHaveLength(3)) + + expect(fakes.controls[1]!.options.previousGeneration).toBe(7) + expect(fakes.controls[1]!.options.controlResumeSecret).toBe('R'.repeat(43)) + expect(fakes.controls[2]!.options.previousGeneration).toBeUndefined() + expect(fakes.controls[2]!.options.controlResumeSecret).toBeUndefined() + expect(fakes.transports).toHaveLength(2) + await vi.waitFor(() => expect(onStatus).toHaveBeenLastCalledWith('registered')) + expect(broker.endpoint?.cellUrl).toBe('https://relay.example.test') + }) +}) + +function brokerBasisIds(broker: RelaySessionBroker): string[] { + const pool = (broker as unknown as { originPool: unknown }).originPool + return [...(pool as { basisOrigins: Map }).basisOrigins.keys()] +} + +function brokerOptions( + overrides: Partial[0]> = {} +): Parameters[0] { + const keypair = nacl.box.keyPair() + return { + authConfig: { + relayTokenEndpoint: 'https://auth.example.test/v1/relay-token', + relayDirectorUrl: 'https://relay.example.test' + } as OrcaCloudAuthConfig, + accessToken: 'access-token', + identity: { userId: 'user-1', profileId: 'profile-1', organizationId: 'org-1' }, + keypair: { + ...keypair, + publicKeyB64: Buffer.from(keypair.publicKey).toString('base64') + }, + appVersion: '1.0.0', + mobileSocketWiring: { attachTransport: vi.fn() } as never, + isCurrent: () => true, + refreshAccessToken: async () => null, + onStatus: vi.fn(), + now: () => 0, + random: () => 0, + ...overrides + } +} diff --git a/src/main/runtime/relay/relay-session-broker.ts b/src/main/runtime/relay/relay-session-broker.ts new file mode 100644 index 00000000000..920bd7b57e9 --- /dev/null +++ b/src/main/runtime/relay/relay-session-broker.ts @@ -0,0 +1,285 @@ +import type { PairingRelay } from '../../../shared/mobile-relay-pairing-offer' +import type { + DeviceCredentialInstalled, + DeviceCredentialInstallStatusResult, + DeviceResumeConfirmed, + MobileRelayEndpoint, + PairingProvisionRelayParams +} from '../../../shared/mobile-relay-credential-contract' +import type { DeviceCredentialInstallAuthorization } from './relay-control-requests' +import { + deriveRelayHostId, + exchangeRelayAuthorization, + requestRelayAssignment, + type RelayAuthorization, + type RelayAssignment +} from './relay-http-client' +import { RelayOriginPool } from './relay-origin-pool' +import type { RelayBrokerStatus, RelaySessionBrokerOptions } from './relay-session-broker-contract' + +export type { RelayBrokerStatus } from './relay-session-broker-contract' + +export class StaleRelayBrokerError extends Error { + constructor() { + super('stale_relay_broker') + } +} + +export class RelaySessionBroker { + private readonly options: RelaySessionBrokerOptions + private readonly relayHostId: string + private readonly originPool: RelayOriginPool + private authorization: RelayAuthorization | null = null + private refreshTimer: ReturnType | null = null + private closed = false + + private constructor(options: RelaySessionBrokerOptions) { + this.options = options + this.relayHostId = deriveRelayHostId(options.keypair.publicKey) + this.originPool = new RelayOriginPool({ + directorUrl: options.authConfig.relayDirectorUrl, + relayHostId: this.relayHostId, + identity: options.identity, + keypair: options.keypair, + appVersion: options.appVersion, + mobileSocketWiring: options.mobileSocketWiring, + isCurrent: () => this.isCurrent(), + onStatus: (status) => this.publishStatus(status), + fetch: options.fetch, + createControlSocket: options.createControlSocket, + createDataSocket: options.createDataSocket, + random: options.random, + now: options.now + }) + } + + static async connect(options: RelaySessionBrokerOptions): Promise { + const broker = new RelaySessionBroker(options) + try { + await broker.open(options.accessToken) + return broker + } catch (error) { + broker.closeNow() + throw error + } + } + + get hostId(): string { + return this.relayHostId + } + + get currentAssignment(): RelayAssignment | null { + return this.originPool.activeAssignment + } + + get ownerIdentityKey(): string { + const identity = this.options.identity + return `${identity.userId}\0${identity.profileId}\0${identity.organizationId}` + } + + get endpoint(): MobileRelayEndpoint | null { + const assignment = this.originPool.activeAssignment + if (!assignment) { + return null + } + return { + v: 1, + directorUrl: this.options.authConfig.relayDirectorUrl, + cellUrl: assignment.cellUrl, + assignmentEpoch: assignment.assignmentEpoch, + relayHostId: this.relayHostId, + e2eeFraming: 2 + } + } + + createInvite(relayDeviceId: string) { + const control = this.originPool.activeControl + if (!control) { + return Promise.reject(new Error('relay_control_not_active')) + } + return control.createInvite(relayDeviceId) + } + + async createPairingRelay(relayDeviceId: string): Promise { + const assignment = this.originPool.activeAssignment + const control = this.originPool.activeControl + if (!assignment || !control) { + throw new Error('relay_control_not_active') + } + const invite = await control.createInvite(relayDeviceId) + this.assertCurrent() + return { + v: 1, + directorUrl: this.options.authConfig.relayDirectorUrl, + cellUrl: assignment.cellUrl, + assignmentEpoch: assignment.assignmentEpoch, + relayHostId: this.relayHostId, + inviteToken: invite.inviteToken, + inviteExpiresAt: invite.expiresAt, + e2eeFraming: 2 + } + } + + revokeDevice(relayDeviceId: string, reqId?: string): Promise { + const control = this.originPool.activeControl + if (!control) { + return Promise.reject(new Error('relay_control_not_active')) + } + return control.revokeDevice(relayDeviceId, reqId) + } + + async installCredential( + relayDeviceId: string, + params: PairingProvisionRelayParams, + authorization: DeviceCredentialInstallAuthorization + ): Promise { + const control = + authorization.mode === 'relay-basis' + ? this.originPool.controlForBasis(authorization.basisConnId) + : this.originPool.activeControl + if (!control) { + throw new Error('relay_control_not_active') + } + const message = await control.installCredential({ + relayDeviceId, + authorization, + ...params + }) + this.assertCurrent() + const { type: _type, ...result } = message + return result + } + + async credentialInstallStatus( + relayDeviceId: string, + reqId: string + ): Promise { + const control = this.originPool.activeControl + if (!control) { + throw new Error('relay_control_not_active') + } + const message = await control.credentialInstallStatus(relayDeviceId, reqId) + this.assertCurrent() + const { type: _type, ...result } = message + return result + } + + async confirmResume(basisConnId: string, reqId: string): Promise { + const control = this.originPool.controlForBasis(basisConnId) + if (!control) { + throw new Error('relay_basis_origin_not_found') + } + const message = await control.confirmResume(basisConnId, reqId) + this.assertCurrent() + const { type: _type, ...result } = message + return result + } + + closeNow(): void { + if (this.closed) { + return + } + const publishOffline = this.options.isCurrent() + this.closed = true + if (this.refreshTimer) { + clearTimeout(this.refreshTimer) + this.refreshTimer = null + } + this.originPool.closeNow() + if (publishOffline) { + this.options.onStatus('offline') + } + } + + private async open(accessToken: string): Promise { + this.publishStatus('connecting') + const authorization = await exchangeRelayAuthorization({ + endpoint: this.options.authConfig.relayTokenEndpoint, + accessToken, + keypair: this.options.keypair, + fetch: this.options.fetch + }) + this.assertCurrent() + const assignment = await requestRelayAssignment({ + directorUrl: this.options.authConfig.relayDirectorUrl, + relayToken: authorization.relayToken, + relayHostId: this.relayHostId, + fetch: this.options.fetch + }) + this.assertCurrent() + try { + await this.originPool.openInitial(assignment, authorization.relayToken) + } catch (error) { + if (!this.isCurrent()) { + throw new StaleRelayBrokerError() + } + throw error + } + this.assertCurrent() + this.authorization = authorization + this.publishStatus('registered') + this.scheduleRefresh() + } + + private scheduleRefresh(): void { + const authorization = this.authorization + if (!authorization || this.closed) { + return + } + const now = (this.options.now ?? Date.now)() + const random = this.options.random ?? Math.random + const earlyMs = 60_000 + Math.floor(random() * 60_001) + const delay = Math.max(0, authorization.expiresAt - earlyMs - now) + this.refreshTimer = setTimeout(() => void this.refreshAuthorization(), delay) + } + + private async refreshAuthorization(): Promise { + this.refreshTimer = null + try { + const accessToken = await this.options.refreshAccessToken() + this.assertCurrent() + if (!accessToken) { + this.closeNow() + return + } + const authorization = await exchangeRelayAuthorization({ + endpoint: this.options.authConfig.relayTokenEndpoint, + accessToken, + keypair: this.options.keypair, + fetch: this.options.fetch + }) + this.assertCurrent() + this.originPool.refreshAuthorization(authorization.relayToken) + this.authorization = authorization + this.scheduleRefresh() + } catch { + const expiry = this.authorization?.expiresAt ?? 0 + const now = (this.options.now ?? Date.now)() + if (!this.closed && this.options.isCurrent() && now <= expiry + 60_000) { + const random = this.options.random ?? Math.random + this.refreshTimer = setTimeout( + () => void this.refreshAuthorization(), + 5_000 + Math.floor(random() * 10_001) + ) + return + } + this.closeNow() + } + } + + private assertCurrent(): void { + if (!this.isCurrent()) { + throw new StaleRelayBrokerError() + } + } + + private isCurrent(): boolean { + return !this.closed && this.options.isCurrent() + } + + private publishStatus(status: RelayBrokerStatus): void { + if (this.isCurrent()) { + this.options.onStatus(status) + } + } +} diff --git a/src/main/runtime/relay/simulated-mobile-e2ee-v2-peer.ts b/src/main/runtime/relay/simulated-mobile-e2ee-v2-peer.ts new file mode 100644 index 00000000000..ce590affde3 --- /dev/null +++ b/src/main/runtime/relay/simulated-mobile-e2ee-v2-peer.ts @@ -0,0 +1,116 @@ +import nacl from 'tweetnacl' +import { + encodeMobileE2EEV2Transcript, + validateMobileE2EEV2Handshake, + type MobileE2EEPayloadKind, + type MobileE2EEV2Hello +} from '../../../shared/mobile-e2ee-v2-contract' +import { + openMobileE2EEV2Frame, + sealMobileE2EEV2Frame +} from '../../../shared/mobile-e2ee-v2-framing' +import { deriveSharedKey } from '../rpc/e2ee-crypto' +import { deriveMobileE2EEV2KeySchedule } from '../rpc/mobile-e2ee-v2-key-schedule' + +// Why: the relay integration test needs an independent mobile-side wire peer +// without importing Expo modules into the desktop Node TypeScript project. +export class SimulatedMobileE2EEV2Peer { + readonly hello: MobileE2EEV2Hello + private inboundCounter = 0n + private outboundCounter = 0n + private schedule: ReturnType | null = null + + constructor( + private readonly clientKeys: nacl.BoxKeyPair, + private readonly desktopPublicKey: Uint8Array, + relayHostId: string, + clientNonce = nacl.randomBytes(32) + ) { + this.hello = { + type: 'e2ee_hello', + v: 2, + clientPublicKeyB64: Buffer.from(clientKeys.publicKey).toString('base64'), + clientNonceB64: Buffer.from(clientNonce).toString('base64'), + capabilities: { framing: [2], payloadKinds: ['text', 'binary'] }, + context: { + protocol: 'orca-mobile-e2ee', + initiator: 'mobile', + responder: 'desktop', + transport: 'relay', + relayHostId + } + } + } + + acceptReady(value: unknown): boolean { + const handshake = validateMobileE2EEV2Handshake(this.hello, value) + if (!handshake || !nacl.verify(handshake.desktopPublicKey, this.desktopPublicKey)) { + return false + } + this.schedule = deriveMobileE2EEV2KeySchedule({ + sharedSecret: deriveSharedKey(this.clientKeys.secretKey, this.desktopPublicKey), + transcript: encodeMobileE2EEV2Transcript(handshake), + clientNonce: handshake.clientNonce, + desktopNonce: handshake.desktopNonce + }) + return true + } + + get transcriptHashB64(): string { + return Buffer.from(this.requireSchedule().transcriptHash).toString('base64') + } + + sealText(plaintext: string): string { + return Buffer.from(this.seal(new TextEncoder().encode(plaintext), 'text')).toString('base64') + } + + sealBinary(plaintext: Uint8Array): Uint8Array { + return this.seal(plaintext, 'binary') + } + + openText(frameB64: string): string | null { + const plaintext = this.open(Buffer.from(frameB64, 'base64'), 'text') + return plaintext ? new TextDecoder().decode(plaintext) : null + } + + openBinary(frame: Uint8Array): Uint8Array | null { + return this.open(frame, 'binary') + } + + private seal(payload: Uint8Array, payloadKind: MobileE2EEPayloadKind): Uint8Array { + const schedule = this.requireSchedule() + const frame = sealMobileE2EEV2Frame({ + payload, + key: schedule.mobileToDesktopKey, + sessionId: schedule.sessionId, + direction: 'mobile-to-desktop', + payloadKind, + counter: this.outboundCounter + }) + this.outboundCounter++ + return frame + } + + private open(frame: Uint8Array, payloadKind: MobileE2EEPayloadKind): Uint8Array | null { + const schedule = this.requireSchedule() + const plaintext = openMobileE2EEV2Frame({ + frame, + key: schedule.desktopToMobileKey, + sessionId: schedule.sessionId, + direction: 'desktop-to-mobile', + payloadKind, + expectedCounter: this.inboundCounter + }) + if (plaintext) { + this.inboundCounter++ + } + return plaintext + } + + private requireSchedule(): ReturnType { + if (!this.schedule) { + throw new Error('Simulated mobile peer has not accepted E2EE ready') + } + return this.schedule + } +} diff --git a/src/main/runtime/remote-runtime-request-connection.integration.test.ts b/src/main/runtime/remote-runtime-request-connection.integration.test.ts index 8ede941b4d5..e81b299fafd 100644 --- a/src/main/runtime/remote-runtime-request-connection.integration.test.ts +++ b/src/main/runtime/remote-runtime-request-connection.integration.test.ts @@ -478,9 +478,7 @@ describe('remote runtime request connection integration', () => { REMOTE_RUNTIME_REQUEST_TIMEOUT_MS, () => `cleanup count ${subscriptionCleanups.size}, event count ${mixedEvents.length}` ) - expect( - (server as unknown as { wsConnectionIds: Map }).wsConnectionIds.size - ).toBe(1) + expect(server.getMobileSocketWiring()?.connectionCount).toBe(1) for (const mixed of mixedSubscriptions) { mixed.close() } @@ -500,9 +498,7 @@ describe('remote runtime request connection integration', () => { ) ) ) - expect( - (server as unknown as { wsConnectionIds: Map }).wsConnectionIds.size - ).toBe(1) + expect(server.getMobileSocketWiring()?.connectionCount).toBe(1) for (const extra of extraSubscriptions) { extra.close() } diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 1ef817bf552..3552b680caf 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -5,6 +5,17 @@ import { ZodError, type ZodType } from 'zod' import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol' import type { OrcaRuntimeService } from '../orca-runtime' +import type { + DeviceCredentialInstalled, + PairingGetEndpointsParams, + PairingGetEndpointsResult, + PairingProvisionRelayParams +} from '../../../shared/mobile-relay-credential-contract' + +export type PairingRpcContext = { + getEndpoints(params: PairingGetEndpointsParams): Promise + provisionRelay(params: PairingProvisionRelayParams): Promise +} export type RpcEnvelopeMeta = { runtimeId: string @@ -64,6 +75,7 @@ export type RpcContext = { // clients. Carries the paired device's scope so handlers can gate the diet to // phones only. Undefined for in-process callers → treat as full-class (no clip). clientKind?: 'mobile' | 'runtime' + pairing?: PairingRpcContext // Why: mobile terminal traffic is byte-oriented and bypasses JSON streaming // responses after the binary terminal cutover. Undefined on Unix/socket // transports and non-E2EE WebSocket paths. diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 24ff44597b0..bf7e26fb461 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -10,6 +10,7 @@ import { isStreamingMethod, type RpcAnyMethod, type RpcEnvelopeMeta, + type PairingRpcContext, type RpcRegistry, type RpcRequest, type RpcResponse @@ -102,6 +103,7 @@ export class RpcDispatcher { signal?: AbortSignal clientId?: string clientKind?: 'mobile' | 'runtime' + pairing?: PairingRpcContext sendBinary?: (bytes: Uint8Array) => boolean | void registerBinaryStreamHandler?: ( streamId: number, @@ -135,6 +137,7 @@ export class RpcDispatcher { connectionId: options?.connectionId, clientId: options?.clientId, clientKind: options?.clientKind, + pairing: options?.pairing, sendBinary: options?.sendBinary, registerBinaryStreamHandler: options?.registerBinaryStreamHandler }) diff --git a/src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts b/src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts index 5d0b2d3da92..4c45e97ea10 100644 --- a/src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts +++ b/src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts @@ -34,7 +34,10 @@ function setup(overrides?: Partial) { const onError = vi.fn() const channel = new E2EEChannel(ws as unknown as WebSocket, { serverSecretKey: serverKeys.secretKey, - validateToken: (token) => token === 'valid-token', + resolveAuthenticatedDevice: (token) => + token === 'valid-token' + ? { deviceId: 'device-1', deviceToken: token, scope: 'mobile' } + : null, onReady: vi.fn(), onError, ...overrides diff --git a/src/main/runtime/rpc/e2ee-channel-v2.test.ts b/src/main/runtime/rpc/e2ee-channel-v2.test.ts new file mode 100644 index 00000000000..00fd0093c4c --- /dev/null +++ b/src/main/runtime/rpc/e2ee-channel-v2.test.ts @@ -0,0 +1,259 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import nacl from 'tweetnacl' +import type { WebSocket } from 'ws' +import { + encodeMobileE2EEV2Transcript, + validateMobileE2EEV2Handshake, + type MobileE2EEV2Hello, + type MobileE2EEV2Ready +} from '../../../shared/mobile-e2ee-v2-contract' +import { + openMobileE2EEV2Frame, + sealMobileE2EEV2Frame +} from '../../../shared/mobile-e2ee-v2-framing' +import { deriveSharedKey } from './e2ee-crypto' +import { E2EEChannel } from './e2ee-channel' +import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule' + +const server = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1)) +const client = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2)) + +function createMockWs() { + const sent: { data: string | Buffer; options?: { binary?: boolean } }[] = [] + return { + OPEN: 1 as const, + readyState: 1, + bufferedAmount: 0, + send: vi.fn((data: string | Buffer, options?: { binary?: boolean }) => { + sent.push({ data, options }) + }), + sent + } +} + +function hello(): MobileE2EEV2Hello { + return { + type: 'e2ee_hello', + v: 2, + clientPublicKeyB64: Buffer.from(client.publicKey).toString('base64'), + clientNonceB64: Buffer.from(new Uint8Array(32).fill(3)).toString('base64'), + capabilities: { framing: [2], payloadKinds: ['text', 'binary'] }, + context: { + protocol: 'orca-mobile-e2ee', + initiator: 'mobile', + responder: 'desktop', + transport: 'relay', + relayHostId: 'AbCdEf0123_-xyZ9' + } + } +} + +function setup() { + const ws = createMockWs() + const onReady = vi.fn() + const onError = vi.fn() + const resolveAuthenticatedDevice = vi.fn((token: string) => + token === 'valid-token' + ? { deviceId: 'device-1', deviceToken: token, scope: 'mobile' as const } + : null + ) + const channel = new E2EEChannel(ws as unknown as WebSocket, { + serverSecretKey: server.secretKey, + resolveAuthenticatedDevice, + onReady, + onError, + transportContext: { transport: 'relay', relayHostId: 'AbCdEf0123_-xyZ9' }, + requireV2: true + }) + return { ws, channel, onReady, onError, resolveAuthenticatedDevice } +} + +function startV2(ctx: ReturnType) { + const clientHello = hello() + ctx.channel.handleRawMessage(JSON.stringify(clientHello)) + const ready = JSON.parse(ctx.ws.sent[0]!.data.toString()) as MobileE2EEV2Ready + const handshake = validateMobileE2EEV2Handshake(clientHello, ready)! + const schedule = deriveMobileE2EEV2KeySchedule({ + sharedSecret: deriveSharedKey(client.secretKey, server.publicKey), + transcript: encodeMobileE2EEV2Transcript(handshake), + clientNonce: handshake.clientNonce, + desktopNonce: handshake.desktopNonce + }) + return { ready, schedule } +} + +function clientText( + plaintext: string, + schedule: ReturnType['schedule'], + counter: bigint +): string { + const frame = sealMobileE2EEV2Frame({ + payload: new TextEncoder().encode(plaintext), + key: schedule.mobileToDesktopKey, + sessionId: schedule.sessionId, + direction: 'mobile-to-desktop', + payloadKind: 'text', + counter + }) + return Buffer.from(frame).toString('base64') +} + +function openServerFrame( + frame: string | Buffer, + kind: 'text' | 'binary', + schedule: ReturnType['schedule'], + counter: bigint +): Uint8Array | null { + return openMobileE2EEV2Frame({ + frame: typeof frame === 'string' ? Buffer.from(frame, 'base64') : frame, + key: schedule.desktopToMobileKey, + sessionId: schedule.sessionId, + direction: 'desktop-to-mobile', + payloadKind: kind, + expectedCounter: counter + }) +} + +function authenticate( + ctx: ReturnType, + schedule: ReturnType['schedule'] +) { + const transcriptHashB64 = Buffer.from(schedule.transcriptHash).toString('base64') + ctx.channel.handleRawMessage( + clientText( + JSON.stringify({ + type: 'e2ee_auth', + v: 2, + transcriptHashB64, + deviceToken: 'valid-token' + }), + schedule, + 0n + ) + ) +} + +describe('E2EEChannel v2', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('confirms the transcript before evaluating DeviceRegistry auth', () => { + const ctx = setup() + const { schedule } = startV2(ctx) + authenticate(ctx, schedule) + + expect(ctx.resolveAuthenticatedDevice).toHaveBeenCalledOnce() + expect(ctx.onReady).toHaveBeenCalledWith(ctx.channel, { + deviceId: 'device-1', + deviceToken: 'valid-token', + scope: 'mobile' + }) + const authenticated = openServerFrame(ctx.ws.sent[1]!.data, 'text', schedule, 0n) + expect(JSON.parse(new TextDecoder().decode(authenticated!))).toEqual({ + type: 'e2ee_authenticated', + v: 2, + transcriptHashB64: Buffer.from(schedule.transcriptHash).toString('base64') + }) + }) + + it('rejects legacy downgrade and injected auth metadata when v2 is required', () => { + const legacy = setup() + legacy.channel.handleRawMessage( + JSON.stringify({ type: 'e2ee_hello', publicKeyB64: 'legacy-key' }) + ) + expect(legacy.onError).toHaveBeenCalledWith(4001, 'E2EE v2 required') + + const ctx = setup() + const { schedule } = startV2(ctx) + const transcriptHashB64 = Buffer.from(schedule.transcriptHash).toString('base64') + ctx.channel.handleRawMessage( + clientText( + JSON.stringify({ + type: 'e2ee_auth', + v: 2, + transcriptHashB64, + deviceToken: 'valid-token', + relayDeviceId: 'injected' + }), + schedule, + 0n + ) + ) + expect(ctx.resolveAuthenticatedDevice).not.toHaveBeenCalled() + }) + + it('rejects a captured auth frame replayed onto a fresh desktop nonce', () => { + const first = setup() + const firstHandshake = startV2(first) + const capturedAuth = clientText( + JSON.stringify({ + type: 'e2ee_auth', + v: 2, + transcriptHashB64: Buffer.from(firstHandshake.schedule.transcriptHash).toString('base64'), + deviceToken: 'valid-token' + }), + firstHandshake.schedule, + 0n + ) + + const second = setup() + const secondHandshake = startV2(second) + expect(secondHandshake.ready.desktopNonceB64).not.toBe(firstHandshake.ready.desktopNonceB64) + second.channel.handleRawMessage(capturedAuth) + expect(second.resolveAuthenticatedDevice).not.toHaveBeenCalled() + expect(second.onReady).not.toHaveBeenCalled() + }) + + it('rejects a captured authenticated mutating trace on a fresh socket', () => { + const first = setup() + const firstHandshake = startV2(first) + const transcriptHashB64 = Buffer.from(firstHandshake.schedule.transcriptHash).toString('base64') + const capturedAuth = clientText( + JSON.stringify({ type: 'e2ee_auth', v: 2, transcriptHashB64, deviceToken: 'valid-token' }), + firstHandshake.schedule, + 0n + ) + const capturedMutation = clientText( + JSON.stringify({ method: 'device.remove', params: { deviceId: 'device-1' } }), + firstHandshake.schedule, + 1n + ) + const firstMutation = vi.fn() + first.channel.onMessage(firstMutation) + first.channel.handleRawMessage(capturedAuth) + first.channel.handleRawMessage(capturedMutation) + expect(firstMutation).toHaveBeenCalledOnce() + + const second = setup() + startV2(second) + const replayedMutation = vi.fn() + second.channel.onMessage(replayedMutation) + second.channel.handleRawMessage(capturedAuth) + second.channel.handleRawMessage(capturedMutation) + expect(second.resolveAuthenticatedDevice).not.toHaveBeenCalled() + expect(replayedMutation).not.toHaveBeenCalled() + }) + + it('preserves one queued counter order across text and binary replies', () => { + const ctx = setup() + const { schedule } = startV2(ctx) + authenticate(ctx, schedule) + ctx.ws.bufferedAmount = 9 * 1024 * 1024 + ctx.channel.onMessage((_request, textReply, binaryReply) => { + textReply('one') + binaryReply(new Uint8Array([2])) + }) + ctx.channel.handleRawMessage(clientText('{"method":"status.get"}', schedule, 1n)) + expect(ctx.ws.sent).toHaveLength(2) + + ctx.ws.bufferedAmount = 0 + vi.runOnlyPendingTimers() + expect( + new TextDecoder().decode(openServerFrame(ctx.ws.sent[2]!.data, 'text', schedule, 1n)!) + ).toBe('one') + expect(openServerFrame(ctx.ws.sent[3]!.data, 'binary', schedule, 2n)).toEqual( + new Uint8Array([2]) + ) + expect(ctx.ws.sent[3]!.options).toEqual({ binary: true }) + }) +}) diff --git a/src/main/runtime/rpc/e2ee-channel.test.ts b/src/main/runtime/rpc/e2ee-channel.test.ts index a1daec13669..846b82903db 100644 --- a/src/main/runtime/rpc/e2ee-channel.test.ts +++ b/src/main/runtime/rpc/e2ee-channel.test.ts @@ -27,7 +27,10 @@ function setup(overrides?: Partial) { const channel = new E2EEChannel(ws as unknown as WebSocket, { serverSecretKey: serverKeys.secretKey, - validateToken: (token) => token === 'valid-token', + resolveAuthenticatedDevice: (token) => + token === 'valid-token' + ? { deviceId: 'device-1', deviceToken: token, scope: 'mobile' } + : null, onReady, onError, ...overrides @@ -63,7 +66,11 @@ describe('E2EEChannel', () => { const ctx = setup() doHandshake(ctx) - expect(ctx.onReady).toHaveBeenCalledWith(ctx.channel) + expect(ctx.onReady).toHaveBeenCalledWith(ctx.channel, { + deviceId: 'device-1', + deviceToken: 'valid-token', + scope: 'mobile' + }) expect(ctx.onError).not.toHaveBeenCalled() expect(ctx.channel.deviceToken).toBe('valid-token') diff --git a/src/main/runtime/rpc/e2ee-channel.ts b/src/main/runtime/rpc/e2ee-channel.ts index 63cc2c0dfcd..cec40fd38ce 100644 --- a/src/main/runtime/rpc/e2ee-channel.ts +++ b/src/main/runtime/rpc/e2ee-channel.ts @@ -7,6 +7,16 @@ import { createWsOutboundBackpressureQueue, type WsOutboundBackpressureQueue } from '../../../shared/ws-outbound-backpressure-queue' +import { + DesktopMobileE2EEV2Session, + type DesktopMobileE2EEV2Context +} from './mobile-e2ee-v2-desktop-session' +import { + createDesktopMobileE2EEV2OutboundQueue, + type DesktopMobileE2EEV2OutboundItem as V2OutboundItem +} from './mobile-e2ee-v2-desktop-outbound' +import { handleDesktopMobileE2EEV2Inbound } from './mobile-e2ee-v2-desktop-inbound' +import { isValidMobileE2EEAuthVersion, type MobileE2EEAuth } from './mobile-e2ee-auth-validation' type ChannelState = 'awaiting_hello' | 'awaiting_auth' | 'ready' @@ -14,21 +24,19 @@ const HANDSHAKE_TIMEOUT_MS = 10_000 const MAX_CONSECUTIVE_DECRYPT_FAILURES = 5 const MAX_BINARY_BUFFERED_AMOUNT = 8 * 1024 * 1024 -type E2EEHello = { - type: 'e2ee_hello' - publicKeyB64: string -} - -type E2EEAuth = { - type: 'e2ee_auth' - deviceToken: string -} - export type E2EEChannelOptions = { serverSecretKey: Uint8Array - validateToken: (token: string) => boolean - onReady: (channel: E2EEChannel) => void + resolveAuthenticatedDevice: (token: string) => E2EEAuthenticatedDevice | null + onReady: (channel: E2EEChannel, device: E2EEAuthenticatedDevice) => void onError: (code: number, reason: string) => void + transportContext?: DesktopMobileE2EEV2Context + requireV2?: boolean +} + +export type E2EEAuthenticatedDevice = { + deviceId: string + deviceToken: string + scope: 'mobile' | 'runtime' } export class E2EEChannel { @@ -38,9 +46,13 @@ export class E2EEChannel { private handshakeTimer: ReturnType | null = null private readonly ws: WebSocket private readonly serverSecretKey: Uint8Array - private readonly validateToken: (token: string) => boolean - private readonly onReady: (channel: E2EEChannel) => void + private readonly resolveAuthenticatedDevice: (token: string) => E2EEAuthenticatedDevice | null + private readonly onReady: (channel: E2EEChannel, device: E2EEAuthenticatedDevice) => void private readonly onError: (code: number, reason: string) => void + private readonly transportContext: DesktopMobileE2EEV2Context + private readonly requireV2: boolean + private v2Session: DesktopMobileE2EEV2Session | null = null + private v2OutboundQueue: WsOutboundBackpressureQueue | null = null // Why: the RPC handler is set after the channel is ready, so the channel // can forward decrypted messages. Kept as a callback rather than constructor // param because the handler needs the encrypt function for replies. @@ -59,13 +71,16 @@ export class E2EEChannel { private textReplyQueue: WsOutboundBackpressureQueue | null = null deviceToken: string | null = null + authenticatedDevice: E2EEAuthenticatedDevice | null = null constructor(ws: WebSocket, options: E2EEChannelOptions) { this.ws = ws this.serverSecretKey = options.serverSecretKey - this.validateToken = options.validateToken + this.resolveAuthenticatedDevice = options.resolveAuthenticatedDevice this.onReady = options.onReady this.onError = options.onError + this.transportContext = options.transportContext ?? { transport: 'direct' } + this.requireV2 = options.requireV2 ?? false this.handshakeTimer = setTimeout(() => { this.onError(4002, 'E2EE handshake timeout') @@ -96,12 +111,17 @@ export class E2EEChannel { return } - if (!this.sharedKey) { + if (this.v2Session) { + this.handleV2RawMessage(raw) + return + } + const sharedKey = this.sharedKey + if (!sharedKey) { return } if (typeof raw !== 'string') { - const plaintextBytes = decryptBytes(raw, this.sharedKey) + const plaintextBytes = decryptBytes(raw, sharedKey) if (plaintextBytes === null) { this.trackDecryptFailure() return @@ -115,7 +135,7 @@ export class E2EEChannel { return } - const plaintext = decrypt(raw, this.sharedKey) + const plaintext = decrypt(raw, sharedKey) if (plaintext === null) { this.trackDecryptFailure() return @@ -160,15 +180,37 @@ export class E2EEChannel { } private handleHello(raw: string): void { - let hello: E2EEHello + let hello: Record try { - hello = JSON.parse(raw) as E2EEHello + hello = JSON.parse(raw) as Record } catch { this.onError(4001, 'Invalid handshake message') return } - if (hello.type !== 'e2ee_hello' || !hello.publicKeyB64) { + if (hello.type === 'e2ee_hello' && hello.v === 2) { + const session = DesktopMobileE2EEV2Session.create({ + hello, + serverSecretKey: this.serverSecretKey, + expectedContext: this.transportContext + }) + if (!session) { + this.onError(4001, 'Invalid e2ee_hello v2') + return + } + this.v2Session = session + this.state = 'awaiting_auth' + if (this.ws.readyState === this.ws.OPEN) { + this.ws.send(JSON.stringify(session.ready)) + } + return + } + + if (this.requireV2) { + this.onError(4001, 'E2EE v2 required') + return + } + if (hello.type !== 'e2ee_hello' || typeof hello.publicKeyB64 !== 'string') { this.onError(4001, 'Invalid e2ee_hello') return } @@ -192,27 +234,33 @@ export class E2EEChannel { } private handleAuth(plaintext: string): void { - let auth: E2EEAuth + let auth: MobileE2EEAuth try { - auth = JSON.parse(plaintext) as E2EEAuth + auth = JSON.parse(plaintext) as MobileE2EEAuth } catch { this.sendEncryptedControl({ type: 'e2ee_error', error: { code: 'bad_auth' } }) this.onError(4001, 'Invalid e2ee_auth') return } - if (auth.type !== 'e2ee_auth' || !auth.deviceToken) { + if ( + auth.type !== 'e2ee_auth' || + !auth.deviceToken || + !isValidMobileE2EEAuthVersion(auth, this.v2Session) + ) { this.sendEncryptedControl({ type: 'e2ee_error', error: { code: 'bad_auth' } }) this.onError(4001, 'Invalid e2ee_auth') return } - if (!this.validateToken(auth.deviceToken)) { + const authenticatedDevice = this.resolveAuthenticatedDevice(auth.deviceToken) + if (!authenticatedDevice || authenticatedDevice.deviceToken !== auth.deviceToken) { this.sendEncryptedControl({ type: 'e2ee_error', error: { code: 'unauthorized' } }) this.onError(4001, 'Unauthorized') return } this.deviceToken = auth.deviceToken + this.authenticatedDevice = authenticatedDevice this.state = 'ready' if (this.handshakeTimer) { @@ -220,8 +268,52 @@ export class E2EEChannel { this.handshakeTimer = null } - this.sendEncryptedControl({ type: 'e2ee_authenticated' }) - this.onReady(this) + // Why: transport-bound identity checks must complete before the peer sees + // authentication success; relay sockets additionally bind this context to + // their immutable relayDeviceId in the resolver. + this.onReady(this, authenticatedDevice) + this.sendEncryptedControl( + this.v2Session + ? { + type: 'e2ee_authenticated', + v: 2, + transcriptHashB64: this.v2Session.transcriptHashB64 + } + : { type: 'e2ee_authenticated' } + ) + } + + private handleV2RawMessage(raw: string | Uint8Array): void { + handleDesktopMobileE2EEV2Inbound({ + session: this.v2Session!, + raw, + awaitingAuth: this.state === 'awaiting_auth', + onDecryptFailure: () => this.trackDecryptFailure(), + onDecryptSuccess: () => (this.consecutiveFailures = 0), + onAuth: (plaintext) => this.handleAuth(plaintext), + onBinary: (plaintext) => this.binaryMessageHandler?.(plaintext), + onText: (plaintext) => + this.messageHandler?.( + plaintext, + (response) => this.enqueueV2({ kind: 'text', plaintext: response }), + (response) => (this.enqueueV2({ kind: 'binary', plaintext: response }), true) + ), + onProtocolError: () => this.onError(4001, 'Invalid binary message before authentication') + }) + } + + private enqueueV2(item: V2OutboundItem): void { + if (!this.v2Session || this.ws.readyState !== this.ws.OPEN) { + return + } + if (!this.v2OutboundQueue) { + this.v2OutboundQueue = createDesktopMobileE2EEV2OutboundQueue({ + ws: this.ws, + session: this.v2Session, + onOverflow: () => this.onError(1013, 'Outbound reply buffer overflow') + }) + } + this.v2OutboundQueue.enqueue(item) } private ensureTextReplyQueue(): WsOutboundBackpressureQueue { @@ -241,7 +333,9 @@ export class E2EEChannel { } private sendEncryptedControl(message: unknown): void { - if (this.ws.readyState === this.ws.OPEN && this.sharedKey) { + if (this.v2Session) { + this.enqueueV2({ kind: 'text', plaintext: JSON.stringify(message) }) + } else if (this.ws.readyState === this.ws.OPEN && this.sharedKey) { this.ws.send(encrypt(JSON.stringify(message), this.sharedKey)) } } @@ -252,9 +346,13 @@ export class E2EEChannel { this.handshakeTimer = null } this.sharedKey = null + this.authenticatedDevice = null + this.v2Session = null this.messageHandler = null this.binaryMessageHandler = null this.textReplyQueue?.dispose() this.textReplyQueue = null + this.v2OutboundQueue?.dispose() + this.v2OutboundQueue = null } } diff --git a/src/main/runtime/rpc/e2ee-crypto.test.ts b/src/main/runtime/rpc/e2ee-crypto.test.ts index aaa5db016f4..93fc12ff063 100644 --- a/src/main/runtime/rpc/e2ee-crypto.test.ts +++ b/src/main/runtime/rpc/e2ee-crypto.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import nacl from 'tweetnacl' import { generateKeyPair, deriveSharedKey, @@ -7,8 +8,24 @@ import { encryptBytes, decryptBytes } from './e2ee-crypto' +import { MOBILE_E2EE_LEGACY_FIXTURE } from '../../../shared/mobile-e2ee-legacy-fixtures' describe('e2ee-crypto', () => { + it('preserves the captured legacy key and text/binary frame bytes', () => { + const fixture = MOBILE_E2EE_LEGACY_FIXTURE + const server = nacl.box.keyPair.fromSecretKey(fixture.serverSecretKey) + const client = nacl.box.keyPair.fromSecretKey(fixture.clientSecretKey) + const shared = deriveSharedKey(client.secretKey, server.publicKey) + + expect(Buffer.from(server.publicKey).toString('base64')).toBe(fixture.serverPublicKeyB64) + expect(Buffer.from(client.publicKey).toString('base64')).toBe(fixture.clientPublicKeyB64) + expect(Buffer.from(shared).toString('hex')).toBe(fixture.sharedKeyHex) + expect(decrypt(fixture.authFrameB64, shared)).toBe(fixture.authPlaintext) + expect(decryptBytes(Buffer.from(fixture.binaryFrameHex, 'hex'), shared)).toEqual( + fixture.binaryPlaintext + ) + }) + it('encrypt/decrypt round-trips with shared key', () => { const server = generateKeyPair() const client = generateKeyPair() diff --git a/src/main/runtime/rpc/e2ee-integration.test.ts b/src/main/runtime/rpc/e2ee-integration.test.ts index f4029b82473..9065a7b21aa 100644 --- a/src/main/runtime/rpc/e2ee-integration.test.ts +++ b/src/main/runtime/rpc/e2ee-integration.test.ts @@ -44,7 +44,10 @@ describe('E2EE integration (simulated mobile ↔ desktop)', () => { channel = new E2EEChannel(mockWs as unknown as WebSocket, { serverSecretKey: serverKeys.secretKey, - validateToken: (token) => token === 'device-abc', + resolveAuthenticatedDevice: (token) => + token === 'device-abc' + ? { deviceId: 'device-abc', deviceToken: token, scope: 'mobile' } + : null, onReady, onError }) @@ -118,7 +121,10 @@ describe('E2EE integration (simulated mobile ↔ desktop)', () => { const newMobileKeys = generateKeyPair() const newChannel = new E2EEChannel(mockWs as unknown as WebSocket, { serverSecretKey: serverKeys.secretKey, - validateToken: (token) => token === 'device-abc', + resolveAuthenticatedDevice: (token) => + token === 'device-abc' + ? { deviceId: 'device-abc', deviceToken: token, scope: 'mobile' } + : null, onReady, onError }) diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index bc0a73985f9..00d2fd974be 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -34,6 +34,7 @@ import { SKILL_METHODS } from './skills' import { CLIPBOARD_METHODS } from './clipboard' import { HOST_CAPABILITY_METHODS } from './host-capabilities' import { EMULATOR_METHODS } from './emulator' +import { PAIRING_METHODS } from './pairing' // Why: a flat manifest keeps registration order explicit and provides one // grep-point for "what methods does the RPC server expose?" — useful when @@ -73,5 +74,6 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...HOST_CAPABILITY_METHODS, ...CLIENT_EVENT_METHODS, ...CLIENT_UI_METHODS, - ...EMULATOR_METHODS + ...EMULATOR_METHODS, + ...PAIRING_METHODS ] diff --git a/src/main/runtime/rpc/methods/pairing.test.ts b/src/main/runtime/rpc/methods/pairing.test.ts new file mode 100644 index 00000000000..58a12a1f058 --- /dev/null +++ b/src/main/runtime/rpc/methods/pairing.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../../orca-runtime' +import { RpcDispatcher } from '../dispatcher' +import { PAIRING_METHODS } from './pairing' + +function dispatchPairing( + method: string, + params: unknown, + pairing: NonNullable[2]>['pairing'] +): Promise> { + return new Promise((resolve) => { + const dispatcher = new RpcDispatcher({ + runtime: new OrcaRuntimeService(), + methods: PAIRING_METHODS + }) + void dispatcher.dispatchStreaming( + { id: 'request-1', authToken: '', method, params }, + (response) => resolve(JSON.parse(response) as Record), + { pairing } + ) + }) +} + +describe('pairing RPC methods', () => { + it('passes only phone-owned credential material to the server-bound provider', async () => { + const provisionRelay = vi.fn().mockResolvedValue({ + v: 1, + reqId: 'install-1', + authorizationMode: 'authenticated-direct', + currentVersion: 1, + resumeExpiresAt: Date.now() + 60_000 + }) + const pairing = { getEndpoints: vi.fn(), provisionRelay } + + await expect( + dispatchPairing( + 'pairing.provisionRelay', + { reqId: 'install-1', newResumeTokenHash: 'A'.repeat(43) }, + pairing + ) + ).resolves.toMatchObject({ ok: true }) + expect(provisionRelay).toHaveBeenCalledWith({ + reqId: 'install-1', + newResumeTokenHash: 'A'.repeat(43) + }) + }) + + it('rejects caller-selected identity and authorization metadata', async () => { + const pairing = { getEndpoints: vi.fn(), provisionRelay: vi.fn() } + + for (const injected of [ + { relayDeviceId: 'attacker-device' }, + { authorization: { mode: 'relay-basis', basisConnId: 'attacker-basis' } }, + { directAuthId: 'attacker-direct' }, + { acceptedCredentialVersion: 99 } + ]) { + await expect( + dispatchPairing( + 'pairing.provisionRelay', + { reqId: 'install-1', newResumeTokenHash: 'A'.repeat(43), ...injected }, + pairing + ) + ).resolves.toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + } + await expect( + dispatchPairing( + 'pairing.getEndpoints', + { installReqId: 'status-1', basisConnId: 'injected' }, + pairing + ) + ).resolves.toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + expect(pairing.provisionRelay).not.toHaveBeenCalled() + expect(pairing.getEndpoints).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/pairing.ts b/src/main/runtime/rpc/methods/pairing.ts new file mode 100644 index 00000000000..7762881ba37 --- /dev/null +++ b/src/main/runtime/rpc/methods/pairing.ts @@ -0,0 +1,28 @@ +import { defineMethod, type RpcAnyMethod } from '../core' +import { + PairingGetEndpointsParamsSchema, + PairingProvisionRelayParamsSchema +} from '../../../../shared/mobile-relay-credential-contract' + +export const PAIRING_METHODS: readonly RpcAnyMethod[] = [ + defineMethod({ + name: 'pairing.getEndpoints', + params: PairingGetEndpointsParamsSchema, + handler: async (params, ctx) => { + if (!ctx.pairing) { + throw new Error('pairing_context_unavailable') + } + return await ctx.pairing.getEndpoints(params) + } + }), + defineMethod({ + name: 'pairing.provisionRelay', + params: PairingProvisionRelayParamsSchema, + handler: async (params, ctx) => { + if (!ctx.pairing) { + throw new Error('pairing_context_unavailable') + } + return await ctx.pairing.provisionRelay(params) + } + }) +] diff --git a/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts b/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts new file mode 100644 index 00000000000..e5039c28794 --- /dev/null +++ b/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts @@ -0,0 +1,22 @@ +import type { DesktopMobileE2EEV2Session } from './mobile-e2ee-v2-desktop-session' + +export type MobileE2EEAuth = { + type: 'e2ee_auth' + deviceToken: string + v?: 2 + transcriptHashB64?: string +} + +export function isValidMobileE2EEAuthVersion( + auth: MobileE2EEAuth, + v2Session: DesktopMobileE2EEV2Session | null +): boolean { + if (!v2Session) { + return auth.v === undefined && auth.transcriptHashB64 === undefined + } + return ( + Object.keys(auth).sort().join(',') === 'deviceToken,transcriptHashB64,type,v' && + auth.v === 2 && + auth.transcriptHashB64 === v2Session.transcriptHashB64 + ) +} diff --git a/src/main/runtime/rpc/mobile-e2ee-v2-desktop-inbound.ts b/src/main/runtime/rpc/mobile-e2ee-v2-desktop-inbound.ts new file mode 100644 index 00000000000..0b64a855905 --- /dev/null +++ b/src/main/runtime/rpc/mobile-e2ee-v2-desktop-inbound.ts @@ -0,0 +1,34 @@ +import type { DesktopMobileE2EEV2Session } from './mobile-e2ee-v2-desktop-session' + +export function handleDesktopMobileE2EEV2Inbound(args: { + session: DesktopMobileE2EEV2Session + raw: string | Uint8Array + awaitingAuth: boolean + onDecryptFailure: () => void + onDecryptSuccess: () => void + onAuth: (plaintext: string) => void + onBinary: (plaintext: Uint8Array) => void + onText: (plaintext: string) => void + onProtocolError: () => void +}): void { + const plaintext = + typeof args.raw === 'string' + ? args.session.openText(args.raw) + : args.session.openBinary(args.raw) + if (plaintext === null) { + args.onDecryptFailure() + return + } + args.onDecryptSuccess() + if (args.awaitingAuth) { + if (typeof plaintext !== 'string') { + args.onProtocolError() + return + } + args.onAuth(plaintext) + } else if (typeof plaintext === 'string') { + args.onText(plaintext) + } else { + args.onBinary(plaintext) + } +} diff --git a/src/main/runtime/rpc/mobile-e2ee-v2-desktop-outbound.ts b/src/main/runtime/rpc/mobile-e2ee-v2-desktop-outbound.ts new file mode 100644 index 00000000000..4dc6d1fc121 --- /dev/null +++ b/src/main/runtime/rpc/mobile-e2ee-v2-desktop-outbound.ts @@ -0,0 +1,35 @@ +import type { WebSocket } from 'ws' +import { + createWsOutboundBackpressureQueue, + type WsOutboundBackpressureQueue +} from '../../../shared/ws-outbound-backpressure-queue' +import type { DesktopMobileE2EEV2Session } from './mobile-e2ee-v2-desktop-session' + +export type DesktopMobileE2EEV2OutboundItem = + | { kind: 'text'; plaintext: string } + | { kind: 'binary'; plaintext: Uint8Array } + +export function createDesktopMobileE2EEV2OutboundQueue(args: { + ws: WebSocket + session: DesktopMobileE2EEV2Session + onOverflow: () => void +}): WsOutboundBackpressureQueue { + return createWsOutboundBackpressureQueue({ + // Why: sealing happens only after queue admission, so counters cannot be + // consumed by an item rejected at the bounded queue boundary. + send: (item) => { + if (item.kind === 'text') { + args.ws.send(args.session.sealText(item.plaintext)) + } else { + args.ws.send(Buffer.from(args.session.sealBinary(item.plaintext)), { binary: true }) + } + }, + byteLengthOf: (item) => + (item.kind === 'text' + ? new TextEncoder().encode(item.plaintext).length + : item.plaintext.length) + 82, + getBufferedAmount: () => args.ws.bufferedAmount, + isWritable: () => args.ws.readyState === args.ws.OPEN, + onOverflow: args.onOverflow + }) +} diff --git a/src/main/runtime/rpc/mobile-e2ee-v2-desktop-session.test.ts b/src/main/runtime/rpc/mobile-e2ee-v2-desktop-session.test.ts new file mode 100644 index 00000000000..70123a9a683 --- /dev/null +++ b/src/main/runtime/rpc/mobile-e2ee-v2-desktop-session.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import nacl from 'tweetnacl' +import { deriveSharedKey } from './e2ee-crypto' +import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule' +import { + encodeMobileE2EEV2Transcript, + validateMobileE2EEV2Handshake, + type MobileE2EEV2Hello +} from '../../../shared/mobile-e2ee-v2-contract' +import { sealMobileE2EEV2Frame } from '../../../shared/mobile-e2ee-v2-framing' +import { DesktopMobileE2EEV2Session } from './mobile-e2ee-v2-desktop-session' + +const server = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1)) +const client = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2)) + +function hello(): MobileE2EEV2Hello { + return { + type: 'e2ee_hello', + v: 2, + clientPublicKeyB64: Buffer.from(client.publicKey).toString('base64'), + clientNonceB64: Buffer.from(new Uint8Array(32).fill(3)).toString('base64'), + capabilities: { framing: [2], payloadKinds: ['text', 'binary'] }, + context: { + protocol: 'orca-mobile-e2ee', + initiator: 'mobile', + responder: 'desktop', + transport: 'relay', + relayHostId: 'AbCdEf0123_-xyZ9' + } + } +} + +describe('desktop mobile E2EE v2 session', () => { + it('creates a fresh ready message and opens exact-next auth counter zero', () => { + const clientHello = hello() + const session = DesktopMobileE2EEV2Session.create({ + hello: clientHello, + serverSecretKey: server.secretKey, + expectedContext: { transport: 'relay', relayHostId: 'AbCdEf0123_-xyZ9' }, + randomBytes: () => new Uint8Array(32).fill(4) + })! + const handshake = validateMobileE2EEV2Handshake(clientHello, session.ready)! + const schedule = deriveMobileE2EEV2KeySchedule({ + sharedSecret: deriveSharedKey(client.secretKey, server.publicKey), + transcript: encodeMobileE2EEV2Transcript(handshake), + clientNonce: handshake.clientNonce, + desktopNonce: handshake.desktopNonce + }) + const auth = JSON.stringify({ + type: 'e2ee_auth', + v: 2, + transcriptHashB64: session.transcriptHashB64, + deviceToken: 'token' + }) + const frame = sealMobileE2EEV2Frame({ + payload: new TextEncoder().encode(auth), + key: schedule.mobileToDesktopKey, + sessionId: schedule.sessionId, + direction: 'mobile-to-desktop', + payloadKind: 'text', + counter: 0n + }) + + expect(session.openText(Buffer.from(frame).toString('base64'))).toBe(auth) + expect(session.openText(Buffer.from(frame).toString('base64'))).toBeNull() + }) + + it('rejects a transport or relayHostId mismatch before deriving keys', () => { + expect( + DesktopMobileE2EEV2Session.create({ + hello: hello(), + serverSecretKey: server.secretKey, + expectedContext: { transport: 'direct' } + }) + ).toBeNull() + }) + + it('shares one outbound counter across text and binary', () => { + const session = DesktopMobileE2EEV2Session.create({ + hello: hello(), + serverSecretKey: server.secretKey, + expectedContext: { transport: 'relay', relayHostId: 'AbCdEf0123_-xyZ9' }, + randomBytes: () => new Uint8Array(32).fill(4) + })! + + const text = Buffer.from(session.sealText('one'), 'base64') + const binary = session.sealBinary(new Uint8Array([2])) + expect(text.subarray(16, 24)).toEqual(Buffer.alloc(8, 0)) + expect(binary.subarray(16, 24)).toEqual(Uint8Array.from(Buffer.from('0000000000000001', 'hex'))) + }) +}) diff --git a/src/main/runtime/rpc/mobile-e2ee-v2-desktop-session.ts b/src/main/runtime/rpc/mobile-e2ee-v2-desktop-session.ts new file mode 100644 index 00000000000..e656d30ed01 --- /dev/null +++ b/src/main/runtime/rpc/mobile-e2ee-v2-desktop-session.ts @@ -0,0 +1,147 @@ +import nacl from 'tweetnacl' +import { + encodeMobileE2EEV2Transcript, + validateMobileE2EEV2Handshake, + type MobileE2EETransport, + type MobileE2EEV2Hello, + type MobileE2EEV2Ready +} from '../../../shared/mobile-e2ee-v2-contract' +import { + openMobileE2EEV2Frame, + sealMobileE2EEV2Frame +} from '../../../shared/mobile-e2ee-v2-framing' +import { deriveSharedKey } from './e2ee-crypto' +import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule' + +export type DesktopMobileE2EEV2Context = { + transport: MobileE2EETransport + relayHostId?: string +} + +export class DesktopMobileE2EEV2Session { + private inboundCounter = 0n + private outboundCounter = 0n + + private constructor( + readonly ready: MobileE2EEV2Ready, + readonly transcriptHashB64: string, + private readonly mobileToDesktopKey: Uint8Array, + private readonly desktopToMobileKey: Uint8Array, + private readonly sessionId: Uint8Array + ) {} + + static create(args: { + hello: unknown + serverSecretKey: Uint8Array + expectedContext: DesktopMobileE2EEV2Context + randomBytes?: (length: number) => Uint8Array + }): DesktopMobileE2EEV2Session | null { + if (!hasExpectedContext(args.hello, args.expectedContext)) { + return null + } + const hello = args.hello as MobileE2EEV2Hello + const serverKeys = nacl.box.keyPair.fromSecretKey(args.serverSecretKey) + const randomBytes = args.randomBytes ?? ((length: number) => nacl.randomBytes(length)) + const ready: MobileE2EEV2Ready = { + type: 'e2ee_ready', + v: 2, + desktopPublicKeyB64: Buffer.from(serverKeys.publicKey).toString('base64'), + clientNonceB64: hello.clientNonceB64, + desktopNonceB64: Buffer.from(randomBytes(32)).toString('base64'), + selection: { framing: 2, payloadKinds: ['text', 'binary'] }, + context: hello.context + } + const handshake = validateMobileE2EEV2Handshake(hello, ready) + if (!handshake) { + return null + } + const sharedSecret = deriveSharedKey(args.serverSecretKey, handshake.clientPublicKey) + const schedule = deriveMobileE2EEV2KeySchedule({ + sharedSecret, + transcript: encodeMobileE2EEV2Transcript(handshake), + clientNonce: handshake.clientNonce, + desktopNonce: handshake.desktopNonce + }) + return new DesktopMobileE2EEV2Session( + ready, + Buffer.from(schedule.transcriptHash).toString('base64'), + schedule.mobileToDesktopKey, + schedule.desktopToMobileKey, + schedule.sessionId + ) + } + + openText(frameB64: string): string | null { + const frame = decodeCanonicalBase64(frameB64) + if (!frame) { + return null + } + const plaintext = this.open(frame, 'text') + return plaintext ? new TextDecoder().decode(plaintext) : null + } + + openBinary(frame: Uint8Array): Uint8Array | null { + return this.open(frame, 'binary') + } + + sealText(plaintext: string): string { + return Buffer.from(this.seal(new TextEncoder().encode(plaintext), 'text')).toString('base64') + } + + sealBinary(plaintext: Uint8Array): Uint8Array { + return this.seal(plaintext, 'binary') + } + + private open(frame: Uint8Array, payloadKind: 'text' | 'binary'): Uint8Array | null { + const plaintext = openMobileE2EEV2Frame({ + frame, + key: this.mobileToDesktopKey, + sessionId: this.sessionId, + direction: 'mobile-to-desktop', + payloadKind, + expectedCounter: this.inboundCounter + }) + if (plaintext) { + this.inboundCounter++ + } + return plaintext + } + + private seal(plaintext: Uint8Array, payloadKind: 'text' | 'binary'): Uint8Array { + const frame = sealMobileE2EEV2Frame({ + payload: plaintext, + key: this.desktopToMobileKey, + sessionId: this.sessionId, + direction: 'desktop-to-mobile', + payloadKind, + counter: this.outboundCounter + }) + this.outboundCounter++ + return frame + } +} + +function hasExpectedContext( + hello: unknown, + expected: DesktopMobileE2EEV2Context +): hello is MobileE2EEV2Hello { + if (typeof hello !== 'object' || hello === null || !('context' in hello)) { + return false + } + const context = (hello as { context?: unknown }).context + if (typeof context !== 'object' || context === null) { + return false + } + const candidate = context as { transport?: unknown; relayHostId?: unknown } + return ( + candidate.transport === expected.transport && candidate.relayHostId === expected.relayHostId + ) +} + +function decodeCanonicalBase64(value: string): Uint8Array | null { + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { + return null + } + const bytes = Buffer.from(value, 'base64') + return bytes.toString('base64') === value ? bytes : null +} diff --git a/src/main/runtime/rpc/mobile-e2ee-v2-key-schedule.test.ts b/src/main/runtime/rpc/mobile-e2ee-v2-key-schedule.test.ts new file mode 100644 index 00000000000..b25b67bbb0c --- /dev/null +++ b/src/main/runtime/rpc/mobile-e2ee-v2-key-schedule.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { + encodeMobileE2EEV2Transcript, + validateMobileE2EEV2Handshake +} from '../../../shared/mobile-e2ee-v2-contract' +import { + createMobileE2EEV2Fixture, + MOBILE_E2EE_V2_VECTOR +} from '../../../shared/mobile-e2ee-v2-fixtures' +import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule' + +describe('desktop mobile E2EE v2 key schedule', () => { + it('derives the normative 96-byte HKDF vector', () => { + const { hello, ready, sharedSecret } = createMobileE2EEV2Fixture() + const handshake = validateMobileE2EEV2Handshake(hello, ready)! + const schedule = deriveMobileE2EEV2KeySchedule({ + sharedSecret, + transcript: encodeMobileE2EEV2Transcript(handshake), + clientNonce: handshake.clientNonce, + desktopNonce: handshake.desktopNonce + }) + + expect(Buffer.from(schedule.mobileToDesktopKey).toString('hex')).toBe( + MOBILE_E2EE_V2_VECTOR.mobileToDesktopKeyHex + ) + expect(Buffer.from(schedule.desktopToMobileKey).toString('hex')).toBe( + MOBILE_E2EE_V2_VECTOR.desktopToMobileKeyHex + ) + expect(Buffer.from(schedule.sessionId).toString('hex')).toBe(MOBILE_E2EE_V2_VECTOR.sessionIdHex) + expect(Buffer.from(schedule.transcriptHash).toString('hex')).toBe( + MOBILE_E2EE_V2_VECTOR.transcriptHashHex + ) + }) + + it('derives unique direction keys and session IDs across fresh desktop nonces', () => { + const { hello, ready, sharedSecret } = createMobileE2EEV2Fixture() + const fingerprints = new Set() + for (let index = 0; index < 128; index++) { + const nonce = Buffer.alloc(32) + nonce.writeUInt32BE(index, 28) + const handshake = validateMobileE2EEV2Handshake(hello, { + ...ready, + desktopNonceB64: nonce.toString('base64') + })! + const schedule = deriveMobileE2EEV2KeySchedule({ + sharedSecret, + transcript: encodeMobileE2EEV2Transcript(handshake), + clientNonce: handshake.clientNonce, + desktopNonce: handshake.desktopNonce + }) + fingerprints.add( + [schedule.mobileToDesktopKey, schedule.desktopToMobileKey, schedule.sessionId] + .map((bytes) => Buffer.from(bytes).toString('hex')) + .join(':') + ) + } + expect(fingerprints.size).toBe(128) + }) +}) diff --git a/src/main/runtime/rpc/mobile-e2ee-v2-key-schedule.ts b/src/main/runtime/rpc/mobile-e2ee-v2-key-schedule.ts new file mode 100644 index 00000000000..ec61a109b23 --- /dev/null +++ b/src/main/runtime/rpc/mobile-e2ee-v2-key-schedule.ts @@ -0,0 +1,53 @@ +import { createHash, hkdfSync } from 'node:crypto' + +const SALT_LABEL = new TextEncoder().encode('orca-mobile-e2ee/v2/salt\0') +const INFO_LABEL = new TextEncoder().encode('orca-mobile-e2ee/v2/session\0') + +export type MobileE2EEV2KeySchedule = { + mobileToDesktopKey: Uint8Array + desktopToMobileKey: Uint8Array + sessionId: Uint8Array + transcriptHash: Uint8Array +} + +export function deriveMobileE2EEV2KeySchedule(args: { + sharedSecret: Uint8Array + transcript: Uint8Array + clientNonce: Uint8Array + desktopNonce: Uint8Array +}): MobileE2EEV2KeySchedule { + requireLength(args.sharedSecret, 32, 'shared secret') + requireLength(args.clientNonce, 32, 'client nonce') + requireLength(args.desktopNonce, 32, 'desktop nonce') + + const transcriptHash = sha256(args.transcript) + const salt = sha256(concatBytes([SALT_LABEL, args.clientNonce, args.desktopNonce])) + const info = concatBytes([INFO_LABEL, transcriptHash]) + const expanded = new Uint8Array(hkdfSync('sha256', args.sharedSecret, salt, info, 96)) + return { + mobileToDesktopKey: expanded.slice(0, 32), + desktopToMobileKey: expanded.slice(32, 64), + sessionId: expanded.slice(64, 96), + transcriptHash + } +} + +function sha256(bytes: Uint8Array): Uint8Array { + return new Uint8Array(createHash('sha256').update(bytes).digest()) +} + +function concatBytes(parts: readonly Uint8Array[]): Uint8Array { + const result = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)) + let offset = 0 + for (const part of parts) { + result.set(part, offset) + offset += part.length + } + return result +} + +function requireLength(bytes: Uint8Array, expected: number, label: string): void { + if (bytes.length !== expected) { + throw new Error(`Invalid ${label}: expected ${expected} bytes, got ${bytes.length}`) + } +} diff --git a/src/main/runtime/rpc/mobile-socket-wiring.test.ts b/src/main/runtime/rpc/mobile-socket-wiring.test.ts new file mode 100644 index 00000000000..e5cf9e9bff6 --- /dev/null +++ b/src/main/runtime/rpc/mobile-socket-wiring.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it, vi } from 'vitest' +import nacl from 'tweetnacl' +import type { WebSocket } from 'ws' +import { + encodeMobileE2EEV2Transcript, + validateMobileE2EEV2Handshake, + type MobileE2EEV2Hello, + type MobileE2EEV2Ready +} from '../../../shared/mobile-e2ee-v2-contract' +import { sealMobileE2EEV2Frame } from '../../../shared/mobile-e2ee-v2-framing' +import type { DeviceRegistry } from '../device-registry' +import { deriveSharedKey, encrypt, generateKeyPair } from './e2ee-crypto' +import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule' +import { + MobileSocketWiring, + type MobileSocketTransport, + type MobileSocketTransportMetadata +} from './mobile-socket-wiring' + +class FakeSocket { + readonly OPEN = 1 + readyState = this.OPEN + bufferedAmount = 0 + readonly sent: (string | Buffer)[] = [] + readonly send = vi.fn((data: string | Buffer) => this.sent.push(data)) + readonly close = vi.fn() +} + +class FakeTransport implements MobileSocketTransport { + private messageHandler: Parameters[0] | null = null + private closeHandler: Parameters[0] | null = null + readonly setClientId = vi.fn() + readonly terminateClientConnections = vi.fn(() => 0) + + onMessage(handler: Parameters[0]): void { + this.messageHandler = handler + } + + onConnectionClose(handler: Parameters[0]): void { + this.closeHandler = handler + } + + receive(ws: FakeSocket, message: string): void { + this.messageHandler?.(message, vi.fn(), ws as unknown as WebSocket) + } + + disconnect(ws: FakeSocket): void { + this.closeHandler?.(null, ws as unknown as WebSocket, false) + } +} + +function registryFor(deviceId: string, token: string): DeviceRegistry { + return { + validateToken: (candidate: string) => + candidate === token + ? { + deviceId, + token, + name: 'Phone', + scope: 'mobile' as const, + pairedAt: 1, + lastSeenAt: 0 + } + : null, + updateLastSeen: vi.fn() + } as unknown as DeviceRegistry +} + +describe('MobileSocketWiring', () => { + it('terminates a revoked device across every attached transport', () => { + const direct = new FakeTransport() + const relay = new FakeTransport() + direct.terminateClientConnections.mockReturnValue(1) + relay.terminateClientConnections.mockReturnValue(2) + const desktop = generateKeyPair() + const wiring = new MobileSocketWiring({ + deviceRegistry: registryFor('device-1', 'valid-token'), + e2eeKeypair: { + publicKey: desktop.publicKey, + secretKey: desktop.secretKey, + publicKeyB64: Buffer.from(desktop.publicKey).toString('base64') + }, + onText: vi.fn(), + onBinary: vi.fn(), + onClose: vi.fn() + }) + wiring.attachTransport(direct) + wiring.attachTransport(relay) + + expect(wiring.terminateDeviceConnections('valid-token')).toBe(3) + expect(direct.terminateClientConnections).toHaveBeenCalledWith('valid-token') + expect(relay.terminateClientConnections).toHaveBeenCalledWith('valid-token') + }) + + it('preserves the legacy direct handshake, identity, and close cleanup', () => { + const desktop = generateKeyPair() + const phone = generateKeyPair() + const ws = new FakeSocket() + const transport = new FakeTransport() + const onText = vi.fn() + const onClose = vi.fn() + const wiring = new MobileSocketWiring({ + deviceRegistry: registryFor('device-1', 'valid-token'), + e2eeKeypair: { + publicKey: desktop.publicKey, + secretKey: desktop.secretKey, + publicKeyB64: Buffer.from(desktop.publicKey).toString('base64') + }, + onText, + onBinary: vi.fn(), + onClose + }) + wiring.attachTransport(transport) + + transport.receive( + ws, + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: Buffer.from(phone.publicKey).toString('base64') + }) + ) + const sharedKey = deriveSharedKey(phone.secretKey, desktop.publicKey) + transport.receive( + ws, + encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: 'valid-token' }), sharedKey) + ) + transport.receive(ws, encrypt('{"id":"rpc-1","method":"status.get"}', sharedKey)) + + expect(transport.setClientId).toHaveBeenCalledWith(ws, 'valid-token') + expect(onText).toHaveBeenCalledOnce() + expect(onText.mock.calls[0]?.[0]).toMatchObject({ + device: { deviceId: 'device-1', deviceToken: 'valid-token', scope: 'mobile' }, + transport: { transport: 'direct' } + }) + + transport.disconnect(ws) + expect(onClose).toHaveBeenCalledWith(expect.objectContaining({ ws }), false) + expect(wiring.channelCount).toBe(0) + expect(wiring.connectionCount).toBe(0) + }) + + it('rejects a relay socket whose immutable relayDeviceId differs from E2EE identity', () => { + const desktop = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1)) + const phone = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2)) + const ws = new FakeSocket() + const transport = new FakeTransport() + const metadata: MobileSocketTransportMetadata = { + transport: 'relay', + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId: 'outer-device', + basisConnId: 'connection-1', + credentialKind: 'invite' + } + const wiring = new MobileSocketWiring({ + deviceRegistry: registryFor('e2ee-device', 'valid-token'), + e2eeKeypair: { + publicKey: desktop.publicKey, + secretKey: desktop.secretKey, + publicKeyB64: Buffer.from(desktop.publicKey).toString('base64') + }, + onText: vi.fn(), + onBinary: vi.fn(), + onClose: vi.fn() + }) + wiring.attachTransport(transport, () => metadata) + const hello: MobileE2EEV2Hello = { + type: 'e2ee_hello', + v: 2, + clientPublicKeyB64: Buffer.from(phone.publicKey).toString('base64'), + clientNonceB64: Buffer.from(new Uint8Array(32).fill(3)).toString('base64'), + capabilities: { framing: [2], payloadKinds: ['text', 'binary'] }, + context: { + protocol: 'orca-mobile-e2ee', + initiator: 'mobile', + responder: 'desktop', + transport: 'relay', + relayHostId: metadata.relayHostId + } + } + transport.receive(ws, JSON.stringify(hello)) + const ready = JSON.parse(ws.sent[0]!.toString()) as MobileE2EEV2Ready + const handshake = validateMobileE2EEV2Handshake(hello, ready)! + const schedule = deriveMobileE2EEV2KeySchedule({ + sharedSecret: deriveSharedKey(phone.secretKey, desktop.publicKey), + transcript: encodeMobileE2EEV2Transcript(handshake), + clientNonce: handshake.clientNonce, + desktopNonce: handshake.desktopNonce + }) + const auth = sealMobileE2EEV2Frame({ + payload: new TextEncoder().encode( + JSON.stringify({ + type: 'e2ee_auth', + v: 2, + transcriptHashB64: Buffer.from(schedule.transcriptHash).toString('base64'), + deviceToken: 'valid-token' + }) + ), + key: schedule.mobileToDesktopKey, + sessionId: schedule.sessionId, + direction: 'mobile-to-desktop', + payloadKind: 'text', + counter: 0n + }) + transport.receive(ws, Buffer.from(auth).toString('base64')) + + expect(transport.setClientId).not.toHaveBeenCalled() + expect(ws.close).toHaveBeenCalledWith(4001, 'Unauthorized') + }) +}) diff --git a/src/main/runtime/rpc/mobile-socket-wiring.ts b/src/main/runtime/rpc/mobile-socket-wiring.ts new file mode 100644 index 00000000000..bb7ece7be51 --- /dev/null +++ b/src/main/runtime/rpc/mobile-socket-wiring.ts @@ -0,0 +1,189 @@ +import { randomBytes } from 'node:crypto' +import type { WebSocket } from 'ws' +import type { DeviceEntry, DeviceRegistry } from '../device-registry' +import type { E2EEKeypair } from '../e2ee-keypair' +import { E2EEChannel, type E2EEAuthenticatedDevice } from './e2ee-channel' + +type MobileSocketPayload = string | Uint8Array + +export type MobileSocketTransportMetadata = + | { transport: 'direct' } + | { + transport: 'relay' + relayHostId: string + relayDeviceId: string + basisConnId: string + credentialKind: 'invite' | 'resume' + } + +export type MobileSocketTransport = { + onMessage( + handler: ( + message: MobileSocketPayload, + reply: (response: string) => void, + ws: WebSocket + ) => void + ): void + onConnectionClose( + handler: (clientId: string | null, ws: WebSocket, hasOtherConnections: boolean) => void + ): void + setClientId(ws: WebSocket, clientId: string): void + terminateClientConnections(clientId: string): number +} + +export type AuthenticatedMobileSocket = { + ws: WebSocket + connectionId: string + device: E2EEAuthenticatedDevice + transport: MobileSocketTransportMetadata +} + +type MobileSocketWiringOptions = { + deviceRegistry: DeviceRegistry + e2eeKeypair: E2EEKeypair + onText: ( + socket: AuthenticatedMobileSocket, + plaintext: string, + reply: (response: string) => void, + sendBinary: (response: Uint8Array) => boolean | void + ) => void + onBinary: (socket: AuthenticatedMobileSocket, bytes: Uint8Array) => void + onClose: (socket: AuthenticatedMobileSocket | null, hasOtherConnections: boolean) => void + onReady?: (socket: AuthenticatedMobileSocket) => void +} + +function toAuthenticatedDevice(device: DeviceEntry): E2EEAuthenticatedDevice { + return { + deviceId: device.deviceId, + deviceToken: device.token, + scope: device.scope + } +} + +export class MobileSocketWiring { + private readonly deviceRegistry: DeviceRegistry + private readonly e2eeKeypair: E2EEKeypair + private readonly onText: MobileSocketWiringOptions['onText'] + private readonly onBinary: MobileSocketWiringOptions['onBinary'] + private readonly onClose: MobileSocketWiringOptions['onClose'] + private readonly onReady: MobileSocketWiringOptions['onReady'] + private readonly channels = new Map() + private readonly connectionIds = new Map() + private readonly authenticatedSockets = new Map() + private readonly transports = new Set() + + constructor(options: MobileSocketWiringOptions) { + this.deviceRegistry = options.deviceRegistry + this.e2eeKeypair = options.e2eeKeypair + this.onText = options.onText + this.onBinary = options.onBinary + this.onClose = options.onClose + this.onReady = options.onReady + } + + attachTransport( + transport: MobileSocketTransport, + getMetadata: (ws: WebSocket) => MobileSocketTransportMetadata = () => ({ + transport: 'direct' + }) + ): void { + this.transports.add(transport) + transport.onMessage((message, _reply, ws) => { + this.handleRawMessage(transport, ws, message, getMetadata(ws)) + }) + transport.onConnectionClose((_clientId, ws) => this.handleClose(ws)) + } + + getConnectionId(ws: WebSocket): string | undefined { + return this.connectionIds.get(ws) + } + + get channelCount(): number { + return this.channels.size + } + + get connectionCount(): number { + return this.connectionIds.size + } + + terminateDeviceConnections(deviceToken: string): number { + let terminated = 0 + for (const transport of this.transports) { + terminated += transport.terminateClientConnections(deviceToken) + } + return terminated + } + + private handleRawMessage( + transport: MobileSocketTransport, + ws: WebSocket, + message: MobileSocketPayload, + metadata: MobileSocketTransportMetadata + ): void { + let channel = this.channels.get(ws) + if (!channel) { + const connectionId = randomBytes(8).toString('hex') + this.connectionIds.set(ws, connectionId) + channel = new E2EEChannel(ws, { + serverSecretKey: this.e2eeKeypair.secretKey, + transportContext: + metadata.transport === 'relay' + ? { transport: 'relay', relayHostId: metadata.relayHostId } + : { transport: 'direct' }, + requireV2: metadata.transport === 'relay', + resolveAuthenticatedDevice: (token) => { + const device = this.deviceRegistry.validateToken(token) + if (!device) { + return null + } + // 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) { + return null + } + return toAuthenticatedDevice(device) + }, + onReady: (_channel, device) => { + const socket = { ws, connectionId, device, transport: metadata } + this.authenticatedSockets.set(ws, socket) + transport.setClientId(ws, device.deviceToken) + this.deviceRegistry.updateLastSeen(device.deviceId) + this.onReady?.(socket) + }, + onError: (code, reason) => { + this.channels.get(ws)?.destroy() + this.channels.delete(ws) + ws.close(code, reason) + } + }) + channel.onMessage((plaintext, reply, sendBinary) => { + const socket = this.authenticatedSockets.get(ws) + if (socket) { + this.onText(socket, plaintext, reply, sendBinary) + } + }) + channel.onBinaryMessage((bytes) => { + const socket = this.authenticatedSockets.get(ws) + if (socket) { + this.onBinary(socket, bytes) + } + }) + this.channels.set(ws, channel) + } + channel.handleRawMessage(message) + } + + private handleClose(ws: WebSocket): void { + const socket = this.authenticatedSockets.get(ws) ?? null + this.authenticatedSockets.delete(ws) + this.channels.get(ws)?.destroy() + this.channels.delete(ws) + this.connectionIds.delete(ws) + const hasOtherConnections = + socket !== null && + Array.from(this.authenticatedSockets.values()).some( + (candidate) => candidate.device.deviceToken === socket.device.deviceToken + ) + this.onClose(socket, hasOtherConnections) + } +} diff --git a/src/main/runtime/rpc/relay-transport.test.ts b/src/main/runtime/rpc/relay-transport.test.ts new file mode 100644 index 00000000000..432503b9a3c --- /dev/null +++ b/src/main/runtime/rpc/relay-transport.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import WebSocketClient, { WebSocketServer, type WebSocket } from 'ws' +import { CloudRelayTransport } from './relay-transport' + +function nextMessage(ws: WebSocket): Promise<{ data: Buffer; isBinary: boolean }> { + return new Promise((resolve) => { + ws.once('message', (data, isBinary) => resolve({ data: Buffer.from(data as Buffer), isBinary })) + }) +} + +describe('CloudRelayTransport', () => { + const servers: WebSocketServer[] = [] + const transports: CloudRelayTransport[] = [] + + afterEach(async () => { + await Promise.all(transports.splice(0).map((transport) => transport.stop())) + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((resolve) => { + for (const client of server.clients) { + client.terminate() + } + server.close(() => resolve()) + }) + ) + ) + }) + + it('authenticates one query-free host-data socket and forwards messages verbatim', async () => { + const server = new WebSocketServer({ port: 0, perMessageDeflate: false }) + servers.push(server) + await new Promise((resolve) => server.once('listening', resolve)) + const address = server.address() + if (typeof address === 'string' || address === null) { + throw new Error('expected TCP relay test server') + } + const accepted = new Promise<{ socket: WebSocket; path: string }>((resolve) => { + server.once('connection', (socket, request) => resolve({ socket, path: request.url ?? '' })) + }) + let clientSocket: WebSocketClient | null = null + const onConnectionClosed = vi.fn() + const transport = new CloudRelayTransport({ + cellUrl: `http://127.0.0.1:${address.port}`, + relayHostId: 'AbCdEf0123_-xyZ9', + generation: 7, + createSocket: (url) => { + clientSocket = new WebSocketClient(url, { perMessageDeflate: false }) + return clientSocket + }, + onConnectionClosed + }) + transports.push(transport) + const received: (string | Uint8Array)[] = [] + transport.onMessage((message) => received.push(message)) + transport.onConnectionClose(vi.fn()) + await transport.start() + const opening = transport.openConnection({ + connId: 'conn/with spaces', + connTicket: 'ticket-1', + kind: 'resume', + relayDeviceId: 'device-1', + attachDeadlineMs: 1_000 + }) + const { socket, path } = await accepted + const auth = await nextMessage(socket) + await opening + + expect(path).toBe('/v1/host/data/conn%2Fwith%20spaces') + expect(auth.isBinary).toBe(false) + expect(JSON.parse(auth.data.toString())).toEqual({ + type: 'host-data-auth', + v: 1, + connTicket: 'ticket-1', + generation: 7 + }) + socket.send('e2ee-hello') + socket.send(Buffer.from([1, 2, 3]), { binary: true }) + await vi.waitFor(() => expect(received).toHaveLength(2)) + expect(received[0]).toBe('e2ee-hello') + expect(received[1]).toEqual(new Uint8Array([1, 2, 3])) + + expect(clientSocket).not.toBeNull() + const metadata = transport.metadataFor(clientSocket!) + expect(metadata).toEqual({ + transport: 'relay', + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId: 'device-1', + basisConnId: 'conn/with spaces', + credentialKind: 'resume' + }) + socket.close() + await vi.waitFor(() => expect(onConnectionClosed).toHaveBeenCalledWith('conn/with spaces')) + }) + + it('rejects non-origin cell URLs before opening a socket', () => { + expect( + () => + new CloudRelayTransport({ + cellUrl: 'https://relay.example/path?credential=forbidden', + relayHostId: 'AbCdEf0123_-xyZ9', + generation: 1 + }) + ).toThrow('relay_cell_url_must_be_an_origin') + }) +}) diff --git a/src/main/runtime/rpc/relay-transport.ts b/src/main/runtime/rpc/relay-transport.ts new file mode 100644 index 00000000000..1a34afdf849 --- /dev/null +++ b/src/main/runtime/rpc/relay-transport.ts @@ -0,0 +1,217 @@ +import WebSocket from 'ws' +import type { RpcTransport } from './transport' +import type { MobileSocketTransport, MobileSocketTransportMetadata } from './mobile-socket-wiring' + +const MAX_RELAY_MESSAGE_BYTES = 1024 * 1024 + +type RelayMessagePayload = string | Uint8Array + +export type RelayConnectionOpen = { + connId: string + connTicket: string + kind: 'invite' | 'resume' + relayDeviceId: string + attachDeadlineMs: number +} + +type CloudRelayTransportOptions = { + cellUrl: string + relayHostId: string + generation: number + createSocket?: (url: string) => WebSocket + onConnectionClosed?: (connectionId: string) => void +} + +function relayWebSocketOrigin(cellUrl: string): string { + const url = new URL(cellUrl) + if (url.pathname !== '/' || url.search || url.hash) { + throw new Error('relay_cell_url_must_be_an_origin') + } + if (url.protocol === 'https:') { + url.protocol = 'wss:' + } else if (url.protocol === 'http:') { + url.protocol = 'ws:' + } else { + throw new Error('relay_cell_url_must_use_http') + } + return url.origin +} + +export class CloudRelayTransport implements RpcTransport, MobileSocketTransport { + private readonly cellWebSocketOrigin: string + private readonly relayHostId: string + private generation: number + private readonly createSocket: (url: string) => WebSocket + private readonly onConnectionClosed: ((connectionId: string) => void) | undefined + private readonly socketsByConnectionId = new Map() + private readonly metadataBySocket = new Map() + private readonly clientIds = new Map() + private messageHandler: Parameters[0] | null = null + private closeHandler: Parameters[0] | null = null + private stopped = false + + constructor(options: CloudRelayTransportOptions) { + this.cellWebSocketOrigin = relayWebSocketOrigin(options.cellUrl) + this.relayHostId = options.relayHostId + this.generation = options.generation + this.onConnectionClosed = options.onConnectionClosed + this.createSocket = + options.createSocket ?? + ((url) => + new WebSocket(url, { perMessageDeflate: false, maxPayload: MAX_RELAY_MESSAGE_BYTES })) + } + + onMessage(handler: Parameters[0]): void { + this.messageHandler = handler + } + + onConnectionClose(handler: Parameters[0]): void { + this.closeHandler = handler + } + + metadataFor(ws: WebSocket): MobileSocketTransportMetadata { + const metadata = this.metadataBySocket.get(ws) + if (!metadata) { + throw new Error('unknown_relay_socket') + } + return metadata + } + + setClientId(ws: WebSocket, clientId: string): void { + if (this.metadataBySocket.has(ws)) { + this.clientIds.set(ws, clientId) + } + } + + setGeneration(generation: number): void { + if (generation === this.generation) { + return + } + if ( + this.socketsByConnectionId.size > 0 || + !Number.isSafeInteger(generation) || + generation <= 0 + ) { + throw new Error('invalid_relay_generation_transition') + } + this.generation = generation + } + + terminateClientConnections(clientId: string): number { + const sockets = Array.from(this.clientIds.entries()) + .filter(([, candidate]) => candidate === clientId) + .map(([socket]) => socket) + for (const socket of sockets) { + socket.terminate() + } + return sockets.length + } + + async start(): Promise { + this.stopped = false + } + + async stop(): Promise { + this.stopped = true + const sockets = [...this.metadataBySocket.keys()] + for (const socket of sockets) { + socket.terminate() + } + await Promise.all(sockets.map((socket) => this.waitForClose(socket))) + } + + async openConnection(connection: RelayConnectionOpen): Promise { + if (this.stopped) { + throw new Error('relay_transport_stopped') + } + if (this.socketsByConnectionId.has(connection.connId)) { + return + } + const url = `${this.cellWebSocketOrigin}/v1/host/data/${encodeURIComponent(connection.connId)}` + const socket = this.createSocket(url) + const metadata: MobileSocketTransportMetadata = { + transport: 'relay', + relayHostId: this.relayHostId, + relayDeviceId: connection.relayDeviceId, + basisConnId: connection.connId, + credentialKind: connection.kind + } + this.socketsByConnectionId.set(connection.connId, socket) + this.metadataBySocket.set(socket, metadata) + + await new Promise((resolve, reject) => { + let opened = false + let attached = false + let finalized = false + const deadline = setTimeout(() => { + socket.terminate() + if (!opened) { + reject(new Error('relay_host_data_attach_timeout')) + } + }, connection.attachDeadlineMs) + const finalize = (): void => { + if (finalized) { + return + } + finalized = true + clearTimeout(deadline) + this.socketsByConnectionId.delete(connection.connId) + this.metadataBySocket.delete(socket) + const clientId = this.clientIds.get(socket) ?? null + this.clientIds.delete(socket) + this.onConnectionClosed?.(connection.connId) + const hasOtherConnections = + clientId !== null && [...this.clientIds.values()].includes(clientId) + this.closeHandler?.(clientId, socket, hasOtherConnections) + } + socket.on('message', (raw, isBinary) => { + if (!attached) { + attached = true + clearTimeout(deadline) + } + const message: RelayMessagePayload = isBinary + ? new Uint8Array(raw as Buffer) + : raw.toString() + this.messageHandler?.( + message, + (response) => { + if (socket.readyState === socket.OPEN) { + socket.send(response) + } + }, + socket + ) + }) + socket.once('open', () => { + opened = true + const networkSocket = ( + socket as unknown as { _socket?: { setNoDelay(value: boolean): void } } + )._socket + networkSocket?.setNoDelay(true) + socket.send( + JSON.stringify({ + type: 'host-data-auth', + v: 1, + connTicket: connection.connTicket, + generation: this.generation + }) + ) + resolve() + }) + socket.once('error', (error) => { + if (!opened) { + finalize() + reject(error) + } + }) + socket.once('close', finalize) + }) + } + + private waitForClose(socket: WebSocket): Promise { + if (socket.readyState === socket.CLOSED) { + return Promise.resolve() + } + return new Promise((resolve) => socket.once('close', resolve)) + } +} diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 2d7579cce48..03c8225b44d 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -501,6 +501,348 @@ describe('OrcaRuntimeRpcServer', () => { } }) + it('adds only the exact optional relay object to GUI mobile pairing offers', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + const relay = { + v: 1 as const, + directorUrl: 'https://relay.example.com', + cellUrl: 'https://cell.example.com', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: 'A'.repeat(43), + inviteExpiresAt: Date.now() + 60_000, + e2eeFraming: 2 as const + } + server.setMobileRelayPairingProvider({ + createPairingRelay: async (relayDeviceId) => ({ + relay, + binding: { + relayHostId: relay.relayHostId, + relayDeviceId, + ownerIdentityKey: 'user\0profile\0org' + } + }), + onDeviceRevokeQueued: vi.fn(), + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const offer = await server.createMobilePairingOffer({ + address: '100.64.1.20', + name: 'Mobile test' + }) + expect(offer.available).toBe(true) + if (!offer.available) { + throw new Error('WebSocket pairing unavailable') + } + const parsed = parsePairingCode(offer.pairingUrl) + expect(parsed).toEqual( + expect.objectContaining({ endpoint: offer.endpoint, scope: 'mobile', relay }) + ) + expect(parsed).not.toHaveProperty('endpoints') + expect(server.getDeviceRegistry()?.getDevice(offer.deviceId)?.relayBinding).toEqual({ + relayHostId: relay.relayHostId, + relayDeviceId: offer.deviceId, + ownerIdentityKey: 'user\0profile\0org' + }) + } finally { + await server.stop() + } + }) + + it('falls back to a valid direct-only GUI offer when relay invite minting fails', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + server.setMobileRelayPairingProvider({ + createPairingRelay: vi.fn().mockRejectedValue(new Error('relay offline')), + onDeviceRevokeQueued: vi.fn(), + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const offer = await server.createMobilePairingOffer({ address: '100.64.1.20' }) + expect(offer.available).toBe(true) + if (!offer.available) { + throw new Error('WebSocket pairing unavailable') + } + expect(parsePairingCode(offer.pairingUrl)).toMatchObject({ + endpoint: offer.endpoint, + scope: 'mobile' + }) + expect(parsePairingCode(offer.pairingUrl)).not.toHaveProperty('relay') + } finally { + await server.stop() + } + }) + + it('persists local-only pairing and never mints or later binds Relay', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + const createPairingRelay = vi.fn() + server.setMobileRelayPairingProvider({ + createPairingRelay, + onDeviceRevokeQueued: vi.fn(), + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const offer = await server.createMobilePairingOffer({ + address: '100.64.1.20', + connectionMode: 'local-only' + }) + expect(offer.available).toBe(true) + if (!offer.available) { + throw new Error('WebSocket pairing unavailable') + } + expect(parsePairingCode(offer.pairingUrl)).not.toHaveProperty('relay') + expect(createPairingRelay).not.toHaveBeenCalled() + expect(server.getDeviceRegistry()?.getMobilePairingConnectionMode(offer.deviceId)).toBe( + 'local-only' + ) + expect( + server.setMobileRelayBinding(offer.deviceId, { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId: offer.deviceId, + ownerIdentityKey: 'user\0profile\0org' + }) + ).toBe(false) + } finally { + await server.stop() + } + }) + + it('normalizes untrusted pairing modes to automatic at the runtime boundary', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + try { + const offer = await server.createMobilePairingOffer({ + connectionMode: 'renderer-controlled-value' as never + }) + expect(offer.available).toBe(true) + if (!offer.available) { + throw new Error('WebSocket pairing unavailable') + } + expect(server.getDeviceRegistry()?.getMobilePairingConnectionMode(offer.deviceId)).toBe( + 'automatic' + ) + } finally { + await server.stop() + } + }) + + it('revokes and rotates a pending Relay code when switching it to local-only', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + const onDeviceRevokeQueued = vi.fn() + server.setMobileRelayPairingProvider({ + createPairingRelay: async (relayDeviceId) => ({ + relay: { + v: 1, + directorUrl: 'https://relay.example.com', + cellUrl: 'https://cell.example.com', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: 'A'.repeat(43), + inviteExpiresAt: Date.now() + 60_000, + e2eeFraming: 2 + }, + binding: { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId, + ownerIdentityKey: 'user\0profile\0org' + } + }), + onDeviceRevokeQueued, + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const anywhere = await server.createMobilePairingOffer({ address: '100.64.1.20' }) + expect(anywhere.available).toBe(true) + if (!anywhere.available) { + throw new Error('WebSocket pairing unavailable') + } + const local = await server.createMobilePairingOffer({ + address: '100.64.1.20', + connectionMode: 'local-only' + }) + expect(local.available).toBe(true) + if (!local.available) { + throw new Error('WebSocket pairing unavailable') + } + expect(local.deviceId).not.toBe(anywhere.deviceId) + expect(server.getDeviceRegistry()?.getDevice(anywhere.deviceId)).toBeNull() + expect(onDeviceRevokeQueued).toHaveBeenCalledOnce() + expect(parsePairingCode(local.pairingUrl)).not.toHaveProperty('relay') + } finally { + await server.stop() + } + }) + + it('records cloud cleanup before rotating or deleting the local mobile credential', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + const registryPresence: boolean[] = [] + server.setMobileRelayPairingProvider({ + createPairingRelay: async (relayDeviceId) => ({ + relay: { + v: 1, + directorUrl: 'https://relay.example.com', + cellUrl: 'https://cell.example.com', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: 'A'.repeat(43), + inviteExpiresAt: Date.now() + 60_000, + e2eeFraming: 2 + }, + binding: { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId, + ownerIdentityKey: 'user\0profile\0org' + } + }), + onDeviceRevokeQueued: (item) => { + registryPresence.push(server.getDeviceRegistry()?.getDevice(item.relayDeviceId) !== null) + }, + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const first = await server.createMobilePairingOffer({ address: '100.64.1.20' }) + expect(first.available).toBe(true) + if (!first.available) { + throw new Error('WebSocket pairing unavailable') + } + const second = await server.createMobilePairingOffer({ + address: '100.64.1.20', + rotate: true + }) + expect(second.available).toBe(true) + if (!second.available) { + throw new Error('WebSocket pairing unavailable') + } + expect(server.getDeviceRegistry()?.getDevice(first.deviceId)).toBeNull() + await expect(server.revokeMobileDevice(second.deviceId)).resolves.toBe(true) + expect(server.getDeviceRegistry()?.getDevice(second.deviceId)).toBeNull() + expect(registryPresence).toEqual([true, true]) + } finally { + await server.stop() + } + }) + + it('binds pairing RPC providers to the immutable authenticated socket context', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + const getEndpoints = vi.fn().mockResolvedValue({ v: 1, relay: null }) + const provisionRelay = vi.fn().mockResolvedValue({ + v: 1, + reqId: 'install-1', + authorizationMode: 'authenticated-direct', + currentVersion: 1, + resumeExpiresAt: Date.now() + 60_000 + }) + server.setMobileRelayPairingProvider({ + createPairingRelay: vi.fn(), + onDeviceRevokeQueued: vi.fn(), + getEndpoints, + provisionRelay + }) + + await server.start() + try { + const offer = server.createPairingOffer({ + address: '127.0.0.1', + scope: 'mobile' + }) + expect(offer.available).toBe(true) + if (!offer.available) { + throw new Error('WebSocket pairing unavailable') + } + const session = await authenticateMobileWsSession(offer.pairingUrl) + const responses = createEncryptedWsResponseReader(session) + sendEncryptedWsRequest(session, { + id: 'endpoints-1', + method: 'pairing.getEndpoints', + params: { installReqId: 'status-1' } + }) + await expect(responses.next('endpoints-1')).resolves.toMatchObject({ + ok: true, + result: { v: 1, relay: null } + }) + sendEncryptedWsRequest(session, { + id: 'provision-1', + method: 'pairing.provisionRelay', + params: { reqId: 'install-1', newResumeTokenHash: 'A'.repeat(43) } + }) + await expect(responses.next('provision-1')).resolves.toMatchObject({ ok: true }) + + const [endpointContext, endpointParams] = getEndpoints.mock.calls[0]! + expect(endpointContext).toEqual({ + deviceId: offer.deviceId, + connectionId: expect.any(String), + transport: { transport: 'direct' } + }) + expect(endpointParams).toEqual({ installReqId: 'status-1' }) + expect(provisionRelay).toHaveBeenCalledWith(endpointContext, { + reqId: 'install-1', + newResumeTokenHash: 'A'.repeat(43) + }) + responses.dispose() + session.ws.close() + await waitForWsClose(session.ws) + } finally { + await server.stop() + } + }) + it('cleans up pre-auth E2EE WebSocket state when the socket closes', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const runtime = new OrcaRuntimeService() @@ -533,12 +875,16 @@ describe('OrcaRuntimeRpcServer', () => { }) ) expect(JSON.parse(await nextWsMessage(ws))).toEqual({ type: 'e2ee_ready' }) - expect(server['e2eeChannels'].size).toBe(1) - expect(server['wsConnectionIds'].size).toBe(1) + expect(server['mobileSocketWiring']?.channelCount).toBe(1) + expect(server['mobileSocketWiring']?.connectionCount).toBe(1) ws.close() await waitForWsClose(ws) - await waitFor(() => server['e2eeChannels'].size === 0 && server['wsConnectionIds'].size === 0) + await waitFor( + () => + server['mobileSocketWiring']?.channelCount === 0 && + server['mobileSocketWiring']?.connectionCount === 0 + ) } finally { await server.stop() } @@ -570,9 +916,13 @@ describe('OrcaRuntimeRpcServer', () => { const first = await authenticateMobileWs(offer.pairingUrl) const second = await authenticateMobileWs(offer.pairingUrl) - expect(server.revokeMobileDevice(offer.deviceId)).toBe(true) + await expect(server.revokeMobileDevice(offer.deviceId)).resolves.toBe(true) await Promise.all([waitForWsClose(first), waitForWsClose(second)]) - await waitFor(() => server['e2eeChannels'].size === 0 && server['wsConnectionIds'].size === 0) + await waitFor( + () => + server['mobileSocketWiring']?.channelCount === 0 && + server['mobileSocketWiring']?.connectionCount === 0 + ) expect(disconnectSpy).toHaveBeenCalledTimes(1) } finally { @@ -604,7 +954,7 @@ describe('OrcaRuntimeRpcServer', () => { throw new Error('WebSocket pairing unavailable') } - expect(server.revokeMobileDevice(offer.deviceId)).toBe(false) + await expect(server.revokeMobileDevice(offer.deviceId)).resolves.toBe(false) expect(server.getDeviceRegistry()?.getDevice(offer.deviceId)?.scope).toBe('runtime') } finally { await server.stop() @@ -638,7 +988,11 @@ describe('OrcaRuntimeRpcServer', () => { expect(server.revokeRuntimeAccess(offer.deviceId)).toBe(true) await Promise.all([waitForWsClose(first), waitForWsClose(second)]) - await waitFor(() => server['e2eeChannels'].size === 0 && server['wsConnectionIds'].size === 0) + await waitFor( + () => + server['mobileSocketWiring']?.channelCount === 0 && + server['mobileSocketWiring']?.connectionCount === 0 + ) expect(server.getDeviceRegistry()?.getDevice(offer.deviceId)).toBeNull() } finally { @@ -718,7 +1072,9 @@ describe('OrcaRuntimeRpcServer', () => { server['deviceRegistry'] = new DeviceRegistry(userDataPath) const entry = server['deviceRegistry']!.addDevice('runtime-test', 'runtime') const ws = new FakeWebSocket() - server['wsConnectionIds'].set(ws as unknown as WebSocket, 'conn-test') + server['mobileSocketWiring'] = { + getConnectionId: () => 'conn-test' + } as unknown as NonNullable<(typeof server)['mobileSocketWiring']> const replies: Record[] = [] try { @@ -778,7 +1134,9 @@ describe('OrcaRuntimeRpcServer', () => { server['deviceRegistry'] = new DeviceRegistry(userDataPath) const entry = server['deviceRegistry']!.addDevice('runtime-test', 'runtime') const ws = new FakeWebSocket() - server['wsConnectionIds'].set(ws as unknown as WebSocket, 'conn-test') + server['mobileSocketWiring'] = { + getConnectionId: () => 'conn-test' + } as unknown as NonNullable<(typeof server)['mobileSocketWiring']> let activeDispatches = 0 ;( server as unknown as { diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 522d3f0e3ed..62e1af606df 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -20,7 +20,24 @@ import { readWsFallbackPort, writeWsFallbackPort } from './rpc/ws-fallback-port- import type { WebSocket } from 'ws' import { DeviceRegistry, type DeviceScope } from './device-registry' import { loadOrCreateE2EEKeypair, type E2EEKeypair } from './e2ee-keypair' -import { E2EEChannel } from './rpc/e2ee-channel' +import { + MobileSocketWiring, + type AuthenticatedMobileSocket, + type MobileSocketTransportMetadata +} from './rpc/mobile-socket-wiring' +import type { PairingRelay } from '../../shared/mobile-relay-pairing-offer' +import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' +import { + RelayRevokeOutbox, + type RelayDeviceBinding, + type RelayRevokeOutboxItem +} from './relay/relay-revoke-outbox' +import type { + DeviceCredentialInstalled, + PairingGetEndpointsParams, + PairingGetEndpointsResult, + PairingProvisionRelayParams +} from '../../shared/mobile-relay-credential-contract' import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' import { decodeTerminalStreamFrame, @@ -45,6 +62,28 @@ type OrcaRuntimeRpcServerOptions = { longPollCap?: number } +type MobileRelayPairingProvider = { + createPairingRelay( + relayDeviceId: string + ): Promise<{ relay: PairingRelay; binding: RelayDeviceBinding }> + onDeviceRevokeQueued(item: RelayRevokeOutboxItem): void + onDemandStateChanged?(): void + getEndpoints( + context: MobilePairingConnectionContext, + params: PairingGetEndpointsParams + ): Promise + provisionRelay( + context: MobilePairingConnectionContext, + params: PairingProvisionRelayParams + ): Promise +} + +export type MobilePairingConnectionContext = Readonly<{ + deviceId: string + connectionId: string + transport: MobileSocketTransportMetadata +}> + // Why: after 10 s of a pending dispatch we emit a tiny `{"_keepalive":true}` // frame every 10 s until the handler resolves. Each write resets both the // server's own socket idle timer (30 s) and — once §3.1 ships on the client — @@ -288,6 +327,8 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'notifications.getMissedSince', 'notifications.subscribe', 'notifications.unsubscribe', + 'pairing.getEndpoints', + 'pairing.provisionRelay', 'preflight.check', 'preflight.detectAgents', 'preflight.detectRemoteAgents', @@ -411,19 +452,14 @@ export class OrcaRuntimeRpcServer { private readonly authToken = randomBytes(24).toString('hex') private readonly keepaliveIntervalMs: number private readonly longPollCap: number + private readonly relayRevokeOutbox: RelayRevokeOutbox private deviceRegistry: DeviceRegistry | null = null private e2eeKeypair: E2EEKeypair | null = null private tlsFingerprint: string | null = null - private wsTransport: WebSocketTransport | null = null private activeTransports: RpcTransport[] = [] private transports: RuntimeTransportMetadata[] = [] - // Why: each WebSocket connection has its own E2EE channel that manages the - // handshake and encrypt/decrypt lifecycle. Keyed by WebSocket instance. - private e2eeChannels = new Map() - // Why: stable per-WebSocket id used as the cleanup key for streaming - // subscriptions, so the server can reap a closing socket's subscriptions - // without affecting other live sockets that share the same deviceToken. - private wsConnectionIds = new Map() + private mobileSocketWiring: MobileSocketWiring | null = null + private mobileRelayPairingProvider: MobileRelayPairingProvider | null = null private readonly binaryStreamHandlers = new Map< string, Map void> @@ -458,6 +494,7 @@ export class OrcaRuntimeRpcServer { this.webClientRoot = webClientRoot this.keepaliveIntervalMs = keepaliveIntervalMs this.longPollCap = longPollCap + this.relayRevokeOutbox = new RelayRevokeOutbox(userDataPath) } getDeviceRegistry(): DeviceRegistry | null { @@ -476,12 +513,55 @@ export class OrcaRuntimeRpcServer { return this.e2eeKeypair } - revokeMobileDevice(deviceId: string): boolean { - const device = this.deviceRegistry?.getDevice(deviceId) - if (device?.scope !== 'mobile' || !this.deviceRegistry?.removeDevice(deviceId)) { + getMobileSocketWiring(): MobileSocketWiring | null { + return this.mobileSocketWiring + } + + getRelayRevokeOutbox(): RelayRevokeOutbox { + return this.relayRevokeOutbox + } + + setMobileRelayBinding(deviceId: string, binding: RelayDeviceBinding): boolean { + const current = this.deviceRegistry?.getDevice(deviceId) + if ( + current?.scope !== 'mobile' || + this.deviceRegistry?.getMobilePairingConnectionMode(deviceId) === 'local-only' + ) { return false } - this.wsTransport?.terminateClientConnections(device.token) + if ( + current.relayBinding && + (current.relayBinding.relayHostId !== binding.relayHostId || + current.relayBinding.ownerIdentityKey !== binding.ownerIdentityKey) + ) { + // Why: switching the account/host that owns this local pairing cannot + // strand the old cloud credential family even if that account is offline. + this.queueRelayDeviceRevoke(current.relayBinding) + } + const updated = this.deviceRegistry?.setRelayBinding(deviceId, binding) ?? false + if (updated) { + this.mobileRelayPairingProvider?.onDemandStateChanged?.() + } + return updated + } + + setMobileRelayPairingProvider(provider: MobileRelayPairingProvider | null): void { + this.mobileRelayPairingProvider = provider + } + + async revokeMobileDevice(deviceId: string): Promise { + const device = this.deviceRegistry?.getDevice(deviceId) + if (device?.scope !== 'mobile') { + return false + } + if (device.relayBinding) { + this.queueRelayDeviceRevoke(device.relayBinding) + } + if (!this.deviceRegistry?.removeDevice(deviceId)) { + return false + } + this.mobileRelayPairingProvider?.onDemandStateChanged?.() + this.mobileSocketWiring?.terminateDeviceConnections(device.token) return true } @@ -490,7 +570,7 @@ export class OrcaRuntimeRpcServer { if (device?.scope !== 'runtime' || !this.deviceRegistry?.removeDevice(deviceId)) { return false } - this.wsTransport?.terminateClientConnections(device.token) + this.mobileSocketWiring?.terminateDeviceConnections(device.token) return true } @@ -542,6 +622,71 @@ export class OrcaRuntimeRpcServer { } } + async createMobilePairingOffer(args: { + address?: string | null + connectionMode?: MobilePairingConnectionMode + name?: string + rotate?: boolean + }): Promise> { + // Why: the renderer is outside the trust boundary; only the explicit + // local-only value may suppress Relay provisioning. + const connectionMode = args.connectionMode === 'local-only' ? 'local-only' : 'automatic' + const pending = this.deviceRegistry?.getPendingDevice('mobile') + const switchingPendingToLocal = + connectionMode === 'local-only' && pending?.relayBinding !== undefined + if (args.rotate || switchingPendingToLocal) { + if (pending?.relayBinding) { + // Why: the durable cloud revoke is recorded before rotating the local + // token, so a previously displayed relay invite cannot outlive the QR. + this.queueRelayDeviceRevoke(pending.relayBinding) + } + } + const direct = this.createPairingOffer({ + ...args, + rotate: args.rotate || switchingPendingToLocal, + scope: 'mobile' + }) + if (!direct.available) { + return direct + } + this.deviceRegistry?.setMobilePairingConnectionMode(direct.deviceId, connectionMode) + if (connectionMode === 'local-only' || !this.mobileRelayPairingProvider) { + return direct + } + const device = this.deviceRegistry?.getDevice(direct.deviceId) + const publicKeyB64 = this.getE2EEPublicKey() + if (!device || !publicKeyB64) { + return direct + } + try { + const relayPairing = await this.mobileRelayPairingProvider.createPairingRelay(device.deviceId) + if (!this.deviceRegistry?.setRelayBinding(device.deviceId, relayPairing.binding)) { + return direct + } + this.mobileRelayPairingProvider.onDemandStateChanged?.() + return { + ...direct, + pairingUrl: encodePairingOffer({ + v: PAIRING_OFFER_VERSION, + endpoint: direct.endpoint, + deviceToken: device.token, + publicKeyB64, + scope: 'mobile', + relay: relayPairing.relay + }) + } + } catch { + // Why: relay is additive. A transient auth/director/control outage must + // still yield the valid LAN/Tailscale pairing offer. + return direct + } + } + + private queueRelayDeviceRevoke(binding: RelayDeviceBinding): void { + const item = this.relayRevokeOutbox.enqueue(binding) + this.mobileRelayPairingProvider?.onDeviceRevokeQueued(item) + } + private registerBinaryStreamHandler( connectionId: string | undefined, streamId: number, @@ -569,7 +714,7 @@ export class OrcaRuntimeRpcServer { } private handleWebSocketBinaryMessage(bytes: Uint8Array, ws: WebSocket): void { - const connectionId = this.wsConnectionIds.get(ws) + const connectionId = this.mobileSocketWiring?.getConnectionId(ws) if (!connectionId) { return } @@ -721,85 +866,39 @@ export class OrcaRuntimeRpcServer { // pin it. ...(this.wsPort !== 0 ? { fallbackPort: readWsFallbackPort(this.userDataPath) } : {}) }) - this.wsTransport = wsTransport - - // Why: each WebSocket connection gets an E2EE channel that handles the - // handshake before any RPC messages are processed. The channel decrypts - // inbound messages and encrypts outbound replies transparently. - wsTransport.onMessage((msg, _reply, ws) => { - let channel = this.e2eeChannels.get(ws) - if (!channel) { - // Why: stable per-ws id used as the cleanup-index key for - // streaming subscriptions, so the server can reap them exactly - // when this socket closes (without affecting other live sockets - // that share the same deviceToken). - this.wsConnectionIds.set(ws, randomBytes(8).toString('hex')) - channel = new E2EEChannel(ws, { - serverSecretKey: this.e2eeKeypair!.secretKey, - validateToken: (token) => this.deviceRegistry?.validateToken(token) != null, - onReady: (ch) => { - if (ch.deviceToken) { - wsTransport.setClientId(ws, ch.deviceToken) - // Why: mark the device as actually connected so it appears - // in the "Paired Devices" list. Devices that were only - // generated as QR codes but never scanned stay hidden. - const device = this.deviceRegistry?.validateToken(ch.deviceToken) - if (device) { - this.deviceRegistry?.updateLastSeen(device.deviceId) - } - } - }, - onError: (code, reason) => { - this.e2eeChannels.get(ws)?.destroy() - this.e2eeChannels.delete(ws) - ws.close(code, reason) - } - }) - channel.onMessage((plaintext, encryptedReply, encryptedBinaryReply) => { - const authenticatedDeviceToken = this.e2eeChannels.get(ws)?.deviceToken ?? null - void this.handleWebSocketMessage( - plaintext, - encryptedReply, - encryptedBinaryReply, - wsTransport, - ws, - authenticatedDeviceToken - ) - }) - channel.onBinaryMessage((bytes) => this.handleWebSocketBinaryMessage(bytes, ws)) - this.e2eeChannels.set(ws, channel) - } - channel.handleRawMessage(msg) - }) - - // Why: when a mobile client disconnects, the runtime must clean up - // connection-scoped state like mobile-fit overrides and the E2EE - // channel to prevent orphaned state. A single paired device can hold - // multiple concurrent sockets (host screen + accounts screen, etc.), - // so destroy the channel for THIS exact ws and skip the per-client - // teardown when other sockets for the same token are still alive. - wsTransport.onConnectionClose((clientId, ws, hasOtherConnections) => { - this.abortWebSocketDispatches(ws) - // Why: sweep streaming subscriptions for THIS ws regardless of - // hasOtherConnections, so per-ws listeners (notifications, - // accounts, terminal) don't leak across reconnects. This is - // independent of the deviceToken-scoped onClientDisconnected. - const connectionId = this.wsConnectionIds.get(ws) - if (connectionId) { - this.runtime.cleanupSubscriptionsForConnection(connectionId) - this.runtime.cancelMobileDictationForConnection(connectionId) - this.binaryStreamHandlers.delete(connectionId) - this.wsConnectionIds.delete(ws) - } - const channel = this.e2eeChannels.get(ws) - if (channel) { - channel.destroy() - this.e2eeChannels.delete(ws) - } - if (clientId && !hasOtherConnections) { - this.runtime.onClientDisconnected(clientId) + const mobileSocketWiring = new MobileSocketWiring({ + deviceRegistry: this.deviceRegistry, + e2eeKeypair: this.e2eeKeypair, + onText: (socket, plaintext, reply, sendBinary) => { + void this.handleWebSocketMessage( + plaintext, + reply, + sendBinary, + undefined, + socket.ws, + socket.device.deviceToken, + socket + ) + }, + onBinary: (socket, bytes) => this.handleWebSocketBinaryMessage(bytes, socket.ws), + onReady: () => this.mobileRelayPairingProvider?.onDemandStateChanged?.(), + onClose: (socket, hasOtherConnections) => { + if (!socket) { + return + } + this.abortWebSocketDispatches(socket.ws) + // Why: subscriptions and binary streams are socket-scoped, while + // client disconnect state is device-scoped across both transports. + this.runtime.cleanupSubscriptionsForConnection(socket.connectionId) + this.runtime.cancelMobileDictationForConnection(socket.connectionId) + this.binaryStreamHandlers.delete(socket.connectionId) + if (!hasOtherConnections) { + this.runtime.onClientDisconnected(socket.device.deviceToken) + } } }) + mobileSocketWiring.attachTransport(wsTransport) + this.mobileSocketWiring = mobileSocketWiring await wsTransport.start() if (this.wsPort !== 0 && wsTransport.resolvedPort !== this.wsPort) { @@ -815,7 +914,7 @@ export class OrcaRuntimeRpcServer { // function if it fails to start (e.g., port in use). Log and continue // with Unix socket only. console.error('[runtime] Failed to start WebSocket transport:', error) - this.wsTransport = null + this.mobileSocketWiring = null } } @@ -842,7 +941,7 @@ export class OrcaRuntimeRpcServer { const transports = this.activeTransports this.activeTransports = [] this.transports = [] - this.wsTransport = null + this.mobileSocketWiring = null if (transports.length === 0) { return } @@ -936,7 +1035,8 @@ export class OrcaRuntimeRpcServer { sendBinary: (response: Uint8Array) => boolean | void, wsTransport?: WebSocketTransport, ws?: WebSocket, - authenticatedDeviceToken?: string | null + authenticatedDeviceToken?: string | null, + authenticatedSocket?: AuthenticatedMobileSocket ): Promise { let request: RpcRequest try { @@ -1020,7 +1120,31 @@ export class OrcaRuntimeRpcServer { ? (response: string): void => reply(injectDeviceScope(response, device.scope)) : reply - const connectionId = ws ? this.wsConnectionIds.get(ws) : undefined + const connectionId = ws ? this.mobileSocketWiring?.getConnectionId(ws) : undefined + const pairingProvider = this.mobileRelayPairingProvider + const pairingContext = + pairingProvider && authenticatedSocket + ? { + getEndpoints: (params: PairingGetEndpointsParams) => + pairingProvider.getEndpoints( + { + deviceId: authenticatedSocket.device.deviceId, + connectionId: authenticatedSocket.connectionId, + transport: authenticatedSocket.transport + }, + params + ), + provisionRelay: (params: PairingProvisionRelayParams) => + pairingProvider.provisionRelay( + { + deviceId: authenticatedSocket.device.deviceId, + connectionId: authenticatedSocket.connectionId, + transport: authenticatedSocket.transport + }, + params + ) + } + : undefined try { await this.dispatcher.dispatchStreaming(request, replyForRequest, { connectionId, @@ -1028,6 +1152,7 @@ export class OrcaRuntimeRpcServer { // Why: gates the mobile-only payload diet (native-chat char clipping) so // full-screen web/desktop runtime clients aren't truncated. clientKind: device.scope, + pairing: pairingContext, signal: abortRegistration?.signal, sendBinary, registerBinaryStreamHandler: (streamId, handler) => diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 80e284bf31f..9151a2268dd 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -17,6 +17,8 @@ import type { } from '../shared/local-log-tail-types' import type { ReadClipboardTextOptions } from '../shared/clipboard-text' import type { AppIdentity } from '../shared/app-identity' +import type { MobileRelayStatus } from '../shared/mobile-relay-status' +import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' import type { CreateLocalOrcaProfileArgs, CreateLocalOrcaProfileResult, @@ -3142,7 +3144,11 @@ export type PreloadApi = { listNetworkInterfaces: () => Promise<{ interfaces: { name: string; address: string }[] }> - getPairingQR: (args?: { address?: string; rotate?: boolean }) => Promise< + getPairingQR: (args?: { + address?: string + connectionMode?: MobilePairingConnectionMode + rotate?: boolean + }) => Promise< | { available: false } | { available: true @@ -3184,6 +3190,8 @@ export type PreloadApi = { listRuntimeAccessGrants: () => Promise<{ grants: RuntimeAccessGrant[] }> revokeRuntimeAccess: (args: { deviceId: string }) => Promise<{ revoked: boolean }> isWebSocketReady: () => Promise<{ ready: boolean; endpoint: string | null }> + getRelayStatus: () => Promise<{ status: MobileRelayStatus }> + onRelayStatusChanged: (callback: (status: MobileRelayStatus) => void) => () => void } speech: { getCatalog: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index bfea3e27a5d..1f0436caf5a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -12,6 +12,8 @@ import type { TerminalPaneSplitSource } from '../shared/feature-education-teleme import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' import type { StartupCommandDelivery } from '../shared/codex-startup-delivery' import type { SleepingAgentLaunchConfig } from '../shared/agent-session-resume' +import type { MobileRelayStatus } from '../shared/mobile-relay-status' +import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' import type { BaseRefSearchResult, BaseRefDefaultResult, @@ -4251,6 +4253,7 @@ const api = { getPairingQR: (args?: { address?: string + connectionMode?: MobilePairingConnectionMode rotate?: boolean }): Promise< | { available: false } @@ -4297,7 +4300,17 @@ const api = { ipcRenderer.invoke('mobile:revokeRuntimeAccess', args), isWebSocketReady: (): Promise<{ ready: boolean; endpoint: string | null }> => - ipcRenderer.invoke('mobile:isWebSocketReady') + ipcRenderer.invoke('mobile:isWebSocketReady'), + + getRelayStatus: (): Promise<{ status: MobileRelayStatus }> => + ipcRenderer.invoke('mobile:getRelayStatus'), + + onRelayStatusChanged: (callback: (status: MobileRelayStatus) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, status: MobileRelayStatus) => + callback(status) + ipcRenderer.on('mobile:relayStatusChanged', listener) + return () => ipcRenderer.removeListener('mobile:relayStatusChanged', listener) + } }, agentStatus: { diff --git a/src/renderer/src/assets/mobile-page.css b/src/renderer/src/assets/mobile-page.css index 4f7c2a92ba9..9a5ad36175c 100644 --- a/src/renderer/src/assets/mobile-page.css +++ b/src/renderer/src/assets/mobile-page.css @@ -383,16 +383,19 @@ color: var(--foreground); } -/* Why: stack both step screens in the same grid cell so the viewport - sizes to the tallest child. This keeps the back button at the same - Y on Step 1 and Step 2 even though Step 2 has more rows of content. */ +/* Why: only the active step contributes to layout; the measured height lets + the card grow for Step 2 without making the shorter Step 1 oversized. */ .mobile-page-root .mp-flow-viewport { - display: grid; - grid-template-areas: 'stack'; + position: relative; + overflow: hidden; + transition: height 280ms cubic-bezier(0.32, 0.72, 0.24, 1); } .mobile-page-root .mp-flow-screen { - grid-area: stack; + position: absolute; + top: 0; + left: 0; + width: 100%; transform: translateX(40px); opacity: 0; pointer-events: none; @@ -402,6 +405,7 @@ } .mobile-page-root .mp-flow-screen.is-active { + position: relative; transform: translateX(0); opacity: 1; pointer-events: auto; @@ -562,8 +566,10 @@ } .mobile-page-root .mp-qr { + position: relative; display: grid; place-items: center; + overflow: hidden; padding: 8px; border-radius: 12px; background: #fafafa; @@ -576,6 +582,9 @@ } .mobile-page-root .mp-qr-large { + box-sizing: border-box; + width: 184px; + height: 184px; padding: 10px; } @@ -584,6 +593,23 @@ height: 164px; } +.mobile-page-root .mp-qr-refreshing { + filter: blur(5px); + opacity: 0.32; +} + +.mobile-page-root .mp-qr-loading { + position: absolute; + inset: 10px; + display: grid; + place-items: center; + border-radius: 4px; + background: color-mix(in srgb, var(--background) 76%, transparent); + color: var(--foreground); + font-size: 12px; + font-weight: 600; +} + .mobile-page-root .mp-qr-stack { display: flex; flex-direction: column; @@ -621,6 +647,38 @@ align-items: start; } +.mobile-page-root .mp-pairing-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + 'copy qr' + 'relay qr' + 'controls controls'; + column-gap: 32px; + row-gap: 18px; + align-items: start; +} + +.mobile-page-root .mp-pairing-copy { + grid-area: copy; +} + +.mobile-page-root .mp-pairing-qr { + grid-area: qr; + /* Why: the QR belongs to the pairing heading, not the paragraph below it. */ + margin-top: 48px; +} + +.mobile-page-root .mp-pairing-relay { + grid-area: relay; + min-width: 0; +} + +.mobile-page-root .mp-pairing-controls { + grid-area: controls; + min-width: 0; +} + /* Why: align the QR's top with the body copy ("Scan the QR…") instead of the eyebrow row above it. The eyebrow row is ~22px tall + 22px margin-bottom, and the h2 is ~38px line-height with no top margin — @@ -1940,7 +1998,9 @@ /* Reduced motion: pin to home, no slide transitions */ @media (prefers-reduced-motion: reduce) { - .mobile-page-root .mp-screen-slide { + .mobile-page-root .mp-screen-slide, + .mobile-page-root .mp-flow-viewport, + .mobile-page-root .mp-flow-screen { transition: none; } } diff --git a/src/renderer/src/components/mobile/MobileHero.test.tsx b/src/renderer/src/components/mobile/MobileHero.test.tsx new file mode 100644 index 00000000000..4026b03b3b3 --- /dev/null +++ b/src/renderer/src/components/mobile/MobileHero.test.tsx @@ -0,0 +1,127 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('./MobileBrandIcons', () => ({ + AndroidLogo: () => null, + IosBrandIcon: () => null +})) + +vi.mock('./NetworkInterfacePicker', () => ({ + NetworkInterfacePicker: () => null +})) + +vi.mock('../settings/MobilePairingConnectionOptions', () => ({ + MobilePairingConnectionOptions: () => null +})) + +vi.mock('./WindowsFirewallNotice', () => ({ + WindowsFirewallNotice: () => null +})) + +import { HeroFlow, type StepIndex } from './MobileHero' + +class MockResizeObserver { + observe = vi.fn() + disconnect = vi.fn() +} + +describe('HeroFlow height', () => { + const originalScrollHeight = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'scrollHeight' + ) + + beforeEach(() => { + vi.stubGlobal('ResizeObserver', MockResizeObserver) + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get() { + return this.textContent?.includes('Step 1 of 2') ? 300 : 520 + } + }) + }) + + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + if (originalScrollHeight) { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', originalScrollHeight) + } + }) + + function renderFlow(stepIdx: StepIndex) { + return render( + + ) + } + + it('sizes to the active step and updates when the taller pairing step opens', () => { + const { rerender } = renderFlow(0) + const viewport = document.querySelector('.mp-flow-viewport') + expect(viewport).toHaveStyle({ height: '300px' }) + expect(screen.getByText('Step 2 of 2').closest('.mp-flow-screen')).toHaveAttribute('inert') + + rerender( + + ) + + expect(viewport).toHaveStyle({ height: '520px' }) + expect(screen.getByText('Step 1 of 2').closest('.mp-flow-screen')).toHaveAttribute('inert') + }) +}) diff --git a/src/renderer/src/components/mobile/MobileHero.tsx b/src/renderer/src/components/mobile/MobileHero.tsx index 64e0ca7344f..aace116e811 100644 --- a/src/renderer/src/components/mobile/MobileHero.tsx +++ b/src/renderer/src/components/mobile/MobileHero.tsx @@ -1,10 +1,13 @@ +import { useLayoutEffect, useRef, useState } from 'react' import { ArrowLeft, ArrowRight, Copy, RefreshCw } from 'lucide-react' import { cn } from '../../lib/utils' import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection' import { AndroidLogo, IosBrandIcon } from './MobileBrandIcons' import { NetworkInterfacePicker } from './NetworkInterfacePicker' +import { MobilePairingConnectionOptions } from '../settings/MobilePairingConnectionOptions' import { getChannelTagline, type InstallCopy, type IosChannel } from './mobile-platform-copy' import { WindowsFirewallNotice } from './WindowsFirewallNotice' +import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' export { HeroIntro } from './MobileHeroIntro' export { HeroPaired, type PairedDevice } from './MobileHeroPairedDevices' import { translate } from '@/i18n/i18n' @@ -37,6 +40,8 @@ type HeroFlowProps = { pairQrDataUrl: string | null pairingUrl: string | null pairLoading: boolean + connectionMode: MobilePairingConnectionMode + onConnectionModeChange: (mode: MobilePairingConnectionMode) => void onRegeneratePairing: () => void onCopyPairingCode: () => void networkInterfaces: readonly MobileNetworkInterface[] @@ -62,6 +67,8 @@ export function HeroFlow({ pairQrDataUrl, pairingUrl, pairLoading, + connectionMode, + onConnectionModeChange, onRegeneratePairing, onCopyPairingCode, networkInterfaces, @@ -74,11 +81,40 @@ export function HeroFlow({ onDone }: HeroFlowProps): React.JSX.Element { const isLast = stepIdx === 1 + const screenRefs = useRef<(HTMLDivElement | null)[]>([]) + const [viewportHeight, setViewportHeight] = useState() + + useLayoutEffect(() => { + const activeScreen = screenRefs.current[stepIdx] + if (!activeScreen) { + return + } + + const measure = (): void => setViewportHeight(activeScreen.scrollHeight) + measure() + + if (typeof ResizeObserver === 'undefined') { + return + } + const observer = new ResizeObserver(measure) + observer.observe(activeScreen) + return () => observer.disconnect() + }, [stepIdx]) return (
-
-
+
+
{ + screenRefs.current[0] = element + }} + className={cn('mp-flow-screen', stepIdx === 0 ? 'is-active' : 'is-past')} + aria-hidden={stepIdx !== 0} + inert={stepIdx !== 0} + >
@@ -173,9 +209,16 @@ export function HeroFlow({
-
-
-
+
{ + screenRefs.current[1] = element + }} + className={cn('mp-flow-screen', stepIdx === 1 && 'is-active')} + aria-hidden={stepIdx !== 1} + inert={stepIdx !== 1} + > +
+
2
@@ -193,7 +236,50 @@ export function HeroFlow({ {translate('auto.components.mobile.MobileHero.2f077ef4eb', ', and scan the code.')}

- +
+
+ +
+
+
+ {pairQrDataUrl ? ( + {translate('auto.components.mobile.MobileHero.27735e5f4e', + ) : null} + {pairLoading ? ( + + {translate('auto.components.mobile.MobileHero.65b3f2e8bc', 'Generating…')} + + ) : null} +
+ +
+
{translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')} @@ -202,9 +288,8 @@ export function HeroFlow({ networkInterfaces={networkInterfaces} selectedAddress={selectedAddress} onSelectedAddressChange={onSelectedAddressChange} - // Why: keep the picker reachable when interface discovery is - // empty — "Add custom address…" is the only path to enter a - // manual Tailscale hostname / static IP. + // Why: direct-first and local-only pairing both advertise a + // local route; keeping it visible also prevents mode shifts. disabled={false} className="mp-network-select" /> @@ -246,39 +331,6 @@ export function HeroFlow({ className="mt-3" />
-
-
- {pairQrDataUrl ? ( - {translate('auto.components.mobile.MobileHero.27735e5f4e', - ) : pairLoading ? ( - - {translate('auto.components.mobile.MobileHero.65b3f2e8bc', 'Generating…')} - - ) : null} -
- -
diff --git a/src/renderer/src/components/mobile/MobilePage.test.tsx b/src/renderer/src/components/mobile/MobilePage.test.tsx new file mode 100644 index 00000000000..2f56e664cb3 --- /dev/null +++ b/src/renderer/src/components/mobile/MobilePage.test.tsx @@ -0,0 +1,166 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' + +type StoreState = { + closeMobilePage: () => void + orcaProfileAuthStatus: { state: 'connected' | 'local' } + settings: { showMobileButton: boolean } + updateSettings: () => Promise +} + +const mocks = vi.hoisted(() => ({ + storeState: {} as StoreState +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: StoreState) => unknown) => selector(mocks.storeState) +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), message: vi.fn(), success: vi.fn() } +})) + +vi.mock('./use-mobile-install-qr', () => ({ useMobileInstallQr: () => null })) +vi.mock('./use-mobile-page-escape', () => ({ useMobilePageEscape: vi.fn() })) +vi.mock('../settings/mobile-pairing-device-polling', () => ({ + useMobilePairingDevicePolling: vi.fn() +})) + +vi.mock('./MobilePageContent', () => ({ + MobilePageContent: (props: { + connectionMode: MobilePairingConnectionMode + enterFlow: () => void + handleConnectionModeChange: (mode: MobilePairingConnectionMode) => void + handleContinue: () => void + pairQrDataUrl: string | null + pairingUrl: string | null + stage: string | null + stepIdx: number + }) => ( +
+ {props.stage ?? 'loading'} + {props.stepIdx} + {props.connectionMode} + {props.pairQrDataUrl ?? 'none'} + {props.pairingUrl ?? 'none'} + + + + +
+ ) +})) + +import MobilePage from './MobilePage' + +describe('MobilePage pairing connection mode', () => { + const getPairingQR = vi.fn() + + beforeEach(() => { + getPairingQR.mockReset().mockResolvedValue({ + available: true, + qrDataUrl: 'data:image/png;base64,qr', + pairingUrl: 'orca://pair#automatic' + }) + mocks.storeState = { + closeMobilePage: vi.fn(), + orcaProfileAuthStatus: { state: 'connected' }, + settings: { showMobileButton: true }, + updateSettings: vi.fn().mockResolvedValue(undefined) + } + Object.defineProperty(window, 'api', { + configurable: true, + value: { + mobile: { + getPairingQR, + listDevices: vi.fn().mockResolvedValue({ devices: [] }), + listNetworkInterfaces: vi.fn().mockResolvedValue({ interfaces: [] }) + }, + shell: { openUrl: vi.fn() }, + ui: { writeClipboardText: vi.fn().mockResolvedValue(undefined) } + } + }) + }) + + afterEach(cleanup) + + async function openPairingStep(): Promise { + const user = userEvent.setup() + render() + await waitFor(() => expect(screen.getByTestId('stage')).toHaveTextContent('intro')) + await user.click(screen.getByRole('button', { name: 'Enter flow' })) + await user.click(screen.getByRole('button', { name: 'Continue' })) + } + + it('defaults signed-in pairing to local-only and rotates when Relay is selected', async () => { + const user = userEvent.setup() + await openPairingStep() + + await waitFor(() => expect(getPairingQR).toHaveBeenCalledWith({ connectionMode: 'local-only' })) + await waitFor(() => expect(screen.getByTestId('pairing-qr')).toHaveTextContent('base64,qr')) + expect(screen.getByTestId('mode')).toHaveTextContent('local-only') + + let resolveRelayQr: ((value: Record) => void) | undefined + getPairingQR.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRelayQr = resolve + }) + ) + await user.click(screen.getByRole('button', { name: 'Anywhere' })) + await waitFor(() => + expect(getPairingQR).toHaveBeenLastCalledWith({ + connectionMode: 'automatic', + rotate: true + }) + ) + expect(screen.getByTestId('mode')).toHaveTextContent('automatic') + expect(screen.getByTestId('pairing-qr')).toHaveTextContent('base64,qr') + expect(screen.getByTestId('pairing-url')).toHaveTextContent('none') + + resolveRelayQr?.({ + available: true, + qrDataUrl: 'data:image/png;base64,relay-qr', + pairingUrl: 'orca://pair#relay' + }) + await waitFor(() => expect(screen.getByTestId('pairing-qr')).toHaveTextContent('relay-qr')) + }) + + it('defaults signed-out pairing to local-only', async () => { + mocks.storeState.orcaProfileAuthStatus = { state: 'local' } + await openPairingStep() + + await waitFor(() => expect(getPairingQR).toHaveBeenCalledWith({ connectionMode: 'local-only' })) + expect(screen.getByTestId('mode')).toHaveTextContent('local-only') + }) + + it('removes the old QR if policy rotation fails', async () => { + const user = userEvent.setup() + await openPairingStep() + await waitFor(() => expect(screen.getByTestId('pairing-qr')).toHaveTextContent('base64,qr')) + + getPairingQR.mockRejectedValueOnce(new Error('rotation failed')) + await user.click(screen.getByRole('button', { name: 'Anywhere' })) + + await waitFor(() => expect(screen.getByTestId('pairing-qr')).toHaveTextContent('none')) + expect(screen.getByTestId('pairing-url')).toHaveTextContent('none') + }) +}) diff --git a/src/renderer/src/components/mobile/MobilePage.tsx b/src/renderer/src/components/mobile/MobilePage.tsx index c5e52200bb6..64bd6366317 100644 --- a/src/renderer/src/components/mobile/MobilePage.tsx +++ b/src/renderer/src/components/mobile/MobilePage.tsx @@ -3,7 +3,7 @@ import { toast } from 'sonner' import { useMountedRef } from '@/hooks/useMountedRef' import { useAppStore } from '@/store' import type { PairedDevice, Platform, StepIndex } from './MobileHero' -import { getInstallCopy, type IosChannel } from './mobile-platform-copy' +import type { IosChannel } from './mobile-platform-copy' import { selectRefreshedNetworkAddress, type MobileNetworkInterface @@ -17,6 +17,8 @@ import { translate } from '@/i18n/i18n' import { useMobilePageEscape } from './use-mobile-page-escape' import { MobilePageContent } from './MobilePageContent' import { useMobileInstallQr } from './use-mobile-install-qr' +import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' +import { useMobileInstallActions } from './use-mobile-install-actions' export default function MobilePage(): React.JSX.Element { const [stage, setStage] = useState(null) @@ -30,6 +32,10 @@ export default function MobilePage(): React.JSX.Element { const [pairQrDataUrl, setPairQrDataUrl] = useState(null) const [pairingUrl, setPairingUrl] = useState(null) const [pairLoading, setPairLoading] = useState(false) + const signedIn = useAppStore((state) => state.orcaProfileAuthStatus?.state === 'connected') + // Why: Relay is opt-in while compatible mobile builds are limited to the + // TestFlight preview and Android APK. + const [connectionMode, setConnectionMode] = useState('local-only') const [networkInterfaces, setNetworkInterfaces] = useState([]) const [selectedAddress, setSelectedAddress] = useState(undefined) // Why: tracks whether `selectedAddress` came from the user typing a @@ -41,6 +47,8 @@ export default function MobilePage(): React.JSX.Element { const [revokingDeviceIds, setRevokingDeviceIds] = useState([]) const [deviceCountAtPairStart, setDeviceCountAtPairStart] = useState(null) const hasGeneratedRef = useRef(false) + const pairingRequestIdRef = useRef(0) + const wasSignedInRef = useRef(signedIn) const mountedRef = useMountedRef() const stageRef = useRef(null) const deviceCountAtPairStartRef = useRef(null) @@ -48,6 +56,7 @@ export default function MobilePage(): React.JSX.Element { const showMobileButton = useAppStore((s) => s.settings?.showMobileButton !== false) const updateSettings = useAppStore((s) => s.updateSettings) const installQrUrl = useMobileInstallQr(stage, platform, iosChannel) + const { copyInstallUrl, openInstallUrl } = useMobileInstallActions(platform, iosChannel) const setPairingDeviceBaseline = useCallback( (count: number | null): void => { @@ -163,24 +172,39 @@ export default function MobilePage(): React.JSX.Element { ) const generatePairing = useCallback( - async (rotate: boolean, addressOverride?: string) => { + async ( + rotate: boolean, + addressOverride?: string, + connectionModeOverride?: MobilePairingConnectionMode + ) => { + const requestId = ++pairingRequestIdRef.current + // Mark the request synchronously so state changes cannot make the + // Step 2 auto-generate effect start a second offer in parallel. + hasGeneratedRef.current = true if (mountedRef.current) { setPairLoading(true) } try { const address = addressOverride ?? selectedAddress + const nextConnectionMode = connectionModeOverride ?? connectionMode const result = await window.api.mobile.getPairingQR({ ...(address ? { address } : {}), + connectionMode: nextConnectionMode, ...(rotate ? { rotate: true } : {}) }) + if (requestId !== pairingRequestIdRef.current) { + return + } if (result.available) { if (mountedRef.current) { setPairQrDataUrl(result.qrDataUrl) setPairingUrl(result.pairingUrl) } - hasGeneratedRef.current = true } else { + hasGeneratedRef.current = false if (mountedRef.current) { + setPairQrDataUrl(null) + setPairingUrl(null) toast.error( translate( 'auto.components.mobile.MobilePage.b353e18de1', @@ -190,7 +214,10 @@ export default function MobilePage(): React.JSX.Element { } } } catch { - if (mountedRef.current) { + if (mountedRef.current && requestId === pairingRequestIdRef.current) { + hasGeneratedRef.current = false + setPairQrDataUrl(null) + setPairingUrl(null) toast.error( translate( 'auto.components.mobile.MobilePage.4c8bd11c1a', @@ -199,14 +226,41 @@ export default function MobilePage(): React.JSX.Element { ) } } finally { - if (mountedRef.current) { + if (mountedRef.current && requestId === pairingRequestIdRef.current) { setPairLoading(false) } } }, - [mountedRef, selectedAddress] + [connectionMode, mountedRef, selectedAddress] ) + const handleConnectionModeChange = useCallback( + (nextMode: MobilePairingConnectionMode): void => { + if (nextMode === connectionMode) { + return + } + setConnectionMode(nextMode) + // Why: an offer encodes its connection policy. Invalidate the prior + // request before rotating so a late response cannot restore a stale QR. + pairingRequestIdRef.current += 1 + const shouldRegenerate = hasGeneratedRef.current || pairLoading + hasGeneratedRef.current = false + setPairingUrl(null) + if (shouldRegenerate) { + void generatePairing(true, undefined, nextMode) + } + }, + [connectionMode, generatePairing, pairLoading] + ) + + useEffect(() => { + const wasSignedIn = wasSignedInRef.current + wasSignedInRef.current = signedIn + if (wasSignedIn && !signedIn) { + handleConnectionModeChange('local-only') + } + }, [handleConnectionModeChange, signedIn]) + const loadNetworkInterfaces = useCallback(async () => { if (mountedRef.current) { setRefreshingNetworkInterfaces(true) @@ -354,28 +408,6 @@ export default function MobilePage(): React.JSX.Element { } } - const openInstallUrl = (): void => { - void window.api.shell.openUrl(getInstallCopy(platform, iosChannel).url) - } - - const copyInstallUrl = async (): Promise => { - try { - await window.api.ui.writeClipboardText(getInstallCopy(platform, iosChannel).url) - if (mountedRef.current) { - toast.success( - translate('auto.components.mobile.MobilePage.fad833de8d', 'Install link copied') - ) - } - } catch (err) { - console.error('writeClipboardText failed', err) - if (mountedRef.current) { - toast.error( - translate('auto.components.mobile.MobilePage.baea63c445', 'Failed to copy link') - ) - } - } - } - const toggleMobileSidebarButton = useCallback(() => { const nextShowMobileButton = !showMobileButton void updateSettings({ showMobileButton: nextShowMobileButton }) @@ -410,6 +442,8 @@ export default function MobilePage(): React.JSX.Element { openInstallUrl={openInstallUrl} pairAnotherDevice={pairAnotherDevice} pairLoading={pairLoading} + connectionMode={connectionMode} + handleConnectionModeChange={handleConnectionModeChange} pairQrDataUrl={pairQrDataUrl} pairingUrl={pairingUrl} platform={platform} diff --git a/src/renderer/src/components/mobile/MobilePageContent.tsx b/src/renderer/src/components/mobile/MobilePageContent.tsx index 00ebf7fe86e..570d781f91d 100644 --- a/src/renderer/src/components/mobile/MobilePageContent.tsx +++ b/src/renderer/src/components/mobile/MobilePageContent.tsx @@ -6,6 +6,7 @@ import { getInstallCopy, type IosChannel } from './mobile-platform-copy' import type { MobilePageStage } from './mobile-page-stage' import { MobilePageToolbar } from './MobilePageToolbar' import { PhoneCarousel } from './PhoneCarousel' +import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' type MobilePageContentProps = { closeMobilePage: () => void @@ -25,6 +26,8 @@ type MobilePageContentProps = { openInstallUrl: () => void pairAnotherDevice: () => void pairLoading: boolean + connectionMode: MobilePairingConnectionMode + handleConnectionModeChange: (mode: MobilePairingConnectionMode) => void pairQrDataUrl: string | null pairingUrl: string | null platform: Platform @@ -58,6 +61,8 @@ export function MobilePageContent({ openInstallUrl, pairAnotherDevice, pairLoading, + connectionMode, + handleConnectionModeChange, pairQrDataUrl, pairingUrl, platform, @@ -104,6 +109,8 @@ export function MobilePageContent({ pairQrDataUrl={pairQrDataUrl} pairingUrl={pairingUrl} pairLoading={pairLoading} + connectionMode={connectionMode} + onConnectionModeChange={handleConnectionModeChange} onRegeneratePairing={() => generatePairing(true)} onCopyPairingCode={copyPairingCode} networkInterfaces={networkInterfaces} diff --git a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx index af45e066a3c..18aa57faeaf 100644 --- a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx +++ b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx @@ -4,7 +4,7 @@ import { AddressPicker, type AddressOption } from '../network/AddressPicker' import { parseManualNetworkAddress } from '../../../../shared/network/manual-address' import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection' -// Why: MobileHero (mobile pairing screen) and MobileNetworkInterfaceSection +// Why: MobileHero (mobile pairing screen) and MobilePairingSetupSection // (Settings → Mobile) both need the same network selector. This wraps the // generic AddressPicker with the mobile grammar (IPv4, any RFC 1123 // hostname — including Tailscale *.ts.net and DDNS domains — optionally diff --git a/src/renderer/src/components/mobile/use-mobile-install-actions.ts b/src/renderer/src/components/mobile/use-mobile-install-actions.ts new file mode 100644 index 00000000000..8735d2d3a39 --- /dev/null +++ b/src/renderer/src/components/mobile/use-mobile-install-actions.ts @@ -0,0 +1,37 @@ +import { useCallback } from 'react' +import { toast } from 'sonner' +import { useMountedRef } from '@/hooks/useMountedRef' +import { translate } from '@/i18n/i18n' +import type { Platform } from './MobileHero' +import { getInstallCopy, type IosChannel } from './mobile-platform-copy' + +export function useMobileInstallActions( + platform: Platform, + iosChannel: IosChannel +): { copyInstallUrl: () => Promise; openInstallUrl: () => void } { + const mountedRef = useMountedRef() + + const openInstallUrl = useCallback((): void => { + void window.api.shell.openUrl(getInstallCopy(platform, iosChannel).url) + }, [iosChannel, platform]) + + const copyInstallUrl = useCallback(async (): Promise => { + try { + await window.api.ui.writeClipboardText(getInstallCopy(platform, iosChannel).url) + if (mountedRef.current) { + toast.success( + translate('auto.components.mobile.MobilePage.fad833de8d', 'Install link copied') + ) + } + } catch (error) { + console.error('writeClipboardText failed', error) + if (mountedRef.current) { + toast.error( + translate('auto.components.mobile.MobilePage.baea63c445', 'Failed to copy link') + ) + } + } + }, [iosChannel, mountedRef, platform]) + + return { copyInstallUrl, openInstallUrl } +} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileCloudMenuItems.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileCloudMenuItems.tsx index e7f2270dc5d..a21681a48b8 100644 --- a/src/renderer/src/components/orca-profiles/OrcaProfileCloudMenuItems.tsx +++ b/src/renderer/src/components/orca-profiles/OrcaProfileCloudMenuItems.tsx @@ -14,17 +14,20 @@ import type { OrcaProfileSummary } from '../../../../shared/orca-profiles' -function getConnectLabel(authStatus: OrcaProfileAuthStatus | null): string { +function getConnectLabel(authStatus: OrcaProfileAuthStatus | null, connecting: boolean): string { + if (connecting) { + return translate('auto.components.orca.profiles.switcher.signInWaiting', 'Waiting for sign-in…') + } if (authStatus?.configured !== true) { return translate( 'auto.components.orca.profiles.switcher.cloud.unavailable', - 'Cloud sign-in unavailable' + 'Orca sign-in unavailable' ) } - if (authStatus.state === 'connected' || authStatus.state === 'reconnect-required') { - return translate('auto.components.orca.profiles.switcher.reconnect', 'Reconnect profile') + if (authStatus.state === 'reconnect-required') { + return translate('auto.components.orca.profiles.switcher.signInAgain', 'Sign in again') } - return translate('auto.components.orca.profiles.switcher.connect', 'Connect profile') + return translate('auto.components.orca.profiles.switcher.signIn', 'Sign in to Orca') } export function OrcaProfileCloudMenuItems({ @@ -33,6 +36,7 @@ export function OrcaProfileCloudMenuItems({ connecting, profileActionDisabled, allowProfileCreation, + separateAuthActions, onConnect, onCreateProfileForOrg, onSelectOrg, @@ -43,6 +47,7 @@ export function OrcaProfileCloudMenuItems({ connecting: boolean profileActionDisabled: boolean allowProfileCreation: boolean + separateAuthActions: boolean onConnect: () => void onCreateProfileForOrg: (organization: OrcaCloudOrgSummary) => void onSelectOrg: (orgId: string) => void @@ -57,6 +62,7 @@ export function OrcaProfileCloudMenuItems({ allowProfileCreation && activeProfile.kind === 'cloud-linked' && organizations.length > 0 const orgActionDisabled = profileActionDisabled || authStatus?.state !== 'connected' const activeOrgId = activeProfile.cloud?.activeOrgId + const showSignIn = authStatus?.state !== 'connected' return ( <> @@ -110,11 +116,15 @@ export function OrcaProfileCloudMenuItems({ ) : null} - - - {connecting ? : } - {getConnectLabel(authStatus)} - + {separateAuthActions || showOrganizationChoices || showCloudProfileCreation ? ( + + ) : null} + {showSignIn ? ( + + {connecting ? : } + {getConnectLabel(authStatus, connecting)} + + ) : null} {activeProfile.kind === 'cloud-linked' ? ( diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileMenuHeader.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileMenuHeader.tsx new file mode 100644 index 00000000000..692f11163d2 --- /dev/null +++ b/src/renderer/src/components/orca-profiles/OrcaProfileMenuHeader.tsx @@ -0,0 +1,32 @@ +import { CircleUserRound } from 'lucide-react' +import { DropdownMenuLabel } from '@/components/ui/dropdown-menu' +import type { OrcaProfileSummary } from '../../../../shared/orca-profiles' +import { OrcaProfileAvatar } from './OrcaProfileAvatar' + +export function OrcaProfileMenuHeader({ + profile, + title, + subtitle, + showProfileAvatar +}: { + profile: OrcaProfileSummary + title: string + subtitle: string + showProfileAvatar: boolean +}): React.JSX.Element { + return ( + +
+ {showProfileAvatar ? ( + + ) : ( + + )} +
+
{title}
+
{subtitle}
+
+
+
+ ) +} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileSignOutConfirmDialog.test.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileSignOutConfirmDialog.test.tsx new file mode 100644 index 00000000000..fa3961f455e --- /dev/null +++ b/src/renderer/src/components/orca-profiles/OrcaProfileSignOutConfirmDialog.test.tsx @@ -0,0 +1,39 @@ +// @vitest-environment happy-dom + +import type { ReactNode } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { OrcaProfileSignOutConfirmDialog } from './OrcaProfileSignOutConfirmDialog' + +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ children }: { children: ReactNode }) => <>{children}, + DialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: ReactNode }) =>

{children}

, + DialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: ReactNode }) =>

{children}

+})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ children }: { children: ReactNode }) => +})) + +describe('OrcaProfileSignOutConfirmDialog', () => { + it('describes account sign-out without presenting a local profile or warning', () => { + const html = renderToStaticMarkup( + + ) + + expect(html).toContain('Sign out of Orca?') + expect(html).toContain( + 'You'll be signed out of Orca on this device. Your local projects and worktrees won't be affected.' + ) + expect(html).not.toContain('Personal') + expect(html).not.toContain('alert-triangle') + }) +}) diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileSignOutConfirmDialog.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileSignOutConfirmDialog.tsx index afa2b4707de..3e86dcf81a7 100644 --- a/src/renderer/src/components/orca-profiles/OrcaProfileSignOutConfirmDialog.tsx +++ b/src/renderer/src/components/orca-profiles/OrcaProfileSignOutConfirmDialog.tsx @@ -1,4 +1,4 @@ -import { AlertTriangle, Loader2 } from 'lucide-react' +import { Loader2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { Dialog, @@ -14,28 +14,24 @@ export function OrcaProfileSignOutConfirmDialog({ open, onOpenChange, onConfirm, - profileName, signingOut }: { open: boolean onOpenChange: (open: boolean) => void onConfirm: () => void - profileName: string signingOut: boolean }): React.JSX.Element { return ( - - - {translate('auto.components.orca.profiles.signout.confirm.title', 'Sign out?')} + + {translate('auto.components.orca.profiles.signout.confirm.title', 'Sign out of Orca?')} {translate( 'auto.components.orca.profiles.signout.confirm.description', - 'Sign out of {{profileName}} and keep its projects, worktrees, and local metadata on this device.', - { profileName } + "You'll be signed out of Orca on this device. Your local projects and worktrees won't be affected." )} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.test.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.test.tsx index cc52a10c975..3759d07a49f 100644 --- a/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.test.tsx +++ b/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.test.tsx @@ -85,6 +85,16 @@ const cloudProfile: OrcaProfileSummary = { } } +const localProfile: OrcaProfileSummary = { + id: 'local-default', + name: 'Personal', + avatar: { kind: 'initials', initials: 'P', color: 'neutral' }, + kind: 'local', + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1 +} + const connectedAuthStatus: OrcaProfileAuthStatus = { activeProfileId: 'local-default', configured: true, @@ -104,6 +114,13 @@ const unconfiguredAuthStatus: OrcaProfileAuthStatus = { persistence: 'none' } +const signedOutAuthStatus: OrcaProfileAuthStatus = { + activeProfileId: 'local-default', + configured: true, + state: 'local', + persistence: 'none' +} + function baseState(overrides: Partial): Partial { return { orcaProfiles: [cloudProfile], @@ -134,8 +151,11 @@ describe('OrcaProfileSwitcher', () => { const html = renderToStaticMarkup() expect(html).toContain('aria-label="Account"') + expect(html).toContain('nina@example.com') + expect(html).toContain('Acme') // Cloud actions stay reachable in the downscoped account menu. expect(html).toContain('Sign out') + expect(html).not.toContain('Reconnect profile') // Profile management surfaces are gone. expect(html).not.toContain('Manage profiles') expect(html).not.toContain('New local profile') @@ -147,6 +167,48 @@ describe('OrcaProfileSwitcher', () => { expect(html).toContain('data-testid="signout-confirm-dialog"') }) + it('presents only the sign-in action before an account identity exists', () => { + mocks.state = baseState({ + orcaProfiles: [localProfile], + orcaProfileAuthStatus: signedOutAuthStatus, + orcaProfilesMultiProfileUi: false + }) + const html = renderToStaticMarkup() + + expect(html).toContain('Sign in to Orca') + expect(html).not.toContain('Orca account') + expect(html).not.toContain('Signed out') + expect(html).not.toContain('Personal') + expect(html).not.toContain('>Local<') + }) + + it('gives a reconnect-required account an explicit recovery action', () => { + mocks.state = baseState({ + orcaProfileAuthStatus: { + ...connectedAuthStatus, + state: 'reconnect-required' + }, + orcaProfilesMultiProfileUi: false + }) + const html = renderToStaticMarkup() + + expect(html).toContain('nina@example.com') + expect(html).toContain('Sign-in required') + expect(html).toContain('Sign in again') + }) + + it('names the pending browser authentication step', () => { + mocks.state = baseState({ + orcaProfiles: [localProfile], + orcaProfileAuthStatus: signedOutAuthStatus, + orcaProfileConnecting: true, + orcaProfilesMultiProfileUi: false + }) + const html = renderToStaticMarkup() + + expect(html).toContain('Waiting for sign-in…') + }) + it('renders nothing when the flag is off and cloud is unconfigured', () => { mocks.state = baseState({ orcaProfilesMultiProfileUi: false, diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.tsx index 5be9359802e..f5858d4aa7d 100644 --- a/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.tsx +++ b/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.tsx @@ -1,12 +1,21 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { Check, ChevronDown, Cloud, Laptop, Loader2, Plus, Settings2, Users } from 'lucide-react' +import { + Check, + ChevronDown, + CircleUserRound, + Cloud, + Laptop, + Loader2, + Plus, + Settings2, + Users +} from 'lucide-react' import { useShallow } from 'zustand/react/shallow' import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' @@ -20,8 +29,10 @@ import { OrcaProfileCloudMenuItems } from './OrcaProfileCloudMenuItems' import { OrcaProfileCreateDialog } from './OrcaProfileCreateDialog' import { OrcaProfileOrgMembersDialog } from './OrcaProfileOrgMembersDialog' import { OrcaProfileManagementDialog } from './OrcaProfileManagementDialog' +import { OrcaProfileMenuHeader } from './OrcaProfileMenuHeader' import { OrcaProfileSignOutConfirmDialog } from './OrcaProfileSignOutConfirmDialog' import { OrcaProfileSwitchConfirmDialog } from './OrcaProfileSwitchConfirmDialog' +import { getOrcaAccountIdentity } from './orca-account-identity' import { getOrcaProfileSwitchLiveWorkSummary } from './orca-profile-switch-liveness' function isWebClient(): boolean { @@ -194,6 +205,11 @@ export function OrcaProfileSwitcher({ const triggerLabel = multiProfileUi ? translate('auto.components.orca.profiles.switcher.4815f7d163', 'Switch profile') : translate('auto.components.orca.profiles.switcher.account', 'Account') + const accountIdentity = getOrcaAccountIdentity(activeProfile, authStatus) + const showAccountIdentity = + multiProfileUi || + authStatus?.state === 'connected' || + authStatus?.state === 'reconnect-required' return ( <> @@ -213,6 +229,8 @@ export function OrcaProfileSwitcher({ > {sidebarPlacement && switching ? ( + ) : !multiProfileUi ? ( + ) : ( - {activeProfile.name} + {multiProfileUi + ? activeProfile.name + : showAccountIdentity + ? accountIdentity.title + : triggerLabel} {switching ? : } @@ -244,20 +266,19 @@ export function OrcaProfileSwitcher({ sideOffset={sidebarPlacement ? 8 : 6} className="w-64" > - -
- -
-
- {activeProfile.name} -
-
- {getProfileSubtitle(activeProfile)} -
-
-
-
- + {showAccountIdentity ? ( + <> + + + + ) : null} {multiProfileUi ? profiles.map((profile) => { const active = profile.id === activeProfileId @@ -302,6 +323,7 @@ export function OrcaProfileSwitcher({ connecting={connecting} profileActionDisabled={profileActionDisabled} allowProfileCreation={multiProfileUi} + separateAuthActions={showAccountIdentity || showOrgMembers} onConnect={() => { void connectCurrentProfile() }} @@ -380,7 +402,6 @@ export function OrcaProfileSwitcher({ onConfirm={() => { void handleConfirmSignOut() }} - profileName={activeProfile.name} signingOut={signingOut} /> {multiProfileUi ? ( diff --git a/src/renderer/src/components/orca-profiles/orca-account-identity.ts b/src/renderer/src/components/orca-profiles/orca-account-identity.ts new file mode 100644 index 00000000000..65ab7231058 --- /dev/null +++ b/src/renderer/src/components/orca-profiles/orca-account-identity.ts @@ -0,0 +1,40 @@ +import { translate } from '@/i18n/i18n' +import type { OrcaProfileAuthStatus, OrcaProfileSummary } from '../../../../shared/orca-profiles' + +export function getOrcaAccountIdentity( + profile: OrcaProfileSummary, + authStatus: OrcaProfileAuthStatus | null +): { title: string; subtitle: string } { + // Why: the account-only menu must not present a local execution profile as + // an authenticated Orca identity. + const cloud = authStatus?.cloud ?? profile.cloud + if (authStatus?.state === 'connected') { + return { + title: + cloud?.displayName?.trim() || + cloud?.email || + translate('auto.components.orca.profiles.switcher.accountTitle', 'Orca account'), + subtitle: + cloud?.activeOrgName || + (cloud?.displayName && cloud.email + ? cloud.email + : translate('auto.components.orca.profiles.switcher.accountSignedIn', 'Signed in')) + } + } + if (authStatus?.state === 'reconnect-required') { + return { + title: + cloud?.displayName?.trim() || + cloud?.email || + translate('auto.components.orca.profiles.switcher.accountTitle', 'Orca account'), + subtitle: translate( + 'auto.components.orca.profiles.switcher.accountSignInRequired', + 'Sign-in required' + ) + } + } + return { + title: translate('auto.components.orca.profiles.switcher.accountTitle', 'Orca account'), + subtitle: translate('auto.components.orca.profiles.switcher.accountSignedOut', 'Signed out') + } +} diff --git a/src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx b/src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx deleted file mode 100644 index 47d02ae59ec..00000000000 --- a/src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -// @vitest-environment happy-dom - -import '@testing-library/jest-dom/vitest' - -import React from 'react' -import { afterEach, describe, it, expect, vi } from 'vitest' -import { cleanup, render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { MobileNetworkInterfaceSection } from './MobileNetworkInterfaceSection' -import type { MobileNetworkInterface } from './mobile-network-interface-selection' -import { TooltipProvider } from '../ui/tooltip' - -// Why: Radix Select/Dialog portal their content to and don't always -// unmount synchronously when the test container tears down, so content can -// leak between tests. Forcing cleanup restores the empty DOM before each render. -afterEach(() => { - cleanup() -}) - -const LAN: MobileNetworkInterface = { name: 'en0', address: '192.168.1.24' } -const TAILNET: MobileNetworkInterface = { name: 'tailscale0', address: '100.64.1.20' } - -function renderSection( - overrides: Partial> = {} -) { - const onSelectedAddressChange = vi.fn() - const onRefreshNetworkInterfaces = vi.fn() - const onGenerateQr = vi.fn() - const props: React.ComponentProps = { - networkInterfaces: [LAN, TAILNET], - selectedAddress: TAILNET.address, - onSelectedAddressChange, - refreshingNetworkInterfaces: false, - onRefreshNetworkInterfaces, - loading: false, - hasQrCode: false, - onGenerateQr, - ...overrides - } - const user = userEvent.setup() - const utils = render( - - - - ) - return { ...utils, user, onSelectedAddressChange, onRefreshNetworkInterfaces, onGenerateQr } -} - -describe('MobileNetworkInterfaceSection', () => { - it('renders the trigger with the currently selected address', () => { - renderSection() - expect(screen.getByRole('combobox')).toHaveTextContent('100.64.1.20 (tailscale0)') - }) - - it('renders the (custom) label on the trigger when the selection is a manual address', () => { - renderSection({ selectedAddress: 'my-mac.tail-abcd.ts.net' }) - expect(screen.getByRole('combobox')).toHaveTextContent('my-mac.tail-abcd.ts.net (custom)') - }) - - it('commits an OS interface picked from the list', async () => { - const { user, onSelectedAddressChange } = renderSection() - await user.click(screen.getByRole('combobox')) - await user.click(screen.getByRole('option', { name: '192.168.1.24 (en0)' })) - expect(onSelectedAddressChange).toHaveBeenCalledWith('192.168.1.24') - }) - - it('opens the custom-address dialog from the Add custom address row', async () => { - const { user } = renderSection() - await user.click(screen.getByRole('combobox')) - await user.click(screen.getByRole('option', { name: /add custom address/i })) - expect(screen.getByRole('dialog')).toBeInTheDocument() - expect(screen.getByLabelText('Address')).toBeInTheDocument() - }) - - it('confirms a valid custom address typed into the dialog', async () => { - const { user, onSelectedAddressChange } = renderSection() - await user.click(screen.getByRole('combobox')) - await user.click(screen.getByRole('option', { name: /add custom address/i })) - await user.type(screen.getByLabelText('Address'), 'my-mac.tail-abcd.ts.net') - await user.click(screen.getByRole('button', { name: /use address/i })) - expect(onSelectedAddressChange).toHaveBeenCalledWith('my-mac.tail-abcd.ts.net') - }) - - it('disables the confirm button while the typed address is invalid', async () => { - const { user, onSelectedAddressChange } = renderSection() - await user.click(screen.getByRole('combobox')) - await user.click(screen.getByRole('option', { name: /add custom address/i })) - await user.type(screen.getByLabelText('Address'), 'not an address') - expect(screen.getByRole('button', { name: /use address/i })).toBeDisabled() - await user.click(screen.getByRole('button', { name: /use address/i })) - expect(onSelectedAddressChange).not.toHaveBeenCalled() - }) - - it('shows No interfaces found when the list is empty', () => { - renderSection({ networkInterfaces: [], selectedAddress: undefined }) - expect(screen.getByRole('combobox')).toHaveTextContent(/no interfaces found/i) - }) -}) diff --git a/src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx b/src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx deleted file mode 100644 index 4f17ffb248c..00000000000 --- a/src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import React from 'react' -import { ExternalLink, Loader2, QrCode, RefreshCw, Wifi } from 'lucide-react' -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion' -import { Button } from '../ui/button' -import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' -import { translate } from '@/i18n/i18n' -import { NetworkInterfacePicker } from '../mobile/NetworkInterfacePicker' -import type { MobileNetworkInterface } from './mobile-network-interface-selection' - -const TAILSCALE_DOWNLOAD_URL = 'https://tailscale.com/download' - -type MobileNetworkInterfaceSectionProps = { - networkInterfaces: MobileNetworkInterface[] - selectedAddress: string | undefined - onSelectedAddressChange: (address: string) => void - refreshingNetworkInterfaces: boolean - onRefreshNetworkInterfaces: () => void - loading: boolean - hasQrCode: boolean - onGenerateQr: () => void -} - -export function MobileNetworkInterfaceSection({ - networkInterfaces, - selectedAddress, - onSelectedAddressChange, - refreshingNetworkInterfaces, - onRefreshNetworkInterfaces, - loading, - hasQrCode, - onGenerateQr -}: MobileNetworkInterfaceSectionProps): React.JSX.Element { - return ( -
-
- - - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.406a35121c', - 'Network Interface' - )} - -
-

- {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.d536b5e20d', - 'Choose which network address to advertise in the QR code. Use your LAN address for same-network pairing, or an overlay network address (Tailscale, ZeroTier) for cross-network access.' - )} -

-
-
- - - - - - - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.a9db5d771d', - 'Refresh network interfaces' - )} - - -
- -
- - - - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.39fad211d9', - 'Connect outside your Wi-Fi with a tailnet' - )} - - -

- {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.9fc5d203ff', - 'Orca Mobile connects directly to this computer. To use it away from the same local network, put your computer and phone on the same private overlay network, then generate the QR code with that network address selected.' - )} -

-
    -
  1. - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.51d29927eb', - 'Install' - )}{' '} - {' '} - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.668016be7a', - 'on your computer and phone.' - )} -
  2. -
  3. - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.1f7c26d36a', - 'Sign in to the same tailnet on both devices.' - )} -
  4. -
  5. - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.87985ba6f5', - 'In this Network Interface menu, choose the Tailscale address, usually a 100.x.y.z IP.' - )} -
  6. -
  7. - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.63d5e4ae1e', - 'Regenerate the QR code and scan it from the Orca mobile app.' - )} -
  8. -
-
-
-
-
- ) -} diff --git a/src/renderer/src/components/settings/MobilePairingConnectionOptions.test.tsx b/src/renderer/src/components/settings/MobilePairingConnectionOptions.test.tsx new file mode 100644 index 00000000000..41983525be1 --- /dev/null +++ b/src/renderer/src/components/settings/MobilePairingConnectionOptions.test.tsx @@ -0,0 +1,138 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { MobileRelayStatus } from '../../../../shared/mobile-relay-status' +import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles' +import { MobilePairingConnectionOptions } from './MobilePairingConnectionOptions' + +type MobileRelayStoreState = { + orcaProfileAuthStatus: OrcaProfileAuthStatus | null + orcaProfileConnecting: boolean + connectCurrentOrcaProfile: () => Promise +} + +const mocks = vi.hoisted(() => ({ + state: {} as MobileRelayStoreState +})) + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: MobileRelayStoreState) => unknown) => selector(mocks.state) +})) + +vi.mock('../../i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +describe('MobilePairingConnectionOptions', () => { + let statusListener: ((status: MobileRelayStatus) => void) | null + const connect = vi.fn().mockResolvedValue(null) + + beforeEach(() => { + statusListener = null + connect.mockClear() + Object.defineProperty(window, 'api', { + configurable: true, + value: { + mobile: { + getRelayStatus: vi.fn().mockResolvedValue({ status: 'registered' }), + onRelayStatusChanged: vi.fn((listener: (status: MobileRelayStatus) => void) => { + statusListener = listener + return vi.fn() + }) + }, + shell: { openUrl: vi.fn().mockResolvedValue(undefined) } + } + }) + mocks.state = { + orcaProfileAuthStatus: { + activeProfileId: 'profile-1', + configured: true, + state: 'local', + persistence: 'none' + }, + orcaProfileConnecting: false, + connectCurrentOrcaProfile: connect + } + }) + + afterEach(() => cleanup()) + + it('offers local-only pairing while Relay requires sign-in', async () => { + const user = userEvent.setup() + render() + + expect(screen.getByRole('switch', { name: /connect with Orca Relay/i })).toBeDisabled() + expect(screen.getByRole('switch', { name: /connect with Orca Relay/i })).not.toBeChecked() + await user.click(screen.getByRole('button', { name: 'Sign in' })) + expect(connect).toHaveBeenCalledOnce() + }) + + it('selects either automatic fallback or local-only pairing when signed in', async () => { + mocks.state = { + orcaProfileAuthStatus: { + activeProfileId: 'profile-1', + configured: true, + state: 'connected', + persistence: 'encrypted' + }, + orcaProfileConnecting: false, + connectCurrentOrcaProfile: connect + } + const onChange = vi.fn() + const user = userEvent.setup() + render() + + await waitFor(() => expect(screen.getByText('Ready')).toBeVisible()) + expect(screen.getByRole('switch', { name: /connect with Orca Relay/i })).toBeChecked() + await user.click(screen.getByRole('switch', { name: /connect with Orca Relay/i })) + expect(onChange).toHaveBeenCalledWith('local-only') + statusListener?.('standby') + await waitFor(() => expect(screen.getByText('Available')).toBeVisible()) + }) + + it('shows the Relay beta availability inline and opens both compatible mobile builds', async () => { + const user = userEvent.setup() + render() + + expect(screen.getByText('Beta')).toBeVisible() + expect(screen.getByText('Available on')).toBeVisible() + + await user.click(screen.getByRole('button', { name: 'TestFlight' })) + expect(window.api.shell.openUrl).toHaveBeenCalledWith( + 'https://testflight.apple.com/join/YjeGMQBA' + ) + + await user.click(screen.getByRole('button', { name: 'Android APK' })) + expect(window.api.shell.openUrl).toHaveBeenCalledWith( + 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk' + ) + }) + + it('keeps the compact onboarding choices structurally stable across modes', async () => { + mocks.state = { + orcaProfileAuthStatus: { + activeProfileId: 'profile-1', + configured: true, + state: 'connected', + persistence: 'encrypted' + }, + orcaProfileConnecting: false, + connectCurrentOrcaProfile: connect + } + const props = { compact: true, onChange: vi.fn() } + const { rerender } = render() + + expect(screen.getByRole('switch', { name: /connect with Orca Relay/i })).toBeChecked() + expect(screen.getByText(/direct connection when available/i)).toBeVisible() + expect(screen.queryByText('Ready')).toBeNull() + + rerender() + expect(screen.getByRole('switch', { name: /connect with Orca Relay/i })).not.toBeChecked() + expect(screen.getByText(/direct connection when available/i)).toBeVisible() + expect(screen.queryByText(/without connecting this phone through Orca Relay/i)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/settings/MobilePairingConnectionOptions.tsx b/src/renderer/src/components/settings/MobilePairingConnectionOptions.tsx new file mode 100644 index 00000000000..3411cc9d38c --- /dev/null +++ b/src/renderer/src/components/settings/MobilePairingConnectionOptions.tsx @@ -0,0 +1,248 @@ +import { useEffect, useState } from 'react' +import { Cloud, Loader2 } from 'lucide-react' +import { Badge } from '../ui/badge' +import { Button } from '../ui/button' +import { translate } from '../../i18n/i18n' +import { useAppStore } from '../../store' +import type { MobileRelayStatus } from '../../../../shared/mobile-relay-status' +import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' +import { MobileRelayBetaAvailability } from './MobileRelayBetaAvailability' +import { SettingsRow, SettingsSwitch } from './SettingsFormControls' + +function relayStatusLabel(status: MobileRelayStatus): string { + if (status === 'registered') { + return translate('auto.components.settings.MobilePairingConnectionOptions.ready', 'Ready') + } + if (status === 'connecting') { + return translate( + 'auto.components.settings.MobilePairingConnectionOptions.connecting', + 'Connecting' + ) + } + if (status === 'standby') { + return translate( + 'auto.components.settings.MobilePairingConnectionOptions.available', + 'Available' + ) + } + if (status === 'draining') { + return translate( + 'auto.components.settings.MobilePairingConnectionOptions.reconnecting', + 'Reconnecting' + ) + } + return translate( + 'auto.components.settings.MobilePairingConnectionOptions.unavailable', + 'Unavailable' + ) +} + +type CompactConnectionOptionsProps = { + value: MobilePairingConnectionMode + onChange: (value: MobilePairingConnectionMode) => void + signedIn: boolean + configured: boolean + connecting: boolean + connect: () => Promise + relayStatus: MobileRelayStatus +} + +function ConnectionModeSwitch({ + value, + onChange, + signedIn, + relayStatus, + showStatus = true +}: Pick & { + showStatus?: boolean +}): React.JSX.Element { + return ( + + + + {translate( + 'auto.components.settings.MobilePairingConnectionOptions.anywhere', + 'Connect with Orca Relay' + )} + + + } + description={ + + + {signedIn + ? translate( + 'auto.components.settings.MobilePairingConnectionOptions.automaticDescription', + 'Orca uses a direct connection when available and Relay otherwise.' + ) + : translate( + 'auto.components.settings.MobilePairingConnectionOptions.signInDescription', + 'Sign in on this desktop to use Orca Relay.' + )} + + + + } + alignTop + control={ +
+ {showStatus && signedIn && value === 'automatic' ? ( + + {relayStatusLabel(relayStatus)} + + ) : null} + onChange(value === 'automatic' ? 'local-only' : 'automatic')} + /> +
+ } + /> + ) +} + +function CompactConnectionOptions({ + value, + onChange, + signedIn, + configured, + connecting, + connect, + relayStatus +}: CompactConnectionOptionsProps): React.JSX.Element { + return ( +
+ + {!signedIn ? ( +
+ {configured ? ( + + ) : ( + + {translate( + 'auto.components.settings.MobilePairingConnectionOptions.unavailable', + 'Unavailable' + )} + + )} +
+ ) : null} +
+ ) +} + +export function MobilePairingConnectionOptions({ + value, + onChange, + compact = false +}: { + value: MobilePairingConnectionMode + onChange: (value: MobilePairingConnectionMode) => void + compact?: boolean +}): React.JSX.Element { + const authStatus = useAppStore((state) => state.orcaProfileAuthStatus) + const connecting = useAppStore((state) => state.orcaProfileConnecting) + const connect = useAppStore((state) => state.connectCurrentOrcaProfile) + const [relayStatus, setRelayStatus] = useState('offline') + const signedIn = authStatus?.state === 'connected' + const configured = authStatus?.configured !== false + + useEffect(() => { + let receivedEvent = false + let active = true + const unsubscribe = window.api.mobile.onRelayStatusChanged((status) => { + receivedEvent = true + if (active) { + setRelayStatus(status) + } + }) + void window.api.mobile + .getRelayStatus() + .then(({ status }) => { + if (active && !receivedEvent) { + setRelayStatus(status) + } + }) + .catch(() => {}) + return () => { + active = false + unsubscribe() + } + }, []) + + if (compact) { + return ( + + ) + } + + return ( +
+ + {!signedIn ? ( +
+ {configured ? ( + + ) : ( + + {translate( + 'auto.components.settings.MobilePairingConnectionOptions.unavailable', + 'Unavailable' + )} + + )} +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx b/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx new file mode 100644 index 00000000000..cce8bdce341 --- /dev/null +++ b/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx @@ -0,0 +1,73 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import React from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MobilePairingSetupSection } from './MobilePairingSetupSection' +import type { MobileNetworkInterface } from './mobile-network-interface-selection' +import { TooltipProvider } from '../ui/tooltip' + +afterEach(() => cleanup()) + +const LAN: MobileNetworkInterface = { name: 'en0', address: '192.168.1.24' } +const TAILNET: MobileNetworkInterface = { name: 'tailscale0', address: '100.64.1.20' } + +function renderSection( + overrides: Partial> = {} +) { + const onSelectedAddressChange = vi.fn() + const onRefreshNetworkInterfaces = vi.fn() + const onGenerateQr = vi.fn() + const props: React.ComponentProps = { + connectionMode: 'local-only', + relayConnectionControl: null, + networkInterfaces: [LAN, TAILNET], + selectedAddress: TAILNET.address, + onSelectedAddressChange, + refreshingNetworkInterfaces: false, + onRefreshNetworkInterfaces, + loading: false, + hasQrCode: false, + onGenerateQr, + ...overrides + } + const user = userEvent.setup() + const rendered = render( + + + + ) + return { ...rendered, user, onSelectedAddressChange, onGenerateQr } +} + +describe('MobilePairingSetupSection', () => { + it('keeps local settings visible for local-only pairing', () => { + renderSection() + expect(screen.getByRole('combobox')).toHaveTextContent('100.64.1.20 (tailscale0)') + expect(screen.getByText(/connects only through the local network address/i)).toBeVisible() + }) + + it('keeps local settings visible for automatic direct-first pairing', () => { + renderSection({ connectionMode: 'automatic' }) + expect(screen.getByRole('combobox')).toBeVisible() + expect( + screen.getByText(/includes direct access and encrypted Orca Relay fallback/i) + ).toBeVisible() + }) + + it('commits an OS interface picked from the list', async () => { + const { user, onSelectedAddressChange } = renderSection() + await user.click(screen.getByRole('combobox')) + await user.click(screen.getByRole('option', { name: '192.168.1.24 (en0)' })) + expect(onSelectedAddressChange).toHaveBeenCalledWith('192.168.1.24') + }) + + it('generates a pairing code with the selected mode', async () => { + const { user, onGenerateQr } = renderSection() + await user.click(screen.getByRole('button', { name: 'Generate QR Code' })) + expect(onGenerateQr).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/components/settings/MobilePairingSetupSection.tsx b/src/renderer/src/components/settings/MobilePairingSetupSection.tsx new file mode 100644 index 00000000000..caf1f6b6873 --- /dev/null +++ b/src/renderer/src/components/settings/MobilePairingSetupSection.tsx @@ -0,0 +1,156 @@ +import type { ReactNode } from 'react' +import { ExternalLink, Loader2, QrCode, RefreshCw } from 'lucide-react' +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion' +import { Button } from '../ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' +import { translate } from '@/i18n/i18n' +import { NetworkInterfacePicker } from '../mobile/NetworkInterfacePicker' +import type { MobileNetworkInterface } from './mobile-network-interface-selection' +import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' + +const TAILSCALE_DOWNLOAD_URL = 'https://tailscale.com/download' + +type MobilePairingSetupSectionProps = { + connectionMode: MobilePairingConnectionMode + relayConnectionControl: ReactNode + networkInterfaces: MobileNetworkInterface[] + selectedAddress: string | undefined + onSelectedAddressChange: (address: string) => void + refreshingNetworkInterfaces: boolean + onRefreshNetworkInterfaces: () => void + loading: boolean + hasQrCode: boolean + onGenerateQr: () => void +} + +export function MobilePairingSetupSection({ + connectionMode, + relayConnectionControl, + networkInterfaces, + selectedAddress, + onSelectedAddressChange, + refreshingNetworkInterfaces, + onRefreshNetworkInterfaces, + loading, + hasQrCode, + onGenerateQr +}: MobilePairingSetupSectionProps): React.JSX.Element { + return ( +
+

+ {translate('auto.components.settings.MobilePairingSetupSection.title', 'Pair a phone')} +

+

+ {connectionMode === 'automatic' + ? translate( + 'auto.components.settings.MobilePairingSetupSection.automaticDescription', + 'The pairing code includes direct access and encrypted Orca Relay fallback.' + ) + : translate( + 'auto.components.settings.MobilePairingSetupSection.localDescription', + 'The pairing code connects only through the local network address below.' + )} +

+
{relayConnectionControl}
+ + +
+
+

+ {translate( + 'auto.components.settings.MobilePairingSetupSection.localSettings', + 'Local connection settings' + )} +

+

+ {translate( + 'auto.components.settings.MobilePairingSetupSection.localAddressDescription', + 'Choose the LAN or private-network address that Orca Mobile can use to reach this computer directly.' + )} +

+
+
+ + + + + + + {translate( + 'auto.components.settings.MobilePairingSetupSection.refresh', + 'Refresh network interfaces' + )} + + +
+
+ + + + + {translate( + 'auto.components.settings.MobilePairingSetupSection.tailnet', + 'Connect with your own tailnet' + )} + + +

+ {translate( + 'auto.components.settings.MobilePairingSetupSection.tailnetDescription', + 'Install Tailscale on this computer and your phone, sign in to the same tailnet, then select its 100.x.y.z address above.' + )} +

+ +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/MobilePane.tsx b/src/renderer/src/components/settings/MobilePane.tsx index 650d42c29ad..9a0026a2391 100644 --- a/src/renderer/src/components/settings/MobilePane.tsx +++ b/src/renderer/src/components/settings/MobilePane.tsx @@ -7,12 +7,14 @@ import { selectRefreshedNetworkAddress, type MobileNetworkInterface } from './mobile-network-interface-selection' -import { MobileNetworkInterfaceSection } from './MobileNetworkInterfaceSection' import { MobilePairingQrSection } from './MobilePairingQrSection' import { MobilePairedDevicesSection, type PairedDevice } from './MobilePairedDevicesSection' import { MobileAutoRestoreFitSection } from './MobileAutoRestoreFitSection' +import { MobilePairingConnectionOptions } from './MobilePairingConnectionOptions' +import { MobilePairingSetupSection } from './MobilePairingSetupSection' import { WindowsFirewallNotice } from '../mobile/WindowsFirewallNotice' import { translate } from '@/i18n/i18n' +import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' export { getMobilePaneSearchEntries } from './mobile-pane-search' export function MobilePane(): React.JSX.Element { @@ -29,7 +31,13 @@ export function MobilePane(): React.JSX.Element { const [refreshingNetworkInterfaces, setRefreshingNetworkInterfaces] = useState(false) const [codeCopied, setCodeCopied] = useState(false) const [deviceCountAtQr, setDeviceCountAtQr] = useState(null) + const signedIn = useAppStore((state) => state.orcaProfileAuthStatus?.state === 'connected') + // Why: Relay is opt-in while compatible mobile builds are limited to the + // TestFlight preview and Android APK. + const [connectionMode, setConnectionMode] = useState('local-only') + const [rotateNextQr, setRotateNextQr] = useState(false) const devicesRef = useRef([]) + const wasSignedInRef = useRef(signedIn) const codeCopiedResetTimerRef = useRef(null) const mountedRef = useMountedRef() @@ -87,7 +95,8 @@ export function MobilePane(): React.JSX.Element { try { const result = await window.api.mobile.getPairingQR({ ...(selectedAddress ? { address: selectedAddress } : {}), - ...(opts.rotate ? { rotate: true } : {}) + connectionMode, + ...(opts.rotate || rotateNextQr ? { rotate: true } : {}) }) if (result.available) { useAppStore.getState().recordFeatureInteraction('mobile-pairing') @@ -98,6 +107,7 @@ export function MobilePane(): React.JSX.Element { setDeviceCountAtQr(devicesRef.current.length) clearCodeCopiedResetTimer() setCodeCopied(false) + setRotateNextQr(false) void loadDevices() } } else { @@ -125,7 +135,32 @@ export function MobilePane(): React.JSX.Element { } } }, - [clearCodeCopiedResetTimer, loadDevices, mountedRef, selectedAddress] + [ + clearCodeCopiedResetTimer, + connectionMode, + loadDevices, + mountedRef, + rotateNextQr, + selectedAddress + ] + ) + + const changeConnectionMode = useCallback( + (nextMode: MobilePairingConnectionMode) => { + if (nextMode === connectionMode) { + return + } + setConnectionMode(nextMode) + if (qrDataUrl) { + // Why: a displayed code encodes the old connection policy. Hide it and + // rotate its pending credential before showing a code for the new mode. + setQrDataUrl(null) + setPairingUrl(null) + setEndpoint(null) + setRotateNextQr(true) + } + }, + [connectionMode, qrDataUrl] ) useEffect(() => { @@ -133,6 +168,14 @@ export function MobilePane(): React.JSX.Element { void loadNetworkInterfaces() }, [loadDevices, loadNetworkInterfaces]) + useEffect(() => { + const wasSignedIn = wasSignedInRef.current + wasSignedInRef.current = signedIn + if (wasSignedIn && !signedIn) { + changeConnectionMode('local-only') + } + }, [changeConnectionMode, signedIn]) + useMobilePairingDevicePolling({ deviceCountAtQr, currentDeviceCount: devices.length, @@ -161,7 +204,11 @@ export function MobilePane(): React.JSX.Element { return (
- + } networkInterfaces={networkInterfaces} selectedAddress={selectedAddress} onSelectedAddressChange={setSelectedAddress} diff --git a/src/renderer/src/components/settings/MobileRelayBetaAvailability.tsx b/src/renderer/src/components/settings/MobileRelayBetaAvailability.tsx new file mode 100644 index 00000000000..b7f4f946f0e --- /dev/null +++ b/src/renderer/src/components/settings/MobileRelayBetaAvailability.tsx @@ -0,0 +1,40 @@ +import { translate } from '@/i18n/i18n' + +const TESTFLIGHT_URL = 'https://testflight.apple.com/join/YjeGMQBA' +const ANDROID_APK_URL = + 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk' + +export function MobileRelayBetaAvailability(): React.JSX.Element { + return ( + + + {translate('auto.components.settings.MobileRelayBetaAvailability.beta', 'Beta')} + + + + {translate( + 'auto.components.settings.MobileRelayBetaAvailability.availability', + 'Available on' + )} + + + + + + ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4fc41a01115..2e2ec2c6066 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9036,6 +9036,56 @@ "TerminalInteractionSection": { "567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.", "c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu." + }, + "MobileRelayStatusSection": { + "registered": "Registered", + "connecting": "Connecting", + "reconnecting": "Reconnecting", + "offline": "Offline", + "title": "Orca Relay", + "automatic": "Connect from anywhere when a phone is paired", + "signInPrompt": "Sign in on this desktop to connect from anywhere", + "directStillAvailable": "LAN and Tailscale connections remain available.", + "directNeedsNoAccount": "LAN and Tailscale pairing still work without an account.", + "signIn": "Sign in", + "unavailable": "Unavailable", + "standby": "Standby — no relay devices" + }, + "MobilePairingConnectionOptions": { + "ready": "Ready", + "connecting": "Connecting", + "available": "Available", + "reconnecting": "Reconnecting", + "unavailable": "Unavailable", + "title": "How should the new phone connect?", + "anywhere": "Connect with Orca Relay", + "recommended": "Recommended", + "automaticDescription": "Orca uses a direct connection when available and Relay otherwise.", + "signInDescription": "Sign in on this desktop to use Orca Relay.", + "signIn": "Sign in", + "localOnly": "Local network only", + "localShort": "LAN or Tailscale", + "localDescription": "Uses LAN or Tailscale without connecting this phone through Orca Relay." + }, + "MobilePairingSetupSection": { + "title": "Pair a phone", + "automaticDescription": "The pairing code includes direct access and encrypted Orca Relay fallback.", + "localDescription": "The pairing code connects only through the local network address below.", + "regenerate": "Regenerate", + "generate": "Generate QR Code", + "localSettings": "Local connection settings", + "localAddressDescription": "Choose the LAN or private-network address that Orca Mobile can use to reach this computer directly.", + "refresh": "Refresh network interfaces", + "tailnet": "Connect with your own tailnet", + "tailnetDescription": "Install Tailscale on this computer and your phone, sign in to the same tailnet, then select its 100.x.y.z address above.", + "getTailscale": "Get Tailscale" + }, + "MobileRelayBetaAvailability": { + "about": "About the Orca Relay beta", + "beta": "Beta", + "availability": "Available on", + "testFlight": "TestFlight", + "androidApk": "Android APK" } }, "right": { @@ -12790,7 +12840,7 @@ "profiles": { "switcher": { "cloud": { - "unavailable": "Cloud sign-in unavailable" + "unavailable": "Orca sign-in unavailable" }, "account": "Account", "reconnect": "Reconnect profile", @@ -12814,7 +12864,14 @@ "c106c674fe": "New local profile", "org": { "members": "Organization members" - } + }, + "signInWaiting": "Waiting for sign-in…", + "signInAgain": "Sign in again", + "signIn": "Sign in to Orca", + "accountTitle": "Orca account", + "accountSignedIn": "Signed in", + "accountSignInRequired": "Sign-in required", + "accountSignedOut": "Signed out" }, "management": { "04e7bd2a23": "Transfer", @@ -12857,8 +12914,8 @@ }, "signout": { "confirm": { - "title": "Sign out?", - "description": "Sign out of {{profileName}} and keep its projects, worktrees, and local metadata on this device.", + "title": "Sign out of Orca?", + "description": "You'll be signed out of Orca on this device. Your local projects and worktrees won't be affected.", "cancel": "Cancel", "action": "Sign out" } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 788d29209e5..4790364ca88 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -9036,6 +9036,56 @@ "TerminalInteractionSection": { "567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.", "c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu." + }, + "MobileRelayStatusSection": { + "registered": "Registered", + "connecting": "Connecting", + "reconnecting": "Reconnecting", + "offline": "Offline", + "title": "Orca Relay", + "automatic": "Connect from anywhere when a phone is paired", + "signInPrompt": "Sign in on this desktop to connect from anywhere", + "directStillAvailable": "LAN and Tailscale connections remain available.", + "directNeedsNoAccount": "LAN and Tailscale pairing still work without an account.", + "signIn": "Sign in", + "unavailable": "Unavailable", + "standby": "Standby — no relay devices" + }, + "MobilePairingConnectionOptions": { + "ready": "Listo", + "connecting": "Conectando", + "available": "Disponible", + "reconnecting": "Reconectando", + "unavailable": "No disponible", + "title": "¿Cómo debe conectarse el teléfono nuevo?", + "anywhere": "Conectar con Orca Relay", + "recommended": "Recomendado", + "automaticDescription": "Orca usa una conexión directa cuando está disponible y Relay en caso contrario.", + "signInDescription": "Inicia sesión en este ordenador para usar Orca Relay.", + "signIn": "Iniciar sesión", + "localOnly": "Solo red local", + "localShort": "LAN o Tailscale", + "localDescription": "Usa LAN o Tailscale sin conectar este teléfono mediante Orca Relay." + }, + "MobilePairingSetupSection": { + "title": "Vincular un teléfono", + "automaticDescription": "El código de vinculación incluye acceso directo y Orca Relay cifrado como respaldo.", + "localDescription": "El código de vinculación solo se conecta mediante la dirección de red local indicada abajo.", + "regenerate": "Regenerar", + "generate": "Generar código QR", + "localSettings": "Configuración de conexión local", + "localAddressDescription": "Elige la dirección LAN o de red privada que Orca Mobile puede usar para acceder directamente a este ordenador.", + "refresh": "Actualizar interfaces de red", + "tailnet": "Conectarse con tu propia tailnet", + "tailnetDescription": "Instala Tailscale en este ordenador y en tu teléfono, inicia sesión en la misma tailnet y selecciona arriba su dirección 100.x.y.z.", + "getTailscale": "Obtener Tailscale" + }, + "MobileRelayBetaAvailability": { + "about": "Acerca de la beta de Orca Relay", + "beta": "Beta", + "availability": "Disponible en", + "testFlight": "TestFlight", + "androidApk": "APK de Android" } }, "right": { @@ -12790,7 +12840,7 @@ "profiles": { "switcher": { "cloud": { - "unavailable": "Inicio de sesión en la nube no disponible" + "unavailable": "Inicio de sesión en Orca no disponible" }, "account": "Cuenta", "reconnect": "Reconectar perfil", @@ -12814,7 +12864,14 @@ "c106c674fe": "Nuevo perfil local", "org": { "members": "Miembros de la organización" - } + }, + "signInWaiting": "Esperando el inicio de sesión…", + "signInAgain": "Volver a iniciar sesión", + "signIn": "Iniciar sesión en Orca", + "accountTitle": "Cuenta de Orca", + "accountSignedIn": "Sesión iniciada", + "accountSignInRequired": "Inicio de sesión requerido", + "accountSignedOut": "Sesión cerrada" }, "management": { "04e7bd2a23": "Transferir", @@ -12857,8 +12914,8 @@ }, "signout": { "confirm": { - "title": "¿Cerrar sesión?", - "description": "Cerrar sesión de {{profileName}} y mantener sus proyectos, worktrees y metadatos locales en este dispositivo.", + "title": "¿Cerrar sesión en Orca?", + "description": "Se cerrará tu sesión de Orca en este dispositivo. Tus proyectos y worktrees locales no se verán afectados.", "cancel": "Cancelar", "action": "Cerrar sesión" } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b92cf73d46e..c1845e376a7 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -9036,6 +9036,56 @@ "TerminalInteractionSection": { "567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.", "c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu." + }, + "MobileRelayStatusSection": { + "registered": "Registered", + "connecting": "Connecting", + "reconnecting": "Reconnecting", + "offline": "Offline", + "title": "Orca Relay", + "automatic": "Connect from anywhere when a phone is paired", + "signInPrompt": "Sign in on this desktop to connect from anywhere", + "directStillAvailable": "LAN and Tailscale connections remain available.", + "directNeedsNoAccount": "LAN and Tailscale pairing still work without an account.", + "signIn": "Sign in", + "unavailable": "Unavailable", + "standby": "Standby — no relay devices" + }, + "MobilePairingConnectionOptions": { + "ready": "準備完了", + "connecting": "接続中", + "available": "利用可能", + "reconnecting": "再接続中", + "unavailable": "利用不可", + "title": "新しいスマートフォンをどのように接続しますか?", + "anywhere": "Orca Relay で接続", + "recommended": "推奨", + "automaticDescription": "利用可能な場合は直接接続し、それ以外ではOrca Relayを使用します。", + "signInDescription": "Orca Relayを使用するには、このデスクトップでサインインしてください。", + "signIn": "サインイン", + "localOnly": "ローカルネットワークのみ", + "localShort": "LAN または Tailscale", + "localDescription": "このスマートフォンをOrca Relayに接続せず、LANまたはTailscaleを使用します。" + }, + "MobilePairingSetupSection": { + "title": "スマートフォンをペアリング", + "automaticDescription": "ペアリングコードには、直接接続と暗号化されたOrca Relayのフォールバックが含まれます。", + "localDescription": "ペアリングコードは、下のローカルネットワークアドレスだけを使用します。", + "regenerate": "再生成", + "generate": "QRコードを生成", + "localSettings": "ローカル接続設定", + "localAddressDescription": "Orca Mobileからこのコンピューターへ直接接続できるLANまたはプライベートネットワークのアドレスを選択します。", + "refresh": "ネットワークインターフェースを更新", + "tailnet": "自分のtailnetで接続", + "tailnetDescription": "このコンピューターとスマートフォンにTailscaleをインストールし、同じtailnetにサインインして、上で100.x.y.zアドレスを選択します。", + "getTailscale": "Tailscaleを入手" + }, + "MobileRelayBetaAvailability": { + "about": "Orca Relay ベータについて", + "beta": "ベータ", + "availability": "利用可能:", + "testFlight": "TestFlight", + "androidApk": "Android APK" } }, "right": { @@ -12790,7 +12840,7 @@ "profiles": { "switcher": { "cloud": { - "unavailable": "クラウドログインを利用できません" + "unavailable": "Orcaへのサインインは利用できません" }, "reconnect": "プロファイルを再接続", "connect": "プロファイルを接続", @@ -12814,7 +12864,14 @@ "account": "アカウント", "org": { "members": "組織メンバー" - } + }, + "signInWaiting": "サインインを待機中…", + "signInAgain": "もう一度サインイン", + "signIn": "Orcaにサインイン", + "accountTitle": "Orcaアカウント", + "accountSignedIn": "サインイン済み", + "accountSignInRequired": "サインインが必要です", + "accountSignedOut": "サインアウト済み" }, "management": { "04e7bd2a23": "転送", @@ -12857,8 +12914,8 @@ }, "signout": { "confirm": { - "title": "ログアウトしますか?", - "description": "{{profileName}}からログアウトし、プロジェクト、ワークツリー、ローカルメタデータをこのデバイスに保持します。", + "title": "Orcaからログアウトしますか?", + "description": "このデバイスでOrcaからログアウトします。ローカルのプロジェクトとワークツリーには影響しません。", "cancel": "キャンセル", "action": "ログアウト" } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 05eacf25060..48954ef5262 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -9036,6 +9036,56 @@ "TerminalInteractionSection": { "567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.", "c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu." + }, + "MobileRelayStatusSection": { + "registered": "Registered", + "connecting": "Connecting", + "reconnecting": "Reconnecting", + "offline": "Offline", + "title": "Orca Relay", + "automatic": "Connect from anywhere when a phone is paired", + "signInPrompt": "Sign in on this desktop to connect from anywhere", + "directStillAvailable": "LAN and Tailscale connections remain available.", + "directNeedsNoAccount": "LAN and Tailscale pairing still work without an account.", + "signIn": "Sign in", + "unavailable": "Unavailable", + "standby": "Standby — no relay devices" + }, + "MobilePairingConnectionOptions": { + "ready": "준비됨", + "connecting": "연결 중", + "available": "사용 가능", + "reconnecting": "다시 연결 중", + "unavailable": "사용할 수 없음", + "title": "새 휴대폰을 어떻게 연결할까요?", + "anywhere": "Orca Relay로 연결", + "recommended": "권장", + "automaticDescription": "가능하면 직접 연결하고, 그렇지 않으면 Orca Relay를 사용합니다.", + "signInDescription": "Orca Relay를 사용하려면 이 데스크톱에서 로그인하세요.", + "signIn": "로그인", + "localOnly": "로컬 네트워크만", + "localShort": "LAN 또는 Tailscale", + "localDescription": "이 휴대폰을 Orca Relay에 연결하지 않고 LAN 또는 Tailscale을 사용합니다." + }, + "MobilePairingSetupSection": { + "title": "휴대폰 페어링", + "automaticDescription": "페어링 코드에는 직접 연결과 암호화된 Orca Relay 대체 연결이 포함됩니다.", + "localDescription": "페어링 코드는 아래의 로컬 네트워크 주소로만 연결합니다.", + "regenerate": "다시 생성", + "generate": "QR 코드 생성", + "localSettings": "로컬 연결 설정", + "localAddressDescription": "Orca Mobile에서 이 컴퓨터에 직접 연결할 수 있는 LAN 또는 사설 네트워크 주소를 선택하세요.", + "refresh": "네트워크 인터페이스 새로 고침", + "tailnet": "내 tailnet으로 연결", + "tailnetDescription": "이 컴퓨터와 휴대폰에 Tailscale을 설치하고 같은 tailnet에 로그인한 다음 위에서 100.x.y.z 주소를 선택하세요.", + "getTailscale": "Tailscale 받기" + }, + "MobileRelayBetaAvailability": { + "about": "Orca Relay 베타 정보", + "beta": "베타", + "availability": "이용 가능:", + "testFlight": "TestFlight", + "androidApk": "Android APK" } }, "right": { @@ -12790,7 +12840,7 @@ "profiles": { "switcher": { "cloud": { - "unavailable": "클라우드 로그인 사용 불가" + "unavailable": "Orca 로그인 사용 불가" }, "reconnect": "프로필 다시 연결", "connect": "프로필 연결", @@ -12814,7 +12864,14 @@ "account": "계정", "org": { "members": "조직 구성원" - } + }, + "signInWaiting": "로그인 대기 중…", + "signInAgain": "다시 로그인", + "signIn": "Orca에 로그인", + "accountTitle": "Orca 계정", + "accountSignedIn": "로그인됨", + "accountSignInRequired": "로그인 필요", + "accountSignedOut": "로그아웃됨" }, "management": { "04e7bd2a23": "전송", @@ -12857,8 +12914,8 @@ }, "signout": { "confirm": { - "title": "로그아웃하시겠습니까?", - "description": "{{profileName}}에서 로그아웃하고 프로젝트, 워크트리 및 로컬 메타데이터를 이 장치에 유지합니다.", + "title": "Orca에서 로그아웃할까요?", + "description": "이 기기의 Orca에서 로그아웃됩니다. 로컬 프로젝트와 워크트리에는 영향을 주지 않습니다.", "cancel": "취소", "action": "로그아웃" } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 2519b1856e5..7344a7f7a5f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -9036,6 +9036,56 @@ "TerminalInteractionSection": { "567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.", "c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu." + }, + "MobileRelayStatusSection": { + "registered": "Registered", + "connecting": "Connecting", + "reconnecting": "Reconnecting", + "offline": "Offline", + "title": "Orca Relay", + "automatic": "Connect from anywhere when a phone is paired", + "signInPrompt": "Sign in on this desktop to connect from anywhere", + "directStillAvailable": "LAN and Tailscale connections remain available.", + "directNeedsNoAccount": "LAN and Tailscale pairing still work without an account.", + "signIn": "Sign in", + "unavailable": "Unavailable", + "standby": "Standby — no relay devices" + }, + "MobilePairingConnectionOptions": { + "ready": "已就绪", + "connecting": "正在连接", + "available": "可用", + "reconnecting": "正在重新连接", + "unavailable": "不可用", + "title": "新手机应如何连接?", + "anywhere": "使用 Orca Relay 连接", + "recommended": "推荐", + "automaticDescription": "Orca 会优先使用直接连接,否则使用 Orca Relay。", + "signInDescription": "请在此桌面端登录以使用 Orca Relay。", + "signIn": "登录", + "localOnly": "仅限本地网络", + "localShort": "局域网或 Tailscale", + "localDescription": "使用局域网或 Tailscale,不通过 Orca Relay 连接这部手机。" + }, + "MobilePairingSetupSection": { + "title": "配对手机", + "automaticDescription": "配对码包含直接访问方式和加密的 Orca Relay 备用连接。", + "localDescription": "配对码仅通过下方的本地网络地址连接。", + "regenerate": "重新生成", + "generate": "生成二维码", + "localSettings": "本地连接设置", + "localAddressDescription": "选择 Orca Mobile 可用于直接访问此电脑的局域网或专用网络地址。", + "refresh": "刷新网络接口", + "tailnet": "使用自己的 tailnet 连接", + "tailnetDescription": "在此电脑和手机上安装 Tailscale,登录同一个 tailnet,然后在上方选择其 100.x.y.z 地址。", + "getTailscale": "获取 Tailscale" + }, + "MobileRelayBetaAvailability": { + "about": "关于 Orca Relay 测试版", + "beta": "测试版", + "availability": "可用平台:", + "testFlight": "TestFlight", + "androidApk": "Android APK" } }, "right": { @@ -12790,7 +12840,7 @@ "profiles": { "switcher": { "cloud": { - "unavailable": "云登录不可用" + "unavailable": "Orca 登录不可用" }, "reconnect": "重新连接配置文件", "connect": "连接配置文件", @@ -12814,7 +12864,14 @@ "account": "帐户", "org": { "members": "组织成员" - } + }, + "signInWaiting": "正在等待登录…", + "signInAgain": "重新登录", + "signIn": "登录 Orca", + "accountTitle": "Orca 帐户", + "accountSignedIn": "已登录", + "accountSignInRequired": "需要登录", + "accountSignedOut": "已退出登录" }, "management": { "04e7bd2a23": "转移", @@ -12857,8 +12914,8 @@ }, "signout": { "confirm": { - "title": "退出登录?", - "description": "退出 {{profileName}},并在此设备上保留其项目、工作树和本地元数据。", + "title": "退出 Orca?", + "description": "你将在此设备上退出 Orca。本地项目和工作树不会受到影响。", "cancel": "取消", "action": "退出登录" } diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 1af722e3899..08faded33e6 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -760,7 +760,10 @@ function createWebPreloadApi(): Partial { revokeDevice: () => Promise.resolve({ revoked: false }), listRuntimeAccessGrants: () => Promise.resolve({ grants: [] }), revokeRuntimeAccess: () => Promise.resolve({ revoked: false }), - isWebSocketReady: () => Promise.resolve({ ready: Boolean(activeEnvironment), endpoint: null }) + isWebSocketReady: () => + Promise.resolve({ ready: Boolean(activeEnvironment), endpoint: null }), + getRelayStatus: () => Promise.resolve({ status: 'offline' as const }), + onRelayStatusChanged: () => noopUnsubscribe }, telemetryTrack: () => Promise.resolve(), telemetrySetOptIn: () => Promise.resolve(), diff --git a/src/shared/mobile-e2ee-legacy-fixtures.ts b/src/shared/mobile-e2ee-legacy-fixtures.ts new file mode 100644 index 00000000000..0b8b58e0149 --- /dev/null +++ b/src/shared/mobile-e2ee-legacy-fixtures.ts @@ -0,0 +1,15 @@ +export const MOBILE_E2EE_LEGACY_FIXTURE = { + serverSecretKey: new Uint8Array(32).fill(1), + clientSecretKey: new Uint8Array(32).fill(2), + serverPublicKeyB64: 'pOCSkrZRwni5dyxWn1+puxPZBrRqtoyd+dwrRAn4ogk=', + clientPublicKeyB64: 'zo060cy2M+x7cMF4FKXHbs0CloUFDTRHRboFhw5YfVk=', + sharedKeyHex: '18a99320f3488fa18a04239715d8ee738065e65c3d4b2898522d6c3d4ead588c', + helloText: '{"type":"e2ee_hello","publicKeyB64":"zo060cy2M+x7cMF4FKXHbs0CloUFDTRHRboFhw5YfVk="}', + readyText: '{"type":"e2ee_ready"}', + authPlaintext: '{"type":"e2ee_auth","deviceToken":"legacy-token"}', + authFrameB64: + 'BgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGTxV8uZ+DG8yRMwrsAYDOGAHUylsq9vpyPLHIz5lrt+vb8AFA6SXELR72fhVZpQiC5tDhn3RUuo0CNefKVy/njNg=', + binaryPlaintext: new Uint8Array([0, 1, 2, 127, 128, 255]), + binaryFrameHex: + '060606060606060606060606060606060606060606060606200be03dc6f8c733e1b85657d9a52dfe7af7bc5dda6c' +} as const diff --git a/src/shared/mobile-e2ee-v2-contract.test.ts b/src/shared/mobile-e2ee-v2-contract.test.ts new file mode 100644 index 00000000000..4e263c96089 --- /dev/null +++ b/src/shared/mobile-e2ee-v2-contract.test.ts @@ -0,0 +1,79 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { + encodeMobileE2EEV2Transcript, + validateMobileE2EEV2Handshake +} from './mobile-e2ee-v2-contract' +import { createMobileE2EEV2Fixture, MOBILE_E2EE_V2_VECTOR } from './mobile-e2ee-v2-fixtures' + +describe('mobile E2EE v2 contract', () => { + it('validates the exact relay handshake and canonical encodings', () => { + const { hello, ready } = createMobileE2EEV2Fixture() + expect(validateMobileE2EEV2Handshake(hello, ready)).not.toBeNull() + }) + + it('rejects unknown fields, noncanonical base64, and an unechoed nonce', () => { + const { hello, ready } = createMobileE2EEV2Fixture() + expect(validateMobileE2EEV2Handshake({ ...hello, extra: true }, ready)).toBeNull() + expect( + validateMobileE2EEV2Handshake( + { ...hello, clientPublicKeyB64: hello.clientPublicKeyB64.replace(/=$/, '') }, + ready + ) + ).toBeNull() + expect( + validateMobileE2EEV2Handshake(hello, { + ...ready, + clientNonceB64: btoa(String.fromCharCode(...new Uint8Array(32).fill(9))) + }) + ).toBeNull() + }) + + it('rejects context and capability-selection changes', () => { + const { hello, ready } = createMobileE2EEV2Fixture() + expect( + validateMobileE2EEV2Handshake(hello, { + ...ready, + context: { ...ready.context, relayHostId: 'ZbCdEf0123_-xyZ9' } + }) + ).toBeNull() + expect( + validateMobileE2EEV2Handshake( + { ...hello, capabilities: { framing: [2], payloadKinds: ['binary', 'text'] } }, + ready + ) + ).toBeNull() + }) + + it('requires relayHostId only for relay context', () => { + const { hello, ready } = createMobileE2EEV2Fixture() + const relayWithoutId = { ...hello.context } + delete (relayWithoutId as { relayHostId?: string }).relayHostId + expect(validateMobileE2EEV2Handshake({ ...hello, context: relayWithoutId }, ready)).toBeNull() + + const directContext = { + protocol: 'orca-mobile-e2ee' as const, + initiator: 'mobile' as const, + responder: 'desktop' as const, + transport: 'direct' as const + } + expect( + validateMobileE2EEV2Handshake( + { ...hello, context: directContext }, + { ...ready, context: directContext } + ) + ).not.toBeNull() + }) + + it('locks the canonical length-prefixed transcript bytes', () => { + const { hello, ready } = createMobileE2EEV2Fixture() + const handshake = validateMobileE2EEV2Handshake(hello, ready) + expect(handshake).not.toBeNull() + const transcript = encodeMobileE2EEV2Transcript(handshake!) + + expect(transcript).toHaveLength(MOBILE_E2EE_V2_VECTOR.transcriptLength) + expect(createHash('sha256').update(transcript).digest('hex')).toBe( + MOBILE_E2EE_V2_VECTOR.transcriptHashHex + ) + }) +}) diff --git a/src/shared/mobile-e2ee-v2-contract.ts b/src/shared/mobile-e2ee-v2-contract.ts new file mode 100644 index 00000000000..d687779b828 --- /dev/null +++ b/src/shared/mobile-e2ee-v2-contract.ts @@ -0,0 +1,280 @@ +export const MOBILE_E2EE_V2_PROTOCOL = 'orca-mobile-e2ee' +export const MOBILE_E2EE_V2_TRANSCRIPT_DOMAIN = 'orca-mobile-e2ee/v2/transcript' + +export type MobileE2EETransport = 'direct' | 'relay' +export type MobileE2EEPayloadKind = 'text' | 'binary' + +export type MobileE2EEV2Context = { + protocol: typeof MOBILE_E2EE_V2_PROTOCOL + initiator: 'mobile' + responder: 'desktop' + transport: MobileE2EETransport + relayHostId?: string +} + +export type MobileE2EEV2Hello = { + type: 'e2ee_hello' + v: 2 + clientPublicKeyB64: string + clientNonceB64: string + capabilities: { framing: [2]; payloadKinds: ['text', 'binary'] } + context: MobileE2EEV2Context +} + +export type MobileE2EEV2Ready = { + type: 'e2ee_ready' + v: 2 + desktopPublicKeyB64: string + clientNonceB64: string + desktopNonceB64: string + selection: { framing: 2; payloadKinds: ['text', 'binary'] } + context: MobileE2EEV2Context +} + +export type MobileE2EEV2Handshake = { + hello: MobileE2EEV2Hello + ready: MobileE2EEV2Ready + clientPublicKey: Uint8Array + desktopPublicKey: Uint8Array + clientNonce: Uint8Array + desktopNonce: Uint8Array +} + +const BASE64URL_16_PATTERN = /^[A-Za-z0-9_-]{16}$/ + +export function validateMobileE2EEV2Handshake( + helloValue: unknown, + readyValue: unknown +): MobileE2EEV2Handshake | null { + if ( + !isExactRecord(helloValue, [ + 'type', + 'v', + 'clientPublicKeyB64', + 'clientNonceB64', + 'capabilities', + 'context' + ]) + ) { + return null + } + if ( + !isExactRecord(readyValue, [ + 'type', + 'v', + 'desktopPublicKeyB64', + 'clientNonceB64', + 'desktopNonceB64', + 'selection', + 'context' + ]) + ) { + return null + } + if (helloValue.type !== 'e2ee_hello' || helloValue.v !== 2) { + return null + } + if (readyValue.type !== 'e2ee_ready' || readyValue.v !== 2) { + return null + } + if (!hasExactCapabilities(helloValue.capabilities) || !hasExactSelection(readyValue.selection)) { + return null + } + const helloContext = parseContext(helloValue.context) + const readyContext = parseContext(readyValue.context) + if (!helloContext || !readyContext || !contextsEqual(helloContext, readyContext)) { + return null + } + if (readyValue.clientNonceB64 !== helloValue.clientNonceB64) { + return null + } + + const clientPublicKey = decodeCanonicalBase64Bytes(helloValue.clientPublicKeyB64, 32) + const desktopPublicKey = decodeCanonicalBase64Bytes(readyValue.desktopPublicKeyB64, 32) + const clientNonce = decodeCanonicalBase64Bytes(helloValue.clientNonceB64, 32) + const desktopNonce = decodeCanonicalBase64Bytes(readyValue.desktopNonceB64, 32) + if (!clientPublicKey || !desktopPublicKey || !clientNonce || !desktopNonce) { + return null + } + + return { + hello: helloValue as MobileE2EEV2Hello, + ready: readyValue as MobileE2EEV2Ready, + clientPublicKey, + desktopPublicKey, + clientNonce, + desktopNonce + } +} + +export function encodeMobileE2EEV2Transcript(handshake: MobileE2EEV2Handshake): Uint8Array { + const { hello, ready } = handshake + const fields: [string, Uint8Array][] = [ + ['domain', utf8(MOBILE_E2EE_V2_TRANSCRIPT_DOMAIN)], + ['mobile-to-desktop.type', utf8(hello.type)], + ['mobile-to-desktop.version', uint32(hello.v)], + ['mobile-to-desktop.client-public-key', handshake.clientPublicKey], + ['mobile-to-desktop.client-nonce', handshake.clientNonce], + ['mobile-to-desktop.capabilities.framing', encodeNumberList(hello.capabilities.framing)], + [ + 'mobile-to-desktop.capabilities.payload-kinds', + encodeStringList(hello.capabilities.payloadKinds) + ], + ['mobile-to-desktop.context.protocol', utf8(hello.context.protocol)], + ['mobile-to-desktop.context.initiator', utf8(hello.context.initiator)], + ['mobile-to-desktop.context.responder', utf8(hello.context.responder)], + ['mobile-to-desktop.context.transport', utf8(hello.context.transport)], + ['mobile-to-desktop.context.relay-host-id', utf8(hello.context.relayHostId ?? '')], + ['desktop-to-mobile.type', utf8(ready.type)], + ['desktop-to-mobile.version', uint32(ready.v)], + ['desktop-to-mobile.desktop-public-key', handshake.desktopPublicKey], + ['desktop-to-mobile.client-nonce-echo', handshake.clientNonce], + ['desktop-to-mobile.desktop-nonce', handshake.desktopNonce], + ['desktop-to-mobile.selection.framing', uint32(ready.selection.framing)], + ['desktop-to-mobile.selection.payload-kinds', encodeStringList(ready.selection.payloadKinds)], + ['desktop-to-mobile.context.protocol', utf8(ready.context.protocol)], + ['desktop-to-mobile.context.initiator', utf8(ready.context.initiator)], + ['desktop-to-mobile.context.responder', utf8(ready.context.responder)], + ['desktop-to-mobile.context.transport', utf8(ready.context.transport)], + ['desktop-to-mobile.context.relay-host-id', utf8(ready.context.relayHostId ?? '')] + ] + return concatBytes( + fields.map(([name, value]) => + concatBytes([uint32(utf8(name).length), utf8(name), uint32(value.length), value]) + ) + ) +} + +function parseContext(value: unknown): MobileE2EEV2Context | null { + if (!isRecord(value)) { + return null + } + const transport = value.transport + const keys = + transport === 'relay' + ? ['protocol', 'initiator', 'responder', 'transport', 'relayHostId'] + : ['protocol', 'initiator', 'responder', 'transport'] + if (!isExactRecord(value, keys)) { + return null + } + if ( + value.protocol !== MOBILE_E2EE_V2_PROTOCOL || + value.initiator !== 'mobile' || + value.responder !== 'desktop' || + (transport !== 'direct' && transport !== 'relay') + ) { + return null + } + if ( + transport === 'relay' && + (typeof value.relayHostId !== 'string' || !BASE64URL_16_PATTERN.test(value.relayHostId)) + ) { + return null + } + return value as MobileE2EEV2Context +} + +function hasExactCapabilities(value: unknown): boolean { + return ( + isExactRecord(value, ['framing', 'payloadKinds']) && + Array.isArray(value.framing) && + value.framing.length === 1 && + value.framing[0] === 2 && + Array.isArray(value.payloadKinds) && + value.payloadKinds.length === 2 && + value.payloadKinds[0] === 'text' && + value.payloadKinds[1] === 'binary' + ) +} + +function hasExactSelection(value: unknown): boolean { + return ( + isExactRecord(value, ['framing', 'payloadKinds']) && + value.framing === 2 && + Array.isArray(value.payloadKinds) && + value.payloadKinds.length === 2 && + value.payloadKinds[0] === 'text' && + value.payloadKinds[1] === 'binary' + ) +} + +function contextsEqual(left: MobileE2EEV2Context, right: MobileE2EEV2Context): boolean { + return ( + left.protocol === right.protocol && + left.initiator === right.initiator && + left.responder === right.responder && + left.transport === right.transport && + left.relayHostId === right.relayHostId + ) +} + +function decodeCanonicalBase64Bytes(value: unknown, length: number): Uint8Array | null { + if ( + typeof value !== 'string' || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value) + ) { + return null + } + try { + const binary = atob(value) + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)) + return bytes.length === length && encodeBase64(bytes) === value ? bytes : null + } catch { + return null + } +} + +function encodeBase64(bytes: Uint8Array): string { + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return btoa(binary) +} + +function encodeNumberList(values: readonly number[]): Uint8Array { + return concatBytes([uint32(values.length), ...values.map(uint32)]) +} + +function encodeStringList(values: readonly string[]): Uint8Array { + return concatBytes([ + uint32(values.length), + ...values.map((value) => { + const bytes = utf8(value) + return concatBytes([uint32(bytes.length), bytes]) + }) + ]) +} + +function uint32(value: number): Uint8Array { + const bytes = new Uint8Array(4) + new DataView(bytes.buffer).setUint32(0, value, false) + return bytes +} + +function utf8(value: string): Uint8Array { + return new TextEncoder().encode(value) +} + +function concatBytes(parts: readonly Uint8Array[]): Uint8Array { + const result = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)) + let offset = 0 + for (const part of parts) { + result.set(part, offset) + offset += part.length + } + return result +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isExactRecord(value: unknown, keys: readonly string[]): value is Record { + if (!isRecord(value)) { + return false + } + const actual = Object.keys(value).sort() + const expected = [...keys].sort() + return actual.length === expected.length && actual.every((key, index) => key === expected[index]) +} diff --git a/src/shared/mobile-e2ee-v2-fixtures.ts b/src/shared/mobile-e2ee-v2-fixtures.ts new file mode 100644 index 00000000000..f1806223ef8 --- /dev/null +++ b/src/shared/mobile-e2ee-v2-fixtures.ts @@ -0,0 +1,47 @@ +import type { MobileE2EEV2Hello, MobileE2EEV2Ready } from './mobile-e2ee-v2-contract' + +function repeatedByteBase64(byte: number): string { + return btoa(String.fromCharCode(...new Uint8Array(32).fill(byte))) +} + +export function createMobileE2EEV2Fixture(): { + hello: MobileE2EEV2Hello + ready: MobileE2EEV2Ready + sharedSecret: Uint8Array +} { + const context = { + protocol: 'orca-mobile-e2ee' as const, + initiator: 'mobile' as const, + responder: 'desktop' as const, + transport: 'relay' as const, + relayHostId: 'AbCdEf0123_-xyZ9' + } + return { + hello: { + type: 'e2ee_hello', + v: 2, + clientPublicKeyB64: repeatedByteBase64(1), + clientNonceB64: repeatedByteBase64(2), + capabilities: { framing: [2], payloadKinds: ['text', 'binary'] }, + context + }, + ready: { + type: 'e2ee_ready', + v: 2, + desktopPublicKeyB64: repeatedByteBase64(3), + clientNonceB64: repeatedByteBase64(2), + desktopNonceB64: repeatedByteBase64(4), + selection: { framing: 2, payloadKinds: ['text', 'binary'] }, + context + }, + sharedSecret: new Uint8Array(32).fill(5) + } +} + +export const MOBILE_E2EE_V2_VECTOR = { + transcriptLength: 1347, + transcriptHashHex: 'ca6385f8bbf64a223fdd59587bfb67e2373891ce9e6d85ab41df8b7a20a168e3', + mobileToDesktopKeyHex: 'df17ff534df77fd3a30999f4e6200c8fcedefbb15d369301ca62c3cdfea9559a', + desktopToMobileKeyHex: '71365fcf8212a6d63caf909ee28de3c8f689682ef298a374136055e0ab1cde4a', + sessionIdHex: '339ae1f2bdff63481857d2813c2f19dd1f5aa4824705d5e5daeb25dae7b9196e' +} as const diff --git a/src/shared/mobile-e2ee-v2-framing.test.ts b/src/shared/mobile-e2ee-v2-framing.test.ts new file mode 100644 index 00000000000..6567dd77da7 --- /dev/null +++ b/src/shared/mobile-e2ee-v2-framing.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { openMobileE2EEV2Frame, sealMobileE2EEV2Frame } from './mobile-e2ee-v2-framing' + +const key = new Uint8Array(32).fill(7) +const sessionId = new Uint8Array(32).fill(8) +const payload = new TextEncoder().encode('e2ee-auth') + +describe('mobile E2EE v2 framing', () => { + it('round-trips counter-zero auth with the fixed nonce layout', () => { + const frame = sealMobileE2EEV2Frame({ + payload, + key, + sessionId, + direction: 'mobile-to-desktop', + payloadKind: 'text', + counter: 0n + }) + + expect(Buffer.from(frame.subarray(0, 24)).toString('hex')).toBe( + '080808080808080808080808020000000000000000000000' + ) + expect( + openMobileE2EEV2Frame({ + frame, + key, + sessionId, + direction: 'mobile-to-desktop', + payloadKind: 'text', + expectedCounter: 0n + }) + ).toEqual(payload) + }) + + it('rejects replay, gap, reflection, kind confusion, and cross-session frames', () => { + const frame = sealMobileE2EEV2Frame({ + payload, + key, + sessionId, + direction: 'mobile-to-desktop', + payloadKind: 'binary', + counter: 4n + }) + const attempt = (overrides: Partial[0]>) => + openMobileE2EEV2Frame({ + frame, + key, + sessionId, + direction: 'mobile-to-desktop', + payloadKind: 'binary', + expectedCounter: 4n, + ...overrides + }) + + expect(attempt({ expectedCounter: 3n })).toBeNull() + expect(attempt({ expectedCounter: 5n })).toBeNull() + expect(attempt({ direction: 'desktop-to-mobile' })).toBeNull() + expect(attempt({ payloadKind: 'text' })).toBeNull() + expect(attempt({ sessionId: new Uint8Array(32).fill(9) })).toBeNull() + }) + + it('uses one exact-next counter sequence across text and binary kinds', () => { + const kinds = ['text', 'binary', 'text'] as const + const frames = kinds.map((payloadKind, counter) => + sealMobileE2EEV2Frame({ + payload: new Uint8Array([counter]), + key, + sessionId, + direction: 'desktop-to-mobile', + payloadKind, + counter: BigInt(counter) + }) + ) + + for (let counter = 0; counter < frames.length; counter++) { + expect( + openMobileE2EEV2Frame({ + frame: frames[counter]!, + key, + sessionId, + direction: 'desktop-to-mobile', + payloadKind: kinds[counter]!, + expectedCounter: BigInt(counter) + }) + ).toEqual(new Uint8Array([counter])) + } + }) +}) diff --git a/src/shared/mobile-e2ee-v2-framing.ts b/src/shared/mobile-e2ee-v2-framing.ts new file mode 100644 index 00000000000..53ba1e9627e --- /dev/null +++ b/src/shared/mobile-e2ee-v2-framing.ts @@ -0,0 +1,141 @@ +import nacl from 'tweetnacl' +import type { MobileE2EEPayloadKind } from './mobile-e2ee-v2-contract' + +export type MobileE2EEDirection = 'mobile-to-desktop' | 'desktop-to-mobile' + +const NONCE_LENGTH = 24 +const SESSION_ID_LENGTH = 32 +const HEADER_LENGTH = SESSION_ID_LENGTH + 1 + 1 + 8 +const FRAME_VERSION = 2 +const MAX_COUNTER = (1n << 64n) - 1n + +export function sealMobileE2EEV2Frame(args: { + payload: Uint8Array + key: Uint8Array + sessionId: Uint8Array + direction: MobileE2EEDirection + payloadKind: MobileE2EEPayloadKind + counter: bigint +}): Uint8Array { + validateFrameInputs(args.key, args.sessionId, args.counter) + const header = encodeHeader(args) + const nonce = encodeNonce(args) + const plaintext = concatBytes([header, args.payload]) + const ciphertext = nacl.secretbox(plaintext, nonce, args.key) + return concatBytes([nonce, ciphertext]) +} + +export function openMobileE2EEV2Frame(args: { + frame: Uint8Array + key: Uint8Array + sessionId: Uint8Array + direction: MobileE2EEDirection + payloadKind: MobileE2EEPayloadKind + expectedCounter: bigint +}): Uint8Array | null { + validateFrameInputs(args.key, args.sessionId, args.expectedCounter) + if (args.frame.length < NONCE_LENGTH + nacl.secretbox.overheadLength + HEADER_LENGTH) { + return null + } + const expected = { + sessionId: args.sessionId, + direction: args.direction, + payloadKind: args.payloadKind, + counter: args.expectedCounter + } + const nonce = encodeNonce(expected) + if (!equalBytes(args.frame.subarray(0, NONCE_LENGTH), nonce)) { + return null + } + + const plaintext = nacl.secretbox.open(args.frame.subarray(NONCE_LENGTH), nonce, args.key) + if (!plaintext) { + return null + } + const header = encodeHeader(expected) + if (!equalBytes(plaintext.subarray(0, HEADER_LENGTH), header)) { + return null + } + return plaintext.slice(HEADER_LENGTH) +} + +function encodeHeader(args: { + sessionId: Uint8Array + direction: MobileE2EEDirection + payloadKind: MobileE2EEPayloadKind + counter: bigint +}): Uint8Array { + const header = new Uint8Array(HEADER_LENGTH) + header.set(args.sessionId, 0) + header[SESSION_ID_LENGTH] = directionByte(args.direction) + header[SESSION_ID_LENGTH + 1] = payloadKindByte(args.payloadKind) + writeUint64(header, SESSION_ID_LENGTH + 2, args.counter) + return header +} + +function encodeNonce(args: { + sessionId: Uint8Array + direction: MobileE2EEDirection + payloadKind: MobileE2EEPayloadKind + counter: bigint +}): Uint8Array { + const nonce = new Uint8Array(NONCE_LENGTH) + // Why: v2 keys/sessionId are fresh per socket; the fixed layout makes every + // direction/kind/counter nonce unique without relying on another RNG draw. + nonce.set(args.sessionId.subarray(0, 12), 0) + nonce[12] = FRAME_VERSION + nonce[13] = directionByte(args.direction) + nonce[14] = payloadKindByte(args.payloadKind) + nonce[15] = 0 + writeUint64(nonce, 16, args.counter) + return nonce +} + +function directionByte(direction: MobileE2EEDirection): number { + return direction === 'mobile-to-desktop' ? 0 : 1 +} + +function payloadKindByte(kind: MobileE2EEPayloadKind): number { + return kind === 'text' ? 0 : 1 +} + +function validateFrameInputs(key: Uint8Array, sessionId: Uint8Array, counter: bigint): void { + if (key.length !== nacl.secretbox.keyLength) { + throw new Error(`Invalid E2EE v2 key length: ${key.length}`) + } + if (sessionId.length !== SESSION_ID_LENGTH) { + throw new Error(`Invalid E2EE v2 session ID length: ${sessionId.length}`) + } + if (counter < 0n || counter > MAX_COUNTER) { + throw new Error(`Invalid E2EE v2 counter: ${counter}`) + } +} + +function writeUint64(target: Uint8Array, offset: number, value: bigint): void { + let remaining = value + for (let index = 7; index >= 0; index--) { + target[offset + index] = Number(remaining & 0xffn) + remaining >>= 8n + } +} + +function concatBytes(parts: readonly Uint8Array[]): Uint8Array { + const result = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)) + let offset = 0 + for (const part of parts) { + result.set(part, offset) + offset += part.length + } + return result +} + +function equalBytes(left: Uint8Array, right: Uint8Array): boolean { + if (left.length !== right.length) { + return false + } + let difference = 0 + for (let index = 0; index < left.length; index++) { + difference |= left[index]! ^ right[index]! + } + return difference === 0 +} diff --git a/src/shared/mobile-pairing-connection-mode.ts b/src/shared/mobile-pairing-connection-mode.ts new file mode 100644 index 00000000000..cde61a6fde5 --- /dev/null +++ b/src/shared/mobile-pairing-connection-mode.ts @@ -0,0 +1 @@ +export type MobilePairingConnectionMode = 'automatic' | 'local-only' diff --git a/src/shared/mobile-relay-close-codes.test.ts b/src/shared/mobile-relay-close-codes.test.ts new file mode 100644 index 00000000000..829f966c066 --- /dev/null +++ b/src/shared/mobile-relay-close-codes.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { + isMobileRelayCloseCode, + MOBILE_RELAY_CLOSE_CODE, + mobileRelayRecoveryFor +} from './mobile-relay-close-codes' + +describe('mobile relay close-code contract', () => { + it('locks all application close codes', () => { + expect(MOBILE_RELAY_CLOSE_CODE).toEqual({ + BAD_OUTER_CREDENTIAL: 4401, + HOST_OFFLINE: 4404, + PEER_DROPPED: 4408, + WRONG_CELL: 4409, + LIMIT_EXCEEDED: 4429, + DRAINING: 4503 + }) + expect(isMobileRelayCloseCode(4409)).toBe(true) + expect(isMobileRelayCloseCode(1006)).toBe(false) + }) + + it('uses the configured director and never a cell-supplied URL', () => { + expect(mobileRelayRecoveryFor(4503, 'host-control')).toEqual({ + kind: 'resolve-configured-director', + fullJitter: true + }) + }) + + it('separates invite and resume wrong-cell recovery', () => { + expect(mobileRelayRecoveryFor(4409, 'phone-invite')).toEqual({ + kind: 'resolve-invite-through-director-ws', + requireStrictlyNewerEpoch: true + }) + expect(mobileRelayRecoveryFor(4409, 'phone-resume')).toEqual({ + kind: 'resolve-resume-through-director-post' + }) + }) + + it('keeps outer credential failure endpoint-scoped', () => { + expect(mobileRelayRecoveryFor(4401, 'phone-resume')).toEqual({ + kind: 'disable-relay-credential', + directUnaffected: true + }) + }) +}) diff --git a/src/shared/mobile-relay-close-codes.ts b/src/shared/mobile-relay-close-codes.ts new file mode 100644 index 00000000000..6eefb151e52 --- /dev/null +++ b/src/shared/mobile-relay-close-codes.ts @@ -0,0 +1,51 @@ +export const MOBILE_RELAY_CLOSE_CODE = { + BAD_OUTER_CREDENTIAL: 4401, + HOST_OFFLINE: 4404, + PEER_DROPPED: 4408, + WRONG_CELL: 4409, + LIMIT_EXCEEDED: 4429, + DRAINING: 4503 +} as const + +export type MobileRelayCloseCode = + (typeof MOBILE_RELAY_CLOSE_CODE)[keyof typeof MOBILE_RELAY_CLOSE_CODE] +export type MobileRelayLeg = 'host-control' | 'host-data' | 'phone-invite' | 'phone-resume' + +export type MobileRelayRecovery = + | { kind: 'disable-relay-credential'; directUnaffected: true } + | { kind: 'wait-for-host-revival' } + | { kind: 'reconnect-fresh-e2ee'; fullJitter: true } + | { kind: 'resolve-invite-through-director-ws'; requireStrictlyNewerEpoch: true } + | { kind: 'resolve-resume-through-director-post' } + | { kind: 'request-director-assignment' } + | { kind: 'backoff'; fullJitter: true } + | { kind: 'resolve-configured-director'; fullJitter: true } + +export function mobileRelayRecoveryFor( + code: MobileRelayCloseCode, + leg: MobileRelayLeg +): MobileRelayRecovery { + switch (code) { + case MOBILE_RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL: + return { kind: 'disable-relay-credential', directUnaffected: true } + case MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE: + return { kind: 'wait-for-host-revival' } + case MOBILE_RELAY_CLOSE_CODE.PEER_DROPPED: + return { kind: 'reconnect-fresh-e2ee', fullJitter: true } + case MOBILE_RELAY_CLOSE_CODE.WRONG_CELL: + if (leg === 'phone-invite') { + return { kind: 'resolve-invite-through-director-ws', requireStrictlyNewerEpoch: true } + } + return leg === 'phone-resume' + ? { kind: 'resolve-resume-through-director-post' } + : { kind: 'request-director-assignment' } + case MOBILE_RELAY_CLOSE_CODE.LIMIT_EXCEEDED: + return { kind: 'backoff', fullJitter: true } + case MOBILE_RELAY_CLOSE_CODE.DRAINING: + return { kind: 'resolve-configured-director', fullJitter: true } + } +} + +export function isMobileRelayCloseCode(value: number): value is MobileRelayCloseCode { + return Object.values(MOBILE_RELAY_CLOSE_CODE).some((code) => code === value) +} diff --git a/src/shared/mobile-relay-credential-contract.ts b/src/shared/mobile-relay-credential-contract.ts new file mode 100644 index 00000000000..c5c04c632a7 --- /dev/null +++ b/src/shared/mobile-relay-credential-contract.ts @@ -0,0 +1,95 @@ +import { z } from 'zod' + +const OpaqueIdSchema = z.string().min(1).max(128) +const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) +const EpochMsSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) +const RelayHostIdSchema = z.string().regex(/^[A-Za-z0-9_-]{16}$/) + +function isCanonicalHttpsOrigin(value: string): boolean { + try { + const parsed = new URL(value) + return parsed.protocol === 'https:' && value === parsed.origin + } catch { + return false + } +} + +export const PairingProvisionRelayParamsSchema = z + .object({ + reqId: OpaqueIdSchema, + newResumeTokenHash: Base64Url32ByteSchema, + expectedCurrentHash: Base64Url32ByteSchema.optional() + }) + .strict() + +export const PairingGetEndpointsParamsSchema = z + .object({ + installReqId: OpaqueIdSchema.optional(), + resumeConfirmReqId: OpaqueIdSchema.optional() + }) + .strict() + +export const DeviceCredentialInstalledSchema = z + .object({ + v: z.literal(1), + reqId: OpaqueIdSchema, + authorizationMode: z.enum(['relay-basis', 'authenticated-direct']), + currentVersion: z.number().int().positive(), + resumeExpiresAt: EpochMsSchema, + graceExpiresAt: EpochMsSchema.optional() + }) + .strict() + +export const DeviceCredentialInstallStatusResultSchema = z.union([ + z.object({ v: z.literal(1), reqId: OpaqueIdSchema, state: z.literal('not-found') }).strict(), + z + .object({ + v: z.literal(1), + reqId: OpaqueIdSchema, + state: z.literal('committed'), + result: DeviceCredentialInstalledSchema + }) + .strict() +]) + +export const DeviceResumeConfirmedSchema = z + .object({ + v: z.literal(1), + reqId: OpaqueIdSchema, + currentVersion: z.number().int().positive(), + acceptedAs: z.enum(['current', 'grace']), + renewed: z.boolean(), + resumeExpiresAt: EpochMsSchema, + graceExpiresAt: EpochMsSchema.optional() + }) + .strict() + +export const MobileRelayEndpointSchema = z + .object({ + v: z.literal(1), + directorUrl: z.string().refine(isCanonicalHttpsOrigin), + cellUrl: z.string().refine(isCanonicalHttpsOrigin), + assignmentEpoch: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + relayHostId: RelayHostIdSchema, + e2eeFraming: z.literal(2) + }) + .strict() + +export const PairingGetEndpointsResultSchema = z + .object({ + v: z.literal(1), + relay: MobileRelayEndpointSchema.nullable(), + installStatus: DeviceCredentialInstallStatusResultSchema.optional(), + resumeConfirmation: DeviceResumeConfirmedSchema.optional() + }) + .strict() + +export type PairingProvisionRelayParams = z.infer +export type PairingGetEndpointsParams = z.infer +export type DeviceCredentialInstalled = z.infer +export type DeviceCredentialInstallStatusResult = z.infer< + typeof DeviceCredentialInstallStatusResultSchema +> +export type DeviceResumeConfirmed = z.infer +export type MobileRelayEndpoint = z.infer +export type PairingGetEndpointsResult = z.infer diff --git a/src/shared/mobile-relay-pairing-fixtures.ts b/src/shared/mobile-relay-pairing-fixtures.ts new file mode 100644 index 00000000000..6c4f6073370 --- /dev/null +++ b/src/shared/mobile-relay-pairing-fixtures.ts @@ -0,0 +1,126 @@ +import type { PairingOffer } from './pairing' + +const PUBLIC_KEY_B64 = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' +const INVITE_TOKEN = 'abcdefghijklmnopqrstuvwxyzABCDEFGH012345678' + +export type PairingFixture = { + name: string + payload: unknown + expected: PairingOffer | null +} + +export function createMobileRelayPairingFixtures(now: number): PairingFixture[] { + const directOffer: PairingOffer = { + v: 2, + endpoint: 'ws://192.168.1.10:6768', + deviceToken: 'device-token', + publicKeyB64: PUBLIC_KEY_B64 + } + const relay = { + v: 1 as const, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: INVITE_TOKEN, + inviteExpiresAt: now + 5 * 60 * 1000, + e2eeFraming: 2 as const + } + return [ + { name: 'legacy direct offer', payload: directOffer, expected: directOffer }, + { + name: 'legacy direct offer with mobile scope', + payload: { ...directOffer, scope: 'mobile' }, + expected: { ...directOffer, scope: 'mobile' } + }, + { + name: 'relay offer with absent scope', + payload: { ...directOffer, relay }, + expected: { ...directOffer, relay } + }, + { + name: 'relay offer with mobile scope', + payload: { ...directOffer, scope: 'mobile', relay }, + expected: { ...directOffer, scope: 'mobile', relay } + }, + { + name: 'unknown keys are stripped at both levels', + payload: { ...directOffer, ignored: true, relay: { ...relay, ignored: true } }, + expected: { ...directOffer, relay } + }, + { + name: 'offer-level endpoints are stripped', + payload: { ...directOffer, endpoints: [{ kind: 'relay' }] }, + expected: directOffer + }, + { + name: 'runtime relay is invalid', + payload: { ...directOffer, scope: 'runtime', relay }, + expected: null + }, + { + name: 'relay offer public key must be canonical 32-byte base64', + payload: { ...directOffer, publicKeyB64: 'legacy-nonempty-key', relay }, + expected: null + }, + { + name: 'non-canonical director origin is invalid', + payload: { ...directOffer, relay: { ...relay, directorUrl: 'https://relay.onorca.dev/' } }, + expected: null + }, + { + name: 'non-HTTPS cell origin is invalid', + payload: { ...directOffer, relay: { ...relay, cellUrl: 'http://relay-c1.onorca.dev' } }, + expected: null + }, + { + name: 'fractional assignment epoch is invalid', + payload: { ...directOffer, relay: { ...relay, assignmentEpoch: 1.5 } }, + expected: null + }, + { + name: 'negative assignment epoch is invalid', + payload: { ...directOffer, relay: { ...relay, assignmentEpoch: -1 } }, + expected: null + }, + { + name: 'unsafe assignment epoch is invalid', + payload: { + ...directOffer, + relay: { ...relay, assignmentEpoch: Number.MAX_SAFE_INTEGER + 1 } + }, + expected: null + }, + { + name: 'relay host id length is invalid', + payload: { ...directOffer, relay: { ...relay, relayHostId: 'short' } }, + expected: null + }, + { + name: 'invite token length is invalid', + payload: { ...directOffer, relay: { ...relay, inviteToken: 'short' } }, + expected: null + }, + { + name: 'expired invite is invalid', + payload: { ...directOffer, relay: { ...relay, inviteExpiresAt: now } }, + expected: null + }, + { + name: 'invite beyond ten minutes is invalid', + payload: { ...directOffer, relay: { ...relay, inviteExpiresAt: now + 10 * 60 * 1000 + 1 } }, + expected: null + }, + { + name: 'unsupported E2EE framing is invalid', + payload: { ...directOffer, relay: { ...relay, e2eeFraming: 1 } }, + expected: null + } + ] +} + +export function encodePairingFixturePayload(payload: unknown): string { + const json = JSON.stringify(payload) + const code = Buffer.from(json, 'utf8').toString('base64url') + return `orca://pair?code=${code}` +} diff --git a/src/shared/mobile-relay-pairing-offer.test.ts b/src/shared/mobile-relay-pairing-offer.test.ts new file mode 100644 index 00000000000..d8b78c5622e --- /dev/null +++ b/src/shared/mobile-relay-pairing-offer.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { createPairingOfferSchema } from './mobile-relay-pairing-offer' +import { createMobileRelayPairingFixtures } from './mobile-relay-pairing-fixtures' + +describe('desktop mobile-relay pairing contract', () => { + const now = Date.UTC(2026, 6, 12, 16) + const schema = createPairingOfferSchema(() => now) + + for (const fixture of createMobileRelayPairingFixtures(now)) { + it(fixture.name, () => { + const result = schema.safeParse(fixture.payload) + expect(result.success ? result.data : null).toEqual(fixture.expected) + }) + } +}) diff --git a/src/shared/mobile-relay-pairing-offer.ts b/src/shared/mobile-relay-pairing-offer.ts new file mode 100644 index 00000000000..df16dad35a2 --- /dev/null +++ b/src/shared/mobile-relay-pairing-offer.ts @@ -0,0 +1,90 @@ +import { z } from 'zod' + +export const PAIRING_OFFER_VERSION = 2 +const PairingScopeSchema = z.enum(['mobile', 'runtime']) +const BASE64URL_16_PATTERN = /^[A-Za-z0-9_-]{16}$/ +const BASE64URL_43_PATTERN = /^[A-Za-z0-9_-]{43}$/ +const MAX_RELAY_URL_BYTES = 2048 +const MAX_INVITE_TTL_MS = 10 * 60 * 1000 + +function isCanonicalHttpsOrigin(value: string): boolean { + if (new TextEncoder().encode(value).length > MAX_RELAY_URL_BYTES) { + return false + } + try { + const parsed = new URL(value) + return parsed.protocol === 'https:' && value === parsed.origin + } catch { + return false + } +} + +function isCanonicalBase64Key(value: string): boolean { + if (!/^[A-Za-z0-9+/]{43}=$/.test(value)) { + return false + } + try { + const decoded = atob(value) + return decoded.length === 32 && btoa(decoded) === value + } catch { + return false + } +} + +export function createPairingOfferSchema(now: () => number = () => Date.now()) { + const relaySchema = z.object({ + v: z.literal(1), + directorUrl: z + .string() + .min(1) + .refine(isCanonicalHttpsOrigin, 'Expected canonical HTTPS origin'), + cellUrl: z.string().min(1).refine(isCanonicalHttpsOrigin, 'Expected canonical HTTPS origin'), + assignmentEpoch: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), + relayHostId: z.string().regex(BASE64URL_16_PATTERN), + inviteToken: z.string().regex(BASE64URL_43_PATTERN), + inviteExpiresAt: z + .number() + .int() + .refine((value) => { + const currentTime = now() + return value > currentTime && value <= currentTime + MAX_INVITE_TTL_MS + }, 'Expected a future invite expiry no more than 10 minutes away'), + e2eeFraming: z.literal(2) + }) + + return z + .object({ + v: z.literal(PAIRING_OFFER_VERSION), + endpoint: z.string().min(1), + deviceToken: z.string().min(1), + // Why: the desktop's Curve25519 public key is pinned by the pairing + // offer, while relayHostId is verified from its decoded bytes later. + publicKeyB64: z.string().min(1), + scope: PairingScopeSchema.optional(), + relay: relaySchema.optional() + }) + .superRefine((offer, ctx) => { + if (offer.relay && offer.scope === 'runtime') { + // Why: relay v1 is mobile-only; accepting it on runtime offers would + // imply routing and credential support that client does not have. + ctx.addIssue({ + code: 'custom', + path: ['relay'], + message: 'Relay is invalid for runtime scope' + }) + } + if (offer.relay && !isCanonicalBase64Key(offer.publicKeyB64)) { + // Why: relayHostId is derived from the decoded key bytes, so relay + // offers cannot tolerate the permissive legacy base64 aliases. + ctx.addIssue({ + code: 'custom', + path: ['publicKeyB64'], + message: 'Relay offers require a canonical 32-byte public key' + }) + } + }) +} + +export const PairingOfferSchema = createPairingOfferSchema() +export type PairingOffer = z.infer +export type PairingRelay = NonNullable diff --git a/src/shared/mobile-relay-phone-protocol.test.ts b/src/shared/mobile-relay-phone-protocol.test.ts new file mode 100644 index 00000000000..0935c510853 --- /dev/null +++ b/src/shared/mobile-relay-phone-protocol.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { RelayPhoneHelloSchema } from './mobile-relay-phone-protocol' + +describe('relay phone outer protocol', () => { + it('accepts only exact invite, resume, and failure hello variants', () => { + expect( + RelayPhoneHelloSchema.safeParse({ + type: 'relay-hello', + ok: true, + credentialKind: 'invite', + leaseExpiresAt: 1 + }).success + ).toBe(true) + expect( + RelayPhoneHelloSchema.safeParse({ + type: 'relay-hello', + ok: false, + code: 4404, + cellUrl: 'https://untrusted.example' + }).success + ).toBe(false) + expect( + RelayPhoneHelloSchema.safeParse({ + type: 'relay-hello', + ok: true, + credentialKind: 'resume', + leaseExpiresAt: 10, + acceptedCredentialVersion: 2, + acceptedAs: 'grace', + resumeExpiresAt: 8 + }).success + ).toBe(true) + }) +}) diff --git a/src/shared/mobile-relay-phone-protocol.ts b/src/shared/mobile-relay-phone-protocol.ts new file mode 100644 index 00000000000..e4c354114b6 --- /dev/null +++ b/src/shared/mobile-relay-phone-protocol.ts @@ -0,0 +1,51 @@ +import { z } from 'zod' + +const EpochMsSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) + +export const RelayPhoneHelloSchema = z.union([ + z + .object({ + type: z.literal('relay-hello'), + ok: z.literal(false), + code: z.number().int().min(4000).max(4999) + }) + .strict(), + z + .object({ + type: z.literal('relay-hello'), + ok: z.literal(true), + credentialKind: z.literal('invite'), + leaseExpiresAt: EpochMsSchema + }) + .strict(), + z + .object({ + type: z.literal('relay-hello'), + ok: z.literal(true), + credentialKind: z.literal('resume'), + leaseExpiresAt: EpochMsSchema, + acceptedCredentialVersion: z.number().int().positive(), + acceptedAs: z.enum(['current', 'grace']), + resumeExpiresAt: EpochMsSchema, + graceExpiresAt: EpochMsSchema.optional() + }) + .strict() +]) + +export type RelayPhoneHello = z.infer + +export const RelayMovedSchema = z + .object({ + type: z.literal('relay-moved'), + v: z.literal(1), + cellUrl: z.string().refine((value) => { + try { + const parsed = new URL(value) + return parsed.protocol === 'https:' && parsed.origin === value + } catch { + return false + } + }), + assignmentEpoch: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) + }) + .strict() diff --git a/src/shared/mobile-relay-status.ts b/src/shared/mobile-relay-status.ts new file mode 100644 index 00000000000..d3697dea48b --- /dev/null +++ b/src/shared/mobile-relay-status.ts @@ -0,0 +1,9 @@ +export const MOBILE_RELAY_STATUSES = [ + 'connecting', + 'registered', + 'standby', + 'draining', + 'offline' +] as const + +export type MobileRelayStatus = (typeof MOBILE_RELAY_STATUSES)[number] diff --git a/src/shared/pairing.ts b/src/shared/pairing.ts index 9d8364c470e..c39595bbef2 100644 --- a/src/shared/pairing.ts +++ b/src/shared/pairing.ts @@ -1,21 +1,11 @@ -import { z } from 'zod' +import { + PAIRING_OFFER_VERSION, + PairingOfferSchema, + type PairingOffer +} from './mobile-relay-pairing-offer' -export const PAIRING_OFFER_VERSION = 2 -const PairingScopeSchema = z.enum(['mobile', 'runtime']) - -export const PairingOfferSchema = z.object({ - v: z.literal(PAIRING_OFFER_VERSION), - endpoint: z.string().min(1), - deviceToken: z.string().min(1), - // Why: the desktop's Curve25519 public key, base64-encoded. The mobile client - // uses this to derive a shared secret via ECDH for end-to-end encryption. - publicKeyB64: z.string().min(1), - // Why: advisory UI metadata lets the web client reject phone-QR offers before - // opening a socket; the runtime still authorizes solely from deviceToken. - scope: PairingScopeSchema.optional() -}) - -export type PairingOffer = z.infer +export { PAIRING_OFFER_VERSION, PairingOfferSchema } +export type { PairingOffer } export function encodePairingOffer(offer: PairingOffer): string { const json = JSON.stringify(offer)