12345678import Papa from "papaparse";910import {11 base64urlDecode,12 base64urlEncode,13 importPasskeyPrivateKey,14} from "../background/passkey-crypto";15import { parseOtpauthURI } from "../crypto/totp";16import { importEntriesFromPeachJson, type ImportableEntry } from "./backup";1718export type ImportFormat =19 | "csv"20 | "1password-json"21 | "bitwarden-json"22 | "credential-exchange-json"23 | "protonpass-json"24 | "peach-json"25 | "peach-legacy-json"26 | "unknown";2728export interface ImportParseResult {29 format: ImportFormat;30 label: string;31 entries: ImportableEntry[];32 totalItems: number;33 skippedItems: number;34 requiresPassword: boolean;35 36 licenseKey?: string;37 licenseActivatedAt?: number;38}3940export async function parseImportSource(41 raw: string,42 password?: string43): Promise<ImportParseResult> {44 const format = detectImportFormat(raw);4546 switch (format) {47 case "csv": {48 const entries = parseCSV(raw);49 const totalItems = Papa.parse<Record<string, string>>(raw, {50 header: true,51 skipEmptyLines: true,52 }).data.length;53 return {54 format,55 label: "CSV import",56 entries,57 totalItems,58 skippedItems: Math.max(0, totalItems - entries.length),59 requiresPassword: false,60 };61 }62 case "1password-json": {63 const entries = parse1PasswordJSON(raw);64 return {65 format,66 label: "1Password JSON",67 entries,68 totalItems: entries.length,69 skippedItems: 0,70 requiresPassword: false,71 };72 }73 case "bitwarden-json": {74 const { entries, totalItems } = parseBitwardenJSON(raw);75 return {76 format,77 label: "Bitwarden JSON",78 entries,79 totalItems,80 skippedItems: Math.max(0, totalItems - entries.length),81 requiresPassword: false,82 };83 }84 case "credential-exchange-json": {85 const { entries, totalItems } = await parseCredentialExchangeJSON(raw);86 return {87 format,88 label: "Credential Exchange JSON",89 entries,90 totalItems,91 skippedItems: Math.max(0, totalItems - entries.length),92 requiresPassword: false,93 };94 }95 case "protonpass-json": {96 const { entries, totalItems } = parseProtonPassJSON(raw);97 return {98 format,99 label: "Proton Pass JSON",100 entries,101 totalItems,102 skippedItems: Math.max(0, totalItems - entries.length),103 requiresPassword: false,104 };105 }106 case "peach-legacy-json": {107 const { entries, totalItems, skippedItems } = parsePeachLegacyJSON(raw);108 return {109 format,110 label: "Peach (legacy)",111 entries,112 totalItems,113 skippedItems,114 requiresPassword: false,115 };116 }117 case "peach-json": {118 if (!password) {119 return {120 format,121 label: "Peach encrypted backup",122 entries: [],123 totalItems: 0,124 skippedItems: 0,125 requiresPassword: true,126 };127 }128129 const result = await importEntriesFromPeachJson(raw, password);130 return {131 format,132 label: "Peach encrypted backup",133 entries: result.entries,134 totalItems: result.entries.length,135 skippedItems: 0,136 requiresPassword: false,137 ...(result.licenseKey ? { licenseKey: result.licenseKey, licenseActivatedAt: result.licenseActivatedAt } : {}),138 };139 }140 default:141 return {142 format: "unknown",143 label: "Unknown format",144 entries: [],145 totalItems: 0,146 skippedItems: 0,147 requiresPassword: false,148 };149 }150}151152export function detectImportFormat(raw: string): ImportFormat {153 const trimmed = raw.trim();154 if (trimmed.startsWith("{") || trimmed.startsWith("[")) {155 try {156 const data = JSON.parse(trimmed);157 if (hasPeachBackupShape(data)) return "peach-json";158 if ((data as any)?.exportFormat === "peach-vault")159 return "peach-legacy-json";160 if (hasCredentialExchangeShape(data)) return "credential-exchange-json";161 if (has1PasswordShape(data)) return "1password-json";162 if (hasBitwardenShape(data)) return "bitwarden-json";163 if (hasProtonPassShape(data)) return "protonpass-json";164 return "unknown";165 } catch {166 return "unknown";167 }168 }169170 return trimmed.includes(",") ? "csv" : "unknown";171}172173export function parse1PasswordJSON(raw: string): ImportableEntry[] {174 const data = JSON.parse(raw);175 const entries: ImportableEntry[] = [];176177 const itemGroups: any[][] = [];178 const vaults = data?.accounts?.[0]?.vaults ?? [];179 for (const vault of vaults) itemGroups.push(vault.items ?? []);180 if (Array.isArray(data?.items)) itemGroups.push(data.items);181182 for (const items of itemGroups) {183 for (const item of items) {184 if (item.trashed) continue;185 const fields = item.details?.loginFields ?? item.fields ?? [];186 const username =187 fields.find((field: any) => field.designation === "username" || field.id === "username")?.value ??188 "";189 const password =190 fields.find((field: any) => field.designation === "password" || field.id === "password")?.value ??191 "";192 if (!password) continue;193 entries.push({194 type: "login",195 title: (item.overview?.title ?? item.title)?.trim() || "Untitled",196 username: username.trim(),197 password,198 url: ((item.overview?.url ?? item.url) ?? "").trim(),199 notes: stringOrUndefined(item.details?.notesPlain ?? item.notes),200 });201 }202 }203204 return entries;205}206207function parseCSV(csvText: string): ImportableEntry[] {208 const result = Papa.parse<Record<string, string>>(csvText, {209 header: true,210 skipEmptyLines: true,211 transformHeader: (header) => header.trim(),212 });213214 return result.data.reduce<ImportableEntry[]>((entries, row) => {215 const title = pickFirstField(row, [216 "name",217 "title",218 "Title",219 "Name",220 "item",221 ]);222 const url = pickFirstField(row, [223 "url",224 "website",225 "URL",226 "Website",227 "login_uri",228 "loginUrl",229 ]);230 const username = pickFirstField(row, [231 "username",232 "login",233 "email",234 "Username",235 "login_username",236 "user",237 ]);238 const password = pickFirstField(row, [239 "password",240 "Password",241 "login_password",242 ]);243 const notes = pickFirstField(row, [244 "notes",245 "Notes",246 "note",247 "comment",248 "extra",249 ]);250 const totp = normalizeTotp(251 pickFirstField(row, [252 "totp",253 "totpUri",254 "otp",255 "otpSecret",256 "two-factor-secret",257 "login_totp",258 ])259 );260 const grouping = pickFirstField(row, ["grouping", "Grouping", "folder", "Folder"]);261262 if (!password) return entries;263264 entries.push({265 type: "login",266 title: firstNonEmptyString(title, url, username, "Untitled"),267 username,268 password,269 url,270 notes: notes || undefined,271 totp,272 tags: grouping ? [grouping] : undefined,273 });274 return entries;275 }, []);276}277278function parseBitwardenJSON(raw: string): {279 entries: ImportableEntry[];280 totalItems: number;281} {282 const data = JSON.parse(raw);283 const items: any[] = Array.isArray(data?.items) ? data.items : [];284 const loginItems = items.filter((item) => item?.type === 1);285286 return {287 totalItems: loginItems.length,288 entries: loginItems.reduce<ImportableEntry[]>((entries, item) => {289 const password = stringOrEmpty(item.login?.password);290 if (!password) return entries;291292 entries.push({293 type: "login",294 title: firstNonEmptyString(item.name, "Untitled"),295 url: firstNonEmptyString(item.login?.uris?.[0]?.uri, ""),296 username: firstNonEmptyString(item.login?.username, ""),297 password,298 notes: stringOrUndefined(item.notes),299 totp: normalizeTotp(stringOrUndefined(item.login?.totp)),300 });301 return entries;302 }, []),303 };304}305306function parseProtonPassJSON(raw: string): {307 entries: ImportableEntry[];308 totalItems: number;309} {310 const data = JSON.parse(raw) as {311 vaults?: Record<string, { name?: string; items?: any[] }>;312 };313 const vaults = data?.vaults && typeof data.vaults === "object" ? data.vaults : {};314 const entries: ImportableEntry[] = [];315 let totalItems = 0;316317 for (const vault of Object.values(vaults)) {318 if (vault?.name?.toLowerCase() === "recycle bin") continue;319320 for (const item of vault?.items ?? []) {321 const itemData = item?.data ?? item;322 const itemType = itemData?.type ?? item?.type;323 if (itemType !== "login") continue;324325 totalItems += 1;326327 const content = itemData?.content ?? {};328 const metadata = itemData?.metadata ?? {};329 const password = firstNonEmptyString(330 content.password,331 content.itemPassword332 );333 if (!password) continue;334335 entries.push({336 type: "login",337 title: firstNonEmptyString(338 metadata.name,339 item.name,340 item.title,341 content.itemName,342 "Untitled"343 ),344 username: firstNonEmptyString(345 content.username,346 content.itemUsername,347 content.email,348 content.itemEmail,349 item.aliasEmail,350 ""351 ),352 password,353 url: firstNonEmptyString(354 Array.isArray(content.urls) ? content.urls[0] : undefined,355 content.url,356 ""357 ),358 notes: stringOrUndefined(metadata.note ?? metadata.notes),359 totp: normalizeTotp(360 firstNonEmptyString(content.totpUri, content.itemTotpUri)361 ),362 });363 }364 }365366 return { entries, totalItems };367}368369async function parseCredentialExchangeJSON(raw: string): Promise<{370 entries: ImportableEntry[];371 totalItems: number;372}> {373 const data = JSON.parse(raw) as {374 accounts?: Array<{375 items?: Array<{376 title?: string;377 modifiedAt?: number;378 creationAt?: number;379 credentials?: Array<{380 type?: string;381 credentialId?: string;382 rpId?: string;383 username?: string;384 userDisplayName?: string;385 userHandle?: string;386 key?: string;387 }>;388 }>;389 }>;390 };391392 const entries: ImportableEntry[] = [];393 let totalItems = 0;394395 for (const account of data.accounts ?? []) {396 for (const item of account.items ?? []) {397 for (const credential of item.credentials ?? []) {398 if (credential?.type !== "passkey") continue;399 totalItems += 1;400401 if (402 !credential.credentialId ||403 !credential.rpId ||404 !credential.username ||405 !credential.userDisplayName ||406 !credential.userHandle ||407 !credential.key408 ) {409 continue;410 }411412 try {413 const pkcs8 = base64urlDecode(credential.key);414 const imported = await importPasskeyPrivateKey(pkcs8);415 entries.push({416 type: "passkey",417 title: firstNonEmptyString(item.title, credential.rpId),418 rpId: credential.rpId,419 rpName: firstNonEmptyString(item.title, credential.rpId),420 credentialId: credential.credentialId,421 publicKeyCose: base64urlEncode(imported.publicKeyCose),422 privateKeyJwk: imported.privateKeyJwk,423 userHandle: credential.userHandle,424 userName: credential.username,425 userDisplayName: credential.userDisplayName,426 algorithm: imported.algorithm,427 signCount: 0,428 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",429 backupEligible: true,430 backupState: false,431 });432 } catch {433 434 }435 }436 }437 }438439 return { entries, totalItems };440}441442function parsePeachLegacyJSON(raw: string): {443 entries: ImportableEntry[];444 totalItems: number;445 skippedItems: number;446} {447 const data = JSON.parse(raw) as {448 exportFormat: string;449 entries: Array<{450 id: string;451 type: string;452 modified: number;453 created: number;454 name: string;455 favorite: boolean;456 tags: string[];457 login?: {458 urls?: string[];459 username?: string;460 password?: string;461 totp?: {462 secret: string;463 algorithm: string;464 digits: number;465 period: number;466 };467 customFields?: unknown[];468 notes?: string;469 };470 }>;471 };472473 const totalItems = data.entries.length;474 const filtered = data.entries.filter((e) => e.type === "login");475 const skippedItems = totalItems - filtered.length;476477 return {478 totalItems,479 skippedItems,480 entries: filtered.map((entry) => ({481 type: "login" as const,482 title: entry.name ?? "Untitled",483 url: entry.login?.urls?.[0] ?? "",484 username: entry.login?.username ?? "",485 password: entry.login?.password ?? "",486 totp: entry.login?.totp?.secret ?? undefined,487 notes: entry.login?.notes ?? undefined,488 })),489 };490}491492function has1PasswordShape(value: unknown): boolean {493 if (!value || typeof value !== "object") return false;494 const v = value as { accounts?: unknown[]; items?: unknown[] };495 if (Array.isArray(v.accounts)) return true;496 if (!Array.isArray(v.items) || v.items.length === 0) return false;497 const sample = v.items[0] as any;498 return Boolean(sample?.details?.loginFields || sample?.overview?.title);499}500501function hasBitwardenShape(value: unknown): boolean {502 return Boolean(503 value &&504 typeof value === "object" &&505 Array.isArray((value as { items?: unknown[] }).items)506 );507}508509function hasCredentialExchangeShape(value: unknown): boolean {510 return Boolean(511 value &&512 typeof value === "object" &&513 (value as { version?: { major?: number; minor?: number }; accounts?: unknown[] }).version &&514 Array.isArray((value as { accounts?: unknown[] }).accounts)515 );516}517518function hasProtonPassShape(value: unknown): boolean {519 return Boolean(520 value &&521 typeof value === "object" &&522 (value as { vaults?: unknown }).vaults &&523 typeof (value as { vaults?: unknown }).vaults === "object"524 );525}526527function hasPeachBackupShape(value: unknown): boolean {528 if (!value || typeof value !== "object") return false;529 const envelope = value as Partial<{530 type: string;531 version: number;532 kdf: string;533 salt: string;534 nonce: string;535 ciphertext: string;536 tag: string;537 }>;538 return (539 envelope.type === "peach-export" &&540 envelope.version === 2 &&541 envelope.kdf === "PBKDF2-SHA256" &&542 typeof envelope.salt === "string" &&543 typeof envelope.nonce === "string" &&544 typeof envelope.ciphertext === "string" &&545 typeof envelope.tag === "string"546 );547}548549function pickFirstField(550 row: Record<string, string>,551 candidates: string[]552): string {553 for (const key of candidates) {554 const value = row[key];555 if (typeof value === "string" && value.trim()) {556 return value.trim();557 }558 }559 return "";560}561562function normalizeTotp(value?: string): string | undefined {563 if (!value) return undefined;564 const parsed = value.startsWith("otpauth://") ? parseOtpauthURI(value) : null;565 return parsed?.secret ?? value.trim() ?? undefined;566}567568function firstNonEmptyString(569 ...values: Array<string | null | undefined>570): string {571 for (const value of values) {572 if (typeof value === "string" && value.trim()) {573 return value.trim();574 }575 }576 return "";577}578579function stringOrUndefined(value: unknown): string | undefined {580 return typeof value === "string" && value.trim() ? value.trim() : undefined;581}582583function stringOrEmpty(value: unknown): string {584 return typeof value === "string" ? value : "";585}