import.ts

15.7 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  /** License key from the backup, present only on Pro encrypted JSON exports. */30  licenseKey?: string;31  licenseActivatedAt?: number;32}3334export async function parseImportSource(35  raw: string,36  password?: string37): Promise<ImportParseResult> {38  const format = detectImportFormat(raw);3940  switch (format) {41    case "csv": {42      const entries = parseCSV(raw);43      const totalItems = Papa.parse<Record<string, string>>(raw, {44        header: true,45        skipEmptyLines: true,46      }).data.length;47      return {48        format,49        label: "CSV import",50        entries,51        totalItems,52        skippedItems: Math.max(0, totalItems - entries.length),53        requiresPassword: false,54      };55    }56    case "1password-json": {57      const entries = parse1PasswordJSON(raw);58      return {59        format,60        label: "1Password JSON",61        entries,62        totalItems: entries.length,63        skippedItems: 0,64        requiresPassword: false,65      };66    }67    case "bitwarden-json": {68      const { entries, totalItems } = parseBitwardenJSON(raw);69      return {70        format,71        label: "Bitwarden JSON",72        entries,73        totalItems,74        skippedItems: Math.max(0, totalItems - entries.length),75        requiresPassword: false,76      };77    }78    case "credential-exchange-json": {79      const { entries, totalItems } = await parseCredentialExchangeJSON(raw);80      return {81        format,82        label: "Credential Exchange JSON",83        entries,84        totalItems,85        skippedItems: Math.max(0, totalItems - entries.length),86        requiresPassword: false,87      };88    }89    case "protonpass-json": {90      const { entries, totalItems } = parseProtonPassJSON(raw);91      return {92        format,93        label: "Proton Pass JSON",94        entries,95        totalItems,96        skippedItems: Math.max(0, totalItems - entries.length),97        requiresPassword: false,98      };99    }100    case "peach-legacy-json": {101      const { entries, totalItems, skippedItems } = parsePeachLegacyJSON(raw);102      return {103        format,104        label: "Peach (legacy)",105        entries,106        totalItems,107        skippedItems,108        requiresPassword: false,109      };110    }111    case "peach-json": {112      if (!password) {113        return {114          format,115          label: "Peach encrypted backup",116          entries: [],117          totalItems: 0,118          skippedItems: 0,119          requiresPassword: true,120        };121      }122123      const result = await importEntriesFromPeachJson(raw, password);124      return {125        format,126        label: "Peach encrypted backup",127        entries: result.entries,128        totalItems: result.entries.length,129        skippedItems: 0,130        requiresPassword: false,131        ...(result.licenseKey ? { licenseKey: result.licenseKey, licenseActivatedAt: result.licenseActivatedAt } : {}),132      };133    }134    default:135      return {136        format: "unknown",137        label: "Unknown format",138        entries: [],139        totalItems: 0,140        skippedItems: 0,141        requiresPassword: false,142      };143  }144}145146export function detectImportFormat(raw: string): ImportFormat {147  const trimmed = raw.trim();148  if (trimmed.startsWith("{") || trimmed.startsWith("[")) {149    try {150      const data = JSON.parse(trimmed);151      if (hasPeachBackupShape(data)) return "peach-json";152      if ((data as any)?.exportFormat === "peach-vault")153        return "peach-legacy-json";154      if (hasCredentialExchangeShape(data)) return "credential-exchange-json";155      if (has1PasswordShape(data)) return "1password-json";156      if (hasBitwardenShape(data)) return "bitwarden-json";157      if (hasProtonPassShape(data)) return "protonpass-json";158      return "unknown";159    } catch {160      return "unknown";161    }162  }163164  return trimmed.includes(",") ? "csv" : "unknown";165}166167export function parse1PasswordJSON(raw: string): ImportableEntry[] {168  const data = JSON.parse(raw);169  const entries: ImportableEntry[] = [];170171  const itemGroups: any[][] = [];172  const vaults = data?.accounts?.[0]?.vaults ?? [];173  for (const vault of vaults) itemGroups.push(vault.items ?? []);174  if (Array.isArray(data?.items)) itemGroups.push(data.items);175176  for (const items of itemGroups) {177    for (const item of items) {178      if (item.trashed) continue;179      const fields = item.details?.loginFields ?? item.fields ?? [];180      const username =181        fields.find((field: any) => field.designation === "username" || field.id === "username")?.value ??182        "";183      const password =184        fields.find((field: any) => field.designation === "password" || field.id === "password")?.value ??185        "";186      if (!password) continue;187      entries.push({188        type: "login",189        title: (item.overview?.title ?? item.title)?.trim() || "Untitled",190        username: username.trim(),191        password,192        url: ((item.overview?.url ?? item.url) ?? "").trim(),193        notes: stringOrUndefined(item.details?.notesPlain ?? item.notes),194      });195    }196  }197198  return entries;199}200201function parseCSV(csvText: string): ImportableEntry[] {202  const result = Papa.parse<Record<string, string>>(csvText, {203    header: true,204    skipEmptyLines: true,205    transformHeader: (header) => header.trim(),206  });207208  return result.data.reduce<ImportableEntry[]>((entries, row) => {209      const title = pickFirstField(row, [210        "name",211        "title",212        "Title",213        "Name",214        "item",215      ]);216      const url = pickFirstField(row, [217        "url",218        "website",219        "URL",220        "Website",221        "login_uri",222        "loginUrl",223      ]);224      const username = pickFirstField(row, [225        "username",226        "login",227        "email",228        "Username",229        "login_username",230        "user",231      ]);232      const password = pickFirstField(row, [233        "password",234        "Password",235        "login_password",236      ]);237      const notes = pickFirstField(row, [238        "notes",239        "Notes",240        "note",241        "comment",242        "extra",243      ]);244      const totp = normalizeTotp(245        pickFirstField(row, [246          "totp",247          "totpUri",248          "otp",249          "otpSecret",250          "two-factor-secret",251          "login_totp",252        ])253      );254      const grouping = pickFirstField(row, ["grouping", "Grouping", "folder", "Folder"]);255256      if (!password) return entries;257258      entries.push({259        type: "login",260        title: firstNonEmptyString(title, url, username, "Untitled"),261        username,262        password,263        url,264        notes: notes || undefined,265        totp,266        tags: grouping ? [grouping] : undefined,267      });268      return entries;269    }, []);270}271272function parseBitwardenJSON(raw: string): {273  entries: ImportableEntry[];274  totalItems: number;275} {276  const data = JSON.parse(raw);277  const items: any[] = Array.isArray(data?.items) ? data.items : [];278  const loginItems = items.filter((item) => item?.type === 1);279280  return {281    totalItems: loginItems.length,282    entries: loginItems.reduce<ImportableEntry[]>((entries, item) => {283        const password = stringOrEmpty(item.login?.password);284        if (!password) return entries;285286        entries.push({287          type: "login",288          title: firstNonEmptyString(item.name, "Untitled"),289          url: firstNonEmptyString(item.login?.uris?.[0]?.uri, ""),290          username: firstNonEmptyString(item.login?.username, ""),291          password,292          notes: stringOrUndefined(item.notes),293          totp: normalizeTotp(stringOrUndefined(item.login?.totp)),294        });295        return entries;296      }, []),297  };298}299300function parseProtonPassJSON(raw: string): {301  entries: ImportableEntry[];302  totalItems: number;303} {304  const data = JSON.parse(raw) as {305    vaults?: Record<string, { name?: string; items?: any[] }>;306  };307  const vaults = data?.vaults && typeof data.vaults === "object" ? data.vaults : {};308  const entries: ImportableEntry[] = [];309  let totalItems = 0;310311  for (const vault of Object.values(vaults)) {312    if (vault?.name?.toLowerCase() === "recycle bin") continue;313314    for (const item of vault?.items ?? []) {315      const itemData = item?.data ?? item;316      const itemType = itemData?.type ?? item?.type;317      if (itemType !== "login") continue;318319      totalItems += 1;320321      const content = itemData?.content ?? {};322      const metadata = itemData?.metadata ?? {};323      const password = firstNonEmptyString(324        content.password,325        content.itemPassword326      );327      if (!password) continue;328329      entries.push({330        type: "login",331        title: firstNonEmptyString(332          metadata.name,333          item.name,334          item.title,335          content.itemName,336          "Untitled"337        ),338        username: firstNonEmptyString(339          content.username,340          content.itemUsername,341          content.email,342          content.itemEmail,343          item.aliasEmail,344          ""345        ),346        password,347        url: firstNonEmptyString(348          Array.isArray(content.urls) ? content.urls[0] : undefined,349          content.url,350          ""351        ),352        notes: stringOrUndefined(metadata.note ?? metadata.notes),353        totp: normalizeTotp(354          firstNonEmptyString(content.totpUri, content.itemTotpUri)355        ),356      });357    }358  }359360  return { entries, totalItems };361}362363async function parseCredentialExchangeJSON(raw: string): Promise<{364  entries: ImportableEntry[];365  totalItems: number;366}> {367  const data = JSON.parse(raw) as {368    accounts?: Array<{369      items?: Array<{370        title?: string;371        modifiedAt?: number;372        creationAt?: number;373        credentials?: Array<{374          type?: string;375          credentialId?: string;376          rpId?: string;377          username?: string;378          userDisplayName?: string;379          userHandle?: string;380          key?: string;381        }>;382      }>;383    }>;384  };385386  const entries: ImportableEntry[] = [];387  let totalItems = 0;388389  for (const account of data.accounts ?? []) {390    for (const item of account.items ?? []) {391      for (const credential of item.credentials ?? []) {392        if (credential?.type !== "passkey") continue;393        totalItems += 1;394395        if (396          !credential.credentialId ||397          !credential.rpId ||398          !credential.username ||399          !credential.userDisplayName ||400          !credential.userHandle ||401          !credential.key402        ) {403          continue;404        }405406        try {407          const pkcs8 = base64urlDecode(credential.key);408          const imported = await importPasskeyPrivateKey(pkcs8);409          entries.push({410            type: "passkey",411            title: firstNonEmptyString(item.title, credential.rpId),412            rpId: credential.rpId,413            rpName: firstNonEmptyString(item.title, credential.rpId),414            credentialId: credential.credentialId,415            publicKeyCose: base64urlEncode(imported.publicKeyCose),416            privateKeyJwk: imported.privateKeyJwk,417            userHandle: credential.userHandle,418            userName: credential.username,419            userDisplayName: credential.userDisplayName,420            algorithm: imported.algorithm,421            signCount: 0,422            aaguid: "AAAAAAAAAAAAAAAAAAAAAA",423            backupEligible: true,424            backupState: false,425          });426        } catch {427          // Skip passkeys whose private key we cannot import into WebCrypto.428        }429      }430    }431  }432433  return { entries, totalItems };434}435436function parsePeachLegacyJSON(raw: string): {437  entries: ImportableEntry[];438  totalItems: number;439  skippedItems: number;440} {441  const data = JSON.parse(raw) as {442    exportFormat: string;443    entries: Array<{444      id: string;445      type: string;446      modified: number;447      created: number;448      name: string;449      favorite: boolean;450      tags: string[];451      login?: {452        urls?: string[];453        username?: string;454        password?: string;455        totp?: {456          secret: string;457          algorithm: string;458          digits: number;459          period: number;460        };461        customFields?: unknown[];462        notes?: string;463      };464    }>;465  };466467  const totalItems = data.entries.length;468  const filtered = data.entries.filter((e) => e.type === "login");469  const skippedItems = totalItems - filtered.length;470471  return {472    totalItems,473    skippedItems,474    entries: filtered.map((entry) => ({475      type: "login" as const,476      title: entry.name ?? "Untitled",477      url: entry.login?.urls?.[0] ?? "",478      username: entry.login?.username ?? "",479      password: entry.login?.password ?? "",480      totp: entry.login?.totp?.secret ?? undefined,481      notes: entry.login?.notes ?? undefined,482    })),483  };484}485486function has1PasswordShape(value: unknown): boolean {487  if (!value || typeof value !== "object") return false;488  const v = value as { accounts?: unknown[]; items?: unknown[] };489  if (Array.isArray(v.accounts)) return true;490  if (!Array.isArray(v.items) || v.items.length === 0) return false;491  const sample = v.items[0] as any;492  return Boolean(sample?.details?.loginFields || sample?.overview?.title);493}494495function hasBitwardenShape(value: unknown): boolean {496  return Boolean(497    value &&498      typeof value === "object" &&499      Array.isArray((value as { items?: unknown[] }).items)500  );501}502503function hasCredentialExchangeShape(value: unknown): boolean {504  return Boolean(505    value &&506      typeof value === "object" &&507      (value as { version?: { major?: number; minor?: number }; accounts?: unknown[] }).version &&508      Array.isArray((value as { accounts?: unknown[] }).accounts)509  );510}511512function hasProtonPassShape(value: unknown): boolean {513  return Boolean(514    value &&515      typeof value === "object" &&516      (value as { vaults?: unknown }).vaults &&517      typeof (value as { vaults?: unknown }).vaults === "object"518  );519}520521function hasPeachBackupShape(value: unknown): boolean {522  if (!value || typeof value !== "object") return false;523  const envelope = value as Partial<{524    type: string;525    version: number;526    kdf: string;527    salt: string;528    nonce: string;529    ciphertext: string;530    tag: string;531  }>;532  return (533    envelope.type === "peach-export" &&534    envelope.version === 2 &&535    envelope.kdf === "PBKDF2-SHA256" &&536    typeof envelope.salt === "string" &&537    typeof envelope.nonce === "string" &&538    typeof envelope.ciphertext === "string" &&539    typeof envelope.tag === "string"540  );541}542543function pickFirstField(544  row: Record<string, string>,545  candidates: string[]546): string {547  for (const key of candidates) {548    const value = row[key];549    if (typeof value === "string" && value.trim()) {550      return value.trim();551    }552  }553  return "";554}555556function normalizeTotp(value?: string): string | undefined {557  if (!value) return undefined;558  const parsed = value.startsWith("otpauth://") ? parseOtpauthURI(value) : null;559  return parsed?.secret ?? value.trim() ?? undefined;560}561562function firstNonEmptyString(563  ...values: Array<string | null | undefined>564): string {565  for (const value of values) {566    if (typeof value === "string" && value.trim()) {567      return value.trim();568    }569  }570  return "";571}572573function stringOrUndefined(value: unknown): string | undefined {574  return typeof value === "string" && value.trim() ? value.trim() : undefined;575}576577function stringOrEmpty(value: unknown): string {578  return typeof value === "string" ? value : "";579}