chirp-handshake.ts

12.2 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}4243export interface LocalIdentity {44  deviceId: string;45  deviceName: string;46  deviceType: DeviceType;47  longTermPublicKeyHex: string;48  signingPublicKeyHex?: string;49  /** Server-only field — Android/iOS/desktop sync hosts announce their port. */50  wsPort?: number;51  /**52   * Stable per-vault id (`computeVaultId`, sync-identity.ts). Sent in the53   * outbound PAIR_IDENTITY so a desktop helper can record which vault it54   * paired to. Optional: the caller omits it when the vault is locked (no55   * master key). Privacy: this is the LAN-linkable id — see the PRIVACY NOTE56   * at sync-identity.ts:62 (PCH-02-006).57   */58  vaultId?: string;59}6061interface PairingFrame {62  type: "PAIR_IDENTITY";63  device_id: string;64  device_name: string;65  device_type: DeviceType;66  long_term_public_key_hex: string;67  signing_public_key_hex?: string;68  ws_port?: number;69  // Optional: present only when the sender knows its vault id. peach-sync70  // treats this as optional (pairing.zig:54); chirp mobile peers omit it.71  vault_id?: string;72}7374function encodeFrame(frame: PairingFrame): Uint8Array {75  return new TextEncoder().encode(JSON.stringify(frame));76}7778function decodeFrame(bytes: Uint8Array): PairingFrame {79  const parsed = JSON.parse(new TextDecoder().decode(bytes));80  if (parsed?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY frame");81  if (typeof parsed.device_id !== "string" || !parsed.device_id) throw new Error("device_id missing");82  if (typeof parsed.device_name !== "string") throw new Error("device_name missing");83  if (typeof parsed.device_type !== "string") throw new Error("device_type missing");84  if (typeof parsed.long_term_public_key_hex !== "string") throw new Error("long_term_public_key_hex missing");85  // Long-term pubkey is a hex-encoded 32-byte X25519 key.86  if (!/^[0-9a-f]{64}$/.test(parsed.long_term_public_key_hex)) {87    throw new Error("long_term_public_key_hex is not 32-byte hex");88  }89  if (90    parsed.signing_public_key_hex !== undefined &&91    !/^[0-9a-f]{64}$/.test(parsed.signing_public_key_hex)92  ) {93    throw new Error("signing_public_key_hex is not 32-byte hex");94  }95  return parsed as PairingFrame;96}9798/**99 * Run the post-chirp pairing handshake from the INITIATOR side (Android).100 *101 * `remoteEphemeralPublicKey` is the responder's ephemeral pubkey, which we102 * learned from the inbound chirp. The Noise NK transport message 1 will103 * encrypt to that key; the responder must hold the matching private key.104 */105export async function runPairingInitiator(106  remoteEphemeralPublicKey: Uint8Array,107  ourIdentity: LocalIdentity,108  transport: PairingTransport,109): Promise<PairingHandshakeResult> {110  const session = await noiseNKInitiator(111    remoteEphemeralPublicKey,112    transport.send,113    transport.receive,114  );115116  // Send our identity, then receive theirs.117  const ourFrame = encodeFrame({118    type: "PAIR_IDENTITY",119    device_id: ourIdentity.deviceId,120    device_name: ourIdentity.deviceName,121    device_type: ourIdentity.deviceType,122    long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,123    signing_public_key_hex: ourIdentity.signingPublicKeyHex,124    ws_port: ourIdentity.wsPort,125    vault_id: ourIdentity.vaultId,126  });127  await transport.send(session.encrypt(ourFrame));128129  const theirCipher = await transport.receive();130  const theirPlain = session.decrypt(theirCipher);131  const theirFrame = decodeFrame(theirPlain);132  // The SAS is derived from the shared Noise handshake hash; the caller MUST133  // have the user confirm it matches the peer's before persisting the device.134  return { device: frameToPairedDevice(theirFrame), sas: derivePairingSAS(session.handshakeHash) };135}136137// ───────────────────────────────────────────────────────────────────────────138// SECURITY — TRUST MODEL (PCH-02-003): the pairing handshake does NOT cryptographically139// authenticate the initiator. Noise NK authenticates only the RESPONDER (it140// proves the initiator encrypted to the responder's "static" key — here the141// chirp ephemeral the responder BROADCAST in the clear over audio). On its own142// it gives the extension (always the responder) zero assurance about *who*143// completed the handshake, and there is no proof-of-possession over the144// long_term_public_key_hex a peer advertises in its PAIR_IDENTITY frame.145//146// COMPENSATING CONTROL (IMPLEMENTED, MANDATORY on the extension side): a147// user-verified Short Authentication String. Both peers derive the SAME 6-digit148// SAS via `derivePairingSAS(session.handshakeHash)` (below) — identical on both149// ends because the Noise handshake hash is shared — and DISPLAY it. The150// extension pairing flow gates persistence on the user confirming the codes151// match: `gateOnSAS` in `src/popup/chirp-pair.ts` runs BEFORE any device is152// written to the registry and ABORTS pairing on mismatch/rejection. This is the153// Bluetooth-numeric-comparison / Signal-safety-number model and detects a154// MITM/relay despite the lack of full mutual auth. Residual risk is the standard155// 6-digit SAS bound: a MITM survives with probability 1e-6 per attempt, and the156// guarantee depends on the user actually comparing the codes.157//158// OPEN ITEM (NOT verifiable from this repository): the Android/native client159// MUST surface and enforce the identical SAS gate before it persists the peer.160// If the app skips or auto-confirms the SAS, the channel is only as strong as161// the app's weakest end — confirm the app-side gate against the Android source162// at kepr.uk/peach-android before any release that touches pairing. A MITM who163// intercepts the acoustic chirp and completes the handshake against a non-164// enforcing peer can pair without user awareness.165// A future hardening step (coordinated wire change with the app) could add166// proof-of-possession via `performInitiatorAuth`/`performResponderAuth`167// (noise-js) so each side proves possession of the key it advertises, making168// authentication structural rather than dependent on the user's SAS comparison.169//170// This is documented residual risk in the cross-repo trust model, not a bug in171// this codebase: the extension-side gateOnSAS is enforced here and regression-172// tested (PCH-01-024); the peer-side gate lives in Android and must be re-173// verified there on every pairing-touching Android release.174// ───────────────────────────────────────────────────────────────────────────175176/**177 * Derive a 6-digit Short Authentication String from a Noise handshake hash.178 * Both pairing peers compute the SAME value because the Noise `handshakeHash`179 * is shared after the handshake. Showing it on both devices and having the180 * user confirm they match authenticates the channel out-of-band, detecting a181 * man-in-the-middle the NK handshake alone cannot (see SECURITY note above).182 */183export function derivePairingSAS(handshakeHash: Uint8Array): string {184  // Fold the first 4 bytes into an unsigned int, mod 1e6, zero-padded. Uniform185  // enough for a 6-digit comparison code; the handshake hash is already a PRF186  // output so no further mixing is needed.187  if (handshakeHash.length < 4) return "000000";188  const n =189    ((handshakeHash[0] << 24) >>> 0) +190    (handshakeHash[1] << 16) +191    (handshakeHash[2] << 8) +192    handshakeHash[3];193  return String((n >>> 0) % 1_000_000).padStart(6, "0");194}195196/**197 * Run the post-chirp pairing handshake from the RESPONDER side (extension).198 *199 * `ourEphemeralPrivateKey` / `ourEphemeralPublicKey` come from the chirp200 * the responder broadcast; the initiator's chirp told them which key to201 * authenticate against.202 */203export async function runPairingResponder(204  ourEphemeralPrivateKey: Uint8Array,205  ourEphemeralPublicKey: Uint8Array,206  ourIdentity: LocalIdentity,207  transport: PairingTransport,208): Promise<PairingHandshakeResult> {209  const session = await noiseNKResponder(210    ourEphemeralPrivateKey,211    ourEphemeralPublicKey,212    transport.send,213    transport.receive,214  );215216  // Receive theirs first, then send ours — the initiator sends first.217  const theirCipher = await transport.receive();218  const theirPlain = session.decrypt(theirCipher);219  const theirFrame = decodeFrame(theirPlain);220221  const ourFrame = encodeFrame({222    type: "PAIR_IDENTITY",223    device_id: ourIdentity.deviceId,224    device_name: ourIdentity.deviceName,225    device_type: ourIdentity.deviceType,226    long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,227    signing_public_key_hex: ourIdentity.signingPublicKeyHex,228    ws_port: ourIdentity.wsPort,229    vault_id: ourIdentity.vaultId,230  });231  await transport.send(session.encrypt(ourFrame));232233  // SAS derived from the shared handshake hash — caller must have the user234  // confirm it matches the peer's code before persisting (defeats the235  // initiator-impersonation / MITM attack the NK handshake alone allows).236  return { device: frameToPairedDevice(theirFrame), sas: derivePairingSAS(session.handshakeHash) };237}238239function frameToPairedDevice(frame: PairingFrame): PairedDevice {240  return {241    deviceId: frame.device_id,242    deviceName: frame.device_name,243    deviceType: frame.device_type,244    longTermPublicKeyHex: frame.long_term_public_key_hex,245    signingPublicKeyHex: frame.signing_public_key_hex,246    trustSource: "direct",247    wsPort: typeof frame.ws_port === "number" ? frame.ws_port : undefined,248    pairedAt: Date.now(),249  };250}251252/**253 * Test-helper: an in-memory pair of transports that connect to each other.254 * Use one half for the initiator and the other for the responder.255 */256export function memoryTransportPair(): [PairingTransport, PairingTransport] {257  const a: Uint8Array[] = [];258  const b: Uint8Array[] = [];259  const aReaders: Array<(b: Uint8Array) => void> = [];260  const bReaders: Array<(b: Uint8Array) => void> = [];261262  function makeTransport(263    inbox: Uint8Array[],264    outbox: Uint8Array[],265    inboxReaders: Array<(b: Uint8Array) => void>,266    outboxReaders: Array<(b: Uint8Array) => void>,267  ): PairingTransport {268    return {269      async send(msg: Uint8Array) {270        const r = outboxReaders.shift();271        if (r) r(msg);272        else outbox.push(msg);273      },274      receive() {275        const msg = inbox.shift();276        if (msg) return Promise.resolve(msg);277        return new Promise<Uint8Array>((resolve) => inboxReaders.push(resolve));278      },279    };280  }281  return [makeTransport(a, b, aReaders, bReaders), makeTransport(b, a, bReaders, aReaders)];282}