Export can be sealed with a separate password; import accepts file with a different password

- exportSelected(groupIds, vaultKey, exportPassword=''): plain JSON export unchanged when
  no export password; when one is set, re-keys each entry's password to a key derived from
  the export password and AES-256-GCM-seals the entire payload (titles/usernames/notes
  protected too). Returns a { format: 'encrypted-export', salt, data } envelope.
- importAll() detects sealed exports and treats the supplied password as the EXPORT password,
  so it may differ from any vault's master password. Wrong password rejects import instead of
  silently skipping entries.
- ImportExport.svelte: optional 'separate password' field in the export dialog; import dialog's
  field reworded as a generic file password covering both plain and sealed files.
- Tests for sealed export/import round-trip incl. wrong-password & missing-password rejects.
This commit is contained in:
hermes-explorigin 2026-08-26 23:38:25 +00:00
parent a71d5c4658
commit 800feb1d37
4 changed files with 187 additions and 35 deletions

View File

@ -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

View File

@ -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 @@
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="modal" role="dialog" aria-modal="true" aria-label="Export vault" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<h3>Export Vault</h3>
<p>Select which groups to export. You'll need the source vault's master password when importing into another vault.</p>
<p>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).</p>
<div class="group-select-header">
<label class="checkbox-label">
@ -161,6 +167,17 @@
{/each}
</div>
<div class="form-group">
<label for="export-password" class="file-label">Encrypt export with a separate password (optional)</label>
<input
id="export-password"
type="password"
bind:value={exportPassword}
placeholder="Leave a separate password, or clear for a plain JSON export"
autocomplete="new-password"
/>
</div>
<div class="modal-actions">
<button class="btn btn-primary" onclick={handleExport} disabled={exporting || selectedGroupIds.length === 0}>
{exporting ? 'Exporting...' : '📤 Export JSON'}
@ -190,15 +207,15 @@
{/if}
</div>
{:else if parsedFileData}
<p>File loaded. Enter the <strong>source vault's master password</strong> to decrypt and re-encrypt entries under your current vault.</p>
<p>File loaded. Enter the password protecting this file &mdash; for an encrypted export that is the <strong>export password</strong> you chose; for a plain export it is the <strong>source vault's master password</strong>.</p>
<div class="form-group">
<label for="source-password" class="file-label">Source vault password</label>
<label for="source-password" class="file-label">Password for this file</label>
<input
id="source-password"
type="password"
bind:value={sourcePassword}
placeholder="Enter source vault password"
placeholder="Enter the export or source vault password"
autocomplete="current-password"
/>
</div>

View File

