mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
feat(mobile): implement replay-safe E2EE v2 sessions
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user