chirp-handshake.ts

6.6 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  /** Server-only field — Android/iOS/desktop sync hosts announce their port. */41  wsPort?: number;42}4344interface PairingFrame {45  type: "PAIR_IDENTITY";46  device_id: string;47  device_name: string;48  device_type: DeviceType;49  long_term_public_key_hex: string;50  ws_port?: number;51}5253function encodeFrame(frame: PairingFrame): Uint8Array {54  return new TextEncoder().encode(JSON.stringify(frame));55}5657function decodeFrame(bytes: Uint8Array): PairingFrame {58  const parsed = JSON.parse(new TextDecoder().decode(bytes));59  if (parsed?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY frame");60  if (typeof parsed.device_id !== "string" || !parsed.device_id) throw new Error("device_id missing");61  if (typeof parsed.device_name !== "string") throw new Error("device_name missing");62  if (typeof parsed.device_type !== "string") throw new Error("device_type missing");63  if (typeof parsed.long_term_public_key_hex !== "string") throw new Error("long_term_public_key_hex missing");64  // Long-term pubkey is a hex-encoded 32-byte X25519 key.65  if (!/^[0-9a-f]{64}$/.test(parsed.long_term_public_key_hex)) {66    throw new Error("long_term_public_key_hex is not 32-byte hex");67  }68  return parsed as PairingFrame;69}7071/**72 * Run the post-chirp pairing handshake from the INITIATOR side (Android).73 *74 * `remoteEphemeralPublicKey` is the responder's ephemeral pubkey, which we75 * learned from the inbound chirp. The Noise NK transport message 1 will76 * encrypt to that key; the responder must hold the matching private key.77 */78export async function runPairingInitiator(79  remoteEphemeralPublicKey: Uint8Array,80  ourIdentity: LocalIdentity,81  transport: PairingTransport,82): Promise<PairedDevice> {83  const session = await noiseNKInitiator(84    remoteEphemeralPublicKey,85    transport.send,86    transport.receive,87  );8889  // Send our identity, then receive theirs.90  const ourFrame = encodeFrame({91    type: "PAIR_IDENTITY",92    device_id: ourIdentity.deviceId,93    device_name: ourIdentity.deviceName,94    device_type: ourIdentity.deviceType,95    long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,96    ws_port: ourIdentity.wsPort,97  });98  await transport.send(session.encrypt(ourFrame));99100  const theirCipher = await transport.receive();101  const theirPlain = session.decrypt(theirCipher);102  const theirFrame = decodeFrame(theirPlain);103  return frameToPairedDevice(theirFrame);104}105106/**107 * Run the post-chirp pairing handshake from the RESPONDER side (extension).108 *109 * `ourEphemeralPrivateKey` / `ourEphemeralPublicKey` come from the chirp110 * the responder broadcast; the initiator's chirp told them which key to111 * authenticate against.112 */113export async function runPairingResponder(114  ourEphemeralPrivateKey: Uint8Array,115  ourEphemeralPublicKey: Uint8Array,116  ourIdentity: LocalIdentity,117  transport: PairingTransport,118): Promise<PairedDevice> {119  const session = await noiseNKResponder(120    ourEphemeralPrivateKey,121    ourEphemeralPublicKey,122    transport.send,123    transport.receive,124  );125126  // Receive theirs first, then send ours — the initiator sends first.127  const theirCipher = await transport.receive();128  const theirPlain = session.decrypt(theirCipher);129  const theirFrame = decodeFrame(theirPlain);130131  const ourFrame = encodeFrame({132    type: "PAIR_IDENTITY",133    device_id: ourIdentity.deviceId,134    device_name: ourIdentity.deviceName,135    device_type: ourIdentity.deviceType,136    long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,137    ws_port: ourIdentity.wsPort,138  });139  await transport.send(session.encrypt(ourFrame));140141  return frameToPairedDevice(theirFrame);142}143144function frameToPairedDevice(frame: PairingFrame): PairedDevice {145  return {146    deviceId: frame.device_id,147    deviceName: frame.device_name,148    deviceType: frame.device_type,149    longTermPublicKeyHex: frame.long_term_public_key_hex,150    wsPort: typeof frame.ws_port === "number" ? frame.ws_port : undefined,151    pairedAt: Date.now(),152  };153}154155/**156 * Test-helper: an in-memory pair of transports that connect to each other.157 * Use one half for the initiator and the other for the responder.158 */159export function memoryTransportPair(): [PairingTransport, PairingTransport] {160  const a: Uint8Array[] = [];161  const b: Uint8Array[] = [];162  const aReaders: Array<(b: Uint8Array) => void> = [];163  const bReaders: Array<(b: Uint8Array) => void> = [];164165  function makeTransport(166    inbox: Uint8Array[],167    outbox: Uint8Array[],168    inboxReaders: Array<(b: Uint8Array) => void>,169    outboxReaders: Array<(b: Uint8Array) => void>,170  ): PairingTransport {171    return {172      async send(msg: Uint8Array) {173        const r = outboxReaders.shift();174        if (r) r(msg);175        else outbox.push(msg);176      },177      receive() {178        const msg = inbox.shift();179        if (msg) return Promise.resolve(msg);180        return new Promise<Uint8Array>((resolve) => inboxReaders.push(resolve));181      },182    };183  }184  return [makeTransport(a, b, aReaders, bReaders), makeTransport(b, a, bReaders, aReaders)];185}