import.ts

17.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 { 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 = parseCSV(raw);57      const totalItems = Papa.parse<Record<string, string>>(raw, {58        header: true,59        skipEmptyLines: true,60      }).data.length;61      return {62        format,63        label: "CSV import",64        entries,65        totalItems,66        skippedItems: Math.max(0, totalItems - entries.length),67        requiresPassword: false,68      };69    }70    case "1password-json": {71      const entries = parse1PasswordJSON(raw);72      return {73        format,74        label: "1Password JSON",75        entries,76        totalItems: entries.length,77        skippedItems: 0,78        requiresPassword: false,79      };80    }81    case "bitwarden-json": {82      const { entries, totalItems } = parseBitwardenJSON(raw);83      return {84        format,85        label: "Bitwarden JSON",86        entries,87        totalItems,88        skippedItems: Math.max(0, totalItems - entries.length),89        requiresPassword: false,90      };91    }92    case "credential-exchange-json": {93      const { entries, totalItems } = await parseCredentialExchangeJSON(raw);94      return {95        format,96        label: "Credential Exchange JSON",97        entries,98        totalItems,99        skippedItems: Math.max(0, totalItems - entries.length),100        requiresPassword: false,101      };102    }103    case "protonpass-json": {104      const { entries, totalItems } = parseProtonPassJSON(raw);105      return {106        format,107        label: "Proton Pass JSON",108        entries,109        totalItems,110        skippedItems: Math.max(0, totalItems - entries.length),111        requiresPassword: false,112      };113    }114    case "peach-legacy-json": {115      const { entries, totalItems, skippedItems } = parsePeachLegacyJSON(raw);116      return {117        format,118        label: "Peach (legacy)",119        entries,120        totalItems,121        skippedItems,122        requiresPassword: false,123      };124    }125    case "peach-json": {126      if (!password) {127        return {128          format,129          label: "Peach encrypted backup",130          entries: [],131          totalItems: 0,132          skippedItems: 0,133          requiresPassword: true,134        };135      }136137      const result = await importEntriesFromPeachJson(raw, password);138      return {139        format,140        label: "Peach encrypted backup",141        entries: result.entries,142        totalItems: result.entries.length,143        skippedItems: 0,144        requiresPassword: false,145        ...(result.licenseKey ? { licenseKey: result.licenseKey, licenseActivatedAt: result.licenseActivatedAt } : {}),146      };147    }148    default:149      return {150        format: "unknown",151        label: "Unknown format",152        entries: [],153        totalItems: 0,154        skippedItems: 0,155        requiresPassword: false,156      };157  }158}159160export function detectImportFormat(raw: string): ImportFormat {161  const trimmed = raw.trim();162  if (trimmed.startsWith("{") || trimmed.startsWith("[")) {163    try {164      const data = JSON.parse(trimmed);165      if (hasPeachBackupShape(data)) return "peach-json";166      if ((data as any)?.exportFormat === "peach-vault")167        return "peach-legacy-json";168      if (hasCredentialExchangeShape(data)) return "credential-exchange-json";169      if (has1PasswordShape(data)) return "1password-json";170      if (hasBitwardenShape(data)) return "bitwarden-json";171      if (hasProtonPassShape(data)) return "protonpass-json";172      return "unknown";173    } catch {174      return "unknown";175    }176  }177178  return trimmed.includes(",") ? "csv" : "unknown";179}180181export function parse1PasswordJSON(raw: string): ImportableEntry[] {182  const data = JSON.parse(raw);183  const entries: ImportableEntry[] = [];184185  const itemGroups: any[][] = [];186  const vaults = data?.accounts?.[0]?.vaults ?? [];187  for (const vault of vaults) itemGroups.push(vault.items ?? []);188  if (Array.isArray(data?.items)) itemGroups.push(data.items);189190  for (const items of itemGroups) {191    for (const item of items) {192      if (item.trashed) continue;193      const fields = item.details?.loginFields ?? item.fields ?? [];194      const username =195        fields.find((field: any) => field.designation === "username" || field.id === "username")?.value ??196        "";197      const password =198        fields.find((field: any) => field.designation === "password" || field.id === "password")?.value ??199        "";200      if (!password) continue;201      entries.push({202        type: "login",203        title: (item.overview?.title ?? item.title)?.trim() || "Untitled",204        username: username.trim(),205        password,206        url: ((item.overview?.url ?? item.url) ?? "").trim(),207        notes: stringOrUndefined(item.details?.notesPlain ?? item.notes),208      });209    }210  }211212  return entries;213}214215function parseCSV(csvText: string): ImportableEntry[] {216  const result = Papa.parse<Record<string, string>>(csvText, {217    header: true,218    skipEmptyLines: true,219    transformHeader: (header) => header.trim(),220  });221222  return result.data.reduce<ImportableEntry[]>((entries, row) => {223      const title = pickFirstField(row, [224        "name",225        "title",226        "Title",227        "Name",228        "item",229      ]);230      const url = pickFirstField(row, [231        "url",232        "website",233        "URL",234        "Website",235        "login_uri",236        "loginUrl",237      ]);238      const username = pickFirstField(row, [239        "username",240        "login",241        "email",242        "Username",243        "login_username",244        "user",245      ]);246      const password = pickFirstField(row, [247        "password",248        "Password",249        "login_password",250      ]);251      const notes = pickFirstField(row, [252        "notes",253        "Notes",254        "note",255        "comment",256        "extra",257      ]);258      const totpParams = normalizeTotp(259        pickFirstField(row, [260          "totp",261          "totpUri",262          "otp",263          "otpSecret",264          "two-factor-secret",265          "login_totp",266        ])267      );268      const grouping = pickFirstField(row, ["grouping", "Grouping", "folder", "Folder"]);269270      if (!password) return entries;271272      entries.push({273        type: "login",274        title: firstNonEmptyString(title, url, username, "Untitled"),275        username,276        password,277        url,278        notes: notes || undefined,279        ...(totpParams ?? {}),280        tags: grouping ? [grouping] : undefined,281      });282      return entries;283    }, []);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}