chirp-handshake.ts

15.5 KB · raw

1// Pairing handshake — Noise NK, the SAS, and the PAIR_IDENTITY frame.2//3// STATUS: the audio chirp this file describes 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/desktop-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 original design notes follow, for the trust model in the SECURITY block15// further down — which still governs the flow.16//17// After both devices have exchanged ephemeral X25519 keys via the audio18// chirp, the actual trust transfer happens over an authenticated, encrypted19// channel built on top of those ephemerals. The handshake:20//21//   1. Both sides instantiate Noise NK using the chirp-derived ephemerals.22//      The Android device drives the initiator side; the extension drives23//      the responder side. Treating the chirp ephemerals as the NK "static"24//      key works because the chirp transports the ephemeral with checksum25//      integrity; an attacker who could substitute keys would have to do26//      so in the audio band, in real time, in the same room.27//28//   2. Over the resulting Noise transport, each side sends ONE JSON frame29//      with its `device_id`, `device_name`, `device_type`,30//      `long_term_public_key_hex`, and device-manifest signing key. Both31//      sides receive the counterpart's frame and persist it in the paired32//      device registry.33//34//   3. The Noise NK session and the chirp ephemerals are discarded —35//      future WebSocket sessions use Noise XX with the long-term static36//      keys exchanged in step 2.37//38// The transport for the Noise NK round is provided by the caller. In the39// real pairing flow it is a WebSocket (Android-as-server, extension-as-40// client). Tests pass an in-memory transport.4142import { noiseNKInitiator, noiseNKResponder } from "./noise-js";43import type { DeviceType, PairedDevice } from "./sync-types";4445export interface PairingTransport {46  send(msg: Uint8Array): Promise<void>;47  receive(): Promise<Uint8Array>;48}4950/** Result of a pairing handshake: the peer's identity plus the SAS the user51 *  must confirm matches the peer device before the pair is persisted. */52export interface PairingHandshakeResult {53  device: PairedDevice;54  sas: string;55  /**56   * The peer's signed Pro license blob, if it advertised one. The caller57   * activates it (signature-verified) AFTER the SAS is confirmed, so one Pro58   * device can grant Pro to the other when pairing. Never persisted as-is.59   */60  peerLicense?: string;61}6263export interface LocalIdentity {64  deviceId: string;65  deviceName: string;66  deviceType: DeviceType;67  longTermPublicKeyHex: string;68  signingPublicKeyHex?: string;69  /** Server-only field — Android/iOS/desktop sync hosts announce their port. */70  wsPort?: number;71  /**72   * Stable per-vault id (`computeVaultId`, sync-identity.ts). Sent in the73   * outbound PAIR_IDENTITY so a desktop helper can record which vault it74   * paired to. Optional: the caller omits it when the vault is locked (no75   * master key). Privacy: this is the LAN-linkable id — see the PRIVACY NOTE76   * at sync-identity.ts:62 (PCH-02-006).77   */78  vaultId?: string;79  /**80   * This device's signed Pro license blob (`PPK-…`), included only when this81   * device is Pro. The peer activates it (signature-verified) after the SAS is82   * confirmed, so pairing carries Pro to a free device. Either direction.83   */84  license?: string;85}8687interface PairingFrame {88  type: "PAIR_IDENTITY";89  device_id: string;90  device_name: string;91  device_type: DeviceType;92  long_term_public_key_hex: string;93  signing_public_key_hex?: string;94  ws_port?: number;95  // Optional: present only when the sender knows its vault id. peach-sync96  // treats this as optional (pairing.zig:54); chirp mobile peers omit it.97  vault_id?: string;98  // Optional signed Pro license blob — additive; peers that don't understand99  // it ignore it. Activated by the receiver only after SAS confirmation.100  license?: string;101}102103export interface PostNoiseFrame {104  sas: string;105  frame: unknown;106  channel: PairingTransport;107}108109export interface PostNoiseChannel {110  sas: string;111  channel: PairingTransport;112}113114function encodeFrame(frame: PairingFrame): Uint8Array {115  return new TextEncoder().encode(JSON.stringify(frame));116}117118function decodeParsedFrame(parsed: unknown): PairingFrame {119  const value = parsed as Record<string, unknown> | null;120  if (value?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY frame");121  if (typeof value.device_id !== "string" || !value.device_id) throw new Error("device_id missing");122  if (typeof value.device_name !== "string") throw new Error("device_name missing");123  if (typeof value.device_type !== "string") throw new Error("device_type missing");124  if (typeof value.long_term_public_key_hex !== "string") throw new Error("long_term_public_key_hex missing");125  // Long-term pubkey is a hex-encoded 32-byte X25519 key.126  if (!/^[0-9a-f]{64}$/.test(value.long_term_public_key_hex)) {127    throw new Error("long_term_public_key_hex is not 32-byte hex");128  }129  if (130    value.signing_public_key_hex !== undefined &&131    (typeof value.signing_public_key_hex !== "string" || !/^[0-9a-f]{64}$/.test(value.signing_public_key_hex))132  ) {133    throw new Error("signing_public_key_hex is not 32-byte hex");134  }135  return value as unknown as PairingFrame;136}137138/**139 * Run the post-chirp pairing handshake from the INITIATOR side (Android).140 *141 * `remoteEphemeralPublicKey` is the responder's ephemeral pubkey, which we142 * learned from the inbound chirp. The Noise NK transport message 1 will143 * encrypt to that key; the responder must hold the matching private key.144 */145export async function runPairingInitiator(146  remoteEphemeralPublicKey: Uint8Array,147  ourIdentity: LocalIdentity,148  transport: PairingTransport,149): Promise<PairingHandshakeResult> {150  const postNoise = await runPairingInitiatorOpenChannel(remoteEphemeralPublicKey, transport);151152  // Send our identity, then receive theirs.153  await postNoise.channel.send(encodeFrame({154    type: "PAIR_IDENTITY",155    device_id: ourIdentity.deviceId,156    device_name: ourIdentity.deviceName,157    device_type: ourIdentity.deviceType,158    long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,159    signing_public_key_hex: ourIdentity.signingPublicKeyHex,160    ws_port: ourIdentity.wsPort,161    vault_id: ourIdentity.vaultId,162    license: ourIdentity.license,163  }));164165  const theirFrame = decodeParsedFrame(JSON.parse(new TextDecoder().decode(await postNoise.channel.receive())));166  // The SAS is derived from the shared Noise handshake hash; the caller MUST167  // have the user confirm it matches the peer's before persisting the device.168  return {169    device: frameToPairedDevice(theirFrame),170    sas: postNoise.sas,171    peerLicense: peerLicenseOf(theirFrame),172  };173}174175export async function runPairingInitiatorOpenChannel(176  remoteEphemeralPublicKey: Uint8Array,177  transport: PairingTransport,178): Promise<PostNoiseChannel> {179  const session = await noiseNKInitiator(180    remoteEphemeralPublicKey,181    transport.send,182    transport.receive,183  );184185  const channel: PairingTransport = {186    async send(msg) {187      await transport.send(session.encrypt(msg));188    },189    async receive() {190      return session.decrypt(await transport.receive());191    },192  };193  return {194    sas: derivePairingSAS(session.handshakeHash),195    channel,196  };197}198199// ───────────────────────────────────────────────────────────────────────────200// SECURITY — TRUST MODEL (PCH-02-003): the pairing handshake does NOT cryptographically201// authenticate the initiator. Noise NK authenticates only the RESPONDER (it202// proves the initiator encrypted to the responder's "static" key — here the203// chirp ephemeral the responder BROADCAST in the clear over audio). On its own204// it gives the extension (always the responder) zero assurance about *who*205// completed the handshake, and there is no proof-of-possession over the206// long_term_public_key_hex a peer advertises in its PAIR_IDENTITY frame.207//208// COMPENSATING CONTROL (IMPLEMENTED, MANDATORY on the extension side): a209// user-verified Short Authentication String. Both peers derive the SAME 6-digit210// SAS via `derivePairingSAS(session.handshakeHash)` (below) — identical on both211// ends because the Noise handshake hash is shared — and DISPLAY it. The212// extension pairing flow gates persistence on the user confirming the codes213// match: `gateOnSAS` in `src/popup/chirp-pair.ts` runs BEFORE any device is214// written to the registry and ABORTS pairing on mismatch/rejection. This is the215// Bluetooth-numeric-comparison / Signal-safety-number model and detects a216// MITM/relay despite the lack of full mutual auth. Residual risk is the standard217// 6-digit SAS bound: a MITM survives with probability 1e-6 per attempt, and the218// guarantee depends on the user actually comparing the codes.219//220// OPEN ITEM (NOT verifiable from this repository): the Android/native client221// MUST surface and enforce the identical SAS gate before it persists the peer.222// If the app skips or auto-confirms the SAS, the channel is only as strong as223// the app's weakest end — confirm the app-side gate against the Android source224// at kepr.uk/peach-android before any release that touches pairing. A MITM who225// intercepts the acoustic chirp and completes the handshake against a non-226// enforcing peer can pair without user awareness.227// A future hardening step (coordinated wire change with the app) could add228// proof-of-possession via `performInitiatorAuth`/`performResponderAuth`229// (noise-js) so each side proves possession of the key it advertises, making230// authentication structural rather than dependent on the user's SAS comparison.231//232// This is documented residual risk in the cross-repo trust model, not a bug in233// this codebase: the extension-side gateOnSAS is enforced here and regression-234// tested (PCH-01-024); the peer-side gate lives in Android and must be re-235// verified there on every pairing-touching Android release.236// ───────────────────────────────────────────────────────────────────────────237238/**239 * Derive a 6-digit Short Authentication String from a Noise handshake hash.240 * Both pairing peers compute the SAME value because the Noise `handshakeHash`241 * is shared after the handshake. Showing it on both devices and having the242 * user confirm they match authenticates the channel out-of-band, detecting a243 * man-in-the-middle the NK handshake alone cannot (see SECURITY note above).244 */245export function derivePairingSAS(handshakeHash: Uint8Array): string {246  // Fold the first 4 bytes into an unsigned int, mod 1e6, zero-padded. Uniform247  // enough for a 6-digit comparison code; the handshake hash is already a PRF248  // output so no further mixing is needed.249  if (handshakeHash.length < 4) return "000000";250  const n =251    ((handshakeHash[0] << 24) >>> 0) +252    (handshakeHash[1] << 16) +253    (handshakeHash[2] << 8) +254    handshakeHash[3];255  return String((n >>> 0) % 1_000_000).padStart(6, "0");256}257258/**259 * Run the post-chirp pairing handshake from the RESPONDER side (extension).260 *261 * `ourEphemeralPrivateKey` / `ourEphemeralPublicKey` come from the chirp262 * the responder broadcast; the initiator's chirp told them which key to263 * authenticate against.264 */265export async function runPairingResponder(266  ourEphemeralPrivateKey: Uint8Array,267  ourEphemeralPublicKey: Uint8Array,268  ourIdentity: LocalIdentity,269  transport: PairingTransport,270): Promise<PairingHandshakeResult> {271  const postNoise = await runPairingResponderOpenFrame(272    ourEphemeralPrivateKey,273    ourEphemeralPublicKey,274    transport,275  );276  return handlePairIdentityFrame(postNoise.frame, postNoise.channel, ourIdentity, postNoise.sas);277}278279export async function runPairingResponderOpenFrame(280  ourEphemeralPrivateKey: Uint8Array,281  ourEphemeralPublicKey: Uint8Array,282  transport: PairingTransport,283): Promise<PostNoiseFrame> {284  const session = await noiseNKResponder(285    ourEphemeralPrivateKey,286    ourEphemeralPublicKey,287    transport.send,288    transport.receive,289  );290291  const channel: PairingTransport = {292    async send(msg) {293      await transport.send(session.encrypt(msg));294    },295    async receive() {296      return session.decrypt(await transport.receive());297    },298  };299  const theirFrame = JSON.parse(new TextDecoder().decode(await channel.receive()));300  return {301    sas: derivePairingSAS(session.handshakeHash),302    frame: theirFrame,303    channel,304  };305}306307export async function handlePairIdentityFrame(308  frame: unknown,309  channel: PairingTransport,310  ourIdentity: LocalIdentity,311  sas: string,312): Promise<PairingHandshakeResult> {313  const theirFrame = decodeParsedFrame(frame);314  const ourFrame = encodeFrame({315    type: "PAIR_IDENTITY",316    device_id: ourIdentity.deviceId,317    device_name: ourIdentity.deviceName,318    device_type: ourIdentity.deviceType,319    long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,320    signing_public_key_hex: ourIdentity.signingPublicKeyHex,321    ws_port: ourIdentity.wsPort,322    vault_id: ourIdentity.vaultId,323    license: ourIdentity.license,324  });325  await channel.send(ourFrame);326327  // SAS derived from the shared handshake hash — caller must have the user328  // confirm it matches the peer's code before persisting (defeats the329  // initiator-impersonation / MITM attack the NK handshake alone allows).330  return {331    device: frameToPairedDevice(theirFrame),332    sas,333    peerLicense: peerLicenseOf(theirFrame),334  };335}336337/** Pull a plausible license string off a peer frame (signature is verified338 *  later, on activation). Anything non-string is ignored. */339function peerLicenseOf(frame: PairingFrame): string | undefined {340  return typeof frame.license === "string" && frame.license.length > 0 ? frame.license : undefined;341}342343function frameToPairedDevice(frame: PairingFrame): PairedDevice {344  return {345    deviceId: frame.device_id,346    deviceName: frame.device_name,347    deviceType: frame.device_type,348    longTermPublicKeyHex: frame.long_term_public_key_hex,349    signingPublicKeyHex: frame.signing_public_key_hex,350    trustSource: "direct",351    wsPort: typeof frame.ws_port === "number" ? frame.ws_port : undefined,352    pairedAt: Date.now(),353  };354}355356/**357 * Test-helper: an in-memory pair of transports that connect to each other.358 * Use one half for the initiator and the other for the responder.359 */360export function memoryTransportPair(): [PairingTransport, PairingTransport] {361  const a: Uint8Array[] = [];362  const b: Uint8Array[] = [];363  const aReaders: Array<(b: Uint8Array) => void> = [];364  const bReaders: Array<(b: Uint8Array) => void> = [];365366  function makeTransport(367    inbox: Uint8Array[],368    outbox: Uint8Array[],369    inboxReaders: Array<(b: Uint8Array) => void>,370    outboxReaders: Array<(b: Uint8Array) => void>,371  ): PairingTransport {372    return {373      async send(msg: Uint8Array) {374        const r = outboxReaders.shift();375        if (r) r(msg);376        else outbox.push(msg);377      },378      receive() {379        const msg = inbox.shift();380        if (msg) return Promise.resolve(msg);381        return new Promise<Uint8Array>((resolve) => inboxReaders.push(resolve));382      },383    };384  }385  return [makeTransport(a, b, aReaders, bReaders), makeTransport(b, a, bReaders, aReaders)];386}