chirp-handshake.ts
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}5253interface PairingFrame {54 type: "PAIR_IDENTITY";55 device_id: string;56 device_name: string;57 device_type: DeviceType;58 long_term_public_key_hex: string;59 signing_public_key_hex?: string;60 ws_port?: number;61}6263function encodeFrame(frame: PairingFrame): Uint8Array {64 return new TextEncoder().encode(JSON.stringify(frame));65}6667function decodeFrame(bytes: Uint8Array): PairingFrame {68 const parsed = JSON.parse(new TextDecoder().decode(bytes));69 if (parsed?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY frame");70 if (typeof parsed.device_id !== "string" || !parsed.device_id) throw new Error("device_id missing");71 if (typeof parsed.device_name !== "string") throw new Error("device_name missing");72 if (typeof parsed.device_type !== "string") throw new Error("device_type missing");73 if (typeof parsed.long_term_public_key_hex !== "string") throw new Error("long_term_public_key_hex missing");74 // Long-term pubkey is a hex-encoded 32-byte X25519 key.75 if (!/^[0-9a-f]{64}$/.test(parsed.long_term_public_key_hex)) {76 throw new Error("long_term_public_key_hex is not 32-byte hex");77 }78 if (79 parsed.signing_public_key_hex !== undefined &&80 !/^[0-9a-f]{64}$/.test(parsed.signing_public_key_hex)81 ) {82 throw new Error("signing_public_key_hex is not 32-byte hex");83 }84 return parsed as PairingFrame;85}8687/**88 * Run the post-chirp pairing handshake from the INITIATOR side (Android).89 *90 * `remoteEphemeralPublicKey` is the responder's ephemeral pubkey, which we91 * learned from the inbound chirp. The Noise NK transport message 1 will92 * encrypt to that key; the responder must hold the matching private key.93 */94export async function runPairingInitiator(95 remoteEphemeralPublicKey: Uint8Array,96 ourIdentity: LocalIdentity,97 transport: PairingTransport,98): Promise<PairingHandshakeResult> {99 const session = await noiseNKInitiator(100 remoteEphemeralPublicKey,101 transport.send,102 transport.receive,103 );104105 // Send our identity, then receive theirs.106 const ourFrame = encodeFrame({107 type: "PAIR_IDENTITY",108 device_id: ourIdentity.deviceId,109 device_name: ourIdentity.deviceName,110 device_type: ourIdentity.deviceType,111 long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,112 signing_public_key_hex: ourIdentity.signingPublicKeyHex,113 ws_port: ourIdentity.wsPort,114 });115 await transport.send(session.encrypt(ourFrame));116117 const theirCipher = await transport.receive();118 const theirPlain = session.decrypt(theirCipher);119 const theirFrame = decodeFrame(theirPlain);120 // The SAS is derived from the shared Noise handshake hash; the caller MUST121 // have the user confirm it matches the peer's before persisting the device.122 return { device: frameToPairedDevice(theirFrame), sas: derivePairingSAS(session.handshakeHash) };123}124125// ───────────────────────────────────────────────────────────────────────────126// SECURITY — TRUST MODEL (PCH-02-003): the pairing handshake does NOT cryptographically127// authenticate the initiator. Noise NK authenticates only the RESPONDER (it128// proves the initiator encrypted to the responder's "static" key — here the129// chirp ephemeral the responder BROADCAST in the clear over audio). On its own130// it gives the extension (always the responder) zero assurance about *who*131// completed the handshake, and there is no proof-of-possession over the132// long_term_public_key_hex a peer advertises in its PAIR_IDENTITY frame.133//134// COMPENSATING CONTROL (IMPLEMENTED, MANDATORY on the extension side): a135// user-verified Short Authentication String. Both peers derive the SAME 6-digit136// SAS via `derivePairingSAS(session.handshakeHash)` (below) — identical on both137// ends because the Noise handshake hash is shared — and DISPLAY it. The138// extension pairing flow gates persistence on the user confirming the codes139// match: `gateOnSAS` in `src/popup/chirp-pair.ts` runs BEFORE any device is140// written to the registry and ABORTS pairing on mismatch/rejection. This is the141// Bluetooth-numeric-comparison / Signal-safety-number model and detects a142// MITM/relay despite the lack of full mutual auth. Residual risk is the standard143// 6-digit SAS bound: a MITM survives with probability 1e-6 per attempt, and the144// guarantee depends on the user actually comparing the codes.145//146// OPEN ITEM (NOT verifiable from this repository): the Android/native client147// MUST surface and enforce the identical SAS gate before it persists the peer.148// If the app skips or auto-confirms the SAS, the channel is only as strong as149// the app's weakest end — confirm the app-side gate against the Android source150// at kepr.uk/peach-android before any release that touches pairing. A MITM who151// intercepts the acoustic chirp and completes the handshake against a non-152// enforcing peer can pair without user awareness.153// A future hardening step (coordinated wire change with the app) could add154// proof-of-possession via `performInitiatorAuth`/`performResponderAuth`155// (noise-js) so each side proves possession of the key it advertises, making156// authentication structural rather than dependent on the user's SAS comparison.157//158// This is documented residual risk in the cross-repo trust model, not a bug in159// this codebase: the extension-side gateOnSAS is enforced here and regression-160// tested (PCH-01-024); the peer-side gate lives in Android and must be re-161// verified there on every pairing-touching Android release.162// ───────────────────────────────────────────────────────────────────────────163164/**165 * Derive a 6-digit Short Authentication String from a Noise handshake hash.166 * Both pairing peers compute the SAME value because the Noise `handshakeHash`167 * is shared after the handshake. Showing it on both devices and having the168 * user confirm they match authenticates the channel out-of-band, detecting a169 * man-in-the-middle the NK handshake alone cannot (see SECURITY note above).170 */171export function derivePairingSAS(handshakeHash: Uint8Array): string {172 // Fold the first 4 bytes into an unsigned int, mod 1e6, zero-padded. Uniform173 // enough for a 6-digit comparison code; the handshake hash is already a PRF174 // output so no further mixing is needed.175 if (handshakeHash.length < 4) return "000000";176 const n =177 ((handshakeHash[0] << 24) >>> 0) +178 (handshakeHash[1] << 16) +179 (handshakeHash[2] << 8) +180 handshakeHash[3];181 return String((n >>> 0) % 1_000_000).padStart(6, "0");182}183184/**185 * Run the post-chirp pairing handshake from the RESPONDER side (extension).186 *187 * `ourEphemeralPrivateKey` / `ourEphemeralPublicKey` come from the chirp188 * the responder broadcast; the initiator's chirp told them which key to189 * authenticate against.190 */191export async function runPairingResponder(192 ourEphemeralPrivateKey: Uint8Array,193 ourEphemeralPublicKey: Uint8Array,194 ourIdentity: LocalIdentity,195 transport: PairingTransport,196): Promise<PairingHandshakeResult> {197 const session = await noiseNKResponder(198 ourEphemeralPrivateKey,199 ourEphemeralPublicKey,200 transport.send,201 transport.receive,202 );203204 // Receive theirs first, then send ours — the initiator sends first.205 const theirCipher = await transport.receive();206 const theirPlain = session.decrypt(theirCipher);207 const theirFrame = decodeFrame(theirPlain);208209 const ourFrame = encodeFrame({210 type: "PAIR_IDENTITY",211 device_id: ourIdentity.deviceId,212 device_name: ourIdentity.deviceName,213 device_type: ourIdentity.deviceType,214 long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,215 signing_public_key_hex: ourIdentity.signingPublicKeyHex,216 ws_port: ourIdentity.wsPort,217 });218 await transport.send(session.encrypt(ourFrame));219220 // SAS derived from the shared handshake hash — caller must have the user221 // confirm it matches the peer's code before persisting (defeats the222 // initiator-impersonation / MITM attack the NK handshake alone allows).223 return { device: frameToPairedDevice(theirFrame), sas: derivePairingSAS(session.handshakeHash) };224}225226function frameToPairedDevice(frame: PairingFrame): PairedDevice {227 return {228 deviceId: frame.device_id,229 deviceName: frame.device_name,230 deviceType: frame.device_type,231 longTermPublicKeyHex: frame.long_term_public_key_hex,232 signingPublicKeyHex: frame.signing_public_key_hex,233 trustSource: "direct",234 wsPort: typeof frame.ws_port === "number" ? frame.ws_port : undefined,235 pairedAt: Date.now(),236 };237}238239/**240 * Test-helper: an in-memory pair of transports that connect to each other.241 * Use one half for the initiator and the other for the responder.242 */243export function memoryTransportPair(): [PairingTransport, PairingTransport] {244 const a: Uint8Array[] = [];245 const b: Uint8Array[] = [];246 const aReaders: Array<(b: Uint8Array) => void> = [];247 const bReaders: Array<(b: Uint8Array) => void> = [];248249 function makeTransport(250 inbox: Uint8Array[],251 outbox: Uint8Array[],252 inboxReaders: Array<(b: Uint8Array) => void>,253 outboxReaders: Array<(b: Uint8Array) => void>,254 ): PairingTransport {255 return {256 async send(msg: Uint8Array) {257 const r = outboxReaders.shift();258 if (r) r(msg);259 else outbox.push(msg);260 },261 receive() {262 const msg = inbox.shift();263 if (msg) return Promise.resolve(msg);264 return new Promise<Uint8Array>((resolve) => inboxReaders.push(resolve));265 },266 };267 }268 return [makeTransport(a, b, aReaders, bReaders), makeTransport(b, a, bReaders, aReaders)];269}