From cfdf645b0d9a7718daa47967706d2c1914776f21 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 12 Jul 2026 21:14:18 -0400 Subject: [PATCH] feat(mobile): define relay protocol groundwork Co-authored-by: Orca --- mobile/package.json | 1 + mobile/pnpm-lock.yaml | 9 + .../mobile-e2ee-v2-key-schedule.test.ts | 32 ++ .../transport/mobile-e2ee-v2-key-schedule.ts | 48 +++ .../mobile-relay-pairing-offer.test.ts | 27 ++ mobile/src/transport/types.ts | 18 +- .../rpc/mobile-e2ee-v2-key-schedule.test.ts | 34 +++ .../rpc/mobile-e2ee-v2-key-schedule.ts | 53 ++++ src/shared/mobile-e2ee-v2-contract.test.ts | 79 +++++ src/shared/mobile-e2ee-v2-contract.ts | 280 ++++++++++++++++++ src/shared/mobile-e2ee-v2-fixtures.ts | 47 +++ src/shared/mobile-e2ee-v2-framing.test.ts | 87 ++++++ src/shared/mobile-e2ee-v2-framing.ts | 141 +++++++++ src/shared/mobile-relay-close-codes.test.ts | 45 +++ src/shared/mobile-relay-close-codes.ts | 51 ++++ src/shared/mobile-relay-pairing-fixtures.ts | 126 ++++++++ src/shared/mobile-relay-pairing-offer.test.ts | 15 + src/shared/mobile-relay-pairing-offer.ts | 90 ++++++ src/shared/pairing.ts | 24 +- 19 files changed, 1179 insertions(+), 28 deletions(-) create mode 100644 mobile/src/transport/mobile-e2ee-v2-key-schedule.test.ts create mode 100644 mobile/src/transport/mobile-e2ee-v2-key-schedule.ts create mode 100644 mobile/src/transport/mobile-relay-pairing-offer.test.ts create mode 100644 src/main/runtime/rpc/mobile-e2ee-v2-key-schedule.test.ts create mode 100644 src/main/runtime/rpc/mobile-e2ee-v2-key-schedule.ts create mode 100644 src/shared/mobile-e2ee-v2-contract.test.ts create mode 100644 src/shared/mobile-e2ee-v2-contract.ts create mode 100644 src/shared/mobile-e2ee-v2-fixtures.ts create mode 100644 src/shared/mobile-e2ee-v2-framing.test.ts create mode 100644 src/shared/mobile-e2ee-v2-framing.ts create mode 100644 src/shared/mobile-relay-close-codes.test.ts create mode 100644 src/shared/mobile-relay-close-codes.ts create mode 100644 src/shared/mobile-relay-pairing-fixtures.ts create mode 100644 src/shared/mobile-relay-pairing-offer.test.ts create mode 100644 src/shared/mobile-relay-pairing-offer.ts diff --git a/mobile/package.json b/mobile/package.json index 66abfe67e5f..324ec971762 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -17,6 +17,7 @@ "start:emulator": "node scripts/start-emulator.mjs" }, "dependencies": { + "@noble/hashes": "1.8.0", "@orca/expo-two-way-audio": "file:./packages/expo-two-way-audio", "@react-native-async-storage/async-storage": "^2.2.0", "@xterm/addon-unicode11": "0.10.0-beta.285", diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 224511d38c5..60eafe80569 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: .: dependencies: + '@noble/hashes': + specifier: 1.8.0 + version: 1.8.0 '@orca/expo-two-way-audio': specifier: file:./packages/expo-two-way-audio version: file:packages/expo-two-way-audio(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) @@ -1878,6 +1881,10 @@ packages: '@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3': resolution: {integrity: sha512-s88O1aVtXftvp5bCPB7WnmXc5IwOZZ7YPuwNPt+GtOOXpPvad1LfbmjYv+qII7zP6RU2QGnqve27dnLycEnyEQ==} + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@orca/expo-two-way-audio@file:packages/expo-two-way-audio': resolution: {directory: packages/expo-two-way-audio, type: directory} peerDependencies: @@ -8809,6 +8816,8 @@ snapshots: '@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3': optional: true + '@noble/hashes@1.8.0': {} + '@orca/expo-two-way-audio@file:packages/expo-two-way-audio(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': dependencies: expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3) diff --git a/mobile/src/transport/mobile-e2ee-v2-key-schedule.test.ts b/mobile/src/transport/mobile-e2ee-v2-key-schedule.test.ts new file mode 100644 index 00000000000..3ada394b69c --- /dev/null +++ b/mobile/src/transport/mobile-e2ee-v2-key-schedule.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { + encodeMobileE2EEV2Transcript, + validateMobileE2EEV2Handshake +} from '../../../src/shared/mobile-e2ee-v2-contract' +import { + createMobileE2EEV2Fixture, + MOBILE_E2EE_V2_VECTOR +} from '../../../src/shared/mobile-e2ee-v2-fixtures' +import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule' + +function hex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') +} + +describe('mobile E2EE v2 key schedule', () => { + it('matches the desktop normative HKDF vector', () => { + const { hello, ready, sharedSecret } = createMobileE2EEV2Fixture() + const handshake = validateMobileE2EEV2Handshake(hello, ready)! + const schedule = deriveMobileE2EEV2KeySchedule({ + sharedSecret, + transcript: encodeMobileE2EEV2Transcript(handshake), + clientNonce: handshake.clientNonce, + desktopNonce: handshake.desktopNonce + }) + + expect(hex(schedule.mobileToDesktopKey)).toBe(MOBILE_E2EE_V2_VECTOR.mobileToDesktopKeyHex) + expect(hex(schedule.desktopToMobileKey)).toBe(MOBILE_E2EE_V2_VECTOR.desktopToMobileKeyHex) + expect(hex(schedule.sessionId)).toBe(MOBILE_E2EE_V2_VECTOR.sessionIdHex) + expect(hex(schedule.transcriptHash)).toBe(MOBILE_E2EE_V2_VECTOR.transcriptHashHex) + }) +}) diff --git a/mobile/src/transport/mobile-e2ee-v2-key-schedule.ts b/mobile/src/transport/mobile-e2ee-v2-key-schedule.ts new file mode 100644 index 00000000000..d7a0101693e --- /dev/null +++ b/mobile/src/transport/mobile-e2ee-v2-key-schedule.ts @@ -0,0 +1,48 @@ +import { hkdf } from '@noble/hashes/hkdf' +import { sha256 } from '@noble/hashes/sha256' + +const SALT_LABEL = new TextEncoder().encode('orca-mobile-e2ee/v2/salt\0') +const INFO_LABEL = new TextEncoder().encode('orca-mobile-e2ee/v2/session\0') + +export function deriveMobileE2EEV2KeySchedule(args: { + sharedSecret: Uint8Array + transcript: Uint8Array + clientNonce: Uint8Array + desktopNonce: Uint8Array +}): { + mobileToDesktopKey: Uint8Array + desktopToMobileKey: Uint8Array + sessionId: Uint8Array + transcriptHash: Uint8Array +} { + requireLength(args.sharedSecret, 32, 'shared secret') + requireLength(args.clientNonce, 32, 'client nonce') + requireLength(args.desktopNonce, 32, 'desktop nonce') + + const transcriptHash = sha256(args.transcript) + const salt = sha256(concatBytes([SALT_LABEL, args.clientNonce, args.desktopNonce])) + const info = concatBytes([INFO_LABEL, transcriptHash]) + const expanded = hkdf(sha256, args.sharedSecret, salt, info, 96) + return { + mobileToDesktopKey: expanded.slice(0, 32), + desktopToMobileKey: expanded.slice(32, 64), + sessionId: expanded.slice(64, 96), + transcriptHash + } +} + +function concatBytes(parts: readonly Uint8Array[]): Uint8Array { + const result = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)) + let offset = 0 + for (const part of parts) { + result.set(part, offset) + offset += part.length + } + return result +} + +function requireLength(bytes: Uint8Array, expected: number, label: string): void { + if (bytes.length !== expected) { + throw new Error(`Invalid ${label}: expected ${expected} bytes, got ${bytes.length}`) + } +} diff --git a/mobile/src/transport/mobile-relay-pairing-offer.test.ts b/mobile/src/transport/mobile-relay-pairing-offer.test.ts new file mode 100644 index 00000000000..d042896f469 --- /dev/null +++ b/mobile/src/transport/mobile-relay-pairing-offer.test.ts @@ -0,0 +1,27 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + createMobileRelayPairingFixtures, + encodePairingFixturePayload +} from '../../../src/shared/mobile-relay-pairing-fixtures' +import { decodePairingUrl } from './pairing' + +describe('mobile relay pairing contract', () => { + const now = Date.UTC(2026, 6, 12, 16) + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(now) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + for (const fixture of createMobileRelayPairingFixtures(now)) { + it(fixture.name, () => { + expect(decodePairingUrl(encodePairingFixturePayload(fixture.payload))).toEqual( + fixture.expected + ) + }) + } +}) diff --git a/mobile/src/transport/types.ts b/mobile/src/transport/types.ts index 1140c692ecb..a61ca864b81 100644 --- a/mobile/src/transport/types.ts +++ b/mobile/src/transport/types.ts @@ -1,4 +1,11 @@ import { z } from 'zod' +import { + PairingOfferSchema, + type PairingOffer +} from '../../../src/shared/mobile-relay-pairing-offer' + +export { PairingOfferSchema } +export type { PairingOffer } export type RpcRequest = { id: string @@ -24,17 +31,6 @@ export type RpcFailure = { export type RpcResponse = RpcSuccess | RpcFailure -const PAIRING_OFFER_VERSION = 2 - -export const PairingOfferSchema = z.object({ - v: z.literal(PAIRING_OFFER_VERSION), - endpoint: z.string().min(1), - deviceToken: z.string().min(1), - publicKeyB64: z.string().min(1) -}) - -export type PairingOffer = z.infer - export type ConnectionLogLevel = 'info' | 'success' | 'warn' | 'error' export type ConnectionLogEntry = { diff --git a/src/main/runtime/rpc/mobile-e2ee-v2-key-schedule.test.ts b/src/main/runtime/rpc/mobile-e2ee-v2-key-schedule.test.ts new file mode 100644 index 00000000000..12034987d34 --- /dev/null +++ b/src/main/runtime/rpc/mobile-e2ee-v2-key-schedule.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { + encodeMobileE2EEV2Transcript, + validateMobileE2EEV2Handshake +} from '../../../shared/mobile-e2ee-v2-contract' +import { + createMobileE2EEV2Fixture, + MOBILE_E2EE_V2_VECTOR +} from '../../../shared/mobile-e2ee-v2-fixtures' +import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule' + +describe('desktop mobile E2EE v2 key schedule', () => { + it('derives the normative 96-byte HKDF vector', () => { + const { hello, ready, sharedSecret } = createMobileE2EEV2Fixture() + const handshake = validateMobileE2EEV2Handshake(hello, ready)! + const schedule = deriveMobileE2EEV2KeySchedule({ + sharedSecret, + transcript: encodeMobileE2EEV2Transcript(handshake), + clientNonce: handshake.clientNonce, + desktopNonce: handshake.desktopNonce + }) + + expect(Buffer.from(schedule.mobileToDesktopKey).toString('hex')).toBe( + MOBILE_E2EE_V2_VECTOR.mobileToDesktopKeyHex + ) + expect(Buffer.from(schedule.desktopToMobileKey).toString('hex')).toBe( + MOBILE_E2EE_V2_VECTOR.desktopToMobileKeyHex + ) + expect(Buffer.from(schedule.sessionId).toString('hex')).toBe(MOBILE_E2EE_V2_VECTOR.sessionIdHex) + expect(Buffer.from(schedule.transcriptHash).toString('hex')).toBe( + MOBILE_E2EE_V2_VECTOR.transcriptHashHex + ) + }) +}) diff --git a/src/main/runtime/rpc/mobile-e2ee-v2-key-schedule.ts b/src/main/runtime/rpc/mobile-e2ee-v2-key-schedule.ts new file mode 100644 index 00000000000..ec61a109b23 --- /dev/null +++ b/src/main/runtime/rpc/mobile-e2ee-v2-key-schedule.ts @@ -0,0 +1,53 @@ +import { createHash, hkdfSync } from 'node:crypto' + +const SALT_LABEL = new TextEncoder().encode('orca-mobile-e2ee/v2/salt\0') +const INFO_LABEL = new TextEncoder().encode('orca-mobile-e2ee/v2/session\0') + +export type MobileE2EEV2KeySchedule = { + mobileToDesktopKey: Uint8Array + desktopToMobileKey: Uint8Array + sessionId: Uint8Array + transcriptHash: Uint8Array +} + +export function deriveMobileE2EEV2KeySchedule(args: { + sharedSecret: Uint8Array + transcript: Uint8Array + clientNonce: Uint8Array + desktopNonce: Uint8Array +}): MobileE2EEV2KeySchedule { + requireLength(args.sharedSecret, 32, 'shared secret') + requireLength(args.clientNonce, 32, 'client nonce') + requireLength(args.desktopNonce, 32, 'desktop nonce') + + const transcriptHash = sha256(args.transcript) + const salt = sha256(concatBytes([SALT_LABEL, args.clientNonce, args.desktopNonce])) + const info = concatBytes([INFO_LABEL, transcriptHash]) + const expanded = new Uint8Array(hkdfSync('sha256', args.sharedSecret, salt, info, 96)) + return { + mobileToDesktopKey: expanded.slice(0, 32), + desktopToMobileKey: expanded.slice(32, 64), + sessionId: expanded.slice(64, 96), + transcriptHash + } +} + +function sha256(bytes: Uint8Array): Uint8Array { + return new Uint8Array(createHash('sha256').update(bytes).digest()) +} + +function concatBytes(parts: readonly Uint8Array[]): Uint8Array { + const result = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)) + let offset = 0 + for (const part of parts) { + result.set(part, offset) + offset += part.length + } + return result +} + +function requireLength(bytes: Uint8Array, expected: number, label: string): void { + if (bytes.length !== expected) { + throw new Error(`Invalid ${label}: expected ${expected} bytes, got ${bytes.length}`) + } +} diff --git a/src/shared/mobile-e2ee-v2-contract.test.ts b/src/shared/mobile-e2ee-v2-contract.test.ts new file mode 100644 index 00000000000..4e263c96089 --- /dev/null +++ b/src/shared/mobile-e2ee-v2-contract.test.ts @@ -0,0 +1,79 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { + encodeMobileE2EEV2Transcript, + validateMobileE2EEV2Handshake +} from './mobile-e2ee-v2-contract' +import { createMobileE2EEV2Fixture, MOBILE_E2EE_V2_VECTOR } from './mobile-e2ee-v2-fixtures' + +describe('mobile E2EE v2 contract', () => { + it('validates the exact relay handshake and canonical encodings', () => { + const { hello, ready } = createMobileE2EEV2Fixture() + expect(validateMobileE2EEV2Handshake(hello, ready)).not.toBeNull() + }) + + it('rejects unknown fields, noncanonical base64, and an unechoed nonce', () => { + const { hello, ready } = createMobileE2EEV2Fixture() + expect(validateMobileE2EEV2Handshake({ ...hello, extra: true }, ready)).toBeNull() + expect( + validateMobileE2EEV2Handshake( + { ...hello, clientPublicKeyB64: hello.clientPublicKeyB64.replace(/=$/, '') }, + ready + ) + ).toBeNull() + expect( + validateMobileE2EEV2Handshake(hello, { + ...ready, + clientNonceB64: btoa(String.fromCharCode(...new Uint8Array(32).fill(9))) + }) + ).toBeNull() + }) + + it('rejects context and capability-selection changes', () => { + const { hello, ready } = createMobileE2EEV2Fixture() + expect( + validateMobileE2EEV2Handshake(hello, { + ...ready, + context: { ...ready.context, relayHostId: 'ZbCdEf0123_-xyZ9' } + }) + ).toBeNull() + expect( + validateMobileE2EEV2Handshake( + { ...hello, capabilities: { framing: [2], payloadKinds: ['binary', 'text'] } }, + ready + ) + ).toBeNull() + }) + + it('requires relayHostId only for relay context', () => { + const { hello, ready } = createMobileE2EEV2Fixture() + const relayWithoutId = { ...hello.context } + delete (relayWithoutId as { relayHostId?: string }).relayHostId + expect(validateMobileE2EEV2Handshake({ ...hello, context: relayWithoutId }, ready)).toBeNull() + + const directContext = { + protocol: 'orca-mobile-e2ee' as const, + initiator: 'mobile' as const, + responder: 'desktop' as const, + transport: 'direct' as const + } + expect( + validateMobileE2EEV2Handshake( + { ...hello, context: directContext }, + { ...ready, context: directContext } + ) + ).not.toBeNull() + }) + + it('locks the canonical length-prefixed transcript bytes', () => { + const { hello, ready } = createMobileE2EEV2Fixture() + const handshake = validateMobileE2EEV2Handshake(hello, ready) + expect(handshake).not.toBeNull() + const transcript = encodeMobileE2EEV2Transcript(handshake!) + + expect(transcript).toHaveLength(MOBILE_E2EE_V2_VECTOR.transcriptLength) + expect(createHash('sha256').update(transcript).digest('hex')).toBe( + MOBILE_E2EE_V2_VECTOR.transcriptHashHex + ) + }) +}) diff --git a/src/shared/mobile-e2ee-v2-contract.ts b/src/shared/mobile-e2ee-v2-contract.ts new file mode 100644 index 00000000000..d687779b828 --- /dev/null +++ b/src/shared/mobile-e2ee-v2-contract.ts @@ -0,0 +1,280 @@ +export const MOBILE_E2EE_V2_PROTOCOL = 'orca-mobile-e2ee' +export const MOBILE_E2EE_V2_TRANSCRIPT_DOMAIN = 'orca-mobile-e2ee/v2/transcript' + +export type MobileE2EETransport = 'direct' | 'relay' +export type MobileE2EEPayloadKind = 'text' | 'binary' + +export type MobileE2EEV2Context = { + protocol: typeof MOBILE_E2EE_V2_PROTOCOL + initiator: 'mobile' + responder: 'desktop' + transport: MobileE2EETransport + relayHostId?: string +} + +export type MobileE2EEV2Hello = { + type: 'e2ee_hello' + v: 2 + clientPublicKeyB64: string + clientNonceB64: string + capabilities: { framing: [2]; payloadKinds: ['text', 'binary'] } + context: MobileE2EEV2Context +} + +export type MobileE2EEV2Ready = { + type: 'e2ee_ready' + v: 2 + desktopPublicKeyB64: string + clientNonceB64: string + desktopNonceB64: string + selection: { framing: 2; payloadKinds: ['text', 'binary'] } + context: MobileE2EEV2Context +} + +export type MobileE2EEV2Handshake = { + hello: MobileE2EEV2Hello + ready: MobileE2EEV2Ready + clientPublicKey: Uint8Array + desktopPublicKey: Uint8Array + clientNonce: Uint8Array + desktopNonce: Uint8Array +} + +const BASE64URL_16_PATTERN = /^[A-Za-z0-9_-]{16}$/ + +export function validateMobileE2EEV2Handshake( + helloValue: unknown, + readyValue: unknown +): MobileE2EEV2Handshake | null { + if ( + !isExactRecord(helloValue, [ + 'type', + 'v', + 'clientPublicKeyB64', + 'clientNonceB64', + 'capabilities', + 'context' + ]) + ) { + return null + } + if ( + !isExactRecord(readyValue, [ + 'type', + 'v', + 'desktopPublicKeyB64', + 'clientNonceB64', + 'desktopNonceB64', + 'selection', + 'context' + ]) + ) { + return null + } + if (helloValue.type !== 'e2ee_hello' || helloValue.v !== 2) { + return null + } + if (readyValue.type !== 'e2ee_ready' || readyValue.v !== 2) { + return null + } + if (!hasExactCapabilities(helloValue.capabilities) || !hasExactSelection(readyValue.selection)) { + return null + } + const helloContext = parseContext(helloValue.context) + const readyContext = parseContext(readyValue.context) + if (!helloContext || !readyContext || !contextsEqual(helloContext, readyContext)) { + return null + } + if (readyValue.clientNonceB64 !== helloValue.clientNonceB64) { + return null + } + + const clientPublicKey = decodeCanonicalBase64Bytes(helloValue.clientPublicKeyB64, 32) + const desktopPublicKey = decodeCanonicalBase64Bytes(readyValue.desktopPublicKeyB64, 32) + const clientNonce = decodeCanonicalBase64Bytes(helloValue.clientNonceB64, 32) + const desktopNonce = decodeCanonicalBase64Bytes(readyValue.desktopNonceB64, 32) + if (!clientPublicKey || !desktopPublicKey || !clientNonce || !desktopNonce) { + return null + } + + return { + hello: helloValue as MobileE2EEV2Hello, + ready: readyValue as MobileE2EEV2Ready, + clientPublicKey, + desktopPublicKey, + clientNonce, + desktopNonce + } +} + +export function encodeMobileE2EEV2Transcript(handshake: MobileE2EEV2Handshake): Uint8Array { + const { hello, ready } = handshake + const fields: [string, Uint8Array][] = [ + ['domain', utf8(MOBILE_E2EE_V2_TRANSCRIPT_DOMAIN)], + ['mobile-to-desktop.type', utf8(hello.type)], + ['mobile-to-desktop.version', uint32(hello.v)], + ['mobile-to-desktop.client-public-key', handshake.clientPublicKey], + ['mobile-to-desktop.client-nonce', handshake.clientNonce], + ['mobile-to-desktop.capabilities.framing', encodeNumberList(hello.capabilities.framing)], + [ + 'mobile-to-desktop.capabilities.payload-kinds', + encodeStringList(hello.capabilities.payloadKinds) + ], + ['mobile-to-desktop.context.protocol', utf8(hello.context.protocol)], + ['mobile-to-desktop.context.initiator', utf8(hello.context.initiator)], + ['mobile-to-desktop.context.responder', utf8(hello.context.responder)], + ['mobile-to-desktop.context.transport', utf8(hello.context.transport)], + ['mobile-to-desktop.context.relay-host-id', utf8(hello.context.relayHostId ?? '')], + ['desktop-to-mobile.type', utf8(ready.type)], + ['desktop-to-mobile.version', uint32(ready.v)], + ['desktop-to-mobile.desktop-public-key', handshake.desktopPublicKey], + ['desktop-to-mobile.client-nonce-echo', handshake.clientNonce], + ['desktop-to-mobile.desktop-nonce', handshake.desktopNonce], + ['desktop-to-mobile.selection.framing', uint32(ready.selection.framing)], + ['desktop-to-mobile.selection.payload-kinds', encodeStringList(ready.selection.payloadKinds)], + ['desktop-to-mobile.context.protocol', utf8(ready.context.protocol)], + ['desktop-to-mobile.context.initiator', utf8(ready.context.initiator)], + ['desktop-to-mobile.context.responder', utf8(ready.context.responder)], + ['desktop-to-mobile.context.transport', utf8(ready.context.transport)], + ['desktop-to-mobile.context.relay-host-id', utf8(ready.context.relayHostId ?? '')] + ] + return concatBytes( + fields.map(([name, value]) => + concatBytes([uint32(utf8(name).length), utf8(name), uint32(value.length), value]) + ) + ) +} + +function parseContext(value: unknown): MobileE2EEV2Context | null { + if (!isRecord(value)) { + return null + } + const transport = value.transport + const keys = + transport === 'relay' + ? ['protocol', 'initiator', 'responder', 'transport', 'relayHostId'] + : ['protocol', 'initiator', 'responder', 'transport'] + if (!isExactRecord(value, keys)) { + return null + } + if ( + value.protocol !== MOBILE_E2EE_V2_PROTOCOL || + value.initiator !== 'mobile' || + value.responder !== 'desktop' || + (transport !== 'direct' && transport !== 'relay') + ) { + return null + } + if ( + transport === 'relay' && + (typeof value.relayHostId !== 'string' || !BASE64URL_16_PATTERN.test(value.relayHostId)) + ) { + return null + } + return value as MobileE2EEV2Context +} + +function hasExactCapabilities(value: unknown): boolean { + return ( + isExactRecord(value, ['framing', 'payloadKinds']) && + Array.isArray(value.framing) && + value.framing.length === 1 && + value.framing[0] === 2 && + Array.isArray(value.payloadKinds) && + value.payloadKinds.length === 2 && + value.payloadKinds[0] === 'text' && + value.payloadKinds[1] === 'binary' + ) +} + +function hasExactSelection(value: unknown): boolean { + return ( + isExactRecord(value, ['framing', 'payloadKinds']) && + value.framing === 2 && + Array.isArray(value.payloadKinds) && + value.payloadKinds.length === 2 && + value.payloadKinds[0] === 'text' && + value.payloadKinds[1] === 'binary' + ) +} + +function contextsEqual(left: MobileE2EEV2Context, right: MobileE2EEV2Context): boolean { + return ( + left.protocol === right.protocol && + left.initiator === right.initiator && + left.responder === right.responder && + left.transport === right.transport && + left.relayHostId === right.relayHostId + ) +} + +function decodeCanonicalBase64Bytes(value: unknown, length: number): Uint8Array | null { + if ( + typeof value !== 'string' || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value) + ) { + return null + } + try { + const binary = atob(value) + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)) + return bytes.length === length && encodeBase64(bytes) === value ? bytes : null + } catch { + return null + } +} + +function encodeBase64(bytes: Uint8Array): string { + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return btoa(binary) +} + +function encodeNumberList(values: readonly number[]): Uint8Array { + return concatBytes([uint32(values.length), ...values.map(uint32)]) +} + +function encodeStringList(values: readonly string[]): Uint8Array { + return concatBytes([ + uint32(values.length), + ...values.map((value) => { + const bytes = utf8(value) + return concatBytes([uint32(bytes.length), bytes]) + }) + ]) +} + +function uint32(value: number): Uint8Array { + const bytes = new Uint8Array(4) + new DataView(bytes.buffer).setUint32(0, value, false) + return bytes +} + +function utf8(value: string): Uint8Array { + return new TextEncoder().encode(value) +} + +function concatBytes(parts: readonly Uint8Array[]): Uint8Array { + const result = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)) + let offset = 0 + for (const part of parts) { + result.set(part, offset) + offset += part.length + } + return result +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isExactRecord(value: unknown, keys: readonly string[]): value is Record { + if (!isRecord(value)) { + return false + } + const actual = Object.keys(value).sort() + const expected = [...keys].sort() + return actual.length === expected.length && actual.every((key, index) => key === expected[index]) +} diff --git a/src/shared/mobile-e2ee-v2-fixtures.ts b/src/shared/mobile-e2ee-v2-fixtures.ts new file mode 100644 index 00000000000..f1806223ef8 --- /dev/null +++ b/src/shared/mobile-e2ee-v2-fixtures.ts @@ -0,0 +1,47 @@ +import type { MobileE2EEV2Hello, MobileE2EEV2Ready } from './mobile-e2ee-v2-contract' + +function repeatedByteBase64(byte: number): string { + return btoa(String.fromCharCode(...new Uint8Array(32).fill(byte))) +} + +export function createMobileE2EEV2Fixture(): { + hello: MobileE2EEV2Hello + ready: MobileE2EEV2Ready + sharedSecret: Uint8Array +} { + const context = { + protocol: 'orca-mobile-e2ee' as const, + initiator: 'mobile' as const, + responder: 'desktop' as const, + transport: 'relay' as const, + relayHostId: 'AbCdEf0123_-xyZ9' + } + return { + hello: { + type: 'e2ee_hello', + v: 2, + clientPublicKeyB64: repeatedByteBase64(1), + clientNonceB64: repeatedByteBase64(2), + capabilities: { framing: [2], payloadKinds: ['text', 'binary'] }, + context + }, + ready: { + type: 'e2ee_ready', + v: 2, + desktopPublicKeyB64: repeatedByteBase64(3), + clientNonceB64: repeatedByteBase64(2), + desktopNonceB64: repeatedByteBase64(4), + selection: { framing: 2, payloadKinds: ['text', 'binary'] }, + context + }, + sharedSecret: new Uint8Array(32).fill(5) + } +} + +export const MOBILE_E2EE_V2_VECTOR = { + transcriptLength: 1347, + transcriptHashHex: 'ca6385f8bbf64a223fdd59587bfb67e2373891ce9e6d85ab41df8b7a20a168e3', + mobileToDesktopKeyHex: 'df17ff534df77fd3a30999f4e6200c8fcedefbb15d369301ca62c3cdfea9559a', + desktopToMobileKeyHex: '71365fcf8212a6d63caf909ee28de3c8f689682ef298a374136055e0ab1cde4a', + sessionIdHex: '339ae1f2bdff63481857d2813c2f19dd1f5aa4824705d5e5daeb25dae7b9196e' +} as const diff --git a/src/shared/mobile-e2ee-v2-framing.test.ts b/src/shared/mobile-e2ee-v2-framing.test.ts new file mode 100644 index 00000000000..6567dd77da7 --- /dev/null +++ b/src/shared/mobile-e2ee-v2-framing.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { openMobileE2EEV2Frame, sealMobileE2EEV2Frame } from './mobile-e2ee-v2-framing' + +const key = new Uint8Array(32).fill(7) +const sessionId = new Uint8Array(32).fill(8) +const payload = new TextEncoder().encode('e2ee-auth') + +describe('mobile E2EE v2 framing', () => { + it('round-trips counter-zero auth with the fixed nonce layout', () => { + const frame = sealMobileE2EEV2Frame({ + payload, + key, + sessionId, + direction: 'mobile-to-desktop', + payloadKind: 'text', + counter: 0n + }) + + expect(Buffer.from(frame.subarray(0, 24)).toString('hex')).toBe( + '080808080808080808080808020000000000000000000000' + ) + expect( + openMobileE2EEV2Frame({ + frame, + key, + sessionId, + direction: 'mobile-to-desktop', + payloadKind: 'text', + expectedCounter: 0n + }) + ).toEqual(payload) + }) + + it('rejects replay, gap, reflection, kind confusion, and cross-session frames', () => { + const frame = sealMobileE2EEV2Frame({ + payload, + key, + sessionId, + direction: 'mobile-to-desktop', + payloadKind: 'binary', + counter: 4n + }) + const attempt = (overrides: Partial[0]>) => + openMobileE2EEV2Frame({ + frame, + key, + sessionId, + direction: 'mobile-to-desktop', + payloadKind: 'binary', + expectedCounter: 4n, + ...overrides + }) + + expect(attempt({ expectedCounter: 3n })).toBeNull() + expect(attempt({ expectedCounter: 5n })).toBeNull() + expect(attempt({ direction: 'desktop-to-mobile' })).toBeNull() + expect(attempt({ payloadKind: 'text' })).toBeNull() + expect(attempt({ sessionId: new Uint8Array(32).fill(9) })).toBeNull() + }) + + it('uses one exact-next counter sequence across text and binary kinds', () => { + const kinds = ['text', 'binary', 'text'] as const + const frames = kinds.map((payloadKind, counter) => + sealMobileE2EEV2Frame({ + payload: new Uint8Array([counter]), + key, + sessionId, + direction: 'desktop-to-mobile', + payloadKind, + counter: BigInt(counter) + }) + ) + + for (let counter = 0; counter < frames.length; counter++) { + expect( + openMobileE2EEV2Frame({ + frame: frames[counter]!, + key, + sessionId, + direction: 'desktop-to-mobile', + payloadKind: kinds[counter]!, + expectedCounter: BigInt(counter) + }) + ).toEqual(new Uint8Array([counter])) + } + }) +}) diff --git a/src/shared/mobile-e2ee-v2-framing.ts b/src/shared/mobile-e2ee-v2-framing.ts new file mode 100644 index 00000000000..53ba1e9627e --- /dev/null +++ b/src/shared/mobile-e2ee-v2-framing.ts @@ -0,0 +1,141 @@ +import nacl from 'tweetnacl' +import type { MobileE2EEPayloadKind } from './mobile-e2ee-v2-contract' + +export type MobileE2EEDirection = 'mobile-to-desktop' | 'desktop-to-mobile' + +const NONCE_LENGTH = 24 +const SESSION_ID_LENGTH = 32 +const HEADER_LENGTH = SESSION_ID_LENGTH + 1 + 1 + 8 +const FRAME_VERSION = 2 +const MAX_COUNTER = (1n << 64n) - 1n + +export function sealMobileE2EEV2Frame(args: { + payload: Uint8Array + key: Uint8Array + sessionId: Uint8Array + direction: MobileE2EEDirection + payloadKind: MobileE2EEPayloadKind + counter: bigint +}): Uint8Array { + validateFrameInputs(args.key, args.sessionId, args.counter) + const header = encodeHeader(args) + const nonce = encodeNonce(args) + const plaintext = concatBytes([header, args.payload]) + const ciphertext = nacl.secretbox(plaintext, nonce, args.key) + return concatBytes([nonce, ciphertext]) +} + +export function openMobileE2EEV2Frame(args: { + frame: Uint8Array + key: Uint8Array + sessionId: Uint8Array + direction: MobileE2EEDirection + payloadKind: MobileE2EEPayloadKind + expectedCounter: bigint +}): Uint8Array | null { + validateFrameInputs(args.key, args.sessionId, args.expectedCounter) + if (args.frame.length < NONCE_LENGTH + nacl.secretbox.overheadLength + HEADER_LENGTH) { + return null + } + const expected = { + sessionId: args.sessionId, + direction: args.direction, + payloadKind: args.payloadKind, + counter: args.expectedCounter + } + const nonce = encodeNonce(expected) + if (!equalBytes(args.frame.subarray(0, NONCE_LENGTH), nonce)) { + return null + } + + const plaintext = nacl.secretbox.open(args.frame.subarray(NONCE_LENGTH), nonce, args.key) + if (!plaintext) { + return null + } + const header = encodeHeader(expected) + if (!equalBytes(plaintext.subarray(0, HEADER_LENGTH), header)) { + return null + } + return plaintext.slice(HEADER_LENGTH) +} + +function encodeHeader(args: { + sessionId: Uint8Array + direction: MobileE2EEDirection + payloadKind: MobileE2EEPayloadKind + counter: bigint +}): Uint8Array { + const header = new Uint8Array(HEADER_LENGTH) + header.set(args.sessionId, 0) + header[SESSION_ID_LENGTH] = directionByte(args.direction) + header[SESSION_ID_LENGTH + 1] = payloadKindByte(args.payloadKind) + writeUint64(header, SESSION_ID_LENGTH + 2, args.counter) + return header +} + +function encodeNonce(args: { + sessionId: Uint8Array + direction: MobileE2EEDirection + payloadKind: MobileE2EEPayloadKind + counter: bigint +}): Uint8Array { + const nonce = new Uint8Array(NONCE_LENGTH) + // Why: v2 keys/sessionId are fresh per socket; the fixed layout makes every + // direction/kind/counter nonce unique without relying on another RNG draw. + nonce.set(args.sessionId.subarray(0, 12), 0) + nonce[12] = FRAME_VERSION + nonce[13] = directionByte(args.direction) + nonce[14] = payloadKindByte(args.payloadKind) + nonce[15] = 0 + writeUint64(nonce, 16, args.counter) + return nonce +} + +function directionByte(direction: MobileE2EEDirection): number { + return direction === 'mobile-to-desktop' ? 0 : 1 +} + +function payloadKindByte(kind: MobileE2EEPayloadKind): number { + return kind === 'text' ? 0 : 1 +} + +function validateFrameInputs(key: Uint8Array, sessionId: Uint8Array, counter: bigint): void { + if (key.length !== nacl.secretbox.keyLength) { + throw new Error(`Invalid E2EE v2 key length: ${key.length}`) + } + if (sessionId.length !== SESSION_ID_LENGTH) { + throw new Error(`Invalid E2EE v2 session ID length: ${sessionId.length}`) + } + if (counter < 0n || counter > MAX_COUNTER) { + throw new Error(`Invalid E2EE v2 counter: ${counter}`) + } +} + +function writeUint64(target: Uint8Array, offset: number, value: bigint): void { + let remaining = value + for (let index = 7; index >= 0; index--) { + target[offset + index] = Number(remaining & 0xffn) + remaining >>= 8n + } +} + +function concatBytes(parts: readonly Uint8Array[]): Uint8Array { + const result = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)) + let offset = 0 + for (const part of parts) { + result.set(part, offset) + offset += part.length + } + return result +} + +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/src/shared/mobile-relay-close-codes.test.ts b/src/shared/mobile-relay-close-codes.test.ts new file mode 100644 index 00000000000..829f966c066 --- /dev/null +++ b/src/shared/mobile-relay-close-codes.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { + isMobileRelayCloseCode, + MOBILE_RELAY_CLOSE_CODE, + mobileRelayRecoveryFor +} from './mobile-relay-close-codes' + +describe('mobile relay close-code contract', () => { + it('locks all application close codes', () => { + expect(MOBILE_RELAY_CLOSE_CODE).toEqual({ + BAD_OUTER_CREDENTIAL: 4401, + HOST_OFFLINE: 4404, + PEER_DROPPED: 4408, + WRONG_CELL: 4409, + LIMIT_EXCEEDED: 4429, + DRAINING: 4503 + }) + expect(isMobileRelayCloseCode(4409)).toBe(true) + expect(isMobileRelayCloseCode(1006)).toBe(false) + }) + + it('uses the configured director and never a cell-supplied URL', () => { + expect(mobileRelayRecoveryFor(4503, 'host-control')).toEqual({ + kind: 'resolve-configured-director', + fullJitter: true + }) + }) + + it('separates invite and resume wrong-cell recovery', () => { + expect(mobileRelayRecoveryFor(4409, 'phone-invite')).toEqual({ + kind: 'resolve-invite-through-director-ws', + requireStrictlyNewerEpoch: true + }) + expect(mobileRelayRecoveryFor(4409, 'phone-resume')).toEqual({ + kind: 'resolve-resume-through-director-post' + }) + }) + + it('keeps outer credential failure endpoint-scoped', () => { + expect(mobileRelayRecoveryFor(4401, 'phone-resume')).toEqual({ + kind: 'disable-relay-credential', + directUnaffected: true + }) + }) +}) diff --git a/src/shared/mobile-relay-close-codes.ts b/src/shared/mobile-relay-close-codes.ts new file mode 100644 index 00000000000..6eefb151e52 --- /dev/null +++ b/src/shared/mobile-relay-close-codes.ts @@ -0,0 +1,51 @@ +export const MOBILE_RELAY_CLOSE_CODE = { + BAD_OUTER_CREDENTIAL: 4401, + HOST_OFFLINE: 4404, + PEER_DROPPED: 4408, + WRONG_CELL: 4409, + LIMIT_EXCEEDED: 4429, + DRAINING: 4503 +} as const + +export type MobileRelayCloseCode = + (typeof MOBILE_RELAY_CLOSE_CODE)[keyof typeof MOBILE_RELAY_CLOSE_CODE] +export type MobileRelayLeg = 'host-control' | 'host-data' | 'phone-invite' | 'phone-resume' + +export type MobileRelayRecovery = + | { kind: 'disable-relay-credential'; directUnaffected: true } + | { kind: 'wait-for-host-revival' } + | { kind: 'reconnect-fresh-e2ee'; fullJitter: true } + | { kind: 'resolve-invite-through-director-ws'; requireStrictlyNewerEpoch: true } + | { kind: 'resolve-resume-through-director-post' } + | { kind: 'request-director-assignment' } + | { kind: 'backoff'; fullJitter: true } + | { kind: 'resolve-configured-director'; fullJitter: true } + +export function mobileRelayRecoveryFor( + code: MobileRelayCloseCode, + leg: MobileRelayLeg +): MobileRelayRecovery { + switch (code) { + case MOBILE_RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL: + return { kind: 'disable-relay-credential', directUnaffected: true } + case MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE: + return { kind: 'wait-for-host-revival' } + case MOBILE_RELAY_CLOSE_CODE.PEER_DROPPED: + return { kind: 'reconnect-fresh-e2ee', fullJitter: true } + case MOBILE_RELAY_CLOSE_CODE.WRONG_CELL: + if (leg === 'phone-invite') { + return { kind: 'resolve-invite-through-director-ws', requireStrictlyNewerEpoch: true } + } + return leg === 'phone-resume' + ? { kind: 'resolve-resume-through-director-post' } + : { kind: 'request-director-assignment' } + case MOBILE_RELAY_CLOSE_CODE.LIMIT_EXCEEDED: + return { kind: 'backoff', fullJitter: true } + case MOBILE_RELAY_CLOSE_CODE.DRAINING: + return { kind: 'resolve-configured-director', fullJitter: true } + } +} + +export function isMobileRelayCloseCode(value: number): value is MobileRelayCloseCode { + return Object.values(MOBILE_RELAY_CLOSE_CODE).some((code) => code === value) +} diff --git a/src/shared/mobile-relay-pairing-fixtures.ts b/src/shared/mobile-relay-pairing-fixtures.ts new file mode 100644 index 00000000000..6c4f6073370 --- /dev/null +++ b/src/shared/mobile-relay-pairing-fixtures.ts @@ -0,0 +1,126 @@ +import type { PairingOffer } from './pairing' + +const PUBLIC_KEY_B64 = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' +const INVITE_TOKEN = 'abcdefghijklmnopqrstuvwxyzABCDEFGH012345678' + +export type PairingFixture = { + name: string + payload: unknown + expected: PairingOffer | null +} + +export function createMobileRelayPairingFixtures(now: number): PairingFixture[] { + const directOffer: PairingOffer = { + v: 2, + endpoint: 'ws://192.168.1.10:6768', + deviceToken: 'device-token', + publicKeyB64: PUBLIC_KEY_B64 + } + const relay = { + v: 1 as const, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: INVITE_TOKEN, + inviteExpiresAt: now + 5 * 60 * 1000, + e2eeFraming: 2 as const + } + return [ + { name: 'legacy direct offer', payload: directOffer, expected: directOffer }, + { + name: 'legacy direct offer with mobile scope', + payload: { ...directOffer, scope: 'mobile' }, + expected: { ...directOffer, scope: 'mobile' } + }, + { + name: 'relay offer with absent scope', + payload: { ...directOffer, relay }, + expected: { ...directOffer, relay } + }, + { + name: 'relay offer with mobile scope', + payload: { ...directOffer, scope: 'mobile', relay }, + expected: { ...directOffer, scope: 'mobile', relay } + }, + { + name: 'unknown keys are stripped at both levels', + payload: { ...directOffer, ignored: true, relay: { ...relay, ignored: true } }, + expected: { ...directOffer, relay } + }, + { + name: 'offer-level endpoints are stripped', + payload: { ...directOffer, endpoints: [{ kind: 'relay' }] }, + expected: directOffer + }, + { + name: 'runtime relay is invalid', + payload: { ...directOffer, scope: 'runtime', relay }, + expected: null + }, + { + name: 'relay offer public key must be canonical 32-byte base64', + payload: { ...directOffer, publicKeyB64: 'legacy-nonempty-key', relay }, + expected: null + }, + { + name: 'non-canonical director origin is invalid', + payload: { ...directOffer, relay: { ...relay, directorUrl: 'https://relay.onorca.dev/' } }, + expected: null + }, + { + name: 'non-HTTPS cell origin is invalid', + payload: { ...directOffer, relay: { ...relay, cellUrl: 'http://relay-c1.onorca.dev' } }, + expected: null + }, + { + name: 'fractional assignment epoch is invalid', + payload: { ...directOffer, relay: { ...relay, assignmentEpoch: 1.5 } }, + expected: null + }, + { + name: 'negative assignment epoch is invalid', + payload: { ...directOffer, relay: { ...relay, assignmentEpoch: -1 } }, + expected: null + }, + { + name: 'unsafe assignment epoch is invalid', + payload: { + ...directOffer, + relay: { ...relay, assignmentEpoch: Number.MAX_SAFE_INTEGER + 1 } + }, + expected: null + }, + { + name: 'relay host id length is invalid', + payload: { ...directOffer, relay: { ...relay, relayHostId: 'short' } }, + expected: null + }, + { + name: 'invite token length is invalid', + payload: { ...directOffer, relay: { ...relay, inviteToken: 'short' } }, + expected: null + }, + { + name: 'expired invite is invalid', + payload: { ...directOffer, relay: { ...relay, inviteExpiresAt: now } }, + expected: null + }, + { + name: 'invite beyond ten minutes is invalid', + payload: { ...directOffer, relay: { ...relay, inviteExpiresAt: now + 10 * 60 * 1000 + 1 } }, + expected: null + }, + { + name: 'unsupported E2EE framing is invalid', + payload: { ...directOffer, relay: { ...relay, e2eeFraming: 1 } }, + expected: null + } + ] +} + +export function encodePairingFixturePayload(payload: unknown): string { + const json = JSON.stringify(payload) + const code = Buffer.from(json, 'utf8').toString('base64url') + return `orca://pair?code=${code}` +} diff --git a/src/shared/mobile-relay-pairing-offer.test.ts b/src/shared/mobile-relay-pairing-offer.test.ts new file mode 100644 index 00000000000..d8b78c5622e --- /dev/null +++ b/src/shared/mobile-relay-pairing-offer.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { createPairingOfferSchema } from './mobile-relay-pairing-offer' +import { createMobileRelayPairingFixtures } from './mobile-relay-pairing-fixtures' + +describe('desktop mobile-relay pairing contract', () => { + const now = Date.UTC(2026, 6, 12, 16) + const schema = createPairingOfferSchema(() => now) + + for (const fixture of createMobileRelayPairingFixtures(now)) { + it(fixture.name, () => { + const result = schema.safeParse(fixture.payload) + expect(result.success ? result.data : null).toEqual(fixture.expected) + }) + } +}) diff --git a/src/shared/mobile-relay-pairing-offer.ts b/src/shared/mobile-relay-pairing-offer.ts new file mode 100644 index 00000000000..df16dad35a2 --- /dev/null +++ b/src/shared/mobile-relay-pairing-offer.ts @@ -0,0 +1,90 @@ +import { z } from 'zod' + +export const PAIRING_OFFER_VERSION = 2 +const PairingScopeSchema = z.enum(['mobile', 'runtime']) +const BASE64URL_16_PATTERN = /^[A-Za-z0-9_-]{16}$/ +const BASE64URL_43_PATTERN = /^[A-Za-z0-9_-]{43}$/ +const MAX_RELAY_URL_BYTES = 2048 +const MAX_INVITE_TTL_MS = 10 * 60 * 1000 + +function isCanonicalHttpsOrigin(value: string): boolean { + if (new TextEncoder().encode(value).length > MAX_RELAY_URL_BYTES) { + return false + } + try { + const parsed = new URL(value) + return parsed.protocol === 'https:' && value === parsed.origin + } catch { + return false + } +} + +function isCanonicalBase64Key(value: string): boolean { + if (!/^[A-Za-z0-9+/]{43}=$/.test(value)) { + return false + } + try { + const decoded = atob(value) + return decoded.length === 32 && btoa(decoded) === value + } catch { + return false + } +} + +export function createPairingOfferSchema(now: () => number = () => Date.now()) { + const relaySchema = z.object({ + v: z.literal(1), + directorUrl: z + .string() + .min(1) + .refine(isCanonicalHttpsOrigin, 'Expected canonical HTTPS origin'), + cellUrl: z.string().min(1).refine(isCanonicalHttpsOrigin, 'Expected canonical HTTPS origin'), + assignmentEpoch: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), + relayHostId: z.string().regex(BASE64URL_16_PATTERN), + inviteToken: z.string().regex(BASE64URL_43_PATTERN), + inviteExpiresAt: z + .number() + .int() + .refine((value) => { + const currentTime = now() + return value > currentTime && value <= currentTime + MAX_INVITE_TTL_MS + }, 'Expected a future invite expiry no more than 10 minutes away'), + e2eeFraming: z.literal(2) + }) + + return z + .object({ + v: z.literal(PAIRING_OFFER_VERSION), + endpoint: z.string().min(1), + deviceToken: z.string().min(1), + // Why: the desktop's Curve25519 public key is pinned by the pairing + // offer, while relayHostId is verified from its decoded bytes later. + publicKeyB64: z.string().min(1), + scope: PairingScopeSchema.optional(), + relay: relaySchema.optional() + }) + .superRefine((offer, ctx) => { + if (offer.relay && offer.scope === 'runtime') { + // Why: relay v1 is mobile-only; accepting it on runtime offers would + // imply routing and credential support that client does not have. + ctx.addIssue({ + code: 'custom', + path: ['relay'], + message: 'Relay is invalid for runtime scope' + }) + } + if (offer.relay && !isCanonicalBase64Key(offer.publicKeyB64)) { + // Why: relayHostId is derived from the decoded key bytes, so relay + // offers cannot tolerate the permissive legacy base64 aliases. + ctx.addIssue({ + code: 'custom', + path: ['publicKeyB64'], + message: 'Relay offers require a canonical 32-byte public key' + }) + } + }) +} + +export const PairingOfferSchema = createPairingOfferSchema() +export type PairingOffer = z.infer +export type PairingRelay = NonNullable diff --git a/src/shared/pairing.ts b/src/shared/pairing.ts index 9d8364c470e..c39595bbef2 100644 --- a/src/shared/pairing.ts +++ b/src/shared/pairing.ts @@ -1,21 +1,11 @@ -import { z } from 'zod' +import { + PAIRING_OFFER_VERSION, + PairingOfferSchema, + type PairingOffer +} from './mobile-relay-pairing-offer' -export const PAIRING_OFFER_VERSION = 2 -const PairingScopeSchema = z.enum(['mobile', 'runtime']) - -export const PairingOfferSchema = z.object({ - v: z.literal(PAIRING_OFFER_VERSION), - endpoint: z.string().min(1), - deviceToken: z.string().min(1), - // Why: the desktop's Curve25519 public key, base64-encoded. The mobile client - // uses this to derive a shared secret via ECDH for end-to-end encryption. - publicKeyB64: z.string().min(1), - // Why: advisory UI metadata lets the web client reject phone-QR offers before - // opening a socket; the runtime still authorizes solely from deviceToken. - scope: PairingScopeSchema.optional() -}) - -export type PairingOffer = z.infer +export { PAIRING_OFFER_VERSION, PairingOfferSchema } +export type { PairingOffer } export function encodePairingOffer(offer: PairingOffer): string { const json = JSON.stringify(offer)