diff --git a/package-lock.json b/package-lock.json index 883a96b..b48b49d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,9 @@ "version": "1.1.0", "license": "LGPL-3.0", "dependencies": { + "@noble/hashes": "^2.0.1", "@tanstack/react-query": "^5.90.21", + "fflate": "^0.8.2", "lucide-preact": "^0.575.0", "preact": "^10.28.4", "qrcode-generator": "^2.0.4", @@ -1519,6 +1521,18 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmmirror.com/@poppinss/colors/-/colors-4.1.6.tgz", @@ -2413,6 +2427,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", @@ -3119,6 +3139,7 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "bin": { "workerd": "bin/workerd" }, diff --git a/package.json b/package.json index efd7445..9242d8e 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,9 @@ "wrangler": "^4.69.0" }, "dependencies": { + "@noble/hashes": "^2.0.1", "@tanstack/react-query": "^5.90.21", + "fflate": "^0.8.2", "lucide-preact": "^0.575.0", "preact": "^10.28.4", "qrcode-generator": "^2.0.4", diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index bd98688..00b4b4d 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -14,13 +14,14 @@ import SettingsPage from '@/components/SettingsPage'; import SecurityDevicesPage from '@/components/SecurityDevicesPage'; import AdminPage from '@/components/AdminPage'; import HelpPage from '@/components/HelpPage'; -import ImportExportPage from '@/components/ImportExportPage'; +import ImportPage from '@/components/ImportPage'; import { changeMasterPassword, createFolder, createCipher, createAuthedFetch, createInvite, + importCiphers, createSend, deleteAllInvites, deleteCipher, @@ -58,6 +59,7 @@ import { } from '@/lib/api'; import { base64ToBytes, decryptBw, decryptStr, hkdf } from '@/lib/crypto'; import { t } from '@/lib/i18n'; +import type { CiphersImportPayload } from '@/lib/api'; import type { AppPhase, AuthorizedDevice, Cipher, Folder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types'; interface PendingTotp { @@ -70,6 +72,135 @@ type JwtUnsafeReason = 'missing' | 'default' | 'too_short'; const SEND_KEY_SALT = 'bitwarden-send'; const SEND_KEY_PURPOSE = 'send'; +const IMPORT_ROUTE = '/help/import-export'; +const IMPORT_ROUTE_ALIASES = new Set(['/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export']); + +function asText(value: unknown): string { + if (value === null || value === undefined) return ''; + return String(value); +} + +function buildEmptyImportDraft(type: number): VaultDraft { + return { + type, + favorite: false, + name: '', + folderId: '', + notes: '', + reprompt: false, + loginUsername: '', + loginPassword: '', + loginTotp: '', + loginUris: [''], + loginFido2Credentials: [], + cardholderName: '', + cardNumber: '', + cardBrand: '', + cardExpMonth: '', + cardExpYear: '', + cardCode: '', + identTitle: '', + identFirstName: '', + identMiddleName: '', + identLastName: '', + identUsername: '', + identCompany: '', + identSsn: '', + identPassportNumber: '', + identLicenseNumber: '', + identEmail: '', + identPhone: '', + identAddress1: '', + identAddress2: '', + identAddress3: '', + identCity: '', + identState: '', + identPostalCode: '', + identCountry: '', + sshPrivateKey: '', + sshPublicKey: '', + sshFingerprint: '', + customFields: [], + }; +} + +function importCipherToDraft(cipher: Record, folderId: string | null): VaultDraft { + const type = Number(cipher.type || 1) || 1; + const draft = buildEmptyImportDraft(type); + draft.name = asText(cipher.name).trim() || 'Untitled'; + draft.notes = asText(cipher.notes); + draft.favorite = !!cipher.favorite; + draft.reprompt = Number(cipher.reprompt || 0) === 1; + draft.folderId = folderId || ''; + + const customFieldsRaw = Array.isArray(cipher.fields) ? cipher.fields : []; + draft.customFields = customFieldsRaw + .map((raw) => { + const field = (raw || {}) as Record; + const label = asText(field.name).trim(); + if (!label) return null; + const parsedType = Number(field.type ?? 0); + const fieldType = parsedType === 1 || parsedType === 2 || parsedType === 3 ? (parsedType as 1 | 2 | 3) : 0; + return { + type: fieldType, + label, + value: asText(field.value), + }; + }) + .filter((x): x is VaultDraft['customFields'][number] => !!x); + + if (type === 1) { + const login = (cipher.login || {}) as Record; + draft.loginUsername = asText(login.username); + draft.loginPassword = asText(login.password); + draft.loginTotp = asText(login.totp); + draft.loginFido2Credentials = Array.isArray(login.fido2Credentials) + ? login.fido2Credentials + .filter((credential): credential is Record => !!credential && typeof credential === 'object') + .map((credential) => ({ ...credential })) + : []; + const urisRaw = Array.isArray(login.uris) ? login.uris : []; + const uris = urisRaw + .map((u) => asText((u as Record)?.uri).trim()) + .filter((u) => !!u); + draft.loginUris = uris.length ? uris : ['']; + } else if (type === 3) { + const card = (cipher.card || {}) as Record; + draft.cardholderName = asText(card.cardholderName); + draft.cardNumber = asText(card.number); + draft.cardBrand = asText(card.brand); + draft.cardExpMonth = asText(card.expMonth); + draft.cardExpYear = asText(card.expYear); + draft.cardCode = asText(card.code); + } else if (type === 4) { + const identity = (cipher.identity || {}) as Record; + draft.identTitle = asText(identity.title); + draft.identFirstName = asText(identity.firstName); + draft.identMiddleName = asText(identity.middleName); + draft.identLastName = asText(identity.lastName); + draft.identUsername = asText(identity.username); + draft.identCompany = asText(identity.company); + draft.identSsn = asText(identity.ssn); + draft.identPassportNumber = asText(identity.passportNumber); + draft.identLicenseNumber = asText(identity.licenseNumber); + draft.identEmail = asText(identity.email); + draft.identPhone = asText(identity.phone); + draft.identAddress1 = asText(identity.address1); + draft.identAddress2 = asText(identity.address2); + draft.identAddress3 = asText(identity.address3); + draft.identCity = asText(identity.city); + draft.identState = asText(identity.state); + draft.identPostalCode = asText(identity.postalCode); + draft.identCountry = asText(identity.country); + } else if (type === 5) { + const sshKey = (cipher.sshKey || {}) as Record; + draft.sshPrivateKey = asText(sshKey.privateKey); + draft.sshPublicKey = asText(sshKey.publicKey); + draft.sshFingerprint = asText(sshKey.fingerprint); + } + + return draft; +} function buildPublicSendUrl(origin: string, accessId: string, keyPart: string): string { return `${origin}/#/send/${accessId}/${keyPart}`; @@ -470,6 +601,9 @@ export default function App() { decUsername: await decryptField(cipher.login.username || '', itemEnc, itemMac), decPassword: await decryptField(cipher.login.password || '', itemEnc, itemMac), decTotp: await decryptField(cipher.login.totp || '', itemEnc, itemMac), + fido2Credentials: Array.isArray(cipher.login.fido2Credentials) + ? cipher.login.fido2Credentials.map((credential) => ({ ...credential })) + : null, uris: await Promise.all( (cipher.login.uris || []).map(async (u) => ({ ...u, @@ -818,17 +952,126 @@ export default function App() { } } + async function handleImportAction( + payload: CiphersImportPayload, + options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null } + ) { + if (!session?.symEncKey || !session?.symMacKey) throw new Error('Vault key unavailable'); + + const mode = options.folderMode || 'original'; + const targetFolderId = (options.targetFolderId || '').trim() || null; + const folderIdByCipherIndex = new Map(); + if (mode === 'original') { + const folderIdByImportIndex = new Map(); + const folderIdByLegacyId = new Map(); + const folderIdByName = new Map(); + const createdFolderIdByName = new Map(); + for (let i = 0; i < payload.folders.length; i++) { + const folderRaw = (payload.folders[i] || {}) as Record; + const name = String(folderRaw.name || '').trim(); + if (!name) continue; + let folderId = createdFolderIdByName.get(name) || null; + if (!folderId) { + const created = await createFolder(authedFetch, name); + folderId = created.id; + createdFolderIdByName.set(name, folderId); + } + folderIdByImportIndex.set(i, folderId); + folderIdByName.set(name, folderId); + const legacyId = String(folderRaw.id || '').trim(); + if (legacyId) folderIdByLegacyId.set(legacyId, folderId); + } + for (const relation of payload.folderRelationships || []) { + const cipherIndex = Number(relation?.key); + const folderIndex = Number(relation?.value); + if (!Number.isFinite(cipherIndex) || !Number.isFinite(folderIndex)) continue; + const folderId = folderIdByImportIndex.get(folderIndex); + if (folderId) folderIdByCipherIndex.set(cipherIndex, folderId); + } + for (let i = 0; i < payload.ciphers.length; i++) { + if (folderIdByCipherIndex.has(i)) continue; + const raw = (payload.ciphers[i] || {}) as Record; + const rawFolderId = String(raw.folderId || '').trim(); + if (rawFolderId && folderIdByLegacyId.has(rawFolderId)) { + folderIdByCipherIndex.set(i, folderIdByLegacyId.get(rawFolderId)!); + continue; + } + const rawFolderName = String(raw.folder || '').trim(); + if (rawFolderName && folderIdByName.has(rawFolderName)) { + folderIdByCipherIndex.set(i, folderIdByName.get(rawFolderName)!); + } + } + } else if (mode === 'target' && targetFolderId) { + for (let i = 0; i < payload.ciphers.length; i++) { + folderIdByCipherIndex.set(i, targetFolderId); + } + } + + const createdCipherIdsByIndex = new Map(); + for (let i = 0; i < payload.ciphers.length; i++) { + const raw = (payload.ciphers[i] || {}) as Record; + const draft = importCipherToDraft(raw, null); + const created = await createCipher(authedFetch, session, draft); + createdCipherIdsByIndex.set(i, created.id); + } + + const moveIdsByFolderId = new Map(); + for (const [index, folderId] of folderIdByCipherIndex.entries()) { + const cipherId = createdCipherIdsByIndex.get(index); + if (!cipherId || !folderId) continue; + const group = moveIdsByFolderId.get(folderId) || []; + group.push(cipherId); + moveIdsByFolderId.set(folderId, group); + } + for (const [folderId, ids] of moveIdsByFolderId.entries()) { + await bulkMoveCiphers(authedFetch, ids, folderId); + } + + await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); + } + + async function handleImportEncryptedRawAction( + payload: CiphersImportPayload, + options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null } + ) { + const mode = options.folderMode || 'original'; + const targetFolderId = (options.targetFolderId || '').trim() || null; + const nextPayload: CiphersImportPayload = { + ciphers: payload.ciphers.map((raw) => ({ ...(raw as Record) })), + folders: mode === 'original' ? payload.folders : [], + folderRelationships: mode === 'original' ? payload.folderRelationships : [], + }; + if (mode === 'none') { + for (const raw of nextPayload.ciphers) (raw as Record).folderId = null; + } else if (mode === 'target' && targetFolderId) { + for (const raw of nextPayload.ciphers) (raw as Record).folderId = targetFolderId; + } + + await importCiphers(authedFetch, nextPayload); + await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); + } + const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : ''; const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw; + const hashPathOnly = String(hashPath || '').split('?')[0].split('#')[0]; + const normalizedHashPath = `/${hashPathOnly.replace(/^\/+/, '').replace(/\/+$/, '')}`.replace(/^\/$/, '/'); + const isImportHashRoute = IMPORT_ROUTE_ALIASES.has(normalizedHashPath); const effectiveLocation = hashPath.startsWith('/send/') || hashPath === '/recover-2fa' ? hashPath : location; const publicSendMatch = effectiveLocation.match(/^\/send\/([^/]+)(?:\/([^/]+))?\/?$/i); const isRecoverTwoFactorRoute = effectiveLocation === '/recover-2fa'; const isPublicSendRoute = !!publicSendMatch; + const isImportRoute = location === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(location); useEffect(() => { if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault'); }, [phase, location, isPublicSendRoute, navigate]); + useEffect(() => { + if (phase === 'app' && isImportHashRoute && location !== IMPORT_ROUTE) { + navigate(IMPORT_ROUTE); + } + }, [phase, isImportHashRoute, location, navigate]); + if (jwtWarning) { return ; } @@ -984,7 +1227,7 @@ export default function App() { {t('nav_backup_strategy')} - + {t('nav_import_export')} @@ -1132,8 +1375,59 @@ export default function App() { }} /> - - + + + + + + + + + + + + + + + + + diff --git a/webapp/src/components/ImportExportPage.tsx b/webapp/src/components/ImportExportPage.tsx deleted file mode 100644 index c45842b..0000000 --- a/webapp/src/components/ImportExportPage.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { ArrowUpDown } from 'lucide-preact'; -import { t } from '@/lib/i18n'; - -export default function ImportExportPage() { - return ( -
-
-

{t('import_export_title')}

-
-
- -
{t('import_export_under_construction')}
-
-
-
-
- ); -} - diff --git a/webapp/src/components/ImportPage.tsx b/webapp/src/components/ImportPage.tsx new file mode 100644 index 0000000..82a76d1 --- /dev/null +++ b/webapp/src/components/ImportPage.tsx @@ -0,0 +1,378 @@ +import { useState } from 'preact/hooks'; +import { argon2idAsync } from '@noble/hashes/argon2.js'; +import { strFromU8, unzipSync } from 'fflate'; +import { FileUp } from 'lucide-preact'; +import ConfirmDialog from '@/components/ConfirmDialog'; +import type { CiphersImportPayload } from '@/lib/api'; +import { + getFileAcceptBySource, + IMPORT_SOURCES, + type BitwardenJsonInput, + type ImportSourceId, + normalizeBitwardenEncryptedAccountImport, + normalizeBitwardenImport, + parseImportPayloadBySource, +} from '@/lib/import-formats'; +import { base64ToBytes, decryptStr, hkdfExpand, pbkdf2 } from '@/lib/crypto'; +import { t } from '@/lib/i18n'; +import type { Folder } from '@/lib/types'; + +interface ImportPageProps { + onImport: ( + payload: CiphersImportPayload, + options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null } + ) => Promise; + onImportEncryptedRaw: ( + payload: CiphersImportPayload, + options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null } + ) => Promise; + accountKeys?: { encB64: string; macB64: string } | null; + onNotify: (type: 'success' | 'error', text: string) => void; + folders: Folder[]; +} + +interface BitwardenPasswordProtectedInput extends BitwardenJsonInput { + encrypted: true; + passwordProtected: true; + salt?: string; + kdfIterations?: number; + kdfMemory?: number; + kdfParallelism?: number; + kdfType?: number; + data?: string; +} + +const COMMON_IMPORT_SOURCE_IDS: ImportSourceId[] = [ + 'bitwarden_json', + 'bitwarden_csv', + 'onepassword_1pux', + 'onepassword_1pif', + 'onepassword_mac_csv', + 'onepassword_win_csv', + 'protonpass_json', + 'chrome', + 'edge', + 'brave', + 'opera', + 'vivaldi', + 'firefox_csv', + 'safari_csv', + 'lastpass', + 'dashlane_csv', + 'dashlane_json', + 'keepass_xml', + 'keepassx_csv', +]; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} + +function isPasswordProtectedExport(value: unknown): value is BitwardenPasswordProtectedInput { + return isRecord(value) && value.encrypted === true && value.passwordProtected === true; +} + +async function derivePasswordProtectedFileKey( + parsed: BitwardenPasswordProtectedInput, + password: string +): Promise<{ enc: Uint8Array; mac: Uint8Array }> { + const salt = String(parsed.salt || '').trim(); + const iterations = Number(parsed.kdfIterations || 0); + const kdfType = Number(parsed.kdfType); + if (!salt || !Number.isFinite(iterations) || iterations <= 0) { + throw new Error('Invalid password-protected export file.'); + } + + let keyMaterial: Uint8Array; + if (kdfType === 0) { + keyMaterial = await pbkdf2(password, salt, iterations, 32); + } else if (kdfType === 1) { + const memoryMiB = Number(parsed.kdfMemory || 0); + const parallelism = Number(parsed.kdfParallelism || 0); + if (!Number.isFinite(memoryMiB) || memoryMiB <= 0 || !Number.isFinite(parallelism) || parallelism <= 0) { + throw new Error('Invalid Argon2id parameters in export file.'); + } + const memoryKiB = Math.floor(memoryMiB * 1024); + const maxmem = memoryKiB * 1024 + 1024 * 1024; + keyMaterial = await argon2idAsync(new TextEncoder().encode(password), new TextEncoder().encode(salt), { + t: Math.floor(iterations), + m: memoryKiB, + p: Math.floor(parallelism), + dkLen: 32, + maxmem, + asyncTick: 10, + }); + } else { + throw new Error(`Unsupported kdfType: ${kdfType}`); + } + + const enc = await hkdfExpand(keyMaterial, 'enc', 32); + const mac = await hkdfExpand(keyMaterial, 'mac', 32); + return { enc, mac }; +} + +async function decryptPasswordProtectedExport(parsed: BitwardenPasswordProtectedInput, password: string): Promise { + if (!parsed.encKeyValidation_DO_NOT_EDIT || !parsed.data) { + throw new Error('Invalid password-protected export file.'); + } + const pass = String(password || '').trim(); + if (!pass) { + throw new Error('Please enter file password.'); + } + + const key = await derivePasswordProtectedFileKey(parsed, pass); + try { + await decryptStr(parsed.encKeyValidation_DO_NOT_EDIT, key.enc, key.mac); + } catch { + throw new Error('Invalid file password.'); + } + + const plainJson = await decryptStr(parsed.data, key.enc, key.mac); + try { + return JSON.parse(plainJson); + } catch { + throw new Error('Failed to decrypt import file.'); + } +} + +function isZipPayload(bytes: Uint8Array): boolean { + return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04; +} + +function readZipText(bytes: Uint8Array, source: ImportSourceId): string { + const unzipped = unzipSync(bytes); + const fileNames = Object.keys(unzipped); + if (!fileNames.length) throw new Error('Empty zip archive.'); + + const preferred = source === 'onepassword_1pux' ? ['export.data', 'export.json'] : ['protonpass.json', 'export.json']; + for (const p of preferred) { + const hit = fileNames.find((n) => n.toLowerCase().endsWith(p.toLowerCase())); + if (hit) return strFromU8(unzipped[hit]); + } + + const firstJson = fileNames.find((n) => n.toLowerCase().endsWith('.json') || n.toLowerCase().endsWith('.data')); + if (firstJson) return strFromU8(unzipped[firstJson]); + throw new Error('No importable JSON data found in zip archive.'); +} + +async function readImportText(file: File, source: ImportSourceId): Promise { + if (source !== 'onepassword_1pux' && source !== 'protonpass_json') { + return file.text(); + } + const bytes = new Uint8Array(await file.arrayBuffer()); + if (isZipPayload(bytes)) return readZipText(bytes, source); + return new TextDecoder().decode(bytes); +} + +export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys, onNotify, folders }: ImportPageProps) { + const [source, setSource] = useState('bitwarden_json'); + const [file, setFile] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isPasswordSubmitting, setIsPasswordSubmitting] = useState(false); + const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); + const [importPassword, setImportPassword] = useState(''); + const [pendingPasswordImport, setPendingPasswordImport] = useState(null); + const [folderMode, setFolderMode] = useState<'original' | 'none' | 'target'>('original'); + const [targetFolderId, setTargetFolderId] = useState(''); + const commonSourceSet = new Set(COMMON_IMPORT_SOURCE_IDS); + const commonSources = IMPORT_SOURCES.filter((item) => commonSourceSet.has(item.id as ImportSourceId)); + const otherSources = IMPORT_SOURCES.filter((item) => !commonSourceSet.has(item.id as ImportSourceId)); + + async function runBitwardenJsonImport(parsed: unknown): Promise { + if (isRecord(parsed) && parsed.encrypted === true) { + const accountEncrypted = parsed as BitwardenJsonInput; + if (!accountKeys?.encB64 || !accountKeys?.macB64) { + throw new Error('Vault key unavailable. Please unlock vault and try again.'); + } + const validation = String(accountEncrypted.encKeyValidation_DO_NOT_EDIT || '').trim(); + if (!validation) throw new Error('Invalid encrypted export file.'); + const accountEncKey = base64ToBytes(accountKeys.encB64); + const accountMacKey = base64ToBytes(accountKeys.macB64); + try { + await decryptStr(validation, accountEncKey, accountMacKey); + } catch { + throw new Error('This encrypted export belongs to another account.'); + } + await onImportEncryptedRaw(normalizeBitwardenEncryptedAccountImport(accountEncrypted), { + folderMode, + targetFolderId: folderMode === 'target' ? targetFolderId || null : null, + }); + return; + } + await onImport(normalizeBitwardenImport(parsed), { + folderMode, + targetFolderId: folderMode === 'target' ? targetFolderId || null : null, + }); + } + + async function handleSubmit() { + if (!file) { + onNotify('error', t('txt_please_select_a_file')); + return; + } + + setIsSubmitting(true); + try { + const text = await readImportText(file, source); + if (source === 'bitwarden_json') { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new Error('Invalid JSON file'); + } + if (isPasswordProtectedExport(parsed)) { + setPendingPasswordImport(parsed); + setImportPassword(''); + setPasswordDialogOpen(true); + return; + } + await runBitwardenJsonImport(parsed); + } else { + await onImport(parseImportPayloadBySource(source, text), { + folderMode, + targetFolderId: folderMode === 'target' ? targetFolderId || null : null, + }); + } + setFile(null); + onNotify('success', 'Import completed'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Import failed'; + onNotify('error', message); + } finally { + setIsSubmitting(false); + } + } + + async function handlePasswordImportConfirm() { + if (!pendingPasswordImport) return; + setIsPasswordSubmitting(true); + try { + const parsed = await decryptPasswordProtectedExport(pendingPasswordImport, importPassword); + await runBitwardenJsonImport(parsed); + setFile(null); + setImportPassword(''); + setPendingPasswordImport(null); + setPasswordDialogOpen(false); + onNotify('success', 'Import completed'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Import failed'; + onNotify('error', message); + } finally { + setIsPasswordSubmitting(false); + } + } + + return ( +
+
+

Import

+

+ Import vault data into your current account. +

+
+ + + + + + + {folderMode === 'target' && ( + + )} +
+ +
+ +
+
+ + void handlePasswordImportConfirm()} + onCancel={() => { + if (isPasswordSubmitting) return; + setPasswordDialogOpen(false); + setImportPassword(''); + setPendingPasswordImport(null); + }} + > + + +
+ ); +} diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index 956a2e2..ba8f029 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -158,6 +158,7 @@ function createEmptyDraft(type: number): VaultDraft { loginPassword: '', loginTotp: '', loginUris: [''], + loginFido2Credentials: [], cardholderName: '', cardNumber: '', cardBrand: '', @@ -203,6 +204,9 @@ function draftFromCipher(cipher: Cipher): VaultDraft { draft.loginPassword = cipher.login.decPassword || ''; draft.loginTotp = cipher.login.decTotp || ''; draft.loginUris = (cipher.login.uris || []).map((x) => x.decUri || x.uri || ''); + draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials) + ? cipher.login.fido2Credentials.map((credential) => ({ ...credential })) + : []; if (!draft.loginUris.length) draft.loginUris = ['']; } if (cipher.card) { @@ -264,6 +268,16 @@ function formatHistoryTime(value: string | null | undefined): string { return date.toLocaleString(); } +function firstPasskeyCreationTime(cipher: Cipher | null): string | null { + const credentials = cipher?.login?.fido2Credentials; + if (!Array.isArray(credentials) || credentials.length === 0) return null; + for (const credential of credentials) { + const raw = String(credential?.creationDate || '').trim(); + if (raw) return raw; + } + return null; +} + const TOTP_PERIOD_SECONDS = 30; const TOTP_RING_RADIUS = 14; const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS; @@ -419,6 +433,7 @@ export default function VaultPage(props: VaultPageProps) { () => props.ciphers.find((x) => x.id === selectedCipherId) || null, [props.ciphers, selectedCipherId] ); + const passkeyCreatedAt = firstPasskeyCreationTime(selectedCipher); useEffect(() => { const raw = selectedCipher?.login?.decTotp || ''; @@ -1172,6 +1187,15 @@ function folderName(id: string | null | undefined): string { )} + {!!passkeyCreatedAt && ( +
+ {t('txt_passkey')} +
+ {t('txt_passkey_created_at_value', { value: formatHistoryTime(passkeyCreatedAt) })} +
+
+
+ )}
)} diff --git a/webapp/src/lib/api.ts b/webapp/src/lib/api.ts index b11c8f9..1303003 100644 --- a/webapp/src/lib/api.ts +++ b/webapp/src/lib/api.ts @@ -307,13 +307,16 @@ export async function getFolders(authedFetch: (input: string, init?: RequestInit export async function createFolder( authedFetch: (input: string, init?: RequestInit) => Promise, name: string -): Promise { +): Promise<{ id: string; name?: string | null }> { const resp = await authedFetch('/api/folders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }), }); if (!resp.ok) throw new Error('Create folder failed'); + const body = await parseJson<{ id?: string; name?: string | null }>(resp); + if (!body?.id) throw new Error('Create folder failed'); + return { id: body.id, name: body.name ?? null }; } export async function getCiphers(authedFetch: (input: string, init?: RequestInit) => Promise): Promise { @@ -323,6 +326,24 @@ export async function getCiphers(authedFetch: (input: string, init?: RequestInit return body?.data || []; } +export interface CiphersImportPayload { + ciphers: Array>; + folders: Array<{ name: string }>; + folderRelationships: Array<{ key: number; value: number }>; +} + +export async function importCiphers( + authedFetch: (input: string, init?: RequestInit) => Promise, + payload: CiphersImportPayload +): Promise { + const resp = await authedFetch('/api/ciphers/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Import failed')); +} + export async function getSends(authedFetch: (input: string, init?: RequestInit) => Promise): Promise { const resp = await authedFetch('/api/sends'); if (!resp.ok) throw new Error('Failed to load sends'); @@ -571,6 +592,50 @@ async function encryptUris(uris: string[], enc: Uint8Array, mac: Uint8Array): Pr return out; } +function asFidoString(value: unknown, fallback = ''): string { + const normalized = String(value ?? '').trim(); + return normalized || fallback; +} + +function asNullableFidoString(value: unknown): string | null { + const normalized = String(value ?? '').trim(); + return normalized || null; +} + +function toIsoDateOrNow(value: unknown): string { + const raw = String(value ?? '').trim(); + if (!raw) return new Date().toISOString(); + const parsed = new Date(raw); + if (!Number.isFinite(parsed.getTime())) return new Date().toISOString(); + return parsed.toISOString(); +} + +function normalizeFido2Credentials( + credentials: Array> | null | undefined +): Array> | null { + if (!Array.isArray(credentials) || credentials.length === 0) return null; + const out: Array> = []; + for (const credential of credentials) { + if (!credential || typeof credential !== 'object') continue; + out.push({ + credentialId: asFidoString(credential.credentialId), + keyType: asFidoString(credential.keyType, 'public-key'), + keyAlgorithm: asFidoString(credential.keyAlgorithm, 'ECDSA'), + keyCurve: asFidoString(credential.keyCurve, 'P-256'), + keyValue: asFidoString(credential.keyValue), + rpId: asFidoString(credential.rpId), + rpName: asNullableFidoString(credential.rpName), + userHandle: asNullableFidoString(credential.userHandle), + userName: asNullableFidoString(credential.userName), + userDisplayName: asNullableFidoString(credential.userDisplayName), + counter: asFidoString(credential.counter, '0'), + discoverable: asFidoString(credential.discoverable, 'false'), + creationDate: toIsoDateOrNow(credential.creationDate), + }); + } + return out.length ? out : null; +} + async function getCipherKeys(cipher: Cipher | null, userEnc: Uint8Array, userMac: Uint8Array): Promise<{ enc: Uint8Array; mac: Uint8Array; key: string | null }> { if (cipher?.key) { try { @@ -587,7 +652,7 @@ export async function createCipher( authedFetch: (input: string, init?: RequestInit) => Promise, session: SessionState, draft: VaultDraft -): Promise { +): Promise<{ id: string }> { if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable'); const enc = base64ToBytes(session.symEncKey); const mac = base64ToBytes(session.symMacKey); @@ -613,6 +678,7 @@ export async function createCipher( username: await encryptTextValue(draft.loginUsername, enc, mac), password: await encryptTextValue(draft.loginPassword, enc, mac), totp: await encryptTextValue(draft.loginTotp, enc, mac), + fido2Credentials: normalizeFido2Credentials(draft.loginFido2Credentials), uris: await encryptUris(draft.loginUris || [], enc, mac), }; } else if (type === 3) { @@ -661,6 +727,9 @@ export async function createCipher( body: JSON.stringify(payload), }); if (!resp.ok) throw new Error('Create item failed'); + const body = await parseJson<{ id?: string }>(resp); + if (!body?.id) throw new Error('Create item failed'); + return { id: body.id }; } export async function updateCipher( @@ -693,10 +762,15 @@ export async function updateCipher( }; if (type === 1) { + const existingFido2 = + cipher.login && Array.isArray((cipher.login as any).fido2Credentials) + ? (cipher.login as any).fido2Credentials + : null; payload.login = { username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac), password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac), totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac), + fido2Credentials: normalizeFido2Credentials(existingFido2), uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac), }; } else if (type === 3) { diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index 4369fe2..6c51070 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -326,6 +326,8 @@ const messages: Record> = { txt_totp_is_enabled_for_this_account: "TOTP is enabled for this account.", txt_totp_secret: "TOTP Secret", txt_totp_verify_failed: "TOTP verify failed", + txt_passkey: "Passkey", + txt_passkey_created_at_value: "Created at {value}", txt_trash: "Trash", txt_trust_this_device_for_30_days: "Trust this device for 30 days", txt_trusted_until: "Trusted Until", @@ -728,6 +730,8 @@ const zhCNOverrides: Record = { }; zhCNOverrides.txt_lock = '\u9501\u5b9a'; +zhCNOverrides.txt_passkey = 'Passkey'; +zhCNOverrides.txt_passkey_created_at_value = '\u521b\u5efa\u4e8e {value}'; messages['zh-CN'] = { ...messages.en, ...zhCNOverrides }; diff --git a/webapp/src/lib/import-formats.ts b/webapp/src/lib/import-formats.ts new file mode 100644 index 0000000..4553ff8 --- /dev/null +++ b/webapp/src/lib/import-formats.ts @@ -0,0 +1,2549 @@ +import type { CiphersImportPayload } from '@/lib/api'; + +type ImportSourceEntry = { id: string; label: string }; + +export const IMPORT_SOURCES = [ + { id: 'bitwarden_json', label: 'Bitwarden (json)' }, + { id: 'bitwarden_csv', label: 'Bitwarden (csv)' }, + { id: 'onepassword_1pux', label: '1Password (1pux/json)' }, + { id: 'onepassword_1pif', label: '1Password (1pif)' }, + { id: 'onepassword_mac_csv', label: '1Password 6 and 7 Mac (csv)' }, + { id: 'onepassword_win_csv', label: '1Password 6 and 7 Windows (csv)' }, + { id: 'protonpass_json', label: 'ProtonPass (json/zip)' }, + { id: 'avira_csv', label: 'Avira (csv)' }, + { id: 'avast_csv', label: 'Avast Passwords (csv)' }, + { id: 'avast_json', label: 'Avast Passwords (json)' }, + { id: 'chrome', label: 'Chrome' }, + { id: 'edge', label: 'Edge' }, + { id: 'brave', label: 'Brave' }, + { id: 'opera', label: 'Opera' }, + { id: 'vivaldi', label: 'Vivaldi' }, + { id: 'firefox_csv', label: 'Firefox (csv)' }, + { id: 'safari_csv', label: 'Safari and macOS (csv)' }, + { id: 'lastpass', label: 'LastPass (csv)' }, + { id: 'dashlane_csv', label: 'Dashlane (csv)' }, + { id: 'dashlane_json', label: 'Dashlane (json)' }, + { id: 'keepass_xml', label: 'KeePass 2 (xml)' }, + { id: 'keepassx_csv', label: 'KeePassX (csv)' }, + { id: 'arc_csv', label: 'Arc (csv)' }, + { id: 'ascendo_csv', label: 'Ascendo DataVault (csv)' }, + { id: 'blackberry_csv', label: 'BlackBerry Password Keeper (csv)' }, + { id: 'blur_csv', label: 'Blur (csv)' }, + { id: 'buttercup_csv', label: 'Buttercup (csv)' }, + { id: 'codebook_csv', label: 'Codebook (csv)' }, + { id: 'encryptr_csv', label: 'Encryptr (csv)' }, + { id: 'enpass_csv', label: 'Enpass (csv)' }, + { id: 'enpass_json', label: 'Enpass (json)' }, + { id: 'keeper_csv', label: 'Keeper (csv)' }, + { id: 'keeper_json', label: 'Keeper (json)' }, + { id: 'logmeonce_csv', label: 'LogMeOnce (csv)' }, + { id: 'meldium_csv', label: 'Meldium (csv)' }, + { id: 'msecure_csv', label: 'mSecure (csv)' }, + { id: 'myki_csv', label: 'Myki (csv)' }, + { id: 'netwrix_csv', label: 'Netwrix Password Secure (csv)' }, + { id: 'nordpass_csv', label: 'NordPass (csv)' }, + { id: 'roboform_csv', label: 'RoboForm (csv)' }, + { id: 'zohovault_csv', label: 'Zoho Vault (csv)' }, + { id: 'passman_json', label: 'Passman (json)' }, + { id: 'passky_json', label: 'Passky (json)' }, + { id: 'psono_json', label: 'Psono (json)' }, + { id: 'passwordboss_json', label: 'Password Boss (json)' }, + ] as const satisfies readonly ImportSourceEntry[]; + +export type ImportSourceId = (typeof IMPORT_SOURCES)[number]['id']; + +export function getFileAcceptBySource(source: ImportSourceId): string { + if ( + source === 'bitwarden_json' || + source === 'onepassword_1pux' || + source === 'protonpass_json' || + source === 'avast_json' || + source === 'dashlane_json' || + source === 'enpass_json' || + source === 'keeper_json' || + source === 'passman_json' || + source === 'passky_json' || + source === 'psono_json' || + source === 'passwordboss_json' + ) { + if (source === 'onepassword_1pux') return '.1pux,.zip,.json,application/zip,application/json'; + if (source === 'protonpass_json') return '.zip,.json,application/zip,application/json'; + return '.json,application/json'; + } + if (source === 'onepassword_1pif') return '.1pif,.txt,.json,text/plain,application/json'; + if (source === 'keepass_xml') return '.xml,text/xml,application/xml'; + return '.csv,text/csv'; +} + +export interface BitwardenFolderInput { + id?: string | null; + name?: string | null; +} +export interface BitwardenUriInput { + uri?: string | null; + match?: number | null; +} +export interface BitwardenFieldInput { + name?: string | null; + value?: string | null; + type?: number | null; + linkedId?: number | null; +} +export interface BitwardenCipherInput { + type?: number | null; + name?: string | null; + notes?: string | null; + favorite?: boolean | null; + reprompt?: number | null; + key?: string | null; + folderId?: string | null; + login?: { + uris?: BitwardenUriInput[] | null; + username?: string | null; + password?: string | null; + totp?: string | null; + fido2Credentials?: Array> | null; + } | null; + card?: Record | null; + identity?: Record | null; + secureNote?: { type?: number | null } | null; + fields?: BitwardenFieldInput[] | null; + passwordHistory?: Array<{ password?: string | null; lastUsedDate?: string | null }> | null; + sshKey?: Record | null; +} +export interface BitwardenJsonInput { + encrypted?: boolean; + passwordProtected?: boolean; + encKeyValidation_DO_NOT_EDIT?: string; + collections?: Array<{ id?: string | null; name?: string | null }> | null; + folders?: BitwardenFolderInput[] | null; + items?: BitwardenCipherInput[] | null; +} + +type CsvRow = Record; + +function txt(v: unknown): string { + if (v === null || v === undefined) return ''; + return String(v).trim(); +} + +function val(v: unknown, fallback: string | null = null): string | null { + const s = txt(v); + return s ? s : fallback; +} + +function normalizeUri(raw: string): string | null { + const s = txt(raw); + if (!s) return null; + if (!s.includes('://') && s.includes('.')) return (`http://${s}`).slice(0, 1000); + return s.slice(0, 1000); +} + +function nameFromUrl(raw: string): string | null { + const uri = normalizeUri(raw); + if (!uri) return null; + try { + const host = new URL(uri).hostname || ''; + if (!host) return null; + return host.startsWith('www.') ? host.slice(4) : host; + } catch { + return null; + } +} + +function convertToNoteIfNeeded(cipher: Record): void { + if (Number(cipher.type || 1) !== 1) return; + const login = cipher.login as Record | null; + const hasLoginData = + !!txt(login?.username) || + !!txt(login?.password) || + !!txt(login?.totp) || + (Array.isArray(login?.uris) && login!.uris.length > 0); + if (hasLoginData) return; + cipher.type = 2; + cipher.login = null; + cipher.secureNote = { type: 0 }; +} + +function splitFullName(fullName: string | null): { firstName: string | null; middleName: string | null; lastName: string | null } { + const parts = txt(fullName).split(/\s+/).filter(Boolean); + return { + firstName: parts[0] || null, + middleName: parts.length > 2 ? parts.slice(1, -1).join(' ') : null, + lastName: parts.length > 1 ? parts[parts.length - 1] : null, + }; +} + +function parseEpochMaybe(epoch: unknown): string | null { + const n = Number(epoch); + if (!Number.isFinite(n) || n <= 0) return null; + const ms = n >= 1_000_000_000_000 ? n : n * 1000; + const d = new Date(ms); + if (Number.isNaN(d.getTime())) return null; + return d.toISOString(); +} + +function parseCardExpiry(raw: string): { month: string | null; year: string | null } { + const s = txt(raw); + if (!s) return { month: null, year: null }; + const yyyymm = s.match(/^(\d{4})(\d{2})$/); + if (yyyymm) return { month: String(Number(yyyymm[2])), year: yyyymm[1] }; + const mmYYYY = s.match(/^(\d{1,2})\/(\d{4})$/); + if (mmYYYY) return { month: String(Number(mmYYYY[1])), year: mmYYYY[2] }; + const mmYY = s.match(/^(\d{1,2})\/(\d{2})$/); + if (mmYY) return { month: String(Number(mmYY[1])), year: `20${mmYY[2]}` }; + const dashed = s.match(/^(\d{4})-(\d{2})/); + if (dashed) return { month: String(Number(dashed[2])), year: dashed[1] }; + return { month: null, year: null }; +} + +function onePasswordTypeHints(typeName: string): 1 | 2 | 3 | 4 { + const t = txt(typeName).toLowerCase(); + if (t.includes('creditcard') || t.includes('credit card')) return 3; + if (t.includes('identity')) return 4; + if (t.includes('securenote') || t.includes('secure note')) return 2; + return 1; +} + +function onePasswordCategoryType(categoryUuid: string): 1 | 2 | 3 | 4 { + const c = txt(categoryUuid); + if (['002', '101'].includes(c)) return 3; + if (['004', '103', '104', '105', '106', '107', '108'].includes(c)) return 4; + if (['003', '100', '113'].includes(c)) return 2; + return 1; +} + +function parseCsv(raw: string): CsvRow[] { + const rows: string[][] = []; + let cell = ''; + let row: string[] = []; + let inQuotes = false; + for (let i = 0; i < raw.length; i++) { + const ch = raw[i]; + if (inQuotes) { + if (ch === '"') { + if (raw[i + 1] === '"') { + cell += '"'; + i++; + } else inQuotes = false; + } else cell += ch; + continue; + } + if (ch === '"') { + inQuotes = true; + continue; + } + if (ch === ',') { + row.push(cell); + cell = ''; + continue; + } + if (ch === '\n') { + row.push(cell); + rows.push(row); + row = []; + cell = ''; + continue; + } + if (ch === '\r') continue; + cell += ch; + } + row.push(cell); + rows.push(row); + const nonEmpty = rows.filter((r) => r.some((c) => txt(c))); + if (!nonEmpty.length) return []; + const headers = nonEmpty[0].map((h) => txt(h)); + const out: CsvRow[] = []; + for (let i = 1; i < nonEmpty.length; i++) { + const values = nonEmpty[i]; + const obj: CsvRow = {}; + for (let c = 0; c < headers.length; c++) { + if (headers[c]) obj[headers[c]] = values[c] ?? ''; + } + out.push(obj); + } + return out; +} + +function parseCsvRows(raw: string): string[][] { + const rows: string[][] = []; + let cell = ''; + let row: string[] = []; + let inQuotes = false; + for (let i = 0; i < raw.length; i++) { + const ch = raw[i]; + if (inQuotes) { + if (ch === '"') { + if (raw[i + 1] === '"') { + cell += '"'; + i++; + } else inQuotes = false; + } else cell += ch; + continue; + } + if (ch === '"') { + inQuotes = true; + continue; + } + if (ch === ',') { + row.push(cell); + cell = ''; + continue; + } + if (ch === '\n') { + row.push(cell); + rows.push(row); + row = []; + cell = ''; + continue; + } + if (ch === '\r') continue; + cell += ch; + } + row.push(cell); + rows.push(row); + return rows.filter((r) => r.some((c) => txt(c))); +} + +function processKvp(cipher: Record, key: string, value: string, hidden = false): void { + const k = txt(key); + const v = txt(value); + if (!v) return; + const fields = Array.isArray(cipher.fields) ? (cipher.fields as Array>) : []; + if (v.length > 200 || /\r\n|\r|\n/.test(v)) { + const existing = txt(cipher.notes); + cipher.notes = `${existing}${existing ? '\n' : ''}${k ? `${k}: ` : ''}${v}`; + return; + } + fields.push({ type: hidden ? 1 : 0, name: k, value: v, linkedId: null }); + cipher.fields = fields; +} + +function makeLoginCipher(): Record { + return { + type: 1, + name: '--', + notes: null, + favorite: false, + reprompt: 0, + key: null, + login: { username: null, password: null, totp: null, fido2Credentials: null, uris: null }, + card: null, + identity: null, + secureNote: null, + fields: [], + passwordHistory: null, + sshKey: null, + }; +} + +function addFolder(result: CiphersImportPayload, folderName: string, cipherIndex: number): void { + const name = txt(folderName).replace(/\\/g, '/'); + if (!name || name === '(none)') return; + let i = result.folders.findIndex((f) => f.name === name); + if (i < 0) { + i = result.folders.length; + result.folders.push({ name }); + } + result.folderRelationships.push({ key: cipherIndex, value: i }); +} + +function parseChromeCsv(textRaw: string): CiphersImportPayload { + const rows = parseCsv(textRaw); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + for (const row of rows) { + const cipher = makeLoginCipher(); + const m = txt(row.url).match(/^android:\/\/.*@([^/]+)\//); + const uri = m ? `androidapp://${m[1]}` : normalizeUri(row.url || ''); + cipher.name = val(row.name, m?.[1] || '--'); + const login = cipher.login as Record; + login.username = val(row.username); + login.password = val(row.password); + login.uris = uri ? [{ uri, match: null }] : null; + cipher.notes = val(row.note); + result.ciphers.push(cipher); + } + return result; +} + +function parseFirefoxCsv(textRaw: string): CiphersImportPayload { + const rows = parseCsv(textRaw).filter((r) => txt(r.url) !== 'chrome://FirefoxAccounts'); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + for (const row of rows) { + const cipher = makeLoginCipher(); + const raw = val(row.url, val(row.hostname, '') || '') || ''; + let name: string | null = null; + try { + const host = new URL(normalizeUri(raw) || '').hostname || ''; + name = host.startsWith('www.') ? host.slice(4) : host || null; + } catch {} + cipher.name = val(name, '--'); + const login = cipher.login as Record; + login.username = val(row.username); + login.password = val(row.password); + const uri = normalizeUri(raw); + login.uris = uri ? [{ uri, match: null }] : null; + result.ciphers.push(cipher); + } + return result; +} + +function parseSafariCsv(textRaw: string): CiphersImportPayload { + const rows = parseCsv(textRaw); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + for (const row of rows) { + const cipher = makeLoginCipher(); + cipher.name = val(row.Title, '--'); + const login = cipher.login as Record; + login.username = val(row.Username); + login.password = val(row.Password); + const uri = normalizeUri(row.Url || row.URL || ''); + login.uris = uri ? [{ uri, match: null }] : null; + login.totp = val(row.OTPAuth); + cipher.notes = val(row.Notes); + result.ciphers.push(cipher); + } + return result; +} + +function parseBitwardenCsv(textRaw: string): CiphersImportPayload { + const rows = parseCsv(textRaw); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + for (const row of rows) { + const type = txt(row.type).toLowerCase() || 'login'; + if (type === 'note') { + const idx = result.ciphers.push({ + type: 2, + name: val(row.name, '--'), + notes: val(row.notes), + favorite: txt(row.favorite) === '1', + reprompt: 0, + key: null, + login: null, + card: null, + identity: null, + secureNote: { type: 0 }, + fields: null, + passwordHistory: null, + sshKey: null, + }) - 1; + addFolder(result, row.folder, idx); + continue; + } + const cipher = makeLoginCipher(); + cipher.name = val(row.name, '--'); + cipher.notes = val(row.notes); + cipher.favorite = txt(row.favorite) === '1'; + const login = cipher.login as Record; + login.username = val(row.login_username); + login.password = val(row.login_password); + login.totp = val(row.login_totp); + const uri = normalizeUri(row.login_uri || ''); + login.uris = uri ? [{ uri, match: null }] : null; + const idx = result.ciphers.push(cipher) - 1; + addFolder(result, row.folder, idx); + } + return result; +} + +function parseAviraCsv(textRaw: string): CiphersImportPayload { + const rows = parseCsv(textRaw); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + for (const row of rows) { + const cipher = makeLoginCipher(); + cipher.name = val(row.name, val(nameFromUrl(row.website), '--')); + const login = cipher.login as Record; + login.uris = normalizeUri(row.website || '') ? [{ uri: normalizeUri(row.website || ''), match: null }] : null; + login.password = val(row.password); + if (!txt(row.username) && txt(row.secondary_username)) { + login.username = val(row.secondary_username); + } else { + login.username = val(row.username); + cipher.notes = val(row.secondary_username); + } + result.ciphers.push(cipher); + } + return result; +} + +function parseAvastCsv(textRaw: string): CiphersImportPayload { + const rows = parseCsv(textRaw); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + for (const row of rows) { + const cipher = makeLoginCipher(); + cipher.name = val(row.name, '--'); + const login = cipher.login as Record; + login.uris = normalizeUri(row.web || '') ? [{ uri: normalizeUri(row.web || ''), match: null }] : null; + login.password = val(row.password); + login.username = val(row.login); + result.ciphers.push(cipher); + } + return result; +} + +function parseAvastJson(textRaw: string): CiphersImportPayload { + const parsed = JSON.parse(textRaw) as { logins?: any[]; notes?: any[]; cards?: any[] }; + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + for (const value of parsed.logins || []) { + const cipher = makeLoginCipher(); + cipher.name = val(value?.custName, '--'); + cipher.notes = val(value?.note); + const login = cipher.login as Record; + const uri = normalizeUri(value?.url || ''); + login.uris = uri ? [{ uri, match: null }] : null; + login.password = val(value?.pwd); + login.username = val(value?.loginName); + result.ciphers.push(cipher); + } + for (const value of parsed.notes || []) { + result.ciphers.push({ + type: 2, + name: val(value?.label, '--'), + notes: val(value?.text), + favorite: false, + reprompt: 0, + key: null, + login: null, + card: null, + identity: null, + secureNote: { type: 0 }, + fields: null, + passwordHistory: null, + sshKey: null, + }); + } + for (const value of parsed.cards || []) { + result.ciphers.push({ + type: 3, + name: val(value?.custName, '--'), + notes: val(value?.note), + favorite: false, + reprompt: 0, + key: null, + login: null, + card: { + cardholderName: val(value?.holderName), + number: val(value?.cardNumber), + code: val(value?.cvv), + brand: cardBrand(val(value?.cardNumber)), + expMonth: val(value?.expirationDate?.month), + expYear: val(value?.expirationDate?.year), + }, + identity: null, + secureNote: null, + fields: null, + passwordHistory: null, + sshKey: null, + }); + } + return result; +} + +function parseArcCsv(textRaw: string): CiphersImportPayload { + const rows = parseCsv(textRaw); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + for (const row of rows) { + const cipher = makeLoginCipher(); + cipher.name = val(nameFromUrl(row.url), '--'); + const login = cipher.login as Record; + login.username = val(row.username); + login.password = val(row.password); + const uri = normalizeUri(row.url || ''); + login.uris = uri ? [{ uri, match: null }] : null; + cipher.notes = val(row.note); + result.ciphers.push(cipher); + } + return result; +} + +function parseAscendoCsv(textRaw: string): CiphersImportPayload { + const rows = parseCsvRows(textRaw); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + for (const row of rows) { + if (row.length < 2) continue; + const cipher = makeLoginCipher(); + cipher.name = val(row[0], '--'); + cipher.notes = val(row[row.length - 1]); + if (row.length > 2 && row.length % 2 === 0) { + for (let i = 0; i < row.length - 2; i += 2) { + const field = txt(row[i + 1]); + const fieldValue = txt(row[i + 2]); + if (!field || !fieldValue) continue; + const low = field.toLowerCase(); + const login = cipher.login as Record; + if (!txt(login.password) && ['password', 'pass', 'passwd'].includes(low)) login.password = fieldValue; + else if (!txt(login.username) && ['username', 'user', 'email', 'login', 'id'].includes(low)) login.username = fieldValue; + else if ((!Array.isArray(login.uris) || !login.uris.length) && ['url', 'uri', 'website', 'web site', 'host', 'hostname'].includes(low)) { + const uri = normalizeUri(fieldValue); + login.uris = uri ? [{ uri, match: null }] : null; + } else processKvp(cipher, field, fieldValue, false); + } + } + convertToNoteIfNeeded(cipher); + result.ciphers.push(cipher); + } + return result; +} + +function parseBlackberryCsv(textRaw: string): CiphersImportPayload { + const rows = parseCsv(textRaw); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + for (const row of rows) { + if (txt(row.grouping) === 'list') continue; + const cipher = makeLoginCipher(); + cipher.favorite = txt(row.fav) === '1'; + cipher.name = val(row.name, '--'); + cipher.notes = val(row.extra); + if (txt(row.grouping) !== 'note') { + const login = cipher.login as Record; + const uri = normalizeUri(row.url || ''); + login.uris = uri ? [{ uri, match: null }] : null; + login.password = val(row.password); + login.username = val(row.username); + } + convertToNoteIfNeeded(cipher); + result.ciphers.push(cipher); + } + return result; +} + +function parseBlurCsv(textRaw: string): CiphersImportPayload { + const rows = parseCsv(textRaw); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + for (const row of rows) { + const label = txt(row.label) === 'null' ? '' : txt(row.label); + const cipher = makeLoginCipher(); + cipher.name = val(label, val(nameFromUrl(row.domain), '--')); + const login = cipher.login as Record; + const uri = normalizeUri(row.domain || ''); + login.uris = uri ? [{ uri, match: null }] : null; + login.password = val(row.password); + if (!txt(row.email) && txt(row.username)) login.username = val(row.username); + else { + login.username = val(row.email); + cipher.notes = val(row.username); + } + result.ciphers.push(cipher); + } + return result; +} + +function parseButtercupCsv(textRaw: string): CiphersImportPayload { + const rows = parseCsv(textRaw); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + const official = new Set(['!group_id', '!group_name', '!type', 'title', 'username', 'password', 'url', 'note', 'id']); + for (const row of rows) { + const cipher = makeLoginCipher(); + cipher.name = val(row.title, '--'); + const login = cipher.login as Record; + login.username = val(row.username); + login.password = val(row.password); + const uri = normalizeUri(row.URL || row.url || row.Url || ''); + login.uris = uri ? [{ uri, match: null }] : null; + cipher.notes = val(row.note || row.Note || row.notes || row.Notes); + + for (const key of Object.keys(row)) { + if (official.has(key.toLowerCase())) continue; + processKvp(cipher, key, row[key], false); + } + const idx = result.ciphers.push(cipher) - 1; + addFolder(result, row['!group_name'], idx); + } + return result; +} + +function parseCodebookCsv(textRaw: string): CiphersImportPayload { + const rows = parseCsv(textRaw); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + for (const row of rows) { + const cipher = makeLoginCipher(); + cipher.favorite = txt(row.Favorite).toLowerCase() === 'true'; + cipher.name = val(row.Entry, '--'); + cipher.notes = val(row.Note); + const login = cipher.login as Record; + login.username = val(row.Username, val(row.Email)); + login.password = val(row.Password); + login.totp = val(row.TOTP); + const uri = normalizeUri(row.Website || ''); + login.uris = uri ? [{ uri, match: null }] : null; + if (txt(row.Username)) processKvp(cipher, 'Email', row.Email || '', false); + processKvp(cipher, 'Phone', row.Phone || '', false); + processKvp(cipher, 'PIN', row.PIN || '', false); + processKvp(cipher, 'Account', row.Account || '', false); + processKvp(cipher, 'Date', row.Date || '', false); + convertToNoteIfNeeded(cipher); + const idx = result.ciphers.push(cipher) - 1; + addFolder(result, row.Category, idx); + } + return result; +} + +function parseEncryptrCsv(textRaw: string): CiphersImportPayload { + const rows = parseCsv(textRaw); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + for (const row of rows) { + const cipher = makeLoginCipher(); + cipher.name = val(row.Label, '--'); + cipher.notes = val(row.Notes); + const text = val(row.Text); + if (text) cipher.notes = txt(cipher.notes) ? `${txt(cipher.notes)}\n\n${text}` : text; + const type = txt(row['Entry Type']); + if (type === 'Password') { + const login = cipher.login as Record; + login.username = val(row.Username); + login.password = val(row.Password); + const uri = normalizeUri(row['Site URL'] || ''); + login.uris = uri ? [{ uri, match: null }] : null; + } else if (type === 'Credit Card') { + const expiry = txt(row.Expiry); + let expMonth: string | null = null; + let expYear: string | null = null; + const parts = expiry.split('/'); + if (parts.length > 1) { + expMonth = txt(parts[0]); + const y = txt(parts[1]); + expYear = y.length === 2 ? `20${y}` : y || null; + } + cipher.type = 3; + cipher.login = null; + cipher.card = { + cardholderName: val(row['Name on card']), + number: val(row['Card Number']), + brand: cardBrand(val(row['Card Number'])), + code: val(row.CVV), + expMonth, + expYear, + }; + } + convertToNoteIfNeeded(cipher); + result.ciphers.push(cipher); + } + return result; +} + +function parseKeePassXCsv(textRaw: string): CiphersImportPayload { + const rows = parseCsv(textRaw); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + for (const row of rows) { + if (!txt(row.Title)) continue; + const cipher = makeLoginCipher(); + cipher.notes = val(row.Notes); + cipher.name = val(row.Title, '--'); + const login = cipher.login as Record; + login.username = val(row.Username); + login.password = val(row.Password); + login.totp = val(row.TOTP); + const uri = normalizeUri(row.URL || ''); + login.uris = uri ? [{ uri, match: null }] : null; + const idx = result.ciphers.push(cipher) - 1; + addFolder(result, txt(row.Group).replace(/^Root\//, ''), idx); + } + return result; +} + +function parseLastPassCsv(textRaw: string): CiphersImportPayload { + const rows = parseCsv(textRaw); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + for (const row of rows) { + const isSecureNote = txt(row.url) === 'http://sn'; + if (isSecureNote) { + const idx = result.ciphers.push({ + type: 2, + name: val(row.name, '--'), + notes: val(row.extra), + favorite: txt(row.fav) === '1', + reprompt: 0, + key: null, + login: null, + card: null, + identity: null, + secureNote: { type: 0 }, + fields: null, + passwordHistory: null, + sshKey: null, + }) - 1; + addFolder(result, txt(row.grouping).replace(/[\x00-\x1F\x7F-\x9F]/g, ''), idx); + continue; + } + const cipher = makeLoginCipher(); + cipher.name = val(row.name, '--'); + cipher.favorite = txt(row.fav) === '1'; + cipher.notes = val(row.extra); + const login = cipher.login as Record; + login.username = val(row.username); + login.password = val(row.password); + login.totp = val(row.totp); + const uri = normalizeUri(row.url || ''); + login.uris = uri ? [{ uri, match: null }] : null; + const idx = result.ciphers.push(cipher) - 1; + addFolder(result, txt(row.grouping).replace(/[\x00-\x1F\x7F-\x9F]/g, ''), idx); + } + return result; +} + +function parseDashlaneCsv(textRaw: string): CiphersImportPayload { + const rows = parseCsv(textRaw); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + for (const row of rows) { + const keys = Object.keys(row); + if (keys[0] === 'username') { + const cipher = makeLoginCipher(); + cipher.name = val(row.title, '--'); + const login = cipher.login as Record; + login.username = val(row.username); + login.password = val(row.password); + login.totp = val(row.otpUrl || row.otpSecret); + const uri = normalizeUri(row.url || ''); + login.uris = uri ? [{ uri, match: null }] : null; + cipher.notes = val(row.note); + const idx = result.ciphers.push(cipher) - 1; + addFolder(result, row.category, idx); + continue; + } + if (keys[0] === 'title' && keys[1] === 'note') { + result.ciphers.push({ + type: 2, + name: val(row.title, '--'), + notes: val(row.note), + favorite: false, + reprompt: 0, + key: null, + login: null, + card: null, + identity: null, + secureNote: { type: 0 }, + fields: null, + passwordHistory: null, + sshKey: null, + }); + } + } + return result; +} + +function parseDashlaneJson(textRaw: string): CiphersImportPayload { + const data = JSON.parse(textRaw) as Record; + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + const auth = data.AUTHENTIFIANT; + if (Array.isArray(auth)) { + for (const item of auth) { + if (!item || typeof item !== 'object') continue; + const row = item as Record; + const cipher = makeLoginCipher(); + cipher.name = val(row.title, '--'); + const login = cipher.login as Record; + login.username = val(row.login, val(row.secondaryLogin, val(row.email))); + login.password = val(row.password); + const uri = normalizeUri(String(row.domain ?? '')); + login.uris = uri ? [{ uri, match: null }] : null; + cipher.notes = val(row.note); + result.ciphers.push(cipher); + } + } + return result; +} + +function parseKeePassXml(textRaw: string): CiphersImportPayload { + const doc = new DOMParser().parseFromString(textRaw, 'application/xml'); + if (doc.querySelector('parsererror')) throw new Error('Invalid XML file'); + const rootGroup = doc.querySelector('KeePassFile > Root > Group'); + if (!rootGroup) throw new Error('Invalid KeePass XML structure'); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + + function qd(parent: Element, selector: string): Element[] { + return Array.from(parent.querySelectorAll(selector)).filter((x) => x.parentNode === parent); + } + + function ensureFolder(path: string): number { + let i = result.folders.findIndex((f) => f.name === path); + if (i < 0) { + i = result.folders.length; + result.folders.push({ name: path }); + } + return i; + } + + function walk(group: Element, isRoot: boolean, prefix: string): void { + let current = prefix; + let folder = -1; + if (!isRoot) { + const name = txt(qd(group, 'Name')[0]?.textContent) || '-'; + current = current ? `${current}/${name}` : name; + folder = ensureFolder(current); + } + for (const entry of qd(group, 'Entry')) { + const cipher = makeLoginCipher(); + for (const s of qd(entry, 'String')) { + const key = txt(qd(s, 'Key')[0]?.textContent); + const value = txt(qd(s, 'Value')[0]?.textContent); + if (!value) continue; + const login = cipher.login as Record; + if (key === 'Title') cipher.name = value; + else if (key === 'UserName') login.username = value; + else if (key === 'Password') login.password = value; + else if (key === 'URL') { + const uri = normalizeUri(value); + login.uris = uri ? [{ uri, match: null }] : null; + } else if (key === 'otp') login.totp = value.replace('key=', ''); + else if (key === 'Notes') cipher.notes = `${txt(cipher.notes)}${txt(cipher.notes) ? '\n' : ''}${value}`; + } + const idx = result.ciphers.push(cipher) - 1; + if (!isRoot && folder >= 0) result.folderRelationships.push({ key: idx, value: folder }); + } + for (const child of qd(group, 'Group')) walk(child, false, current); + } + + walk(rootGroup, true, ''); + return result; +} + +function cardBrand(number: string | null): string | null { + const n = txt(number).replace(/\s+/g, ''); + if (!n) return null; + if (/^4/.test(n)) return 'Visa'; + if (/^(5[1-5]|2[2-7])/.test(n)) return 'Mastercard'; + if (/^3[47]/.test(n)) return 'Amex'; + if (/^6(?:011|5)/.test(n)) return 'Discover'; + return null; +} + +function parseEnpassCsv(textRaw: string): CiphersImportPayload { + const rows = parseCsvRows(textRaw); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + let first = true; + for (const r of rows) { + if (r.length < 2 || (first && (r[0] === 'Title' || r[0] === 'title'))) { + first = false; + continue; + } + const cipher = makeLoginCipher(); + cipher.name = val(r[0], '--'); + cipher.notes = val(r[r.length - 1]); + const hasLoginHints = r.some((x) => ['username', 'password', 'email', 'url'].includes(txt(x).toLowerCase())); + const hasCardHints = r.some((x) => ['cardholder', 'number', 'expiry date'].includes(txt(x).toLowerCase())); + if (r.length === 2 || !hasLoginHints) { + cipher.type = 2; + cipher.login = null; + cipher.secureNote = { type: 0 }; + } + if (hasCardHints) { + cipher.type = 3; + cipher.login = null; + cipher.card = { cardholderName: null, number: null, brand: null, expMonth: null, expYear: null, code: null }; + } + if (r.length > 2 && r.length % 2 === 0) { + for (let i = 0; i < r.length - 2; i += 2) { + const fieldName = txt(r[i + 1]); + const fieldValue = txt(r[i + 2]); + if (!fieldValue) continue; + const low = fieldName.toLowerCase(); + if (cipher.type === 1) { + const login = cipher.login as Record; + if (low === 'url' && !Array.isArray(login.uris)) { + const uri = normalizeUri(fieldValue); + login.uris = uri ? [{ uri, match: null }] : null; + continue; + } + if ((low === 'username' || low === 'email') && !txt(login.username)) { + login.username = fieldValue; + continue; + } + if (low === 'password' && !txt(login.password)) { + login.password = fieldValue; + continue; + } + if (low === 'totp' && !txt(login.totp)) { + login.totp = fieldValue; + continue; + } + } else if (cipher.type === 3 && cipher.card) { + const card = cipher.card as Record; + if (low === 'cardholder' && !txt(card.cardholderName)) { + card.cardholderName = fieldValue; + continue; + } + if (low === 'number' && !txt(card.number)) { + card.number = fieldValue; + card.brand = cardBrand(fieldValue); + continue; + } + if (low === 'cvc' && !txt(card.code)) { + card.code = fieldValue; + continue; + } + if (low === 'expiry date' && !txt(card.expMonth) && !txt(card.expYear)) { + const m = fieldValue.match(/^0?([1-9]|1[0-2])\/((?:[1-2][0-9])?[0-9]{2})$/); + if (m) { + card.expMonth = m[1]; + card.expYear = m[2].length === 2 ? `20${m[2]}` : m[2]; + continue; + } + } + if (low === 'type') continue; + } + processKvp(cipher, fieldName, fieldValue, false); + } + } + result.ciphers.push(cipher); + } + return result; +} + +function parseEnpassJson(textRaw: string): CiphersImportPayload { + const parsed = JSON.parse(textRaw) as { folders?: any[]; items?: any[] }; + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + const folderTitleById = new Map(); + for (const f of parsed.folders || []) { + if (f?.uuid && f?.title) folderTitleById.set(String(f.uuid), String(f.title).trim()); + } + + for (const item of parsed.items || []) { + const cipher = makeLoginCipher(); + cipher.name = val(item?.title, '--'); + cipher.favorite = Number(item?.favorite || 0) > 0; + cipher.notes = val(item?.note); + const templateType = txt(item?.template_type); + const fields = Array.isArray(item?.fields) ? item.fields : []; + + if (templateType.startsWith('creditcard.')) { + cipher.type = 3; + cipher.login = null; + const card: Record = { + cardholderName: null, + number: null, + code: null, + expMonth: null, + expYear: null, + brand: null, + }; + for (const field of fields) { + const t = txt(field?.type); + const v = txt(field?.value); + if (!v || t === 'section' || t === 'ccType') continue; + if (t === 'ccName' && !txt(card.cardholderName)) card.cardholderName = v; + else if (t === 'ccNumber' && !txt(card.number)) { + card.number = v; + card.brand = cardBrand(v); + } else if (t === 'ccCvc' && !txt(card.code)) card.code = v; + else if (t === 'ccExpiry' && !txt(card.expYear)) { + const m = v.match(/^0?([1-9]|1[0-2])\/((?:[1-2][0-9])?[0-9]{2})$/); + if (m) { + card.expMonth = m[1]; + card.expYear = m[2].length === 2 ? `20${m[2]}` : m[2]; + } else { + processKvp(cipher, txt(field?.label), v, Number(field?.sensitive || 0) === 1); + } + } else { + processKvp(cipher, txt(field?.label), v, Number(field?.sensitive || 0) === 1); + } + } + cipher.card = card; + } else if (templateType.startsWith('login.') || templateType.startsWith('password.') || fields.some((f: any) => txt(f?.type) === 'password' && txt(f?.value))) { + const login = cipher.login as Record; + const urls: string[] = []; + for (const field of fields) { + const t = txt(field?.type); + const v = txt(field?.value); + if (!v || t === 'section') continue; + if ((t === 'username' || t === 'email') && !txt(login.username)) login.username = v; + else if (t === 'password' && !txt(login.password)) login.password = v; + else if (t === 'totp' && !txt(login.totp)) login.totp = v; + else if (t === 'url') { + const n = normalizeUri(v); + if (n) urls.push(n); + } else if (t === '.Android#') { + let cleaned = v.startsWith('androidapp://') ? v : `androidapp://${v}`; + cleaned = cleaned.replace('android://', '').replace(/androidapp:\/\/.*==@/g, 'androidapp://'); + const n = normalizeUri(cleaned) || cleaned; + urls.push(n); + } else { + processKvp(cipher, txt(field?.label), v, Number(field?.sensitive || 0) === 1); + } + } + login.uris = urls.length ? urls.map((u) => ({ uri: u, match: null })) : null; + } else { + cipher.type = 2; + cipher.login = null; + cipher.secureNote = { type: 0 }; + for (const field of fields) { + const v = txt(field?.value); + if (!v || txt(field?.type) === 'section') continue; + processKvp(cipher, txt(field?.label), v, Number(field?.sensitive || 0) === 1); + } + } + + const idx = result.ciphers.push(cipher) - 1; + const folderId = Array.isArray(item?.folders) && item.folders.length ? String(item.folders[0]) : ''; + if (folderId && folderTitleById.has(folderId)) addFolder(result, folderTitleById.get(folderId) || '', idx); + } + return result; +} + +function parseKeeperCsv(textRaw: string): CiphersImportPayload { + const rows = parseCsvRows(textRaw); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + for (const row of rows) { + if (row.length < 6) continue; + const cipher = makeLoginCipher(); + cipher.name = val(row[1], '--'); + const login = cipher.login as Record; + login.username = val(row[2]); + login.password = val(row[3]); + const uri = normalizeUri(row[4] || ''); + login.uris = uri ? [{ uri, match: null }] : null; + cipher.notes = val(row[5]); + if (row.length > 7) { + for (let i = 7; i < row.length; i += 2) { + const k = txt(row[i]); + const v = txt(row[i + 1]); + if (!k) continue; + if (k === 'TFC:Keeper') (cipher.login as Record).totp = val(v); + else processKvp(cipher, k, v, false); + } + } + const idx = result.ciphers.push(cipher) - 1; + addFolder(result, row[0], idx); + } + return result; +} + +function parseKeeperJson(textRaw: string): CiphersImportPayload { + const parsed = JSON.parse(textRaw) as { records?: any[] }; + const records = Array.isArray(parsed.records) ? parsed.records : []; + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + for (const record of records) { + const cipher = makeLoginCipher(); + cipher.name = val(record.title, '--'); + const login = cipher.login as Record; + login.username = val(record.login); + login.password = val(record.password); + const uri = normalizeUri(record.login_url || ''); + login.uris = uri ? [{ uri, match: null }] : null; + cipher.notes = val(record.notes); + const cf = record.custom_fields || {}; + if (cf['TFC:Keeper']) login.totp = val(cf['TFC:Keeper']); + for (const key of Object.keys(cf)) { + if (key === 'TFC:Keeper') continue; + processKvp(cipher, key, String(cf[key] ?? ''), false); + } + if (Array.isArray(record.folders)) { + const idx = result.ciphers.push(cipher) - 1; + for (const f of record.folders) { + const folderName = f?.folder || f?.shared_folder; + if (folderName) addFolder(result, String(folderName), idx); + } + } else { + result.ciphers.push(cipher); + } + } + return result; +} + +function parseLogMeOnceCsv(textRaw: string): CiphersImportPayload { + const rows = parseCsvRows(textRaw); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + for (const row of rows) { + if (row.length < 4) continue; + const cipher = makeLoginCipher(); + cipher.name = val(row[0], '--'); + const login = cipher.login as Record; + login.username = val(row[2]); + login.password = val(row[3]); + const uri = normalizeUri(row[1] || ''); + login.uris = uri ? [{ uri, match: null }] : null; + result.ciphers.push(cipher); + } + return result; +} + +function parseMeldiumCsv(textRaw: string): CiphersImportPayload { + const rows = parseCsv(textRaw); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + for (const row of rows) { + const cipher = makeLoginCipher(); + cipher.name = val(row.DisplayName, '--'); + cipher.notes = val(row.Notes); + const login = cipher.login as Record; + login.username = val(row.UserName); + login.password = val(row.Password); + const uri = normalizeUri(row.Url || ''); + login.uris = uri ? [{ uri, match: null }] : null; + result.ciphers.push(cipher); + } + return result; +} + +function splitPipedField(raw: string): string { + const s = txt(raw); + if (!s) return ''; + const p = s.split('|'); + if (p.length <= 2) return s; + return [...p.slice(0, 2), p.slice(2).join('|')].pop() || ''; +} + +function parseMSecureCsv(textRaw: string): CiphersImportPayload { + const rows = parseCsvRows(textRaw); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + for (const row of rows) { + if (row.length < 3) continue; + const folderName = txt(row[2]) && txt(row[2]) !== 'Unassigned' ? row[2] : ''; + const type = txt(row[1]); + const cipher = makeLoginCipher(); + cipher.name = val(txt(row[0]).split('|')[0], '--'); + + if (type === 'Web Logins' || type === 'Login') { + const login = cipher.login as Record; + login.username = val(splitPipedField(row[5] || '')); + login.password = val(splitPipedField(row[6] || '')); + const uri = normalizeUri(splitPipedField(row[4] || '') || ''); + login.uris = uri ? [{ uri, match: null }] : null; + cipher.notes = val((row[3] || '').split('\\n').join('\n')); + } else if (type === 'Credit Card') { + cipher.type = 3; + cipher.login = null; + const cardNumber = val(splitPipedField(row[4] || '')); + let expMonth: string | null = null; + let expYear: string | null = null; + const exp = splitPipedField(row[5] || ''); + const m = exp.match(/^(\d{1,2})\s*\/\s*(\d{2,4})$/); + if (m) { + expMonth = m[1]; + expYear = m[2].length === 2 ? `20${m[2]}` : m[2]; + } + let code: string | null = null; + let holder: string | null = null; + for (const entry of row) { + if (/^Security Code\|\d*\|/.test(entry)) code = val(splitPipedField(entry)); + if (/^Name on Card\|\d*\|/.test(entry)) holder = val(splitPipedField(entry)); + } + const noteRegex = /\|\d*\|/; + const rawNotes = row.slice(2).filter((entry) => txt(entry) && !noteRegex.test(entry)); + const indexedNotes = [8, 10, 11] + .filter((idx) => row[idx] && noteRegex.test(row[idx])) + .map((idx) => `${txt(row[idx]).split('|')[0]}: ${splitPipedField(row[idx])}`); + cipher.notes = [...rawNotes, ...indexedNotes].join('\n') || null; + cipher.card = { + number: cardNumber, + cardholderName: holder, + code, + expMonth, + expYear, + brand: cardBrand(cardNumber), + }; + } else if (row.length > 3) { + cipher.type = 2; + cipher.login = null; + cipher.secureNote = { type: 0 }; + const noteLines: string[] = []; + for (let i = 3; i < row.length; i++) { + if (txt(row[i])) noteLines.push(row[i]); + } + cipher.notes = noteLines.join('\n') || null; + } + + if (txt(type) && Number(cipher.type) !== 1 && Number(cipher.type) !== 3) { + cipher.name = `${type}: ${txt(cipher.name)}`; + } + const idx = result.ciphers.push(cipher) - 1; + addFolder(result, folderName, idx); + } + return result; +} + +function parseMykiCsv(textRaw: string): CiphersImportPayload { + const rows = parseCsv(textRaw); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + const mappedBase = new Set(['nickname', 'additionalInfo']); + function unmapped(cipher: Record, row: CsvRow, mapped: Set): void { + for (const key of Object.keys(row)) { + if (mapped.has(key)) continue; + processKvp(cipher, key, row[key], false); + } + } + + for (const row of rows) { + const cipher = makeLoginCipher(); + cipher.name = val(row.nickname, '--'); + cipher.notes = val(txt(row.additionalInfo).replace(/\s+$/g, '')); + + if (row.url !== undefined) { + const mapped = new Set([...mappedBase, 'url', 'username', 'password', 'twofaSecret']); + const login = cipher.login as Record; + const uri = normalizeUri(row.url || ''); + login.uris = uri ? [{ uri, match: null }] : null; + login.username = val(row.username); + login.password = val(row.password); + login.totp = val(row.twofaSecret); + unmapped(cipher, row, mapped); + } else if (row.authToken !== undefined) { + const mapped = new Set([...mappedBase, 'authToken']); + (cipher.login as Record).totp = val(row.authToken); + unmapped(cipher, row, mapped); + } else if (row.cardNumber !== undefined) { + const mapped = new Set([...mappedBase, 'cardNumber', 'cardName', 'exp_month', 'exp_year', 'cvv']); + cipher.type = 3; + cipher.login = null; + cipher.card = { + cardholderName: val(row.cardName), + number: val(row.cardNumber), + brand: cardBrand(val(row.cardNumber)), + expMonth: val(row.exp_month), + expYear: val(row.exp_year), + code: val(row.cvv), + }; + unmapped(cipher, row, mapped); + } else if (row.firstName !== undefined) { + const mapped = new Set([ + ...mappedBase, + 'title', + 'firstName', + 'middleName', + 'lastName', + 'email', + 'firstAddressLine', + 'secondAddressLine', + 'city', + 'country', + 'zipCode', + ]); + cipher.type = 4; + cipher.login = null; + cipher.identity = { + title: val(row.title), + firstName: val(row.firstName), + middleName: val(row.middleName), + lastName: val(row.lastName), + phone: val((row as Record).number), + email: val(row.email), + address1: val(row.firstAddressLine), + address2: val(row.secondAddressLine), + city: val(row.city), + country: val(row.country), + postalCode: val(row.zipCode), + }; + unmapped(cipher, row, mapped); + } else if (row.idType !== undefined) { + const mapped = new Set([...mappedBase, 'idName', 'idNumber', 'idCountry']); + const fullName = txt((row as Record).idName); + const parts = fullName.split(/\s+/).filter(Boolean); + const idType = txt((row as Record).idType); + const idNumber = val((row as Record).idNumber); + cipher.type = 4; + cipher.login = null; + cipher.identity = { + firstName: parts[0] || null, + middleName: parts.length >= 3 ? parts[1] : null, + lastName: parts.length >= 2 ? parts.slice(parts.length >= 3 ? 2 : 1).join(' ') : null, + country: val((row as Record).idCountry), + passportNumber: idType === 'Passport' ? idNumber : null, + ssn: idType === 'Social Security' ? idNumber : null, + licenseNumber: idType !== 'Passport' && idType !== 'Social Security' ? idNumber : null, + }; + unmapped(cipher, row, mapped); + } else if (row.content !== undefined) { + const mapped = new Set([...mappedBase, 'content']); + cipher.type = 2; + cipher.login = null; + cipher.secureNote = { type: 0 }; + cipher.notes = val(txt(row.content).replace(/\s+$/g, '')); + unmapped(cipher, row, mapped); + } else { + continue; + } + result.ciphers.push(cipher); + } + return result; +} + +function parseNetwrixCsv(textRaw: string): CiphersImportPayload { + const rows = parseCsv(textRaw); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + const mapped = new Set(['Organisationseinheit', 'Informationen', 'Beschreibung', 'Benutzername', 'Passwort', 'Internetseite', 'One-Time Passwort']); + for (const row of rows) { + const cipher = makeLoginCipher(); + cipher.notes = val(txt(row.Informationen).replace(/\s+$/g, '')); + cipher.name = val(row.Beschreibung, '--'); + const login = cipher.login as Record; + login.username = val(row.Benutzername); + login.password = val(row.Passwort); + login.totp = val((row as Record)['One-Time Passwort']); + const uri = normalizeUri(row.Internetseite || ''); + login.uris = uri ? [{ uri, match: null }] : null; + for (const key of Object.keys(row)) { + if (mapped.has(key)) continue; + processKvp(cipher, key, row[key], false); + } + const idx = result.ciphers.push(cipher) - 1; + addFolder(result, row.Organisationseinheit, idx); + } + return result; +} + +function parseRoboFormCsv(textRaw: string): CiphersImportPayload { + const rows = parseCsv(textRaw); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + for (const row of rows) { + const cipher = makeLoginCipher(); + const folder = txt(row.Folder).startsWith('/') ? txt(row.Folder).slice(1) : txt(row.Folder); + cipher.notes = val(row.Note); + cipher.name = val(row.Name, '--'); + const login = cipher.login as Record; + login.username = val(row.Login); + login.password = val(row.Pwd, val(row.Password)); + const uri = normalizeUri(row.Url || row.URL || ''); + login.uris = uri ? [{ uri, match: null }] : null; + if (txt(row.Rf_fields)) processKvp(cipher, 'Rf_fields', txt(row.Rf_fields), true); + if (txt(row.RfFieldsV2)) processKvp(cipher, 'RfFieldsV2', txt(row.RfFieldsV2), true); + + convertToNoteIfNeeded(cipher); + const idx = result.ciphers.push(cipher) - 1; + addFolder(result, folder, idx); + } + return result; +} + +function parseZohoVaultCsv(textRaw: string): CiphersImportPayload { + const rows = parseCsv(textRaw); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + for (const row of rows) { + if (!txt(row['Password Name']) && !txt(row['Secret Name'])) continue; + const cipher = makeLoginCipher(); + cipher.favorite = txt(row.Favorite) === '1'; + cipher.notes = val(row.Notes); + cipher.name = val(row['Password Name'], val(row['Secret Name'], '--')); + const login = cipher.login as Record; + const uri = normalizeUri(txt(row['Password URL']) || txt(row['Secret URL'])); + login.uris = uri ? [{ uri, match: null }] : null; + login.totp = val(row.login_totp); + + const parseData = (data: string) => { + if (!txt(data)) return; + for (const line of data.split(/\r?\n/)) { + const pos = line.indexOf(':'); + if (pos < 0) continue; + const key = txt(line.slice(0, pos)); + const value = txt(line.slice(pos + 1)); + if (!key || !value || key === 'SecretType') continue; + const low = key.toLowerCase(); + if (!txt(login.username) && ['username', 'user', 'email', 'login', 'id'].includes(low)) login.username = value; + else if (!txt(login.password) && ['password', 'pass', 'passwd'].includes(low)) login.password = value; + else processKvp(cipher, key, value, false); + } + }; + parseData(txt(row.SecretData)); + parseData(txt(row.CustomData)); + + convertToNoteIfNeeded(cipher); + const idx = result.ciphers.push(cipher) - 1; + addFolder(result, row['Folder Name'], idx); + } + return result; +} + +function parseNordpassCsv(textRaw: string): CiphersImportPayload { + const rows = parseCsv(textRaw); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + for (const r of rows) { + const t = txt(r.type); + if (!t) continue; + if (t === 'password') { + const cipher = makeLoginCipher(); + cipher.name = val(r.name, '--'); + cipher.notes = val(r.note); + const login = cipher.login as Record; + login.username = val(r.username); + login.password = val(r.password); + const uris: string[] = []; + const main = normalizeUri(r.url || ''); + if (main) uris.push(main); + if (txt(r.additional_urls)) { + try { + const extra = JSON.parse(r.additional_urls) as string[]; + for (const u of extra || []) { + const n = normalizeUri(u || ''); + if (n) uris.push(n); + } + } catch {} + } + login.uris = uris.length ? uris.map((u) => ({ uri: u, match: null })) : null; + if (txt(r.custom_fields)) { + try { + const cfs = JSON.parse(r.custom_fields) as Array<{ label?: string; type?: string; value?: string }>; + for (const cf of cfs || []) processKvp(cipher, cf.label || '', cf.value || '', cf.type === 'hidden'); + } catch {} + } + const idx = result.ciphers.push(cipher) - 1; + addFolder(result, r.folder, idx); + continue; + } + if (t === 'note') { + const idx = result.ciphers.push({ + type: 2, + name: val(r.name, '--'), + notes: val(r.note), + favorite: false, + reprompt: 0, + key: null, + login: null, + card: null, + identity: null, + secureNote: { type: 0 }, + fields: null, + passwordHistory: null, + sshKey: null, + }) - 1; + addFolder(result, r.folder, idx); + continue; + } + if (t === 'credit_card') { + const idx = result.ciphers.push({ + type: 3, + name: val(r.name, '--'), + notes: val(r.note), + favorite: false, + reprompt: 0, + key: null, + login: null, + card: { + cardholderName: val(r.cardholdername), + number: val(r.cardnumber), + code: val(r.cvc), + expMonth: null, + expYear: null, + brand: cardBrand(val(r.cardnumber)), + }, + identity: null, + secureNote: null, + fields: null, + passwordHistory: null, + sshKey: null, + }) - 1; + addFolder(result, r.folder, idx); + continue; + } + if (t === 'identity') { + const full = txt(r.full_name); + const parts = full.split(/\s+/).filter(Boolean); + const identity: Record = { + firstName: parts[0] || null, + middleName: parts.length >= 3 ? parts[1] : null, + lastName: parts.length >= 2 ? parts.slice(parts.length >= 3 ? 2 : 1).join(' ') : null, + phone: val(r.phone_number), + email: val(r.email), + address1: val(r.address1), + address2: val(r.address2), + city: val(r.city), + state: val(r.state), + postalCode: val(r.zipcode), + country: txt(r.country).toUpperCase() || null, + }; + const idx = result.ciphers.push({ + type: 4, + name: val(r.name, '--'), + notes: val(r.note), + favorite: false, + reprompt: 0, + key: null, + login: null, + card: null, + identity, + secureNote: null, + fields: null, + passwordHistory: null, + sshKey: null, + }) - 1; + addFolder(result, r.folder, idx); + } + } + return result; +} + +function parsePassmanJson(textRaw: string): CiphersImportPayload { + const rows = JSON.parse(textRaw) as any[]; + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + for (const c of rows || []) { + const cipher = makeLoginCipher(); + cipher.name = val(c.label, '--'); + const login = cipher.login as Record; + login.username = val(c.username, val(c.email)); + login.password = val(c.password); + const uri = normalizeUri(c.url || ''); + login.uris = uri ? [{ uri, match: null }] : null; + login.totp = val(c?.otp?.secret); + const email = txt(c.email); + const desc = txt(c.description); + cipher.notes = `${login.username && email && txt(login.username) !== email ? `Email: ${email}\n` : ''}${desc}` || null; + for (const cf of c.custom_fields || []) { + const t = txt(cf.field_type); + if (t === 'text' || t === 'password') processKvp(cipher, cf.label || '', cf.value || '', false); + } + const idx = result.ciphers.push(cipher) - 1; + const folder = c?.tags?.[0]?.text; + if (folder) addFolder(result, String(folder), idx); + } + return result; +} + +function parsePasskyJson(textRaw: string): CiphersImportPayload { + const parsed = JSON.parse(textRaw) as { encrypted?: boolean; passwords?: any[] }; + if (parsed.encrypted === true) throw new Error('Unable to import an encrypted passky backup.'); + const list = Array.isArray(parsed.passwords) ? parsed.passwords : []; + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + for (const p of list) { + const cipher = makeLoginCipher(); + cipher.name = val(p.website, '--'); + const login = cipher.login as Record; + login.username = val(p.username); + login.password = val(p.password); + const uri = normalizeUri(String(p.website || '')); + login.uris = uri ? [{ uri, match: null }] : null; + cipher.notes = val(p.message); + result.ciphers.push(cipher); + } + return result; +} + +function parsePsonoJson(textRaw: string): CiphersImportPayload { + const parsed = JSON.parse(textRaw) as any; + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + + function parseItem(item: any, folderName: string | null) { + if (!item || typeof item !== 'object') return; + const type = txt(item.type); + const cipher = makeLoginCipher(); + if (type === 'website_password') { + cipher.name = val(item.website_password_title, '--'); + cipher.notes = val(item.website_password_notes); + const login = cipher.login as Record; + login.username = val(item.website_password_username); + login.password = val(item.website_password_password); + const uri = normalizeUri(item.website_password_url || ''); + login.uris = uri ? [{ uri, match: null }] : null; + const idx = result.ciphers.push(cipher) - 1; + if (folderName) addFolder(result, folderName, idx); + return; + } + if (type === 'application_password') { + cipher.name = val(item.application_password_title, '--'); + cipher.notes = val(item.application_password_notes); + const login = cipher.login as Record; + login.username = val(item.application_password_username); + login.password = val(item.application_password_password); + const idx = result.ciphers.push(cipher) - 1; + if (folderName) addFolder(result, folderName, idx); + return; + } + if (type === 'totp') { + cipher.name = val(item.totp_title, '--'); + cipher.notes = val(item.totp_notes); + (cipher.login as Record).totp = val(item.totp_code); + const idx = result.ciphers.push(cipher) - 1; + if (folderName) addFolder(result, folderName, idx); + return; + } + if (type === 'bookmark') { + cipher.name = val(item.bookmark_title, '--'); + cipher.notes = val(item.bookmark_notes); + const uri = normalizeUri(item.bookmark_url || ''); + (cipher.login as Record).uris = uri ? [{ uri, match: null }] : null; + const idx = result.ciphers.push(cipher) - 1; + if (folderName) addFolder(result, folderName, idx); + return; + } + if (type === 'note' || type === 'environment_variables') { + const secure = { + type: 2, + name: val(type === 'note' ? item.note_title : item.environment_variables_title, '--'), + notes: val(type === 'note' ? item.note_notes : item.environment_variables_notes), + favorite: false, + reprompt: 0, + key: null, + login: null, + card: null, + identity: null, + secureNote: { type: 0 }, + fields: null, + passwordHistory: null, + sshKey: null, + } as Record; + const idx = result.ciphers.push(secure) - 1; + if (folderName) addFolder(result, folderName, idx); + } + } + + function walkFolders(folders: any[], parent: string | null) { + for (const f of folders || []) { + const name = parent ? `${parent}/${txt(f.name)}` : txt(f.name); + for (const item of f.items || []) parseItem(item, name); + if (Array.isArray(f.folders)) walkFolders(f.folders, name); + } + } + + for (const item of parsed.items || []) parseItem(item, null); + walkFolders(parsed.folders || [], null); + return result; +} + +function parsePasswordBossJson(textRaw: string): CiphersImportPayload { + const parsed = JSON.parse(textRaw) as { folders?: any[]; items?: any[] }; + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + const folderNameById = new Map(); + for (const f of parsed.folders || []) { + if (f?.id && f?.name) folderNameById.set(String(f.id), String(f.name)); + } + for (const item of parsed.items || []) { + const ids = item?.identifiers || {}; + const isCard = txt(item?.type) === 'CreditCard'; + const base = isCard + ? { + type: 3, + name: val(item?.name, '--'), + notes: val(ids.notes), + favorite: false, + reprompt: 0, + key: null, + login: null, + card: { + number: val(ids.cardNumber), + cardholderName: val(ids.nameOnCard), + code: val(ids.security_code), + brand: cardBrand(val(ids.cardNumber)), + expMonth: null, + expYear: null, + }, + identity: null, + secureNote: null, + fields: [], + passwordHistory: null, + sshKey: null, + } + : makeLoginCipher(); + if (!isCard) { + base.name = val(item?.name, '--'); + base.notes = val(ids.notes); + const login = base.login as Record; + login.username = val(ids.username, val(ids.email)); + login.password = val(ids.password); + login.totp = val(ids.totp); + const uri = normalizeUri(item?.login_url || ids.url || ''); + login.uris = uri ? [{ uri, match: null }] : null; + } + if (Array.isArray(ids.custom_fields)) { + for (const cf of ids.custom_fields) processKvp(base as Record, cf?.name || '', cf?.value || '', false); + } + const idx = result.ciphers.push(base as Record) - 1; + const folderId = item?.folder; + if (folderId && folderNameById.has(String(folderId))) addFolder(result, folderNameById.get(String(folderId)) || '', idx); + } + return result; +} + +function parseOnePasswordCsv(textRaw: string, isMac: boolean): CiphersImportPayload { + const rows = parseCsv(textRaw); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + const ignored = new Set(['ainfo', 'autosubmit', 'notesplain', 'ps', 'scope', 'tags', 'title', 'uuid', 'notes', 'type']); + for (const row of rows) { + const title = txt(row.title || row.Title); + if (!title) continue; + const cipher = makeLoginCipher(); + cipher.name = title || '--'; + cipher.notes = `${txt(row.notesPlain)}\n${txt(row.notes)}`.trim() || null; + + let type: 1 | 2 | 3 | 4 = 1; + if (isMac) { + const t = txt(row.type).toLowerCase(); + if (t === 'credit card') type = 3; + else if (t === 'identity') type = 4; + else if (t === 'secure note') type = 2; + } else { + const values = Object.keys(row).map((k) => `${k}:${txt(row[k])}`.toLowerCase()); + const hasCard = values.some((x) => /number/i.test(x)) && values.some((x) => /expiry date/i.test(x)); + const hasIdentity = values.some((x) => /first name|initial|last name|email/.test(x)); + if (hasCard) type = 3; + else if (hasIdentity) type = 4; + } + if (type === 2) { + cipher.type = 2; + cipher.login = null; + cipher.secureNote = { type: 0 }; + } else if (type === 3) { + cipher.type = 3; + cipher.login = null; + cipher.card = { cardholderName: null, number: null, brand: null, expMonth: null, expYear: null, code: null }; + } else if (type === 4) { + cipher.type = 4; + cipher.login = null; + cipher.identity = { + firstName: null, + middleName: null, + lastName: null, + username: null, + email: null, + phone: null, + company: null, + }; + } + + let altUsername: string | null = null; + for (const property of Object.keys(row)) { + const rawVal = txt(row[property]); + if (!rawVal) continue; + const lower = property.toLowerCase(); + + if (Number(cipher.type) === 1) { + const login = cipher.login as Record; + if (!txt(login.username) && lower === 'username') { + login.username = rawVal; + continue; + } + if (!txt(login.password) && lower === 'password') { + login.password = rawVal; + continue; + } + if ((!Array.isArray(login.uris) || !login.uris.length) && (lower === 'url' || lower === 'website')) { + const uri = normalizeUri(rawVal); + login.uris = uri ? [{ uri, match: null }] : null; + continue; + } + } else if (Number(cipher.type) === 3 && cipher.card) { + const card = cipher.card as Record; + if (!txt(card.number) && lower.includes('number')) { + card.number = rawVal; + card.brand = cardBrand(rawVal); + continue; + } + if (!txt(card.code) && lower.includes('verification number')) { + card.code = rawVal; + continue; + } + if (!txt(card.cardholderName) && lower.includes('cardholder name')) { + card.cardholderName = rawVal; + continue; + } + if ((!txt(card.expMonth) || !txt(card.expYear)) && lower.includes('expiry date')) { + const { month, year } = parseCardExpiry(rawVal); + card.expMonth = month; + card.expYear = year; + continue; + } + } else if (Number(cipher.type) === 4 && cipher.identity) { + const identity = cipher.identity as Record; + if (!txt(identity.firstName) && lower.includes('first name')) { + identity.firstName = rawVal; + continue; + } + if (!txt(identity.middleName) && lower.includes('initial')) { + identity.middleName = rawVal; + continue; + } + if (!txt(identity.lastName) && lower.includes('last name')) { + identity.lastName = rawVal; + continue; + } + if (!txt(identity.username) && lower.includes('username')) { + identity.username = rawVal; + continue; + } + if (!txt(identity.email) && lower.includes('email')) { + identity.email = rawVal; + continue; + } + if (!txt(identity.phone) && lower.includes('default phone')) { + identity.phone = rawVal; + continue; + } + if (!txt(identity.company) && lower.includes('company')) { + identity.company = rawVal; + continue; + } + } + + if (!ignored.has(lower) && !lower.startsWith('section:') && !lower.startsWith('section ')) { + if (!altUsername && lower === 'email') altUsername = rawVal; + if (lower === 'created date' || lower === 'modified date') { + const readable = parseEpochMaybe(rawVal); + processKvp(cipher, `1Password ${property}`, readable || rawVal, false); + } else { + const hidden = lower.includes('password') || lower.includes('key') || lower.includes('secret'); + processKvp(cipher, property, rawVal, hidden); + } + } + } + if (Number(cipher.type) === 1 && !txt((cipher.login as Record).username) && altUsername && !altUsername.includes('://')) { + (cipher.login as Record).username = altUsername; + } + convertToNoteIfNeeded(cipher); + result.ciphers.push(cipher); + } + return result; +} + +function parseOnePasswordFieldsIntoCipher(cipher: Record, fields: any[], designationKey: string, valueKey: string, nameKey: string): void { + for (const field of fields || []) { + const raw = field?.[valueKey]; + if (raw === null || raw === undefined || txt(raw) === '') continue; + const designation = txt(field?.[designationKey]).toLowerCase(); + const k = txt(field?.k).toLowerCase(); + const fieldName = txt(field?.[nameKey] ?? field?.t ?? field?.title) || 'no_name'; + let value = txt(raw); + if (k === 'date') { + const asDate = parseEpochMaybe(raw); + value = asDate ? new Date(asDate).toUTCString() : value; + } + if (Number(cipher.type) === 1) { + const login = cipher.login as Record; + if (!txt(login.username) && designation === 'username') { + login.username = value; + continue; + } + if (!txt(login.password) && designation === 'password') { + login.password = value; + continue; + } + if (!txt(login.totp) && designation.startsWith('totp_')) { + login.totp = value; + continue; + } + } else if (Number(cipher.type) === 3 && cipher.card) { + const card = cipher.card as Record; + if (!txt(card.number) && designation === 'ccnum') { + card.number = value; + card.brand = cardBrand(value); + continue; + } + if (!txt(card.code) && designation === 'cvv') { + card.code = value; + continue; + } + if (!txt(card.cardholderName) && designation === 'cardholder') { + card.cardholderName = value; + continue; + } + if ((!txt(card.expMonth) || !txt(card.expYear)) && designation === 'expiry') { + const { month, year } = parseCardExpiry(value); + card.expMonth = month; + card.expYear = year; + continue; + } + if (designation === 'type') continue; + } else if (Number(cipher.type) === 4 && cipher.identity) { + const identity = cipher.identity as Record; + if (!txt(identity.firstName) && designation === 'firstname') { + identity.firstName = value; + continue; + } + if (!txt(identity.lastName) && designation === 'lastname') { + identity.lastName = value; + continue; + } + if (!txt(identity.middleName) && designation === 'initial') { + identity.middleName = value; + continue; + } + if (!txt(identity.phone) && designation === 'defphone') { + identity.phone = value; + continue; + } + if (!txt(identity.company) && designation === 'company') { + identity.company = value; + continue; + } + if (!txt(identity.email) && designation === 'email') { + identity.email = value; + continue; + } + if (!txt(identity.username) && designation === 'username') { + identity.username = value; + continue; + } + if (designation === 'address' && raw && typeof raw === 'object') { + const addr = raw as Record; + identity.address1 = val(addr.street); + identity.city = val(addr.city); + identity.country = txt(addr.country) ? txt(addr.country).toUpperCase() : null; + identity.postalCode = val(addr.zip); + identity.state = val(addr.state); + continue; + } + } + processKvp(cipher, fieldName, value, k === 'concealed'); + } +} + +function parseOnePasswordPasswordHistory(cipher: Record, history: any[]): void { + const parsed = (history || []) + .map((h) => ({ password: val(h?.value), lastUsedDate: parseEpochMaybe(h?.time) })) + .filter((x) => !!x.password && !!x.lastUsedDate) + .sort((a, b) => String(b.lastUsedDate).localeCompare(String(a.lastUsedDate))) + .slice(0, 5); + cipher.passwordHistory = parsed.length ? parsed : null; +} + +function parseOnePassword1Pif(textRaw: string): CiphersImportPayload { + const lines = textRaw.split(/\r?\n/); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith('{')) continue; + let item: any; + try { + item = JSON.parse(trimmed); + } catch { + continue; + } + if (item?.trashed === true) continue; + const cipher = makeLoginCipher(); + cipher.name = val(item?.title || item?.overview?.title, '--'); + cipher.favorite = !!item?.openContents?.faveIndex; + + let type = onePasswordTypeHints(item?.typeName); + const details = item?.details || item?.secureContents || {}; + if (details?.ccnum || details?.cvv) type = 3; + if (details?.firstname || details?.address1) type = 4; + if (type === 2) { + cipher.type = 2; + cipher.login = null; + cipher.secureNote = { type: 0 }; + } else if (type === 3) { + cipher.type = 3; + cipher.login = null; + cipher.card = { cardholderName: null, number: null, brand: null, expMonth: null, expYear: null, code: null }; + } else if (type === 4) { + cipher.type = 4; + cipher.login = null; + cipher.identity = { + firstName: null, + middleName: null, + lastName: null, + phone: null, + email: null, + username: null, + company: null, + }; + } + + const uris: string[] = []; + const locationUri = normalizeUri(item?.location || ''); + if (locationUri) uris.push(locationUri); + for (const u of item?.URLs || item?.secureContents?.URLs || item?.overview?.URLs || []) { + const uri = normalizeUri(u?.url || u?.u || ''); + if (uri) uris.push(uri); + } + if (Number(cipher.type) === 1) { + (cipher.login as Record).uris = uris.length ? uris.map((uri) => ({ uri, match: null })) : null; + (cipher.login as Record).password = val(details?.password); + } + cipher.notes = val(details?.notesPlain); + parseOnePasswordPasswordHistory(cipher, details?.passwordHistory || []); + parseOnePasswordFieldsIntoCipher(cipher, details?.fields || [], 'designation', 'value', 'name'); + for (const section of details?.sections || []) { + parseOnePasswordFieldsIntoCipher(cipher, section?.fields || [], 'n', 'v', 't'); + } + convertToNoteIfNeeded(cipher); + result.ciphers.push(cipher); + } + return result; +} + +function parseOnePassword1PuxJson(textRaw: string): CiphersImportPayload { + const parsed = JSON.parse(textRaw) as { accounts?: any[] }; + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + const accounts = Array.isArray(parsed?.accounts) ? parsed.accounts : []; + for (const account of accounts) { + for (const vault of account?.vaults || []) { + const vaultName = txt(vault?.attrs?.name); + for (const item of vault?.items || []) { + if (txt(item?.state) === 'archived') continue; + const cipher = makeLoginCipher(); + const categoryType = onePasswordCategoryType(item?.categoryUuid); + if (categoryType === 2) { + cipher.type = 2; + cipher.login = null; + cipher.secureNote = { type: 0 }; + } else if (categoryType === 3) { + cipher.type = 3; + cipher.login = null; + cipher.card = { cardholderName: null, number: null, brand: null, expMonth: null, expYear: null, code: null }; + } else if (categoryType === 4) { + cipher.type = 4; + cipher.login = null; + cipher.identity = { + firstName: null, + middleName: null, + lastName: null, + phone: null, + email: null, + username: null, + company: null, + address1: null, + city: null, + state: null, + postalCode: null, + country: null, + passportNumber: null, + ssn: null, + licenseNumber: null, + }; + } + cipher.favorite = Number(item?.favIndex) === 1; + cipher.name = val(item?.overview?.title, '--'); + cipher.notes = val(item?.details?.notesPlain); + + if (Number(cipher.type) === 1) { + const urls: string[] = []; + for (const u of item?.overview?.urls || []) { + const uri = normalizeUri(u?.url || ''); + if (uri) urls.push(uri); + } + const fallbackUrl = normalizeUri(item?.overview?.url || ''); + if (fallbackUrl) urls.push(fallbackUrl); + (cipher.login as Record).uris = urls.length ? urls.map((uri) => ({ uri, match: null })) : null; + } + + for (const loginField of item?.details?.loginFields || []) { + const lv = txt(loginField?.value); + if (!lv) continue; + const designation = txt(loginField?.designation).toLowerCase(); + const fieldName = txt(loginField?.name); + const fieldType = txt(loginField?.fieldType); + if (Number(cipher.type) === 1) { + const login = cipher.login as Record; + if (designation === 'username') { + login.username = lv; + continue; + } + if (designation === 'password') { + login.password = lv; + continue; + } + if (designation.includes('totp') || fieldName.toLowerCase().includes('totp')) { + login.totp = lv; + continue; + } + } + processKvp(cipher, fieldName || designation || 'field', lv, fieldType === 'P'); + } + + const detailsPassword = val(item?.details?.password); + if (Number(cipher.type) === 1 && detailsPassword && !txt((cipher.login as Record).password)) { + (cipher.login as Record).password = detailsPassword; + } + parseOnePasswordPasswordHistory(cipher, item?.details?.passwordHistory || []); + + for (const section of item?.details?.sections || []) { + for (const field of section?.fields || []) { + const rawValue = field?.value; + const valueObj = rawValue && typeof rawValue === 'object' ? (rawValue as Record) : {}; + const valueKey = Object.keys(valueObj)[0]; + const fieldValueObj = valueKey ? valueObj[valueKey] : null; + let fieldValue = ''; + let hidden = false; + if (valueKey === 'concealed') { + fieldValue = txt(fieldValueObj); + hidden = true; + } else if (valueKey === 'date') { + const iso = parseEpochMaybe(fieldValueObj); + fieldValue = iso ? new Date(iso).toUTCString() : txt(fieldValueObj); + } else if (valueKey === 'monthYear') { + fieldValue = txt(fieldValueObj); + } else if (valueKey === 'email' && fieldValueObj && typeof fieldValueObj === 'object') { + fieldValue = txt((fieldValueObj as Record).email_address); + } else if (valueKey === 'address' && fieldValueObj && typeof fieldValueObj === 'object') { + const a = fieldValueObj as Record; + fieldValue = [txt(a.street), txt(a.city), txt(a.state), txt(a.zip), txt(a.country)].filter(Boolean).join(', '); + } else { + fieldValue = txt(fieldValueObj); + } + if (!fieldValue) continue; + + const fieldId = txt(field?.id).toLowerCase(); + const fieldTitle = txt(field?.title); + const lowTitle = fieldTitle.toLowerCase(); + if (Number(cipher.type) === 1) { + const login = cipher.login as Record; + if (!txt(login.username) && (fieldId === 'username' || lowTitle.includes('username'))) { + login.username = fieldValue; + continue; + } + if (!txt(login.password) && (fieldId === 'password' || lowTitle.includes('password'))) { + login.password = fieldValue; + continue; + } + if (!txt(login.totp) && (fieldId.includes('totp') || lowTitle.includes('totp') || lowTitle.includes('otp'))) { + login.totp = fieldValue; + continue; + } + if ((!Array.isArray(login.uris) || !login.uris.length) && (fieldId === 'url' || lowTitle.includes('url') || lowTitle.includes('website'))) { + const uri = normalizeUri(fieldValue); + if (uri) login.uris = [{ uri, match: null }]; + continue; + } + } else if (Number(cipher.type) === 3 && cipher.card) { + const card = cipher.card as Record; + if (!txt(card.cardholderName) && (fieldId.includes('cardholder') || lowTitle.includes('cardholder'))) { + card.cardholderName = fieldValue; + continue; + } + if (!txt(card.number) && (valueKey === 'creditCardNumber' || fieldId.includes('number') || lowTitle.includes('number'))) { + card.number = fieldValue; + card.brand = cardBrand(fieldValue); + continue; + } + if (!txt(card.code) && (fieldId === 'cvv' || lowTitle.includes('cvv') || lowTitle.includes('security code'))) { + card.code = fieldValue; + continue; + } + if ((!txt(card.expMonth) || !txt(card.expYear)) && (valueKey === 'monthYear' || fieldId.includes('expiry') || lowTitle.includes('expiry'))) { + const { month, year } = parseCardExpiry(fieldValue); + card.expMonth = month; + card.expYear = year; + continue; + } + } else if (Number(cipher.type) === 4 && cipher.identity) { + const identity = cipher.identity as Record; + if (!txt(identity.firstName) && (fieldId === 'firstname' || lowTitle.includes('first name'))) { + identity.firstName = fieldValue; + continue; + } + if (!txt(identity.middleName) && (fieldId === 'initial' || lowTitle.includes('middle') || lowTitle.includes('initial'))) { + identity.middleName = fieldValue; + continue; + } + if (!txt(identity.lastName) && (fieldId === 'lastname' || lowTitle.includes('last name'))) { + identity.lastName = fieldValue; + continue; + } + if (!txt(identity.email) && (valueKey === 'email' || fieldId.includes('email') || lowTitle.includes('email'))) { + identity.email = fieldValue; + continue; + } + if (!txt(identity.phone) && (valueKey === 'phone' || fieldId.includes('phone') || lowTitle.includes('phone'))) { + identity.phone = fieldValue; + continue; + } + if (!txt(identity.company) && (fieldId.includes('company') || lowTitle.includes('company'))) { + identity.company = fieldValue; + continue; + } + if (valueKey === 'address' && fieldValueObj && typeof fieldValueObj === 'object') { + const a = fieldValueObj as Record; + identity.address1 = val(a.street); + identity.city = val(a.city); + identity.state = val(a.state); + identity.postalCode = val(a.zip); + identity.country = txt(a.country) ? txt(a.country).toUpperCase() : null; + continue; + } + if (!txt(identity.passportNumber) && lowTitle.includes('passport')) { + identity.passportNumber = fieldValue; + continue; + } + if (!txt(identity.ssn) && (lowTitle.includes('social security') || lowTitle === 'ssn')) { + identity.ssn = fieldValue; + continue; + } + if (!txt(identity.licenseNumber) && lowTitle.includes('license')) { + identity.licenseNumber = fieldValue; + continue; + } + } + processKvp(cipher, fieldTitle || fieldId || 'field', fieldValue, hidden); + } + } + + convertToNoteIfNeeded(cipher); + const idx = result.ciphers.push(cipher) - 1; + if (vaultName) addFolder(result, vaultName, idx); + } + } + } + return result; +} + +function parseProtonPassJson(textRaw: string): CiphersImportPayload { + const parsed = JSON.parse(textRaw) as { encrypted?: boolean; vaults?: Record }; + if (parsed?.encrypted) throw new Error('Unable to import an encrypted Proton Pass export.'); + const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + const vaults = parsed?.vaults && typeof parsed.vaults === 'object' ? parsed.vaults : {}; + for (const vault of Object.values(vaults)) { + const vaultName = txt((vault as Record).name); + const items = Array.isArray((vault as Record).items) ? ((vault as Record).items as any[]) : []; + for (const item of items) { + if (Number(item?.state) === 2) continue; + const itemType = txt(item?.data?.type); + const cipher = makeLoginCipher(); + cipher.name = val(item?.data?.metadata?.name, '--'); + cipher.notes = val(item?.data?.metadata?.note); + cipher.favorite = !!item?.pinned; + + if (itemType === 'login') { + const content = item?.data?.content || {}; + const login = cipher.login as Record; + const urls: string[] = []; + for (const u of content?.urls || []) { + const uri = normalizeUri(u || ''); + if (uri) urls.push(uri); + } + login.uris = urls.length ? urls.map((uri) => ({ uri, match: null })) : null; + const username = val(content?.itemUsername); + const email = val(content?.itemEmail); + login.username = username || email; + if (username && email) processKvp(cipher, 'email', email, false); + login.password = val(content?.password); + login.totp = val(content?.totpUri); + for (const extra of item?.data?.extraFields || []) { + const t = txt(extra?.type); + const fieldValue = t === 'totp' ? val(extra?.data?.totpUri) : val(extra?.data?.content); + processKvp(cipher, txt(extra?.fieldName), fieldValue || '', t !== 'text'); + } + } else if (itemType === 'note') { + cipher.type = 2; + cipher.login = null; + cipher.secureNote = { type: 0 }; + } else if (itemType === 'creditCard') { + const content = item?.data?.content || {}; + const { month, year } = parseCardExpiry(txt(content?.expirationDate)); + cipher.type = 3; + cipher.login = null; + cipher.card = { + cardholderName: val(content?.cardholderName), + number: val(content?.number), + brand: cardBrand(val(content?.number)), + code: val(content?.verificationNumber), + expMonth: month, + expYear: year, + }; + if (txt(content?.pin)) processKvp(cipher, 'PIN', txt(content.pin), true); + } else if (itemType === 'identity') { + const content = item?.data?.content || {}; + const name = splitFullName(val(content?.fullName)); + cipher.type = 4; + cipher.login = null; + cipher.identity = { + firstName: val(content?.firstName) || name.firstName, + middleName: val(content?.middleName) || name.middleName, + lastName: val(content?.lastName) || name.lastName, + email: val(content?.email), + phone: val(content?.phoneNumber), + company: val(content?.company), + ssn: val(content?.socialSecurityNumber), + passportNumber: val(content?.passportNumber), + licenseNumber: val(content?.licenseNumber), + address1: val(content?.organization), + address2: val(content?.streetAddress), + address3: `${txt(content?.floor)} ${txt(content?.county)}`.trim() || null, + city: val(content?.city), + state: val(content?.stateOrProvince), + postalCode: val(content?.zipOrPostalCode), + country: val(content?.countryOrRegion), + }; + for (const key of Object.keys(content || {})) { + if ( + [ + 'fullName', + 'firstName', + 'middleName', + 'lastName', + 'email', + 'phoneNumber', + 'company', + 'socialSecurityNumber', + 'passportNumber', + 'licenseNumber', + 'organization', + 'streetAddress', + 'floor', + 'county', + 'city', + 'stateOrProvince', + 'zipOrPostalCode', + 'countryOrRegion', + ].includes(key) + ) { + continue; + } + if (key === 'extraSections' && Array.isArray(content[key])) { + for (const section of content[key]) { + for (const extra of section?.sectionFields || []) { + processKvp(cipher, txt(extra?.fieldName), txt(extra?.data?.content), txt(extra?.type) === 'hidden'); + } + } + continue; + } + if (Array.isArray(content[key])) { + for (const extra of content[key]) { + processKvp(cipher, txt(extra?.fieldName), txt(extra?.data?.content), txt(extra?.type) === 'hidden'); + } + continue; + } + processKvp(cipher, key, txt(content[key]), false); + } + for (const extra of item?.data?.extraFields || []) { + processKvp(cipher, txt(extra?.fieldName), txt(extra?.data?.content), txt(extra?.type) === 'hidden'); + } + } else { + continue; + } + + const idx = result.ciphers.push(cipher) - 1; + if (vaultName) addFolder(result, vaultName, idx); + } + } + return result; +} + +export function normalizeBitwardenImport(raw: unknown): CiphersImportPayload { + const parsed = raw as BitwardenJsonInput | null; + if (!parsed || typeof parsed !== 'object') throw new Error('Invalid Bitwarden JSON'); + if (parsed.encrypted === true) throw new Error('Encrypted export requires encrypted import flow.'); + + const foldersRaw = Array.isArray(parsed.folders) ? parsed.folders : []; + const itemsRaw = Array.isArray(parsed.items) ? parsed.items : []; + const folders: Array<{ name: string }> = []; + const folderIndexById = new Map(); + for (const folder of foldersRaw) { + const name = txt(folder?.name); + if (!name) continue; + const idx = folders.length; + folders.push({ name }); + const id = txt(folder?.id); + if (id) folderIndexById.set(id, idx); + } + + const ciphers: Array> = []; + const folderRelationships: Array<{ key: number; value: number }> = []; + let hasAnyExplicitFolderLink = false; + for (const item of itemsRaw) { + ciphers.push({ + type: Number(item?.type || 1) || 1, + name: item?.name ?? 'Untitled', + notes: item?.notes ?? null, + favorite: !!item?.favorite, + reprompt: Number(item?.reprompt ?? 0) || 0, + key: item?.key ?? null, + login: item?.login + ? { + username: item.login.username ?? null, + password: item.login.password ?? null, + totp: item.login.totp ?? null, + fido2Credentials: Array.isArray(item.login.fido2Credentials) ? item.login.fido2Credentials : null, + uris: Array.isArray(item.login.uris) + ? item.login.uris.map((u) => ({ uri: u?.uri ?? null, match: u?.match ?? null })) + : null, + } + : null, + card: item?.card ?? null, + identity: item?.identity ?? null, + secureNote: item?.secureNote ?? null, + fields: Array.isArray(item?.fields) + ? item.fields.map((f) => ({ + name: f?.name ?? null, + value: f?.value ?? null, + type: Number(f?.type ?? 0) || 0, + linkedId: f?.linkedId ?? null, + })) + : null, + passwordHistory: Array.isArray(item?.passwordHistory) + ? item.passwordHistory + .map((x) => ({ password: x?.password ?? null, lastUsedDate: x?.lastUsedDate ?? null })) + .filter((x) => !!x.password) + : null, + sshKey: item?.sshKey ?? null, + }); + const folderId = txt(item?.folderId); + if (!folderId) continue; + const folderIndex = folderIndexById.get(folderId); + if (folderIndex !== undefined) { + hasAnyExplicitFolderLink = true; + folderRelationships.push({ key: ciphers.length - 1, value: folderIndex }); + } + } + + // Compatibility fallback: + // Some exports contain a single folder entry but omit item.folderId on all items. + // In that malformed shape, users still expect "original path" to place everything + // into that only folder. + if (!hasAnyExplicitFolderLink && folders.length === 1 && ciphers.length > 0) { + for (let i = 0; i < ciphers.length; i++) { + folderRelationships.push({ key: i, value: 0 }); + } + } + + return { ciphers, folders, folderRelationships }; +} + +export function normalizeBitwardenEncryptedAccountImport(raw: BitwardenJsonInput): CiphersImportPayload { + const itemsRaw = Array.isArray(raw.items) ? raw.items : []; + const foldersRaw = Array.isArray(raw.folders) ? raw.folders : []; + if (!Array.isArray(raw.folders) && Array.isArray(raw.collections)) throw new Error('Encrypted organization export is not supported yet.'); + + const folders = foldersRaw.map((f) => ({ name: String(f?.name ?? '') })); + const folderIndexByLegacyId = new Map(); + for (let i = 0; i < foldersRaw.length; i++) { + const folderId = txt(foldersRaw[i]?.id); + if (folderId) folderIndexByLegacyId.set(folderId, i); + } + const ciphers = itemsRaw.map((x) => ({ ...(x as Record) })); + const folderRelationships: Array<{ key: number; value: number }> = []; + for (let i = 0; i < itemsRaw.length; i++) { + const folderId = txt(itemsRaw[i]?.folderId); + if (!folderId) continue; + const folderIndex = folderIndexByLegacyId.get(folderId); + if (folderIndex !== undefined) folderRelationships.push({ key: i, value: folderIndex }); + } + return { ciphers, folders, folderRelationships }; +} + +const IMPORT_SOURCE_PARSERS: Record CiphersImportPayload> = { + bitwarden_json: () => { + throw new Error('bitwarden_json is handled by dedicated JSON flow'); + }, + bitwarden_csv: parseBitwardenCsv, + onepassword_1pux: parseOnePassword1PuxJson, + onepassword_1pif: parseOnePassword1Pif, + onepassword_mac_csv: (textRaw) => parseOnePasswordCsv(textRaw, true), + onepassword_win_csv: (textRaw) => parseOnePasswordCsv(textRaw, false), + protonpass_json: parseProtonPassJson, + avira_csv: parseAviraCsv, + avast_csv: parseAvastCsv, + avast_json: parseAvastJson, + chrome: parseChromeCsv, + edge: parseChromeCsv, + brave: parseChromeCsv, + opera: parseChromeCsv, + vivaldi: parseChromeCsv, + firefox_csv: parseFirefoxCsv, + safari_csv: parseSafariCsv, + lastpass: parseLastPassCsv, + dashlane_csv: parseDashlaneCsv, + dashlane_json: parseDashlaneJson, + keepass_xml: parseKeePassXml, + keepassx_csv: parseKeePassXCsv, + arc_csv: parseArcCsv, + ascendo_csv: parseAscendoCsv, + blackberry_csv: parseBlackberryCsv, + blur_csv: parseBlurCsv, + buttercup_csv: parseButtercupCsv, + codebook_csv: parseCodebookCsv, + encryptr_csv: parseEncryptrCsv, + enpass_csv: parseEnpassCsv, + enpass_json: parseEnpassJson, + keeper_csv: parseKeeperCsv, + keeper_json: parseKeeperJson, + logmeonce_csv: parseLogMeOnceCsv, + meldium_csv: parseMeldiumCsv, + msecure_csv: parseMSecureCsv, + myki_csv: parseMykiCsv, + netwrix_csv: parseNetwrixCsv, + nordpass_csv: parseNordpassCsv, + roboform_csv: parseRoboFormCsv, + zohovault_csv: parseZohoVaultCsv, + passman_json: parsePassmanJson, + passky_json: parsePasskyJson, + psono_json: parsePsonoJson, + passwordboss_json: parsePasswordBossJson, +}; + +export function parseImportPayloadBySource(source: ImportSourceId, textRaw: string): CiphersImportPayload { + return IMPORT_SOURCE_PARSERS[source](textRaw); +} diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts index 82c69cd..ac97d45 100644 --- a/webapp/src/lib/types.ts +++ b/webapp/src/lib/types.ts @@ -28,11 +28,17 @@ export interface CipherLoginUri { decUri?: string; } +export interface CipherLoginPasskey { + creationDate?: string | null; + [key: string]: unknown; +} + export interface CipherLogin { username?: string | null; password?: string | null; totp?: string | null; uris?: CipherLoginUri[] | null; + fido2Credentials?: CipherLoginPasskey[] | null; decUsername?: string; decPassword?: string; decTotp?: string; @@ -196,6 +202,7 @@ export interface VaultDraft { loginPassword: string; loginTotp: string; loginUris: string[]; + loginFido2Credentials: Array>; cardholderName: string; cardNumber: string; cardBrand: string;