fix(mobile): direct connections use v2 framing and never fall back once v2 is seen (#27473)

* fix(mobile): direct connections use v2 framing and never fall back once v2 is seen

* test: drop casts in direct v2 tests

* fix(mobile): count v2 handshake failures against the auth retry budget; latch only after host auth

* fix(mobile): pick direct framing at open, recheck the latch before legacy auth, and keep old hosts off the auth budget

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-11 06:41:39 -07:00
committed by GitHub
co-authored by m4air
parent 265e4d495c
commit 7f30a7aa35
26 changed files with 1006 additions and 109 deletions
@@ -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),
@@ -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()
}
}
}
@@ -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<typeof createDirectSocketFraming>[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<typeof createDirectSocketFraming>[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<void> => {
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
}
}
}
@@ -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')
})
})
@@ -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<string> | null = null
const pendingLatches = new Set<string>()
let hydration: Promise<void> | null = null
let writeChain: Promise<void> = Promise.resolve()
export function hydrateE2EEV2HostLatch(): Promise<void> {
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 []
}
}
+4
View File
@@ -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)))
}
@@ -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),
@@ -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: () => {}
}
}
@@ -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}`)
}
@@ -32,6 +32,7 @@ export class MobileE2EEV2PhysicalChannel {
socket: MobileE2EEV2Socket
deviceToken: string
decodeBinary: (raw: unknown) => Promise<Uint8Array | null>
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({
@@ -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)
@@ -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()
},
@@ -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),
@@ -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),
@@ -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),
@@ -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<string, string>()
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<MockWebSocket> {
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<DesktopMobileE2EEV2Session> {
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)
})
})
@@ -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),
@@ -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),
@@ -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,
+91 -105
View File
@@ -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<typeof setTimeout> | 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<void> {
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
@@ -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),
@@ -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),
@@ -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),
+2
View File
@@ -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),
@@ -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),
@@ -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<string, unknown>) => 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 } })
})
})