Add end-to-end encryption for mobile WebSocket transport

Replace the disabled TLS cert pinning approach with application-layer
E2EE using tweetnacl (Curve25519 ECDH + XSalsa20-Poly1305). The
WebSocket stays plain ws:// but all RPC traffic is encrypted after
a one-round-trip handshake.

Desktop: generate persistent Curve25519 keypair, E2EE channel module
handles handshake state machine and transparent encrypt/decrypt.
Mobile: ephemeral keypair per connection for forward secrecy, expo-crypto
PRNG polyfill for Hermes compatibility.

Pairing schema updated from v1 (certFingerprint) to v2 (publicKeyB64).
Mock server updated with E2EE support. 24 tests covering crypto,
channel handshake, and integration.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-04-30 18:03:24 -04:00
co-authored by Orca
parent cd340227ad
commit fc75b28f7d
25 changed files with 1191 additions and 201 deletions
+2 -1
View File
@@ -57,6 +57,7 @@ type GroupMode = 'none' | 'repo' | 'prStatus'
const STATUS_LABELS: Record<ConnectionState, string> = {
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)
+12 -11
View File
@@ -58,6 +58,7 @@ const persistedFittedHandles = new Map<string, Set<string>>()
const STATUS_LABELS: Record<ConnectionState, string> = {
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?.()
+5 -75
View File
@@ -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 ? (
<View style={styles.emptyContent}>
<View style={styles.emptyBeacon}>
<View style={styles.beaconRingOuter} />
<View style={styles.beaconRingInner} />
<View style={styles.beaconCore}>
<QrCode size={34} color={colors.textPrimary} strokeWidth={1.8} />
</View>
</View>
<Text style={styles.emptyTitle}>Pair your first desktop</Text>
<Text style={styles.emptySubtitle}>
Scan the QR code from Orca desktop to see live worktrees, terminals, and agent output
from your phone.
</Text>
<Pressable style={styles.primaryButton} onPress={() => router.push('/pair-scan')}>
<QrCode size={17} color="#fff" />
<QrCode size={17} color={colors.bgBase} />
<Text style={styles.primaryButtonText}>Scan Pairing Code</Text>
</Pressable>
<View style={styles.emptyChecklist}>
<View style={styles.checkItem}>
<ShieldCheck size={15} color={colors.statusGreen} />
<Text style={styles.checkText}>Token-based local connection</Text>
</View>
<View style={styles.checkItem}>
<Sparkles size={15} color={colors.statusAmber} />
<Text style={styles.checkText}>Jump back into active worktrees</Text>
</View>
</View>
</View>
) : (
<FlatList
@@ -266,40 +249,6 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.xl,
paddingBottom: spacing.xl
},
emptyBeacon: {
width: 168,
height: 168,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.xl
},
beaconRingOuter: {
position: 'absolute',
width: 168,
height: 168,
borderRadius: 84,
borderWidth: 1,
borderColor: colors.borderSubtle
},
beaconRingInner: {
position: 'absolute',
width: 112,
height: 112,
borderRadius: 56,
borderWidth: 1,
borderColor: colors.bgRaised,
backgroundColor: colors.bgPanel
},
beaconCore: {
width: 72,
height: 72,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgRaised,
borderWidth: 1,
borderColor: colors.borderSubtle
},
emptyTitle: {
fontSize: 23,
fontWeight: '700',
@@ -318,35 +267,16 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
backgroundColor: colors.accentBlue,
backgroundColor: colors.textPrimary,
paddingHorizontal: spacing.xl,
paddingVertical: spacing.md,
borderRadius: radii.button
},
primaryButtonText: {
color: '#fff',
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '700'
},
emptyChecklist: {
alignSelf: 'stretch',
marginTop: spacing.xl,
padding: spacing.md,
borderRadius: radii.row,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
gap: spacing.sm
},
checkItem: {
flexDirection: 'row',
alignItems: 'center'
},
checkText: {
color: colors.textSecondary,
fontSize: 13,
marginLeft: spacing.sm
},
list: {
paddingHorizontal: spacing.lg,
paddingBottom: spacing.xl
+4 -4
View File
@@ -36,7 +36,7 @@ export default function PairScanScreen() {
async function testAndSave(offer: PairingOffer) {
setStatus('connecting')
const client = connect(offer.endpoint, offer.deviceToken)
const client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64)
try {
const response = await client.sendRequest('status.get')
@@ -63,7 +63,7 @@ export default function PairScanScreen() {
name: hostName,
endpoint: offer.endpoint,
deviceToken: offer.deviceToken,
certFingerprint: offer.certFingerprint,
publicKeyB64: offer.publicKeyB64,
lastConnected: Date.now()
})
@@ -187,13 +187,13 @@ const styles = StyleSheet.create({
lineHeight: 20
},
primaryButton: {
backgroundColor: colors.accentBlue,
backgroundColor: colors.textPrimary,
paddingHorizontal: spacing.xl,
paddingVertical: spacing.sm + 2,
borderRadius: radii.button
},
primaryButtonText: {
color: '#fff',
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '600'
}
+2
View File
@@ -16,6 +16,7 @@
"expo": "^55.0.17",
"expo-camera": "^55.0.16",
"expo-constants": "^55.0.15",
"expo-crypto": "^55.0.14",
"expo-linking": "^55.0.14",
"expo-router": "^55.0.13",
"expo-status-bar": "^55.0.5",
@@ -30,6 +31,7 @@
"react-native-web": "^0.21.2",
"react-native-webview": "^13.16.1",
"react-native-worklets": "^0.7.4",
"tweetnacl": "^1.0.3",
"ws": "^8.18.0",
"zod": "^4.3.6",
"zustand": "^5.0.12"
+20
View File
@@ -20,6 +20,9 @@ importers:
expo-constants:
specifier: ^55.0.15
version: 55.0.15(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))
expo-crypto:
specifier: ^55.0.14
version: 55.0.14(expo@55.0.17)
expo-linking:
specifier: ^55.0.14
version: 55.0.14(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))(react@19.2.0)
@@ -62,6 +65,9 @@ importers:
react-native-worklets:
specifier: ^0.7.4
version: 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)
tweetnacl:
specifier: ^1.0.3
version: 1.0.3
ws:
specifier: ^8.18.0
version: 8.20.0
@@ -3090,6 +3096,11 @@ packages:
expo: '*'
react-native: '*'
expo-crypto@55.0.14:
resolution: {integrity: sha512-TfAADBGZNNv9OOmdKFJCz54wDj87ufxtzQNSY+Roycpm8e5tuCnDIL7EjqUOmNTGH99Jj8ftPGFt4KGG2Ii2fg==}
peerDependencies:
expo: '*'
expo-file-system@55.0.17:
resolution: {integrity: sha512-d27K1cagUOt2BwxwPka9KW8Znu5kN1tnairozCzzCRZviZFtWnBxwFuJ3KU6MAbav/9UhSMkp5Ve/oZ+SR0UgQ==}
peerDependencies:
@@ -5307,6 +5318,9 @@ packages:
engines: {node: '>=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
+141 -55
View File
@@ -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<WebSocket, E2EEState>()
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`)
+1
View File
@@ -5,6 +5,7 @@ import type { ConnectionState } from '../transport/types'
const stateColors: Record<ConnectionState, string> = {
connected: colors.statusGreen,
connecting: colors.statusAmber,
handshaking: colors.statusAmber,
reconnecting: colors.statusAmber,
disconnected: colors.statusRed,
'auth-failed': colors.statusRed
+77
View File
@@ -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)
}
+1 -1
View File
@@ -16,4 +16,4 @@ export type {
HostProfile,
PairingOffer
} from './types'
export { PairingOfferV1, PAIRING_OFFER_VERSION } from './types'
export { PairingOfferSchema, PAIRING_OFFER_VERSION } from './types'
+2 -2
View File
@@ -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
}
+89 -17
View File
@@ -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<typeof setTimeout> | null = null
let handshakeTimer: ReturnType<typeof setTimeout> | 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<string, PendingRequest>()
const streamListeners = new Map<string, StreamRequest>()
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<string, unknown> | 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')
}
+6 -5
View File
@@ -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<typeof PairingOfferV1>
export type PairingOffer = z.infer<typeof PairingOfferSchema>
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
}
+1
View File
@@ -108,6 +108,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"
+8
View File
@@ -217,6 +217,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
@@ -6064,6 +6067,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'}
@@ -12761,6 +12767,8 @@ snapshots:
tweetnacl@0.14.5: {}
tweetnacl@1.0.3: {}
type-fest@0.13.1:
optional: true
+5 -5
View File
@@ -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, {
+50
View File
@@ -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 }
}
+243
View File
@@ -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<E2EEChannelOptions>) {
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<typeof setup>) {
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([])
})
})
})
+145
View File
@@ -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<typeof setTimeout> | 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
}
}
+79
View File
@@ -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()
})
})
+41
View File
@@ -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)
}
@@ -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<typeof generateKeyPair>
let mobileEphemeralKeys: ReturnType<typeof generateKeyPair>
let wsSent: string[]
let mockWs: {
OPEN: 1
readyState: number
send: ReturnType<typeof vi.fn>
close: ReturnType<typeof vi.fn>
}
let channel: E2EEChannel
let onReady: ReturnType<typeof vi.fn>
let onError: ReturnType<typeof vi.fn>
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`)
}
})
})
+52 -13
View File
@@ -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<WebSocket, E2EEChannel>()
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)
})
+7 -7
View File
@@ -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()
})
+7 -5
View File
@@ -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<typeof PairingOfferV1>
export type PairingOffer = z.infer<typeof PairingOfferSchema>
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))
}