mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
142 lines
4.3 KiB
TypeScript
142 lines
4.3 KiB
TypeScript
import nacl from 'tweetnacl'
|
|
import type { MobileE2EEPayloadKind } from './mobile-e2ee-v2-contract'
|
|
|
|
export type MobileE2EEDirection = 'mobile-to-desktop' | 'desktop-to-mobile'
|
|
|
|
const NONCE_LENGTH = 24
|
|
const SESSION_ID_LENGTH = 32
|
|
const HEADER_LENGTH = SESSION_ID_LENGTH + 1 + 1 + 8
|
|
const FRAME_VERSION = 2
|
|
const MAX_COUNTER = (1n << 64n) - 1n
|
|
|
|
export function sealMobileE2EEV2Frame(args: {
|
|
payload: Uint8Array
|
|
key: Uint8Array
|
|
sessionId: Uint8Array
|
|
direction: MobileE2EEDirection
|
|
payloadKind: MobileE2EEPayloadKind
|
|
counter: bigint
|
|
}): Uint8Array {
|
|
validateFrameInputs(args.key, args.sessionId, args.counter)
|
|
const header = encodeHeader(args)
|
|
const nonce = encodeNonce(args)
|
|
const plaintext = concatBytes([header, args.payload])
|
|
const ciphertext = nacl.secretbox(plaintext, nonce, args.key)
|
|
return concatBytes([nonce, ciphertext])
|
|
}
|
|
|
|
export function openMobileE2EEV2Frame(args: {
|
|
frame: Uint8Array
|
|
key: Uint8Array
|
|
sessionId: Uint8Array
|
|
direction: MobileE2EEDirection
|
|
payloadKind: MobileE2EEPayloadKind
|
|
expectedCounter: bigint
|
|
}): Uint8Array | null {
|
|
validateFrameInputs(args.key, args.sessionId, args.expectedCounter)
|
|
if (args.frame.length < NONCE_LENGTH + nacl.secretbox.overheadLength + HEADER_LENGTH) {
|
|
return null
|
|
}
|
|
const expected = {
|
|
sessionId: args.sessionId,
|
|
direction: args.direction,
|
|
payloadKind: args.payloadKind,
|
|
counter: args.expectedCounter
|
|
}
|
|
const nonce = encodeNonce(expected)
|
|
if (!equalBytes(args.frame.subarray(0, NONCE_LENGTH), nonce)) {
|
|
return null
|
|
}
|
|
|
|
const plaintext = nacl.secretbox.open(args.frame.subarray(NONCE_LENGTH), nonce, args.key)
|
|
if (!plaintext) {
|
|
return null
|
|
}
|
|
const header = encodeHeader(expected)
|
|
if (!equalBytes(plaintext.subarray(0, HEADER_LENGTH), header)) {
|
|
return null
|
|
}
|
|
return plaintext.slice(HEADER_LENGTH)
|
|
}
|
|
|
|
function encodeHeader(args: {
|
|
sessionId: Uint8Array
|
|
direction: MobileE2EEDirection
|
|
payloadKind: MobileE2EEPayloadKind
|
|
counter: bigint
|
|
}): Uint8Array {
|
|
const header = new Uint8Array(HEADER_LENGTH)
|
|
header.set(args.sessionId, 0)
|
|
header[SESSION_ID_LENGTH] = directionByte(args.direction)
|
|
header[SESSION_ID_LENGTH + 1] = payloadKindByte(args.payloadKind)
|
|
writeUint64(header, SESSION_ID_LENGTH + 2, args.counter)
|
|
return header
|
|
}
|
|
|
|
function encodeNonce(args: {
|
|
sessionId: Uint8Array
|
|
direction: MobileE2EEDirection
|
|
payloadKind: MobileE2EEPayloadKind
|
|
counter: bigint
|
|
}): Uint8Array {
|
|
const nonce = new Uint8Array(NONCE_LENGTH)
|
|
// Why: v2 keys/sessionId are fresh per socket; the fixed layout makes every
|
|
// direction/kind/counter nonce unique without relying on another RNG draw.
|
|
nonce.set(args.sessionId.subarray(0, 12), 0)
|
|
nonce[12] = FRAME_VERSION
|
|
nonce[13] = directionByte(args.direction)
|
|
nonce[14] = payloadKindByte(args.payloadKind)
|
|
nonce[15] = 0
|
|
writeUint64(nonce, 16, args.counter)
|
|
return nonce
|
|
}
|
|
|
|
function directionByte(direction: MobileE2EEDirection): number {
|
|
return direction === 'mobile-to-desktop' ? 0 : 1
|
|
}
|
|
|
|
function payloadKindByte(kind: MobileE2EEPayloadKind): number {
|
|
return kind === 'text' ? 0 : 1
|
|
}
|
|
|
|
function validateFrameInputs(key: Uint8Array, sessionId: Uint8Array, counter: bigint): void {
|
|
if (key.length !== nacl.secretbox.keyLength) {
|
|
throw new Error(`Invalid E2EE v2 key length: ${key.length}`)
|
|
}
|
|
if (sessionId.length !== SESSION_ID_LENGTH) {
|
|
throw new Error(`Invalid E2EE v2 session ID length: ${sessionId.length}`)
|
|
}
|
|
if (counter < 0n || counter > MAX_COUNTER) {
|
|
throw new Error(`Invalid E2EE v2 counter: ${counter}`)
|
|
}
|
|
}
|
|
|
|
function writeUint64(target: Uint8Array, offset: number, value: bigint): void {
|
|
let remaining = value
|
|
for (let index = 7; index >= 0; index--) {
|
|
target[offset + index] = Number(remaining & 0xffn)
|
|
remaining >>= 8n
|
|
}
|
|
}
|
|
|
|
function concatBytes(parts: readonly Uint8Array[]): Uint8Array {
|
|
const result = new Uint8Array(parts.reduce((total, part) => total + part.length, 0))
|
|
let offset = 0
|
|
for (const part of parts) {
|
|
result.set(part, offset)
|
|
offset += part.length
|
|
}
|
|
return result
|
|
}
|
|
|
|
function equalBytes(left: Uint8Array, right: Uint8Array): boolean {
|
|
if (left.length !== right.length) {
|
|
return false
|
|
}
|
|
let difference = 0
|
|
for (let index = 0; index < left.length; index++) {
|
|
difference |= left[index]! ^ right[index]!
|
|
}
|
|
return difference === 0
|
|
}
|