pairing-handshake.ts

14.1 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. This first-pair NK session is discarded. Future WebSocket reconnects27//      (`shared/sync-transport.ts`) use standard Noise KK with both long-term28//      static keys from paired local state, followed by a bound29//      PAIR_IDENTITY exchange. KK failure never falls back to NK.3031import { noiseNKInitiator, noiseNKResponder } from "./noise-js";32import type { DeviceType, PairedDevice } from "./sync-types";33import {34  bytesToHex,35  deriveRendezvousSecret,36  normalizeRelayUrl,37} from "./rendezvous";3839export interface PairingTransport {40  send(msg: Uint8Array): Promise<void>;41  receive(): Promise<Uint8Array>;42}4344/** Result of a pairing handshake: the peer's identity plus the SAS the user45 *  must confirm matches the peer device before the pair is persisted. */46export interface PairingHandshakeResult {47  device: PairedDevice;48  sas: string;49  /**50   * The peer's signed Pro license blob, if it advertised one. The caller51   * activates it (signature-verified) AFTER the SAS is confirmed, so one Pro52   * device can grant Pro to the other when pairing. Never persisted as-is.53   */54  peerLicense?: string;55}5657export interface LocalIdentity {58  deviceId: string;59  deviceName: string;60  deviceType: DeviceType;61  longTermPublicKeyHex: string;62  signingPublicKeyHex?: string;63  /** Server-only field — Android/iOS sync hosts announce their port. */64  wsPort?: number;65  /**66   * Stable per-vault id (`computeVaultId`, sync-identity.ts). Sent in the67   * outbound PAIR_IDENTITY so a native peer can record which vault it68   * paired to. Optional: the caller omits it when the vault is locked (no69   * master key). Privacy: this is the LAN-linkable id — see the PRIVACY NOTE70   * at sync-identity.ts:62 (PCH-02-006).71   */72  vaultId?: string;73  /**74   * This device's signed Pro license blob (`PPK-…`), included only when this75   * device is Pro. The peer activates it (signature-verified) after the SAS is76   * confirmed, so pairing carries Pro to a free device. Either direction.77   */78  license?: string;79  relayUrl?: string;80}8182interface PairingFrame {83  type: "PAIR_IDENTITY";84  device_id: string;85  device_name: string;86  device_type: DeviceType;87  long_term_public_key_hex: string;88  signing_public_key_hex?: string;89  ws_port?: number;90  // Optional: present only when the sender knows its vault id. peach-sync91  // treats this as optional (pairing.zig:54); mobile peers may omit it.92  vault_id?: string;93  // Optional signed Pro license blob — additive; peers that don't understand94  // it ignore it. Activated by the receiver only after SAS confirmation.95  license?: string;96  relay_url?: string;97}9899export interface PostNoiseFrame {100  sas: string;101  rendezvousSecretHex: string;102  frame: unknown;103  channel: PairingTransport;104}105106export interface PostNoiseChannel {107  sas: string;108  rendezvousSecretHex: string;109  channel: PairingTransport;110}111112function encodeFrame(frame: PairingFrame): Uint8Array {113  return new TextEncoder().encode(JSON.stringify(frame));114}115116function decodeParsedFrame(parsed: unknown): PairingFrame {117  const value = parsed as Record<string, unknown> | null;118  if (value?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY frame");119  if (typeof value.device_id !== "string" || !value.device_id) throw new Error("device_id missing");120  if (typeof value.device_name !== "string") throw new Error("device_name missing");121  if (typeof value.device_type !== "string") throw new Error("device_type missing");122  if (typeof value.long_term_public_key_hex !== "string") throw new Error("long_term_public_key_hex missing");123  // Long-term pubkey is a hex-encoded 32-byte X25519 key.124  if (!/^[0-9a-f]{64}$/.test(value.long_term_public_key_hex)) {125    throw new Error("long_term_public_key_hex is not 32-byte hex");126  }127  if (128    value.signing_public_key_hex !== undefined &&129    (typeof value.signing_public_key_hex !== "string" || !/^[0-9a-f]{64}$/.test(value.signing_public_key_hex))130  ) {131    throw new Error("signing_public_key_hex is not 32-byte hex");132  }133  if (value.relay_url !== undefined) {134    if (typeof value.relay_url !== "string") throw new Error("relay_url invalid");135    value.relay_url = normalizeRelayUrl(value.relay_url);136  }137  return value as unknown as PairingFrame;138}139140/**141 * Run the pairing handshake from the INITIATOR side.142 *143 * `remoteEphemeralPublicKey` is the responder's ephemeral pubkey, learned out144 * of band. The Noise NK transport message 1 encrypts to that key; the145 * responder must hold the matching private key.146 */147export async function runPairingInitiator(148  remoteEphemeralPublicKey: Uint8Array,149  ourIdentity: LocalIdentity,150  transport: PairingTransport,151): Promise<PairingHandshakeResult> {152  const postNoise = await runPairingInitiatorOpenChannel(remoteEphemeralPublicKey, transport);153154  // Send our identity, then receive theirs.155  await postNoise.channel.send(encodeFrame({156    type: "PAIR_IDENTITY",157    device_id: ourIdentity.deviceId,158    device_name: ourIdentity.deviceName,159    device_type: ourIdentity.deviceType,160    long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,161    signing_public_key_hex: ourIdentity.signingPublicKeyHex,162    ws_port: ourIdentity.wsPort,163    vault_id: ourIdentity.vaultId,164    license: ourIdentity.license,165    relay_url: ourIdentity.relayUrl,166  }));167168  const theirFrame = decodeParsedFrame(JSON.parse(new TextDecoder().decode(await postNoise.channel.receive())));169  // The SAS is derived from the shared Noise handshake hash; the caller MUST170  // have the user confirm it matches the peer's before persisting the device.171  return {172    device: {173      ...frameToPairedDevice(theirFrame),174      rendezvousSecretHex: postNoise.rendezvousSecretHex,175    },176    sas: postNoise.sas,177    peerLicense: peerLicenseOf(theirFrame),178  };179}180181export async function runPairingInitiatorOpenChannel(182  remoteEphemeralPublicKey: Uint8Array,183  transport: PairingTransport,184): Promise<PostNoiseChannel> {185  const session = await noiseNKInitiator(186    remoteEphemeralPublicKey,187    transport.send,188    transport.receive,189  );190191  const channel: PairingTransport = {192    async send(msg) {193      await transport.send(session.encrypt(msg));194    },195    async receive() {196      return session.decrypt(await transport.receive());197    },198  };199  return {200    sas: derivePairingSAS(session.handshakeHash),201    rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),202    channel,203  };204}205206// ───────────────────────────────────────────────────────────────────────────207// SECURITY — TRUST MODEL: first pairing uses NK because the responder key is208// known from the one-time code but the initiator identity is not paired yet.209// Immediately after NK, Android sends a transcript-bound challenge and the210// browser proves possession of the private half of the X25519 key it will place211// in PAIR_IDENTITY. Android constant-time binds that proven key to the frame;212// browser likewise requires Android's identity key to equal the NK responder213// static. This is structural authentication, not a claim carried by JSON.214//215// The user-compared SAS remains mandatory defense in depth. Both sides derive216// the same value from the handshake hash, display it, and persist nothing until217// the user confirms. Android's live CodePairingServer blocks on the same gate.218// Reconnect is a separate local-state-selected Noise KK path and never falls219// back to NK.220// ───────────────────────────────────────────────────────────────────────────221222/**223 * Derive an 8-digit Short Authentication String from a Noise handshake hash.224 * Both pairing peers compute the SAME value because the Noise `handshakeHash`225 * is shared after the handshake. Showing it on both devices and having the226 * user confirm they match authenticates the channel out-of-band, detecting a227 * man-in-the-middle the NK handshake alone cannot (see SECURITY note above).228 */229export function derivePairingSAS(handshakeHash: Uint8Array): string {230  if (handshakeHash.length < 8) {231    throw new Error("handshake hash too short for SAS");232  }233  // Use 64 PRF-output bits so modulo bias at the 1e8 boundary is negligible.234  // The identical big-endian rule is pinned by Android cross-platform KATs.235  let value = 0n;236  for (const byte of handshakeHash.subarray(0, 8)) {237    value = (value << 8n) | BigInt(byte);238  }239  return String(value % 100_000_000n).padStart(8, "0");240}241242/**243 * Run the pairing handshake from the RESPONDER side.244 *245 * NOT REACHABLE FROM A BROWSER — responding means accepting an inbound246 * connection. Retained for the protocol definition and its specs only.247 */248export async function runPairingResponder(249  ourEphemeralPrivateKey: Uint8Array,250  ourEphemeralPublicKey: Uint8Array,251  ourIdentity: LocalIdentity,252  transport: PairingTransport,253): Promise<PairingHandshakeResult> {254  const postNoise = await runPairingResponderOpenFrame(255    ourEphemeralPrivateKey,256    ourEphemeralPublicKey,257    transport,258  );259  return handlePairIdentityFrame(260    postNoise.frame,261    postNoise.channel,262    ourIdentity,263    postNoise.sas,264    postNoise.rendezvousSecretHex,265  );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    rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),292    frame: theirFrame,293    channel,294  };295}296297export async function handlePairIdentityFrame(298  frame: unknown,299  channel: PairingTransport,300  ourIdentity: LocalIdentity,301  sas: string,302  rendezvousSecretHex: string,303): Promise<PairingHandshakeResult> {304  const theirFrame = decodeParsedFrame(frame);305  const ourFrame = encodeFrame({306    type: "PAIR_IDENTITY",307    device_id: ourIdentity.deviceId,308    device_name: ourIdentity.deviceName,309    device_type: ourIdentity.deviceType,310    long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,311    signing_public_key_hex: ourIdentity.signingPublicKeyHex,312    ws_port: ourIdentity.wsPort,313    vault_id: ourIdentity.vaultId,314    license: ourIdentity.license,315    relay_url: ourIdentity.relayUrl,316  });317  await channel.send(ourFrame);318319  // SAS derived from the shared handshake hash — caller must have the user320  // confirm it matches the peer's code before persisting (defeats the321  // initiator-impersonation / MITM attack the NK handshake alone allows).322  return {323    device: {324      ...frameToPairedDevice(theirFrame),325      rendezvousSecretHex,326    },327    sas,328    peerLicense: peerLicenseOf(theirFrame),329  };330}331332/** Pull a plausible license string off a peer frame (signature is verified333 *  later, on activation). Anything non-string is ignored. */334function peerLicenseOf(frame: PairingFrame): string | undefined {335  return typeof frame.license === "string" && frame.license.length > 0 ? frame.license : undefined;336}337338function frameToPairedDevice(frame: PairingFrame): PairedDevice {339  return {340    deviceId: frame.device_id,341    deviceName: frame.device_name,342    deviceType: frame.device_type,343    longTermPublicKeyHex: frame.long_term_public_key_hex,344    signingPublicKeyHex: frame.signing_public_key_hex,345    trustSource: "direct",346    wsPort: typeof frame.ws_port === "number" ? frame.ws_port : undefined,347    relayUrl: frame.relay_url,348    pairedAt: Date.now(),349  };350}351352/**353 * Test-helper: an in-memory pair of transports that connect to each other.354 * Use one half for the initiator and the other for the responder.355 */356export function memoryTransportPair(): [PairingTransport, PairingTransport] {357  const a: Uint8Array[] = [];358  const b: Uint8Array[] = [];359  const aReaders: Array<(b: Uint8Array) => void> = [];360  const bReaders: Array<(b: Uint8Array) => void> = [];361362  function makeTransport(363    inbox: Uint8Array[],364    outbox: Uint8Array[],365    inboxReaders: Array<(b: Uint8Array) => void>,366    outboxReaders: Array<(b: Uint8Array) => void>,367  ): PairingTransport {368    return {369      async send(msg: Uint8Array) {370        const r = outboxReaders.shift();371        if (r) r(msg);372        else outbox.push(msg);373      },374      receive() {375        const msg = inbox.shift();376        if (msg) return Promise.resolve(msg);377        return new Promise<Uint8Array>((resolve) => inboxReaders.push(resolve));378      },379    };380  }381  return [makeTransport(a, b, aReaders, bReaders), makeTransport(b, a, bReaders, aReaders)];382}