Files
orca/src/shared/mobile-e2ee-v2-contract.ts
T
NeilandOrca 879aad7dd6 oom(foundation): bound shared readers/limits + add BoundedMap primitive (#10299)
* oom(01): A1-shared-readers — reintroduce #10179 subset

Files: 18 applied, 0 deleted (from 6eb70d8370)

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

* oom(02): A2-shared-image-media — reintroduce #10179 subset

Files: 7 applied, 0 deleted (from 6eb70d8370)

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

* oom(03): A3-shared-fs-listing — reintroduce #10179 subset

Files: 21 applied, 0 deleted (from 6eb70d8370)

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

* oom(04): A4-shared-remote-relay — reintroduce #10179 subset

Files: 8 applied, 0 deleted (from 6eb70d8370)

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

* oom(05): A5-shared-misc — reintroduce #10179 subset

Files: 28 applied, 0 deleted (from 6eb70d8370)

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

* oom(06): B-shared-wiring — reintroduce #10179 subset

Files: 81 applied, 0 deleted (from 6eb70d8370)

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-24 21:36:57 -07:00

282 lines
8.7 KiB
TypeScript

export const MOBILE_E2EE_V2_PROTOCOL = 'orca-mobile-e2ee'
export const MOBILE_E2EE_V2_TRANSCRIPT_DOMAIN = 'orca-mobile-e2ee/v2/transcript'
export type MobileE2EETransport = 'direct' | 'relay'
export type MobileE2EEPayloadKind = 'text' | 'binary'
export type MobileE2EEV2Context = {
protocol: typeof MOBILE_E2EE_V2_PROTOCOL
initiator: 'mobile'
responder: 'desktop'
transport: MobileE2EETransport
relayHostId?: string
}
export type MobileE2EEV2Hello = {
type: 'e2ee_hello'
v: 2
clientPublicKeyB64: string
clientNonceB64: string
capabilities: { framing: [2]; payloadKinds: ['text', 'binary'] }
context: MobileE2EEV2Context
}
export type MobileE2EEV2Ready = {
type: 'e2ee_ready'
v: 2
desktopPublicKeyB64: string
clientNonceB64: string
desktopNonceB64: string
selection: { framing: 2; payloadKinds: ['text', 'binary'] }
context: MobileE2EEV2Context
}
export type MobileE2EEV2Handshake = {
hello: MobileE2EEV2Hello
ready: MobileE2EEV2Ready
clientPublicKey: Uint8Array
desktopPublicKey: Uint8Array
clientNonce: Uint8Array
desktopNonce: Uint8Array
}
const BASE64URL_16_PATTERN = /^[A-Za-z0-9_-]{16}$/
export function validateMobileE2EEV2Handshake(
helloValue: unknown,
readyValue: unknown
): MobileE2EEV2Handshake | null {
if (
!isExactRecord(helloValue, [
'type',
'v',
'clientPublicKeyB64',
'clientNonceB64',
'capabilities',
'context'
])
) {
return null
}
if (
!isExactRecord(readyValue, [
'type',
'v',
'desktopPublicKeyB64',
'clientNonceB64',
'desktopNonceB64',
'selection',
'context'
])
) {
return null
}
if (helloValue.type !== 'e2ee_hello' || helloValue.v !== 2) {
return null
}
if (readyValue.type !== 'e2ee_ready' || readyValue.v !== 2) {
return null
}
if (!hasExactCapabilities(helloValue.capabilities) || !hasExactSelection(readyValue.selection)) {
return null
}
const helloContext = parseContext(helloValue.context)
const readyContext = parseContext(readyValue.context)
if (!helloContext || !readyContext || !contextsEqual(helloContext, readyContext)) {
return null
}
if (readyValue.clientNonceB64 !== helloValue.clientNonceB64) {
return null
}
const clientPublicKey = decodeCanonicalBase64Bytes(helloValue.clientPublicKeyB64, 32)
const desktopPublicKey = decodeCanonicalBase64Bytes(readyValue.desktopPublicKeyB64, 32)
const clientNonce = decodeCanonicalBase64Bytes(helloValue.clientNonceB64, 32)
const desktopNonce = decodeCanonicalBase64Bytes(readyValue.desktopNonceB64, 32)
if (!clientPublicKey || !desktopPublicKey || !clientNonce || !desktopNonce) {
return null
}
return {
hello: helloValue as MobileE2EEV2Hello,
ready: readyValue as MobileE2EEV2Ready,
clientPublicKey,
desktopPublicKey,
clientNonce,
desktopNonce
}
}
export function encodeMobileE2EEV2Transcript(handshake: MobileE2EEV2Handshake): Uint8Array {
const { hello, ready } = handshake
const fields: [string, Uint8Array][] = [
['domain', utf8(MOBILE_E2EE_V2_TRANSCRIPT_DOMAIN)],
['mobile-to-desktop.type', utf8(hello.type)],
['mobile-to-desktop.version', uint32(hello.v)],
['mobile-to-desktop.client-public-key', handshake.clientPublicKey],
['mobile-to-desktop.client-nonce', handshake.clientNonce],
['mobile-to-desktop.capabilities.framing', encodeNumberList(hello.capabilities.framing)],
[
'mobile-to-desktop.capabilities.payload-kinds',
encodeStringList(hello.capabilities.payloadKinds)
],
['mobile-to-desktop.context.protocol', utf8(hello.context.protocol)],
['mobile-to-desktop.context.initiator', utf8(hello.context.initiator)],
['mobile-to-desktop.context.responder', utf8(hello.context.responder)],
['mobile-to-desktop.context.transport', utf8(hello.context.transport)],
['mobile-to-desktop.context.relay-host-id', utf8(hello.context.relayHostId ?? '')],
['desktop-to-mobile.type', utf8(ready.type)],
['desktop-to-mobile.version', uint32(ready.v)],
['desktop-to-mobile.desktop-public-key', handshake.desktopPublicKey],
['desktop-to-mobile.client-nonce-echo', handshake.clientNonce],
['desktop-to-mobile.desktop-nonce', handshake.desktopNonce],
['desktop-to-mobile.selection.framing', uint32(ready.selection.framing)],
['desktop-to-mobile.selection.payload-kinds', encodeStringList(ready.selection.payloadKinds)],
['desktop-to-mobile.context.protocol', utf8(ready.context.protocol)],
['desktop-to-mobile.context.initiator', utf8(ready.context.initiator)],
['desktop-to-mobile.context.responder', utf8(ready.context.responder)],
['desktop-to-mobile.context.transport', utf8(ready.context.transport)],
['desktop-to-mobile.context.relay-host-id', utf8(ready.context.relayHostId ?? '')]
]
return concatBytes(
fields.map(([name, value]) =>
concatBytes([uint32(utf8(name).length), utf8(name), uint32(value.length), value])
)
)
}
function parseContext(value: unknown): MobileE2EEV2Context | null {
if (!isRecord(value)) {
return null
}
const transport = value.transport
const keys =
transport === 'relay'
? ['protocol', 'initiator', 'responder', 'transport', 'relayHostId']
: ['protocol', 'initiator', 'responder', 'transport']
if (!isExactRecord(value, keys)) {
return null
}
if (
value.protocol !== MOBILE_E2EE_V2_PROTOCOL ||
value.initiator !== 'mobile' ||
value.responder !== 'desktop' ||
(transport !== 'direct' && transport !== 'relay')
) {
return null
}
if (
transport === 'relay' &&
(typeof value.relayHostId !== 'string' || !BASE64URL_16_PATTERN.test(value.relayHostId))
) {
return null
}
return value as MobileE2EEV2Context
}
function hasExactCapabilities(value: unknown): boolean {
return (
isExactRecord(value, ['framing', 'payloadKinds']) &&
Array.isArray(value.framing) &&
value.framing.length === 1 &&
value.framing[0] === 2 &&
Array.isArray(value.payloadKinds) &&
value.payloadKinds.length === 2 &&
value.payloadKinds[0] === 'text' &&
value.payloadKinds[1] === 'binary'
)
}
function hasExactSelection(value: unknown): boolean {
return (
isExactRecord(value, ['framing', 'payloadKinds']) &&
value.framing === 2 &&
Array.isArray(value.payloadKinds) &&
value.payloadKinds.length === 2 &&
value.payloadKinds[0] === 'text' &&
value.payloadKinds[1] === 'binary'
)
}
function contextsEqual(left: MobileE2EEV2Context, right: MobileE2EEV2Context): boolean {
return (
left.protocol === right.protocol &&
left.initiator === right.initiator &&
left.responder === right.responder &&
left.transport === right.transport &&
left.relayHostId === right.relayHostId
)
}
function decodeCanonicalBase64Bytes(value: unknown, length: number): Uint8Array | null {
if (
typeof value !== 'string' ||
value.length !== Math.ceil(length / 3) * 4 ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)
) {
return null
}
try {
const binary = atob(value)
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
return bytes.length === length && encodeBase64(bytes) === value ? bytes : null
} catch {
return null
}
}
function encodeBase64(bytes: Uint8Array): string {
let binary = ''
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
return btoa(binary)
}
function encodeNumberList(values: readonly number[]): Uint8Array {
return concatBytes([uint32(values.length), ...values.map(uint32)])
}
function encodeStringList(values: readonly string[]): Uint8Array {
return concatBytes([
uint32(values.length),
...values.map((value) => {
const bytes = utf8(value)
return concatBytes([uint32(bytes.length), bytes])
})
])
}
function uint32(value: number): Uint8Array {
const bytes = new Uint8Array(4)
new DataView(bytes.buffer).setUint32(0, value, false)
return bytes
}
function utf8(value: string): Uint8Array {
return new TextEncoder().encode(value)
}
function concatBytes(parts: readonly Uint8Array[]): Uint8Array {
const result = new Uint8Array(parts.reduce((total, part) => total + part.length, 0))
let offset = 0
for (const part of parts) {
result.set(part, offset)
offset += part.length
}
return result
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isExactRecord(value: unknown, keys: readonly string[]): value is Record<string, unknown> {
if (!isRecord(value)) {
return false
}
const actual = Object.keys(value).sort()
const expected = [...keys].sort()
return actual.length === expected.length && actual.every((key, index) => key === expected[index])
}