mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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 []
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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 } })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user