// 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 { describe, expect, it } from "vitest"; import { tryDecryptSlotResult, type TryDecryptResult, } from "../../background/vault"; import type { StoredVault } from "../../background/vault"; // --------------------------------------------------------------------------- // 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. async function aesGcmEncrypt( key: CryptoKey, nonce: Uint8Array, plaintext: string, aad?: string, ): Promise<{ ciphertext: string; tag: string }> { const enc = new TextEncoder(); const combined = await crypto.subtle.encrypt( { name: "AES-GCM", iv: nonce, additionalData: aad ? enc.encode(aad) : undefined, }, key, enc.encode(plaintext), ); // Last 16 bytes are the GCM tag; the rest is ciphertext. const combined8 = new Uint8Array(combined); const ciphertext = combined8.slice(0, -16); const tag = combined8.slice(-16); return { ciphertext: btoa(String.fromCharCode(...ciphertext)), tag: btoa(String.fromCharCode(...tag)), }; } function bytesToB64(b: Uint8Array): string { return btoa(String.fromCharCode(...b)); } async function buildValidSlot( key: CryptoKey, json: string, aadTag = "slot_a_v1", ): Promise { const nonce = crypto.getRandomValues(new Uint8Array(12)); const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, json, aadTag); return { version: 1, salt: bytesToB64(crypto.getRandomValues(new Uint8Array(16))), nonce: bytesToB64(nonce), ciphertext, tag, } as unknown as StoredVault; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("T-018: vault corruption vs wrong-password discrimination", () => { // 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 }, false, ["encrypt", "decrypt"], ); const validPayload = JSON.stringify({ entries: [], version: 1, // Minimal fields that satisfy decodePayload's shape check }); const slot = await buildValidSlot(key, validPayload); 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 }, false, ["encrypt", "decrypt"], ); const wrongKey = await crypto.subtle.generateKey( { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"], ); const slot = await buildValidSlot(correctKey, JSON.stringify({ entries: [] })); 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 (e.g., a filesystem bitflip that scrambled the // plaintext BEFORE encryption in an unusual scenario, or a developer bug that // stored non-JSON in the slot). // // 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 slot = await buildValidSlot(key, "THIS IS NOT JSON {{{"); 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". // This covers the COR-007 scenario: the vault data decrypts but the entries // field has been replaced with a non-array (e.g., null, an object, or a number). 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 slot = await buildValidSlot(key, JSON.stringify({ entries: "NOT_AN_ARRAY" })); 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: "dGVzdA==", nonce: "dGVzdA==", ciphertext: "dGVzdA==", // tag: deliberately missing } as unknown as StoredVault; 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"); } }); // T-018f: consumeLastUnlockFailureReason returns non-null after a corrupted // vault unlock attempt and is cleared on the next call. // This is the integration path that messages.ts uses to surface vault_corrupted. // // Bug this catches: forgetting to set `lastUnlockFailureReason` in the // corrupted branch of unlock() means messages.ts always sees null and reports // "wrong_password" to the user even when the vault is structurally corrupted. it.todo( "T-018f: consumeLastUnlockFailureReason non-null after corrupted unlock, null after consumed (requires chrome mock + vault setup)", ); });