Files
orca/src/shared/mobile-e2ee-v2-framing.ts
T
Jinwoo HongandOrca 77b154d5dd Add Orca Relay desktop and mobile transport (#8536)
* feat(mobile): define relay protocol groundwork

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): implement replay-safe E2EE v2 sessions

Co-authored-by: Orca <help@stably.ai>

* test(auth): lock cloud refresh single-flight

Co-authored-by: Orca <help@stably.ai>

* test(mobile): complete E2EE v2 adversarial coverage

Co-authored-by: Orca <help@stably.ai>

* refactor(runtime): unify mobile socket wiring

Co-authored-by: Orca <help@stably.ai>

* feat(runtime): add relay control and data clients

Co-authored-by: Orca <help@stably.ai>

* feat(runtime): coordinate desktop relay sessions

Co-authored-by: Orca <help@stably.ai>

* fix(auth): fence stale cloud session mutations

Co-authored-by: Orca <help@stably.ai>

* feat(runtime): add relay pairing and durable revoke

Co-authored-by: Orca <help@stably.ai>

* feat(runtime): add relay credential pairing RPCs

Co-authored-by: Orca <help@stably.ai>

* feat(settings): show Orca Relay sign-in status

Co-authored-by: Orca <help@stably.ai>

* test(relay): prove desktop lifecycle and E2EE splice

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): persist relay pairing state

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): race direct and relay pairing

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): recover pairing through relay director

Co-authored-by: Orca <help@stably.ai>

* fix(relay): preserve origin controls during drain

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): recover interrupted relay pairing

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): add stable relay RPC sessions

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): supervise direct and relay endpoints

Co-authored-by: Orca <help@stably.ai>

* Cover mobile relay director fallback matrix

Co-authored-by: Orca <help@stably.ai>

* Fix relay settings component test isolation

Co-authored-by: Orca <help@stably.ai>

* Remove unrelated merge formatting drift

Co-authored-by: Orca <help@stably.ai>

* Update runtime connection count integration assertion

Co-authored-by: Orca <help@stably.ai>

* Run mobile typecheck through pnpm

Co-authored-by: Orca <help@stably.ai>

* feat(relay): gate desktop controls on mobile demand

Co-authored-by: Orca <help@stably.ai>

* test(mobile): cover served relay recovery

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): upgrade direct pairings to relay

Co-authored-by: Orca <help@stably.ai>

* fix(relay): harden mobile reconnect and teardown

Co-authored-by: Orca <help@stably.ai>

* fix(auth): clarify account sign-in state

Co-authored-by: Orca <help@stably.ai>

* fix(auth): polish sign-in completion flow

Co-authored-by: Orca <help@stably.ai>

* fix(auth): clarify sign-out confirmation

Co-authored-by: Orca <help@stably.ai>

* fix(auth): simplify sign-in completion page

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): add per-device pairing connection mode

Co-authored-by: Orca <help@stably.ai>

* fix(mobile): stabilize pairing option layout

Co-authored-by: Orca <help@stably.ai>

* fix(mobile): give pairing choices stable space

Co-authored-by: Orca <help@stably.ai>

* fix(mobile): stabilize pairing QR regeneration

Co-authored-by: Orca <help@stably.ai>

* Animate mobile pairing flow height

Co-authored-by: Orca <help@stably.ai>

* Configure auth in packaged builds

Co-authored-by: Orca <help@stably.ai>

* Make Orca Relay pairing an opt-in beta

Co-authored-by: Orca <help@stably.ai>

* Show Relay beta details on hover

Co-authored-by: Orca <help@stably.ai>

* Refine mobile relay pairing choice

Co-authored-by: Orca <help@stably.ai>

* Polish Orca Relay pairing controls

Co-authored-by: Orca <help@stably.ai>

* Keep mobile contract fallback test additive

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-14 11:47:05 -07:00

142 lines
4.3 KiB
TypeScript

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
}