1import Papa from "papaparse";23import {4 base64urlDecode,5 base64urlEncode,6 importPasskeyPrivateKey,7} from "../background/passkey-crypto";8import { parseOtpauthURI } from "../crypto/totp";9import type { VaultEntry } from "./types";10import { importEntriesFromPeachJson, type ImportableEntry } from "./backup";1112export type ImportFormat =13 | "csv"14 | "1password-json"15 | "bitwarden-json"16 | "credential-exchange-json"17 | "protonpass-json"18 | "peach-json"19 | "peach-legacy-json"20 | "unknown";2122export interface ImportParseResult {23 format: ImportFormat;24 label: string;25 entries: ImportableEntry[];26 totalItems: number;27 skippedItems: number;28 requiresPassword: boolean;29}3031export async function parseImportSource(32 raw: string,33 password?: string34): Promise<ImportParseResult> {35 const format = detectImportFormat(raw);3637 switch (format) {38 case "csv": {39 const entries = parseCSV(raw);40 const totalItems = Papa.parse<Record<string, string>>(raw, {41 header: true,42 skipEmptyLines: true,43 }).data.length;44 return {45 format,46 label: "CSV import",47 entries,48 totalItems,49 skippedItems: Math.max(0, totalItems - entries.length),50 requiresPassword: false,51 };52 }53 case "1password-json": {54 const entries = parse1PasswordJSON(raw);55 return {56 format,57 label: "1Password JSON",58 entries,59 totalItems: entries.length,60 skippedItems: 0,61 requiresPassword: false,62 };63 }64 case "bitwarden-json": {65 const { entries, totalItems } = parseBitwardenJSON(raw);66 return {67 format,68 label: "Bitwarden JSON",69 entries,70 totalItems,71 skippedItems: Math.max(0, totalItems - entries.length),72 requiresPassword: false,73 };74 }75 case "credential-exchange-json": {76 const { entries, totalItems } = await parseCredentialExchangeJSON(raw);77 return {78 format,79 label: "Credential Exchange JSON",80 entries,81 totalItems,82 skippedItems: Math.max(0, totalItems - entries.length),83 requiresPassword: false,84 };85 }86 case "protonpass-json": {87 const { entries, totalItems } = parseProtonPassJSON(raw);88 return {89 format,90 label: "Proton Pass JSON",91 entries,92 totalItems,93 skippedItems: Math.max(0, totalItems - entries.length),94 requiresPassword: false,95 };96 }97 case "peach-legacy-json": {98 const { entries, totalItems, skippedItems } = parsePeachLegacyJSON(raw);99 return {100 format,101 label: "Peach (legacy)",102 entries,103 totalItems,104 skippedItems,105 requiresPassword: false,106 };107 }108 case "peach-json": {109 if (!password) {110 return {111 format,112 label: "Peach encrypted backup",113 entries: [],114 totalItems: 0,115 skippedItems: 0,116 requiresPassword: true,117 };118 }119120 const entries = await importEntriesFromPeachJson(raw, password);121 return {122 format,123 label: "Peach encrypted backup",124 entries,125 totalItems: entries.length,126 skippedItems: 0,127 requiresPassword: false,128 };129 }130 default:131 return {132 format: "unknown",133 label: "Unknown format",134 entries: [],135 totalItems: 0,136 skippedItems: 0,137 requiresPassword: false,138 };139 }140}141142export function detectImportFormat(raw: string): ImportFormat {143 const trimmed = raw.trim();144 if (trimmed.startsWith("{") || trimmed.startsWith("[")) {145 try {146 const data = JSON.parse(trimmed);147 if (hasPeachBackupShape(data)) return "peach-json";148 if ((data as any)?.exportFormat === "peach-vault")149 return "peach-legacy-json";150 if (hasCredentialExchangeShape(data)) return "credential-exchange-json";151 if (has1PasswordShape(data)) return "1password-json";152 if (hasBitwardenShape(data)) return "bitwarden-json";153 if (hasProtonPassShape(data)) return "protonpass-json";154 return "unknown";155 } catch {156 return "unknown";157 }158 }159160 return trimmed.includes(",") ? "csv" : "unknown";161}162163export function parse1PasswordJSON(raw: string): ImportableEntry[] {164 const data = JSON.parse(raw);165 const entries: ImportableEntry[] = [];166167 const itemGroups: any[][] = [];168 const vaults = data?.accounts?.[0]?.vaults ?? [];169 for (const vault of vaults) itemGroups.push(vault.items ?? []);170 if (Array.isArray(data?.items)) itemGroups.push(data.items);171172 for (const items of itemGroups) {173 for (const item of items) {174 if (item.trashed) continue;175 const fields = item.details?.loginFields ?? item.fields ?? [];176 const username =177 fields.find((field: any) => field.designation === "username" || field.id === "username")?.value ??178 "";179 const password =180 fields.find((field: any) => field.designation === "password" || field.id === "password")?.value ??181 "";182 if (!password) continue;183 entries.push({184 type: "login",185 title: (item.overview?.title ?? item.title)?.trim() || "Untitled",186 username: username.trim(),187 password,188 url: ((item.overview?.url ?? item.url) ?? "").trim(),189 notes: stringOrUndefined(item.details?.notesPlain ?? item.notes),190 });191 }192 }193194 return entries;195}196197function parseCSV(csvText: string): ImportableEntry[] {198 const result = Papa.parse<Record<string, string>>(csvText, {199 header: true,200 skipEmptyLines: true,201 transformHeader: (header) => header.trim(),202 });203204 return result.data.reduce<ImportableEntry[]>((entries, row) => {205 const title = pickFirstField(row, [206 "name",207 "title",208 "Title",209 "Name",210 "item",211 ]);212 const url = pickFirstField(row, [213 "url",214 "website",215 "URL",216 "Website",217 "login_uri",218 "loginUrl",219 ]);220 const username = pickFirstField(row, [221 "username",222 "login",223 "email",224 "Username",225 "login_username",226 "user",227 ]);228 const password = pickFirstField(row, [229 "password",230 "Password",231 "login_password",232 ]);233 const notes = pickFirstField(row, [234 "notes",235 "Notes",236 "note",237 "comment",238 "extra",239 ]);240 const totp = normalizeTotp(241 pickFirstField(row, [242 "totp",243 "totpUri",244 "otp",245 "otpSecret",246 "two-factor-secret",247 "login_totp",248 ])249 );250 const grouping = pickFirstField(row, ["grouping", "Grouping", "folder", "Folder"]);251252 if (!password) return entries;253254 entries.push({255 type: "login",256 title: firstNonEmptyString(title, url, username, "Untitled"),257 username,258 password,259 url,260 notes: notes || undefined,261 totp,262 tags: grouping ? [grouping] : undefined,263 });264 return entries;265 }, []);266}267268function parseBitwardenJSON(raw: string): {269 entries: ImportableEntry[];270 totalItems: number;271} {272 const data = JSON.parse(raw);273 const items: any[] = Array.isArray(data?.items) ? data.items : [];274 const loginItems = items.filter((item) => item?.type === 1);275276 return {277 totalItems: loginItems.length,278 entries: loginItems.reduce<ImportableEntry[]>((entries, item) => {279 const password = stringOrEmpty(item.login?.password);280 if (!password) return entries;281282 entries.push({283 type: "login",284 title: firstNonEmptyString(item.name, "Untitled"),285 url: firstNonEmptyString(item.login?.uris?.[0]?.uri, ""),286 username: firstNonEmptyString(item.login?.username, ""),287 password,288 notes: stringOrUndefined(item.notes),289 totp: normalizeTotp(stringOrUndefined(item.login?.totp)),290 });291 return entries;292 }, []),293 };294}295296function parseProtonPassJSON(raw: string): {297 entries: ImportableEntry[];298 totalItems: number;299} {300 const data = JSON.parse(raw) as {301 vaults?: Record<string, { name?: string; items?: any[] }>;302 };303 const vaults = data?.vaults && typeof data.vaults === "object" ? data.vaults : {};304 const entries: ImportableEntry[] = [];305 let totalItems = 0;306307 for (const vault of Object.values(vaults)) {308 if (vault?.name?.toLowerCase() === "recycle bin") continue;309310 for (const item of vault?.items ?? []) {311 const itemData = item?.data ?? item;312 const itemType = itemData?.type ?? item?.type;313 if (itemType !== "login") continue;314315 totalItems += 1;316317 const content = itemData?.content ?? {};318 const metadata = itemData?.metadata ?? {};319 const password = firstNonEmptyString(320 content.password,321 content.itemPassword322 );323 if (!password) continue;324325 entries.push({326 type: "login",327 title: firstNonEmptyString(328 metadata.name,329 item.name,330 item.title,331 content.itemName,332 "Untitled"333 ),334 username: firstNonEmptyString(335 content.username,336 content.itemUsername,337 content.email,338 content.itemEmail,339 item.aliasEmail,340 ""341 ),342 password,343 url: firstNonEmptyString(344 Array.isArray(content.urls) ? content.urls[0] : undefined,345 content.url,346 ""347 ),348 notes: stringOrUndefined(metadata.note ?? metadata.notes),349 totp: normalizeTotp(350 firstNonEmptyString(content.totpUri, content.itemTotpUri)351 ),352 });353 }354 }355356 return { entries, totalItems };357}358359async function parseCredentialExchangeJSON(raw: string): Promise<{360 entries: ImportableEntry[];361 totalItems: number;362}> {363 const data = JSON.parse(raw) as {364 accounts?: Array<{365 items?: Array<{366 title?: string;367 modifiedAt?: number;368 creationAt?: number;369 credentials?: Array<{370 type?: string;371 credentialId?: string;372 rpId?: string;373 username?: string;374 userDisplayName?: string;375 userHandle?: string;376 key?: string;377 }>;378 }>;379 }>;380 };381382 const entries: ImportableEntry[] = [];383 let totalItems = 0;384385 for (const account of data.accounts ?? []) {386 for (const item of account.items ?? []) {387 for (const credential of item.credentials ?? []) {388 if (credential?.type !== "passkey") continue;389 totalItems += 1;390391 if (392 !credential.credentialId ||393 !credential.rpId ||394 !credential.username ||395 !credential.userDisplayName ||396 !credential.userHandle ||397 !credential.key398 ) {399 continue;400 }401402 try {403 const pkcs8 = base64urlDecode(credential.key);404 const imported = await importPasskeyPrivateKey(pkcs8);405 entries.push({406 type: "passkey",407 title: firstNonEmptyString(item.title, credential.rpId),408 rpId: credential.rpId,409 rpName: firstNonEmptyString(item.title, credential.rpId),410 credentialId: credential.credentialId,411 publicKeyCose: base64urlEncode(imported.publicKeyCose),412 privateKeyJwk: imported.privateKeyJwk,413 userHandle: credential.userHandle,414 userName: credential.username,415 userDisplayName: credential.userDisplayName,416 algorithm: imported.algorithm,417 signCount: 0,418 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",419 backupEligible: true,420 backupState: false,421 });422 } catch {423 424 }425 }426 }427 }428429 return { entries, totalItems };430}431432function parsePeachLegacyJSON(raw: string): {433 entries: ImportableEntry[];434 totalItems: number;435 skippedItems: number;436} {437 const data = JSON.parse(raw) as {438 exportFormat: string;439 entries: Array<{440 id: string;441 type: string;442 modified: number;443 created: number;444 name: string;445 favorite: boolean;446 tags: string[];447 login?: {448 urls?: string[];449 username?: string;450 password?: string;451 totp?: {452 secret: string;453 algorithm: string;454 digits: number;455 period: number;456 };457 customFields?: unknown[];458 notes?: string;459 };460 }>;461 };462463 const totalItems = data.entries.length;464 const filtered = data.entries.filter((e) => e.type === "login");465 const skippedItems = totalItems - filtered.length;466467 return {468 totalItems,469 skippedItems,470 entries: filtered.map((entry) => ({471 type: "login" as const,472 title: entry.name ?? "Untitled",473 url: entry.login?.urls?.[0] ?? "",474 username: entry.login?.username ?? "",475 password: entry.login?.password ?? "",476 totp: entry.login?.totp?.secret ?? undefined,477 notes: entry.login?.notes ?? undefined,478 })),479 };480}481482function has1PasswordShape(value: unknown): boolean {483 if (!value || typeof value !== "object") return false;484 const v = value as { accounts?: unknown[]; items?: unknown[] };485 if (Array.isArray(v.accounts)) return true;486 if (!Array.isArray(v.items) || v.items.length === 0) return false;487 const sample = v.items[0] as any;488 return Boolean(sample?.details?.loginFields || sample?.overview?.title);489}490491function hasBitwardenShape(value: unknown): boolean {492 return Boolean(493 value &&494 typeof value === "object" &&495 Array.isArray((value as { items?: unknown[] }).items)496 );497}498499function hasCredentialExchangeShape(value: unknown): boolean {500 return Boolean(501 value &&502 typeof value === "object" &&503 (value as { version?: { major?: number; minor?: number }; accounts?: unknown[] }).version &&504 Array.isArray((value as { accounts?: unknown[] }).accounts)505 );506}507508function hasProtonPassShape(value: unknown): boolean {509 return Boolean(510 value &&511 typeof value === "object" &&512 (value as { vaults?: unknown }).vaults &&513 typeof (value as { vaults?: unknown }).vaults === "object"514 );515}516517function hasPeachBackupShape(value: unknown): boolean {518 if (!value || typeof value !== "object") return false;519 const envelope = value as Partial<{520 type: string;521 version: number;522 kdf: string;523 salt: string;524 nonce: string;525 ciphertext: string;526 tag: string;527 }>;528 return (529 envelope.type === "peach-export" &&530 envelope.version === 2 &&531 envelope.kdf === "PBKDF2-SHA256" &&532 typeof envelope.salt === "string" &&533 typeof envelope.nonce === "string" &&534 typeof envelope.ciphertext === "string" &&535 typeof envelope.tag === "string"536 );537}538539function pickFirstField(540 row: Record<string, string>,541 candidates: string[]542): string {543 for (const key of candidates) {544 const value = row[key];545 if (typeof value === "string" && value.trim()) {546 return value.trim();547 }548 }549 return "";550}551552function normalizeTotp(value?: string): string | undefined {553 if (!value) return undefined;554 const parsed = value.startsWith("otpauth://") ? parseOtpauthURI(value) : null;555 return parsed?.secret ?? value.trim() ?? undefined;556}557558function firstNonEmptyString(559 ...values: Array<string | null | undefined>560): string {561 for (const value of values) {562 if (typeof value === "string" && value.trim()) {563 return value.trim();564 }565 }566 return "";567}568569function stringOrUndefined(value: unknown): string | undefined {570 return typeof value === "string" && value.trim() ? value.trim() : undefined;571}572573function stringOrEmpty(value: unknown): string {574 return typeof value === "string" ? value : "";575}