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:
+3
-1
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
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.<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)');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user