1234567891011121314151617181920import { beforeAll, describe, expect, it, vi } from "vitest";21222324252627282930function toHex(b: Uint8Array): string {31 return Array.from(b).map(v => v.toString(16).padStart(2, "0")).join("");32}3334async function aesGcmEncrypt(35 key: CryptoKey,36 nonce: Uint8Array<ArrayBuffer>,37 plaintext: string,38 aadTag = "slot_a_v1",39): Promise<{ ciphertext: string; tag: string }> {40 const enc = new TextEncoder();41 const combined = await crypto.subtle.encrypt(42 {43 name: "AES-GCM",44 iv: nonce,45 additionalData: enc.encode(aadTag),46 },47 key,48 enc.encode(plaintext),49 );50 const combined8 = new Uint8Array(combined);51 const ciphertext = combined8.slice(0, -16);52 const tag = combined8.slice(-16);53 54 return { ciphertext: toHex(ciphertext), tag: toHex(tag) };55}565758596061describe("T-018: vault corruption vs wrong-password discrimination", () => {62 let tryDecryptSlotResult: typeof import("./vault")["tryDecryptSlotResult"];6364 65 66 beforeAll(async () => {67 vi.resetModules();68 const chromeMock = {69 storage: {70 local: {71 get: vi.fn(async () => ({})),72 set: vi.fn(async () => {}),73 remove: vi.fn(async () => {}),74 clear: vi.fn(async () => {}),75 },76 session: {77 get: vi.fn(async () => ({})),78 set: vi.fn(async () => {}),79 remove: vi.fn(async () => {}),80 },81 },82 alarms: {83 clear: vi.fn(),84 create: vi.fn(),85 onAlarm: { addListener: vi.fn() },86 },87 idle: {88 setDetectionInterval: vi.fn(),89 onStateChanged: { addListener: vi.fn() },90 },91 } as unknown as typeof chrome;92 (globalThis as Record<string, unknown>).chrome = chromeMock;93 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); }));9495 const vault = await import("./vault");96 tryDecryptSlotResult = vault.tryDecryptSlotResult;97 });9899 100 101 it("T-018a: correct key + valid JSON → status ok", async () => {102 const key = await crypto.subtle.generateKey(103 { name: "AES-GCM", length: 256 },104 true,105 ["encrypt", "decrypt"],106 );107108 const validPayload = JSON.stringify({ entries: [], version: 1 });109 const nonce = crypto.getRandomValues(new Uint8Array(12));110 const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, validPayload);111 const slot = {112 version: 1,113 salt: toHex(crypto.getRandomValues(new Uint8Array(16))),114 nonce: toHex(nonce),115 ciphertext,116 tag,117 } as Parameters<typeof tryDecryptSlotResult>[0];118 const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key));119120 const result = await tryDecryptSlotResult(slot, rawKey, "slot_a_v1");121 expect(result.status).toBe("ok");122 });123124 125 126 it("T-018b: wrong key → status wrong_key", async () => {127 const correctKey = await crypto.subtle.generateKey(128 { name: "AES-GCM", length: 256 },129 true,130 ["encrypt", "decrypt"],131 );132 const wrongKey = await crypto.subtle.generateKey(133 { name: "AES-GCM", length: 256 },134 true,135 ["encrypt", "decrypt"],136 );137138 const nonce = crypto.getRandomValues(new Uint8Array(12));139 const { ciphertext, tag } = await aesGcmEncrypt(correctKey, nonce, JSON.stringify({ entries: [] }));140 const slot = {141 version: 1,142 salt: toHex(crypto.getRandomValues(new Uint8Array(16))),143 nonce: toHex(nonce),144 ciphertext,145 tag,146 } as Parameters<typeof tryDecryptSlotResult>[0];147 const rawWrongKey = new Uint8Array(await crypto.subtle.exportKey("raw", wrongKey));148149 const result = await tryDecryptSlotResult(slot, rawWrongKey, "slot_a_v1");150 expect(result.status).toBe("wrong_key");151 });152153 154 155 156 157 158 159 it("T-018c: correct key + non-JSON plaintext → status corrupted, reason invalid_payload_json", async () => {160 const key = await crypto.subtle.generateKey(161 { name: "AES-GCM", length: 256 },162 true,163 ["encrypt", "decrypt"],164 );165 const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key));166167 const nonce = crypto.getRandomValues(new Uint8Array(12));168 const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, "THIS IS NOT JSON {{{");169 const slot = {170 version: 1,171 salt: toHex(crypto.getRandomValues(new Uint8Array(16))),172 nonce: toHex(nonce),173 ciphertext,174 tag,175 } as Parameters<typeof tryDecryptSlotResult>[0];176177 const result = await tryDecryptSlotResult(slot, rawKey, "slot_a_v1");178 expect(result.status).toBe("corrupted");179 if (result.status === "corrupted") {180 expect(result.reason).toBe("invalid_payload_json");181 }182 });183184 185 186 it("T-018d: correct key + valid JSON but non-array entries → status corrupted, reason invalid_payload_shape", async () => {187 const key = await crypto.subtle.generateKey(188 { name: "AES-GCM", length: 256 },189 true,190 ["encrypt", "decrypt"],191 );192 const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key));193194 const nonce = crypto.getRandomValues(new Uint8Array(12));195 const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, JSON.stringify({ entries: "NOT_AN_ARRAY" }));196 const slot = {197 version: 1,198 salt: toHex(crypto.getRandomValues(new Uint8Array(16))),199 nonce: toHex(nonce),200 ciphertext,201 tag,202 } as Parameters<typeof tryDecryptSlotResult>[0];203204 const result = await tryDecryptSlotResult(slot, rawKey, "slot_a_v1");205 expect(result.status).toBe("corrupted");206 if (result.status === "corrupted") {207 expect(result.reason).toBe("invalid_payload_shape");208 }209 });210211 212 213 214 215 216 217 it("T-018e: StoredVault missing required field → status corrupted, reason invalid_slot_schema", async () => {218 const badSlot = {219 version: 1,220 salt: "aabbccdd",221 nonce: "aabbccdd",222 ciphertext: "aabbccdd",223 224 } as Parameters<typeof tryDecryptSlotResult>[0];225226 const key = new Uint8Array(32).fill(0x42);227 const result = await tryDecryptSlotResult(badSlot, key, "slot_a_v1");228 expect(result.status).toBe("corrupted");229 if (result.status === "corrupted") {230 expect(result.reason).toBe("invalid_slot_schema");231 }232 });233});