// T-006: backup/import NFC raw-fallback decrypt (LV-1 / PCH-01-001 sibling) // // Why this test exists: backup.ts:96–111 and import.ts:96–111 both contain // the LV-1 "raw retry" pattern: if NFC-normalized decryption fails, retry // with the un-normalized password bytes. This fallback ensures users with // non-NFC-normalized passwords (common on some input methods) can still // restore old backups created before the LV-1 NFC-normalize fix. // // This decrypt path was never directly exercised — the gap was flagged in the // coverage analysis §4 as a data-loss risk on the disaster path (backup.ts // specifically is the canonical PCH-01-001 pattern source). import { describe, expect, it } from "vitest"; import { exportEntriesToPeachJson, importEntriesFromPeachJson, derivePeachExportKey, } from "./backup"; import { encrypt } from "../background/aes"; import type { VaultEntry } from "./types"; // Use explicit Unicode escapes so the byte sequences are unambiguous regardless // of how the source file is saved or read. String literals with composed/decomposed // characters may be silently normalized by editors or the JS runtime. // NFD: c, a, f + e (U+0065) + combining acute accent (U+0301) = two code points for é const NFD_PASSWORD = "café"; // NFC: c, a, f + precomposed é (U+00E9) = one code point const NFC_PASSWORD = "café"; // Sanity-check the assumptions at module load time. if ((NFD_PASSWORD as string) === NFC_PASSWORD) { throw new Error("Test assumption broken: NFD and NFC passwords are identical on this platform"); } if (NFD_PASSWORD.normalize("NFC") !== NFC_PASSWORD) { throw new Error("Test assumption broken: NFD normalize(NFC) !== NFC"); } function sampleEntries(): VaultEntry[] { return [ { id: "test-entry-1", type: "login", title: "Test Site", username: "user@example.com", password: "correct-horse-battery", url: "https://example.com", createdAt: 1700000000000, modifiedAt: 1700000000000, }, ]; } describe("T-006: backup/import NFC raw-fallback decrypt", () => { // T-006a: normal path — export sealed with NFC password → import with NFC password. // This is the standard post-LV-1 happy path. it("T-006a: export sealed with NFC password → import with NFC password succeeds", async () => { const envelope = await exportEntriesToPeachJson(sampleEntries(), NFC_PASSWORD); const result = await importEntriesFromPeachJson(JSON.stringify(envelope), NFC_PASSWORD); expect(result.entries).toHaveLength(1); expect(result.entries[0].title).toBe("Test Site"); }); // T-006b: NFD-sealed export opened with NFD password — the raw fallback path. // This simulates an old backup created before LV-1, where the password was // stored/used in NFD form (e.g. macOS dead-key typed "café" as e + combining accent). // The import must succeed via the raw fallback: // 1. NFC-normalize NFD_PASSWORD → NFC_PASSWORD → derive key → decrypt fails (sealed with NFD) // 2. Fallback: use NFD_PASSWORD raw → derive key → decrypt succeeds it("T-006b: export sealed with NFD password (raw) → import with NFD password succeeds via fallback", async () => { // Build the envelope manually using the raw (un-normalized) NFD password. const salt = crypto.getRandomValues(new Uint8Array(16)); const keyBytes = await derivePeachExportKey(NFD_PASSWORD, salt, { raw: true }); const inner = { entries: sampleEntries() }; const { nonce, ciphertext, tag } = await encrypt(keyBytes, JSON.stringify(inner)); function toHex(buf: Uint8Array): string { return Array.from(buf).map(b => b.toString(16).padStart(2, "0")).join(""); } const envelope = JSON.stringify({ version: 2, type: "peach-export", kdf: "PBKDF2-SHA256", salt: toHex(salt), nonce, ciphertext, tag, }); // Importing with the NFD password triggers: first NFC-normalize attempt fails // (sealed with raw NFD), then the raw fallback succeeds. const result = await importEntriesFromPeachJson(envelope, NFD_PASSWORD); expect(result.entries).toHaveLength(1); expect(result.entries[0].title).toBe("Test Site"); }); // T-006c: NFC-sealed export opened with NFD password. // Passing an NFD password to the importer: the first attempt NFC-normalizes it // (producing NFC), which matches the sealed key → success without needing fallback. it("T-006c: export sealed with NFC password → import with NFD password succeeds", async () => { const envelope = await exportEntriesToPeachJson(sampleEntries(), NFC_PASSWORD); // The importer NFC-normalizes the passphrase on the first attempt, // so NFD_PASSWORD.normalize("NFC") === NFC_PASSWORD → succeeds directly. const result = await importEntriesFromPeachJson(JSON.stringify(envelope), NFD_PASSWORD); expect(result.entries).toHaveLength(1); expect(result.entries[0].title).toBe("Test Site"); }); // T-006d: completely wrong password → both NFC and raw attempts fail. // The fallback must not open the vault with a wrong password. it("T-006d: completely wrong password → fails (neither NFC nor raw match)", async () => { const envelope = await exportEntriesToPeachJson(sampleEntries(), NFC_PASSWORD); await expect( importEntriesFromPeachJson(JSON.stringify(envelope), "wrong-password-entirely"), ).rejects.toThrow(/INVALID_EXPORT_PASSWORD/); }); // T-006e: explicit NFD vs NFC byte sequences produce the same decryption result. // U+0065 U+0301 (NFD "é") vs U+00E9 (NFC "é") must both open a NFC-sealed vault. // This is the canonical PCH-01-001 / LV-1 guarantee. it("T-006e: NFD and NFC forms of the same password both open the same vault", async () => { const envelope = await exportEntriesToPeachJson(sampleEntries(), NFC_PASSWORD); const envelopeStr = JSON.stringify(envelope); // Both the NFC and NFD forms of the password must open the same vault. const resultNfc = await importEntriesFromPeachJson(envelopeStr, NFC_PASSWORD); const resultNfd = await importEntriesFromPeachJson(envelopeStr, NFD_PASSWORD); expect(resultNfc.entries[0].title).toBe("Test Site"); expect(resultNfd.entries[0].title).toBe("Test Site"); expect(resultNfc.entries[0].password).toBe(resultNfd.entries[0].password); }); });