import.ts

14.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 type { VaultEntry } from "./types";1819export type ImportableEntry = Omit<VaultEntry, "id" | "createdAt" | "modifiedAt">;2021export type ImportFormat =22  | "csv"23  | "1password-json"24  | "bitwarden-json"25  | "credential-exchange-json"26  | "protonpass-json"27  | "unknown";2829export interface ImportParseResult {30  format: ImportFormat;31  label: string;32  entries: ImportableEntry[];33  totalItems: number;34  skippedItems: number;35}3637export async function parseImportSource(38  raw: string39): Promise<ImportParseResult> {40  // MV-5: bound the input before any JSON.parse / CSV parse allocates41  // proportional structures. Covers file picks AND pasted strings.42  if (raw.length > IMPORT_LIMITS.MAX_JSON_BYTES) {43    throw importTooLarge(44      `import source ${raw.length} chars exceeds ${IMPORT_LIMITS.MAX_JSON_BYTES}`,45    );46  }47  const format = detectImportFormat(raw);4849  switch (format) {50    case "csv": {51      const { entries, totalItems } = parseCSV(raw);52      return {53        format,54        label: "CSV import",55        entries,56        totalItems,57        skippedItems: Math.max(0, totalItems - entries.length),58      };59    }60    case "1password-json": {61      const entries = parse1PasswordJSON(raw);62      return {63        format,64        label: "1Password JSON",65        entries,66        totalItems: entries.length,67        skippedItems: 0,68      };69    }70    case "bitwarden-json": {71      const { entries, totalItems } = parseBitwardenJSON(raw);72      return {73        format,74        label: "Bitwarden JSON",75        entries,76        totalItems,77        skippedItems: Math.max(0, totalItems - entries.length),78      };79    }80    case "credential-exchange-json": {81      const { entries, totalItems } = await parseCredentialExchangeJSON(raw);82      return {83        format,84        label: "Credential Exchange JSON",85        entries,86        totalItems,87        skippedItems: Math.max(0, totalItems - entries.length),88      };89    }90    case "protonpass-json": {91      const { entries, totalItems } = parseProtonPassJSON(raw);92      return {93        format,94        label: "Proton Pass JSON",95        entries,96        totalItems,97        skippedItems: Math.max(0, totalItems - entries.length),98      };99    }100    default:101      return {102        format: "unknown",103        label: "Unknown format",104        entries: [],105        totalItems: 0,106        skippedItems: 0,107      };108  }109}110111export function detectImportFormat(raw: string): ImportFormat {112  const trimmed = raw.trim();113  if (trimmed.startsWith("{") || trimmed.startsWith("[")) {114    try {115      const data = JSON.parse(trimmed);116      if (hasCredentialExchangeShape(data)) return "credential-exchange-json";117      if (has1PasswordShape(data)) return "1password-json";118      if (hasBitwardenShape(data)) return "bitwarden-json";119      if (hasProtonPassShape(data)) return "protonpass-json";120      return "unknown";121    } catch {122      return "unknown";123    }124  }125126  return trimmed.includes(",") ? "csv" : "unknown";127}128129export function parse1PasswordJSON(raw: string): ImportableEntry[] {130  const data = JSON.parse(raw);131  const entries: ImportableEntry[] = [];132133  const itemGroups: any[][] = [];134  const vaults = data?.accounts?.[0]?.vaults ?? [];135  for (const vault of vaults) itemGroups.push(vault.items ?? []);136  if (Array.isArray(data?.items)) itemGroups.push(data.items);137138  for (const items of itemGroups) {139    for (const item of items) {140      if (item.trashed) continue;141      const fields = item.details?.loginFields ?? item.fields ?? [];142      const username =143        fields.find((field: any) => field.designation === "username" || field.id === "username")?.value ??144        "";145      const password =146        fields.find((field: any) => field.designation === "password" || field.id === "password")?.value ??147        "";148      if (!password) continue;149      entries.push({150        type: "login",151        title: (item.overview?.title ?? item.title)?.trim() || "Untitled",152        username: username.trim(),153        password,154        url: ((item.overview?.url ?? item.url) ?? "").trim(),155        notes: stringOrUndefined(item.details?.notesPlain ?? item.notes),156      });157    }158  }159160  return entries;161}162163function parseCSV(csvText: string): { entries: ImportableEntry[]; totalItems: number } {164  const result = Papa.parse<Record<string, string>>(csvText, {165    header: true,166    skipEmptyLines: true,167    transformHeader: (header) => header.trim(),168  });169170  // PCH-01-026 #5: return totalItems from this single parse so parseImportSource171  // doesn't re-run Papa.parse over the whole file just to count rows.172  const entries = result.data.reduce<ImportableEntry[]>((entries, row) => {173      const title = pickFirstField(row, [174        "name",175        "title",176        "Title",177        "Name",178        "item",179      ]);180      const url = pickFirstField(row, [181        "url",182        "website",183        "URL",184        "Website",185        "login_uri",186        "loginUrl",187      ]);188      const username = pickFirstField(row, [189        "username",190        "login",191        "email",192        "Username",193        "login_username",194        "user",195      ]);196      const password = pickFirstField(row, [197        "password",198        "Password",199        "login_password",200      ]);201      const notes = pickFirstField(row, [202        "notes",203        "Notes",204        "note",205        "comment",206        "extra",207      ]);208      const totpParams = normalizeTotp(209        pickFirstField(row, [210          "totp",211          "totpUri",212          "otp",213          "otpSecret",214          "two-factor-secret",215          "login_totp",216        ])217      );218      const grouping = pickFirstField(row, ["grouping", "Grouping", "folder", "Folder"]);219220      if (!password) return entries;221222      entries.push({223        type: "login",224        title: firstNonEmptyString(title, url, username, "Untitled"),225        username,226        password,227        url,228        notes: notes || undefined,229        ...(totpParams ?? {}),230        tags: grouping ? [grouping] : undefined,231      });232      return entries;233    }, []);234235  return { entries, totalItems: result.data.length };236}237238function parseBitwardenJSON(raw: string): {239  entries: ImportableEntry[];240  totalItems: number;241} {242  const data = JSON.parse(raw);243  const items: any[] = Array.isArray(data?.items) ? data.items : [];244  const loginItems = items.filter((item) => item?.type === 1);245246  return {247    totalItems: loginItems.length,248    entries: loginItems.reduce<ImportableEntry[]>((entries, item) => {249        const password = stringOrEmpty(item.login?.password);250        if (!password) return entries;251252        entries.push({253          type: "login",254          title: firstNonEmptyString(item.name, "Untitled"),255          url: firstNonEmptyString(item.login?.uris?.[0]?.uri, ""),256          username: firstNonEmptyString(item.login?.username, ""),257          password,258          notes: stringOrUndefined(item.notes),259          ...(normalizeTotp(stringOrUndefined(item.login?.totp)) ?? {}),260        });261        return entries;262      }, []),263  };264}265266function parseProtonPassJSON(raw: string): {267  entries: ImportableEntry[];268  totalItems: number;269} {270  const data = JSON.parse(raw) as {271    vaults?: Record<string, { name?: string; items?: any[] }>;272  };273  const vaults = data?.vaults && typeof data.vaults === "object" ? data.vaults : {};274  const entries: ImportableEntry[] = [];275  let totalItems = 0;276277  for (const vault of Object.values(vaults)) {278    if (vault?.name?.toLowerCase() === "recycle bin") continue;279280    for (const item of vault?.items ?? []) {281      const itemData = item?.data ?? item;282      const itemType = itemData?.type ?? item?.type;283      if (itemType !== "login") continue;284285      totalItems += 1;286287      const content = itemData?.content ?? {};288      const metadata = itemData?.metadata ?? {};289      const password = firstNonEmptyString(290        content.password,291        content.itemPassword292      );293      if (!password) continue;294295      entries.push({296        type: "login",297        title: firstNonEmptyString(298          metadata.name,299          item.name,300          item.title,301          content.itemName,302          "Untitled"303        ),304        username: firstNonEmptyString(305          content.username,306          content.itemUsername,307          content.email,308          content.itemEmail,309          item.aliasEmail,310          ""311        ),312        password,313        url: firstNonEmptyString(314          Array.isArray(content.urls) ? content.urls[0] : undefined,315          content.url,316          ""317        ),318        notes: stringOrUndefined(metadata.note ?? metadata.notes),319        ...(normalizeTotp(320          firstNonEmptyString(content.totpUri, content.itemTotpUri)321        ) ?? {}),322      });323    }324  }325326  return { entries, totalItems };327}328329async function parseCredentialExchangeJSON(raw: string): Promise<{330  entries: ImportableEntry[];331  totalItems: number;332}> {333  const data = JSON.parse(raw) as {334    accounts?: Array<{335      items?: Array<{336        title?: string;337        modifiedAt?: number;338        creationAt?: number;339        credentials?: Array<{340          type?: string;341          credentialId?: string;342          rpId?: string;343          username?: string;344          userDisplayName?: string;345          userHandle?: string;346          key?: string;347        }>;348      }>;349    }>;350  };351352  const entries: ImportableEntry[] = [];353  let totalItems = 0;354355  for (const account of data.accounts ?? []) {356    for (const item of account.items ?? []) {357      for (const credential of item.credentials ?? []) {358        if (credential?.type !== "passkey") continue;359        totalItems += 1;360361        if (362          !credential.credentialId ||363          !credential.rpId ||364          !credential.username ||365          !credential.userDisplayName ||366          !credential.userHandle ||367          !credential.key368        ) {369          continue;370        }371372        try {373          const pkcs8 = base64urlDecode(credential.key);374          const imported = await importPasskeyPrivateKey(pkcs8);375          const createdAt = Date.now();376          entries.push({377            type: "login",378            title: firstNonEmptyString(item.title, credential.rpId),379            username: credential.username,380            url: `https://${credential.rpId}`,381            passkey: {382              rpId: credential.rpId,383              rpName: firstNonEmptyString(item.title, credential.rpId),384              credentialId: credential.credentialId,385              publicKeyCose: base64urlEncode(imported.publicKeyCose),386              privateKeyJwk: imported.privateKeyJwk,387              userHandle: credential.userHandle,388              userName: credential.username,389              userDisplayName: credential.userDisplayName,390              algorithm: imported.algorithm,391              signCount: 0,392              aaguid: "AAAAAAAAAAAAAAAAAAAAAA",393              backupEligible: true,394              backupState: false,395              createdAt,396            },397          });398        } catch {399          // Skip passkeys whose private key we cannot import into WebCrypto.400        }401      }402    }403  }404405  return { entries, totalItems };406}407408function has1PasswordShape(value: unknown): boolean {409  if (!value || typeof value !== "object") return false;410  const v = value as { accounts?: unknown[]; items?: unknown[] };411  if (Array.isArray(v.accounts)) return true;412  if (!Array.isArray(v.items) || v.items.length === 0) return false;413  const sample = v.items[0] as any;414  return Boolean(sample?.details?.loginFields || sample?.overview?.title);415}416417function hasBitwardenShape(value: unknown): boolean {418  return Boolean(419    value &&420      typeof value === "object" &&421      Array.isArray((value as { items?: unknown[] }).items)422  );423}424425function hasCredentialExchangeShape(value: unknown): boolean {426  return Boolean(427    value &&428      typeof value === "object" &&429      (value as { version?: { major?: number; minor?: number }; accounts?: unknown[] }).version &&430      Array.isArray((value as { accounts?: unknown[] }).accounts)431  );432}433434function hasProtonPassShape(value: unknown): boolean {435  return Boolean(436    value &&437      typeof value === "object" &&438      (value as { vaults?: unknown }).vaults &&439      typeof (value as { vaults?: unknown }).vaults === "object"440  );441}442443function pickFirstField(444  row: Record<string, string>,445  candidates: string[]446): string {447  for (const key of candidates) {448    const value = row[key];449    if (typeof value === "string" && value.trim()) {450      return value.trim();451    }452  }453  return "";454}455456function normalizeTotp(457  value?: string458): { totp: string; totpAlgorithm?: "SHA1" | "SHA256" | "SHA512"; totpDigits?: number; totpPeriod?: number } | undefined {459  if (!value) return undefined;460  const parsed = value.startsWith("otpauth://") ? parseOtpauthURI(value) : null;461  const totp = parsed?.secret ?? value.trim();462  if (!totp) return undefined;463  if (!parsed) return { totp };464  // Persist only non-default params so bare-seed entries stay compact while465  // generating the RFC-default codes.466  return {467    totp,468    totpAlgorithm: parsed.algorithm && parsed.algorithm !== "SHA1" ? parsed.algorithm : undefined,469    totpDigits: parsed.digits && parsed.digits !== 6 ? parsed.digits : undefined,470    totpPeriod: parsed.period && parsed.period !== 30 ? parsed.period : undefined,471  };472}473474function firstNonEmptyString(475  ...values: Array<string | null | undefined>476): string {477  for (const value of values) {478    if (typeof value === "string" && value.trim()) {479      return value.trim();480    }481  }482  return "";483}484485function stringOrUndefined(value: unknown): string | undefined {486  return typeof value === "string" && value.trim() ? value.trim() : undefined;487}488489function stringOrEmpty(value: unknown): string {490  return typeof value === "string" ? value : "";491}