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:
parent
a71d5c4658
commit
800feb1d37
@ -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
|
||||
|
||||
|
||||
@ -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 — 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>
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user