104 lines
7.7 KiB
Markdown

# 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 `$state` memory; cleared on lock, tab switch, or page close.
- **Single-file build** — `vite-plugin-singlefile` inlines all JS/CSS; post-build script inlines favicon.
- **IndexedDB via `idb`** — Three stores: `entries`, `groups`, `meta`. Only `encryptedPassword` is 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_COLORS` exported from schema.js** — Shared between `createGroup()` and `Sidebar.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 utils
- `tests/lib/models/schema.test.js` — ID generation, entry/group CRUD, validation, trash group
- `tests/lib/storage/db.test.js` — Vault meta, settings, groups CRUD, entries CRUD, search, trash, export/import
- `tests/lib/stores/app.test.js` — AppStore state and lockVault
- `tests/lib/stores/search.test.js` — SearchStore query, group filter, clear
- `tests/lib/stores/settings.test.js` — SettingsStore defaults, load, save
- `tests/lib/stores/security.test.js` — Auto-lock timer, visibility change, beforeunload, activity reset
- Run with `npm run test` or `npm run test:run`
## Security Notes
- Only `encryptedPassword` and `encryptedTotpSecret` are encrypted at rest; other fields (title, username, URL, notes) are plaintext in IndexedDB. Passwords and TOTP secrets are optional on an entry.
- `testPlaintext` for password verification is stored unencrypted in the `meta` store.
- 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.
## TOTP (2FA)
- `src/lib/crypto/totp.js`: native RFC 6238 TOTP via Web Crypto HMAC-SHA1 (no external lib). Exports `generateTotp(secret, {timestamp, period=30, digits=6})`, `base32Decode`, `extractSecret` (accepts bare base32 OR `otpauth://` URI), `totpRemainingSeconds`.
- Entries store an optional `encryptedTotpSecret` (AES-GCM, like passwords). EntryForm accepts a base32 secret or otpauth:// URI; EntryDetail shows a live, copyable 6-digit code with a 1s countdown + urgency color, cleaned up via `onDestroy`.
- Export/import re-key `encryptedTotpSecret` alongside passwords for sealed exports (and decrypt/re-encrypt on import), so TOTP survives migration between vaults.
- Note: `extractSecret` must strip hyphens from hyphen-grouped secrets (authenticator display style) as well as whitespace.
## 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.
- `importAll(data, mode, password, targetKey)` detects a sealed export (`data.format === 'encrypted-export'`): `password` is 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 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 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
- `base64ToUint8Array` was used in `db.js` `importAll()` but never imported — now imported from `crypto.js`.
- `handlePermanentDelete()` in `EntryDetail.svelte` called `moveToTrash()` + `emptyTrash()` (wiping ALL trash) — now uses `deleteEntry()` directly.