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("parse_csv_returns_total_items_matching_rows (PCH-01-026 #5)", async () => {82 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); 512 await expect(parseImportSource(huge)).rejects.toThrow(/IMPORT_TOO_LARGE/);513 });514});