chirp-handshake.ts

6.3 KB · raw

1// Post-chirp pairing handshake.2//3// After both devices have exchanged ephemeral X25519 keys via the audio4// chirp, the actual trust transfer happens over an authenticated, encrypted5// channel built on top of those ephemerals. The handshake:6//7//   1. Both sides instantiate Noise NK using the chirp-derived ephemerals.8//      The Android device drives the initiator side; the extension drives9//      the responder side. Treating the chirp ephemerals as the NK "static"10//      key works because the chirp transports the ephemeral with checksum11//      integrity; an attacker who could substitute keys would have to do12//      so in the audio band, in real time, in the same room.13//14//   2. Over the resulting Noise transport, each side sends ONE JSON frame15//      with its `device_id`, `device_name`, `device_type`, and16//      `long_term_public_key_hex`. Both sides receive the counterpart's17//      frame and persist it in the paired device registry.18//19//   3. The Noise NK session and the chirp ephemerals are discarded —20//      future WebSocket sessions use Noise XX with the long-term static21//      keys exchanged in step 2.22//23// The transport for the Noise NK round is provided by the caller. In the24// real pairing flow it is a WebSocket (Android-as-server, extension-as-25// client). Tests pass an in-memory transport.2627import { noiseNKInitiator, noiseNKResponder } from "./noise-js";28import type { DeviceType, PairedDevice } from "./sync-types";2930export interface PairingTransport {31  send(msg: Uint8Array): Promise<void>;32  receive(): Promise<Uint8Array>;33}3435export interface LocalIdentity {36  deviceId: string;37  deviceName: string;38  deviceType: DeviceType;39  longTermPublicKeyHex: string;40}4142interface PairingFrame {43  type: "PAIR_IDENTITY";44  device_id: string;45  device_name: string;46  device_type: DeviceType;47  long_term_public_key_hex: string;48}4950function encodeFrame(frame: PairingFrame): Uint8Array {51  return new TextEncoder().encode(JSON.stringify(frame));52}5354function decodeFrame(bytes: Uint8Array): PairingFrame {55  const parsed = JSON.parse(new TextDecoder().decode(bytes));56  if (parsed?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY frame");57  if (typeof parsed.device_id !== "string" || !parsed.device_id) throw new Error("device_id missing");58  if (typeof parsed.device_name !== "string") throw new Error("device_name missing");59  if (typeof parsed.device_type !== "string") throw new Error("device_type missing");60  if (typeof parsed.long_term_public_key_hex !== "string") throw new Error("long_term_public_key_hex missing");61  // Long-term pubkey is a hex-encoded 32-byte X25519 key.62  if (!/^[0-9a-f]{64}$/.test(parsed.long_term_public_key_hex)) {63    throw new Error("long_term_public_key_hex is not 32-byte hex");64  }65  return parsed as PairingFrame;66}6768/**69 * Run the post-chirp pairing handshake from the INITIATOR side (Android).70 *71 * `remoteEphemeralPublicKey` is the responder's ephemeral pubkey, which we72 * learned from the inbound chirp. The Noise NK transport message 1 will73 * encrypt to that key; the responder must hold the matching private key.74 */75export async function runPairingInitiator(76  remoteEphemeralPublicKey: Uint8Array,77  ourIdentity: LocalIdentity,78  transport: PairingTransport,79): Promise<PairedDevice> {80  const session = await noiseNKInitiator(81    remoteEphemeralPublicKey,82    transport.send,83    transport.receive,84  );8586  // Send our identity, then receive theirs.87  const ourFrame = encodeFrame({88    type: "PAIR_IDENTITY",89    device_id: ourIdentity.deviceId,90    device_name: ourIdentity.deviceName,91    device_type: ourIdentity.deviceType,92    long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,93  });94  await transport.send(session.encrypt(ourFrame));9596  const theirCipher = await transport.receive();97  const theirPlain = session.decrypt(theirCipher);98  const theirFrame = decodeFrame(theirPlain);99  return frameToPairedDevice(theirFrame);100}101102/**103 * Run the post-chirp pairing handshake from the RESPONDER side (extension).104 *105 * `ourEphemeralPrivateKey` / `ourEphemeralPublicKey` come from the chirp106 * the responder broadcast; the initiator's chirp told them which key to107 * authenticate against.108 */109export async function runPairingResponder(110  ourEphemeralPrivateKey: Uint8Array,111  ourEphemeralPublicKey: Uint8Array,112  ourIdentity: LocalIdentity,113  transport: PairingTransport,114): Promise<PairedDevice> {115  const session = await noiseNKResponder(116    ourEphemeralPrivateKey,117    ourEphemeralPublicKey,118    transport.send,119    transport.receive,120  );121122  // Receive theirs first, then send ours — the initiator sends first.123  const theirCipher = await transport.receive();124  const theirPlain = session.decrypt(theirCipher);125  const theirFrame = decodeFrame(theirPlain);126127  const ourFrame = encodeFrame({128    type: "PAIR_IDENTITY",129    device_id: ourIdentity.deviceId,130    device_name: ourIdentity.deviceName,131    device_type: ourIdentity.deviceType,132    long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,133  });134  await transport.send(session.encrypt(ourFrame));135136  return frameToPairedDevice(theirFrame);137}138139function frameToPairedDevice(frame: PairingFrame): PairedDevice {140  return {141    deviceId: frame.device_id,142    deviceName: frame.device_name,143    deviceType: frame.device_type,144    longTermPublicKeyHex: frame.long_term_public_key_hex,145    pairedAt: Date.now(),146  };147}148149/**150 * Test-helper: an in-memory pair of transports that connect to each other.151 * Use one half for the initiator and the other for the responder.152 */153export function memoryTransportPair(): [PairingTransport, PairingTransport] {154  const a: Uint8Array[] = [];155  const b: Uint8Array[] = [];156  const aReaders: Array<(b: Uint8Array) => void> = [];157  const bReaders: Array<(b: Uint8Array) => void> = [];158159  function makeTransport(160    inbox: Uint8Array[],161    outbox: Uint8Array[],162    inboxReaders: Array<(b: Uint8Array) => void>,163    outboxReaders: Array<(b: Uint8Array) => void>,164  ): PairingTransport {165    return {166      async send(msg: Uint8Array) {167        const r = outboxReaders.shift();168        if (r) r(msg);169        else outbox.push(msg);170      },171      receive() {172        const msg = inbox.shift();173        if (msg) return Promise.resolve(msg);174        return new Promise<Uint8Array>((resolve) => inboxReaders.push(resolve));175      },176    };177  }178  return [makeTransport(a, b, aReaders, bReaders), makeTransport(b, a, bReaders, aReaders)];179}