1import { describe, expect, it } from "vitest";2import { generateKeyPair } from "@stablelib/x25519";3import {4 derivePairingSAS,5 memoryTransportPair,6 runPairingInitiator,7 runPairingResponder,8 type LocalIdentity,9} from "./pairing-handshake";10import { deriveSyncIdentityKeypair, pubkeyToHex } from "./sync-identity";1112const MASTER = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i * 5 + 3) & 0xff));1314function identityFor(deviceId: string, deviceName: string, type: LocalIdentity["deviceType"]): LocalIdentity {15 const kp = deriveSyncIdentityKeypair(MASTER, deviceId);16 return {17 deviceId,18 deviceName,19 deviceType: type,20 longTermPublicKeyHex: pubkeyToHex(kp.publicKey),21 signingPublicKeyHex: "b".repeat(64),22 };23}2425describe("pairing handshake (Noise NK + identity exchange)", () => {26 it("exchanges long-term sync identities between initiator and responder", async () => {27 28 const respEphemeral = generateKeyPair();2930 const initIdent = identityFor("android-001", "June's Pixel 9", "android");31 const respIdent = identityFor("ext-001", "Firefox on MacBook", "extension");3233 const [t1, t2] = memoryTransportPair();34 const respPromise = runPairingResponder(35 respEphemeral.secretKey,36 respEphemeral.publicKey,37 respIdent,38 t2,39 );40 const initPromise = runPairingInitiator(respEphemeral.publicKey, initIdent, t1);4142 const [respResult, initResult] = await Promise.all([respPromise, initPromise]);43 const initLearned = initResult.device;44 const respLearned = respResult.device;45 expect(initLearned.deviceId).toBe("ext-001");46 expect(initLearned.deviceName).toBe("Firefox on MacBook");47 expect(initLearned.deviceType).toBe("extension");48 expect(initLearned.longTermPublicKeyHex).toBe(respIdent.longTermPublicKeyHex);49 expect(initLearned.signingPublicKeyHex).toBe(respIdent.signingPublicKeyHex);50 expect(initLearned.trustSource).toBe("direct");5152 expect(respLearned.deviceId).toBe("android-001");53 expect(respLearned.deviceName).toBe("June's Pixel 9");54 expect(respLearned.deviceType).toBe("android");55 expect(respLearned.longTermPublicKeyHex).toBe(initIdent.longTermPublicKeyHex);56 expect(respLearned.signingPublicKeyHex).toBe(initIdent.signingPublicKeyHex);57 expect(respLearned.trustSource).toBe("direct");5859 60 61 expect(initResult.sas).toMatch(/^\d{8}$/);62 expect(initResult.sas).toBe(respResult.sas);63 });6465 it("carries a Pro license from the device that has one to the peer", async () => {66 const respEphemeral = generateKeyPair();67 68 const initIdent = { ...identityFor("android-pro", "Pro Pixel", "android"), license: "PPK-AAA-BBB" };69 const respIdent = identityFor("ext-free", "Free Firefox", "extension"); 7071 const [t1, t2] = memoryTransportPair();72 const respPromise = runPairingResponder(respEphemeral.secretKey, respEphemeral.publicKey, respIdent, t2);73 const initPromise = runPairingInitiator(respEphemeral.publicKey, initIdent, t1);74 const [respResult, initResult] = await Promise.all([respPromise, initPromise]);7576 77 78 expect(respResult.peerLicense).toBe("PPK-AAA-BBB");79 expect(initResult.peerLicense).toBeUndefined();80 expect("license" in respResult.device).toBe(false);81 });8283 it("rejects a malformed identity frame", async () => {84 const respEphemeral = generateKeyPair();85 const respIdent = identityFor("ext-002", "Firefox", "extension");86 const [t1, t2] = memoryTransportPair();8788 89 const respPromise = runPairingResponder(90 respEphemeral.secretKey,91 respEphemeral.publicKey,92 respIdent,93 t2,94 );95 const evilInitiator = (async () => {96 const { noiseNKInitiator } = await import("./noise-js");97 const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive);98 await t1.send(session.encrypt(new TextEncoder().encode("{}"))); 99 })();100101 await expect(respPromise).rejects.toThrow(/PAIR_IDENTITY/);102 await evilInitiator.catch(() => undefined);103 });104105 it("rejects an identity with a malformed long_term_public_key_hex", async () => {106 const respEphemeral = generateKeyPair();107 const respIdent = identityFor("ext-003", "Firefox", "extension");108 const [t1, t2] = memoryTransportPair();109110 const respPromise = runPairingResponder(111 respEphemeral.secretKey,112 respEphemeral.publicKey,113 respIdent,114 t2,115 );116 const evilInitiator = (async () => {117 const { noiseNKInitiator } = await import("./noise-js");118 const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive);119 const frame = JSON.stringify({120 type: "PAIR_IDENTITY",121 device_id: "evil",122 device_name: "evil",123 device_type: "android",124 long_term_public_key_hex: "deadbeef", 125 });126 await t1.send(session.encrypt(new TextEncoder().encode(frame)));127 })();128129 await expect(respPromise).rejects.toThrow(/32-byte hex/);130 await evilInitiator.catch(() => undefined);131 });132133 it("rejects an identity with a malformed signing_public_key_hex", async () => {134 const respEphemeral = generateKeyPair();135 const respIdent = identityFor("ext-004", "Firefox", "extension");136 const [t1, t2] = memoryTransportPair();137138 const respPromise = runPairingResponder(139 respEphemeral.secretKey,140 respEphemeral.publicKey,141 respIdent,142 t2,143 );144 const evilInitiator = (async () => {145 const { noiseNKInitiator } = await import("./noise-js");146 const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive);147 const frame = JSON.stringify({148 type: "PAIR_IDENTITY",149 device_id: "evil",150 device_name: "evil",151 device_type: "android",152 long_term_public_key_hex: "a".repeat(64),153 signing_public_key_hex: "deadbeef",154 });155 await t1.send(session.encrypt(new TextEncoder().encode(frame)));156 })();157158 await expect(respPromise).rejects.toThrow(/signing_public_key_hex/);159 await evilInitiator.catch(() => undefined);160 });161});162163describe("derivePairingSAS (out-of-band MITM-detection code)", () => {164 it("returns a deterministic 8-digit code", () => {165 const h = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i * 7 + 1) & 0xff));166 const sas = derivePairingSAS(h);167 expect(sas).toMatch(/^\d{8}$/);168 expect(derivePairingSAS(h)).toBe(sas);169 });170171 it("both peers derive the same SAS from the shared Noise handshake hash", () => {172 173 174 const shared = crypto.getRandomValues(new Uint8Array(32));175 expect(derivePairingSAS(shared.slice())).toBe(derivePairingSAS(shared.slice()));176 });177178 it("differs for different handshake hashes", () => {179 expect(derivePairingSAS(Uint8Array.of(0, 0, 0, 0, 0, 0, 0, 0))).not.toBe(180 derivePairingSAS(Uint8Array.of(0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff)),181 );182 });183184 it("pins the cross-platform big-endian 64-bit vector", () => {185 expect(derivePairingSAS(Uint8Array.of(0, 1, 2, 3, 4, 5, 6, 7))).toBe("52306183");186 expect(derivePairingSAS(new Uint8Array(8).fill(0xff))).toBe("09551615");187 });188189 it("rejects undersized input", () => {190 expect(() => derivePairingSAS(new Uint8Array(7))).toThrow(/too short/);191 });192});