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 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); 494 await expect(parseImportSource(huge)).rejects.toThrow(/IMPORT_TOO_LARGE/);495 });496});