mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
feat(mobile): define relay protocol groundwork
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
+9
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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<typeof PairingOfferSchema>
|
||||
|
||||
export type ConnectionLogLevel = 'info' | 'success' | 'warn' | 'error'
|
||||
|
||||
export type ConnectionLogEntry = {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isExactRecord(value: unknown, keys: readonly string[]): value is Record<string, unknown> {
|
||||
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])
|
||||
}
|
||||
@@ -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
|
||||
@@ -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<Parameters<typeof openMobileE2EEV2Frame>[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]))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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<typeof PairingOfferSchema>
|
||||
export type PairingRelay = NonNullable<PairingOffer['relay']>
|
||||
+7
-17
@@ -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<typeof PairingOfferSchema>
|
||||
export { PAIRING_OFFER_VERSION, PairingOfferSchema }
|
||||
export type { PairingOffer }
|
||||
|
||||
export function encodePairingOffer(offer: PairingOffer): string {
|
||||
const json = JSON.stringify(offer)
|
||||
|
||||
Reference in New Issue
Block a user