import Papa from "papaparse"; import { base64urlDecode, base64urlEncode, importPasskeyPrivateKey, } from "../background/passkey-crypto"; import { parseOtpauthURI } from "../crypto/totp"; import { importEntriesFromPeachJson, type ImportableEntry } from "./backup"; export type ImportFormat = | "csv" | "1password-json" | "bitwarden-json" | "credential-exchange-json" | "protonpass-json" | "peach-json" | "peach-legacy-json" | "unknown"; export interface ImportParseResult { format: ImportFormat; label: string; entries: ImportableEntry[]; totalItems: number; skippedItems: number; requiresPassword: boolean; /** License key from the backup, present only on Pro encrypted JSON exports. */ licenseKey?: string; licenseActivatedAt?: number; } export async function parseImportSource( raw: string, password?: string ): Promise { const format = detectImportFormat(raw); switch (format) { case "csv": { const entries = parseCSV(raw); const totalItems = Papa.parse>(raw, { header: true, skipEmptyLines: true, }).data.length; return { format, label: "CSV import", entries, totalItems, skippedItems: Math.max(0, totalItems - entries.length), requiresPassword: false, }; } case "1password-json": { const entries = parse1PasswordJSON(raw); return { format, label: "1Password JSON", entries, totalItems: entries.length, skippedItems: 0, requiresPassword: false, }; } case "bitwarden-json": { const { entries, totalItems } = parseBitwardenJSON(raw); return { format, label: "Bitwarden JSON", entries, totalItems, skippedItems: Math.max(0, totalItems - entries.length), requiresPassword: false, }; } case "credential-exchange-json": { const { entries, totalItems } = await parseCredentialExchangeJSON(raw); return { format, label: "Credential Exchange JSON", entries, totalItems, skippedItems: Math.max(0, totalItems - entries.length), requiresPassword: false, }; } case "protonpass-json": { const { entries, totalItems } = parseProtonPassJSON(raw); return { format, label: "Proton Pass JSON", entries, totalItems, skippedItems: Math.max(0, totalItems - entries.length), requiresPassword: false, }; } case "peach-legacy-json": { const { entries, totalItems, skippedItems } = parsePeachLegacyJSON(raw); return { format, label: "Peach (legacy)", entries, totalItems, skippedItems, requiresPassword: false, }; } case "peach-json": { if (!password) { return { format, label: "Peach encrypted backup", entries: [], totalItems: 0, skippedItems: 0, requiresPassword: true, }; } const result = await importEntriesFromPeachJson(raw, password); return { format, label: "Peach encrypted backup", entries: result.entries, totalItems: result.entries.length, skippedItems: 0, requiresPassword: false, ...(result.licenseKey ? { licenseKey: result.licenseKey, licenseActivatedAt: result.licenseActivatedAt } : {}), }; } default: return { format: "unknown", label: "Unknown format", entries: [], totalItems: 0, skippedItems: 0, requiresPassword: false, }; } } export function detectImportFormat(raw: string): ImportFormat { const trimmed = raw.trim(); if (trimmed.startsWith("{") || trimmed.startsWith("[")) { try { const data = JSON.parse(trimmed); if (hasPeachBackupShape(data)) return "peach-json"; if ((data as any)?.exportFormat === "peach-vault") return "peach-legacy-json"; if (hasCredentialExchangeShape(data)) return "credential-exchange-json"; if (has1PasswordShape(data)) return "1password-json"; if (hasBitwardenShape(data)) return "bitwarden-json"; if (hasProtonPassShape(data)) return "protonpass-json"; return "unknown"; } catch { return "unknown"; } } return trimmed.includes(",") ? "csv" : "unknown"; } export function parse1PasswordJSON(raw: string): ImportableEntry[] { const data = JSON.parse(raw); const entries: ImportableEntry[] = []; const itemGroups: any[][] = []; const vaults = data?.accounts?.[0]?.vaults ?? []; for (const vault of vaults) itemGroups.push(vault.items ?? []); if (Array.isArray(data?.items)) itemGroups.push(data.items); for (const items of itemGroups) { for (const item of items) { if (item.trashed) continue; const fields = item.details?.loginFields ?? item.fields ?? []; const username = fields.find((field: any) => field.designation === "username" || field.id === "username")?.value ?? ""; const password = fields.find((field: any) => field.designation === "password" || field.id === "password")?.value ?? ""; if (!password) continue; entries.push({ type: "login", title: (item.overview?.title ?? item.title)?.trim() || "Untitled", username: username.trim(), password, url: ((item.overview?.url ?? item.url) ?? "").trim(), notes: stringOrUndefined(item.details?.notesPlain ?? item.notes), }); } } return entries; } function parseCSV(csvText: string): ImportableEntry[] { const result = Papa.parse>(csvText, { header: true, skipEmptyLines: true, transformHeader: (header) => header.trim(), }); return result.data.reduce((entries, row) => { const title = pickFirstField(row, [ "name", "title", "Title", "Name", "item", ]); const url = pickFirstField(row, [ "url", "website", "URL", "Website", "login_uri", "loginUrl", ]); const username = pickFirstField(row, [ "username", "login", "email", "Username", "login_username", "user", ]); const password = pickFirstField(row, [ "password", "Password", "login_password", ]); const notes = pickFirstField(row, [ "notes", "Notes", "note", "comment", "extra", ]); const totp = normalizeTotp( pickFirstField(row, [ "totp", "totpUri", "otp", "otpSecret", "two-factor-secret", "login_totp", ]) ); const grouping = pickFirstField(row, ["grouping", "Grouping", "folder", "Folder"]); if (!password) return entries; entries.push({ type: "login", title: firstNonEmptyString(title, url, username, "Untitled"), username, password, url, notes: notes || undefined, totp, tags: grouping ? [grouping] : undefined, }); return entries; }, []); } function parseBitwardenJSON(raw: string): { entries: ImportableEntry[]; totalItems: number; } { const data = JSON.parse(raw); const items: any[] = Array.isArray(data?.items) ? data.items : []; const loginItems = items.filter((item) => item?.type === 1); return { totalItems: loginItems.length, entries: loginItems.reduce((entries, item) => { const password = stringOrEmpty(item.login?.password); if (!password) return entries; entries.push({ type: "login", title: firstNonEmptyString(item.name, "Untitled"), url: firstNonEmptyString(item.login?.uris?.[0]?.uri, ""), username: firstNonEmptyString(item.login?.username, ""), password, notes: stringOrUndefined(item.notes), totp: normalizeTotp(stringOrUndefined(item.login?.totp)), }); return entries; }, []), }; } function parseProtonPassJSON(raw: string): { entries: ImportableEntry[]; totalItems: number; } { const data = JSON.parse(raw) as { vaults?: Record; }; const vaults = data?.vaults && typeof data.vaults === "object" ? data.vaults : {}; const entries: ImportableEntry[] = []; let totalItems = 0; for (const vault of Object.values(vaults)) { if (vault?.name?.toLowerCase() === "recycle bin") continue; for (const item of vault?.items ?? []) { const itemData = item?.data ?? item; const itemType = itemData?.type ?? item?.type; if (itemType !== "login") continue; totalItems += 1; const content = itemData?.content ?? {}; const metadata = itemData?.metadata ?? {}; const password = firstNonEmptyString( content.password, content.itemPassword ); if (!password) continue; entries.push({ type: "login", title: firstNonEmptyString( metadata.name, item.name, item.title, content.itemName, "Untitled" ), username: firstNonEmptyString( content.username, content.itemUsername, content.email, content.itemEmail, item.aliasEmail, "" ), password, url: firstNonEmptyString( Array.isArray(content.urls) ? content.urls[0] : undefined, content.url, "" ), notes: stringOrUndefined(metadata.note ?? metadata.notes), totp: normalizeTotp( firstNonEmptyString(content.totpUri, content.itemTotpUri) ), }); } } return { entries, totalItems }; } async function parseCredentialExchangeJSON(raw: string): Promise<{ entries: ImportableEntry[]; totalItems: number; }> { const data = JSON.parse(raw) as { accounts?: Array<{ items?: Array<{ title?: string; modifiedAt?: number; creationAt?: number; credentials?: Array<{ type?: string; credentialId?: string; rpId?: string; username?: string; userDisplayName?: string; userHandle?: string; key?: string; }>; }>; }>; }; const entries: ImportableEntry[] = []; let totalItems = 0; for (const account of data.accounts ?? []) { for (const item of account.items ?? []) { for (const credential of item.credentials ?? []) { if (credential?.type !== "passkey") continue; totalItems += 1; if ( !credential.credentialId || !credential.rpId || !credential.username || !credential.userDisplayName || !credential.userHandle || !credential.key ) { continue; } try { const pkcs8 = base64urlDecode(credential.key); const imported = await importPasskeyPrivateKey(pkcs8); entries.push({ type: "passkey", title: firstNonEmptyString(item.title, credential.rpId), rpId: credential.rpId, rpName: firstNonEmptyString(item.title, credential.rpId), credentialId: credential.credentialId, publicKeyCose: base64urlEncode(imported.publicKeyCose), privateKeyJwk: imported.privateKeyJwk, userHandle: credential.userHandle, userName: credential.username, userDisplayName: credential.userDisplayName, algorithm: imported.algorithm, signCount: 0, aaguid: "AAAAAAAAAAAAAAAAAAAAAA", backupEligible: true, backupState: false, }); } catch { // Skip passkeys whose private key we cannot import into WebCrypto. } } } } return { entries, totalItems }; } function parsePeachLegacyJSON(raw: string): { entries: ImportableEntry[]; totalItems: number; skippedItems: number; } { const data = JSON.parse(raw) as { exportFormat: string; entries: Array<{ id: string; type: string; modified: number; created: number; name: string; favorite: boolean; tags: string[]; login?: { urls?: string[]; username?: string; password?: string; totp?: { secret: string; algorithm: string; digits: number; period: number; }; customFields?: unknown[]; notes?: string; }; }>; }; const totalItems = data.entries.length; const filtered = data.entries.filter((e) => e.type === "login"); const skippedItems = totalItems - filtered.length; return { totalItems, skippedItems, entries: filtered.map((entry) => ({ type: "login" as const, title: entry.name ?? "Untitled", url: entry.login?.urls?.[0] ?? "", username: entry.login?.username ?? "", password: entry.login?.password ?? "", totp: entry.login?.totp?.secret ?? undefined, notes: entry.login?.notes ?? undefined, })), }; } function has1PasswordShape(value: unknown): boolean { if (!value || typeof value !== "object") return false; const v = value as { accounts?: unknown[]; items?: unknown[] }; if (Array.isArray(v.accounts)) return true; if (!Array.isArray(v.items) || v.items.length === 0) return false; const sample = v.items[0] as any; return Boolean(sample?.details?.loginFields || sample?.overview?.title); } function hasBitwardenShape(value: unknown): boolean { return Boolean( value && typeof value === "object" && Array.isArray((value as { items?: unknown[] }).items) ); } function hasCredentialExchangeShape(value: unknown): boolean { return Boolean( value && typeof value === "object" && (value as { version?: { major?: number; minor?: number }; accounts?: unknown[] }).version && Array.isArray((value as { accounts?: unknown[] }).accounts) ); } function hasProtonPassShape(value: unknown): boolean { return Boolean( value && typeof value === "object" && (value as { vaults?: unknown }).vaults && typeof (value as { vaults?: unknown }).vaults === "object" ); } function hasPeachBackupShape(value: unknown): boolean { if (!value || typeof value !== "object") return false; const envelope = value as Partial<{ type: string; version: number; kdf: string; salt: string; nonce: string; ciphertext: string; tag: string; }>; return ( envelope.type === "peach-export" && envelope.version === 2 && envelope.kdf === "PBKDF2-SHA256" && typeof envelope.salt === "string" && typeof envelope.nonce === "string" && typeof envelope.ciphertext === "string" && typeof envelope.tag === "string" ); } function pickFirstField( row: Record, candidates: string[] ): string { for (const key of candidates) { const value = row[key]; if (typeof value === "string" && value.trim()) { return value.trim(); } } return ""; } function normalizeTotp(value?: string): string | undefined { if (!value) return undefined; const parsed = value.startsWith("otpauth://") ? parseOtpauthURI(value) : null; return parsed?.secret ?? value.trim() ?? undefined; } function firstNonEmptyString( ...values: Array ): string { for (const value of values) { if (typeof value === "string" && value.trim()) { return value.trim(); } } return ""; } function stringOrUndefined(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } function stringOrEmpty(value: unknown): string { return typeof value === "string" ? value : ""; }