Import doesn't assume the same password.
This commit is contained in:
parent
289e8c9e34
commit
49b3994de4
6
dist/index.html
vendored
6
dist/index.html
vendored
File diff suppressed because one or more lines are too long
@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { exportAll, importAll } from '../lib/storage/db.js'
|
||||
import { search as searchStore } from '../lib/stores/search.svelte.js'
|
||||
import { app } from '../lib/stores/app.svelte.js'
|
||||
|
||||
let showExport = $state(false)
|
||||
let showImport = $state(false)
|
||||
@ -10,6 +11,8 @@
|
||||
let importing = $state(false)
|
||||
let exportData = $state(null)
|
||||
let exporting = $state(false)
|
||||
let sourcePassword = $state('')
|
||||
let parsedFileData = $state(null)
|
||||
|
||||
async function handleExport() {
|
||||
exporting = true
|
||||
@ -30,13 +33,13 @@
|
||||
exporting = false
|
||||
}
|
||||
|
||||
async function handleImport(event) {
|
||||
async function handleFileSelect(event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
importing = true
|
||||
importError = ''
|
||||
importResult = null
|
||||
sourcePassword = ''
|
||||
|
||||
try {
|
||||
const text = await file.text()
|
||||
@ -44,20 +47,40 @@
|
||||
|
||||
if (!data.entries || !data.groups) {
|
||||
importError = 'Invalid file format — missing entries or groups data'
|
||||
importing = false
|
||||
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
|
||||
sourcePassword = ''
|
||||
parsedFileData = null
|
||||
searchStore.refresh()
|
||||
} catch (e) {
|
||||
importError = 'Import failed: ' + e.message
|
||||
}
|
||||
|
||||
importing = false
|
||||
// Reset file input
|
||||
event.target.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -78,7 +101,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>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">
|
||||
<button class="btn btn-primary" onclick={handleExport} disabled={exporting}>
|
||||
{exporting ? 'Exporting...' : '📤 Export JSON'}
|
||||
@ -107,6 +130,37 @@
|
||||
({importResult.skipped} skipped)
|
||||
{/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>
|
||||
|
||||
<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}
|
||||
<p>Select how to handle existing data:</p>
|
||||
|
||||
@ -127,7 +181,7 @@
|
||||
id="import-file"
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
onchange={handleImport}
|
||||
onchange={handleFileSelect}
|
||||
disabled={importing}
|
||||
/>
|
||||
</div>
|
||||
@ -246,4 +300,24 @@
|
||||
font-size: 0.85rem;
|
||||
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>
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
import { openDB } from 'idb'
|
||||
import { deriveKey, decrypt, encrypt } from '../crypto/crypto.js'
|
||||
|
||||
const DB_NAME = 'password-vault'
|
||||
const DB_VERSION = 1
|
||||
@ -281,7 +282,9 @@ export async function getEntryCountsByGroup() {
|
||||
|
||||
/**
|
||||
* 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>}
|
||||
*/
|
||||
@ -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.
|
||||
*
|
||||
* 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 {'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 }>}
|
||||
*/
|
||||
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)) {
|
||||
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()
|
||||
|
||||
if (mode === 'replace') {
|
||||
await db.clear('entries')
|
||||
await db.clear('groups')
|
||||
// Clear meta so user can re-setup
|
||||
await db.clear('meta')
|
||||
// Do NOT clear meta — preserve the target vault's identity
|
||||
}
|
||||
|
||||
let skipped = 0
|
||||
let importedEntries = 0
|
||||
let importedGroups = 0
|
||||
|
||||
// Import groups
|
||||
// Import groups (groups are not encrypted)
|
||||
for (const group of data.groups) {
|
||||
try {
|
||||
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) {
|
||||
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++
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.warn('Failed to import entry:', entry.title, e)
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
|
||||
// Restore meta if present
|
||||
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 })
|
||||
}
|
||||
// Never overwrite the target vault's meta
|
||||
|
||||
return {
|
||||
imported: { entries: importedEntries, groups: importedGroups },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user