import.ts

17.2 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 { IMPORT_LIMITS, importTooLarge } from "./import-limits";17import { importEntriesFromPeachJson, type ImportableEntry } from "./backup";1819export type ImportFormat =20  | "csv"21  | "1password-json"22  | "bitwarden-json"23  | "credential-exchange-json"24  | "protonpass-json"25  | "peach-json"26  | "peach-legacy-json"27  | "unknown";2829export interface ImportParseResult {30  format: ImportFormat;31  label: string;32  entries: ImportableEntry[];33  totalItems: number;34  skippedItems: number;35  requiresPassword: boolean;36  /** License key from the backup, present only on Pro encrypted JSON exports. */37  licenseKey?: string;38  licenseActivatedAt?: number;39}4041export async function parseImportSource(42  raw: string,43  password?: string44): Promise<ImportParseResult> {45  // MV-5: bound the input before any JSON.parse / CSV parse allocates46  // proportional structures. Covers file picks AND pasted strings.47  if (raw.length > IMPORT_LIMITS.MAX_JSON_BYTES) {48    throw importTooLarge(49      `import source ${raw.length} chars exceeds ${IMPORT_LIMITS.MAX_JSON_BYTES}`,50    );51  }52  const format = detectImportFormat(raw);5354  switch (format) {55    case "csv": {56      const { entries, totalItems } = parseCSV(raw);57      return {58        format,59        label: "CSV import",60        entries,61        totalItems,62        skippedItems: Math.max(0, totalItems - entries.length),63        requiresPassword: false,64      };65    }66    case "1password-json": {67      const entries = parse1PasswordJSON(raw);68      return {69        format,70        label: "1Password JSON",71        entries,72        totalItems: entries.length,73        skippedItems: 0,74        requiresPassword: false,75      };76    }77    case "bitwarden-json": {78      const { entries, totalItems } = parseBitwardenJSON(raw);79      return {80        format,81        label: "Bitwarden JSON",82        entries,83        totalItems,84        skippedItems: Math.max(0, totalItems - entries.length),85        requiresPassword: false,86      };87    }88    case "credential-exchange-json": {89      const { entries, totalItems } = await parseCredentialExchangeJSON(raw);90      return {91        format,92        label: "Credential Exchange JSON",93        entries,94        totalItems,95        skippedItems: Math.max(0, totalItems - entries.length),96        requiresPassword: false,97      };98    }99    case "protonpass-json": {100      const { entries, totalItems } = parseProtonPassJSON(raw);101      return {102        format,103        label: "Proton Pass JSON",104        entries,105        totalItems,106        skippedItems: Math.max(0, totalItems - entries.length),107        requiresPassword: false,108      };109    }110    case "peach-legacy-json": {111      const { entries, totalItems, skippedItems } = parsePeachLegacyJSON(raw);112      return {113        format,114        label: "Peach (legacy)",115        entries,116        totalItems,117        skippedItems,118        requiresPassword: false,119      };120    }121    case "peach-json": {122      if (!password) {123        return {124          format,125          label: "Peach encrypted backup",126          entries: [],127          totalItems: 0,128          skippedItems: 0,129          requiresPassword: true,130        };131      }132133      const result = await importEntriesFromPeachJson(raw, password);134      return {135        format,136        label: "Peach encrypted backup",137        entries: result.entries,138        totalItems: result.entries.length,139        skippedItems: 0,140        requiresPassword: false,141        ...(result.licenseKey ? { licenseKey: result.licenseKey, licenseActivatedAt: result.licenseActivatedAt } : {}),142      };143    }144    default:145      return {146        format: "unknown",147        label: "Unknown format",148        entries: [],149        totalItems: 0,150        skippedItems: 0,151        requiresPassword: false,152      };153  }154}155156export function detectImportFormat(raw: string): ImportFormat {157  const trimmed = raw.trim();158  if (trimmed.startsWith("{") || trimmed.startsWith("[")) {159    try {160      const data = JSON.parse(trimmed);161      if (hasPeachBackupShape(data)) return "peach-json";162      if ((data as any)?.exportFormat === "peach-vault")163        return "peach-legacy-json";164      if (hasCredentialExchangeShape(data)) return "credential-exchange-json";165      if (has1PasswordShape(data)) return "1password-json";166      if (hasBitwardenShape(data)) return "bitwarden-json";167      if (hasProtonPassShape(data)) return "protonpass-json";168      return "unknown";169    } catch {170      return "unknown";171    }172  }173174  return trimmed.includes(",") ? "csv" : "unknown";175}176177export function parse1PasswordJSON(raw: string): ImportableEntry[] {178  const data = JSON.parse(raw);179  const entries: ImportableEntry[] = [];180181  const itemGroups: any[][] = [];182  const vaults = data?.accounts?.[0]?.vaults ?? [];183  for (const vault of vaults) itemGroups.push(vault.items ?? []);184  if (Array.isArray(data?.items)) itemGroups.push(data.items);185186  for (const items of itemGroups) {187    for (const item of items) {188      if (item.trashed) continue;189      const fields = item.details?.loginFields ?? item.fields ?? [];190      const username =191        fields.find((field: any) => field.designation === "username" || field.id === "username")?.value ??192        "";193      const password =194        fields.find((field: any) => field.designation === "password" || field.id === "password")?.value ??195        "";196      if (!password) continue;197      entries.push({198        type: "login",199        title: (item.overview?.title ?? item.title)?.trim() || "Untitled",200        username: username.trim(),201        password,202        url: ((item.overview?.url ?? item.url) ?? "").trim(),203        notes: stringOrUndefined(item.details?.notesPlain ?? item.notes),204      });205    }206  }207208  return entries;209}210211function parseCSV(csvText: string): { entries: ImportableEntry[]; totalItems: number } {212  const result = Papa.parse<Record<string, string>>(csvText, {213    header: true,214    skipEmptyLines: true,215    transformHeader: (header) => header.trim(),216  });217218  // PCH-01-026 #5: return totalItems from this single parse so parseImportSource219  // doesn't re-run Papa.parse over the whole file just to count rows.220  const entries = result.data.reduce<ImportableEntry[]>((entries, row) => {221      const title = pickFirstField(row, [222        "name",223        "title",224        "Title",225        "Name",226        "item",227      ]);228      const url = pickFirstField(row, [229        "url",230        "website",231        "URL",232        "Website",233        "login_uri",234        "loginUrl",235      ]);236      const username = pickFirstField(row, [237        "username",238        "login",239        "email",240        "Username",241        "login_username",242        "user",243      ]);244      const password = pickFirstField(row, [245        "password",246        "Password",247        "login_password",248      ]);249      const notes = pickFirstField(row, [250        "notes",251        "Notes",252        "note",253        "comment",254        "extra",255      ]);256      const totpParams = normalizeTotp(257        pickFirstField(row, [258          "totp",259          "totpUri",260          "otp",261          "otpSecret",262          "two-factor-secret",263          "login_totp",264        ])265      );266      const grouping = pickFirstField(row, ["grouping", "Grouping", "folder", "Folder"]);267268      if (!password) return entries;269270      entries.push({271        type: "login",272        title: firstNonEmptyString(title, url, username, "Untitled"),273        username,274        password,275        url,276        notes: notes || undefined,277        ...(totpParams ?? {}),278        tags: grouping ? [grouping] : undefined,279      });280      return entries;281    }, []);282283  return { entries, totalItems: result.data.length };284}285286function parseBitwardenJSON(raw: string): {287  entries: ImportableEntry[];288  totalItems: number;289} {290  const data = JSON.parse(raw);291  const items: any[] = Array.isArray(data?.items) ? data.items : [];292  const loginItems = items.filter((item) => item?.type === 1);293294  return {295    totalItems: loginItems.length,296    entries: loginItems.reduce<ImportableEntry[]>((entries, item) => {297        const password = stringOrEmpty(item.login?.password);298        if (!password) return entries;299300        entries.push({301          type: "login",302          title: firstNonEmptyString(item.name, "Untitled"),303          url: firstNonEmptyString(item.login?.uris?.[0]?.uri, ""),304          username: firstNonEmptyString(item.login?.username, ""),305          password,306          notes: stringOrUndefined(item.notes),307          ...(normalizeTotp(stringOrUndefined(item.login?.totp)) ?? {}),308        });309        return entries;310      }, []),311  };312}313314function parseProtonPassJSON(raw: string): {315  entries: ImportableEntry[];316  totalItems: number;317} {318  const data = JSON.parse(raw) as {319    vaults?: Record<string, { name?: string; items?: any[] }>;320  };321  const vaults = data?.vaults && typeof data.vaults === "object" ? data.vaults : {};322  const entries: ImportableEntry[] = [];323  let totalItems = 0;324325  for (const vault of Object.values(vaults)) {326    if (vault?.name?.toLowerCase() === "recycle bin") continue;327328    for (const item of vault?.items ?? []) {329      const itemData = item?.data ?? item;330      const itemType = itemData?.type ?? item?.type;331      if (itemType !== "login") continue;332333      totalItems += 1;334335      const content = itemData?.content ?? {};336      const metadata = itemData?.metadata ?? {};337      const password = firstNonEmptyString(338        content.password,339        content.itemPassword340      );341      if (!password) continue;342343      entries.push({344        type: "login",345        title: firstNonEmptyString(346          metadata.name,347          item.name,348          item.title,349          content.itemName,350          "Untitled"351        ),352        username: firstNonEmptyString(353          content.username,354          content.itemUsername,355          content.email,356          content.itemEmail,357          item.aliasEmail,358          ""359        ),360        password,361        url: firstNonEmptyString(362          Array.isArray(content.urls) ? content.urls[0] : undefined,363          content.url,364          ""365        ),366        notes: stringOrUndefined(metadata.note ?? metadata.notes),367        ...(normalizeTotp(368          firstNonEmptyString(content.totpUri, content.itemTotpUri)369        ) ?? {}),370      });371    }372  }373374  return { entries, totalItems };375}376377async function parseCredentialExchangeJSON(raw: string): Promise<{378  entries: ImportableEntry[];379  totalItems: number;380}> {381  const data = JSON.parse(raw) as {382    accounts?: Array<{383      items?: Array<{384        title?: string;385        modifiedAt?: number;386        creationAt?: number;387        credentials?: Array<{388          type?: string;389          credentialId?: string;390          rpId?: string;391          username?: string;392          userDisplayName?: string;393          userHandle?: string;394          key?: string;395        }>;396      }>;397    }>;398  };399400  const entries: ImportableEntry[] = [];401  let totalItems = 0;402403  for (const account of data.accounts ?? []) {404    for (const item of account.items ?? []) {405      for (const credential of item.credentials ?? []) {406        if (credential?.type !== "passkey") continue;407        totalItems += 1;408409        if (410          !credential.credentialId ||411          !credential.rpId ||412          !credential.username ||413          !credential.userDisplayName ||414          !credential.userHandle ||415          !credential.key416        ) {417          continue;418        }419420        try {421          const pkcs8 = base64urlDecode(credential.key);422          const imported = await importPasskeyPrivateKey(pkcs8);423          entries.push({424            type: "passkey",425            title: firstNonEmptyString(item.title, credential.rpId),426            rpId: credential.rpId,427            rpName: firstNonEmptyString(item.title, credential.rpId),428            credentialId: credential.credentialId,429            publicKeyCose: base64urlEncode(imported.publicKeyCose),430            privateKeyJwk: imported.privateKeyJwk,431            userHandle: credential.userHandle,432            userName: credential.username,433            userDisplayName: credential.userDisplayName,434            algorithm: imported.algorithm,435            signCount: 0,436            aaguid: "AAAAAAAAAAAAAAAAAAAAAA",437            backupEligible: true,438            backupState: false,439          });440        } catch {441          // Skip passkeys whose private key we cannot import into WebCrypto.442        }443      }444    }445  }446447  return { entries, totalItems };448}449450function parsePeachLegacyJSON(raw: string): {451  entries: ImportableEntry[];452  totalItems: number;453  skippedItems: number;454} {455  const data = JSON.parse(raw) as {456    exportFormat: string;457    entries: Array<{458      id: string;459      type: string;460      modified: number;461      created: number;462      name: string;463      favorite: boolean;464      tags: string[];465      login?: {466        urls?: string[];467        username?: string;468        password?: string;469        totp?: {470          secret: string;471          algorithm: string;472          digits: number;473          period: number;474        };475        customFields?: unknown[];476        notes?: string;477      };478    }>;479  };480481  const totalItems = data.entries.length;482  const filtered = data.entries.filter((e) => e.type === "login");483  const skippedItems = totalItems - filtered.length;484485  return {486    totalItems,487    skippedItems,488    entries: filtered.map((entry) => ({489      type: "login" as const,490      title: entry.name ?? "Untitled",491      url: entry.login?.urls?.[0] ?? "",492      username: entry.login?.username ?? "",493      password: entry.login?.password ?? "",494      totp: entry.login?.totp?.secret ?? undefined,495      notes: entry.login?.notes ?? undefined,496    })),497  };498}499500function has1PasswordShape(value: unknown): boolean {501  if (!value || typeof value !== "object") return false;502  const v = value as { accounts?: unknown[]; items?: unknown[] };503  if (Array.isArray(v.accounts)) return true;504  if (!Array.isArray(v.items) || v.items.length === 0) return false;505  const sample = v.items[0] as any;506  return Boolean(sample?.details?.loginFields || sample?.overview?.title);507}508509function hasBitwardenShape(value: unknown): boolean {510  return Boolean(511    value &&512      typeof value === "object" &&513      Array.isArray((value as { items?: unknown[] }).items)514  );515}516517function hasCredentialExchangeShape(value: unknown): boolean {518  return Boolean(519    value &&520      typeof value === "object" &&521      (value as { version?: { major?: number; minor?: number }; accounts?: unknown[] }).version &&522      Array.isArray((value as { accounts?: unknown[] }).accounts)523  );524}525526function hasProtonPassShape(value: unknown): boolean {527  return Boolean(528    value &&529      typeof value === "object" &&530      (value as { vaults?: unknown }).vaults &&531      typeof (value as { vaults?: unknown }).vaults === "object"532  );533}534535function hasPeachBackupShape(value: unknown): boolean {536  if (!value || typeof value !== "object") return false;537  const envelope = value as Partial<{538    type: string;539    version: number;540    kdf: string;541    salt: string;542    nonce: string;543    ciphertext: string;544    tag: string;545  }>;546  return (547    envelope.type === "peach-export" &&548    envelope.version === 2 &&549    envelope.kdf === "PBKDF2-SHA256" &&550    typeof envelope.salt === "string" &&551    typeof envelope.nonce === "string" &&552    typeof envelope.ciphertext === "string" &&553    typeof envelope.tag === "string"554  );555}556557function pickFirstField(558  row: Record<string, string>,559  candidates: string[]560): string {561  for (const key of candidates) {562    const value = row[key];563    if (typeof value === "string" && value.trim()) {564      return value.trim();565    }566  }567  return "";568}569570function normalizeTotp(571  value?: string572): { totp: string; totpAlgorithm?: "SHA1" | "SHA256" | "SHA512"; totpDigits?: number; totpPeriod?: number } | undefined {573  if (!value) return undefined;574  const parsed = value.startsWith("otpauth://") ? parseOtpauthURI(value) : null;575  const totp = parsed?.secret ?? value.trim();576  if (!totp) return undefined;577  if (!parsed) return { totp };578  // PCH-01-019: persist only non-default params so legacy bare-seed entries579  // stay byte-identical and keep generating the same codes.580  return {581    totp,582    totpAlgorithm: parsed.algorithm && parsed.algorithm !== "SHA1" ? parsed.algorithm : undefined,583    totpDigits: parsed.digits && parsed.digits !== 6 ? parsed.digits : undefined,584    totpPeriod: parsed.period && parsed.period !== 30 ? parsed.period : undefined,585  };586}587588function firstNonEmptyString(589  ...values: Array<string | null | undefined>590): string {591  for (const value of values) {592    if (typeof value === "string" && value.trim()) {593      return value.trim();594    }595  }596  return "";597}598599function stringOrUndefined(value: unknown): string | undefined {600  return typeof value === "string" && value.trim() ? value.trim() : undefined;601}602603function stringOrEmpty(value: unknown): string {604  return typeof value === "string" ? value : "";605}