1import Papa from "papaparse";23import {4 base64urlDecode,5 base64urlEncode,6 importPasskeyPrivateKey,7} from "../background/passkey-crypto";8import { parseOtpauthURI } from "../crypto/totp";9import { importEntriesFromPeachJson, type ImportableEntry } from "./backup";1011export type ImportFormat =12 | "csv"13 | "1password-json"14 | "bitwarden-json"15 | "credential-exchange-json"16 | "protonpass-json"17 | "peach-json"18 | "peach-legacy-json"19 | "unknown";2021export interface ImportParseResult {22 format: ImportFormat;23 label: string;24 entries: ImportableEntry[];25 totalItems: number;26 skippedItems: number;27 requiresPassword: boolean;28 29 licenseKey?: string;30 licenseActivatedAt?: number;31}3233export async function parseImportSource(34 raw: string,35 password?: string36): Promise<ImportParseResult> {37 const format = detectImportFormat(raw);3839 switch (format) {40 case "csv": {41 const entries = parseCSV(raw);42 const totalItems = Papa.parse<Record<string, string>>(raw, {43 header: true,44 skipEmptyLines: true,45 }).data.length;46 return {47 format,48 label: "CSV import",49 entries,50 totalItems,51 skippedItems: Math.max(0, totalItems - entries.length),52 requiresPassword: false,53 };54 }55 case "1password-json": {56 const entries = parse1PasswordJSON(raw);57 return {58 format,59 label: "1Password JSON",60 entries,61 totalItems: entries.length,62 skippedItems: 0,63 requiresPassword: false,64 };65 }66 case "bitwarden-json": {67 const { entries, totalItems } = parseBitwardenJSON(raw);68 return {69 format,70 label: "Bitwarden JSON",71 entries,72 totalItems,73 skippedItems: Math.max(0, totalItems - entries.length),74 requiresPassword: false,75 };76 }77 case "credential-exchange-json": {78 const { entries, totalItems } = await parseCredentialExchangeJSON(raw);79 return {80 format,81 label: "Credential Exchange JSON",82 entries,83 totalItems,84 skippedItems: Math.max(0, totalItems - entries.length),85 requiresPassword: false,86 };87 }88 case "protonpass-json": {89 const { entries, totalItems } = parseProtonPassJSON(raw);90 return {91 format,92 label: "Proton Pass JSON",93 entries,94 totalItems,95 skippedItems: Math.max(0, totalItems - entries.length),96 requiresPassword: false,97 };98 }99 case "peach-legacy-json": {100 const { entries, totalItems, skippedItems } = parsePeachLegacyJSON(raw);101 return {102 format,103 label: "Peach (legacy)",104 entries,105 totalItems,106 skippedItems,107 requiresPassword: false,108 };109 }110 case "peach-json": {111 if (!password) {112 return {113 format,114 label: "Peach encrypted backup",115 entries: [],116 totalItems: 0,117 skippedItems: 0,118 requiresPassword: true,119 };120 }121122 const result = await importEntriesFromPeachJson(raw, password);123 return {124 format,125 label: "Peach encrypted backup",126 entries: result.entries,127 totalItems: result.entries.length,128 skippedItems: 0,129 requiresPassword: false,130 ...(result.licenseKey ? { licenseKey: result.licenseKey, licenseActivatedAt: result.licenseActivatedAt } : {}),131 };132 }133 default:134 return {135 format: "unknown",136 label: "Unknown format",137 entries: [],138 totalItems: 0,139 skippedItems: 0,140 requiresPassword: false,141 };142 }143}144145export function detectImportFormat(raw: string): ImportFormat {146 const trimmed = raw.trim();147 if (trimmed.startsWith("{") || trimmed.startsWith("[")) {148 try {149 const data = JSON.parse(trimmed);150 if (hasPeachBackupShape(data)) return "peach-json";151 if ((data as any)?.exportFormat === "peach-vault")152 return "peach-legacy-json";153 if (hasCredentialExchangeShape(data)) return "credential-exchange-json";154 if (has1PasswordShape(data)) return "1password-json";155 if (hasBitwardenShape(data)) return "bitwarden-json";156 if (hasProtonPassShape(data)) return "protonpass-json";157 return "unknown";158 } catch {159 return "unknown";160 }161 }162163 return trimmed.includes(",") ? "csv" : "unknown";164}165166export function parse1PasswordJSON(raw: string): ImportableEntry[] {167 const data = JSON.parse(raw);168 const entries: ImportableEntry[] = [];169170 const itemGroups: any[][] = [];171 const vaults = data?.accounts?.[0]?.vaults ?? [];172 for (const vault of vaults) itemGroups.push(vault.items ?? []);173 if (Array.isArray(data?.items)) itemGroups.push(data.items);174175 for (const items of itemGroups) {176 for (const item of items) {177 if (item.trashed) continue;178 const fields = item.details?.loginFields ?? item.fields ?? [];179 const username =180 fields.find((field: any) => field.designation === "username" || field.id === "username")?.value ??181 "";182 const password =183 fields.find((field: any) => field.designation === "password" || field.id === "password")?.value ??184 "";185 if (!password) continue;186 entries.push({187 type: "login",188 title: (item.overview?.title ?? item.title)?.trim() || "Untitled",189 username: username.trim(),190 password,191 url: ((item.overview?.url ?? item.url) ?? "").trim(),192 notes: stringOrUndefined(item.details?.notesPlain ?? item.notes),193 });194 }195 }196197 return entries;198}199200function parseCSV(csvText: string): ImportableEntry[] {201 const result = Papa.parse<Record<string, string>>(csvText, {202 header: true,203 skipEmptyLines: true,204 transformHeader: (header) => header.trim(),205 });206207 return result.data.reduce<ImportableEntry[]>((entries, row) => {208 const title = pickFirstField(row, [209 "name",210 "title",211 "Title",212 "Name",213 "item",214 ]);215 const url = pickFirstField(row, [216 "url",217 "website",218 "URL",219 "Website",220 "login_uri",221 "loginUrl",222 ]);223 const username = pickFirstField(row, [224 "username",225 "login",226 "email",227 "Username",228 "login_username",229 "user",230 ]);231 const password = pickFirstField(row, [232 "password",233 "Password",234 "login_password",235 ]);236 const notes = pickFirstField(row, [237 "notes",238 "Notes",239 "note",240 "comment",241 "extra",242 ]);243 const totp = normalizeTotp(244 pickFirstField(row, [245 "totp",246 "totpUri",247 "otp",248 "otpSecret",249 "two-factor-secret",250 "login_totp",251 ])252 );253 const grouping = pickFirstField(row, ["grouping", "Grouping", "folder", "Folder"]);254255 if (!password) return entries;256257 entries.push({258 type: "login",259 title: firstNonEmptyString(title, url, username, "Untitled"),260 username,261 password,262 url,263 notes: notes || undefined,264 totp,265 tags: grouping ? [grouping] : undefined,266 });267 return entries;268 }, []);269}270271function parseBitwardenJSON(raw: string): {272 entries: ImportableEntry[];273 totalItems: number;274} {275 const data = JSON.parse(raw);276 const items: any[] = Array.isArray(data?.items) ? data.items : [];277 const loginItems = items.filter((item) => item?.type === 1);278279 return {280 totalItems: loginItems.length,281 entries: loginItems.reduce<ImportableEntry[]>((entries, item) => {282 const password = stringOrEmpty(item.login?.password);283 if (!password) return entries;284285 entries.push({286 type: "login",287 title: firstNonEmptyString(item.name, "Untitled"),288 url: firstNonEmptyString(item.login?.uris?.[0]?.uri, ""),289 username: firstNonEmptyString(item.login?.username, ""),290 password,291 notes: stringOrUndefined(item.notes),292 totp: normalizeTotp(stringOrUndefined(item.login?.totp)),293 });294 return entries;295 }, []),296 };297}298299function parseProtonPassJSON(raw: string): {300 entries: ImportableEntry[];301 totalItems: number;302} {303 const data = JSON.parse(raw) as {304 vaults?: Record<string, { name?: string; items?: any[] }>;305 };306 const vaults = data?.vaults && typeof data.vaults === "object" ? data.vaults : {};307 const entries: ImportableEntry[] = [];308 let totalItems = 0;309310 for (const vault of Object.values(vaults)) {311 if (vault?.name?.toLowerCase() === "recycle bin") continue;312313 for (const item of vault?.items ?? []) {314 const itemData = item?.data ?? item;315 const itemType = itemData?.type ?? item?.type;316 if (itemType !== "login") continue;317318 totalItems += 1;319320 const content = itemData?.content ?? {};321 const metadata = itemData?.metadata ?? {};322 const password = firstNonEmptyString(323 content.password,324 content.itemPassword325 );326 if (!password) continue;327328 entries.push({329 type: "login",330 title: firstNonEmptyString(331 metadata.name,332 item.name,333 item.title,334 content.itemName,335 "Untitled"336 ),337 username: firstNonEmptyString(338 content.username,339 content.itemUsername,340 content.email,341 content.itemEmail,342 item.aliasEmail,343 ""344 ),345 password,346 url: firstNonEmptyString(347 Array.isArray(content.urls) ? content.urls[0] : undefined,348 content.url,349 ""350 ),351 notes: stringOrUndefined(metadata.note ?? metadata.notes),352 totp: normalizeTotp(353 firstNonEmptyString(content.totpUri, content.itemTotpUri)354 ),355 });356 }357 }358359 return { entries, totalItems };360}361362async function parseCredentialExchangeJSON(raw: string): Promise<{363 entries: ImportableEntry[];364 totalItems: number;365}> {366 const data = JSON.parse(raw) as {367 accounts?: Array<{368 items?: Array<{369 title?: string;370 modifiedAt?: number;371 creationAt?: number;372 credentials?: Array<{373 type?: string;374 credentialId?: string;375 rpId?: string;376 username?: string;377 userDisplayName?: string;378 userHandle?: string;379 key?: string;380 }>;381 }>;382 }>;383 };384385 const entries: ImportableEntry[] = [];386 let totalItems = 0;387388 for (const account of data.accounts ?? []) {389 for (const item of account.items ?? []) {390 for (const credential of item.credentials ?? []) {391 if (credential?.type !== "passkey") continue;392 totalItems += 1;393394 if (395 !credential.credentialId ||396 !credential.rpId ||397 !credential.username ||398 !credential.userDisplayName ||399 !credential.userHandle ||400 !credential.key401 ) {402 continue;403 }404405 try {406 const pkcs8 = base64urlDecode(credential.key);407 const imported = await importPasskeyPrivateKey(pkcs8);408 entries.push({409 type: "passkey",410 title: firstNonEmptyString(item.title, credential.rpId),411 rpId: credential.rpId,412 rpName: firstNonEmptyString(item.title, credential.rpId),413 credentialId: credential.credentialId,414 publicKeyCose: base64urlEncode(imported.publicKeyCose),415 privateKeyJwk: imported.privateKeyJwk,416 userHandle: credential.userHandle,417 userName: credential.username,418 userDisplayName: credential.userDisplayName,419 algorithm: imported.algorithm,420 signCount: 0,421 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",422 backupEligible: true,423 backupState: false,424 });425 } catch {426 427 }428 }429 }430 }431432 return { entries, totalItems };433}434435function parsePeachLegacyJSON(raw: string): {436 entries: ImportableEntry[];437 totalItems: number;438 skippedItems: number;439} {440 const data = JSON.parse(raw) as {441 exportFormat: string;442 entries: Array<{443 id: string;444 type: string;445 modified: number;446 created: number;447 name: string;448 favorite: boolean;449 tags: string[];450 login?: {451 urls?: string[];452 username?: string;453 password?: string;454 totp?: {455 secret: string;456 algorithm: string;457 digits: number;458 period: number;459 };460 customFields?: unknown[];461 notes?: string;462 };463 }>;464 };465466 const totalItems = data.entries.length;467 const filtered = data.entries.filter((e) => e.type === "login");468 const skippedItems = totalItems - filtered.length;469470 return {471 totalItems,472 skippedItems,473 entries: filtered.map((entry) => ({474 type: "login" as const,475 title: entry.name ?? "Untitled",476 url: entry.login?.urls?.[0] ?? "",477 username: entry.login?.username ?? "",478 password: entry.login?.password ?? "",479 totp: entry.login?.totp?.secret ?? undefined,480 notes: entry.login?.notes ?? undefined,481 })),482 };483}484485function has1PasswordShape(value: unknown): boolean {486 if (!value || typeof value !== "object") return false;487 const v = value as { accounts?: unknown[]; items?: unknown[] };488 if (Array.isArray(v.accounts)) return true;489 if (!Array.isArray(v.items) || v.items.length === 0) return false;490 const sample = v.items[0] as any;491 return Boolean(sample?.details?.loginFields || sample?.overview?.title);492}493494function hasBitwardenShape(value: unknown): boolean {495 return Boolean(496 value &&497 typeof value === "object" &&498 Array.isArray((value as { items?: unknown[] }).items)499 );500}501502function hasCredentialExchangeShape(value: unknown): boolean {503 return Boolean(504 value &&505 typeof value === "object" &&506 (value as { version?: { major?: number; minor?: number }; accounts?: unknown[] }).version &&507 Array.isArray((value as { accounts?: unknown[] }).accounts)508 );509}510511function hasProtonPassShape(value: unknown): boolean {512 return Boolean(513 value &&514 typeof value === "object" &&515 (value as { vaults?: unknown }).vaults &&516 typeof (value as { vaults?: unknown }).vaults === "object"517 );518}519520function hasPeachBackupShape(value: unknown): boolean {521 if (!value || typeof value !== "object") return false;522 const envelope = value as Partial<{523 type: string;524 version: number;525 kdf: string;526 salt: string;527 nonce: string;528 ciphertext: string;529 tag: string;530 }>;531 return (532 envelope.type === "peach-export" &&533 envelope.version === 2 &&534 envelope.kdf === "PBKDF2-SHA256" &&535 typeof envelope.salt === "string" &&536 typeof envelope.nonce === "string" &&537 typeof envelope.ciphertext === "string" &&538 typeof envelope.tag === "string"539 );540}541542function pickFirstField(543 row: Record<string, string>,544 candidates: string[]545): string {546 for (const key of candidates) {547 const value = row[key];548 if (typeof value === "string" && value.trim()) {549 return value.trim();550 }551 }552 return "";553}554555function normalizeTotp(value?: string): string | undefined {556 if (!value) return undefined;557 const parsed = value.startsWith("otpauth://") ? parseOtpauthURI(value) : null;558 return parsed?.secret ?? value.trim() ?? undefined;559}560561function firstNonEmptyString(562 ...values: Array<string | null | undefined>563): string {564 for (const value of values) {565 if (typeof value === "string" && value.trim()) {566 return value.trim();567 }568 }569 return "";570}571572function stringOrUndefined(value: unknown): string | undefined {573 return typeof value === "string" && value.trim() ? value.trim() : undefined;574}575576function stringOrEmpty(value: unknown): string {577 return typeof value === "string" ? value : "";578}