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 type { VaultEntry } from "./types";1819export type ImportableEntry = Omit<VaultEntry, "id" | "createdAt" | "modifiedAt">;2021export type ImportFormat =22 | "csv"23 | "1password-json"24 | "bitwarden-json"25 | "credential-exchange-json"26 | "protonpass-json"27 | "unknown";2829export interface ImportParseResult {30 format: ImportFormat;31 label: string;32 entries: ImportableEntry[];33 totalItems: number;34 skippedItems: number;35}3637export async function parseImportSource(38 raw: string39): Promise<ImportParseResult> {40 41 42 if (raw.length > IMPORT_LIMITS.MAX_JSON_BYTES) {43 throw importTooLarge(44 `import source ${raw.length} chars exceeds ${IMPORT_LIMITS.MAX_JSON_BYTES}`,45 );46 }47 const format = detectImportFormat(raw);4849 switch (format) {50 case "csv": {51 const { entries, totalItems } = parseCSV(raw);52 return {53 format,54 label: "CSV import",55 entries,56 totalItems,57 skippedItems: Math.max(0, totalItems - entries.length),58 };59 }60 case "1password-json": {61 const entries = parse1PasswordJSON(raw);62 return {63 format,64 label: "1Password JSON",65 entries,66 totalItems: entries.length,67 skippedItems: 0,68 };69 }70 case "bitwarden-json": {71 const { entries, totalItems } = parseBitwardenJSON(raw);72 return {73 format,74 label: "Bitwarden JSON",75 entries,76 totalItems,77 skippedItems: Math.max(0, totalItems - entries.length),78 };79 }80 case "credential-exchange-json": {81 const { entries, totalItems } = await parseCredentialExchangeJSON(raw);82 return {83 format,84 label: "Credential Exchange JSON",85 entries,86 totalItems,87 skippedItems: Math.max(0, totalItems - entries.length),88 };89 }90 case "protonpass-json": {91 const { entries, totalItems } = parseProtonPassJSON(raw);92 return {93 format,94 label: "Proton Pass JSON",95 entries,96 totalItems,97 skippedItems: Math.max(0, totalItems - entries.length),98 };99 }100 default:101 return {102 format: "unknown",103 label: "Unknown format",104 entries: [],105 totalItems: 0,106 skippedItems: 0,107 };108 }109}110111export function detectImportFormat(raw: string): ImportFormat {112 const trimmed = raw.trim();113 if (trimmed.startsWith("{") || trimmed.startsWith("[")) {114 try {115 const data = JSON.parse(trimmed);116 if (hasCredentialExchangeShape(data)) return "credential-exchange-json";117 if (has1PasswordShape(data)) return "1password-json";118 if (hasBitwardenShape(data)) return "bitwarden-json";119 if (hasProtonPassShape(data)) return "protonpass-json";120 return "unknown";121 } catch {122 return "unknown";123 }124 }125126 return trimmed.includes(",") ? "csv" : "unknown";127}128129export function parse1PasswordJSON(raw: string): ImportableEntry[] {130 const data = JSON.parse(raw);131 const entries: ImportableEntry[] = [];132133 const itemGroups: any[][] = [];134 const vaults = data?.accounts?.[0]?.vaults ?? [];135 for (const vault of vaults) itemGroups.push(vault.items ?? []);136 if (Array.isArray(data?.items)) itemGroups.push(data.items);137138 for (const items of itemGroups) {139 for (const item of items) {140 if (item.trashed) continue;141 const fields = item.details?.loginFields ?? item.fields ?? [];142 const username =143 fields.find((field: any) => field.designation === "username" || field.id === "username")?.value ??144 "";145 const password =146 fields.find((field: any) => field.designation === "password" || field.id === "password")?.value ??147 "";148 if (!password) continue;149 entries.push({150 type: "login",151 title: (item.overview?.title ?? item.title)?.trim() || "Untitled",152 username: username.trim(),153 password,154 url: ((item.overview?.url ?? item.url) ?? "").trim(),155 notes: stringOrUndefined(item.details?.notesPlain ?? item.notes),156 });157 }158 }159160 return entries;161}162163function parseCSV(csvText: string): { entries: ImportableEntry[]; totalItems: number } {164 const result = Papa.parse<Record<string, string>>(csvText, {165 header: true,166 skipEmptyLines: true,167 transformHeader: (header) => header.trim(),168 });169170 171 172 const entries = result.data.reduce<ImportableEntry[]>((entries, row) => {173 const title = pickFirstField(row, [174 "name",175 "title",176 "Title",177 "Name",178 "item",179 ]);180 const url = pickFirstField(row, [181 "url",182 "website",183 "URL",184 "Website",185 "login_uri",186 "loginUrl",187 ]);188 const username = pickFirstField(row, [189 "username",190 "login",191 "email",192 "Username",193 "login_username",194 "user",195 ]);196 const password = pickFirstField(row, [197 "password",198 "Password",199 "login_password",200 ]);201 const notes = pickFirstField(row, [202 "notes",203 "Notes",204 "note",205 "comment",206 "extra",207 ]);208 const totpParams = normalizeTotp(209 pickFirstField(row, [210 "totp",211 "totpUri",212 "otp",213 "otpSecret",214 "two-factor-secret",215 "login_totp",216 ])217 );218 const grouping = pickFirstField(row, ["grouping", "Grouping", "folder", "Folder"]);219220 if (!password) return entries;221222 entries.push({223 type: "login",224 title: firstNonEmptyString(title, url, username, "Untitled"),225 username,226 password,227 url,228 notes: notes || undefined,229 ...(totpParams ?? {}),230 tags: grouping ? [grouping] : undefined,231 });232 return entries;233 }, []);234235 return { entries, totalItems: result.data.length };236}237238function parseBitwardenJSON(raw: string): {239 entries: ImportableEntry[];240 totalItems: number;241} {242 const data = JSON.parse(raw);243 const items: any[] = Array.isArray(data?.items) ? data.items : [];244 const loginItems = items.filter((item) => item?.type === 1);245246 return {247 totalItems: loginItems.length,248 entries: loginItems.reduce<ImportableEntry[]>((entries, item) => {249 const password = stringOrEmpty(item.login?.password);250 if (!password) return entries;251252 entries.push({253 type: "login",254 title: firstNonEmptyString(item.name, "Untitled"),255 url: firstNonEmptyString(item.login?.uris?.[0]?.uri, ""),256 username: firstNonEmptyString(item.login?.username, ""),257 password,258 notes: stringOrUndefined(item.notes),259 ...(normalizeTotp(stringOrUndefined(item.login?.totp)) ?? {}),260 });261 return entries;262 }, []),263 };264}265266function parseProtonPassJSON(raw: string): {267 entries: ImportableEntry[];268 totalItems: number;269} {270 const data = JSON.parse(raw) as {271 vaults?: Record<string, { name?: string; items?: any[] }>;272 };273 const vaults = data?.vaults && typeof data.vaults === "object" ? data.vaults : {};274 const entries: ImportableEntry[] = [];275 let totalItems = 0;276277 for (const vault of Object.values(vaults)) {278 if (vault?.name?.toLowerCase() === "recycle bin") continue;279280 for (const item of vault?.items ?? []) {281 const itemData = item?.data ?? item;282 const itemType = itemData?.type ?? item?.type;283 if (itemType !== "login") continue;284285 totalItems += 1;286287 const content = itemData?.content ?? {};288 const metadata = itemData?.metadata ?? {};289 const password = firstNonEmptyString(290 content.password,291 content.itemPassword292 );293 if (!password) continue;294295 entries.push({296 type: "login",297 title: firstNonEmptyString(298 metadata.name,299 item.name,300 item.title,301 content.itemName,302 "Untitled"303 ),304 username: firstNonEmptyString(305 content.username,306 content.itemUsername,307 content.email,308 content.itemEmail,309 item.aliasEmail,310 ""311 ),312 password,313 url: firstNonEmptyString(314 Array.isArray(content.urls) ? content.urls[0] : undefined,315 content.url,316 ""317 ),318 notes: stringOrUndefined(metadata.note ?? metadata.notes),319 ...(normalizeTotp(320 firstNonEmptyString(content.totpUri, content.itemTotpUri)321 ) ?? {}),322 });323 }324 }325326 return { entries, totalItems };327}328329async function parseCredentialExchangeJSON(raw: string): Promise<{330 entries: ImportableEntry[];331 totalItems: number;332}> {333 const data = JSON.parse(raw) as {334 accounts?: Array<{335 items?: Array<{336 title?: string;337 modifiedAt?: number;338 creationAt?: number;339 credentials?: Array<{340 type?: string;341 credentialId?: string;342 rpId?: string;343 username?: string;344 userDisplayName?: string;345 userHandle?: string;346 key?: string;347 }>;348 }>;349 }>;350 };351352 const entries: ImportableEntry[] = [];353 let totalItems = 0;354355 for (const account of data.accounts ?? []) {356 for (const item of account.items ?? []) {357 for (const credential of item.credentials ?? []) {358 if (credential?.type !== "passkey") continue;359 totalItems += 1;360361 if (362 !credential.credentialId ||363 !credential.rpId ||364 !credential.username ||365 !credential.userDisplayName ||366 !credential.userHandle ||367 !credential.key368 ) {369 continue;370 }371372 try {373 const pkcs8 = base64urlDecode(credential.key);374 const imported = await importPasskeyPrivateKey(pkcs8);375 const createdAt = Date.now();376 entries.push({377 type: "login",378 title: firstNonEmptyString(item.title, credential.rpId),379 username: credential.username,380 url: `https://${credential.rpId}`,381 passkey: {382 rpId: credential.rpId,383 rpName: firstNonEmptyString(item.title, credential.rpId),384 credentialId: credential.credentialId,385 publicKeyCose: base64urlEncode(imported.publicKeyCose),386 privateKeyJwk: imported.privateKeyJwk,387 userHandle: credential.userHandle,388 userName: credential.username,389 userDisplayName: credential.userDisplayName,390 algorithm: imported.algorithm,391 signCount: 0,392 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",393 backupEligible: true,394 backupState: false,395 createdAt,396 },397 });398 } catch {399 400 }401 }402 }403 }404405 return { entries, totalItems };406}407408function has1PasswordShape(value: unknown): boolean {409 if (!value || typeof value !== "object") return false;410 const v = value as { accounts?: unknown[]; items?: unknown[] };411 if (Array.isArray(v.accounts)) return true;412 if (!Array.isArray(v.items) || v.items.length === 0) return false;413 const sample = v.items[0] as any;414 return Boolean(sample?.details?.loginFields || sample?.overview?.title);415}416417function hasBitwardenShape(value: unknown): boolean {418 return Boolean(419 value &&420 typeof value === "object" &&421 Array.isArray((value as { items?: unknown[] }).items)422 );423}424425function hasCredentialExchangeShape(value: unknown): boolean {426 return Boolean(427 value &&428 typeof value === "object" &&429 (value as { version?: { major?: number; minor?: number }; accounts?: unknown[] }).version &&430 Array.isArray((value as { accounts?: unknown[] }).accounts)431 );432}433434function hasProtonPassShape(value: unknown): boolean {435 return Boolean(436 value &&437 typeof value === "object" &&438 (value as { vaults?: unknown }).vaults &&439 typeof (value as { vaults?: unknown }).vaults === "object"440 );441}442443function pickFirstField(444 row: Record<string, string>,445 candidates: string[]446): string {447 for (const key of candidates) {448 const value = row[key];449 if (typeof value === "string" && value.trim()) {450 return value.trim();451 }452 }453 return "";454}455456function normalizeTotp(457 value?: string458): { totp: string; totpAlgorithm?: "SHA1" | "SHA256" | "SHA512"; totpDigits?: number; totpPeriod?: number } | undefined {459 if (!value) return undefined;460 const parsed = value.startsWith("otpauth://") ? parseOtpauthURI(value) : null;461 const totp = parsed?.secret ?? value.trim();462 if (!totp) return undefined;463 if (!parsed) return { totp };464 465 466 return {467 totp,468 totpAlgorithm: parsed.algorithm && parsed.algorithm !== "SHA1" ? parsed.algorithm : undefined,469 totpDigits: parsed.digits && parsed.digits !== 6 ? parsed.digits : undefined,470 totpPeriod: parsed.period && parsed.period !== 30 ? parsed.period : undefined,471 };472}473474function firstNonEmptyString(475 ...values: Array<string | null | undefined>476): string {477 for (const value of values) {478 if (typeof value === "string" && value.trim()) {479 return value.trim();480 }481 }482 return "";483}484485function stringOrUndefined(value: unknown): string | undefined {486 return typeof value === "string" && value.trim() ? value.trim() : undefined;487}488489function stringOrEmpty(value: unknown): string {490 return typeof value === "string" ? value : "";491}