import.ts

14.7 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">;2021/**22 * Accumulate one parsed entry, enforcing MAX_IMPORT_ENTRY_COUNT. Every parser23 * routes through here so the cap holds while entries are still being built,24 * not after the whole array exists.25 */26function pushEntry(entries: ImportableEntry[], entry: ImportableEntry): void {27  if (entries.length >= IMPORT_LIMITS.MAX_IMPORT_ENTRY_COUNT) {28    throw importTooLarge(29      `import exceeds ${IMPORT_LIMITS.MAX_IMPORT_ENTRY_COUNT} entries`,30    );31  }32  entries.push(entry);33}3435export type ImportFormat =36  | "csv"37  | "1password-json"38  | "bitwarden-json"39  | "credential-exchange-json"40  | "protonpass-json"41  | "unknown";4243export interface ImportParseResult {44  format: ImportFormat;45  label: string;46  entries: ImportableEntry[];47  totalItems: number;48  skippedItems: number;49}5051export async function parseImportSource(52  raw: string53): Promise<ImportParseResult> {54  // MV-5: bound the input before any JSON.parse / CSV parse allocates55  // proportional structures. Covers file picks AND pasted strings.56  if (raw.length > IMPORT_LIMITS.MAX_JSON_BYTES) {57    throw importTooLarge(58      `import source ${raw.length} chars exceeds ${IMPORT_LIMITS.MAX_JSON_BYTES}`,59    );60  }61  const format = detectImportFormat(raw);6263  switch (format) {64    case "csv": {65      const { entries, totalItems } = parseCSV(raw);66      return {67        format,68        label: "CSV import",69        entries,70        totalItems,71        skippedItems: Math.max(0, totalItems - entries.length),72      };73    }74    case "1password-json": {75      const entries = parse1PasswordJSON(raw);76      return {77        format,78        label: "1Password JSON",79        entries,80        totalItems: entries.length,81        skippedItems: 0,82      };83    }84    case "bitwarden-json": {85      const { entries, totalItems } = parseBitwardenJSON(raw);86      return {87        format,88        label: "Bitwarden JSON",89        entries,90        totalItems,91        skippedItems: Math.max(0, totalItems - entries.length),92      };93    }94    case "credential-exchange-json": {95      const { entries, totalItems } = await parseCredentialExchangeJSON(raw);96      return {97        format,98        label: "Credential Exchange JSON",99        entries,100        totalItems,101        skippedItems: Math.max(0, totalItems - entries.length),102      };103    }104    case "protonpass-json": {105      const { entries, totalItems } = parseProtonPassJSON(raw);106      return {107        format,108        label: "Proton Pass JSON",109        entries,110        totalItems,111        skippedItems: Math.max(0, totalItems - entries.length),112      };113    }114    default:115      return {116        format: "unknown",117        label: "Unknown format",118        entries: [],119        totalItems: 0,120        skippedItems: 0,121      };122  }123}124125export function detectImportFormat(raw: string): ImportFormat {126  const trimmed = raw.trim();127  if (trimmed.startsWith("{") || trimmed.startsWith("[")) {128    try {129      const data = JSON.parse(trimmed);130      if (hasCredentialExchangeShape(data)) return "credential-exchange-json";131      if (has1PasswordShape(data)) return "1password-json";132      if (hasBitwardenShape(data)) return "bitwarden-json";133      if (hasProtonPassShape(data)) return "protonpass-json";134      return "unknown";135    } catch {136      return "unknown";137    }138  }139140  return trimmed.includes(",") ? "csv" : "unknown";141}142143export function parse1PasswordJSON(raw: string): ImportableEntry[] {144  const data = JSON.parse(raw);145  const entries: ImportableEntry[] = [];146147  const itemGroups: any[][] = [];148  const vaults = data?.accounts?.[0]?.vaults ?? [];149  for (const vault of vaults) itemGroups.push(vault.items ?? []);150  if (Array.isArray(data?.items)) itemGroups.push(data.items);151152  for (const items of itemGroups) {153    for (const item of items) {154      if (item.trashed) continue;155      const fields = item.details?.loginFields ?? item.fields ?? [];156      const username =157        fields.find((field: any) => field.designation === "username" || field.id === "username")?.value ??158        "";159      const password =160        fields.find((field: any) => field.designation === "password" || field.id === "password")?.value ??161        "";162      if (!password) continue;163      pushEntry(entries, {164        type: "login",165        title: (item.overview?.title ?? item.title)?.trim() || "Untitled",166        username: username.trim(),167        password,168        url: ((item.overview?.url ?? item.url) ?? "").trim(),169        notes: stringOrUndefined(item.details?.notesPlain ?? item.notes),170      });171    }172  }173174  return entries;175}176177function parseCSV(csvText: string): { entries: ImportableEntry[]; totalItems: number } {178  const result = Papa.parse<Record<string, string>>(csvText, {179    header: true,180    skipEmptyLines: true,181    transformHeader: (header) => header.trim(),182  });183184  // PCH-01-026 #5: return totalItems from this single parse so parseImportSource185  // doesn't re-run Papa.parse over the whole file just to count rows.186  const entries = result.data.reduce<ImportableEntry[]>((entries, row) => {187      const title = pickFirstField(row, [188        "name",189        "title",190        "Title",191        "Name",192        "item",193      ]);194      const url = pickFirstField(row, [195        "url",196        "website",197        "URL",198        "Website",199        "login_uri",200        "loginUrl",201      ]);202      const username = pickFirstField(row, [203        "username",204        "login",205        "email",206        "Username",207        "login_username",208        "user",209      ]);210      const password = pickFirstField(row, [211        "password",212        "Password",213        "login_password",214      ]);215      const notes = pickFirstField(row, [216        "notes",217        "Notes",218        "note",219        "comment",220        "extra",221      ]);222      const totpParams = normalizeTotp(223        pickFirstField(row, [224          "totp",225          "totpUri",226          "otp",227          "otpSecret",228          "two-factor-secret",229          "login_totp",230        ])231      );232      const grouping = pickFirstField(row, ["grouping", "Grouping", "folder", "Folder"]);233234      if (!password) return entries;235236      pushEntry(entries, {237        type: "login",238        title: firstNonEmptyString(title, url, username, "Untitled"),239        username,240        password,241        url,242        notes: notes || undefined,243        ...(totpParams ?? {}),244        tags: grouping ? [grouping] : undefined,245      });246      return entries;247    }, []);248249  return { entries, totalItems: result.data.length };250}251252function parseBitwardenJSON(raw: string): {253  entries: ImportableEntry[];254  totalItems: number;255} {256  const data = JSON.parse(raw);257  const items: any[] = Array.isArray(data?.items) ? data.items : [];258  const loginItems = items.filter((item) => item?.type === 1);259260  return {261    totalItems: loginItems.length,262    entries: loginItems.reduce<ImportableEntry[]>((entries, item) => {263        const password = stringOrEmpty(item.login?.password);264        if (!password) return entries;265266        pushEntry(entries, {267          type: "login",268          title: firstNonEmptyString(item.name, "Untitled"),269          url: firstNonEmptyString(item.login?.uris?.[0]?.uri, ""),270          username: firstNonEmptyString(item.login?.username, ""),271          password,272          notes: stringOrUndefined(item.notes),273          ...(normalizeTotp(stringOrUndefined(item.login?.totp)) ?? {}),274        });275        return entries;276      }, []),277  };278}279280function parseProtonPassJSON(raw: string): {281  entries: ImportableEntry[];282  totalItems: number;283} {284  const data = JSON.parse(raw) as {285    vaults?: Record<string, { name?: string; items?: any[] }>;286  };287  const vaults = data?.vaults && typeof data.vaults === "object" ? data.vaults : {};288  const entries: ImportableEntry[] = [];289  let totalItems = 0;290291  for (const vault of Object.values(vaults)) {292    if (vault?.name?.toLowerCase() === "recycle bin") continue;293294    for (const item of vault?.items ?? []) {295      const itemData = item?.data ?? item;296      const itemType = itemData?.type ?? item?.type;297      if (itemType !== "login") continue;298299      totalItems += 1;300301      const content = itemData?.content ?? {};302      const metadata = itemData?.metadata ?? {};303      const password = firstNonEmptyString(304        content.password,305        content.itemPassword306      );307      if (!password) continue;308309      pushEntry(entries, {310        type: "login",311        title: firstNonEmptyString(312          metadata.name,313          item.name,314          item.title,315          content.itemName,316          "Untitled"317        ),318        username: firstNonEmptyString(319          content.username,320          content.itemUsername,321          content.email,322          content.itemEmail,323          item.aliasEmail,324          ""325        ),326        password,327        url: firstNonEmptyString(328          Array.isArray(content.urls) ? content.urls[0] : undefined,329          content.url,330          ""331        ),332        notes: stringOrUndefined(metadata.note ?? metadata.notes),333        ...(normalizeTotp(334          firstNonEmptyString(content.totpUri, content.itemTotpUri)335        ) ?? {}),336      });337    }338  }339340  return { entries, totalItems };341}342343async function parseCredentialExchangeJSON(raw: string): Promise<{344  entries: ImportableEntry[];345  totalItems: number;346}> {347  const data = JSON.parse(raw) as {348    accounts?: Array<{349      items?: Array<{350        title?: string;351        modifiedAt?: number;352        creationAt?: number;353        credentials?: Array<{354          type?: string;355          credentialId?: string;356          rpId?: string;357          username?: string;358          userDisplayName?: string;359          userHandle?: string;360          key?: string;361        }>;362      }>;363    }>;364  };365366  const entries: ImportableEntry[] = [];367  let totalItems = 0;368369  for (const account of data.accounts ?? []) {370    for (const item of account.items ?? []) {371      for (const credential of item.credentials ?? []) {372        if (credential?.type !== "passkey") continue;373        totalItems += 1;374375        if (376          !credential.credentialId ||377          !credential.rpId ||378          !credential.username ||379          !credential.userDisplayName ||380          !credential.userHandle ||381          !credential.key382        ) {383          continue;384        }385386        try {387          const pkcs8 = base64urlDecode(credential.key);388          const imported = await importPasskeyPrivateKey(pkcs8);389          const createdAt = Date.now();390          pushEntry(entries, {391            type: "login",392            title: firstNonEmptyString(item.title, credential.rpId),393            username: credential.username,394            url: `https://${credential.rpId}`,395            passkey: {396              rpId: credential.rpId,397              rpName: firstNonEmptyString(item.title, credential.rpId),398              credentialId: credential.credentialId,399              publicKeyCose: base64urlEncode(imported.publicKeyCose),400              privateKeyJwk: imported.privateKeyJwk,401              userHandle: credential.userHandle,402              userName: credential.username,403              userDisplayName: credential.userDisplayName,404              algorithm: imported.algorithm,405              signCount: 0,406              aaguid: "AAAAAAAAAAAAAAAAAAAAAA",407              backupEligible: true,408              backupState: false,409              createdAt,410            },411          });412        } catch {413          // Skip passkeys whose private key we cannot import into WebCrypto.414        }415      }416    }417  }418419  return { entries, totalItems };420}421422function has1PasswordShape(value: unknown): boolean {423  if (!value || typeof value !== "object") return false;424  const v = value as { accounts?: unknown[]; items?: unknown[] };425  if (Array.isArray(v.accounts)) return true;426  if (!Array.isArray(v.items) || v.items.length === 0) return false;427  const sample = v.items[0] as any;428  return Boolean(sample?.details?.loginFields || sample?.overview?.title);429}430431function hasBitwardenShape(value: unknown): boolean {432  return Boolean(433    value &&434      typeof value === "object" &&435      Array.isArray((value as { items?: unknown[] }).items)436  );437}438439function hasCredentialExchangeShape(value: unknown): boolean {440  return Boolean(441    value &&442      typeof value === "object" &&443      (value as { version?: { major?: number; minor?: number }; accounts?: unknown[] }).version &&444      Array.isArray((value as { accounts?: unknown[] }).accounts)445  );446}447448function hasProtonPassShape(value: unknown): boolean {449  return Boolean(450    value &&451      typeof value === "object" &&452      (value as { vaults?: unknown }).vaults &&453      typeof (value as { vaults?: unknown }).vaults === "object"454  );455}456457function pickFirstField(458  row: Record<string, string>,459  candidates: string[]460): string {461  for (const key of candidates) {462    const value = row[key];463    if (typeof value === "string" && value.trim()) {464      return value.trim();465    }466  }467  return "";468}469470function normalizeTotp(471  value?: string472): { totp: string; totpAlgorithm?: "SHA1" | "SHA256" | "SHA512"; totpDigits?: number; totpPeriod?: number } | undefined {473  if (!value) return undefined;474  const parsed = value.startsWith("otpauth://") ? parseOtpauthURI(value) : null;475  const totp = parsed?.secret ?? value.trim();476  if (!totp) return undefined;477  if (!parsed) return { totp };478  // Persist only non-default params so bare-seed entries stay compact while479  // generating the RFC-default codes.480  return {481    totp,482    totpAlgorithm: parsed.algorithm && parsed.algorithm !== "SHA1" ? parsed.algorithm : undefined,483    totpDigits: parsed.digits && parsed.digits !== 6 ? parsed.digits : undefined,484    totpPeriod: parsed.period && parsed.period !== 30 ? parsed.period : undefined,485  };486}487488function firstNonEmptyString(489  ...values: Array<string | null | undefined>490): string {491  for (const value of values) {492    if (typeof value === "string" && value.trim()) {493      return value.trim();494    }495  }496  return "";497}498499function stringOrUndefined(value: unknown): string | undefined {500  return typeof value === "string" && value.trim() ? value.trim() : undefined;501}502503function stringOrEmpty(value: unknown): string {504  return typeof value === "string" ? value : "";505}