import.test.ts

13.7 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  });7677  it("parses Proton Pass JSON exports", async () => {78    const result = await parseImportSource(79      JSON.stringify({80        version: "1.8.0",81        encrypted: false,82        vaults: {83          primary: {84            name: "Personal",85            items: [86              {87                data: {88                  type: "login",89                  metadata: {90                    name: "Proton Login",91                    note: "switching in",92                  },93                  content: {94                    username: "peach-user",95                    password: "pass123",96                    urls: ["https://proton.me"],97                    totpUri:98                      "otpauth://totp/Proton?secret=JBSWY3DPEHPK3PXP&issuer=Proton",99                  },100                },101                state: 1,102              },103            ],104          },105        },106      })107    );108109    expect(result.format).toBe("protonpass-json");110    expect(result.entries).toHaveLength(1);111    expect(result.entries[0]).toMatchObject({112      title: "Proton Login",113      username: "peach-user",114      password: "pass123",115      url: "https://proton.me",116      notes: "switching in",117      totp: "JBSWY3DPEHPK3PXP",118    });119  });120121  it("requires a password before decrypting Peach backups", async () => {122    const envelope = await exportEntriesToPeachJson([123      {124        ...SAMPLE_IMPORT_ENTRY,125        id: "1",126        createdAt: 1,127        modifiedAt: 1,128      },129    ], "export-pass");130131    const locked = await parseImportSource(JSON.stringify(envelope));132    expect(locked.format).toBe("peach-json");133    expect(locked.requiresPassword).toBe(true);134135    const unlocked = await parseImportSource(136      JSON.stringify(envelope),137      "export-pass"138    );139    expect(unlocked.entries).toHaveLength(1);140    expect(unlocked.entries[0]).toMatchObject({141      title: "GitHub",142      username: "june",143      password: "secret123",144      url: "https://github.com",145    });146  });147148  it("rejects the wrong password for Peach backups", async () => {149    const envelope = await exportEntriesToPeachJson([150      {151        ...SAMPLE_IMPORT_ENTRY,152        id: "1",153        createdAt: 1,154        modifiedAt: 1,155      },156    ], "export-pass");157158    await expect(159      importEntriesFromPeachJson(JSON.stringify(envelope), "wrong-pass")160    ).rejects.toThrow("INVALID_EXPORT_PASSWORD");161  });162163  it("restores passkeys from Peach encrypted backups", async () => {164    const generated = await generatePasskeyKeypair(-7);165    const envelope = await exportEntriesToPeachJson([166      {167        id: "passkey-1",168        type: "passkey",169        title: "WebAuthn.io",170        rpId: "webauthn.io",171        rpName: "WebAuthn.io",172        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),173        publicKeyCose: base64urlEncode(generated.publicKeyCose),174        privateKeyJwk: generated.privateKeyJwk,175        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),176        userName: "june",177        userDisplayName: "June",178        algorithm: -7,179        signCount: 2,180        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",181        backupEligible: true,182        backupState: false,183        createdAt: 1,184        modifiedAt: 1,185      },186    ], "export-pass");187188    const { entries: restored } = await importEntriesFromPeachJson(189      JSON.stringify(envelope),190      "export-pass"191    );192193    expect(restored).toHaveLength(1);194    expect(restored[0]).toMatchObject({195      type: "passkey",196      title: "WebAuthn.io",197      rpId: "webauthn.io",198      userName: "june",199      algorithm: -7,200      signCount: 2,201    });202  });203204  it("parses credential exchange passkey imports", async () => {205    const generated = await generatePasskeyKeypair(-7);206    const privateKey = await crypto.subtle.importKey(207      "jwk",208      generated.privateKeyJwk,209      { name: "ECDSA", namedCurve: "P-256" },210      true,211      ["sign"]212    );213    const pkcs8 = new Uint8Array(await crypto.subtle.exportKey("pkcs8", privateKey));214215    const result = await parseImportSource(216      JSON.stringify({217        version: { major: 1, minor: 0 },218        exporterRpId: "bitwarden.example",219        exporterDisplayName: "Bitwarden",220        timestamp: 1705228800,221        accounts: [222          {223            id: "account-1",224            username: "june",225            items: [226              {227                id: "item-1",228                title: "WebAuthn.io",229                credentials: [230                  {231                    type: "passkey",232                    credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),233                    rpId: "webauthn.io",234                    username: "june",235                    userDisplayName: "June",236                    userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),237                    key: base64urlEncode(pkcs8),238                  },239                ],240              },241            ],242          },243        ],244      })245    );246247    expect(result.format).toBe("credential-exchange-json");248    expect(result.entries).toHaveLength(1);249    expect(result.entries[0]).toMatchObject({250      type: "passkey",251      title: "WebAuthn.io",252      rpId: "webauthn.io",253      userName: "june",254      userDisplayName: "June",255      signCount: 0,256    });257  });258259  it("parses a valid peach-vault export and returns correct ImportableEntry[]", async () => {260    const result = await parseImportSource(261      JSON.stringify({262        exportFormat: "peach-vault",263        exportFormatVersion: 2,264        exportedAt: "2025-01-15T10:00:00.000Z",265        exportedAtMs: 1736935200000,266        entryCount: 2,267        passkeyPrivateKeysWrapped: false,268        entries: [269          {270            id: "550e8400-e29b-41d4-a716-446655440000",271            type: "login",272            modified: 1736935100000,273            created: 1736930000000,274            name: "GitHub",275            favorite: false,276            tags: [],277            login: {278              urls: ["https://github.com"],279              username: "june",280              password: "supersecret",281              totp: {282                secret: "BASE32SECRET",283                algorithm: "SHA1",284                digits: 6,285                period: 30,286              },287              customFields: [],288            },289          },290        ],291      })292    );293294    expect(result.format).toBe("peach-legacy-json");295    expect(result.label).toBe("Peach (legacy)");296    expect(result.entries).toHaveLength(1);297    expect(result.entries[0]).toMatchObject({298      title: "GitHub",299      url: "https://github.com",300      username: "june",301      password: "supersecret",302      totp: "BASE32SECRET",303      notes: undefined,304    });305expect(result.totalItems).toBe(1);306    expect(result.skippedItems).toBe(0);307  });308309  it("handles missing optional fields without throwing", async () => {310    const result = await parseImportSource(311      JSON.stringify({312        exportFormat: "peach-vault",313        exportFormatVersion: 2,314        exportedAt: "2025-01-15T10:00:00.000Z",315        exportedAtMs: 1736935200000,316        entryCount: 1,317        passkeyPrivateKeysWrapped: false,318        entries: [319          {320            id: "1",321            type: "login",322            modified: 1736935100000,323            created: 1736930000000,324            name: "Minimal Login",325            favorite: false,326            tags: [],327            login: {328              username: "june",329              password: "secret",330            },331          },332        ],333      })334    );335336    expect(result.format).toBe("peach-legacy-json");337    expect(result.entries).toHaveLength(1);338    expect(result.entries[0]).toMatchObject({339      title: "Minimal Login",340      url: "",341      username: "june",342      password: "secret",343      totp: undefined,344      notes: undefined,345    });346  });347348  it("falls back gracefully on malformed JSON", async () => {349    const result = await parseImportSource("not valid json at all");350    expect(result.format).toBe("unknown");351  });352353  it("detectImportFormat identifies the supported JSON and CSV shapes", () => {354    expect(detectImportFormat('{"items":[{"type":1}]}')).toBe("bitwarden-json");355    expect(356      detectImportFormat(357        JSON.stringify({358          accounts: [{ vaults: [{ items: [{ overview: { title: "GitHub" } }] }] }],359        })360      )361    ).toBe("1password-json");362    expect(363      detectImportFormat(364        JSON.stringify({365          vaults: { primary: { items: [{ data: { type: "login" } }] } },366        })367      )368    ).toBe("protonpass-json");369    expect(370      detectImportFormat(371        JSON.stringify({372          type: "peach-export",373          version: 2,374          kdf: "PBKDF2-SHA256",375          salt: "00",376          nonce: "00",377          ciphertext: "00",378          tag: "00",379        })380      )381    ).toBe("peach-json");382    expect(detectImportFormat("name,url,username,password\nGitHub,https://github.com,june,secret")).toBe("csv");383    expect(detectImportFormat("totally unrecognized")).toBe("unknown");384  });385386  it("parseCSV maps LastPass-style grouping columns to tags", async () => {387    const result = await parseImportSource(388      [389        "name,url,username,password,grouping",390        '"GitHub","https://github.com","june","secret123","work/team"',391      ].join("\n")392    );393394    expect(result.entries).toMatchObject([395      {396        title: "GitHub",397        tags: ["work/team"],398      },399    ]);400  });401402  it("parse1PasswordJSON handles 1Password 8 data.items shape", () => {403    const entries = parse1PasswordJSON(404      JSON.stringify({405        items: [406          {407            title: "GitHub",408            url: "https://github.com",409            fields: [410              { id: "username", value: "june" },411              { id: "password", value: "secret123" },412            ],413            notes: "from 1password",414          },415        ],416      })417    );418419    expect(entries).toMatchObject([420      {421        title: "GitHub",422        username: "june",423        password: "secret123",424        url: "https://github.com",425        notes: "from 1password",426      },427    ]);428  });429430  it("parseImportSource returns format 'peach-legacy-json' with correct entries", async () => {431    const result = await parseImportSource(432      JSON.stringify({433        exportFormat: "peach-vault",434        exportFormatVersion: 2,435        exportedAt: "2025-01-15T10:00:00.000Z",436        exportedAtMs: 1736935200000,437        entryCount: 1,438        passkeyPrivateKeysWrapped: false,439        entries: [440          {441            id: "1",442            type: "login",443            modified: 1736935100000,444            created: 1736930000000,445            name: "Test Login",446            favorite: false,447            tags: [],448            login: {449              urls: ["https://example.com"],450              username: "user",451              password: "pass123",452              totp: { secret: "TEST123", algorithm: "SHA1", digits: 6, period: 30 },453              customFields: [],454              notes: "A note",455            },456          },457        ],458      })459    );460461    expect(result.format).toBe("peach-legacy-json");462    expect(result.entries).toHaveLength(1);463    expect(result.entries[0].title).toBe("Test Login");464    expect(result.entries[0].username).toBe("user");465    expect(result.entries[0].password).toBe("pass123");466    expect(result.entries[0].url).toBe("https://example.com");467    expect(result.entries[0].totp).toBe("TEST123");468    expect(result.entries[0].notes).toBe("A note");469  });470471  it("MV-5: parseImportSource rejects an over-limit raw string with IMPORT_TOO_LARGE", async () => {472    const huge = "x".repeat(11 * 1024 * 1024); // 11 MiB > 10 MiB cap473    await expect(parseImportSource(huge)).rejects.toThrow(/IMPORT_TOO_LARGE/);474  });475});