From 819734ce5cb5d1a084030dff1ed3fa11deced604 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Wed, 4 Mar 2026 01:03:49 +0800 Subject: [PATCH] feat: add export and import functionality for Bitwarden and NodeWarden formats - Implemented export formats for Bitwarden (JSON, encrypted JSON, ZIP) and NodeWarden (JSON). - Added support for attachments in ciphers and introduced new types for handling attachments. - Enhanced import formats to include Bitwarden ZIP and NodeWarden JSON. - Updated internationalization strings for attachment-related features. - Improved UI styles for attachment management and import summary display. --- package-lock.json | 12 + package.json | 1 + src/handlers/import.ts | 16 +- webapp/src/App.tsx | 439 ++++++++++++++- webapp/src/components/ConfirmDialog.tsx | 3 + webapp/src/components/ImportPage.tsx | 583 ++++++++++++++++++-- webapp/src/components/SendsPage.tsx | 12 +- webapp/src/components/SettingsPage.tsx | 1 + webapp/src/components/VaultPage.tsx | 222 +++++++- webapp/src/lib/api.ts | 228 +++++++- webapp/src/lib/export-formats.ts | 694 ++++++++++++++++++++++++ webapp/src/lib/i18n.ts | 89 ++- webapp/src/lib/import-formats.ts | 12 + webapp/src/lib/types.ts | 15 + webapp/src/styles.css | 127 +++++ 15 files changed, 2379 insertions(+), 75 deletions(-) create mode 100644 webapp/src/lib/export-formats.ts diff --git a/package-lock.json b/package-lock.json index b48b49d..4711475 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@noble/hashes": "^2.0.1", "@tanstack/react-query": "^5.90.21", + "@zip.js/zip.js": "^2.8.22", "fflate": "^0.8.2", "lucide-preact": "^0.575.0", "preact": "^10.28.4", @@ -2089,6 +2090,17 @@ "undici-types": "~7.16.0" } }, + "node_modules/@zip.js/zip.js": { + "version": "2.8.22", + "resolved": "https://registry.npmmirror.com/@zip.js/zip.js/-/zip.js-2.8.22.tgz", + "integrity": "sha512-0KlzbVR6r8irIX2o3zvUlosBDef62VDl47oUfa1U/qgEs67h4/eGBrX/6HWa1RQbt+J6sAeVmtyFKbTHNdF8qQ==", + "license": "BSD-3-Clause", + "engines": { + "bun": ">=0.7.0", + "deno": ">=1.0.0", + "node": ">=18.0.0" + } + }, "node_modules/babel-plugin-transform-hook-names": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz", diff --git a/package.json b/package.json index 9242d8e..120cce2 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "dependencies": { "@noble/hashes": "^2.0.1", "@tanstack/react-query": "^5.90.21", + "@zip.js/zip.js": "^2.8.22", "fflate": "^0.8.2", "lucide-preact": "^0.575.0", "preact": "^10.28.4", diff --git a/src/handlers/import.ts b/src/handlers/import.ts index f40910a..aa38963 100644 --- a/src/handlers/import.ts +++ b/src/handlers/import.ts @@ -1,6 +1,6 @@ import { Env, Cipher, Folder, CipherType } from '../types'; import { StorageService } from '../services/storage'; -import { errorResponse } from '../utils/response'; +import { errorResponse, jsonResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; import { LIMITS } from '../config/limits'; import { normalizeCipherLoginForCompatibility } from './ciphers'; @@ -8,6 +8,7 @@ import { normalizeCipherLoginForCompatibility } from './ciphers'; // Bitwarden client import request format interface CiphersImportRequest { ciphers: Array<{ + id?: string | null; type: number; name?: string | null; notes?: string | null; @@ -90,6 +91,8 @@ async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[ // POST /api/ciphers/import - Bitwarden client import endpoint export async function handleCiphersImport(request: Request, env: Env, userId: string): Promise { const storage = new StorageService(env.DB); + const url = new URL(request.url); + const returnCipherMap = url.searchParams.get('returnCipherMap') === '1'; let importData: CiphersImportRequest; try { @@ -151,9 +154,12 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st // Create ciphers const cipherRows: Cipher[] = []; + const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = []; for (let i = 0; i < ciphers.length; i++) { const c = ciphers[i]; const folderId = cipherFolderMap.get(i) || null; + const sourceIdRaw = String(c?.id ?? '').trim(); + const sourceId = sourceIdRaw || null; const cipher: Cipher = { ...c, @@ -229,6 +235,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st cipher.login = normalizeCipherLoginForCompatibility(cipher.login); cipherRows.push(cipher); + cipherMapRows.push({ index: i, sourceId, id: cipher.id }); } if (cipherRows.length > 0) { @@ -263,5 +270,12 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st // Update revision date await storage.updateRevisionDate(userId); + if (returnCipherMap) { + return jsonResponse({ + object: 'import-result', + cipherMap: cipherMapRows, + }); + } + return new Response(null, { status: 200 }); } diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 04995f4..83bac97 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -15,14 +15,17 @@ import SecurityDevicesPage from '@/components/SecurityDevicesPage'; import AdminPage from '@/components/AdminPage'; import HelpPage from '@/components/HelpPage'; import ImportPage from '@/components/ImportPage'; +import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage'; import { changeMasterPassword, createFolder, updateFolder, + deleteCipherAttachment, deleteFolder, createCipher, createAuthedFetch, createInvite, + downloadCipherAttachmentDecrypted, importCiphers, createSend, deleteAllInvites, @@ -30,9 +33,11 @@ import { deleteSend, deleteUser, deriveLoginHash, + getAttachmentDownloadInfo, bulkMoveCiphers, getCiphers, getFolders, + getPreloginKdfConfig, getProfile, getAuthorizedDevices, getSetupStatus, @@ -53,13 +58,28 @@ import { setTotp, setUserStatus, deleteAuthorizedDevice, + uploadCipherAttachment, updateCipher, updateSend, buildSendShareKey, unlockVaultKey, verifyMasterPassword, + type ImportedCipherMapEntry, } from '@/lib/api'; -import { base64ToBytes, decryptBw, decryptStr, hkdf } from '@/lib/crypto'; +import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, hkdf } from '@/lib/crypto'; +import { + attachNodeWardenEncryptedAttachmentPayload, + buildAccountEncryptedBitwardenJsonString, + buildBitwardenZipBytes, + buildExportFileName, + buildNodeWardenAttachmentRecords, + buildNodeWardenPlainJsonDocument, + buildPasswordProtectedBitwardenJsonString, + buildPlainBitwardenJsonString, + encryptZipBytesWithPassword, + type ExportRequest, + type ZipAttachmentEntry, +} from '@/lib/export-formats'; 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'; @@ -86,6 +106,35 @@ function asText(value: unknown): string { return String(value); } +function summarizeImportResult( + ciphers: Array>, + folderCount: number +): ImportResultSummary { + const counter = new Map(); + const typeLabel = (type: number): string => { + if (type === 1) return '登录'; + if (type === 2) return '安全备注'; + if (type === 3) return '卡片'; + if (type === 4) return '身份'; + if (type === 5) return 'SSH 密钥'; + return '其他'; + }; + for (const raw of ciphers) { + const t = Number(raw?.type || 1) || 1; + const label = typeLabel(t); + counter.set(label, (counter.get(label) || 0) + 1); + } + const order = ['登录', '安全备注', '卡片', '身份', 'SSH 密钥', '其他']; + const typeCounts = order + .filter((label) => (counter.get(label) || 0) > 0) + .map((label) => ({ label, count: counter.get(label) || 0 })); + return { + totalItems: ciphers.length, + folderCount: Math.max(0, folderCount), + typeCounts, + }; +} + function buildEmptyImportDraft(type: number): VaultDraft { return { type, @@ -670,6 +719,14 @@ export default function App() { })) ); } + if (Array.isArray(cipher.attachments)) { + nextCipher.attachments = await Promise.all( + cipher.attachments.map(async (attachment) => ({ + ...attachment, + decFileName: await decryptField(attachment.fileName || '', itemEnc, itemMac), + })) + ); + } return nextCipher; }) ); @@ -836,10 +893,13 @@ export default function App() { pushToast('success', t('txt_device_removed')); } - async function createVaultItem(draft: VaultDraft) { + async function createVaultItem(draft: VaultDraft, attachments: File[] = []) { if (!session) return; try { - await createCipher(authedFetch, session, draft); + const created = await createCipher(authedFetch, session, draft); + for (const file of attachments) { + await uploadCipherAttachment(authedFetch, session, created.id, file); + } await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); pushToast('success', t('txt_item_created')); } catch (error) { @@ -848,10 +908,24 @@ export default function App() { } } - async function updateVaultItem(cipher: Cipher, draft: VaultDraft) { + async function updateVaultItem( + cipher: Cipher, + draft: VaultDraft, + options?: { addFiles?: File[]; removeAttachmentIds?: string[] } + ) { if (!session) return; + const addFiles = Array.isArray(options?.addFiles) ? options.addFiles : []; + const removeAttachmentIds = Array.isArray(options?.removeAttachmentIds) ? options.removeAttachmentIds : []; try { await updateCipher(authedFetch, session, cipher, draft); + for (const attachmentId of removeAttachmentIds) { + const id = String(attachmentId || '').trim(); + if (!id) continue; + await deleteCipherAttachment(authedFetch, cipher.id, id); + } + for (const file of addFiles) { + await uploadCipherAttachment(authedFetch, session, cipher.id, file, cipher); + } await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); pushToast('success', t('txt_item_updated')); } catch (error) { @@ -860,6 +934,29 @@ export default function App() { } } + async function downloadVaultAttachment(cipher: Cipher, attachmentId: string) { + if (!session) return; + try { + const file = await downloadCipherAttachmentDecrypted(authedFetch, session, cipher, attachmentId); + const fileName = String(file.fileName || '').trim() || 'attachment.bin'; + const payload = new ArrayBuffer(file.bytes.byteLength); + new Uint8Array(payload).set(file.bytes); + const blob = new Blob([payload], { type: 'application/octet-stream' }); + const href = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = href; + anchor.download = fileName; + anchor.rel = 'noopener'; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(href); + } catch (error) { + pushToast('error', error instanceof Error ? error.message : t('txt_download_failed')); + throw error; + } + } + async function deleteVaultItem(cipher: Cipher) { try { await deleteCipher(authedFetch, cipher.id); @@ -1001,15 +1098,83 @@ export default function App() { } } + function buildImportedCipherMaps( + payloadCiphers: Array>, + createdCipherIdsByIndex: Map + ): { byIndex: Map; bySourceId: Map } { + const byIndex = new Map(createdCipherIdsByIndex); + const bySourceId = new Map(); + for (const [index, id] of createdCipherIdsByIndex.entries()) { + const raw = (payloadCiphers[index] || {}) as Record; + const sourceId = String(raw.id || '').trim(); + if (sourceId) bySourceId.set(sourceId, id); + } + return { byIndex, bySourceId }; + } + + async function uploadImportedAttachments( + attachments: ImportAttachmentFile[], + idMaps: { byIndex: Map; bySourceId: Map } + ): Promise { + if (!attachments.length) return; + if (!session?.symEncKey || !session?.symMacKey) throw new Error('Vault key unavailable'); + + const initialCiphers = (await ciphersQuery.refetch()).data || []; + const cipherById = new Map(initialCiphers.map((cipher) => [String(cipher.id || ''), cipher])); + const unresolved: ImportAttachmentFile[] = []; + + for (const attachment of attachments) { + const sourceId = String(attachment.sourceCipherId || '').trim(); + const sourceIndex = Number(attachment.sourceCipherIndex); + const byId = sourceId ? idMaps.bySourceId.get(sourceId) : null; + const byIndex = Number.isFinite(sourceIndex) ? idMaps.byIndex.get(sourceIndex) : null; + const targetCipherId = byId || byIndex || null; + if (!targetCipherId) { + unresolved.push(attachment); + continue; + } + + const name = String(attachment.fileName || '').trim() || 'attachment.bin'; + const fileBytes = Uint8Array.from(attachment.bytes); + const file = new File([fileBytes], name, { type: 'application/octet-stream' }); + const cipher = cipherById.get(targetCipherId) || null; + await uploadCipherAttachment(authedFetch, session, targetCipherId, file, cipher); + } + + if (unresolved.length) { + throw new Error(`Failed to map ${unresolved.length} attachment(s) to imported items.`); + } + + await ciphersQuery.refetch(); + } + + function toImportedCipherMapsFromResponse( + cipherMap: ImportedCipherMapEntry[] | null + ): { byIndex: Map; bySourceId: Map } { + const byIndex = new Map(); + const bySourceId = new Map(); + for (const row of cipherMap || []) { + const idx = Number(row?.index); + const id = String(row?.id || '').trim(); + if (!Number.isFinite(idx) || !id) continue; + byIndex.set(idx, id); + const sourceId = String(row?.sourceId || '').trim(); + if (sourceId) bySourceId.set(sourceId, id); + } + return { byIndex, bySourceId }; + } + async function handleImportAction( payload: CiphersImportPayload, - options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null } - ) { + options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null }, + attachments: ImportAttachmentFile[] = [] + ): Promise { 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(); + let createdFolderCount = 0; if (mode === 'original') { const folderIdByImportIndex = new Map(); const folderIdByLegacyId = new Map(); @@ -1024,6 +1189,7 @@ export default function App() { const created = await createFolder(authedFetch, session, name); folderId = created.id; createdFolderIdByName.set(name, folderId); + createdFolderCount += 1; } folderIdByImportIndex.set(i, folderId); folderIdByName.set(name, folderId); @@ -1076,13 +1242,20 @@ export default function App() { await bulkMoveCiphers(authedFetch, ids, folderId); } - await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); + const idMaps = buildImportedCipherMaps(payload.ciphers, createdCipherIdsByIndex); + await foldersQuery.refetch(); + await ciphersQuery.refetch(); + if (attachments.length) { + await uploadImportedAttachments(attachments, idMaps); + } + return summarizeImportResult(payload.ciphers, mode === 'original' ? createdFolderCount : 0); } async function handleImportEncryptedRawAction( payload: CiphersImportPayload, - options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null } - ) { + options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null }, + attachments: ImportAttachmentFile[] = [] + ): Promise { const mode = options.folderMode || 'original'; const targetFolderId = (options.targetFolderId || '').trim() || null; const nextPayload: CiphersImportPayload = { @@ -1096,8 +1269,247 @@ export default function App() { for (const raw of nextPayload.ciphers) (raw as Record).folderId = targetFolderId; } - await importCiphers(authedFetch, nextPayload); + const importedCipherMap = await importCiphers(authedFetch, nextPayload, { + returnCipherMap: attachments.length > 0, + }); await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); + if (attachments.length) { + const idMaps = toImportedCipherMapsFromResponse(importedCipherMap); + await uploadImportedAttachments(attachments, idMaps); + } + return summarizeImportResult(nextPayload.ciphers, mode === 'original' ? nextPayload.folders.length : 0); + } + + async function handleExportAction(request: ExportRequest) { + if (!session?.symEncKey || !session?.symMacKey) throw new Error('Vault key unavailable'); + const masterPassword = String(request.masterPassword || '').trim(); + if (!masterPassword) throw new Error(t('txt_master_password_is_required')); + const email = String(profile?.email || session.email || '').trim().toLowerCase(); + if (!email) throw new Error(t('txt_profile_unavailable')); + const verifyDerived = await deriveLoginHash(email, masterPassword, defaultKdfIterations); + await verifyMasterPassword(authedFetch, verifyDerived.hash); + + const rawFolders = foldersQuery.data || []; + const rawCiphers = ciphersQuery.data || []; + if (!rawFolders || !rawCiphers) throw new Error('Vault is not ready yet'); + + let plainJsonCache: string | null = null; + let plainJsonDocCache: Record | null = null; + let encryptedJsonCache: string | null = null; + let nodeWardenAttachmentsCache: ReturnType | null = null; + const getPlainJson = async () => { + if (!plainJsonCache) { + plainJsonCache = await buildPlainBitwardenJsonString({ + folders: rawFolders, + ciphers: rawCiphers, + userEncB64: session.symEncKey!, + userMacB64: session.symMacKey!, + }); + } + return plainJsonCache; + }; + const getPlainJsonDoc = async () => { + if (!plainJsonDocCache) { + plainJsonDocCache = JSON.parse(await getPlainJson()) as Record; + } + return plainJsonDocCache; + }; + const getEncryptedJson = async () => { + if (!encryptedJsonCache) { + encryptedJsonCache = await buildAccountEncryptedBitwardenJsonString({ + folders: rawFolders, + ciphers: rawCiphers, + userEncB64: session.symEncKey!, + userMacB64: session.symMacKey!, + }); + } + return encryptedJsonCache; + }; + + const zipAttachments = async (): Promise => { + const userEnc = base64ToBytes(session.symEncKey!); + const userMac = base64ToBytes(session.symMacKey!); + const out: ZipAttachmentEntry[] = []; + const activeCiphers = rawCiphers.filter((cipher) => !cipher.deletedDate && !(cipher as { organizationId?: unknown }).organizationId); + + for (const cipher of activeCiphers) { + const cipherId = String(cipher.id || '').trim(); + if (!cipherId) continue; + const attachments = Array.isArray(cipher.attachments) ? cipher.attachments : []; + if (!attachments.length) continue; + + let itemEnc = userEnc; + let itemMac = userMac; + const itemKey = String(cipher.key || '').trim(); + if (itemKey && looksLikeCipherString(itemKey)) { + try { + const rawItemKey = await decryptBw(itemKey, userEnc, userMac); + if (rawItemKey.length >= 64) { + itemEnc = rawItemKey.slice(0, 32); + itemMac = rawItemKey.slice(32, 64); + } + } catch { + // fallback to user key + } + } + + for (const attachment of attachments) { + const attachmentId = String(attachment?.id || '').trim(); + if (!attachmentId) continue; + const info = await getAttachmentDownloadInfo(authedFetch, cipherId, attachmentId); + const fileResp = await fetch(info.url, { cache: 'no-store' }); + if (!fileResp.ok) throw new Error(`Failed to download attachment ${attachmentId}`); + const encryptedBytes = new Uint8Array(await fileResp.arrayBuffer()); + + let fileEnc = itemEnc; + let fileMac = itemMac; + const attachmentKeyCipher = String(info.key || attachment?.key || '').trim(); + if (attachmentKeyCipher && looksLikeCipherString(attachmentKeyCipher)) { + try { + const rawAttachmentKey = await decryptBw(attachmentKeyCipher, itemEnc, itemMac); + if (rawAttachmentKey.length >= 64) { + fileEnc = rawAttachmentKey.slice(0, 32); + fileMac = rawAttachmentKey.slice(32, 64); + } + } catch { + // fallback to item key + } + } + + const plainBytes = await decryptBwFileData(encryptedBytes, fileEnc, fileMac); + + const fileNameRaw = String(info.fileName || attachment?.fileName || '').trim(); + let fileName = fileNameRaw || `attachment-${attachmentId}`; + if (fileNameRaw && looksLikeCipherString(fileNameRaw)) { + try { + fileName = (await decryptStr(fileNameRaw, itemEnc, itemMac)) || fileName; + } catch { + // fallback to raw encrypted name + } + } + + out.push({ + cipherId, + fileName, + bytes: plainBytes, + }); + } + } + return out; + }; + + const getNodeWardenAttachmentRecords = async () => { + if (nodeWardenAttachmentsCache) return nodeWardenAttachmentsCache; + const [doc, attachments] = await Promise.all([getPlainJsonDoc(), zipAttachments()]); + const cipherIndexById = new Map(); + const items = Array.isArray(doc.items) ? (doc.items as Array>) : []; + for (let i = 0; i < items.length; i++) { + const id = String(items[i]?.id || '').trim(); + if (id) cipherIndexById.set(id, i); + } + nodeWardenAttachmentsCache = buildNodeWardenAttachmentRecords(attachments, cipherIndexById); + return nodeWardenAttachmentsCache; + }; + + const format = request.format; + if (format === 'bitwarden_json') { + const bytes = new TextEncoder().encode(await getPlainJson()); + return { + fileName: buildExportFileName(format), + mimeType: 'application/json', + bytes, + }; + } + + if (format === 'bitwarden_encrypted_json') { + if (request.encryptedJsonMode === 'password') { + const plainJson = await getPlainJson(); + const kdf = await getPreloginKdfConfig(profile?.email || session.email, defaultKdfIterations); + const encrypted = await buildPasswordProtectedBitwardenJsonString({ + plaintextJson: plainJson, + password: String(request.filePassword || ''), + kdf, + }); + return { + fileName: buildExportFileName(format), + mimeType: 'application/json', + bytes: new TextEncoder().encode(encrypted), + }; + } + const bytes = new TextEncoder().encode(await getEncryptedJson()); + return { + fileName: buildExportFileName(format), + mimeType: 'application/json', + bytes, + }; + } + + if (format === 'nodewarden_json') { + const [plainDoc, attachments] = await Promise.all([getPlainJsonDoc(), getNodeWardenAttachmentRecords()]); + const nodeWardenDoc = buildNodeWardenPlainJsonDocument(plainDoc, attachments); + return { + fileName: buildExportFileName(format), + mimeType: 'application/json', + bytes: new TextEncoder().encode(JSON.stringify(nodeWardenDoc, null, 2)), + }; + } + + if (format === 'nodewarden_encrypted_json') { + if (request.encryptedJsonMode === 'password') { + const [plainDoc, attachments] = await Promise.all([getPlainJsonDoc(), getNodeWardenAttachmentRecords()]); + const nodeWardenDoc = buildNodeWardenPlainJsonDocument(plainDoc, attachments); + const kdf = await getPreloginKdfConfig(profile?.email || session.email, defaultKdfIterations); + const encrypted = await buildPasswordProtectedBitwardenJsonString({ + plaintextJson: JSON.stringify(nodeWardenDoc, null, 2), + password: String(request.filePassword || ''), + kdf, + }); + return { + fileName: buildExportFileName(format), + mimeType: 'application/json', + bytes: new TextEncoder().encode(encrypted), + }; + } + + const [encryptedJson, attachments] = await Promise.all([getEncryptedJson(), getNodeWardenAttachmentRecords()]); + const withAttachments = await attachNodeWardenEncryptedAttachmentPayload( + encryptedJson, + attachments, + session.symEncKey!, + session.symMacKey! + ); + return { + fileName: buildExportFileName(format), + mimeType: 'application/json', + bytes: new TextEncoder().encode(withAttachments), + }; + } + + if (format === 'bitwarden_json_zip' || format === 'bitwarden_encrypted_json_zip') { + let dataJson = await getPlainJson(); + if (format === 'bitwarden_encrypted_json_zip') { + if (request.encryptedJsonMode === 'password') { + const kdf = await getPreloginKdfConfig(profile?.email || session.email, defaultKdfIterations); + dataJson = await buildPasswordProtectedBitwardenJsonString({ + plaintextJson: await getPlainJson(), + password: String(request.filePassword || ''), + kdf, + }); + } else { + dataJson = await getEncryptedJson(); + } + } + const attachments = await zipAttachments(); + const zipBytes = buildBitwardenZipBytes(dataJson, attachments); + const encryptedZip = await encryptZipBytesWithPassword(zipBytes, String(request.zipPassword || '')); + return { + fileName: buildExportFileName(format, encryptedZip.encrypted), + mimeType: 'application/zip', + bytes: encryptedZip.bytes, + }; + } + + throw new Error('Unsupported export format'); } const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : ''; @@ -1311,6 +1723,7 @@ export default function App() { onNotify={pushToast} onCreateFolder={createFolderAction} onDeleteFolder={deleteFolderAction} + onDownloadAttachment={downloadVaultAttachment} /> @@ -1432,6 +1845,7 @@ export default function App() { accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null} onNotify={pushToast} folders={decryptedFolders} + onExport={handleExportAction} /> @@ -1441,6 +1855,7 @@ export default function App() { accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null} onNotify={pushToast} folders={decryptedFolders} + onExport={handleExportAction} /> @@ -1450,6 +1865,7 @@ export default function App() { accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null} onNotify={pushToast} folders={decryptedFolders} + onExport={handleExportAction} /> @@ -1459,6 +1875,7 @@ export default function App() { accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null} onNotify={pushToast} folders={decryptedFolders} + onExport={handleExportAction} /> @@ -1468,6 +1885,7 @@ export default function App() { accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null} onNotify={pushToast} folders={decryptedFolders} + onExport={handleExportAction} /> @@ -1477,6 +1895,7 @@ export default function App() { accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null} onNotify={pushToast} folders={decryptedFolders} + onExport={handleExportAction} /> diff --git a/webapp/src/components/ConfirmDialog.tsx b/webapp/src/components/ConfirmDialog.tsx index 6751bb8..8743570 100644 --- a/webapp/src/components/ConfirmDialog.tsx +++ b/webapp/src/components/ConfirmDialog.tsx @@ -1,4 +1,5 @@ import type { ComponentChildren } from 'preact'; +import { Check, X } from 'lucide-preact'; import { t } from '@/lib/i18n'; interface ConfirmDialogProps { @@ -28,9 +29,11 @@ export default function ConfirmDialog(props: ConfirmDialogProps) { className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`} onClick={props.onConfirm} > + {props.confirmText || t('txt_yes')} {props.afterActions} diff --git a/webapp/src/components/ImportPage.tsx b/webapp/src/components/ImportPage.tsx index 82a76d1..923106a 100644 --- a/webapp/src/components/ImportPage.tsx +++ b/webapp/src/components/ImportPage.tsx @@ -1,9 +1,17 @@ -import { useState } from 'preact/hooks'; +import { useState } from 'preact/hooks'; import { argon2idAsync } from '@noble/hashes/argon2.js'; import { strFromU8, unzipSync } from 'fflate'; -import { FileUp } from 'lucide-preact'; +import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js'; +import { Download, FileUp } from 'lucide-preact'; import ConfirmDialog from '@/components/ConfirmDialog'; import type { CiphersImportPayload } from '@/lib/api'; +import { + type EncryptedJsonMode, + EXPORT_FORMATS, + type ExportDownloadPayload, + type ExportFormatId, + type ExportRequest, +} from '@/lib/export-formats'; import { getFileAcceptBySource, IMPORT_SOURCES, @@ -17,18 +25,36 @@ import { base64ToBytes, decryptStr, hkdfExpand, pbkdf2 } from '@/lib/crypto'; import { t } from '@/lib/i18n'; import type { Folder } from '@/lib/types'; +configureZipJs({ useWebWorkers: false }); + +export interface ImportAttachmentFile { + sourceCipherId: string | null; + sourceCipherIndex: number | null; + fileName: string; + bytes: Uint8Array; +} + interface ImportPageProps { onImport: ( payload: CiphersImportPayload, - options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null } - ) => Promise; + options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null }, + attachments?: ImportAttachmentFile[] + ) => Promise; onImportEncryptedRaw: ( payload: CiphersImportPayload, - options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null } - ) => Promise; + options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null }, + attachments?: ImportAttachmentFile[] + ) => Promise; accountKeys?: { encB64: string; macB64: string } | null; onNotify: (type: 'success' | 'error', text: string) => void; folders: Folder[]; + onExport: (request: ExportRequest) => Promise; +} + +export interface ImportResultSummary { + totalItems: number; + folderCount: number; + typeCounts: Array<{ label: string; count: number }>; } interface BitwardenPasswordProtectedInput extends BitwardenJsonInput { @@ -45,6 +71,8 @@ interface BitwardenPasswordProtectedInput extends BitwardenJsonInput { const COMMON_IMPORT_SOURCE_IDS: ImportSourceId[] = [ 'bitwarden_json', 'bitwarden_csv', + 'bitwarden_zip', + 'nodewarden_json', 'onepassword_1pux', 'onepassword_1pif', 'onepassword_mac_csv', @@ -80,7 +108,7 @@ async function derivePasswordProtectedFileKey( 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.'); + throw new Error(t('txt_import_invalid_password_protected_file')); } let keyMaterial: Uint8Array; @@ -113,11 +141,11 @@ async function derivePasswordProtectedFileKey( async function decryptPasswordProtectedExport(parsed: BitwardenPasswordProtectedInput, password: string): Promise { if (!parsed.encKeyValidation_DO_NOT_EDIT || !parsed.data) { - throw new Error('Invalid password-protected export file.'); + throw new Error(t('txt_import_invalid_password_protected_file')); } const pass = String(password || '').trim(); if (!pass) { - throw new Error('Please enter file password.'); + throw new Error(t('txt_import_file_password_required')); } const key = await derivePasswordProtectedFileKey(parsed, pass); @@ -131,7 +159,7 @@ async function decryptPasswordProtectedExport(parsed: BitwardenPasswordProtected try { return JSON.parse(plainJson); } catch { - throw new Error('Failed to decrypt import file.'); + throw new Error(t('txt_import_decrypt_failed')); } } @@ -142,7 +170,7 @@ function isZipPayload(bytes: Uint8Array): boolean { 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.'); + if (!fileNames.length) throw new Error(t('txt_import_empty_zip_archive')); const preferred = source === 'onepassword_1pux' ? ['export.data', 'export.json'] : ['protonpass.json', 'export.json']; for (const p of preferred) { @@ -152,7 +180,7 @@ function readZipText(bytes: Uint8Array, source: ImportSourceId): string { 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.'); + throw new Error(t('txt_import_no_json_found_in_zip')); } async function readImportText(file: File, source: ImportSourceId): Promise { @@ -164,21 +192,128 @@ async function readImportText(file: File, source: ImportSourceId): Promise { + const password = String(passwordRaw || '').trim(); + const reader = new ZipReader(new BlobReader(file), { useWebWorkers: false }); + try { + const entries = await reader.getEntries(); + if (!entries.length) throw new Error(t('txt_import_empty_zip_archive')); + + let jsonText = ''; + const attachments: ImportAttachmentFile[] = []; + const options = password ? { password } : undefined; + + for (const entry of entries) { + if (entry.directory) continue; + const name = String(entry.filename || '').trim().replace(/\\/g, '/'); + if (!name) continue; + + const bytes = await entry.getData(new Uint8ArrayWriter(), options); + const lower = name.toLowerCase(); + if (lower === 'data.json') { + jsonText = new TextDecoder().decode(bytes); + continue; + } + + const attachmentMatch = name.match(/^attachments\/([^/]+)\/(.+)$/i); + if (!attachmentMatch) continue; + const sourceCipherId = String(attachmentMatch[1] || '').trim() || null; + const fileName = String(attachmentMatch[2] || '').trim() || 'attachment.bin'; + attachments.push({ + sourceCipherId, + sourceCipherIndex: null, + fileName, + bytes, + }); + } + + if (!jsonText) throw new Error(t('txt_import_data_json_not_found')); + return { jsonText, attachments }; + } catch (error) { + if (looksLikeZipPasswordError(error)) { + if (!password) throw new ZipNeedsPasswordError(t('txt_import_zip_password_required')); + throw new ZipInvalidPasswordError(t('txt_import_invalid_zip_password')); + } + if (!password && error instanceof Error && /invalid|corrupt|unsupported/.test(error.message.toLowerCase())) { + throw error; + } + throw error; + } finally { + await reader.close(); + } +} + +function parseNodeWardenAttachmentArray(raw: unknown): ImportAttachmentFile[] { + if (!Array.isArray(raw)) return []; + const out: ImportAttachmentFile[] = []; + for (const entry of raw) { + if (!entry || typeof entry !== 'object') continue; + const row = entry as Record; + const fileName = String(row.fileName || '').trim() || 'attachment.bin'; + const base64 = String(row.data || '').trim(); + if (!base64) continue; + try { + const bytes = base64ToBytes(base64); + const sourceCipherId = String(row.cipherId || '').trim() || null; + const indexRaw = Number(row.cipherIndex); + out.push({ + sourceCipherId, + sourceCipherIndex: Number.isFinite(indexRaw) ? indexRaw : null, + fileName, + bytes, + }); + } catch { + // skip malformed attachment row + } + } + return out; +} + +export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys, onNotify, folders, onExport }: 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 [pendingPasswordImport, setPendingPasswordImport] = useState(null); + const [zipPasswordDialogOpen, setZipPasswordDialogOpen] = useState(false); + const [zipImportPassword, setZipImportPassword] = useState(''); + const [pendingZipFile, setPendingZipFile] = useState(null); + const [isZipPasswordSubmitting, setIsZipPasswordSubmitting] = useState(false); const [folderMode, setFolderMode] = useState<'original' | 'none' | 'target'>('original'); const [targetFolderId, setTargetFolderId] = useState(''); + const [exportFormat, setExportFormat] = useState('bitwarden_json'); + const [encryptedJsonMode, setEncryptedJsonMode] = useState('account'); + const [exportPassword, setExportPassword] = useState(''); + const [zipPassword, setZipPassword] = useState(''); + const [isExporting, setIsExporting] = useState(false); + const [exportAuthDialogOpen, setExportAuthDialogOpen] = useState(false); + const [exportAuthPassword, setExportAuthPassword] = useState(''); + const [importSummary, setImportSummary] = useState(null); 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 { + async function runBitwardenJsonImport(parsed: unknown, attachments: ImportAttachmentFile[] = []): Promise { if (isRecord(parsed) && parsed.encrypted === true) { const accountEncrypted = parsed as BitwardenJsonInput; if (!accountKeys?.encB64 || !accountKeys?.macB64) { @@ -193,16 +328,53 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys } catch { throw new Error('This encrypted export belongs to another account.'); } - await onImportEncryptedRaw(normalizeBitwardenEncryptedAccountImport(accountEncrypted), { + return onImportEncryptedRaw( + normalizeBitwardenEncryptedAccountImport(accountEncrypted), + { + folderMode, + targetFolderId: folderMode === 'target' ? targetFolderId || null : null, + }, + attachments + ); + } + return onImport( + normalizeBitwardenImport(parsed), + { folderMode, targetFolderId: folderMode === 'target' ? targetFolderId || null : null, - }); - return; + }, + attachments + ); + } + + async function extractNodeWardenAttachments(parsed: unknown): Promise { + if (!isRecord(parsed)) return []; + const direct = parseNodeWardenAttachmentArray(parsed.nodewardenAttachments); + if (direct.length) return direct; + + const encryptedPayload = String(parsed.nodewardenAttachmentsEnc || '').trim(); + if (!encryptedPayload) return []; + if (!accountKeys?.encB64 || !accountKeys?.macB64) { + throw new Error('Vault key unavailable. Please unlock vault and try again.'); } - await onImport(normalizeBitwardenImport(parsed), { - folderMode, - targetFolderId: folderMode === 'target' ? targetFolderId || null : null, - }); + const accountEnc = base64ToBytes(accountKeys.encB64); + const accountMac = base64ToBytes(accountKeys.macB64); + const plain = await decryptStr(encryptedPayload, accountEnc, accountMac); + const unpacked = JSON.parse(plain) as Record; + return parseNodeWardenAttachmentArray(unpacked.nodewardenAttachments); + } + + async function runNodeWardenJsonImport(parsed: unknown, extraAttachments: ImportAttachmentFile[] = []): Promise { + const bundled = await extractNodeWardenAttachments(parsed); + return runBitwardenJsonImport(parsed, [...bundled, ...extraAttachments]); + } + + async function processPasswordProtectedImport(ctx: PendingPasswordImportContext): Promise { + const parsed = await decryptPasswordProtectedExport(ctx.parsed, importPassword); + if (ctx.source === 'nodewarden_json') { + return runNodeWardenJsonImport(parsed, ctx.attachments); + } + return runBitwardenJsonImport(parsed, ctx.attachments); } async function handleSubmit() { @@ -213,31 +385,77 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys setIsSubmitting(true); try { + if (source === 'bitwarden_zip') { + try { + const bundle = await readBitwardenZipPayload(file, ''); + let parsed: unknown; + try { + parsed = JSON.parse(bundle.jsonText); + } catch { + throw new Error(t('txt_import_invalid_json_file')); + } + if (isPasswordProtectedExport(parsed)) { + setPendingPasswordImport({ + parsed, + source: 'bitwarden_zip', + attachments: bundle.attachments, + }); + setImportPassword(''); + setPasswordDialogOpen(true); + return; + } + const summary = await runBitwardenJsonImport(parsed, bundle.attachments); + setImportSummary(summary); + setFile(null); + return; + } catch (error) { + if (error instanceof ZipNeedsPasswordError) { + setPendingZipFile(file); + setZipImportPassword(''); + setZipPasswordDialogOpen(true); + return; + } + throw error; + } + } + const text = await readImportText(file, source); - if (source === 'bitwarden_json') { + if (source === 'bitwarden_json' || source === 'nodewarden_json') { let parsed: unknown; try { parsed = JSON.parse(text); } catch { - throw new Error('Invalid JSON file'); + throw new Error(t('txt_import_invalid_json_file')); } if (isPasswordProtectedExport(parsed)) { - setPendingPasswordImport(parsed); + setPendingPasswordImport({ + parsed, + source, + attachments: [], + }); setImportPassword(''); setPasswordDialogOpen(true); return; } - await runBitwardenJsonImport(parsed); + const summary = + source === 'nodewarden_json' + ? await runNodeWardenJsonImport(parsed) + : await runBitwardenJsonImport(parsed); + setImportSummary(summary); } else { - await onImport(parseImportPayloadBySource(source, text), { - folderMode, - targetFolderId: folderMode === 'target' ? targetFolderId || null : null, - }); + const summary = await onImport( + parseImportPayloadBySource(source, text), + { + folderMode, + targetFolderId: folderMode === 'target' ? targetFolderId || null : null, + }, + [] + ); + setImportSummary(summary); } setFile(null); - onNotify('success', 'Import completed'); } catch (error) { - const message = error instanceof Error ? error.message : 'Import failed'; + const message = error instanceof Error ? error.message : t('txt_import_failed'); onNotify('error', message); } finally { setIsSubmitting(false); @@ -248,31 +466,130 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys if (!pendingPasswordImport) return; setIsPasswordSubmitting(true); try { - const parsed = await decryptPasswordProtectedExport(pendingPasswordImport, importPassword); - await runBitwardenJsonImport(parsed); + const summary = await processPasswordProtectedImport(pendingPasswordImport); + setImportSummary(summary); setFile(null); setImportPassword(''); setPendingPasswordImport(null); setPasswordDialogOpen(false); - onNotify('success', 'Import completed'); } catch (error) { - const message = error instanceof Error ? error.message : 'Import failed'; + const message = error instanceof Error ? error.message : t('txt_import_failed'); onNotify('error', message); } finally { setIsPasswordSubmitting(false); } } + async function handleZipPasswordImportConfirm() { + if (!pendingZipFile) return; + setIsZipPasswordSubmitting(true); + try { + const bundle = await readBitwardenZipPayload(pendingZipFile, zipImportPassword); + let parsed: unknown; + try { + parsed = JSON.parse(bundle.jsonText); + } catch { + throw new Error(t('txt_import_invalid_json_file')); + } + if (isPasswordProtectedExport(parsed)) { + setPendingPasswordImport({ + parsed, + source: 'bitwarden_zip', + attachments: bundle.attachments, + }); + setImportPassword(''); + setPasswordDialogOpen(true); + } else { + const summary = await runBitwardenJsonImport(parsed, bundle.attachments); + setImportSummary(summary); + setFile(null); + } + setZipPasswordDialogOpen(false); + setPendingZipFile(null); + setZipImportPassword(''); + } catch (error) { + if (error instanceof ZipInvalidPasswordError) { + onNotify('error', t('txt_import_invalid_zip_password')); + return; + } + const message = error instanceof Error ? error.message : t('txt_import_failed'); + onNotify('error', message); + } finally { + setIsZipPasswordSubmitting(false); + } + } + + const exportNeedsMode = + exportFormat === 'bitwarden_encrypted_json' || + exportFormat === 'bitwarden_encrypted_json_zip' || + exportFormat === 'nodewarden_encrypted_json'; + const exportNeedsFilePassword = exportNeedsMode && encryptedJsonMode === 'password'; + const exportIsZip = exportFormat === 'bitwarden_json_zip' || exportFormat === 'bitwarden_encrypted_json_zip'; + + async function runExportWithMasterPassword(masterPassword: string) { + const filePassword = exportPassword.trim(); + const zipPass = zipPassword.trim(); + if (exportNeedsFilePassword && !filePassword) { + onNotify('error', t('txt_import_file_password_required')); + return; + } + + setIsExporting(true); + try { + const payload = await onExport({ + format: exportFormat, + encryptedJsonMode: exportNeedsMode ? encryptedJsonMode : undefined, + filePassword, + zipPassword: exportIsZip ? zipPass : '', + masterPassword, + }); + const blobBytes = Uint8Array.from(payload.bytes); + const blob = new Blob([blobBytes], { type: payload.mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = payload.fileName; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + onNotify('success', t('txt_export_completed')); + } catch (error) { + const message = error instanceof Error ? error.message : t('txt_export_failed'); + onNotify('error', message); + } finally { + setIsExporting(false); + } + } + + async function handleExportConfirmPassword() { + const masterPassword = String(exportAuthPassword || '').trim(); + if (!masterPassword) { + onNotify('error', t('txt_master_password_is_required')); + return; + } + await runExportWithMasterPassword(masterPassword); + if (!isExporting) { + setExportAuthPassword(''); + setExportAuthDialogOpen(false); + } + } + + function handleExport() { + setExportAuthPassword(''); + setExportAuthDialogOpen(true); + } + return (
-

