Hunted the classes behind the recent quick-search fixes across the codebase:
- Esc priority: the command palette is .cmd-palette (not .modal), so the
slideover's capture-phase Esc handler didn't see it and popped the
discard-confirm UNDER the open palette. Capture handler now also yields
to the palette.
- Esc fall-through: the fallback Esc branch closed palette + slideover +
entry modal + generator ALL on one keystroke. Now closes exactly one
surface per keystroke, topmost first.
- Modifier chord: the real-VK Tab between username and password becomes
Shift+Tab if the user holds Shift mid-fill -> focus moves backward and
the password lands in the username field. ForceReleaseModifiers before
SendVKey(VK_TAB).
- Stale placement: RestoreFromTray replayed the MinimizeToTray snapshot for
a merely taskbar-minimised window, teleporting it to the last tray-hide
position. Snapshot now replays only on a genuine tray return (captured
before Show flips Visible); iconic windows use Windows' own placement.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- The qsInput Esc handler didn't stopPropagation, so the SAME keystroke fell
through to the document-level Esc handlers which, seeing the modal now
closed, also closed (or discard-prompted) the dirty slideover behind it.
- autofill/cancel now hands the foreground back to the saved target HWND:
Esc after a fill-mode hotkey returns the user to the window they came
from instead of leaving our app focused. Locked-vault path still ends
focused on us (cancelAutofill runs before focusApp).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
wasHidden counted IsIconic too, so a window merely minimised to the taskbar
("visible" to the user) was treated as tray-origin: Esc/pick sent it to the
TRAY, vanishing from the taskbar it came from. wasHidden is now strictly
"came from the tray" (Visible=false); an iconic window gets restored by the
hotkey and simply stays open when the modal closes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copy a password (30s auto-clear armed), then copy something else from
another app before the timer fires: the tick emptied the clipboard anyway,
destroying the user's newer content. Guard with the Win32 clipboard sequence
number: SetText snapshots GetClipboardSequenceNumber, ClearIfOurs only
empties when it hasn't moved. Applied to the auto-clear timer AND the
clear-on-minimize path (same bug class); the explicit JS clipboard/clear
command stays unconditional (user-initiated).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Residual new-entry trigger (1 in 6): password chars go out as
KEYEVENTF_UNICODE (VK_PACKET, can't match a hotkey) — the real chord risk is
the Ctrl+A clear-field, which sends a real VK_A. If the user re-presses
Ctrl+Shift mid-sequence, that VK_A becomes physical Ctrl+Shift+A = our own
new-entry hotkey. ForceReleaseModifiers now runs inside
SendSelectAllAndDelete, at the risky instant, not just once up front.
Balloon: was gated by "Show tray notifications" (OFF for this user) — now
gated by its own synced setting "Tray alert when autofill is blocked"
(autofillFailBalloon, Settings > Autofill, default ON), carried as notify=0
on cmd://autofill/execute. ShowBalloon no longer gates internally; each
caller applies its own setting.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of "Ctrl+Shift+P opened the new-entry modal": if the user still
holds Ctrl+Shift when WaitForModifierRelease times out (1s), every password
letter is typed as a Ctrl+Shift+<letter> chord — garbage in the field AND it
fires our own global hotkeys (a password containing 'a' triggers Ctrl+Shift+A
= new entry). ForceReleaseModifiers now injects KEYUP for any still-held
modifier before typing.
Also: when the fill is blocked (elevated target) while the window is hidden
in the tray, the in-app toast is invisible — show a tray balloon instead.
ShowFirstTimeBalloon generalized into ShowBalloon(title, text, warning),
gated by the existing "Show tray notifications" setting.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Ctrl+A + Del sent before each field misbehaves on targets where Ctrl+A
isn't select-all (terminals, some remote desktops). New synced setting
(autofillClearField, Settings > Autofill) gates it: JS appends clear=0 to
cmd://autofill/execute when off; ExecuteAutofill wraps the three
SendSelectAllAndDelete calls behind AClearFirst. Absent param = ON, so
existing behavior is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bug 1: a maximized window trayed via the quick-search fill flow came back
"normal" on the next restore. ExecuteAutofill minimizes the window BEFORE
MinimizeToTray snapshots the placement, so the snapshot said SHOWMINIMIZED
and the never-restore-minimized guard forced SHOWNORMAL. Now honours
WPF_RESTORETOMAXIMIZED (Windows keeps the pre-minimize state in flags).
Bug 2: filling into an elevated app (admin Notepad) showed "password sent"
while UIPI silently discarded the keystrokes (SendInput even reports
success). ExecuteAutofill is now a function: it checks the target process
elevation up front (can't-open counts as elevated) and returns False without
typing. UMainForm feeds the result to JS via Bridge.onAutofillResult; the
quick-search success toast is deferred until Delphi confirms, and a failure
shows "Autofill blocked - the target window runs as administrator".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The minimize-then-SW_SHOWNOACTIVATE approach flickered and sometimes lost the
restore race (window stayed minimized). Root fix: when the window was open
before the hotkey (ARestoreAfter), skip the minimize entirely — being the
foreground process is exactly what allows handing focus to the target, so the
window simply stays in place beside it. Tray-origin flow keeps the old
minimize (the window is a temporary overlay, trayed after the fill anyway).
Safety: if the target refuses the foreground (elevated / UIPI) and our window
is still foreground, bail before typing — otherwise the password would be
typed into the vault's own visible UI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two bugs when Ctrl+Shift+Q fires while the app window is open beside the
target app:
- RestoreFromTray re-applied FSavedPlacement (captured at the LAST
MinimizeToTray) to an already-visible window -> it jumped to a stale
position. Now: visible and not iconic -> just SetForegroundWindow.
- ExecuteAutofill minimizes our window when it is foreground (the user just
clicked the entry) and never brought it back when hide_after was false.
New ARestoreAfter param (= not HideAfter): restore with SW_SHOWNOACTIVATE
after the fill, so the window returns to its position without stealing
focus from the freshly-filled target.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Migration verified (0 unmigrated cleartext templates across both accounts)
before contracting:
- GET/POST/PUT/bulk no longer read or write the cleartext template column
(only template_enc/iv); the PUT partial-update gate stays keyed on the
'template' JSON key presence.
- Removed AddColumnIfMissing for template AND tags/title — those two had
silently re-added the dropped columns as empty ghosts at every start.
- CREATE TABLE: removed site/username NOT NULL cleartext columns — a FRESH
database rejected the very first INSERT (which no longer ships them).
User can now DROP COLUMN template (and re-drop the ghost tags/title).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
app.js 9363 -> 7962 lines. Three new classic-script modules:
- app.attachments.js (250): blob crypto + upload/download UI, pure
declarations, loads before app.js
- app.autofill.js (276): Win32 combos, title->entry matching, picker,
pure declarations, loads before app.js
- app.unlock.js (907): Quick Unlock + PIN + recovery code grouped (same
"enter without master pw" theme); assigns Bridge.onPinResult /
onQuickUnlockResult at top level so it loads AFTER app.js, like app.sync.js
Audit viewer stays in app.js (only 65 lines, not worth a file). Clipboard
bridge helpers stay too (were interleaved in the quick-unlock section but
unrelated). Registered in BuildAssets whitelist + index.html + APP_PARTS.
Verified in-app: quick unlock cold-start, attachment upload/download.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
assets.res/.rc/.inc are regenerated by BuildAssets before every Delphi build
and linked into the exe — tracking them just churned a binary each session.
Untrack + gitignore (matches the intent already stated in CLAUDE.md). A fresh
clone runs BuildAssets first anyway (documented build step 1).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The unit suite concatenates + parses every embedded app.*.js (plus argon2)
in a vm, so a syntax error already fails it. Run the ~10 cold `node --check`
spawns (~3-4s) only when tests are bypassed (PM_SKIP_TESTS) or absent.
Cuts the pre-compile freeze from ~7s to ~4s.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add --disable-renderer-backgrounding / -backgrounding-occluded-windows /
-background-timer-throttling to the WebView2 args. Without them Chromium
freezes a hidden renderer, so the first autofill hotkey after a tray-only
start (Start with Windows) waited 3-5s for onAutofillRequest to run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Auto-backup no longer wipes the stored password when disabled, so
re-enabling reuses it silently. A dedicated "Set/Change backup password"
button (mirrors sync) owns the password, with a warning status when
unset. Corrected the stale hint that claimed the backup pwd was derived
from the master password. Added icons to each settings tab.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Authenticate/RequireCSRF write a 401 then raise ESessionRejected; when it
reached the dispatcher catch-all, the generic `on E: Exception` overwrote it
with a 500. Added `on ESessionRejected do Exit` before the generic clause in
both dispatchers (GET + Other) — one place, covers every handler whether or
not it wraps Authenticate. Root cause, not per-handler patch.
ponytail: runtime check only (expired token → 401) — no Delphi unit harness.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rebuilt assets.res embedding the current JS (metadata-at-rest encryption,
dropped-column code, slideover focus fix). Runtime-validated: create/edit/
import/reload all OK.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
User dropped the title + tags cleartext columns too — all four searchable
metadata columns are now gone. Removed their refs from GET emission and the
POST/PUT/bulk INSERT/UPDATE (columns + params + binds). Only *_enc columns
remain; the client reads everything via decryptEntryMeta.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
User dropped the now-empty cleartext `site` + `username` columns after the
§1.3 migration completed. Removed every reference so the code matches the
schema: GET emission, POST/PUT/bulk INSERT/UPDATE (columns + params + binds).
title/tags cleartext columns still exist and are untouched.
decryptEntryMeta defaults e[f]='' for rows without *_enc (notes w/o site),
since GET no longer returns the dropped columns.
ponytail: contract phase of expand→migrate→contract; only safe because the
migration is proven complete (0 cleartext, both accounts).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extends the username-at-rest scheme to site, title and tags — the last
searchable metadata still stored cleartext. Same design: dedicated
<f>_enc/<f>_iv columns (AES-GCM under the vault key), decrypted at load into
e.<f>, so client-side search/sort/render/favicon/autofill-match are unchanged.
Full-strength random-IV AES-GCM (no searchable encryption) because search is
client-side.
Generalized the helpers over ENCRYPTED_META_FIELDS = [username, site, title,
tags]:
- withEncryptedUsername → withEncryptedMeta (encrypts all four, blanks
cleartext) — wraps every POST/PUT body.
- decryptEntryUsernames → decryptEntryMeta (decrypts all four at load).
- migrateUsernamesAtRest → migrateMetadataAtRest (sweeps any field still
cleartext, live + trash).
- doChangeMasterPassword re-encrypts all four under the new key.
Server (Entries + Auth + Database):
- Columns site_enc/iv, title_enc/iv, tags_enc/iv; GET emits them (new
AddNullableField helper); POST/PUT/bulk read+persist (BindNullable helper);
rotation UPDATE re-encrypts them.
- Removed the server "Site required" validation (site='' when encrypted — the
client enforces it) at POST/PUT/bulk.
- ?q= server search neutralized (site+username ciphertext → LIKE useless; the
frontend never sends ?search=).
Tests: merge assertions updated to decrypt site (encrypted on import). 65/65.
username was runtime-validated earlier; site/title/tags NOT yet compiled/
runtime-tested (Delphi) — large multi-handler change. Rebuild BuildAssets +
PMServer, then create/edit/dup/move/tag/import/rotate and verify the DB shows
no cleartext site/title/tags (and the app still renders/searches).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
username is no longer stored cleartext. New columns username_enc/username_iv
(AES-GCM under the vault key, same as encrypted_password). Search/sort/render
stay client-side, so the field is decrypted at loadEntries into e.username in
memory — everything downstream is unchanged. Full-strength random-IV AES-GCM
(no searchable/deterministic encryption) precisely because search is
client-side.
Server (PM.Handler.Entries / .Auth / PM.Database):
- Schema: vault_entries.username_enc, username_iv.
- GET returns them; POST/PUT/bulk-import read + persist them; master-pw
rotation re-encrypts them under the new key (UPDATE + loop).
- ?q= server search drops `username LIKE` (ciphertext won't match; frontend
searches client-side anyway).
Client (app.js / app.import.js):
- loadEntries/loadTrash decrypt username_enc → e.username (fallback to
cleartext for un-migrated rows).
- withEncryptedUsername(obj): write choke point — encrypts obj.username into
username_enc/username_iv and blanks the cleartext. Wraps every POST/PUT
body: saveEntry, soSave, duplicateEntry, moveEntryToFolder, addTagToEntry,
batchMove/AddTag, encryptImportEntry (import + sync-apply).
- doChangeMasterPassword re-encrypts username under the new key.
- migrateUsernamesAtRest(): one-time sweep at enterApp, PUT-re-ships rows that
still carry cleartext username so the DB gets scrubbed (bumps updated_at
once; plaintext unchanged so devices converge).
site/title/tags stay cleartext (same pattern later — see memory note). +1
merge test (username encrypted on import). 65/65.
NOT compiled/tested at runtime (Delphi) — large multi-handler change; rebuild
BuildAssets + PMServer and test create/edit/rotate/import/sync + verify the
DB shows no cleartext username.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Eighth slice. Quick search wasn't contiguous — its functions sat on both
sides of the cheatsheet and password-history modals (lines 725-1104). Rather
than a fiddly non-contiguous cut, the whole overlay cluster is extracted as
one byte-identical block: js/app.overlays.js (quick search + cheatsheet +
password history). Pure declarations, no top-level side effects → loads
before app.js; all state/api/Bridge/render/decryptPwd refs resolve via shared
global scope at call time.
- Byte-for-byte identical; syntax OK on all nine app parts; 62/62 tests green.
- index.html + BuildAssets whitelist + harness APP_PARTS updated.
app.js: 11936 → 9170 lines (8 modules extracted, ~2770 lines). Load order:
argon2 → crypto → totp → favicon → import → backup → health → overlays →
app → sync.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Seventh slice of the app.js split. Moves the vault-health dashboard
(computeHealthCache, healthScoreBand, renderHealthDashboard/Section,
openEntryForFix, entryAgeDays + scoring consts) to js/app.health.js. Pure
declarations, no top-level side effects → loads before app.js. Uses
computeStrength/decryptPwd/state/api via shared global scope at call time.
- Byte-for-byte identical extraction; syntax OK on all eight app parts.
- auditCache/auditFilter sit in this var block but drive the separate
Audit-log viewer in app.js — they ride along and resolve cross-file via
shared scope (documented).
- index.html + BuildAssets whitelist + harness APP_PARTS updated.
- 62/62 tests green.
app.js: 11936 → 9545 lines (7 modules extracted).
NOTE: quick search is NOT contiguous (interleaved with cheatsheet +
history-modal code, lines 886-1063), so a clean byte-identical extraction
isn't trivial — deferred.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sixth slice of the app.js split. Moves the favicon fetch/cache section
(faviconHost, saveEntryIcon, ensureEntryFavicon, backfillFavicons,
clearAllFavicons) to js/app.favicon.js. Pure declarations, no top-level
side effects → loads before app.js.
- Code moved byte-for-byte; no duplicate const; syntax OK on all 7 app parts.
- NEW: js/tests/favicon.test.js — 7 tests for faviconHost, the pure
URL→validated-hostname function that decides which domain is sent to the
DuckDuckGo proxy (a bug there leaks the wrong host). Covers scheme/www/
path/port stripping, non-hostname rejection, malformed dotting, unsafe
chars, and the 253-char DNS cap.
- Fixed an inaccurate source comment surfaced by the tests: it claimed raw
IPs "stay valid", but the TLD rule /\.[a-z]{2,}$/ rejects a numeric final
label, so IPs get no favicon lookup (fine). Test pins the real behaviour.
- Suite: 55 → 62 tests, all green. Assets regenerated (8 ordered JS files).
app.js: 11936 → 9790 lines (6 modules extracted).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fifth slice of the app.js split. Moves the scheduled encrypted-backup
feature (config, retention, runAutoBackupNow/runAutoBackupIfDue) to
js/app.backup.js. Pure declarations + two consts, no top-level side effects
→ loads before app.js; uses encryptExportPayload (app.import.js), Bridge,
api, state via shared global scope at call time.
- Byte-for-byte identical extraction; no duplicate const; no top-level
backup reference left in app.js; syntax OK on all six app parts.
- index.html + BuildAssets whitelist + harness APP_PARTS updated; assets
regenerated (manifest embeds all 7 ordered JS files).
- 55/55 tests green.
app.js: 11936 → 9900 lines — now under 10k. Five modules extracted
(~2000 lines): argon2 → crypto → totp → import → backup → app → sync.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fourth slice of the app.js split. Moves TOTP (base32Decode, generateTOTP,
parseOtpAuthUri) plus the TOTP-secret and custom-field AES-GCM wrappers to
js/app.totp.js. Loads before app.js (pure declarations), after app.crypto.js
(uses encryptPwd/decryptPwd). Also called by app.import.js and app.sync.js
via shared global scope.
- Byte-for-byte identical extraction; no duplicate const; syntax OK on all
five app parts.
- NEW: js/tests/totp.test.js — 13 tests including the 5 RFC 6238 Appendix B
reference vectors (generateTOTP reads Date.now(), so each case stubs the
sandbox clock to the vector's fixed time), base32 decode edge cases, and
parseOtpAuthUri. Extraction AND new coverage in one slice.
- Suite: 42 → 55 tests, all green.
- Assets regenerated (manifest now embeds all 6 ordered JS files:
argon2 → crypto → totp → import → app → sync); also fixes the previous
import commit's not-yet-rebuilt manifest.
- Delphi build artifacts (*.vrc, *.$manifest) gitignored.
app.js: 11936 → 10138 lines (4 modules extracted, ~1800 lines).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Third slice of the app.js split. Moves the encrypted export container,
CSV/JSON import parsing (parseCSV, findColumn, parseEntriesFromCSV,
parseEntriesFromJSON), and the doImport/doExport/doExportCSV flows to
js/app.import.js. encryptImportEntry moves here too (also called by
app.sync.js — resolved via shared global scope at call time).
- Byte-for-byte identical to the extracted block; no duplicate const;
no top-level import ref left in app.js.
- Load order: BEFORE app.js (pure declarations, no top-level side effects),
alongside app.crypto.js. Full order: argon2 → crypto → import → app → sync.
- index.html + BuildAssets whitelist + harness APP_PARTS updated.
- Safety net: the 14 CSV tests exercise parseCSV/parseEntriesFromCSV from
the extracted file and stay green (42/42).
app.js: 11936 → 10253 lines (crypto + sync + import now separate, ~1700
lines moved into 3 modules).
NOTE: assets.res not regenerated here (needs brcc32/Delphi) — run
BuildAssets before the next Delphi build to embed js/app.import.js.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Second slice of the app.js split (after crypto). Moves the WebDAV sync
section to js/app.sync.js: transport (_webdavCall), buildSyncSnapshot,
applyRemoteSnapshot (merge + tombstone arbitration), runSyncNow, and the
sync settings UI.
- Byte-for-byte identical to the extracted block (verified before removal);
no duplicate const; no top-level sync reference left in app.js.
- Load order: AFTER app.js (unlike crypto, which loads before) because this
module has a top-level side effect — `Bridge.onWebdavResult = …` — that
needs Bridge/state/api already declared. Rule documented in CLAUDE.md.
- index.html + BuildAssets whitelist + harness APP_PARTS updated; assets
rebuilt to embed the new file.
- Safety net: the existing merge tests exercise applyRemoteSnapshot /
buildSyncSnapshot from the extracted file and stay green (42/42).
app.js: 11936 → 11256 lines (crypto + sync now separate).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both UPDATEs mutated a synced column without touching updated_at, so the
change rode in the sync snapshot but other devices skipped it (last-write-
wins saw "not newer"). Now both SET updated_at = datetime('now') (UTC).
- POST /entries/{id}/icon (PM.Handler.Entries)
- folder delete → entries reassigned to 'All' (PM.Handler.Folders)
accessed_at stays exempt (read timestamp, not synced); bulk "clear all
icons" stays exempt (device-local favicon cache purge). Invariant documented
in CLAUDE.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First slice of the app.js split. Approach: ordered classic-script files
loaded via separate <script> tags (argon2.js → app.crypto.js → app.js),
NOT ES modules / a bundler. Classic scripts share one global lexical
environment, so consts/functions cross-reference across files exactly as
in the monofile — zero call-site rewrites, near-zero risk. Chosen over the
audit's esbuild/ES-module suggestion because the code is written entirely
in global scope (functions call each other by bare name everywhere).
- js/app.crypto.js: KDF (PBKDF2 + Argon2id), verifier, AES-GCM encrypt/
decrypt, key persist/restore. Verified byte-for-byte identical to the
original block before removal; no duplicate const across the two scripts.
- index.html + BuildAssets whitelist + test harness updated for the load
order. Harness CONCATENATES app.crypto.js + app.js (node:vm doesn't share
top-level const across separate runInContext calls the way browsers share
it across <script> tags); argon2.js stays a separate IIFE.
- Runtime-validated: rebuilt exe unlocks via quick-unlock and loads/decrypts
entries — the extracted crypto (restoreCryptoKey, verifierFromKeyHex,
decryptPwd) works from the separate file. 42/42 tests green.
- Docs: CLAUDE.md "Découpage frontend" (pattern + rules), file map, tests
README, CODE_AUDIT §3.1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 2 of CODE_AUDIT §1.2 — live adoption of the Argon2id foundation.
Verified at runtime: a rotated account shows hash_algo=argon2id-v2 with
argon2_m=19456,t=2,p=1 in vault.db.
Server (never runs Argon2 — zero-knowledge, only stores/echoes params):
- DB: users.argon2_m/t/p columns (default 0 = PBKDF2).
- PM.Handler.Auth: HASH_ALGO_ARGON2 + param bounds, ReadArgon2Params /
AppendArgon2Params helpers. /register and /change-master-password accept
hashAlgo='argon2id-v2' + argon2:{m,t,p} and persist them; /login/challenge
echoes them. Verify path (VerifierToStoredHash/CheckVerifier) is
KDF-agnostic — the 64-hex verifier is SHA256-wrapped as for any -v2 scheme.
Client (app.js):
- state.argon2Params, cached from the challenge and persisted to
sessionStorage + the quick-unlock / PIN cold-start blobs (so a cold-started
session can still derive-from-password for reauth/rotation).
- Register + master-pw rotation derive with argon2id-v2 + ARGON2_DEFAULT_PARAMS
(OWASP m=19MiB,t=2,p=1) and send the params. Rotation re-encrypts the whole
vault under the new Argon2 key (natural migration point). Existing accounts
stay PBKDF2 until they rotate.
- Params threaded through every derive-from-password site (login, reauth,
recovery setup, change-pw current verifier). Cold-start verifier-from-raw-key
paths need no params (isDecoupledVerifierAlgo handles the -v2 wrap).
Tests: +2 param-contract tests (register<->login determinism, param
sensitivity). 42/42. Assets rebuilt to embed js/argon2.js.
Docs: CLAUDE.md auth-hash section rewritten (4 markers); CODE_AUDIT §1.2 +
table + plan marked done.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 1 of CODE_AUDIT §1.2 — additive, no live account uses Argon2id yet.
- Vendor @noble/hashes@2.2.0 argon2id as js/argon2.js (esbuild IIFE exposing
globalThis.NobleArgon2). Pure-JS, not WASM: CSP is script-src 'self' with no
wasm-unsafe-eval, so WASM would require weakening it. Verified against the
RFC 9106 §5.3 test vector. Server needs zero Argon2 (zero-knowledge: it only
ever SHA256-wraps the client verifier).
- app.js: deriveKeyBytes(pwd, salt, algo, iters, argonParams) branches Argon2id
vs PBKDF2; deriveKeyAndVerifier refactored around it. New markers
HASH_ALGO_ARGON2='argon2id-v2' + ARGON2_DEFAULT_PARAMS (OWASP m=19MiB,t=2,p=1,
~0.65s/unlock). isDecoupledVerifierAlgo() generalises the decoupled-verifier
rule to any '-v2' scheme so argon2id-v2 inherits it. AES key is still ALWAYS
the raw KDF output → entries decryptable, legacy accounts untouched.
- index.html loads js/argon2.js before app.js; added to BuildAssets whitelist;
test harness loads it into the sandbox first.
- Tests: +5 (RFC 9106 vector via vendored bundle, argon2 branch derives Argon2
key not PBKDF2, decoupled verifier, AES round-trip under Argon2 key). 40/40.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two CODE_AUDIT items in one session.
§3.2 — Frontend regression net (js/tests/, 35 tests, node:test, zero deps):
- harness.js loads app.js (monofile, no exports) into a node:vm with browser
globals stubbed, surfacing internals via an export epilogue.
- crypto: deriveKeyAndVerifier (AES key == raw PBKDF2, cross-checked vs Node
pbkdf2Sync), legacy-vs-v2 verifier decoupling, encrypt/decrypt round-trip,
IV uniqueness, AEAD tamper/wrong-key.
- csv: parseCSV tokenizer, findColumn heuristics, Bitwarden/KeePass mapping.
- merge: applyRemoteSnapshot add/update/skip (LWW), tombstone delete,
resurrection arbitration (both NaN branches), local-tombstone veto,
additive folder merge. Only api() is stubbed; loadEntries/encryptImportEntry
run for real.
- Wired as a build gate in BuildAssets.ps1 (after node --check, bypass
PM_SKIP_TESTS=1).
§2.2 — Unify timestamps on UTC:
- Entry created_at/updated_at were written via Delphi FormatDateTime(Now)
= LOCAL, while deleted_at/tombstones use SQLite CURRENT_TIMESTAMP = UTC.
The tombstone-resurrection arbitration compared the two zones, skewing by
the machine's UTC offset even single-device.
- Add NowUTC/NowUTCStr to PM.Database, swap in at every entry/attachment
write site (Entries create/update/bulk, Attachments POST echo).
- No JS change needed: arbitration now compares same-zone values.
- Existing rows self-heal on next edit (no destructive migration).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sync optimistic concurrency (ETag/If-Match)
- webdav GET captures the response ETag; PUT sends it back as If-Match so
the server rejects (412) our write when another device changed the file
between our pull and push. A 412 re-runs the whole pull→merge→push
(bounded to 3) so the other device's changes are folded in instead of
clobbered. Servers without ETags → empty etag → no If-Match → falls
back to last-write-wins (no regression). onWebdavResult gained a 4th
etag arg.
Chunked webdav PUT (big vaults no longer black-screen on sync)
- The whole encrypted snapshot base64'd into a single cmd://webdav/put URL
blew past WebView2's cap → black screen once the vault grew (20MB of
attachments). PUT bodies now stream through the file/chunk transport and
commit via a new webdav/put-commit (reads the accumulated buffer).
Chunk-transfer hang fix (root cause of the stuck "Preparing…" sync)
- All chunked transfers (saveFile/writeFile/webdav PUT) share one
reqId-keyed resolver. A resolved chunk's stale 30s timeout would later
delete the CURRENT chunk's resolver and fire the wrong res(), leaving
that chunk's await pending forever. Extracted a single _streamChunks()
helper whose ack CLEARS the pending timeout, so resolvers stay strictly
one-at-a-time. Also fixed _webdavCall referencing the Bridge-local cmd()
from module scope (latent ReferenceError).
Sync busy overlay
- syncStatus() now drives the global busy overlay too, so a running sync
blocks stray clicks (e.g. the auto-backup "Choose…" picker) and reads
like the manual backup. The account-mismatch confirm hideBusy()s first
so it's visible above the overlay.
Auto-VACUUM (reclaim space after deleting large attachments)
- SQLite never shrinks the file on DELETE, so deleting big attachments
left vault.db bloated (35MB for 11 tiny entries). DB.CompactIfBloated
VACUUMs when >20% of pages are free AND >~2MB is reclaimable — called on
startup and after each attachment delete. A healthy small vault pays
nothing. (Verified: 35MB → 695KB after the deletes.)
Rebuild: BuildAssets + F9 (UMainForm + PM.Database + PM.Handler.Attachments).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
--bg-elev-3 was referenced (spinner ring, search-clear hover) but never
defined in either theme, so var(--bg-elev-3) with no fallback produced an
invalid declaration — that's why the busy spinner ring was invisible
until it was switched to --border. Define it as a real elevation step
above --bg-elev-2 in dark (#34343f) and light (#e3e3dd).
Also define --accent-fg (#fff) explicitly instead of relying on the
inline var(--accent-fg, #fff) fallback.
Audited used-vs-defined custom properties: the only remaining "undefined"
one is --strength, which is set at runtime by JS on the password-strength
bar (has a 0% fallback) — intentional, not a bug.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- doChangeMasterPassword shows the busy overlay while it re-encrypts the
vault: "Re-encrypting vault…" → "Re-encrypting entries… N/total" →
"Re-encrypting attachments… N/total", cleared in finally. A rotation on
a big vault took tens of seconds with no feedback before.
- Quick-unlock now SURVIVES a master-pw change instead of being wiped.
The blob stores the raw key (DPAPI-wrapped, no user secret), so it's
re-wrapped in place with the new key/salt/iters/algo (state already
holds the new values at that point). Cold-start then re-logs in with a
verifier derived from the new key. Falls back to clearing if the
re-wrap throws, so a stale old-key blob is never left behind.
- PIN blob still cleared (wrapped by PBKDF2(pin) — can't re-wrap without
the PIN). A setTimeout(0) separates the quickunlock/store and pin/clear
navigations so the back-to-back window.location.href assignments don't
coalesce and drop the re-wrap.
- Fixed a `failed` counter declaration accidentally dropped from the
attachment re-encryption loop while adding progress (ReferenceError at
runtime; node --check wouldn't catch it).
- CLAUDE.md updated for the re-wrap vs clear distinction.
Rebuild: BuildAssets + F9 (JS only this commit; F9 to re-embed).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bridge.writeFile had the same URL-length trap saveFile did: a large
auto-backup base64'd into a single cmd://file/write URL blew past
WebView2's ~2MB navigation cap, so backups of big vaults failed
silently (or blanked the page) — for BOTH the manual "Backup now"
button and the silent scheduled run.
- writeFile now streams payloads over ~1MB in chunks (reusing the same
file/chunk transport as saveFile), committed via a new
file/write-commit. Delphi shares the decode+write logic through a new
WriteDecodedFile helper and the existing FFileSaveChunks buffer.
- runAutoBackupNow(silent): the manual run shows the busy overlay
("Reading vault… N/total" → "Encrypting backup…" → "Writing file… N%")
since a 20MB backup takes ~30s; the scheduled on-unlock run passes
silent=true (no overlay, but still chunked so it no longer fails on
large vaults).
- Fixed the "Backup now" click handler passing the click Event as the
silent arg (truthy → would have suppressed the spinner and swallowed
errors); wrapped in () => runAutoBackupNow().
- Pre-sync backup benefits from the chunked writeFile automatically.
Rebuild: BuildAssets + F9 (UMainForm.pas changed).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Large file save (fixes black screen on big attachment download / export)
- Bridge.saveFile streams anything over ~1MB base64 in chunks through the
cmd:// channel instead of stuffing the whole payload in one URL — a
multi-MB base64 URL blew past WebView2's ~2MB navigation cap and blanked
the document (black screen). Small payloads keep the single-shot path.
- Chunks are sent sequentially (each acked via Bridge.onFileChunkAck
before the next) so repeated location.href assignments don't coalesce.
- Delphi accumulates chunks per reqId in a TStringBuilder
(FFileSaveChunks), commits on file/save-commit, and shares the
decode+dialog+write logic with the single-shot path via SaveDecodedFile.
- Chunk size 1MB → far fewer round-trips (a 20MB export dropped from ~67
to ~27 hops).
Busy overlay + progress
- Global spinner overlay (showBusy/updateBusy/hideBusy). doExport shows it
immediately on click — BEFORE the entry-decrypt + attachment-fetch loop
that is the real cost — with a 0ms yield so it paints before the thread
blocks (was appearing 3-5s late). Phases: "Reading vault… N/total" →
"Encrypting export…" → "Preparing file… N%" (real chunk progress).
Attachment download shows the same for files > 512KB.
- Spinner ring used an undefined --bg-elev-3 (invalid border → invisible);
switched to --border. Fixed a second stale --bg-elev-3 use on the
settings-search clear button hover.
Password reveal
- promptDialog gets an eye toggle in password mode, so every encrypted
prompt (export, import, backup password, recovery code, sync password)
can show/hide the typed value.
Avatar
- Top-right chip avatar enlarged 22px → 30px with the chip padding
rebalanced.
Rebuild: BuildAssets + F9 (UMainForm.pas changed for the chunk handlers).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The zero-knowledge verifier sent to /login used to be the raw PBKDF2
output in hex — i.e. the exact bytes of the AES key that encrypts every
entry. Intercepting a /login body (loopback, but still) handed over the
vault key. This introduces a decoupled scheme where the transmitted
verifier is a one-way function of the key.
New auth-hash scheme
- users.hash_algo 'pbkdf2-sha256-v2': the client sends
verifier = SHA256(keyHex + "pmserver/auth-verifier/v2") instead of
keyHex. Stored form is still SHA256(verifier) (identical server wrap
to 'pbkdf2-sha256'), so only the algo LABEL differs — it tells the
client which verifier formula to use. Verification needs no new server
branch (VerifierToStoredHash already SHA256-wraps any non-legacy
verifier).
- The AES key (cryptoKey) stays hex(PBKDF2) for EVERY algo, so entries
remain decryptable and switching schemes never re-encrypts data.
Adoption: new-registration + master-pw-change only
- Register and change-master-password write v2. Existing accounts keep
their algo until they rotate — the login/reauth migration signal now
fires only for LEGACY 'pbkdf2' (was: anything != CURRENT), so
sha256/v2 accounts are never force-migrated (which would have
downgraded v2 → sha256 via migrate-kdf).
Client (js/app.js): algo-aware everywhere
- verifierFromKeyHex(keyHex, algo) central helper; deriveKeyAndVerifier
/ computeVerifier take an algo arg. state.hashAlgo caches the account
scheme, set from /login/challenge, register, change-master, the
quick-unlock / PIN cold-start blobs, and the /recovery-key/redeem
response. All ~12 verifier sites updated (login, register, reauth ×4,
change-master current+new, migrate-kdf, quick-unlock + PIN cold-start,
recovery-mode current verifier).
Safety invariant: unknown/empty hashAlgo → key hex → byte-identical to
the old behaviour, so every pre-decoupling account (and every existing
quick-unlock / PIN blob without the new field) keeps working unchanged.
Verified: existing account + pre-change quick-unlock still unlocks; a
master-pw change now writes 'pbkdf2-sha256-v2' in vault.db.
Server: recovery redeem returns hashAlgo; register + change-master store
the decoupled algo; login + reauth migration signal narrowed to legacy.
Also: BuildAssets.ps1 pipes $null into node --check so the JS syntax
gate can't block on stdin in the Delphi pre-build environment.
Addresses CODE_AUDIT.md section 1.1.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Quick search (Ctrl+Shift+Q fill mode)
- Enter / left-click → full autofill (username + Tab + password), like
Ctrl+Shift+L.
- Shift+Enter / right-click → username only (new Delphi username-only
SendInput path via field=user; ExecuteAutofill AUsernameOnly param).
- Ctrl+Enter / Ctrl+click → password only.
- Copy mode (tray / palette) unchanged: Enter/left = password,
Shift+Enter/right = username.
- Clipboard fix: copy-then-minimise no longer wipes the just-copied
password — MinimizeToTray takes an AClearClipboard flag (False on the
quick-search copy path, driven by app/minimize?keepclip=1). The 30s
auto-clear still guards it.
- Right-click on a result row suppresses the native/custom context menu
(preventDefault + stopPropagation).
Editable custom-field combobox
- Option-backed custom fields (card brand, expiry year/month, etc.) now
render a custom editable combobox instead of a locked <select>: an
arrow drops a menu of ALL options (a native <datalist> filtered to the
typed text, which confused users), while the input stays freely
typeable for values not in the list. Storage shape unchanged.
- Outside-click closes the menu via the existing slideover mousedown
handler; item mousedown + preventDefault so blur doesn't race the pick.
Build safety
- BuildAssets.ps1 runs `node --check` on every embedded .js before
generating assets.res. A syntax error now aborts the asset build
(exit 1, file + line logged) instead of shipping a dead bundle that
only surfaces after a full Delphi rebuild. Node is optional: absent →
warn and continue.
Docs
- CODE_AUDIT.md: full static-analysis report (security, latent bugs,
maintainability, future features, prioritized action plan).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Profile picture / avatar
- users.avatar_b64 column (nullable, cosmetic, not encrypted) + GET/POST
/avatar endpoints mirroring the settings handler pattern.
- Top-right chip + Settings→Account show a round avatar: custom picture
if set, otherwise the username's initial on a deterministic
hash-picked colour (stable across renders).
- Upload downscales + center-crops to a 128px JPEG via FileReader →
data: URI (NOT blob:, which the CSP's `img-src 'self' data:` blocks)
before POSTing. Remove button clears it.
- Carried in the encrypted JSON export; restored on import only when the
current account has no picture (never clobbers a local one).
Tombstone restore-then-sync fix
- POST /entries and POST /entries/bulk-import now DELETE any tombstone
matching an inserted uuid (same transaction) so a restored backup
isn't re-killed on the next sync by its own stale tombstone.
- applyRemoteSnapshot arbitrates remote tombstones by timestamp: a
tombstone is skipped when the local entry with that uuid is newer than
deleted_at (resurrection wins). Ties / unparseable timestamps favour
KEEP. loadEntries() up front so updated_at reflects the live rows.
WebView2 navigation race
- Black-window-on-cold-start fix: the 1.5s nav timer no longer consumes
FPendingURL when WebView2 isn't initialised yet (it re-arms, bounded
to ~10 retries). FBrowserInitialized flag set in OnInitialized; after
the retry budget we Navigate best-effort rather than loop forever.
Sync UX
- Bidirectional toast: "pulled X new · Y updated · Z deleted · pushed N
entries" so a 0/0/0 pull still shows the vault was uploaded.
- FolderPOST/PUT: pre-declare ftString on color/icon params (fixes the
earlier [SQLite]-335 on NULL bind, already in play for CSV import).
Docs
- CLAUDE.md sync section documents tombstone purge-on-insert +
resurrection arbitration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sync engine
- Cross-account guard: refuse to merge a remote snapshot whose
username differs from the currently-signed-in one (confirm dialog,
Cancel by default) so a shared WebDAV URL / same sync password
between accounts stops silently mixing vaults.
- Local tombstones veto: skip any remote entry whose uuid is already
in the local entry_tombstones table — otherwise a perm-delete on
this device was getting undone on the next pull.
- "deleted" counter fixed: report only tombstones that actually
removed a live local entry this round, not the accumulated history
the toast used to inflate ("73 deleted" for 48 real deletes).
- Push failure surfaces syncStatus reset + toast so state doesn't
get stuck on a stale phase label.
Sync progress feedback
- Inline #syncStatus label next to the Sync button reports each
phase: Local backup… → Pulling… → Merging… → Preparing… N/total
(per-entry counter during the slow buildSyncSnapshot decrypt loop)
→ Encrypting… → Pushing…, then clears.
- Sync now / Test connection buttons are disabled while any run is
in flight so double-clicks can't kick off a concurrent sync.
Import (JSON only — CSV out of scope)
- Uuid-aware dedup: split parsed rows into fresh (new uuid) vs
overlaps (uuid already present locally).
- Overlaps prompt: confirm dialog offers Overwrite (restore/roll
back) or Skip. Overwrite PUTs the file's payload over each match;
Skip drops them and only imports fresh. Prevents the "re-import
doubles everything" regression while still allowing restore.
- Bulk-import call is skipped entirely when there's nothing fresh to
send (avoids a POST with an empty entries array).
- Template notes with empty body but populated custom_fields
(credit-card, ssh-key, etc.) no longer skipped as "note body
required" — kept as long as at least one custom field has a value.
Sidebar / uncategorised view
- "(no folder)" pseudo-entry stays visible whenever the vault has
any real folder, so it always works as a drag target for
uncategorising — and doesn't vanish mid-action when the user is
currently viewing it.
- Header title reads "(no folder)" for state.view === 'folder:All'
instead of the ambiguous "All".
- Dedicated empty-state copy for the uncategorised view.
Rebuild assets required.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Slideover Save action row is now sticky at the bottom of the scrolling
body (background + top border) so it stays reachable on entries with
many custom fields or attachments — was previously buried below the
fold.
- Uncategorised view (state.view = 'folder:All'):
- Header title reads "(no folder)" instead of the ambiguous "All".
- Sidebar pseudo-entry stays visible whenever the vault has at least
one real folder, so it can be used as a drag drop-target to move
entries out of a folder even when its own count is 0.
- Empty state gets its own copy ("No uncategorised entries" + hint
to drop entries here to uncategorise) instead of the generic
"Folder is empty".
- TEST_REGRESSION.md checked in — 16-section list scoped to what the
recent Esc / drag / sync / import work touched, so post-commit
regressions can be walked through methodically instead of poking
the app ad-hoc.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pressing Esc after editing a card silently failed: requestCloseSlideOver
opened the discard-confirm modal, then the global Esc-fallback handler
fired on the same keystroke and either closed the just-opened modal
(when Esc came on a clean slideover via the modal path) or, when
Settings was layered on top of a dirty slideover, opened the discard
prompt in the background while Settings closed.
Both panel-level Esc handlers now run in capture phase and
stopPropagation:
- Slideover handler stops only when it actually acts (slideover open,
no modal up, Settings not on top) so a single Esc opens the discard
confirm without the global fallback racing to close it.
- Settings handler stops when Settings is the active panel so the
global fallback's requestCloseSlideOver branch can't fire underneath
and pop a discard confirm on the slideover the user left dirty.
Priority order is now: open modal > Settings > slideover. First Esc on
"card dirty + Settings open" closes Settings; second Esc shows the
discard confirm. Clean slideover + Esc still closes directly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Bitwarden CSV import: folders auto-created server-side; notes column on
login rows surfaces as a "Notes" custom field instead of polluting tags;
type=card / type=identity rows now mapped to kind=note with the
credit-card / identity template + card_* / identity_* columns
pulled into custom_fields; `fields` column parsed (Bitwarden's
"label: value\nlabel: value" lines + our own JSON shape).
- Settings panel search: live filter at top of the panel, matches each
.setting-row individually, hides whole section when no row matches,
shows a "No matches" banner. Esc clears query (without closing
Settings); Esc with empty query closes the panel.
- Quick-search hotkey customizable: SetQuickSearchHotkey added to
PM.Bridge; cmd://autofill/hotkeys extended with qs_mods/qs_vk
(independent of the autofill enabled flag — quick-search stays
armed even when autofill is off); state.quickSearchHotkey synced
via settings_json; new "Quick search picker" row in Settings.
- FireDAC SQLite folder POST/PUT: pre-declare ftString on color/icon
params so .Clear (NULL) doesn't trip "[FireDAC][Phys][SQLite]-335
type unknown" at Prepare — was crashing the CSV-import folder
auto-creation path.
- Edge form-data autocomplete suppressed on slideover inputs (title,
site, username, password, TOTP, note body, custom fields):
autocomplete=off (new-password on secrets) + spellcheck=false. Fixes
the "Informations enregistrées" dropdown popping over data after a
field was edited.
- closeSlideOver blurs any focused descendant before removing .is-open
so an invisible focused field can't react to arrow-down / backspace
after dismissal.
- Slideover Esc handler upgraded to capture phase so it fires before
the input's own keydown or browser-level Esc swallow on the active
autocomplete popup.
- Settings panel Esc closes the panel when search input is empty;
search keeps the keystroke when it has a query to clear.
- Discard-fantome on note open: customFields working copy and
originalCustomJson now share the SAME normalized array — comparing
raw plainCustom against the .map()'d working copy made notes look
dirty on open.
- Delete / Backspace global shortcut: batch-trash on normal views,
batch perm-delete on trash view, gated on selection + no input
focused + no modal up.
- Toggle thumb vertical centering via top:50% + translateY(-50%);
state checked uses translate(16px, -50%) to keep the centring.
- Batch bar disappears after per-card restore/perm-delete/trash:
state.checked.delete(id) before render for the relevant flows;
state.checked.clear() before render in emptyTrash and the new
moveEntriesToFolder helper.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>