import { describe, expect, it } from "vitest"; import { generateKeyPair } from "@stablelib/x25519"; import { memoryTransportPair, runPairingInitiator, runPairingResponder, type LocalIdentity, } from "./chirp-handshake"; import { deriveSyncIdentityKeypair, pubkeyToHex } from "./sync-identity"; const MASTER = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i * 5 + 3) & 0xff)); function identityFor(deviceId: string, deviceName: string, type: LocalIdentity["deviceType"]): LocalIdentity { const kp = deriveSyncIdentityKeypair(MASTER, deviceId); return { deviceId, deviceName, deviceType: type, longTermPublicKeyHex: pubkeyToHex(kp.publicKey), signingPublicKeyHex: "b".repeat(64), }; } describe("chirp pairing handshake (Noise NK + identity exchange)", () => { it("exchanges long-term sync identities between initiator and responder", async () => { // Responder owns the ephemeral that the initiator targets. const respEphemeral = generateKeyPair(); const initIdent = identityFor("android-001", "June's Pixel 9", "android"); const respIdent = identityFor("ext-001", "Firefox on MacBook", "extension"); const [t1, t2] = memoryTransportPair(); const respPromise = runPairingResponder( respEphemeral.secretKey, respEphemeral.publicKey, respIdent, t2, ); const initPromise = runPairingInitiator(respEphemeral.publicKey, initIdent, t1); const [respLearned, initLearned] = await Promise.all([respPromise, initPromise]); expect(initLearned.deviceId).toBe("ext-001"); expect(initLearned.deviceName).toBe("Firefox on MacBook"); expect(initLearned.deviceType).toBe("extension"); expect(initLearned.longTermPublicKeyHex).toBe(respIdent.longTermPublicKeyHex); expect(initLearned.signingPublicKeyHex).toBe(respIdent.signingPublicKeyHex); expect(initLearned.trustSource).toBe("direct"); expect(respLearned.deviceId).toBe("android-001"); expect(respLearned.deviceName).toBe("June's Pixel 9"); expect(respLearned.deviceType).toBe("android"); expect(respLearned.longTermPublicKeyHex).toBe(initIdent.longTermPublicKeyHex); expect(respLearned.signingPublicKeyHex).toBe(initIdent.signingPublicKeyHex); expect(respLearned.trustSource).toBe("direct"); }); it("rejects a malformed identity frame", async () => { const respEphemeral = generateKeyPair(); const respIdent = identityFor("ext-002", "Firefox", "extension"); const [t1, t2] = memoryTransportPair(); // Initiator sends garbage instead of a PAIR_IDENTITY frame const respPromise = runPairingResponder( respEphemeral.secretKey, respEphemeral.publicKey, respIdent, t2, ); const evilInitiator = (async () => { const { noiseNKInitiator } = await import("./noise-js"); const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive); await t1.send(session.encrypt(new TextEncoder().encode("{}"))); // not a PAIR_IDENTITY })(); await expect(respPromise).rejects.toThrow(/PAIR_IDENTITY/); await evilInitiator.catch(() => undefined); }); it("rejects an identity with a malformed long_term_public_key_hex", async () => { const respEphemeral = generateKeyPair(); const respIdent = identityFor("ext-003", "Firefox", "extension"); const [t1, t2] = memoryTransportPair(); const respPromise = runPairingResponder( respEphemeral.secretKey, respEphemeral.publicKey, respIdent, t2, ); const evilInitiator = (async () => { const { noiseNKInitiator } = await import("./noise-js"); const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive); const frame = JSON.stringify({ type: "PAIR_IDENTITY", device_id: "evil", device_name: "evil", device_type: "android", long_term_public_key_hex: "deadbeef", // too short }); await t1.send(session.encrypt(new TextEncoder().encode(frame))); })(); await expect(respPromise).rejects.toThrow(/32-byte hex/); await evilInitiator.catch(() => undefined); }); it("rejects an identity with a malformed signing_public_key_hex", async () => { const respEphemeral = generateKeyPair(); const respIdent = identityFor("ext-004", "Firefox", "extension"); const [t1, t2] = memoryTransportPair(); const respPromise = runPairingResponder( respEphemeral.secretKey, respEphemeral.publicKey, respIdent, t2, ); const evilInitiator = (async () => { const { noiseNKInitiator } = await import("./noise-js"); const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive); const frame = JSON.stringify({ type: "PAIR_IDENTITY", device_id: "evil", device_name: "evil", device_type: "android", long_term_public_key_hex: "a".repeat(64), signing_public_key_hex: "deadbeef", }); await t1.send(session.encrypt(new TextEncoder().encode(frame))); })(); await expect(respPromise).rejects.toThrow(/signing_public_key_hex/); await evilInitiator.catch(() => undefined); }); });