// ============================================================ // app.import.js — IMPORT / EXPORT module (extracted from app.js, §3.1) // ============================================================ // // Encrypted export container, CSV/JSON import parsing, and the import/export // UI flows. Pure declarations, no top-level side effects → loads BEFORE // app.js (alongside app.crypto.js). Cross-file refs (state, api, crypto // helpers, parseTags…) resolve via the shared global lexical environment at // call time. NOTE: encryptImportEntry lives here and is also called by // app.sync.js (applyRemoteSnapshot) — works via shared scope. See CLAUDE.md // "Découpage frontend". // // ============================================================ // ENCRYPTED EXPORT CONTAINER // ============================================================ // // File format (JSON): // { // "format": "pm-encrypted-export-v1", // "kdf": "pbkdf2-sha256", // "kdf_iterations": 600000, // "kdf_salt": "", // "iv": "", // "ciphertext":"", // "created_at": "" // } // payload = same shape produced by the plaintext exporter (entries array). // // The encryption password is INDEPENDENT of the master password — the // user picks it at export time and provides it again at import time. // Decoupling means a master-password change doesn't brick old backups, // and the backup can be shared without revealing the master pw. function bytesToBase64(arr) { if (arr instanceof ArrayBuffer) arr = new Uint8Array(arr); let s = ''; for (let i = 0; i < arr.length; i++) s += String.fromCharCode(arr[i]); return btoa(s); } function base64ToBytes(b64) { return Uint8Array.from(atob(b64), c => c.charCodeAt(0)); } // Derive an AES-GCM key from a user-chosen export password + random salt. // Uses the same 600k iteration PBKDF2 as the rest of the app. async function deriveExportKey(password, saltBytes, iterations) { const km = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']); return crypto.subtle.deriveKey( { name: 'PBKDF2', salt: saltBytes, iterations: iterations, hash: 'SHA-256' }, km, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); } async function encryptExportPayload(payloadObj, exportPwd) { const plaintext = new TextEncoder().encode(JSON.stringify(payloadObj)); const salt = crypto.getRandomValues(new Uint8Array(32)); const iv = crypto.getRandomValues(new Uint8Array(12)); const key = await deriveExportKey(exportPwd, salt, 600000); const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext); return { format: 'pm-encrypted-export-v1', kdf: 'pbkdf2-sha256', kdf_iterations: 600000, kdf_salt: bytesToBase64(salt), iv: bytesToBase64(iv), ciphertext: bytesToBase64(ct), created_at: new Date().toISOString(), }; } async function decryptExportContainer(container, exportPwd) { const salt = base64ToBytes(container.kdf_salt); const iv = base64ToBytes(container.iv); const ct = base64ToBytes(container.ciphertext); const key = await deriveExportKey(exportPwd, salt, container.kdf_iterations || 600000); const plainBuf = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct); return JSON.parse(new TextDecoder().decode(plainBuf)); } // ============================================================ // IMPORT — JSON (native round-trip) + CSV (universal) // ============================================================ // // Two supported input formats: // 1. Native JSON: the same shape produced by doExport() above // { version, exported_at, username, entries: [ // { site, username, password, folder, tags, favorite, ... } // ]} // 2. CSV: with a header row. Column names are mapped heuristically so // exports from Bitwarden / KeePass / Chrome / 1Password generally // "just work" without manual column mapping. // // Each parsed entry is encrypted client-side with the vault key (same // flow as a single-entry add), then sent to /entries/bulk-import as one // transactional batch. // Minimal RFC 4180-ish CSV parser. Handles quoted fields, escaped quotes // (""), commas inside quotes, and CRLF line endings. Returns an array of // arrays (rows × columns). No streaming — fine for the ~MB-scale imports // a password manager realistically deals with. function parseCSV(text) { const rows = []; let row = [], field = '', inQuotes = false; for (let i = 0; i < text.length; i++) { const c = text[i]; if (inQuotes) { if (c === '"') { if (text[i + 1] === '"') { field += '"'; i++; } // escaped "" else inQuotes = false; } else field += c; } else { if (c === '"') inQuotes = true; else if (c === ',') { row.push(field); field = ''; } else if (c === '\n' || c === '\r') { if (c === '\r' && text[i + 1] === '\n') i++; // CRLF row.push(field); field = ''; if (row.length > 1 || (row.length === 1 && row[0] !== '')) rows.push(row); row = []; } else field += c; } } // Flush trailing field/row (file without final newline) if (field !== '' || row.length > 0) { row.push(field); rows.push(row); } return rows; } // Header heuristics: pick the first matching column name (case-insensitive, // underscore/space-tolerant). Returns null if no candidate header matches. function findColumn(headers, candidates) { const norm = s => String(s || '').toLowerCase().replace(/[\s_-]+/g, ''); const cand = candidates.map(norm); for (let i = 0; i < headers.length; i++) { if (cand.indexOf(norm(headers[i])) >= 0) return i; } return null; } // Parse a CSV text into an array of plaintext entries // ({ site, username, password, folder, tags, totp_secret }). Returns // { entries, skipped, columns } so the preview can show what was matched. function parseEntriesFromCSV(text) { const rows = parseCSV(text); if (rows.length < 2) { throw new Error('CSV needs a header row and at least one data row'); } const headers = rows[0]; // Candidate names per format observed in real exports: // Bitwarden CSV : folder, name, login_uri, login_username, login_password, login_totp, notes // KeePass CSV : Title, URL, Username, Password, Group, Notes // Chrome/Edge : name, url, username, password // 1Password CSV : Title, URL, Username, Password, Notes // findColumn normalizes (lowercase, strip _ and -) so 'login_uri' and // 'loginuri' both match the same candidate. // Prefer URL-shaped columns for `site` and human-readable name for // `title` so KeePass/Bitwarden exports keep both. Fall back: if only // one is present, reuse it for the other. const colTitle = findColumn(headers, ['name', 'title', 'item_name', 'entry_name']); const colSite = findColumn(headers, ['url', 'login_uri', 'login_url', 'site', 'website', 'web_site']); const colUser = findColumn(headers, ['login_username', 'username', 'user', 'login', 'email', 'user_name']); const colPwd = findColumn(headers, ['login_password', 'password', 'pass', 'pwd']); const colFolder = findColumn(headers, ['folder', 'group', 'category', 'path', 'collection']); const colTags = findColumn(headers, ['tags', 'labels']); const colNotes = findColumn(headers, ['notes', 'note', 'comment', 'comments']); const colTotp = findColumn(headers, ['login_totp', 'totp', 'totp_secret', 'otp', 'otpauth', 'authenticator', 'two_factor', 'twofa']); // Our own CSV export carries an explicit `kind` column. When absent we // fall back to a heuristic (empty site + non-empty notes = a note). const colKind = findColumn(headers, ['kind', 'type', 'item_type']); const colTemplate = findColumn(headers, ['template', 'subtype']); const colCustom = findColumn(headers, ['custom_fields', 'custom', 'fields']); // Bitwarden card columns — only used when type=card. Each maps to a // custom field on a credit-card-template note. const colCardHolder = findColumn(headers, ['card_cardholdername', 'card_holder', 'cardholder']); const colCardBrand = findColumn(headers, ['card_brand', 'card_type']); const colCardNumber = findColumn(headers, ['card_number', 'cardnumber']); const colCardExpM = findColumn(headers, ['card_expmonth', 'card_exp_month']); const colCardExpY = findColumn(headers, ['card_expyear', 'card_exp_year']); const colCardCode = findColumn(headers, ['card_code', 'card_cvv', 'card_cvc']); // Bitwarden identity columns — mapped to identity-template note. const colIdFirst = findColumn(headers, ['identity_firstname']); const colIdLast = findColumn(headers, ['identity_lastname']); const colIdEmail = findColumn(headers, ['identity_email']); const colIdPhone = findColumn(headers, ['identity_phone']); if (colSite === null && colTitle === null && colUser === null) throw new Error('No recognizable title/url or username column in CSV header'); if (colPwd === null) throw new Error('No recognizable password column in CSV header'); const entries = []; let skipped = 0; for (let i = 1; i < rows.length; i++) { const r = rows[i]; const titleRaw = (colTitle !== null ? r[colTitle] : '').trim(); const siteRaw = (colSite !== null ? r[colSite] : '').trim(); const pwd = (colPwd !== null ? r[colPwd] : ''); const notesRaw = (colNotes !== null ? r[colNotes] : '').trim(); // Resolve kind: explicit column wins. Heuristic fallback for foreign // CSVs (Bitwarden/KeePass) — when site+user+pwd are all empty but // notes/title is set, that's a secure-note row. let kindRaw = (colKind !== null ? String(r[colKind] || '').toLowerCase().trim() : ''); let kind = (kindRaw === 'note' || kindRaw === 'secure_note') ? 'note' : (kindRaw === 'card' || kindRaw === 'identity') ? 'note' : 'login'; if (kindRaw === '' && !siteRaw && !pwd && notesRaw) kind = 'note'; let templateRaw = (colTemplate !== null ? String(r[colTemplate] || '').trim() : ''); // Bitwarden type=card / type=identity → note kind + appropriate // template. The card/identity columns become custom fields below. if (kindRaw === 'card') templateRaw = templateRaw || 'credit-card'; if (kindRaw === 'identity') templateRaw = templateRaw || 'identity'; // Notes legitimately have no site; their body is in `notes` (or in // `password` when round-tripping our own CSV — we wrote the body // into the password column for the export). // Custom fields cell carries a JSON-stringified array (our own // export shape). Parse defensively — a malformed cell drops to // [] rather than failing the row. let cf = []; if (colCustom !== null) { const raw = String(r[colCustom] || '').trim(); if (raw) { try { // Our own export: JSON array of {label, value, is_secret} const arr = JSON.parse(raw); if (Array.isArray(arr)) cf = arr.filter(f => f && typeof f === 'object' && f.label); } catch { // Bitwarden / Chrome / KeePass CSV: newline-separated // "label: value" lines (sometimes "label=value"). Split, // pick the FIRST separator only so values can contain // ":" or "=" without being mangled. raw.split(/\r?\n/).forEach(line => { line = line.trim(); if (!line) return; const sep = line.search(/[:=]/); if (sep <= 0) return; const label = line.slice(0, sep).trim(); const value = line.slice(sep + 1).trim(); if (label) cf.push({ label, value, is_secret: false }); }); } } } if (kind === 'note') { // Pull Bitwarden card/identity columns into custom_fields so // the type=card / type=identity rows survive the import. const push = (label, val, is_secret) => { if (val) cf.push({ label, value: val, is_secret: !!is_secret }); }; if (kindRaw === 'card') { push('Cardholder', colCardHolder !== null ? String(r[colCardHolder] || '').trim() : ''); push('Brand', colCardBrand !== null ? String(r[colCardBrand] || '').trim() : ''); push('Number', colCardNumber !== null ? String(r[colCardNumber] || '').trim() : '', true); const expM = colCardExpM !== null ? String(r[colCardExpM] || '').trim() : ''; const expY = colCardExpY !== null ? String(r[colCardExpY] || '').trim() : ''; if (expM || expY) push('Expires', (expM && expY) ? (expM + '/' + expY) : (expM || expY)); push('CVV', colCardCode !== null ? String(r[colCardCode] || '').trim() : '', true); } if (kindRaw === 'identity') { const f = colIdFirst !== null ? String(r[colIdFirst] || '').trim() : ''; const l = colIdLast !== null ? String(r[colIdLast] || '').trim() : ''; if (f || l) push('Name', (f && l) ? (f + ' ' + l) : (f || l)); push('Email', colIdEmail !== null ? String(r[colIdEmail] || '').trim() : ''); push('Phone', colIdPhone !== null ? String(r[colIdPhone] || '').trim() : ''); } const body = pwd || notesRaw || ' '; // template carries data via cf if (!body && cf.length === 0) { skipped++; continue; } const tagsArr = []; if (colTags !== null) { String(r[colTags] || '').split(/[,;]/).forEach(t => { t = t.trim(); if (t) tagsArr.push(t); }); } entries.push({ site: '', title: titleRaw, username: '', password: body, folder: (colFolder !== null ? r[colFolder] : '').trim() || 'All', tags: tagsArr.join(','), totp_secret: '', kind: 'note', template: templateRaw, custom_fields: cf, }); continue; } // Fall through chain: site → title → username so we always have // something to display. The unused string becomes the title for // browser-style cards. const site = siteRaw || titleRaw || (colUser !== null ? r[colUser] : '').trim(); const title = titleRaw || ''; if (!site || !pwd) { skipped++; continue; } // Tags: only the explicit tags column. Free-form notes are // surfaced as a custom "Notes" field below — putting prose into // the tag chip strip turned it into noise (and lost line breaks). let tagsArr = []; if (colTags !== null) { String(r[colTags] || '').split(/[,;]/).forEach(t => { t = t.trim(); if (t) tagsArr.push(t); }); } // Bitwarden / KeePass / Chrome login rows carry per-entry notes // in a `notes` column. Preserve them as a non-secret custom field // so the body survives round-trip without polluting tags. if (colNotes !== null) { const n = String(r[colNotes] || '').trim(); if (n) cf.push({ label: 'Notes', value: n, is_secret: false }); } // TOTP: support raw base32 OR full otpauth:// URI in the cell. let totp = ''; if (colTotp !== null) { const raw = String(r[colTotp] || '').trim(); totp = parseOtpAuthUri(raw) || raw; } entries.push({ site: site, title: title, username: (colUser !== null ? r[colUser] : '').trim(), password: pwd, folder: (colFolder !== null ? r[colFolder] : '').trim() || 'All', tags: tagsArr.join(','), totp_secret: totp, kind: 'login', template: templateRaw, custom_fields: cf, }); } return { entries, skipped, columns: { title: colTitle, site: colSite, username: colUser, password: colPwd, folder: colFolder, tags: colTags, notes: colNotes, totp: colTotp, kind: colKind, custom_fields: colCustom, } }; } // Parse a native JSON export. Forgiving: accepts both our own format and // a flat array of entry objects. function parseEntriesFromJSON(text) { let data; try { data = JSON.parse(text); } catch (e) { throw new Error('Invalid JSON: ' + e.message); } const raw = Array.isArray(data) ? data : (data.entries || []); if (!Array.isArray(raw) || raw.length === 0) throw new Error('No entries in JSON file'); // Folders metadata (color, icon) — only present on payloads produced // by our own JSON exporter from 2026-06 onward. Silently absent for // older backups or foreign formats; the per-entry `folder` name is // still respected either way. const folders = Array.isArray(data.folders) ? data.folders.filter(f => f && f.name && f.name !== 'All') .map(f => ({ name: String(f.name).trim(), color: String(f.color || '').trim(), icon: String(f.icon || '').trim(), })) : []; const entries = []; let skipped = 0; for (const e of raw) { if (!e || typeof e !== 'object') { skipped++; continue; } const kind = (e.kind === 'note') ? 'note' : 'login'; const site = String(e.site || e.url || e.name || '').trim(); const pwd = String(e.password || ''); // Notes legitimately have no `site` — their "content" lives in // password (the note body). Logins still need both site + pwd. if (kind === 'login' && (!site || !pwd)) { skipped++; continue; } // Notes with a template (credit-card, ssh-key, etc.) carry data // in custom_fields — an empty body is legitimate as long as at // least one custom field has content. Only skip a note if BOTH // the body AND every custom field are empty. if (kind === 'note' && !pwd) { const cfList = Array.isArray(e.custom_fields) ? e.custom_fields : []; const anyFieldFilled = cfList.some(f => f && (String(f.value || '').trim() !== '')); if (!anyFieldFilled) { skipped++; continue; } } const tagsVal = e.tags; const tagsStr = Array.isArray(tagsVal) ? tagsVal.join(',') : String(tagsVal || ''); // Custom fields: array of {label, value, is_secret}. Tolerate // missing / malformed gracefully — drop the field rather than // failing the entry. let cf = []; if (Array.isArray(e.custom_fields)) { cf = e.custom_fields.filter(f => f && typeof f === 'object' && f.label); } // Attachments: pass through as-is; the import side re-encrypts // the base64 content with the current vault key and POSTs each. let atts = []; if (Array.isArray(e.attachments)) { atts = e.attachments.filter(a => a && typeof a === 'object' && a.filename && a.content_b64); } entries.push({ uuid: String(e.uuid || '').trim(), site: site, title: String(e.title || '').trim(), username: String(e.username || e.user || e.login || '').trim(), password: pwd, folder: String(e.folder || e.group || 'All').trim() || 'All', tags: tagsStr, totp_secret: String(e.totp || e.totp_secret || e.otpauth || '').trim(), kind: kind, template: String(e.template || '').trim(), custom_fields: cf, attachments: atts, icon_b64: String(e.icon_b64 || '').trim(), }); } return { entries, skipped, columns: null, folders, avatar_b64: typeof data.avatar_b64 === 'string' ? data.avatar_b64 : '' }; } // Encrypt one parsed entry (plaintext password + optional TOTP) into the // shape the bulk-import endpoint expects. Reuses encryptPwd which already // generates a fresh IV per call. async function encryptImportEntry(plain) { const pw = await encryptPwd(plain.password); let totpEnc = '', totpIv = ''; if (plain.totp_secret) { try { base32Decode(plain.totp_secret); // validate before encrypting const t = await encryptPwd(plain.totp_secret); totpEnc = t.encrypted; totpIv = t.iv; } catch (e) { // Bad TOTP secret in source file — keep the entry but drop the // 2FA silently. The user can fix it later via the slide-over. } } // Custom fields: encrypt the same way the slideover does so the row // round-trips through the regular GET path. let cfEnc = '', cfIv = ''; if (Array.isArray(plain.custom_fields) && plain.custom_fields.length > 0) { try { const c = await encryptCustomFields(plain.custom_fields); cfEnc = c.encrypted; cfIv = c.iv; } catch (e) { /* drop silently */ } } // tags may arrive as a comma-separated string (CSV / our own JSON // export) or as a real array (buildSyncSnapshot uses parseTags → []). // The server's HandleCreateEntry does GetValue which throws // "TJSONArray → string non supporté" on an array — normalise here. const tagsStr = Array.isArray(plain.tags) ? plain.tags.filter(Boolean).join(',') : (plain.tags || ''); // Encrypt the username at rest (username_enc/username_iv, cleartext blanked) // via the shared choke point in app.js — same as the interactive save path. return await withEncryptedMeta({ uuid: plain.uuid || '', site: plain.site, title: plain.title || '', username: plain.username || '', encrypted_password: pw.encrypted, iv: pw.iv, folder: plain.folder || 'All', tags: tagsStr, totp_secret: totpEnc, totp_iv: totpIv, kind: plain.kind === 'note' ? 'note' : 'login', custom_fields: cfEnc, custom_fields_iv: cfIv, icon_b64: plain.icon_b64 || '', template: plain.template || '', }); } // Open a hidden file picker, route the result through the right parser, // show a preview confirmation, then bulk-encrypt + POST. async function doImport() { const fileInput = el('input', { type: 'file', accept: '.json,.csv,application/json,text/csv', style: 'display:none', }); document.body.appendChild(fileInput); fileInput.addEventListener('change', async () => { const file = fileInput.files && fileInput.files[0]; fileInput.remove(); if (!file) return; let text; try { text = await file.text(); } catch (e) { return toast('Cannot read file: ' + e.message, 'error'); } const isJSON = /\.json$/i.test(file.name) || text.trim().startsWith('{') || text.trim().startsWith('['); // If the JSON is an encrypted-export container, prompt for the // backup password and decrypt before handing the plaintext payload // to the regular JSON parser. if (isJSON) { let raw; try { raw = JSON.parse(text); } catch (e) { raw = null; } if (raw && raw.format === 'pm-encrypted-export-v1') { const pw = await promptDialog({ title: 'Encrypted backup', message: 'This backup is encrypted. Enter the password ' + 'you set when you exported it.', placeholder: 'Backup encryption password', okText: 'Decrypt', password: true, }); if (!pw) return; try { const payload = await decryptExportContainer(raw, pw); // Hand the decrypted payload back to parseEntriesFromJSON // via JSON.stringify — keeps the parser code path single. text = JSON.stringify(payload); } catch (e) { return toast('Decryption failed — wrong password or corrupted file', 'error'); } } } let parsed; try { parsed = isJSON ? parseEntriesFromJSON(text) : parseEntriesFromCSV(text); } catch (e) { return toast('Parse error: ' + e.message, 'error'); } if (parsed.entries.length === 0) { return toast('No valid entries found in file', 'warning'); } // Build the preview message (innerHTML target → escape user data) const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); const parts = []; parts.push('' + parsed.entries.length + ' entries detected in ' + esc(file.name) + ''); if (parsed.skipped > 0) parts.push('' + parsed.skipped + ' rows skipped (missing site or password)'); const sample = parsed.entries.slice(0, 3).map(e => '• ' + esc(e.site || '?') + (e.username ? ' (' + esc(e.username) + ')' : '') ).join('
'); parts.push('
' + sample + (parsed.entries.length > 3 ? '
…' : '') + '
'); parts.push('
Import now? This adds the entries to your existing vault.
'); const confirmed = await confirmDialog({ title: 'Import vault', message: parts.join('
'), okText: 'Import', }); if (!confirmed) return; // parsedAtt is computed AFTER dedup — see below where // dedupedEntries is defined. let parsedAtt = 0; // Apply folder customisation (color, icon) from the payload — // additive only: existing local folders are left untouched so the // user's current customisation isn't overwritten by an older // backup. Folders referenced by entries but absent from the // folders[] block will still be auto-created with defaults during // the bulk-import step server-side. if (Array.isArray(parsed.folders) && parsed.folders.length > 0) { const existing = new Set((state.folders || []) .filter(f => f && f.name).map(f => f.name)); let createdFolders = 0; for (const f of parsed.folders) { if (!f.name || existing.has(f.name)) continue; try { await api('/folders', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ name: f.name, color: f.color || '', icon: f.icon || '', }), }); createdFolders++; } catch (_) { /* duplicate or invalid — skip silently */ } } if (createdFolders > 0) { await loadFolders(); toast(createdFolders + ' folder(s) added'); } } // Restore the profile picture from the backup — only when the // current account has none, so an import doesn't clobber a // picture the user already set on this device. if (parsed.avatar_b64 && !state.avatarDataUri) { try { await api('/avatar', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ avatar_b64: parsed.avatar_b64 }), }); state.avatarDataUri = parsed.avatar_b64; renderUserAvatar(); } catch (_) { /* non-critical */ } } // CSV imports (Bitwarden / KeePass / Chrome) don't carry a // folders[] block — they just stamp a folder name on each row. // Bulk-import stores the name but never creates the folders // table row, so the sidebar wouldn't show the new folder. // Auto-create any referenced folder that doesn't exist yet. const referenced = new Set(); for (const e of parsed.entries) { const f = (e.folder || '').trim(); if (f && f !== 'All') referenced.add(f); } if (referenced.size > 0) { const localNames = new Set((state.folders || []) .filter(f => f && f.name).map(f => f.name)); let createdMissing = 0; for (const name of referenced) { if (localNames.has(name)) continue; try { await api('/folders', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ name, color: '', icon: '' }), }); createdMissing++; } catch (_) { /* duplicate or invalid — skip silently */ } } if (createdMissing > 0) await loadFolders(); } // Dedupe by uuid: split parsed rows into "fresh" (uuid absent // locally, safe to bulk-insert) and "overlapping" (uuid already // exists — the user is either re-importing a backup or rolling // back to an earlier version). Ask what to do with overlapping // entries so a restore isn't silently blocked by the dedup. const localByUuid = new Map(); for (const e of state.entries) if (e && e.uuid) localByUuid.set(e.uuid, e); const fresh = []; const overlaps = []; for (const e of parsed.entries) { if (e.uuid && localByUuid.has(e.uuid)) overlaps.push(e); else fresh.push(e); } let overwriteOverlaps = false; if (overlaps.length > 0) { overwriteOverlaps = await confirmDialog({ title: overlaps.length + ' entries already in vault', message: '' + overlaps.length + ' entries in this file ' + 'already exist locally (same UUID).

