diff --git a/mobile/src/transport/mobile-e2ee-legacy-fixtures.test.ts b/mobile/src/transport/mobile-e2ee-legacy-fixtures.test.ts new file mode 100644 index 00000000000..7e0628960e1 --- /dev/null +++ b/mobile/src/transport/mobile-e2ee-legacy-fixtures.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest' +import nacl from 'tweetnacl' +import { MOBILE_E2EE_LEGACY_FIXTURE } from '../../../src/shared/mobile-e2ee-legacy-fixtures' + +vi.mock('expo-crypto', () => ({ + getRandomBytes: (length: number) => new Uint8Array(length).fill(9) +})) + +import { decrypt, decryptBytes, deriveSharedKey } from './e2ee' + +describe('mobile legacy E2EE fixtures', () => { + it('matches the captured desktop key and text/binary frames', () => { + const fixture = MOBILE_E2EE_LEGACY_FIXTURE + const server = nacl.box.keyPair.fromSecretKey(fixture.serverSecretKey) + const client = nacl.box.keyPair.fromSecretKey(fixture.clientSecretKey) + const shared = deriveSharedKey(client.secretKey, server.publicKey) + + expect(hex(shared)).toBe(fixture.sharedKeyHex) + expect(decrypt(fixture.authFrameB64, shared)).toBe(fixture.authPlaintext) + expect(decryptBytes(fromHex(fixture.binaryFrameHex), shared)).toEqual(fixture.binaryPlaintext) + }) +}) + +function hex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') +} + +function fromHex(value: string): Uint8Array { + return Uint8Array.from(value.match(/../g) ?? [], (byte) => Number.parseInt(byte, 16)) +} diff --git a/mobile/src/transport/mobile-e2ee-v2-client-session.test.ts b/mobile/src/transport/mobile-e2ee-v2-client-session.test.ts new file mode 100644 index 00000000000..6cff0ef3523 --- /dev/null +++ b/mobile/src/transport/mobile-e2ee-v2-client-session.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from 'vitest' +import nacl from 'tweetnacl' +import { + encodeMobileE2EEV2Transcript, + validateMobileE2EEV2Handshake, + type MobileE2EEV2Ready +} from '../../../src/shared/mobile-e2ee-v2-contract' +import { sealMobileE2EEV2Frame } from '../../../src/shared/mobile-e2ee-v2-framing' + +vi.mock('expo-crypto', () => ({ + getRandomBytes: (length: number) => new Uint8Array(length).fill(9) +})) + +import { deriveSharedKey } from './e2ee' +import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session' +import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule' + +const desktop = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1)) +const client = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2)) + +function setup() { + const session = MobileE2EEV2ClientSession.create({ + desktopPublicKeyB64: Buffer.from(desktop.publicKey).toString('base64'), + transport: 'relay', + relayHostId: 'AbCdEf0123_-xyZ9', + clientNonce: new Uint8Array(32).fill(3), + clientKeyPair: client + }) + const ready: MobileE2EEV2Ready = { + type: 'e2ee_ready', + v: 2, + desktopPublicKeyB64: Buffer.from(desktop.publicKey).toString('base64'), + clientNonceB64: session.hello.clientNonceB64, + desktopNonceB64: Buffer.from(new Uint8Array(32).fill(4)).toString('base64'), + selection: { framing: 2, payloadKinds: ['text', 'binary'] }, + context: session.hello.context + } + return { session, ready } +} + +describe('mobile E2EE v2 client session', () => { + it('pins the desktop key and accepts the exact transcript', () => { + const { session, ready } = setup() + expect(session.acceptReady(ready)).toBe(true) + expect(session.transcriptHashB64).toHaveLength(44) + expect( + session.acceptReady({ + ...ready, + desktopPublicKeyB64: Buffer.from(new Uint8Array(32).fill(8)).toString('base64') + }) + ).toBe(false) + }) + + it('seals auth at counter zero and rejects replayed desktop frames', () => { + const { session, ready } = setup() + expect(session.acceptReady(ready)).toBe(true) + const auth = JSON.stringify({ + type: 'e2ee_auth', + v: 2, + transcriptHashB64: session.transcriptHashB64, + deviceToken: 'token' + }) + const authFrame = Buffer.from(session.sealText(auth), 'base64') + expect(authFrame.subarray(16, 24)).toEqual(Buffer.alloc(8, 0)) + + const handshake = validateMobileE2EEV2Handshake(session.hello, ready)! + const schedule = deriveMobileE2EEV2KeySchedule({ + sharedSecret: deriveSharedKey(desktop.secretKey, client.publicKey), + transcript: encodeMobileE2EEV2Transcript(handshake), + clientNonce: handshake.clientNonce, + desktopNonce: handshake.desktopNonce + }) + const response = sealMobileE2EEV2Frame({ + payload: new TextEncoder().encode('authenticated'), + key: schedule.desktopToMobileKey, + sessionId: schedule.sessionId, + direction: 'desktop-to-mobile', + payloadKind: 'text', + counter: 0n + }) + const encoded = Buffer.from(response).toString('base64') + expect(session.openText(encoded)).toBe('authenticated') + expect(session.openText(encoded)).toBeNull() + }) +}) diff --git a/mobile/src/transport/mobile-e2ee-v2-client-session.ts b/mobile/src/transport/mobile-e2ee-v2-client-session.ts new file mode 100644 index 00000000000..1cce2946860 --- /dev/null +++ b/mobile/src/transport/mobile-e2ee-v2-client-session.ts @@ -0,0 +1,167 @@ +import * as ExpoCrypto from 'expo-crypto' +import { + encodeMobileE2EEV2Transcript, + validateMobileE2EEV2Handshake, + type MobileE2EETransport, + type MobileE2EEV2Hello +} from '../../../src/shared/mobile-e2ee-v2-contract' +import { + openMobileE2EEV2Frame, + sealMobileE2EEV2Frame +} from '../../../src/shared/mobile-e2ee-v2-framing' +import { deriveSharedKey, generateKeyPair, publicKeyFromBase64, publicKeyToBase64 } from './e2ee' +import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule' + +export class MobileE2EEV2ClientSession { + readonly hello: MobileE2EEV2Hello + private inboundCounter = 0n + private outboundCounter = 0n + private schedule: ReturnType | null = null + private transcriptHashB64Value: string | null = null + + private constructor( + private readonly clientSecretKey: Uint8Array, + private readonly pinnedDesktopPublicKey: Uint8Array, + hello: MobileE2EEV2Hello + ) { + this.hello = hello + } + + static create(args: { + desktopPublicKeyB64: string + transport: MobileE2EETransport + relayHostId?: string + clientNonce?: Uint8Array + clientKeyPair?: { publicKey: Uint8Array; secretKey: Uint8Array } + }): MobileE2EEV2ClientSession { + const keyPair = args.clientKeyPair ?? generateKeyPair() + const clientNonce = args.clientNonce ?? ExpoCrypto.getRandomBytes(32) + if (clientNonce.length !== 32) { + throw new Error(`Invalid client nonce length: ${clientNonce.length}`) + } + return new MobileE2EEV2ClientSession( + keyPair.secretKey, + publicKeyFromBase64(args.desktopPublicKeyB64), + { + type: 'e2ee_hello', + v: 2, + clientPublicKeyB64: publicKeyToBase64(keyPair.publicKey), + clientNonceB64: encodeBase64(clientNonce), + capabilities: { framing: [2], payloadKinds: ['text', 'binary'] }, + context: { + protocol: 'orca-mobile-e2ee', + initiator: 'mobile', + responder: 'desktop', + transport: args.transport, + ...(args.relayHostId ? { relayHostId: args.relayHostId } : {}) + } + } + ) + } + + acceptReady(ready: unknown): boolean { + const handshake = validateMobileE2EEV2Handshake(this.hello, ready) + if (!handshake || !equalBytes(handshake.desktopPublicKey, this.pinnedDesktopPublicKey)) { + return false + } + this.schedule = deriveMobileE2EEV2KeySchedule({ + sharedSecret: deriveSharedKey(this.clientSecretKey, this.pinnedDesktopPublicKey), + transcript: encodeMobileE2EEV2Transcript(handshake), + clientNonce: handshake.clientNonce, + desktopNonce: handshake.desktopNonce + }) + this.transcriptHashB64Value = encodeBase64(this.schedule.transcriptHash) + return true + } + + get transcriptHashB64(): string { + if (!this.transcriptHashB64Value) { + throw new Error('E2EE v2 ready has not been accepted') + } + return this.transcriptHashB64Value + } + + openText(frameB64: string): string | null { + const frame = decodeCanonicalBase64(frameB64) + if (!frame) { + return null + } + const plaintext = this.open(frame, 'text') + return plaintext ? new TextDecoder().decode(plaintext) : null + } + + openBinary(frame: Uint8Array): Uint8Array | null { + return this.open(frame, 'binary') + } + + sealText(plaintext: string): string { + return encodeBase64(this.seal(new TextEncoder().encode(plaintext), 'text')) + } + + sealBinary(plaintext: Uint8Array): Uint8Array { + return this.seal(plaintext, 'binary') + } + + private open(frame: Uint8Array, payloadKind: 'text' | 'binary'): Uint8Array | null { + if (!this.schedule) { + return null + } + const plaintext = openMobileE2EEV2Frame({ + frame, + key: this.schedule.desktopToMobileKey, + sessionId: this.schedule.sessionId, + direction: 'desktop-to-mobile', + payloadKind, + expectedCounter: this.inboundCounter + }) + if (plaintext) { + this.inboundCounter++ + } + return plaintext + } + + private seal(plaintext: Uint8Array, payloadKind: 'text' | 'binary'): Uint8Array { + if (!this.schedule) { + throw new Error('E2EE v2 ready has not been accepted') + } + const frame = sealMobileE2EEV2Frame({ + payload: plaintext, + key: this.schedule.mobileToDesktopKey, + sessionId: this.schedule.sessionId, + direction: 'mobile-to-desktop', + payloadKind, + counter: this.outboundCounter + }) + this.outboundCounter++ + return frame + } +} + +function encodeBase64(bytes: Uint8Array): string { + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return btoa(binary) +} + +function decodeCanonicalBase64(value: string): Uint8Array | null { + try { + const binary = atob(value) + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)) + return encodeBase64(bytes) === value ? bytes : null + } catch { + return null + } +} + +function equalBytes(left: Uint8Array, right: Uint8Array): boolean { + if (left.length !== right.length) { + return false + } + let difference = 0 + for (let index = 0; index < left.length; index++) { + difference |= left[index]! ^ right[index]! + } + return difference === 0 +} diff --git a/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts b/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts new file mode 100644 index 00000000000..b517dbda6c0 --- /dev/null +++ b/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import nacl from 'tweetnacl' +import { + encodeMobileE2EEV2Transcript, + validateMobileE2EEV2Handshake, + type MobileE2EEV2Ready +} from '../../../src/shared/mobile-e2ee-v2-contract' +import { + openMobileE2EEV2Frame, + sealMobileE2EEV2Frame +} from '../../../src/shared/mobile-e2ee-v2-framing' + +vi.mock('expo-crypto', () => ({ + getRandomBytes: (length: number) => new Uint8Array(length).fill(9) +})) + +import { deriveSharedKey } from './e2ee' +import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session' +import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule' +import { + MobileE2EEV2PhysicalChannel, + type MobileE2EEV2Socket +} from './mobile-e2ee-v2-physical-channel' + +const desktop = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1)) +const client = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2)) + +function setup(decodeBinary: (raw: unknown) => Promise) { + const session = MobileE2EEV2ClientSession.create({ + desktopPublicKeyB64: Buffer.from(desktop.publicKey).toString('base64'), + transport: 'relay', + relayHostId: 'AbCdEf0123_-xyZ9', + clientNonce: new Uint8Array(32).fill(3), + clientKeyPair: client + }) + const sent: (string | Uint8Array)[] = [] + const socket = { + OPEN: 1, + readyState: 1, + bufferedAmount: 0, + send: (frame: string | Uint8Array) => sent.push(frame) + } satisfies MobileE2EEV2Socket + const events: string[] = [] + const onAuthenticated = vi.fn(() => events.push('authenticated')) + const onError = vi.fn() + const channel = new MobileE2EEV2PhysicalChannel({ + session, + socket, + deviceToken: 'valid-token', + decodeBinary, + onAuthenticated, + onText: (plaintext) => events.push(`text:${plaintext}`), + onBinary: (plaintext) => events.push(`binary:${plaintext[0]}`), + onError + }) + channel.start() + + const ready: MobileE2EEV2Ready = { + type: 'e2ee_ready', + v: 2, + desktopPublicKeyB64: Buffer.from(desktop.publicKey).toString('base64'), + clientNonceB64: session.hello.clientNonceB64, + desktopNonceB64: Buffer.from(new Uint8Array(32).fill(4)).toString('base64'), + selection: { framing: 2, payloadKinds: ['text', 'binary'] }, + context: session.hello.context + } + const handshake = validateMobileE2EEV2Handshake(session.hello, ready)! + const schedule = deriveMobileE2EEV2KeySchedule({ + sharedSecret: deriveSharedKey(desktop.secretKey, client.publicKey), + transcript: encodeMobileE2EEV2Transcript(handshake), + clientNonce: handshake.clientNonce, + desktopNonce: handshake.desktopNonce + }) + return { channel, session, socket, sent, events, onAuthenticated, onError, ready, schedule } +} + +function serverFrame( + payload: Uint8Array, + kind: 'text' | 'binary', + counter: bigint, + schedule: ReturnType['schedule'] +): Uint8Array { + return sealMobileE2EEV2Frame({ + payload, + key: schedule.desktopToMobileKey, + sessionId: schedule.sessionId, + direction: 'desktop-to-mobile', + payloadKind: kind, + counter + }) +} + +async function authenticate(ctx: ReturnType): Promise { + await ctx.channel.handleMessage(JSON.stringify(ctx.ready)) + const response = serverFrame( + new TextEncoder().encode( + JSON.stringify({ + type: 'e2ee_authenticated', + v: 2, + transcriptHashB64: ctx.session.transcriptHashB64 + }) + ), + 'text', + 0n, + ctx.schedule + ) + await ctx.channel.handleMessage(Buffer.from(response).toString('base64')) +} + +describe('mobile E2EE v2 physical channel', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('sends hello/auth and confirms the exact transcript', async () => { + const ctx = setup(async () => null) + await authenticate(ctx) + + expect(JSON.parse(ctx.sent[0] as string)).toEqual(ctx.session.hello) + expect(typeof ctx.sent[1]).toBe('string') + expect(ctx.onAuthenticated).toHaveBeenCalledOnce() + expect(ctx.onError).not.toHaveBeenCalled() + }) + + it('serializes delayed binary conversion before a later text counter', async () => { + let releaseBinary!: (bytes: Uint8Array) => void + const pendingBinary = new Promise((resolve) => (releaseBinary = resolve)) + const ctx = setup(async () => pendingBinary) + await authenticate(ctx) + ctx.events.length = 0 + + const binary = serverFrame(new Uint8Array([7]), 'binary', 1n, ctx.schedule) + const text = serverFrame(new TextEncoder().encode('later'), 'text', 2n, ctx.schedule) + const first = ctx.channel.handleMessage({ delayedBlob: true }) + const second = ctx.channel.handleMessage(Buffer.from(text).toString('base64')) + await Promise.resolve() + expect(ctx.events).toEqual([]) + + releaseBinary(binary) + await Promise.all([first, second]) + expect(ctx.events).toEqual(['binary:7', 'text:later']) + expect(ctx.onError).not.toHaveBeenCalled() + }) + + it('queues outbound text and binary in one counter order', async () => { + const ctx = setup(async () => null) + await authenticate(ctx) + ctx.socket.bufferedAmount = 9 * 1024 * 1024 + expect(ctx.channel.sendText('one')).toBe(true) + expect(ctx.channel.sendBinary(new Uint8Array([2]))).toBe(true) + expect(ctx.sent).toHaveLength(2) + + ctx.socket.bufferedAmount = 0 + vi.runOnlyPendingTimers() + expect(typeof ctx.sent[2]).toBe('string') + expect(ctx.sent[3]).toBeInstanceOf(Uint8Array) + expect( + openMobileE2EEV2Frame({ + frame: Buffer.from(ctx.sent[2] as string, 'base64'), + key: ctx.schedule.mobileToDesktopKey, + sessionId: ctx.schedule.sessionId, + direction: 'mobile-to-desktop', + payloadKind: 'text', + expectedCounter: 1n + }) + ).toEqual(new TextEncoder().encode('one')) + expect( + openMobileE2EEV2Frame({ + frame: ctx.sent[3] as Uint8Array, + key: ctx.schedule.mobileToDesktopKey, + sessionId: ctx.schedule.sessionId, + direction: 'mobile-to-desktop', + payloadKind: 'binary', + expectedCounter: 2n + }) + ).toEqual(new Uint8Array([2])) + }) +}) diff --git a/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts b/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts new file mode 100644 index 00000000000..c558285973d --- /dev/null +++ b/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts @@ -0,0 +1,167 @@ +import { + createWsOutboundBackpressureQueue, + type WsOutboundBackpressureQueue +} from '../../../src/shared/ws-outbound-backpressure-queue' +import type { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session' + +type ChannelState = 'awaiting-ready' | 'awaiting-authenticated' | 'ready' +type OutboundItem = { kind: 'text'; plaintext: string } | { kind: 'binary'; plaintext: Uint8Array } + +export type MobileE2EEV2Socket = { + readonly OPEN: number + readonly readyState: number + readonly bufferedAmount: number + send: (frame: string | Uint8Array) => void +} + +export class MobileE2EEV2PhysicalChannel { + private state: ChannelState = 'awaiting-ready' + private generation = 0 + private inboundChain: Promise = Promise.resolve() + private readonly outboundQueue: WsOutboundBackpressureQueue + + constructor( + private readonly args: { + session: MobileE2EEV2ClientSession + socket: MobileE2EEV2Socket + deviceToken: string + decodeBinary: (raw: unknown) => Promise + onAuthenticated: () => void + onText: (plaintext: string) => void + onBinary: (plaintext: Uint8Array) => void + onError: (error: Error) => void + } + ) { + this.outboundQueue = createWsOutboundBackpressureQueue({ + // Why: encryption happens only when an admitted item reaches the wire, + // so a bounded-queue rejection cannot burn an ordered v2 counter. + send: (item) => { + args.socket.send( + item.kind === 'text' + ? args.session.sealText(item.plaintext) + : args.session.sealBinary(item.plaintext) + ) + }, + byteLengthOf: (item) => + (item.kind === 'text' + ? new TextEncoder().encode(item.plaintext).length + : item.plaintext.length) + 82, + getBufferedAmount: () => args.socket.bufferedAmount, + isWritable: () => args.socket.readyState === args.socket.OPEN, + onOverflow: () => args.onError(new Error('E2EE v2 outbound buffer overflow')) + }) + } + + start(): void { + this.args.socket.send(JSON.stringify(this.args.session.hello)) + } + + handleMessage(raw: unknown): Promise { + const generation = this.generation + this.inboundChain = this.inboundChain + .then(() => this.processMessage(raw, generation)) + .catch((error: unknown) => { + if (generation === this.generation) { + this.args.onError(error instanceof Error ? error : new Error(String(error))) + } + }) + return this.inboundChain + } + + sendText(plaintext: string): boolean { + return this.enqueueReady({ kind: 'text', plaintext }) + } + + sendBinary(plaintext: Uint8Array): boolean { + return this.enqueueReady({ kind: 'binary', plaintext }) + } + + dispose(): void { + this.generation++ + this.outboundQueue.dispose() + } + + private async processMessage(raw: unknown, generation: number): Promise { + if (generation !== this.generation) { + return + } + if (this.state === 'awaiting-ready') { + this.acceptReady(raw) + return + } + + const plaintext = + typeof raw === 'string' + ? this.args.session.openText(raw) + : await this.openBinary(raw, generation) + if (generation !== this.generation || plaintext === null) { + return + } + if (this.state === 'awaiting-authenticated') { + if (typeof plaintext !== 'string' || !this.isAuthenticated(plaintext)) { + throw new Error('Invalid E2EE v2 authenticated response') + } + this.state = 'ready' + this.args.onAuthenticated() + } else if (typeof plaintext === 'string') { + this.args.onText(plaintext) + } else { + this.args.onBinary(plaintext) + } + } + + private acceptReady(raw: unknown): void { + if (typeof raw !== 'string') { + throw new Error('Expected plaintext E2EE v2 ready') + } + let ready: unknown + try { + ready = JSON.parse(raw) + } catch { + throw new Error('Invalid E2EE v2 ready JSON') + } + if (!this.args.session.acceptReady(ready)) { + throw new Error('Invalid E2EE v2 ready') + } + this.state = 'awaiting-authenticated' + this.outboundQueue.enqueue({ + kind: 'text', + plaintext: JSON.stringify({ + type: 'e2ee_auth', + v: 2, + transcriptHashB64: this.args.session.transcriptHashB64, + deviceToken: this.args.deviceToken + }) + }) + } + + private async openBinary(raw: unknown, generation: number): Promise { + const bytes = await this.args.decodeBinary(raw) + if (!bytes || generation !== this.generation) { + return null + } + return this.args.session.openBinary(bytes) + } + + private isAuthenticated(plaintext: string): boolean { + try { + const message = JSON.parse(plaintext) as Record + return ( + Object.keys(message).sort().join(',') === 'transcriptHashB64,type,v' && + message.type === 'e2ee_authenticated' && + message.v === 2 && + message.transcriptHashB64 === this.args.session.transcriptHashB64 + ) + } catch { + return false + } + } + + private enqueueReady(item: OutboundItem): boolean { + if (this.state !== 'ready') { + return false + } + this.outboundQueue.enqueue(item) + return true + } +} diff --git a/src/main/runtime/rpc/e2ee-channel-v2.test.ts b/src/main/runtime/rpc/e2ee-channel-v2.test.ts new file mode 100644 index 00000000000..278936642ae --- /dev/null +++ b/src/main/runtime/rpc/e2ee-channel-v2.test.ts @@ -0,0 +1,221 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import nacl from 'tweetnacl' +import type { WebSocket } from 'ws' +import { + encodeMobileE2EEV2Transcript, + validateMobileE2EEV2Handshake, + type MobileE2EEV2Hello, + type MobileE2EEV2Ready +} from '../../../shared/mobile-e2ee-v2-contract' +import { + openMobileE2EEV2Frame, + sealMobileE2EEV2Frame +} from '../../../shared/mobile-e2ee-v2-framing' +import { deriveSharedKey } from './e2ee-crypto' +import { E2EEChannel } from './e2ee-channel' +import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule' + +const server = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1)) +const client = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2)) + +function createMockWs() { + const sent: { data: string | Buffer; options?: { binary?: boolean } }[] = [] + return { + OPEN: 1 as const, + readyState: 1, + bufferedAmount: 0, + send: vi.fn((data: string | Buffer, options?: { binary?: boolean }) => { + sent.push({ data, options }) + }), + sent + } +} + +function hello(): MobileE2EEV2Hello { + return { + type: 'e2ee_hello', + v: 2, + clientPublicKeyB64: Buffer.from(client.publicKey).toString('base64'), + clientNonceB64: Buffer.from(new Uint8Array(32).fill(3)).toString('base64'), + capabilities: { framing: [2], payloadKinds: ['text', 'binary'] }, + context: { + protocol: 'orca-mobile-e2ee', + initiator: 'mobile', + responder: 'desktop', + transport: 'relay', + relayHostId: 'AbCdEf0123_-xyZ9' + } + } +} + +function setup() { + const ws = createMockWs() + const onReady = vi.fn() + const onError = vi.fn() + const validateToken = vi.fn((token: string) => token === 'valid-token') + const channel = new E2EEChannel(ws as unknown as WebSocket, { + serverSecretKey: server.secretKey, + validateToken, + onReady, + onError, + transportContext: { transport: 'relay', relayHostId: 'AbCdEf0123_-xyZ9' }, + requireV2: true + }) + return { ws, channel, onReady, onError, validateToken } +} + +function startV2(ctx: ReturnType) { + const clientHello = hello() + ctx.channel.handleRawMessage(JSON.stringify(clientHello)) + const ready = JSON.parse(ctx.ws.sent[0]!.data.toString()) as MobileE2EEV2Ready + const handshake = validateMobileE2EEV2Handshake(clientHello, ready)! + const schedule = deriveMobileE2EEV2KeySchedule({ + sharedSecret: deriveSharedKey(client.secretKey, server.publicKey), + transcript: encodeMobileE2EEV2Transcript(handshake), + clientNonce: handshake.clientNonce, + desktopNonce: handshake.desktopNonce + }) + return { ready, schedule } +} + +function clientText( + plaintext: string, + schedule: ReturnType['schedule'], + counter: bigint +): string { + const frame = sealMobileE2EEV2Frame({ + payload: new TextEncoder().encode(plaintext), + key: schedule.mobileToDesktopKey, + sessionId: schedule.sessionId, + direction: 'mobile-to-desktop', + payloadKind: 'text', + counter + }) + return Buffer.from(frame).toString('base64') +} + +function openServerFrame( + frame: string | Buffer, + kind: 'text' | 'binary', + schedule: ReturnType['schedule'], + counter: bigint +): Uint8Array | null { + return openMobileE2EEV2Frame({ + frame: typeof frame === 'string' ? Buffer.from(frame, 'base64') : frame, + key: schedule.desktopToMobileKey, + sessionId: schedule.sessionId, + direction: 'desktop-to-mobile', + payloadKind: kind, + expectedCounter: counter + }) +} + +function authenticate( + ctx: ReturnType, + schedule: ReturnType['schedule'] +) { + const transcriptHashB64 = Buffer.from(schedule.transcriptHash).toString('base64') + ctx.channel.handleRawMessage( + clientText( + JSON.stringify({ + type: 'e2ee_auth', + v: 2, + transcriptHashB64, + deviceToken: 'valid-token' + }), + schedule, + 0n + ) + ) +} + +describe('E2EEChannel v2', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('confirms the transcript before evaluating DeviceRegistry auth', () => { + const ctx = setup() + const { schedule } = startV2(ctx) + authenticate(ctx, schedule) + + expect(ctx.validateToken).toHaveBeenCalledOnce() + expect(ctx.onReady).toHaveBeenCalledWith(ctx.channel) + const authenticated = openServerFrame(ctx.ws.sent[1]!.data, 'text', schedule, 0n) + expect(JSON.parse(new TextDecoder().decode(authenticated!))).toEqual({ + type: 'e2ee_authenticated', + v: 2, + transcriptHashB64: Buffer.from(schedule.transcriptHash).toString('base64') + }) + }) + + it('rejects legacy downgrade and injected auth metadata when v2 is required', () => { + const legacy = setup() + legacy.channel.handleRawMessage( + JSON.stringify({ type: 'e2ee_hello', publicKeyB64: 'legacy-key' }) + ) + expect(legacy.onError).toHaveBeenCalledWith(4001, 'E2EE v2 required') + + const ctx = setup() + const { schedule } = startV2(ctx) + const transcriptHashB64 = Buffer.from(schedule.transcriptHash).toString('base64') + ctx.channel.handleRawMessage( + clientText( + JSON.stringify({ + type: 'e2ee_auth', + v: 2, + transcriptHashB64, + deviceToken: 'valid-token', + relayDeviceId: 'injected' + }), + schedule, + 0n + ) + ) + expect(ctx.validateToken).not.toHaveBeenCalled() + }) + + it('rejects a captured auth frame replayed onto a fresh desktop nonce', () => { + const first = setup() + const firstHandshake = startV2(first) + const capturedAuth = clientText( + JSON.stringify({ + type: 'e2ee_auth', + v: 2, + transcriptHashB64: Buffer.from(firstHandshake.schedule.transcriptHash).toString('base64'), + deviceToken: 'valid-token' + }), + firstHandshake.schedule, + 0n + ) + + const second = setup() + const secondHandshake = startV2(second) + expect(secondHandshake.ready.desktopNonceB64).not.toBe(firstHandshake.ready.desktopNonceB64) + second.channel.handleRawMessage(capturedAuth) + expect(second.validateToken).not.toHaveBeenCalled() + expect(second.onReady).not.toHaveBeenCalled() + }) + + it('preserves one queued counter order across text and binary replies', () => { + const ctx = setup() + const { schedule } = startV2(ctx) + authenticate(ctx, schedule) + ctx.ws.bufferedAmount = 9 * 1024 * 1024 + ctx.channel.onMessage((_request, textReply, binaryReply) => { + textReply('one') + binaryReply(new Uint8Array([2])) + }) + ctx.channel.handleRawMessage(clientText('{"method":"status.get"}', schedule, 1n)) + expect(ctx.ws.sent).toHaveLength(2) + + ctx.ws.bufferedAmount = 0 + vi.runOnlyPendingTimers() + expect( + new TextDecoder().decode(openServerFrame(ctx.ws.sent[2]!.data, 'text', schedule, 1n)!) + ).toBe('one') + expect(openServerFrame(ctx.ws.sent[3]!.data, 'binary', schedule, 2n)).toEqual( + new Uint8Array([2]) + ) + expect(ctx.ws.sent[3]!.options).toEqual({ binary: true }) + }) +}) diff --git a/src/main/runtime/rpc/e2ee-channel.ts b/src/main/runtime/rpc/e2ee-channel.ts index 63cc2c0dfcd..cf79f562e2d 100644 --- a/src/main/runtime/rpc/e2ee-channel.ts +++ b/src/main/runtime/rpc/e2ee-channel.ts @@ -7,6 +7,16 @@ import { createWsOutboundBackpressureQueue, type WsOutboundBackpressureQueue } from '../../../shared/ws-outbound-backpressure-queue' +import { + DesktopMobileE2EEV2Session, + type DesktopMobileE2EEV2Context +} from './mobile-e2ee-v2-desktop-session' +import { + createDesktopMobileE2EEV2OutboundQueue, + type DesktopMobileE2EEV2OutboundItem as V2OutboundItem +} from './mobile-e2ee-v2-desktop-outbound' +import { handleDesktopMobileE2EEV2Inbound } from './mobile-e2ee-v2-desktop-inbound' +import { isValidMobileE2EEAuthVersion, type MobileE2EEAuth } from './mobile-e2ee-auth-validation' type ChannelState = 'awaiting_hello' | 'awaiting_auth' | 'ready' @@ -14,21 +24,13 @@ const HANDSHAKE_TIMEOUT_MS = 10_000 const MAX_CONSECUTIVE_DECRYPT_FAILURES = 5 const MAX_BINARY_BUFFERED_AMOUNT = 8 * 1024 * 1024 -type E2EEHello = { - type: 'e2ee_hello' - publicKeyB64: string -} - -type E2EEAuth = { - type: 'e2ee_auth' - deviceToken: string -} - export type E2EEChannelOptions = { serverSecretKey: Uint8Array validateToken: (token: string) => boolean onReady: (channel: E2EEChannel) => void onError: (code: number, reason: string) => void + transportContext?: DesktopMobileE2EEV2Context + requireV2?: boolean } export class E2EEChannel { @@ -41,6 +43,10 @@ export class E2EEChannel { private readonly validateToken: (token: string) => boolean private readonly onReady: (channel: E2EEChannel) => void private readonly onError: (code: number, reason: string) => void + private readonly transportContext: DesktopMobileE2EEV2Context + private readonly requireV2: boolean + private v2Session: DesktopMobileE2EEV2Session | null = null + private v2OutboundQueue: WsOutboundBackpressureQueue | null = null // Why: the RPC handler is set after the channel is ready, so the channel // can forward decrypted messages. Kept as a callback rather than constructor // param because the handler needs the encrypt function for replies. @@ -66,6 +72,8 @@ export class E2EEChannel { this.validateToken = options.validateToken this.onReady = options.onReady this.onError = options.onError + this.transportContext = options.transportContext ?? { transport: 'direct' } + this.requireV2 = options.requireV2 ?? false this.handshakeTimer = setTimeout(() => { this.onError(4002, 'E2EE handshake timeout') @@ -96,12 +104,17 @@ export class E2EEChannel { return } - if (!this.sharedKey) { + if (this.v2Session) { + this.handleV2RawMessage(raw) + return + } + const sharedKey = this.sharedKey + if (!sharedKey) { return } if (typeof raw !== 'string') { - const plaintextBytes = decryptBytes(raw, this.sharedKey) + const plaintextBytes = decryptBytes(raw, sharedKey) if (plaintextBytes === null) { this.trackDecryptFailure() return @@ -115,7 +128,7 @@ export class E2EEChannel { return } - const plaintext = decrypt(raw, this.sharedKey) + const plaintext = decrypt(raw, sharedKey) if (plaintext === null) { this.trackDecryptFailure() return @@ -160,15 +173,37 @@ export class E2EEChannel { } private handleHello(raw: string): void { - let hello: E2EEHello + let hello: Record try { - hello = JSON.parse(raw) as E2EEHello + hello = JSON.parse(raw) as Record } catch { this.onError(4001, 'Invalid handshake message') return } - if (hello.type !== 'e2ee_hello' || !hello.publicKeyB64) { + if (hello.type === 'e2ee_hello' && hello.v === 2) { + const session = DesktopMobileE2EEV2Session.create({ + hello, + serverSecretKey: this.serverSecretKey, + expectedContext: this.transportContext + }) + if (!session) { + this.onError(4001, 'Invalid e2ee_hello v2') + return + } + this.v2Session = session + this.state = 'awaiting_auth' + if (this.ws.readyState === this.ws.OPEN) { + this.ws.send(JSON.stringify(session.ready)) + } + return + } + + if (this.requireV2) { + this.onError(4001, 'E2EE v2 required') + return + } + if (hello.type !== 'e2ee_hello' || typeof hello.publicKeyB64 !== 'string') { this.onError(4001, 'Invalid e2ee_hello') return } @@ -192,16 +227,20 @@ export class E2EEChannel { } private handleAuth(plaintext: string): void { - let auth: E2EEAuth + let auth: MobileE2EEAuth try { - auth = JSON.parse(plaintext) as E2EEAuth + auth = JSON.parse(plaintext) as MobileE2EEAuth } catch { this.sendEncryptedControl({ type: 'e2ee_error', error: { code: 'bad_auth' } }) this.onError(4001, 'Invalid e2ee_auth') return } - if (auth.type !== 'e2ee_auth' || !auth.deviceToken) { + if ( + auth.type !== 'e2ee_auth' || + !auth.deviceToken || + !isValidMobileE2EEAuthVersion(auth, this.v2Session) + ) { this.sendEncryptedControl({ type: 'e2ee_error', error: { code: 'bad_auth' } }) this.onError(4001, 'Invalid e2ee_auth') return @@ -220,10 +259,51 @@ export class E2EEChannel { this.handshakeTimer = null } - this.sendEncryptedControl({ type: 'e2ee_authenticated' }) + this.sendEncryptedControl( + this.v2Session + ? { + type: 'e2ee_authenticated', + v: 2, + transcriptHashB64: this.v2Session.transcriptHashB64 + } + : { type: 'e2ee_authenticated' } + ) this.onReady(this) } + private handleV2RawMessage(raw: string | Uint8Array): void { + handleDesktopMobileE2EEV2Inbound({ + session: this.v2Session!, + raw, + awaitingAuth: this.state === 'awaiting_auth', + onDecryptFailure: () => this.trackDecryptFailure(), + onDecryptSuccess: () => (this.consecutiveFailures = 0), + onAuth: (plaintext) => this.handleAuth(plaintext), + onBinary: (plaintext) => this.binaryMessageHandler?.(plaintext), + onText: (plaintext) => + this.messageHandler?.( + plaintext, + (response) => this.enqueueV2({ kind: 'text', plaintext: response }), + (response) => (this.enqueueV2({ kind: 'binary', plaintext: response }), true) + ), + onProtocolError: () => this.onError(4001, 'Invalid binary message before authentication') + }) + } + + private enqueueV2(item: V2OutboundItem): void { + if (!this.v2Session || this.ws.readyState !== this.ws.OPEN) { + return + } + if (!this.v2OutboundQueue) { + this.v2OutboundQueue = createDesktopMobileE2EEV2OutboundQueue({ + ws: this.ws, + session: this.v2Session, + onOverflow: () => this.onError(1013, 'Outbound reply buffer overflow') + }) + } + this.v2OutboundQueue.enqueue(item) + } + private ensureTextReplyQueue(): WsOutboundBackpressureQueue { if (!this.textReplyQueue) { this.textReplyQueue = createWsOutboundBackpressureQueue({ @@ -241,7 +321,9 @@ export class E2EEChannel { } private sendEncryptedControl(message: unknown): void { - if (this.ws.readyState === this.ws.OPEN && this.sharedKey) { + if (this.v2Session) { + this.enqueueV2({ kind: 'text', plaintext: JSON.stringify(message) }) + } else if (this.ws.readyState === this.ws.OPEN && this.sharedKey) { this.ws.send(encrypt(JSON.stringify(message), this.sharedKey)) } } @@ -252,9 +334,12 @@ export class E2EEChannel { this.handshakeTimer = null } this.sharedKey = null + this.v2Session = null this.messageHandler = null this.binaryMessageHandler = null this.textReplyQueue?.dispose() this.textReplyQueue = null + this.v2OutboundQueue?.dispose() + this.v2OutboundQueue = null } } diff --git a/src/main/runtime/rpc/e2ee-crypto.test.ts b/src/main/runtime/rpc/e2ee-crypto.test.ts index aaa5db016f4..93fc12ff063 100644 --- a/src/main/runtime/rpc/e2ee-crypto.test.ts +++ b/src/main/runtime/rpc/e2ee-crypto.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import nacl from 'tweetnacl' import { generateKeyPair, deriveSharedKey, @@ -7,8 +8,24 @@ import { encryptBytes, decryptBytes } from './e2ee-crypto' +import { MOBILE_E2EE_LEGACY_FIXTURE } from '../../../shared/mobile-e2ee-legacy-fixtures' describe('e2ee-crypto', () => { + it('preserves the captured legacy key and text/binary frame bytes', () => { + const fixture = MOBILE_E2EE_LEGACY_FIXTURE + const server = nacl.box.keyPair.fromSecretKey(fixture.serverSecretKey) + const client = nacl.box.keyPair.fromSecretKey(fixture.clientSecretKey) + const shared = deriveSharedKey(client.secretKey, server.publicKey) + + expect(Buffer.from(server.publicKey).toString('base64')).toBe(fixture.serverPublicKeyB64) + expect(Buffer.from(client.publicKey).toString('base64')).toBe(fixture.clientPublicKeyB64) + expect(Buffer.from(shared).toString('hex')).toBe(fixture.sharedKeyHex) + expect(decrypt(fixture.authFrameB64, shared)).toBe(fixture.authPlaintext) + expect(decryptBytes(Buffer.from(fixture.binaryFrameHex, 'hex'), shared)).toEqual( + fixture.binaryPlaintext + ) + }) + it('encrypt/decrypt round-trips with shared key', () => { const server = generateKeyPair() const client = generateKeyPair() diff --git a/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts b/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts new file mode 100644 index 00000000000..e5039c28794 --- /dev/null +++ b/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts @@ -0,0 +1,22 @@ +import type { DesktopMobileE2EEV2Session } from './mobile-e2ee-v2-desktop-session' + +export type MobileE2EEAuth = { + type: 'e2ee_auth' + deviceToken: string + v?: 2 + transcriptHashB64?: string +} + +export function isValidMobileE2EEAuthVersion( + auth: MobileE2EEAuth, + v2Session: DesktopMobileE2EEV2Session | null +): boolean { + if (!v2Session) { + return auth.v === undefined && auth.transcriptHashB64 === undefined + } + return ( + Object.keys(auth).sort().join(',') === 'deviceToken,transcriptHashB64,type,v' && + auth.v === 2 && + auth.transcriptHashB64 === v2Session.transcriptHashB64 + ) +} diff --git a/src/main/runtime/rpc/mobile-e2ee-v2-desktop-inbound.ts b/src/main/runtime/rpc/mobile-e2ee-v2-desktop-inbound.ts new file mode 100644 index 00000000000..0b64a855905 --- /dev/null +++ b/src/main/runtime/rpc/mobile-e2ee-v2-desktop-inbound.ts @@ -0,0 +1,34 @@ +import type { DesktopMobileE2EEV2Session } from './mobile-e2ee-v2-desktop-session' + +export function handleDesktopMobileE2EEV2Inbound(args: { + session: DesktopMobileE2EEV2Session + raw: string | Uint8Array + awaitingAuth: boolean + onDecryptFailure: () => void + onDecryptSuccess: () => void + onAuth: (plaintext: string) => void + onBinary: (plaintext: Uint8Array) => void + onText: (plaintext: string) => void + onProtocolError: () => void +}): void { + const plaintext = + typeof args.raw === 'string' + ? args.session.openText(args.raw) + : args.session.openBinary(args.raw) + if (plaintext === null) { + args.onDecryptFailure() + return + } + args.onDecryptSuccess() + if (args.awaitingAuth) { + if (typeof plaintext !== 'string') { + args.onProtocolError() + return + } + args.onAuth(plaintext) + } else if (typeof plaintext === 'string') { + args.onText(plaintext) + } else { + args.onBinary(plaintext) + } +} diff --git a/src/main/runtime/rpc/mobile-e2ee-v2-desktop-outbound.ts b/src/main/runtime/rpc/mobile-e2ee-v2-desktop-outbound.ts new file mode 100644 index 00000000000..4dc6d1fc121 --- /dev/null +++ b/src/main/runtime/rpc/mobile-e2ee-v2-desktop-outbound.ts @@ -0,0 +1,35 @@ +import type { WebSocket } from 'ws' +import { + createWsOutboundBackpressureQueue, + type WsOutboundBackpressureQueue +} from '../../../shared/ws-outbound-backpressure-queue' +import type { DesktopMobileE2EEV2Session } from './mobile-e2ee-v2-desktop-session' + +export type DesktopMobileE2EEV2OutboundItem = + | { kind: 'text'; plaintext: string } + | { kind: 'binary'; plaintext: Uint8Array } + +export function createDesktopMobileE2EEV2OutboundQueue(args: { + ws: WebSocket + session: DesktopMobileE2EEV2Session + onOverflow: () => void +}): WsOutboundBackpressureQueue { + return createWsOutboundBackpressureQueue({ + // Why: sealing happens only after queue admission, so counters cannot be + // consumed by an item rejected at the bounded queue boundary. + send: (item) => { + if (item.kind === 'text') { + args.ws.send(args.session.sealText(item.plaintext)) + } else { + args.ws.send(Buffer.from(args.session.sealBinary(item.plaintext)), { binary: true }) + } + }, + byteLengthOf: (item) => + (item.kind === 'text' + ? new TextEncoder().encode(item.plaintext).length + : item.plaintext.length) + 82, + getBufferedAmount: () => args.ws.bufferedAmount, + isWritable: () => args.ws.readyState === args.ws.OPEN, + onOverflow: args.onOverflow + }) +} diff --git a/src/main/runtime/rpc/mobile-e2ee-v2-desktop-session.test.ts b/src/main/runtime/rpc/mobile-e2ee-v2-desktop-session.test.ts new file mode 100644 index 00000000000..70123a9a683 --- /dev/null +++ b/src/main/runtime/rpc/mobile-e2ee-v2-desktop-session.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import nacl from 'tweetnacl' +import { deriveSharedKey } from './e2ee-crypto' +import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule' +import { + encodeMobileE2EEV2Transcript, + validateMobileE2EEV2Handshake, + type MobileE2EEV2Hello +} from '../../../shared/mobile-e2ee-v2-contract' +import { sealMobileE2EEV2Frame } from '../../../shared/mobile-e2ee-v2-framing' +import { DesktopMobileE2EEV2Session } from './mobile-e2ee-v2-desktop-session' + +const server = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1)) +const client = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2)) + +function hello(): MobileE2EEV2Hello { + return { + type: 'e2ee_hello', + v: 2, + clientPublicKeyB64: Buffer.from(client.publicKey).toString('base64'), + clientNonceB64: Buffer.from(new Uint8Array(32).fill(3)).toString('base64'), + capabilities: { framing: [2], payloadKinds: ['text', 'binary'] }, + context: { + protocol: 'orca-mobile-e2ee', + initiator: 'mobile', + responder: 'desktop', + transport: 'relay', + relayHostId: 'AbCdEf0123_-xyZ9' + } + } +} + +describe('desktop mobile E2EE v2 session', () => { + it('creates a fresh ready message and opens exact-next auth counter zero', () => { + const clientHello = hello() + const session = DesktopMobileE2EEV2Session.create({ + hello: clientHello, + serverSecretKey: server.secretKey, + expectedContext: { transport: 'relay', relayHostId: 'AbCdEf0123_-xyZ9' }, + randomBytes: () => new Uint8Array(32).fill(4) + })! + const handshake = validateMobileE2EEV2Handshake(clientHello, session.ready)! + const schedule = deriveMobileE2EEV2KeySchedule({ + sharedSecret: deriveSharedKey(client.secretKey, server.publicKey), + transcript: encodeMobileE2EEV2Transcript(handshake), + clientNonce: handshake.clientNonce, + desktopNonce: handshake.desktopNonce + }) + const auth = JSON.stringify({ + type: 'e2ee_auth', + v: 2, + transcriptHashB64: session.transcriptHashB64, + deviceToken: 'token' + }) + const frame = sealMobileE2EEV2Frame({ + payload: new TextEncoder().encode(auth), + key: schedule.mobileToDesktopKey, + sessionId: schedule.sessionId, + direction: 'mobile-to-desktop', + payloadKind: 'text', + counter: 0n + }) + + expect(session.openText(Buffer.from(frame).toString('base64'))).toBe(auth) + expect(session.openText(Buffer.from(frame).toString('base64'))).toBeNull() + }) + + it('rejects a transport or relayHostId mismatch before deriving keys', () => { + expect( + DesktopMobileE2EEV2Session.create({ + hello: hello(), + serverSecretKey: server.secretKey, + expectedContext: { transport: 'direct' } + }) + ).toBeNull() + }) + + it('shares one outbound counter across text and binary', () => { + const session = DesktopMobileE2EEV2Session.create({ + hello: hello(), + serverSecretKey: server.secretKey, + expectedContext: { transport: 'relay', relayHostId: 'AbCdEf0123_-xyZ9' }, + randomBytes: () => new Uint8Array(32).fill(4) + })! + + const text = Buffer.from(session.sealText('one'), 'base64') + const binary = session.sealBinary(new Uint8Array([2])) + expect(text.subarray(16, 24)).toEqual(Buffer.alloc(8, 0)) + expect(binary.subarray(16, 24)).toEqual(Uint8Array.from(Buffer.from('0000000000000001', 'hex'))) + }) +}) diff --git a/src/main/runtime/rpc/mobile-e2ee-v2-desktop-session.ts b/src/main/runtime/rpc/mobile-e2ee-v2-desktop-session.ts new file mode 100644 index 00000000000..e656d30ed01 --- /dev/null +++ b/src/main/runtime/rpc/mobile-e2ee-v2-desktop-session.ts @@ -0,0 +1,147 @@ +import nacl from 'tweetnacl' +import { + encodeMobileE2EEV2Transcript, + validateMobileE2EEV2Handshake, + type MobileE2EETransport, + type MobileE2EEV2Hello, + type MobileE2EEV2Ready +} from '../../../shared/mobile-e2ee-v2-contract' +import { + openMobileE2EEV2Frame, + sealMobileE2EEV2Frame +} from '../../../shared/mobile-e2ee-v2-framing' +import { deriveSharedKey } from './e2ee-crypto' +import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule' + +export type DesktopMobileE2EEV2Context = { + transport: MobileE2EETransport + relayHostId?: string +} + +export class DesktopMobileE2EEV2Session { + private inboundCounter = 0n + private outboundCounter = 0n + + private constructor( + readonly ready: MobileE2EEV2Ready, + readonly transcriptHashB64: string, + private readonly mobileToDesktopKey: Uint8Array, + private readonly desktopToMobileKey: Uint8Array, + private readonly sessionId: Uint8Array + ) {} + + static create(args: { + hello: unknown + serverSecretKey: Uint8Array + expectedContext: DesktopMobileE2EEV2Context + randomBytes?: (length: number) => Uint8Array + }): DesktopMobileE2EEV2Session | null { + if (!hasExpectedContext(args.hello, args.expectedContext)) { + return null + } + const hello = args.hello as MobileE2EEV2Hello + const serverKeys = nacl.box.keyPair.fromSecretKey(args.serverSecretKey) + const randomBytes = args.randomBytes ?? ((length: number) => nacl.randomBytes(length)) + const ready: MobileE2EEV2Ready = { + type: 'e2ee_ready', + v: 2, + desktopPublicKeyB64: Buffer.from(serverKeys.publicKey).toString('base64'), + clientNonceB64: hello.clientNonceB64, + desktopNonceB64: Buffer.from(randomBytes(32)).toString('base64'), + selection: { framing: 2, payloadKinds: ['text', 'binary'] }, + context: hello.context + } + const handshake = validateMobileE2EEV2Handshake(hello, ready) + if (!handshake) { + return null + } + const sharedSecret = deriveSharedKey(args.serverSecretKey, handshake.clientPublicKey) + const schedule = deriveMobileE2EEV2KeySchedule({ + sharedSecret, + transcript: encodeMobileE2EEV2Transcript(handshake), + clientNonce: handshake.clientNonce, + desktopNonce: handshake.desktopNonce + }) + return new DesktopMobileE2EEV2Session( + ready, + Buffer.from(schedule.transcriptHash).toString('base64'), + schedule.mobileToDesktopKey, + schedule.desktopToMobileKey, + schedule.sessionId + ) + } + + openText(frameB64: string): string | null { + const frame = decodeCanonicalBase64(frameB64) + if (!frame) { + return null + } + const plaintext = this.open(frame, 'text') + return plaintext ? new TextDecoder().decode(plaintext) : null + } + + openBinary(frame: Uint8Array): Uint8Array | null { + return this.open(frame, 'binary') + } + + sealText(plaintext: string): string { + return Buffer.from(this.seal(new TextEncoder().encode(plaintext), 'text')).toString('base64') + } + + sealBinary(plaintext: Uint8Array): Uint8Array { + return this.seal(plaintext, 'binary') + } + + private open(frame: Uint8Array, payloadKind: 'text' | 'binary'): Uint8Array | null { + const plaintext = openMobileE2EEV2Frame({ + frame, + key: this.mobileToDesktopKey, + sessionId: this.sessionId, + direction: 'mobile-to-desktop', + payloadKind, + expectedCounter: this.inboundCounter + }) + if (plaintext) { + this.inboundCounter++ + } + return plaintext + } + + private seal(plaintext: Uint8Array, payloadKind: 'text' | 'binary'): Uint8Array { + const frame = sealMobileE2EEV2Frame({ + payload: plaintext, + key: this.desktopToMobileKey, + sessionId: this.sessionId, + direction: 'desktop-to-mobile', + payloadKind, + counter: this.outboundCounter + }) + this.outboundCounter++ + return frame + } +} + +function hasExpectedContext( + hello: unknown, + expected: DesktopMobileE2EEV2Context +): hello is MobileE2EEV2Hello { + if (typeof hello !== 'object' || hello === null || !('context' in hello)) { + return false + } + const context = (hello as { context?: unknown }).context + if (typeof context !== 'object' || context === null) { + return false + } + const candidate = context as { transport?: unknown; relayHostId?: unknown } + return ( + candidate.transport === expected.transport && candidate.relayHostId === expected.relayHostId + ) +} + +function decodeCanonicalBase64(value: string): Uint8Array | null { + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { + return null + } + const bytes = Buffer.from(value, 'base64') + return bytes.toString('base64') === value ? bytes : null +} diff --git a/src/shared/mobile-e2ee-legacy-fixtures.ts b/src/shared/mobile-e2ee-legacy-fixtures.ts new file mode 100644 index 00000000000..0b8b58e0149 --- /dev/null +++ b/src/shared/mobile-e2ee-legacy-fixtures.ts @@ -0,0 +1,15 @@ +export const MOBILE_E2EE_LEGACY_FIXTURE = { + serverSecretKey: new Uint8Array(32).fill(1), + clientSecretKey: new Uint8Array(32).fill(2), + serverPublicKeyB64: 'pOCSkrZRwni5dyxWn1+puxPZBrRqtoyd+dwrRAn4ogk=', + clientPublicKeyB64: 'zo060cy2M+x7cMF4FKXHbs0CloUFDTRHRboFhw5YfVk=', + sharedKeyHex: '18a99320f3488fa18a04239715d8ee738065e65c3d4b2898522d6c3d4ead588c', + helloText: '{"type":"e2ee_hello","publicKeyB64":"zo060cy2M+x7cMF4FKXHbs0CloUFDTRHRboFhw5YfVk="}', + readyText: '{"type":"e2ee_ready"}', + authPlaintext: '{"type":"e2ee_auth","deviceToken":"legacy-token"}', + authFrameB64: + 'BgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGTxV8uZ+DG8yRMwrsAYDOGAHUylsq9vpyPLHIz5lrt+vb8AFA6SXELR72fhVZpQiC5tDhn3RUuo0CNefKVy/njNg=', + binaryPlaintext: new Uint8Array([0, 1, 2, 127, 128, 255]), + binaryFrameHex: + '060606060606060606060606060606060606060606060606200be03dc6f8c733e1b85657d9a52dfe7af7bc5dda6c' +} as const