diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index d03a46ec929..48ae99ff410 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -57,6 +57,7 @@ type GroupMode = 'none' | 'repo' | 'prStatus' const STATUS_LABELS: Record = { connecting: 'Connecting…', + handshaking: 'Securing…', connected: 'Connected', disconnected: 'Disconnected', reconnecting: 'Reconnecting…', @@ -269,7 +270,7 @@ export default function HostScreen() { } setHostName(host.name) - rpcClient = connect(host.endpoint, host.deviceToken, setConnState) + rpcClient = connect(host.endpoint, host.deviceToken, host.publicKeyB64, setConnState) setClient(rpcClient) await updateLastConnected(host.id) diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index d3dffd55ee4..bf58eff5e49 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -58,6 +58,7 @@ const persistedFittedHandles = new Map>() const STATUS_LABELS: Record = { connecting: 'Connecting', + handshaking: 'Securing', connected: 'Connected', disconnected: 'Disconnected', reconnecting: 'Reconnecting', @@ -122,6 +123,16 @@ export default function SessionScreen() { const canSend = connState === 'connected' && activeHandle != null + const writeLines = useCallback((lines: string[] | string | undefined) => { + const text = Array.isArray(lines) ? lines.join('\r\n') : (lines ?? '') + if (text) { + termRef.current?.write(text) + if (Array.isArray(lines)) { + termRef.current?.write('\r\n') + } + } + }, []) + // Why: after a fit/restore resize the PTY grid changes, so we must // resubscribe to get a fresh scrollback snapshot at the new geometry. // We skip terminal.focus to avoid a race where the desktop renderer @@ -245,7 +256,7 @@ export default function SessionScreen() { if (!host) return deviceTokenRef.current = host.deviceToken - rpcClient = connect(host.endpoint, host.deviceToken, setConnState) + rpcClient = connect(host.endpoint, host.deviceToken, host.publicKeyB64, setConnState) setClient(rpcClient) })() @@ -264,16 +275,6 @@ export default function SessionScreen() { termRef.current?.clear() }, [worktreeId]) - const writeLines = useCallback((lines: string[] | string | undefined) => { - const text = Array.isArray(lines) ? lines.join('\r\n') : (lines ?? '') - if (text) { - termRef.current?.write(text) - if (Array.isArray(lines)) { - termRef.current?.write('\r\n') - } - } - }, []) - const subscribeToTerminal = useCallback( (handle: string) => { unsubRef.current?.() diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 1a5460999b8..2cba39717d8 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useMemo } from 'react' import { View, Text, StyleSheet, Pressable, FlatList } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { useRouter, useFocusEffect } from 'expo-router' -import { MoreHorizontal, QrCode, Server, ShieldCheck, Sparkles } from 'lucide-react-native' +import { MoreHorizontal, QrCode, Server } from 'lucide-react-native' import { loadHosts, removeHost, renameHost } from '../src/transport/host-store' import { connect } from '../src/transport/rpc-client' import type { ConnectionState, HostProfile } from '../src/transport/types' @@ -47,7 +47,7 @@ export default function HomeScreen() { ...prev, [host.id]: prev[host.id] ?? 'connecting' })) - return connect(host.endpoint, host.deviceToken, (state) => { + return connect(host.endpoint, host.deviceToken, host.publicKeyB64, (state) => { if (disposed) return setHostStates((prev) => ({ ...prev, [host.id]: state })) }) @@ -88,32 +88,15 @@ export default function HomeScreen() { {hosts.length === 0 ? ( - - - - - - - Pair your first desktop Scan the QR code from Orca desktop to see live worktrees, terminals, and agent output from your phone. router.push('/pair-scan')}> - + Scan Pairing Code - - - - Token-based local connection - - - - Jump back into active worktrees - - ) : ( =18.0.0'} hasBin: true + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -9319,6 +9333,10 @@ snapshots: transitivePeerDependencies: - supports-color + expo-crypto@55.0.14(expo@55.0.17): + dependencies: + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-file-system@55.0.17(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)): dependencies: expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) @@ -12176,6 +12194,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tweetnacl@1.0.3: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/mobile/scripts/mock-server.ts b/mobile/scripts/mock-server.ts index 7b959b10449..6578c9912d0 100644 --- a/mobile/scripts/mock-server.ts +++ b/mobile/scripts/mock-server.ts @@ -1,12 +1,47 @@ #!/usr/bin/env npx tsx // Why: standalone mock WebSocket server for developing the mobile app without // a running Orca desktop instance. Responds to the same RPC methods the real -// runtime exposes, with realistic fake data. +// runtime exposes, with realistic fake data. Supports E2EE handshake. import { WebSocketServer, type WebSocket } from 'ws' +import nacl from 'tweetnacl' const PORT = Number(process.env.PORT) || 6768 const AUTH_TOKEN = 'mock-device-token' +// Why: generate a persistent server keypair for this mock session. +// The public key is printed at startup so it can be used in pairing QR data. +const serverKeyPair = nacl.box.keyPair() +const serverPublicKeyB64 = Buffer.from(serverKeyPair.publicKey).toString('base64') + +type E2EEState = { + sharedKey: Uint8Array + deviceToken: string +} + +function deriveSharedKey(ourSecret: Uint8Array, peerPublic: Uint8Array): Uint8Array { + return nacl.box.before(peerPublic, ourSecret) +} + +function e2eeEncrypt(plaintext: string, sharedKey: Uint8Array): string { + const nonce = nacl.randomBytes(nacl.box.nonceLength) + const msg = new TextEncoder().encode(plaintext) + const ciphertext = nacl.box.after(msg, nonce, sharedKey) + const bundle = new Uint8Array(nonce.length + ciphertext.length) + bundle.set(nonce) + bundle.set(ciphertext, nonce.length) + return Buffer.from(bundle).toString('base64') +} + +function e2eeDecrypt(encrypted: string, sharedKey: Uint8Array): string | null { + const bundle = Uint8Array.from(Buffer.from(encrypted, 'base64')) + if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) return null + const nonce = bundle.slice(0, nacl.box.nonceLength) + const ciphertext = bundle.slice(nacl.box.nonceLength) + const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey) + if (!plaintext) return null + return new TextDecoder().decode(plaintext) +} + const FAKE_WORKTREES = [ { worktreeId: 'repo-1::/home/user/projects/acme-api', @@ -103,121 +138,172 @@ function error(id: string, code: string, message: string): RpcResponse { return { id, ok: false, error: { code, message }, _meta: { runtimeId: 'mock-runtime' } } } -function handleRequest(request: RpcRequest, ws: WebSocket): void { - if (request.deviceToken !== AUTH_TOKEN) { - ws.send(JSON.stringify(error(request.id, 'unauthorized', 'Invalid device token'))) - return - } - +function handleRequest( + request: RpcRequest, + send: (response: RpcResponse) => void, + ws: WebSocket +): void { switch (request.method) { case 'status.get': - ws.send( - JSON.stringify( - success(request.id, { - runtimeId: 'mock-runtime', - graphStatus: 'ready', - windowCount: 1, - tabCount: 2, - terminalCount: 2 - }) - ) + send( + success(request.id, { + runtimeId: 'mock-runtime', + graphStatus: 'ready', + windowCount: 1, + tabCount: 2, + terminalCount: 2 + }) ) break case 'worktree.ps': - ws.send( - JSON.stringify( - success(request.id, { - worktrees: FAKE_WORKTREES, - totalCount: FAKE_WORKTREES.length, - truncated: false - }) - ) + send( + success(request.id, { + worktrees: FAKE_WORKTREES, + totalCount: FAKE_WORKTREES.length, + truncated: false + }) ) break case 'terminal.list': - ws.send( - JSON.stringify( - success(request.id, { - terminals: FAKE_TERMINALS, - totalCount: FAKE_TERMINALS.length, - truncated: false - }) - ) + send( + success(request.id, { + terminals: FAKE_TERMINALS, + totalCount: FAKE_TERMINALS.length, + truncated: false + }) ) break case 'terminal.subscribe': { - ws.send( - JSON.stringify( - success(request.id, { type: 'scrollback', lines: FAKE_SCROLLBACK, truncated: false }) - ) - ) + send(success(request.id, { type: 'scrollback', lines: FAKE_SCROLLBACK, truncated: false })) let chunkIndex = 0 const interval = setInterval(() => { if (chunkIndex >= STREAMING_CHUNKS.length || ws.readyState !== ws.OPEN) { clearInterval(interval) if (ws.readyState === ws.OPEN) { - ws.send(JSON.stringify(success(request.id, { type: 'end' }))) + send(success(request.id, { type: 'end' })) } return } - ws.send( - JSON.stringify( - success(request.id, { type: 'data', chunk: STREAMING_CHUNKS[chunkIndex] }, true) - ) - ) + send(success(request.id, { type: 'data', chunk: STREAMING_CHUNKS[chunkIndex] }, true)) chunkIndex++ }, 500) break } case 'terminal.send': - ws.send(JSON.stringify(success(request.id, { send: { handle: 'term-1', ok: true } }))) + send(success(request.id, { send: { handle: 'term-1', ok: true } })) break case 'terminal.unsubscribe': - ws.send(JSON.stringify(success(request.id, { unsubscribed: true }))) + send(success(request.id, { unsubscribed: true })) break default: - ws.send( - JSON.stringify(error(request.id, 'method_not_found', `Unknown method: ${request.method}`)) - ) + send(error(request.id, 'method_not_found', `Unknown method: ${request.method}`)) } } const wss = new WebSocketServer({ port: PORT }) +// Why: each connection goes through an E2EE handshake before any RPC traffic. +// The first message must be e2ee_hello (plaintext), then all subsequent +// messages are encrypted with the derived shared key. +const connectionState = new Map() + wss.on('connection', (ws) => { - console.log('[mock] Client connected') + console.log('[mock] Client connected — waiting for e2ee_hello') ws.on('message', (data) => { const msg = typeof data === 'string' ? data : data.toString('utf-8') + const e2ee = connectionState.get(ws) + + if (!e2ee) { + // Handshake phase — expect e2ee_hello + let hello: { type?: string; publicKeyB64?: string; deviceToken?: string } + try { + hello = JSON.parse(msg) + } catch { + ws.send(JSON.stringify({ type: 'e2ee_error', message: 'Invalid JSON' })) + ws.close() + return + } + + if (hello.type !== 'e2ee_hello' || !hello.publicKeyB64 || !hello.deviceToken) { + ws.send(JSON.stringify({ type: 'e2ee_error', message: 'Expected e2ee_hello' })) + ws.close() + return + } + + if (hello.deviceToken !== AUTH_TOKEN) { + ws.send( + JSON.stringify({ ok: false, error: { code: 'unauthorized', message: 'Bad token' } }) + ) + ws.close() + return + } + + const clientPublicKey = Uint8Array.from(Buffer.from(hello.publicKeyB64, 'base64')) + if (clientPublicKey.length !== 32) { + ws.send(JSON.stringify({ type: 'e2ee_error', message: 'Invalid public key' })) + ws.close() + return + } + + const sharedKey = deriveSharedKey(serverKeyPair.secretKey, clientPublicKey) + connectionState.set(ws, { sharedKey, deviceToken: hello.deviceToken }) + + ws.send(JSON.stringify({ type: 'e2ee_ready' })) + console.log('[mock] E2EE handshake complete') + return + } + + // Post-handshake — decrypt, handle, encrypt reply + const plaintext = e2eeDecrypt(msg, e2ee.sharedKey) + if (plaintext === null) { + console.log('[mock] Decryption failed — dropping message') + return + } + let request: RpcRequest try { - request = JSON.parse(msg) as RpcRequest + request = JSON.parse(plaintext) as RpcRequest } catch { - ws.send(JSON.stringify(error('unknown', 'bad_request', 'Invalid JSON'))) + const encrypted = e2eeEncrypt( + JSON.stringify(error('unknown', 'bad_request', 'Invalid JSON')), + e2ee.sharedKey + ) + ws.send(encrypted) return } console.log(`[mock] ${request.method} (id: ${request.id})`) - handleRequest(request, ws) + handleRequest( + request, + (response) => { + if (ws.readyState === ws.OPEN) { + ws.send(e2eeEncrypt(JSON.stringify(response), e2ee.sharedKey)) + } + }, + ws + ) }) ws.on('close', () => { + connectionState.delete(ws) console.log('[mock] Client disconnected') }) ws.on('error', () => { + connectionState.delete(ws) ws.close() }) }) console.log(`[mock] Orca mock server listening on ws://localhost:${PORT}`) console.log(`[mock] Auth token: ${AUTH_TOKEN}`) -console.log(`[mock] Try: npx wscat -c ws://localhost:${PORT}`) -console.log(`[mock] Then send: {"id":"1","deviceToken":"${AUTH_TOKEN}","method":"status.get"}`) +console.log(`[mock] Server public key (base64): ${serverPublicKeyB64}`) +console.log(`[mock] E2EE enabled — clients must send e2ee_hello before RPC`) diff --git a/mobile/src/components/StatusDot.tsx b/mobile/src/components/StatusDot.tsx index d45d430c6b6..63777890d70 100644 --- a/mobile/src/components/StatusDot.tsx +++ b/mobile/src/components/StatusDot.tsx @@ -5,6 +5,7 @@ import type { ConnectionState } from '../transport/types' const stateColors: Record = { connected: colors.statusGreen, connecting: colors.statusAmber, + handshaking: colors.statusAmber, reconnecting: colors.statusAmber, disconnected: colors.statusRed, 'auth-failed': colors.statusRed diff --git a/mobile/src/transport/e2ee.ts b/mobile/src/transport/e2ee.ts new file mode 100644 index 00000000000..2b0b3474ba8 --- /dev/null +++ b/mobile/src/transport/e2ee.ts @@ -0,0 +1,77 @@ +// Why: E2EE primitives for the mobile side. Uses tweetnacl for Curve25519 ECDH +// key exchange and XSalsa20-Poly1305 authenticated encryption. Wire format is +// base64([24-byte nonce][ciphertext]) over WebSocket text frames. +import nacl from 'tweetnacl' +import * as ExpoCrypto from 'expo-crypto' + +// Why: Hermes (React Native's JS engine) lacks crypto.getRandomValues, +// which tweetnacl requires. expo-crypto provides a native secure RNG +// that works in Expo Go without a custom dev build. +nacl.setPRNG((_x: Uint8Array, n: number) => { + const bytes = ExpoCrypto.getRandomBytes(n) + _x.set(bytes) +}) + +export function generateKeyPair(): nacl.BoxKeyPair { + return nacl.box.keyPair() +} + +export function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey: Uint8Array): Uint8Array { + return nacl.box.before(peerPublicKey, ourSecretKey) +} + +// Why: React Native doesn't have Node's Buffer. Use atob/btoa which are +// available in the Hermes JS engine. +function uint8ToBase64(bytes: Uint8Array): string { + let binary = '' + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]!) + } + return btoa(binary) +} + +function base64ToUint8(b64: string): Uint8Array { + const binary = atob(b64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + return bytes +} + +export function publicKeyFromBase64(b64: string): Uint8Array { + return base64ToUint8(b64) +} + +export function publicKeyToBase64(key: Uint8Array): string { + return uint8ToBase64(key) +} + +export function encrypt(plaintext: string, sharedKey: Uint8Array): string { + const nonce = nacl.randomBytes(nacl.box.nonceLength) + const messageBytes = new TextEncoder().encode(plaintext) + const ciphertext = nacl.box.after(messageBytes, nonce, sharedKey) + + const bundle = new Uint8Array(nonce.length + ciphertext.length) + bundle.set(nonce) + bundle.set(ciphertext, nonce.length) + + return uint8ToBase64(bundle) +} + +export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null { + const bundle = base64ToUint8(encrypted) + if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) { + return null + } + + const nonce = new Uint8Array(bundle.subarray(0, nacl.box.nonceLength)) + const ciphertext = new Uint8Array(bundle.subarray(nacl.box.nonceLength)) + const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey) + + if (!plaintext) { + return null + } + + return new TextDecoder().decode(plaintext) +} diff --git a/mobile/src/transport/index.ts b/mobile/src/transport/index.ts index f2597f34497..8aacca59eff 100644 --- a/mobile/src/transport/index.ts +++ b/mobile/src/transport/index.ts @@ -16,4 +16,4 @@ export type { HostProfile, PairingOffer } from './types' -export { PairingOfferV1, PAIRING_OFFER_VERSION } from './types' +export { PairingOfferSchema, PAIRING_OFFER_VERSION } from './types' diff --git a/mobile/src/transport/pairing.ts b/mobile/src/transport/pairing.ts index 9c1bd83e029..ada3e2715eb 100644 --- a/mobile/src/transport/pairing.ts +++ b/mobile/src/transport/pairing.ts @@ -1,4 +1,4 @@ -import { PairingOfferV1, type PairingOffer } from './types' +import { PairingOfferSchema, type PairingOffer } from './types' export function decodePairingUrl(url: string): PairingOffer | null { try { @@ -9,7 +9,7 @@ export function decodePairingUrl(url: string): PairingOffer | null { const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') const json = atob(base64) const parsed = JSON.parse(json) - return PairingOfferV1.parse(parsed) + return PairingOfferSchema.parse(parsed) } catch { return null } diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 39a09b3ef3a..04433e67f26 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -1,4 +1,12 @@ import type { RpcRequest, RpcResponse, RpcSuccess, ConnectionState } from './types' +import { + generateKeyPair, + deriveSharedKey, + publicKeyFromBase64, + publicKeyToBase64, + encrypt, + decrypt +} from './e2ee' type PendingRequest = { resolve: (response: RpcResponse) => void @@ -23,10 +31,12 @@ export type RpcClient = { const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000] const REQUEST_TIMEOUT_MS = 30_000 +const HANDSHAKE_TIMEOUT_MS = 5_000 export function connect( endpoint: string, deviceToken: string, + serverPublicKeyB64: string, onStateChange?: (state: ConnectionState) => void ): RpcClient { let ws: WebSocket | null = null @@ -34,12 +44,17 @@ export function connect( let requestCounter = 0 let reconnectAttempt = 0 let reconnectTimer: ReturnType | null = null + let handshakeTimer: ReturnType | null = null let intentionallyClosed = false + // Why: fresh ephemeral keypair per connection provides forward secrecy. + // The shared key is derived from our ephemeral secret + server's static public key. + let sharedKey: Uint8Array | null = null + const serverPublicKey = publicKeyFromBase64(serverPublicKeyB64) + const pending = new Map() const streamListeners = new Map() const stateListeners = new Set<(state: ConnectionState) => void>() - // Callers that are waiting for the socket to reach 'connected' state const connectWaiters: Array<{ resolve: () => void; reject: (e: Error) => void }> = [] if (onStateChange) { @@ -75,28 +90,78 @@ export function connect( if (intentionallyClosed) return setState('connecting') + sharedKey = null ws = new WebSocket(endpoint) ws.onopen = () => { reconnectAttempt = 0 - setState('connected') - for (const [id, stream] of streamListeners) { - sendRaw({ id, deviceToken, method: stream.method, params: stream.params }) - } + setState('handshaking') + + // Why: generate a fresh ephemeral keypair for each connection. + // This provides forward secrecy — compromising one session's key + // doesn't compromise past or future sessions. + const ephemeral = generateKeyPair() + const hello = JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(ephemeral.publicKey), + deviceToken + }) + ws?.send(hello) + + sharedKey = deriveSharedKey(ephemeral.secretKey, serverPublicKey) + + handshakeTimer = setTimeout(() => { + handshakeTimer = null + ws?.close() + }, HANDSHAKE_TIMEOUT_MS) } ws.onmessage = (event) => { + const raw = typeof event.data === 'string' ? event.data : String(event.data) + + // Why: during handshaking, only accept e2ee_ready (plaintext). + // All other messages are silently discarded. + if (state === 'handshaking') { + try { + const msg = JSON.parse(raw) + if (msg.type === 'e2ee_ready') { + if (handshakeTimer) { + clearTimeout(handshakeTimer) + handshakeTimer = null + } + setState('connected') + for (const [id, stream] of streamListeners) { + sendEncrypted({ id, deviceToken, method: stream.method, params: stream.params }) + } + } else if (msg.type === 'e2ee_error' || (!msg.ok && msg.error?.code === 'unauthorized')) { + intentionallyClosed = true + ws?.close() + ws = null + setState('auth-failed') + rejectAllPending('Unauthorized — pairing may be revoked') + } + } catch { + // Not JSON — ignore during handshake + } + return + } + + // Why: post-handshake, all messages are encrypted. Decrypt before processing. + const plaintext = decrypt(raw, sharedKey!) + if (plaintext === null) { + return + } + let response: RpcResponse try { - response = JSON.parse(typeof event.data === 'string' ? event.data : String(event.data)) + response = JSON.parse(plaintext) } catch { return } // Why: auth failure is distinct from transient disconnect — retrying - // with a rejected token causes infinite reconnect churn and hides the - // re-pair action the user needs to take. + // with a rejected token causes infinite reconnect churn. if (!response.ok && response.error.code === 'unauthorized') { intentionallyClosed = true ws?.close() @@ -116,8 +181,6 @@ export function connect( return } - // Non-streaming: check if it's a final streaming message (type: 'end') - // or a one-shot response if (response.ok) { const result = (response as RpcSuccess).result as Record | null if (result && result.type === 'end') { @@ -128,7 +191,6 @@ export function connect( return } } - // Scrollback (first message from terminal.subscribe) also goes to stream listener if (result && result.type === 'scrollback') { const stream = streamListeners.get(response.id) if (stream) { @@ -147,6 +209,11 @@ export function connect( ws.onclose = () => { ws = null + sharedKey = null + if (handshakeTimer) { + clearTimeout(handshakeTimer) + handshakeTimer = null + } if (intentionallyClosed) { setState('disconnected') rejectAllPending('Connection closed') @@ -177,9 +244,9 @@ export function connect( } } - function sendRaw(request: RpcRequest) { - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify(request)) + function sendEncrypted(request: RpcRequest) { + if (ws && ws.readyState === WebSocket.OPEN && sharedKey) { + ws.send(encrypt(JSON.stringify(request), sharedKey)) } } @@ -207,7 +274,7 @@ export function connect( } }) - sendRaw({ id, deviceToken, method, params }) + sendEncrypted({ id, deviceToken, method, params }) }) }, @@ -216,13 +283,13 @@ export function connect( streamListeners.set(id, { method, params, listener: onData }) if (state === 'connected') { - sendRaw({ id, deviceToken, method, params }) + sendEncrypted({ id, deviceToken, method, params }) } return () => { const stream = streamListeners.get(id) streamListeners.delete(id) - sendRaw({ + sendEncrypted({ id: nextId(), deviceToken, method: 'terminal.unsubscribe', @@ -254,10 +321,15 @@ export function connect( clearTimeout(reconnectTimer) reconnectTimer = null } + if (handshakeTimer) { + clearTimeout(handshakeTimer) + handshakeTimer = null + } if (ws) { ws.close() ws = null } + sharedKey = null setState('disconnected') rejectAllPending('Client closed') } diff --git a/mobile/src/transport/types.ts b/mobile/src/transport/types.ts index 7d7019e91f2..9cb7a1f45fd 100644 --- a/mobile/src/transport/types.ts +++ b/mobile/src/transport/types.ts @@ -24,19 +24,20 @@ export type RpcFailure = { export type RpcResponse = RpcSuccess | RpcFailure -export const PAIRING_OFFER_VERSION = 1 +export const PAIRING_OFFER_VERSION = 2 -export const PairingOfferV1 = z.object({ +export const PairingOfferSchema = z.object({ v: z.literal(PAIRING_OFFER_VERSION), endpoint: z.string().min(1), deviceToken: z.string().min(1), - certFingerprint: z.string().startsWith('sha256:') + publicKeyB64: z.string().min(1) }) -export type PairingOffer = z.infer +export type PairingOffer = z.infer export type ConnectionState = | 'connecting' + | 'handshaking' | 'connected' | 'disconnected' | 'reconnecting' @@ -47,6 +48,6 @@ export type HostProfile = { name: string endpoint: string deviceToken: string - certFingerprint: string + publicKeyB64: string lastConnected: number } diff --git a/package.json b/package.json index 1c5e4ff2b5c..a88f050ce14 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "ssh2": "^1.17.0", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", + "tweetnacl": "^1.0.3", "ws": "^8.20.0", "zod": "^4.3.6", "zustand": "^5.0.12" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 346d53f5aba..be402a0d0b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -214,6 +214,9 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + tweetnacl: + specifier: ^1.0.3 + version: 1.0.3 ws: specifier: ^8.20.0 version: 8.20.0 @@ -5985,6 +5988,9 @@ packages: tweetnacl@0.14.5: resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + type-fest@0.13.1: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} @@ -12596,6 +12602,8 @@ snapshots: tweetnacl@0.14.5: {} + tweetnacl@1.0.3: {} + type-fest@0.13.1: optional: true diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index 22e9c9bfc40..a9c62b6acc3 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -42,16 +42,16 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void { const device = registry.addDevice(`Mobile ${new Date().toLocaleDateString()}`) - // Why: certFingerprint is 'sha256:pending' when TLS is disabled (Phase 1 - // will add cert pinning on mobile). The mobile app stores it but doesn't - // enforce it until TLS is re-enabled. - const fingerprint = rpcServer.getTlsFingerprint() ?? 'sha256:pending' + const publicKeyB64 = rpcServer.getE2EEPublicKey() + if (!publicKeyB64) { + return { available: false as const } + } const url = encodePairingOffer({ v: PAIRING_OFFER_VERSION, endpoint, deviceToken: device.token, - certFingerprint: fingerprint + publicKeyB64 }) const qrDataUrl = await QRCode.toDataURL(url, { diff --git a/src/main/runtime/e2ee-keypair.ts b/src/main/runtime/e2ee-keypair.ts new file mode 100644 index 00000000000..de0373ebafe --- /dev/null +++ b/src/main/runtime/e2ee-keypair.ts @@ -0,0 +1,50 @@ +// Why: the E2EE keypair enables application-layer encryption between mobile +// and desktop over plain ws://. The public key is embedded in the QR pairing +// offer so the mobile client can derive a shared secret via ECDH. +import { existsSync, readFileSync, writeFileSync, chmodSync } from 'fs' +import { join } from 'path' +import nacl from 'tweetnacl' + +const KEYPAIR_FILENAME = 'orca-e2ee-keypair.json' +const KEYPAIR_VERSION = 1 + +type KeypairFile = { + v: number + publicKeyB64: string + secretKeyB64: string +} + +export type E2EEKeypair = { + publicKey: Uint8Array + secretKey: Uint8Array + publicKeyB64: string +} + +export function loadOrCreateE2EEKeypair(userDataPath: string): E2EEKeypair { + const filePath = join(userDataPath, KEYPAIR_FILENAME) + + if (existsSync(filePath)) { + try { + const raw: KeypairFile = JSON.parse(readFileSync(filePath, 'utf-8')) + if (raw.v === KEYPAIR_VERSION && raw.publicKeyB64 && raw.secretKeyB64) { + const publicKey = Uint8Array.from(Buffer.from(raw.publicKeyB64, 'base64')) + const secretKey = Uint8Array.from(Buffer.from(raw.secretKeyB64, 'base64')) + if (publicKey.length === 32 && secretKey.length === 32) { + return { publicKey, secretKey, publicKeyB64: raw.publicKeyB64 } + } + } + } catch { + // Malformed file — regenerate below. + } + } + + const keypair = nacl.box.keyPair() + const publicKeyB64 = Buffer.from(keypair.publicKey).toString('base64') + const secretKeyB64 = Buffer.from(keypair.secretKey).toString('base64') + + const data: KeypairFile = { v: KEYPAIR_VERSION, publicKeyB64, secretKeyB64 } + writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8') + chmodSync(filePath, 0o600) + + return { publicKey: keypair.publicKey, secretKey: keypair.secretKey, publicKeyB64 } +} diff --git a/src/main/runtime/rpc/e2ee-channel.test.ts b/src/main/runtime/rpc/e2ee-channel.test.ts new file mode 100644 index 00000000000..7c49ee8594a --- /dev/null +++ b/src/main/runtime/rpc/e2ee-channel.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import type { WebSocket } from 'ws' +import { E2EEChannel, type E2EEChannelOptions } from './e2ee-channel' +import { generateKeyPair, deriveSharedKey, encrypt, decrypt } from './e2ee-crypto' + +function publicKeyToBase64(key: Uint8Array): string { + return Buffer.from(key).toString('base64') +} + +function createMockWs() { + const sent: string[] = [] + return { + OPEN: 1 as const, + readyState: 1, + send: vi.fn((data: string) => sent.push(data)), + close: vi.fn(), + sent + } +} + +function setup(overrides?: Partial) { + const serverKeys = generateKeyPair() + const clientKeys = generateKeyPair() + const ws = createMockWs() + const onReady = vi.fn() + const onError = vi.fn() + + const channel = new E2EEChannel(ws as unknown as WebSocket, { + serverSecretKey: serverKeys.secretKey, + validateToken: (token) => token === 'valid-token', + onReady, + onError, + ...overrides + }) + + return { channel, ws, serverKeys, clientKeys, onReady, onError } +} + +function doHandshake(ctx: ReturnType) { + const hello = JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(ctx.clientKeys.publicKey), + deviceToken: 'valid-token' + }) + ctx.channel.handleRawMessage(hello) + return deriveSharedKey(ctx.clientKeys.secretKey, ctx.serverKeys.publicKey) +} + +describe('E2EEChannel', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('handshake', () => { + it('completes handshake with valid hello', () => { + const ctx = setup() + doHandshake(ctx) + + expect(ctx.onReady).toHaveBeenCalledWith(ctx.channel) + expect(ctx.onError).not.toHaveBeenCalled() + expect(ctx.channel.deviceToken).toBe('valid-token') + + const readyMsg = JSON.parse(ctx.ws.sent[0]!) + expect(readyMsg).toEqual({ type: 'e2ee_ready' }) + }) + + it('rejects invalid token', () => { + const ctx = setup() + ctx.channel.handleRawMessage( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(ctx.clientKeys.publicKey), + deviceToken: 'bad-token' + }) + ) + + expect(ctx.onError).toHaveBeenCalledWith(4001, 'Unauthorized') + expect(ctx.onReady).not.toHaveBeenCalled() + }) + + it('rejects malformed JSON', () => { + const ctx = setup() + ctx.channel.handleRawMessage('not json') + + expect(ctx.onError).toHaveBeenCalledWith(4001, 'Invalid handshake message') + }) + + it('rejects missing fields', () => { + const ctx = setup() + ctx.channel.handleRawMessage(JSON.stringify({ type: 'e2ee_hello' })) + + expect(ctx.onError).toHaveBeenCalledWith(4001, 'Invalid e2ee_hello') + }) + + it('rejects invalid public key length', () => { + const ctx = setup() + ctx.channel.handleRawMessage( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: Buffer.from('short').toString('base64'), + deviceToken: 'valid-token' + }) + ) + + expect(ctx.onError).toHaveBeenCalledWith(4001, 'Invalid public key') + }) + + it('times out if no hello received', () => { + const ctx = setup() + + vi.advanceTimersByTime(10_001) + + expect(ctx.onError).toHaveBeenCalledWith(4002, 'E2EE handshake timeout') + }) + + it('clears timeout after successful handshake', () => { + const ctx = setup() + doHandshake(ctx) + + vi.advanceTimersByTime(10_001) + + expect(ctx.onError).not.toHaveBeenCalled() + }) + }) + + describe('post-handshake messaging', () => { + it('decrypts and forwards messages', () => { + const ctx = setup() + const sharedKey = doHandshake(ctx) + const received: string[] = [] + + ctx.channel.onMessage((plaintext) => { + received.push(plaintext) + }) + + const request = '{"id":"rpc-1","method":"status.get"}' + ctx.channel.handleRawMessage(encrypt(request, sharedKey)) + + expect(received).toEqual([request]) + }) + + it('provides encrypted reply function', () => { + const ctx = setup() + const sharedKey = doHandshake(ctx) + + ctx.channel.onMessage((_plaintext, encryptedReply) => { + encryptedReply('{"id":"rpc-1","ok":true}') + }) + + ctx.channel.handleRawMessage(encrypt('{"id":"rpc-1","method":"status.get"}', sharedKey)) + + // The reply (ws.sent[1], after e2ee_ready) should be encrypted + const replyEncrypted = ctx.ws.sent[1]! + const replyPlain = decrypt(replyEncrypted, sharedKey) + expect(replyPlain).toBe('{"id":"rpc-1","ok":true}') + }) + + it('silently drops messages with wrong key', () => { + const ctx = setup() + doHandshake(ctx) + const received: string[] = [] + + ctx.channel.onMessage((plaintext) => { + received.push(plaintext) + }) + + const attackerKey = deriveSharedKey(generateKeyPair().secretKey, generateKeyPair().publicKey) + ctx.channel.handleRawMessage(encrypt('attack', attackerKey)) + + expect(received).toEqual([]) + }) + + it('closes after too many consecutive decrypt failures', () => { + const ctx = setup() + doHandshake(ctx) + ctx.channel.onMessage(() => {}) + + const badKey = deriveSharedKey(generateKeyPair().secretKey, generateKeyPair().publicKey) + + for (let i = 0; i < 4; i++) { + ctx.channel.handleRawMessage(encrypt('bad', badKey)) + } + expect(ctx.onError).not.toHaveBeenCalled() + + ctx.channel.handleRawMessage(encrypt('bad', badKey)) + expect(ctx.onError).toHaveBeenCalledWith(4003, 'Too many decryption failures') + }) + + it('resets failure count on successful decrypt', () => { + const ctx = setup() + const sharedKey = doHandshake(ctx) + ctx.channel.onMessage(() => {}) + + const badKey = deriveSharedKey(generateKeyPair().secretKey, generateKeyPair().publicKey) + + for (let i = 0; i < 4; i++) { + ctx.channel.handleRawMessage(encrypt('bad', badKey)) + } + + // Successful decrypt resets the counter + ctx.channel.handleRawMessage(encrypt('good', sharedKey)) + + for (let i = 0; i < 4; i++) { + ctx.channel.handleRawMessage(encrypt('bad', badKey)) + } + expect(ctx.onError).not.toHaveBeenCalled() + }) + }) + + describe('cross-compatibility', () => { + it('desktop encrypt is decryptable by desktop decrypt (sanity)', () => { + const a = generateKeyPair() + const b = generateKeyPair() + const sharedA = deriveSharedKey(a.secretKey, b.publicKey) + const sharedB = deriveSharedKey(b.secretKey, a.publicKey) + + const msg = '{"method":"terminal.subscribe","params":{"terminal":"t1"}}' + const enc = encrypt(msg, sharedA) + expect(decrypt(enc, sharedB)).toBe(msg) + }) + }) + + describe('destroy', () => { + it('clears state and stops forwarding', () => { + const ctx = setup() + const sharedKey = doHandshake(ctx) + const received: string[] = [] + + ctx.channel.onMessage((plaintext) => { + received.push(plaintext) + }) + + ctx.channel.destroy() + ctx.channel.handleRawMessage(encrypt('after destroy', sharedKey)) + + expect(received).toEqual([]) + }) + }) +}) diff --git a/src/main/runtime/rpc/e2ee-channel.ts b/src/main/runtime/rpc/e2ee-channel.ts new file mode 100644 index 00000000000..760629e225c --- /dev/null +++ b/src/main/runtime/rpc/e2ee-channel.ts @@ -0,0 +1,145 @@ +// Why: the E2EE channel sits between the WebSocket transport and the RPC handler. +// It owns the handshake state machine and transparent encrypt/decrypt so the RPC +// handler only sees plaintext JSON, identical to the Unix socket path. +import type { WebSocket } from 'ws' +import { deriveSharedKey, encrypt, decrypt } from './e2ee-crypto' + +type ChannelState = 'awaiting_hello' | 'ready' + +const HANDSHAKE_TIMEOUT_MS = 10_000 +const MAX_CONSECUTIVE_DECRYPT_FAILURES = 5 + +type E2EEHello = { + type: 'e2ee_hello' + publicKeyB64: string + deviceToken: string +} + +export type E2EEChannelOptions = { + serverSecretKey: Uint8Array + validateToken: (token: string) => boolean + onReady: (channel: E2EEChannel) => void + onError: (code: number, reason: string) => void +} + +export class E2EEChannel { + private state: ChannelState = 'awaiting_hello' + private sharedKey: Uint8Array | null = null + private consecutiveFailures = 0 + 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 onError: (code: number, reason: string) => void + // 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. + private messageHandler: + | ((plaintext: string, encryptedReply: (response: string) => void) => void) + | null = null + + deviceToken: string | null = null + + constructor(ws: WebSocket, options: E2EEChannelOptions) { + this.ws = ws + this.serverSecretKey = options.serverSecretKey + this.validateToken = options.validateToken + this.onReady = options.onReady + this.onError = options.onError + + this.handshakeTimer = setTimeout(() => { + this.onError(4002, 'E2EE handshake timeout') + }, HANDSHAKE_TIMEOUT_MS) + } + + onMessage( + handler: (plaintext: string, encryptedReply: (response: string) => void) => void + ): void { + this.messageHandler = handler + } + + handleRawMessage(raw: string): void { + if (this.state === 'awaiting_hello') { + this.handleHello(raw) + return + } + + if (!this.sharedKey) { + return + } + + const plaintext = decrypt(raw, this.sharedKey) + if (plaintext === null) { + this.consecutiveFailures++ + if (this.consecutiveFailures >= MAX_CONSECUTIVE_DECRYPT_FAILURES) { + this.onError(4003, 'Too many decryption failures') + } + return + } + + this.consecutiveFailures = 0 + const encryptedReply = (response: string) => { + if (this.ws.readyState === this.ws.OPEN) { + this.ws.send(encrypt(response, this.sharedKey!)) + } + } + this.messageHandler?.(plaintext, encryptedReply) + } + + private handleHello(raw: string): void { + let hello: E2EEHello + try { + hello = JSON.parse(raw) as E2EEHello + } catch { + this.onError(4001, 'Invalid handshake message') + return + } + + if (hello.type !== 'e2ee_hello' || !hello.publicKeyB64 || !hello.deviceToken) { + this.onError(4001, 'Invalid e2ee_hello') + return + } + + if (!this.validateToken(hello.deviceToken)) { + this.onError(4001, 'Unauthorized') + return + } + + this.deviceToken = hello.deviceToken + + // Why: derive the shared key from our secret + client's public key. + // Both sides compute the same shared secret via ECDH. + const clientPublicKey = Uint8Array.from(Buffer.from(hello.publicKeyB64, 'base64')) + if (clientPublicKey.length !== 32) { + this.onError(4001, 'Invalid public key') + return + } + + this.sharedKey = deriveSharedKey(this.serverSecretKey, clientPublicKey) + this.state = 'ready' + + if (this.handshakeTimer) { + clearTimeout(this.handshakeTimer) + this.handshakeTimer = null + } + + // Why: send e2ee_ready as plaintext — the client needs it to know the + // handshake succeeded before it starts encrypting. The first actual + // encrypted message from the server will implicitly prove key possession. + if (this.ws.readyState === this.ws.OPEN) { + this.ws.send(JSON.stringify({ type: 'e2ee_ready' })) + } + + this.onReady(this) + } + + destroy(): void { + if (this.handshakeTimer) { + clearTimeout(this.handshakeTimer) + this.handshakeTimer = null + } + this.sharedKey = null + this.messageHandler = null + } +} diff --git a/src/main/runtime/rpc/e2ee-crypto.test.ts b/src/main/runtime/rpc/e2ee-crypto.test.ts new file mode 100644 index 00000000000..09e546db63c --- /dev/null +++ b/src/main/runtime/rpc/e2ee-crypto.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { generateKeyPair, deriveSharedKey, encrypt, decrypt } from './e2ee-crypto' + +describe('e2ee-crypto', () => { + it('encrypt/decrypt round-trips with shared key', () => { + const server = generateKeyPair() + const client = generateKeyPair() + + const serverShared = deriveSharedKey(server.secretKey, client.publicKey) + const clientShared = deriveSharedKey(client.secretKey, server.publicKey) + + const message = '{"id":"rpc-1","method":"status.get"}' + const encrypted = encrypt(message, clientShared) + const decrypted = decrypt(encrypted, serverShared) + + expect(decrypted).toBe(message) + }) + + it('decrypt returns null with wrong key', () => { + const server = generateKeyPair() + const client = generateKeyPair() + const attacker = generateKeyPair() + + const clientShared = deriveSharedKey(client.secretKey, server.publicKey) + const attackerShared = deriveSharedKey(attacker.secretKey, server.publicKey) + + const encrypted = encrypt('secret data', clientShared) + expect(decrypt(encrypted, attackerShared)).toBeNull() + }) + + it('each encryption produces unique ciphertext (random nonce)', () => { + const server = generateKeyPair() + const client = generateKeyPair() + const shared = deriveSharedKey(client.secretKey, server.publicKey) + + const message = 'same message' + const a = encrypt(message, shared) + const b = encrypt(message, shared) + + expect(a).not.toBe(b) + }) + + it('handles empty string', () => { + const server = generateKeyPair() + const client = generateKeyPair() + const shared = deriveSharedKey(client.secretKey, server.publicKey) + + const encrypted = encrypt('', shared) + expect(decrypt(encrypted, shared)).toBe('') + }) + + it('handles unicode content', () => { + const server = generateKeyPair() + const client = generateKeyPair() + const shared = deriveSharedKey(client.secretKey, server.publicKey) + + const message = '日本語テスト 🎉 émojis' + const encrypted = encrypt(message, shared) + expect(decrypt(encrypted, shared)).toBe(message) + }) + + it('decrypt returns null for truncated data', () => { + const shared = deriveSharedKey(generateKeyPair().secretKey, generateKeyPair().publicKey) + expect(decrypt('dG9vc2hvcnQ=', shared)).toBeNull() + }) + + it('decrypt returns null for tampered data', () => { + const server = generateKeyPair() + const client = generateKeyPair() + const shared = deriveSharedKey(client.secretKey, server.publicKey) + + const encrypted = encrypt('hello', shared) + const bytes = Buffer.from(encrypted, 'base64') + bytes[bytes.length - 1] ^= 0xff + const tampered = bytes.toString('base64') + + expect(decrypt(tampered, shared)).toBeNull() + }) +}) diff --git a/src/main/runtime/rpc/e2ee-crypto.ts b/src/main/runtime/rpc/e2ee-crypto.ts new file mode 100644 index 00000000000..39adec27160 --- /dev/null +++ b/src/main/runtime/rpc/e2ee-crypto.ts @@ -0,0 +1,41 @@ +// Why: shared E2EE primitives for the desktop side. Wraps tweetnacl to provide +// encrypt/decrypt with the NaCl box format: [24-byte nonce][ciphertext], encoded +// as base64 for transmission over WebSocket text frames. +import nacl from 'tweetnacl' + +export function generateKeyPair(): nacl.BoxKeyPair { + return nacl.box.keyPair() +} + +export function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey: Uint8Array): Uint8Array { + return nacl.box.before(peerPublicKey, ourSecretKey) +} + +export function encrypt(plaintext: string, sharedKey: Uint8Array): string { + const nonce = nacl.randomBytes(nacl.box.nonceLength) + const messageBytes = new TextEncoder().encode(plaintext) + const ciphertext = nacl.box.after(messageBytes, nonce, sharedKey) + + const bundle = new Uint8Array(nonce.length + ciphertext.length) + bundle.set(nonce) + bundle.set(ciphertext, nonce.length) + + return Buffer.from(bundle).toString('base64') +} + +export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null { + const bundle = Uint8Array.from(Buffer.from(encrypted, 'base64')) + if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) { + return null + } + + const nonce = bundle.slice(0, nacl.box.nonceLength) + const ciphertext = bundle.slice(nacl.box.nonceLength) + const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey) + + if (!plaintext) { + return null + } + + return new TextDecoder().decode(plaintext) +} diff --git a/src/main/runtime/rpc/e2ee-integration.test.ts b/src/main/runtime/rpc/e2ee-integration.test.ts new file mode 100644 index 00000000000..2268ee3d314 --- /dev/null +++ b/src/main/runtime/rpc/e2ee-integration.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import type { WebSocket } from 'ws' +import { E2EEChannel } from './e2ee-channel' +import { generateKeyPair, deriveSharedKey, encrypt, decrypt } from './e2ee-crypto' + +// Why: this test simulates the full mobile → desktop E2EE flow without a real +// WebSocket. The "mobile" side generates an ephemeral keypair, sends e2ee_hello, +// the E2EEChannel (desktop) derives the shared key, and then both sides can +// exchange encrypted RPC messages. This validates that the handshake protocol +// and crypto are end-to-end compatible. + +function publicKeyToBase64(key: Uint8Array): string { + return Buffer.from(key).toString('base64') +} + +describe('E2EE integration (simulated mobile ↔ desktop)', () => { + let serverKeys: ReturnType + let mobileEphemeralKeys: ReturnType + let wsSent: string[] + let mockWs: { + OPEN: 1 + readyState: number + send: ReturnType + close: ReturnType + } + let channel: E2EEChannel + let onReady: ReturnType + let onError: ReturnType + + beforeEach(() => { + vi.useFakeTimers() + + serverKeys = generateKeyPair() + mobileEphemeralKeys = generateKeyPair() + wsSent = [] + mockWs = { + OPEN: 1, + readyState: 1, + send: vi.fn((data: string) => wsSent.push(data)), + close: vi.fn() + } + onReady = vi.fn() + onError = vi.fn() + + channel = new E2EEChannel(mockWs as unknown as WebSocket, { + serverSecretKey: serverKeys.secretKey, + validateToken: (token) => token === 'device-abc', + onReady, + onError + }) + }) + + afterEach(() => { + channel.destroy() + vi.useRealTimers() + }) + + it('full handshake → encrypted RPC round-trip', () => { + // Mobile sends e2ee_hello (plaintext) + const hello = JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(mobileEphemeralKeys.publicKey), + deviceToken: 'device-abc' + }) + channel.handleRawMessage(hello) + + // Desktop should respond with e2ee_ready + expect(onReady).toHaveBeenCalled() + expect(wsSent).toHaveLength(1) + expect(JSON.parse(wsSent[0]!)).toEqual({ type: 'e2ee_ready' }) + + // Both sides derive the same shared key + const mobileShared = deriveSharedKey(mobileEphemeralKeys.secretKey, serverKeys.publicKey) + + // Set up the desktop message handler + const desktopReceived: string[] = [] + channel.onMessage((plaintext, encryptedReply) => { + desktopReceived.push(plaintext) + encryptedReply(JSON.stringify({ id: 'rpc-1', ok: true, result: { status: 'ready' } })) + }) + + // Mobile sends encrypted RPC request + const request = JSON.stringify({ id: 'rpc-1', method: 'status.get' }) + channel.handleRawMessage(encrypt(request, mobileShared)) + + // Desktop received the plaintext + expect(desktopReceived).toEqual([request]) + + // Desktop's encrypted reply (wsSent[1]) is decryptable by mobile + expect(wsSent).toHaveLength(2) + const replyPlain = decrypt(wsSent[1]!, mobileShared) + expect(JSON.parse(replyPlain!)).toEqual({ + id: 'rpc-1', + ok: true, + result: { status: 'ready' } + }) + }) + + it('mobile reconnects with fresh ephemeral key', () => { + // First connection + channel.handleRawMessage( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(mobileEphemeralKeys.publicKey), + deviceToken: 'device-abc' + }) + ) + expect(onReady).toHaveBeenCalledTimes(1) + + const firstShared = deriveSharedKey(mobileEphemeralKeys.secretKey, serverKeys.publicKey) + + // Simulate disconnect + reconnect with new ephemeral key + channel.destroy() + wsSent.length = 0 + + const newMobileKeys = generateKeyPair() + const newChannel = new E2EEChannel(mockWs as unknown as WebSocket, { + serverSecretKey: serverKeys.secretKey, + validateToken: (token) => token === 'device-abc', + onReady, + onError + }) + + newChannel.handleRawMessage( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(newMobileKeys.publicKey), + deviceToken: 'device-abc' + }) + ) + + expect(onReady).toHaveBeenCalledTimes(2) + + const secondShared = deriveSharedKey(newMobileKeys.secretKey, serverKeys.publicKey) + + // Keys from different sessions must not be interchangeable + expect(Buffer.from(firstShared).toString('hex')).not.toBe( + Buffer.from(secondShared).toString('hex') + ) + + // Verify new session works + const received: string[] = [] + newChannel.onMessage((plaintext) => received.push(plaintext)) + newChannel.handleRawMessage(encrypt('{"id":"2","method":"test"}', secondShared)) + expect(received).toEqual(['{"id":"2","method":"test"}']) + + newChannel.destroy() + }) + + it('streaming messages work through E2EE', () => { + channel.handleRawMessage( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(mobileEphemeralKeys.publicKey), + deviceToken: 'device-abc' + }) + ) + + const mobileShared = deriveSharedKey(mobileEphemeralKeys.secretKey, serverKeys.publicKey) + const received: string[] = [] + + channel.onMessage((plaintext, encryptedReply) => { + received.push(plaintext) + // Simulate streaming: send multiple encrypted responses + for (let i = 0; i < 3; i++) { + encryptedReply( + JSON.stringify({ + id: 'stream-1', + ok: true, + streaming: true, + result: { type: 'data', chunk: `line ${i}\n` } + }) + ) + } + }) + + channel.handleRawMessage( + encrypt(JSON.stringify({ id: 'stream-1', method: 'terminal.subscribe' }), mobileShared) + ) + + // 1 e2ee_ready + 3 streaming responses + expect(wsSent).toHaveLength(4) + + for (let i = 1; i < 4; i++) { + const plain = decrypt(wsSent[i]!, mobileShared) + const parsed = JSON.parse(plain!) + expect(parsed.streaming).toBe(true) + expect(parsed.result.chunk).toBe(`line ${i - 1}\n`) + } + }) +}) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index db05fd00455..07415a07862 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -16,6 +16,8 @@ import { UnixSocketTransport } from './rpc/unix-socket-transport' import { WebSocketTransport } from './rpc/ws-transport' import type { WebSocket } from 'ws' import { DeviceRegistry } from './device-registry' +import { loadOrCreateE2EEKeypair, type E2EEKeypair } from './e2ee-keypair' +import { E2EEChannel } from './rpc/e2ee-channel' const DEFAULT_WS_PORT = 6768 @@ -38,9 +40,13 @@ export class OrcaRuntimeRpcServer { private readonly wsPort: number private readonly authToken = randomBytes(24).toString('hex') private deviceRegistry: DeviceRegistry | null = null + private e2eeKeypair: E2EEKeypair | null = null private tlsFingerprint: string | 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() constructor({ runtime, @@ -67,6 +73,14 @@ export class OrcaRuntimeRpcServer { return this.tlsFingerprint } + getE2EEPublicKey(): string | null { + return this.e2eeKeypair?.publicKeyB64 ?? null + } + + getE2EEKeypair(): E2EEKeypair | null { + return this.e2eeKeypair + } + getWebSocketEndpoint(): string | null { const ws = this.transports.find((t) => t.kind === 'websocket') return ws?.endpoint ?? null @@ -104,32 +118,57 @@ export class OrcaRuntimeRpcServer { const transportsMeta: RuntimeTransportMetadata[] = [transportMeta] // Why: WebSocket transport is opt-in and starts alongside the Unix socket. - // It uses TLS with a self-signed cert and per-device tokens from the - // device registry, not the shared runtime auth token. + // It uses per-device tokens and E2EE (application-layer encryption via + // tweetnacl) rather than TLS, since React Native can't pin self-signed certs. if (this.enableWebSocket) { try { this.deviceRegistry = new DeviceRegistry(this.userDataPath) + this.e2eeKeypair = loadOrCreateE2EEKeypair(this.userDataPath) - // Why: React Native's built-in WebSocket cannot disable TLS verification - // for self-signed certificates. Use plain ws:// for now — per-device - // tokens still provide auth. TLS will be re-enabled once the mobile app - // implements certificate pinning via the fingerprint from QR pairing. const wsTransport = new WebSocketTransport({ host: '0.0.0.0', port: this.wsPort }) - // Why: WebSocket transport uses dispatchStreaming which can emit - // multiple responses for subscription methods. Auth validation - // happens before dispatch. - wsTransport.onMessage((msg, reply, ws) => { - void this.handleWebSocketMessage(msg, reply, wsTransport, ws) + // 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) { + 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) + } + }, + onError: (code, reason) => { + this.e2eeChannels.get(ws)?.destroy() + this.e2eeChannels.delete(ws) + ws.close(code, reason) + } + }) + channel.onMessage((plaintext, encryptedReply) => { + void this.handleWebSocketMessage(plaintext, encryptedReply, wsTransport, 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 to prevent - // orphaned phone-fit state on the desktop terminal. + // connection-scoped state like mobile-fit overrides and the E2EE + // channel to prevent orphaned state. wsTransport.onConnectionClose((clientId) => { + for (const [ws, channel] of this.e2eeChannels) { + if (channel.deviceToken === clientId) { + channel.destroy() + this.e2eeChannels.delete(ws) + break + } + } this.runtime.onClientDisconnected(clientId) }) diff --git a/src/shared/pairing.test.ts b/src/shared/pairing.test.ts index 7670943c701..de0738b5907 100644 --- a/src/shared/pairing.test.ts +++ b/src/shared/pairing.test.ts @@ -3,10 +3,10 @@ import { encodePairingOffer, decodePairingOffer, type PairingOffer } from './pai describe('pairing offer', () => { const offer: PairingOffer = { - v: 1, - endpoint: 'wss://192.168.1.10:6768', + v: 2, + endpoint: 'ws://192.168.1.10:6768', deviceToken: 'abcdef1234567890abcdef1234567890abcdef1234567890', - certFingerprint: 'sha256:deadbeef' + publicKeyB64: 'dGVzdC1wdWJsaWMta2V5LWJhc2U2NC1lbmNvZGVk' } it('encode then decode round-trips correctly', () => { @@ -32,19 +32,19 @@ describe('pairing offer', () => { }) it('rejects payloads with missing fields', () => { - const partial = { v: 1, endpoint: 'wss://host:1234' } + const partial = { v: 2, endpoint: 'ws://host:1234' } const base64 = Buffer.from(JSON.stringify(partial)).toString('base64') expect(() => decodePairingOffer(`orca://pair#${base64}`)).toThrow() }) it('rejects payloads with wrong version', () => { - const wrong = { ...offer, v: 2 } + const wrong = { ...offer, v: 1 } const base64 = Buffer.from(JSON.stringify(wrong)).toString('base64') expect(() => decodePairingOffer(`orca://pair#${base64}`)).toThrow() }) - it('rejects payloads with invalid fingerprint prefix', () => { - const wrong = { ...offer, certFingerprint: 'md5:abc' } + it('rejects payloads with missing publicKeyB64', () => { + const wrong = { v: 2, endpoint: 'ws://host:1234', deviceToken: 'tok' } const base64 = Buffer.from(JSON.stringify(wrong)).toString('base64') expect(() => decodePairingOffer(`orca://pair#${base64}`)).toThrow() }) diff --git a/src/shared/pairing.ts b/src/shared/pairing.ts index 17f5a706312..5f8d4afc3b4 100644 --- a/src/shared/pairing.ts +++ b/src/shared/pairing.ts @@ -1,15 +1,17 @@ import { z } from 'zod' -export const PAIRING_OFFER_VERSION = 1 +export const PAIRING_OFFER_VERSION = 2 -export const PairingOfferV1 = z.object({ +export const PairingOfferSchema = z.object({ v: z.literal(PAIRING_OFFER_VERSION), endpoint: z.string().min(1), deviceToken: z.string().min(1), - certFingerprint: z.string().startsWith('sha256:') + // 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) }) -export type PairingOffer = z.infer +export type PairingOffer = z.infer export function encodePairingOffer(offer: PairingOffer): string { const json = JSON.stringify(offer) @@ -29,5 +31,5 @@ export function decodePairingOffer(url: string): PairingOffer { const base64url = url.slice(hashIndex + 1) const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') const json = Buffer.from(base64, 'base64').toString('utf-8') - return PairingOfferV1.parse(JSON.parse(json)) + return PairingOfferSchema.parse(JSON.parse(json)) }