Import doesn't assume the same password.

This commit is contained in:
Timothy Farrell 2026-05-12 20:34:54 +00:00
parent 289e8c9e34
commit 49b3994de4
3 changed files with 138 additions and 29 deletions

6
dist/index.html vendored

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,7 @@
<script> <script>
import { exportAll, importAll } from '../lib/storage/db.js' import { exportAll, importAll } from '../lib/storage/db.js'
import { search as searchStore } from '../lib/stores/search.svelte.js' import { search as searchStore } from '../lib/stores/search.svelte.js'
import { app } from '../lib/stores/app.svelte.js'
let showExport = $state(false) let showExport = $state(false)
let showImport = $state(false) let showImport = $state(false)
@ -10,6 +11,8 @@
let importing = $state(false) let importing = $state(false)
let exportData = $state(null) let exportData = $state(null)
let exporting = $state(false) let exporting = $state(false)
let sourcePassword = $state('')
let parsedFileData = $state(null)
async function handleExport() { async function handleExport() {
exporting = true exporting = true
@ -30,13 +33,13 @@
exporting = false exporting = false
} }
async function handleImport(event) { async function handleFileSelect(event) {
const file = event.target.files[0] const file = event.target.files[0]
if (!file) return if (!file) return
importing = true
importError = '' importError = ''
importResult = null importResult = null
sourcePassword = ''
try { try {
const text = await file.text() const text = await file.text()
@ -44,20 +47,40 @@
if (!data.entries || !data.groups) { if (!data.entries || !data.groups) {
importError = 'Invalid file format — missing entries or groups data' importError = 'Invalid file format — missing entries or groups data'
importing = false
return return
} }
const result = await importAll(data, importMode) parsedFileData = data
} catch (e) {
importError = 'Failed to parse file: ' + e.message
}
// Reset file input
event.target.value = ''
}
async function handleImportSubmit() {
if (!parsedFileData) return
if (!sourcePassword.trim()) {
importError = 'Source vault password is required'
return
}
importing = true
importError = ''
importResult = null
try {
const result = await importAll(parsedFileData, importMode, sourcePassword, app.encryptionKey)
importResult = result importResult = result
sourcePassword = ''
parsedFileData = null
searchStore.refresh() searchStore.refresh()
} catch (e) { } catch (e) {
importError = 'Import failed: ' + e.message importError = 'Import failed: ' + e.message
} }
importing = false importing = false
// Reset file input
event.target.value = ''
} }
</script> </script>
@ -78,7 +101,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> <!-- 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()}> <div class="modal" role="dialog" aria-modal="true" aria-label="Export vault" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<h3>Export Vault</h3> <h3>Export Vault</h3>
<p>All entries and groups will be exported as encrypted JSON. You'll need your master password to import them later.</p> <p>All entries and groups will be exported. You'll need the source vault's master password when importing into another vault.</p>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-primary" onclick={handleExport} disabled={exporting}> <button class="btn btn-primary" onclick={handleExport} disabled={exporting}>
{exporting ? 'Exporting...' : '📤 Export JSON'} {exporting ? 'Exporting...' : '📤 Export JSON'}
@ -107,6 +130,37 @@
({importResult.skipped} skipped) ({importResult.skipped} skipped)
{/if} {/if}
</div> </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>
<div class="form-group">
<label for="source-password" class="file-label">Source vault password</label>
<input
id="source-password"
type="password"
bind:value={sourcePassword}
placeholder="Enter source vault password"
autocomplete="current-password"
/>
</div>
<div class="import-mode">
<label class="radio-label">
<input type="radio" name="importMode" value="merge" bind:group={importMode} />
<span>Merge — add to existing data</span>
</label>
<label class="radio-label">
<input type="radio" name="importMode" value="replace" bind:group={importMode} />
<span>Replace — clear all existing data first</span>
</label>
</div>
<div class="modal-actions">
<button class="btn btn-primary" onclick={handleImportSubmit} disabled={importing}>
{importing ? 'Importing...' : '📥 Import'}
</button>
<button class="btn btn-ghost" onclick={() => { parsedFileData = null; sourcePassword = ''; }}>Cancel</button>
</div>
{:else} {:else}
<p>Select how to handle existing data:</p> <p>Select how to handle existing data:</p>
@ -127,7 +181,7 @@
id="import-file" id="import-file"
type="file" type="file"
accept=".json,application/json" accept=".json,application/json"
onchange={handleImport} onchange={handleFileSelect}
disabled={importing} disabled={importing}
/> />
</div> </div>
@ -246,4 +300,24 @@
font-size: 0.85rem; font-size: 0.85rem;
padding: 8px; padding: 8px;
} }
.form-group {
margin-bottom: 16px;
}
.form-group input[type="password"] {
width: 100%;
padding: 8px 12px;
font-size: 0.85rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
box-sizing: border-box;
}
.form-group input[type="password"]:focus {
outline: none;
border-color: var(--color-primary);
}
</style> </style>

