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-label">Export</div>
|
||||
<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>
|
||||
<button class="btn btn-ghost btn-sm" id="exportBtn">
|
||||
<svg><use href="#i-log-out"/></svg> Export vault
|
||||
|
||||
Reference in New Issue
Block a user