// Post-chirp pairing handshake. // // After both devices have exchanged ephemeral X25519 keys via the audio // chirp, the actual trust transfer happens over an authenticated, encrypted // channel built on top of those ephemerals. The handshake: // // 1. Both sides instantiate Noise NK using the chirp-derived ephemerals. // The Android device drives the initiator side; the extension drives // the responder side. Treating the chirp ephemerals as the NK "static" // key works because the chirp transports the ephemeral with checksum // integrity; an attacker who could substitute keys would have to do // so in the audio band, in real time, in the same room. // // 2. Over the resulting Noise transport, each side sends ONE JSON frame // with its `device_id`, `device_name`, `device_type`, // `long_term_public_key_hex`, and device-manifest signing key. Both // sides receive the counterpart's frame and persist it in the paired // device registry. // // 3. The Noise NK session and the chirp ephemerals are discarded — // future WebSocket sessions use Noise XX with the long-term static // keys exchanged in step 2. // // The transport for the Noise NK round is provided by the caller. In the // real pairing flow it is a WebSocket (Android-as-server, extension-as- // client). Tests pass an in-memory transport. import { noiseNKInitiator, noiseNKResponder } from "./noise-js"; import type { DeviceType, PairedDevice } from "./sync-types"; export interface PairingTransport { send(msg: Uint8Array): Promise; receive(): Promise; } export interface LocalIdentity { deviceId: string; deviceName: string; deviceType: DeviceType; longTermPublicKeyHex: string; signingPublicKeyHex?: string; /** Server-only field — Android/iOS/desktop sync hosts announce their port. */ wsPort?: number; } interface PairingFrame { type: "PAIR_IDENTITY"; device_id: string; device_name: string; device_type: DeviceType; long_term_public_key_hex: string; signing_public_key_hex?: string; ws_port?: number; } function encodeFrame(frame: PairingFrame): Uint8Array { return new TextEncoder().encode(JSON.stringify(frame)); } function decodeFrame(bytes: Uint8Array): PairingFrame { const parsed = JSON.parse(new TextDecoder().decode(bytes)); if (parsed?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY frame"); if (typeof parsed.device_id !== "string" || !parsed.device_id) throw new Error("device_id missing"); if (typeof parsed.device_name !== "string") throw new Error("device_name missing"); if (typeof parsed.device_type !== "string") throw new Error("device_type missing"); if (typeof parsed.long_term_public_key_hex !== "string") throw new Error("long_term_public_key_hex missing"); // Long-term pubkey is a hex-encoded 32-byte X25519 key. if (!/^[0-9a-f]{64}$/.test(parsed.long_term_public_key_hex)) { throw new Error("long_term_public_key_hex is not 32-byte hex"); } if ( parsed.signing_public_key_hex !== undefined && !/^[0-9a-f]{64}$/.test(parsed.signing_public_key_hex) ) { throw new Error("signing_public_key_hex is not 32-byte hex"); } return parsed as PairingFrame; } /** * Run the post-chirp pairing handshake from the INITIATOR side (Android). * * `remoteEphemeralPublicKey` is the responder's ephemeral pubkey, which we * learned from the inbound chirp. The Noise NK transport message 1 will * encrypt to that key; the responder must hold the matching private key. */ export async function runPairingInitiator( remoteEphemeralPublicKey: Uint8Array, ourIdentity: LocalIdentity, transport: PairingTransport, ): Promise { const session = await noiseNKInitiator( remoteEphemeralPublicKey, transport.send, transport.receive, ); // Send our identity, then receive theirs. const ourFrame = encodeFrame({ type: "PAIR_IDENTITY", device_id: ourIdentity.deviceId, device_name: ourIdentity.deviceName, device_type: ourIdentity.deviceType, long_term_public_key_hex: ourIdentity.longTermPublicKeyHex, signing_public_key_hex: ourIdentity.signingPublicKeyHex, ws_port: ourIdentity.wsPort, }); await transport.send(session.encrypt(ourFrame)); const theirCipher = await transport.receive(); const theirPlain = session.decrypt(theirCipher); const theirFrame = decodeFrame(theirPlain); return frameToPairedDevice(theirFrame); } /** * Run the post-chirp pairing handshake from the RESPONDER side (extension). * * `ourEphemeralPrivateKey` / `ourEphemeralPublicKey` come from the chirp * the responder broadcast; the initiator's chirp told them which key to * authenticate against. */ export async function runPairingResponder( ourEphemeralPrivateKey: Uint8Array, ourEphemeralPublicKey: Uint8Array, ourIdentity: LocalIdentity, transport: PairingTransport, ): Promise { const session = await noiseNKResponder( ourEphemeralPrivateKey, ourEphemeralPublicKey, transport.send, transport.receive, ); // Receive theirs first, then send ours — the initiator sends first. const theirCipher = await transport.receive(); const theirPlain = session.decrypt(theirCipher); const theirFrame = decodeFrame(theirPlain); const ourFrame = encodeFrame({ type: "PAIR_IDENTITY", device_id: ourIdentity.deviceId, device_name: ourIdentity.deviceName, device_type: ourIdentity.deviceType, long_term_public_key_hex: ourIdentity.longTermPublicKeyHex, signing_public_key_hex: ourIdentity.signingPublicKeyHex, ws_port: ourIdentity.wsPort, }); await transport.send(session.encrypt(ourFrame)); return frameToPairedDevice(theirFrame); } function frameToPairedDevice(frame: PairingFrame): PairedDevice { return { deviceId: frame.device_id, deviceName: frame.device_name, deviceType: frame.device_type, longTermPublicKeyHex: frame.long_term_public_key_hex, signingPublicKeyHex: frame.signing_public_key_hex, trustSource: "direct", wsPort: typeof frame.ws_port === "number" ? frame.ws_port : undefined, pairedAt: Date.now(), }; } /** * Test-helper: an in-memory pair of transports that connect to each other. * Use one half for the initiator and the other for the responder. */ export function memoryTransportPair(): [PairingTransport, PairingTransport] { const a: Uint8Array[] = []; const b: Uint8Array[] = []; const aReaders: Array<(b: Uint8Array) => void> = []; const bReaders: Array<(b: Uint8Array) => void> = []; function makeTransport( inbox: Uint8Array[], outbox: Uint8Array[], inboxReaders: Array<(b: Uint8Array) => void>, outboxReaders: Array<(b: Uint8Array) => void>, ): PairingTransport { return { async send(msg: Uint8Array) { const r = outboxReaders.shift(); if (r) r(msg); else outbox.push(msg); }, receive() { const msg = inbox.shift(); if (msg) return Promise.resolve(msg); return new Promise((resolve) => inboxReaders.push(resolve)); }, }; } return [makeTransport(a, b, aReaders, bReaders), makeTransport(b, a, bReaders, aReaders)]; }