chirp-handshake.ts

11.0 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}3536/** Result of a pairing handshake: the peer's identity plus the SAS the user37 *  must confirm matches the peer device before the pair is persisted. */38export interface PairingHandshakeResult {39  device: PairedDevice;40  sas: string;41}4243export interface LocalIdentity {44  deviceId: string;45  deviceName: string;46  deviceType: DeviceType;47  longTermPublicKeyHex: string;48  signingPublicKeyHex?: string;49  /** Server-only field — Android/iOS/desktop sync hosts announce their port. */50  wsPort?: number;51}5253interface PairingFrame {54  type: "PAIR_IDENTITY";55  device_id: string;56  device_name: string;57  device_type: DeviceType;58  long_term_public_key_hex: string;59  signing_public_key_hex?: string;60  ws_port?: number;61}6263function encodeFrame(frame: PairingFrame): Uint8Array {64  return new TextEncoder().encode(JSON.stringify(frame));65}6667function decodeFrame(bytes: Uint8Array): PairingFrame {68  const parsed = JSON.parse(new TextDecoder().decode(bytes));69  if (parsed?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY frame");70  if (typeof parsed.device_id !== "string" || !parsed.device_id) throw new Error("device_id missing");71  if (typeof parsed.device_name !== "string") throw new Error("device_name missing");72  if (typeof parsed.device_type !== "string") throw new Error("device_type missing");73  if (typeof parsed.long_term_public_key_hex !== "string") throw new Error("long_term_public_key_hex missing");74  // Long-term pubkey is a hex-encoded 32-byte X25519 key.75  if (!/^[0-9a-f]{64}$/.test(parsed.long_term_public_key_hex)) {76    throw new Error("long_term_public_key_hex is not 32-byte hex");77  }78  if (79    parsed.signing_public_key_hex !== undefined &&80    !/^[0-9a-f]{64}$/.test(parsed.signing_public_key_hex)81  ) {82    throw new Error("signing_public_key_hex is not 32-byte hex");83  }84  return parsed as PairingFrame;85}8687/**88 * Run the post-chirp pairing handshake from the INITIATOR side (Android).89 *90 * `remoteEphemeralPublicKey` is the responder's ephemeral pubkey, which we91 * learned from the inbound chirp. The Noise NK transport message 1 will92 * encrypt to that key; the responder must hold the matching private key.93 */94export async function runPairingInitiator(95  remoteEphemeralPublicKey: Uint8Array,96  ourIdentity: LocalIdentity,97  transport: PairingTransport,98): Promise<PairingHandshakeResult> {99  const session = await noiseNKInitiator(100    remoteEphemeralPublicKey,101    transport.send,102    transport.receive,103  );104105  // Send our identity, then receive theirs.106  const ourFrame = encodeFrame({107    type: "PAIR_IDENTITY",108    device_id: ourIdentity.deviceId,109    device_name: ourIdentity.deviceName,110    device_type: ourIdentity.deviceType,111    long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,112    signing_public_key_hex: ourIdentity.signingPublicKeyHex,113    ws_port: ourIdentity.wsPort,114  });115  await transport.send(session.encrypt(ourFrame));116117  const theirCipher = await transport.receive();118  const theirPlain = session.decrypt(theirCipher);119  const theirFrame = decodeFrame(theirPlain);120  // The SAS is derived from the shared Noise handshake hash; the caller MUST121  // have the user confirm it matches the peer's before persisting the device.122  return { device: frameToPairedDevice(theirFrame), sas: derivePairingSAS(session.handshakeHash) };123}124125// ───────────────────────────────────────────────────────────────────────────126// SECURITY — KNOWN LIMITATION: the pairing handshake does NOT authenticate the127// initiator (audit HIGH). Noise NK authenticates only the RESPONDER (it proves128// the initiator encrypted to the responder's "static" key — here the chirp129// ephemeral the responder BROADCAST in the clear over audio). It gives the130// extension (always the responder) zero cryptographic assurance about *who*131// completed the handshake, and there is no proof-of-possession over the132// long_term_public_key_hex a peer advertises in its PAIR_IDENTITY frame. An133// attacker in audio range (or on the same LAN, racing the WebSocket accept)134// who captures the broadcast ephemeral can complete a valid NK handshake and135// enroll an attacker-controlled device into the registry as trustSource:"direct"136// — which then becomes a Noise XX sync target and can exfiltrate the vault.137//138// PROPER FIX (requires a COORDINATED change with the mobile/native app, since139// the wire protocol is shared and a unilateral change breaks live pairing):140//   1. Mutually authenticate — bind BOTH parties' chirp ephemerals into the141//      handshake (Noise KK/IK) or run the existing challenge-response142//      proof-of-possession (performInitiatorAuth/performResponderAuth in143//      noise-js) so each side proves possession of the key it advertises.144//   2. Add a user-verified Short Authentication String (SAS): both devices145//      derive `derivePairingSAS(session.handshakeHash)` (below) — identical on146//      both ends because the Noise handshake hash is shared — and DISPLAY it;147//      the user confirms the codes match before either side persists the peer.148//      This is the Bluetooth-numeric-comparison / Signal-safety-number model149//      and detects a MITM/relay even without full mutual auth.150// The SAS primitive below is wire-compatible and ready; activating it requires151// surfacing + confirming it in BOTH the extension pairing UI and the app.152// ───────────────────────────────────────────────────────────────────────────153154/**155 * Derive a 6-digit Short Authentication String from a Noise handshake hash.156 * Both pairing peers compute the SAME value because the Noise `handshakeHash`157 * is shared after the handshake. Showing it on both devices and having the158 * user confirm they match authenticates the channel out-of-band, detecting a159 * man-in-the-middle the NK handshake alone cannot (see SECURITY note above).160 */161export function derivePairingSAS(handshakeHash: Uint8Array): string {162  // Fold the first 4 bytes into an unsigned int, mod 1e6, zero-padded. Uniform163  // enough for a 6-digit comparison code; the handshake hash is already a PRF164  // output so no further mixing is needed.165  if (handshakeHash.length < 4) return "000000";166  const n =167    ((handshakeHash[0] << 24) >>> 0) +168    (handshakeHash[1] << 16) +169    (handshakeHash[2] << 8) +170    handshakeHash[3];171  return String((n >>> 0) % 1_000_000).padStart(6, "0");172}173174/**175 * Run the post-chirp pairing handshake from the RESPONDER side (extension).176 *177 * `ourEphemeralPrivateKey` / `ourEphemeralPublicKey` come from the chirp178 * the responder broadcast; the initiator's chirp told them which key to179 * authenticate against.180 */181export async function runPairingResponder(182  ourEphemeralPrivateKey: Uint8Array,183  ourEphemeralPublicKey: Uint8Array,184  ourIdentity: LocalIdentity,185  transport: PairingTransport,186): Promise<PairingHandshakeResult> {187  const session = await noiseNKResponder(188    ourEphemeralPrivateKey,189    ourEphemeralPublicKey,190    transport.send,191    transport.receive,192  );193194  // Receive theirs first, then send ours — the initiator sends first.195  const theirCipher = await transport.receive();196  const theirPlain = session.decrypt(theirCipher);197  const theirFrame = decodeFrame(theirPlain);198199  const ourFrame = encodeFrame({200    type: "PAIR_IDENTITY",201    device_id: ourIdentity.deviceId,202    device_name: ourIdentity.deviceName,203    device_type: ourIdentity.deviceType,204    long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,205    signing_public_key_hex: ourIdentity.signingPublicKeyHex,206    ws_port: ourIdentity.wsPort,207  });208  await transport.send(session.encrypt(ourFrame));209210  // SAS derived from the shared handshake hash — caller must have the user211  // confirm it matches the peer's code before persisting (defeats the212  // initiator-impersonation / MITM attack the NK handshake alone allows).213  return { device: frameToPairedDevice(theirFrame), sas: derivePairingSAS(session.handshakeHash) };214}215216function frameToPairedDevice(frame: PairingFrame): PairedDevice {217  return {218    deviceId: frame.device_id,219    deviceName: frame.device_name,220    deviceType: frame.device_type,221    longTermPublicKeyHex: frame.long_term_public_key_hex,222    signingPublicKeyHex: frame.signing_public_key_hex,223    trustSource: "direct",224    wsPort: typeof frame.ws_port === "number" ? frame.ws_port : undefined,225    pairedAt: Date.now(),226  };227}228229/**230 * Test-helper: an in-memory pair of transports that connect to each other.231 * Use one half for the initiator and the other for the responder.232 */233export function memoryTransportPair(): [PairingTransport, PairingTransport] {234  const a: Uint8Array[] = [];235  const b: Uint8Array[] = [];236  const aReaders: Array<(b: Uint8Array) => void> = [];237  const bReaders: Array<(b: Uint8Array) => void> = [];238239  function makeTransport(240    inbox: Uint8Array[],241    outbox: Uint8Array[],242    inboxReaders: Array<(b: Uint8Array) => void>,243    outboxReaders: Array<(b: Uint8Array) => void>,244  ): PairingTransport {245    return {246      async send(msg: Uint8Array) {247        const r = outboxReaders.shift();248        if (r) r(msg);249        else outbox.push(msg);250      },251      receive() {252        const msg = inbox.shift();253        if (msg) return Promise.resolve(msg);254        return new Promise<Uint8Array>((resolve) => inboxReaders.push(resolve));255      },256    };257  }258  return [makeTransport(a, b, aReaders, bReaders), makeTransport(b, a, bReaders, aReaders)];259}