feat(export): encrypt vault backups with an independent password

Replaces the plaintext JSON exporter with an encrypted container.
The previous plaintext flow was a known security gap — a backup file
on disk or in a cloud sync folder gave full plaintext access to
every password if accessed by anyone (or anything) other than the
user.

Container format
================
Self-describing JSON:
  {
    "format":         "pm-encrypted-export-v1",
    "kdf":            "pbkdf2-sha256",
    "kdf_iterations": 600000,
    "kdf_salt":       "<base64 32B>",
    "iv":             "<base64 12B>",
    "ciphertext":     "<base64 AES-GCM(payload)>",
    "created_at":     "<ISO>"
  }
payload = same shape as the legacy plaintext exporter (entries array
with site, username, password, folder, tags, favorite, totp_secret,
timestamps), so the round-trip through the JSON importer works
without a separate code path.

Export password
===============
User-chosen, INDEPENDENT of the master password — the export modal
explicitly explains this. Rationale:
 - A master-password change doesn't invalidate old backups.
 - The backup file can be shared with another person without
   revealing the master pw.
 - Trade-off: one more password for the user to remember. We assume
   they're storing the backup intentionally and can record the pw.
Minimum length 6 enforced client-side.

Flow
====
Export:
  1. askReauth(master pw) → server /reauth verifies (defense against
     someone reaching the unlocked laptop and dumping the vault).
  2. promptDialog(password: true) → export password.
  3. Walk state.entries, decrypt each password + TOTP with the vault
     key, assemble payload.
  4. encryptExportPayload(payload, exportPwd) — random 32B salt,
     random 12B IV, PBKDF2 600k, AES-GCM-256.
  5. Download the container as
     vault-export-YYYY-MM-DD.json.

Import:
  1. Read file, detect format. JSON with format === "pm-encrypted-
     export-v1" → prompt for the export password.
  2. decryptExportContainer → plaintext payload, then JSON.stringify
     back into the existing parseEntriesFromJSON path so the rest of
     the import flow (preview confirm, bulk encrypt, /entries/bulk-
     import) is unchanged.
  3. Wrong password → AES-GCM tag fails → "Decryption failed" toast,
     user retries.

Other changes
=============
 - promptDialog gains a `password: true` option that flips the
   confirm input's type so the value is masked on screen.
 - Export modal copy in the Settings panel updated to mention the
   encrypted format and the independent password.
 - The 429-lockout path on /reauth is now handled explicitly in
   doExport (was previously falling through to "wrong password").

Backward compatibility
======================
Plaintext JSON exports produced by the previous version still
import — parseEntriesFromJSON doesn't care whether the input came
from a fresh decryption or directly from a plaintext file. The
exporter no longer produces plaintext though; users with old
backups should re-export after upgrading.
This commit is contained in:
2026-05-23 05:35:03 +01:00
parent 4b15811221
commit 3c786366fc
2 changed files with 168 additions and 16 deletions
+3 -1
View File
@@ -344,7 +344,9 @@
<div class="slideover-field"> <div class="slideover-field">
<div class="slideover-field-label">Export</div> <div class="slideover-field-label">Export</div>
<p style="font-size:12px;color:var(--text-dim);margin:0 0 8px;line-height:1.5"> <p style="font-size:12px;color:var(--text-dim);margin:0 0 8px;line-height:1.5">
Download all your passwords as JSON. Requires your master password — file is plaintext. Download an <b>encrypted</b> backup of your vault. You'll
choose a password independent of your master password —
save it carefully, you need it to restore.
</p> </p>
<button class="btn btn-ghost btn-sm" id="exportBtn"> <button class="btn btn-ghost btn-sm" id="exportBtn">
<svg><use href="#i-log-out"/></svg> Export vault <svg><use href="#i-log-out"/></svg> Export vault
+165 -15
View File
@@ -2357,7 +2357,7 @@ function confirmDialog(opts) {
} }
function promptDialog(opts) { function promptDialog(opts) {
// opts: { title, message, okText, placeholder, value } // opts: { title, message, okText, placeholder, value, password }
opts = opts || {}; opts = opts || {};
$('#confirmTitle').textContent = opts.title || 'Enter value'; $('#confirmTitle').textContent = opts.title || 'Enter value';
$('#confirmMessage').innerHTML = opts.message || ''; $('#confirmMessage').innerHTML = opts.message || '';
@@ -2367,6 +2367,8 @@ function promptDialog(opts) {
$('#confirmInputField').classList.remove('is-hidden'); $('#confirmInputField').classList.remove('is-hidden');
$('#confirmInput').value = opts.value || ''; $('#confirmInput').value = opts.value || '';
$('#confirmInput').placeholder = opts.placeholder || ''; $('#confirmInput').placeholder = opts.placeholder || '';
// Allow password-style masking (used by encrypted import/export).
$('#confirmInput').type = opts.password ? 'password' : 'text';
$('#confirmModal').classList.remove('is-hidden'); $('#confirmModal').classList.remove('is-hidden');
setTimeout(() => $('#confirmInput').focus(), 50); setTimeout(() => $('#confirmInput').focus(), 50);
return new Promise(res => { confirmResolver = res; }); 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": "<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) // IMPORT — JSON (native round-trip) + CSV (universal)
// ============================================================ // ============================================================
@@ -2622,6 +2694,34 @@ async function doImport() {
catch (e) { return toast('Cannot read file: ' + e.message, 'error'); } catch (e) { return toast('Cannot read file: ' + e.message, 'error'); }
const isJSON = /\.json$/i.test(file.name) || text.trim().startsWith('{') || text.trim().startsWith('['); 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; let parsed;
try { try {
parsed = isJSON ? parseEntriesFromJSON(text) : parseEntriesFromCSV(text); parsed = isJSON ? parseEntriesFromJSON(text) : parseEntriesFromCSV(text);
@@ -2683,30 +2783,80 @@ async function doImport() {
} }
async function doExport() { async function doExport() {
const pwd = await askReauth('Enter your master password to export the vault as JSON. The file will be UNENCRYPTED.'); // Step 1: reauth — verifies the human in front of the screen is the
if (!pwd) return; // 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 { try {
await api('/reauth', { await api('/reauth', {
method: 'POST', method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }), headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ masterPassword: pwd }), body: JSON.stringify({ masterPassword: masterPwd }),
}); });
} catch (err) { } catch (err) {
toast('Wrong master password', 'error'); // 429 (account lockout) is possible here too — propagate as a clear
return; // 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');
} }
// Decrypt all entries return toast('Wrong master password', 'error');
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.<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,
});
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) { for (const e of state.entries) {
const plain = await decryptPwd(e.encrypted_password, e.iv); const plain = await decryptPwd(e.encrypted_password, e.iv);
out.entries.push({ let plainTotp = '';
site: e.site, username: e.username, if (e.totp_secret && e.totp_iv) {
password: plain, folder: e.folder, plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
tags: parseTags(e.tags), favorite: !!e.favorite, if (plainTotp === '[ERROR]') plainTotp = '';
created_at: e.created_at, updated_at: e.updated_at, }
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 url = URL.createObjectURL(blob);
const a = el('a', { const a = el('a', {
href: url, href: url,
@@ -2715,7 +2865,7 @@ async function doExport() {
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100); setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
toast(out.entries.length + ' entries exported'); toast(payload.entries.length + ' entries exported (encrypted)');
} }
// ============================================================ // ============================================================