1import { describe, expect, it } from "vitest";2import { generateKeyPair } from "@stablelib/x25519";3import {4 memoryTransportPair,5 runPairingInitiator,6 runPairingResponder,7 type LocalIdentity,8} from "./chirp-handshake";9import { deriveSyncIdentityKeypair, pubkeyToHex } from "./sync-identity";1011const MASTER = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i * 5 + 3) & 0xff));1213function identityFor(deviceId: string, deviceName: string, type: LocalIdentity["deviceType"]): LocalIdentity {14 const kp = deriveSyncIdentityKeypair(MASTER, deviceId);15 return { deviceId, deviceName, deviceType: type, longTermPublicKeyHex: pubkeyToHex(kp.publicKey) };16}1718describe("chirp pairing handshake (Noise NK + identity exchange)", () => {19 it("exchanges long-term sync identities between initiator and responder", async () => {20 21 const respEphemeral = generateKeyPair();2223 const initIdent = identityFor("android-001", "June's Pixel 9", "android");24 const respIdent = identityFor("ext-001", "Firefox on MacBook", "extension");2526 const [t1, t2] = memoryTransportPair();27 const respPromise = runPairingResponder(28 respEphemeral.secretKey,29 respEphemeral.publicKey,30 respIdent,31 t2,32 );33 const initPromise = runPairingInitiator(respEphemeral.publicKey, initIdent, t1);3435 const [respLearned, initLearned] = await Promise.all([respPromise, initPromise]);36 expect(initLearned.deviceId).toBe("ext-001");37 expect(initLearned.deviceName).toBe("Firefox on MacBook");38 expect(initLearned.deviceType).toBe("extension");39 expect(initLearned.longTermPublicKeyHex).toBe(respIdent.longTermPublicKeyHex);4041 expect(respLearned.deviceId).toBe("android-001");42 expect(respLearned.deviceName).toBe("June's Pixel 9");43 expect(respLearned.deviceType).toBe("android");44 expect(respLearned.longTermPublicKeyHex).toBe(initIdent.longTermPublicKeyHex);45 });4647 it("rejects a malformed identity frame", async () => {48 const respEphemeral = generateKeyPair();49 const respIdent = identityFor("ext-002", "Firefox", "extension");50 const [t1, t2] = memoryTransportPair();5152 53 const respPromise = runPairingResponder(54 respEphemeral.secretKey,55 respEphemeral.publicKey,56 respIdent,57 t2,58 );59 const evilInitiator = (async () => {60 const { noiseNKInitiator } = await import("./noise-js");61 const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive);62 await t1.send(session.encrypt(new TextEncoder().encode("{}"))); 63 })();6465 await expect(respPromise).rejects.toThrow(/PAIR_IDENTITY/);66 await evilInitiator.catch(() => undefined);67 });6869 it("rejects an identity with a malformed long_term_public_key_hex", async () => {70 const respEphemeral = generateKeyPair();71 const respIdent = identityFor("ext-003", "Firefox", "extension");72 const [t1, t2] = memoryTransportPair();7374 const respPromise = runPairingResponder(75 respEphemeral.secretKey,76 respEphemeral.publicKey,77 respIdent,78 t2,79 );80 const evilInitiator = (async () => {81 const { noiseNKInitiator } = await import("./noise-js");82 const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive);83 const frame = JSON.stringify({84 type: "PAIR_IDENTITY",85 device_id: "evil",86 device_name: "evil",87 device_type: "android",88 long_term_public_key_hex: "deadbeef", 89 });90 await t1.send(session.encrypt(new TextEncoder().encode(frame)));91 })();9293 await expect(respPromise).rejects.toThrow(/32-byte hex/);94 await evilInitiator.catch(() => undefined);95 });96});