View File

@ -12,6 +12,7 @@
*/ */
import { openDB } from 'idb' import { openDB } from 'idb'
import { deriveKey, decrypt, encrypt } from '../crypto/crypto.js'
const DB_NAME = 'password-vault' const DB_NAME = 'password-vault'
const DB_VERSION = 1 const DB_VERSION = 1
@ -281,7 +282,9 @@ export async function getEntryCountsByGroup() {
/** /**
* Export all data (entries + groups + meta) as a JSON object. * Export all data (entries + groups + meta) as a JSON object.
* Entries remain encrypted the importer needs the same master password. * 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.
* *
* @returns {Promise<Object>} * @returns {Promise<Object>}
*/ */
@ -307,32 +310,58 @@ export async function exportAll() {
} }
} }
/**
* Convert a base64 string back to Uint8Array.
* @param {string} base64
* @returns {Uint8Array}
*/
function base64ToUint8Array(base64) {
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
}
/** /**
* Import data from a previously exported JSON object. * Import data from a previously exported JSON object.
* *
* Requires the source vault's master password to decrypt entries, then
* re-encrypts them under the target vault's current encryption key.
* The target vault's meta (salt, test payload) is never overwritten.
*
* @param {Object} data * @param {Object} data
* @param {'merge'|'replace'} mode - 'merge' adds to existing, 'replace' clears first * @param {'merge'|'replace'} mode - 'merge' adds to existing, 'replace' clears first
* @param {string} sourcePassword - Master password of the source vault
* @param {CryptoKey} targetKey - Current encryption key of the target vault
* @returns {Promise<{ imported: { entries: number, groups: number }, skipped: number }>} * @returns {Promise<{ imported: { entries: number, groups: number }, skipped: number }>}
*/ */
export async function importAll(data, mode = 'merge') { export async function importAll(data, mode = 'merge', sourcePassword = '', targetKey = null) {
if (!data || !Array.isArray(data.entries) || !Array.isArray(data.groups)) { if (!data || !Array.isArray(data.entries) || !Array.isArray(data.groups)) {
throw new Error('Invalid import data format') throw new Error('Invalid import data format')
} }
// Derive the source vault's key from its salt + password
let sourceKey = null
if (data.meta?.salt && sourcePassword) {
const sourceSalt = base64ToUint8Array(data.meta.salt)
sourceKey = await deriveKey(sourcePassword, sourceSalt)
}
const db = await getDb() const db = await getDb()
if (mode === 'replace') { if (mode === 'replace') {
await db.clear('entries') await db.clear('entries')
await db.clear('groups') await db.clear('groups')
// Clear meta so user can re-setup // Do NOT clear meta — preserve the target vault's identity
await db.clear('meta')
} }
let skipped = 0 let skipped = 0
let importedEntries = 0 let importedEntries = 0
let importedGroups = 0 let importedGroups = 0
// Import groups // Import groups (groups are not encrypted)
for (const group of data.groups) { for (const group of data.groups) {
try { try {
await db.put('groups', group) await db.put('groups', group)
@ -342,26 +371,32 @@ export async function importAll(data, mode = 'merge') {
} }
} }
// Import entries // Import entries — decrypt with source key, re-encrypt with target key
for (const entry of data.entries) { for (const entry of data.entries) {
try { try {
await db.put('entries', entry) let reencryptedEntry = { ...entry }
if (sourceKey && targetKey && entry.encryptedPassword) {
// Decrypt password with source key
const plaintext = await decrypt(entry.encryptedPassword, sourceKey)
// Re-encrypt under target vault's key
reencryptedEntry.encryptedPassword = await encrypt(plaintext, targetKey)
} else if (!sourceKey || !targetKey) {
// Can't re-encrypt — skip this entry with a warning
console.warn('Skipping entry (missing source password or target key):', entry.title)
skipped++
continue
}
await db.put('entries', reencryptedEntry)
importedEntries++ importedEntries++
} catch { } catch (e) {
console.warn('Failed to import entry:', entry.title, e)
skipped++ skipped++
} }
} }
// Restore meta if present // Never overwrite the target vault's meta
if (data.meta?.salt) {
await db.put('meta', { key: 'salt', value: data.meta.salt })
}
if (data.meta?.testEncrypted) {
await db.put('meta', { key: 'testEncrypted', value: data.meta.testEncrypted })
}
if (data.meta?.testPlaintext) {
await db.put('meta', { key: 'testPlaintext', value: data.meta.testPlaintext })
}
return { return {
imported: { entries: importedEntries, groups: importedGroups }, imported: { entries: importedEntries, groups: importedGroups },