Import

+

{t('txt_import')}

- Import vault data into your current account. + {t('txt_import_vault_data_hint')}

+ +
+
+ void handleExportConfirmPassword()} + onCancel={() => { + if (isExporting) return; + setExportAuthDialogOpen(false); + setExportAuthPassword(''); + }} + > + + + void handlePasswordImportConfirm()} @@ -364,7 +777,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys }} > + + void handleZipPasswordImportConfirm()} + onCancel={() => { + if (isZipPasswordSubmitting) return; + setZipPasswordDialogOpen(false); + setZipImportPassword(''); + setPendingZipFile(null); + }} + > + + + + {importSummary && ( +
+
+ +

{t('txt_import_success')}

+
{t('txt_import_success_number_of_items', { count: importSummary.totalItems })}
+
+ + + + + + + + + {importSummary.typeCounts.map((row) => ( + + + + + ))} + + + + + +
{t('txt_type')}{t('txt_total')}
{row.label}{row.count}
{t('txt_folder')}{importSummary.folderCount}
+
+ +
+
+ )}
); } diff --git a/webapp/src/components/SendsPage.tsx b/webapp/src/components/SendsPage.tsx index 7433048..1965efc 100644 --- a/webapp/src/components/SendsPage.tsx +++ b/webapp/src/components/SendsPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'preact/hooks'; -import { Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Send as SendIcon, Trash2 } from 'lucide-preact'; +import { CheckCheck, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact'; import type { Send, SendDraft } from '@/lib/types'; import { t } from '@/lib/i18n'; @@ -224,10 +224,12 @@ export default function SendsPage(props: SendsPageProps) { setSelectedMap(map); }} > + {t('txt_select_all')} {!!selectedCount && ( )} @@ -364,8 +366,12 @@ export default function SendsPage(props: SendsPageProps) {
- - + +
)} diff --git a/webapp/src/components/SettingsPage.tsx b/webapp/src/components/SettingsPage.tsx index d03a5b9..dc0393b 100644 --- a/webapp/src/components/SettingsPage.tsx +++ b/webapp/src/components/SettingsPage.tsx @@ -182,6 +182,7 @@ export default function SettingsPage(props: SettingsPageProps) { props.onNotify?.('success', t('txt_recovery_code_copied')); }} > + {t('txt_copy_code')} diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index 4b1a69d..0917cb7 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -6,6 +6,7 @@ import { CheckCheck, Clipboard, CreditCard, + Download, Eye, EyeOff, ExternalLink, @@ -17,6 +18,7 @@ import { Globe, KeyRound, LayoutGrid, + Paperclip, Pencil, Plus, RefreshCw, @@ -25,9 +27,10 @@ import { StarOff, StickyNote, Trash2, + Upload, X, } from 'lucide-preact'; -import type { Cipher, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types'; +import type { Cipher, CipherAttachment, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types'; import { t } from '@/lib/i18n'; interface VaultPageProps { @@ -36,8 +39,8 @@ interface VaultPageProps { loading: boolean; emailForReprompt: string; onRefresh: () => Promise; - onCreate: (draft: VaultDraft) => Promise; - onUpdate: (cipher: Cipher, draft: VaultDraft) => Promise; + onCreate: (draft: VaultDraft, attachments?: File[]) => Promise; + onUpdate: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise; onDelete: (cipher: Cipher) => Promise; onBulkDelete: (ids: string[]) => Promise; onBulkMove: (ids: string[], folderId: string | null) => Promise; @@ -45,6 +48,7 @@ interface VaultPageProps { onNotify: (type: 'success' | 'error', text: string) => void; onCreateFolder: (name: string) => Promise; onDeleteFolder: (folderId: string) => Promise; + onDownloadAttachment: (cipher: Cipher, attachmentId: string) => Promise; } type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh'; @@ -269,6 +273,25 @@ function formatHistoryTime(value: string | null | undefined): string { return date.toLocaleString(); } +function parseAttachmentSizeBytes(attachment: CipherAttachment): number { + const raw = attachment?.size; + if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw; + const parsed = Number(raw); + if (Number.isFinite(parsed) && parsed >= 0) return parsed; + return 0; +} + +function formatAttachmentSize(attachment: CipherAttachment): string { + const sizeName = String(attachment?.sizeName || '').trim(); + if (sizeName) return sizeName; + const bytes = parseAttachmentSizeBytes(attachment); + if (bytes <= 0) return '0 B'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + function firstPasskeyCreationTime(cipher: Cipher | null): string | null { const credentials = cipher?.login?.fido2Credentials; if (!Array.isArray(credentials) || credentials.length === 0) return null; @@ -343,11 +366,14 @@ export default function VaultPage(props: VaultPageProps) { const [pendingDeleteFolder, setPendingDeleteFolder] = useState(null); const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null); const [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState>({}); + const [attachmentQueue, setAttachmentQueue] = useState([]); + const [removedAttachmentIds, setRemovedAttachmentIds] = useState>({}); const [busy, setBusy] = useState(false); const [repromptOpen, setRepromptOpen] = useState(false); const [repromptPassword, setRepromptPassword] = useState(''); const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState(null); const createMenuRef = useRef(null); + const attachmentInputRef = useRef(null); const sshSeedTicketRef = useRef(0); const sshFingerprintTicketRef = useRef(0); @@ -436,6 +462,19 @@ export default function VaultPage(props: VaultPageProps) { [props.ciphers, selectedCipherId] ); const passkeyCreatedAt = firstPasskeyCreationTime(selectedCipher); + const selectedAttachments = useMemo( + () => (Array.isArray(selectedCipher?.attachments) ? selectedCipher.attachments : []), + [selectedCipher] + ); + const editExistingAttachments = useMemo( + () => + selectedAttachments.filter((attachment) => { + const id = String(attachment?.id || '').trim(); + return !!id; + }), + [selectedAttachments] + ); + const removedAttachmentCount = useMemo(() => Object.values(removedAttachmentIds).filter(Boolean).length, [removedAttachmentIds]); useEffect(() => { const raw = selectedCipher?.login?.decTotp || ''; @@ -487,6 +526,8 @@ function folderName(id: string | null | undefined): string { setSelectedCipherId(''); setShowPassword(false); setLocalError(''); + setAttachmentQueue([]); + setRemovedAttachmentIds({}); if (type === 5) void seedSshDefaults(); } @@ -497,6 +538,8 @@ function folderName(id: string | null | undefined): string { setIsEditing(true); setShowPassword(false); setLocalError(''); + setAttachmentQueue([]); + setRemovedAttachmentIds({}); } function cancelEdit(): void { @@ -504,6 +547,8 @@ function folderName(id: string | null | undefined): string { setIsEditing(false); setIsCreating(false); setLocalError(''); + setAttachmentQueue([]); + setRemovedAttachmentIds({}); } function updateDraft(patch: Partial): void { @@ -572,6 +617,28 @@ function folderName(id: string | null | undefined): string { }); } + function queueAttachmentFiles(list: FileList | null): void { + if (!list || !list.length) return; + const next = Array.from(list).filter((file) => file && file.size >= 0); + if (!next.length) return; + setAttachmentQueue((prev) => [...prev, ...next]); + } + + function removeQueuedAttachment(index: number): void { + setAttachmentQueue((prev) => prev.filter((_, i) => i !== index)); + } + + function toggleExistingAttachmentRemoval(attachmentId: string): void { + const id = String(attachmentId || '').trim(); + if (!id) return; + setRemovedAttachmentIds((prev) => { + const next = { ...prev }; + if (next[id]) delete next[id]; + else next[id] = true; + return next; + }); + } + async function saveDraft(): Promise { if (!draft) return; let nextDraft = draft; @@ -589,14 +656,20 @@ function folderName(id: string | null | undefined): string { setBusy(true); try { if (isCreating) { - await props.onCreate(nextDraft); + await props.onCreate(nextDraft, attachmentQueue); } else if (selectedCipher) { - await props.onUpdate(selectedCipher, nextDraft); + const removeAttachmentIds = Object.keys(removedAttachmentIds).filter((id) => !!removedAttachmentIds[id]); + await props.onUpdate(selectedCipher, nextDraft, { + addFiles: attachmentQueue, + removeAttachmentIds, + }); } setIsCreating(false); setIsEditing(false); setDraft(null); setLocalError(''); + setAttachmentQueue([]); + setRemovedAttachmentIds({}); } finally { setBusy(false); } @@ -864,6 +937,9 @@ function folderName(id: string | null | undefined): string { type="button" className="row-main" onClick={() => { + if (isEditing || isCreating) { + cancelEdit(); + } setSelectedCipherId(cipher.id); setRepromptApprovedCipherId(null); }} @@ -971,6 +1047,7 @@ function folderName(id: string | null | undefined): string { className="btn btn-secondary small" onClick={() => updateDraft({ loginUris: draft.loginUris.filter((_, i) => i !== index) })} > + {t('txt_remove')} )} @@ -1059,6 +1136,104 @@ function folderName(id: string | null | undefined): string { )} +
+
+

{t('txt_attachments')}

+ +
+ {!isCreating && selectedCipher && editExistingAttachments.length > 0 && ( +
+ {editExistingAttachments.map((attachment) => { + const attachmentId = String(attachment?.id || '').trim(); + if (!attachmentId) return null; + const removed = !!removedAttachmentIds[attachmentId]; + const fileName = String(attachment.decFileName || attachment.fileName || attachmentId).trim() || attachmentId; + return ( +
+
+ +
+ {fileName} + {formatAttachmentSize(attachment)} +
+
+
+ + +
+
+ ); + })} +
+ )} + {!!removedAttachmentCount && ( +
{t('txt_marked_for_removal_count', { count: removedAttachmentCount })}
+ )} + { + const input = e.currentTarget as HTMLInputElement; + queueAttachmentFiles(input.files); + input.value = ''; + }} + /> + {!!attachmentQueue.length && ( +
+
{t('txt_new_attachments')}
+ {attachmentQueue.map((file, index) => ( +
+
+ +
+ {file.name} + {formatAttachmentSize({ size: file.size })} +
+
+
+ +
+
+ ))} +
+ )} +
+

{t('txt_additional_options')}

@@ -1114,14 +1290,17 @@ function folderName(id: string | null | undefined): string {
{!isCreating && selectedCipher && ( )} @@ -1351,6 +1530,39 @@ function folderName(id: string | null | undefined): string {
)} + {selectedAttachments.some((attachment) => String(attachment?.id || '').trim()) && ( +
+

{t('txt_attachments')}

+
+ {selectedAttachments.map((attachment) => { + const attachmentId = String(attachment?.id || '').trim(); + if (!attachmentId) return null; + const fileName = String(attachment.decFileName || attachment.fileName || attachmentId).trim() || attachmentId; + return ( +
+
+ +
+ {fileName} + {formatAttachmentSize(attachment)} +
+
+
+ +
+
+ ); + })} +
+
+ )} + {(selectedCipher.creationDate || selectedCipher.revisionDate) && (

{t('txt_item_history')}

diff --git a/webapp/src/lib/api.ts b/webapp/src/lib/api.ts index eb121c1..1aabd79 100644 --- a/webapp/src/lib/api.ts +++ b/webapp/src/lib/api.ts @@ -80,6 +80,13 @@ export interface PreloginResult { kdfIterations: number; } +export interface PreloginKdfConfig { + kdfType: number; + kdfIterations: number; + kdfMemory: number | null; + kdfParallelism: number | null; +} + function randomHex(length: number): string { const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2)))); return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length); @@ -130,6 +137,24 @@ export async function deriveLoginHash(email: string, password: string, fallbackI return { hash: bytesToBase64(hash), masterKey, kdfIterations: iterations }; } +export async function getPreloginKdfConfig(email: string, fallbackIterations: number): Promise { + const normalized = String(email || '').trim().toLowerCase(); + if (!normalized) throw new Error('Email is required'); + const pre = await fetch('/identity/accounts/prelogin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: normalized }), + }); + if (!pre.ok) throw new Error('prelogin failed'); + const data = (await parseJson<{ kdf?: number; kdfIterations?: number; kdfMemory?: number | null; kdfParallelism?: number | null }>(pre)) || {}; + return { + kdfType: Number(data.kdf ?? 0) || 0, + kdfIterations: Number(data.kdfIterations || fallbackIterations), + kdfMemory: data.kdfMemory == null ? null : Number(data.kdfMemory), + kdfParallelism: data.kdfParallelism == null ? null : Number(data.kdfParallelism), + }; +} + export async function loginWithPassword( email: string, passwordHash: string, @@ -369,16 +394,213 @@ export interface CiphersImportPayload { folderRelationships: Array<{ key: number; value: number }>; } +export interface ImportedCipherMapEntry { + index: number; + sourceId: string | null; + id: string; +} + export async function importCiphers( authedFetch: (input: string, init?: RequestInit) => Promise, - payload: CiphersImportPayload -): Promise { - const resp = await authedFetch('/api/ciphers/import', { + payload: CiphersImportPayload, + options?: { returnCipherMap?: boolean } +): Promise { + const returnCipherMap = !!options?.returnCipherMap; + const url = returnCipherMap ? '/api/ciphers/import?returnCipherMap=1' : '/api/ciphers/import'; + const resp = await authedFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Import failed')); + if (!returnCipherMap) return null; + const body = + (await parseJson<{ + cipherMap?: Array<{ index?: number; sourceId?: string | null; id?: string }>; + }>(resp)) || {}; + if (!Array.isArray(body.cipherMap)) return []; + const out: ImportedCipherMapEntry[] = []; + for (const row of body.cipherMap) { + const index = Number(row?.index); + const id = String(row?.id || '').trim(); + if (!Number.isFinite(index) || !id) continue; + const sourceRaw = String(row?.sourceId || '').trim(); + out.push({ + index, + id, + sourceId: sourceRaw || null, + }); + } + return out; +} + +export interface AttachmentDownloadInfo { + id: string; + url: string; + fileName: string | null; + key: string | null; + size: string | null; + sizeName: string | null; +} + +export async function getAttachmentDownloadInfo( + authedFetch: (input: string, init?: RequestInit) => Promise, + cipherId: string, + attachmentId: string +): Promise { + const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipherId)}/attachment/${encodeURIComponent(attachmentId)}`); + if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Failed to load attachment')); + const body = + (await parseJson<{ + id?: string; + url?: string; + fileName?: string | null; + key?: string | null; + size?: string | null; + sizeName?: string | null; + }>(resp)) || {}; + const id = String(body.id || attachmentId || '').trim(); + const url = String(body.url || '').trim(); + if (!id || !url) throw new Error('Invalid attachment download response'); + return { + id, + url, + fileName: body.fileName ?? null, + key: body.key ?? null, + size: body.size ?? null, + sizeName: body.sizeName ?? null, + }; +} + +function looksLikeCipherString(value: unknown): boolean { + return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim()); +} + +export async function uploadCipherAttachment( + authedFetch: (input: string, init?: RequestInit) => Promise, + session: SessionState, + cipherId: string, + file: File, + cipherForKey?: Cipher | null +): Promise { + if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable'); + const id = String(cipherId || '').trim(); + if (!id) throw new Error('Cipher id is required'); + if (!file) throw new Error('File is required'); + + const userEnc = base64ToBytes(session.symEncKey); + const userMac = base64ToBytes(session.symMacKey); + const itemKeys = await getCipherKeys(cipherForKey || null, userEnc, userMac); + + const encryptedFileName = await encryptTextValue(file.name, itemKeys.enc, itemKeys.mac); + if (!encryptedFileName) throw new Error('Invalid attachment name'); + + const attachmentRawKey = crypto.getRandomValues(new Uint8Array(64)); + const attachmentWrappedKey = await encryptBw(attachmentRawKey, itemKeys.enc, itemKeys.mac); + const fileBytes = new Uint8Array(await file.arrayBuffer()); + const encryptedBytes = await encryptBwFileData(fileBytes, attachmentRawKey.slice(0, 32), attachmentRawKey.slice(32, 64)); + + const metaResp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/attachment/v2`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + fileName: encryptedFileName, + key: attachmentWrappedKey, + fileSize: encryptedBytes.byteLength, + }), + }); + if (!metaResp.ok) throw new Error(await parseErrorMessage(metaResp, 'Create attachment failed')); + + const meta = + (await parseJson<{ + attachmentId?: string; + url?: string; + }>(metaResp)) || {}; + const attachmentId = String(meta.attachmentId || '').trim(); + const uploadUrl = String(meta.url || '').trim(); + if (!attachmentId || !uploadUrl) throw new Error('Create attachment failed'); + + const payload = new ArrayBuffer(encryptedBytes.byteLength); + new Uint8Array(payload).set(encryptedBytes); + const formData = new FormData(); + formData.set('data', new Blob([payload], { type: 'application/octet-stream' }), encryptedFileName); + + const uploadResp = await authedFetch(uploadUrl, { + method: 'POST', + body: formData, + }); + if (!uploadResp.ok) { + try { + await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/attachment/${encodeURIComponent(attachmentId)}`, { method: 'DELETE' }); + } catch { + // ignore rollback failure + } + throw new Error(await parseErrorMessage(uploadResp, 'Upload attachment failed')); + } +} + +export async function deleteCipherAttachment( + authedFetch: (input: string, init?: RequestInit) => Promise, + cipherId: string, + attachmentId: string +): Promise { + const cid = String(cipherId || '').trim(); + const aid = String(attachmentId || '').trim(); + if (!cid || !aid) throw new Error('Attachment id is required'); + const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cid)}/attachment/${encodeURIComponent(aid)}`, { + method: 'DELETE', + }); + if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Delete attachment failed')); +} + +export async function downloadCipherAttachmentDecrypted( + authedFetch: (input: string, init?: RequestInit) => Promise, + session: SessionState, + cipher: Cipher, + attachmentId: string +): Promise<{ fileName: string; bytes: Uint8Array }> { + if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable'); + const cid = String(cipher?.id || '').trim(); + const aid = String(attachmentId || '').trim(); + if (!cid || !aid) throw new Error('Attachment id is required'); + + const info = await getAttachmentDownloadInfo(authedFetch, cid, aid); + const rawResp = await fetch(info.url, { cache: 'no-store' }); + if (!rawResp.ok) throw new Error('Download attachment failed'); + const encryptedBytes = new Uint8Array(await rawResp.arrayBuffer()); + + const userEnc = base64ToBytes(session.symEncKey); + const userMac = base64ToBytes(session.symMacKey); + const itemKeys = await getCipherKeys(cipher, userEnc, userMac); + + let fileEnc = itemKeys.enc; + let fileMac = itemKeys.mac; + const keyCipher = String(info.key || '').trim(); + if (keyCipher && looksLikeCipherString(keyCipher)) { + try { + const fileRawKey = await decryptBw(keyCipher, itemKeys.enc, itemKeys.mac); + if (fileRawKey.length >= 64) { + fileEnc = fileRawKey.slice(0, 32); + fileMac = fileRawKey.slice(32, 64); + } + } catch { + // fallback to item key + } + } + + const plainBytes = await decryptBwFileData(encryptedBytes, fileEnc, fileMac); + + const fileNameRaw = String(info.fileName || '').trim(); + let fileName = fileNameRaw || `attachment-${aid}`; + if (fileNameRaw && looksLikeCipherString(fileNameRaw)) { + try { + fileName = (await decryptStr(fileNameRaw, itemKeys.enc, itemKeys.mac)) || fileName; + } catch { + // keep fallback name + } + } + + return { fileName, bytes: plainBytes }; } export async function getSends(authedFetch: (input: string, init?: RequestInit) => Promise): Promise { diff --git a/webapp/src/lib/export-formats.ts b/webapp/src/lib/export-formats.ts new file mode 100644 index 0000000..97f41f7 --- /dev/null +++ b/webapp/src/lib/export-formats.ts @@ -0,0 +1,694 @@ +import { argon2idAsync } from '@noble/hashes/argon2.js'; +import { strToU8, zipSync } from 'fflate'; +import { Uint8ArrayReader, Uint8ArrayWriter, ZipReader, ZipWriter, configure as configureZipJs } from '@zip.js/zip.js'; +import type { PreloginKdfConfig } from './api'; +import { base64ToBytes, bytesToBase64, decryptBw, decryptStr, encryptBw, hkdfExpand, pbkdf2 } from './crypto'; +import type { Cipher, Folder } from './types'; + +configureZipJs({ useWebWorkers: false }); + +export const EXPORT_FORMATS = [ + { id: 'bitwarden_json', label: 'Bitwarden (vault as json)' }, + { id: 'bitwarden_encrypted_json', label: 'Bitwarden (encrypted vault as json)' }, + { id: 'bitwarden_json_zip', label: 'Bitwarden (vault + attachments as zip)' }, + { id: 'bitwarden_encrypted_json_zip', label: 'Bitwarden (encrypted vault + attachments as zip)' }, + { id: 'nodewarden_json', label: 'NodeWarden (vault + attachments as json)' }, + { id: 'nodewarden_encrypted_json', label: 'NodeWarden (encrypted vault + attachments as json)' }, +] as const; + +export type ExportFormatId = (typeof EXPORT_FORMATS)[number]['id']; +export type EncryptedJsonMode = 'account' | 'password'; + +export interface ExportRequest { + format: ExportFormatId; + encryptedJsonMode?: EncryptedJsonMode; + filePassword?: string; + zipPassword?: string; + masterPassword?: string; +} + +export interface ExportDownloadPayload { + fileName: string; + mimeType: string; + bytes: Uint8Array; +} + +export interface ZipAttachmentEntry { + cipherId: string; + fileName: string; + bytes: Uint8Array; +} + +export interface NodeWardenAttachmentRecord { + cipherId: string; + cipherIndex: number | null; + fileName: string; + data: string; +} + +interface BuildPlainJsonArgs { + folders: Folder[]; + ciphers: Cipher[]; + userEncB64: string; + userMacB64: string; +} + +interface BuildEncryptedJsonArgs { + folders: Folder[]; + ciphers: Cipher[]; + userEncB64: string; + userMacB64: string; +} + +interface PasswordProtectedArgs { + plaintextJson: string; + password: string; + kdf: PreloginKdfConfig; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} + +function isCipherString(value: string): boolean { + return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim()); +} + +function normalizeString(value: unknown): string | null { + if (value === null || value === undefined) return null; + return String(value); +} + +function normalizeNumber(value: unknown, fallback = 0): number { + const n = Number(value); + if (!Number.isFinite(n)) return fallback; + return n; +} + +function cloneValue(value: T): T { + if (value === null || value === undefined) return value; + if (typeof structuredClone === 'function') { + try { + return structuredClone(value); + } catch { + // ignore and fallback + } + } + try { + return JSON.parse(JSON.stringify(value)) as T; + } catch { + return value; + } +} + +function randomGuid(): string { + if (typeof crypto.randomUUID === 'function') return crypto.randomUUID(); + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +function toAesBuffer(bytes: Uint8Array): ArrayBuffer { + return new Uint8Array(bytes).buffer; +} + +async function getCipherKeyParts(cipher: Cipher, userEnc: Uint8Array, userMac: Uint8Array): Promise<{ enc: Uint8Array; mac: Uint8Array }> { + if (cipher.key && typeof cipher.key === 'string') { + try { + const raw = await decryptBw(cipher.key, userEnc, userMac); + if (raw.length >= 64) { + return { enc: raw.slice(0, 32), mac: raw.slice(32, 64) }; + } + } catch { + // Fallback to user key. + } + } + return { enc: userEnc, mac: userMac }; +} + +async function decryptMaybe(value: unknown, enc: Uint8Array, mac: Uint8Array): Promise { + if (value === null || value === undefined) return null; + if (typeof value !== 'string') return String(value); + const raw = value; + if (!raw) return ''; + if (!isCipherString(raw)) return raw; + try { + return await decryptStr(raw, enc, mac); + } catch { + return raw; + } +} + +async function deepDecryptUnknown(value: unknown, enc: Uint8Array, mac: Uint8Array): Promise { + if (value === null || value === undefined) return value; + if (typeof value === 'string') return decryptMaybe(value, enc, mac); + if (Array.isArray(value)) { + return Promise.all(value.map((item) => deepDecryptUnknown(item, enc, mac))); + } + if (isRecord(value)) { + const out: Record = {}; + for (const [k, v] of Object.entries(value)) { + out[k] = await deepDecryptUnknown(v, enc, mac); + } + return out; + } + return value; +} + +function mapCipherCommonMetadata(cipher: Cipher): Record { + const out: Record = { + id: cipher.id, + type: normalizeNumber(cipher.type, 1), + reprompt: normalizeNumber(cipher.reprompt, 0), + favorite: !!cipher.favorite, + folderId: normalizeString(cipher.folderId), + creationDate: normalizeString(cipher.creationDate), + revisionDate: normalizeString(cipher.revisionDate), + collectionIds: null, + }; + if ((out.creationDate as string | null) === null) delete out.creationDate; + if ((out.revisionDate as string | null) === null) delete out.revisionDate; + if ((out.folderId as string | null) === null) delete out.folderId; + return out; +} + +function mapCipherEncrypted(cipher: Cipher): Record { + const out = mapCipherCommonMetadata(cipher); + out.name = cipher.name ?? null; + out.notes = cipher.notes ?? null; + out.key = cipher.key ?? null; + out.fields = Array.isArray(cipher.fields) + ? cipher.fields.map((field) => ({ + name: field?.name ?? null, + value: field?.value ?? null, + type: normalizeNumber(field?.type, 0), + linkedId: field?.linkedId ?? null, + })) + : []; + + const login = cipher.login; + out.login = login + ? { + username: login.username ?? null, + password: login.password ?? null, + totp: login.totp ?? null, + uris: Array.isArray(login.uris) + ? login.uris.map((uri) => ({ + uri: uri?.uri ?? null, + match: (uri as { match?: unknown })?.match ?? null, + })) + : [], + fido2Credentials: Array.isArray(login.fido2Credentials) ? cloneValue(login.fido2Credentials) : [], + } + : null; + + out.card = cipher.card + ? { + cardholderName: cipher.card.cardholderName ?? null, + brand: cipher.card.brand ?? null, + number: cipher.card.number ?? null, + expMonth: cipher.card.expMonth ?? null, + expYear: cipher.card.expYear ?? null, + code: cipher.card.code ?? null, + } + : null; + + out.identity = cipher.identity + ? { + title: cipher.identity.title ?? null, + firstName: cipher.identity.firstName ?? null, + middleName: cipher.identity.middleName ?? null, + lastName: cipher.identity.lastName ?? null, + username: cipher.identity.username ?? null, + company: cipher.identity.company ?? null, + ssn: cipher.identity.ssn ?? null, + passportNumber: cipher.identity.passportNumber ?? null, + licenseNumber: cipher.identity.licenseNumber ?? null, + email: cipher.identity.email ?? null, + phone: cipher.identity.phone ?? null, + address1: cipher.identity.address1 ?? null, + address2: cipher.identity.address2 ?? null, + address3: cipher.identity.address3 ?? null, + city: cipher.identity.city ?? null, + state: cipher.identity.state ?? null, + postalCode: cipher.identity.postalCode ?? null, + country: cipher.identity.country ?? null, + } + : null; + + out.secureNote = cipher.secureNote + ? { + type: normalizeNumber((cipher.secureNote as { type?: unknown }).type, 0), + } + : null; + + out.passwordHistory = Array.isArray(cipher.passwordHistory) + ? cipher.passwordHistory.map((entry) => ({ + password: (entry as { password?: unknown }).password ?? null, + lastUsedDate: (entry as { lastUsedDate?: unknown }).lastUsedDate ?? null, + })) + : []; + + out.sshKey = cipher.sshKey + ? { + privateKey: cipher.sshKey.privateKey ?? null, + publicKey: cipher.sshKey.publicKey ?? null, + fingerprint: cipher.sshKey.fingerprint ?? null, + } + : null; + + return out; +} + +async function mapCipherPlain(cipher: Cipher, userEnc: Uint8Array, userMac: Uint8Array): Promise> { + const keyParts = await getCipherKeyParts(cipher, userEnc, userMac); + const out = mapCipherCommonMetadata(cipher); + + out.name = await decryptMaybe(cipher.name ?? null, keyParts.enc, keyParts.mac); + out.notes = await decryptMaybe(cipher.notes ?? null, keyParts.enc, keyParts.mac); + out.fields = Array.isArray(cipher.fields) + ? await Promise.all( + cipher.fields.map(async (field) => ({ + name: await decryptMaybe(field?.name ?? null, keyParts.enc, keyParts.mac), + value: await decryptMaybe(field?.value ?? null, keyParts.enc, keyParts.mac), + type: normalizeNumber(field?.type, 0), + linkedId: field?.linkedId ?? null, + })) + ) + : []; + + if (cipher.login) { + out.login = { + username: await decryptMaybe(cipher.login.username ?? null, keyParts.enc, keyParts.mac), + password: await decryptMaybe(cipher.login.password ?? null, keyParts.enc, keyParts.mac), + totp: await decryptMaybe(cipher.login.totp ?? null, keyParts.enc, keyParts.mac), + uris: Array.isArray(cipher.login.uris) + ? await Promise.all( + cipher.login.uris.map(async (uri) => ({ + uri: await decryptMaybe(uri?.uri ?? null, keyParts.enc, keyParts.mac), + match: (uri as { match?: unknown })?.match ?? null, + })) + ) + : [], + fido2Credentials: Array.isArray(cipher.login.fido2Credentials) + ? await Promise.all(cipher.login.fido2Credentials.map((credential) => deepDecryptUnknown(credential, keyParts.enc, keyParts.mac))) + : [], + }; + } else { + out.login = null; + } + + out.card = cipher.card ? await deepDecryptUnknown(cipher.card, keyParts.enc, keyParts.mac) : null; + out.identity = cipher.identity ? await deepDecryptUnknown(cipher.identity, keyParts.enc, keyParts.mac) : null; + out.sshKey = cipher.sshKey ? await deepDecryptUnknown(cipher.sshKey, keyParts.enc, keyParts.mac) : null; + out.secureNote = cipher.secureNote + ? { + type: normalizeNumber((cipher.secureNote as { type?: unknown }).type, 0), + } + : null; + + out.passwordHistory = Array.isArray(cipher.passwordHistory) + ? await Promise.all( + cipher.passwordHistory.map(async (entry) => ({ + password: await decryptMaybe((entry as { password?: unknown }).password ?? null, keyParts.enc, keyParts.mac), + lastUsedDate: normalizeString((entry as { lastUsedDate?: unknown }).lastUsedDate), + })) + ) + : []; + + return out; +} + +async function decryptFolderName(folder: Folder, userEnc: Uint8Array, userMac: Uint8Array): Promise { + const value = await decryptMaybe(folder.name ?? '', userEnc, userMac); + return value || ''; +} + +function trimNullKeys(value: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(value)) { + if (v !== undefined) out[k] = v; + } + return out; +} + +function filterExportableCiphers(ciphers: Cipher[]): Cipher[] { + return ciphers.filter((cipher) => !cipher.deletedDate && !(cipher as { organizationId?: unknown }).organizationId); +} + +export async function buildPlainBitwardenJsonDocument(args: BuildPlainJsonArgs): Promise> { + const userEnc = base64ToBytes(args.userEncB64); + const userMac = base64ToBytes(args.userMacB64); + + const folders = await Promise.all( + args.folders.map(async (folder) => ({ + id: folder.id, + name: await decryptFolderName(folder, userEnc, userMac), + })) + ); + + const items = await Promise.all(filterExportableCiphers(args.ciphers).map((cipher) => mapCipherPlain(cipher, userEnc, userMac))); + + return { + encrypted: false, + folders, + items: items.map((item) => trimNullKeys(item)), + }; +} + +export async function buildPlainBitwardenJsonString(args: BuildPlainJsonArgs): Promise { + const doc = await buildPlainBitwardenJsonDocument(args); + return JSON.stringify(doc, null, 2); +} + +export async function buildBitwardenCsvString(args: BuildPlainJsonArgs): Promise { + const doc = await buildPlainBitwardenJsonDocument(args); + const folders = Array.isArray(doc.folders) ? (doc.folders as Array>) : []; + const items = Array.isArray(doc.items) ? (doc.items as Array>) : []; + + const folderNameById = new Map(); + for (const folder of folders) { + const id = normalizeString(folder.id); + if (!id) continue; + folderNameById.set(id, normalizeString(folder.name) || ''); + } + + const header = [ + 'folder', + 'favorite', + 'type', + 'name', + 'notes', + 'fields', + 'reprompt', + 'archivedDate', + 'login_uri', + 'login_username', + 'login_password', + 'login_totp', + ]; + + const rows: string[][] = [header]; + for (const item of items) { + const type = normalizeNumber(item.type, 1); + if (type !== 1 && type !== 2) continue; + const folderId = normalizeString(item.folderId); + const folderName = folderId ? folderNameById.get(folderId) || '' : ''; + const fields = Array.isArray(item.fields) + ? (item.fields as Array>) + .map((field) => { + const name = normalizeString(field.name) || ''; + const value = normalizeString(field.value) || ''; + if (!name && !value) return ''; + return `${name}: ${value}`; + }) + .filter((line) => !!line) + .join('\n') + : ''; + + const login = isRecord(item.login) ? (item.login as Record) : null; + const loginUris = login && Array.isArray(login.uris) + ? (login.uris as Array>) + .map((uri) => normalizeString(uri.uri) || '') + .filter((uri) => !!uri) + .join(',') + : ''; + + rows.push([ + folderName, + item.favorite ? '1' : '', + type === 1 ? 'login' : 'note', + normalizeString(item.name) || '', + normalizeString(item.notes) || '', + fields, + String(normalizeNumber(item.reprompt, 0)), + normalizeString(item.archivedDate) || '', + loginUris, + normalizeString(login?.username) || '', + normalizeString(login?.password) || '', + normalizeString(login?.totp) || '', + ]); + } + + const escapeCsv = (value: string): string => { + if (/[",\n\r]/.test(value)) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; + }; + + return rows.map((row) => row.map((cell) => escapeCsv(String(cell || ''))).join(',')).join('\n'); +} + +export async function buildAccountEncryptedBitwardenJsonString(args: BuildEncryptedJsonArgs): Promise { + const userEnc = base64ToBytes(args.userEncB64); + const userMac = base64ToBytes(args.userMacB64); + const validation = await encryptBw(new TextEncoder().encode(randomGuid()), userEnc, userMac); + + const folders = args.folders.map((folder) => ({ + id: folder.id, + name: folder.name, + })); + + const items = filterExportableCiphers(args.ciphers).map((cipher) => mapCipherEncrypted(cipher)); + + const doc = { + encrypted: true, + encKeyValidation_DO_NOT_EDIT: validation, + folders, + items, + }; + return JSON.stringify(doc, null, 2); +} + +async function derivePasswordProtectedKey(kdf: PreloginKdfConfig, password: string, saltB64: string): Promise<{ enc: Uint8Array; mac: Uint8Array }> { + const iterations = Math.max(1, normalizeNumber(kdf.kdfIterations, 600000)); + const kdfType = normalizeNumber(kdf.kdfType, 0); + const saltTextBytes = new TextEncoder().encode(saltB64); + + let keyMaterial: Uint8Array; + if (kdfType === 1) { + const memoryMiB = Math.max(16, normalizeNumber(kdf.kdfMemory, 64)); + const parallelism = Math.max(1, normalizeNumber(kdf.kdfParallelism, 4)); + const memoryKiB = Math.floor(memoryMiB * 1024); + const maxmem = memoryKiB * 1024 + 1024 * 1024; + keyMaterial = await argon2idAsync(new TextEncoder().encode(password), saltTextBytes, { + t: Math.floor(iterations), + m: memoryKiB, + p: Math.floor(parallelism), + dkLen: 32, + maxmem, + asyncTick: 10, + }); + } else { + keyMaterial = await pbkdf2(password, saltTextBytes, iterations, 32); + } + + const enc = await hkdfExpand(keyMaterial, 'enc', 32); + const mac = await hkdfExpand(keyMaterial, 'mac', 32); + return { enc, mac }; +} + +export async function buildPasswordProtectedBitwardenJsonString(args: PasswordProtectedArgs): Promise { + const password = String(args.password || '').trim(); + if (!password) throw new Error('File password is required'); + + const salt = crypto.getRandomValues(new Uint8Array(16)); + const saltB64 = bytesToBase64(salt); + const key = await derivePasswordProtectedKey(args.kdf, password, saltB64); + + const validation = await encryptBw(new TextEncoder().encode(randomGuid()), key.enc, key.mac); + const data = await encryptBw(new TextEncoder().encode(args.plaintextJson), key.enc, key.mac); + + const kdfType = normalizeNumber(args.kdf.kdfType, 0); + const out: Record = { + encrypted: true, + passwordProtected: true, + salt: saltB64, + kdfType, + kdfIterations: Math.max(1, normalizeNumber(args.kdf.kdfIterations, 600000)), + encKeyValidation_DO_NOT_EDIT: validation, + data, + }; + if (kdfType === 1) { + out.kdfMemory = Math.max(16, normalizeNumber(args.kdf.kdfMemory, 64)); + out.kdfParallelism = Math.max(1, normalizeNumber(args.kdf.kdfParallelism, 4)); + } + + return JSON.stringify(out, null, 2); +} + +function sanitizeFileName(name: string): string { + const normalized = String(name || '').trim().replace(/[\\/]/g, '_').replace(/[\x00-\x1F\x7F]/g, ''); + if (!normalized) return 'attachment.bin'; + if (normalized.length > 240) { + const dot = normalized.lastIndexOf('.'); + if (dot > 0 && dot > normalized.length - 16) { + const ext = normalized.slice(dot); + return `${normalized.slice(0, 240 - ext.length)}${ext}`; + } + return normalized.slice(0, 240); + } + return normalized; +} + +function uniqueAttachmentFileName(cipherId: string, originalName: string, used: Set): string { + const safe = sanitizeFileName(originalName); + const keyBase = `${cipherId}/${safe}`; + if (!used.has(keyBase)) { + used.add(keyBase); + return safe; + } + + const dot = safe.lastIndexOf('.'); + const base = dot > 0 ? safe.slice(0, dot) : safe; + const ext = dot > 0 ? safe.slice(dot) : ''; + let idx = 1; + while (idx < 10000) { + const candidate = `${base} (${idx})${ext}`; + const key = `${cipherId}/${candidate}`; + if (!used.has(key)) { + used.add(key); + return candidate; + } + idx += 1; + } + return `${base}-${Date.now()}${ext}`; +} + +export function buildBitwardenZipBytes(dataJson: string, attachments: ZipAttachmentEntry[]): Uint8Array { + const files: Record = { + 'data.json': strToU8(dataJson), + }; + const used = new Set(); + for (const attachment of attachments) { + const cipherId = String(attachment.cipherId || '').trim(); + if (!cipherId) continue; + const fileName = uniqueAttachmentFileName(cipherId, attachment.fileName || 'attachment.bin', used); + files[`attachments/${cipherId}/${fileName}`] = attachment.bytes; + } + return zipSync(files, { level: 6 }); +} + +export async function encryptZipBytesWithPassword( + zipBytes: Uint8Array, + passwordRaw: string +): Promise<{ bytes: Uint8Array; encrypted: boolean }> { + const password = String(passwordRaw || '').trim(); + if (!password) return { bytes: zipBytes, encrypted: false }; + const zipReader = new ZipReader(new Uint8ArrayReader(zipBytes), { useWebWorkers: false }); + const zipWriter = new ZipWriter(new Uint8ArrayWriter(), { useWebWorkers: false }); + try { + const entries = await zipReader.getEntries(); + for (const entry of entries) { + const filename = String(entry.filename || '').trim(); + if (!filename) continue; + + if (entry.directory) { + await zipWriter.add(filename, undefined, { + directory: true, + password, + encryptionStrength: 3, + }); + continue; + } + + const data = await entry.getData(new Uint8ArrayWriter()); + await zipWriter.add(filename, new Uint8ArrayReader(data), { + password, + encryptionStrength: 3, + level: 6, + }); + } + + return { + bytes: await zipWriter.close(), + encrypted: true, + }; + } finally { + await zipReader.close(); + } +} + +function nowStamp(now = new Date()): string { + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, '0'); + const d = String(now.getDate()).padStart(2, '0'); + const hh = String(now.getHours()).padStart(2, '0'); + const mm = String(now.getMinutes()).padStart(2, '0'); + const ss = String(now.getSeconds()).padStart(2, '0'); + return `${y}${m}${d}_${hh}${mm}${ss}`; +} + +export function buildExportFileName(format: ExportFormatId, zipEncrypted = false): string { + const stamp = nowStamp(); + if ( + format === 'bitwarden_json' || + format === 'bitwarden_encrypted_json' || + format === 'nodewarden_json' || + format === 'nodewarden_encrypted_json' + ) { + if (format.startsWith('nodewarden_')) return `nodewarden_export_${stamp}.json`; + return `bitwarden_export_${stamp}.json`; + } + if (format === 'bitwarden_json_zip' || format === 'bitwarden_encrypted_json_zip') { + if (zipEncrypted) return `bitwarden_export_${stamp}.zip`; + return `bitwarden_export_${stamp}.zip`; + } + return `bitwarden_export_${stamp}.bin`; +} + +export function buildNodeWardenAttachmentRecords( + attachments: ZipAttachmentEntry[], + cipherIndexById?: Map +): NodeWardenAttachmentRecord[] { + const out: NodeWardenAttachmentRecord[] = []; + for (const attachment of attachments) { + const cipherId = String(attachment.cipherId || '').trim(); + if (!cipherId) continue; + const fileName = sanitizeFileName(String(attachment.fileName || '').trim() || 'attachment.bin'); + out.push({ + cipherId, + cipherIndex: cipherIndexById?.get(cipherId) ?? null, + fileName, + data: bytesToBase64(attachment.bytes), + }); + } + return out; +} + +export function buildNodeWardenPlainJsonDocument( + bitwardenJsonDoc: Record, + attachments: NodeWardenAttachmentRecord[] +): Record { + return { + ...bitwardenJsonDoc, + nodewardenFormat: 'nodewarden_json', + nodewardenVersion: 1, + nodewardenAttachments: attachments, + }; +} + +export async function attachNodeWardenEncryptedAttachmentPayload( + encryptedBitwardenJson: string, + attachments: NodeWardenAttachmentRecord[], + userEncB64: string, + userMacB64: string +): Promise { + const parsed = JSON.parse(encryptedBitwardenJson) as Record; + const userEnc = base64ToBytes(userEncB64); + const userMac = base64ToBytes(userMacB64); + const payload = JSON.stringify({ + nodewardenFormat: 'nodewarden_json', + nodewardenVersion: 1, + nodewardenAttachments: attachments, + }); + parsed.nodewardenFormat = 'nodewarden_json'; + parsed.nodewardenVersion = 1; + parsed.nodewardenAttachmentsEnc = await encryptBw(new TextEncoder().encode(payload), userEnc, userMac); + return JSON.stringify(parsed, null, 2); +} diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index 6c51070..103f446 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -328,6 +328,10 @@ const messages: Record> = { txt_totp_verify_failed: "TOTP verify failed", txt_passkey: "Passkey", txt_passkey_created_at_value: "Created at {value}", + txt_attachments: "Attachments", + txt_upload_attachments: "Upload attachments", + txt_new_attachments: "New attachments", + txt_marked_for_removal_count: "{count} attachment(s) will be removed on save", txt_trash: "Trash", txt_trust_this_device_for_30_days: "Trust this device for 30 days", txt_trusted_until: "Trusted Until", @@ -729,9 +733,90 @@ const zhCNOverrides: Record = { txt_copied: '已复制', }; -zhCNOverrides.txt_lock = '\u9501\u5b9a'; +zhCNOverrides.txt_lock = '锁定'; zhCNOverrides.txt_passkey = 'Passkey'; -zhCNOverrides.txt_passkey_created_at_value = '\u521b\u5efa\u4e8e {value}'; +zhCNOverrides.txt_passkey_created_at_value = '创建于 {value}'; +zhCNOverrides.txt_attachments = '附件'; +zhCNOverrides.txt_upload_attachments = '上传附件'; +zhCNOverrides.txt_new_attachments = '待上传附件'; +zhCNOverrides.txt_marked_for_removal_count = '保存后将删除 {count} 个附件'; +messages.en.txt_import = 'Import'; +messages.en.txt_export = 'Export'; +messages.en.txt_format = 'Format'; +messages.en.txt_source_file = 'Source file'; +messages.en.txt_folder_handling = 'Folder handling'; +messages.en.txt_import_folder_mode_original = 'Original path from import file'; +messages.en.txt_import_folder_mode_none = 'No folder'; +messages.en.txt_import_folder_mode_target = 'One selected folder'; +messages.en.txt_target_folder = 'Target folder'; +messages.en.txt_select_folder_placeholder = '-- Select folder --'; +messages.en.txt_import_vault_data_hint = 'Import vault data into your current account.'; +messages.en.txt_export_vault_data_hint = 'Export vault data from your current account.'; +messages.en.txt_encrypted_mode = 'Encrypted mode'; +messages.en.txt_account_verification = 'Account verification'; +messages.en.txt_password_verification = 'Password verification'; +messages.en.txt_file_password = 'File password'; +messages.en.txt_zip_password_optional = 'ZIP password (optional)'; +messages.en.txt_zip_password = 'ZIP password'; +messages.en.txt_close = 'Close'; +messages.en.txt_total = 'Total'; +messages.en.txt_import_success = 'Import successful'; +messages.en.txt_import_success_number_of_items = 'Imported {count} item(s) in total.'; +messages.en.txt_import_file_password_required = 'Please enter file password.'; +messages.en.txt_import_invalid_zip_password = 'Invalid ZIP password.'; +messages.en.txt_export_completed = 'Export completed'; +messages.en.txt_export_failed = 'Export failed'; +messages.en.txt_import_invalid_password_protected_file = 'Invalid password-protected export file.'; +messages.en.txt_import_decrypt_failed = 'Failed to decrypt import file.'; +messages.en.txt_import_empty_zip_archive = 'Empty zip archive.'; +messages.en.txt_import_no_json_found_in_zip = 'No importable JSON data found in zip archive.'; +messages.en.txt_import_data_json_not_found = 'data.json not found in zip archive.'; +messages.en.txt_import_zip_password_required = 'ZIP password is required.'; +messages.en.txt_import_invalid_json_file = 'Invalid JSON file'; +messages.en.txt_import_failed = 'Import failed'; +messages.en.txt_import_encrypted_file_title = 'Import encrypted file'; +messages.en.txt_import_encrypted_file_message = 'This Bitwarden export is password-protected. Enter the export file password to continue.'; +messages.en.txt_import_encrypted_zip_title = 'Import encrypted ZIP'; +messages.en.txt_import_encrypted_zip_message = 'This ZIP archive is password-protected. Enter the ZIP password to continue.'; + +zhCNOverrides.txt_import = '导入'; +zhCNOverrides.txt_export = '导出'; +zhCNOverrides.txt_format = '格式'; +zhCNOverrides.txt_source_file = '源文件'; +zhCNOverrides.txt_folder_handling = '文件夹处理'; +zhCNOverrides.txt_import_folder_mode_original = '保留导入文件中的原始路径'; +zhCNOverrides.txt_import_folder_mode_none = '不使用文件夹'; +zhCNOverrides.txt_import_folder_mode_target = '导入到指定文件夹'; +zhCNOverrides.txt_target_folder = '目标文件夹'; +zhCNOverrides.txt_select_folder_placeholder = '-- 选择文件夹 --'; +zhCNOverrides.txt_import_vault_data_hint = '将数据导入到当前账号。'; +zhCNOverrides.txt_export_vault_data_hint = '从当前账号导出数据。'; +zhCNOverrides.txt_encrypted_mode = '加密方式'; +zhCNOverrides.txt_account_verification = '账号验证'; +zhCNOverrides.txt_password_verification = '密码验证'; +zhCNOverrides.txt_file_password = '文件密码'; +zhCNOverrides.txt_zip_password_optional = 'ZIP 密码(可选)'; +zhCNOverrides.txt_zip_password = 'ZIP 密码'; +zhCNOverrides.txt_close = '关闭'; +zhCNOverrides.txt_total = '总计'; +zhCNOverrides.txt_import_success = '数据导入成功'; +zhCNOverrides.txt_import_success_number_of_items = '一共导入了 {count} 个项目。'; +zhCNOverrides.txt_import_file_password_required = '请输入文件密码。'; +zhCNOverrides.txt_import_invalid_zip_password = 'ZIP 密码错误。'; +zhCNOverrides.txt_export_completed = '导出完成'; +zhCNOverrides.txt_export_failed = '导出失败'; +zhCNOverrides.txt_import_invalid_password_protected_file = '密码保护导出文件格式无效。'; +zhCNOverrides.txt_import_decrypt_failed = '导入文件解密失败。'; +zhCNOverrides.txt_import_empty_zip_archive = 'ZIP 压缩包为空。'; +zhCNOverrides.txt_import_no_json_found_in_zip = 'ZIP 内未找到可导入的 JSON 数据。'; +zhCNOverrides.txt_import_data_json_not_found = 'ZIP 内未找到 data.json。'; +zhCNOverrides.txt_import_zip_password_required = '该 ZIP 需要密码。'; +zhCNOverrides.txt_import_invalid_json_file = 'JSON 文件无效'; +zhCNOverrides.txt_import_failed = '导入失败'; +zhCNOverrides.txt_import_encrypted_file_title = '导入加密文件'; +zhCNOverrides.txt_import_encrypted_file_message = '该 Bitwarden 导出文件已加密,请输入文件密码继续。'; +zhCNOverrides.txt_import_encrypted_zip_title = '导入加密 ZIP'; +zhCNOverrides.txt_import_encrypted_zip_message = '该 ZIP 压缩包已加密,请输入 ZIP 密码继续。'; messages['zh-CN'] = { ...messages.en, ...zhCNOverrides }; diff --git a/webapp/src/lib/import-formats.ts b/webapp/src/lib/import-formats.ts index 4553ff8..6e61435 100644 --- a/webapp/src/lib/import-formats.ts +++ b/webapp/src/lib/import-formats.ts @@ -5,6 +5,8 @@ type ImportSourceEntry = { id: string; label: string }; export const IMPORT_SOURCES = [ { id: 'bitwarden_json', label: 'Bitwarden (json)' }, { id: 'bitwarden_csv', label: 'Bitwarden (csv)' }, + { id: 'bitwarden_zip', label: 'Bitwarden (zip)' }, + { id: 'nodewarden_json', label: 'NodeWarden (json)' }, { id: 'onepassword_1pux', label: '1Password (1pux/json)' }, { id: 'onepassword_1pif', label: '1Password (1pif)' }, { id: 'onepassword_mac_csv', label: '1Password 6 and 7 Mac (csv)' }, @@ -53,8 +55,10 @@ export const IMPORT_SOURCES = [ export type ImportSourceId = (typeof IMPORT_SOURCES)[number]['id']; export function getFileAcceptBySource(source: ImportSourceId): string { + if (source === 'bitwarden_zip') return '.zip,application/zip,application/x-zip-compressed'; if ( source === 'bitwarden_json' || + source === 'nodewarden_json' || source === 'onepassword_1pux' || source === 'protonpass_json' || source === 'avast_json' || @@ -90,6 +94,7 @@ export interface BitwardenFieldInput { linkedId?: number | null; } export interface BitwardenCipherInput { + id?: string | null; type?: number | null; name?: string | null; notes?: string | null; @@ -2415,6 +2420,7 @@ export function normalizeBitwardenImport(raw: unknown): CiphersImportPayload { let hasAnyExplicitFolderLink = false; for (const item of itemsRaw) { ciphers.push({ + id: item?.id ?? null, type: Number(item?.type || 1) || 1, name: item?.name ?? 'Untitled', notes: item?.notes ?? null, @@ -2498,6 +2504,12 @@ const IMPORT_SOURCE_PARSERS: Record Ciphers bitwarden_json: () => { throw new Error('bitwarden_json is handled by dedicated JSON flow'); }, + bitwarden_zip: () => { + throw new Error('bitwarden_zip is handled by dedicated zip flow'); + }, + nodewarden_json: () => { + throw new Error('nodewarden_json is handled by dedicated JSON flow'); + }, bitwarden_csv: parseBitwardenCsv, onepassword_1pux: parseOnePassword1PuxJson, onepassword_1pif: parseOnePassword1Pif, diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts index ac97d45..647075b 100644 --- a/webapp/src/lib/types.ts +++ b/webapp/src/lib/types.ts @@ -28,6 +28,17 @@ export interface CipherLoginUri { decUri?: string; } +export interface CipherAttachment { + id?: string; + url?: string | null; + fileName?: string | null; + decFileName?: string; + key?: string | null; + size?: string | number | null; + sizeName?: string | null; + object?: string; +} + export interface CipherLoginPasskey { creationDate?: string | null; [key: string]: unknown; @@ -111,6 +122,7 @@ export interface CipherField { type?: number | string | null; name?: string | null; value?: string | null; + linkedId?: number | null; decName?: string; decValue?: string; } @@ -127,10 +139,13 @@ export interface Cipher { creationDate?: string; revisionDate?: string; deletedDate?: string | null; + attachments?: CipherAttachment[] | null; login?: CipherLogin | null; card?: CipherCard | null; identity?: CipherIdentity | null; sshKey?: CipherSshKey | null; + secureNote?: { type?: number | null } | null; + passwordHistory?: Array<{ password?: string | null; lastUsedDate?: string | null }> | null; fields?: CipherField[] | null; decName?: string; decNotes?: string; diff --git a/webapp/src/styles.css b/webapp/src/styles.css index 37fba99..6ad506d 100644 --- a/webapp/src/styles.css +++ b/webapp/src/styles.css @@ -321,6 +321,7 @@ input[type='file'].input::file-selector-button:hover { width: 100%; height: 50px; font-size: 22px; + margin: 10px 0; } .btn.small { @@ -930,6 +931,74 @@ input[type='file'].input::file-selector-button:hover { flex-shrink: 0; } +.attachment-list { + display: grid; + gap: 0; +} + +.attachment-head { + margin-bottom: 8px; +} + +.attachment-head h4 { + margin-bottom: 0; +} + +.attachment-add-btn { + min-width: 32px; + padding: 0 8px; +} + +.attachment-file-input { + display: none; +} + +.attachment-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + border-bottom: 1px solid #ecf0f5; + padding: 10px 0; +} + +.attachment-row:last-child { + border-bottom: none; +} + +.attachment-main { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.attachment-text { + min-width: 0; + display: grid; + gap: 2px; +} + +.attachment-text span { + color: #64748b; + font-size: 12px; +} + +.attachment-row.is-removed { + opacity: 0.6; +} + +.attachment-row.is-removed .attachment-text strong { + text-decoration: line-through; +} + +.attachment-queue-title { + font-size: 12px; + color: #64748b; + font-weight: 700; + padding: 8px 0 2px; +} + .custom-field-row { grid-template-columns: minmax(110px, 220px) minmax(0, 1fr) auto; } @@ -1255,6 +1324,64 @@ input[type='file'].input::file-selector-button:hover { margin: 8px 0 10px; } +.import-summary-dialog { + max-width: 520px; + text-align: left; + position: relative; + padding-top: 16px; +} + +.import-summary-close { + position: absolute; + top: 10px; + right: 10px; + border: none; + background: transparent; + color: #64748b; + font-size: 24px; + line-height: 1; + cursor: pointer; +} + +.import-summary-close:hover { + color: #0f172a; +} + +.import-summary-table-wrap { + margin-top: 8px; + border: 1px solid var(--line); + border-radius: 10px; + overflow: hidden; +} + +.import-summary-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} + +.import-summary-table th, +.import-summary-table td { + padding: 10px 12px; + border-bottom: 1px solid var(--line); +} + +.import-summary-table th { + text-align: left; + color: #475467; + background: #f8fafc; +} + +.import-summary-table td:last-child, +.import-summary-table th:last-child { + text-align: right; + width: 96px; +} + +.import-summary-table tbody tr:last-child td { + border-bottom: none; +} + .settings-twofactor-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));