Files
Password-Manager/js/app.import.js
T
r-zakarya 829056f1fa fix(import): normalize timestamps to the DB format at the import door
vault_entries uses SQLite's space-separated UTC format everywhere, and both
sorting and sync last-write-wins compare the strings lexically — so a foreign
JSON import carrying strict-ISO 'T'/millis/Z/offset timestamps would slot in
with a different format and subtly break ordering and merge arbitration.
normalizeImportTimestamp converts any ISO-ish variant to 'YYYY-MM-DD
HH:MM:SS' UTC (bare strings treated as UTC, garbage -> '' = server stamps
now). +1 unit test (69 total).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 00:13:41 +01:00

1047 lines
48 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 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": "<base64 random 32 bytes>",
// "iv": "<base64 random 12 bytes>",
// "ciphertext":"<base64 AES-GCM ciphertext of JSON.stringify(payload)>",
// "created_at": "<ISO timestamp>"
// }
// 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(),
created_at: String(e.created_at || '').trim(),
updated_at: String(e.updated_at || '').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.
// Normalize any ISO-ish timestamp to the DB's 'YYYY-MM-DD HH:MM:SS' (UTC).
// Imports are the ONE door where a foreign format (T separator, millis, Z,
// offset) could enter vault_entries — and sorting + sync last-write-wins
// compare these strings LEXICALLY, so a mixed format breaks both. Bare
// strings are treated as UTC (our own exports carry UTC without a marker).
function normalizeImportTimestamp(s) {
if (!s) return '';
s = String(s).trim();
const d = new Date(s.replace(' ', 'T') +
(/(Z|[+-]\d\d:?\d\d)$/.test(s) ? '' : 'Z'));
if (isNaN(d)) return '';
return d.toISOString().slice(0, 19).replace('T', ' ');
}
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<string> 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 || '',
// Preserve original timestamps on restore — bulk-import falls back
// to now only when these are absent (foreign CSV imports).
created_at: normalizeImportTimestamp(plain.created_at),
updated_at: normalizeImportTimestamp(plain.updated_at),
});
}
// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const parts = [];
parts.push('<b>' + parsed.entries.length + '</b> entries detected in <code>' +
esc(file.name) + '</code>');
if (parsed.skipped > 0)
parts.push('<span style="color:var(--text-dim)">' + parsed.skipped +
' rows skipped (missing site or password)</span>');
const sample = parsed.entries.slice(0, 3).map(e =>
'• ' + esc(e.site || '?') +
(e.username ? ' <span style="color:var(--text-dim)">(' + esc(e.username) + ')</span>' : '')
).join('<br>');
parts.push('<div style="margin-top:8px;font-size:12px">' + sample +
(parsed.entries.length > 3 ? '<br>…' : '') + '</div>');
parts.push('<div style="margin-top:10px">Import now? This adds the entries to your existing vault.</div>');
const confirmed = await confirmDialog({
title: 'Import vault',
message: parts.join('<br>'),
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: '<b>' + overlaps.length + '</b> entries in this file ' +
'already exist locally (same UUID).<br><br>' +
'Choose <b>Overwrite</b> to replace the local version with the ' +
'file\'s (rolls back edits made since the backup was taken).<br><br>' +
'Choose <b>Skip</b> 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),
});
// PUT ignores icon_b64 (dedicated endpoint owns it), so
// restore the file's icon separately — else overwriting
// an entry whose icon was cleared never brings it back.
if (src.icon_b64) await saveEntryIcon(local.id, src.icon_b64);
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.<br>' +
'<small style="color:var(--text-dim)">' +
'You will need this password to restore the file. ' +
'It is independent of your master password.</small>',
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 <b>NOT encrypted</b>. Passwords, note bodies and ' +
'custom field values will be written in plaintext, readable by ' +
'anyone who opens the file.<br><br>' +
'<b>Not included in CSV</b>: encrypted attachments and custom ' +
'favicons. Use <i>Encrypted JSON</i> export for a complete ' +
'backup that round-trips everything.<br><br>' +
'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');
}