chirp-handshake.ts

14.8 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  /**42   * The peer's signed Pro license blob, if it advertised one. The caller43   * activates it (signature-verified) AFTER the SAS is confirmed, so one Pro44   * device can grant Pro to the other when pairing. Never persisted as-is.45   */46  peerLicense?: string;47}4849export interface LocalIdentity {50  deviceId: string;51  deviceName: string;52  deviceType: DeviceType;53  longTermPublicKeyHex: string;54  signingPublicKeyHex?: string;55  /** Server-only field — Android/iOS/desktop sync hosts announce their port. */56  wsPort?: number;57  /**58   * Stable per-vault id (`computeVaultId`, sync-identity.ts). Sent in the59   * outbound PAIR_IDENTITY so a desktop helper can record which vault it60   * paired to. Optional: the caller omits it when the vault is locked (no61   * master key). Privacy: this is the LAN-linkable id — see the PRIVACY NOTE62   * at sync-identity.ts:62 (PCH-02-006).63   */64  vaultId?: string;65  /**66   * This device's signed Pro license blob (`PPK-…`), included only when this67   * device is Pro. The peer activates it (signature-verified) after the SAS is68   * confirmed, so pairing carries Pro to a free device. Either direction.69   */70  license?: string;71}7273interface PairingFrame {74  type: "PAIR_IDENTITY";75  device_id: string;76  device_name: string;77  device_type: DeviceType;78  long_term_public_key_hex: string;79  signing_public_key_hex?: string;80  ws_port?: number;81  // Optional: present only when the sender knows its vault id. peach-sync82  // treats this as optional (pairing.zig:54); chirp mobile peers omit it.83  vault_id?: string;84  // Optional signed Pro license blob — additive; peers that don't understand85  // it ignore it. Activated by the receiver only after SAS confirmation.86  license?: string;87}8889export interface PostNoiseFrame {90  sas: string;91  frame: unknown;92  channel: PairingTransport;93}9495export interface PostNoiseChannel {96  sas: string;97  channel: PairingTransport;98}99100function encodeFrame(frame: PairingFrame): Uint8Array {101  return new TextEncoder().encode(JSON.stringify(frame));102}103104function decodeParsedFrame(parsed: unknown): PairingFrame {105  const value = parsed as Record<string, unknown> | null;106  if (value?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY frame");107  if (typeof value.device_id !== "string" || !value.device_id) throw new Error("device_id missing");108  if (typeof value.device_name !== "string") throw new Error("device_name missing");109  if (typeof value.device_type !== "string") throw new Error("device_type missing");110  if (typeof value.long_term_public_key_hex !== "string") throw new Error("long_term_public_key_hex missing");111  // Long-term pubkey is a hex-encoded 32-byte X25519 key.112  if (!/^[0-9a-f]{64}$/.test(value.long_term_public_key_hex)) {113    throw new Error("long_term_public_key_hex is not 32-byte hex");114  }115  if (116    value.signing_public_key_hex !== undefined &&117    (typeof value.signing_public_key_hex !== "string" || !/^[0-9a-f]{64}$/.test(value.signing_public_key_hex))118  ) {119    throw new Error("signing_public_key_hex is not 32-byte hex");120  }121  return value as unknown as PairingFrame;122}123124/**125 * Run the post-chirp pairing handshake from the INITIATOR side (Android).126 *127 * `remoteEphemeralPublicKey` is the responder's ephemeral pubkey, which we128 * learned from the inbound chirp. The Noise NK transport message 1 will129 * encrypt to that key; the responder must hold the matching private key.130 */131export async function runPairingInitiator(132  remoteEphemeralPublicKey: Uint8Array,133  ourIdentity: LocalIdentity,134  transport: PairingTransport,135): Promise<PairingHandshakeResult> {136  const postNoise = await runPairingInitiatorOpenChannel(remoteEphemeralPublicKey, transport);137138  // Send our identity, then receive theirs.139  await postNoise.channel.send(encodeFrame({140    type: "PAIR_IDENTITY",141    device_id: ourIdentity.deviceId,142    device_name: ourIdentity.deviceName,143    device_type: ourIdentity.deviceType,144    long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,145    signing_public_key_hex: ourIdentity.signingPublicKeyHex,146    ws_port: ourIdentity.wsPort,147    vault_id: ourIdentity.vaultId,148    license: ourIdentity.license,149  }));150151  const theirFrame = decodeParsedFrame(JSON.parse(new TextDecoder().decode(await postNoise.channel.receive())));152  // The SAS is derived from the shared Noise handshake hash; the caller MUST153  // have the user confirm it matches the peer's before persisting the device.154  return {155    device: frameToPairedDevice(theirFrame),156    sas: postNoise.sas,157    peerLicense: peerLicenseOf(theirFrame),158  };159}160161export async function runPairingInitiatorOpenChannel(162  remoteEphemeralPublicKey: Uint8Array,163  transport: PairingTransport,164): Promise<PostNoiseChannel> {165  const session = await noiseNKInitiator(166    remoteEphemeralPublicKey,167    transport.send,168    transport.receive,169  );170171  const channel: PairingTransport = {172    async send(msg) {173      await transport.send(session.encrypt(msg));174    },175    async receive() {176      return session.decrypt(await transport.receive());177    },178  };179  return {180    sas: derivePairingSAS(session.handshakeHash),181    channel,182  };183}184185// ───────────────────────────────────────────────────────────────────────────186// SECURITY — TRUST MODEL (PCH-02-003): the pairing handshake does NOT cryptographically187// authenticate the initiator. Noise NK authenticates only the RESPONDER (it188// proves the initiator encrypted to the responder's "static" key — here the189// chirp ephemeral the responder BROADCAST in the clear over audio). On its own190// it gives the extension (always the responder) zero assurance about *who*191// completed the handshake, and there is no proof-of-possession over the192// long_term_public_key_hex a peer advertises in its PAIR_IDENTITY frame.193//194// COMPENSATING CONTROL (IMPLEMENTED, MANDATORY on the extension side): a195// user-verified Short Authentication String. Both peers derive the SAME 6-digit196// SAS via `derivePairingSAS(session.handshakeHash)` (below) — identical on both197// ends because the Noise handshake hash is shared — and DISPLAY it. The198// extension pairing flow gates persistence on the user confirming the codes199// match: `gateOnSAS` in `src/popup/chirp-pair.ts` runs BEFORE any device is200// written to the registry and ABORTS pairing on mismatch/rejection. This is the201// Bluetooth-numeric-comparison / Signal-safety-number model and detects a202// MITM/relay despite the lack of full mutual auth. Residual risk is the standard203// 6-digit SAS bound: a MITM survives with probability 1e-6 per attempt, and the204// guarantee depends on the user actually comparing the codes.205//206// OPEN ITEM (NOT verifiable from this repository): the Android/native client207// MUST surface and enforce the identical SAS gate before it persists the peer.208// If the app skips or auto-confirms the SAS, the channel is only as strong as209// the app's weakest end — confirm the app-side gate against the Android source210// at kepr.uk/peach-android before any release that touches pairing. A MITM who211// intercepts the acoustic chirp and completes the handshake against a non-212// enforcing peer can pair without user awareness.213// A future hardening step (coordinated wire change with the app) could add214// proof-of-possession via `performInitiatorAuth`/`performResponderAuth`215// (noise-js) so each side proves possession of the key it advertises, making216// authentication structural rather than dependent on the user's SAS comparison.217//218// This is documented residual risk in the cross-repo trust model, not a bug in219// this codebase: the extension-side gateOnSAS is enforced here and regression-220// tested (PCH-01-024); the peer-side gate lives in Android and must be re-221// verified there on every pairing-touching Android release.222// ───────────────────────────────────────────────────────────────────────────223224/**225 * Derive a 6-digit Short Authentication String from a Noise handshake hash.226 * Both pairing peers compute the SAME value because the Noise `handshakeHash`227 * is shared after the handshake. Showing it on both devices and having the228 * user confirm they match authenticates the channel out-of-band, detecting a229 * man-in-the-middle the NK handshake alone cannot (see SECURITY note above).230 */231export function derivePairingSAS(handshakeHash: Uint8Array): string {232  // Fold the first 4 bytes into an unsigned int, mod 1e6, zero-padded. Uniform233  // enough for a 6-digit comparison code; the handshake hash is already a PRF234  // output so no further mixing is needed.235  if (handshakeHash.length < 4) return "000000";236  const n =237    ((handshakeHash[0] << 24) >>> 0) +238    (handshakeHash[1] << 16) +239    (handshakeHash[2] << 8) +240    handshakeHash[3];241  return String((n >>> 0) % 1_000_000).padStart(6, "0");242}243244/**245 * Run the post-chirp pairing handshake from the RESPONDER side (extension).246 *247 * `ourEphemeralPrivateKey` / `ourEphemeralPublicKey` come from the chirp248 * the responder broadcast; the initiator's chirp told them which key to249 * authenticate against.250 */251export async function runPairingResponder(252  ourEphemeralPrivateKey: Uint8Array,253  ourEphemeralPublicKey: Uint8Array,254  ourIdentity: LocalIdentity,255  transport: PairingTransport,256): Promise<PairingHandshakeResult> {257  const postNoise = await runPairingResponderOpenFrame(258    ourEphemeralPrivateKey,259    ourEphemeralPublicKey,260    transport,261  );262  return handlePairIdentityFrame(postNoise.frame, postNoise.channel, ourIdentity, postNoise.sas);263}264265export async function runPairingResponderOpenFrame(266  ourEphemeralPrivateKey: Uint8Array,267  ourEphemeralPublicKey: Uint8Array,268  transport: PairingTransport,269): Promise<PostNoiseFrame> {270  const session = await noiseNKResponder(271    ourEphemeralPrivateKey,272    ourEphemeralPublicKey,273    transport.send,274    transport.receive,275  );276277  const channel: PairingTransport = {278    async send(msg) {279      await transport.send(session.encrypt(msg));280    },281    async receive() {282      return session.decrypt(await transport.receive());283    },284  };285  const theirFrame = JSON.parse(new TextDecoder().decode(await channel.receive()));286  return {287    sas: derivePairingSAS(session.handshakeHash),288    frame: theirFrame,289    channel,290  };291}292293export async function handlePairIdentityFrame(294  frame: unknown,295  channel: PairingTransport,296  ourIdentity: LocalIdentity,297  sas: string,298): Promise<PairingHandshakeResult> {299  const theirFrame = decodeParsedFrame(frame);300  const ourFrame = encodeFrame({301    type: "PAIR_IDENTITY",302    device_id: ourIdentity.deviceId,303    device_name: ourIdentity.deviceName,304    device_type: ourIdentity.deviceType,305    long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,306    signing_public_key_hex: ourIdentity.signingPublicKeyHex,307    ws_port: ourIdentity.wsPort,308    vault_id: ourIdentity.vaultId,309    license: ourIdentity.license,310  });311  await channel.send(ourFrame);312313  // SAS derived from the shared handshake hash — caller must have the user314  // confirm it matches the peer's code before persisting (defeats the315  // initiator-impersonation / MITM attack the NK handshake alone allows).316  return {317    device: frameToPairedDevice(theirFrame),318    sas,319    peerLicense: peerLicenseOf(theirFrame),320  };321}322323/** Pull a plausible license string off a peer frame (signature is verified324 *  later, on activation). Anything non-string is ignored. */325function peerLicenseOf(frame: PairingFrame): string | undefined {326  return typeof frame.license === "string" && frame.license.length > 0 ? frame.license : undefined;327}328329function frameToPairedDevice(frame: PairingFrame): PairedDevice {330  return {331    deviceId: frame.device_id,332    deviceName: frame.device_name,333    deviceType: frame.device_type,334    longTermPublicKeyHex: frame.long_term_public_key_hex,335    signingPublicKeyHex: frame.signing_public_key_hex,336    trustSource: "direct",337    wsPort: typeof frame.ws_port === "number" ? frame.ws_port : undefined,338    pairedAt: Date.now(),339  };340}341342/**343 * Test-helper: an in-memory pair of transports that connect to each other.344 * Use one half for the initiator and the other for the responder.345 */346export function memoryTransportPair(): [PairingTransport, PairingTransport] {347  const a: Uint8Array[] = [];348  const b: Uint8Array[] = [];349  const aReaders: Array<(b: Uint8Array) => void> = [];350  const bReaders: Array<(b: Uint8Array) => void> = [];351352  function makeTransport(353    inbox: Uint8Array[],354    outbox: Uint8Array[],355    inboxReaders: Array<(b: Uint8Array) => void>,356    outboxReaders: Array<(b: Uint8Array) => void>,357  ): PairingTransport {358    return {359      async send(msg: Uint8Array) {360        const r = outboxReaders.shift();361        if (r) r(msg);362        else outbox.push(msg);363      },364      receive() {365        const msg = inbox.shift();366        if (msg) return Promise.resolve(msg);367        return new Promise<Uint8Array>((resolve) => inboxReaders.push(resolve));368      },369    };370  }371  return [makeTransport(a, b, aReaders, bReaders), makeTransport(b, a, bReaders, aReaders)];372}