Files
orca/src/shared/e2ee-crypto.ts
T
OrcaWinandm4air f9794d8efc refactor(remote): one paired E2EE handshake core for desktop, CLI, web and mobile clients (#27523)
* refactor(remote): one paired E2EE handshake core for desktop, CLI, web and mobile clients

Every paired client now builds its hello, checks the host's ready, seals its
auth frame and opens host frames through src/shared/paired-e2ee-client-handshake.
Platforms inject their own NaCl/base64 primitives. The v2 client session and its
pure-JS key schedule (@noble/hashes 1.8.0, the version mobile pins) move to
src/shared, and the per-host v2 latch policy is shared with an injected store.

No wire change: frames match the captured legacy and v2 fixtures byte for byte,
and a cross-version test drives the core against the released host and the
released client against this host. The auth step is now async so a device-key
signer can slot in later.

* fix(remote): keep @noble/hashes out of the CLI's unbundled runtime

The packaged CLI loads src/shared output from disk, so a bare @noble/hashes
import failed the packaged-runtime check. The v2 key schedule is now a
primitive: Node clients use the node:crypto schedule (moved to src/shared so
the CLI can reach it), while the browser and phone keep the pure-JS one.

* fix(mobile): re-check the v2 latch before sending a legacy auth frame

The auth step is async, so another socket can latch the host to v2 while a
legacy auth frame is being sealed. Refuse it at send time, as before.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 12:14:51 -07:00

77 lines
2.6 KiB
TypeScript

// Why: Orca's remote runtime transports share one NaCl box format across
// desktop, CLI, and mobile pairing. Keeping the Node-compatible primitives in
// shared code prevents the CLI from importing main-process modules.
import nacl from 'tweetnacl'
const MAX_E2EE_TEXT_PLAINTEXT_BYTES = 4 * 1024 * 1024
const LEGACY_E2EE_FRAME_OVERHEAD_BYTES = nacl.box.nonceLength + nacl.box.overheadLength
export const MAX_E2EE_ENCRYPTED_BASE64_CHARACTERS =
Math.ceil((MAX_E2EE_TEXT_PLAINTEXT_BYTES + LEGACY_E2EE_FRAME_OVERHEAD_BYTES) / 3) * 4
export function generateKeyPair(): nacl.BoxKeyPair {
return nacl.box.keyPair()
}
export function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey: Uint8Array): Uint8Array {
return nacl.box.before(peerPublicKey, ourSecretKey)
}
export function publicKeyFromBase64(b64: string): Uint8Array {
if (b64.length > 44) {
throw new Error('Invalid public key: encoded value is too large')
}
const key = Uint8Array.from(Buffer.from(b64, 'base64'))
if (key.length !== 32) {
throw new Error(`Invalid public key: expected 32 bytes, got ${key.length}`)
}
return key
}
export function publicKeyToBase64(key: Uint8Array): string {
return Buffer.from(key).toString('base64')
}
export function encrypt(plaintext: string, sharedKey: Uint8Array): string {
const messageBytes = new TextEncoder().encode(plaintext)
return Buffer.from(encryptBytes(messageBytes, sharedKey)).toString('base64')
}
export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null {
if (encrypted.length > MAX_E2EE_ENCRYPTED_BASE64_CHARACTERS) {
return null
}
const bundle = Uint8Array.from(Buffer.from(encrypted, 'base64'))
const plaintext = decryptBytes(bundle, sharedKey)
return plaintext ? new TextDecoder().decode(plaintext) : null
}
export function encryptBytes(
plaintext: Uint8Array<ArrayBufferLike>,
sharedKey: Uint8Array
): Uint8Array<ArrayBuffer> {
const nonce = nacl.randomBytes(nacl.box.nonceLength)
const ciphertext = nacl.box.after(plaintext, nonce, sharedKey)
const bundle = new Uint8Array(nonce.length + ciphertext.length)
bundle.set(nonce)
bundle.set(ciphertext, nonce.length)
return bundle
}
export function decryptBytes(bundle: Uint8Array, sharedKey: Uint8Array): Uint8Array | null {
if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) {
return null
}
const nonce = bundle.slice(0, nacl.box.nonceLength)
const ciphertext = bundle.slice(nacl.box.nonceLength)
const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey)
if (!plaintext) {
return null
}
return plaintext
}