diff --git a/CLAUDE.md b/CLAUDE.md index 8fcb36e..48fab6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,11 +33,16 @@ visibles des suivants exactement comme dans le monofichier. Ordre actuel : ``` js/argon2.js (IIFE, globalThis.NobleArgon2) js/app.crypto.js (KDF, verifier, encrypt/decrypt — extrait §3.1) +js/app.import.js (export container + import CSV/JSON — extrait §3.1) js/app.js (le reste : state, Bridge, api, UI…) js/app.sync.js (WebDAV + merge — extrait §3.1, APRÈS app.js car effet de bord top-level `Bridge.onWebdavResult = …`) ``` +`app.import.js` contient `encryptImportEntry`, appelé aussi par +`app.sync.js` (`applyRemoteSnapshot`) — OK, référence cross-fichier résolue +au call-time via le scope global partagé. + Note ordre : un module **sans** exécution top-level (que des déclarations, comme `app.crypto.js`) peut se charger AVANT `app.js`. Un module **avec** un effet de bord top-level qui touche un global d'`app.js` (`Bridge`, `state`…) @@ -94,6 +99,7 @@ parsing CSV d'import, et l'arbitrage merge/tombstone de sync | Handlers REST | `delphi-backend/Handlers/PM.Handler.*.pas` | | Frontend principal (en cours de découpage §3.1) | `js/app.js` | | Crypto frontend (KDF, verifier, AES-GCM) — extrait §3.1 | `js/app.crypto.js` | +| Import/export frontend (CSV/JSON parse, export container) — extrait §3.1 | `js/app.import.js` | | Sync frontend (WebDAV, snapshot, merge) — extrait §3.1 | `js/app.sync.js` | | Argon2id vendé (bundle `@noble/hashes`, IIFE) | `js/argon2.js` | | HTML racine | `index.html` | diff --git a/CODE_AUDIT.md b/CODE_AUDIT.md index fca8bff..77242ed 100644 --- a/CODE_AUDIT.md +++ b/CODE_AUDIT.md @@ -228,8 +228,12 @@ réécriture des call-sites, risque quasi nul vs conversion en modules ES). identique, 42 tests verts, pas de `const` dupliqué. Chargé AVANT app.js. - ✅ `js/app.sync.js` extrait (WebDAV, snapshot, merge `applyRemoteSnapshot`) — byte-for-byte identique, couvert par les tests merge, chargé APRÈS app.js - (effet de bord top-level `Bridge.onWebdavResult`). Pattern + règles d'ordre + (effet de bord top-level `Bridge.onWebdavResult`). +- ✅ `js/app.import.js` extrait (export container + parsing CSV/JSON + + `doImport`/`doExport`) — byte-for-byte identique, couvert par les 14 tests + CSV, chargé AVANT app.js (pures déclarations). Pattern + règles d'ordre documentés dans CLAUDE.md « Découpage frontend ». +- `app.js` : 11 936 → **10 253 lignes** (crypto + sync + import sortis). - Reste à extraire (grosses sections cohésives) : slideover, settings, quicksearch, autofill, auto-backup… - ✅ `node --check` en pré-étape de `BuildAssets.ps1` : **déjà fait** (cf. §3.2). diff --git a/delphi-backend/assets/BuildAssets.ps1 b/delphi-backend/assets/BuildAssets.ps1 index aadd59b..d947c5e 100644 --- a/delphi-backend/assets/BuildAssets.ps1 +++ b/delphi-backend/assets/BuildAssets.ps1 @@ -53,6 +53,7 @@ $patterns = @( 'index.html', 'js\argon2.js', 'js\app.crypto.js', + 'js\app.import.js', 'js\app.js', 'js\app.sync.js', 'css\style.css' diff --git a/index.html b/index.html index 6351c02..71ac05d 100644 --- a/index.html +++ b/index.html @@ -1194,6 +1194,7 @@ + diff --git a/js/app.import.js b/js/app.import.js new file mode 100644 index 0000000..9ddb56e --- /dev/null +++ b/js/app.import.js @@ -0,0 +1,1020 @@ +// ============================================================ +// 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 || ''); + return { + 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'); +} + diff --git a/js/app.js b/js/app.js index b850e34..90be4c1 100644 --- a/js/app.js +++ b/js/app.js @@ -8121,1012 +8121,9 @@ async function doChangeMasterPassword() { } // ============================================================ -// ENCRYPTED EXPORT CONTAINER +// IMPORT / EXPORT — extracted to js/app.import.js (§3.1), loaded as +// a separate