123456789101112131415import { beforeEach, describe, expect, it, vi } from "vitest";1617import type { Message, MessageResponse } from "../shared/types";1819type StorageShape = Record<string, unknown>;20type MessageListener = (21 msg: unknown,22 sender: chrome.runtime.MessageSender,23 sendResponse: (response: MessageResponse) => void24) => boolean;2526describe("sync adversarial", () => {27 let storage: StorageShape;28 let sessionStorage: StorageShape;29 let listener: MessageListener | undefined;3031 beforeEach(async () => {32 vi.resetModules();33 vi.useRealTimers();34 storage = { peach_settings: { breachMonitoring: false } };35 sessionStorage = {};36 listener = undefined;3738 vi.stubGlobal(39 "fetch",40 vi.fn(async () => ({ ok: true, status: 200, text: async () => "" }))41 );42 vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);4344 const chromeMock = {45 runtime: {46 id: "peach-extension",47 onMessage: {48 addListener: vi.fn((fn: MessageListener) => {49 listener = fn;50 }),51 },52 sendMessage: vi.fn(async () => ({ ok: true })),53 },54 tabs: {55 query: vi.fn((_q: unknown, cb?: (tabs: unknown[]) => void) => {56 if (typeof cb === "function") {57 cb([{ id: 1 }]);58 return;59 }60 return Promise.resolve([{ id: 1 }]);61 }),62 sendMessage: vi.fn(async () => ({ ok: true })),63 },64 storage: {65 local: {66 get: vi.fn(async (key?: string | string[]) => {67 if (!key) return storage;68 if (typeof key === "string") return { [key]: storage[key] };69 return key.reduce<Record<string, unknown>>((acc, k) => {70 acc[k] = storage[k];71 return acc;72 }, {});73 }),74 set: vi.fn(async (next: Record<string, unknown>) => {75 Object.assign(storage, next);76 }),77 remove: vi.fn(async (keys: string | string[]) => {78 const list = Array.isArray(keys) ? keys : [keys];79 for (const key of list) delete storage[key];80 }),81 },82 session: {83 get: vi.fn(async (key?: string | string[]) => {84 if (!key) return sessionStorage;85 if (typeof key === "string") return { [key]: sessionStorage[key] };86 return key.reduce<Record<string, unknown>>((acc, k) => {87 acc[k] = sessionStorage[k];88 return acc;89 }, {});90 }),91 set: vi.fn(async (next: Record<string, unknown>) => {92 Object.assign(sessionStorage, next);93 }),94 remove: vi.fn(async (keys: string | string[]) => {95 const list = Array.isArray(keys) ? keys : [keys];96 for (const key of list) delete sessionStorage[key];97 }),98 clear: vi.fn(async () => {99 for (const k of Object.keys(sessionStorage)) delete sessionStorage[k];100 }),101 },102 },103 alarms: {104 clear: vi.fn(),105 create: vi.fn(),106 onAlarm: { addListener: vi.fn() },107 },108 idle: {109 setDetectionInterval: vi.fn(),110 onStateChanged: { addListener: vi.fn() },111 },112 scripting: { executeScript: vi.fn(async () => []) },113 } as unknown as typeof chrome;114115 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;116117 const messages = await import("./messages");118 messages.setupMessageHandler();119 const vault = await import("./vault");120 await vault.ensureSettingsLoaded();121 });122123 async function dispatch(message: Message): Promise<MessageResponse> {124 if (!listener) throw new Error("listener not registered");125 return new Promise<MessageResponse>((resolve) => {126 const keepOpen = listener!(127 message,128 { id: "peach-extension" } as chrome.runtime.MessageSender,129 resolve130 );131 expect(keepOpen).toBe(true);132 });133 }134135 async function setupBlobs() {136 const vault = await import("./vault");137 await vault.createVault("test-pass-12345");138 139 storage.peach_last_modified_at = String(1_700_000_000_000);140 return vault;141 }142143 function makeIncomingBlob(slotsLike?: {144 a?: Record<string, unknown>;145 b?: Record<string, unknown>;146 }): string {147 const a = slotsLike?.a ?? storage.peach_vault_a;148 const b = slotsLike?.b ?? storage.peach_vault_b;149 return JSON.stringify({ a, b });150 }151152 153154 describe("applyIncomingVault timestamp adversarial", () => {155 const olderDeltas = [156 -1, -10, -1000, -60_000, -3_600_000, -86_400_000, -1_000_000_000,157 ];158 for (const delta of olderDeltas) {159 it(`incoming vault with timestamp delta ${delta}ms (older): applied=false`, async () => {160 const vault = await setupBlobs();161 const remoteTs = 1_700_000_000_000 + delta;162 const applied = await vault.applyIncomingVault(163 makeIncomingBlob(),164 remoteTs165 );166 expect(applied).toBe(false);167 });168 }169170 it("same timestamp: applied=false (not equal-overwrite)", async () => {171 const vault = await setupBlobs();172 const applied = await vault.applyIncomingVault(173 makeIncomingBlob(),174 1_700_000_000_000175 );176 expect(applied).toBe(false);177 });178179 const newerDeltas = [1, 10, 1_000, 60_000, 3_600_000, 1_000_000_000];180 for (const delta of newerDeltas) {181 it(`incoming vault delta +${delta}ms (newer): applied=true`, async () => {182 const vault = await setupBlobs();183 const applied = await vault.applyIncomingVault(184 makeIncomingBlob(),185 1_700_000_000_000 + delta186 );187 expect(applied).toBe(true);188 });189 }190191 it("far-future timestamp accepted (no upper bound on remote ts)", async () => {192 const vault = await setupBlobs();193 const applied = await vault.applyIncomingVault(194 makeIncomingBlob(),195 Date.now() * 100196 );197 expect(applied).toBe(true);198 });199 });200201 202203 describe("applyIncomingVault malformed blob handling", () => {204 const malformedBlobs = [205 "",206 "not json",207 "{",208 "[]",209 "null",210 "true",211 "42",212 '{"a": null}',213 '{"a": "not-an-object"}',214 ];215 for (const blob of malformedBlobs) {216 it(`malformed blob ${JSON.stringify(blob).slice(0, 30)} does not poison local vault`, async () => {217 const vault = await setupBlobs();218 const before = storage.peach_vault_a;219 let threw = false;220 try {221 await vault.applyIncomingVault(blob, Date.now() + 100_000);222 } catch {223 threw = true;224 }225 226 227 const reloaded = storage.peach_vault_a;228 if (!threw) {229 230 231 232 233 expect(reloaded).not.toBe(undefined);234 } else {235 expect(reloaded).toBe(before);236 }237 });238 }239 });240241 242243 describe("applyIncomingVault license handling", () => {244 it("existing license is not overwritten by incoming license", async () => {245 const vault = await setupBlobs();246 storage.peach_license = "my-existing-license";247 await vault.applyIncomingVault(248 makeIncomingBlob(),249 Date.now() + 100_000,250 "their-different-license"251 );252 expect(storage.peach_license).toBe("my-existing-license");253 });254255 it("no local license: invalid incoming license is rejected (L16/SEC-010)", async () => {256 257 258 259 260 const vault = await setupBlobs();261 delete storage.peach_license;262 await vault.applyIncomingVault(263 makeIncomingBlob(),264 Date.now() + 100_000,265 "incoming-license"266 );267 expect(storage.peach_license).toBeUndefined();268 });269270 it("no incoming license: local license is unchanged", async () => {271 const vault = await setupBlobs();272 storage.peach_license = "local-pro";273 await vault.applyIncomingVault(274 makeIncomingBlob(),275 Date.now() + 100_000276 );277 expect(storage.peach_license).toBe("local-pro");278 });279280 it("empty-string incoming license is treated as no license", async () => {281 const vault = await setupBlobs();282 delete storage.peach_license;283 await vault.applyIncomingVault(284 makeIncomingBlob(),285 Date.now() + 100_000,286 ""287 );288 289 expect(storage.peach_license).toBeUndefined();290 });291 });292293 294295 describe("applyIncomingVault snapshot", () => {296 it("a snapshot is taken before applying a newer incoming vault", async () => {297 const vault = await setupBlobs();298 const historyBefore = await vault.getVaultHistory();299 await vault.applyIncomingVault(300 makeIncomingBlob(),301 Date.now() + 100_000302 );303 const historyAfter = await vault.getVaultHistory();304 expect(historyAfter.length).toBeGreaterThanOrEqual(historyBefore.length);305 });306 });307308 309310 describe("pairing token lifecycle", () => {311 it("START_PAIRING_SERVER yields a 6-digit token", async () => {312 const r = await dispatch({ type: "START_PAIRING_SERVER" });313 expect(r.ok).toBe(true);314 const data = (r as { data: { token: string; expiresAt: number } }).data;315 expect(data.token).toMatch(/^\d{6}$/);316 expect(data.expiresAt).toBeGreaterThan(Date.now());317 });318319 it("STOP_PAIRING_SERVER clears the token", async () => {320 await dispatch({ type: "START_PAIRING_SERVER" });321 const r = await dispatch({ type: "STOP_PAIRING_SERVER" });322 expect(r.ok).toBe(true);323 });324325 it("SCAN_PAIRING_QR returns android_only off mobile", async () => {326 const r = await dispatch({327 type: "SCAN_PAIRING_QR",328 payload: { token: "123456" },329 });330 expect(r.ok).toBe(false);331 expect(r.error).toBe("android_only");332 });333334 it("SCAN_PAIRING_QR with garbage token still returns android_only off mobile", async () => {335 const r = await dispatch({336 type: "SCAN_PAIRING_QR",337 payload: { token: "not-a-token" as unknown as string },338 });339 expect(r.ok).toBe(false);340 });341342 it("ACCEPT_PAIRING always returns ok (free tier)", async () => {343 const r = await dispatch({ type: "ACCEPT_PAIRING", payload: {} });344 expect(r.ok).toBe(true);345 });346347 for (let i = 0; i < 10; i++) {348 it(`generated token #${i} is a fresh 6-digit code`, async () => {349 const r = await dispatch({ type: "START_PAIRING_SERVER" });350 const data = (r as { data: { token: string } }).data;351 expect(data.token).toMatch(/^\d{6}$/);352 });353 }354 });355356 357358 describe("RESPOND_INCOMING_PAIRING adversarial", () => {359 it("no pending request: returns no_pending_request", async () => {360 const r = await dispatch({361 type: "RESPOND_INCOMING_PAIRING",362 payload: { accept: true },363 });364 expect(r.ok).toBe(false);365 expect(r.error).toBe("no_pending_request");366 });367368 it("expired request: getIncomingPairingRequest returns null", async () => {369 await dispatch({370 type: "DEBUG_INJECT_INCOMING_PAIRING",371 payload: {372 deviceName: "fake",373 publicKeyHex: "00".repeat(32),374 },375 });376 377 const sessionRaw = sessionStorage["peach_incoming_pair"] as378 | Record<string, unknown>379 | undefined;380 if (sessionRaw && typeof sessionRaw === "object") {381 (sessionRaw as Record<string, unknown>).expiresAt = Date.now() - 1000;382 }383 const r = await dispatch({384 type: "RESPOND_INCOMING_PAIRING",385 payload: { accept: true },386 });387 388 389 expect(r.ok).toBe(false);390 });391392 it("missing payload returns no_pending or error", async () => {393 const r = await dispatch({394 type: "RESPOND_INCOMING_PAIRING",395 payload: {} as { accept: boolean },396 });397 expect(r).toBeDefined();398 });399 });400401 402403 describe("DEBUG_INJECT_INCOMING_PAIRING adversarial", () => {404 it("missing fields returns missing_payload", async () => {405 const r = await dispatch({406 type: "DEBUG_INJECT_INCOMING_PAIRING",407 payload: {},408 });409 expect(r.ok).toBe(false);410 expect(r.error).toBe("missing_payload");411 });412413 const partials = [414 { deviceName: "x" },415 { publicKeyHex: "y" },416 { deviceName: "", publicKeyHex: "" },417 ];418 for (const [i, p] of partials.entries()) {419 it(`partial payload #${i} returns missing_payload`, async () => {420 const r = await dispatch({421 type: "DEBUG_INJECT_INCOMING_PAIRING",422 payload: p,423 });424 expect(r.ok).toBe(false);425 expect(r.error).toBe("missing_payload");426 });427 }428429 it("valid payload returns ok", async () => {430 const r = await dispatch({431 type: "DEBUG_INJECT_INCOMING_PAIRING",432 payload: {433 deviceName: "test-device",434 publicKeyHex: "ab".repeat(32),435 },436 });437 expect(r.ok).toBe(true);438 });439 });440441 442443 describe("GET_SYNC_STATUS shape", () => {444 it("never synced: status=never", async () => {445 delete storage.peach_last_sync_at;446 const r = await dispatch({ type: "GET_SYNC_STATUS" });447 const data = (r as { data: { status: string } }).data;448 expect(data.status).toBe("never");449 });450451 it("with last_sync_at: status=up_to_date", async () => {452 storage.peach_last_sync_at = String(Date.now());453 const r = await dispatch({ type: "GET_SYNC_STATUS" });454 const data = (r as { data: { status: string } }).data;455 expect(data.status).toBe("up_to_date");456 });457458 it("garbage last_sync_at: status=never", async () => {459 storage.peach_last_sync_at = "not-a-number";460 const r = await dispatch({ type: "GET_SYNC_STATUS" });461 const data = (r as { data: { status: string } }).data;462 expect(data.status).toBe("never");463 });464 });465466 467468 describe("REMOVE_PAIRED_DEVICE adversarial", () => {469 const badIds = ["", " ", "not-a-real-id", "\x00", "a".repeat(1000)];470 for (const deviceId of badIds) {471 it(`unknown deviceId ${JSON.stringify(deviceId.slice(0, 20))} succeeds (idempotent)`, async () => {472 const r = await dispatch({473 type: "REMOVE_PAIRED_DEVICE",474 payload: { deviceId },475 });476 477 expect(r.ok).toBe(true);478 });479 }480 });481482 483484 describe("timestamp boundary sweep", () => {485 for (const baseTs of [486 0,487 1,488 1_000,489 1_700_000_000_000,490 Number.MAX_SAFE_INTEGER - 10,491 ]) {492 it(`base ts ${baseTs}: applies when remote is strictly newer`, async () => {493 const vault = await import("./vault");494 await vault.createVault("test-pass-12345");495 storage.peach_last_modified_at = String(baseTs);496 const applied = await vault.applyIncomingVault(497 makeIncomingBlob(),498 baseTs + 1499 );500 expect(applied).toBe(true);501 });502503 it(`base ts ${baseTs}: rejects when remote is equal`, async () => {504 const vault = await import("./vault");505 await vault.createVault("test-pass-12345");506 storage.peach_last_modified_at = String(baseTs);507 const applied = await vault.applyIncomingVault(508 makeIncomingBlob(),509 baseTs510 );511 expect(applied).toBe(false);512 });513514 it(`base ts ${baseTs}: rejects when remote is strictly older`, async () => {515 const vault = await import("./vault");516 await vault.createVault("test-pass-12345");517 storage.peach_last_modified_at = String(baseTs);518 const applied = await vault.applyIncomingVault(519 makeIncomingBlob(),520 baseTs - 1521 );522 expect(applied).toBe(false);523 });524 }525 });526527 528529 describe("token format adversarial", () => {530 const badFormats = [531 "",532 "12345",533 "1234567",534 "abcdef",535 "12 34 56",536 "<script>",537 "null",538 "undefined",539 "-12345",540 "1.23456",541 "🔢🔢🔢🔢🔢🔢",542 ];543 for (const token of badFormats) {544 it(`SCAN_PAIRING_QR with token ${JSON.stringify(token)} returns android_only`, async () => {545 const r = await dispatch({546 type: "SCAN_PAIRING_QR",547 payload: { token },548 });549 expect(r.ok).toBe(false);550 });551 }552 });553554 555556 describe("runBlobSyncSession safety", () => {557 it("locked vault returns skipped status", async () => {558 const { runBlobSyncSession } = await import("./sync-coordinator");559 560 const result = await runBlobSyncSession(561 "initiator",562 {563 send: vi.fn(async () => {}),564 receive: vi.fn(async () => new Uint8Array()),565 close: vi.fn(async () => {}),566 } as unknown as Parameters<typeof runBlobSyncSession>[1],567 {568 getVaultBlobForSync: async () => null,569 applyIncomingVault: async () => false,570 isVaultUnlocked: () => false,571 }572 );573 expect(result.status).toBe("skipped");574 expect(result.applied).toBe(false);575 });576 });577578 579 580 581 describe("H3/COR-005 sync re-decrypt failure lock", () => {582 async function buildRemoteBlobWithDifferentKey(): Promise<string> {583 const vault = await import("./vault");584 const slotA = await vault.loadSlotA();585 if (!slotA) throw new Error("no slot A");586 587 588 589 const rand = (n: number) =>590 Array.from(crypto.getRandomValues(new Uint8Array(n)))591 .map(b => b.toString(16).padStart(2, "0"))592 .join("");593 const remoteA = {594 ...slotA,595 salt: rand(16),596 nonce: rand(12),597 ciphertext: rand(64),598 tag: rand(16),599 };600 return JSON.stringify({ a: remoteA, b: storage.peach_vault_b });601 }602603 it("vault locks when reloadEntriesAfterSync fails after apply", async () => {604 const vault = await setupBlobs();605 const messages = await import("./messages");606 const deps = messages.buildSyncDeps();607 const remote = await buildRemoteBlobWithDifferentKey();608 609 const remoteTs = 1_700_000_000_000 + 100_000;610 expect(vault.isUnlocked()).toBe(true);611 const applied = await deps.applyIncomingVault(remote, remoteTs);612 expect(applied).toBe(true);613 expect(vault.isUnlocked()).toBe(false);614 });615616 it("stale in-memory entries cannot clobber remote blob after sync", async () => {617 await setupBlobs();618 const messages = await import("./messages");619 const deps = messages.buildSyncDeps();620 const remote = await buildRemoteBlobWithDifferentKey();621 const remoteTs = 1_700_000_000_000 + 100_000;622 await deps.applyIncomingVault(remote, remoteTs);623 624 const r = await dispatch({625 type: "ADD_ENTRY",626 payload: { title: "Ghost", password: "p" },627 });628 expect(r.ok).toBe(false);629 });630631 it("when reloadEntriesAfterSync succeeds, vault stays unlocked", async () => {632 const vault = await setupBlobs();633 const messages = await import("./messages");634 const deps = messages.buildSyncDeps();635 636 637 const slotA = storage.peach_vault_a;638 const sameKeyBlob = JSON.stringify({ a: slotA, b: storage.peach_vault_b });639 const applied = await deps.applyIncomingVault(640 sameKeyBlob,641 1_700_000_000_000 + 50_000,642 );643 expect(applied).toBe(true);644 expect(vault.isUnlocked()).toBe(true);645 });646 });647648 649 650 651 652 describe("sync initiator authentication", () => {653 654 655 656 657 async function simulateResponderHandshake(opts: {658 initiatorKeypair: { secretKey: Uint8Array; publicKey: Uint8Array };659 pairedX25519Hex: string[];660 }): Promise<{ status: "completed" | "rejected"; vaultBlobSent: boolean }> {661 const { generateKeyPair, sharedKey } = await import("@stablelib/x25519");662 const { HMAC } = await import("@stablelib/hmac");663 const { SHA256 } = await import("@stablelib/sha256");664665 666 667 668 const responderKp = generateKeyPair();669 const b64 = (bytes: Uint8Array) => {670 let s = "";671 for (const b of bytes) s += String.fromCharCode(b);672 return btoa(s);673 };674 storage.peach_sync_identity = JSON.stringify({675 priv: b64(responderKp.secretKey),676 pub: b64(responderKp.publicKey),677 });678679 680 storage.peach_paired_devices = pairedX25519Hex(opts.pairedX25519Hex);681682 683 684 const sentFrames: string[] = [];685 (globalThis as unknown as { Capacitor: unknown }).Capacitor = {686 isNativePlatform: () => true,687 Plugins: {688 SyncPlugin: {689 addListener: () => {},690 sendToConnection: async ({ data }: { connectionId: string; data: string }) => {691 sentFrames.push(data);692 },693 },694 },695 };696697 698 vi.resetModules();699 const messages = await import("./messages");700 messages.setupMessageHandler();701 const vault = await import("./vault");702 await vault.ensureSettingsLoaded();703 await vault.createVault("test-pass-sync");704705 706 const sessionPromise = dispatch({707 type: "SYNC_SESSION_STARTED",708 payload: { connectionId: "test-conn" },709 });710711 712 const waitForFrame = async (): Promise<string> => {713 for (let i = 0; i < 200; i++) {714 if (sentFrames.length > 0) return sentFrames.shift()!;715 await new Promise(r => setTimeout(r, 5));716 }717 throw new Error("timeout waiting for responder frame");718 };719 const challengeB64 = await waitForFrame();720 const challengeBytes = base64ToU8(challengeB64);721 const challenge = JSON.parse(new TextDecoder().decode(challengeBytes)) as {722 type: string;723 nonce_hex: string;724 responder_public_key_hex: string;725 };726 if (challenge.type !== "CHALLENGE") throw new Error("expected CHALLENGE");727728 729 730 const responderPub = hexToBytes(challenge.responder_public_key_hex);731 const dh = sharedKey(opts.initiatorKeypair.secretKey, responderPub);732 const mac = new HMAC(SHA256, dh);733 mac.update(new TextEncoder().encode("peach-sync-v1-initiator-auth"));734 mac.update(opts.initiatorKeypair.publicKey);735 mac.update(responderPub);736 mac.update(hexToBytes(challenge.nonce_hex));737 const proof = mac.digest();738739 const proofFrame = JSON.stringify({740 type: "PROOF",741 initiator_public_key_hex: bytesToHexStr(opts.initiatorKeypair.publicKey),742 proof_hex: bytesToHexStr(proof),743 });744 745 const { deliverAndroidSyncFrame } = messages;746 deliverAndroidSyncFrame("test-conn", u8ToBase64(new TextEncoder().encode(proofFrame)));747748 749 750 751 const result = await Promise.race([752 sessionPromise.then(r => ({ kind: "resolved" as const, response: r })),753 new Promise<{ kind: "blob-stage" }>(resolve =>754 setTimeout(() => resolve({ kind: "blob-stage" }), 200)755 ),756 ]);757758 if (result.kind === "resolved") {759 const resp = result.response;760 if (resp.ok === false) {761 return { status: "rejected", vaultBlobSent: false };762 }763 764 765 return { status: "completed", vaultBlobSent: sentFrames.length > 0 };766 }767 768 return { status: "completed", vaultBlobSent: sentFrames.length > 0 };769 }770771 function pairedX25519Hex(keys: string[]): Array<Record<string, unknown>> {772 return keys.map((k, i) => ({773 deviceId: k,774 displayName: `Dev${i}`,775 x25519PublicKey: k,776 pairedAt: 1_700_000_000_000,777 lastSeenAt: 1_700_000_000_000,778 }));779 }780781 function hexToBytes(s: string): Uint8Array {782 const out = new Uint8Array(s.length / 2);783 for (let i = 0; i < s.length; i += 2) out[i / 2] = parseInt(s.slice(i, i + 2), 16);784 return out;785 }786 function bytesToHexStr(b: Uint8Array): string {787 return Array.from(b).map(x => x.toString(16).padStart(2, "0")).join("");788 }789 function base64ToU8(b64: string): Uint8Array {790 const bin = atob(b64);791 const out = new Uint8Array(bin.length);792 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);793 return out;794 }795 function u8ToBase64(u: Uint8Array): string {796 let s = "";797 for (const b of u) s += String.fromCharCode(b);798 return btoa(s);799 }800801 it("unpaired initiator is rejected — vault blob never sent", async () => {802 const { generateKeyPair } = await import("@stablelib/x25519");803 const initKp = generateKeyPair();804 const result = await simulateResponderHandshake({805 initiatorKeypair: initKp,806 pairedX25519Hex: [],807 });808 expect(result.status).toBe("rejected");809 expect(result.vaultBlobSent).toBe(false);810 });811812 it("paired initiator passes the auth gate", async () => {813 const { generateKeyPair } = await import("@stablelib/x25519");814 const initKp = generateKeyPair();815 const initHex = Array.from(initKp.publicKey)816 .map(x => x.toString(16).padStart(2, "0"))817 .join("");818 const result = await simulateResponderHandshake({819 initiatorKeypair: initKp,820 pairedX25519Hex: [initHex],821 });822 expect(result.status).toBe("completed");823 });824825 it("forged public key in PROOF frame is rejected (proof MAC fails)", async () => {826 const { generateKeyPair } = await import("@stablelib/x25519");827 const realKp = generateKeyPair();828 const realHex = Array.from(realKp.publicKey)829 .map(x => x.toString(16).padStart(2, "0"))830 .join("");831 832 833 834 835 const forgedKp = generateKeyPair();836 const result = await simulateResponderHandshake({837 initiatorKeypair: forgedKp,838 pairedX25519Hex: [realHex], 839 });840 expect(result.status).toBe("rejected");841 expect(result.vaultBlobSent).toBe(false);842 });843 });844});