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 { importEntriesFromPeachJson, type ImportableEntry } from "./backup";1011export type ImportFormat =12  | "csv"13  | "1password-json"14  | "bitwarden-json"15  | "credential-exchange-json"16  | "protonpass-json"17  | "peach-json"18  | "peach-legacy-json"19  | "unknown";2021export interface ImportParseResult {22  format: ImportFormat;23  label: string;24  entries: ImportableEntry[];25  totalItems: number;26  skippedItems: number;27  requiresPassword: boolean;28  /** License key from the backup, present only on Pro encrypted JSON exports. */29  licenseKey?: string;30  licenseActivatedAt?: number;31}3233export async function parseImportSource(34  raw: string,35  password?: string36): Promise<ImportParseResult> {37  const format = detectImportFormat(raw);3839  switch (format) {40    case "csv": {41      const entries = parseCSV(raw);42      const totalItems = Papa.parse<Record<string, string>>(raw, {43        header: true,44        skipEmptyLines: true,45      }).data.length;46      return {47        format,48        label: "CSV import",49        entries,50        totalItems,51        skippedItems: Math.max(0, totalItems - entries.length),52        requiresPassword: false,53      };54    }55    case "1password-json": {56      const entries = parse1PasswordJSON(raw);57      return {58        format,59        label: "1Password JSON",60        entries,61        totalItems: entries.length,62        skippedItems: 0,63        requiresPassword: false,64      };65    }66    case "bitwarden-json": {67      const { entries, totalItems } = parseBitwardenJSON(raw);68      return {69        format,70        label: "Bitwarden JSON",71        entries,72        totalItems,73        skippedItems: Math.max(0, totalItems - entries.length),74        requiresPassword: false,75      };76    }77    case "credential-exchange-json": {78      const { entries, totalItems } = await parseCredentialExchangeJSON(raw);79      return {80        format,81        label: "Credential Exchange JSON",82        entries,83        totalItems,84        skippedItems: Math.max(0, totalItems - entries.length),85        requiresPassword: false,86      };87    }88    case "protonpass-json": {89      const { entries, totalItems } = parseProtonPassJSON(raw);90      return {91        format,92        label: "Proton Pass JSON",93        entries,94        totalItems,95        skippedItems: Math.max(0, totalItems - entries.length),96        requiresPassword: false,97      };98    }99    case "peach-legacy-json": {100      const { entries, totalItems, skippedItems } = parsePeachLegacyJSON(raw);101      return {102        format,103        label: "Peach (legacy)",104        entries,105        totalItems,106        skippedItems,107        requiresPassword: false,108      };109    }110    case "peach-json": {111      if (!password) {112        return {113          format,114          label: "Peach encrypted backup",115          entries: [],116          totalItems: 0,117          skippedItems: 0,118          requiresPassword: true,119        };120      }121122      const result = await importEntriesFromPeachJson(raw, password);123      return {124        format,125        label: "Peach encrypted backup",126        entries: result.entries,127        totalItems: result.entries.length,128        skippedItems: 0,129        requiresPassword: false,130        ...(result.licenseKey ? { licenseKey: result.licenseKey, licenseActivatedAt: result.licenseActivatedAt } : {}),131      };132    }133    default:134      return {135        format: "unknown",136        label: "Unknown format",137        entries: [],138        totalItems: 0,139        skippedItems: 0,140        requiresPassword: false,141      };142  }143}144145export function detectImportFormat(raw: string): ImportFormat {146  const trimmed = raw.trim();147  if (trimmed.startsWith("{") || trimmed.startsWith("[")) {148    try {149      const data = JSON.parse(trimmed);150      if (hasPeachBackupShape(data)) return "peach-json";151      if ((data as any)?.exportFormat === "peach-vault")152        return "peach-legacy-json";153      if (hasCredentialExchangeShape(data)) return "credential-exchange-json";154      if (has1PasswordShape(data)) return "1password-json";155      if (hasBitwardenShape(data)) return "bitwarden-json";156      if (hasProtonPassShape(data)) return "protonpass-json";157      return "unknown";158    } catch {159      return "unknown";160    }161  }162163  return trimmed.includes(",") ? "csv" : "unknown";164}165166export function parse1PasswordJSON(raw: string): ImportableEntry[] {167  const data = JSON.parse(raw);168  const entries: ImportableEntry[] = [];169170  const itemGroups: any[][] = [];171  const vaults = data?.accounts?.[0]?.vaults ?? [];172  for (const vault of vaults) itemGroups.push(vault.items ?? []);173  if (Array.isArray(data?.items)) itemGroups.push(data.items);174175  for (const items of itemGroups) {176    for (const item of items) {177      if (item.trashed) continue;178      const fields = item.details?.loginFields ?? item.fields ?? [];179      const username =180        fields.find((field: any) => field.designation === "username" || field.id === "username")?.value ??181        "";182      const password =183        fields.find((field: any) => field.designation === "password" || field.id === "password")?.value ??184        "";185      if (!password) continue;186      entries.push({187        type: "login",188        title: (item.overview?.title ?? item.title)?.trim() || "Untitled",189        username: username.trim(),190        password,191        url: ((item.overview?.url ?? item.url) ?? "").trim(),192        notes: stringOrUndefined(item.details?.notesPlain ?? item.notes),193      });194    }195  }196197  return entries;198}199200function parseCSV(csvText: string): ImportableEntry[] {201  const result = Papa.parse<Record<string, string>>(csvText, {202    header: true,203    skipEmptyLines: true,204    transformHeader: (header) => header.trim(),205  });206207  return result.data.reduce<ImportableEntry[]>((entries, row) => {208      const title = pickFirstField(row, [209        "name",210        "title",211        "Title",212        "Name",213        "item",214      ]);215      const url = pickFirstField(row, [216        "url",217        "website",218        "URL",219        "Website",220        "login_uri",221        "loginUrl",222      ]);223      const username = pickFirstField(row, [224        "username",225        "login",226        "email",227        "Username",228        "login_username",229        "user",230      ]);231      const password = pickFirstField(row, [232        "password",233        "Password",234        "login_password",235      ]);236      const notes = pickFirstField(row, [237        "notes",238        "Notes",239        "note",240        "comment",241        "extra",242      ]);243      const totp = normalizeTotp(244        pickFirstField(row, [245          "totp",246          "totpUri",247          "otp",248          "otpSecret",249          "two-factor-secret",250          "login_totp",251        ])252      );253      const grouping = pickFirstField(row, ["grouping", "Grouping", "folder", "Folder"]);254255      if (!password) return entries;256257      entries.push({258        type: "login",259        title: firstNonEmptyString(title, url, username, "Untitled"),260        username,261        password,262        url,263        notes: notes || undefined,264        totp,265        tags: grouping ? [grouping] : undefined,266      });267      return entries;268    }, []);269}270271function parseBitwardenJSON(raw: string): {272  entries: ImportableEntry[];273  totalItems: number;274} {275  const data = JSON.parse(raw);276  const items: any[] = Array.isArray(data?.items) ? data.items : [];277  const loginItems = items.filter((item) => item?.type === 1);278279  return {280    totalItems: loginItems.length,281    entries: loginItems.reduce<ImportableEntry[]>((entries, item) => {282        const password = stringOrEmpty(item.login?.password);283        if (!password) return entries;284285        entries.push({286          type: "login",287          title: firstNonEmptyString(item.name, "Untitled"),288          url: firstNonEmptyString(item.login?.uris?.[0]?.uri, ""),289          username: firstNonEmptyString(item.login?.username, ""),290          password,291          notes: stringOrUndefined(item.notes),292          totp: normalizeTotp(stringOrUndefined(item.login?.totp)),293        });294        return entries;295      }, []),296  };297}298299function parseProtonPassJSON(raw: string): {300  entries: ImportableEntry[];301  totalItems: number;302} {303  const data = JSON.parse(raw) as {304    vaults?: Record<string, { name?: string; items?: any[] }>;305  };306  const vaults = data?.vaults && typeof data.vaults === "object" ? data.vaults : {};307  const entries: ImportableEntry[] = [];308  let totalItems = 0;309310  for (const vault of Object.values(vaults)) {311    if (vault?.name?.toLowerCase() === "recycle bin") continue;312313    for (const item of vault?.items ?? []) {314      const itemData = item?.data ?? item;315      const itemType = itemData?.type ?? item?.type;316      if (itemType !== "login") continue;317318      totalItems += 1;319320      const content = itemData?.content ?? {};321      const metadata = itemData?.metadata ?? {};322      const password = firstNonEmptyString(323        content.password,324        content.itemPassword325      );326      if (!password) continue;327328      entries.push({329        type: "login",330        title: firstNonEmptyString(331          metadata.name,332          item.name,333          item.title,334          content.itemName,335          "Untitled"336        ),337        username: firstNonEmptyString(338          content.username,339          content.itemUsername,340          content.email,341          content.itemEmail,342          item.aliasEmail,343          ""344        ),345        password,346        url: firstNonEmptyString(347          Array.isArray(content.urls) ? content.urls[0] : undefined,348          content.url,349          ""350        ),351        notes: stringOrUndefined(metadata.note ?? metadata.notes),352        totp: normalizeTotp(353          firstNonEmptyString(content.totpUri, content.itemTotpUri)354        ),355      });356    }357  }358359  return { entries, totalItems };360}361362async function parseCredentialExchangeJSON(raw: string): Promise<{363  entries: ImportableEntry[];364  totalItems: number;365}> {366  const data = JSON.parse(raw) as {367    accounts?: Array<{368      items?: Array<{369        title?: string;370        modifiedAt?: number;371        creationAt?: number;372        credentials?: Array<{373          type?: string;374          credentialId?: string;375          rpId?: string;376          username?: string;377          userDisplayName?: string;378          userHandle?: string;379          key?: string;380        }>;381      }>;382    }>;383  };384385  const entries: ImportableEntry[] = [];386  let totalItems = 0;387388  for (const account of data.accounts ?? []) {389    for (const item of account.items ?? []) {390      for (const credential of item.credentials ?? []) {391        if (credential?.type !== "passkey") continue;392        totalItems += 1;393394        if (395          !credential.credentialId ||396          !credential.rpId ||397          !credential.username ||398          !credential.userDisplayName ||399          !credential.userHandle ||400          !credential.key401        ) {402          continue;403        }404405        try {406          const pkcs8 = base64urlDecode(credential.key);407          const imported = await importPasskeyPrivateKey(pkcs8);408          entries.push({409            type: "passkey",410            title: firstNonEmptyString(item.title, credential.rpId),411            rpId: credential.rpId,412            rpName: firstNonEmptyString(item.title, credential.rpId),413            credentialId: credential.credentialId,414            publicKeyCose: base64urlEncode(imported.publicKeyCose),415            privateKeyJwk: imported.privateKeyJwk,416            userHandle: credential.userHandle,417            userName: credential.username,418            userDisplayName: credential.userDisplayName,419            algorithm: imported.algorithm,420            signCount: 0,421            aaguid: "AAAAAAAAAAAAAAAAAAAAAA",422            backupEligible: true,423            backupState: false,424          });425        } catch {426          // Skip passkeys whose private key we cannot import into WebCrypto.427        }428      }429    }430  }431432  return { entries, totalItems };433}434435function parsePeachLegacyJSON(raw: string): {436  entries: ImportableEntry[];437  totalItems: number;438  skippedItems: number;439} {440  const data = JSON.parse(raw) as {441    exportFormat: string;442    entries: Array<{443      id: string;444      type: string;445      modified: number;446      created: number;447      name: string;448      favorite: boolean;449      tags: string[];450      login?: {451        urls?: string[];452        username?: string;453        password?: string;454        totp?: {455          secret: string;456          algorithm: string;457          digits: number;458          period: number;459        };460        customFields?: unknown[];461        notes?: string;462      };463    }>;464  };465466  const totalItems = data.entries.length;467  const filtered = data.entries.filter((e) => e.type === "login");468  const skippedItems = totalItems - filtered.length;469470  return {471    totalItems,472    skippedItems,473    entries: filtered.map((entry) => ({474      type: "login" as const,475      title: entry.name ?? "Untitled",476      url: entry.login?.urls?.[0] ?? "",477      username: entry.login?.username ?? "",478      password: entry.login?.password ?? "",479      totp: entry.login?.totp?.secret ?? undefined,480      notes: entry.login?.notes ?? undefined,481    })),482  };483}484485function has1PasswordShape(value: unknown): boolean {486  if (!value || typeof value !== "object") return false;487  const v = value as { accounts?: unknown[]; items?: unknown[] };488  if (Array.isArray(v.accounts)) return true;489  if (!Array.isArray(v.items) || v.items.length === 0) return false;490  const sample = v.items[0] as any;491  return Boolean(sample?.details?.loginFields || sample?.overview?.title);492}493494function hasBitwardenShape(value: unknown): boolean {495  return Boolean(496    value &&497      typeof value === "object" &&498      Array.isArray((value as { items?: unknown[] }).items)499  );500}501502function hasCredentialExchangeShape(value: unknown): boolean {503  return Boolean(504    value &&505      typeof value === "object" &&506      (value as { version?: { major?: number; minor?: number }; accounts?: unknown[] }).version &&507      Array.isArray((value as { accounts?: unknown[] }).accounts)508  );509}510511function hasProtonPassShape(value: unknown): boolean {512  return Boolean(513    value &&514      typeof value === "object" &&515      (value as { vaults?: unknown }).vaults &&516      typeof (value as { vaults?: unknown }).vaults === "object"517  );518}519520function hasPeachBackupShape(value: unknown): boolean {521  if (!value || typeof value !== "object") return false;522  const envelope = value as Partial<{523    type: string;524    version: number;525    kdf: string;526    salt: string;527    nonce: string;528    ciphertext: string;529    tag: string;530  }>;531  return (532    envelope.type === "peach-export" &&533    envelope.version === 2 &&534    envelope.kdf === "PBKDF2-SHA256" &&535    typeof envelope.salt === "string" &&536    typeof envelope.nonce === "string" &&537    typeof envelope.ciphertext === "string" &&538    typeof envelope.tag === "string"539  );540}541542function pickFirstField(543  row: Record<string, string>,544  candidates: string[]545): string {546  for (const key of candidates) {547    const value = row[key];548    if (typeof value === "string" && value.trim()) {549      return value.trim();550    }551  }552  return "";553}554555function normalizeTotp(value?: string): string | undefined {556  if (!value) return undefined;557  const parsed = value.startsWith("otpauth://") ? parseOtpauthURI(value) : null;558  return parsed?.secret ?? value.trim() ?? undefined;559}560561function firstNonEmptyString(562  ...values: Array<string | null | undefined>563): string {564  for (const value of values) {565    if (typeof value === "string" && value.trim()) {566      return value.trim();567    }568  }569  return "";570}571572function stringOrUndefined(value: unknown): string | undefined {573  return typeof value === "string" && value.trim() ? value.trim() : undefined;574}575576function stringOrEmpty(value: unknown): string {577  return typeof value === "string" ? value : "";578}