backup.nfc-fallback.test.ts

6.0 KB · raw

1// T-006: backup/import NFC raw-fallback decrypt (LV-1 / PCH-01-001 sibling)2//3// Why this test exists: backup.ts:96–111 and import.ts:96–111 both contain4// the LV-1 "raw retry" pattern: if NFC-normalized decryption fails, retry5// with the un-normalized password bytes. This fallback ensures users with6// non-NFC-normalized passwords (common on some input methods) can still7// restore old backups created before the LV-1 NFC-normalize fix.8//9// This decrypt path was never directly exercised — the gap was flagged in the10// coverage analysis §4 as a data-loss risk on the disaster path (backup.ts11// specifically is the canonical PCH-01-001 pattern source).1213import { describe, expect, it } from "vitest";14import {15  exportEntriesToPeachJson,16  importEntriesFromPeachJson,17  derivePeachExportKey,18} from "../../shared/backup";19import { decrypt, encrypt } from "../../background/aes";20import type { VaultEntry } from "../../shared/types";2122// A password that is NOT in NFC form. U+0065 (e) + U+0301 (combining acute)23// decomposes "é" into two code points. NFC would compose them into U+00E9.24const NFD_PASSWORD = "café"; // NFD: "café" as e + combining acute25const NFC_PASSWORD = "café";        // NFC: "café" as single é character2627// Check that the passwords are genuinely different as byte sequences28if (NFD_PASSWORD === NFC_PASSWORD) {29  throw new Error("Test assumption broken: NFD and NFC passwords are identical on this platform");30}31if (NFD_PASSWORD.normalize("NFC") !== NFC_PASSWORD) {32  throw new Error("Test assumption broken: NFD normalize(NFC) !== NFC");33}3435function sampleEntries(): VaultEntry[] {36  return [37    {38      id: "test-entry-1",39      type: "login",40      title: "Test Site",41      username: "[email protected]",42      password: "correct-horse-battery",43      url: "https://example.com",44      createdAt: 1700000000000,45      modifiedAt: 1700000000000,46    },47  ];48}4950describe("T-006: backup/import NFC raw-fallback decrypt", () => {51  // T-006a: normal path — export sealed with NFC password → import with NFC password.52  // This is the standard post-LV-1 happy path.53  it("T-006a: export sealed with NFC password → import with NFC password succeeds", async () => {54    const envelope = await exportEntriesToPeachJson(sampleEntries(), NFC_PASSWORD);55    const result = await importEntriesFromPeachJson(JSON.stringify(envelope), NFC_PASSWORD);56    expect(result.entries).toHaveLength(1);57    expect(result.entries[0].title).toBe("Test Site");58  });5960  // T-006b: NFD-sealed export opened with NFC password — the raw fallback path.61  // This simulates an old backup created before LV-1, where the password was62  // stored/used in NFD form. The import must succeed via the raw fallback.63  //64  // Implementation note: exportEntriesToPeachJson always NFC-normalizes the65  // passphrase (the LV-1 fix). To create a pre-LV-1 NFD-sealed backup we need66  // to call derivePeachExportKey with { raw: true } directly.67  it("T-006b: export sealed with NFD password (raw) → import with NFC password succeeds via fallback", async () => {68    // Build the envelope manually using the raw (un-normalized) NFD password69    const salt = crypto.getRandomValues(new Uint8Array(16));70    const keyBytes = await derivePeachExportKey(NFD_PASSWORD, salt, { raw: true });71    const inner = { entries: sampleEntries() };72    const { nonce, ciphertext, tag } = await encrypt(keyBytes, JSON.stringify(inner));7374    function toHex(buf: Uint8Array): string {75      return Array.from(buf).map(b => b.toString(16).padStart(2, "0")).join("");76    }7778    const envelope = JSON.stringify({79      version: 2,80      type: "peach-export",81      kdf: "PBKDF2-SHA256",82      salt: toHex(salt),83      nonce,84      ciphertext,85      tag,86    });8788    // Importing with the NFC password should succeed via the raw fallback89    const result = await importEntriesFromPeachJson(envelope, NFC_PASSWORD);90    expect(result.entries).toHaveLength(1);91    expect(result.entries[0].title).toBe("Test Site");92  });9394  // T-006c: NFC-sealed export opened with NFD password.95  // Passing an NFD password to the importer: the first attempt NFC-normalizes it96  // (producing NFC), which matches the sealed key → success without needing fallback.97  it("T-006c: export sealed with NFC password → import with NFD password succeeds", async () => {98    const envelope = await exportEntriesToPeachJson(sampleEntries(), NFC_PASSWORD);99    // The importer NFC-normalizes the passphrase on the first attempt,100    // so NFD_PASSWORD.normalize("NFC") === NFC_PASSWORD → succeeds directly.101    const result = await importEntriesFromPeachJson(JSON.stringify(envelope), NFD_PASSWORD);102    expect(result.entries).toHaveLength(1);103    expect(result.entries[0].title).toBe("Test Site");104  });105106  // T-006d: completely wrong password → both NFC and raw attempts fail.107  // The fallback must not open the vault with a wrong password.108  it("T-006d: completely wrong password → fails (neither NFC nor raw match)", async () => {109    const envelope = await exportEntriesToPeachJson(sampleEntries(), NFC_PASSWORD);110    await expect(111      importEntriesFromPeachJson(JSON.stringify(envelope), "wrong-password-entirely"),112    ).rejects.toThrow(/INVALID_EXPORT_PASSWORD/);113  });114115  // T-006e: explicit NFD vs NFC byte sequences produce the same decryption result.116  // U+0065 U+0301 (NFD "é") vs U+00E9 (NFC "é") must both open a NFC-sealed vault.117  // This is the canonical PCH-01-001 / LV-1 guarantee.118  it("T-006e: NFD 'café' (e + combining acute) and NFC 'café' (single é) open the same vault", async () => {119    const envelope = await exportEntriesToPeachJson(sampleEntries(), NFC_PASSWORD);120    const envelopeStr = JSON.stringify(envelope);121122    // Both the NFC and NFD forms of the password must open the same vault.123    const resultNfc = await importEntriesFromPeachJson(envelopeStr, NFC_PASSWORD);124    const resultNfd = await importEntriesFromPeachJson(envelopeStr, NFD_PASSWORD);125126    expect(resultNfc.entries[0].title).toBe("Test Site");127    expect(resultNfd.entries[0].title).toBe("Test Site");128    expect(resultNfc.entries[0].password).toBe(resultNfd.entries[0].password);129  });130});