// `any` is intentional throughout this module (L17/I3/QUA-010): import files // are user-supplied external JSON whose schemas vary by source app and version // (1Password CPG, Bitwarden, CXF, generic CSV). Structural extraction is // defensive — every field is null-checked or falls back to "" — so a wrong // schema can't crash the import. Tightening to `unknown` would force a noisy // runtime schema validator on data we already treat as untrusted. import Papa from "papaparse"; import { base64urlDecode, base64urlEncode, importPasskeyPrivateKey, } from "../background/passkey-crypto"; import { parseOtpauthURI } from "../crypto/totp"; import { IMPORT_LIMITS, importTooLarge } from "./import-limits"; import type { VaultEntry } from "./types"; export type ImportableEntry = Omit; /** * Accumulate one parsed entry, enforcing MAX_IMPORT_ENTRY_COUNT. Every parser * routes through here so the cap holds while entries are still being built, * not after the whole array exists. */ function pushEntry(entries: ImportableEntry[], entry: ImportableEntry): void { if (entries.length >= IMPORT_LIMITS.MAX_IMPORT_ENTRY_COUNT) { throw importTooLarge( `import exceeds ${IMPORT_LIMITS.MAX_IMPORT_ENTRY_COUNT} entries`, ); } entries.push(entry); } export type ImportFormat = | "csv" | "1password-json" | "bitwarden-json" | "credential-exchange-json" | "protonpass-json" | "unknown"; export interface ImportParseResult { format: ImportFormat; label: string; entries: ImportableEntry[]; totalItems: number; skippedItems: number; } export async function parseImportSource( raw: string ): Promise { // MV-5: bound the input before any JSON.parse / CSV parse allocates // proportional structures. Covers file picks AND pasted strings. if (raw.length > IMPORT_LIMITS.MAX_JSON_BYTES) { throw importTooLarge( `import source ${raw.length} chars exceeds ${IMPORT_LIMITS.MAX_JSON_BYTES}`, ); } const format = detectImportFormat(raw); switch (format) { case "csv": { const { entries, totalItems } = parseCSV(raw); return { format, label: "CSV import", entries, totalItems, skippedItems: Math.max(0, totalItems - entries.length), }; } case "1password-json": { const entries = parse1PasswordJSON(raw); return { format, label: "1Password JSON", entries, totalItems: entries.length, skippedItems: 0, }; } case "bitwarden-json": { const { entries, totalItems } = parseBitwardenJSON(raw); return { format, label: "Bitwarden JSON", entries, totalItems, skippedItems: Math.max(0, totalItems - entries.length), }; } case "credential-exchange-json": { const { entries, totalItems } = await parseCredentialExchangeJSON(raw); return { format, label: "Credential Exchange JSON", entries, totalItems, skippedItems: Math.max(0, totalItems - entries.length), }; } case "protonpass-json": { const { entries, totalItems } = parseProtonPassJSON(raw); return { format, label: "Proton Pass JSON", entries, totalItems, skippedItems: Math.max(0, totalItems - entries.length), }; } default: return { format: "unknown", label: "Unknown format", entries: [], totalItems: 0, skippedItems: 0, }; } } export function detectImportFormat(raw: string): ImportFormat { const trimmed = raw.trim(); if (trimmed.startsWith("{") || trimmed.startsWith("[")) { try { const data = JSON.parse(trimmed); if (hasCredentialExchangeShape(data)) return "credential-exchange-json"; if (has1PasswordShape(data)) return "1password-json"; if (hasBitwardenShape(data)) return "bitwarden-json"; if (hasProtonPassShape(data)) return "protonpass-json"; return "unknown"; } catch { return "unknown"; } } return trimmed.includes(",") ? "csv" : "unknown"; } export function parse1PasswordJSON(raw: string): ImportableEntry[] { const data = JSON.parse(raw); const entries: ImportableEntry[] = []; const itemGroups: any[][] = []; const vaults = data?.accounts?.[0]?.vaults ?? []; for (const vault of vaults) itemGroups.push(vault.items ?? []); if (Array.isArray(data?.items)) itemGroups.push(data.items); for (const items of itemGroups) { for (const item of items) { if (item.trashed) continue; const fields = item.details?.loginFields ?? item.fields ?? []; const username = fields.find((field: any) => field.designation === "username" || field.id === "username")?.value ?? ""; const password = fields.find((field: any) => field.designation === "password" || field.id === "password")?.value ?? ""; if (!password) continue; pushEntry(entries, { type: "login", title: (item.overview?.title ?? item.title)?.trim() || "Untitled", username: username.trim(), password, url: ((item.overview?.url ?? item.url) ?? "").trim(), notes: stringOrUndefined(item.details?.notesPlain ?? item.notes), }); } } return entries; } function parseCSV(csvText: string): { entries: ImportableEntry[]; totalItems: number } { const result = Papa.parse>(csvText, { header: true, skipEmptyLines: true, transformHeader: (header) => header.trim(), }); // PCH-01-026 #5: return totalItems from this single parse so parseImportSource // doesn't re-run Papa.parse over the whole file just to count rows. const entries = result.data.reduce((entries, row) => { const title = pickFirstField(row, [ "name", "title", "Title", "Name", "item", ]); const url = pickFirstField(row, [ "url", "website", "URL", "Website", "login_uri", "loginUrl", ]); const username = pickFirstField(row, [ "username", "login", "email", "Username", "login_username", "user", ]); const password = pickFirstField(row, [ "password", "Password", "login_password", ]); const notes = pickFirstField(row, [ "notes", "Notes", "note", "comment", "extra", ]); const totpParams = normalizeTotp( pickFirstField(row, [ "totp", "totpUri", "otp", "otpSecret", "two-factor-secret", "login_totp", ]) ); const grouping = pickFirstField(row, ["grouping", "Grouping", "folder", "Folder"]); if (!password) return entries; pushEntry(entries, { type: "login", title: firstNonEmptyString(title, url, username, "Untitled"), username, password, url, notes: notes || undefined, ...(totpParams ?? {}), tags: grouping ? [grouping] : undefined, }); return entries; }, []); return { entries, totalItems: result.data.length }; } function parseBitwardenJSON(raw: string): { entries: ImportableEntry[]; totalItems: number; } { const data = JSON.parse(raw); const items: any[] = Array.isArray(data?.items) ? data.items : []; const loginItems = items.filter((item) => item?.type === 1); return { totalItems: loginItems.length, entries: loginItems.reduce((entries, item) => { const password = stringOrEmpty(item.login?.password); if (!password) return entries; pushEntry(entries, { type: "login", title: firstNonEmptyString(item.name, "Untitled"), url: firstNonEmptyString(item.login?.uris?.[0]?.uri, ""), username: firstNonEmptyString(item.login?.username, ""), password, notes: stringOrUndefined(item.notes), ...(normalizeTotp(stringOrUndefined(item.login?.totp)) ?? {}), }); return entries; }, []), }; } function parseProtonPassJSON(raw: string): { entries: ImportableEntry[]; totalItems: number; } { const data = JSON.parse(raw) as { vaults?: Record; }; const vaults = data?.vaults && typeof data.vaults === "object" ? data.vaults : {}; const entries: ImportableEntry[] = []; let totalItems = 0; for (const vault of Object.values(vaults)) { if (vault?.name?.toLowerCase() === "recycle bin") continue; for (const item of vault?.items ?? []) { const itemData = item?.data ?? item; const itemType = itemData?.type ?? item?.type; if (itemType !== "login") continue; totalItems += 1; const content = itemData?.content ?? {}; const metadata = itemData?.metadata ?? {}; const password = firstNonEmptyString( content.password, content.itemPassword ); if (!password) continue; pushEntry(entries, { type: "login", title: firstNonEmptyString( metadata.name, item.name, item.title, content.itemName, "Untitled" ), username: firstNonEmptyString( content.username, content.itemUsername, content.email, content.itemEmail, item.aliasEmail, "" ), password, url: firstNonEmptyString( Array.isArray(content.urls) ? content.urls[0] : undefined, content.url, "" ), notes: stringOrUndefined(metadata.note ?? metadata.notes), ...(normalizeTotp( firstNonEmptyString(content.totpUri, content.itemTotpUri) ) ?? {}), }); } } return { entries, totalItems }; } async function parseCredentialExchangeJSON(raw: string): Promise<{ entries: ImportableEntry[]; totalItems: number; }> { const data = JSON.parse(raw) as { accounts?: Array<{ items?: Array<{ title?: string; modifiedAt?: number; creationAt?: number; credentials?: Array<{ type?: string; credentialId?: string; rpId?: string; username?: string; userDisplayName?: string; userHandle?: string; key?: string; }>; }>; }>; }; const entries: ImportableEntry[] = []; let totalItems = 0; for (const account of data.accounts ?? []) { for (const item of account.items ?? []) { for (const credential of item.credentials ?? []) { if (credential?.type !== "passkey") continue; totalItems += 1; if ( !credential.credentialId || !credential.rpId || !credential.username || !credential.userDisplayName || !credential.userHandle || !credential.key ) { continue; } try { const pkcs8 = base64urlDecode(credential.key); const imported = await importPasskeyPrivateKey(pkcs8); const createdAt = Date.now(); pushEntry(entries, { type: "login", title: firstNonEmptyString(item.title, credential.rpId), username: credential.username, url: `https://${credential.rpId}`, passkey: { rpId: credential.rpId, rpName: firstNonEmptyString(item.title, credential.rpId), credentialId: credential.credentialId, publicKeyCose: base64urlEncode(imported.publicKeyCose), privateKeyJwk: imported.privateKeyJwk, userHandle: credential.userHandle, userName: credential.username, userDisplayName: credential.userDisplayName, algorithm: imported.algorithm, signCount: 0, aaguid: "AAAAAAAAAAAAAAAAAAAAAA", backupEligible: true, backupState: false, createdAt, }, }); } catch { // Skip passkeys whose private key we cannot import into WebCrypto. } } } } return { entries, totalItems }; } function has1PasswordShape(value: unknown): boolean { if (!value || typeof value !== "object") return false; const v = value as { accounts?: unknown[]; items?: unknown[] }; if (Array.isArray(v.accounts)) return true; if (!Array.isArray(v.items) || v.items.length === 0) return false; const sample = v.items[0] as any; return Boolean(sample?.details?.loginFields || sample?.overview?.title); } function hasBitwardenShape(value: unknown): boolean { return Boolean( value && typeof value === "object" && Array.isArray((value as { items?: unknown[] }).items) ); } function hasCredentialExchangeShape(value: unknown): boolean { return Boolean( value && typeof value === "object" && (value as { version?: { major?: number; minor?: number }; accounts?: unknown[] }).version && Array.isArray((value as { accounts?: unknown[] }).accounts) ); } function hasProtonPassShape(value: unknown): boolean { return Boolean( value && typeof value === "object" && (value as { vaults?: unknown }).vaults && typeof (value as { vaults?: unknown }).vaults === "object" ); } function pickFirstField( row: Record, candidates: string[] ): string { for (const key of candidates) { const value = row[key]; if (typeof value === "string" && value.trim()) { return value.trim(); } } return ""; } function normalizeTotp( value?: string ): { totp: string; totpAlgorithm?: "SHA1" | "SHA256" | "SHA512"; totpDigits?: number; totpPeriod?: number } | undefined { if (!value) return undefined; const parsed = value.startsWith("otpauth://") ? parseOtpauthURI(value) : null; const totp = parsed?.secret ?? value.trim(); if (!totp) return undefined; if (!parsed) return { totp }; // Persist only non-default params so bare-seed entries stay compact while // generating the RFC-default codes. return { totp, totpAlgorithm: parsed.algorithm && parsed.algorithm !== "SHA1" ? parsed.algorithm : undefined, totpDigits: parsed.digits && parsed.digits !== 6 ? parsed.digits : undefined, totpPeriod: parsed.period && parsed.period !== 30 ? parsed.period : undefined, }; } function firstNonEmptyString( ...values: Array ): string { for (const value of values) { if (typeof value === "string" && value.trim()) { return value.trim(); } } return ""; } function stringOrUndefined(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } function stringOrEmpty(value: unknown): string { return typeof value === "string" ? value : ""; }