Files
orca/src/main/runtime/e2ee-keypair.ts
T
f307391637 fix(pairing): pairing with this same Orca profile is refused (#27166)
Every add/re-pair path goes through the environment store; it now compares the
offer's public key with this profile's own runtime key before saving.

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: s546126 <s546126@users.noreply.github.com>
2026-10-09 23:46:14 -07:00

87 lines
3.5 KiB
TypeScript

// Why: the E2EE keypair enables application-layer encryption between mobile
// and desktop over plain ws://. The public key is embedded in the QR pairing
// offer so the mobile client can derive a shared secret via ECDH.
import { existsSync, readFileSync, statSync } from 'node:fs'
import { join } from 'node:path'
import nacl from 'tweetnacl'
import {
hardenExistingSecureFile,
isUnreadableError,
writeSecureJsonFile
} from '../../shared/secure-file'
import {
E2EE_KEYPAIR_FILENAME as KEYPAIR_FILENAME,
MAX_E2EE_KEYPAIR_FILE_BYTES as MAX_KEYPAIR_FILE_BYTES
} from '../../shared/runtime-e2ee-keypair-file'
const KEYPAIR_VERSION = 1
type KeypairFile = {
v: number
publicKeyB64: string
secretKeyB64: string
}
export type E2EEKeypair = {
publicKey: Uint8Array
secretKey: Uint8Array
publicKeyB64: string
}
// The listener decrypts with the secret key, so the advertised key must be the
// one derived from it; a file whose stored public key disagrees would otherwise
// put a key in every pairing offer that no listener holds. The file is left as is.
function keypairFromSecret(secretKey: Uint8Array, storedPublicKey: Uint8Array): E2EEKeypair {
const { publicKey } = nacl.box.keyPair.fromSecretKey(secretKey)
if (!Buffer.from(publicKey).equals(Buffer.from(storedPublicKey))) {
console.warn(
'[e2ee] stored public key does not match the secret key; advertising the derived key'
)
}
return { publicKey, secretKey, publicKeyB64: Buffer.from(publicKey).toString('base64') }
}
export function loadOrCreateE2EEKeypair(userDataPath: string): E2EEKeypair {
const filePath = join(userDataPath, KEYPAIR_FILENAME)
if (existsSync(filePath)) {
try {
hardenExistingSecureFile(filePath)
// Why: this startup path reads synchronously; valid keypair files are
// tiny, so oversized/corrupt files should be replaced without loading.
if (statSync(filePath).size > MAX_KEYPAIR_FILE_BYTES) {
throw new Error('E2EE keypair file is too large')
}
const raw: KeypairFile = JSON.parse(readFileSync(filePath, 'utf-8'))
if (raw.v === KEYPAIR_VERSION && raw.publicKeyB64 && raw.secretKeyB64) {
const publicKey = Uint8Array.from(Buffer.from(raw.publicKeyB64, 'base64'))
const secretKey = Uint8Array.from(Buffer.from(raw.secretKeyB64, 'base64'))
if (publicKey.length === 32 && secretKey.length === 32) {
return keypairFromSecret(secretKey, publicKey)
}
}
} catch (error) {
// A read this process is not permitted to make says nothing about the contents. Falling
// through would overwrite the only copy of the secret key — and the overwrite succeeds, so
// nothing downstream stops it. Every paired device derives its shared secret from this key,
// so regenerating silently un-pairs all of them and no old message stays decryptable.
if (isUnreadableError(error)) {
throw new Error(
`Cannot read the E2EE keypair at ${filePath}: the read failed. Refusing to regenerate it, which would invalidate every paired device.`,
{ cause: error }
)
}
// Malformed file — regenerate below.
}
}
const keypair = nacl.box.keyPair()
const publicKeyB64 = Buffer.from(keypair.publicKey).toString('base64')
const secretKeyB64 = Buffer.from(keypair.secretKey).toString('base64')
const data: KeypairFile = { v: KEYPAIR_VERSION, publicKeyB64, secretKeyB64 }
writeSecureJsonFile(filePath, data)
return { publicKey: keypair.publicKey, secretKey: keypair.secretKey, publicKeyB64 }
}