import.ts

15.4 KB · raw

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}3031export async function parseImportSource(32  raw: string,33  password?: string34): Promise<ImportParseResult> {35  const format = detectImportFormat(raw);3637  switch (format) {38    case "csv": {39      const entries = parseCSV(raw);40      const totalItems = Papa.parse<Record<string, string>>(raw, {41        header: true,42        skipEmptyLines: true,43      }).data.length;44      return {45        format,46        label: "CSV import",47        entries,48        totalItems,49        skippedItems: Math.max(0, totalItems - entries.length),50        requiresPassword: false,51      };52    }53    case "1password-json": {54      const entries = parse1PasswordJSON(raw);55      return {56        format,57        label: "1Password JSON",58        entries,59        totalItems: entries.length,60        skippedItems: 0,61        requiresPassword: false,62      };63    }64    case "bitwarden-json": {65      const { entries, totalItems } = parseBitwardenJSON(raw);66      return {67        format,68        label: "Bitwarden JSON",69        entries,70        totalItems,71        skippedItems: Math.max(0, totalItems - entries.length),72        requiresPassword: false,73      };74    }75    case "credential-exchange-json": {76      const { entries, totalItems } = await parseCredentialExchangeJSON(raw);77      return {78        format,79        label: "Credential Exchange JSON",80        entries,81        totalItems,82        skippedItems: Math.max(0, totalItems - entries.length),83        requiresPassword: false,84      };85    }86    case "protonpass-json": {87      const { entries, totalItems } = parseProtonPassJSON(raw);88      return {89        format,90        label: "Proton Pass JSON",91        entries,92        totalItems,93        skippedItems: Math.max(0, totalItems - entries.length),94        requiresPassword: false,95      };96    }97    case "peach-legacy-json": {98      const { entries, totalItems, skippedItems } = parsePeachLegacyJSON(raw);99      return {100        format,101        label: "Peach (legacy)",102        entries,103        totalItems,104        skippedItems,105        requiresPassword: false,106      };107    }108    case "peach-json": {109      if (!password) {110        return {111          format,112          label: "Peach encrypted backup",113          entries: [],114          totalItems: 0,115          skippedItems: 0,116          requiresPassword: true,117        };118      }119120      const entries = await importEntriesFromPeachJson(raw, password);121      return {122        format,123        label: "Peach encrypted backup",124        entries,125        totalItems: entries.length,126        skippedItems: 0,127        requiresPassword: false,128      };129    }130    default:131      return {132        format: "unknown",133        label: "Unknown format",134        entries: [],135        totalItems: 0,136        skippedItems: 0,137        requiresPassword: false,138      };139  }140}141142export function detectImportFormat(raw: string): ImportFormat {143  const trimmed = raw.trim();144  if (trimmed.startsWith("{") || trimmed.startsWith("[")) {145    try {146      const data = JSON.parse(trimmed);147      if (hasPeachBackupShape(data)) return "peach-json";148      if ((data as any)?.exportFormat === "peach-vault")149        return "peach-legacy-json";150      if (hasCredentialExchangeShape(data)) return "credential-exchange-json";151      if (has1PasswordShape(data)) return "1password-json";152      if (hasBitwardenShape(data)) return "bitwarden-json";153      if (hasProtonPassShape(data)) return "protonpass-json";154      return "unknown";155    } catch {156      return "unknown";157    }158  }159160  return trimmed.includes(",") ? "csv" : "unknown";161}162163export function parse1PasswordJSON(raw: string): ImportableEntry[] {164  const data = JSON.parse(raw);165  const entries: ImportableEntry[] = [];166167  const itemGroups: any[][] = [];168  const vaults = data?.accounts?.[0]?.vaults ?? [];169  for (const vault of vaults) itemGroups.push(vault.items ?? []);170  if (Array.isArray(data?.items)) itemGroups.push(data.items);171172  for (const items of itemGroups) {173    for (const item of items) {174      if (item.trashed) continue;175      const fields = item.details?.loginFields ?? item.fields ?? [];176      const username =177        fields.find((field: any) => field.designation === "username" || field.id === "username")?.value ??178        "";179      const password =180        fields.find((field: any) => field.designation === "password" || field.id === "password")?.value ??181        "";182      if (!password) continue;183      entries.push({184        type: "login",185        title: (item.overview?.title ?? item.title)?.trim() || "Untitled",186        username: username.trim(),187        password,188        url: ((item.overview?.url ?? item.url) ?? "").trim(),189        notes: stringOrUndefined(item.details?.notesPlain ?? item.notes),190      });191    }192  }193194  return entries;195}196197function parseCSV(csvText: string): ImportableEntry[] {198  const result = Papa.parse<Record<string, string>>(csvText, {199    header: true,200    skipEmptyLines: true,201    transformHeader: (header) => header.trim(),202  });203204  return result.data.reduce<ImportableEntry[]>((entries, row) => {205      const title = pickFirstField(row, [206        "name",207        "title",208        "Title",209        "Name",210        "item",211      ]);212      const url = pickFirstField(row, [213        "url",214        "website",215        "URL",216        "Website",217        "login_uri",218        "loginUrl",219      ]);220      const username = pickFirstField(row, [221        "username",222        "login",223        "email",224        "Username",225        "login_username",226        "user",227      ]);228      const password = pickFirstField(row, [229        "password",230        "Password",231        "login_password",232      ]);233      const notes = pickFirstField(row, [234        "notes",235        "Notes",236        "note",237        "comment",238        "extra",239      ]);240      const totp = normalizeTotp(241        pickFirstField(row, [242          "totp",243          "totpUri",244          "otp",245          "otpSecret",246          "two-factor-secret",247          "login_totp",248        ])249      );250      const grouping = pickFirstField(row, ["grouping", "Grouping", "folder", "Folder"]);251252      if (!password) return entries;253254      entries.push({255        type: "login",256        title: firstNonEmptyString(title, url, username, "Untitled"),257        username,258        password,259        url,260        notes: notes || undefined,261        totp,262        tags: grouping ? [grouping] : undefined,263      });264      return entries;265    }, []);266}267268function parseBitwardenJSON(raw: string): {269  entries: ImportableEntry[];270  totalItems: number;271} {272  const data = JSON.parse(raw);273  const items: any[] = Array.isArray(data?.items) ? data.items : [];274  const loginItems = items.filter((item) => item?.type === 1);275276  return {277    totalItems: loginItems.length,278    entries: loginItems.reduce<ImportableEntry[]>((entries, item) => {279        const password = stringOrEmpty(item.login?.password);280        if (!password) return entries;281282        entries.push({283          type: "login",284          title: firstNonEmptyString(item.name, "Untitled"),285          url: firstNonEmptyString(item.login?.uris?.[0]?.uri, ""),286          username: firstNonEmptyString(item.login?.username, ""),287          password,288          notes: stringOrUndefined(item.notes),289          totp: normalizeTotp(stringOrUndefined(item.login?.totp)),290        });291        return entries;292      }, []),293  };294}295296function parseProtonPassJSON(raw: string): {297  entries: ImportableEntry[];298  totalItems: number;299} {300  const data = JSON.parse(raw) as {301    vaults?: Record<string, { name?: string; items?: any[] }>;302  };303  const vaults = data?.vaults && typeof data.vaults === "object" ? data.vaults : {};304  const entries: ImportableEntry[] = [];305  let totalItems = 0;306307  for (const vault of Object.values(vaults)) {308    if (vault?.name?.toLowerCase() === "recycle bin") continue;309310    for (const item of vault?.items ?? []) {311      const itemData = item?.data ?? item;312      const itemType = itemData?.type ?? item?.type;313      if (itemType !== "login") continue;314315      totalItems += 1;316317      const content = itemData?.content ?? {};318      const metadata = itemData?.metadata ?? {};319      const password = firstNonEmptyString(320        content.password,321        content.itemPassword322      );323      if (!password) continue;324325      entries.push({326        type: "login",327        title: firstNonEmptyString(328          metadata.name,329          item.name,330          item.title,331          content.itemName,332          "Untitled"333        ),334        username: firstNonEmptyString(335          content.username,336          content.itemUsername,337          content.email,338          content.itemEmail,339          item.aliasEmail,340          ""341        ),342        password,343        url: firstNonEmptyString(344          Array.isArray(content.urls) ? content.urls[0] : undefined,345          content.url,346          ""347        ),348        notes: stringOrUndefined(metadata.note ?? metadata.notes),349        totp: normalizeTotp(350          firstNonEmptyString(content.totpUri, content.itemTotpUri)351        ),352      });353    }354  }355356  return { entries, totalItems };357}358359async function parseCredentialExchangeJSON(raw: string): Promise<{360  entries: ImportableEntry[];361  totalItems: number;362}> {363  const data = JSON.parse(raw) as {364    accounts?: Array<{365      items?: Array<{366        title?: string;367        modifiedAt?: number;368        creationAt?: number;369        credentials?: Array<{370          type?: string;371          credentialId?: string;372          rpId?: string;373          username?: string;374          userDisplayName?: string;375          userHandle?: string;376          key?: string;377        }>;378      }>;379    }>;380  };381382  const entries: ImportableEntry[] = [];383  let totalItems = 0;384385  for (const account of data.accounts ?? []) {386    for (const item of account.items ?? []) {387      for (const credential of item.credentials ?? []) {388        if (credential?.type !== "passkey") continue;389        totalItems += 1;390391        if (392          !credential.credentialId ||393          !credential.rpId ||394          !credential.username ||395          !credential.userDisplayName ||396          !credential.userHandle ||397          !credential.key398        ) {399          continue;400        }401402        try {403          const pkcs8 = base64urlDecode(credential.key);404          const imported = await importPasskeyPrivateKey(pkcs8);405          entries.push({406            type: "passkey",407            title: firstNonEmptyString(item.title, credential.rpId),408            rpId: credential.rpId,409            rpName: firstNonEmptyString(item.title, credential.rpId),410            credentialId: credential.credentialId,411            publicKeyCose: base64urlEncode(imported.publicKeyCose),412            privateKeyJwk: imported.privateKeyJwk,413            userHandle: credential.userHandle,414            userName: credential.username,415            userDisplayName: credential.userDisplayName,416            algorithm: imported.algorithm,417            signCount: 0,418            aaguid: "AAAAAAAAAAAAAAAAAAAAAA",419            backupEligible: true,420            backupState: false,421          });422        } catch {423          // Skip passkeys whose private key we cannot import into WebCrypto.424        }425      }426    }427  }428429  return { entries, totalItems };430}431432function parsePeachLegacyJSON(raw: string): {433  entries: ImportableEntry[];434  totalItems: number;435  skippedItems: number;436} {437  const data = JSON.parse(raw) as {438    exportFormat: string;439    entries: Array<{440      id: string;441      type: string;442      modified: number;443      created: number;444      name: string;445      favorite: boolean;446      tags: string[];447      login?: {448        urls?: string[];449        username?: string;450        password?: string;451        totp?: {452          secret: string;453          algorithm: string;454          digits: number;455          period: number;456        };457        customFields?: unknown[];458        notes?: string;459      };460    }>;461  };462463  const totalItems = data.entries.length;464  const filtered = data.entries.filter((e) => e.type === "login");465  const skippedItems = totalItems - filtered.length;466467  return {468    totalItems,469    skippedItems,470    entries: filtered.map((entry) => ({471      type: "login" as const,472      title: entry.name ?? "Untitled",473      url: entry.login?.urls?.[0] ?? "",474      username: entry.login?.username ?? "",475      password: entry.login?.password ?? "",476      totp: entry.login?.totp?.secret ?? undefined,477      notes: entry.login?.notes ?? undefined,478    })),479  };480}481482function has1PasswordShape(value: unknown): boolean {483  if (!value || typeof value !== "object") return false;484  const v = value as { accounts?: unknown[]; items?: unknown[] };485  if (Array.isArray(v.accounts)) return true;486  if (!Array.isArray(v.items) || v.items.length === 0) return false;487  const sample = v.items[0] as any;488  return Boolean(sample?.details?.loginFields || sample?.overview?.title);489}490491function hasBitwardenShape(value: unknown): boolean {492  return Boolean(493    value &&494      typeof value === "object" &&495      Array.isArray((value as { items?: unknown[] }).items)496  );497}498499function hasCredentialExchangeShape(value: unknown): boolean {500  return Boolean(501    value &&502      typeof value === "object" &&503      (value as { version?: { major?: number; minor?: number }; accounts?: unknown[] }).version &&504      Array.isArray((value as { accounts?: unknown[] }).accounts)505  );506}507508function hasProtonPassShape(value: unknown): boolean {509  return Boolean(510    value &&511      typeof value === "object" &&512      (value as { vaults?: unknown }).vaults &&513      typeof (value as { vaults?: unknown }).vaults === "object"514  );515}516517function hasPeachBackupShape(value: unknown): boolean {518  if (!value || typeof value !== "object") return false;519  const envelope = value as Partial<{520    type: string;521    version: number;522    kdf: string;523    salt: string;524    nonce: string;525    ciphertext: string;526    tag: string;527  }>;528  return (529    envelope.type === "peach-export" &&530    envelope.version === 2 &&531    envelope.kdf === "PBKDF2-SHA256" &&532    typeof envelope.salt === "string" &&533    typeof envelope.nonce === "string" &&534    typeof envelope.ciphertext === "string" &&535    typeof envelope.tag === "string"536  );537}538539function pickFirstField(540  row: Record<string, string>,541  candidates: string[]542): string {543  for (const key of candidates) {544    const value = row[key];545    if (typeof value === "string" && value.trim()) {546      return value.trim();547    }548  }549  return "";550}551552function normalizeTotp(value?: string): string | undefined {553  if (!value) return undefined;554  const parsed = value.startsWith("otpauth://") ? parseOtpauthURI(value) : null;555  return parsed?.secret ?? value.trim() ?? undefined;556}557558function firstNonEmptyString(559  ...values: Array<string | null | undefined>560): string {561  for (const value of values) {562    if (typeof value === "string" && value.trim()) {563      return value.trim();564    }565  }566  return "";567}568569function stringOrUndefined(value: unknown): string | undefined {570  return typeof value === "string" && value.trim() ? value.trim() : undefined;571}572573function stringOrEmpty(value: unknown): string {574  return typeof value === "string" ? value : "";575}