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">;20212223242526function pushEntry(entries: ImportableEntry[], entry: ImportableEntry): void {27 if (entries.length >= IMPORT_LIMITS.MAX_IMPORT_ENTRY_COUNT) {28 throw importTooLarge(29 `import exceeds ${IMPORT_LIMITS.MAX_IMPORT_ENTRY_COUNT} entries`,30 );31 }32 entries.push(entry);33}3435export type ImportFormat =36 | "csv"37 | "1password-json"38 | "bitwarden-json"39 | "credential-exchange-json"40 | "protonpass-json"41 | "unknown";4243export interface ImportParseResult {44 format: ImportFormat;45 label: string;46 entries: ImportableEntry[];47 totalItems: number;48 skippedItems: number;49}5051export async function parseImportSource(52 raw: string53): Promise<ImportParseResult> {54 55 56 if (raw.length > IMPORT_LIMITS.MAX_JSON_BYTES) {57 throw importTooLarge(58 `import source ${raw.length} chars exceeds ${IMPORT_LIMITS.MAX_JSON_BYTES}`,59 );60 }61 const format = detectImportFormat(raw);6263 switch (format) {64 case "csv": {65 const { entries, totalItems } = parseCSV(raw);66 return {67 format,68 label: "CSV import",69 entries,70 totalItems,71 skippedItems: Math.max(0, totalItems - entries.length),72 };73 }74 case "1password-json": {75 const entries = parse1PasswordJSON(raw);76 return {77 format,78 label: "1Password JSON",79 entries,80 totalItems: entries.length,81 skippedItems: 0,82 };83 }84 case "bitwarden-json": {85 const { entries, totalItems } = parseBitwardenJSON(raw);86 return {87 format,88 label: "Bitwarden JSON",89 entries,90 totalItems,91 skippedItems: Math.max(0, totalItems - entries.length),92 };93 }94 case "credential-exchange-json": {95 const { entries, totalItems } = await parseCredentialExchangeJSON(raw);96 return {97 format,98 label: "Credential Exchange JSON",99 entries,100 totalItems,101 skippedItems: Math.max(0, totalItems - entries.length),102 };103 }104 case "protonpass-json": {105 const { entries, totalItems } = parseProtonPassJSON(raw);106 return {107 format,108 label: "Proton Pass JSON",109 entries,110 totalItems,111 skippedItems: Math.max(0, totalItems - entries.length),112 };113 }114 default:115 return {116 format: "unknown",117 label: "Unknown format",118 entries: [],119 totalItems: 0,120 skippedItems: 0,121 };122 }123}124125export function detectImportFormat(raw: string): ImportFormat {126 const trimmed = raw.trim();127 if (trimmed.startsWith("{") || trimmed.startsWith("[")) {128 try {129 const data = JSON.parse(trimmed);130 if (hasCredentialExchangeShape(data)) return "credential-exchange-json";131 if (has1PasswordShape(data)) return "1password-json";132 if (hasBitwardenShape(data)) return "bitwarden-json";133 if (hasProtonPassShape(data)) return "protonpass-json";134 return "unknown";135 } catch {136 return "unknown";137 }138 }139140 return trimmed.includes(",") ? "csv" : "unknown";141}142143export function parse1PasswordJSON(raw: string): ImportableEntry[] {144 const data = JSON.parse(raw);145 const entries: ImportableEntry[] = [];146147 const itemGroups: any[][] = [];148 const vaults = data?.accounts?.[0]?.vaults ?? [];149 for (const vault of vaults) itemGroups.push(vault.items ?? []);150 if (Array.isArray(data?.items)) itemGroups.push(data.items);151152 for (const items of itemGroups) {153 for (const item of items) {154 if (item.trashed) continue;155 const fields = item.details?.loginFields ?? item.fields ?? [];156 const username =157 fields.find((field: any) => field.designation === "username" || field.id === "username")?.value ??158 "";159 const password =160 fields.find((field: any) => field.designation === "password" || field.id === "password")?.value ??161 "";162 if (!password) continue;163 pushEntry(entries, {164 type: "login",165 title: (item.overview?.title ?? item.title)?.trim() || "Untitled",166 username: username.trim(),167 password,168 url: ((item.overview?.url ?? item.url) ?? "").trim(),169 notes: stringOrUndefined(item.details?.notesPlain ?? item.notes),170 });171 }172 }173174 return entries;175}176177function parseCSV(csvText: string): { entries: ImportableEntry[]; totalItems: number } {178 const result = Papa.parse<Record<string, string>>(csvText, {179 header: true,180 skipEmptyLines: true,181 transformHeader: (header) => header.trim(),182 });183184 185 186 const entries = result.data.reduce<ImportableEntry[]>((entries, row) => {187 const title = pickFirstField(row, [188 "name",189 "title",190 "Title",191 "Name",192 "item",193 ]);194 const url = pickFirstField(row, [195 "url",196 "website",197 "URL",198 "Website",199 "login_uri",200 "loginUrl",201 ]);202 const username = pickFirstField(row, [203 "username",204 "login",205 "email",206 "Username",207 "login_username",208 "user",209 ]);210 const password = pickFirstField(row, [211 "password",212 "Password",213 "login_password",214 ]);215 const notes = pickFirstField(row, [216 "notes",217 "Notes",218 "note",219 "comment",220 "extra",221 ]);222 const totpParams = normalizeTotp(223 pickFirstField(row, [224 "totp",225 "totpUri",226 "otp",227 "otpSecret",228 "two-factor-secret",229 "login_totp",230 ])231 );232 const grouping = pickFirstField(row, ["grouping", "Grouping", "folder", "Folder"]);233234 if (!password) return entries;235236 pushEntry(entries, {237 type: "login",238 title: firstNonEmptyString(title, url, username, "Untitled"),239 username,240 password,241 url,242 notes: notes || undefined,243 ...(totpParams ?? {}),244 tags: grouping ? [grouping] : undefined,245 });246 return entries;247 }, []);248249 return { entries, totalItems: result.data.length };250}251252function parseBitwardenJSON(raw: string): {253 entries: ImportableEntry[];254 totalItems: number;255} {256 const data = JSON.parse(raw);257 const items: any[] = Array.isArray(data?.items) ? data.items : [];258 const loginItems = items.filter((item) => item?.type === 1);259260 return {261 totalItems: loginItems.length,262 entries: loginItems.reduce<ImportableEntry[]>((entries, item) => {263 const password = stringOrEmpty(item.login?.password);264 if (!password) return entries;265266 pushEntry(entries, {267 type: "login",268 title: firstNonEmptyString(item.name, "Untitled"),269 url: firstNonEmptyString(item.login?.uris?.[0]?.uri, ""),270 username: firstNonEmptyString(item.login?.username, ""),271 password,272 notes: stringOrUndefined(item.notes),273 ...(normalizeTotp(stringOrUndefined(item.login?.totp)) ?? {}),274 });275 return entries;276 }, []),277 };278}279280function parseProtonPassJSON(raw: string): {281 entries: ImportableEntry[];282 totalItems: number;283} {284 const data = JSON.parse(raw) as {285 vaults?: Record<string, { name?: string; items?: any[] }>;286 };287 const vaults = data?.vaults && typeof data.vaults === "object" ? data.vaults : {};288 const entries: ImportableEntry[] = [];289 let totalItems = 0;290291 for (const vault of Object.values(vaults)) {292 if (vault?.name?.toLowerCase() === "recycle bin") continue;293294 for (const item of vault?.items ?? []) {295 const itemData = item?.data ?? item;296 const itemType = itemData?.type ?? item?.type;297 if (itemType !== "login") continue;298299 totalItems += 1;300301 const content = itemData?.content ?? {};302 const metadata = itemData?.metadata ?? {};303 const password = firstNonEmptyString(304 content.password,305 content.itemPassword306 );307 if (!password) continue;308309 pushEntry(entries, {310 type: "login",311 title: firstNonEmptyString(312 metadata.name,313 item.name,314 item.title,315 content.itemName,316 "Untitled"317 ),318 username: firstNonEmptyString(319 content.username,320 content.itemUsername,321 content.email,322 content.itemEmail,323 item.aliasEmail,324 ""325 ),326 password,327 url: firstNonEmptyString(328 Array.isArray(content.urls) ? content.urls[0] : undefined,329 content.url,330 ""331 ),332 notes: stringOrUndefined(metadata.note ?? metadata.notes),333 ...(normalizeTotp(334 firstNonEmptyString(content.totpUri, content.itemTotpUri)335 ) ?? {}),336 });337 }338 }339340 return { entries, totalItems };341}342343async function parseCredentialExchangeJSON(raw: string): Promise<{344 entries: ImportableEntry[];345 totalItems: number;346}> {347 const data = JSON.parse(raw) as {348 accounts?: Array<{349 items?: Array<{350 title?: string;351 modifiedAt?: number;352 creationAt?: number;353 credentials?: Array<{354 type?: string;355 credentialId?: string;356 rpId?: string;357 username?: string;358 userDisplayName?: string;359 userHandle?: string;360 key?: string;361 }>;362 }>;363 }>;364 };365366 const entries: ImportableEntry[] = [];367 let totalItems = 0;368369 for (const account of data.accounts ?? []) {370 for (const item of account.items ?? []) {371 for (const credential of item.credentials ?? []) {372 if (credential?.type !== "passkey") continue;373 totalItems += 1;374375 if (376 !credential.credentialId ||377 !credential.rpId ||378 !credential.username ||379 !credential.userDisplayName ||380 !credential.userHandle ||381 !credential.key382 ) {383 continue;384 }385386 try {387 const pkcs8 = base64urlDecode(credential.key);388 const imported = await importPasskeyPrivateKey(pkcs8);389 const createdAt = Date.now();390 pushEntry(entries, {391 type: "login",392 title: firstNonEmptyString(item.title, credential.rpId),393 username: credential.username,394 url: `https://${credential.rpId}`,395 passkey: {396 rpId: credential.rpId,397 rpName: firstNonEmptyString(item.title, credential.rpId),398 credentialId: credential.credentialId,399 publicKeyCose: base64urlEncode(imported.publicKeyCose),400 privateKeyJwk: imported.privateKeyJwk,401 userHandle: credential.userHandle,402 userName: credential.username,403 userDisplayName: credential.userDisplayName,404 algorithm: imported.algorithm,405 signCount: 0,406 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",407 backupEligible: true,408 backupState: false,409 createdAt,410 },411 });412 } catch {413 414 }415 }416 }417 }418419 return { entries, totalItems };420}421422function has1PasswordShape(value: unknown): boolean {423 if (!value || typeof value !== "object") return false;424 const v = value as { accounts?: unknown[]; items?: unknown[] };425 if (Array.isArray(v.accounts)) return true;426 if (!Array.isArray(v.items) || v.items.length === 0) return false;427 const sample = v.items[0] as any;428 return Boolean(sample?.details?.loginFields || sample?.overview?.title);429}430431function hasBitwardenShape(value: unknown): boolean {432 return Boolean(433 value &&434 typeof value === "object" &&435 Array.isArray((value as { items?: unknown[] }).items)436 );437}438439function hasCredentialExchangeShape(value: unknown): boolean {440 return Boolean(441 value &&442 typeof value === "object" &&443 (value as { version?: { major?: number; minor?: number }; accounts?: unknown[] }).version &&444 Array.isArray((value as { accounts?: unknown[] }).accounts)445 );446}447448function hasProtonPassShape(value: unknown): boolean {449 return Boolean(450 value &&451 typeof value === "object" &&452 (value as { vaults?: unknown }).vaults &&453 typeof (value as { vaults?: unknown }).vaults === "object"454 );455}456457function pickFirstField(458 row: Record<string, string>,459 candidates: string[]460): string {461 for (const key of candidates) {462 const value = row[key];463 if (typeof value === "string" && value.trim()) {464 return value.trim();465 }466 }467 return "";468}469470function normalizeTotp(471 value?: string472): { totp: string; totpAlgorithm?: "SHA1" | "SHA256" | "SHA512"; totpDigits?: number; totpPeriod?: number } | undefined {473 if (!value) return undefined;474 const parsed = value.startsWith("otpauth://") ? parseOtpauthURI(value) : null;475 const totp = parsed?.secret ?? value.trim();476 if (!totp) return undefined;477 if (!parsed) return { totp };478 479 480 return {481 totp,482 totpAlgorithm: parsed.algorithm && parsed.algorithm !== "SHA1" ? parsed.algorithm : undefined,483 totpDigits: parsed.digits && parsed.digits !== 6 ? parsed.digits : undefined,484 totpPeriod: parsed.period && parsed.period !== 30 ? parsed.period : undefined,485 };486}487488function firstNonEmptyString(489 ...values: Array<string | null | undefined>490): string {491 for (const value of values) {492 if (typeof value === "string" && value.trim()) {493 return value.trim();494 }495 }496 return "";497}498499function stringOrUndefined(value: unknown): string | undefined {500 return typeof value === "string" && value.trim() ? value.trim() : undefined;501}502503function stringOrEmpty(value: unknown): string {504 return typeof value === "string" ? value : "";505}