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, totalItems } = parseCSV(raw);57 return {58 format,59 label: "CSV import",60 entries,61 totalItems,62 skippedItems: Math.max(0, totalItems - entries.length),63 requiresPassword: false,64 };65 }66 case "1password-json": {67 const entries = parse1PasswordJSON(raw);68 return {69 format,70 label: "1Password JSON",71 entries,72 totalItems: entries.length,73 skippedItems: 0,74 requiresPassword: false,75 };76 }77 case "bitwarden-json": {78 const { entries, totalItems } = parseBitwardenJSON(raw);79 return {80 format,81 label: "Bitwarden JSON",82 entries,83 totalItems,84 skippedItems: Math.max(0, totalItems - entries.length),85 requiresPassword: false,86 };87 }88 case "credential-exchange-json": {89 const { entries, totalItems } = await parseCredentialExchangeJSON(raw);90 return {91 format,92 label: "Credential Exchange JSON",93 entries,94 totalItems,95 skippedItems: Math.max(0, totalItems - entries.length),96 requiresPassword: false,97 };98 }99 case "protonpass-json": {100 const { entries, totalItems } = parseProtonPassJSON(raw);101 return {102 format,103 label: "Proton Pass JSON",104 entries,105 totalItems,106 skippedItems: Math.max(0, totalItems - entries.length),107 requiresPassword: false,108 };109 }110 case "peach-legacy-json": {111 const { entries, totalItems, skippedItems } = parsePeachLegacyJSON(raw);112 return {113 format,114 label: "Peach (legacy)",115 entries,116 totalItems,117 skippedItems,118 requiresPassword: false,119 };120 }121 case "peach-json": {122 if (!password) {123 return {124 format,125 label: "Peach encrypted backup",126 entries: [],127 totalItems: 0,128 skippedItems: 0,129 requiresPassword: true,130 };131 }132133 const result = await importEntriesFromPeachJson(raw, password);134 return {135 format,136 label: "Peach encrypted backup",137 entries: result.entries,138 totalItems: result.entries.length,139 skippedItems: 0,140 requiresPassword: false,141 ...(result.licenseKey ? { licenseKey: result.licenseKey, licenseActivatedAt: result.licenseActivatedAt } : {}),142 };143 }144 default:145 return {146 format: "unknown",147 label: "Unknown format",148 entries: [],149 totalItems: 0,150 skippedItems: 0,151 requiresPassword: false,152 };153 }154}155156export function detectImportFormat(raw: string): ImportFormat {157 const trimmed = raw.trim();158 if (trimmed.startsWith("{") || trimmed.startsWith("[")) {159 try {160 const data = JSON.parse(trimmed);161 if (hasPeachBackupShape(data)) return "peach-json";162 if ((data as any)?.exportFormat === "peach-vault")163 return "peach-legacy-json";164 if (hasCredentialExchangeShape(data)) return "credential-exchange-json";165 if (has1PasswordShape(data)) return "1password-json";166 if (hasBitwardenShape(data)) return "bitwarden-json";167 if (hasProtonPassShape(data)) return "protonpass-json";168 return "unknown";169 } catch {170 return "unknown";171 }172 }173174 return trimmed.includes(",") ? "csv" : "unknown";175}176177export function parse1PasswordJSON(raw: string): ImportableEntry[] {178 const data = JSON.parse(raw);179 const entries: ImportableEntry[] = [];180181 const itemGroups: any[][] = [];182 const vaults = data?.accounts?.[0]?.vaults ?? [];183 for (const vault of vaults) itemGroups.push(vault.items ?? []);184 if (Array.isArray(data?.items)) itemGroups.push(data.items);185186 for (const items of itemGroups) {187 for (const item of items) {188 if (item.trashed) continue;189 const fields = item.details?.loginFields ?? item.fields ?? [];190 const username =191 fields.find((field: any) => field.designation === "username" || field.id === "username")?.value ??192 "";193 const password =194 fields.find((field: any) => field.designation === "password" || field.id === "password")?.value ??195 "";196 if (!password) continue;197 entries.push({198 type: "login",199 title: (item.overview?.title ?? item.title)?.trim() || "Untitled",200 username: username.trim(),201 password,202 url: ((item.overview?.url ?? item.url) ?? "").trim(),203 notes: stringOrUndefined(item.details?.notesPlain ?? item.notes),204 });205 }206 }207208 return entries;209}210211function parseCSV(csvText: string): { entries: ImportableEntry[]; totalItems: number } {212 const result = Papa.parse<Record<string, string>>(csvText, {213 header: true,214 skipEmptyLines: true,215 transformHeader: (header) => header.trim(),216 });217218 219 220 const entries = result.data.reduce<ImportableEntry[]>((entries, row) => {221 const title = pickFirstField(row, [222 "name",223 "title",224 "Title",225 "Name",226 "item",227 ]);228 const url = pickFirstField(row, [229 "url",230 "website",231 "URL",232 "Website",233 "login_uri",234 "loginUrl",235 ]);236 const username = pickFirstField(row, [237 "username",238 "login",239 "email",240 "Username",241 "login_username",242 "user",243 ]);244 const password = pickFirstField(row, [245 "password",246 "Password",247 "login_password",248 ]);249 const notes = pickFirstField(row, [250 "notes",251 "Notes",252 "note",253 "comment",254 "extra",255 ]);256 const totpParams = normalizeTotp(257 pickFirstField(row, [258 "totp",259 "totpUri",260 "otp",261 "otpSecret",262 "two-factor-secret",263 "login_totp",264 ])265 );266 const grouping = pickFirstField(row, ["grouping", "Grouping", "folder", "Folder"]);267268 if (!password) return entries;269270 entries.push({271 type: "login",272 title: firstNonEmptyString(title, url, username, "Untitled"),273 username,274 password,275 url,276 notes: notes || undefined,277 ...(totpParams ?? {}),278 tags: grouping ? [grouping] : undefined,279 });280 return entries;281 }, []);282283 return { entries, totalItems: result.data.length };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}