pairing-handshake.ts

15.9 KB · raw

1// Pairing handshake — Noise NK, the SAS, and the PAIR_IDENTITY frame.2//3// STATUS: the audio-pairing flow this module was written for is gone. What production still4// uses from here is `derivePairingSAS` and the `LocalIdentity` /5// `PairingTransport` types; the live dial-and-handshake path is6// `shared/pairing-dial.ts`, and the live PAIR_IDENTITY exchange is7// `background/code-pairing.ts`.8//9// The `runPairing*` helpers below are exercised only by this module's own10// specs. Do NOT reach for the RESPONDER ones in new code: they assume this11// device can accept an inbound connection, which a browser extension cannot12// do. The browser is always the dialler, always the NK initiator.13//14// The shape of the exchange, which the live path still follows:15//16//   1. Both sides instantiate Noise NK over a caller-supplied transport. The17//      responder's "static" is an ephemeral it published out of band — today18//      that is the `ephemeralPublicKeyHex` it returns in PEACH_HELLO_ACK,19//      which is why the SAS below carries the real authentication weight.20//21//   2. Over the resulting Noise transport, each side sends ONE JSON frame with22//      its `device_id`, `device_name`, `device_type`,23//      `long_term_public_key_hex`, and device-manifest signing key. Both sides24//      persist the counterpart in the paired-device registry.25//26//   3. The Noise NK session and the ephemerals are discarded — future27//      WebSocket sessions (`shared/sync-transport.ts`) run Noise NK again,28//      this time against the long-term static key exchanged in step 2,29//      followed by a fresh PAIR_IDENTITY exchange (see30//      HANDSHAKE_STANDARDIZE_ON_NK.md; ongoing sync uses the same pattern).3132import { noiseNKInitiator, noiseNKResponder } from "./noise-js";33import type { DeviceType, PairedDevice } from "./sync-types";34import {35  bytesToHex,36  deriveRendezvousSecret,37  normalizeRelayUrl,38} from "./rendezvous";3940export interface PairingTransport {41  send(msg: Uint8Array): Promise<void>;42  receive(): Promise<Uint8Array>;43}4445/** Result of a pairing handshake: the peer's identity plus the SAS the user46 *  must confirm matches the peer device before the pair is persisted. */47export interface PairingHandshakeResult {48  device: PairedDevice;49  sas: string;50  /**51   * The peer's signed Pro license blob, if it advertised one. The caller52   * activates it (signature-verified) AFTER the SAS is confirmed, so one Pro53   * device can grant Pro to the other when pairing. Never persisted as-is.54   */55  peerLicense?: string;56}5758export interface LocalIdentity {59  deviceId: string;60  deviceName: string;61  deviceType: DeviceType;62  longTermPublicKeyHex: string;63  signingPublicKeyHex?: string;64  /** Server-only field — Android/iOS sync hosts announce their port. */65  wsPort?: number;66  /**67   * Stable per-vault id (`computeVaultId`, sync-identity.ts). Sent in the68   * outbound PAIR_IDENTITY so a native peer can record which vault it69   * paired to. Optional: the caller omits it when the vault is locked (no70   * master key). Privacy: this is the LAN-linkable id — see the PRIVACY NOTE71   * at sync-identity.ts:62 (PCH-02-006).72   */73  vaultId?: string;74  /**75   * This device's signed Pro license blob (`PPK-…`), included only when this76   * device is Pro. The peer activates it (signature-verified) after the SAS is77   * confirmed, so pairing carries Pro to a free device. Either direction.78   */79  license?: string;80  relayUrl?: string;81}8283interface PairingFrame {84  type: "PAIR_IDENTITY";85  device_id: string;86  device_name: string;87  device_type: DeviceType;88  long_term_public_key_hex: string;89  signing_public_key_hex?: string;90  ws_port?: number;91  // Optional: present only when the sender knows its vault id. peach-sync92  // treats this as optional (pairing.zig:54); mobile peers may omit it.93  vault_id?: string;94  // Optional signed Pro license blob — additive; peers that don't understand95  // it ignore it. Activated by the receiver only after SAS confirmation.96  license?: string;97  relay_url?: string;98}99100export interface PostNoiseFrame {101  sas: string;102  rendezvousSecretHex: string;103  frame: unknown;104  channel: PairingTransport;105}106107export interface PostNoiseChannel {108  sas: string;109  rendezvousSecretHex: string;110  channel: PairingTransport;111}112113function encodeFrame(frame: PairingFrame): Uint8Array {114  return new TextEncoder().encode(JSON.stringify(frame));115}116117function decodeParsedFrame(parsed: unknown): PairingFrame {118  const value = parsed as Record<string, unknown> | null;119  if (value?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY frame");120  if (typeof value.device_id !== "string" || !value.device_id) throw new Error("device_id missing");121  if (typeof value.device_name !== "string") throw new Error("device_name missing");122  if (typeof value.device_type !== "string") throw new Error("device_type missing");123  if (typeof value.long_term_public_key_hex !== "string") throw new Error("long_term_public_key_hex missing");124  // Long-term pubkey is a hex-encoded 32-byte X25519 key.125  if (!/^[0-9a-f]{64}$/.test(value.long_term_public_key_hex)) {126    throw new Error("long_term_public_key_hex is not 32-byte hex");127  }128  if (129    value.signing_public_key_hex !== undefined &&130    (typeof value.signing_public_key_hex !== "string" || !/^[0-9a-f]{64}$/.test(value.signing_public_key_hex))131  ) {132    throw new Error("signing_public_key_hex is not 32-byte hex");133  }134  if (value.relay_url !== undefined) {135    if (typeof value.relay_url !== "string") throw new Error("relay_url invalid");136    value.relay_url = normalizeRelayUrl(value.relay_url);137  }138  return value as unknown as PairingFrame;139}140141/**142 * Run the pairing handshake from the INITIATOR side.143 *144 * `remoteEphemeralPublicKey` is the responder's ephemeral pubkey, learned out145 * of band. The Noise NK transport message 1 encrypts to that key; the146 * responder must hold the matching private key.147 */148export async function runPairingInitiator(149  remoteEphemeralPublicKey: Uint8Array,150  ourIdentity: LocalIdentity,151  transport: PairingTransport,152): Promise<PairingHandshakeResult> {153  const postNoise = await runPairingInitiatorOpenChannel(remoteEphemeralPublicKey, transport);154155  // Send our identity, then receive theirs.156  await postNoise.channel.send(encodeFrame({157    type: "PAIR_IDENTITY",158    device_id: ourIdentity.deviceId,159    device_name: ourIdentity.deviceName,160    device_type: ourIdentity.deviceType,161    long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,162    signing_public_key_hex: ourIdentity.signingPublicKeyHex,163    ws_port: ourIdentity.wsPort,164    vault_id: ourIdentity.vaultId,165    license: ourIdentity.license,166    relay_url: ourIdentity.relayUrl,167  }));168169  const theirFrame = decodeParsedFrame(JSON.parse(new TextDecoder().decode(await postNoise.channel.receive())));170  // The SAS is derived from the shared Noise handshake hash; the caller MUST171  // have the user confirm it matches the peer's before persisting the device.172  return {173    device: {174      ...frameToPairedDevice(theirFrame),175      rendezvousSecretHex: postNoise.rendezvousSecretHex,176    },177    sas: postNoise.sas,178    peerLicense: peerLicenseOf(theirFrame),179  };180}181182export async function runPairingInitiatorOpenChannel(183  remoteEphemeralPublicKey: Uint8Array,184  transport: PairingTransport,185): Promise<PostNoiseChannel> {186  const session = await noiseNKInitiator(187    remoteEphemeralPublicKey,188    transport.send,189    transport.receive,190  );191192  const channel: PairingTransport = {193    async send(msg) {194      await transport.send(session.encrypt(msg));195    },196    async receive() {197      return session.decrypt(await transport.receive());198    },199  };200  return {201    sas: derivePairingSAS(session.handshakeHash),202    rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),203    channel,204  };205}206207// ───────────────────────────────────────────────────────────────────────────208// SECURITY — TRUST MODEL (PCH-02-003): the pairing handshake does NOT cryptographically209// authenticate the initiator. Noise NK authenticates only the RESPONDER (it210// proves the initiator encrypted to the responder's "static" key — here the211// ephemeral the responder published in the clear out of band). On its own212// it gives the extension (always the responder) zero assurance about *who*213// completed the handshake, and there is no proof-of-possession over the214// long_term_public_key_hex a peer advertises in its PAIR_IDENTITY frame.215//216// COMPENSATING CONTROL (IMPLEMENTED, MANDATORY on the extension side): a217// user-verified Short Authentication String. Both peers derive the SAME 6-digit218// SAS via `derivePairingSAS(session.handshakeHash)` (below) — identical on both219// ends because the Noise handshake hash is shared — and DISPLAY it. The220// extension pairing flow gates persistence on the user confirming the codes221// match: the SAS gate in `background/code-pairing.ts` runs BEFORE any device is222// written to the registry and ABORTS pairing on mismatch/rejection. This is the223// Bluetooth-numeric-comparison / Signal-safety-number model and detects a224// MITM/relay despite the lack of full mutual auth. Residual risk is the standard225// 6-digit SAS bound: a MITM survives with probability 1e-6 per attempt, and the226// guarantee depends on the user actually comparing the codes.227//228// OPEN ITEM (NOT verifiable from this repository): the Android/native client229// MUST surface and enforce the identical SAS gate before it persists the peer.230// If the app skips or auto-confirms the SAS, the channel is only as strong as231// the app's weakest end — confirm the app-side gate against the Android source232// at kepr.uk/peach-android before any release that touches pairing. A MITM who233// intercepts the out-of-band ephemeral and completes the handshake against a non-234// enforcing peer can pair without user awareness.235// A future hardening step (coordinated wire change with the app) could add236// proof-of-possession via `performInitiatorAuth`/`performResponderAuth`237// (noise-js) so each side proves possession of the key it advertises, making238// authentication structural rather than dependent on the user's SAS comparison.239//240// This is documented residual risk in the cross-repo trust model, not a bug in241// this codebase: the extension-side gateOnSAS is enforced here and regression-242// tested (PCH-01-024); the peer-side gate lives in Android and must be re-243// verified there on every pairing-touching Android release.244// ───────────────────────────────────────────────────────────────────────────245246/**247 * Derive a 6-digit Short Authentication String from a Noise handshake hash.248 * Both pairing peers compute the SAME value because the Noise `handshakeHash`249 * is shared after the handshake. Showing it on both devices and having the250 * user confirm they match authenticates the channel out-of-band, detecting a251 * man-in-the-middle the NK handshake alone cannot (see SECURITY note above).252 */253export function derivePairingSAS(handshakeHash: Uint8Array): string {254  // Fold the first 4 bytes into an unsigned int, mod 1e6, zero-padded. Uniform255  // enough for a 6-digit comparison code; the handshake hash is already a PRF256  // output so no further mixing is needed.257  if (handshakeHash.length < 4) return "000000";258  const n =259    ((handshakeHash[0] << 24) >>> 0) +260    (handshakeHash[1] << 16) +261    (handshakeHash[2] << 8) +262    handshakeHash[3];263  return String((n >>> 0) % 1_000_000).padStart(6, "0");264}265266/**267 * Run the pairing handshake from the RESPONDER side.268 *269 * NOT REACHABLE FROM A BROWSER — responding means accepting an inbound270 * connection. Retained for the protocol definition and its specs only.271 */272export async function runPairingResponder(273  ourEphemeralPrivateKey: Uint8Array,274  ourEphemeralPublicKey: Uint8Array,275  ourIdentity: LocalIdentity,276  transport: PairingTransport,277): Promise<PairingHandshakeResult> {278  const postNoise = await runPairingResponderOpenFrame(279    ourEphemeralPrivateKey,280    ourEphemeralPublicKey,281    transport,282  );283  return handlePairIdentityFrame(284    postNoise.frame,285    postNoise.channel,286    ourIdentity,287    postNoise.sas,288    postNoise.rendezvousSecretHex,289  );290}291292export async function runPairingResponderOpenFrame(293  ourEphemeralPrivateKey: Uint8Array,294  ourEphemeralPublicKey: Uint8Array,295  transport: PairingTransport,296): Promise<PostNoiseFrame> {297  const session = await noiseNKResponder(298    ourEphemeralPrivateKey,299    ourEphemeralPublicKey,300    transport.send,301    transport.receive,302  );303304  const channel: PairingTransport = {305    async send(msg) {306      await transport.send(session.encrypt(msg));307    },308    async receive() {309      return session.decrypt(await transport.receive());310    },311  };312  const theirFrame = JSON.parse(new TextDecoder().decode(await channel.receive()));313  return {314    sas: derivePairingSAS(session.handshakeHash),315    rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),316    frame: theirFrame,317    channel,318  };319}320321export async function handlePairIdentityFrame(322  frame: unknown,323  channel: PairingTransport,324  ourIdentity: LocalIdentity,325  sas: string,326  rendezvousSecretHex: string,327): Promise<PairingHandshakeResult> {328  const theirFrame = decodeParsedFrame(frame);329  const ourFrame = encodeFrame({330    type: "PAIR_IDENTITY",331    device_id: ourIdentity.deviceId,332    device_name: ourIdentity.deviceName,333    device_type: ourIdentity.deviceType,334    long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,335    signing_public_key_hex: ourIdentity.signingPublicKeyHex,336    ws_port: ourIdentity.wsPort,337    vault_id: ourIdentity.vaultId,338    license: ourIdentity.license,339    relay_url: ourIdentity.relayUrl,340  });341  await channel.send(ourFrame);342343  // SAS derived from the shared handshake hash — caller must have the user344  // confirm it matches the peer's code before persisting (defeats the345  // initiator-impersonation / MITM attack the NK handshake alone allows).346  return {347    device: {348      ...frameToPairedDevice(theirFrame),349      rendezvousSecretHex,350    },351    sas,352    peerLicense: peerLicenseOf(theirFrame),353  };354}355356/** Pull a plausible license string off a peer frame (signature is verified357 *  later, on activation). Anything non-string is ignored. */358function peerLicenseOf(frame: PairingFrame): string | undefined {359  return typeof frame.license === "string" && frame.license.length > 0 ? frame.license : undefined;360}361362function frameToPairedDevice(frame: PairingFrame): PairedDevice {363  return {364    deviceId: frame.device_id,365    deviceName: frame.device_name,366    deviceType: frame.device_type,367    longTermPublicKeyHex: frame.long_term_public_key_hex,368    signingPublicKeyHex: frame.signing_public_key_hex,369    trustSource: "direct",370    wsPort: typeof frame.ws_port === "number" ? frame.ws_port : undefined,371    relayUrl: frame.relay_url,372    pairedAt: Date.now(),373  };374}375376/**377 * Test-helper: an in-memory pair of transports that connect to each other.378 * Use one half for the initiator and the other for the responder.379 */380export function memoryTransportPair(): [PairingTransport, PairingTransport] {381  const a: Uint8Array[] = [];382  const b: Uint8Array[] = [];383  const aReaders: Array<(b: Uint8Array) => void> = [];384  const bReaders: Array<(b: Uint8Array) => void> = [];385386  function makeTransport(387    inbox: Uint8Array[],388    outbox: Uint8Array[],389    inboxReaders: Array<(b: Uint8Array) => void>,390    outboxReaders: Array<(b: Uint8Array) => void>,391  ): PairingTransport {392    return {393      async send(msg: Uint8Array) {394        const r = outboxReaders.shift();395        if (r) r(msg);396        else outbox.push(msg);397      },398      receive() {399        const msg = inbox.shift();400        if (msg) return Promise.resolve(msg);401        return new Promise<Uint8Array>((resolve) => inboxReaders.push(resolve));402      },403    };404  }405  return [makeTransport(a, b, aReaders, bReaders), makeTransport(b, a, bReaders, aReaders)];406}