import.test.ts

15.1 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("parse_csv_returns_total_items_matching_rows (PCH-01-026 #5)", async () => {82    // 5 data rows; one has no password and is skipped.83    const result = await parseImportSource(84      [85        "name,url,email,password,note,totp",86        '"A","https://a.test","[email protected]","pw","",""',87        '"B","https://b.test","[email protected]","pw","",""',88        '"C","https://c.test","[email protected]","pw","",""',89        '"D","https://d.test","[email protected]","pw","",""',90        '"E","https://e.test","[email protected]","","",""',91      ].join("\n")92    );93    expect(result.format).toBe("csv");94    expect(result.entries).toHaveLength(4);95    expect(result.totalItems).toBe(5);96    expect(result.skippedItems).toBe(1);97  });9899  it("preserves non-default TOTP params from an otpauth URI (PCH-01-019)", async () => {100    const result = await parseImportSource(101      [102        "name,url,email,password,note,totp",103        '"Acct","https://e.test","[email protected]","pw","","otpauth://totp/Acct?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256&digits=8&period=60&issuer=Acct"',104      ].join("\n")105    );106107    expect(result.entries).toHaveLength(1);108    expect(result.entries[0]).toMatchObject({109      totp: "JBSWY3DPEHPK3PXP",110      totpAlgorithm: "SHA256",111      totpDigits: 8,112      totpPeriod: 60,113    });114  });115116  it("parses Proton Pass JSON exports", async () => {117    const result = await parseImportSource(118      JSON.stringify({119        version: "1.8.0",120        encrypted: false,121        vaults: {122          primary: {123            name: "Personal",124            items: [125              {126                data: {127                  type: "login",128                  metadata: {129                    name: "Proton Login",130                    note: "switching in",131                  },132                  content: {133                    username: "peach-user",134                    password: "pass123",135                    urls: ["https://proton.me"],136                    totpUri:137                      "otpauth://totp/Proton?secret=JBSWY3DPEHPK3PXP&issuer=Proton",138                  },139                },140                state: 1,141              },142            ],143          },144        },145      })146    );147148    expect(result.format).toBe("protonpass-json");149    expect(result.entries).toHaveLength(1);150    expect(result.entries[0]).toMatchObject({151      title: "Proton Login",152      username: "peach-user",153      password: "pass123",154      url: "https://proton.me",155      notes: "switching in",156      totp: "JBSWY3DPEHPK3PXP",157    });158  });159160  it("requires a password before decrypting Peach backups", async () => {161    const envelope = await exportEntriesToPeachJson([162      {163        ...SAMPLE_IMPORT_ENTRY,164        id: "1",165        createdAt: 1,166        modifiedAt: 1,167      },168    ], "export-pass");169170    const locked = await parseImportSource(JSON.stringify(envelope));171    expect(locked.format).toBe("peach-json");172    expect(locked.requiresPassword).toBe(true);173174    const unlocked = await parseImportSource(175      JSON.stringify(envelope),176      "export-pass"177    );178    expect(unlocked.entries).toHaveLength(1);179    expect(unlocked.entries[0]).toMatchObject({180      title: "GitHub",181      username: "june",182      password: "secret123",183      url: "https://github.com",184    });185  });186187  it("rejects the wrong password for Peach backups", async () => {188    const envelope = await exportEntriesToPeachJson([189      {190        ...SAMPLE_IMPORT_ENTRY,191        id: "1",192        createdAt: 1,193        modifiedAt: 1,194      },195    ], "export-pass");196197    await expect(198      importEntriesFromPeachJson(JSON.stringify(envelope), "wrong-pass")199    ).rejects.toThrow("INVALID_EXPORT_PASSWORD");200  });201202  it("restores passkeys from Peach encrypted backups", async () => {203    const generated = await generatePasskeyKeypair(-7);204    const envelope = await exportEntriesToPeachJson([205      {206        id: "passkey-1",207        type: "passkey",208        title: "WebAuthn.io",209        rpId: "webauthn.io",210        rpName: "WebAuthn.io",211        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),212        publicKeyCose: base64urlEncode(generated.publicKeyCose),213        privateKeyJwk: generated.privateKeyJwk,214        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),215        userName: "june",216        userDisplayName: "June",217        algorithm: -7,218        signCount: 2,219        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",220        backupEligible: true,221        backupState: false,222        createdAt: 1,223        modifiedAt: 1,224      },225    ], "export-pass");226227    const { entries: restored } = await importEntriesFromPeachJson(228      JSON.stringify(envelope),229      "export-pass"230    );231232    expect(restored).toHaveLength(1);233    expect(restored[0]).toMatchObject({234      type: "passkey",235      title: "WebAuthn.io",236      rpId: "webauthn.io",237      userName: "june",238      algorithm: -7,239      signCount: 2,240    });241  });242243  it("parses credential exchange passkey imports", async () => {244    const generated = await generatePasskeyKeypair(-7);245    const privateKey = await crypto.subtle.importKey(246      "jwk",247      generated.privateKeyJwk,248      { name: "ECDSA", namedCurve: "P-256" },249      true,250      ["sign"]251    );252    const pkcs8 = new Uint8Array(await crypto.subtle.exportKey("pkcs8", privateKey));253254    const result = await parseImportSource(255      JSON.stringify({256        version: { major: 1, minor: 0 },257        exporterRpId: "bitwarden.example",258        exporterDisplayName: "Bitwarden",259        timestamp: 1705228800,260        accounts: [261          {262            id: "account-1",263            username: "june",264            items: [265              {266                id: "item-1",267                title: "WebAuthn.io",268                credentials: [269                  {270                    type: "passkey",271                    credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),272                    rpId: "webauthn.io",273                    username: "june",274                    userDisplayName: "June",275                    userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),276                    key: base64urlEncode(pkcs8),277                  },278                ],279              },280            ],281          },282        ],283      })284    );285286    expect(result.format).toBe("credential-exchange-json");287    expect(result.entries).toHaveLength(1);288    expect(result.entries[0]).toMatchObject({289      type: "passkey",290      title: "WebAuthn.io",291      rpId: "webauthn.io",292      userName: "june",293      userDisplayName: "June",294      signCount: 0,295    });296  });297298  it("parses a valid peach-vault export and returns correct ImportableEntry[]", async () => {299    const result = await parseImportSource(300      JSON.stringify({301        exportFormat: "peach-vault",302        exportFormatVersion: 2,303        exportedAt: "2025-01-15T10:00:00.000Z",304        exportedAtMs: 1736935200000,305        entryCount: 2,306        passkeyPrivateKeysWrapped: false,307        entries: [308          {309            id: "550e8400-e29b-41d4-a716-446655440000",310            type: "login",311            modified: 1736935100000,312            created: 1736930000000,313            name: "GitHub",314            favorite: false,315            tags: [],316            login: {317              urls: ["https://github.com"],318              username: "june",319              password: "supersecret",320              totp: {321                secret: "BASE32SECRET",322                algorithm: "SHA1",323                digits: 6,324                period: 30,325              },326              customFields: [],327            },328          },329        ],330      })331    );332333    expect(result.format).toBe("peach-legacy-json");334    expect(result.label).toBe("Peach (legacy)");335    expect(result.entries).toHaveLength(1);336    expect(result.entries[0]).toMatchObject({337      title: "GitHub",338      url: "https://github.com",339      username: "june",340      password: "supersecret",341      totp: "BASE32SECRET",342      notes: undefined,343    });344expect(result.totalItems).toBe(1);345    expect(result.skippedItems).toBe(0);346  });347348  it("handles missing optional fields without throwing", async () => {349    const result = await parseImportSource(350      JSON.stringify({351        exportFormat: "peach-vault",352        exportFormatVersion: 2,353        exportedAt: "2025-01-15T10:00:00.000Z",354        exportedAtMs: 1736935200000,355        entryCount: 1,356        passkeyPrivateKeysWrapped: false,357        entries: [358          {359            id: "1",360            type: "login",361            modified: 1736935100000,362            created: 1736930000000,363            name: "Minimal Login",364            favorite: false,365            tags: [],366            login: {367              username: "june",368              password: "secret",369            },370          },371        ],372      })373    );374375    expect(result.format).toBe("peach-legacy-json");376    expect(result.entries).toHaveLength(1);377    expect(result.entries[0]).toMatchObject({378      title: "Minimal Login",379      url: "",380      username: "june",381      password: "secret",382      totp: undefined,383      notes: undefined,384    });385  });386387  it("falls back gracefully on malformed JSON", async () => {388    const result = await parseImportSource("not valid json at all");389    expect(result.format).toBe("unknown");390  });391392  it("detectImportFormat identifies the supported JSON and CSV shapes", () => {393    expect(detectImportFormat('{"items":[{"type":1}]}')).toBe("bitwarden-json");394    expect(395      detectImportFormat(396        JSON.stringify({397          accounts: [{ vaults: [{ items: [{ overview: { title: "GitHub" } }] }] }],398        })399      )400    ).toBe("1password-json");401    expect(402      detectImportFormat(403        JSON.stringify({404          vaults: { primary: { items: [{ data: { type: "login" } }] } },405        })406      )407    ).toBe("protonpass-json");408    expect(409      detectImportFormat(410        JSON.stringify({411          type: "peach-export",412          version: 2,413          kdf: "PBKDF2-SHA256",414          salt: "00",415          nonce: "00",416          ciphertext: "00",417          tag: "00",418        })419      )420    ).toBe("peach-json");421    expect(detectImportFormat("name,url,username,password\nGitHub,https://github.com,june,secret")).toBe("csv");422    expect(detectImportFormat("totally unrecognized")).toBe("unknown");423  });424425  it("parseCSV maps LastPass-style grouping columns to tags", async () => {426    const result = await parseImportSource(427      [428        "name,url,username,password,grouping",429        '"GitHub","https://github.com","june","secret123","work/team"',430      ].join("\n")431    );432433    expect(result.entries).toMatchObject([434      {435        title: "GitHub",436        tags: ["work/team"],437      },438    ]);439  });440441  it("parse1PasswordJSON handles 1Password 8 data.items shape", () => {442    const entries = parse1PasswordJSON(443      JSON.stringify({444        items: [445          {446            title: "GitHub",447            url: "https://github.com",448            fields: [449              { id: "username", value: "june" },450              { id: "password", value: "secret123" },451            ],452            notes: "from 1password",453          },454        ],455      })456    );457458    expect(entries).toMatchObject([459      {460        title: "GitHub",461        username: "june",462        password: "secret123",463        url: "https://github.com",464        notes: "from 1password",465      },466    ]);467  });468469  it("parseImportSource returns format 'peach-legacy-json' with correct entries", async () => {470    const result = await parseImportSource(471      JSON.stringify({472        exportFormat: "peach-vault",473        exportFormatVersion: 2,474        exportedAt: "2025-01-15T10:00:00.000Z",475        exportedAtMs: 1736935200000,476        entryCount: 1,477        passkeyPrivateKeysWrapped: false,478        entries: [479          {480            id: "1",481            type: "login",482            modified: 1736935100000,483            created: 1736930000000,484            name: "Test Login",485            favorite: false,486            tags: [],487            login: {488              urls: ["https://example.com"],489              username: "user",490              password: "pass123",491              totp: { secret: "TEST123", algorithm: "SHA1", digits: 6, period: 30 },492              customFields: [],493              notes: "A note",494            },495          },496        ],497      })498    );499500    expect(result.format).toBe("peach-legacy-json");501    expect(result.entries).toHaveLength(1);502    expect(result.entries[0].title).toBe("Test Login");503    expect(result.entries[0].username).toBe("user");504    expect(result.entries[0].password).toBe("pass123");505    expect(result.entries[0].url).toBe("https://example.com");506    expect(result.entries[0].totp).toBe("TEST123");507    expect(result.entries[0].notes).toBe("A note");508  });509510  it("MV-5: parseImportSource rejects an over-limit raw string with IMPORT_TOO_LARGE", async () => {511    const huge = "x".repeat(11 * 1024 * 1024); // 11 MiB > 10 MiB cap512    await expect(parseImportSource(huge)).rejects.toThrow(/IMPORT_TOO_LARGE/);513  });514});