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 });470});