backup.nfc-fallback.test.ts

6.2 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 "./backup";19import { encrypt } from "../background/aes";20import type { VaultEntry } from "./types";2122// Use explicit Unicode escapes so the byte sequences are unambiguous regardless23// of how the source file is saved or read. String literals with composed/decomposed24// characters may be silently normalized by editors or the JS runtime.25// NFD: c, a, f + e (U+0065) + combining acute accent (U+0301) = two code points for é26const NFD_PASSWORD = "café";27// NFC: c, a, f + precomposed é (U+00E9) = one code point28const NFC_PASSWORD = "café";2930// Sanity-check the assumptions at module load time.31if ((NFD_PASSWORD as string) === NFC_PASSWORD) {32  throw new Error("Test assumption broken: NFD and NFC passwords are identical on this platform");33}34if (NFD_PASSWORD.normalize("NFC") !== NFC_PASSWORD) {35  throw new Error("Test assumption broken: NFD normalize(NFC) !== NFC");36}3738function sampleEntries(): VaultEntry[] {39  return [40    {41      id: "test-entry-1",42      type: "login",43      title: "Test Site",44      username: "[email protected]",45      password: "correct-horse-battery",46      url: "https://example.com",47      createdAt: 1700000000000,48      modifiedAt: 1700000000000,49    },50  ];51}5253describe("T-006: backup/import NFC raw-fallback decrypt", () => {54  // T-006a: normal path — export sealed with NFC password → import with NFC password.55  // This is the standard post-LV-1 happy path.56  it("T-006a: export sealed with NFC password → import with NFC password succeeds", async () => {57    const envelope = await exportEntriesToPeachJson(sampleEntries(), NFC_PASSWORD);58    const result = await importEntriesFromPeachJson(JSON.stringify(envelope), NFC_PASSWORD);59    expect(result.entries).toHaveLength(1);60    expect(result.entries[0].title).toBe("Test Site");61  });6263  // T-006b: NFD-sealed export opened with NFD password — the raw fallback path.64  // This simulates an old backup created before LV-1, where the password was65  // stored/used in NFD form (e.g. macOS dead-key typed "café" as e + combining accent).66  // The import must succeed via the raw fallback:67  //   1. NFC-normalize NFD_PASSWORD → NFC_PASSWORD → derive key → decrypt fails (sealed with NFD)68  //   2. Fallback: use NFD_PASSWORD raw → derive key → decrypt succeeds69  it("T-006b: export sealed with NFD password (raw) → import with NFD password succeeds via fallback", async () => {70    // Build the envelope manually using the raw (un-normalized) NFD password.71    const salt = crypto.getRandomValues(new Uint8Array(16));72    const keyBytes = await derivePeachExportKey(NFD_PASSWORD, salt, { raw: true });73    const inner = { entries: sampleEntries() };74    const { nonce, ciphertext, tag } = await encrypt(keyBytes, JSON.stringify(inner));7576    function toHex(buf: Uint8Array): string {77      return Array.from(buf).map(b => b.toString(16).padStart(2, "0")).join("");78    }7980    const envelope = JSON.stringify({81      version: 2,82      type: "peach-export",83      kdf: "PBKDF2-SHA256",84      salt: toHex(salt),85      nonce,86      ciphertext,87      tag,88    });8990    // Importing with the NFD password triggers: first NFC-normalize attempt fails91    // (sealed with raw NFD), then the raw fallback succeeds.92    const result = await importEntriesFromPeachJson(envelope, NFD_PASSWORD);93    expect(result.entries).toHaveLength(1);94    expect(result.entries[0].title).toBe("Test Site");95  });9697  // T-006c: NFC-sealed export opened with NFD password.98  // Passing an NFD password to the importer: the first attempt NFC-normalizes it99  // (producing NFC), which matches the sealed key → success without needing fallback.100  it("T-006c: export sealed with NFC password → import with NFD password succeeds", async () => {101    const envelope = await exportEntriesToPeachJson(sampleEntries(), NFC_PASSWORD);102    // The importer NFC-normalizes the passphrase on the first attempt,103    // so NFD_PASSWORD.normalize("NFC") === NFC_PASSWORD → succeeds directly.104    const result = await importEntriesFromPeachJson(JSON.stringify(envelope), NFD_PASSWORD);105    expect(result.entries).toHaveLength(1);106    expect(result.entries[0].title).toBe("Test Site");107  });108109  // T-006d: completely wrong password → both NFC and raw attempts fail.110  // The fallback must not open the vault with a wrong password.111  it("T-006d: completely wrong password → fails (neither NFC nor raw match)", async () => {112    const envelope = await exportEntriesToPeachJson(sampleEntries(), NFC_PASSWORD);113    await expect(114      importEntriesFromPeachJson(JSON.stringify(envelope), "wrong-password-entirely"),115    ).rejects.toThrow(/INVALID_EXPORT_PASSWORD/);116  });117118  // T-006e: explicit NFD vs NFC byte sequences produce the same decryption result.119  // U+0065 U+0301 (NFD "é") vs U+00E9 (NFC "é") must both open a NFC-sealed vault.120  // This is the canonical PCH-01-001 / LV-1 guarantee.121  it("T-006e: NFD and NFC forms of the same password both open the same vault", async () => {122    const envelope = await exportEntriesToPeachJson(sampleEntries(), NFC_PASSWORD);123    const envelopeStr = JSON.stringify(envelope);124125    // Both the NFC and NFD forms of the password must open the same vault.126    const resultNfc = await importEntriesFromPeachJson(envelopeStr, NFC_PASSWORD);127    const resultNfd = await importEntriesFromPeachJson(envelopeStr, NFD_PASSWORD);128129    expect(resultNfc.entries[0].title).toBe("Test Site");130    expect(resultNfd.entries[0].title).toBe("Test Site");131    expect(resultNfc.entries[0].password).toBe(resultNfd.entries[0].password);132  });133});