diff --git a/AGENTS.md b/AGENTS.md
index 375f47a..5e2d45f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -81,10 +81,13 @@ Password verification uses a test payload (random string encrypted at vault crea
- Clipboard auto-clears after 15 seconds.
- No browser fingerprinting or anti-keylogger protections.
-## Export
+## Export / Import
-- `exportSelected(groupIds)` replaces the old `exportAll()` — accepts an array of group IDs to export. Pass `null` or `[]` for a full export. Vault meta (salt, test payload) is always included for import decryption.
-- `ImportExport.svelte` fetches groups/entries on modal open and shows a checkbox list for group selection with live entry count.
+- `exportSelected(groupIds, vaultKey, exportPassword)` — group IDs to export; `null`/`[]` = full export (include `''` for ungrouped). Vault meta (salt, test payload) is always included for plain import decryption.
+ - **Plain export** (no export password): unchanged — entries keep their source-vault-encrypted passwords; import needs the source vault's master password.
+ - **Password-protected export**: pass a `vaultKey` (in-memory) plus an independent `exportPassword`. Re-keys every exported entry's password to the export-derived key and AES-256-GCM-seals the entire payload (titles/usernames/notes included). Returns `{ format: 'encrypted-export', salt, data }`.
+- `importAll(data, mode, sourcePassword, targetKey)` detects a sealed export (`data.format === 'encrypted-export'`): `sourcePassword` is then the EXPORT password and may differ from any vault's master password. A wrong export password rejects the import (it never silently skips entries). The entry loop decrypts with the source key and re-encrypts under `targetKey`.
+- `ImportExport.svelte` fetches groups/entries on modal open and shows a checkbox list for group selection with live entry count. The export dialog has an optional "separate password" field; the import dialog's field is a generic file password (export password for sealed files, source vault master for plain files).
## Known Bug Fixes
diff --git a/src/components/ImportExport.svelte b/src/components/ImportExport.svelte
index 6f9a206..d02cc16 100644
--- a/src/components/ImportExport.svelte
+++ b/src/components/ImportExport.svelte
@@ -22,6 +22,7 @@
let exporting = $state(false)
let sourcePassword = $state('')
let parsedFileData = $state(null)
+ let exportPassword = $state('')
// Group selection for export
let allGroups = $state([])
@@ -37,7 +38,12 @@
async function handleExport() {
exporting = true
try {
- exportData = await exportSelected(selectedGroupIds.length === allGroups.length ? null : selectedGroupIds)
+ exportData = await exportSelected(
+ selectedGroupIds.length === allGroups.length ? null : selectedGroupIds,
+ app.encryptionKey,
+ exportPassword.trim()
+ )
+ exportPassword = ''
const json = JSON.stringify(exportData, null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
@@ -137,7 +143,7 @@
e.stopPropagation()}>
Export Vault
-
Select which groups to export. You'll need the source vault's master password when importing into another vault.
+
Select which groups to export. Optionally set a separate password below to encrypt the file; otherwise it exports as readable JSON (passwords stay encrypted under this vault's key).
+
+
+
+
+
{:else if parsedFileData}
-
File loaded. Enter the source vault's master password to decrypt and re-encrypt entries under your current vault.
+
File loaded. Enter the password protecting this file — for an encrypted export that is the export password you chose; for a plain export it is the source vault's master password.
-
+
diff --git a/src/lib/storage/db.js b/src/lib/storage/db.js
index 4417c0c..a7997ed 100644
--- a/src/lib/storage/db.js
+++ b/src/lib/storage/db.js
@@ -12,7 +12,14 @@
*/
import { openDB } from 'idb'
-import { deriveKey, decrypt, encrypt, base64ToUint8Array } from '../crypto/crypto.js'
+import {
+ deriveKey,
+ decrypt,
+ encrypt,
+ generateSalt,
+ base64ToUint8Array,
+ uint8ArrayToBase64,
+} from '../crypto/crypto.js'
import { TRASH_GROUP_ID, createTrashGroup, isTrashGroup } from '../models/schema.js'
// Re-export for convenience
@@ -377,15 +384,25 @@ export async function moveEntryToGroup(entryId, groupId) {
/**
* Export data (entries + groups + meta) as a JSON object.
- * Entries remain encrypted with the source vault's key. The import function
- * requires the source vault's master password to decrypt and re-encrypt
- * entries under the target vault's key.
*
- * @param {string[]} [groupIds] - Array of group IDs to export. If null/empty, exports everything.
- * Include '' to export ungrouped entries.
+ * Without an export password the file is plaintext JSON: entries keep
+ * their source-vault-encrypted passwords and the source vault's meta (salt,
+ * test payload) is included so that import can decrypt them using the SOURCE
+ * VAULT's master password (existing behaviour, unchanged).
+ *
+ * When a password is supplied the WHOLE payload is sealed with AES-256-GCM under a key
+ * derived from that independent password. Each entry's password is first
+ * re-encrypted from the vault key to the export key, and the payload's
+ * meta.salt points at the export salt, so importing the sealed file requires
+ * ONLY the export password (never the source vault's master password). The
+ * envelope also protects titles, usernames, URLs and notes at rest.
+ *
+ * @param {string[]} [groupIds] - Group IDs to export. null/[] = export (ungrouped '').
+ * @param {CryptoKey|null} [vaultKey] - Current vault's in-memory encryption key (required when exportPassword is set).
+ * @param {string} [exportPassword] - Optional separate password used to seal the exported file.
* @returns {Promise