@ -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<Object>}
*/
export async function exportSelected(groupIds) {
export async function exportSelected(groupIds, vaultKey = null, exportPassword = '') {
const db = await getDb()
const allEntries = await db.getAll('entries')
const allGroups = await db.getAll('groups')
@ -395,24 +412,11 @@ export async function exportSelected(groupIds) {
const testPlaintextRow = await db.get('meta', 'testPlaintext')
// If no groups selected, export everything
if (!groupIds || groupIds.length === 0) {
return {
version: DB_VERSION,
exportedAt: new Date().toISOString(),
meta: {
salt: saltRow?.value || null,
testEncrypted: testEncryptedRow?.value || null,
testPlaintext: testPlaintextRow?.value || null,
},
groups: allGroups,
entries: allEntries,
}
}
const full = !groupIds || groupIds.length === 0
const pickEntry = full ? () => true : e => groupIds.includes(e.groupId)
const pickGroup = full ? () => true : g => groupIds.includes(g.id)
const entries = allEntries.filter(e => groupIds.includes(e.groupId))
const groups = allGroups.filter(g => groupIds.includes(g.id))
return {
const payload = {
version: DB_VERSION,
exportedAt: new Date().toISOString(),
meta: {
@ -420,12 +424,48 @@ export async function exportSelected(groupIds) {
testEncrypted: testEncryptedRow?.value || null,
testPlaintext: testPlaintextRow?.value || null,
},
groups,
entries,
groups: allGroups.filter(pickGroup),
entries: allEntries.filter(pickEntry),
}
// Plain export (no export password) - existing, backward-compatible behavior.
if (!exportPassword) {
return payload
}
// Password-protected export: re-key entries to the export key, then seal.
if (!vaultKey) {
throw new Error('Vault key is required to create a password-protected export')
}
const exportSalt = generateSalt()
const exportKey = await deriveKey(exportPassword, exportSalt)
// Re-encrypt each entry password from the vault key to the export key.
for (const entry of payload.entries) {
if (!entry.encryptedPassword) continue
const plaintext = await decrypt(entry.encryptedPassword, vaultKey)
entry.encryptedPassword = await encrypt(plaintext, exportKey)
}
// Point meta.salt at the export salt so import derives the correct key.
payload.meta.salt = uint8ArrayToBase64(exportSalt)
// Seal the entire payload (also protects titles/usernames/notes at rest).
const sealed = await encrypt(JSON.stringify(payload), exportKey)
return {
version: 1,
exportedAt: new Date().toISOString(),
format: 'encrypted-export',
kdfIterations: 600_000,
salt: uint8ArrayToBase64(exportSalt),
data: sealed, // encrypt() output string: { iv, ciphertext }
}
}
/**
* Import data from a previously exported JSON object.
*
@ -440,6 +480,24 @@ export async function exportSelected(groupIds) {
* @returns {Promise<{ imported: { entries: number, groups: number }, skipped: number }>}
*/
export async function importAll(data, mode = 'merge', sourcePassword = '', targetKey = null) {
// Accept a password-protected (sealed) export. `sourcePassword` here is the
// EXPORT password - it may differ from any vault's master password. If the
// wrong password is entered, decryption fails and import is rejected, rather
// than silently skipping entries.
if (data && data.format === 'encrypted-export') {
if (!sourcePassword) {
throw new Error('The export password is required to import this encrypted file')
}
const exportKey = await deriveKey(sourcePassword, base64ToUint8Array(data.salt))
let innerJson
try {
innerJson = await decrypt(data.data, exportKey)
} catch {
throw new Error('Incorrect export password - could not decrypt this file')
}
data = JSON.parse(innerJson)
}
if (!data || !Array.isArray(data.entries) || !Array.isArray(data.groups)) {
throw new Error('Invalid import data format')
}

View File

@ -25,7 +25,7 @@ import {
importAll,
TRASH_GROUP_ID,
} from '../../../src/lib/storage/db.js'
import { generateSalt, deriveKey, encrypt } from '../../../src/lib/crypto/crypto.js'
import { generateSalt, deriveKey, encrypt, decrypt } from '../../../src/lib/crypto/crypto.js'
import { createEntry, createGroup, createTrashGroup } from '../../../src/lib/models/schema.js'
const DB_NAME = 'password-vault'
@ -601,4 +601,78 @@ describe('Export / Import', () => {
expect(result.skipped).toBe(1)
expect(result.imported.entries).toBe(0)
})
describe('password-protected encrypted export/import', () => {
async function setupSourceVault() {
await clearAllData()
const salt = generateSalt()
const key = await deriveKey('source-key', salt)
const testPlaintext = 'vault_test_src'
const testEncrypted = await encrypt(testPlaintext, key)
await saveVaultMeta(salt, testEncrypted, testPlaintext)
const group = createGroup('Work')
await addGroup(group)
const enc = await encrypt('topsecret', key)
await addEntry(createEntry({
title: 'GitHub',
encryptedPassword: enc,
groupId: group.id,
}))
return key
}
async function setupTargetVault() {
await clearAllData()
const salt = generateSalt()
const key = await deriveKey('target-key', salt)
const testPlaintext = 'vault_test_tgt'
const testEncrypted = await encrypt(testPlaintext, key)
await saveVaultMeta(salt, testEncrypted, testPlaintext)
return key
}
it('seals the export envelope with the separate password', async () => {
const sourceKey = await setupSourceVault()
const sealed = await exportSelected(null, sourceKey, 'exportpw')
expect(sealed.format).toBe('encrypted-export')
expect(sealed.salt).toBeDefined()
expect(sealed.data).toBeDefined()
expect(sealed.groups).toBeUndefined()
expect(sealed.entries).toBeUndefined()
// The plaintext payload (incl. entry title) must not leak into the envelope
expect(JSON.stringify(sealed)).not.toContain('GitHub')
})
it('imports a sealed export into another vault using only the export password', async () => {
const sourceKey = await setupSourceVault()
const sealed = await exportSelected(null, sourceKey, 'exportpw')
const targetKey = await setupTargetVault()
const result = await importAll(sealed, 'merge', 'exportpw', targetKey)
expect(result.skipped).toBe(0)
expect(result.imported.entries).toBe(1)
const entries = await getEntries()
expect(entries).toHaveLength(1)
expect(entries[0].title).toBe('GitHub')
const plain = await decrypt(entries[0].encryptedPassword, targetKey)
expect(plain).toBe('topsecret')
})
it('rejects a sealed export when the export password is wrong', async () => {
const sourceKey = await setupSourceVault()
const sealed = await exportSelected(null, sourceKey, 'exportpw')
const targetKey = await setupTargetVault()
await expect(importAll(sealed, 'merge', 'wrong-password', targetKey))
.rejects.toThrow('Incorrect export password')
})
it('requires an export password to import a sealed export', async () => {
const sourceKey = await setupSourceVault()
const sealed = await exportSelected(null, sourceKey, 'exportpw')
const targetKey = await setupTargetVault()
await expect(importAll(sealed, 'merge', '', targetKey))
.rejects.toThrow('The export password is required')
})
})
})