import.test.ts

14.5 KB · raw

1import { describe, expect, it } from "vitest";23import {4  base64urlEncode,5  generatePasskeyKeypair,6} from "../background/passkey-crypto";7import {8  exportEntriesToPeachJson,9  importEntriesFromPeachJson,10  type ImportableEntry,11} from "./backup";12import { detectImportFormat, parse1PasswordJSON, parseImportSource } from "./import";1314const SAMPLE_IMPORT_ENTRY: ImportableEntry = {15  title: "GitHub",16  username: "june",17  password: "secret123",18  url: "https://github.com",19  notes: "2fa enabled",20  totp: "otpauth://totp/Peach:GitHub?secret=JBSWY3DPEHPK3PXP&issuer=Peach",21};2223describe("shared import parsing", () => {24  it("parses Bitwarden JSON login exports", async () => {25    const result = await parseImportSource(26      JSON.stringify({27        encrypted: false,28        items: [29          {30            type: 1,31            name: "Bitwarden Login",32            notes: "imported",33            login: {34              username: "[email protected]",35              password: "hunter2",36              uris: [{ uri: "https://vault.bitwarden.com" }],37              totp:38                "otpauth://totp/Bitwarden?secret=JBSWY3DPEHPK3PXP&issuer=Bitwarden",39            },40          },41        ],42      })43    );4445    expect(result.format).toBe("bitwarden-json");46    expect(result.entries).toHaveLength(1);47    expect(result.entries[0]).toMatchObject({48      title: "Bitwarden Login",49      username: "[email protected]",50      password: "hunter2",51      url: "https://vault.bitwarden.com",52      notes: "imported",53      totp: "JBSWY3DPEHPK3PXP",54    });55  });5657  it("parses Proton Pass CSV exports", async () => {58    const result = await parseImportSource(59      [60        "name,url,email,password,note,totp",61        '"Peach","https://peachpasswords.com","[email protected]","pass123","daily driver","otpauth://totp/Peach?secret=JBSWY3DPEHPK3PXP&issuer=Peach"',62      ].join("\n")63    );6465    expect(result.format).toBe("csv");66    expect(result.entries).toHaveLength(1);67    expect(result.entries[0]).toMatchObject({68      title: "Peach",69      username: "[email protected]",70      password: "pass123",71      url: "https://peachpasswords.com",72      notes: "daily driver",73      totp: "JBSWY3DPEHPK3PXP",74    });75    // PCH-01-019: a default-param URI carries no sibling fields (stays legacy).76    expect(result.entries[0].totpAlgorithm).toBeUndefined();77    expect(result.entries[0].totpDigits).toBeUndefined();78    expect(result.entries[0].totpPeriod).toBeUndefined();79  });8081  it("preserves non-default TOTP params from an otpauth URI (PCH-01-019)", async () => {82    const result = await parseImportSource(83      [84        "name,url,email,password,note,totp",85        '"Acct","https://e.test","[email protected]","pw","","otpauth://totp/Acct?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256&digits=8&period=60&issuer=Acct"',86      ].join("\n")87    );8889    expect(result.entries).toHaveLength(1);90    expect(result.entries[0]).toMatchObject({91      totp: "JBSWY3DPEHPK3PXP",92      totpAlgorithm: "SHA256",93      totpDigits: 8,94      totpPeriod: 60,95    });96  });9798  it("parses Proton Pass JSON exports", async () => {99    const result = await parseImportSource(100      JSON.stringify({101        version: "1.8.0",102        encrypted: false,103        vaults: {104          primary: {105            name: "Personal",106            items: [107              {108                data: {109                  type: "login",110                  metadata: {111                    name: "Proton Login",112                    note: "switching in",113                  },114                  content: {115                    username: "peach-user",116                    password: "pass123",117                    urls: ["https://proton.me"],118                    totpUri:119                      "otpauth://totp/Proton?secret=JBSWY3DPEHPK3PXP&issuer=Proton",120                  },121                },122                state: 1,123              },124            ],125          },126        },127      })128    );129130    expect(result.format).toBe("protonpass-json");131    expect(result.entries).toHaveLength(1);132    expect(result.entries[0]).toMatchObject({133      title: "Proton Login",134      username: "peach-user",135      password: "pass123",136      url: "https://proton.me",137      notes: "switching in",138      totp: "JBSWY3DPEHPK3PXP",139    });140  });141142  it("requires a password before decrypting Peach backups", async () => {143    const envelope = await exportEntriesToPeachJson([144      {145        ...SAMPLE_IMPORT_ENTRY,146        id: "1",147        createdAt: 1,148        modifiedAt: 1,149      },150    ], "export-pass");151152    const locked = await parseImportSource(JSON.stringify(envelope));153    expect(locked.format).toBe("peach-json");154    expect(locked.requiresPassword).toBe(true);155156    const unlocked = await parseImportSource(157      JSON.stringify(envelope),158      "export-pass"159    );160    expect(unlocked.entries).toHaveLength(1);161    expect(unlocked.entries[0]).toMatchObject({162      title: "GitHub",163      username: "june",164      password: "secret123",165      url: "https://github.com",166    });167  });168169  it("rejects the wrong password for Peach backups", async () => {170    const envelope = await exportEntriesToPeachJson([171      {172        ...SAMPLE_IMPORT_ENTRY,173        id: "1",174        createdAt: 1,175        modifiedAt: 1,176      },177    ], "export-pass");178179    await expect(180      importEntriesFromPeachJson(JSON.stringify(envelope), "wrong-pass")181    ).rejects.toThrow("INVALID_EXPORT_PASSWORD");182  });183184  it("restores passkeys from Peach encrypted backups", async () => {185    const generated = await generatePasskeyKeypair(-7);186    const envelope = await exportEntriesToPeachJson([187      {188        id: "passkey-1",189        type: "passkey",190        title: "WebAuthn.io",191        rpId: "webauthn.io",192        rpName: "WebAuthn.io",193        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),194        publicKeyCose: base64urlEncode(generated.publicKeyCose),195        privateKeyJwk: generated.privateKeyJwk,196        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),197        userName: "june",198        userDisplayName: "June",199        algorithm: -7,200        signCount: 2,201        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",202        backupEligible: true,203        backupState: false,204        createdAt: 1,205        modifiedAt: 1,206      },207    ], "export-pass");208209    const { entries: restored } = await importEntriesFromPeachJson(210      JSON.stringify(envelope),211      "export-pass"212    );213214    expect(restored).toHaveLength(1);215    expect(restored[0]).toMatchObject({216      type: "passkey",217      title: "WebAuthn.io",218      rpId: "webauthn.io",219      userName: "june",220      algorithm: -7,221      signCount: 2,222    });223  });224225  it("parses credential exchange passkey imports", async () => {226    const generated = await generatePasskeyKeypair(-7);227    const privateKey = await crypto.subtle.importKey(228      "jwk",229      generated.privateKeyJwk,230      { name: "ECDSA", namedCurve: "P-256" },231      true,232      ["sign"]233    );234    const pkcs8 = new Uint8Array(await crypto.subtle.exportKey("pkcs8", privateKey));235236    const result = await parseImportSource(237      JSON.stringify({238        version: { major: 1, minor: 0 },239        exporterRpId: "bitwarden.example",240        exporterDisplayName: "Bitwarden",241        timestamp: 1705228800,242        accounts: [243          {244            id: "account-1",245            username: "june",246            items: [247              {248                id: "item-1",249                title: "WebAuthn.io",250                credentials: [251                  {252                    type: "passkey",253                    credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),254                    rpId: "webauthn.io",255                    username: "june",256                    userDisplayName: "June",257                    userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),258                    key: base64urlEncode(pkcs8),259                  },260                ],261              },262            ],263          },264        ],265      })266    );267268    expect(result.format).toBe("credential-exchange-json");269    expect(result.entries).toHaveLength(1);270    expect(result.entries[0]).toMatchObject({271      type: "passkey",272      title: "WebAuthn.io",273      rpId: "webauthn.io",274      userName: "june",275      userDisplayName: "June",276      signCount: 0,277    });278  });279280  it("parses a valid peach-vault export and returns correct ImportableEntry[]", async () => {281    const result = await parseImportSource(282      JSON.stringify({283        exportFormat: "peach-vault",284        exportFormatVersion: 2,285        exportedAt: "2025-01-15T10:00:00.000Z",286        exportedAtMs: 1736935200000,287        entryCount: 2,288        passkeyPrivateKeysWrapped: false,289        entries: [290          {291            id: "550e8400-e29b-41d4-a716-446655440000",292            type: "login",293            modified: 1736935100000,294            created: 1736930000000,295            name: "GitHub",296            favorite: false,297            tags: [],298            login: {299              urls: ["https://github.com"],300              username: "june",301              password: "supersecret",302              totp: {303                secret: "BASE32SECRET",304                algorithm: "SHA1",305                digits: 6,306                period: 30,307              },308              customFields: [],309            },310          },311        ],312      })313    );314315    expect(result.format).toBe("peach-legacy-json");316    expect(result.label).toBe("Peach (legacy)");317    expect(result.entries).toHaveLength(1);318    expect(result.entries[0]).toMatchObject({319      title: "GitHub",320      url: "https://github.com",321      username: "june",322      password: "supersecret",323      totp: "BASE32SECRET",324      notes: undefined,325    });326expect(result.totalItems).toBe(1);327    expect(result.skippedItems).toBe(0);328  });329330  it("handles missing optional fields without throwing", async () => {331    const result = await parseImportSource(332      JSON.stringify({333        exportFormat: "peach-vault",334        exportFormatVersion: 2,335        exportedAt: "2025-01-15T10:00:00.000Z",336        exportedAtMs: 1736935200000,337        entryCount: 1,338        passkeyPrivateKeysWrapped: false,339        entries: [340          {341            id: "1",342            type: "login",343            modified: 1736935100000,344            created: 1736930000000,345            name: "Minimal Login",346            favorite: false,347            tags: [],348            login: {349              username: "june",350              password: "secret",351            },352          },353        ],354      })355    );356357    expect(result.format).toBe("peach-legacy-json");358    expect(result.entries).toHaveLength(1);359    expect(result.entries[0]).toMatchObject({360      title: "Minimal Login",361      url: "",362      username: "june",363      password: "secret",364      totp: undefined,365      notes: undefined,366    });367  });368369  it("falls back gracefully on malformed JSON", async () => {370    const result = await parseImportSource("not valid json at all");371    expect(result.format).toBe("unknown");372  });373374  it("detectImportFormat identifies the supported JSON and CSV shapes", () => {375    expect(detectImportFormat('{"items":[{"type":1}]}')).toBe("bitwarden-json");376    expect(377      detectImportFormat(378        JSON.stringify({379          accounts: [{ vaults: [{ items: [{ overview: { title: "GitHub" } }] }] }],380        })381      )382    ).toBe("1password-json");383    expect(384      detectImportFormat(385        JSON.stringify({386          vaults: { primary: { items: [{ data: { type: "login" } }] } },387        })388      )389    ).toBe("protonpass-json");390    expect(391      detectImportFormat(392        JSON.stringify({393          type: "peach-export",394          version: 2,395          kdf: "PBKDF2-SHA256",396          salt: "00",397          nonce: "00",398          ciphertext: "00",399          tag: "00",400        })401      )402    ).toBe("peach-json");403    expect(detectImportFormat("name,url,username,password\nGitHub,https://github.com,june,secret")).toBe("csv");404    expect(detectImportFormat("totally unrecognized")).toBe("unknown");405  });406407  it("parseCSV maps LastPass-style grouping columns to tags", async () => {408    const result = await parseImportSource(409      [410        "name,url,username,password,grouping",411        '"GitHub","https://github.com","june","secret123","work/team"',412      ].join("\n")413    );414415    expect(result.entries).toMatchObject([416      {417        title: "GitHub",418        tags: ["work/team"],419      },420    ]);421  });422423  it("parse1PasswordJSON handles 1Password 8 data.items shape", () => {424    const entries = parse1PasswordJSON(425      JSON.stringify({426        items: [427          {428            title: "GitHub",429            url: "https://github.com",430            fields: [431              { id: "username", value: "june" },432              { id: "password", value: "secret123" },433            ],434            notes: "from 1password",435          },436        ],437      })438    );439440    expect(entries).toMatchObject([441      {442        title: "GitHub",443        username: "june",444        password: "secret123",445        url: "https://github.com",446        notes: "from 1password",447      },448    ]);449  });450451  it("parseImportSource returns format 'peach-legacy-json' with correct entries", async () => {452    const result = await parseImportSource(453      JSON.stringify({454        exportFormat: "peach-vault",455        exportFormatVersion: 2,456        exportedAt: "2025-01-15T10:00:00.000Z",457        exportedAtMs: 1736935200000,458        entryCount: 1,459        passkeyPrivateKeysWrapped: false,460        entries: [461          {462            id: "1",463            type: "login",464            modified: 1736935100000,465            created: 1736930000000,466            name: "Test Login",467            favorite: false,468            tags: [],469            login: {470              urls: ["https://example.com"],471              username: "user",472              password: "pass123",473              totp: { secret: "TEST123", algorithm: "SHA1", digits: 6, period: 30 },474              customFields: [],475              notes: "A note",476            },477          },478        ],479      })480    );481482    expect(result.format).toBe("peach-legacy-json");483    expect(result.entries).toHaveLength(1);484    expect(result.entries[0].title).toBe("Test Login");485    expect(result.entries[0].username).toBe("user");486    expect(result.entries[0].password).toBe("pass123");487    expect(result.entries[0].url).toBe("https://example.com");488    expect(result.entries[0].totp).toBe("TEST123");489    expect(result.entries[0].notes).toBe("A note");490  });491492  it("MV-5: parseImportSource rejects an over-limit raw string with IMPORT_TOO_LARGE", async () => {493    const huge = "x".repeat(11 * 1024 * 1024); // 11 MiB > 10 MiB cap494    await expect(parseImportSource(huge)).rejects.toThrow(/IMPORT_TOO_LARGE/);495  });496});