- exportSelected(groupIds, { vaultKey, password, useExistingPassword }) replaces the
positional (groupIds, vaultKey, exportPassword) form. Protection is now an explicit
choice, never an ambiguous optional field.
- password mode: re-key entries under a fresh export-derived key (unchanged semantics).
- reuseExistingPassword mode: seal with the vault's own key, keep vault salt embedded,
so import derives the key from the vault master password. No second password required.
- ImportExport.svelte: replaces the long-placeholder free-text field with two radio
options (Use a new password / Reuse my vault password); short placeholder, with
client-side validation that a new password isn't empty.
- Cryptography importAll unchanged: derives the envelope key from the supplied password +
embedded salt, which covers both sealed modes; wrong password still rejects import.
- Tests: updated call sites to options object; added round-trip + wrong-password tests for
reuseExistingPassword.
6.8 KiB
6.8 KiB
Password Vault — Agent Context
Project Overview
An offline-first, single-file password manager built with Svelte 5 (runes-based reactivity). All data is encrypted in-browser with AES-256-GCM and stored in IndexedDB. The production build is a single dist/index.html with zero network dependencies — it works from file://, USB, or any static server.
Architecture
src/
├── App.svelte # Root component — routes between LockScreen, MainLayout
├── main.js # Entry point
├── components/
│ ├── LockScreen.svelte # Master password setup + unlock UI
│ ├── MainLayout.svelte # Shell: sidebar + content area
│ ├── Sidebar.svelte # Group list + search bar
│ ├── EntryList.svelte # Credential entries grid
│ ├── EntryForm.svelte # Create/edit credential form
│ ├── EntryDetail.svelte # View single entry (copy password/username)
│ ├── ImportExport.svelte # JSON import/export with merge/replace
│ └── SettingsDialog.svelte # Auto-lock and tab-switch settings
├── lib/
│ ├── crypto/crypto.js # Web Crypto API: PBKDF2 key derivation, AES-GCM encrypt/decrypt, password generator
│ ├── storage/db.js # IndexedDB layer (idb wrapper): entries, groups, meta stores, exportSelected(groupIds)
│ ├── models/schema.js # Data models: CredentialEntry, Group; validation; ID generation
│ ├── autofocus.js # Svelte action for autofocus on mount
│ └── stores/
│ ├── app.svelte.js # AppStore: $state(isUnlocked, encryptionKey, salt)
│ ├── security.svelte.js # Auto-lock timer, visibility change, beforeunload cleanup
│ ├── search.svelte.js # Reactive search state
│ └── settings.svelte.js # Reactive settings (auto-lock minutes, tab-switch lock)
Key Design Decisions
- Svelte 5 runes — Use
$state,$derived,$effect. Props-based event passing (no Svelte events). Event handler attributes are all lower-case (e.g. oninput) - No external crypto libraries — Uses the browser's native Web Crypto API exclusively.
- Key never persisted — Encryption key lives only in
$statememory; cleared on lock, tab switch, or page close. - Single-file build —
vite-plugin-singlefileinlines all JS/CSS; post-build script inlines favicon. - IndexedDB via
idb— Three stores:entries,groups,meta. OnlyencryptedPasswordis encrypted at rest; titles, usernames, URLs, and notes are plaintext for searchability. - PBKDF2 key derivation — 600,000 iterations, SHA-256, 256-bit AES-GCM key.
GROUP_COLORSexported from schema.js — Shared betweencreateGroup()andSidebar.svelte.
Encryption Flow
Master Password → PBKDF2 (600k iters, 16-byte salt) → AES-256-GCM Key → encrypt/decrypt credentials
Password verification uses a test payload (random string encrypted at vault creation). On unlock, the entered password derives a key that must successfully decrypt the test payload.
Scripts
| Command | Description |
|---|---|
npm run dev |
Vite dev server with HMR on :5173 |
npm run build |
Production build → dist/index.html (single self-contained file) |
npm run preview |
Preview production build |
npm run test |
Vitest (watch mode) |
npm run test:run |
Vitest (single run) |
Testing
- Framework: Vitest with jsdom environment
- Test setup:
tests/setup.js(fake-indexeddb polyfill, Web Crypto API polyfill) - Test files:
tests/lib/crypto/crypto.test.js— Password generation, key derivation, encrypt/decrypt, verify, test payload, base64 utilstests/lib/models/schema.test.js— ID generation, entry/group CRUD, validation, trash grouptests/lib/storage/db.test.js— Vault meta, settings, groups CRUD, entries CRUD, search, trash, export/importtests/lib/stores/app.test.js— AppStore state and lockVaulttests/lib/stores/search.test.js— SearchStore query, group filter, cleartests/lib/stores/settings.test.js— SettingsStore defaults, load, savetests/lib/stores/security.test.js— Auto-lock timer, visibility change, beforeunload, activity reset
- Run with
npm run testornpm run test:run
Security Notes
- Only
encryptedPasswordis encrypted at rest; other fields (title, username, URL, notes) are plaintext in IndexedDB. testPlaintextfor password verification is stored unencrypted in themetastore.- Auto-lock triggers on tab visibility change and configurable inactivity timer (default 5 min).
- Clipboard auto-clears after 15 seconds.
- No browser fingerprinting or anti-keylogger protections.
Export / Import
exportSelected(groupIds, options)— group IDs to export;null/[]= full export (include''for ungrouped).options = { vaultKey, password = '', useExistingPassword = false }. An explicit protection choice is required; every protected export AES-256-GCM-seals the whole payload (titles/usernames/notes included) into{ format: 'encrypted-export', salt, data }.- New password (
options.password): re-keys every exported entry's password to a key derived from that password + a fresh random salt (embedded in the envelope), so import needs only this password — never the vault's. - Reuse existing password (
options.useExistingPassword): seals with the vault's own key and keeps the vault's salt embedded, so import derives the key from the vault master password. No second password to create/remember. - No option set: falls back to a plaintext JSON export (entries keep source-vault-encrypted passwords) for backward compatibility; the UI never offers this.
- New password (
importAll(data, mode, password, targetKey)detects a sealed export (data.format === 'encrypted-export'):passwordis whichever password opens the envelope — a separate export password OR the source vault's master password (for reuse-existing). A wrong password rejects the import (never silently skips). The entry loop decrypts with the reproduced source key and re-encrypts undertargetKey.ImportExport.sveltefetches groups/entries on modal open and shows a checkbox list for group selection with live entry count. The export dialog forces an explicit choice via two radio options: "Use a new password" (shows a short password field, validated non-empty) or "Reuse my vault password". The import dialog's single password field is generic (export password for sealed files, source vault master for plain/old files).
Known Bug Fixes
base64ToUint8Arraywas used indb.jsimportAll()but never imported — now imported fromcrypto.js.handlePermanentDelete()inEntryDetail.sveltecalledmoveToTrash()+emptyTrash()(wiping ALL trash) — now usesdeleteEntry()directly.