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>
|
<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>
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user