diff --git a/mobile/src/transport/cellular-connecting-label-stall.test.ts b/mobile/src/transport/cellular-connecting-label-stall.test.ts index 358b0abab41..425aa0a1208 100644 --- a/mobile/src/transport/cellular-connecting-label-stall.test.ts +++ b/mobile/src/transport/cellular-connecting-label-stall.test.ts @@ -9,6 +9,8 @@ import { classifyConnection, verdictDisplayLabel } from './connection-health' // reverted to "Connecting…" on every redial, so no escalation assertion here // could pass: the label looped "Connecting…" forever. +vi.mock('./direct-e2ee-framing-policy', () => import('./legacy-direct-framing-test-policy')) + vi.mock('./e2ee', () => ({ generateKeyPair: () => ({ publicKey: new Uint8Array(32), secretKey: new Uint8Array(32) }), deriveSharedKey: () => new Uint8Array(32), diff --git a/mobile/src/transport/direct-e2ee-framing-policy.ts b/mobile/src/transport/direct-e2ee-framing-policy.ts new file mode 100644 index 00000000000..1abab7c7b21 --- /dev/null +++ b/mobile/src/transport/direct-e2ee-framing-policy.ts @@ -0,0 +1,38 @@ +import { + hydrateE2EEV2HostLatch, + isHostLatchedToE2EEV2, + latchHostToE2EEV2 +} from './e2ee-v2-host-latch' + +export type DirectE2EEFraming = 'v2' | 'legacy' + +export type DirectE2EEFramingPolicy = { + framingForNextDial: () => DirectE2EEFraming + // Re-checked before a legacy credential is sent: another socket may have latched meanwhile. + mayUseLegacy: () => boolean + // Only a latched host refusing a v2 hello is an auth verdict; otherwise it is an old host. + helloRefusalIsAuthFailure: () => boolean + noteV2Authenticated: () => void + noteV2HelloRefused: () => void +} + +export function createDirectE2EEFramingPolicy(hostPublicKeyB64: string): DirectE2EEFramingPolicy { + void hydrateE2EEV2HostLatch() + let helloRefused = false + const mayUseLegacy = () => isHostLatchedToE2EEV2(hostPublicKeyB64) === false + return { + // Why: only a host never seen speaking v2 that refused a v2 hello predates + // direct v2; anything else falling back would be a downgrade. + framingForNextDial: () => (helloRefused && mayUseLegacy() ? 'legacy' : 'v2'), + mayUseLegacy, + helloRefusalIsAuthFailure: () => isHostLatchedToE2EEV2(hostPublicKeyB64) === true, + noteV2Authenticated: () => { + helloRefused = false + latchHostToE2EEV2(hostPublicKeyB64) + }, + noteV2HelloRefused: () => { + helloRefused = true + void hydrateE2EEV2HostLatch() + } + } +} diff --git a/mobile/src/transport/direct-socket-framing.ts b/mobile/src/transport/direct-socket-framing.ts new file mode 100644 index 00000000000..18af1915967 --- /dev/null +++ b/mobile/src/transport/direct-socket-framing.ts @@ -0,0 +1,192 @@ +import { + decrypt, + decryptBytes, + deriveSharedKey, + encrypt, + generateKeyPair, + publicKeyToBase64 +} from './e2ee' +import type { DirectE2EEFraming } from './direct-e2ee-framing-policy' +import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session' +import { + MobileE2EEAuthenticationError, + MobileE2EEV2PhysicalChannel +} from './mobile-e2ee-v2-physical-channel' +import type { ConnectionLogEmitter } from './types' +import { websocketPayloadToUint8 } from './websocket-payload-bytes' + +export type DirectSocketFramingEvents = { + onReadyAccepted: () => void + onAuthenticated: () => void + onText: (plaintext: string) => void + onBinary: (plaintext: Uint8Array) => void + onAuthRejected: () => void + onProtocolError: (error: Error) => void +} + +export type DirectSocketFraming = { + // Throws when the hello cannot be written. + start: () => void + handleInbound: (raw: unknown) => void + // false = not ready yet; throws when the socket write fails. + sendText: (plaintext: string) => boolean + dispose: () => void +} + +export function createDirectSocketFraming(args: { + framing: DirectE2EEFraming + mayUseLegacy: () => boolean + socket: WebSocket + serverPublicKey: Uint8Array + serverPublicKeyB64: string + deviceToken: string + emitLog: ConnectionLogEmitter + events: DirectSocketFramingEvents +}): DirectSocketFraming { + return args.framing === 'v2' ? createV2Framing(args) : createLegacyFraming(args) +} + +function createV2Framing(args: Parameters[0]) { + const { events } = args + let authenticated = false + const channel = new MobileE2EEV2PhysicalChannel({ + session: MobileE2EEV2ClientSession.create({ + desktopPublicKeyB64: args.serverPublicKeyB64, + transport: 'direct' + }), + socket: args.socket, + deviceToken: args.deviceToken, + decodeBinary: websocketPayloadToUint8, + onReadyAccepted: () => { + args.emitLog('success', 'Received e2ee_ready (v2)', 'Sending device token') + events.onReadyAccepted() + }, + onAuthenticated: () => { + authenticated = true + events.onAuthenticated() + }, + onText: events.onText, + onBinary: events.onBinary, + // Why: a handshake failure (a re-keyed host, or a legacy e2ee_ready answering a v2 + // hello) counts against the auth retry budget, as a legacy 4001 did — never a downgrade. + onError: (error) => + error instanceof MobileE2EEAuthenticationError || !authenticated + ? events.onAuthRejected() + : events.onProtocolError(error) + }) + return { + start: () => { + channel.start() + args.emitLog('info', 'Sent e2ee_hello (v2)', 'Awaiting server e2ee_ready') + }, + handleInbound: (raw: unknown) => void channel.handleMessage(raw), + sendText: (plaintext: string) => channel.sendText(plaintext), + dispose: () => channel.dispose() + } +} + +function createLegacyFraming(args: Parameters[0]) { + const { events, socket } = args + let sharedKey: Uint8Array | null = null + let authenticated = false + let disposed = false + + const sendText = (plaintext: string): boolean => { + if (!sharedKey) { + return false + } + socket.send(encrypt(plaintext, sharedKey)) + return true + } + + const handleHandshake = (raw: string): void => { + try { + const message = JSON.parse(raw) as { type?: unknown } + if (message.type === 'e2ee_ready') { + if (!args.mayUseLegacy()) { + events.onProtocolError(new Error('host is latched to E2EE v2; refusing legacy auth')) + return + } + args.emitLog('success', 'Received e2ee_ready', 'Sending device token') + events.onReadyAccepted() + try { + sendText(JSON.stringify({ type: 'e2ee_auth', deviceToken: args.deviceToken })) + } catch (error) { + events.onProtocolError(error instanceof Error ? error : new Error(String(error))) + } + return + } + } catch { + // The authenticated handshake messages are encrypted. + } + const plaintext = sharedKey ? decrypt(raw, sharedKey) : null + if (plaintext === null) { + return + } + try { + const message = JSON.parse(plaintext) as { + type?: unknown + ok?: unknown + error?: { code?: unknown } + } + if (message.type === 'e2ee_authenticated') { + authenticated = true + events.onAuthenticated() + } else if ( + message.type === 'e2ee_error' || + (message.ok === false && message.error?.code === 'unauthorized') + ) { + console.log('[net] e2ee auth FAILED', { msgType: message.type, error: message.error }) + events.onAuthRejected() + } + } catch { + // Ignore malformed handshake payloads. + } + } + + const handleInbound = async (raw: unknown): Promise => { + if (typeof raw === 'string') { + if (!authenticated) { + handleHandshake(raw) + return + } + const plaintext = sharedKey ? decrypt(raw, sharedKey) : null + if (plaintext !== null) { + events.onText(plaintext) + } + return + } + if (!authenticated) { + return + } + const bytes = await websocketPayloadToUint8(raw) + const key = sharedKey + if (disposed || !bytes || !key) { + return + } + const plaintext = decryptBytes(bytes, key) + if (plaintext) { + events.onBinary(plaintext) + } + } + + return { + start: () => { + if (!args.mayUseLegacy()) { + throw new Error('host is latched to E2EE v2; refusing legacy hello') + } + const ephemeral = generateKeyPair() + socket.send( + JSON.stringify({ type: 'e2ee_hello', publicKeyB64: publicKeyToBase64(ephemeral.publicKey) }) + ) + args.emitLog('info', 'Sent e2ee_hello', 'Awaiting server e2ee_ready') + sharedKey = deriveSharedKey(ephemeral.secretKey, args.serverPublicKey) + }, + handleInbound: (raw: unknown) => void handleInbound(raw), + sendText, + dispose: () => { + disposed = true + sharedKey = null + } + } +} diff --git a/mobile/src/transport/e2ee-v2-host-latch.test.ts b/mobile/src/transport/e2ee-v2-host-latch.test.ts new file mode 100644 index 00000000000..7cc963ee4c8 --- /dev/null +++ b/mobile/src/transport/e2ee-v2-host-latch.test.ts @@ -0,0 +1,111 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { getItem: vi.fn(), setItem: vi.fn() } +})) + +const channelArgs: { onAuthenticated: () => void }[] = [] +vi.mock('./mobile-e2ee-v2-client-session', () => ({ + MobileE2EEV2ClientSession: { create: () => ({}) } +})) +vi.mock('./mobile-e2ee-v2-physical-channel', () => ({ + MobileE2EEV2PhysicalChannel: class { + constructor(args: { onAuthenticated: () => void }) { + channelArgs.push(args) + } + start = vi.fn() + handleMessage = vi.fn(async () => {}) + dispose = vi.fn() + } +})) + +import { createDirectE2EEFramingPolicy } from './direct-e2ee-framing-policy' +import { + hydrateE2EEV2HostLatch, + isHostLatchedToE2EEV2, + latchHostToE2EEV2, + resetE2EEV2HostLatchForTests +} from './e2ee-v2-host-latch' +import { MobileRelayE2eeLink } from './mobile-relay-e2ee-link' + +describe('E2EE v2 host latch', () => { + beforeEach(() => { + resetE2EEV2HostLatchForTests() + channelArgs.length = 0 + vi.mocked(AsyncStorage.getItem).mockReset().mockResolvedValue(null) + vi.mocked(AsyncStorage.setItem).mockReset().mockResolvedValue() + }) + + it('reports unknown until hydrated, so a refused hello cannot fall back yet', async () => { + let release!: (value: string | null) => void + vi.mocked(AsyncStorage.getItem).mockReturnValue(new Promise((resolve) => (release = resolve))) + const policy = createDirectE2EEFramingPolicy('host-key') + policy.noteV2HelloRefused() + expect(isHostLatchedToE2EEV2('host-key')).toBeNull() + expect(policy.framingForNextDial()).toBe('v2') + + release(null) + await hydrateE2EEV2HostLatch() + expect(policy.framingForNextDial()).toBe('legacy') + }) + + it('keeps a latch recorded before hydration and persists it', async () => { + latchHostToE2EEV2('host-key') + expect(isHostLatchedToE2EEV2('host-key')).toBe(true) + await hydrateE2EEV2HostLatch() + await vi.waitFor(() => + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + 'orca:e2ee-v2-hosts', + JSON.stringify(['host-key']) + ) + ) + }) + + it('keeps unreadable storage unknown (no fallback) and retries the read', async () => { + vi.mocked(AsyncStorage.getItem) + .mockRejectedValueOnce(new Error('transient read failure')) + .mockResolvedValueOnce(JSON.stringify(['host-key'])) + const policy = createDirectE2EEFramingPolicy('host-key') + await hydrateE2EEV2HostLatch() + expect(isHostLatchedToE2EEV2('host-key')).toBeNull() + policy.noteV2HelloRefused() + expect(policy.framingForNextDial()).toBe('v2') + expect(policy.helloRefusalIsAuthFailure()).toBe(false) + + await hydrateE2EEV2HostLatch() + expect(isHostLatchedToE2EEV2('host-key')).toBe(true) + expect(policy.framingForNextDial()).toBe('v2') + expect(policy.helloRefusalIsAuthFailure()).toBe(true) + }) + + it('ignores malformed persisted entries', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValueOnce(JSON.stringify(['a', 7, '', null])) + await hydrateE2EEV2HostLatch() + expect(isHostLatchedToE2EEV2('a')).toBe(true) + }) + + it('a relay authentication latches the host, so a later LAN dial never uses legacy', async () => { + await hydrateE2EEV2HostLatch() + const onAuthenticated = vi.fn() + new MobileRelayE2eeLink({ + endpoint: { cellUrl: 'https://relay-c1.onorca.dev', relayHostId: 'AbCdEf0123_-xyZ9' }, + credential: 'credential', + expectedCredentialKind: 'resume', + deviceToken: 'device-token', + desktopPublicKeyB64: 'host-key', + onAuthenticated, + onText: vi.fn(), + onBinary: vi.fn(), + onError: vi.fn(), + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the link only calls send/close and assigns handlers on this stub. + createSocket: () => ({ close: vi.fn(), send: vi.fn() }) as unknown as WebSocket + }) + channelArgs[0]!.onAuthenticated() + expect(onAuthenticated).toHaveBeenCalledOnce() + + const policy = createDirectE2EEFramingPolicy('host-key') + policy.noteV2HelloRefused() + expect(policy.framingForNextDial()).toBe('v2') + }) +}) diff --git a/mobile/src/transport/e2ee-v2-host-latch.ts b/mobile/src/transport/e2ee-v2-host-latch.ts new file mode 100644 index 00000000000..1cdbdc57b67 --- /dev/null +++ b/mobile/src/transport/e2ee-v2-host-latch.ts @@ -0,0 +1,88 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +// Why: once a host has completed a v2 handshake on any path, a later legacy +// handshake to it can only be a downgrade, so the phone refuses to fall back. +// Keyed by the pinned host public key, which is not a secret. +const STORAGE_KEY = 'orca:e2ee-v2-hosts' +const MAX_LATCHED_HOSTS = 128 + +let latched: Set | null = null +const pendingLatches = new Set() +let hydration: Promise | null = null +let writeChain: Promise = Promise.resolve() + +export function hydrateE2EEV2HostLatch(): Promise { + hydration ??= (async () => { + let stored: string[] + try { + stored = parseLatchedHosts(await AsyncStorage.getItem(STORAGE_KEY)) + } catch { + // Why: unreadable is not "nothing latched" — stay unknown (no fallback) and retry later. + hydration = null + return + } + latched = new Set(stored) + if (pendingLatches.size > 0) { + for (const key of pendingLatches) { + latched.add(key) + } + pendingLatches.clear() + persist() + } + })() + return hydration +} + +// null = not hydrated yet; callers must then treat the host as latched. +export function isHostLatchedToE2EEV2(hostPublicKeyB64: string): boolean | null { + if (pendingLatches.has(hostPublicKeyB64)) { + return true + } + return latched ? latched.has(hostPublicKeyB64) : null +} + +export function latchHostToE2EEV2(hostPublicKeyB64: string): void { + if (!hostPublicKeyB64) { + return + } + if (!latched) { + pendingLatches.add(hostPublicKeyB64) + void hydrateE2EEV2HostLatch() + return + } + if (latched.has(hostPublicKeyB64)) { + return + } + latched.add(hostPublicKeyB64) + persist() +} + +export function resetE2EEV2HostLatchForTests(): void { + latched = null + pendingLatches.clear() + hydration = null + writeChain = Promise.resolve() +} + +function persist(): void { + const snapshot = [...(latched ?? [])].slice(-MAX_LATCHED_HOSTS) + writeChain = writeChain + .then(() => AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot))) + .catch(() => { + // The in-memory latch still applies for this app session. + }) +} + +function parseLatchedHosts(raw: string | null): string[] { + if (!raw) { + return [] + } + try { + const parsed: unknown = JSON.parse(raw) + return Array.isArray(parsed) + ? parsed.filter((item): item is string => typeof item === 'string' && item.length > 0) + : [] + } catch { + return [] + } +} diff --git a/mobile/src/transport/e2ee.ts b/mobile/src/transport/e2ee.ts index 2ba135ad84d..fe6fbc11847 100644 --- a/mobile/src/transport/e2ee.ts +++ b/mobile/src/transport/e2ee.ts @@ -28,6 +28,10 @@ export function generateKeyPair(): { publicKey: Uint8Array; secretKey: Uint8Arra return { publicKey: u8(kp.publicKey), secretKey: u8(kp.secretKey) } } +export function randomBytes(length: number): Uint8Array { + return u8(nacl.randomBytes(length)) +} + export function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey: Uint8Array): Uint8Array { return u8(nacl.box.before(u8(peerPublicKey), u8(ourSecretKey))) } diff --git a/mobile/src/transport/foreground-stale-dial-restart.test.ts b/mobile/src/transport/foreground-stale-dial-restart.test.ts index b7467f6354c..51cf26487f2 100644 --- a/mobile/src/transport/foreground-stale-dial-restart.test.ts +++ b/mobile/src/transport/foreground-stale-dial-restart.test.ts @@ -15,6 +15,8 @@ import { classifyConnection, verdictDisplayLabel } from './connection-health' // desktop that was answering the whole time. By the next foreground the client // had landed in 'reconnecting', where the nudge works, so it connected at once. +vi.mock('./direct-e2ee-framing-policy', () => import('./legacy-direct-framing-test-policy')) + vi.mock('./e2ee', () => ({ generateKeyPair: () => ({ publicKey: new Uint8Array(32), secretKey: new Uint8Array(32) }), deriveSharedKey: () => new Uint8Array(32), diff --git a/mobile/src/transport/legacy-direct-framing-test-policy.ts b/mobile/src/transport/legacy-direct-framing-test-policy.ts new file mode 100644 index 00000000000..584e6f49429 --- /dev/null +++ b/mobile/src/transport/legacy-direct-framing-test-policy.ts @@ -0,0 +1,13 @@ +import type { DirectE2EEFramingPolicy } from './direct-e2ee-framing-policy' + +// Why: transport tests that model a host answering legacy framing pin it here; +// rpc-client-direct-e2ee-v2.test.ts covers the v2 default and its fallback rules. +export function createDirectE2EEFramingPolicy(): DirectE2EEFramingPolicy { + return { + framingForNextDial: () => 'legacy', + mayUseLegacy: () => true, + helloRefusalIsAuthFailure: () => true, + noteV2Authenticated: () => {}, + noteV2HelloRefused: () => {} + } +} diff --git a/mobile/src/transport/mobile-e2ee-v2-client-session.ts b/mobile/src/transport/mobile-e2ee-v2-client-session.ts index 821856955b7..e0b8a5264e1 100644 --- a/mobile/src/transport/mobile-e2ee-v2-client-session.ts +++ b/mobile/src/transport/mobile-e2ee-v2-client-session.ts @@ -1,4 +1,3 @@ -import * as ExpoCrypto from 'expo-crypto' import { encodeMobileE2EEV2Transcript, validateMobileE2EEV2Handshake, @@ -9,7 +8,13 @@ import { openMobileE2EEV2Frame, sealMobileE2EEV2Frame } from '../../../src/shared/mobile-e2ee-v2-framing' -import { deriveSharedKey, generateKeyPair, publicKeyFromBase64, publicKeyToBase64 } from './e2ee' +import { + deriveSharedKey, + generateKeyPair, + publicKeyFromBase64, + publicKeyToBase64, + randomBytes +} from './e2ee' import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule' import { decodeBase64Bytes, encodeBase64Bytes } from './base64-byte-codec' @@ -36,7 +41,7 @@ export class MobileE2EEV2ClientSession { clientKeyPair?: { publicKey: Uint8Array; secretKey: Uint8Array } }): MobileE2EEV2ClientSession { const keyPair = args.clientKeyPair ?? generateKeyPair() - const clientNonce = args.clientNonce ?? ExpoCrypto.getRandomBytes(32) + const clientNonce = args.clientNonce ?? randomBytes(32) if (clientNonce.length !== 32) { throw new Error(`Invalid client nonce length: ${clientNonce.length}`) } diff --git a/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts b/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts index ec3b8088915..879ad76e020 100644 --- a/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts +++ b/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts @@ -32,6 +32,7 @@ export class MobileE2EEV2PhysicalChannel { socket: MobileE2EEV2Socket deviceToken: string decodeBinary: (raw: unknown) => Promise + onReadyAccepted?: () => void onAuthenticated: () => void onText: (plaintext: string) => void onBinary: (plaintext: Uint8Array) => void @@ -133,6 +134,7 @@ export class MobileE2EEV2PhysicalChannel { throw new Error('Invalid E2EE v2 ready') } this.state = 'awaiting-authenticated' + this.args.onReadyAccepted?.() this.outboundQueue.enqueue({ kind: 'text', plaintext: JSON.stringify({ diff --git a/mobile/src/transport/mobile-relay-e2ee-link.ts b/mobile/src/transport/mobile-relay-e2ee-link.ts index a8e96e5a786..ed1daf02508 100644 --- a/mobile/src/transport/mobile-relay-e2ee-link.ts +++ b/mobile/src/transport/mobile-relay-e2ee-link.ts @@ -6,6 +6,7 @@ import { relayHostCloseReasonFrom, type RelayHostCloseReason } from '../../../src/shared/relay-host-close-reason' +import { latchHostToE2EEV2 } from './e2ee-v2-host-latch' import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session' import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel' import { websocketPayloadToUint8 } from './websocket-payload-bytes' @@ -71,7 +72,10 @@ export class MobileRelayE2eeLink { socket: this.socket, deviceToken: options.deviceToken, decodeBinary: websocketPayloadToUint8, - onAuthenticated: options.onAuthenticated, + onAuthenticated: () => { + latchHostToE2EEV2(options.desktopPublicKeyB64) + options.onAuthenticated() + }, onText: options.onText, onBinary: options.onBinary, onError: (error) => this.fail(error) diff --git a/mobile/src/transport/mobile-relay-physical-client.ts b/mobile/src/transport/mobile-relay-physical-client.ts index 95b357a4982..c6c8ce66c6f 100644 --- a/mobile/src/transport/mobile-relay-physical-client.ts +++ b/mobile/src/transport/mobile-relay-physical-client.ts @@ -1,5 +1,6 @@ import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer' import { RelayPhoneHelloSchema } from '../../../src/shared/mobile-relay-phone-protocol' +import { latchHostToE2EEV2 } from './e2ee-v2-host-latch' import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session' import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel' import { createPairingRelayLogger, pairingRelayErrorDetail } from './pairing-relay-log' @@ -65,6 +66,7 @@ export function connectMobileRelayForPairing(args: { decodeBinary: websocketPayloadToUint8, onAuthenticated: () => { authenticated = true + latchHostToE2EEV2(args.desktopPublicKeyB64) log('success', 'Relay: authenticated', 'Channel ready for RPC') resolveAuthenticated() }, diff --git a/mobile/src/transport/rpc-client-capabilities.test.ts b/mobile/src/transport/rpc-client-capabilities.test.ts index cc670ab8a41..3c7d36ff504 100644 --- a/mobile/src/transport/rpc-client-capabilities.test.ts +++ b/mobile/src/transport/rpc-client-capabilities.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { connect } from './rpc-client' +vi.mock('./direct-e2ee-framing-policy', () => import('./legacy-direct-framing-test-policy')) + vi.mock('./e2ee', () => ({ generateKeyPair: () => ({ publicKey: new Uint8Array(32), diff --git a/mobile/src/transport/rpc-client-connect-wait-replay.test.ts b/mobile/src/transport/rpc-client-connect-wait-replay.test.ts index 55a9dc60311..82653db4265 100644 --- a/mobile/src/transport/rpc-client-connect-wait-replay.test.ts +++ b/mobile/src/transport/rpc-client-connect-wait-replay.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { connect } from './rpc-client' +vi.mock('./direct-e2ee-framing-policy', () => import('./legacy-direct-framing-test-policy')) + vi.mock('./e2ee', () => ({ generateKeyPair: () => ({ publicKey: new Uint8Array(32), diff --git a/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts b/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts index 29456c2eaa1..971ccda3a93 100644 --- a/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts +++ b/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts @@ -7,6 +7,8 @@ import { LogicalClientCutoverError } from './stable-logical-rpc-client' +vi.mock('./direct-e2ee-framing-policy', () => import('./legacy-direct-framing-test-policy')) + vi.mock('./e2ee', () => ({ generateKeyPair: () => ({ publicKey: new Uint8Array(32), diff --git a/mobile/src/transport/rpc-client-direct-e2ee-v2.test.ts b/mobile/src/transport/rpc-client-direct-e2ee-v2.test.ts new file mode 100644 index 00000000000..c46af3ffc2e --- /dev/null +++ b/mobile/src/transport/rpc-client-direct-e2ee-v2.test.ts @@ -0,0 +1,280 @@ +import { randomBytes as nodeRandomBytes } from 'node:crypto' +import nacl from 'tweetnacl' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DesktopMobileE2EEV2Session } from '../../../src/main/runtime/rpc/mobile-e2ee-v2-desktop-session' + +const storage = new Map() +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: vi.fn(async (key: string) => storage.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => void storage.set(key, value)) + } +})) +vi.mock('expo-crypto', () => ({ + getRandomBytes: (length: number) => new Uint8Array(nodeRandomBytes(length)) +})) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + +import { + hydrateE2EEV2HostLatch, + isHostLatchedToE2EEV2, + latchHostToE2EEV2, + resetE2EEV2HostLatchForTests +} from './e2ee-v2-host-latch' +import { connect } from './rpc-client' + +const desktopKeys = nacl.box.keyPair() +const desktopPublicKeyB64 = Buffer.from(desktopKeys.publicKey).toString('base64') + +class MockWebSocket { + static OPEN = 1 + readonly OPEN = 1 + readyState = 0 + bufferedAmount = 0 + onopen: (() => void) | null = null + onclose: ((event?: { code?: number; reason?: string; wasClean?: boolean }) => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + onerror: (() => void) | null = null + sent: (string | Uint8Array)[] = [] + close = vi.fn(() => { + if (this.readyState !== 3) { + this.readyState = 3 + this.onclose?.() + } + }) + constructor(readonly endpoint: string) { + sockets.push(this) + } + send(frame: string | Uint8Array): void { + this.sent.push(frame) + } + open(): void { + this.readyState = 1 + this.onopen?.() + } + receive(data: unknown): void { + this.onmessage?.({ data }) + } + serverClose(code: number): void { + this.readyState = 3 + this.onclose?.({ code, reason: '', wasClean: true }) + } +} + +const sockets: MockWebSocket[] = [] +const originalWebSocket = globalThis.WebSocket +const flush = () => vi.advanceTimersByTimeAsync(0) + +async function nextSocket(): Promise { + const count = sockets.length + for (let elapsed = 0; sockets.length === count && elapsed < 60_000; elapsed += 100) { + await vi.advanceTimersByTimeAsync(100) + } + return sockets.at(-1)! +} + +function firstFrame(socket: MockWebSocket): unknown { + return JSON.parse(String(socket.sent[0])) +} + +function requestIdOf(plaintext: string | null): string { + const value: unknown = JSON.parse(plaintext ?? 'null') + return value && typeof value === 'object' && 'id' in value ? String(value.id) : '' +} + +// Plays the current host's direct v2 handshake with real crypto. +async function completeHostV2Handshake(socket: MockWebSocket): Promise { + const host = DesktopMobileE2EEV2Session.create({ + hello: firstFrame(socket), + serverSecretKey: desktopKeys.secretKey, + expectedContext: { transport: 'direct' } + }) + expect(host).not.toBeNull() + socket.receive(JSON.stringify(host!.ready)) + await flush() + expect(JSON.parse(host!.openText(String(socket.sent[1]))!)).toEqual({ + type: 'e2ee_auth', + v: 2, + transcriptHashB64: host!.transcriptHashB64, + deviceToken: 'device-token' + }) + socket.receive( + host!.sealText( + JSON.stringify({ + type: 'e2ee_authenticated', + v: 2, + transcriptHashB64: host!.transcriptHashB64 + }) + ) + ) + await flush() + return host! +} + +describe('direct connections use E2EE v2 framing', () => { + beforeEach(() => { + vi.useFakeTimers() + sockets.length = 0 + storage.clear() + resetE2EEV2HostLatchForTests() + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket + }) + + afterEach(() => { + vi.useRealTimers() + globalThis.WebSocket = originalWebSocket + }) + + it('sends a direct v2 hello, authenticates, and exchanges sealed RPC frames', async () => { + const client = connect('ws://desktop.invalid', 'device-token', desktopPublicKeyB64) + const socket = sockets[0]! + socket.open() + expect(firstFrame(socket)).toMatchObject({ + type: 'e2ee_hello', + v: 2, + context: { transport: 'direct' } + }) + expect(firstFrame(socket)).not.toHaveProperty('publicKeyB64') + + const host = await completeHostV2Handshake(socket) + expect(client.getState()).toBe('connected') + + const pending = client.sendRequest('status.get', {}) + await flush() + const id = requestIdOf(host.openText(String(socket.sent.at(-1)))) + socket.receive(host.sealText(JSON.stringify({ id, ok: true, result: { up: 1 } }))) + await expect(pending).resolves.toMatchObject({ ok: true, result: { up: 1 } }) + expect(isHostLatchedToE2EEV2(desktopPublicKeyB64)).toBe(true) + client.close() + }) + + it('falls back to legacy framing only for a never-latched host that refuses the v2 hello', async () => { + await hydrateE2EEV2HostLatch() + const client = connect('ws://desktop.invalid', 'device-token', desktopPublicKeyB64) + sockets[0]!.open() + sockets[0]!.serverClose(4001) + await vi.advanceTimersByTimeAsync(2_000) + + const retry = sockets[1]! + retry.open() + expect(firstFrame(retry)).toEqual({ type: 'e2ee_hello', publicKeyB64: expect.any(String) }) + client.close() + }) + + it('never falls back to legacy for a host already seen speaking v2 (relay then LAN)', async () => { + await hydrateE2EEV2HostLatch() + latchHostToE2EEV2(desktopPublicKeyB64) + const client = connect('ws://desktop.invalid', 'device-token', desktopPublicKeyB64) + sockets[0]!.open() + sockets[0]!.serverClose(4001) + await vi.advanceTimersByTimeAsync(2_000) + + sockets[1]!.open() + expect(firstFrame(sockets[1]!)).toMatchObject({ type: 'e2ee_hello', v: 2 }) + client.close() + }) + + it('refuses a legacy e2ee_ready answering its v2 hello and sends no credential', async () => { + const client = connect('ws://desktop.invalid', 'device-token', desktopPublicKeyB64) + const socket = sockets[0]! + socket.open() + socket.receive(JSON.stringify({ type: 'e2ee_ready' })) + await flush() + + expect(socket.close).toHaveBeenCalled() + expect(socket.sent).toHaveLength(1) + expect(client.getState()).not.toBe('connected') + client.close() + }) + + it('does not exhaust the auth budget on an old host while the latch store is still loading', async () => { + let releaseStorage!: () => void + const { default: AsyncStorage } = await import('@react-native-async-storage/async-storage') + vi.mocked(AsyncStorage.getItem).mockImplementationOnce( + () => new Promise((resolve) => (releaseStorage = () => resolve(null))) + ) + const client = connect('ws://desktop.invalid', 'device-token', desktopPublicKeyB64) + let socket = sockets[0]! + for (let attempt = 0; attempt < 4; attempt++) { + socket.open() + expect(firstFrame(socket)).toMatchObject({ v: 2 }) + socket.serverClose(4001) + socket = await nextSocket() + } + expect(client.getState()).not.toBe('auth-failed') + + releaseStorage() + await flush() + socket.serverClose(1006) + socket = await nextSocket() + socket.open() + expect(firstFrame(socket)).toEqual({ type: 'e2ee_hello', publicKeyB64: expect.any(String) }) + client.close() + }) + + it('applies a latch recorded while a legacy dial was pending', async () => { + await hydrateE2EEV2HostLatch() + const client = connect('ws://desktop.invalid', 'device-token', desktopPublicKeyB64) + sockets[0]!.open() + sockets[0]!.serverClose(4001) + await vi.advanceTimersByTimeAsync(2_000) + + // The retry chose legacy, but another connection proves v2 before this one opens. + latchHostToE2EEV2(desktopPublicKeyB64) + sockets[1]!.open() + expect(firstFrame(sockets[1]!)).toMatchObject({ type: 'e2ee_hello', v: 2 }) + client.close() + }) + + it('sends no legacy credential when the host latches between hello and ready', async () => { + await hydrateE2EEV2HostLatch() + const client = connect('ws://desktop.invalid', 'device-token', desktopPublicKeyB64) + sockets[0]!.open() + sockets[0]!.serverClose(4001) + await vi.advanceTimersByTimeAsync(2_000) + const legacy = sockets[1]! + legacy.open() + expect(firstFrame(legacy)).toEqual({ type: 'e2ee_hello', publicKeyB64: expect.any(String) }) + + latchHostToE2EEV2(desktopPublicKeyB64) + legacy.receive(JSON.stringify({ type: 'e2ee_ready' })) + await flush() + expect(legacy.sent).toHaveLength(1) + expect(legacy.close).toHaveBeenCalled() + client.close() + }) + + it('latches auth-failed when a re-keyed host answers, as a legacy 4001 did', async () => { + const client = connect('ws://desktop.invalid', 'device-token', desktopPublicKeyB64) + const rekeyed = nacl.box.keyPair() + for (let attempt = 0; attempt < 3; attempt++) { + if (attempt > 0) { + await vi.advanceTimersByTimeAsync(2_000) + } + const socket = sockets.at(-1)! + socket.open() + const host = DesktopMobileE2EEV2Session.create({ + hello: firstFrame(socket), + serverSecretKey: rekeyed.secretKey, + expectedContext: { transport: 'direct' } + }) + socket.receive(JSON.stringify(host!.ready)) + await flush() + expect(socket.sent).toHaveLength(1) + } + expect(client.getState()).toBe('auth-failed') + expect(isHostLatchedToE2EEV2(desktopPublicKeyB64)).toBe(false) + client.close() + }) + + it('persists the latch so a later app session refuses to fall back', async () => { + await hydrateE2EEV2HostLatch() + latchHostToE2EEV2(desktopPublicKeyB64) + await flush() + resetE2EEV2HostLatchForTests() + await hydrateE2EEV2HostLatch() + expect(isHostLatchedToE2EEV2(desktopPublicKeyB64)).toBe(true) + }) +}) diff --git a/mobile/src/transport/rpc-client-request-deadline.test.ts b/mobile/src/transport/rpc-client-request-deadline.test.ts index 05fe81d7944..4df997326d0 100644 --- a/mobile/src/transport/rpc-client-request-deadline.test.ts +++ b/mobile/src/transport/rpc-client-request-deadline.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { connect } from './rpc-client' +vi.mock('./direct-e2ee-framing-policy', () => import('./legacy-direct-framing-test-policy')) + vi.mock('./e2ee', () => ({ generateKeyPair: () => ({ publicKey: new Uint8Array(32), diff --git a/mobile/src/transport/rpc-client-runtime-events.test.ts b/mobile/src/transport/rpc-client-runtime-events.test.ts index ce035f63b0b..cd6cd7e3708 100644 --- a/mobile/src/transport/rpc-client-runtime-events.test.ts +++ b/mobile/src/transport/rpc-client-runtime-events.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { connect, type RpcClient } from './rpc-client' +vi.mock('./direct-e2ee-framing-policy', () => import('./legacy-direct-framing-test-policy')) + vi.mock('./e2ee', () => ({ generateKeyPair: () => ({ publicKey: new Uint8Array(32), diff --git a/mobile/src/transport/rpc-client-socket-factory.ts b/mobile/src/transport/rpc-client-socket-factory.ts index df82e460d92..c5fdcb17db8 100644 --- a/mobile/src/transport/rpc-client-socket-factory.ts +++ b/mobile/src/transport/rpc-client-socket-factory.ts @@ -1,3 +1,7 @@ +import { + createDirectE2EEFramingPolicy, + type DirectE2EEFramingPolicy +} from './direct-e2ee-framing-policy' import { publicKeyFromBase64 } from './e2ee' import { RpcClientSocketSession } from './rpc-client-socket-session' import { redactSocketEndpoint } from './socket-event-debug' @@ -25,6 +29,7 @@ type SocketFactoryOptions = { export class RpcClientSocketFactory { private readonly serverPublicKey: Uint8Array + private readonly framingPolicy: DirectE2EEFramingPolicy private lastInboundAt: number | null = null private lastSocketClosedAt: number | null = null private constructionCount = 0 @@ -32,6 +37,7 @@ export class RpcClientSocketFactory { constructor(private readonly options: SocketFactoryOptions) { this.serverPublicKey = publicKeyFromBase64(options.serverPublicKeyB64) + this.framingPolicy = createDirectE2EEFramingPolicy(options.serverPublicKeyB64) } open(): RpcClientSocketSession { @@ -58,6 +64,8 @@ export class RpcClientSocketFactory { endpoint: this.options.endpoint, deviceToken: this.options.deviceToken, serverPublicKey: this.serverPublicKey, + serverPublicKeyB64: this.options.serverPublicKeyB64, + framingPolicy: this.framingPolicy, getCurrentSocket: this.options.getCurrentSocket, getState: this.options.getState, getReconnectAttempt: this.options.getReconnectAttempt, diff --git a/mobile/src/transport/rpc-client-socket-session.ts b/mobile/src/transport/rpc-client-socket-session.ts index bc8f9d13e2c..1331781a4fd 100644 --- a/mobile/src/transport/rpc-client-socket-session.ts +++ b/mobile/src/transport/rpc-client-socket-session.ts @@ -1,25 +1,21 @@ -import { - decrypt, - decryptBytes, - deriveSharedKey, - encrypt, - generateKeyPair, - publicKeyToBase64 -} from './e2ee' +import type { DirectE2EEFraming, DirectE2EEFramingPolicy } from './direct-e2ee-framing-policy' +import { createDirectSocketFraming, type DirectSocketFraming } from './direct-socket-framing' import { isRpcResponse } from './rpc-response-shape' import { isStaleRpcSocketEvent, logRpcSocketClose } from './rpc-socket-close-evidence' import { describeSocketEvent, redactSocketEndpoint } from './socket-event-debug' import type { ConnectionLogEmitter, ConnectionState, RpcResponse } from './types' -import { websocketPayloadToUint8 } from './websocket-payload-bytes' const CONNECT_TIMEOUT_MS = 12_000 const HANDSHAKE_TIMEOUT_MS = 5_000 const WEBSOCKET_CONNECTING_STATE = 0 +const UNAUTHORIZED_CLOSE_CODE = 4001 type SocketSessionOptions = { endpoint: string deviceToken: string serverPublicKey: Uint8Array + serverPublicKeyB64: string + framingPolicy: DirectE2EEFramingPolicy getCurrentSocket: () => WebSocket | null getState: () => ConnectionState getReconnectAttempt: () => number @@ -39,7 +35,10 @@ type SocketSessionOptions = { export class RpcClientSocketSession { readonly socket: WebSocket readonly constructedAt = Date.now() - private sharedKey: Uint8Array | null = null + private framing: DirectSocketFraming | null = null + // Why: chosen when the socket opens, so a latch recorded while it was dialing applies. + private framingKind: DirectE2EEFraming | null = null + private readyAccepted = false private authenticated = false private lastInboundAt: number | null = null private connectTimer: ReturnType | null = null @@ -52,10 +51,11 @@ export class RpcClientSocketSession { } sendEncrypted(request: unknown): boolean { - if (this.socket.readyState === WebSocket.OPEN && this.sharedKey) { + if (this.socket.readyState === WebSocket.OPEN && this.framing) { try { - this.socket.send(encrypt(JSON.stringify(request), this.sharedKey)) - return true + if (this.framing.sendText(JSON.stringify(request))) { + return true + } } catch { if (this.options.getCurrentSocket() === this.socket) { this.options.onForcedClose(this) @@ -66,7 +66,7 @@ export class RpcClientSocketSession { console.log('[net] sendEncrypted FAILED — channel not ready', { hasWs: this.options.getCurrentSocket() !== null, readyState: this.socket.readyState, - hasKey: this.sharedKey !== null, + hasKey: this.framing !== null, state: this.options.getState() }) if ( @@ -98,7 +98,8 @@ export class RpcClientSocketSession { } clearKey(): void { - this.sharedKey = null + this.framing?.dispose() + this.framing = null } private attachHandlers(): void { @@ -110,25 +111,16 @@ export class RpcClientSocketSession { this.clearConnectTimer() this.options.onHandshakeStarted() this.options.emitLog('success', 'WebSocket open', 'Starting E2EE handshake') - const ephemeral = generateKeyPair() - const hello = JSON.stringify({ - type: 'e2ee_hello', - publicKeyB64: publicKeyToBase64(ephemeral.publicKey) - }) - try { - this.socket.send(hello) - } catch { - this.options.onForcedClose(this) - return - } - this.options.emitLog('info', 'Sent e2ee_hello', 'Awaiting server e2ee_ready') - this.sharedKey = deriveSharedKey(ephemeral.secretKey, this.options.serverPublicKey) - this.armHandshakeTimeout() + this.startHandshake() } this.socket.onmessage = (event) => { - if (!this.isStale('message')) { - void this.handleMessage(event.data) + if (this.isStale('message')) { + return } + const receivedAt = Date.now() + this.lastInboundAt = receivedAt + this.options.onAnyInbound(receivedAt) + this.framing?.handleInbound(event.data) } this.socket.onclose = (event) => { const closeCode = logRpcSocketClose({ @@ -141,7 +133,7 @@ export class RpcClientSocketSession { authenticated: this.authenticated, lastInboundAt: this.lastInboundAt }) - this.options.onClosed(this, closeCode) + this.options.onClosed(this, this.classifyClose(closeCode)) } this.socket.onerror = (event) => { if (this.isStale('error')) { @@ -159,36 +151,77 @@ export class RpcClientSocketSession { } } - private async handleMessage(rawData: unknown): Promise { - const receivedAt = Date.now() - this.lastInboundAt = receivedAt - this.options.onAnyInbound(receivedAt) - const raw = typeof rawData === 'string' ? rawData : null - if (!this.authenticated) { - this.handleHandshakeMessage(raw) - return + // Why: hosts that predate direct v2 refuse the v2 hello with 4001 before any ready; + // that is an auth verdict only for a host already proven to speak v2. + private classifyClose(closeCode: number | undefined): number | undefined { + if (this.framingKind !== 'v2' || this.readyAccepted || closeCode !== UNAUTHORIZED_CLOSE_CODE) { + return closeCode } - if (!this.sharedKey || this.sharedKey.length !== 32) { - return - } - if (raw === null) { - const bytes = await websocketPayloadToUint8(rawData) - if (this.options.getCurrentSocket() !== this.socket || !bytes) { - return + const policy = this.options.framingPolicy + const countsAsAuthFailure = policy.helloRefusalIsAuthFailure() + policy.noteV2HelloRefused() + return countsAsAuthFailure ? closeCode : undefined + } + + private startHandshake(): void { + const isCurrent = () => this.options.getCurrentSocket() === this.socket + const policy = this.options.framingPolicy + this.framingKind = policy.framingForNextDial() + this.framing = createDirectSocketFraming({ + framing: this.framingKind, + mayUseLegacy: policy.mayUseLegacy, + socket: this.socket, + serverPublicKey: this.options.serverPublicKey, + serverPublicKeyB64: this.options.serverPublicKeyB64, + deviceToken: this.options.deviceToken, + emitLog: this.options.emitLog, + events: { + onReadyAccepted: () => { + this.readyAccepted = true + }, + onAuthenticated: () => { + this.clearHandshakeTimer() + this.authenticated = true + // Why: latch only once the host proved its key; an unauthenticated ready is forgeable. + if (this.framingKind === 'v2') { + policy.noteV2Authenticated() + } + this.options.onAuthenticated(this) + }, + onText: (plaintext) => { + this.options.onAuthenticatedInbound(this) + this.deliverRpcText(plaintext) + }, + onBinary: (plaintext) => { + if (isCurrent()) { + this.options.onAuthenticatedInbound(this) + this.options.onBinary(plaintext) + } + }, + onAuthRejected: () => { + if (isCurrent()) { + this.clearHandshakeTimer() + this.options.onAuthRejected('Unauthorized — pairing may be revoked') + } + }, + onProtocolError: (error) => { + if (isCurrent()) { + console.log('[net] e2ee protocol error — closing', { message: error.message }) + this.options.onForcedClose(this) + } + } } - const plaintext = decryptBytes(bytes, this.sharedKey) - if (!plaintext) { - return - } - this.options.onAuthenticatedInbound(this) - this.options.onBinary(plaintext) + }) + try { + this.framing.start() + } catch { + this.options.onForcedClose(this) return } - const plaintext = decrypt(raw, this.sharedKey) - if (plaintext === null) { - return - } - this.options.onAuthenticatedInbound(this) + this.armHandshakeTimeout() + } + + private deliverRpcText(plaintext: string): void { let response: unknown try { response = JSON.parse(plaintext) @@ -200,53 +233,6 @@ export class RpcClientSocketSession { } } - private handleHandshakeMessage(raw: string | null): void { - if (raw === null) { - return - } - try { - const message = JSON.parse(raw) as { type?: unknown } - if (message.type === 'e2ee_ready') { - this.options.emitLog('success', 'Received e2ee_ready', 'Sending device token') - this.sendEncrypted({ type: 'e2ee_auth', deviceToken: this.options.deviceToken }) - return - } - } catch { - // The authenticated handshake messages are encrypted. - } - if (!this.sharedKey || this.sharedKey.length !== 32) { - return - } - const plaintext = decrypt(raw, this.sharedKey) - if (plaintext === null) { - return - } - try { - const message = JSON.parse(plaintext) as { - type?: unknown - ok?: unknown - error?: { code?: unknown } - } - if (message.type === 'e2ee_authenticated') { - this.clearHandshakeTimer() - this.authenticated = true - this.options.onAuthenticated(this) - } else if ( - message.type === 'e2ee_error' || - (message.ok === false && message.error?.code === 'unauthorized') - ) { - console.log('[net] e2ee auth FAILED', { - msgType: message.type, - error: message.error - }) - this.clearHandshakeTimer() - this.options.onAuthRejected('Unauthorized — pairing may be revoked') - } - } catch { - // Ignore malformed handshake payloads. - } - } - private armConnectTimeout(): void { this.connectTimer = setTimeout(() => { this.connectTimer = null diff --git a/mobile/src/transport/rpc-client-synthesized-close-diagnostics.test.ts b/mobile/src/transport/rpc-client-synthesized-close-diagnostics.test.ts index 5898685bb70..7b75593f40e 100644 --- a/mobile/src/transport/rpc-client-synthesized-close-diagnostics.test.ts +++ b/mobile/src/transport/rpc-client-synthesized-close-diagnostics.test.ts @@ -4,6 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { connect } from './rpc-client' +vi.mock('./direct-e2ee-framing-policy', () => import('./legacy-direct-framing-test-policy')) + vi.mock('./e2ee', () => ({ generateKeyPair: () => ({ publicKey: new Uint8Array(32), diff --git a/mobile/src/transport/rpc-client-terminal-reconnect.test.ts b/mobile/src/transport/rpc-client-terminal-reconnect.test.ts index 83f40113cae..57b3183b809 100644 --- a/mobile/src/transport/rpc-client-terminal-reconnect.test.ts +++ b/mobile/src/transport/rpc-client-terminal-reconnect.test.ts @@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { connect } from './rpc-client' import { encodeTerminalStreamFrame, TerminalStreamOpcode } from './terminal-stream-protocol' +vi.mock('./direct-e2ee-framing-policy', () => import('./legacy-direct-framing-test-policy')) + vi.mock('./e2ee', () => ({ generateKeyPair: () => ({ publicKey: new Uint8Array(32), diff --git a/mobile/src/transport/rpc-client-unauthorized-close.test.ts b/mobile/src/transport/rpc-client-unauthorized-close.test.ts index 1ba79015331..e0253395f55 100644 --- a/mobile/src/transport/rpc-client-unauthorized-close.test.ts +++ b/mobile/src/transport/rpc-client-unauthorized-close.test.ts @@ -4,6 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { connect } from './rpc-client' +vi.mock('./direct-e2ee-framing-policy', () => import('./legacy-direct-framing-test-policy')) + vi.mock('./e2ee', () => ({ generateKeyPair: () => ({ publicKey: new Uint8Array(32), diff --git a/mobile/src/transport/rpc-client.test.ts b/mobile/src/transport/rpc-client.test.ts index ff290f72b84..1adc32dadbc 100644 --- a/mobile/src/transport/rpc-client.test.ts +++ b/mobile/src/transport/rpc-client.test.ts @@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { connect } from './rpc-client' import { encodeTerminalStreamFrame, TerminalStreamOpcode } from './terminal-stream-protocol' +vi.mock('./direct-e2ee-framing-policy', () => import('./legacy-direct-framing-test-policy')) + vi.mock('./e2ee', () => ({ generateKeyPair: () => ({ publicKey: new Uint8Array(32), diff --git a/mobile/src/transport/rpc-session-liveness-integration.test.ts b/mobile/src/transport/rpc-session-liveness-integration.test.ts index 88e71a0862c..675ad54da77 100644 --- a/mobile/src/transport/rpc-session-liveness-integration.test.ts +++ b/mobile/src/transport/rpc-session-liveness-integration.test.ts @@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { connect } from './rpc-client' import { encodeTerminalStreamFrame, TerminalStreamOpcode } from './terminal-stream-protocol' +vi.mock('./direct-e2ee-framing-policy', () => import('./legacy-direct-framing-test-policy')) + vi.mock('./e2ee', () => ({ generateKeyPair: () => ({ publicKey: new Uint8Array(32), diff --git a/tests/e2e/cross-version-wire/cross-version-mobile-direct-e2ee-v2.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-mobile-direct-e2ee-v2.unit.test.ts new file mode 100644 index 00000000000..a5bed6b99a0 --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-mobile-direct-e2ee-v2.unit.test.ts @@ -0,0 +1,140 @@ +import nacl from 'tweetnacl' +import { beforeAll, describe, expect, it, vi } from 'vitest' +import { + encodeMobileE2EEV2Transcript, + validateMobileE2EEV2Handshake, + type MobileE2EEV2Hello +} from '../../../src/shared/mobile-e2ee-v2-contract' +import { + openMobileE2EEV2Frame, + sealMobileE2EEV2Frame +} from '../../../src/shared/mobile-e2ee-v2-framing' +import { deriveMobileE2EEV2KeySchedule } from '../../../src/main/runtime/rpc/mobile-e2ee-v2-key-schedule' +import { + importReleaseCheckoutModule, + materializeReleaseCheckout, + resolveBaselineReleaseRef +} from './release-checkout' + +/** + * Updated phones open direct (LAN) sockets with E2EE v2 instead of the legacy hello. The + * previously released host must accept that handshake on its direct transport, where it does + * not require v2, and route sealed requests to its RPC handler. + */ +const SUITE_TIMEOUT_MS = 180_000 + +type ChannelLike = { + handleRawMessage(raw: string | Uint8Array): void + onMessage(handler: (plaintext: string, reply: (response: string) => void) => void): void +} +type ChannelConstructor = new (ws: unknown, options: Record) => ChannelLike + +const host = nacl.box.keyPair() +const phone = nacl.box.keyPair() +let ReleasedChannel: ChannelConstructor + +beforeAll(async () => { + const checkout = await materializeReleaseCheckout(resolveBaselineReleaseRef()) + const module = await importReleaseCheckoutModule(checkout, 'src/main/runtime/rpc/e2ee-channel.ts') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned release's E2EEChannel class; a changed shape fails the assertions below. + ReleasedChannel = module.E2EEChannel as ChannelConstructor +}, SUITE_TIMEOUT_MS) + +// Mirrors MobileE2EEV2ClientSession.create({ transport: 'direct' }) in mobile/. +function directHello(): MobileE2EEV2Hello { + return { + type: 'e2ee_hello', + v: 2, + clientPublicKeyB64: Buffer.from(phone.publicKey).toString('base64'), + clientNonceB64: Buffer.from(nacl.randomBytes(32)).toString('base64'), + capabilities: { framing: [2], payloadKinds: ['text', 'binary'] }, + context: { + protocol: 'orca-mobile-e2ee', + initiator: 'mobile', + responder: 'desktop', + transport: 'direct' + } + } +} + +describe('cross-version: updated phone dials a released host over direct v2', () => { + it('authenticates with the device token and round-trips a sealed request', () => { + const sent: (string | Uint8Array)[] = [] + const ws = { + OPEN: 1, + readyState: 1, + bufferedAmount: 0, + send: vi.fn((data: string | Uint8Array) => sent.push(data)), + close: vi.fn() + } + const onReady = vi.fn() + const onError = vi.fn() + const channel = new ReleasedChannel(ws, { + serverSecretKey: host.secretKey, + resolveAuthenticatedDevice: (token: string) => + token === 'device-token' + ? { deviceId: 'device-1', deviceToken: token, scope: 'mobile' } + : null, + onReady, + onError + }) + + const hello = directHello() + channel.handleRawMessage(JSON.stringify(hello)) + const ready: unknown = JSON.parse(String(sent[0])) + const handshake = validateMobileE2EEV2Handshake(hello, ready) + expect(handshake).not.toBeNull() + const schedule = deriveMobileE2EEV2KeySchedule({ + sharedSecret: nacl.box.before(host.publicKey, phone.secretKey), + transcript: encodeMobileE2EEV2Transcript(handshake!), + clientNonce: handshake!.clientNonce, + desktopNonce: handshake!.desktopNonce + }) + const transcriptHashB64 = Buffer.from(schedule.transcriptHash).toString('base64') + let outbound = 0n + let inbound = 0n + const seal = (text: string) => + Buffer.from( + sealMobileE2EEV2Frame({ + payload: new TextEncoder().encode(text), + key: schedule.mobileToDesktopKey, + sessionId: schedule.sessionId, + direction: 'mobile-to-desktop', + payloadKind: 'text', + counter: outbound++ + }) + ).toString('base64') + const open = (frame: string | Uint8Array) => + new TextDecoder().decode( + openMobileE2EEV2Frame({ + frame: Buffer.from(String(frame), 'base64'), + key: schedule.desktopToMobileKey, + sessionId: schedule.sessionId, + direction: 'desktop-to-mobile', + payloadKind: 'text', + expectedCounter: inbound++ + })! + ) + + channel.handleRawMessage( + seal( + JSON.stringify({ type: 'e2ee_auth', v: 2, transcriptHashB64, deviceToken: 'device-token' }) + ) + ) + expect(onError).not.toHaveBeenCalled() + expect(onReady).toHaveBeenCalledOnce() + expect(JSON.parse(open(sent[1]!))).toEqual({ + type: 'e2ee_authenticated', + v: 2, + transcriptHashB64 + }) + + channel.onMessage((plaintext, reply) => { + const request: unknown = JSON.parse(plaintext) + const id = request && typeof request === 'object' && 'id' in request ? request.id : null + reply(JSON.stringify({ id, ok: true, result: { echoed: true } })) + }) + channel.handleRawMessage(seal(JSON.stringify({ id: 'r1', method: 'status.get' }))) + expect(JSON.parse(open(sent[2]!))).toEqual({ id: 'r1', ok: true, result: { echoed: true } }) + }) +})