// Pairing handshake — Noise NK, the SAS, and the PAIR_IDENTITY frame. // // STATUS: the audio-pairing flow this module was written for is gone. What production still // uses from here is `derivePairingSAS` and the `LocalIdentity` / // `PairingTransport` types; the live dial-and-handshake path is // `shared/pairing-dial.ts`, and the live PAIR_IDENTITY exchange is // `background/code-pairing.ts`. // // The `runPairing*` helpers below are exercised only by this module's own // specs. Do NOT reach for the RESPONDER ones in new code: they assume this // device can accept an inbound connection, which a browser extension cannot // do. The browser is always the dialler, always the NK initiator. // // The shape of the exchange, which the live path still follows: // // 1. Both sides instantiate Noise NK over a caller-supplied transport. The // responder's "static" is an ephemeral it published out of band — today // that is the `ephemeralPublicKeyHex` it returns in PEACH_HELLO_ACK, // which is why the SAS below carries the real authentication weight. // // 2. Over the resulting Noise transport, each side sends ONE JSON frame with // its `device_id`, `device_name`, `device_type`, // `long_term_public_key_hex`, and device-manifest signing key. Both sides // persist the counterpart in the paired-device registry. // // 3. This first-pair NK session is discarded. Future WebSocket reconnects // (`shared/sync-transport.ts`) use standard Noise KK with both long-term // static keys from paired local state, followed by a bound // PAIR_IDENTITY exchange. KK failure never falls back to NK. import { noiseNKInitiator, noiseNKResponder } from "./noise-js"; import type { DeviceType, PairedDevice } from "./sync-types"; import { bytesToHex, deriveRendezvousSecret, normalizeRelayUrl, } from "./rendezvous"; export interface PairingTransport { send(msg: Uint8Array): Promise; receive(): Promise; } /** Result of a pairing handshake: the peer's identity plus the SAS the user * must confirm matches the peer device before the pair is persisted. */ export interface PairingHandshakeResult { device: PairedDevice; sas: string; /** * The peer's signed Pro license blob, if it advertised one. The caller * activates it (signature-verified) AFTER the SAS is confirmed, so one Pro * device can grant Pro to the other when pairing. Never persisted as-is. */ peerLicense?: string; } export interface LocalIdentity { deviceId: string; deviceName: string; deviceType: DeviceType; longTermPublicKeyHex: string; signingPublicKeyHex?: string; /** Server-only field — Android/iOS sync hosts announce their port. */ wsPort?: number; /** * Stable per-vault id (`computeVaultId`, sync-identity.ts). Sent in the * outbound PAIR_IDENTITY so a native peer can record which vault it * paired to. Optional: the caller omits it when the vault is locked (no * master key). Privacy: this is the LAN-linkable id — see the PRIVACY NOTE * at sync-identity.ts:62 (PCH-02-006). */ vaultId?: string; /** * This device's signed Pro license blob (`PPK-…`), included only when this * device is Pro. The peer activates it (signature-verified) after the SAS is * confirmed, so pairing carries Pro to a free device. Either direction. */ license?: string; relayUrl?: string; } interface PairingFrame { type: "PAIR_IDENTITY"; device_id: string; device_name: string; device_type: DeviceType; long_term_public_key_hex: string; signing_public_key_hex?: string; ws_port?: number; // Optional: present only when the sender knows its vault id. peach-sync // treats this as optional (pairing.zig:54); mobile peers may omit it. vault_id?: string; // Optional signed Pro license blob — additive; peers that don't understand // it ignore it. Activated by the receiver only after SAS confirmation. license?: string; relay_url?: string; } export interface PostNoiseFrame { sas: string; rendezvousSecretHex: string; frame: unknown; channel: PairingTransport; } export interface PostNoiseChannel { sas: string; rendezvousSecretHex: string; channel: PairingTransport; } function encodeFrame(frame: PairingFrame): Uint8Array { return new TextEncoder().encode(JSON.stringify(frame)); } function decodeParsedFrame(parsed: unknown): PairingFrame { const value = parsed as Record | null; if (value?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY frame"); if (typeof value.device_id !== "string" || !value.device_id) throw new Error("device_id missing"); if (typeof value.device_name !== "string") throw new Error("device_name missing"); if (typeof value.device_type !== "string") throw new Error("device_type missing"); if (typeof value.long_term_public_key_hex !== "string") throw new Error("long_term_public_key_hex missing"); // Long-term pubkey is a hex-encoded 32-byte X25519 key. if (!/^[0-9a-f]{64}$/.test(value.long_term_public_key_hex)) { throw new Error("long_term_public_key_hex is not 32-byte hex"); } if ( value.signing_public_key_hex !== undefined && (typeof value.signing_public_key_hex !== "string" || !/^[0-9a-f]{64}$/.test(value.signing_public_key_hex)) ) { throw new Error("signing_public_key_hex is not 32-byte hex"); } if (value.relay_url !== undefined) { if (typeof value.relay_url !== "string") throw new Error("relay_url invalid"); value.relay_url = normalizeRelayUrl(value.relay_url); } return value as unknown as PairingFrame; } /** * Run the pairing handshake from the INITIATOR side. * * `remoteEphemeralPublicKey` is the responder's ephemeral pubkey, learned out * of band. The Noise NK transport message 1 encrypts to that key; the * responder must hold the matching private key. */ export async function runPairingInitiator( remoteEphemeralPublicKey: Uint8Array, ourIdentity: LocalIdentity, transport: PairingTransport, ): Promise { const postNoise = await runPairingInitiatorOpenChannel(remoteEphemeralPublicKey, transport); // Send our identity, then receive theirs. await postNoise.channel.send(encodeFrame({ type: "PAIR_IDENTITY", device_id: ourIdentity.deviceId, device_name: ourIdentity.deviceName, device_type: ourIdentity.deviceType, long_term_public_key_hex: ourIdentity.longTermPublicKeyHex, signing_public_key_hex: ourIdentity.signingPublicKeyHex, ws_port: ourIdentity.wsPort, vault_id: ourIdentity.vaultId, license: ourIdentity.license, relay_url: ourIdentity.relayUrl, })); const theirFrame = decodeParsedFrame(JSON.parse(new TextDecoder().decode(await postNoise.channel.receive()))); // The SAS is derived from the shared Noise handshake hash; the caller MUST // have the user confirm it matches the peer's before persisting the device. return { device: { ...frameToPairedDevice(theirFrame), rendezvousSecretHex: postNoise.rendezvousSecretHex, }, sas: postNoise.sas, peerLicense: peerLicenseOf(theirFrame), }; } export async function runPairingInitiatorOpenChannel( remoteEphemeralPublicKey: Uint8Array, transport: PairingTransport, ): Promise { const session = await noiseNKInitiator( remoteEphemeralPublicKey, transport.send, transport.receive, ); const channel: PairingTransport = { async send(msg) { await transport.send(session.encrypt(msg)); }, async receive() { return session.decrypt(await transport.receive()); }, }; return { sas: derivePairingSAS(session.handshakeHash), rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)), channel, }; } // ─────────────────────────────────────────────────────────────────────────── // SECURITY — TRUST MODEL: first pairing uses NK because the responder key is // known from the one-time code but the initiator identity is not paired yet. // Immediately after NK, Android sends a transcript-bound challenge and the // browser proves possession of the private half of the X25519 key it will place // in PAIR_IDENTITY. Android constant-time binds that proven key to the frame; // browser likewise requires Android's identity key to equal the NK responder // static. This is structural authentication, not a claim carried by JSON. // // The user-compared SAS remains mandatory defense in depth. Both sides derive // the same value from the handshake hash, display it, and persist nothing until // the user confirms. Android's live CodePairingServer blocks on the same gate. // Reconnect is a separate local-state-selected Noise KK path and never falls // back to NK. // ─────────────────────────────────────────────────────────────────────────── /** * Derive an 8-digit Short Authentication String from a Noise handshake hash. * Both pairing peers compute the SAME value because the Noise `handshakeHash` * is shared after the handshake. Showing it on both devices and having the * user confirm they match authenticates the channel out-of-band, detecting a * man-in-the-middle the NK handshake alone cannot (see SECURITY note above). */ export function derivePairingSAS(handshakeHash: Uint8Array): string { if (handshakeHash.length < 8) { throw new Error("handshake hash too short for SAS"); } // Use 64 PRF-output bits so modulo bias at the 1e8 boundary is negligible. // The identical big-endian rule is pinned by Android cross-platform KATs. let value = 0n; for (const byte of handshakeHash.subarray(0, 8)) { value = (value << 8n) | BigInt(byte); } return String(value % 100_000_000n).padStart(8, "0"); } /** * Run the pairing handshake from the RESPONDER side. * * NOT REACHABLE FROM A BROWSER — responding means accepting an inbound * connection. Retained for the protocol definition and its specs only. */ export async function runPairingResponder( ourEphemeralPrivateKey: Uint8Array, ourEphemeralPublicKey: Uint8Array, ourIdentity: LocalIdentity, transport: PairingTransport, ): Promise { const postNoise = await runPairingResponderOpenFrame( ourEphemeralPrivateKey, ourEphemeralPublicKey, transport, ); return handlePairIdentityFrame( postNoise.frame, postNoise.channel, ourIdentity, postNoise.sas, postNoise.rendezvousSecretHex, ); } export async function runPairingResponderOpenFrame( ourEphemeralPrivateKey: Uint8Array, ourEphemeralPublicKey: Uint8Array, transport: PairingTransport, ): Promise { const session = await noiseNKResponder( ourEphemeralPrivateKey, ourEphemeralPublicKey, transport.send, transport.receive, ); const channel: PairingTransport = { async send(msg) { await transport.send(session.encrypt(msg)); }, async receive() { return session.decrypt(await transport.receive()); }, }; const theirFrame = JSON.parse(new TextDecoder().decode(await channel.receive())); return { sas: derivePairingSAS(session.handshakeHash), rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)), frame: theirFrame, channel, }; } export async function handlePairIdentityFrame( frame: unknown, channel: PairingTransport, ourIdentity: LocalIdentity, sas: string, rendezvousSecretHex: string, ): Promise { const theirFrame = decodeParsedFrame(frame); const ourFrame = encodeFrame({ type: "PAIR_IDENTITY", device_id: ourIdentity.deviceId, device_name: ourIdentity.deviceName, device_type: ourIdentity.deviceType, long_term_public_key_hex: ourIdentity.longTermPublicKeyHex, signing_public_key_hex: ourIdentity.signingPublicKeyHex, ws_port: ourIdentity.wsPort, vault_id: ourIdentity.vaultId, license: ourIdentity.license, relay_url: ourIdentity.relayUrl, }); await channel.send(ourFrame); // SAS derived from the shared handshake hash — caller must have the user // confirm it matches the peer's code before persisting (defeats the // initiator-impersonation / MITM attack the NK handshake alone allows). return { device: { ...frameToPairedDevice(theirFrame), rendezvousSecretHex, }, sas, peerLicense: peerLicenseOf(theirFrame), }; } /** Pull a plausible license string off a peer frame (signature is verified * later, on activation). Anything non-string is ignored. */ function peerLicenseOf(frame: PairingFrame): string | undefined { return typeof frame.license === "string" && frame.license.length > 0 ? frame.license : undefined; } function frameToPairedDevice(frame: PairingFrame): PairedDevice { return { deviceId: frame.device_id, deviceName: frame.device_name, deviceType: frame.device_type, longTermPublicKeyHex: frame.long_term_public_key_hex, signingPublicKeyHex: frame.signing_public_key_hex, trustSource: "direct", wsPort: typeof frame.ws_port === "number" ? frame.ws_port : undefined, relayUrl: frame.relay_url, pairedAt: Date.now(), }; } /** * Test-helper: an in-memory pair of transports that connect to each other. * Use one half for the initiator and the other for the responder. */ export function memoryTransportPair(): [PairingTransport, PairingTransport] { const a: Uint8Array[] = []; const b: Uint8Array[] = []; const aReaders: Array<(b: Uint8Array) => void> = []; const bReaders: Array<(b: Uint8Array) => void> = []; function makeTransport( inbox: Uint8Array[], outbox: Uint8Array[], inboxReaders: Array<(b: Uint8Array) => void>, outboxReaders: Array<(b: Uint8Array) => void>, ): PairingTransport { return { async send(msg: Uint8Array) { const r = outboxReaders.shift(); if (r) r(msg); else outbox.push(msg); }, receive() { const msg = inbox.shift(); if (msg) return Promise.resolve(msg); return new Promise((resolve) => inboxReaders.push(resolve)); }, }; } return [makeTransport(a, b, aReaders, bReaders), makeTransport(b, a, bReaders, aReaders)]; }