12345678import Papa from "papaparse";910import {11 base64urlDecode,12 base64urlEncode,13 importPasskeyPrivateKey,14} from "../background/passkey-crypto";15import { parseOtpauthURI } from "../crypto/totp";16import { IMPORT_LIMITS, importTooLarge } from "./import-limits";17import { importEntriesFromPeachJson, type ImportableEntry } from "./backup";1819export type ImportFormat =20 | "csv"21 | "1password-json"22 | "bitwarden-json"23 | "credential-exchange-json"24 | "protonpass-json"25 | "peach-json"26 | "peach-legacy-json"27 | "unknown";2829export interface ImportParseResult {30 format: ImportFormat;31 label: string;32 entries: ImportableEntry[];33 totalItems: number;34 skippedItems: number;35 requiresPassword: boolean;36 37 licenseKey?: string;38 licenseActivatedAt?: number;39}4041export async function parseImportSource(42 raw: string,43 password?: string44): Promise<ImportParseResult> {45 46 47 if (raw.length > IMPORT_LIMITS.MAX_JSON_BYTES) {48 throw importTooLarge(49 `import source ${raw.length} chars exceeds ${IMPORT_LIMITS.MAX_JSON_BYTES}`,50 );51 }52 const format = detectImportFormat(raw);5354 switch (format) {55 case "csv": {56 const entries = parseCSV(raw);57 const totalItems = Papa.parse<Record<string, string>>(raw, {58 header: true,59 skipEmptyLines: true,60 }).data.length;61 return {62 format,63 label: "CSV import",64 entries,65 totalItems,66 skippedItems: Math.max(0, totalItems - entries.length),67 requiresPassword: false,68 };69 }70 case "1password-json": {71 const entries = parse1PasswordJSON(raw);72 return {73 format,74 label: "1Password JSON",75 entries,76 totalItems: entries.length,77 skippedItems: 0,78 requiresPassword: false,79 };80 }81 case "bitwarden-json": {82 const { entries, totalItems } = parseBitwardenJSON(raw);83 return {84 format,85 label: "Bitwarden JSON",86 entries,87 totalItems,88 skippedItems: Math.max(0, totalItems - entries.length),89 requiresPassword: false,90 };91 }92 case "credential-exchange-json": {93 const { entries, totalItems } = await parseCredentialExchangeJSON(raw);94 return {95 format,96 label: "Credential Exchange JSON",97 entries,98 totalItems,99 skippedItems: Math.max(0, totalItems - entries.length),100 requiresPassword: false,101 };102 }103 case "protonpass-json": {104 const { entries, totalItems } = parseProtonPassJSON(raw);105 return {106 format,107 label: "Proton Pass JSON",108 entries,109 totalItems,110 skippedItems: Math.max(0, totalItems - entries.length),111 requiresPassword: false,112 };113 }114 case "peach-legacy-json": {115 const { entries, totalItems, skippedItems } = parsePeachLegacyJSON(raw);116 return {117 format,118 label: "Peach (legacy)",119 entries,120 totalItems,121 skippedItems,122 requiresPassword: false,123 };124 }125 case "peach-json": {126 if (!password) {127 return {128 format,129 label: "Peach encrypted backup",130 entries: [],131 totalItems: 0,132 skippedItems: 0,133 requiresPassword: true,134 };135 }136137 const result = await importEntriesFromPeachJson(raw, password);138 return {139 format,140 label: "Peach encrypted backup",141 entries: result.entries,142 totalItems: result.entries.length,143 skippedItems: 0,144 requiresPassword: false,145 ...(result.licenseKey ? { licenseKey: result.licenseKey, licenseActivatedAt: result.licenseActivatedAt } : {}),146 };147 }148 default:149 return {150 format: "unknown",151 label: "Unknown format",152 entries: [],153 totalItems: 0,154 skippedItems: 0,155 requiresPassword: false,156 };157 }158}159160export function detectImportFormat(raw: string): ImportFormat {161 const trimmed = raw.trim();162 if (trimmed.startsWith("{") || trimmed.startsWith("[")) {163 try {164 const data = JSON.parse(trimmed);165 if (hasPeachBackupShape(data)) return "peach-json";166 if ((data as any)?.exportFormat === "peach-vault")167 return "peach-legacy-json";168 if (hasCredentialExchangeShape(data)) return "credential-exchange-json";169 if (has1PasswordShape(data)) return "1password-json";170 if (hasBitwardenShape(data)) return "bitwarden-json";171 if (hasProtonPassShape(data)) return "protonpass-json";172 return "unknown";173 } catch {174 return "unknown";175 }176 }177178 return trimmed.includes(",") ? "csv" : "unknown";179}180181export function parse1PasswordJSON(raw: string): ImportableEntry[] {182 const data = JSON.parse(raw);183 const entries: ImportableEntry[] = [];184185 const itemGroups: any[][] = [];186 const vaults = data?.accounts?.[0]?.vaults ?? [];187 for (const vault of vaults) itemGroups.push(vault.items ?? []);188 if (Array.isArray(data?.items)) itemGroups.push(data.items);189190 for (const items of itemGroups) {191 for (const item of items) {192 if (item.trashed) continue;193 const fields = item.details?.loginFields ?? item.fields ?? [];194 const username =195 fields.find((field: any) => field.designation === "username" || field.id === "username")?.value ??196 "";197 const password =198 fields.find((field: any) => field.designation === "password" || field.id === "password")?.value ??199 "";200 if (!password) continue;201 entries.push({202 type: "login",203 title: (item.overview?.title ?? item.title)?.trim() || "Untitled",204 username: username.trim(),205 password,206 url: ((item.overview?.url ?? item.url) ?? "").trim(),207 notes: stringOrUndefined(item.details?.notesPlain ?? item.notes),208 });209 }210 }211212 return entries;213}214215function parseCSV(csvText: string): ImportableEntry[] {216 const result = Papa.parse<Record<string, string>>(csvText, {217 header: true,218 skipEmptyLines: true,219 transformHeader: (header) => header.trim(),220 });221222 return result.data.reduce<ImportableEntry[]>((entries, row) => {223 const title = pickFirstField(row, [224 "name",225 "title",226 "Title",227 "Name",228 "item",229 ]);230 const url = pickFirstField(row, [231 "url",232 "website",233 "URL",234 "Website",235 "login_uri",236 "loginUrl",237 ]);238 const username = pickFirstField(row, [239 "username",240 "login",241 "email",242 "Username",243 "login_username",244 "user",245 ]);246 const password = pickFirstField(row, [247 "password",248 "Password",249 "login_password",250 ]);251 const notes = pickFirstField(row, [252 "notes",253 "Notes",254 "note",255 "comment",256 "extra",257 ]);258 const totpParams = normalizeTotp(259 pickFirstField(row, [260 "totp",261 "totpUri",262 "otp",263 "otpSecret",264 "two-factor-secret",265 "login_totp",266 ])267 );268 const grouping = pickFirstField(row, ["grouping", "Grouping", "folder", "Folder"]);269270 if (!password) return entries;271272 entries.push({273 type: "login",274 title: firstNonEmptyString(title, url, username, "Untitled"),275 username,276 password,277 url,278 notes: notes || undefined,279 ...(totpParams ?? {}),280 tags: grouping ? [grouping] : undefined,281 });282 return entries;283 }, []);284}285286function parseBitwardenJSON(raw: string): {287 entries: ImportableEntry[];288 totalItems: number;289} {290 const data = JSON.parse(raw);291 const items: any[] = Array.isArray(data?.items) ? data.items : [];292 const loginItems = items.filter((item) => item?.type === 1);293294 return {295 totalItems: loginItems.length,296 entries: loginItems.reduce<ImportableEntry[]>((entries, item) => {297 const password = stringOrEmpty(item.login?.password);298 if (!password) return entries;299300 entries.push({301 type: "login",302 title: firstNonEmptyString(item.name, "Untitled"),303 url: firstNonEmptyString(item.login?.uris?.[0]?.uri, ""),304 username: firstNonEmptyString(item.login?.username, ""),305 password,306 notes: stringOrUndefined(item.notes),307 ...(normalizeTotp(stringOrUndefined(item.login?.totp)) ?? {}),308 });309 return entries;310 }, []),311 };312}313314function parseProtonPassJSON(raw: string): {315 entries: ImportableEntry[];316 totalItems: number;317} {318 const data = JSON.parse(raw) as {319 vaults?: Record<string, { name?: string; items?: any[] }>;320 };321 const vaults = data?.vaults && typeof data.vaults === "object" ? data.vaults : {};322 const entries: ImportableEntry[] = [];323 let totalItems = 0;324325 for (const vault of Object.values(vaults)) {326 if (vault?.name?.toLowerCase() === "recycle bin") continue;327328 for (const item of vault?.items ?? []) {329 const itemData = item?.data ?? item;330 const itemType = itemData?.type ?? item?.type;331 if (itemType !== "login") continue;332333 totalItems += 1;334335 const content = itemData?.content ?? {};336 const metadata = itemData?.metadata ?? {};337 const password = firstNonEmptyString(338 content.password,339 content.itemPassword340 );341 if (!password) continue;342343 entries.push({344 type: "login",345 title: firstNonEmptyString(346 metadata.name,347 item.name,348 item.title,349 content.itemName,350 "Untitled"351 ),352 username: firstNonEmptyString(353 content.username,354 content.itemUsername,355 content.email,356 content.itemEmail,357 item.aliasEmail,358 ""359 ),360 password,361 url: firstNonEmptyString(362 Array.isArray(content.urls) ? content.urls[0] : undefined,363 content.url,364 ""365 ),366 notes: stringOrUndefined(metadata.note ?? metadata.notes),367 ...(normalizeTotp(368 firstNonEmptyString(content.totpUri, content.itemTotpUri)369 ) ?? {}),370 });371 }372 }373374 return { entries, totalItems };375}376377async function parseCredentialExchangeJSON(raw: string): Promise<{378 entries: ImportableEntry[];379 totalItems: number;380}> {381 const data = JSON.parse(raw) as {382 accounts?: Array<{383 items?: Array<{384 title?: string;385 modifiedAt?: number;386 creationAt?: number;387 credentials?: Array<{388 type?: string;389 credentialId?: string;390 rpId?: string;391 username?: string;392 userDisplayName?: string;393 userHandle?: string;394 key?: string;395 }>;396 }>;397 }>;398 };399400 const entries: ImportableEntry[] = [];401 let totalItems = 0;402403 for (const account of data.accounts ?? []) {404 for (const item of account.items ?? []) {405 for (const credential of item.credentials ?? []) {406 if (credential?.type !== "passkey") continue;407 totalItems += 1;408409 if (410 !credential.credentialId ||411 !credential.rpId ||412 !credential.username ||413 !credential.userDisplayName ||414 !credential.userHandle ||415 !credential.key416 ) {417 continue;418 }419420 try {421 const pkcs8 = base64urlDecode(credential.key);422 const imported = await importPasskeyPrivateKey(pkcs8);423 entries.push({424 type: "passkey",425 title: firstNonEmptyString(item.title, credential.rpId),426 rpId: credential.rpId,427 rpName: firstNonEmptyString(item.title, credential.rpId),428 credentialId: credential.credentialId,429 publicKeyCose: base64urlEncode(imported.publicKeyCose),430 privateKeyJwk: imported.privateKeyJwk,431 userHandle: credential.userHandle,432 userName: credential.username,433 userDisplayName: credential.userDisplayName,434 algorithm: imported.algorithm,435 signCount: 0,436 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",437 backupEligible: true,438 backupState: false,439 });440 } catch {441 442 }443 }444 }445 }446447 return { entries, totalItems };448}449450function parsePeachLegacyJSON(raw: string): {451 entries: ImportableEntry[];452 totalItems: number;453 skippedItems: number;454} {455 const data = JSON.parse(raw) as {456 exportFormat: string;457 entries: Array<{458 id: string;459 type: string;460 modified: number;461 created: number;462 name: string;463 favorite: boolean;464 tags: string[];465 login?: {466 urls?: string[];467 username?: string;468 password?: string;469 totp?: {470 secret: string;471 algorithm: string;472 digits: number;473 period: number;474 };475 customFields?: unknown[];476 notes?: string;477 };478 }>;479 };480481 const totalItems = data.entries.length;482 const filtered = data.entries.filter((e) => e.type === "login");483 const skippedItems = totalItems - filtered.length;484485 return {486 totalItems,487 skippedItems,488 entries: filtered.map((entry) => ({489 type: "login" as const,490 title: entry.name ?? "Untitled",491 url: entry.login?.urls?.[0] ?? "",492 username: entry.login?.username ?? "",493 password: entry.login?.password ?? "",494 totp: entry.login?.totp?.secret ?? undefined,495 notes: entry.login?.notes ?? undefined,496 })),497 };498}499500function has1PasswordShape(value: unknown): boolean {501 if (!value || typeof value !== "object") return false;502 const v = value as { accounts?: unknown[]; items?: unknown[] };503 if (Array.isArray(v.accounts)) return true;504 if (!Array.isArray(v.items) || v.items.length === 0) return false;505 const sample = v.items[0] as any;506 return Boolean(sample?.details?.loginFields || sample?.overview?.title);507}508509function hasBitwardenShape(value: unknown): boolean {510 return Boolean(511 value &&512 typeof value === "object" &&513 Array.isArray((value as { items?: unknown[] }).items)514 );515}516517function hasCredentialExchangeShape(value: unknown): boolean {518 return Boolean(519 value &&520 typeof value === "object" &&521 (value as { version?: { major?: number; minor?: number }; accounts?: unknown[] }).version &&522 Array.isArray((value as { accounts?: unknown[] }).accounts)523 );524}525526function hasProtonPassShape(value: unknown): boolean {527 return Boolean(528 value &&529 typeof value === "object" &&530 (value as { vaults?: unknown }).vaults &&531 typeof (value as { vaults?: unknown }).vaults === "object"532 );533}534535function hasPeachBackupShape(value: unknown): boolean {536 if (!value || typeof value !== "object") return false;537 const envelope = value as Partial<{538 type: string;539 version: number;540 kdf: string;541 salt: string;542 nonce: string;543 ciphertext: string;544 tag: string;545 }>;546 return (547 envelope.type === "peach-export" &&548 envelope.version === 2 &&549 envelope.kdf === "PBKDF2-SHA256" &&550 typeof envelope.salt === "string" &&551 typeof envelope.nonce === "string" &&552 typeof envelope.ciphertext === "string" &&553 typeof envelope.tag === "string"554 );555}556557function pickFirstField(558 row: Record<string, string>,559 candidates: string[]560): string {561 for (const key of candidates) {562 const value = row[key];563 if (typeof value === "string" && value.trim()) {564 return value.trim();565 }566 }567 return "";568}569570function normalizeTotp(571 value?: string572): { totp: string; totpAlgorithm?: "SHA1" | "SHA256" | "SHA512"; totpDigits?: number; totpPeriod?: number } | undefined {573 if (!value) return undefined;574 const parsed = value.startsWith("otpauth://") ? parseOtpauthURI(value) : null;575 const totp = parsed?.secret ?? value.trim();576 if (!totp) return undefined;577 if (!parsed) return { totp };578 579 580 return {581 totp,582 totpAlgorithm: parsed.algorithm && parsed.algorithm !== "SHA1" ? parsed.algorithm : undefined,583 totpDigits: parsed.digits && parsed.digits !== 6 ? parsed.digits : undefined,584 totpPeriod: parsed.period && parsed.period !== 30 ? parsed.period : undefined,585 };586}587588function firstNonEmptyString(589 ...values: Array<string | null | undefined>590): string {591 for (const value of values) {592 if (typeof value === "string" && value.trim()) {593 return value.trim();594 }595 }596 return "";597}598599function stringOrUndefined(value: unknown): string | undefined {600 return typeof value === "string" && value.trim() ? value.trim() : undefined;601}602603function stringOrEmpty(value: unknown): string {604 return typeof value === "string" ? value : "";605}