pairing-handshake.ts

15.0 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 does NOT use Noise XX).3132import { noiseNKInitiator, noiseNKResponder } from "./noise-js";33import type { DeviceType, PairedDevice } from "./sync-types";3435export interface PairingTransport {36  send(msg: Uint8Array): Promise<void>;37  receive(): Promise<Uint8Array>;38}3940/** Result of a pairing handshake: the peer's identity plus the SAS the user41 *  must confirm matches the peer device before the pair is persisted. */42export interface PairingHandshakeResult {43  device: PairedDevice;44  sas: string;45  /**46   * The peer's signed Pro license blob, if it advertised one. The caller47   * activates it (signature-verified) AFTER the SAS is confirmed, so one Pro48   * device can grant Pro to the other when pairing. Never persisted as-is.49   */50  peerLicense?: string;51}5253export interface LocalIdentity {54  deviceId: string;55  deviceName: string;56  deviceType: DeviceType;57  longTermPublicKeyHex: string;58  signingPublicKeyHex?: string;59  /** Server-only field — Android/iOS/desktop sync hosts announce their port. */60  wsPort?: number;61  /**62   * Stable per-vault id (`computeVaultId`, sync-identity.ts). Sent in the63   * outbound PAIR_IDENTITY so a desktop helper can record which vault it64   * paired to. Optional: the caller omits it when the vault is locked (no65   * master key). Privacy: this is the LAN-linkable id — see the PRIVACY NOTE66   * at sync-identity.ts:62 (PCH-02-006).67   */68  vaultId?: string;69  /**70   * This device's signed Pro license blob (`PPK-…`), included only when this71   * device is Pro. The peer activates it (signature-verified) after the SAS is72   * confirmed, so pairing carries Pro to a free device. Either direction.73   */74  license?: string;75}7677interface PairingFrame {78  type: "PAIR_IDENTITY";79  device_id: string;80  device_name: string;81  device_type: DeviceType;82  long_term_public_key_hex: string;83  signing_public_key_hex?: string;84  ws_port?: number;85  // Optional: present only when the sender knows its vault id. peach-sync86  // treats this as optional (pairing.zig:54); mobile peers may omit it.87  vault_id?: string;88  // Optional signed Pro license blob — additive; peers that don't understand89  // it ignore it. Activated by the receiver only after SAS confirmation.90  license?: string;91}9293export interface PostNoiseFrame {94  sas: string;95  frame: unknown;96  channel: PairingTransport;97}9899export interface PostNoiseChannel {100  sas: string;101  channel: PairingTransport;102}103104function encodeFrame(frame: PairingFrame): Uint8Array {105  return new TextEncoder().encode(JSON.stringify(frame));106}107108function decodeParsedFrame(parsed: unknown): PairingFrame {109  const value = parsed as Record<string, unknown> | null;110  if (value?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY frame");111  if (typeof value.device_id !== "string" || !value.device_id) throw new Error("device_id missing");112  if (typeof value.device_name !== "string") throw new Error("device_name missing");113  if (typeof value.device_type !== "string") throw new Error("device_type missing");114  if (typeof value.long_term_public_key_hex !== "string") throw new Error("long_term_public_key_hex missing");115  // Long-term pubkey is a hex-encoded 32-byte X25519 key.116  if (!/^[0-9a-f]{64}$/.test(value.long_term_public_key_hex)) {117    throw new Error("long_term_public_key_hex is not 32-byte hex");118  }119  if (120    value.signing_public_key_hex !== undefined &&121    (typeof value.signing_public_key_hex !== "string" || !/^[0-9a-f]{64}$/.test(value.signing_public_key_hex))122  ) {123    throw new Error("signing_public_key_hex is not 32-byte hex");124  }125  return value as unknown as PairingFrame;126}127128/**129 * Run the pairing handshake from the INITIATOR side.130 *131 * `remoteEphemeralPublicKey` is the responder's ephemeral pubkey, learned out132 * of band. The Noise NK transport message 1 encrypts to that key; the133 * responder must hold the matching private key.134 */135export async function runPairingInitiator(136  remoteEphemeralPublicKey: Uint8Array,137  ourIdentity: LocalIdentity,138  transport: PairingTransport,139): Promise<PairingHandshakeResult> {140  const postNoise = await runPairingInitiatorOpenChannel(remoteEphemeralPublicKey, transport);141142  // Send our identity, then receive theirs.143  await postNoise.channel.send(encodeFrame({144    type: "PAIR_IDENTITY",145    device_id: ourIdentity.deviceId,146    device_name: ourIdentity.deviceName,147    device_type: ourIdentity.deviceType,148    long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,149    signing_public_key_hex: ourIdentity.signingPublicKeyHex,150    ws_port: ourIdentity.wsPort,151    vault_id: ourIdentity.vaultId,152    license: ourIdentity.license,153  }));154155  const theirFrame = decodeParsedFrame(JSON.parse(new TextDecoder().decode(await postNoise.channel.receive())));156  // The SAS is derived from the shared Noise handshake hash; the caller MUST157  // have the user confirm it matches the peer's before persisting the device.158  return {159    device: frameToPairedDevice(theirFrame),160    sas: postNoise.sas,161    peerLicense: peerLicenseOf(theirFrame),162  };163}164165export async function runPairingInitiatorOpenChannel(166  remoteEphemeralPublicKey: Uint8Array,167  transport: PairingTransport,168): Promise<PostNoiseChannel> {169  const session = await noiseNKInitiator(170    remoteEphemeralPublicKey,171    transport.send,172    transport.receive,173  );174175  const channel: PairingTransport = {176    async send(msg) {177      await transport.send(session.encrypt(msg));178    },179    async receive() {180      return session.decrypt(await transport.receive());181    },182  };183  return {184    sas: derivePairingSAS(session.handshakeHash),185    channel,186  };187}188189// ───────────────────────────────────────────────────────────────────────────190// SECURITY — TRUST MODEL (PCH-02-003): the pairing handshake does NOT cryptographically191// authenticate the initiator. Noise NK authenticates only the RESPONDER (it192// proves the initiator encrypted to the responder's "static" key — here the193// ephemeral the responder published in the clear out of band). On its own194// it gives the extension (always the responder) zero assurance about *who*195// completed the handshake, and there is no proof-of-possession over the196// long_term_public_key_hex a peer advertises in its PAIR_IDENTITY frame.197//198// COMPENSATING CONTROL (IMPLEMENTED, MANDATORY on the extension side): a199// user-verified Short Authentication String. Both peers derive the SAME 6-digit200// SAS via `derivePairingSAS(session.handshakeHash)` (below) — identical on both201// ends because the Noise handshake hash is shared — and DISPLAY it. The202// extension pairing flow gates persistence on the user confirming the codes203// match: the SAS gate in `background/code-pairing.ts` runs BEFORE any device is204// written to the registry and ABORTS pairing on mismatch/rejection. This is the205// Bluetooth-numeric-comparison / Signal-safety-number model and detects a206// MITM/relay despite the lack of full mutual auth. Residual risk is the standard207// 6-digit SAS bound: a MITM survives with probability 1e-6 per attempt, and the208// guarantee depends on the user actually comparing the codes.209//210// OPEN ITEM (NOT verifiable from this repository): the Android/native client211// MUST surface and enforce the identical SAS gate before it persists the peer.212// If the app skips or auto-confirms the SAS, the channel is only as strong as213// the app's weakest end — confirm the app-side gate against the Android source214// at kepr.uk/peach-android before any release that touches pairing. A MITM who215// intercepts the out-of-band ephemeral and completes the handshake against a non-216// enforcing peer can pair without user awareness.217// A future hardening step (coordinated wire change with the app) could add218// proof-of-possession via `performInitiatorAuth`/`performResponderAuth`219// (noise-js) so each side proves possession of the key it advertises, making220// authentication structural rather than dependent on the user's SAS comparison.221//222// This is documented residual risk in the cross-repo trust model, not a bug in223// this codebase: the extension-side gateOnSAS is enforced here and regression-224// tested (PCH-01-024); the peer-side gate lives in Android and must be re-225// verified there on every pairing-touching Android release.226// ───────────────────────────────────────────────────────────────────────────227228/**229 * Derive a 6-digit Short Authentication String from a Noise handshake hash.230 * Both pairing peers compute the SAME value because the Noise `handshakeHash`231 * is shared after the handshake. Showing it on both devices and having the232 * user confirm they match authenticates the channel out-of-band, detecting a233 * man-in-the-middle the NK handshake alone cannot (see SECURITY note above).234 */235export function derivePairingSAS(handshakeHash: Uint8Array): string {236  // Fold the first 4 bytes into an unsigned int, mod 1e6, zero-padded. Uniform237  // enough for a 6-digit comparison code; the handshake hash is already a PRF238  // output so no further mixing is needed.239  if (handshakeHash.length < 4) return "000000";240  const n =241    ((handshakeHash[0] << 24) >>> 0) +242    (handshakeHash[1] << 16) +243    (handshakeHash[2] << 8) +244    handshakeHash[3];245  return String((n >>> 0) % 1_000_000).padStart(6, "0");246}247248/**249 * Run the pairing handshake from the RESPONDER side.250 *251 * NOT REACHABLE FROM A BROWSER — responding means accepting an inbound252 * connection. Retained for the protocol definition and its specs only.253 */254export async function runPairingResponder(255  ourEphemeralPrivateKey: Uint8Array,256  ourEphemeralPublicKey: Uint8Array,257  ourIdentity: LocalIdentity,258  transport: PairingTransport,259): Promise<PairingHandshakeResult> {260  const postNoise = await runPairingResponderOpenFrame(261    ourEphemeralPrivateKey,262    ourEphemeralPublicKey,263    transport,264  );265  return handlePairIdentityFrame(postNoise.frame, postNoise.channel, ourIdentity, postNoise.sas);266}267268export async function runPairingResponderOpenFrame(269  ourEphemeralPrivateKey: Uint8Array,270  ourEphemeralPublicKey: Uint8Array,271  transport: PairingTransport,272): Promise<PostNoiseFrame> {273  const session = await noiseNKResponder(274    ourEphemeralPrivateKey,275    ourEphemeralPublicKey,276    transport.send,277    transport.receive,278  );279280  const channel: PairingTransport = {281    async send(msg) {282      await transport.send(session.encrypt(msg));283    },284    async receive() {285      return session.decrypt(await transport.receive());286    },287  };288  const theirFrame = JSON.parse(new TextDecoder().decode(await channel.receive()));289  return {290    sas: derivePairingSAS(session.handshakeHash),291    frame: theirFrame,292    channel,293  };294}295296export async function handlePairIdentityFrame(297  frame: unknown,298  channel: PairingTransport,299  ourIdentity: LocalIdentity,300  sas: string,301): Promise<PairingHandshakeResult> {302  const theirFrame = decodeParsedFrame(frame);303  const ourFrame = encodeFrame({304    type: "PAIR_IDENTITY",305    device_id: ourIdentity.deviceId,306    device_name: ourIdentity.deviceName,307    device_type: ourIdentity.deviceType,308    long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,309    signing_public_key_hex: ourIdentity.signingPublicKeyHex,310    ws_port: ourIdentity.wsPort,311    vault_id: ourIdentity.vaultId,312    license: ourIdentity.license,313  });314  await channel.send(ourFrame);315316  // SAS derived from the shared handshake hash — caller must have the user317  // confirm it matches the peer's code before persisting (defeats the318  // initiator-impersonation / MITM attack the NK handshake alone allows).319  return {320    device: frameToPairedDevice(theirFrame),321    sas,322    peerLicense: peerLicenseOf(theirFrame),323  };324}325326/** Pull a plausible license string off a peer frame (signature is verified327 *  later, on activation). Anything non-string is ignored. */328function peerLicenseOf(frame: PairingFrame): string | undefined {329  return typeof frame.license === "string" && frame.license.length > 0 ? frame.license : undefined;330}331332function frameToPairedDevice(frame: PairingFrame): PairedDevice {333  return {334    deviceId: frame.device_id,335    deviceName: frame.device_name,336    deviceType: frame.device_type,337    longTermPublicKeyHex: frame.long_term_public_key_hex,338    signingPublicKeyHex: frame.signing_public_key_hex,339    trustSource: "direct",340    wsPort: typeof frame.ws_port === "number" ? frame.ws_port : undefined,341    pairedAt: Date.now(),342  };343}344345/**346 * Test-helper: an in-memory pair of transports that connect to each other.347 * Use one half for the initiator and the other for the responder.348 */349export function memoryTransportPair(): [PairingTransport, PairingTransport] {350  const a: Uint8Array[] = [];351  const b: Uint8Array[] = [];352  const aReaders: Array<(b: Uint8Array) => void> = [];353  const bReaders: Array<(b: Uint8Array) => void> = [];354355  function makeTransport(356    inbox: Uint8Array[],357    outbox: Uint8Array[],358    inboxReaders: Array<(b: Uint8Array) => void>,359    outboxReaders: Array<(b: Uint8Array) => void>,360  ): PairingTransport {361    return {362      async send(msg: Uint8Array) {363        const r = outboxReaders.shift();364        if (r) r(msg);365        else outbox.push(msg);366      },367      receive() {368        const msg = inbox.shift();369        if (msg) return Promise.resolve(msg);370        return new Promise<Uint8Array>((resolve) => inboxReaders.push(resolve));371      },372    };373  }374  return [makeTransport(a, b, aReaders, bReaders), makeTransport(b, a, bReaders, aReaders)];375}