chirp-handshake.ts

7.1 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`,16//      `long_term_public_key_hex`, and device-manifest signing key. Both17//      sides receive the counterpart's frame and persist it in the paired18//      device registry.19//20//   3. The Noise NK session and the chirp ephemerals are discarded —21//      future WebSocket sessions use Noise XX with the long-term static22//      keys exchanged in step 2.23//24// The transport for the Noise NK round is provided by the caller. In the25// real pairing flow it is a WebSocket (Android-as-server, extension-as-26// client). Tests pass an in-memory transport.2728import { noiseNKInitiator, noiseNKResponder } from "./noise-js";29import type { DeviceType, PairedDevice } from "./sync-types";3031export interface PairingTransport {32  send(msg: Uint8Array): Promise<void>;33  receive(): Promise<Uint8Array>;34}3536export interface LocalIdentity {37  deviceId: string;38  deviceName: string;39  deviceType: DeviceType;40  longTermPublicKeyHex: string;41  signingPublicKeyHex?: string;42  /** Server-only field — Android/iOS/desktop sync hosts announce their port. */43  wsPort?: number;44}4546interface PairingFrame {47  type: "PAIR_IDENTITY";48  device_id: string;49  device_name: string;50  device_type: DeviceType;51  long_term_public_key_hex: string;52  signing_public_key_hex?: string;53  ws_port?: number;54}5556function encodeFrame(frame: PairingFrame): Uint8Array {57  return new TextEncoder().encode(JSON.stringify(frame));58}5960function decodeFrame(bytes: Uint8Array): PairingFrame {61  const parsed = JSON.parse(new TextDecoder().decode(bytes));62  if (parsed?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY frame");63  if (typeof parsed.device_id !== "string" || !parsed.device_id) throw new Error("device_id missing");64  if (typeof parsed.device_name !== "string") throw new Error("device_name missing");65  if (typeof parsed.device_type !== "string") throw new Error("device_type missing");66  if (typeof parsed.long_term_public_key_hex !== "string") throw new Error("long_term_public_key_hex missing");67  // Long-term pubkey is a hex-encoded 32-byte X25519 key.68  if (!/^[0-9a-f]{64}$/.test(parsed.long_term_public_key_hex)) {69    throw new Error("long_term_public_key_hex is not 32-byte hex");70  }71  if (72    parsed.signing_public_key_hex !== undefined &&73    !/^[0-9a-f]{64}$/.test(parsed.signing_public_key_hex)74  ) {75    throw new Error("signing_public_key_hex is not 32-byte hex");76  }77  return parsed as PairingFrame;78}7980/**81 * Run the post-chirp pairing handshake from the INITIATOR side (Android).82 *83 * `remoteEphemeralPublicKey` is the responder's ephemeral pubkey, which we84 * learned from the inbound chirp. The Noise NK transport message 1 will85 * encrypt to that key; the responder must hold the matching private key.86 */87export async function runPairingInitiator(88  remoteEphemeralPublicKey: Uint8Array,89  ourIdentity: LocalIdentity,90  transport: PairingTransport,91): Promise<PairedDevice> {92  const session = await noiseNKInitiator(93    remoteEphemeralPublicKey,94    transport.send,95    transport.receive,96  );9798  // Send our identity, then receive theirs.99  const ourFrame = encodeFrame({100    type: "PAIR_IDENTITY",101    device_id: ourIdentity.deviceId,102    device_name: ourIdentity.deviceName,103    device_type: ourIdentity.deviceType,104    long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,105    signing_public_key_hex: ourIdentity.signingPublicKeyHex,106    ws_port: ourIdentity.wsPort,107  });108  await transport.send(session.encrypt(ourFrame));109110  const theirCipher = await transport.receive();111  const theirPlain = session.decrypt(theirCipher);112  const theirFrame = decodeFrame(theirPlain);113  return frameToPairedDevice(theirFrame);114}115116/**117 * Run the post-chirp pairing handshake from the RESPONDER side (extension).118 *119 * `ourEphemeralPrivateKey` / `ourEphemeralPublicKey` come from the chirp120 * the responder broadcast; the initiator's chirp told them which key to121 * authenticate against.122 */123export async function runPairingResponder(124  ourEphemeralPrivateKey: Uint8Array,125  ourEphemeralPublicKey: Uint8Array,126  ourIdentity: LocalIdentity,127  transport: PairingTransport,128): Promise<PairedDevice> {129  const session = await noiseNKResponder(130    ourEphemeralPrivateKey,131    ourEphemeralPublicKey,132    transport.send,133    transport.receive,134  );135136  // Receive theirs first, then send ours — the initiator sends first.137  const theirCipher = await transport.receive();138  const theirPlain = session.decrypt(theirCipher);139  const theirFrame = decodeFrame(theirPlain);140141  const ourFrame = encodeFrame({142    type: "PAIR_IDENTITY",143    device_id: ourIdentity.deviceId,144    device_name: ourIdentity.deviceName,145    device_type: ourIdentity.deviceType,146    long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,147    signing_public_key_hex: ourIdentity.signingPublicKeyHex,148    ws_port: ourIdentity.wsPort,149  });150  await transport.send(session.encrypt(ourFrame));151152  return frameToPairedDevice(theirFrame);153}154155function frameToPairedDevice(frame: PairingFrame): PairedDevice {156  return {157    deviceId: frame.device_id,158    deviceName: frame.device_name,159    deviceType: frame.device_type,160    longTermPublicKeyHex: frame.long_term_public_key_hex,161    signingPublicKeyHex: frame.signing_public_key_hex,162    trustSource: "direct",163    wsPort: typeof frame.ws_port === "number" ? frame.ws_port : undefined,164    pairedAt: Date.now(),165  };166}167168/**169 * Test-helper: an in-memory pair of transports that connect to each other.170 * Use one half for the initiator and the other for the responder.171 */172export function memoryTransportPair(): [PairingTransport, PairingTransport] {173  const a: Uint8Array[] = [];174  const b: Uint8Array[] = [];175  const aReaders: Array<(b: Uint8Array) => void> = [];176  const bReaders: Array<(b: Uint8Array) => void> = [];177178  function makeTransport(179    inbox: Uint8Array[],180    outbox: Uint8Array[],181    inboxReaders: Array<(b: Uint8Array) => void>,182    outboxReaders: Array<(b: Uint8Array) => void>,183  ): PairingTransport {184    return {185      async send(msg: Uint8Array) {186        const r = outboxReaders.shift();187        if (r) r(msg);188        else outbox.push(msg);189      },190      receive() {191        const msg = inbox.shift();192        if (msg) return Promise.resolve(msg);193        return new Promise<Uint8Array>((resolve) => inboxReaders.push(resolve));194      },195    };196  }197  return [makeTransport(a, b, aReaders, bReaders), makeTransport(b, a, bReaders, aReaders)];198}