' + 'Choose Overwrite to replace the local version with the ' + 'file\'s (rolls back edits made since the backup was taken).

' + 'Choose Skip to keep the current local version and only ' + 'import genuinely new entries.', okText: 'Overwrite', cancelText: 'Skip', danger: true, }); } if (fresh.length === 0 && !overwriteOverlaps) { return toast('Nothing new to import', 'warning'); } parsedAtt = fresh.reduce( (n, e) => n + (Array.isArray(e.attachments) ? e.attachments.length : 0), 0); toast('Encrypting ' + fresh.length + ' entries…'); const encrypted = []; for (const e of fresh) { encrypted.push(await encryptImportEntry(e)); } // Overwriting overlaps: PUT each existing entry with the file's // content. Attachments on the local entry stay in place — the // user typically wants to roll back credentials, not lose // manually-uploaded files. Adjust if that assumption changes. let overwritten = 0; if (overwriteOverlaps) { for (const src of overlaps) { try { const local = localByUuid.get(src.uuid); if (!local) continue; const enc = await encryptImportEntry(src); await api('/entries/' + local.id, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify(enc), }); overwritten++; } catch (_) { /* skip the single row on failure */ } } } // Use dedupedEntries as an alias for fresh so downstream code // (attachments loop) keeps working without a second rename. const dedupedEntries = fresh; try { let r = { imported: 0, ids: [] }; if (encrypted.length > 0) { r = await api('/entries/bulk-import', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ entries: encrypted }), }); } const tailMsg = overwritten > 0 ? ' · ' + overwritten + ' overwritten' : ''; toast('Imported ' + r.imported + ' entries' + tailMsg); // Restore attachments. ids[] is parallel-indexed with the // input (-1 = server skipped this row), so we can map back // from each parsed entry to its newly-created server id. const ids = Array.isArray(r.ids) ? r.ids : []; if (parsedAtt > 0 && ids.length === 0) { toast(parsedAtt + ' attachment(s) skipped — server missing /ids response', 'warning'); } let attachCount = 0; for (let i = 0; i < dedupedEntries.length; i++) { const newId = ids[i]; const atts = dedupedEntries[i].attachments; if (typeof newId !== 'number' || newId <= 0) continue; if (!Array.isArray(atts) || atts.length === 0) continue; for (const a of atts) { try { const bytes = base64ToBytes(a.content_b64 || ''); // Re-encrypt under the CURRENT vault key — the // export stored plaintext bytes so a cross- // account / post-rotation restore still works. const { encrypted: blob, iv } = await encryptBlobBytes(bytes); await api('/entries/' + newId + '/attachments', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ filename: a.filename, mime: a.mime || 'application/octet-stream', encrypted_blob: blob, iv, size_bytes: a.size_bytes || bytes.length, }), }); attachCount++; } catch (e) { /* skip the single attachment */ } } } if (attachCount > 0) toast(attachCount + ' attachment(s) restored'); await loadEntries(); render(); if (state.hibpEnabled) hibpCheckAllEntries(); // scan the new entries too } catch (err) { toast('Import failed: ' + err.message, 'error'); } }); fileInput.click(); } async function doExport() { // Step 1: reauth — verifies the human in front of the screen is the // vault owner before we hand them every plaintext password. Defense // against a stranger reaching the open laptop and exfiltrating data. // Up to 5 retries with inline error in the modal — beyond that the // server-side rate limiter takes over (429 with retry_after). let lastError = null; let attempts = 0; const MAX_ATTEMPTS = 5; let reauthed = false; while (!reauthed) { const masterPwd = await askReauth( 'Enter your master password to start an encrypted export.', { error: lastError }); if (!masterPwd) return; // user cancelled try { const verifier = await computeVerifier( masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params); await api('/reauth', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ verifier: verifier }), }); reauthed = true; } catch (err) { if (err.status === 429 && err.body && err.body.retry_after) { return toast('Account locked. Try again in ' + Math.ceil(err.body.retry_after / 60) + ' min', 'warning'); } attempts++; if (attempts >= MAX_ATTEMPTS) { return toast('Too many wrong attempts — try again later', 'error'); } lastError = 'Wrong master password. Attempt ' + attempts + ' / ' + MAX_ATTEMPTS + '.'; } } // Step 2: ask for an INDEPENDENT export password. Decoupled from the // master pw so a master-pw change later doesn't invalidate the backup, // and so the backup can be shared without revealing the master pw. let exportPwd = null; { let pwdErr = ''; let pwdAttempts = 0; const PWD_MAX = 5; for (;;) { const v = await promptDialog({ title: 'Encrypted export', message: 'Choose a password to encrypt the backup file.
' + '' + 'You will need this password to restore the file. ' + 'It is independent of your master password.', placeholder: 'Backup encryption password', okText: 'Export', password: true, error: pwdErr, }); if (!v) return; if (v.length >= 6) { exportPwd = v; break; } pwdAttempts++; if (pwdAttempts >= PWD_MAX) { return toast('Too many invalid attempts', 'error'); } pwdErr = 'Use at least 6 characters (attempt ' + pwdAttempts + ' / ' + PWD_MAX + ').'; } } // Show the spinner BEFORE the heavy work — the entry-decrypt + // attachment-fetch loop below is the real cost on big vaults, not // just the final encrypt/save. A 0ms yield lets the overlay paint // before we block the thread. showBusy('Reading vault…'); await new Promise(r => setTimeout(r, 0)); try { // Step 3: assemble the plaintext payload (same shape as the legacy // plaintext exporter — round-trips with the existing JSON importer // after decryption). const payload = { version: 1, exported_at: new Date().toISOString(), username: state.username, // Profile picture (data URI) so a restore brings the avatar // back. Empty string when none set. avatar_b64: state.avatarDataUri || '', // Folder customisation (color, icon) so restoring on a fresh // install brings the sidebar back the way the user had it, // not the default gray + folder-icon. 'All' is synthetic and // never persisted, skip it. folders: (state.folders || []) .filter(f => f && f.name && f.name !== 'All') .map(f => ({ name: f.name, color: f.color || '', icon: f.icon || '', })), entries: [], }; let _expDone = 0; const _expTotal = state.entries.length; for (const e of state.entries) { _expDone++; if (_expTotal > 10 && (_expDone % 5 === 0 || _expDone === _expTotal)) updateBusy('Reading vault… ' + _expDone + '/' + _expTotal); const plain = await decryptPwd(e.encrypted_password, e.iv); let plainTotp = ''; if (e.totp_secret && e.totp_iv) { plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv); if (plainTotp === '[ERROR]') plainTotp = ''; } let plainCustom = []; if (e.custom_fields && e.custom_fields_iv) { try { plainCustom = await decryptCustomFields( e.custom_fields, e.custom_fields_iv); } catch (_) { plainCustom = []; } } // Attachments are fetched separately (one extra GET per entry // that has them) so this branch stays cheap on vaults without. let attachments = []; try { const metas = await api('/entries/' + e.id + '/attachments', { headers: authHeaders() }); for (const m of (metas || [])) { const full = await api('/attachments/' + m.id, { headers: authHeaders() }); const bytes = await decryptBlobBytes( full.encrypted_blob, full.iv); attachments.push({ filename: m.filename, mime: m.mime, size_bytes: m.size_bytes, content_b64: bytesToBase64(bytes), }); } } catch (_) { /* partial export beats a failed one */ } payload.entries.push({ uuid: e.uuid || '', site: e.site, title: e.title || '', username: e.username, password: plain, folder: e.folder, tags: parseTags(e.tags), favorite: !!e.favorite, totp_secret: plainTotp, kind: e.kind || 'login', template: e.template || '', custom_fields: plainCustom, attachments: attachments, icon_b64: e.icon_b64 || '', created_at: e.created_at, updated_at: e.updated_at, }); } const attachTotal = payload.entries.reduce( (n, e) => n + (Array.isArray(e.attachments) ? e.attachments.length : 0), 0); // Step 4: encrypt + save via native dialog. Big vaults (many // attachments) take a few seconds — show a spinner so the app // doesn't look frozen while the Save dialog is being prepared. showBusy('Encrypting export…'); let res; try { const container = await encryptExportPayload(payload, exportPwd); const json = JSON.stringify(container, null, 2); const fname = 'vault-export-' + new Date().toISOString().slice(0, 10) + '.json'; updateBusy('Preparing file…'); res = await Bridge.saveFile(fname, json, pct => updateBusy('Preparing file… ' + pct + '%')); } finally { hideBusy(); } if (res.ok) { const tail = attachTotal > 0 ? ' + ' + attachTotal + ' attachment(s)' : ''; toast(payload.entries.length + ' entries' + tail + ' exported to ' + res.path); } else if (res.error) toast('Export failed: ' + res.error, 'error'); } catch (err) { hideBusy(); toast('Export failed: ' + (err && err.message ? err.message : err), 'error'); } } // CSV escape: wrap in quotes if the value contains comma / quote / newline. // Inner quotes doubled per RFC 4180. function csvEscape(s) { if (s == null) return ''; s = String(s); if (/[",\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"'; return s; } async function doExportCSV() { const ok = await confirmDialog({ title: 'Export to CSV?', message: 'The CSV file is NOT encrypted. Passwords, note bodies and ' + 'custom field values will be written in plaintext, readable by ' + 'anyone who opens the file.

