// T-018: Vault corruption vs wrong-password discrimination (M7/COR-007) // // Why this test exists: `unlock()` in vault.ts distinguishes two failure modes: // - "wrong_key": AES-GCM tag verification failed — wrong password, or // ciphertext was truncated // - "corrupted": AES-GCM tag passed (correct key), but the decrypted JSON // failed schema validation — the vault data was corrupted post-encryption // // Both paths return `false` from unlock(), but the corrupted path sets // `lastUnlockFailureReason` (consumed by `consumeLastUnlockFailureReason()`). // Messages.ts uses this to surface "vault_corrupted" instead of "wrong_password" // so the popup can offer the history-restore flow (M7). // // No existing test exercises `tryDecryptSlotResult` with corrupted data or // verifies that `consumeLastUnlockFailureReason` is non-null after a corrupted // unlock. A regression that collapses both paths into "wrong_key" would cause // the popup to tell a user with a corrupted vault that they "typed the wrong // password" — a confusing UX and a blocker for recovery. import { beforeAll, describe, expect, it, vi } from "vitest"; // --------------------------------------------------------------------------- // Helpers: build StoredVault objects for specific scenarios // --------------------------------------------------------------------------- // We can't import deriveKey+encrypt from vault.ts (they are not exported). // Instead, build StoredVaults by hand using the same AES-256-GCM schema so // we can control exactly what decrypts to what. function toHex(b: Uint8Array): string { return Array.from(b).map(v => v.toString(16).padStart(2, "0")).join(""); } async function aesGcmEncrypt( key: CryptoKey, nonce: Uint8Array, plaintext: string, aadTag = "slot_a_v1", ): Promise<{ ciphertext: string; tag: string }> { const enc = new TextEncoder(); const combined = await crypto.subtle.encrypt( { name: "AES-GCM", iv: nonce, additionalData: enc.encode(aadTag), }, key, enc.encode(plaintext), ); const combined8 = new Uint8Array(combined); const ciphertext = combined8.slice(0, -16); const tag = combined8.slice(-16); // StoredVault fields are hex-encoded (aes.ts uses hex throughout) return { ciphertext: toHex(ciphertext), tag: toHex(tag) }; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("T-018: vault corruption vs wrong-password discrimination", () => { let tryDecryptSlotResult: typeof import("./vault")["tryDecryptSlotResult"]; // vault.ts imports alarms.ts which calls chrome.alarms at module load time. // Set up a minimal chrome stub before the dynamic import so the module loads. beforeAll(async () => { vi.resetModules(); const chromeMock = { storage: { local: { get: vi.fn(async () => ({})), set: vi.fn(async () => {}), remove: vi.fn(async () => {}), clear: vi.fn(async () => {}), }, session: { get: vi.fn(async () => ({})), set: vi.fn(async () => {}), remove: vi.fn(async () => {}), }, }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() }, }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() }, }, } as unknown as typeof chrome; (globalThis as Record).chrome = chromeMock; vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); })); const vault = await import("./vault"); tryDecryptSlotResult = vault.tryDecryptSlotResult; }); // T-018a: correct key + valid JSON schema → status "ok". // Baseline: a well-formed vault slot decrypts correctly. it("T-018a: correct key + valid JSON → status ok", async () => { const key = await crypto.subtle.generateKey( { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"], ); const validPayload = JSON.stringify({ entries: [], version: 1 }); const nonce = crypto.getRandomValues(new Uint8Array(12)); const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, validPayload); const slot = { version: 1, salt: toHex(crypto.getRandomValues(new Uint8Array(16))), nonce: toHex(nonce), ciphertext, tag, } as Parameters[0]; const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key)); const result = await tryDecryptSlotResult(slot, rawKey, "slot_a_v1"); expect(result.status).toBe("ok"); }); // T-018b: wrong key → status "wrong_key". // AES-GCM tag verification fails — this is the standard "wrong password" path. it("T-018b: wrong key → status wrong_key", async () => { const correctKey = await crypto.subtle.generateKey( { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"], ); const wrongKey = await crypto.subtle.generateKey( { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"], ); const nonce = crypto.getRandomValues(new Uint8Array(12)); const { ciphertext, tag } = await aesGcmEncrypt(correctKey, nonce, JSON.stringify({ entries: [] })); const slot = { version: 1, salt: toHex(crypto.getRandomValues(new Uint8Array(16))), nonce: toHex(nonce), ciphertext, tag, } as Parameters[0]; const rawWrongKey = new Uint8Array(await crypto.subtle.exportKey("raw", wrongKey)); const result = await tryDecryptSlotResult(slot, rawWrongKey, "slot_a_v1"); expect(result.status).toBe("wrong_key"); }); // T-018c: correct key + valid AES-GCM tag but JSON does not parse → "corrupted". // This simulates post-encryption corruption where the auth tag is intact but // the plaintext is garbled. // // Bug this catches: collapsing the JSON-parse error into "wrong_key" would // make the popup show "wrong password" to a user with a corrupted vault. it("T-018c: correct key + non-JSON plaintext → status corrupted, reason invalid_payload_json", async () => { const key = await crypto.subtle.generateKey( { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"], ); const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key)); const nonce = crypto.getRandomValues(new Uint8Array(12)); const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, "THIS IS NOT JSON {{{"); const slot = { version: 1, salt: toHex(crypto.getRandomValues(new Uint8Array(16))), nonce: toHex(nonce), ciphertext, tag, } as Parameters[0]; const result = await tryDecryptSlotResult(slot, rawKey, "slot_a_v1"); expect(result.status).toBe("corrupted"); if (result.status === "corrupted") { expect(result.reason).toBe("invalid_payload_json"); } }); // T-018d: correct key + valid JSON but invalid schema (non-array entries field) // → status "corrupted", reason "invalid_payload_shape". it("T-018d: correct key + valid JSON but non-array entries → status corrupted, reason invalid_payload_shape", async () => { const key = await crypto.subtle.generateKey( { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"], ); const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key)); const nonce = crypto.getRandomValues(new Uint8Array(12)); const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, JSON.stringify({ entries: "NOT_AN_ARRAY" })); const slot = { version: 1, salt: toHex(crypto.getRandomValues(new Uint8Array(16))), nonce: toHex(nonce), ciphertext, tag, } as Parameters[0]; const result = await tryDecryptSlotResult(slot, rawKey, "slot_a_v1"); expect(result.status).toBe("corrupted"); if (result.status === "corrupted") { expect(result.reason).toBe("invalid_payload_shape"); } }); // T-018e: StoredVault with missing required field (no `tag` field) → "corrupted" // status from `hasValidVaultSchema`. This fires BEFORE any decryption attempt. // // Bug this catches: a schema check that skips missing-field validation would // pass this slot to AES-GCM decrypt, which would throw — and the error handler // would (incorrectly) report "wrong_key" instead of "corrupted". it("T-018e: StoredVault missing required field → status corrupted, reason invalid_slot_schema", async () => { const badSlot = { version: 1, salt: "aabbccdd", nonce: "aabbccdd", ciphertext: "aabbccdd", // tag: deliberately missing — triggers invalid_slot_schema } as Parameters[0]; const key = new Uint8Array(32).fill(0x42); const result = await tryDecryptSlotResult(badSlot, key, "slot_a_v1"); expect(result.status).toBe("corrupted"); if (result.status === "corrupted") { expect(result.reason).toBe("invalid_slot_schema"); } }); });