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 {16 deviceId,17 deviceName,18 deviceType: type,19 longTermPublicKeyHex: pubkeyToHex(kp.publicKey),20 signingPublicKeyHex: "b".repeat(64),21 };22}2324describe("chirp pairing handshake (Noise NK + identity exchange)", () => {25 it("exchanges long-term sync identities between initiator and responder", async () => {26 27 const respEphemeral = generateKeyPair();2829 const initIdent = identityFor("android-001", "June's Pixel 9", "android");30 const respIdent = identityFor("ext-001", "Firefox on MacBook", "extension");3132 const [t1, t2] = memoryTransportPair();33 const respPromise = runPairingResponder(34 respEphemeral.secretKey,35 respEphemeral.publicKey,36 respIdent,37 t2,38 );39 const initPromise = runPairingInitiator(respEphemeral.publicKey, initIdent, t1);4041 const [respLearned, initLearned] = await Promise.all([respPromise, initPromise]);42 expect(initLearned.deviceId).toBe("ext-001");43 expect(initLearned.deviceName).toBe("Firefox on MacBook");44 expect(initLearned.deviceType).toBe("extension");45 expect(initLearned.longTermPublicKeyHex).toBe(respIdent.longTermPublicKeyHex);46 expect(initLearned.signingPublicKeyHex).toBe(respIdent.signingPublicKeyHex);47 expect(initLearned.trustSource).toBe("direct");4849 expect(respLearned.deviceId).toBe("android-001");50 expect(respLearned.deviceName).toBe("June's Pixel 9");51 expect(respLearned.deviceType).toBe("android");52 expect(respLearned.longTermPublicKeyHex).toBe(initIdent.longTermPublicKeyHex);53 expect(respLearned.signingPublicKeyHex).toBe(initIdent.signingPublicKeyHex);54 expect(respLearned.trustSource).toBe("direct");55 });5657 it("rejects a malformed identity frame", async () => {58 const respEphemeral = generateKeyPair();59 const respIdent = identityFor("ext-002", "Firefox", "extension");60 const [t1, t2] = memoryTransportPair();6162 63 const respPromise = runPairingResponder(64 respEphemeral.secretKey,65 respEphemeral.publicKey,66 respIdent,67 t2,68 );69 const evilInitiator = (async () => {70 const { noiseNKInitiator } = await import("./noise-js");71 const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive);72 await t1.send(session.encrypt(new TextEncoder().encode("{}"))); 73 })();7475 await expect(respPromise).rejects.toThrow(/PAIR_IDENTITY/);76 await evilInitiator.catch(() => undefined);77 });7879 it("rejects an identity with a malformed long_term_public_key_hex", async () => {80 const respEphemeral = generateKeyPair();81 const respIdent = identityFor("ext-003", "Firefox", "extension");82 const [t1, t2] = memoryTransportPair();8384 const respPromise = runPairingResponder(85 respEphemeral.secretKey,86 respEphemeral.publicKey,87 respIdent,88 t2,89 );90 const evilInitiator = (async () => {91 const { noiseNKInitiator } = await import("./noise-js");92 const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive);93 const frame = JSON.stringify({94 type: "PAIR_IDENTITY",95 device_id: "evil",96 device_name: "evil",97 device_type: "android",98 long_term_public_key_hex: "deadbeef", 99 });100 await t1.send(session.encrypt(new TextEncoder().encode(frame)));101 })();102103 await expect(respPromise).rejects.toThrow(/32-byte hex/);104 await evilInitiator.catch(() => undefined);105 });106107 it("rejects an identity with a malformed signing_public_key_hex", async () => {108 const respEphemeral = generateKeyPair();109 const respIdent = identityFor("ext-004", "Firefox", "extension");110 const [t1, t2] = memoryTransportPair();111112 const respPromise = runPairingResponder(113 respEphemeral.secretKey,114 respEphemeral.publicKey,115 respIdent,116 t2,117 );118 const evilInitiator = (async () => {119 const { noiseNKInitiator } = await import("./noise-js");120 const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive);121 const frame = JSON.stringify({122 type: "PAIR_IDENTITY",123 device_id: "evil",124 device_name: "evil",125 device_type: "android",126 long_term_public_key_hex: "a".repeat(64),127 signing_public_key_hex: "deadbeef",128 });129 await t1.send(session.encrypt(new TextEncoder().encode(frame)));130 })();131132 await expect(respPromise).rejects.toThrow(/signing_public_key_hex/);133 await evilInitiator.catch(() => undefined);134 });135});