Adds RFC 6238 TOTP (Google Authenticator-style) support to every entry.
The secret is encrypted client-side with the same AES-GCM key as the
password — the server stores opaque ciphertext and never sees the
plaintext base32 secret.
Schema
======
vault_entries.totp_secret TEXT -- AES-GCM ciphertext, base64
vault_entries.totp_iv TEXT -- 12-byte IV, base64
Both NULL when the entry has no 2FA configured. Added via
ApplyMigrations.AddColumnIfMissing so existing vaults migrate cleanly.
Backend
=======
HandleListEntries: includes totp_secret + totp_iv in the response (or
JSON null when not configured).
HandleCreateEntry / HandleUpdateEntry: accept both fields; empty string
in the body → server stores NULL. Clearing the secret removes 2FA
from the entry.
Frontend
========
TOTP primitives (pure crypto.subtle, no external lib):
- base32Decode(s) — RFC 4648, tolerates spaces / lowercase
- generateTOTP(secret) — HMAC-SHA1 + RFC 4226 dynamic truncation
- parseOtpAuthUri(raw) — extracts ?secret from otpauth:// URIs
UI in the slide-over (the canonical entry detail view):
- New "Two-factor (TOTP)" field below the password row.
- Input is password-masked by default with eye-toggle to reveal.
- Pasting a full otpauth:// URI auto-extracts the secret param so the
user can copy directly from a QR-code scanner without manual cleanup.
- X button clears the secret (= removes 2FA on next save).
- Live code panel below: large monospace "123 456" + Copy button
(routes through Bridge.copySecure → secure clipboard + 30s auto-clear).
- Linear progress bar drains over the 30s window, turns red < 5s.
- Refresh tick runs once per second while the slide-over is open;
stops on closeSlideOver to avoid background work.
Entry card meta now shows a "2FA" chip when totp_secret is non-null —
quick visual scan for which accounts have 2FA configured without
opening the slide-over.
Validation
==========
soSave calls base32Decode(secret) before encrypting to refuse obviously
broken input. Otherwise garbled base32 would save fine and only fail
in the code panel next time.
Migration interaction (KDF 100k→600k)
=====================================
KNOWN MINOR ISSUE: /migrate-kdf only re-encrypts encrypted_password+iv,
not totp_secret+totp_iv. In practice this is harmless because:
1) KDF migration runs immediately after login on legacy accounts —
before the user has a chance to add a TOTP secret.
2) New accounts start at 600k iterations, no migration ever needed.
A legacy user who somehow added a TOTP between login and the
background migration completing would end up with a TOTP encrypted
under the old key. The fix (extend /migrate-kdf to re-encrypt TOTP
fields too) is a one-line follow-up if anyone hits the edge case.
HIBP integration
================
Opt-in (default OFF) password breach check via the Have I Been Pwned
range API. The full master / entry password never leaves the machine —
only the first 5 characters of its SHA-1 hash. HIBP returns ~500
candidate suffixes; the client matches its own suffix locally.
UI:
- New "Check passwords against breach database (HIBP)" toggle in
Settings → Security with an explainer hint about k-anonymity.
- On enable: background batch scan of all entries, results cached in
state.hibpResults keyed by entry id. Concurrency capped at 6 to
avoid hammering HIBP / hitting browser connection limits.
- Entry cards show a red "Pwned" chip + breach count in the tooltip
when count > 0. New i-alert icon added to the SVG sprite.
- Auto-scan triggered after every enterApp() when the toggle is on.
Functions added to app.js:
- sha1Hex(text) — crypto.subtle wrapper
- hibpCheckPassword(plaintext) — single-password check, returns count
- hibpCheckAllEntries() — batched scan over state.entries
The "Add-Padding: true" header is sent on every range request to defeat
the response-size side-channel (HIBP adds 800-1000 random extra entries
so an observer counting bytes can't narrow the prefix queried).
CSP tightening
==============
Audited the served HTML: zero <script> tags inline, only the external
js/app.js. Removed 'unsafe-inline' from script-src — real XSS defense.
Kept 'unsafe-inline' on style-src for now because index.html contains
inline style="" attributes and app.js calls element.style.cssText
extensively. Refactoring to CSS classes is a separate cleanup. Style
injection alone cannot execute code, so the residual risk is bounded
to visual manipulation in a single-user loopback app.
Added api.pwnedpasswords.com to connect-src as the only allowed
external origin (required by the HIBP feature above). Default still
'self' — everything else stays loopback.
Before:
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
connect-src 'self';
After:
script-src 'self';
style-src 'self' 'unsafe-inline';
connect-src 'self' https://api.pwnedpasswords.com;
Dark theme: neutral dark grays (#0e1015 bg, #6b7280 accent). Light theme: white cards, better contrast. Cards now have subtle shadows for separation. All hardcoded rgba(59,130,246) replaced with rgba(var(--accent-rgb), ...). Arrow key handler skips when add/edit modal is open. Detail view now uses getFilteredEntries() instead of getFilteredEntries(true) to respect folder selection.
Replace showCenterConfirm with showBatchConfirm(btn,message,callback) that creates a transparent modal overlay (z-index 9999) to block background clicks, positions the confirm dialog beside the trigger button. Updated all callers (batchDelete, batchPermanentDelete, permanentDelete, emptyTrash) to pass the clicked button. Added CSS for batch-confirm-overlay backdrop.
Replace view toggle buttons with dropdown menu containing all 7 views. Card view: 2-column grid with bigger cards. Grouped view: entries grouped by folder with sticky headers. Detail view: single entry at a time, large text, prev/next navigation. Arrow key navigation for detail view.
Added user-select:none and -webkit-user-drag:none to #entriesContainer.
Also added a dragstart listener that prevents default for any element
that is not [draggable='true'], stopping the browser from creating drag
ghosts of selected table text/headers during rectangle selection.
- Brighter selection highlight with stronger box-shadow, background, and left-border accent
- Rectangle/band selection: click-drag on empty space to select multiple entries via intersection test
- Drag selected entries to a folder chip to move all, or to trash button to multi-delete
- Multi-drag reorder: dragging a selected entry reorders all selected together
- Table view selection now highlights individual td cells (box-shadow on tr was invisible)
- Escape cancels rectangle selection in progress
- Remove separate 'select mode' toggle and checkbox UI
- Click any entry to select it (blue highlight border)
- Shift+click for range selection between two entries
- Ctrl/Cmd+click to toggle individual entries
- Click entry background or press Escape to clear selection
- Batch action bar appears automatically when items are selected
- Selected entries get accent-color border and highlight
- Single delEntry() cleans up selectedIds
- Shortcut help updated with selection tips
- Inline password reveal on hover (controlled by showView setting, replaces eye button)
- Drag an entry card onto a folder chip to move it (no modal needed)
- Generator presets: Strong 16, Strong 20, Paranoid 32 buttons
- Favorites: star toggle button per entry, entries sort to top
- Add favorite column to vault_entries, toggle endpoint, star UI in all views
- Gold border/background for favorited entries
- Rewrite keyboard shortcuts using e.code and early preventDefault() to reliably override browser defaults
- Add ? key and toolbar button for shortcuts help modal
- Add password strength meter to register form
- Add search highlighting in all view modes (grid/list/compact/table)
- Add hash-based color coding for folder chips
- Add highlightText utility with regex escaping