import.ts

16.1 KB · raw

1// `any` is intentional throughout this module (L17/I3/QUA-010): import files2// are user-supplied external JSON whose schemas vary by source app and version3// (1Password CPG, Bitwarden, CXF, generic CSV). Structural extraction is4// defensive — every field is null-checked or falls back to "" — so a wrong5// schema can't crash the import. Tightening to `unknown` would force a noisy6// runtime schema validator on data we already treat as untrusted.78import Papa from "papaparse";910import {11  base64urlDecode,12  base64urlEncode,13  importPasskeyPrivateKey,14} from "../background/passkey-crypto";15import { parseOtpauthURI } from "../crypto/totp";16import { importEntriesFromPeachJson, type ImportableEntry } from "./backup";1718export type ImportFormat =19  | "csv"20  | "1password-json"21  | "bitwarden-json"22  | "credential-exchange-json"23  | "protonpass-json"24  | "peach-json"25  | "peach-legacy-json"26  | "unknown";2728export interface ImportParseResult {29  format: ImportFormat;30  label: string;31  entries: ImportableEntry[];32  totalItems: number;33  skippedItems: number;34  requiresPassword: boolean;35  /** License key from the backup, present only on Pro encrypted JSON exports. */36  licenseKey?: string;37  licenseActivatedAt?: number;38}3940export async function parseImportSource(41  raw: string,42  password?: string43): Promise<ImportParseResult> {44  const format = detectImportFormat(raw);4546  switch (format) {47    case "csv": {48      const entries = parseCSV(raw);49      const totalItems = Papa.parse<Record<string, string>>(raw, {50        header: true,51        skipEmptyLines: true,52      }).data.length;53      return {54        format,55        label: "CSV import",56        entries,57        totalItems,58        skippedItems: Math.max(0, totalItems - entries.length),59        requiresPassword: false,60      };61    }62    case "1password-json": {63      const entries = parse1PasswordJSON(raw);64      return {65        format,66        label: "1Password JSON",67        entries,68        totalItems: entries.length,69        skippedItems: 0,70        requiresPassword: false,71      };72    }73    case "bitwarden-json": {74      const { entries, totalItems } = parseBitwardenJSON(raw);75      return {76        format,77        label: "Bitwarden JSON",78        entries,79        totalItems,80        skippedItems: Math.max(0, totalItems - entries.length),81        requiresPassword: false,82      };83    }84    case "credential-exchange-json": {85      const { entries, totalItems } = await parseCredentialExchangeJSON(raw);86      return {87        format,88        label: "Credential Exchange JSON",89        entries,90        totalItems,91        skippedItems: Math.max(0, totalItems - entries.length),92        requiresPassword: false,93      };94    }95    case "protonpass-json": {96      const { entries, totalItems } = parseProtonPassJSON(raw);97      return {98        format,99        label: "Proton Pass JSON",100        entries,101        totalItems,102        skippedItems: Math.max(0, totalItems - entries.length),103        requiresPassword: false,104      };105    }106    case "peach-legacy-json": {107      const { entries, totalItems, skippedItems } = parsePeachLegacyJSON(raw);108      return {109        format,110        label: "Peach (legacy)",111        entries,112        totalItems,113        skippedItems,114        requiresPassword: false,115      };116    }117    case "peach-json": {118      if (!password) {119        return {120          format,121          label: "Peach encrypted backup",122          entries: [],123          totalItems: 0,124          skippedItems: 0,125          requiresPassword: true,126        };127      }128129      const result = await importEntriesFromPeachJson(raw, password);130      return {131        format,132        label: "Peach encrypted backup",133        entries: result.entries,134        totalItems: result.entries.length,135        skippedItems: 0,136        requiresPassword: false,137        ...(result.licenseKey ? { licenseKey: result.licenseKey, licenseActivatedAt: result.licenseActivatedAt } : {}),138      };139    }140    default:141      return {142        format: "unknown",143        label: "Unknown format",144        entries: [],145        totalItems: 0,146        skippedItems: 0,147        requiresPassword: false,148      };149  }150}151152export function detectImportFormat(raw: string): ImportFormat {153  const trimmed = raw.trim();154  if (trimmed.startsWith("{") || trimmed.startsWith("[")) {155    try {156      const data = JSON.parse(trimmed);157      if (hasPeachBackupShape(data)) return "peach-json";158      if ((data as any)?.exportFormat === "peach-vault")159        return "peach-legacy-json";160      if (hasCredentialExchangeShape(data)) return "credential-exchange-json";161      if (has1PasswordShape(data)) return "1password-json";162      if (hasBitwardenShape(data)) return "bitwarden-json";163      if (hasProtonPassShape(data)) return "protonpass-json";164      return "unknown";165    } catch {166      return "unknown";167    }168  }169170  return trimmed.includes(",") ? "csv" : "unknown";171}172173export function parse1PasswordJSON(raw: string): ImportableEntry[] {174  const data = JSON.parse(raw);175  const entries: ImportableEntry[] = [];176177  const itemGroups: any[][] = [];178  const vaults = data?.accounts?.[0]?.vaults ?? [];179  for (const vault of vaults) itemGroups.push(vault.items ?? []);180  if (Array.isArray(data?.items)) itemGroups.push(data.items);181182  for (const items of itemGroups) {183    for (const item of items) {184      if (item.trashed) continue;185      const fields = item.details?.loginFields ?? item.fields ?? [];186      const username =187        fields.find((field: any) => field.designation === "username" || field.id === "username")?.value ??188        "";189      const password =190        fields.find((field: any) => field.designation === "password" || field.id === "password")?.value ??191        "";192      if (!password) continue;193      entries.push({194        type: "login",195        title: (item.overview?.title ?? item.title)?.trim() || "Untitled",196        username: username.trim(),197        password,198        url: ((item.overview?.url ?? item.url) ?? "").trim(),199        notes: stringOrUndefined(item.details?.notesPlain ?? item.notes),200      });201    }202  }203204  return entries;205}206207function parseCSV(csvText: string): ImportableEntry[] {208  const result = Papa.parse<Record<string, string>>(csvText, {209    header: true,210    skipEmptyLines: true,211    transformHeader: (header) => header.trim(),212  });213214  return result.data.reduce<ImportableEntry[]>((entries, row) => {215      const title = pickFirstField(row, [216        "name",217        "title",218        "Title",219        "Name",220        "item",221      ]);222      const url = pickFirstField(row, [223        "url",224        "website",225        "URL",226        "Website",227        "login_uri",228        "loginUrl",229      ]);230      const username = pickFirstField(row, [231        "username",232        "login",233        "email",234        "Username",235        "login_username",236        "user",237      ]);238      const password = pickFirstField(row, [239        "password",240        "Password",241        "login_password",242      ]);243      const notes = pickFirstField(row, [244        "notes",245        "Notes",246        "note",247        "comment",248        "extra",249      ]);250      const totp = normalizeTotp(251        pickFirstField(row, [252          "totp",253          "totpUri",254          "otp",255          "otpSecret",256          "two-factor-secret",257          "login_totp",258        ])259      );260      const grouping = pickFirstField(row, ["grouping", "Grouping", "folder", "Folder"]);261262      if (!password) return entries;263264      entries.push({265        type: "login",266        title: firstNonEmptyString(title, url, username, "Untitled"),267        username,268        password,269        url,270        notes: notes || undefined,271        totp,272        tags: grouping ? [grouping] : undefined,273      });274      return entries;275    }, []);276}277278function parseBitwardenJSON(raw: string): {279  entries: ImportableEntry[];280  totalItems: number;281} {282  const data = JSON.parse(raw);283  const items: any[] = Array.isArray(data?.items) ? data.items : [];284  const loginItems = items.filter((item) => item?.type === 1);285286  return {287    totalItems: loginItems.length,288    entries: loginItems.reduce<ImportableEntry[]>((entries, item) => {289        const password = stringOrEmpty(item.login?.password);290        if (!password) return entries;291292        entries.push({293          type: "login",294          title: firstNonEmptyString(item.name, "Untitled"),295          url: firstNonEmptyString(item.login?.uris?.[0]?.uri, ""),296          username: firstNonEmptyString(item.login?.username, ""),297          password,298          notes: stringOrUndefined(item.notes),299          totp: normalizeTotp(stringOrUndefined(item.login?.totp)),300        });301        return entries;302      }, []),303  };304}305306function parseProtonPassJSON(raw: string): {307  entries: ImportableEntry[];308  totalItems: number;309} {310  const data = JSON.parse(raw) as {311    vaults?: Record<string, { name?: string; items?: any[] }>;312  };313  const vaults = data?.vaults && typeof data.vaults === "object" ? data.vaults : {};314  const entries: ImportableEntry[] = [];315  let totalItems = 0;316317  for (const vault of Object.values(vaults)) {318    if (vault?.name?.toLowerCase() === "recycle bin") continue;319320    for (const item of vault?.items ?? []) {321      const itemData = item?.data ?? item;322      const itemType = itemData?.type ?? item?.type;323      if (itemType !== "login") continue;324325      totalItems += 1;326327      const content = itemData?.content ?? {};328      const metadata = itemData?.metadata ?? {};329      const password = firstNonEmptyString(330        content.password,331        content.itemPassword332      );333      if (!password) continue;334335      entries.push({336        type: "login",337        title: firstNonEmptyString(338          metadata.name,339          item.name,340          item.title,341          content.itemName,342          "Untitled"343        ),344        username: firstNonEmptyString(345          content.username,346          content.itemUsername,347          content.email,348          content.itemEmail,349          item.aliasEmail,350          ""351        ),352        password,353        url: firstNonEmptyString(354          Array.isArray(content.urls) ? content.urls[0] : undefined,355          content.url,356          ""357        ),358        notes: stringOrUndefined(metadata.note ?? metadata.notes),359        totp: normalizeTotp(360          firstNonEmptyString(content.totpUri, content.itemTotpUri)361        ),362      });363    }364  }365366  return { entries, totalItems };367}368369async function parseCredentialExchangeJSON(raw: string): Promise<{370  entries: ImportableEntry[];371  totalItems: number;372}> {373  const data = JSON.parse(raw) as {374    accounts?: Array<{375      items?: Array<{376        title?: string;377        modifiedAt?: number;378        creationAt?: number;379        credentials?: Array<{380          type?: string;381          credentialId?: string;382          rpId?: string;383          username?: string;384          userDisplayName?: string;385          userHandle?: string;386          key?: string;387        }>;388      }>;389    }>;390  };391392  const entries: ImportableEntry[] = [];393  let totalItems = 0;394395  for (const account of data.accounts ?? []) {396    for (const item of account.items ?? []) {397      for (const credential of item.credentials ?? []) {398        if (credential?.type !== "passkey") continue;399        totalItems += 1;400401        if (402          !credential.credentialId ||403          !credential.rpId ||404          !credential.username ||405          !credential.userDisplayName ||406          !credential.userHandle ||407          !credential.key408        ) {409          continue;410        }411412        try {413          const pkcs8 = base64urlDecode(credential.key);414          const imported = await importPasskeyPrivateKey(pkcs8);415          entries.push({416            type: "passkey",417            title: firstNonEmptyString(item.title, credential.rpId),418            rpId: credential.rpId,419            rpName: firstNonEmptyString(item.title, credential.rpId),420            credentialId: credential.credentialId,421            publicKeyCose: base64urlEncode(imported.publicKeyCose),422            privateKeyJwk: imported.privateKeyJwk,423            userHandle: credential.userHandle,424            userName: credential.username,425            userDisplayName: credential.userDisplayName,426            algorithm: imported.algorithm,427            signCount: 0,428            aaguid: "AAAAAAAAAAAAAAAAAAAAAA",429            backupEligible: true,430            backupState: false,431          });432        } catch {433          // Skip passkeys whose private key we cannot import into WebCrypto.434        }435      }436    }437  }438439  return { entries, totalItems };440}441442function parsePeachLegacyJSON(raw: string): {443  entries: ImportableEntry[];444  totalItems: number;445  skippedItems: number;446} {447  const data = JSON.parse(raw) as {448    exportFormat: string;449    entries: Array<{450      id: string;451      type: string;452      modified: number;453      created: number;454      name: string;455      favorite: boolean;456      tags: string[];457      login?: {458        urls?: string[];459        username?: string;460        password?: string;461        totp?: {462          secret: string;463          algorithm: string;464          digits: number;465          period: number;466        };467        customFields?: unknown[];468        notes?: string;469      };470    }>;471  };472473  const totalItems = data.entries.length;474  const filtered = data.entries.filter((e) => e.type === "login");475  const skippedItems = totalItems - filtered.length;476477  return {478    totalItems,479    skippedItems,480    entries: filtered.map((entry) => ({481      type: "login" as const,482      title: entry.name ?? "Untitled",483      url: entry.login?.urls?.[0] ?? "",484      username: entry.login?.username ?? "",485      password: entry.login?.password ?? "",486      totp: entry.login?.totp?.secret ?? undefined,487      notes: entry.login?.notes ?? undefined,488    })),489  };490}491492function has1PasswordShape(value: unknown): boolean {493  if (!value || typeof value !== "object") return false;494  const v = value as { accounts?: unknown[]; items?: unknown[] };495  if (Array.isArray(v.accounts)) return true;496  if (!Array.isArray(v.items) || v.items.length === 0) return false;497  const sample = v.items[0] as any;498  return Boolean(sample?.details?.loginFields || sample?.overview?.title);499}500501function hasBitwardenShape(value: unknown): boolean {502  return Boolean(503    value &&504      typeof value === "object" &&505      Array.isArray((value as { items?: unknown[] }).items)506  );507}508509function hasCredentialExchangeShape(value: unknown): boolean {510  return Boolean(511    value &&512      typeof value === "object" &&513      (value as { version?: { major?: number; minor?: number }; accounts?: unknown[] }).version &&514      Array.isArray((value as { accounts?: unknown[] }).accounts)515  );516}517518function hasProtonPassShape(value: unknown): boolean {519  return Boolean(520    value &&521      typeof value === "object" &&522      (value as { vaults?: unknown }).vaults &&523      typeof (value as { vaults?: unknown }).vaults === "object"524  );525}526527function hasPeachBackupShape(value: unknown): boolean {528  if (!value || typeof value !== "object") return false;529  const envelope = value as Partial<{530    type: string;531    version: number;532    kdf: string;533    salt: string;534    nonce: string;535    ciphertext: string;536    tag: string;537  }>;538  return (539    envelope.type === "peach-export" &&540    envelope.version === 2 &&541    envelope.kdf === "PBKDF2-SHA256" &&542    typeof envelope.salt === "string" &&543    typeof envelope.nonce === "string" &&544    typeof envelope.ciphertext === "string" &&545    typeof envelope.tag === "string"546  );547}548549function pickFirstField(550  row: Record<string, string>,551  candidates: string[]552): string {553  for (const key of candidates) {554    const value = row[key];555    if (typeof value === "string" && value.trim()) {556      return value.trim();557    }558  }559  return "";560}561562function normalizeTotp(value?: string): string | undefined {563  if (!value) return undefined;564  const parsed = value.startsWith("otpauth://") ? parseOtpauthURI(value) : null;565  return parsed?.secret ?? value.trim() ?? undefined;566}567568function firstNonEmptyString(569  ...values: Array<string | null | undefined>570): string {571  for (const value of values) {572    if (typeof value === "string" && value.trim()) {573      return value.trim();574    }575  }576  return "";577}578579function stringOrUndefined(value: unknown): string | undefined {580  return typeof value === "string" && value.trim() ? value.trim() : undefined;581}582583function stringOrEmpty(value: unknown): string {584  return typeof value === "string" ? value : "";585}