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 /**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}9495function encodeFrame(frame: PairingFrame): Uint8Array {96 return new TextEncoder().encode(JSON.stringify(frame));97}9899function decodeFrame(bytes: Uint8Array): PairingFrame {100 const parsed = JSON.parse(new TextDecoder().decode(bytes));101 return decodeParsedFrame(parsed);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 session = await noiseNKInitiator(137 remoteEphemeralPublicKey,138 transport.send,139 transport.receive,140 );141142 // Send our identity, then receive theirs.143 const ourFrame = encodeFrame({144 type: "PAIR_IDENTITY",145 device_id: ourIdentity.deviceId,146 device_name: ourIdentity.deviceName,147 device_type: ourIdentity.deviceType,148 long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,149 signing_public_key_hex: ourIdentity.signingPublicKeyHex,150 ws_port: ourIdentity.wsPort,151 vault_id: ourIdentity.vaultId,152 license: ourIdentity.license,153 });154 await transport.send(session.encrypt(ourFrame));155156 const theirCipher = await transport.receive();157 const theirPlain = session.decrypt(theirCipher);158 const theirFrame = decodeFrame(theirPlain);159 // The SAS is derived from the shared Noise handshake hash; the caller MUST160 // have the user confirm it matches the peer's before persisting the device.161 return {162 device: frameToPairedDevice(theirFrame),163 sas: derivePairingSAS(session.handshakeHash),164 peerLicense: peerLicenseOf(theirFrame),165 };166}167168// ───────────────────────────────────────────────────────────────────────────169// SECURITY — TRUST MODEL (PCH-02-003): the pairing handshake does NOT cryptographically170// authenticate the initiator. Noise NK authenticates only the RESPONDER (it171// proves the initiator encrypted to the responder's "static" key — here the172// chirp ephemeral the responder BROADCAST in the clear over audio). On its own173// it gives the extension (always the responder) zero assurance about *who*174// completed the handshake, and there is no proof-of-possession over the175// long_term_public_key_hex a peer advertises in its PAIR_IDENTITY frame.176//177// COMPENSATING CONTROL (IMPLEMENTED, MANDATORY on the extension side): a178// user-verified Short Authentication String. Both peers derive the SAME 6-digit179// SAS via `derivePairingSAS(session.handshakeHash)` (below) — identical on both180// ends because the Noise handshake hash is shared — and DISPLAY it. The181// extension pairing flow gates persistence on the user confirming the codes182// match: `gateOnSAS` in `src/popup/chirp-pair.ts` runs BEFORE any device is183// written to the registry and ABORTS pairing on mismatch/rejection. This is the184// Bluetooth-numeric-comparison / Signal-safety-number model and detects a185// MITM/relay despite the lack of full mutual auth. Residual risk is the standard186// 6-digit SAS bound: a MITM survives with probability 1e-6 per attempt, and the187// guarantee depends on the user actually comparing the codes.188//189// OPEN ITEM (NOT verifiable from this repository): the Android/native client190// MUST surface and enforce the identical SAS gate before it persists the peer.191// If the app skips or auto-confirms the SAS, the channel is only as strong as192// the app's weakest end — confirm the app-side gate against the Android source193// at kepr.uk/peach-android before any release that touches pairing. A MITM who194// intercepts the acoustic chirp and completes the handshake against a non-195// enforcing peer can pair without user awareness.196// A future hardening step (coordinated wire change with the app) could add197// proof-of-possession via `performInitiatorAuth`/`performResponderAuth`198// (noise-js) so each side proves possession of the key it advertises, making199// authentication structural rather than dependent on the user's SAS comparison.200//201// This is documented residual risk in the cross-repo trust model, not a bug in202// this codebase: the extension-side gateOnSAS is enforced here and regression-203// tested (PCH-01-024); the peer-side gate lives in Android and must be re-204// verified there on every pairing-touching Android release.205// ───────────────────────────────────────────────────────────────────────────206207/**208 * Derive a 6-digit Short Authentication String from a Noise handshake hash.209 * Both pairing peers compute the SAME value because the Noise `handshakeHash`210 * is shared after the handshake. Showing it on both devices and having the211 * user confirm they match authenticates the channel out-of-band, detecting a212 * man-in-the-middle the NK handshake alone cannot (see SECURITY note above).213 */214export function derivePairingSAS(handshakeHash: Uint8Array): string {215 // Fold the first 4 bytes into an unsigned int, mod 1e6, zero-padded. Uniform216 // enough for a 6-digit comparison code; the handshake hash is already a PRF217 // output so no further mixing is needed.218 if (handshakeHash.length < 4) return "000000";219 const n =220 ((handshakeHash[0] << 24) >>> 0) +221 (handshakeHash[1] << 16) +222 (handshakeHash[2] << 8) +223 handshakeHash[3];224 return String((n >>> 0) % 1_000_000).padStart(6, "0");225}226227/**228 * Run the post-chirp pairing handshake from the RESPONDER side (extension).229 *230 * `ourEphemeralPrivateKey` / `ourEphemeralPublicKey` come from the chirp231 * the responder broadcast; the initiator's chirp told them which key to232 * authenticate against.233 */234export async function runPairingResponder(235 ourEphemeralPrivateKey: Uint8Array,236 ourEphemeralPublicKey: Uint8Array,237 ourIdentity: LocalIdentity,238 transport: PairingTransport,239): Promise<PairingHandshakeResult> {240 const postNoise = await runPairingResponderOpenFrame(241 ourEphemeralPrivateKey,242 ourEphemeralPublicKey,243 transport,244 );245 return handlePairIdentityFrame(postNoise.frame, postNoise.channel, ourIdentity, postNoise.sas);246}247248export async function runPairingResponderOpenFrame(249 ourEphemeralPrivateKey: Uint8Array,250 ourEphemeralPublicKey: Uint8Array,251 transport: PairingTransport,252): Promise<PostNoiseFrame> {253 const session = await noiseNKResponder(254 ourEphemeralPrivateKey,255 ourEphemeralPublicKey,256 transport.send,257 transport.receive,258 );259260 const channel: PairingTransport = {261 async send(msg) {262 await transport.send(session.encrypt(msg));263 },264 async receive() {265 return session.decrypt(await transport.receive());266 },267 };268 const theirFrame = JSON.parse(new TextDecoder().decode(await channel.receive()));269 return {270 sas: derivePairingSAS(session.handshakeHash),271 frame: theirFrame,272 channel,273 };274}275276export async function handlePairIdentityFrame(277 frame: unknown,278 channel: PairingTransport,279 ourIdentity: LocalIdentity,280 sas: string,281): Promise<PairingHandshakeResult> {282 const theirFrame = decodeParsedFrame(frame);283 const ourFrame = encodeFrame({284 type: "PAIR_IDENTITY",285 device_id: ourIdentity.deviceId,286 device_name: ourIdentity.deviceName,287 device_type: ourIdentity.deviceType,288 long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,289 signing_public_key_hex: ourIdentity.signingPublicKeyHex,290 ws_port: ourIdentity.wsPort,291 vault_id: ourIdentity.vaultId,292 license: ourIdentity.license,293 });294 await channel.send(ourFrame);295296 // SAS derived from the shared handshake hash — caller must have the user297 // confirm it matches the peer's code before persisting (defeats the298 // initiator-impersonation / MITM attack the NK handshake alone allows).299 return {300 device: frameToPairedDevice(theirFrame),301 sas,302 peerLicense: peerLicenseOf(theirFrame),303 };304}305306/** Pull a plausible license string off a peer frame (signature is verified307 * later, on activation). Anything non-string is ignored. */308function peerLicenseOf(frame: PairingFrame): string | undefined {309 return typeof frame.license === "string" && frame.license.length > 0 ? frame.license : undefined;310}311312function frameToPairedDevice(frame: PairingFrame): PairedDevice {313 return {314 deviceId: frame.device_id,315 deviceName: frame.device_name,316 deviceType: frame.device_type,317 longTermPublicKeyHex: frame.long_term_public_key_hex,318 signingPublicKeyHex: frame.signing_public_key_hex,319 trustSource: "direct",320 wsPort: typeof frame.ws_port === "number" ? frame.ws_port : undefined,321 pairedAt: Date.now(),322 };323}324325/**326 * Test-helper: an in-memory pair of transports that connect to each other.327 * Use one half for the initiator and the other for the responder.328 */329export function memoryTransportPair(): [PairingTransport, PairingTransport] {330 const a: Uint8Array[] = [];331 const b: Uint8Array[] = [];332 const aReaders: Array<(b: Uint8Array) => void> = [];333 const bReaders: Array<(b: Uint8Array) => void> = [];334335 function makeTransport(336 inbox: Uint8Array[],337 outbox: Uint8Array[],338 inboxReaders: Array<(b: Uint8Array) => void>,339 outboxReaders: Array<(b: Uint8Array) => void>,340 ): PairingTransport {341 return {342 async send(msg: Uint8Array) {343 const r = outboxReaders.shift();344 if (r) r(msg);345 else outbox.push(msg);346 },347 receive() {348 const msg = inbox.shift();349 if (msg) return Promise.resolve(msg);350 return new Promise<Uint8Array>((resolve) => inboxReaders.push(resolve));351 },352 };353 }354 return [makeTransport(a, b, aReaders, bReaders), makeTransport(b, a, bReaders, aReaders)];355}