1234567891011121314151617181920import { beforeAll, describe, expect, it, vi } from "vitest";2122type StorageShape = Record<string, unknown>;23242526272829303132function toHex(b: Uint8Array): string {33 return Array.from(b).map(v => v.toString(16).padStart(2, "0")).join("");34}3536async function aesGcmEncrypt(37 key: CryptoKey,38 nonce: Uint8Array,39 plaintext: string,40 aadTag = "slot_a_v1",41): Promise<{ ciphertext: string; tag: string }> {42 const enc = new TextEncoder();43 const combined = await crypto.subtle.encrypt(44 {45 name: "AES-GCM",46 iv: nonce,47 additionalData: enc.encode(aadTag),48 },49 key,50 enc.encode(plaintext),51 );52 const combined8 = new Uint8Array(combined);53 const ciphertext = combined8.slice(0, -16);54 const tag = combined8.slice(-16);55 56 return { ciphertext: toHex(ciphertext), tag: toHex(tag) };57}585960616263describe("T-018: vault corruption vs wrong-password discrimination", () => {64 let tryDecryptSlotResult: typeof import("./vault")["tryDecryptSlotResult"];6566 67 68 beforeAll(async () => {69 vi.resetModules();70 const chromeMock = {71 storage: {72 local: {73 get: vi.fn(async () => ({})),74 set: vi.fn(async () => {}),75 remove: vi.fn(async () => {}),76 clear: vi.fn(async () => {}),77 },78 session: {79 get: vi.fn(async () => ({})),80 set: vi.fn(async () => {}),81 remove: vi.fn(async () => {}),82 },83 },84 alarms: {85 clear: vi.fn(),86 create: vi.fn(),87 onAlarm: { addListener: vi.fn() },88 },89 idle: {90 setDetectionInterval: vi.fn(),91 onStateChanged: { addListener: vi.fn() },92 },93 } as unknown as typeof chrome;94 (globalThis as Record<string, unknown>).chrome = chromeMock;95 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); }));9697 const vault = await import("./vault");98 tryDecryptSlotResult = vault.tryDecryptSlotResult;99 });100101 102 103 it("T-018a: correct key + valid JSON → status ok", async () => {104 const key = await crypto.subtle.generateKey(105 { name: "AES-GCM", length: 256 },106 true,107 ["encrypt", "decrypt"],108 );109110 const validPayload = JSON.stringify({ entries: [], version: 1 });111 const nonce = crypto.getRandomValues(new Uint8Array(12));112 const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, validPayload);113 const slot = {114 version: 1,115 salt: toHex(crypto.getRandomValues(new Uint8Array(16))),116 nonce: toHex(nonce),117 ciphertext,118 tag,119 } as Parameters<typeof tryDecryptSlotResult>[0];120 const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key));121122 const result = await tryDecryptSlotResult(slot, rawKey, "slot_a_v1");123 expect(result.status).toBe("ok");124 });125126 127 128 it("T-018b: wrong key → status wrong_key", async () => {129 const correctKey = await crypto.subtle.generateKey(130 { name: "AES-GCM", length: 256 },131 true,132 ["encrypt", "decrypt"],133 );134 const wrongKey = await crypto.subtle.generateKey(135 { name: "AES-GCM", length: 256 },136 true,137 ["encrypt", "decrypt"],138 );139140 const nonce = crypto.getRandomValues(new Uint8Array(12));141 const { ciphertext, tag } = await aesGcmEncrypt(correctKey, nonce, JSON.stringify({ entries: [] }));142 const slot = {143 version: 1,144 salt: toHex(crypto.getRandomValues(new Uint8Array(16))),145 nonce: toHex(nonce),146 ciphertext,147 tag,148 } as Parameters<typeof tryDecryptSlotResult>[0];149 const rawWrongKey = new Uint8Array(await crypto.subtle.exportKey("raw", wrongKey));150151 const result = await tryDecryptSlotResult(slot, rawWrongKey, "slot_a_v1");152 expect(result.status).toBe("wrong_key");153 });154155 156 157 158 159 160 161 it("T-018c: correct key + non-JSON plaintext → status corrupted, reason invalid_payload_json", async () => {162 const key = await crypto.subtle.generateKey(163 { name: "AES-GCM", length: 256 },164 true,165 ["encrypt", "decrypt"],166 );167 const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key));168169 const nonce = crypto.getRandomValues(new Uint8Array(12));170 const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, "THIS IS NOT JSON {{{");171 const slot = {172 version: 1,173 salt: toHex(crypto.getRandomValues(new Uint8Array(16))),174 nonce: toHex(nonce),175 ciphertext,176 tag,177 } as Parameters<typeof tryDecryptSlotResult>[0];178179 const result = await tryDecryptSlotResult(slot, rawKey, "slot_a_v1");180 expect(result.status).toBe("corrupted");181 if (result.status === "corrupted") {182 expect(result.reason).toBe("invalid_payload_json");183 }184 });185186 187 188 it("T-018d: correct key + valid JSON but non-array entries → status corrupted, reason invalid_payload_shape", async () => {189 const key = await crypto.subtle.generateKey(190 { name: "AES-GCM", length: 256 },191 true,192 ["encrypt", "decrypt"],193 );194 const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key));195196 const nonce = crypto.getRandomValues(new Uint8Array(12));197 const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, JSON.stringify({ entries: "NOT_AN_ARRAY" }));198 const slot = {199 version: 1,200 salt: toHex(crypto.getRandomValues(new Uint8Array(16))),201 nonce: toHex(nonce),202 ciphertext,203 tag,204 } as Parameters<typeof tryDecryptSlotResult>[0];205206 const result = await tryDecryptSlotResult(slot, rawKey, "slot_a_v1");207 expect(result.status).toBe("corrupted");208 if (result.status === "corrupted") {209 expect(result.reason).toBe("invalid_payload_shape");210 }211 });212213 214 215 216 217 218 219 it("T-018e: StoredVault missing required field → status corrupted, reason invalid_slot_schema", async () => {220 const badSlot = {221 version: 1,222 salt: "aabbccdd",223 nonce: "aabbccdd",224 ciphertext: "aabbccdd",225 226 } as Parameters<typeof tryDecryptSlotResult>[0];227228 const key = new Uint8Array(32).fill(0x42);229 const result = await tryDecryptSlotResult(badSlot, key, "slot_a_v1");230 expect(result.status).toBe("corrupted");231 if (result.status === "corrupted") {232 expect(result.reason).toBe("invalid_slot_schema");233 }234 });235236 237 238 239 240 241 242 243 it.todo(244 "T-018f: consumeLastUnlockFailureReason non-null after corrupted unlock, null after consumed (requires chrome mock + vault setup)",245 );246});