' + 'Not included in CSV: encrypted attachments and custom ' + 'favicons. Use Encrypted JSON export for a complete ' + 'backup that round-trips everything.

' + 'Use this format only for migration to another password ' + 'manager — delete the file as soon as the import is done.', okText: 'Export plaintext', danger: true, }); if (!ok) return; // Decrypt everything client-side (server never sees plaintext). const rows = [[ 'kind', 'template', 'title', 'site', 'username', 'password', 'totp_secret', 'folder', 'tags', 'custom_fields', 'created_at', 'updated_at', ]]; for (const e of state.entries) { const pwd = await decryptPwd(e.encrypted_password, e.iv); let totp = ''; if (e.totp_secret && e.totp_iv) { totp = await decryptTotpSecret(e.totp_secret, e.totp_iv); if (totp === '[ERROR]') totp = ''; } let cf = ''; if (e.custom_fields && e.custom_fields_iv) { const arr = await decryptCustomFields(e.custom_fields, e.custom_fields_iv); if (arr.length) cf = JSON.stringify(arr); } rows.push([ e.kind || 'login', e.template || '', e.title || '', e.site || '', e.username || '', pwd === '[ERROR]' ? '' : pwd, totp, e.folder || '', e.tags || '', cf, e.created_at || '', e.updated_at || '', ]); } const csv = rows.map(r => r.map(csvEscape).join(',')).join('\r\n'); // Prepend UTF-8 BOM so Excel reads accented chars correctly. const body = '' + csv; const fname = 'vault-export-' + new Date().toISOString().slice(0, 10) + '.csv'; const res = await Bridge.saveFile(fname, body); if (res.ok) toast(state.entries.length + ' entries exported to ' + res.path, 'warning'); else if (res.error) toast('Export failed: ' + res.error, 'error'); }