mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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>
87 lines
3.5 KiB
TypeScript
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 }
|
|
}
|