mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 16:03:03 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+20
@@ -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
@@ -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`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -16,4 +16,4 @@ export type {
|
||||
HostProfile,
|
||||
PairingOffer
|
||||
} from './types'
|
||||
export { PairingOfferV1, PAIRING_OFFER_VERSION } from './types'
|
||||
export { PairingOfferSchema, PAIRING_OFFER_VERSION } from './types'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+8
@@ -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
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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`)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user