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 192 193 194 describe("AUD-006: timestamp skew budget", () => {195 it("rejects remote timestamp beyond 5-minute skew budget", async () => {196 const vault = await setupBlobs();197 const farFuture = Date.now() + 6 * 60 * 1_000;198 const applied = await vault.applyIncomingVault(199 makeIncomingBlob(),200 farFuture201 );202 expect(applied).toBe(false);203 });204205 it("rejects Number.MAX_SAFE_INTEGER timestamp", async () => {206 const vault = await setupBlobs();207 const applied = await vault.applyIncomingVault(208 makeIncomingBlob(),209 Number.MAX_SAFE_INTEGER210 );211 expect(applied).toBe(false);212 });213214 it("rejects Date.now() * 100 (previously accepted)", async () => {215 const vault = await setupBlobs();216 const applied = await vault.applyIncomingVault(217 makeIncomingBlob(),218 Date.now() * 100219 );220 expect(applied).toBe(false);221 });222223 it("accepts remote timestamp within 5-minute skew budget", async () => {224 const vault = await setupBlobs();225 const slightlyFuture = Date.now() + 4 * 60 * 1_000;226 const applied = await vault.applyIncomingVault(227 makeIncomingBlob(),228 slightlyFuture229 );230 expect(applied).toBe(true);231 });232233 it("accepts legitimately newer remote timestamp", async () => {234 const vault = await setupBlobs();235 const applied = await vault.applyIncomingVault(236 makeIncomingBlob(),237 1_700_000_000_000 + 10_000238 );239 expect(applied).toBe(true);240 });241 });242 });243244 245246 describe("applyIncomingVault malformed blob handling", () => {247 const malformedBlobs = [248 "",249 "not json",250 "{",251 "[]",252 "null",253 "true",254 "42",255 '{"a": null}',256 '{"a": "not-an-object"}',257 ];258 for (const blob of malformedBlobs) {259 it(`malformed blob ${JSON.stringify(blob).slice(0, 30)} does not poison local vault`, async () => {260 const vault = await setupBlobs();261 const before = storage.peach_vault_a;262 let threw = false;263 try {264 await vault.applyIncomingVault(blob, Date.now() + 100_000);265 } catch {266 threw = true;267 }268 269 270 const reloaded = storage.peach_vault_a;271 if (!threw) {272 273 274 275 276 expect(reloaded).not.toBe(undefined);277 } else {278 expect(reloaded).toBe(before);279 }280 });281 }282 });283284 285286 describe("applyIncomingVault license handling", () => {287 it("existing license is not overwritten by incoming license", async () => {288 const vault = await setupBlobs();289 storage.peach_license = "my-existing-license";290 await vault.applyIncomingVault(291 makeIncomingBlob(),292 Date.now() + 100_000,293 "their-different-license"294 );295 expect(storage.peach_license).toBe("my-existing-license");296 });297298 it("no local license: invalid incoming license is rejected (L16/SEC-010)", async () => {299 300 301 302 303 const vault = await setupBlobs();304 delete storage.peach_license;305 await vault.applyIncomingVault(306 makeIncomingBlob(),307 Date.now() + 100_000,308 "incoming-license"309 );310 expect(storage.peach_license).toBeUndefined();311 });312313 it("no incoming license: local license is unchanged", async () => {314 const vault = await setupBlobs();315 storage.peach_license = "local-pro";316 await vault.applyIncomingVault(317 makeIncomingBlob(),318 Date.now() + 100_000319 );320 expect(storage.peach_license).toBe("local-pro");321 });322323 it("empty-string incoming license is treated as no license", async () => {324 const vault = await setupBlobs();325 delete storage.peach_license;326 await vault.applyIncomingVault(327 makeIncomingBlob(),328 Date.now() + 100_000,329 ""330 );331 332 expect(storage.peach_license).toBeUndefined();333 });334 });335336 337338 describe("applyIncomingVault snapshot", () => {339 it("a snapshot is taken before applying a newer incoming vault", async () => {340 const vault = await setupBlobs();341 const historyBefore = await vault.getVaultHistory();342 await vault.applyIncomingVault(343 makeIncomingBlob(),344 Date.now() + 100_000345 );346 const historyAfter = await vault.getVaultHistory();347 expect(historyAfter.length).toBeGreaterThanOrEqual(historyBefore.length);348 });349 });350351 352353 describe("pairing token lifecycle", () => {354 it("START_PAIRING_SERVER yields a 6-digit token", async () => {355 const r = await dispatch({ type: "START_PAIRING_SERVER" });356 expect(r.ok).toBe(true);357 const data = (r as { data: { token: string; expiresAt: number } }).data;358 expect(data.token).toMatch(/^\d{6}$/);359 expect(data.expiresAt).toBeGreaterThan(Date.now());360 });361362 it("STOP_PAIRING_SERVER clears the token", async () => {363 await dispatch({ type: "START_PAIRING_SERVER" });364 const r = await dispatch({ type: "STOP_PAIRING_SERVER" });365 expect(r.ok).toBe(true);366 });367368 it("SCAN_PAIRING_QR returns android_only off mobile", async () => {369 const r = await dispatch({370 type: "SCAN_PAIRING_QR",371 payload: { token: "123456" },372 });373 expect(r.ok).toBe(false);374 expect(r.error).toBe("android_only");375 });376377 it("SCAN_PAIRING_QR with garbage token still returns android_only off mobile", async () => {378 const r = await dispatch({379 type: "SCAN_PAIRING_QR",380 payload: { token: "not-a-token" as unknown as string },381 });382 expect(r.ok).toBe(false);383 });384385 it("ACCEPT_PAIRING always returns ok (free tier)", async () => {386 const r = await dispatch({ type: "ACCEPT_PAIRING", payload: {} });387 expect(r.ok).toBe(true);388 });389390 for (let i = 0; i < 10; i++) {391 it(`generated token #${i} is a fresh 6-digit code`, async () => {392 const r = await dispatch({ type: "START_PAIRING_SERVER" });393 const data = (r as { data: { token: string } }).data;394 expect(data.token).toMatch(/^\d{6}$/);395 });396 }397 });398399 400401 describe("RESPOND_INCOMING_PAIRING adversarial", () => {402 it("no pending request: returns no_pending_request", async () => {403 const r = await dispatch({404 type: "RESPOND_INCOMING_PAIRING",405 payload: { accept: true },406 });407 expect(r.ok).toBe(false);408 expect(r.error).toBe("no_pending_request");409 });410411 it("expired request: getIncomingPairingRequest returns null", async () => {412 await dispatch({413 type: "DEBUG_INJECT_INCOMING_PAIRING",414 payload: {415 deviceName: "fake",416 publicKeyHex: "00".repeat(32),417 },418 });419 420 const sessionRaw = sessionStorage["peach_incoming_pair"] as421 | Record<string, unknown>422 | undefined;423 if (sessionRaw && typeof sessionRaw === "object") {424 (sessionRaw as Record<string, unknown>).expiresAt = Date.now() - 1000;425 }426 const r = await dispatch({427 type: "RESPOND_INCOMING_PAIRING",428 payload: { accept: true },429 });430 431 432 expect(r.ok).toBe(false);433 });434435 it("missing payload returns no_pending or error", async () => {436 const r = await dispatch({437 type: "RESPOND_INCOMING_PAIRING",438 payload: {} as { accept: boolean },439 });440 expect(r).toBeDefined();441 });442 });443444 445446 describe("DEBUG_INJECT_INCOMING_PAIRING adversarial", () => {447 it("missing fields returns missing_payload", async () => {448 const r = await dispatch({449 type: "DEBUG_INJECT_INCOMING_PAIRING",450 payload: {},451 });452 expect(r.ok).toBe(false);453 expect(r.error).toBe("missing_payload");454 });455456 const partials = [457 { deviceName: "x" },458 { publicKeyHex: "y" },459 { deviceName: "", publicKeyHex: "" },460 ];461 for (const [i, p] of partials.entries()) {462 it(`partial payload #${i} returns missing_payload`, async () => {463 const r = await dispatch({464 type: "DEBUG_INJECT_INCOMING_PAIRING",465 payload: p,466 });467 expect(r.ok).toBe(false);468 expect(r.error).toBe("missing_payload");469 });470 }471472 it("valid payload returns ok", async () => {473 const r = await dispatch({474 type: "DEBUG_INJECT_INCOMING_PAIRING",475 payload: {476 deviceName: "test-device",477 publicKeyHex: "ab".repeat(32),478 },479 });480 expect(r.ok).toBe(true);481 });482 });483484 485486 describe("GET_SYNC_STATUS shape", () => {487 it("never synced: status=never", async () => {488 delete storage.peach_last_sync_at;489 const r = await dispatch({ type: "GET_SYNC_STATUS" });490 const data = (r as { data: { status: string } }).data;491 expect(data.status).toBe("never");492 });493494 it("with last_sync_at: status=up_to_date", async () => {495 storage.peach_last_sync_at = String(Date.now());496 const r = await dispatch({ type: "GET_SYNC_STATUS" });497 const data = (r as { data: { status: string } }).data;498 expect(data.status).toBe("up_to_date");499 });500501 it("garbage last_sync_at: status=never", async () => {502 storage.peach_last_sync_at = "not-a-number";503 const r = await dispatch({ type: "GET_SYNC_STATUS" });504 const data = (r as { data: { status: string } }).data;505 expect(data.status).toBe("never");506 });507 });508509 510511 describe("REMOVE_PAIRED_DEVICE adversarial", () => {512 const badIds = ["", " ", "not-a-real-id", "\x00", "a".repeat(1000)];513 for (const deviceId of badIds) {514 it(`unknown deviceId ${JSON.stringify(deviceId.slice(0, 20))} succeeds (idempotent)`, async () => {515 const r = await dispatch({516 type: "REMOVE_PAIRED_DEVICE",517 payload: { deviceId },518 });519 520 expect(r.ok).toBe(true);521 });522 }523 });524525 526527 describe("timestamp boundary sweep", () => {528 529 530 for (const baseTs of [0, 1, 1_000, 1_700_000_000_000]) {531 it(`base ts ${baseTs}: applies when remote is strictly newer`, async () => {532 const vault = await import("./vault");533 await vault.createVault("test-pass-12345");534 storage.peach_last_modified_at = String(baseTs);535 const applied = await vault.applyIncomingVault(536 makeIncomingBlob(),537 baseTs + 1538 );539 expect(applied).toBe(true);540 });541542 it(`base ts ${baseTs}: rejects when remote is equal`, async () => {543 const vault = await import("./vault");544 await vault.createVault("test-pass-12345");545 storage.peach_last_modified_at = String(baseTs);546 const applied = await vault.applyIncomingVault(547 makeIncomingBlob(),548 baseTs549 );550 expect(applied).toBe(false);551 });552553 it(`base ts ${baseTs}: rejects when remote is strictly older`, async () => {554 const vault = await import("./vault");555 await vault.createVault("test-pass-12345");556 storage.peach_last_modified_at = String(baseTs);557 const applied = await vault.applyIncomingVault(558 makeIncomingBlob(),559 baseTs - 1560 );561 expect(applied).toBe(false);562 });563 }564 });565566 567568 describe("token format adversarial", () => {569 const badFormats = [570 "",571 "12345",572 "1234567",573 "abcdef",574 "12 34 56",575 "<script>",576 "null",577 "undefined",578 "-12345",579 "1.23456",580 "🔢🔢🔢🔢🔢🔢",581 ];582 for (const token of badFormats) {583 it(`SCAN_PAIRING_QR with token ${JSON.stringify(token)} returns android_only`, async () => {584 const r = await dispatch({585 type: "SCAN_PAIRING_QR",586 payload: { token },587 });588 expect(r.ok).toBe(false);589 });590 }591 });592593 594595 describe("runBlobSyncSession safety", () => {596 it("locked vault returns skipped status", async () => {597 const { runBlobSyncSession } = await import("./sync-coordinator");598 599 const result = await runBlobSyncSession(600 "initiator",601 {602 send: vi.fn(async () => {}),603 receive: vi.fn(async () => new Uint8Array()),604 close: vi.fn(async () => {}),605 } as unknown as Parameters<typeof runBlobSyncSession>[1],606 {607 getVaultBlobForSync: async () => null,608 applyIncomingVault: async () => false,609 isVaultUnlocked: () => false,610 }611 );612 expect(result.status).toBe("skipped");613 expect(result.applied).toBe(false);614 });615 });616617 618 619 620 describe("H3/COR-005 sync re-decrypt failure lock", () => {621 async function buildRemoteBlobWithDifferentKey(): Promise<string> {622 const vault = await import("./vault");623 const slotA = await vault.loadSlotA();624 if (!slotA) throw new Error("no slot A");625 626 627 628 const rand = (n: number) =>629 Array.from(crypto.getRandomValues(new Uint8Array(n)))630 .map(b => b.toString(16).padStart(2, "0"))631 .join("");632 const remoteA = {633 ...slotA,634 salt: rand(16),635 nonce: rand(12),636 ciphertext: rand(64),637 tag: rand(16),638 };639 return JSON.stringify({ a: remoteA, b: storage.peach_vault_b });640 }641642 it("vault locks when reloadEntriesAfterSync fails after apply", async () => {643 const vault = await setupBlobs();644 const messages = await import("./messages");645 const deps = messages.buildSyncDeps();646 const remote = await buildRemoteBlobWithDifferentKey();647 648 const remoteTs = 1_700_000_000_000 + 100_000;649 expect(vault.isUnlocked()).toBe(true);650 const applied = await deps.applyIncomingVault(remote, remoteTs);651 expect(applied).toBe(true);652 expect(vault.isUnlocked()).toBe(false);653 });654655 it("stale in-memory entries cannot clobber remote blob after sync", async () => {656 await setupBlobs();657 const messages = await import("./messages");658 const deps = messages.buildSyncDeps();659 const remote = await buildRemoteBlobWithDifferentKey();660 const remoteTs = 1_700_000_000_000 + 100_000;661 await deps.applyIncomingVault(remote, remoteTs);662 663 const r = await dispatch({664 type: "ADD_ENTRY",665 payload: { title: "Ghost", password: "p" },666 });667 expect(r.ok).toBe(false);668 });669670 it("when reloadEntriesAfterSync succeeds, vault stays unlocked", async () => {671 const vault = await setupBlobs();672 const messages = await import("./messages");673 const deps = messages.buildSyncDeps();674 675 676 const slotA = storage.peach_vault_a;677 const sameKeyBlob = JSON.stringify({ a: slotA, b: storage.peach_vault_b });678 const applied = await deps.applyIncomingVault(679 sameKeyBlob,680 1_700_000_000_000 + 50_000,681 );682 expect(applied).toBe(true);683 expect(vault.isUnlocked()).toBe(true);684 });685 });686687 688 689 690 691 describe("sync initiator authentication", () => {692 693 694 695 696 async function simulateResponderHandshake(opts: {697 initiatorKeypair: { secretKey: Uint8Array; publicKey: Uint8Array };698 pairedX25519Hex: string[];699 }): Promise<{ status: "completed" | "rejected"; vaultBlobSent: boolean }> {700 const { generateKeyPair, sharedKey } = await import("@stablelib/x25519");701 const { HMAC } = await import("@stablelib/hmac");702 const { SHA256 } = await import("@stablelib/sha256");703704 705 706 707 const responderKp = generateKeyPair();708 const b64 = (bytes: Uint8Array) => {709 let s = "";710 for (const b of bytes) s += String.fromCharCode(b);711 return btoa(s);712 };713 storage.peach_sync_identity = JSON.stringify({714 priv: b64(responderKp.secretKey),715 pub: b64(responderKp.publicKey),716 });717718 719 storage.peach_paired_devices = pairedX25519Hex(opts.pairedX25519Hex);720721 722 723 const sentFrames: string[] = [];724 (globalThis as unknown as { Capacitor: unknown }).Capacitor = {725 isNativePlatform: () => true,726 Plugins: {727 SyncPlugin: {728 addListener: () => {},729 sendToConnection: async ({ data }: { connectionId: string; data: string }) => {730 sentFrames.push(data);731 },732 },733 },734 };735736 737 vi.resetModules();738 const messages = await import("./messages");739 messages.setupMessageHandler();740 const vault = await import("./vault");741 await vault.ensureSettingsLoaded();742 await vault.createVault("test-pass-sync");743744 745 const sessionPromise = dispatch({746 type: "SYNC_SESSION_STARTED",747 payload: { connectionId: "test-conn" },748 });749750 751 const waitForFrame = async (): Promise<string> => {752 for (let i = 0; i < 200; i++) {753 if (sentFrames.length > 0) return sentFrames.shift()!;754 await new Promise(r => setTimeout(r, 5));755 }756 throw new Error("timeout waiting for responder frame");757 };758 const challengeB64 = await waitForFrame();759 const challengeBytes = base64ToU8(challengeB64);760 const challenge = JSON.parse(new TextDecoder().decode(challengeBytes)) as {761 type: string;762 nonce_hex: string;763 responder_public_key_hex: string;764 };765 if (challenge.type !== "CHALLENGE") throw new Error("expected CHALLENGE");766767 768 769 const responderPub = hexToBytes(challenge.responder_public_key_hex);770 const dh = sharedKey(opts.initiatorKeypair.secretKey, responderPub);771 const mac = new HMAC(SHA256, dh);772 mac.update(new TextEncoder().encode("peach-sync-v1-initiator-auth"));773 mac.update(opts.initiatorKeypair.publicKey);774 mac.update(responderPub);775 mac.update(hexToBytes(challenge.nonce_hex));776 const proof = mac.digest();777778 const proofFrame = JSON.stringify({779 type: "PROOF",780 initiator_public_key_hex: bytesToHexStr(opts.initiatorKeypair.publicKey),781 proof_hex: bytesToHexStr(proof),782 });783 784 const { deliverAndroidSyncFrame } = messages;785 deliverAndroidSyncFrame("test-conn", u8ToBase64(new TextEncoder().encode(proofFrame)));786787 788 789 790 const result = await Promise.race([791 sessionPromise.then(r => ({ kind: "resolved" as const, response: r })),792 new Promise<{ kind: "blob-stage" }>(resolve =>793 setTimeout(() => resolve({ kind: "blob-stage" }), 200)794 ),795 ]);796797 if (result.kind === "resolved") {798 const resp = result.response;799 if (resp.ok === false) {800 return { status: "rejected", vaultBlobSent: false };801 }802 803 804 return { status: "completed", vaultBlobSent: sentFrames.length > 0 };805 }806 807 return { status: "completed", vaultBlobSent: sentFrames.length > 0 };808 }809810 function pairedX25519Hex(keys: string[]): Array<Record<string, unknown>> {811 return keys.map((k, i) => ({812 deviceId: k,813 displayName: `Dev${i}`,814 x25519PublicKey: k,815 pairedAt: 1_700_000_000_000,816 lastSeenAt: 1_700_000_000_000,817 }));818 }819820 function hexToBytes(s: string): Uint8Array {821 const out = new Uint8Array(s.length / 2);822 for (let i = 0; i < s.length; i += 2) out[i / 2] = parseInt(s.slice(i, i + 2), 16);823 return out;824 }825 function bytesToHexStr(b: Uint8Array): string {826 return Array.from(b).map(x => x.toString(16).padStart(2, "0")).join("");827 }828 function base64ToU8(b64: string): Uint8Array {829 const bin = atob(b64);830 const out = new Uint8Array(bin.length);831 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);832 return out;833 }834 function u8ToBase64(u: Uint8Array): string {835 let s = "";836 for (const b of u) s += String.fromCharCode(b);837 return btoa(s);838 }839840 it("unpaired initiator is rejected — vault blob never sent", async () => {841 const { generateKeyPair } = await import("@stablelib/x25519");842 const initKp = generateKeyPair();843 const result = await simulateResponderHandshake({844 initiatorKeypair: initKp,845 pairedX25519Hex: [],846 });847 expect(result.status).toBe("rejected");848 expect(result.vaultBlobSent).toBe(false);849 });850851 it("paired initiator passes the auth gate", async () => {852 const { generateKeyPair } = await import("@stablelib/x25519");853 const initKp = generateKeyPair();854 const initHex = Array.from(initKp.publicKey)855 .map(x => x.toString(16).padStart(2, "0"))856 .join("");857 const result = await simulateResponderHandshake({858 initiatorKeypair: initKp,859 pairedX25519Hex: [initHex],860 });861 expect(result.status).toBe("completed");862 });863864 it("forged public key in PROOF frame is rejected (proof MAC fails)", async () => {865 const { generateKeyPair } = await import("@stablelib/x25519");866 const realKp = generateKeyPair();867 const realHex = Array.from(realKp.publicKey)868 .map(x => x.toString(16).padStart(2, "0"))869 .join("");870 871 872 873 874 const forgedKp = generateKeyPair();875 const result = await simulateResponderHandshake({876 initiatorKeypair: forgedKp,877 pairedX25519Hex: [realHex], 878 });879 expect(result.status).toBe("rejected");880 expect(result.vaultBlobSent).toBe(false);881 });882 });883});