7.7 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 $state memory; cleared on lock, tab switch, or page close.
  • Single-file buildvite-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.