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

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