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 "./chirp-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("chirp 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{6}$/);62 expect(initResult.sas).toBe(respResult.sas);63 });6465 it("rejects a malformed identity frame", async () => {66 const respEphemeral = generateKeyPair();67 const respIdent = identityFor("ext-002", "Firefox", "extension");68 const [t1, t2] = memoryTransportPair();6970 71 const respPromise = runPairingResponder(72 respEphemeral.secretKey,73 respEphemeral.publicKey,74 respIdent,75 t2,76 );77 const evilInitiator = (async () => {78 const { noiseNKInitiator } = await import("./noise-js");79 const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive);80 await t1.send(session.encrypt(new TextEncoder().encode("{}"))); 81 })();8283 await expect(respPromise).rejects.toThrow(/PAIR_IDENTITY/);84 await evilInitiator.catch(() => undefined);85 });8687 it("rejects an identity with a malformed long_term_public_key_hex", async () => {88 const respEphemeral = generateKeyPair();89 const respIdent = identityFor("ext-003", "Firefox", "extension");90 const [t1, t2] = memoryTransportPair();9192 const respPromise = runPairingResponder(93 respEphemeral.secretKey,94 respEphemeral.publicKey,95 respIdent,96 t2,97 );98 const evilInitiator = (async () => {99 const { noiseNKInitiator } = await import("./noise-js");100 const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive);101 const frame = JSON.stringify({102 type: "PAIR_IDENTITY",103 device_id: "evil",104 device_name: "evil",105 device_type: "android",106 long_term_public_key_hex: "deadbeef", 107 });108 await t1.send(session.encrypt(new TextEncoder().encode(frame)));109 })();110111 await expect(respPromise).rejects.toThrow(/32-byte hex/);112 await evilInitiator.catch(() => undefined);113 });114115 it("rejects an identity with a malformed signing_public_key_hex", async () => {116 const respEphemeral = generateKeyPair();117 const respIdent = identityFor("ext-004", "Firefox", "extension");118 const [t1, t2] = memoryTransportPair();119120 const respPromise = runPairingResponder(121 respEphemeral.secretKey,122 respEphemeral.publicKey,123 respIdent,124 t2,125 );126 const evilInitiator = (async () => {127 const { noiseNKInitiator } = await import("./noise-js");128 const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive);129 const frame = JSON.stringify({130 type: "PAIR_IDENTITY",131 device_id: "evil",132 device_name: "evil",133 device_type: "android",134 long_term_public_key_hex: "a".repeat(64),135 signing_public_key_hex: "deadbeef",136 });137 await t1.send(session.encrypt(new TextEncoder().encode(frame)));138 })();139140 await expect(respPromise).rejects.toThrow(/signing_public_key_hex/);141 await evilInitiator.catch(() => undefined);142 });143});144145describe("derivePairingSAS (out-of-band MITM-detection code)", () => {146 it("returns a deterministic 6-digit code", () => {147 const h = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i * 7 + 1) & 0xff));148 const sas = derivePairingSAS(h);149 expect(sas).toMatch(/^\d{6}$/);150 expect(derivePairingSAS(h)).toBe(sas);151 });152153 it("both peers derive the same SAS from the shared Noise handshake hash", () => {154 155 156 const shared = crypto.getRandomValues(new Uint8Array(32));157 expect(derivePairingSAS(shared.slice())).toBe(derivePairingSAS(shared.slice()));158 });159160 it("differs for different handshake hashes", () => {161 expect(derivePairingSAS(Uint8Array.of(0, 0, 0, 0))).not.toBe(162 derivePairingSAS(Uint8Array.of(0xff, 0xff, 0xff, 0xff)),163 );164 });165166 it("handles undersized input without throwing", () => {167 expect(derivePairingSAS(new Uint8Array(2))).toBe("000000");168 });169});