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 30 licenseKey?: string;31 licenseActivatedAt?: number;32}3334export async function parseImportSource(35 raw: string,36 password?: string37): Promise<ImportParseResult> {38 const format = detectImportFormat(raw);3940 switch (format) {41 case "csv": {42 const entries = parseCSV(raw);43 const totalItems = Papa.parse<Record<string, string>>(raw, {44 header: true,45 skipEmptyLines: true,46 }).data.length;47 return {48 format,49 label: "CSV import",50 entries,51 totalItems,52 skippedItems: Math.max(0, totalItems - entries.length),53 requiresPassword: false,54 };55 }56 case "1password-json": {57 const entries = parse1PasswordJSON(raw);58 return {59 format,60 label: "1Password JSON",61 entries,62 totalItems: entries.length,63 skippedItems: 0,64 requiresPassword: false,65 };66 }67 case "bitwarden-json": {68 const { entries, totalItems } = parseBitwardenJSON(raw);69 return {70 format,71 label: "Bitwarden JSON",72 entries,73 totalItems,74 skippedItems: Math.max(0, totalItems - entries.length),75 requiresPassword: false,76 };77 }78 case "credential-exchange-json": {79 const { entries, totalItems } = await parseCredentialExchangeJSON(raw);80 return {81 format,82 label: "Credential Exchange JSON",83 entries,84 totalItems,85 skippedItems: Math.max(0, totalItems - entries.length),86 requiresPassword: false,87 };88 }89 case "protonpass-json": {90 const { entries, totalItems } = parseProtonPassJSON(raw);91 return {92 format,93 label: "Proton Pass JSON",94 entries,95 totalItems,96 skippedItems: Math.max(0, totalItems - entries.length),97 requiresPassword: false,98 };99 }100 case "peach-legacy-json": {101 const { entries, totalItems, skippedItems } = parsePeachLegacyJSON(raw);102 return {103 format,104 label: "Peach (legacy)",105 entries,106 totalItems,107 skippedItems,108 requiresPassword: false,109 };110 }111 case "peach-json": {112 if (!password) {113 return {114 format,115 label: "Peach encrypted backup",116 entries: [],117 totalItems: 0,118 skippedItems: 0,119 requiresPassword: true,120 };121 }122123 const result = await importEntriesFromPeachJson(raw, password);124 return {125 format,126 label: "Peach encrypted backup",127 entries: result.entries,128 totalItems: result.entries.length,129 skippedItems: 0,130 requiresPassword: false,131 ...(result.licenseKey ? { licenseKey: result.licenseKey, licenseActivatedAt: result.licenseActivatedAt } : {}),132 };133 }134 default:135 return {136 format: "unknown",137 label: "Unknown format",138 entries: [],139 totalItems: 0,140 skippedItems: 0,141 requiresPassword: false,142 };143 }144}145146export function detectImportFormat(raw: string): ImportFormat {147 const trimmed = raw.trim();148 if (trimmed.startsWith("{") || trimmed.startsWith("[")) {149 try {150 const data = JSON.parse(trimmed);151 if (hasPeachBackupShape(data)) return "peach-json";152 if ((data as any)?.exportFormat === "peach-vault")153 return "peach-legacy-json";154 if (hasCredentialExchangeShape(data)) return "credential-exchange-json";155 if (has1PasswordShape(data)) return "1password-json";156 if (hasBitwardenShape(data)) return "bitwarden-json";157 if (hasProtonPassShape(data)) return "protonpass-json";158 return "unknown";159 } catch {160 return "unknown";161 }162 }163164 return trimmed.includes(",") ? "csv" : "unknown";165}166167export function parse1PasswordJSON(raw: string): ImportableEntry[] {168 const data = JSON.parse(raw);169 const entries: ImportableEntry[] = [];170171 const itemGroups: any[][] = [];172 const vaults = data?.accounts?.[0]?.vaults ?? [];173 for (const vault of vaults) itemGroups.push(vault.items ?? []);174 if (Array.isArray(data?.items)) itemGroups.push(data.items);175176 for (const items of itemGroups) {177 for (const item of items) {178 if (item.trashed) continue;179 const fields = item.details?.loginFields ?? item.fields ?? [];180 const username =181 fields.find((field: any) => field.designation === "username" || field.id === "username")?.value ??182 "";183 const password =184 fields.find((field: any) => field.designation === "password" || field.id === "password")?.value ??185 "";186 if (!password) continue;187 entries.push({188 type: "login",189 title: (item.overview?.title ?? item.title)?.trim() || "Untitled",190 username: username.trim(),191 password,192 url: ((item.overview?.url ?? item.url) ?? "").trim(),193 notes: stringOrUndefined(item.details?.notesPlain ?? item.notes),194 });195 }196 }197198 return entries;199}200201function parseCSV(csvText: string): ImportableEntry[] {202 const result = Papa.parse<Record<string, string>>(csvText, {203 header: true,204 skipEmptyLines: true,205 transformHeader: (header) => header.trim(),206 });207208 return result.data.reduce<ImportableEntry[]>((entries, row) => {209 const title = pickFirstField(row, [210 "name",211 "title",212 "Title",213 "Name",214 "item",215 ]);216 const url = pickFirstField(row, [217 "url",218 "website",219 "URL",220 "Website",221 "login_uri",222 "loginUrl",223 ]);224 const username = pickFirstField(row, [225 "username",226 "login",227 "email",228 "Username",229 "login_username",230 "user",231 ]);232 const password = pickFirstField(row, [233 "password",234 "Password",235 "login_password",236 ]);237 const notes = pickFirstField(row, [238 "notes",239 "Notes",240 "note",241 "comment",242 "extra",243 ]);244 const totp = normalizeTotp(245 pickFirstField(row, [246 "totp",247 "totpUri",248 "otp",249 "otpSecret",250 "two-factor-secret",251 "login_totp",252 ])253 );254 const grouping = pickFirstField(row, ["grouping", "Grouping", "folder", "Folder"]);255256 if (!password) return entries;257258 entries.push({259 type: "login",260 title: firstNonEmptyString(title, url, username, "Untitled"),261 username,262 password,263 url,264 notes: notes || undefined,265 totp,266 tags: grouping ? [grouping] : undefined,267 });268 return entries;269 }, []);270}271272function parseBitwardenJSON(raw: string): {273 entries: ImportableEntry[];274 totalItems: number;275} {276 const data = JSON.parse(raw);277 const items: any[] = Array.isArray(data?.items) ? data.items : [];278 const loginItems = items.filter((item) => item?.type === 1);279280 return {281 totalItems: loginItems.length,282 entries: loginItems.reduce<ImportableEntry[]>((entries, item) => {283 const password = stringOrEmpty(item.login?.password);284 if (!password) return entries;285286 entries.push({287 type: "login",288 title: firstNonEmptyString(item.name, "Untitled"),289 url: firstNonEmptyString(item.login?.uris?.[0]?.uri, ""),290 username: firstNonEmptyString(item.login?.username, ""),291 password,292 notes: stringOrUndefined(item.notes),293 totp: normalizeTotp(stringOrUndefined(item.login?.totp)),294 });295 return entries;296 }, []),297 };298}299300function parseProtonPassJSON(raw: string): {301 entries: ImportableEntry[];302 totalItems: number;303} {304 const data = JSON.parse(raw) as {305 vaults?: Record<string, { name?: string; items?: any[] }>;306 };307 const vaults = data?.vaults && typeof data.vaults === "object" ? data.vaults : {};308 const entries: ImportableEntry[] = [];309 let totalItems = 0;310311 for (const vault of Object.values(vaults)) {312 if (vault?.name?.toLowerCase() === "recycle bin") continue;313314 for (const item of vault?.items ?? []) {315 const itemData = item?.data ?? item;316 const itemType = itemData?.type ?? item?.type;317 if (itemType !== "login") continue;318319 totalItems += 1;320321 const content = itemData?.content ?? {};322 const metadata = itemData?.metadata ?? {};323 const password = firstNonEmptyString(324 content.password,325 content.itemPassword326 );327 if (!password) continue;328329 entries.push({330 type: "login",331 title: firstNonEmptyString(332 metadata.name,333 item.name,334 item.title,335 content.itemName,336 "Untitled"337 ),338 username: firstNonEmptyString(339 content.username,340 content.itemUsername,341 content.email,342 content.itemEmail,343 item.aliasEmail,344 ""345 ),346 password,347 url: firstNonEmptyString(348 Array.isArray(content.urls) ? content.urls[0] : undefined,349 content.url,350 ""351 ),352 notes: stringOrUndefined(metadata.note ?? metadata.notes),353 totp: normalizeTotp(354 firstNonEmptyString(content.totpUri, content.itemTotpUri)355 ),356 });357 }358 }359360 return { entries, totalItems };361}362363async function parseCredentialExchangeJSON(raw: string): Promise<{364 entries: ImportableEntry[];365 totalItems: number;366}> {367 const data = JSON.parse(raw) as {368 accounts?: Array<{369 items?: Array<{370 title?: string;371 modifiedAt?: number;372 creationAt?: number;373 credentials?: Array<{374 type?: string;375 credentialId?: string;376 rpId?: string;377 username?: string;378 userDisplayName?: string;379 userHandle?: string;380 key?: string;381 }>;382 }>;383 }>;384 };385386 const entries: ImportableEntry[] = [];387 let totalItems = 0;388389 for (const account of data.accounts ?? []) {390 for (const item of account.items ?? []) {391 for (const credential of item.credentials ?? []) {392 if (credential?.type !== "passkey") continue;393 totalItems += 1;394395 if (396 !credential.credentialId ||397 !credential.rpId ||398 !credential.username ||399 !credential.userDisplayName ||400 !credential.userHandle ||401 !credential.key402 ) {403 continue;404 }405406 try {407 const pkcs8 = base64urlDecode(credential.key);408 const imported = await importPasskeyPrivateKey(pkcs8);409 entries.push({410 type: "passkey",411 title: firstNonEmptyString(item.title, credential.rpId),412 rpId: credential.rpId,413 rpName: firstNonEmptyString(item.title, credential.rpId),414 credentialId: credential.credentialId,415 publicKeyCose: base64urlEncode(imported.publicKeyCose),416 privateKeyJwk: imported.privateKeyJwk,417 userHandle: credential.userHandle,418 userName: credential.username,419 userDisplayName: credential.userDisplayName,420 algorithm: imported.algorithm,421 signCount: 0,422 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",423 backupEligible: true,424 backupState: false,425 });426 } catch {427 428 }429 }430 }431 }432433 return { entries, totalItems };434}435436function parsePeachLegacyJSON(raw: string): {437 entries: ImportableEntry[];438 totalItems: number;439 skippedItems: number;440} {441 const data = JSON.parse(raw) as {442 exportFormat: string;443 entries: Array<{444 id: string;445 type: string;446 modified: number;447 created: number;448 name: string;449 favorite: boolean;450 tags: string[];451 login?: {452 urls?: string[];453 username?: string;454 password?: string;455 totp?: {456 secret: string;457 algorithm: string;458 digits: number;459 period: number;460 };461 customFields?: unknown[];462 notes?: string;463 };464 }>;465 };466467 const totalItems = data.entries.length;468 const filtered = data.entries.filter((e) => e.type === "login");469 const skippedItems = totalItems - filtered.length;470471 return {472 totalItems,473 skippedItems,474 entries: filtered.map((entry) => ({475 type: "login" as const,476 title: entry.name ?? "Untitled",477 url: entry.login?.urls?.[0] ?? "",478 username: entry.login?.username ?? "",479 password: entry.login?.password ?? "",480 totp: entry.login?.totp?.secret ?? undefined,481 notes: entry.login?.notes ?? undefined,482 })),483 };484}485486function has1PasswordShape(value: unknown): boolean {487 if (!value || typeof value !== "object") return false;488 const v = value as { accounts?: unknown[]; items?: unknown[] };489 if (Array.isArray(v.accounts)) return true;490 if (!Array.isArray(v.items) || v.items.length === 0) return false;491 const sample = v.items[0] as any;492 return Boolean(sample?.details?.loginFields || sample?.overview?.title);493}494495function hasBitwardenShape(value: unknown): boolean {496 return Boolean(497 value &&498 typeof value === "object" &&499 Array.isArray((value as { items?: unknown[] }).items)500 );501}502503function hasCredentialExchangeShape(value: unknown): boolean {504 return Boolean(505 value &&506 typeof value === "object" &&507 (value as { version?: { major?: number; minor?: number }; accounts?: unknown[] }).version &&508 Array.isArray((value as { accounts?: unknown[] }).accounts)509 );510}511512function hasProtonPassShape(value: unknown): boolean {513 return Boolean(514 value &&515 typeof value === "object" &&516 (value as { vaults?: unknown }).vaults &&517 typeof (value as { vaults?: unknown }).vaults === "object"518 );519}520521function hasPeachBackupShape(value: unknown): boolean {522 if (!value || typeof value !== "object") return false;523 const envelope = value as Partial<{524 type: string;525 version: number;526 kdf: string;527 salt: string;528 nonce: string;529 ciphertext: string;530 tag: string;531 }>;532 return (533 envelope.type === "peach-export" &&534 envelope.version === 2 &&535 envelope.kdf === "PBKDF2-SHA256" &&536 typeof envelope.salt === "string" &&537 typeof envelope.nonce === "string" &&538 typeof envelope.ciphertext === "string" &&539 typeof envelope.tag === "string"540 );541}542543function pickFirstField(544 row: Record<string, string>,545 candidates: string[]546): string {547 for (const key of candidates) {548 const value = row[key];549 if (typeof value === "string" && value.trim()) {550 return value.trim();551 }552 }553 return "";554}555556function normalizeTotp(value?: string): string | undefined {557 if (!value) return undefined;558 const parsed = value.startsWith("otpauth://") ? parseOtpauthURI(value) : null;559 return parsed?.secret ?? value.trim() ?? undefined;560}561562function firstNonEmptyString(563 ...values: Array<string | null | undefined>564): string {565 for (const value of values) {566 if (typeof value === "string" && value.trim()) {567 return value.trim();568 }569 }570 return "";571}572573function stringOrUndefined(value: unknown): string | undefined {574 return typeof value === "string" && value.trim() ? value.trim() : undefined;575}576577function stringOrEmpty(value: unknown): string {578 return typeof value === "string" ? value : "";579}