orca/src/shared/e2ee-crypto.ts

77 lines
2.5 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 {
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
}