diff --git a/index.html b/index.html
index 635a13e..944fdce 100644
--- a/index.html
+++ b/index.html
@@ -344,7 +344,9 @@
Export
- Download all your passwords as JSON. Requires your master password — file is plaintext.
+ Download an encrypted backup of your vault. You'll
+ choose a password independent of your master password —
+ save it carefully, you need it to restore.
Export vault
diff --git a/js/app.js b/js/app.js
index f348984..b02618d 100644
--- a/js/app.js
+++ b/js/app.js
@@ -2357,7 +2357,7 @@ function confirmDialog(opts) {
}
function promptDialog(opts) {
- // opts: { title, message, okText, placeholder, value }
+ // opts: { title, message, okText, placeholder, value, password }
opts = opts || {};
$('#confirmTitle').textContent = opts.title || 'Enter value';
$('#confirmMessage').innerHTML = opts.message || '';
@@ -2367,6 +2367,8 @@ function promptDialog(opts) {
$('#confirmInputField').classList.remove('is-hidden');
$('#confirmInput').value = opts.value || '';
$('#confirmInput').placeholder = opts.placeholder || '';
+ // Allow password-style masking (used by encrypted import/export).
+ $('#confirmInput').type = opts.password ? 'password' : 'text';
$('#confirmModal').classList.remove('is-hidden');
setTimeout(() => $('#confirmInput').focus(), 50);
return new Promise(res => { confirmResolver = res; });
@@ -2407,6 +2409,76 @@ function closeReauth(ok) {
}
}
+// ============================================================
+// 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)
// ============================================================
@@ -2622,6 +2694,34 @@ async function doImport() {
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);
@@ -2683,30 +2783,80 @@ async function doImport() {
}
async function doExport() {
- const pwd = await askReauth('Enter your master password to export the vault as JSON. The file will be UNENCRYPTED.');
- if (!pwd) return;
+ // 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.
+ const masterPwd = await askReauth(
+ 'Enter your master password to start an encrypted export.');
+ if (!masterPwd) return;
try {
await api('/reauth', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
- body: JSON.stringify({ masterPassword: pwd }),
+ body: JSON.stringify({ masterPassword: masterPwd }),
});
} catch (err) {
- toast('Wrong master password', 'error');
- return;
+ // 429 (account lockout) is possible here too — propagate as a clear
+ // message rather than the generic "wrong master password" toast.
+ 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');
+ }
+ return toast('Wrong master password', 'error');
}
- // Decrypt all entries
- const out = { version: 1, exported_at: new Date().toISOString(), username: state.username, entries: [] };
+
+ // 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.
+ const exportPwd = 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,
+ });
+ if (!exportPwd) return;
+ if (exportPwd.length < 6) {
+ return toast('Use at least 6 characters', 'warning');
+ }
+
+ // 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,
+ entries: [],
+ };
for (const e of state.entries) {
const plain = await decryptPwd(e.encrypted_password, e.iv);
- out.entries.push({
- site: e.site, username: e.username,
- password: plain, folder: e.folder,
- tags: parseTags(e.tags), favorite: !!e.favorite,
- created_at: e.created_at, updated_at: e.updated_at,
+ let plainTotp = '';
+ if (e.totp_secret && e.totp_iv) {
+ plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
+ if (plainTotp === '[ERROR]') plainTotp = '';
+ }
+ payload.entries.push({
+ site: e.site,
+ username: e.username,
+ password: plain,
+ folder: e.folder,
+ tags: parseTags(e.tags),
+ favorite: !!e.favorite,
+ totp_secret: plainTotp,
+ created_at: e.created_at,
+ updated_at: e.updated_at,
});
}
- const blob = new Blob([JSON.stringify(out, null, 2)], { type: 'application/json' });
+
+ // Step 4: encrypt + download
+ const container = await encryptExportPayload(payload, exportPwd);
+ const blob = new Blob([JSON.stringify(container, null, 2)], {
+ type: 'application/json',
+ });
const url = URL.createObjectURL(blob);
const a = el('a', {
href: url,
@@ -2715,7 +2865,7 @@ async function doExport() {
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
- toast(out.entries.length + ' entries exported');
+ toast(payload.entries.length + ' entries exported (encrypted)');
}
// ============================================================