feat: MFA tools, single-instance, tray polish, prefs persistence

Session highlights:

- feat(prefs): DPAPI-backed key/value store (PM.UserPrefs) — fixes
  rememberedUsername being lost across reboots due to the random
  ephemeral HTTP port changing the localStorage origin every launch.
  Bridge cmd://prefs/{get,set} round-trips through Delphi.

- feat(tray): icon visible from startup (NIM_ADD at constructor, not
  at first minimize). Tray context menu themed via uxtheme!135
  SetPreferredAppMode so it follows the app's dark/light setting.

- feat(single-instance): named mutex + RegisterWindowMessage broadcast.
  Second launch posts WM_PMSHOW to HWND_BROADCAST and exits; the
  running bridge restores the window from tray. Mutex lives in Local\
  namespace so distinct Windows users can still each run one.

- feat(mfa): Authenticator sidebar view (live TOTP codes for every
  entry with a secret) + standalone TOTP generator modal (paste
  base32 / otpauth:// URI, or generate a random 20-byte secret).

- feat(sidebar): Folders / Tags / Tools sections collapsible with
  chevron toggle. Badge counts stay visible when collapsed. State
  persisted in settings_json (synced across devices).

- feat(autofill): hotkey when vault is locked now restores the app
  and focuses the master password input instead of no-op'ing
  silently. Cleaner UX for the common "I hit Ctrl+Shift+L but the
  vault was locked" path.

- feat(quick-unlock): when enabled, skip lockVault on Windows lock /
  sleep. Rationale: the DPAPI blob already gates access via the
  Windows account, so re-locking on top of the OS lock is redundant.
  Idle auto-lock still fires (separate opt-in).

- fix(quick-unlock): re-sync state.quickUnlockEnabled from DPAPI
  source-of-truth at boot, instead of trusting (now-volatile)
  localStorage.

- docs: CLAUDE.md updated with all new modules, bridge commands,
  and the port-ephemeral pitfall.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 21:31:39 +01:00
parent 664db65437
commit 40b3154a34
38 changed files with 8165 additions and 548 deletions
+440
View File
@@ -0,0 +1,440 @@
# Password Manager — Architecture éclair
Desktop password vault Windows. Exe Delphi FMX embarquant WebView2 qui
charge un frontend HTML/JS via Indy HTTP loopback (127.0.0.1:8765).
100 % offline, multi-utilisateur, chiffrement client-side (AES-GCM,
clé dérivée PBKDF2-SHA256 600 k iter).
## Stack
- **Backend** : Delphi 12, FMX, TMS FNC WebBrowser (Edge WebView2)
- **HTTP** : Indy `TIdHTTPServer` bindé 127.0.0.1 only
- **DB** : SQLite via FireDAC (`vault.db`, gitignored)
- **Frontend** : vanilla JS + CSS, pas de framework (`js/app.js` monofile ~4000 lignes)
## Build pipeline (CRITIQUE)
Toute modif `index.html` / `js/` / `css/` nécessite :
1. `delphi-backend\assets\BuildAssets.cmd` → régénère `assets.res`
2. Build Delphi (F9 ou MSBuild `PMServer.dproj`)
Sans étape 1, l'exe embarque l'ancienne version des assets — le bug le
plus courant après modif frontend.
## Carte des fichiers
| Rôle | Path |
|---|---|
| Form principale, wire bridge, dispatch cmd:// | `delphi-backend/UMainForm.pas` |
| Bridge JS↔Delphi (clipboard, tray, hotkeys, autofill, focus, debug) | `delphi-backend/Source/PM.Bridge.pas` |
| HTTP server (ordre dispatch : router → embedded → static → 404) | `delphi-backend/Source/PM.HTTPServer.pas` |
| Routes API (regex match) | `delphi-backend/Source/PM.Router.pas` |
| Auth + sessions + CSRF | `delphi-backend/Source/PM.Session.pas` |
| Migrations DB idempotentes (`AddColumnIfMissing`) | `delphi-backend/Source/PM.Database.pas` |
| Static files disque (mort en prod, embedded gagne) | `delphi-backend/Source/PM.StaticFiles.pas` |
| Assets embedded dans .res | `delphi-backend/Source/PM.EmbeddedAssets.pas` |
| Quick unlock DPAPI (device-bound) | `delphi-backend/Source/PM.QuickUnlock.pas` |
| Prefs key/value DPAPI (`prefs.bin`) | `delphi-backend/Source/PM.UserPrefs.pas` |
| Single-instance mutex + broadcast | `delphi-backend/Source/PM.SingleInstance.pas` |
| Handlers REST | `delphi-backend/Handlers/PM.Handler.*.pas` |
| Frontend complet | `js/app.js` |
| HTML racine | `index.html` |
| Styles | `css/style.css` |
## Bridge cmd:// (JS → Delphi)
JS navigue vers `cmd://action/sub?params` → intercepté par
`WebBrowserBeforeNavigate``HandleBridgeCommand`.
Commandes connues :
- `clipboard/copy`, `clipboard/clear`, `clipboard/read` (Paste custom menu)
- `quickunlock/{store,get,clear,status}`
- `prefs/{get,set}?key=...` (device-bound DPAPI key/value, voir plus bas)
- `autofill/{configure,hotkeys,execute,cancel}`
- `app/focus` (ramène la fenêtre au premier plan, pour le picker)
- `app/ready` (page chargée → SetFocus WebBrowser + DOM focus auth input)
- `app/theme?mode=dark|light` (sync title bar + popup menus via `SetPreferredAppMode`)
- `audit` (POST log d'action user-visible)
- `entry/new-from-title` (Ctrl+Shift+A → modal pré-rempli)
Retour Delphi → JS via `WebBrowser.ExecuteJavaScript('Bridge.onX(...)')`.
## Hotkeys globaux Win32
- `Ctrl+Shift+L` : autofill complet (user + Tab + password)
- `Ctrl+Shift+P` : autofill password seul (forms step-2, unlock screens)
- `Ctrl+Shift+A` : new entry pré-rempli avec le titre de la fenêtre foreground
- `Ctrl+Shift+D` : toggle debug panel (uniquement si `config.txt` présent)
Combos autofill configurables depuis Settings (synced en DB
via `settings_json`).
## Debug mode
Fichier `config.txt` à côté de l'exe (tous champs optionnels) :
```
port=8765 # 0 ou absent = port aléatoire ephemeral (49152-65535)
debug=true # affiche PanelTop (Start/Stop, log memo)
require_token=false # désactive le token validation (curl/Postman direct)
require_process_check=false # désactive le PID-on-socket check
```
Sans `config.txt`, Ctrl+Shift+D ne fait rien (sécurité, panel jamais
exposé en prod).
## HTTP server lockdown (anti-browser-direct)
Le serveur Indy n'accepte une requête que si elle a :
- Query param `?pmt=<token>` (sur la 1ʳᵉ navigation) → Set-Cookie
- OU cookie `pm_token=<token>; HttpOnly; SameSite=Strict; Path=/`
Au start :
- Port aléatoire dans `[49152, 65535]` si pas configuré
- Token random 32-chars hex généré
- WebView2 navigate vers `http://127.0.0.1:{port}/index.html?pmt={token}`
- JS strip le `?pmt=...` du URL bar via `history.replaceState`
Requête sans token valide → `404` (pas 403 pour ne pas révéler que le
serveur existe). Override via `require_token=false`.
### Couche 2 : PID-on-socket process check
Sur chaque request, `PM.ProcessLockdown` :
- `GetExtendedTcpTable` (winapi `iphlpapi.dll`) lookup le PID qui possède
la connexion entrante (match sur Chrome's `localPort` + `remotePort = our port`)
- `IsDescendantOfCurrentProcess` walk parent tree via `Toolhelp32`
notre exe est-il ancêtre ?
- Si non (curl externe, autre browser, etc.) → 404
Defense in depth : si quelqu'un connaît le token (leaked logs, mémoire),
il faut AUSSI exécuter le code dans notre lignée de process pour passer.
Override via `require_process_check=false`. Notre WebView2 (children
`msedgewebview2.exe`) passent naturellement.
### Token masqué dans les logs
`MaskAccessToken(url)` dans UMainForm remplace `?pmt=<32 chars>` par
`?pmt=***` avant `LogLine`. Évite le leak via copie/upload du log.
## Title bar + popup menus dark mode
`PM.Bridge.ApplyTitleBarTheme(ADark: Boolean)` fait 2 choses :
1. `DwmSetWindowAttribute(DWMWA_USE_IMMERSIVE_DARK_MODE=20)` → title bar.
No-op sur Win < 19044.
2. `uxtheme!SetPreferredAppMode` (ordinal #135) + `FlushMenuThemes` (#136)
— API privée stable depuis Win10 1809 utilisée par Explorer/Edge/Office.
Themea automatiquement les popup menus (tray context menu inclus),
scrollbars, tooltips. Valeurs : `ForceDark=2`, `ForceLight=3`.
Appelé au FormCreate + via `cmd://app/theme?mode=dark|light` quand JS toggle.
## Single instance
Au démarrage, `PM.SingleInstance.AcquireOrSignal` :
- Crée un mutex nommé `Local\PMServer.SingleInstance.Mutex` (namespace
`Local\` = per-session, donc deux users Windows distincts peuvent
chacun lancer le leur)
- Si `ERROR_ALREADY_EXISTS` : `PostMessage(HWND_BROADCAST, WM_PMSHOW, 0, 0)`
`WM_PMSHOW = RegisterWindowMessage('PMServer_ShowExisting')` est un
ID system-unique. Puis exit immédiat avant `Application.Initialize`.
L'instance existante reçoit `WM_PMSHOW` dans son message-only window
(`PM.Bridge.MsgWindowHandler`) → déclenche `FOnTrayRestore`
`RestoreFromTray`. `AllocateHWnd` crée une fenêtre top-level (pas
`HWND_MESSAGE`) donc elle reçoit bien le broadcast.
## Tray icon
`Shell_NotifyIcon(NIM_ADD)` au **constructor** de `TPMBridge`, pas
au premier minimize → ic ône visible dès le démarrage, même si la
fenêtre est ouverte. `NIM_DELETE` uniquement au destructor.
`MinimizeToTray` ne touche plus à l'icône, juste à la visibilité de
la fenêtre + clipboard clear + balloon first-time.
Menu : Open / Lock vault / Quit (via `TrackPopupMenu`, themé par
`SetPreferredAppMode` ci-dessus).
## Device-bound prefs (`PM.UserPrefs`)
**Problème résolu** : le serveur HTTP bind un port éphémère aléatoire
(49152-65535) sur chaque start. `localStorage` est keyed par origin
(scheme+host+**port**) → reboot = nouvelle origine = `localStorage`
wipé. Pour les prefs qui doivent survivre (`rememberedUsername`,
etc.), on persiste via DPAPI.
- `%LOCALAPPDATA%\PMServer\prefs.bin` — DPAPI-encrypted UTF-8 JSON
`{"key":"value",...}`
- `PM.UserPrefs.GetPref(key) / SetPref(key, value)` Delphi-side
- Bridge JS : `await Bridge.getPref(key)` (Promise) / `Bridge.setPref(key, value)`
- Callback : `Bridge.onPrefResult(key, value)` posé par Delphi via
`ExecuteJavaScript`. Resolvers stockés dans `prefResolvers[key]`
avec timeout 2 s.
`LoadAll` retourne toujours un `TJSONObject` valide (jamais nil) —
défault = objet vide, remplacé seulement sur parse success.
## Quick unlock — interactions
- Au boot, `init()` call `bridgeQuickUnlockStatus()` et synchronise
`state.quickUnlockEnabled` + `localStorage` depuis la source of truth
DPAPI (corrige le décalage localStorage quand le port change).
- Sur Windows lock / sleep (`BridgeSystemLock``Bridge.onSystemLock`) :
- Si `state.quickUnlockEnabled`**pas de lockVault**. Le blob DPAPI
gate déjà l'accès via le compte Windows, re-locker est redondant.
Toast informatif affiché.
- Sinon → comportement d'origine (`lockVault()`).
- L'auto-lock par inactivité (`autoLockTimer`) reste indépendant — il
ignore le flag QU (opt-in utilisateur explicite via Settings).
## Authenticator + TOTP tool
Sidebar Tools expose 2 items MFA :
- **Authenticator** (`state.view = 'authenticator'`) : grille de cards
(chaque entry avec TOTP secret) — code 6 chiffres en gros + barre
countdown. `renderAuthenticatorGrid()` décrypte tous les secrets
upfront puis tick 1 s. `authTickTimer` clear sur lock / view change.
Snap instantané à 100% sur reset de période (sinon CSS transition
anime le saut backward → effet "freeze").
- **TOTP generator** (`openTotpTool()`) : modal standalone. Paste
base32 ou `otpauth://` URI → code live. Bouton "Generate" génère un
secret base32 aléatoire 20 bytes (`randomBase32Secret()`). Rien
n'est sauvegardé.
Code TOTP retourne `{ code, period, secondsLeft }` — attention au nom,
**pas `remaining`** (utiliser `t.secondsLeft`).
## Sidebar sections collapsibles
Sections `Folders`, `Tags`, `Tools` ont chacune un `.section-toggle`
button (chevron + label + badge count) qui toggle `.is-collapsed` sur
la section parent. Body masqué via `display: none` sous `.is-collapsed`.
État persisté dans `state.sidebarCollapsed = { folders, tags, tools }`,
synced via `SYNCED_SETTING_KEYS` (settings_json). Badges (`#countFolders`,
`#countTags`) restent visibles quand replié.
## Hotkey verrouillé (UX)
`autofillHandleRequest` : si `state.locked` ou pas de cryptoKey →
`Bridge.cancelAutofill() + Bridge.focusApp()` puis focus le master
password input via DOM. Remplace l'ancien no-op silencieux qui laissait
l'utilisateur perplexe.
## WebView2 / TMS settings — pièges majeurs
`TTMSFNCWebBrowser` expose des props qui semblent settables au FormCreate
mais ne sont effectivement appliquées qu'après l'init async du WebView2
sous-jacent. **Set ces props dans `WebBrowser.OnInitialized`, pas FormCreate** :
- `EnableContextMenu := False` → kill le menu Edge natif (Inspecter, Importer mots de passe…)
- `EnableShowDebugConsole := False` → kill DevTools
- `EnableAcceleratorKeys := False`**NE PAS METTRE** : casse aussi Ctrl+C/V/X/Z/A/K dans les inputs
Le blocking F12 / Ctrl+Shift+I/J / Ctrl+U se fait côté JS (keydown
preventDefault) en complément.
## Custom right-click menu
Le menu natif est désactivé via TMS. On gère un mini-menu JS dédié sur
right-click dans les inputs : `installCustomContextMenu()` → Cut / Copy /
Paste / Select All. Paste utilise `Bridge.readClipboard()` (sans prompt
WebView2 contrairement à `navigator.clipboard.readText()`). `stopPropagation`
sur les click items sinon le slideover se ferme (chip retiré du DOM
avant que le doc click handler check `.closest('.slideover')`).
## Browser shortcuts bloqués (keydown JS)
Bloqués via `preventDefault` :
- `F12`, `Ctrl+Shift+I/J`, `Ctrl+U` — DevTools / View source
- `Ctrl+J` — Downloads overlay (le moins évident, déclenche un popup Edge)
- `Ctrl+H/S/P/T/N/R`, `F5` — History / Save / Print / New tab+win / Reload
- `Ctrl+Shift+N/W/Delete` — Incognito / Close window / Clear data
**Conservés** : `Ctrl+C/V/X/A/Z` (édition), `Ctrl+F` (find in page), `Ctrl+0/+/-`
(zoom accessibility), `Ctrl+K` (notre command palette), `Ctrl+Shift+L/P/A/D`
(nos hotkeys).
## Pagination
State : `pageSize` (10/25/50/100, default 25, **synced via settings_json**)
+ `currentPage` runtime-only. Active sur les 3 view modes (cards/list/
table) si > 10 items. Position **top** (au-dessus du grid/table).
CSS : `.pagination { grid-column: 1 / -1 }` pour span full width en cards
view (sinon ça occupe une slot de card).
Reset `currentPage = 1` sur : search, sort, view (folder/fav/tag),
viewMode change, pageSize change. Render via `renderPagination(total, totalPages)`
+ `computePageList(current, total)` avec ellipsis intelligente
(`1 … 4 5 6 … 12`).
## Flash animation (nouvelle entry)
`flashEntry(id)` appelée après `saveEntry` success + `duplicateEntry` :
`setTimeout(0)``querySelector('.entry-card[data-id],.entry-row[data-id]')`
`scrollIntoView({block:'center'})` + classe `is-flash` 2.5s
(`@keyframes entryFlash` pulse cyan).
Résout le cas "j'ai ajouté un mot de passe avec tri A-Z, où se loge-t-il ?"
— scroll + pulse trouvent la nouvelle entry dans la grille triée.
## Settings sync
Per-user blob JSON dans `users.settings_json`, exposé via `GET/PUT
/settings`. Synced : theme, autoLock, viewMode, sortBy/sortDir, hibp,
hotkeys autofill, etc. **Device-only** (localStorage seulement) :
`quickUnlockEnabled` (DPAPI lié au compte Windows), `autofillEnabled`
(toggle hotkey Win32), `rememberedUsername` (auth screen autofill local).
## Quick Unlock
DPAPI blob à `%LOCALAPPDATA%\PMServer\quickunlock.bin` (tied to Windows
user, **pas** à l'emplacement de l'exe — survit au déplacement).
**Cold-start restore** : `tryQuickUnlock` ne check pas `localStorage`
comme source of truth (WebView2 user-data folder est relatif à l'exe →
move = localStorage perdu). Le fichier DPAPI seul gouverne. Si présent :
- Décrypte blob → restore key
- **Re-login fresh** via `/login` avec `verifier = bytesToHex(rawKey)`
(le token stocké dans le blob peut être expiré ; on en récupère un
nouveau à chaque cold-start)
- Resync `localStorage` à `'1'` pour que Settings affiche "Disable"
## Recovery code (refactor 2026-05)
- **5 uses par code** (compteur `remaining_uses` dans `recovery_keys`)
- Décrémenté à chaque redeem. Si tombe à 0 → row deletée
- Row deletée aussi sur `successful change-master-password`
- Workflow attendu : recover → set new master pw (consomme le code) →
regenerate un nouveau code
**State `justRecovered`** : flag JS posé après redeem, cleared sur
- successful master pw change (le code est consumed)
- `lockVault` (lock = perte de contexte recovery)
- `doLogin` success (login normal avec pw = sortie du mode recovery)
En mode recovery, le modal "Change master password" masque le champ
"Current master password" ET retire son `required` (sinon HTML5 form
validation bloque silencieusement le submit). Le verifier "current" est
dérivé du `state.cryptoKey` en mémoire (qu'on vient de recover).
## Autofill pipeline
### Workflow utilisateur (important)
**L'utilisateur doit cliquer le champ cible AVANT de presser le hotkey.**
Le code ne fait plus de "click au centre de la fenêtre" pour deviner le
champ — ça détruisait le focus sur les forms qui n'étaient pas centrés
(Gitea login, etc.).
- `Ctrl+Shift+L` (full) : click sur **username field** → hotkey → user+Tab+pwd
- `Ctrl+Shift+P` (password only) : click sur **password field** → hotkey → pwd seul
### Pipeline Delphi
1. `RegisterHotKey` Ctrl+Shift+L/P sur message-only window
2. `WM_HOTKEY` → capture `GetForegroundWindow` + titre, fire callback
3. JS reçoit `Bridge.onAutofillRequest(title, kind)` → strip browser
suffix (`- Google Chrome`, etc.) → matching `state.entries.site`
4. Single match → `Bridge.executeAutofill(user, pwd)` direct
Multi-match → `Bridge.focusApp()` + picker modal → user pick → execute
Note : `closeAutofillPicker(false)` quand user pick (skip cancel
cmd qui clearrait `FAutofillTargetHWND`)
5. Delphi `ExecuteAutofill` :
- Si app foreground → `SW_MINIMIZE` (laisse la place au target)
- `ForceForegroundWindow(target)` avec `AttachThreadInput` + poll
jusqu'à `GetForegroundWindow == target` (max 600ms)
- `WaitForModifierRelease` (sinon Ctrl+Shift restent down → toutes
les frappes deviennent Ctrl+Shift+X)
- `Sleep(120)` settle
- **Pas de click parasite** — le user a déjà focusé le bon champ
- `Ctrl+A + Del` avant chaque champ (clear contenu existant)
- SendInput : `Ctrl+A+Del → username → 200ms → Tab → 200ms → Ctrl+A+Del → password`
### Matching titre → entry
- Strip suffixe browser via `BROWSER_SUFFIX_RE` (Chrome, Firefox, Edge,
Brave, Opera, Vivaldi, Safari, Tor, Arc) avant toute comparaison
- Score = max sur 3 champs : `entry.title` (display name, score 1500+),
`entry.site` hostname full (1000+), SLD (500+)
- Pour SLD courts (`x`, `qq`), match avec word boundaries
- `entry.title` priorisé → si utilisateur stocke `site = "git.example.com"`
(hostname), mettre aussi `title = "Gitea"` (brand) couvre le browser
title sans ambiguïté
### Contraintes connues autofill
- **UIPI** : SendInput vers process élevé bloqué. Notepad++ admin,
regedit → autofill silencieusement no-op. Tester avec apps non-élevées.
- **Chrome password manager** : si Chrome a des credentials sauvegardés
pour le domaine, il peut racer avec notre SendInput et écraser notre
username pendant le Tab. Workaround : user désactive Chrome PM par
site (clic-droit password field → "Never save passwords for this site")
- **Gitea-like forms** : tabindex explicite + `autocomplete="current-password"`
→ Chrome PM s'active. Désactiver pour ce domaine ou utiliser Ctrl+Shift+P
- **Title-based matching** : si l'utilisateur stocke `site = "git.ai-agents4you.com"`
(hostname) mais le titre browser n'a que la marque ("Gitea"), pas de
match. Convention : préférer `site = "Gitea"` (brand) pour les sites
brandés. URL hostname-only fonctionne pour les sites dont le titre
contient le hostname (rare).
## Contraintes / pièges connus
- **UIPI** : process non-élevé ne peut pas SendInput vers process élevé
(Notepad++ admin, regedit, …). Solution future : manifest
`uiAccess=true` + exe signé EV + installé dans `C:\Program Files\`.
- **Assets embedded > static** dans le dispatch HTTP : modifier JS sans
rebuild `assets.res` n'a aucun effet sur l'exe.
- **WebView2 mange tous les keydown** : ne pas utiliser `FormKeyDown`
pour des raccourcis globaux → toujours `RegisterHotKey`.
- **FMX `FormCloseQuery`** : minimize-to-tray sauf si `FQuitting=True`
(set par le tray menu "Quit" uniquement).
- **`SetForegroundWindow` Win10/11** : règles strictes anti-stealing →
nécessite `AttachThreadInput` pour cross-process.
- **`updated_at`** est bumpé à chaque PUT entry → sort par défaut =
`name` asc pour que modifier une entry ne change pas sa position.
- **TMS render-time vs DOM-attachment** : pattern courant — fonctions
qui rendent un widget (`soTagsField`, `soTotpField`) appellent un
helper (`renderSoChips`, `startTotpTick`) qui fait `$('#id')` sur un
élément pas encore dans le DOM. Solution : render inline OU
`setTimeout(fn, 0)` pour deferrer jusqu'au tick suivant.
- **HTML5 `required` + `display:none`** : un input `required` caché
bloque silencieusement le form submit. Toggle aussi `.required = false`
quand on cache (cf. modal recovery).
- **WebView2 user-data folder est relatif à l'exe** : déplacer l'exe =
nouveau folder = `localStorage`/`sessionStorage` perdu. Pour ce qui
doit survivre au move, stocker via Delphi (DPAPI, fichiers user).
- **Port éphémère = nouvelle origin = localStorage wipé chaque launch** :
cause distincte du point précédent (l'exe ne bouge pas, mais le port
change). Mêmes conséquences : pour tout pref qui doit survivre au
reboot, passer par `PM.UserPrefs` via `Bridge.getPref/setPref`. Le
fix port-fixe est possible (`port=N` dans `config.txt`) mais on perd
l'aléatoire qui complique le scan externe.
- **Modifier les `Enable*` props TMS en `OnInitialized`**, pas FormCreate.
## Conventions code (héritage sessions précédentes)
- **Pas** de commentaires multi-lignes pour chaque fix : préférer noms
de variables/fonctions descriptifs (`OwnFormHwnd`,
`EnsureTargetThreadHasKeyboardFocus`, `FocusSettleDelayMs`)
- Migrations DB : toujours via `AddColumnIfMissing` (idempotent)
- Nouveau setting UI : ajouter à `SYNCED_SETTING_KEYS` + handler dans
`loadServerSettings` switch case + listener change qui appelle
`saveServerSettings()`
- Tout new bridge cmd : handler dans `UMainForm.HandleBridgeCommand`,
log via `LogLine` pour traçabilité
## Audit / docs
- `security-issues.md` à la racine : audit sécu legacy (api.php)
- Ce CLAUDE.md = source de vérité pour l'architecture courante
## Git
- Pas de remote configuré (dev local)
- `vault.db`, `*.db-shm`, `*.db-wal`, `delphi-backend/Win32/`, `*.dcu`,
`assets.res` gitignored
- Messages commit : `type(scope): description` style conventional
+343
View File
@@ -0,0 +1,343 @@
:root {
--bg: #0e1015; --bg2: #191d27; --text: #e2e6ea; --text2: #7f8a98;
--accent: #6b7280; --accent-rgb: 107,114,128;
--danger: #ef4444; --success: #22c55e; --warning: #f59e0b;
--border: rgba(255,255,255,0.12); --card: #212734;
--input: #11141a; --vault-bg: rgba(255,255,255,0.06);
}
.light {
--bg: #eef0f4; --bg2: #e2e5eb; --text: #161b24; --text2: #5f6a7a;
--accent: #6b7280; --accent-rgb: 107,114,128;
--card: #ffffff; --input: #ffffff;
--border: rgba(0,0,0,0.16); --vault-bg: rgba(255,255,255,0.7);
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
background: linear-gradient(145deg, var(--bg) 0%, var(--bg2) 100%);
font-family: 'Segoe UI', system-ui, sans-serif;
min-height: 100vh; display: flex; justify-content: center;
align-items: flex-start; padding: 1.2rem; color: var(--text);
transition: background 0.3s, color 0.3s;
}
.toast-container { position:fixed; top:1rem; right:1rem; z-index:9999; display:flex; flex-direction:column; gap:0.5rem; }
.toast { padding:0.7rem 1.2rem; border-radius:0.8rem; font-size:0.85rem; animation:slideIn 0.3s ease; color:#fff; }
.toast.error { background:var(--danger); }
.toast.success { background:var(--success); }
.toast { display:flex; align-items:center; gap:0.6rem; max-width:400px; }
.toast-action { background:rgba(0,0,0,0.3); border:1px solid rgba(255,255,255,0.4); color:#fff; font-weight:700; font-size:0.8rem; padding:0.25rem 0.7rem; border-radius:1rem; cursor:pointer; white-space:nowrap; flex-shrink:0; transition:0.15s; }
.toast-action:hover { background:rgba(0,0,0,0.5); }
@keyframes slideIn { from { transform:translateX(100%); opacity:0; } to { transform:translateX(0); opacity:1; } }
.vault {
width:96%; max-width:1700px; background:var(--vault-bg);
backdrop-filter:blur(20px); border:1px solid var(--border);
border-radius:2rem; padding:1.8rem; margin:0.5rem auto;
box-shadow:0 30px 50px rgba(0,0,0,0.4);
user-select:none; -webkit-user-drag:none;
}
h1 { font-size:2rem; margin-bottom:0.6rem; display:flex; align-items:center; gap:0.6rem; flex-wrap:wrap; }
h1 span { background:var(--accent); padding:0.2rem 0.7rem; border-radius:3rem; font-size:0.8rem; color:#fff; }
/* Auth */
.auth-section { background:rgba(0,0,0,0.25); border-radius:1.5rem; padding:1.3rem; margin-bottom:1rem; border:1px solid var(--border); }
.auth-tabs { display:flex; gap:1rem; margin-bottom:0.8rem; }
.auth-tab { background:none; border:none; color:var(--text2); padding:0.4rem 0.8rem; cursor:pointer; border-bottom:2px solid transparent; font-size:0.9rem; }
.auth-tab.active { color:var(--accent); border-bottom-color:var(--accent); }
input, select, textarea {
flex:1; min-width:130px; background:var(--input); border:1px solid #2d3748;
padding:0.65rem 1rem; border-radius:2rem; color:var(--text); font-size:0.85rem;
outline:none; font-family:inherit;
}
input:focus, select:focus { border-color:var(--accent); }
.btn {
background:linear-gradient(135deg, #6b7280, #4b5563); border:none; color:#fff;
font-weight:600; padding:0.65rem 1.3rem; border-radius:2rem; cursor:pointer;
font-size:0.85rem; transition:0.2s; white-space:nowrap;
}
.btn:hover { filter:brightness(1.15); transform:scale(1.02); }
.btn:disabled { opacity:0.5; cursor:not-allowed; transform:none; }
.btn-outline { background:transparent; border:1px solid #475569; color:var(--text2); }
.btn-sm { padding:0.35rem 0.9rem; font-size:0.75rem; }
.btn-xs { padding:0.2rem 0.5rem; font-size:0.7rem; }
.btn-danger { background:var(--danger); }
.input-group { display:flex; gap:0.5rem; margin:0.7rem 0; flex-wrap:wrap; align-items:center; }
/* Toolbar */
.toolbar { display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:0.5rem; margin-bottom:0.7rem; }
.view-dropdown { position:relative; }
.view-dropdown-menu { position:absolute; top:100%; right:0; margin-top:4px; min-width:155px; background:var(--bg2); border:1px solid var(--border); border-radius:0.6rem; padding:0.3rem; box-shadow:0 8px 24px rgba(0,0,0,0.3); z-index:100; display:flex; flex-direction:column; gap:2px; }
.view-opt { background:none; border:none; color:var(--text); padding:0.45rem 0.8rem; border-radius:0.4rem; cursor:pointer; font-size:0.8rem; text-align:left; transition:0.12s; white-space:nowrap; }
.view-opt:hover { background:var(--accent); color:#fff; }
.view-opt.active { background:var(--accent); color:#fff; font-weight:600; }
.toggles-row { display:flex; gap:1.2rem; align-items:center; flex-wrap:wrap; }
.toggle-item { display:flex; align-items:center; gap:0.4rem; font-size:0.75rem; color:var(--text2); }
.toggle-switch { position:relative; width:36px; height:20px; background:#334155; border-radius:10px; cursor:pointer; transition:0.2s; }
.toggle-switch.active { background:var(--accent); }
.toggle-switch::after { content:''; position:absolute; top:2px; left:2px; width:16px; height:16px; background:#fff; border-radius:50%; transition:0.2s; }
.toggle-switch.active::after { left:18px; }
.status-badge { background:var(--bg2); padding:0.25rem 0.8rem; border-radius:2rem; font-size:0.75rem; white-space:nowrap; }
.hidden { display:none !important; }
/* Strength */
.strength-bar { height:4px; border-radius:2px; transition:0.3s; margin-top:0.2rem; }
.s0 { background:var(--danger); width:20%; }
.s1 { background:var(--warning); width:40%; }
.s2 { background:#eab308; width:60%; }
.s3 { background:#84cc16; width:80%; }
.s4 { background:var(--success); width:100%; }
/* ========== SEARCH BOX WITH CLEAR BUTTON (FIXED) ========== */
.search-box {
position: relative;
width: 240px; /* fixed width adjust if needed */
flex-shrink: 0;
}
.search-box input {
width: 100%;
box-sizing: border-box;
padding-right: 30px; /* space for the ✕ button */
}
.clear-search-btn {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--text2);
cursor: pointer;
font-size: 0.9rem;
line-height: 1;
padding: 6px;
display: none;
}
/* Folders */
.folders-bar { display:flex; gap:0.4rem; margin-bottom:0.8rem; flex-wrap:wrap; align-items:center; padding:0.4rem 0.6rem; background:rgba(0,0,0,0.2); border-radius:1rem; }
.folder-chip { background:var(--bg2); border:1px solid var(--border); color:var(--text2); padding:0.3rem 0.8rem; border-radius:2rem; cursor:pointer; font-size:0.78rem; transition:0.2s; white-space:nowrap; }
.folder-chip:hover { background:var(--chip-color, var(--accent)); color:#fff; border-color:var(--chip-color, var(--accent)); }
.folder-chip.active { background:var(--chip-color, var(--accent)); color:#fff; border-color:var(--chip-color, var(--accent)); }
.folder-chip.drag-over { border-color:var(--accent)!important; box-shadow:0 0 12px rgba(var(--accent-rgb),0.5); transform:scale(1.05); }
.pw-display.pw-hover { cursor:pointer; }
.folder-count { background:rgba(0,0,0,0.3); padding:0.1rem 0.4rem; border-radius:1rem; margin-left:0.3rem; font-size:0.7rem; }
.folder-delete-btn { background:transparent; border:none; color:var(--danger); cursor:pointer; font-size:0.7rem; margin-left:0.2rem; opacity:0.7; }
.folder-delete-btn:hover { opacity:1; }
.folder-add-btn { background:transparent; border:1px dashed #475569; color:var(--text2); padding:0.3rem 0.6rem; border-radius:2rem; cursor:pointer; font-size:0.75rem; transition:0.2s; }
.folder-add-btn:hover { border-color:var(--accent); color:var(--accent); }
.light .entry-card, .light .entry-row, .light .entry-compact { box-shadow:0 1px 4px rgba(0,0,0,0.08); }
#addFolderSelect { min-width:110px; max-width:150px; background:var(--input); border:1px solid #2d3748; color:var(--text); padding:0.5rem 0.8rem; border-radius:2rem; font-size:0.8rem; cursor:pointer; }
/* Entries */
#entriesContainer { user-select:none; -webkit-user-drag:none; }
#entriesContainer.grid-view { display:grid; grid-template-columns: repeat(auto-fill, minmax(180px,1fr)); gap:0.6rem; }
#entriesContainer.list-view { display:flex; flex-direction:column; gap:0.4rem; }
#entriesContainer.compact-view { display:flex; flex-direction:column; gap:0.2rem; }
#entriesContainer.table-view { overflow-x:auto; }
#entriesContainer.table-view table { width:100%; border-collapse:collapse; }
#entriesContainer.table-view th { text-align:left; padding:0.4rem 0.6rem; color:var(--text2); font-size:0.75rem; border-bottom:1px solid var(--border); }
#entriesContainer.table-view td { padding:0.4rem 0.6rem; font-size:0.8rem; border-bottom:1px solid rgba(255,255,255,0.03); }
.entry-card, .entry-row, .entry-compact, .table-row-drag { cursor:pointer; user-select:none; }
.entry-card.drag-over, .entry-row.drag-over, .entry-compact.drag-over, .table-row-drag.drag-over { border-color:var(--accent)!important; box-shadow:0 0 15px rgba(var(--accent-rgb),0.3); }
.entry-card.selected { border-color:var(--accent)!important; box-shadow:0 0 0 2px var(--accent),0 0 18px rgba(var(--accent-rgb),0.35); background:rgba(var(--accent-rgb),0.18); }
.entry-row.selected, .entry-compact.selected { border-color:var(--accent)!important; box-shadow:0 0 0 2px var(--accent),0 0 18px rgba(var(--accent-rgb),0.35); background:rgba(var(--accent-rgb),0.14); border-left:3px solid var(--accent); }
.table-row-drag.selected td { background:rgba(var(--accent-rgb),0.14)!important; box-shadow:inset 0 0 0 1px var(--accent); border-bottom:1px solid var(--accent); }
.entry-card { background:var(--card); border:1px solid var(--border); border-radius:0.8rem; padding:0.9rem 2.8rem 0.9rem 0.9rem; transition:0.2s; position:relative; word-break:break-word; box-shadow:0 2px 6px rgba(0,0,0,0.2); }
.entry-card:hover { border-color:rgba(255,255,255,0.15); transform:translateY(-2px); box-shadow:0 4px 12px rgba(0,0,0,0.3); }
.card-site { font-weight:700; font-size:0.9rem; color:var(--text); margin-bottom:0.2rem; }
.card-user { color:var(--text2); font-size:0.75rem; margin-bottom:0.3rem; }
.card-folder { font-size:0.65rem; color:var(--accent); margin-bottom:0.3rem; background:rgba(var(--accent-rgb),0.15); display:inline-block; padding:0.1rem 0.5rem; border-radius:1rem; }
.card-password { background:var(--bg2); padding:0.3rem 0.5rem; border-radius:0.6rem; display:flex; align-items:center; justify-content:space-between; font-family:monospace; font-size:0.75rem; gap:0.2rem; }
#entriesContainer.card-view .entry-card { padding:1.2rem 3rem 1.2rem 1.2rem; }
#entriesContainer.card-view .card-site { font-size:1.05rem; }
#entriesContainer.card-view .card-password { font-size:0.9rem; }
#entriesContainer.card-view .entry-card:hover { transform:translateY(-3px); }
.entry-row { background:var(--card); border:1px solid var(--border); border-radius:0.8rem; padding:0.7rem 2.8rem 0.7rem 0.9rem; display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:0.4rem; position:relative; box-shadow:0 2px 6px rgba(0,0,0,0.15); }
.entry-compact { display:flex; align-items:center; gap:0.5rem; padding:0.35rem 2.8rem 0.35rem 0.7rem; background:var(--card); border-radius:0.5rem; border:1px solid var(--border); font-size:0.8rem; position:relative; box-shadow:0 1px 4px rgba(0,0,0,0.12); }
.action-btns { position:absolute; top:4px; right:6px; display:flex; gap:4px; z-index:1; }
.delete-btn { width:20px; height:20px; background:transparent; color:var(--danger); border:none; cursor:pointer; font-size:0.9rem; font-weight:700; display:flex; align-items:center; justify-content:center; }
.delete-btn:hover { color:#fff; transform:scale(1.2); }
.light .delete-btn:hover { color:#000!important; }
.edit-btn { width:20px; height:20px; background:transparent; color:var(--accent); border:none; cursor:pointer; font-size:0.75rem; display:flex; align-items:center; justify-content:center; }
.edit-btn:hover { color:#fff; transform:scale(1.2); }
.light .edit-btn:hover { color:#000!important; }
.star-btn { width:20px; height:20px; background:transparent; border:none; cursor:pointer; font-size:0.85rem; display:flex; align-items:center; justify-content:center; color:var(--text2); }
.star-btn:hover { transform:scale(1.3); }
.entry-card.favorite { border-color:rgba(255,200,0,0.3); background:rgba(255,200,0,0.05); }
.entry-row.favorite { border-color:rgba(255,200,0,0.3); background:rgba(255,200,0,0.05); }
.entry-compact.favorite { border-color:rgba(255,200,0,0.3); background:rgba(255,200,0,0.05); }
.table-row-drag.favorite td { background:rgba(255,200,0,0.05); }
/* Grouped view */
#entriesContainer.grouped-view { display:flex; flex-direction:column; gap:0.15rem; }
.grouped-header { position:sticky; top:0; z-index:2; background:var(--bg); padding:0.55rem 0.8rem; border-radius:0.5rem; font-weight:600; font-size:0.9rem; color:var(--accent); display:flex; align-items:center; gap:0.5rem; border-bottom:2px solid var(--accent); margin-top:0.4rem; }
.grouped-header:first-child { margin-top:0; }
#entriesContainer.grouped-view .entry-row { border-left:3px solid transparent; transition:0.15s; padding-left:1rem; }
#entriesContainer.grouped-view .entry-row:hover { border-left-color:var(--accent); }
/* Detail view */
#entriesContainer.detail-view { display:flex; flex-direction:column; gap:1rem; }
.detail-nav { display:flex; align-items:center; justify-content:center; gap:1rem; padding:0.5rem 0; position:sticky; top:0; z-index:2; background:var(--bg); }
.detail-nav button { background:var(--bg2); border:1px solid var(--border); color:var(--text); padding:0.4rem 1rem; border-radius:0.5rem; cursor:pointer; transition:0.15s; font-size:0.85rem; }
.detail-nav button:hover { border-color:var(--accent); color:var(--accent); }
.detail-nav button:disabled { opacity:0.4; cursor:default; }
.detail-nav .detail-counter { font-size:0.8rem; color:var(--text2); min-width:80px; text-align:center; }
.detail-card { background:var(--bg2); border:1px solid var(--border); border-radius:1rem; padding:1.5rem; display:flex; flex-direction:column; gap:0.9rem; max-width:520px; margin:0 auto; width:100%; }
.detail-field { display:flex; flex-direction:column; gap:0.15rem; }
.detail-label { font-size:0.7rem; color:var(--text2); text-transform:uppercase; letter-spacing:0.5px; }
.detail-value { font-size:1.2rem; word-break:break-all; }
.detail-value.pw-display { font-family:monospace; letter-spacing:2px; font-size:1.3rem; cursor:default; }
.detail-folder { display:flex; gap:0.5rem; align-items:center; }
.detail-actions { display:flex; gap:0.5rem; margin-top:0.3rem; }
.detail-actions button { flex:1; padding:0.5rem; border-radius:0.5rem; border:1px solid var(--border); background:var(--bg); cursor:pointer; transition:0.15s; font-size:0.85rem; }
.detail-actions button:hover { border-color:var(--accent); background:var(--bg2); }
.entry-info { display:flex; gap:0.5rem; align-items:center; flex-wrap:wrap; flex:1; }
.entry-site { font-weight:700; color:var(--text); }
.entry-user { color:var(--text2); }
.entry-folder { font-size:0.7rem; color:var(--accent); background:rgba(var(--accent-rgb),0.15); padding:0.1rem 0.5rem; border-radius:1rem; }
.password-field { display:flex; align-items:center; gap:0.3rem; background:var(--bg2); padding:0.2rem 0.5rem; border-radius:2rem; }
.password-text { font-family:monospace; color:var(--text2); font-size:0.8rem; }
.icon-btn { background:none; border:1px solid #475569; color:var(--text2); border-radius:2rem; padding:0.2rem 0.5rem; font-size:0.65rem; cursor:pointer; }
.icon-btn:hover { background:rgba(var(--accent-rgb),0.2); }
/* Settings Dropdown */
.settings-dropdown { position:relative; display:inline-block; }
.settings-menu { position:absolute; top:100%; right:0; background:var(--bg2); border:1px solid var(--border); border-radius:0.8rem; padding:0.5rem; min-width:200px; z-index:1000; box-shadow:0 10px 25px rgba(0,0,0,0.4); }
.settings-item { display:flex; justify-content:space-between; align-items:center; padding:0.4rem 0.6rem; border-radius:0.5rem; font-size:0.8rem; color:var(--text2); cursor:pointer; }
.settings-item:hover { background:rgba(255,255,255,0.05); }
.settings-item select { background:var(--input); border:1px solid #334155; color:var(--text); padding:0.2rem 0.5rem; border-radius:1rem; font-size:0.75rem; }
/* Trash */
.trash-badge { background:var(--danger); color:#fff; padding:0.15rem 0.5rem; border-radius:1rem; font-size:0.65rem; margin-left:0.3rem; }
.trash-info { font-size:0.7rem; color:var(--text2); margin-top:0.2rem; }
.restore-btn { background:none; color:#fff; border:none; padding:0.2rem 0.6rem; border-radius:1.5rem; cursor:pointer; font-size:0.7rem; }
.restore-btn:hover { filter:brightness(1.2); }
.empty-trash-btn { background:var(--danger); color:#fff; border:none; padding:0.3rem 0.8rem; border-radius:1.5rem; cursor:pointer; font-size:0.75rem; }
.empty-trash-btn:hover { filter:brightness(1.2); }
/* Edit Modal */
.edit-modal { position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.7); display:none; justify-content:center; align-items:center; z-index:1001; }
.edit-modal.show { display:flex; }
.edit-box { background:var(--bg2); border-radius:1.5rem; padding:1.5rem; min-width:380px; max-width:90%; }
.edit-box h3 { margin-bottom:1rem; color:var(--text); }
.edit-box label { color:var(--text2); font-size:0.8rem; display:block; margin-bottom:0.2rem; }
.edit-box input, .edit-box select { width:100%; margin-bottom:0.5rem; }
/* Custom modals */
.custom-modal-overlay { position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.6); display:flex; justify-content:center; align-items:center; z-index:10001; display:none; }
.custom-modal-overlay.show { display:flex; }
.custom-modal { background:var(--bg2); border:1px solid var(--border); border-radius:1.2rem; padding:1.5rem; min-width:300px; max-width:90%; }
.custom-modal h3 { margin-bottom:1rem; color:var(--text); }
.custom-modal input { width:100%; margin-bottom:1rem; }
.custom-modal .modal-actions { display:flex; gap:0.5rem; justify-content:flex-end; }
/* Confirm popup near element */
.custom-confirm { position:fixed; background:var(--bg2); border:1px solid var(--accent); border-radius:0.8rem; padding:0.7rem 1rem; z-index:9999; box-shadow:0 10px 30px rgba(0,0,0,0.5); display:none; font-size:0.8rem; color:var(--text); white-space:nowrap; }
.custom-confirm.show { display:block; }
.custom-confirm .confirm-text { margin-bottom:0.5rem; }
.custom-confirm .confirm-btns { display:flex; gap:0.4rem; }
.custom-confirm .confirm-yes { background:var(--danger); color:#fff; border:none; padding:0.3rem 0.8rem; border-radius:1.5rem; cursor:pointer; font-size:0.75rem; }
.custom-confirm .confirm-no { background:#334155; color:#fff; border:none; padding:0.3rem 0.8rem; border-radius:1.5rem; cursor:pointer; font-size:0.75rem; }
/* Zigzag toast */
.toast-zigzag { position:fixed; z-index:9998; padding:0.4rem 0.7rem; border-radius:0.5rem; font-size:0.72rem; font-weight:600; pointer-events:none; animation:zigzagUp 1.2s ease-out forwards; white-space:nowrap; }
.toast-zigzag.success { background:rgba(34,197,94,0.95); color:#fff; }
.toast-zigzag.error { background:rgba(239,68,68,0.95); color:#fff; }
@keyframes zigzagUp { 0%{opacity:1;transform:translate(0,0) scale(1)} 20%{opacity:0.9;transform:translate(-10px,-14px) scale(0.9)} 40%{opacity:0.65;transform:translate(12px,-28px) scale(0.75)} 70%{opacity:0.3;transform:translate(-8px,-42px) scale(0.6)} 100%{opacity:0;transform:translate(0,-60px) scale(0.4)} }
.idle-warning { position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); background:rgba(0,0,0,0.95); color:#fff; padding:2rem; border-radius:2rem; z-index:10000; text-align:center; display:none; }
.idle-warning.show { display:block; }
@media(max-width:700px) {
.vault { padding:1rem; border-radius:1.5rem; width:98%; }
.entry-row { flex-direction:column; }
#entriesContainer.grid-view { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
.toolbar { flex-direction:column; }
.folders-bar { flex-direction:column; align-items:stretch; }
}
/* Floating action button */
.fab {
position: fixed; bottom: 2rem; right: 2rem;
width: 56px; height: 56px; border-radius: 50%;
background: var(--accent); color: #fff; border: none;
font-size: 1.8rem; cursor: pointer; box-shadow: 0 8px 20px rgba(0,0,0,0.4);
display: flex; align-items: center; justify-content: center;
transition: 0.2s; z-index: 100;
}
.fab:hover { transform: scale(1.1); filter: brightness(1.1); }
.fab-trash { left: 2rem; right: auto; font-size:1.4rem; background:transparent; box-shadow:none; }
.fab-trash.active { box-shadow:0 0 18px rgba(var(--accent-rgb),0.5); }
/* Generic modal overlay (if you dont already have it) */
.modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.7); display: none;
justify-content: center; align-items: center; z-index: 1001;
}
.modal-overlay.show { display: flex; }
/* Modal box (used by both edit and add modals) */
.modal-box {
background: var(--bg2); border-radius: 1.5rem; padding: 1.5rem;
min-width: 380px; max-width: 90%;
}
.modal-box h3 { margin-bottom: 1rem; color: var(--text); }
.modal-box label { color: var(--text2); font-size: 0.8rem; display: block; margin-bottom: 0.2rem; }
.modal-box input, .modal-box select { width: 100%; margin-bottom: 0.5rem; }
/* Batch select */
.select-checkbox {
position: absolute;
top: 6px;
left: 6px;
z-index: 2;
accent-color: var(--accent);
}
.batch-actions {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: var(--bg2);
border: 1px solid var(--accent);
border-radius: 2rem;
padding: 0.5rem 1.5rem;
display: flex;
gap: 0.8rem;
align-items: center;
z-index: 1000;
box-shadow: 0 10px 25px rgba(0,0,0,0.5);
font-size: 0.85rem;
color: var(--text);
}
.batch-actions button {
font-size: 0.78rem;
padding: 0.35rem 0.8rem;
}
.selected-count {
font-weight: 600;
color: var(--accent);
}
#rectSelect {
position:fixed; pointer-events:none; z-index:999;
border:1px solid var(--accent);
background:rgba(var(--accent-rgb),0.1);
display:none;
}
#trashBtn.drag-over {
border-color:var(--danger)!important;
box-shadow:0 0 15px rgba(239,68,68,0.4);
background:rgba(239,68,68,0.15);
}
/* Batch confirm modal backdrop */
.batch-confirm-overlay { background:rgba(0,0,0,0.3); }
+428 -19
View File
@@ -490,6 +490,41 @@ input[type="range"]::-webkit-slider-thumb {
letter-spacing: 0.5px;
color: var(--text-faint);
}
.section-toggle {
flex: 1;
display: flex; align-items: center; gap: 6px;
background: none; border: none; padding: 2px 0;
font: inherit; color: inherit; text-transform: inherit;
letter-spacing: inherit;
cursor: pointer;
text-align: left;
}
.section-toggle:hover { color: var(--text-dim); }
.section-count {
min-width: 16px;
padding: 1px 6px;
border-radius: 9px;
background: var(--bg);
color: var(--text-dim);
font-size: 10px;
font-weight: 600;
text-align: center;
letter-spacing: 0;
}
/* Push everything after the count toward the right of the toggle button
so the +/etc. action button stays at the row's edge. */
.section-toggle { gap: 6px; }
.section-chevron {
width: 12px; height: 12px;
transition: transform 0.18s ease;
flex-shrink: 0;
}
.sidebar-section.is-collapsed .section-chevron {
transform: rotate(-90deg);
}
.sidebar-section.is-collapsed .section-body {
display: none;
}
.sidebar-bottom {
margin-top: auto;
padding-top: 12px;
@@ -513,6 +548,13 @@ input[type="range"]::-webkit-slider-thumb {
background: var(--bg-glass);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
position: relative;
/* backdrop-filter creates a stacking context, so child z-indexes (e.g.
.user-dropdown : 50) are confined here. Without an explicit z-index
the topbar lands behind .slideover (z-index 30), clipping the
dropdown when an entry detail panel is open. 40 puts the topbar
(and its children) above the slideover; modals at 200+ still win. */
z-index: 40;
}
.search { flex: 1; max-width: 480px; position: relative; display: flex; align-items: center; }
.search svg {
@@ -615,14 +657,6 @@ input[type="range"]::-webkit-slider-thumb {
}
/* Marquee rectangle for rubber-band selection */
.marquee {
position: fixed;
z-index: 5;
background: var(--accent-soft);
border: 1px solid var(--accent);
border-radius: 2px;
pointer-events: none;
}
.entry-card {
position: relative;
@@ -714,26 +748,55 @@ input[type="range"]::-webkit-slider-thumb {
restore + purge (trash view), or kebab (compact mode). Same order slot. */
.entry-grid.is-list .entry-fav,
.entry-grid.is-list .entry-del,
.entry-grid.is-list .entry-dup,
.entry-grid.is-list .entry-kebab-wrap,
.entry-grid.is-list .entry-head .icon-btn {
order: 6;
flex-shrink: 0;
}
.entry-avatar.is-checkable {
position: relative;
cursor: pointer;
}
.entry-avatar.is-checkable:hover::after {
content: '';
position: absolute; inset: 0;
/* Selection checkbox overlay — nested INSIDE the avatar (.entry-avatar
is position:relative) so it covers the avatar 1:1, no overlap with the
avatar's footprint. Hidden by default; appears on card hover OR when
the entry is already checked. The avatar's initials stay visible
underneath through the checkbox's transparent-then-solid transition. */
.entry-check {
position: absolute;
inset: 0;
display: grid; place-items: center;
background: var(--bg);
border: 1.5px solid var(--accent);
border-radius: var(--radius-sm);
background: var(--accent-soft);
border: 2px solid var(--accent);
color: var(--text);
font-size: 14px;
line-height: 1;
cursor: pointer;
opacity: 0;
transition: opacity var(--t-fast),
background var(--t-fast), border-color var(--t-fast);
z-index: 2;
}
.entry-card.is-checked .entry-avatar {
.entry-card:hover .entry-check,
.entry-check.is-checked {
opacity: 1;
}
.entry-check.is-checked {
background: var(--accent);
border-color: var(--accent);
color: white;
}
/* Standalone variant used in the table view's check column. The default
rule above is absolute+inset:0 so the box sizes itself from its avatar
parent (card view). When the checkbox lives on its own in a <td>, no
parent provides a size and the box collapses to a thin line. This
modifier restores explicit dimensions and always-on visibility. */
.entry-check.entry-check-static {
position: static;
width: 18px; height: 18px;
border-radius: 4px;
border-width: 1.5px;
opacity: 1;
font-size: 11px;
}
/* Batch action bar (appears when selection is non-empty) */
.batch-bar {
@@ -766,6 +829,7 @@ input[type="range"]::-webkit-slider-thumb {
.entry-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.entry-avatar {
position: relative; /* anchor for .entry-check overlay */
width: 32px; height: 32px;
display: grid; place-items: center;
background: var(--bg-elev-2);
@@ -784,7 +848,7 @@ input[type="range"]::-webkit-slider-thumb {
font-size: 12px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.entry-fav, .entry-del {
.entry-fav, .entry-del, .entry-dup {
color: var(--text-faint);
background: transparent; border: none;
padding: 4px;
@@ -793,6 +857,18 @@ input[type="range"]::-webkit-slider-thumb {
}
.entry-fav.is-on { color: var(--warning); }
.entry-fav:hover { color: var(--warning); transform: scale(1.1); }
.entry-dup {
color: var(--text-dim);
opacity: 0;
}
.entry-card:hover .entry-dup { opacity: 0.8; }
.entry-dup:hover {
opacity: 1;
color: var(--accent);
background: var(--accent-soft);
border-radius: 6px;
transform: scale(1.1);
}
/* Delete button — always visible, brightens on hover */
.entry-del {
@@ -862,6 +938,10 @@ input[type="range"]::-webkit-slider-thumb {
animation: modalIn var(--t-fast);
}
.entry-kebab-menu.is-open { display: block; }
/* Lift the card containing an open kebab menu above its siblings so the
dropdown isn't clipped by the next card in DOM order. */
.entry-card:has(.entry-kebab-menu.is-open) { z-index: 100; }
.entry-row:has(.entry-kebab-menu.is-open) { position: relative; z-index: 100; }
.kebab-item {
display: flex; align-items: center; gap: 10px;
@@ -966,6 +1046,62 @@ input[type="range"]::-webkit-slider-thumb {
transition: width 0.8s linear, background 0.2s;
}
/* ---- Authenticator view (TOTP grid) ---------------------- */
.entry-grid.is-auth {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
}
.auth-card {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
cursor: pointer;
transition: border-color 0.15s, transform 0.05s;
}
.auth-card:hover { border-color: var(--accent); }
.auth-card:active { transform: scale(0.99); }
.auth-card-head { margin-bottom: 8px; }
.auth-card-title {
font-weight: 600;
color: var(--text);
font-size: 14px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.auth-card-sub {
font-size: 12px;
color: var(--text-dim);
margin-top: 2px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.auth-card-code-row {
display: flex; align-items: center; gap: 8px;
}
.auth-card-code {
flex: 1;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 26px;
letter-spacing: 3px;
color: var(--accent);
font-weight: 600;
user-select: all;
}
.auth-card-bar-wrap {
margin-top: 8px;
height: 3px;
background: var(--bg);
border-radius: 2px;
overflow: hidden;
}
.auth-card-bar {
height: 100%;
background: var(--accent);
width: 100%;
transition: width 0.8s linear, background 0.2s;
}
.auth-card-bar.is-warning { background: #f59e0b; }
/* ---- 10. SLIDE-OVER -------------------------------------- */
.slideover {
@@ -1276,3 +1412,276 @@ input[type="range"]::-webkit-slider-thumb {
.content { padding: 16px; }
.slideover { width: 100%; }
}
/* ---- 17. AUTOFILL PICKER -------------------------------- */
.autofill-pick-btn {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 10px 16px;
background: none;
border: none;
text-align: left;
cursor: pointer;
transition: background var(--t-fast);
}
.autofill-pick-btn:hover { background: var(--bg-elev-2); }
.autofill-pick-site {
font-size: 13px;
font-weight: 500;
color: var(--text);
}
.autofill-pick-user {
font-size: 11px;
color: var(--text-dim);
}
/* ---- Hotkey capture buttons (Settings → Autofill) ---- */
.hotkey-capture-btn {
font-family: ui-monospace, "Cascadia Code", Menlo, monospace;
font-size: 11px;
min-width: 140px;
text-align: center;
}
.hotkey-capture-btn.is-capturing {
background: var(--accent);
color: var(--accent-fg, #fff);
animation: pulse-capture 1.2s ease-in-out infinite;
}
@keyframes pulse-capture {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
/* ---- Card display title + subtitle (when entry.title set) ---- */
.entry-subtitle {
display: block;
font-size: 11px;
color: var(--text-faint);
margin-top: 1px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---- Sidebar "(no folder)" pseudo-entry ---- */
.nav-item.is-uncategorized {
color: var(--text-faint);
font-style: italic;
}
.nav-item.is-uncategorized:hover { color: var(--text-dim); }
.nav-item.is-uncategorized.is-active { color: var(--text); font-style: normal; }
/* Spacer occupies the same footprint as the SVG folder icon so labels
stay vertically aligned across real-folder rows and "(no folder)". */
.nav-icon-spacer {
display: inline-block;
width: 16px;
height: 16px;
flex-shrink: 0;
}
/* ============================================================
Table view
============================================================ */
.entry-grid.is-table { display: block; padding: 0; }
.entry-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.entry-table thead th {
text-align: left;
font-weight: 500;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-faint);
padding: 10px 12px;
border-bottom: 1px solid var(--border);
background: var(--bg-elev);
user-select: none;
position: sticky; top: 0;
z-index: 1;
}
.entry-table th.is-sortable { cursor: pointer; }
.entry-table th.is-sortable:hover { color: var(--text-dim); }
.entry-table th.is-active { color: var(--accent); }
.entry-table th .sort-arrow {
margin-left: 4px;
font-size: 10px;
vertical-align: middle;
}
.entry-table tbody td {
padding: 8px 12px;
border-bottom: 1px solid var(--border-soft);
color: var(--text);
vertical-align: middle;
}
.entry-row { cursor: pointer; transition: background var(--t-fast); }
.entry-row:hover { background: var(--bg-elev); }
.entry-row.is-selected { background: var(--accent-soft); }
.entry-row.is-checked { background: var(--accent-soft); box-shadow: inset 3px 0 0 var(--accent); }
.col-check { width: 36px; }
.col-name { min-width: 180px; }
.col-name .entry-avatar-sm {
width: 22px; height: 22px;
display: inline-grid; place-items: center;
font-size: 10px;
margin-right: 8px;
vertical-align: middle;
}
.col-name .cell-name-wrap { display: inline-flex; align-items: center; gap: 6px; }
.col-name .fav-dot { color: var(--warning); font-size: 12px; }
.col-site, .col-user { color: var(--text-dim); }
.col-site { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.col-user { max-width: 200px; }
.col-user .icon-btn { margin-left: 4px; opacity: 0; transition: opacity var(--t-fast); }
.entry-row:hover .col-user .icon-btn { opacity: 0.7; }
.col-folder { color: var(--text-dim); white-space: nowrap; }
.col-updated { color: var(--text-faint); white-space: nowrap; font-variant-numeric: tabular-nums; }
.col-actions { width: 80px; text-align: right; white-space: nowrap; }
.col-actions .icon-btn { opacity: 0; transition: opacity var(--t-fast); }
.entry-row:hover .col-actions .icon-btn { opacity: 0.7; }
.col-actions .entry-kebab-wrap { display: inline-block; vertical-align: middle; }
/* Hide Edge/IE native password reveal eye (we ship our own toggle). */
input[type="password"]::-ms-reveal,
input[type="password"]::-ms-clear {
display: none !important;
width: 0;
height: 0;
}
/* Login screen pw toggle — only one action button, override the
2-buttons layout from .input-with-action. */
.input-with-action:has(> .icon-btn:only-of-type) input {
padding-right: 40px;
}
.input-with-action:has(> .icon-btn:only-of-type) .icon-btn:only-of-type {
right: 4px;
}
/* Custom right-click menu (replaces native Edge contextmenu in inputs). */
.custom-ctxmenu {
position: fixed;
z-index: 9999;
min-width: 160px;
padding: 4px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
}
.custom-ctxmenu-item {
display: block;
width: 100%;
text-align: left;
padding: 7px 12px;
font-size: 13px;
background: transparent;
border: none;
border-radius: 4px;
color: var(--text);
cursor: pointer;
}
.custom-ctxmenu-item:hover:not(.is-disabled) { background: var(--bg-elev-2); }
.custom-ctxmenu-item.is-disabled { color: var(--text-faint); cursor: default; }
/* Folder delete X button — visible on row hover only. */
.nav-item .folder-delete {
margin-left: auto;
width: 20px; height: 20px;
padding: 0;
line-height: 0;
display: inline-flex; align-items: center; justify-content: center;
background: transparent; border: none;
color: var(--text-faint);
border-radius: 4px;
opacity: 0;
transition: opacity var(--t-fast), color var(--t-fast), background var(--t-fast);
cursor: pointer;
}
.nav-item .folder-delete svg { width: 14px; height: 14px; display: block; }
.nav-item:hover .folder-delete { opacity: 0.7; }
.nav-item .folder-delete:hover {
opacity: 1;
color: var(--danger);
background: var(--danger-soft);
}
.nav-item:hover .nav-count { display: none; }
/* Remember username checkbox row on auth screen. */
.remember-row {
display: flex; align-items: center; gap: 8px;
margin: -4px 0 12px;
font-size: 12px;
color: var(--text-dim);
cursor: pointer;
user-select: none;
}
.remember-row input[type="checkbox"] {
width: 14px; height: 14px;
accent-color: var(--accent);
cursor: pointer;
}
/* Just-added entry feedback — pulse background fades out over 2.5s. */
@keyframes entryFlash {
0% { background: var(--accent-soft); box-shadow: 0 0 0 2px var(--accent), 0 8px 24px rgba(6,182,212,0.3); }
40% { background: var(--accent-soft); box-shadow: 0 0 0 2px var(--accent), 0 8px 24px rgba(6,182,212,0.2); }
100% { background: var(--bg-elev); box-shadow: 0 0 0 0 transparent; }
}
.entry-card.is-flash { animation: entryFlash 2.5s ease-out; }
.entry-row.is-flash { animation: entryFlash 2.5s ease-out; }
/* Pagination footer (list + table views). */
.pagination {
display: flex; align-items: center; gap: 6px;
padding: 14px 12px 4px;
flex-wrap: wrap;
font-size: 13px;
color: var(--text-dim);
grid-column: 1 / -1;
}
.pagination-info {
margin-right: auto;
color: var(--text-faint);
font-size: 12px;
}
.pagination-btn {
min-width: 32px;
padding: 5px 9px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-dim);
cursor: pointer;
transition: all var(--t-fast);
}
.pagination-btn:hover:not(.is-active):not(.is-disabled) {
border-color: var(--accent);
color: var(--text);
}
.pagination-btn.is-active {
background: var(--accent);
border-color: var(--accent);
color: white;
cursor: default;
}
.pagination-btn.is-disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pagination-ellipsis { padding: 0 4px; color: var(--text-faint); }
.pagination-size {
margin-left: 8px;
padding: 5px 8px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-dim);
font-size: 12px;
cursor: pointer;
}
@@ -0,0 +1,65 @@
unit PM.Handler.Audit;
(*
POST /audit body {action, site} -> {ok:true}
Light-weight endpoint that lets the JS layer append an entry to audit_log
without going through the full entries pipeline. Used by the autofill
feature to record which site was filled (action = "autofill:<site>").
The bearer token identifies the user — no data beyond the action string
is stored.
*)
interface
implementation
uses
System.SysUtils, System.JSON,
IdCustomHTTPServer,
PM.Router, PM.JSON, PM.Session, PM.Audit;
function GetClientIP(ARequest: TIdHTTPRequestInfo): string;
begin
Result := ARequest.RemoteIP;
if Result = '' then Result := '127.0.0.1';
end;
procedure HandlePostAudit(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var
LUserId: Integer;
LBody: TJSONObject;
LAction, LSite: string;
begin
LUserId := Authenticate(ARequest, AResponse);
RequireCSRF(ARequest, AResponse, LUserId);
LBody := TJSONHelper.ReadBody(ARequest);
try
LAction := LBody.GetValue<string>('action', '');
LSite := LBody.GetValue<string>('site', '');
finally
LBody.Free;
end;
if LAction = '' then
begin
TJSONHelper.SendError(AResponse, 400, 'action required');
Exit;
end;
// Keep the log compact: "autofill:github.com" rather than repeating
// structured columns we don't have in the current schema.
if LSite <> '' then
LAction := LAction + ':' + LSite;
LogAudit(LUserId, LAction, GetClientIP(ARequest));
TJSONHelper.SendOK(AResponse);
end;
initialization
Router.Register('POST', '/audit', HandlePostAudit);
end.
+18 -5
View File
@@ -19,9 +19,9 @@ interface
implementation
uses
System.SysUtils, System.JSON, System.Classes,
FireDAC.Comp.Client,
IdCustomHTTPServer,Data.DB,
System.SysUtils, System.JSON, System.Classes, System.Generics.Collections,
Data.DB, FireDAC.Comp.Client, FireDAC.Stan.Param,
IdCustomHTTPServer,
PM.Router, PM.JSON, PM.Database, PM.Crypto,
PM.Session, PM.RateLimit, PM.Audit;
@@ -961,8 +961,21 @@ begin
DB.Unlock;
end;
// Step 5: invalidate every other session for this user. The CURRENT
// session token is still valid — caller stays logged in.
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text := 'DELETE FROM recovery_keys WHERE user_id = :uid';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ExecSQL;
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
DeleteAllUserSessions(LUserId);
finally
LBody.Free;
+64 -8
View File
@@ -16,6 +16,7 @@ implementation
uses
System.SysUtils, System.JSON, System.StrUtils, System.NetEncoding,
System.Generics.Collections,
Data.DB, FireDAC.Comp.Client, FireDAC.Stan.Param,
IdCustomHTTPServer, IdGlobalProtocols, IdURI,
PM.Router, PM.JSON, PM.Database, PM.Session, PM.Audit, PM.RateLimit;
@@ -91,6 +92,7 @@ begin
LObj := TJSONObject.Create;
LObj.AddPair('id', TJSONNumber.Create(LQ.FieldByName('id').AsInteger));
LObj.AddPair('site', LQ.FieldByName('site').AsString);
LObj.AddPair('title', LQ.FieldByName('title').AsString);
LObj.AddPair('username', LQ.FieldByName('username').AsString);
LObj.AddPair('encrypted_password', LQ.FieldByName('encrypted_password').AsString);
LObj.AddPair('iv', LQ.FieldByName('iv').AsString);
@@ -135,7 +137,7 @@ procedure HandleCreateEntry(ARequest: TIdHTTPRequestInfo;
var
LUserId, LNewId: Integer;
LBody, LObj: TJSONObject;
LSite, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv: string;
LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv: string;
LQ: TFDQuery;
begin
try
@@ -148,6 +150,7 @@ begin
LBody := TJSONHelper.ReadBody(ARequest);
try
LSite := Trim(LBody.GetValue<string>('site', ''));
LTitle := Trim(LBody.GetValue<string>('title', ''));
LUser := Trim(LBody.GetValue<string>('username', ''));
LFolder := Trim(LBody.GetValue<string>('folder', 'All'));
LEnc := LBody.GetValue<string>('encrypted_password', '');
@@ -175,11 +178,12 @@ begin
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'INSERT INTO vault_entries ' +
'(user_id, site, username, encrypted_password, iv, encryption_method, ' +
'(user_id, site, title, username, encrypted_password, iv, encryption_method, ' +
' folder, tags, totp_secret, totp_iv, created_at, updated_at) ' +
'VALUES (:uid, :s, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :c, :c2)';
'VALUES (:uid, :s, :tt, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :c, :c2)';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('s').AsString := LSite;
LQ.ParamByName('tt').AsString := LTitle;
LQ.ParamByName('u').AsString := LUser;
LQ.ParamByName('e').AsString := LEnc;
LQ.ParamByName('i').AsString := LIV;
@@ -216,6 +220,7 @@ begin
LObj := TJSONObject.Create;
LObj.AddPair('id', TJSONNumber.Create(LNewId));
LObj.AddPair('site', LSite);
LObj.AddPair('title', LTitle);
LObj.AddPair('username', LUser);
LObj.AddPair('folder', LFolder);
LObj.AddPair('tags', LTags);
@@ -229,7 +234,7 @@ procedure HandleUpdateEntry(ARequest: TIdHTTPRequestInfo;
var
LUserId, LId: Integer;
LBody: TJSONObject;
LSite, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv: string;
LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv: string;
LQ: TFDQuery;
begin
try
@@ -249,6 +254,7 @@ begin
LBody := TJSONHelper.ReadBody(ARequest);
try
LSite := Trim(LBody.GetValue<string>('site', ''));
LTitle := Trim(LBody.GetValue<string>('title', ''));
LUser := Trim(LBody.GetValue<string>('username', ''));
LFolder := Trim(LBody.GetValue<string>('folder', 'All'));
LEnc := LBody.GetValue<string>('encrypted_password', '');
@@ -274,11 +280,12 @@ begin
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'UPDATE vault_entries ' +
'SET site=:s, username=:u, encrypted_password=:e, iv=:i, ' +
'SET site=:s, title=:tt, username=:u, encrypted_password=:e, iv=:i, ' +
' folder=:f, tags=:t, totp_secret=:ts, totp_iv=:tiv, ' +
' updated_at=:c ' +
'WHERE id=:id AND user_id=:uid';
LQ.ParamByName('s').AsString := LSite;
LQ.ParamByName('tt').AsString := LTitle;
LQ.ParamByName('u').AsString := LUser;
LQ.ParamByName('e').AsString := LEnc;
LQ.ParamByName('i').AsString := LIV;
@@ -501,7 +508,7 @@ var
LUserId, I, LImported: Integer;
LBody, LObj, LEntry: TJSONObject;
LArr: TJSONArray;
LSite, LUser, LFolder, LEnc, LIV, LTags, LTotpSec, LTotpIv, LNow: string;
LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LTotpSec, LTotpIv, LNow: string;
LQ: TFDQuery;
begin
try
@@ -540,9 +547,9 @@ begin
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'INSERT INTO vault_entries ' +
'(user_id, site, username, encrypted_password, iv, encryption_method, ' +
'(user_id, site, title, username, encrypted_password, iv, encryption_method, ' +
' folder, tags, totp_secret, totp_iv, created_at, updated_at) ' +
'VALUES (:uid, :s, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :c, :c2)';
'VALUES (:uid, :s, :tt, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :c, :c2)';
// Declare optional TOTP param types ONCE — the prepared statement
// is reused across every imported entry, and FireDAC needs the
// type set before the first .Clear call would otherwise fail
@@ -554,6 +561,7 @@ begin
begin
LEntry := LArr.Items[I] as TJSONObject;
LSite := Trim(LEntry.GetValue<string>('site', ''));
LTitle := Trim(LEntry.GetValue<string>('title', ''));
LUser := Trim(LEntry.GetValue<string>('username', ''));
LFolder := Trim(LEntry.GetValue<string>('folder', 'All'));
LEnc := LEntry.GetValue<string>('encrypted_password', '');
@@ -569,6 +577,7 @@ begin
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('s').AsString := LSite;
LQ.ParamByName('tt').AsString := LTitle;
LQ.ParamByName('u').AsString := LUser;
LQ.ParamByName('e').AsString := LEnc;
LQ.ParamByName('i').AsString := LIV;
@@ -604,6 +613,52 @@ begin
TJSONHelper.SendJSON(AResponse, LObj);
end;
procedure HandleEntriesCount(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var
LUserId, LActive, LTrashed: Integer;
LQ: TFDQuery;
LObj: TJSONObject;
begin
try
LUserId := Authenticate(ARequest, AResponse);
except
on ESessionRejected do Exit;
end;
LActive := 0;
LTrashed := 0;
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'SELECT deleted, COUNT(*) AS cnt FROM vault_entries ' +
'WHERE user_id = :uid GROUP BY deleted';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.Open;
while not LQ.Eof do
begin
if LQ.FieldByName('deleted').AsInteger = 0 then
LActive := LQ.FieldByName('cnt').AsInteger
else
LTrashed := LQ.FieldByName('cnt').AsInteger;
LQ.Next;
end;
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
LObj := TJSONObject.Create;
LObj.AddPair('active', TJSONNumber.Create(LActive));
LObj.AddPair('trashed', TJSONNumber.Create(LTrashed));
TJSONHelper.SendJSON(AResponse, LObj);
end;
initialization
// /entries/trash/empty must be registered BEFORE /entries/{id} to win the regex match.
// Same logic for /entries/bulk-import — register before the catch-all /entries/{id}.
@@ -611,6 +666,7 @@ initialization
Router.Register('POST', '/entries/bulk-import', HandleBulkImport);
Router.Register('POST', '/entries/(\d+)/restore', HandleRestoreEntry);
Router.Register('POST', '/entries/(\d+)/favorite', HandleToggleFavorite);
Router.Register('GET', '/entries/count', HandleEntriesCount);
Router.Register('GET', '/entries', HandleGetEntries);
Router.Register('POST', '/entries', HandleCreateEntry);
Router.Register('PUT', '/entries/(\d+)', HandleUpdateEntry);
+33 -19
View File
@@ -33,7 +33,7 @@ implementation
uses
System.SysUtils, System.JSON,
FireDAC.Comp.Client, FireDAC.Stan.Param,
Data.DB, FireDAC.Comp.Client, FireDAC.Stan.Param,
IdCustomHTTPServer,
PM.Router, PM.JSON, PM.Database, PM.Crypto, PM.Session, PM.Audit, PM.RateLimit;
@@ -122,19 +122,21 @@ begin
LConfigured := False;
LCreatedAt := '';
var RemainingUses: Integer := 0;
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'SELECT created_at FROM recovery_keys WHERE user_id = :uid';
'SELECT created_at, remaining_uses FROM recovery_keys WHERE user_id = :uid';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.Open;
if not LQ.IsEmpty then
begin
LConfigured := True;
LCreatedAt := LQ.FieldByName('created_at').AsString;
RemainingUses := LQ.FieldByName('remaining_uses').AsInteger;
end;
finally
LQ.Free;
@@ -146,6 +148,7 @@ begin
LObj := TJSONObject.Create;
LObj.AddPair('configured', TJSONBool.Create(LConfigured));
if LConfigured then LObj.AddPair('created_at', LCreatedAt);
if LConfigured then LObj.AddPair('remaining_uses', TJSONNumber.Create(RemainingUses));
TJSONHelper.SendJSON(AResponse, LObj);
end;
@@ -208,8 +211,8 @@ begin
LQ.SQL.Text :=
'INSERT INTO recovery_keys ' +
' (user_id, code_hash, kdf_salt, wrapped_key, wrapped_iv) ' +
'VALUES (:uid, :ch, :ks, :wk, :wi)';
' (user_id, code_hash, kdf_salt, wrapped_key, wrapped_iv, remaining_uses) ' +
'VALUES (:uid, :ch, :ks, :wk, :wi, 5)';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('ch').AsString := LCodeHash;
LQ.ParamByName('ks').AsString := LKdfSalt;
@@ -271,7 +274,7 @@ var
LBody, LObj: TJSONObject;
LUser, LCode, LCodeHash, LIP, LStoredHash, LKdfSalt, LWrappedKey, LWrappedIv,
LSalt, LToken, LCSRF: string;
LUserId, LKdfIters: Integer;
LUserId, LKdfIters, LCurrentUses, LNewUses: Integer;
LQ: TFDQuery;
begin
LIP := GetClientIP(ARequest);
@@ -307,7 +310,7 @@ begin
// Join to users to look up by username + verify the code in one shot.
LQ.SQL.Text :=
'SELECT u.id, u.salt, u.kdf_iterations, ' +
' rk.code_hash, rk.kdf_salt, rk.wrapped_key, rk.wrapped_iv ' +
' rk.code_hash, rk.kdf_salt, rk.wrapped_key, rk.wrapped_iv, rk.remaining_uses ' +
'FROM users u ' +
'LEFT JOIN recovery_keys rk ON rk.user_id = u.id ' +
'WHERE u.username = :u';
@@ -315,20 +318,19 @@ begin
LQ.Open;
if LQ.IsEmpty then
begin
// User doesn't exist OR has no recovery key configured. Same error
// either way to avoid leaking which.
RecordAttempt(LIP);
RecordFailedAccountAttempt(LUser, LIP);
TJSONHelper.SendError(AResponse, 401, 'Invalid username or recovery code');
Exit;
end;
LUserId := LQ.FieldByName('id').AsInteger;
LSalt := LQ.FieldByName('salt').AsString;
LKdfIters := LQ.FieldByName('kdf_iterations').AsInteger;
LStoredHash := LQ.FieldByName('code_hash').AsString;
LKdfSalt := LQ.FieldByName('kdf_salt').AsString;
LWrappedKey := LQ.FieldByName('wrapped_key').AsString;
LWrappedIv := LQ.FieldByName('wrapped_iv').AsString;
LUserId := LQ.FieldByName('id').AsInteger;
LSalt := LQ.FieldByName('salt').AsString;
LKdfIters := LQ.FieldByName('kdf_iterations').AsInteger;
LStoredHash := LQ.FieldByName('code_hash').AsString;
LKdfSalt := LQ.FieldByName('kdf_salt').AsString;
LWrappedKey := LQ.FieldByName('wrapped_key').AsString;
LWrappedIv := LQ.FieldByName('wrapped_iv').AsString;
LCurrentUses := LQ.FieldByName('remaining_uses').AsInteger;
finally
LQ.Free;
end;
@@ -361,13 +363,24 @@ begin
Exit;
end;
// Code matches. Consume (delete the row) inside the same lock so the
// single-use guarantee holds even under concurrent requests.
// Code matches. Decrement remaining_uses ; if it drops to 0, delete
// the row (last use). The row is also deleted when the user
// successfully changes their master password (in PM.Handler.Auth).
LNewUses := LCurrentUses - 1;
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text := 'DELETE FROM recovery_keys WHERE user_id = :uid';
LQ.ParamByName('uid').AsInteger := LUserId;
if LNewUses <= 0 then
begin
LQ.SQL.Text := 'DELETE FROM recovery_keys WHERE user_id = :uid';
LQ.ParamByName('uid').AsInteger := LUserId;
end
else
begin
LQ.SQL.Text := 'UPDATE recovery_keys SET remaining_uses = :u WHERE user_id = :uid';
LQ.ParamByName('u').AsInteger := LNewUses;
LQ.ParamByName('uid').AsInteger := LUserId;
end;
LQ.ExecSQL;
finally
LQ.Free;
@@ -391,6 +404,7 @@ begin
LObj.AddPair('wrappedKey', LWrappedKey);
LObj.AddPair('wrappedIv', LWrappedIv);
LObj.AddPair('kdfSalt', LKdfSalt);
LObj.AddPair('remainingUses', TJSONNumber.Create(LNewUses));
TJSONHelper.SendJSON(AResponse, LObj);
end;
@@ -0,0 +1,113 @@
unit PM.Handler.Settings;
(*
GET /settings -> {<arbitrary JSON object stored as-is>}
PUT /settings body: {<arbitrary JSON object>} -> {message:"OK"}
Persists a per-user preferences blob (users.settings_json). The server
treats the body as opaque JSON — schema lives in the JS layer. Any client
reading it should tolerate unknown keys for forward compatibility.
Device-specific toggles (quick-unlock DPAPI, autofill hotkey) deliberately
stay in localStorage on the client and are NOT included in this blob.
*)
interface
implementation
uses
System.SysUtils, System.JSON, System.Classes,
Data.DB, FireDAC.Comp.Client, FireDAC.Stan.Param,
IdCustomHTTPServer,
PM.Router, PM.JSON, PM.Session, PM.Database;
procedure HandleGetSettings(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var
LUserId: Integer;
LQ: TFDQuery;
LRaw: string;
LObj: TJSONValue;
begin
LUserId := Authenticate(ARequest, AResponse);
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text := 'SELECT settings_json FROM users WHERE id = :uid';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.Open;
if LQ.IsEmpty then
LRaw := '{}'
else
LRaw := LQ.FieldByName('settings_json').AsString;
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
if Trim(LRaw) = '' then LRaw := '{}';
// Validate so a corrupt row doesn't return malformed JSON to the client.
LObj := TJSONObject.ParseJSONValue(LRaw);
if LObj = nil then LObj := TJSONObject.Create;
TJSONHelper.SendJSON(AResponse, LObj); // SendJSON frees the object
end;
procedure HandlePutSettings(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var
LUserId: Integer;
LBody: TJSONObject;
LSerialized: string;
LQ: TFDQuery;
begin
LUserId := Authenticate(ARequest, AResponse);
RequireCSRF(ARequest, AResponse, LUserId);
LBody := TJSONHelper.ReadBody(ARequest);
try
// Re-serialize to a canonical compact form (strips comments / extra
// whitespace, and guarantees what we store is valid JSON).
LSerialized := LBody.ToJSON;
finally
LBody.Free;
end;
// Soft cap to protect the row from a runaway client (typical settings
// blob is a few hundred bytes; 16 KB leaves room for future flags).
if Length(LSerialized) > 16384 then
begin
TJSONHelper.SendError(AResponse, 413, 'Settings payload too large');
Exit;
end;
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text := 'UPDATE users SET settings_json = :s WHERE id = :uid';
LQ.ParamByName('s').AsString := LSerialized;
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ExecSQL;
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
TJSONHelper.SendOK(AResponse);
end;
initialization
Router.Register('GET', '/settings', HandleGetSettings);
Router.Register('PUT', '/settings', HandlePutSettings);
end.
+12 -1
View File
@@ -3,6 +3,7 @@ program PMServer;
uses
System.StartUpCopy,
FMX.Forms,
PM.SingleInstance in 'Source\PM.SingleInstance.pas',
UMainForm in 'UMainForm.pas' {MainForm},
PM.JSON in 'Source\PM.JSON.pas',
PM.Database in 'Source\PM.Database.pas',
@@ -16,17 +17,27 @@ uses
PM.HTTPServer in 'Source\PM.HTTPServer.pas',
PM.Bridge in 'Source\PM.Bridge.pas',
PM.QuickUnlock in 'Source\PM.QuickUnlock.pas',
PM.UserPrefs in 'Source\PM.UserPrefs.pas',
PM.ProcessLockdown in 'Source\PM.ProcessLockdown.pas',
PM.Handler.Ping in 'Handlers\PM.Handler.Ping.pas',
PM.Handler.Auth in 'Handlers\PM.Handler.Auth.pas',
PM.Handler.Folders in 'Handlers\PM.Handler.Folders.pas',
PM.Handler.Entries in 'Handlers\PM.Handler.Entries.pas',
PM.Handler.Passkey in 'Handlers\PM.Handler.Passkey.pas',
PM.Handler.Recovery in 'Handlers\PM.Handler.Recovery.pas';
PM.Handler.Recovery in 'Handlers\PM.Handler.Recovery.pas',
PM.Handler.Audit in 'Handlers\PM.Handler.Audit.pas',
PM.Handler.Settings in 'Handlers\PM.Handler.Settings.pas';
{$R *.res}
{$R assets\assets.res}
begin
// Single-instance: if another PMServer is running, bring it to front
// (it'll handle WM_PMSHOW on its message-only window) and exit. Avoids
// two icons in the tray and two HTTP servers fighting for the port.
if not PM.SingleInstance.AcquireOrSignal then
Exit;
Application.Initialize;
Application.CreateForm(TMainForm, MainForm);
Application.Run;
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

+519 -14
View File
@@ -31,7 +31,8 @@ interface
uses
System.SysUtils, System.Classes, System.Math,
FMX.Types, FMX.Forms,
Winapi.Windows, Winapi.ShellAPI, Winapi.Messages;
Winapi.Windows, Winapi.ShellAPI, Winapi.Messages,
PM.SingleInstance;
type
// -------------------------------------------------------------------------
@@ -44,12 +45,29 @@ type
public
constructor Create;
destructor Destroy; override;
function ReadText: string;
// Copy AText to the clipboard, excluding it from Win+V history.
// AClearAfterMs = 0 disables auto-clear; default is 30 seconds.
procedure SetText(const AText: string; AClearAfterMs: Integer = 30000);
procedure Clear;
end;
// Distinguishes the "fill everything" hotkey (Ctrl+Shift+L) from the
// "password only" hotkey (Ctrl+Shift+P). The host decides what to type
// based on this kind.
TAutofillKind = (akFull, akPasswordOnly);
// Fired when an autofill hotkey is pressed. Args are the foreground
// window HWND and its title (captured before any focus change), plus
// the kind of fill requested.
// Declared as a method pointer (not TProc<>) because Delphi has no implicit
// conversion from "procedure of object" to "reference to procedure" — the
// host wires this with a regular form method (BridgeAutofillRequest).
TAutofillRequestEvent = procedure(AKind: TAutofillKind;
ATargetHWND: HWND; const ATitle: string) of object;
TNewEntryHotkeyEvent = procedure(const AWindowTitle: string) of object;
// -------------------------------------------------------------------------
// TPMBridge
// -------------------------------------------------------------------------
@@ -64,10 +82,28 @@ type
FPowerNotify: THandle; // registration handle from PowerRegisterSuspendResumeNotification
FSecureClipboard: TSecureClipboard;
FBalloonShown: Boolean;
// Window placement captured at MinimizeToTray time. Replayed on
// RestoreFromTray so the window comes back in the same state
// (maximised / normal + position + size) as before hiding.
FSavedPlacement: TWindowPlacement;
FHasSavedPlacement: Boolean;
FOnSystemLock: TProc;
FOnTrayRestore: TProc;
FOnLockRequest: TProc;
FOnQuit: TProc;
// Autofill: global hotkeys → inject credentials into browser. Combos
// are user-configurable from Settings; defaults are Ctrl+Shift+L /
// Ctrl+Shift+P. We track which IDs are actually live so unregister
// doesn't blindly call UnregisterHotKey on unregistered IDs (which
// would set GetLastError noise during shutdown).
FAutofillRegistered: Boolean;
FAutofillFullActive: Boolean;
FAutofillPwdActive: Boolean;
FOnAutofillRequest: TAutofillRequestEvent;
FDebugHotkeyRegistered: Boolean;
FOnDebugHotkey: TProc;
FNewEntryHotkeyRegistered: Boolean;
FOnNewEntryHotkey: TNewEntryHotkeyEvent;
procedure MsgWindowHandler(var AMsg: TMessage);
procedure PrepareNid;
procedure ShowTrayMenu;
@@ -80,8 +116,35 @@ type
procedure MinimizeToTray;
// Restore main window and remove tray icon.
procedure RestoreFromTray;
// Apply Windows dark-mode title bar to the main form. Win10 19044+
// / Win11 only — no-op on older builds. Safe to call repeatedly.
procedure ApplyTitleBarTheme(ADark: Boolean);
// Register the two autofill global hotkeys (full + password-only) with
// the given Win32 modifier flags (MOD_CONTROL/MOD_SHIFT/MOD_ALT/MOD_WIN
// bitmask) and virtual-key codes. Replaces any prior registration —
// safe to call repeatedly to swap combos at runtime.
// Returns True if both hotkeys registered successfully. If one or both
// failed (clash with another app), best-effort: whichever succeeded
// stays active.
function SetAutofillHotkeys(AFullMods, AFullVk,
APwdMods, APwdVk: Word): Boolean;
// Convenience wrapper: register the historical defaults (Ctrl+Shift+L
// and Ctrl+Shift+P). Used by the host on first start; runtime changes
// go through SetAutofillHotkeys.
procedure RegisterAutofillHotkey;
// Unregister both autofill hotkeys.
procedure UnregisterAutofillHotkey;
// Simulate username + Tab + password keystrokes into ATargetHWND.
// ATargetHWND = 0 → type into whatever window has focus.
// If AUsername is empty, only the password is typed (no Tab) — matches
// the password-only hotkey path AND avoids spurious Tab on entries
// without a stored username.
procedure ExecuteAutofill(ATargetHWND: HWND;
const AUsername, APassword: string);
property SecureClipboard: TSecureClipboard read FSecureClipboard;
property TrayAdded: Boolean read FTrayAdded;
property AutofillRegistered: Boolean read FAutofillRegistered;
// Fired on main thread when Windows locks the session (WTS_SESSION_LOCK).
property OnSystemLock: TProc read FOnSystemLock write FOnSystemLock;
// Fired on main thread when the user clicks the tray icon.
@@ -94,6 +157,17 @@ type
// bridge does not call it itself, so the host stays in control of
// shutdown order (server stop, save state, etc.).
property OnQuit: TProc read FOnQuit write FOnQuit;
// Fired on main thread when the autofill hotkey fires.
// Args: (ATargetHWND, AWindowTitle). Handler calls ExecuteJavaScript
// to let JS match the title against vault entries.
property OnAutofillRequest: TAutofillRequestEvent
read FOnAutofillRequest write FOnAutofillRequest;
property OnDebugHotkey: TProc
read FOnDebugHotkey write FOnDebugHotkey;
// Fires on Ctrl+Shift+A. Arg = foreground window title (stripped of
// browser suffix by the JS layer before pre-fill).
property OnNewEntryHotkey: TNewEntryHotkeyEvent
read FOnNewEntryHotkey write FOnNewEntryHotkey;
end;
implementation
@@ -130,6 +204,17 @@ const
PBT_APMRESUMEAUTOMATIC = $0012;
PBT_APMRESUMESUSPEND = $0007;
// Autofill hotkeys — Ctrl+Shift+L (full) and Ctrl+Shift+P (password only).
// IDs must not clash with other RegisterHotKey calls in this process;
// 42-43 are arbitrary and well outside the range used by FMX internals.
const
AUTOFILL_HOTKEY_ID_FULL = 42; // Ctrl+Shift+L → user + Tab + password
AUTOFILL_HOTKEY_ID_PWDONLY = 43; // Ctrl+Shift+P → password only
DEBUG_HOTKEY_ID = 44; // Ctrl+Shift+D → toggle debug panel
NEW_ENTRY_HOTKEY_ID = 45; // Ctrl+Shift+A → quick-add from window title
AF_MOD_CONTROL = $0002; // same value as MOD_CONTROL
AF_MOD_SHIFT = $0004; // same value as MOD_SHIFT
// Dynamic WTS function pointers — wtsapi32.dll is not guaranteed on all
// Windows SKUs (e.g. minimal Server Core without Session Services), so
// we load it at runtime and tolerate absence gracefully.
@@ -256,6 +341,28 @@ begin
end;
end;
function TSecureClipboard.ReadText: string;
var
H: THandle;
P: PChar;
begin
Result := '';
if not OpenClipboard(0) then Exit;
try
H := GetClipboardData(CF_UNICODETEXT);
if H = 0 then Exit;
P := PChar(GlobalLock(H));
if P <> nil then
try
Result := P;
finally
GlobalUnlock(H);
end;
finally
CloseClipboard;
end;
end;
// =============================================================================
// TPMBridge
// =============================================================================
@@ -273,6 +380,14 @@ begin
PrepareNid;
// Add the tray icon eagerly so it's visible from app startup, regardless
// of whether the window is shown or hidden. Without this, the tray icon
// only appears the first time the user minimizes — meaning fresh-launch
// users can't lock/quit from the tray and discover the feature only by
// accident. NIM_DELETE is now only called at shutdown.
if Shell_NotifyIcon(NIM_ADD, @FNid) then
FTrayAdded := True;
// Session-lock detection (fails silently if wtsapi32.dll is absent).
LoadWtsApi;
if Assigned(_WTSRegister) then
@@ -286,10 +401,21 @@ begin
LoadPowerApi;
if Assigned(_PowerRegister) then
_PowerRegister(DEVICE_NOTIFY_WINDOW_HANDLE, FMsgWindow, FPowerNotify);
FDebugHotkeyRegistered := RegisterHotKey(FMsgWindow, DEBUG_HOTKEY_ID,
AF_MOD_CONTROL or AF_MOD_SHIFT, Ord('D'));
FNewEntryHotkeyRegistered := RegisterHotKey(FMsgWindow, NEW_ENTRY_HOTKEY_ID,
AF_MOD_CONTROL or AF_MOD_SHIFT, Ord('A'));
end;
destructor TPMBridge.Destroy;
begin
if FDebugHotkeyRegistered then
UnregisterHotKey(FMsgWindow, DEBUG_HOTKEY_ID);
if FNewEntryHotkeyRegistered then
UnregisterHotKey(FMsgWindow, NEW_ENTRY_HOTKEY_ID);
if (FPowerNotify <> 0) and Assigned(_PowerUnregister) then
_PowerUnregister(FPowerNotify);
@@ -379,11 +505,8 @@ procedure TPMBridge.MinimizeToTray;
var
LFormHwnd, LAppHwnd: HWND;
begin
if not FTrayAdded then
begin
if Shell_NotifyIcon(NIM_ADD, @FNid) then
FTrayAdded := True;
end;
// Tray icon is added at construction time and persists for the app's
// lifetime — no NIM_ADD here.
// Extra safety: clear the clipboard immediately when the user minimizes,
// rather than waiting for the 30s auto-clear timer to fire. A password
@@ -394,6 +517,15 @@ begin
LFormHwnd := MainFormHWND(FMainForm);
LAppHwnd := FindFMXAppWindow;
// 0. Snapshot the window placement BEFORE hiding so RestoreFromTray can
// replay the exact same state (maximised / normal + size + position).
// Without this, ShowWindow(SW_RESTORE) below always returns to the
// "normal" state — a window that was maximised before hiding comes
// back un-maximised.
FillChar(FSavedPlacement, SizeOf(FSavedPlacement), 0);
FSavedPlacement.length := SizeOf(FSavedPlacement);
FHasSavedPlacement := GetWindowPlacement(LFormHwnd, @FSavedPlacement);
// 1. Hide the visible form via both FMX state and Win32 ShowWindow.
// Keeps the form invisible to the user.
FMainForm.Hide;
@@ -442,12 +574,7 @@ procedure TPMBridge.RestoreFromTray;
var
LFormHwnd, LAppHwnd: HWND;
begin
if FTrayAdded then
begin
Shell_NotifyIcon(NIM_DELETE, @FNid);
FTrayAdded := False;
end;
// Tray icon stays in the tray — we only show the window again.
LFormHwnd := MainFormHWND(FMainForm);
LAppHwnd := FindFMXAppWindow;
@@ -457,8 +584,23 @@ begin
ShowWindow(LAppHwnd, SW_SHOW);
FMainForm.Show;
ShowWindow(LFormHwnd, SW_SHOW);
ShowWindow(LFormHwnd, SW_RESTORE);
// Restore to the exact pre-tray state (maximised/normal + size + pos).
// Falls back to SW_RESTORE if we never captured a placement (e.g. tray
// restore was triggered without a prior MinimizeToTray call).
if FHasSavedPlacement then
begin
// showCmd governs whether the window comes back maximised or normal;
// it's what SW_RESTORE clobbers. We force it ourselves.
if FSavedPlacement.showCmd = SW_SHOWMINIMIZED then
FSavedPlacement.showCmd := SW_SHOWNORMAL; // never restore as minimised
SetWindowPlacement(LFormHwnd, @FSavedPlacement);
end
else
begin
ShowWindow(LFormHwnd, SW_SHOW);
ShowWindow(LFormHwnd, SW_RESTORE);
end;
SetForegroundWindow(LFormHwnd);
end;
@@ -543,9 +685,372 @@ begin
// suspends — fast handler required (no UI prompts, no network).
if AMsg.WParam = PBT_APMSUSPEND then
if Assigned(FOnSystemLock) then FOnSystemLock();
end
else if (AMsg.Msg <> 0) and (AMsg.Msg = WM_PMShowMessage) then
begin
// A second instance was launched and PostMessage'd HWND_BROADCAST.
// Bring our window back to the front instead of letting that second
// process spawn its own UI.
if Assigned(FOnTrayRestore) then FOnTrayRestore();
end
else if (AMsg.Msg = WM_HOTKEY) and (AMsg.WParam = DEBUG_HOTKEY_ID) then
begin
if Assigned(FOnDebugHotkey) then FOnDebugHotkey();
end
else if (AMsg.Msg = WM_HOTKEY) and (AMsg.WParam = NEW_ENTRY_HOTKEY_ID) then
begin
if Assigned(FOnNewEntryHotkey) then
begin
var LTarget := GetForegroundWindow;
var LTitle: string;
SetLength(LTitle, 512);
var LLen := GetWindowTextW(LTarget, PChar(LTitle), 512);
SetLength(LTitle, LLen);
FOnNewEntryHotkey(LTitle);
end;
end
else if (AMsg.Msg = WM_HOTKEY) and Assigned(FOnAutofillRequest) and
((AMsg.WParam = AUTOFILL_HOTKEY_ID_FULL) or
(AMsg.WParam = AUTOFILL_HOTKEY_ID_PWDONLY)) then
begin
// Capture the foreground window BEFORE any focus change, then fire the
// callback so the host can match the title against vault entries.
var LKind: TAutofillKind;
if AMsg.WParam = AUTOFILL_HOTKEY_ID_PWDONLY then
LKind := akPasswordOnly
else
LKind := akFull;
var LTarget := GetForegroundWindow;
var LTitle: string;
SetLength(LTitle, 512);
var LLen := GetWindowTextW(LTarget, PChar(LTitle), 512);
SetLength(LTitle, LLen);
FOnAutofillRequest(LKind, LTarget, LTitle);
end;
AMsg.Result := DefWindowProc(FMsgWindow, AMsg.Msg, AMsg.WParam, AMsg.LParam);
end;
// =============================================================================
// TPMBridge — Autofill hotkey + SendInput
// =============================================================================
function TPMBridge.SetAutofillHotkeys(AFullMods, AFullVk,
APwdMods, APwdVk: Word): Boolean;
begin
// Tear down whatever is currently registered before installing the new
// combos. RegisterHotKey would fail if the same ID is already taken.
if FAutofillFullActive then
begin
UnregisterHotKey(FMsgWindow, AUTOFILL_HOTKEY_ID_FULL);
FAutofillFullActive := False;
end;
if FAutofillPwdActive then
begin
UnregisterHotKey(FMsgWindow, AUTOFILL_HOTKEY_ID_PWDONLY);
FAutofillPwdActive := False;
end;
// Best-effort registration. A failure (typically MOD_x clash with another
// app's global hotkey) is silent: the other slot can still be live.
if (AFullVk <> 0) and (AFullMods <> 0) then
FAutofillFullActive := RegisterHotKey(FMsgWindow,
AUTOFILL_HOTKEY_ID_FULL, AFullMods, AFullVk);
if (APwdVk <> 0) and (APwdMods <> 0) then
FAutofillPwdActive := RegisterHotKey(FMsgWindow,
AUTOFILL_HOTKEY_ID_PWDONLY, APwdMods, APwdVk);
FAutofillRegistered := FAutofillFullActive or FAutofillPwdActive;
Result := FAutofillFullActive and FAutofillPwdActive;
end;
procedure TPMBridge.ApplyTitleBarTheme(ADark: Boolean);
const
DWMWA_USE_IMMERSIVE_DARK_MODE = 20;
// uxtheme.dll private API, stable since Win10 1809. File Explorer, Edge
// and Office use this to opt their UI (including popup menus, scrollbars,
// tooltips) into dark mode. Signature changed in 1903 to take an enum:
// 0=Default 1=AllowDark 2=ForceDark 3=ForceLight 4=Max
// We use ForceDark / ForceLight for unambiguous behaviour.
APPMODE_DEFAULT = 0;
APPMODE_FORCE_DARK = 2;
APPMODE_FORCE_LIGHT = 3;
type
TDwmSetWindowAttribute = function(hwnd: HWND; dwAttribute: DWORD;
pvAttribute: Pointer; cbAttribute: DWORD): HRESULT; stdcall;
TSetPreferredAppMode = function(AppMode: Integer): Integer; stdcall;
TFlushMenuThemes = procedure; stdcall;
var
DwmLib, UxLib: HMODULE;
DwmSetWindowAttribute: TDwmSetWindowAttribute;
SetPreferredAppMode: TSetPreferredAppMode;
FlushMenuThemes: TFlushMenuThemes;
DarkFlag: BOOL;
FormHwnd: HWND;
begin
if FMainForm = nil then Exit;
FormHwnd := MainFormHWND(FMainForm);
if FormHwnd = 0 then Exit;
// 1. Title bar (DWM immersive dark mode).
DwmLib := LoadLibrary('dwmapi.dll');
if DwmLib <> 0 then
try
@DwmSetWindowAttribute := GetProcAddress(DwmLib, 'DwmSetWindowAttribute');
if Assigned(DwmSetWindowAttribute) then
begin
DarkFlag := ADark;
DwmSetWindowAttribute(FormHwnd, DWMWA_USE_IMMERSIVE_DARK_MODE,
@DarkFlag, SizeOf(DarkFlag));
end;
finally
FreeLibrary(DwmLib);
end;
// 2. App-wide preferred mode (themes popup menus, scrollbars, tooltips).
// Loaded by ordinal because the functions are not exported by name.
UxLib := LoadLibrary('uxtheme.dll');
if UxLib <> 0 then
try
@SetPreferredAppMode := GetProcAddress(UxLib, MAKEINTRESOURCE(135));
@FlushMenuThemes := GetProcAddress(UxLib, MAKEINTRESOURCE(136));
if Assigned(SetPreferredAppMode) then
begin
if ADark then SetPreferredAppMode(APPMODE_FORCE_DARK)
else SetPreferredAppMode(APPMODE_FORCE_LIGHT);
if Assigned(FlushMenuThemes) then FlushMenuThemes;
end;
finally
FreeLibrary(UxLib);
end;
end;
procedure TPMBridge.RegisterAutofillHotkey;
begin
// Convenience default — Ctrl+Shift+L (full) + Ctrl+Shift+P (password).
// Idempotent: calling twice with the same combos is harmless.
SetAutofillHotkeys(AF_MOD_CONTROL or AF_MOD_SHIFT, Ord('L'),
AF_MOD_CONTROL or AF_MOD_SHIFT, Ord('P'));
end;
procedure TPMBridge.UnregisterAutofillHotkey;
begin
if FAutofillFullActive then
begin
UnregisterHotKey(FMsgWindow, AUTOFILL_HOTKEY_ID_FULL);
FAutofillFullActive := False;
end;
if FAutofillPwdActive then
begin
UnregisterHotKey(FMsgWindow, AUTOFILL_HOTKEY_ID_PWDONLY);
FAutofillPwdActive := False;
end;
FAutofillRegistered := False;
end;
// Block until the user releases Ctrl, Shift, Alt, and Win, or until ATimeoutMs
// elapses. Without this, an autofill triggered by Ctrl+Shift+L injects
// keystrokes WHILE Ctrl+Shift are physically held — turning our Tab into
// Ctrl+Tab (next tab in Chrome), our 's' into Ctrl+S, etc. 1000 ms is a
// generous bound; typical release happens within 50-150 ms.
procedure WaitForModifierRelease(ATimeoutMs: Cardinal);
var
LStart: Cardinal;
begin
LStart := GetTickCount;
while ((GetAsyncKeyState(VK_CONTROL) and $8000) <> 0)
or ((GetAsyncKeyState(VK_SHIFT) and $8000) <> 0)
or ((GetAsyncKeyState(VK_MENU) and $8000) <> 0) // Alt
or ((GetAsyncKeyState(VK_LWIN) and $8000) <> 0)
or ((GetAsyncKeyState(VK_RWIN) and $8000) <> 0) do
begin
Sleep(15);
if GetTickCount - LStart > ATimeoutMs then Break;
end;
end;
// Build (and immediately send) a key-down+up pair for each char in AText
// using KEYEVENTF_UNICODE. Returns nothing — best-effort.
procedure SendUnicodeString(const AText: string);
var
LInputs: TArray<TInput>;
LCount, I: Integer;
begin
if AText = '' then Exit;
SetLength(LInputs, Length(AText) * 2);
LCount := 0;
for I := 1 to Length(AText) do
begin
FillChar(LInputs[LCount], SizeOf(TInput), 0);
FillChar(LInputs[LCount + 1], SizeOf(TInput), 0);
LInputs[LCount].Itype := INPUT_KEYBOARD;
LInputs[LCount].ki.wScan := Ord(AText[I]);
LInputs[LCount].ki.dwFlags := KEYEVENTF_UNICODE;
LInputs[LCount + 1] := LInputs[LCount];
LInputs[LCount + 1].ki.dwFlags := KEYEVENTF_UNICODE or KEYEVENTF_KEYUP;
Inc(LCount, 2);
end;
SendInput(LCount, @LInputs[0], SizeOf(TInput));
end;
// Send one virtual-key press (down+up).
procedure SendVKey(AVk: Word);
var
LInputs: array[0..1] of TInput;
begin
FillChar(LInputs, SizeOf(LInputs), 0);
LInputs[0].Itype := INPUT_KEYBOARD;
LInputs[0].ki.wVk := AVk;
LInputs[1] := LInputs[0];
LInputs[1].ki.dwFlags := KEYEVENTF_KEYUP;
SendInput(2, @LInputs[0], SizeOf(TInput));
end;
procedure SendSelectAllAndDelete;
var
LInputs: array[0..5] of TInput;
begin
FillChar(LInputs, SizeOf(LInputs), 0);
LInputs[0].Itype := INPUT_KEYBOARD;
LInputs[0].ki.wVk := VK_CONTROL;
LInputs[1].Itype := INPUT_KEYBOARD;
LInputs[1].ki.wVk := Ord('A');
LInputs[2].Itype := INPUT_KEYBOARD;
LInputs[2].ki.wVk := Ord('A');
LInputs[2].ki.dwFlags := KEYEVENTF_KEYUP;
LInputs[3].Itype := INPUT_KEYBOARD;
LInputs[3].ki.wVk := VK_CONTROL;
LInputs[3].ki.dwFlags := KEYEVENTF_KEYUP;
LInputs[4].Itype := INPUT_KEYBOARD;
LInputs[4].ki.wVk := VK_DELETE;
LInputs[5] := LInputs[4];
LInputs[5].ki.dwFlags := KEYEVENTF_KEYUP;
SendInput(6, @LInputs[0], SizeOf(TInput));
end;
// Always-attach foreground switch. The early SetForegroundWindow shortcut
// was unreliable after the picker click — Win10/11 still refused the focus
// hand-off even when our process was foreground. Always doing the attach
// dance is slightly slower but actually works.
function ForceForegroundWindow(ATargetHwnd: HWND): Boolean;
const
ForegroundPollIntervalMs = 20;
ForegroundPollTimeoutMs = 600;
var
CallerThread, TargetThread, TargetPid: DWORD;
ThreadsAttached: Boolean;
WaitStart: Cardinal;
begin
Result := False;
if (ATargetHwnd = 0) or not IsWindow(ATargetHwnd) then Exit;
TargetPid := 0;
TargetThread := GetWindowThreadProcessId(ATargetHwnd, TargetPid);
CallerThread := GetCurrentThreadId;
if TargetThread = 0 then Exit;
ThreadsAttached := (TargetThread <> CallerThread) and
AttachThreadInput(CallerThread, TargetThread, True);
try
if IsIconic(ATargetHwnd) then
ShowWindow(ATargetHwnd, SW_RESTORE);
BringWindowToTop(ATargetHwnd);
SetWindowPos(ATargetHwnd, HWND_TOP, 0, 0, 0, 0,
SWP_NOMOVE or SWP_NOSIZE or SWP_NOACTIVATE);
SetForegroundWindow(ATargetHwnd);
WaitStart := GetTickCount;
while GetForegroundWindow <> ATargetHwnd do
begin
if GetTickCount - WaitStart > ForegroundPollTimeoutMs then Break;
Sleep(ForegroundPollIntervalMs);
SetForegroundWindow(ATargetHwnd);
end;
Result := GetForegroundWindow = ATargetHwnd;
finally
if ThreadsAttached then
AttachThreadInput(CallerThread, TargetThread, False);
end;
end;
procedure ClickTargetCenterToGrabFocus(ATargetHwnd: HWND);
const
PostClickSettleMs = 40;
var
WindowRect: TRect;
CenterAbsX, CenterAbsY, ScreenW, ScreenH: Integer;
SavedCursor: TPoint;
MouseInputs: array[0..2] of TInput;
begin
if (ATargetHwnd = 0) or not IsWindow(ATargetHwnd) then Exit;
if not GetWindowRect(ATargetHwnd, WindowRect) then Exit;
CenterAbsX := (WindowRect.Left + WindowRect.Right) div 2;
CenterAbsY := (WindowRect.Top + WindowRect.Bottom) div 2;
ScreenW := GetSystemMetrics(SM_CXSCREEN);
ScreenH := GetSystemMetrics(SM_CYSCREEN);
if (ScreenW <= 0) or (ScreenH <= 0) then Exit;
GetCursorPos(SavedCursor);
FillChar(MouseInputs, SizeOf(MouseInputs), 0);
MouseInputs[0].Itype := INPUT_MOUSE;
MouseInputs[0].mi.dx := (CenterAbsX * 65535) div ScreenW;
MouseInputs[0].mi.dy := (CenterAbsY * 65535) div ScreenH;
MouseInputs[0].mi.dwFlags:= MOUSEEVENTF_ABSOLUTE or MOUSEEVENTF_MOVE or MOUSEEVENTF_LEFTDOWN;
MouseInputs[1] := MouseInputs[0];
MouseInputs[1].mi.dwFlags:= MOUSEEVENTF_LEFTUP;
MouseInputs[2].Itype := INPUT_MOUSE;
MouseInputs[2].mi.dx := (SavedCursor.X * 65535) div ScreenW;
MouseInputs[2].mi.dy := (SavedCursor.Y * 65535) div ScreenH;
MouseInputs[2].mi.dwFlags:= MOUSEEVENTF_ABSOLUTE or MOUSEEVENTF_MOVE;
SendInput(3, @MouseInputs[0], SizeOf(TInput));
Sleep(PostClickSettleMs);
end;
procedure TPMBridge.ExecuteAutofill(ATargetHWND: HWND;
const AUsername, APassword: string);
const
MinimizeSettleMs = 80;
FocusSettleDelayMs = 120;
var
OwnFormHwnd: HWND;
begin
OwnFormHwnd := MainFormHWND(FMainForm);
if GetForegroundWindow = OwnFormHwnd then
begin
ShowWindow(OwnFormHwnd, SW_MINIMIZE);
Sleep(MinimizeSettleMs);
end;
if ATargetHWND <> 0 then
ForceForegroundWindow(ATargetHWND);
WaitForModifierRelease(1000);
Sleep(FocusSettleDelayMs);
if AUsername = '' then
begin
SendSelectAllAndDelete;
Sleep(60);
SendUnicodeString(APassword);
Exit;
end;
SendSelectAllAndDelete;
Sleep(60);
SendUnicodeString(AUsername);
Sleep(200);
SendVKey(VK_TAB);
Sleep(200);
SendSelectAllAndDelete;
Sleep(60);
SendUnicodeString(APassword);
end;
end.
+10
View File
@@ -231,6 +231,10 @@ begin
// UI V2: tags stored as comma-separated TEXT (e.g. "work,important,2fa").
// Simple format, search via LIKE %tag%. Frontend handles parsing/joining.
AddColumnIfMissing('vault_entries', 'tags', 'TEXT DEFAULT ''''');
// Optional human-friendly display name. When empty, the UI falls back
// to `site`. Lets the user store the raw URL/host (used for autofill
// domain matching) while showing something nicer on cards/slideovers.
AddColumnIfMissing('vault_entries', 'title', 'TEXT DEFAULT ''''');
// TOTP (2FA) — RFC 6238. Secret + IV are AES-GCM ciphertext / IV pair
// encrypted client-side with the user's master-derived key, exactly like
// encrypted_password. The server treats them as opaque blobs and never
@@ -244,6 +248,12 @@ begin
// (600 000 as of 2026). Login flow transparently re-hashes legacy users
// and re-encrypts their entries on the client side.
AddColumnIfMissing('users', 'kdf_iterations', 'INTEGER DEFAULT 100000');
AddColumnIfMissing('recovery_keys', 'remaining_uses', 'INTEGER DEFAULT 5');
// Server-side preferences blob (JSON). Synced across devices on login,
// saved on every change from the JS settings panel. Device-specific
// toggles (quick-unlock DPAPI, Win32 autofill hotkey) intentionally stay
// in localStorage and are NOT included here.
AddColumnIfMissing('users', 'settings_json', 'TEXT DEFAULT ''{}''');
AddColumnIfMissing('sessions', 'csrf_token', 'TEXT');
end;
+111 -14
View File
@@ -1,4 +1,4 @@
unit PM.HTTPServer;
unit PM.HTTPServer;
{
Indy TIdHTTPServer wrapper.
@@ -12,8 +12,10 @@ interface
uses
System.SysUtils, System.Classes, System.IOUtils,
IdHTTPServer, IdContext, IdCustomHTTPServer, IdSocketHandle,
PM.Router, PM.JSON, PM.Database, PM.StaticFiles, PM.EmbeddedAssets;
Winapi.Windows,
IdHTTPServer, IdContext, IdCustomHTTPServer, IdSocketHandle, IdTCPConnection,
PM.Router, PM.JSON, PM.Database, PM.StaticFiles, PM.EmbeddedAssets,
PM.Crypto, PM.ProcessLockdown;
type
TLogProc = reference to procedure(const AMsg: string);
@@ -22,6 +24,10 @@ type
private
FServer: TIdHTTPServer;
FOnLog: TLogProc;
FAccessToken: string;
FRequireAccessToken: Boolean;
FRequireProcessCheck: Boolean;
FBoundPort: Integer;
procedure HandleCommand(AContext: TIdContext;
ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo);
procedure HandleCommandOther(AContext: TIdContext;
@@ -34,13 +40,23 @@ type
AResponse: TIdHTTPResponseInfo);
procedure Log(const AMsg: string);
function GetActive: Boolean;
function ValidateAccessToken(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo): Boolean;
function ValidateConnectingProcess(AContext: TIdContext;
AResponse: TIdHTTPResponseInfo): Boolean;
public
constructor Create;
destructor Destroy; override;
procedure Start(APort: Integer);
procedure Start(APort: Integer; SameFolder: Boolean;
ARequireAccessToken: Boolean = True;
ARequireProcessCheck: Boolean = True);
procedure Stop;
property Active: Boolean read GetActive;
property OnLog: TLogProc read FOnLog write FOnLog;
property AccessToken: string read FAccessToken;
property RequireAccessToken: Boolean read FRequireAccessToken;
property RequireProcessCheck: Boolean read FRequireProcessCheck;
property BoundPort: Integer read FBoundPort;
end;
implementation
@@ -96,16 +112,34 @@ begin
if Assigned(FOnLog) then FOnLog(AMsg);
end;
procedure TPMHTTPServer.Start(APort: Integer);
procedure TPMHTTPServer.Start(APort: Integer; SameFolder: Boolean;
ARequireAccessToken: Boolean; ARequireProcessCheck: Boolean);
const
EphemeralPortMin = 49152;
EphemeralPortMax = 65535;
var
LBinding: TIdSocketHandle;
LDBPath, LWebRoot: string;
LDBPath, LWebRoot, LResolvedPort: string;
LRequestedPort: Integer;
begin
if FServer.Active then Exit;
// Resolve vault.db AND the web root (parent of the exe = Z:\password-manager\)
LDBPath := TPath.GetFullPath(TPath.Combine(ExtractFilePath(ParamStr(0)), '..\vault.db'));
LWebRoot := TPath.GetFullPath(TPath.Combine(ExtractFilePath(ParamStr(0)), '..\'));
FRequireAccessToken := ARequireAccessToken;
FRequireProcessCheck := ARequireProcessCheck;
if FRequireAccessToken then
FAccessToken := PM.Crypto.RandomHex(32)
else
FAccessToken := '';
LRequestedPort := APort;
if LRequestedPort = 0 then
LRequestedPort := EphemeralPortMin + Random(EphemeralPortMax - EphemeralPortMin);
var pathParent := '..\';
if SameFolder then
pathParent := '';
LDBPath := TPath.GetFullPath(TPath.Combine(ExtractFilePath(ParamStr(0)), pathParent+'vault.db'));
LWebRoot := TPath.GetFullPath(TPath.Combine(ExtractFilePath(ParamStr(0)), pathParent));
Log('Opening database: ' + LDBPath);
InitDatabase(LDBPath);
Log('Database ready.');
@@ -115,10 +149,15 @@ begin
FServer.Bindings.Clear;
LBinding := FServer.Bindings.Add;
LBinding.IP := '127.0.0.1';
LBinding.Port := APort;
LBinding.Port := LRequestedPort;
FServer.Active := True;
Log('Server started on http://127.0.0.1:' + IntToStr(APort));
FBoundPort := LRequestedPort;
LResolvedPort := IntToStr(FBoundPort);
Log(Format('Server started on http://127.0.0.1:%s (token:%s process_check:%s)',
[LResolvedPort,
BoolToStr(FRequireAccessToken, True),
BoolToStr(FRequireProcessCheck, True)]));
end;
procedure TPMHTTPServer.Stop;
@@ -176,12 +215,70 @@ begin
end;
end;
function TPMHTTPServer.ValidateConnectingProcess(AContext: TIdContext;
AResponse: TIdHTTPResponseInfo): Boolean;
var
Binding: TIdSocketHandle;
ConnectingPid: DWORD;
begin
if not FRequireProcessCheck then Exit(True);
Result := False;
Binding := AContext.Binding;
if Binding = nil then
begin
AResponse.ResponseNo := 404;
AResponse.ContentText := '';
Exit;
end;
ConnectingPid := GetPidOfTcpConnection(Word(Binding.PeerPort), Word(Binding.Port));
if (ConnectingPid <> 0) and IsDescendantOfCurrentProcess(ConnectingPid) then
Exit(True);
Log(Format('Rejected request from foreign PID %d (%s %s)',
[ConnectingPid, AContext.Connection.Socket.Binding.PeerIP, '']));
AResponse.ResponseNo := 404;
AResponse.ContentText := '';
end;
function TPMHTTPServer.ValidateAccessToken(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo): Boolean;
const
CookieName = 'pm_token';
QueryParamName = 'pmt';
SetCookieHeader = 'Set-Cookie';
var
CookieHeader, QueryToken: string;
begin
if not FRequireAccessToken then Exit(True);
CookieHeader := ARequest.RawHeaders.Values['Cookie'];
if (CookieHeader <> '') and
(Pos(CookieName + '=' + FAccessToken, CookieHeader) > 0) then
Exit(True);
QueryToken := ARequest.Params.Values[QueryParamName];
if QueryToken = FAccessToken then
begin
AResponse.CustomHeaders.AddValue(SetCookieHeader,
CookieName + '=' + FAccessToken +
'; Path=/; HttpOnly; SameSite=Strict');
Exit(True);
end;
AResponse.ResponseNo := 404;
AResponse.ContentText := '';
Result := False;
end;
procedure TPMHTTPServer.HandleCommand(AContext: TIdContext;
ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo);
begin
ApplySecurityHeaders(ARequest, AResponse);
if not ValidateConnectingProcess(AContext, AResponse) then Exit;
if not ValidateAccessToken(ARequest, AResponse) then Exit;
try
// Order: API route → embedded resource (production) → disk static (dev) → 404
if Router.DispatchRequest(ARequest, AResponse) then Exit;
if TryServeEmbedded(ARequest, AResponse) then Exit;
if Assigned(StaticServer) and StaticServer.TryServe(ARequest, AResponse) then Exit;
@@ -199,14 +296,14 @@ procedure TPMHTTPServer.HandleCommandOther(AContext: TIdContext;
ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo);
begin
ApplySecurityHeaders(ARequest, AResponse);
// OPTIONS preflight
if SameText(ARequest.Command, 'OPTIONS') then
begin
AResponse.ResponseNo := 204;
AResponse.ContentText := '';
Exit;
end;
// Routes for PUT / DELETE go through here in Indy
if not ValidateConnectingProcess(AContext, AResponse) then Exit;
if not ValidateAccessToken(ARequest, AResponse) then Exit;
try
if not Router.DispatchRequest(ARequest, AResponse) then
TJSONHelper.SendError(AResponse, 404, 'Not found');
-1
View File
@@ -24,7 +24,6 @@ var
LSS: TStringStream;
LValue: TJSONValue;
begin
Result := nil;
if ARequest.PostStream = nil then Exit(TJSONObject.Create);
LSS := TStringStream.Create('', TEncoding.UTF8);
try
@@ -0,0 +1,116 @@
unit PM.ProcessLockdown;
interface
uses
Winapi.Windows;
function GetPidOfTcpConnection(ALocalPort, ARemotePort: Word): DWORD;
function IsDescendantOfCurrentProcess(APid: DWORD): Boolean;
implementation
uses
System.SysUtils, System.Generics.Collections, Winapi.WinSock,
Winapi.TlHelp32;
const
IPHLPAPI = 'iphlpapi.dll';
AF_INET_LOCAL = 2;
TCP_TABLE_OWNER_PID_CONNECTIONS = 4;
NO_ERROR = 0;
type
MIB_TCPROW_OWNER_PID = record
dwState: DWORD;
dwLocalAddr: DWORD;
dwLocalPort: DWORD;
dwRemoteAddr: DWORD;
dwRemotePort: DWORD;
dwOwningPid: DWORD;
end;
MIB_TCPTABLE_OWNER_PID = record
dwNumEntries: DWORD;
table: array[0..0] of MIB_TCPROW_OWNER_PID;
end;
PMIB_TCPTABLE_OWNER_PID = ^MIB_TCPTABLE_OWNER_PID;
function GetExtendedTcpTable(pTcpTable: Pointer; pdwSize: PDWORD;
bOrder: BOOL; ulAf: ULONG; TableClass: DWORD; Reserved: ULONG): DWORD;
stdcall; external IPHLPAPI;
function GetPidOfTcpConnection(ALocalPort, ARemotePort: Word): DWORD;
var
Size: DWORD;
Buffer: PMIB_TCPTABLE_OWNER_PID;
i: Integer;
Row: ^MIB_TCPROW_OWNER_PID;
WantedLocal, WantedRemote: Word;
begin
Result := 0;
Size := 0;
GetExtendedTcpTable(nil, @Size, False, AF_INET_LOCAL,
TCP_TABLE_OWNER_PID_CONNECTIONS, 0);
if Size = 0 then Exit;
GetMem(Buffer, Size);
try
if GetExtendedTcpTable(Buffer, @Size, False, AF_INET_LOCAL,
TCP_TABLE_OWNER_PID_CONNECTIONS, 0) <> NO_ERROR then Exit;
WantedLocal := ntohs(ALocalPort);
WantedRemote := ntohs(ARemotePort);
Row := @Buffer.table[0];
for i := 0 to Buffer.dwNumEntries - 1 do
begin
if (Word(Row.dwLocalPort) = WantedLocal) and
(Word(Row.dwRemotePort) = WantedRemote) then
Exit(Row.dwOwningPid);
Inc(Row);
end;
finally
FreeMem(Buffer);
end;
end;
function IsDescendantOfCurrentProcess(APid: DWORD): Boolean;
const
MaxDepth = 32;
var
Snap: THandle;
Entry: TProcessEntry32W;
ParentMap: TDictionary<DWORD, DWORD>;
Current, RootPid: DWORD;
Depth: Integer;
begin
Result := False;
if APid = 0 then Exit;
RootPid := GetCurrentProcessId;
if APid = RootPid then Exit(True);
Snap := CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if Snap = INVALID_HANDLE_VALUE then Exit;
ParentMap := TDictionary<DWORD, DWORD>.Create;
try
Entry.dwSize := SizeOf(Entry);
if Process32FirstW(Snap, Entry) then
repeat
ParentMap.AddOrSetValue(Entry.th32ProcessID, Entry.th32ParentProcessID);
until not Process32NextW(Snap, Entry);
Current := APid;
for Depth := 0 to MaxDepth do
begin
if Current = RootPid then Exit(True);
if not ParentMap.TryGetValue(Current, Current) then Exit;
if (Current = 0) or (Current = 4) then Exit;
end;
finally
ParentMap.Free;
CloseHandle(Snap);
end;
end;
end.
+1 -1
View File
@@ -23,6 +23,7 @@ interface
uses
System.SysUtils, System.JSON,
Data.DB,
FireDAC.Comp.Client, FireDAC.Stan.Param, IdCustomHTTPServer,
PM.Database;
@@ -67,7 +68,6 @@ function CheckRateLimit(const AIP: string): Integer;
var
LQ: TFDQuery;
begin
Result := 0;
DB.Lock;
try
LQ := TFDQuery.Create(nil);
+1 -1
View File
@@ -16,6 +16,7 @@ interface
uses
System.SysUtils, System.Classes, System.StrUtils,
Data.DB,
FireDAC.Comp.Client, FireDAC.Stan.Param,
IdCustomHTTPServer,
PM.Database, PM.Crypto, PM.JSON;
@@ -55,7 +56,6 @@ var
LQ: TFDQuery;
LExpires: TDateTime;
begin
Result := 0;
LToken := ExtractBearerToken(ARequest);
if LToken = '' then
begin
@@ -0,0 +1,76 @@
unit PM.SingleInstance;
{
Single-instance guard.
AcquireOrSignal:
- First instance: creates a named mutex and returns True. Caller proceeds.
- Subsequent instance: detects the mutex, broadcasts WM_PMSHOW so the
running instance restores from tray, returns False. Caller exits.
WM_PMSHOW is a RegisterWindowMessage('PMServer_ShowExisting') — system-
unique, all processes that register the same string get the same ID.
PM.Bridge listens for it on its message-only window.
}
interface
uses
Winapi.Windows, Winapi.Messages;
const
// Mutex name lives in the Local\ namespace → per-user-session, so a
// second user on the same machine (RDP, Switch User) can still launch
// their own instance. The Global\ namespace would block them.
PMSERVER_MUTEX_NAME = 'Local\PMServer.SingleInstance.Mutex';
// System-wide unique message ID, computed once. Bridge + .dpr both call
// this to get the same UINT.
function WM_PMShowMessage: UINT;
// Try to become the single instance. True = we are first; False = another
// instance was already running (we have signalled it and the caller must
// exit immediately).
function AcquireOrSignal: Boolean;
implementation
var
_Mutex: THandle = 0;
_WmShow: UINT = 0;
function WM_PMShowMessage: UINT;
begin
if _WmShow = 0 then
_WmShow := RegisterWindowMessage('PMServer_ShowExisting');
Result := _WmShow;
end;
function AcquireOrSignal: Boolean;
var
LErr: DWORD;
begin
_Mutex := CreateMutex(nil, True, PMSERVER_MUTEX_NAME);
LErr := GetLastError;
if (_Mutex <> 0) and (LErr <> ERROR_ALREADY_EXISTS) then
begin
// We are the first instance. Keep the mutex alive for the process
// lifetime — Windows releases it automatically on exit.
Result := True;
Exit;
end;
// Another instance is already running. Close our handle (it isn't ours)
// and broadcast the show-message to all top-level windows. The running
// bridge picks it up on its message-only window.
if _Mutex <> 0 then
begin
CloseHandle(_Mutex);
_Mutex := 0;
end;
PostMessage(HWND_BROADCAST, WM_PMShowMessage, 0, 0);
Result := False;
end;
end.
+180
View File
@@ -0,0 +1,180 @@
unit PM.UserPrefs;
{
Device-bound key/value prefs persisted across launches.
Problem solved: the HTTP server binds an ephemeral port that changes on
every start (49152-65535). localStorage is keyed by origin (scheme+host
+port) so a different port = a fresh localStorage = anything persisted
there is lost between launches. For prefs that must survive a reboot
(remembered username, etc.) we persist them via this unit instead.
Storage: %LOCALAPPDATA%\PMServer\prefs.bin
Format: DPAPI-encrypted UTF-8 JSON object {"key":"value",....
Scope: current Windows user (same threat model as PM.QuickUnlock).
}
interface
uses
System.SysUtils, System.Classes, System.IOUtils, System.JSON,
Winapi.Windows;
function GetPref(const AKey: string): string;
procedure SetPref(const AKey, AValue: string);
implementation
type
TDataBlob = record
cbData: DWORD;
pbData: PByte;
end;
PDataBlob = ^TDataBlob;
function CryptProtectData(pDataIn: PDataBlob; szDataDescr: PWideChar;
pOptionalEntropy: PDataBlob; pvReserved: Pointer; pPromptStruct: Pointer;
dwFlags: DWORD; pDataOut: PDataBlob): BOOL; stdcall;
external 'crypt32.dll' name 'CryptProtectData';
function CryptUnprotectData(pDataIn: PDataBlob; ppszDataDescr: PPWideChar;
pOptionalEntropy: PDataBlob; pvReserved: Pointer; pPromptStruct: Pointer;
dwFlags: DWORD; pDataOut: PDataBlob): BOOL; stdcall;
external 'crypt32.dll' name 'CryptUnprotectData';
function LocalFree(hMem: HLOCAL): HLOCAL; stdcall;
external 'kernel32.dll' name 'LocalFree';
function StorageDir: string;
begin
Result := TPath.Combine(GetEnvironmentVariable('LOCALAPPDATA'), 'PMServer');
end;
function StorageFile: string;
begin
Result := TPath.Combine(StorageDir, 'prefs.bin');
end;
procedure EnsureStorageDir;
begin
if not TDirectory.Exists(StorageDir) then
TDirectory.CreateDirectory(StorageDir);
end;
function LoadAll: TJSONObject;
var
LEncrypted: TBytes;
LIn, LOut: TDataBlob;
LStream: TFileStream;
LPlain: string;
LValue: TJSONValue;
begin
// Default to an empty object; every error path just Exits with this.
// Only the success path replaces it with the parsed JSON.
Result := TJSONObject.Create;
if not TFile.Exists(StorageFile) then Exit;
try
LStream := TFileStream.Create(StorageFile, fmOpenRead or fmShareDenyWrite);
try
SetLength(LEncrypted, LStream.Size);
if Length(LEncrypted) > 0 then
LStream.ReadBuffer(LEncrypted[0], LStream.Size);
finally
LStream.Free;
end;
except
Exit;
end;
if Length(LEncrypted) = 0 then Exit;
LIn.cbData := Length(LEncrypted);
LIn.pbData := @LEncrypted[0];
LOut.pbData := nil;
LOut.cbData := 0;
if not CryptUnprotectData(@LIn, nil, nil, nil, nil, 0, @LOut) then Exit;
try
SetString(LPlain, PAnsiChar(LOut.pbData), LOut.cbData);
LValue := TJSONObject.ParseJSONValue(TEncoding.UTF8.GetBytes(LPlain), 0);
if LValue is TJSONObject then
begin
// Replace the default empty object with the parsed one.
Result.Free;
Result := TJSONObject(LValue);
end
else if LValue <> nil then
LValue.Free;
finally
if LOut.pbData <> nil then LocalFree(HLOCAL(LOut.pbData));
end;
end;
procedure SaveAll(AObj: TJSONObject);
var
LBytes: TBytes;
LIn, LOut: TDataBlob;
LStream: TFileStream;
LJsonStr: string;
begin
LJsonStr := AObj.ToJSON;
LBytes := TEncoding.UTF8.GetBytes(LJsonStr);
if Length(LBytes) = 0 then Exit;
LIn.cbData := Length(LBytes);
LIn.pbData := @LBytes[0];
LOut.pbData := nil;
LOut.cbData := 0;
if not CryptProtectData(@LIn, nil, nil, nil, nil, 0, @LOut) then Exit;
try
EnsureStorageDir;
LStream := TFileStream.Create(StorageFile, fmCreate);
try
LStream.WriteBuffer(LOut.pbData^, LOut.cbData);
finally
LStream.Free;
end;
finally
if LOut.pbData <> nil then LocalFree(HLOCAL(LOut.pbData));
end;
end;
function GetPref(const AKey: string): string;
var
LObj: TJSONObject;
LValue: TJSONValue;
begin
Result := '';
LObj := LoadAll;
try
if LObj = nil then Exit;
LValue := LObj.GetValue(AKey);
if LValue <> nil then
Result := LValue.Value;
finally
LObj.Free;
end;
end;
procedure SetPref(const AKey, AValue: string);
var
LObj: TJSONObject;
LExisting: TJSONValue;
begin
LObj := LoadAll;
try
if LObj = nil then LObj := TJSONObject.Create;
LExisting := LObj.GetValue(AKey);
if LExisting <> nil then
LObj.RemovePair(AKey).Free;
LObj.AddPair(AKey, AValue);
SaveAll(LObj);
finally
LObj.Free;
end;
end;
end.
+2 -2
View File
@@ -1,15 +1,15 @@
object MainForm: TMainForm
Left = 0
Top = 0
Caption = 'Password Manager - Delphi Backend'
Caption = 'Password Manager'
ClientHeight = 720
ClientWidth = 1100
FormFactor.Width = 320
FormFactor.Height = 480
FormFactor.Devices = [Desktop]
OnCreate = FormCreate
OnDestroy = FormDestroy
OnCloseQuery = FormCloseQuery
OnDestroy = FormDestroy
DesignerMasterStyle = 0
object PanelTop: TPanel
Align = Top
+290 -21
View File
@@ -4,12 +4,14 @@ interface
uses
System.SysUtils, System.Classes, System.UITypes, System.NetEncoding,
System.StrUtils,
Winapi.Windows,
FMX.Forms, FMX.Controls, FMX.Controls.Presentation, FMX.StdCtrls,
FMX.Memo, FMX.Memo.Types, FMX.ScrollBox, FMX.Edit, FMX.Layouts, FMX.Types,
FMX.Dialogs,
FMX.Dialogs, FMX.DialogService,
FMX.TMSFNCTypes, FMX.TMSFNCUtils, FMX.TMSFNCGraphics, FMX.TMSFNCGraphicsTypes,
FMX.TMSFNCCustomControl, FMX.TMSFNCWebBrowser,
PM.HTTPServer, PM.Bridge, PM.QuickUnlock;
PM.HTTPServer, PM.Bridge, PM.QuickUnlock, PM.UserPrefs;
type
TMainForm = class(TForm)
@@ -37,9 +39,17 @@ type
FBridge: TPMBridge;
FPendingURL: string;
FNavTimer: TTimer;
FNavAttempts: Integer;
FRequireAccessToken: Boolean;
FRequireProcessCheck: Boolean;
FQuitting: Boolean; // set when user picks "Quit" in tray menu — bypasses
// FormCloseQuery's minimize-to-tray intercept.
FAutofillTargetHWND: HWND; // saved at hotkey time, consumed on /execute
// Pending payload for the 60ms delay timer (focus settle before SendInput).
// Cleared inside AutofillTimerTick.
FAutofillPendingHWND: HWND;
FAutofillPendingUser: string;
FAutofillPendingPass: string;
procedure AutofillTimerTick(Sender: TObject);
procedure LogLine(const AMsg: string);
procedure UpdateButtons;
procedure NavigateToVault;
@@ -52,6 +62,11 @@ type
procedure BridgeTrayRestore;
procedure BridgeLockRequest;
procedure BridgeQuit;
procedure BridgeAutofillRequest(AKind: TAutofillKind;
ATargetHWND: HWND; const ATitle: string);
procedure BridgeDebugHotkey;
procedure BridgeNewEntryHotkey(const AWindowTitle: string);
procedure WebBrowserInitialized(Sender: TObject);
end;
var
@@ -67,13 +82,19 @@ begin
FServer.OnLog := LogLine;
FBridge := TPMBridge.Create(Self);
FBridge.OnSystemLock := BridgeSystemLock;
FBridge.OnTrayRestore := BridgeTrayRestore;
FBridge.OnLockRequest := BridgeLockRequest;
FBridge.OnQuit := BridgeQuit;
FBridge.OnSystemLock := BridgeSystemLock;
FBridge.OnTrayRestore := BridgeTrayRestore;
FBridge.OnLockRequest := BridgeLockRequest;
FBridge.OnQuit := BridgeQuit;
FBridge.OnAutofillRequest := BridgeAutofillRequest;
FBridge.OnDebugHotkey := BridgeDebugHotkey;
FBridge.OnNewEntryHotkey := BridgeNewEntryHotkey;
FBridge.RegisterAutofillHotkey; // Ctrl+Shift+L active from startup
FBridge.ApplyTitleBarTheme(True); // dark by default, JS may toggle later
FAutofillTargetHWND := 0;
// Wire the cmd:// bridge before any navigation happens.
WebBrowser.OnBeforeNavigate := WebBrowserBeforeNavigate;
WebBrowser.OnInitialized := WebBrowserInitialized;
// Delayed-Navigate timer: TTMSFNCWebBrowser (WebView2 backend) ignores
// Navigate() calls until Edge Chromium finishes its async init (~1-2s).
@@ -88,6 +109,28 @@ begin
UpdateButtons;
LogLine('Password Manager - Delphi backend ready.');
LogLine('Click Start to launch server + embedded web vault.');
PanelTop.Visible := False;
FRequireAccessToken := True;
FRequireProcessCheck := True;
edtPort.Text := '0';
if FileExists('config.txt') then
begin
var configList := TStringList.Create;
configList.LoadFromFile('config.txt');
try
var defPort := StrToIntDef(configList.Values['port'], 0);
edtPort.Text := defPort.ToString;
PanelTop.Visible := configList.Values['debug'].ToLower.Equals('true');
if configList.Values['require_token'].ToLower.Equals('false') then
FRequireAccessToken := False;
if configList.Values['require_process_check'].ToLower.Equals('false') then
FRequireProcessCheck := False;
finally
FreeAndNil(configList);
end;
end;
btnStartClick(Nil);
btnToggleLogClick(nil);
end;
procedure TMainForm.FormDestroy(Sender: TObject);
@@ -96,7 +139,36 @@ begin
FServer.Free;
end;
procedure TMainForm.FormCloseQuery(Sender: TObject; var CanClose: Boolean);
procedure TMainForm.WebBrowserInitialized(Sender: TObject);
begin
WebBrowser.EnableContextMenu := False;
WebBrowser.EnableShowDebugConsole := False;
end;
procedure TMainForm.BridgeNewEntryHotkey(const AWindowTitle: string);
var
EscapedTitle: string;
begin
if not FServer.Active then Exit;
FBridge.RestoreFromTray;
EscapedTitle := StringReplace(AWindowTitle, '\', '\\', [rfReplaceAll]);
EscapedTitle := StringReplace(EscapedTitle, '"', '\"', [rfReplaceAll]);
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&typeof Bridge.onNewEntryFromTitle==="function")' +
'Bridge.onNewEntryFromTitle("' + EscapedTitle + '")');
LogLine('New entry hotkey — title: "' + AWindowTitle + '"');
end;
procedure TMainForm.BridgeDebugHotkey;
begin
if not FileExists('config.txt') then
Exit;
PanelTop.Visible := not PanelTop.Visible;
LogLine('Debug panel ' + IfThen(PanelTop.Visible, 'shown', 'hidden') +
' via Ctrl+Shift+D');
end;
Procedure TMainForm.FormCloseQuery(Sender: TObject; var CanClose: Boolean);
begin
// The tray-menu "Quit" handler sets FQuitting before triggering close,
// so we bypass the minimize-to-tray intercept in that case.
@@ -138,20 +210,31 @@ begin
lblStatus.Text := 'Stopped';
end;
function MaskAccessToken(const AUrl: string): string;
var
TokenPos: Integer;
begin
Result := AUrl;
TokenPos := Pos('?pmt=', Result);
if TokenPos > 0 then
Result := Copy(Result, 1, TokenPos + 4) + '***';
end;
procedure TMainForm.NavigateToVault;
begin
FPendingURL := 'http://127.0.0.1:' + edtPort.Text + '/index.html';
LogLine('Will navigate embedded browser in ~1.5s to: ' + FPendingURL);
// Schedule a single Navigate after Edge has had time to initialize.
FNavTimer.Enabled := False; // restart timer if already running
FPendingURL := 'http://127.0.0.1:' + FServer.BoundPort.ToString + '/index.html';
if FServer.RequireAccessToken then
FPendingURL := FPendingURL + '?pmt=' + FServer.AccessToken;
LogLine('Will navigate embedded browser in ~1.5s to: ' + MaskAccessToken(FPendingURL));
FNavTimer.Enabled := False;
FNavTimer.Enabled := True;
end;
procedure TMainForm.NavTimerTick(Sender: TObject);
begin
FNavTimer.Enabled := False; // one-shot
FNavTimer.Enabled := False;
if FPendingURL = '' then Exit;
LogLine('Navigating to: ' + FPendingURL);
LogLine('Navigating to: ' + MaskAccessToken(FPendingURL));
WebBrowser.Navigate(FPendingURL);
FPendingURL := '';
end;
@@ -162,15 +245,16 @@ var
begin
LPort := StrToIntDef(edtPort.Text, 8765);
try
FServer.Start(LPort);
FServer.Start(LPort, True, FRequireAccessToken, FRequireProcessCheck);
edtPort.Text := FServer.BoundPort.ToString;
UpdateButtons;
NavigateToVault;
except
on E: Exception do
begin
LogLine('ERROR starting server: ' + E.Message);
MessageDlg('Failed to start: ' + E.Message,
TMsgDlgType.mtError, [TMsgDlgBtn.mbOK], 0);
TDialogService.MessageDialog('Failed to start: ' + E.Message,
TMsgDlgType.mtError, [TMsgDlgBtn.mbOK], TMsgDlgBtn.mbOK, 0, nil);
end;
end;
end;
@@ -279,6 +363,17 @@ begin
LogLine('Clipboard cleared by JS request');
end
else if ACmd = 'clipboard/read' then
begin
var ClipText := FBridge.SecureClipboard.ReadText;
var Escaped := StringReplace(ClipText, '\', '\\', [rfReplaceAll]);
Escaped := StringReplace(Escaped, '"', '\"', [rfReplaceAll]);
Escaped := StringReplace(Escaped, #13, '\r', [rfReplaceAll]);
Escaped := StringReplace(Escaped, #10, '\n', [rfReplaceAll]);
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&Bridge.onClipboardRead)Bridge.onClipboardRead("' + Escaped + '")');
end
// ---- Quick unlock (DPAPI persistence of the vault key) ----
// store: client provides a base64-encoded blob (UTF-8 JSON, content
// opaque to us). We DPAPI-encrypt and stash on disk.
@@ -332,15 +427,145 @@ begin
BoolToStr(PM.QuickUnlock.HasQuickUnlock, True).ToLower + ')');
end
// ---- Autofill --------------------------------------------------------
// configure: JS calls this on page load / settings change to sync the
// hotkey registration state with the user's localStorage preference.
// Uses the historical defaults (Ctrl+Shift+L / Ctrl+Shift+P) — for
// custom combos, JS sends cmd://autofill/hotkeys instead.
else if ACmd = 'autofill/configure' then
begin
if GetParam('enabled') = '1' then
begin
FBridge.RegisterAutofillHotkey;
LogLine('Autofill hotkeys registered (defaults)');
end
else
begin
FBridge.UnregisterAutofillHotkey;
LogLine('Autofill hotkeys unregistered');
end;
end
// hotkeys: JS pushes the user-configured combos. Params:
// enabled = '1' | '0'
// full_mods = MOD_x bitmask (decimal), full_vk = VK code (decimal)
// pwd_mods, pwd_vk = same for the password-only hotkey
// If enabled=0, we just unregister and ignore the rest. If enabled=1,
// we register both with the supplied combos (replacing any prior).
else if ACmd = 'autofill/hotkeys' then
begin
if GetParam('enabled') <> '1' then
begin
FBridge.UnregisterAutofillHotkey;
LogLine('Autofill hotkeys unregistered (custom)');
end
else
begin
var LFullMods := Word(StrToIntDef(GetParam('full_mods'), 6)); // Ctrl+Shift
var LFullVk := Word(StrToIntDef(GetParam('full_vk'), Ord('L')));
var LPwdMods := Word(StrToIntDef(GetParam('pwd_mods'), 6));
var LPwdVk := Word(StrToIntDef(GetParam('pwd_vk'), Ord('P')));
var LAllOk := FBridge.SetAutofillHotkeys(LFullMods, LFullVk,
LPwdMods, LPwdVk);
LogLine(Format('Autofill hotkeys set — full=mods:%d vk:%d pwd=mods:%d vk:%d (all_ok=%s)',
[LFullMods, LFullVk, LPwdMods, LPwdVk, BoolToStr(LAllOk, True)]));
// Notify JS of the result so the UI can flag a failed-to-register combo
// (typically a clash with another app's global hotkey).
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&Bridge.onAutofillHotkeysResult)' +
'Bridge.onAutofillHotkeysResult(' + BoolToStr(LAllOk, True).ToLower + ')');
end;
end
// execute: JS has matched an entry, decrypted the password, and is
// telling Delphi to type username + Tab + password into the saved HWND.
else if ACmd = 'autofill/execute' then
begin
FAutofillPendingUser := GetParam('username');
FAutofillPendingPass := GetParam('password');
FAutofillPendingHWND := FAutofillTargetHWND;
FAutofillTargetHWND := 0;
// Small timer so SetForegroundWindow has time to take effect before
// SendInput fires — avoids the first keystrokes going to our window.
// TTimer.OnTimer is a TNotifyEvent (method, not anon proc) → we use a
// dedicated method on the form and stash the payload in fields.
var LTimer := TTimer.Create(Self);
LTimer.Interval := 60;
LTimer.OnTimer := AutofillTimerTick;
LTimer.Enabled := True;
end
// cancel: JS found no match or user dismissed the picker — nothing to type.
else if ACmd = 'autofill/cancel' then
begin
FAutofillTargetHWND := 0;
LogLine('Autofill cancelled (no match or dismissed)');
end
// focus: JS asks us to bring the main window to front (e.g. when the
// autofill picker opens — without this the picker is shown in the
// WebView but the user might not notice if our window was minimised
// or behind other apps). The Target HWND stays saved; ExecuteAutofill
// restores it later via ForceForegroundWindow.
else if ACmd = 'app/focus' then
begin
FBridge.RestoreFromTray;
LogLine('App brought to front (autofill picker)');
end
else if ACmd = 'app/ready' then
begin
WebBrowser.SetFocus;
WebBrowser.ExecuteJavaScript(
'setTimeout(()=>{var u=document.getElementById("loginUsername"),' +
'p=document.getElementById("loginPassword");' +
'if(u&&u.value){p&&p.focus();}else{u&&u.focus();}},0)');
end
else if ACmd = 'app/theme' then
FBridge.ApplyTitleBarTheme(GetParam('mode') = 'dark')
// ---- Device-bound prefs (DPAPI key/value) ----------------------------
// Used for prefs that must survive the ephemeral-port reset of the
// WebView2 localStorage (rememberedUsername, etc.).
else if ACmd = 'prefs/get' then
begin
var LKey := GetParam('key');
if LKey = '' then Exit;
var LVal := PM.UserPrefs.GetPref(LKey);
var LEscapedKey := StringReplace(LKey, '\', '\\', [rfReplaceAll]);
LEscapedKey := StringReplace(LEscapedKey, '"', '\"', [rfReplaceAll]);
var LEscapedVal := StringReplace(LVal, '\', '\\', [rfReplaceAll]);
LEscapedVal := StringReplace(LEscapedVal, '"', '\"', [rfReplaceAll]);
LEscapedVal := StringReplace(LEscapedVal, #13, '\r', [rfReplaceAll]);
LEscapedVal := StringReplace(LEscapedVal, #10, '\n', [rfReplaceAll]);
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&Bridge.onPrefResult)' +
'Bridge.onPrefResult("' + LEscapedKey + '","' + LEscapedVal + '")');
end
else if ACmd = 'prefs/set' then
begin
var LKey := GetParam('key');
if LKey = '' then Exit;
PM.UserPrefs.SetPref(LKey, GetParam('value'));
end
else
LogLine('Bridge: unknown command "' + ACmd + '"');
end;
procedure TMainForm.BridgeSystemLock;
begin
// Windows session locked — lock the vault in the JS layer immediately.
LogLine('Windows session locked — locking vault');
WebBrowser.ExecuteJavaScript('if(typeof lockVault==="function")lockVault()');
// Windows session locked or system suspending — delegate to Bridge.onSystemLock
// in JS which honours the "quick unlock" opt-out (DPAPI already gates
// access via the Windows account, so re-locking on top of Windows lock
// is redundant for users who enabled it).
LogLine('Windows session locked / suspend — notifying JS');
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&typeof Bridge.onSystemLock==="function")Bridge.onSystemLock();' +
'else if(typeof lockVault==="function")lockVault()');
end;
procedure TMainForm.BridgeTrayRestore;
@@ -375,4 +600,48 @@ begin
Application.Terminate;
end;
procedure TMainForm.AutofillTimerTick(Sender: TObject);
var
TargetHwnd: HWND;
PendingUser, PendingPass: string;
ForegroundAfter: HWND;
begin
TargetHwnd := FAutofillPendingHWND;
PendingUser := FAutofillPendingUser;
PendingPass := FAutofillPendingPass;
FAutofillPendingHWND := 0;
FAutofillPendingUser := '';
FAutofillPendingPass := '';
TTimer(Sender).Enabled := False;
TTimer(Sender).Free;
FBridge.ExecuteAutofill(TargetHwnd, PendingUser, PendingPass);
ForegroundAfter := GetForegroundWindow;
LogLine(Format('Autofill executed — target=%s, foreground_after=%s, match=%s',
[IntToHex(TargetHwnd, 8), IntToHex(ForegroundAfter, 8),
BoolToStr(ForegroundAfter = TargetHwnd, True)]));
end;
procedure TMainForm.BridgeAutofillRequest(AKind: TAutofillKind;
ATargetHWND: HWND; const ATitle: string);
var
LTitle, LKind: string;
begin
if not FServer.Active then Exit;
FAutofillTargetHWND := ATargetHWND;
// Escape the title for safe injection into a JS string literal.
LTitle := ATitle;
LTitle := LTitle.Replace('\', '\\');
LTitle := LTitle.Replace('"', '\"');
if AKind = akPasswordOnly then LKind := 'password' else LKind := 'full';
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&typeof Bridge.onAutofillRequest==="function")' +
'Bridge.onAutofillRequest("' + LTitle + '","' + LKind + '")');
LogLine('Autofill hotkey (' + LKind + ') — foreground: "' + ATitle + '"');
end;
end.
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.
+43
View File
@@ -0,0 +1,43 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"
shape-rendering="crispEdges">
<!-- Pixel-perfect hexagon: vertices at integer coords -->
<!-- Strategy: paint hex row by row as 1px tall rects (no diagonals) -->
<!-- Row 1 (y=0): tip 4px wide -->
<rect x="6" y="0" width="4" height="1" fill="#0891b2"/>
<!-- Row 2 (y=1): 6px -->
<rect x="5" y="1" width="6" height="1" fill="#0891b2"/>
<!-- Row 3 (y=2): 8px -->
<rect x="4" y="2" width="8" height="1" fill="#06b6d4"/>
<!-- Row 4 (y=3): 10px -->
<rect x="3" y="3" width="10" height="1" fill="#06b6d4"/>
<!-- Row 5 (y=4): 12px -->
<rect x="2" y="4" width="12" height="1" fill="#06b6d4"/>
<!-- Rows 5-10 (y=5..10): full 14px wide body -->
<rect x="1" y="5" width="14" height="6" fill="#06b6d4"/>
<!-- Row 11 (y=11): 12px -->
<rect x="2" y="11" width="12" height="1" fill="#06b6d4"/>
<!-- Row 12: 10px -->
<rect x="3" y="12" width="10" height="1" fill="#06b6d4"/>
<!-- Row 13: 8px -->
<rect x="4" y="13" width="8" height="1" fill="#0891b2"/>
<!-- Row 14: 6px -->
<rect x="5" y="14" width="6" height="1" fill="#0891b2"/>
<!-- Row 15: tip 4px -->
<rect x="6" y="15" width="4" height="1" fill="#0891b2"/>
<!-- Center dot: 4x4 dark square + 2x2 purple accent -->
<rect x="6" y="6" width="4" height="4" fill="#0f172a"/>
<rect x="7" y="7" width="2" height="2" fill="#a78bfa"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+61
View File
@@ -0,0 +1,61 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs>
<linearGradient id="hexFill" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#0e7490"/>
<stop offset="0.5" stop-color="#06b6d4"/>
<stop offset="1" stop-color="#22d3ee"/>
</linearGradient>
<linearGradient id="hexHighlight" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.35"/>
<stop offset="0.5" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="1" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<radialGradient id="keyholeGlow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#a78bfa" stop-opacity="0.6"/>
<stop offset="0.6" stop-color="#a78bfa" stop-opacity="0.15"/>
<stop offset="1" stop-color="#a78bfa" stop-opacity="0"/>
</radialGradient>
<filter id="dropHex" x="-15%" y="-15%" width="130%" height="130%">
<feDropShadow dx="0" dy="6" stdDeviation="10"
flood-color="#06b6d4" flood-opacity="0.5"/>
</filter>
</defs>
<!-- Hexagon body — pointy-top orientation -->
<g filter="url(#dropHex)">
<path d="M 128 8
L 230 66
V 190
L 128 248
L 26 190
V 66 Z"
fill="url(#hexFill)"
stroke="#0e7490" stroke-width="3" stroke-linejoin="round"/>
</g>
<!-- Top highlight band -->
<path d="M 128 8
L 230 66
V 128
L 128 100
L 26 128
V 66 Z"
fill="url(#hexHighlight)"/>
<!-- Glow around keyhole -->
<circle cx="128" cy="140" r="68" fill="url(#keyholeGlow)"/>
<!-- Inner panel (darker hex) -->
<path d="M 128 50
L 196 90
V 166
L 128 206
L 60 166
V 90 Z"
fill="#0f172a" fill-opacity="0.55"/>
<!-- Keyhole — circle + tapered slit -->
<circle cx="128" cy="120" r="18" fill="#22d3ee"/>
<path d="M 120 132 L 116 172 L 140 172 L 136 132 Z" fill="#22d3ee"/>
<circle cx="128" cy="120" r="7" fill="#0f172a"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+49
View File
@@ -0,0 +1,49 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs>
<linearGradient id="keyBow" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#22d3ee"/>
<stop offset="1" stop-color="#0891b2"/>
</linearGradient>
<linearGradient id="keyStem" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#06b6d4"/>
<stop offset="1" stop-color="#0e7490"/>
</linearGradient>
<radialGradient id="bowHole" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#a78bfa"/>
<stop offset="0.7" stop-color="#7c3aed"/>
<stop offset="1" stop-color="#5b21b6"/>
</radialGradient>
<filter id="dropK" x="-15%" y="-15%" width="130%" height="130%">
<feDropShadow dx="0" dy="6" stdDeviation="8" flood-opacity="0.4"/>
</filter>
</defs>
<g filter="url(#dropK)">
<!-- Key bow (hexagonal head) -->
<path d="M 128 24
L 196 64
V 144
L 128 184
L 60 144
V 64 Z"
fill="url(#keyBow)"
stroke="#0e7490" stroke-width="3" stroke-linejoin="round"/>
<!-- Stem -->
<rect x="114" y="170" width="28" height="60" rx="6"
fill="url(#keyStem)"/>
<!-- Tooth 1 -->
<rect x="142" y="190" width="22" height="12" rx="3"
fill="url(#keyStem)"/>
<!-- Tooth 2 (smaller) -->
<rect x="142" y="212" width="14" height="10" rx="3"
fill="url(#keyStem)"/>
</g>
<!-- Center hole in bow (with accent glow) -->
<circle cx="128" cy="104" r="26" fill="url(#bowHole)"/>
<circle cx="128" cy="104" r="14" fill="#0f172a"/>
<circle cx="128" cy="104" r="6" fill="#a78bfa" opacity="0.7"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+41
View File
@@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs>
<linearGradient id="shieldFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#22d3ee"/>
<stop offset="0.5" stop-color="#06b6d4"/>
<stop offset="1" stop-color="#0891b2"/>
</linearGradient>
<linearGradient id="shieldGloss" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.30"/>
<stop offset="1" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<filter id="dropS" x="-10%" y="-10%" width="120%" height="120%">
<feDropShadow dx="0" dy="6" stdDeviation="8" flood-opacity="0.35"/>
</filter>
</defs>
<!-- Free-form shield silhouette, no container -->
<g filter="url(#dropS)">
<path d="M 128 16
L 224 56
V 134
C 224 184, 184 224, 128 244
C 72 224, 32 184, 32 134
V 56 Z"
fill="url(#shieldFill)"
stroke="#0e7490" stroke-width="3"/>
</g>
<!-- Top gloss -->
<path d="M 128 16
L 224 56
V 124
C 184 138, 72 138, 32 124
V 56 Z"
fill="url(#shieldGloss)"/>
<!-- Keyhole -->
<circle cx="128" cy="116" r="22" fill="#0f172a"/>
<path d="M 117 130 L 113 184 L 143 184 L 139 130 Z" fill="#0f172a"/>
<circle cx="128" cy="116" r="10" fill="#a78bfa"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+49
View File
@@ -0,0 +1,49 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs>
<radialGradient id="bodyV" cx="0.5" cy="0.35" r="0.7">
<stop offset="0" stop-color="#475569"/>
<stop offset="1" stop-color="#1e293b"/>
</radialGradient>
<radialGradient id="dial" cx="0.5" cy="0.4" r="0.6">
<stop offset="0" stop-color="#f1f5f9"/>
<stop offset="1" stop-color="#94a3b8"/>
</radialGradient>
<linearGradient id="accentV" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#22d3ee"/>
<stop offset="1" stop-color="#06b6d4"/>
</linearGradient>
<filter id="dropV" x="-10%" y="-10%" width="120%" height="120%">
<feDropShadow dx="0" dy="4" stdDeviation="6" flood-opacity="0.35"/>
</filter>
</defs>
<!-- Vault body — circle with radial gradient, no container -->
<g filter="url(#dropV)">
<circle cx="128" cy="128" r="120" fill="url(#bodyV)"/>
<circle cx="128" cy="128" r="120" fill="none"
stroke="#0f172a" stroke-width="2"/>
</g>
<!-- Inner dial bezel -->
<circle cx="128" cy="128" r="88" fill="#334155"/>
<circle cx="128" cy="128" r="80" fill="url(#dial)"/>
<!-- Tick marks -->
<g stroke="#475569" stroke-width="3" stroke-linecap="round">
<line x1="128" y1="54" x2="128" y2="64"/>
<line x1="128" y1="192" x2="128" y2="202"/>
<line x1="54" y1="128" x2="64" y2="128"/>
<line x1="192" y1="128" x2="202" y2="128"/>
<line x1="76" y1="76" x2="83" y2="83"/>
<line x1="180" y1="76" x2="173" y2="83"/>
<line x1="76" y1="180" x2="83" y2="173"/>
<line x1="180" y1="180" x2="173" y2="173"/>
</g>
<!-- Center hub -->
<circle cx="128" cy="128" r="28" fill="url(#accentV)"/>
<circle cx="128" cy="128" r="10" fill="#0f172a"/>
<!-- Dial pointer -->
<rect x="124" y="60" width="8" height="34" rx="3" fill="url(#accentV)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+29
View File
@@ -0,0 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#0891b2"/>
<stop offset="1" stop-color="#06b6d4"/>
</linearGradient>
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#a78bfa"/>
<stop offset="1" stop-color="#7c3aed"/>
</linearGradient>
</defs>
<!-- Rounded square background -->
<rect x="0" y="0" width="256" height="256" rx="56" fill="url(#bg)"/>
<!-- Shackle -->
<path d="M 88 110 V 84 a 40 40 0 0 1 80 0 V 110"
fill="none" stroke="#ffffff" stroke-width="20"
stroke-linecap="round"/>
<!-- Lock body -->
<rect x="64" y="108" width="128" height="100" rx="18"
fill="#ffffff"/>
<!-- Three "password" dots -->
<circle cx="96" cy="158" r="11" fill="url(#accent)"/>
<circle cx="128" cy="158" r="11" fill="url(#accent)"/>
<circle cx="160" cy="158" r="11" fill="url(#accent)"/>
</svg>

After

Width:  |  Height:  |  Size: 1011 B

+218
View File
@@ -0,0 +1,218 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🔐 Vault — Legacy UI</title>
<link rel="stylesheet" href="css/style-legacy.css">
</head>
<body>
<div class="toast-container" id="toastContainer"></div>
<div class="idle-warning" id="idleWarning">
<h3>⏰ Auto-lock</h3>
<p>Vault locks in <span id="idleCountdown">30</span>s</p>
<button class="btn btn-sm" onclick="resetIdle()">Stay Unlocked</button>
</div>
<div class="edit-modal" id="editModal">
<div class="edit-box">
<h3>✏️ Edit Entry</h3>
<label>Website / App</label><input type="text" id="editSite" placeholder="example.com">
<label style="margin-top:.6rem">Username / Email</label><input type="text" id="editUsername" placeholder="user@example.com">
<label style="margin-top:.6rem">Password</label>
<div style="position:relative">
<input type="password" id="editPassword" placeholder="Password" style="width:100%;padding-right:40px">
<button type="button" onclick="toggleEditPassword()" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--text2);cursor:pointer;font-size:.9rem">👁️</button>
</div>
<label style="margin-top:.6rem">Folder</label>
<select id="editFolder" style="width:100%;margin-bottom:.4rem"></select>
<input type="hidden" id="editId">
<div style="display:flex;gap:.5rem;margin-top:1rem">
<button class="btn" onclick="saveEdit()">💾 Save</button>
<button class="btn btn-outline" onclick="closeEdit()">Cancel</button>
</div>
</div>
</div>
<!-- Add Password Modal -->
<div class="modal-overlay" id="addModal">
<div class="modal-box">
<h3> New Password</h3>
<label>Website / App</label><input type="text" id="addSite" placeholder="example.com">
<label style="margin-top:.6rem">Username / Email (optional)</label><input type="text" id="addUsername" placeholder="user@example.com">
<label style="margin-top:.6rem">Password</label>
<div style="position:relative">
<input type="password" id="addPassword" placeholder="Password" style="width:100%;padding-right:40px" oninput="checkAddStrength()">
<button type="button" onclick="openGen()" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--text2);cursor:pointer;font-size:1.1rem;line-height:1" title="Generate">🎲</button>
</div>
<div class="strength-bar s0" id="addStrengthBar" style="margin-top:0.2rem;"></div>
<label style="margin-top:.6rem">Folder</label>
<select id="addFolder" style="width:100%;margin-bottom:.4rem"></select>
<div style="display:flex;gap:.5rem;margin-top:1rem">
<button class="btn" id="addEntryBtn" onclick="addEntry()">💾 Save</button>
<button class="btn btn-outline" onclick="closeAdd()">Cancel</button>
</div>
</div>
</div>
<!-- Floating action buttons -->
<button class="fab" onclick="openAdd()" title="Add password">+</button>
<button class="fab fab-trash" id="trashBtn" onclick="toggleTrash()" title="Trash">🗑️</button>
<div class="vault">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.6rem">
<h1 style="margin-bottom:0">🔐 Vault <span>XAMPP</span></h1>
<button class="btn btn-outline btn-xs" onclick="toggleTheme()" title="Toggle theme">🌓</button>
</div>
<!-- Auth Section -->
<div id="authSection" class="auth-section">
<div class="auth-tabs">
<button class="auth-tab active" onclick="switchTab('login')">Login</button>
<button class="auth-tab" onclick="switchTab('register')">Register</button>
</div>
<div id="loginForm">
<div class="input-group">
<input type="text" id="loginUsername" placeholder="Username" autocomplete="off">
<input type="password" id="loginPassword" placeholder="Master Password" autocomplete="off">
<button class="btn" id="loginBtn" onclick="login()">🔓 Unlock</button>
<button class="btn btn-outline btn-sm" onclick="loginWithPasskey()" title="Use passkey" style="white-space:nowrap">🔐 Passkey</button>
</div>
</div>
<div id="registerForm" class="hidden">
<div class="input-group">
<input type="text" id="regUsername" placeholder="Username (min 3)" autocomplete="off">
<input type="password" id="regPassword" placeholder="Password (min 8)" autocomplete="off" oninput="checkRegStrength()">
<button class="btn" id="registerBtn" onclick="register()">✨ Create</button>
</div>
<div class="strength-bar s0" id="regStrengthBar" style="margin-top:0.4rem;margin-bottom:0.2rem;flex-basis:100%"></div>
</div>
</div>
<!-- Vault Section -->
<div id="vaultSection" class="hidden">
<!-- Top bar -->
<div style="display:flex;justify-content:space-between;margin-bottom:.5rem;align-items:center;flex-wrap:wrap;gap:.4rem">
<span class="status-badge" id="connectionStatus">🟢 Connected</span>
<span id="entryCount" style="color:var(--text2);font-size:.8rem"></span>
<div style="display:flex;gap:.4rem;align-items:center">
<span id="currentUser" style="color:var(--text2);font-size:.8rem"></span>
<div class="settings-dropdown">
<button class="btn btn-outline btn-sm" id="settingsBtn" onclick="toggleSettings()">⚙️</button>
<div class="settings-menu hidden" id="settingsMenu">
<div class="settings-item">
<span>Auto-lock</span>
<select id="autoLockTimer" onchange="setAutoLock()">
<option value="0">No lock</option>
<option value="1">1 min</option>
<option value="5" selected>5 min</option>
<option value="15">15 min</option>
<option value="30">30 min</option>
<option value="60">1 hour</option>
</select>
</div>
<div class="settings-item" onclick="toggleSound();event.stopPropagation();">
<span>Sound</span>
<div class="toggle-switch" id="soundToggleSwitch"></div>
</div>
<div class="settings-item" onclick="toggleTheme();event.stopPropagation();">
<span>Theme</span>
<div class="toggle-switch" id="themeToggleSwitch"></div>
</div>
<div class="settings-item" onclick="toggleViewBtn();event.stopPropagation();">
<span>👁️ View password</span>
<div class="toggle-switch active" id="showViewBtnToggle"></div>
</div>
<div class="settings-item" onclick="toggleShowEmail();event.stopPropagation();">
<span>📧 Show email</span>
<div class="toggle-switch active" id="showEmailToggle"></div>
</div>
<div class="settings-item" onclick="if(window.PublicKeyCredential){registerPasskey()}else{toast('Passkeys not supported','error')};event.stopPropagation();">
<span>🔐 Set up passkey</span>
<span style="color:var(--accent);font-size:0.7rem"></span>
</div>
</div>
</div>
<button class="btn btn-outline btn-sm" onclick="doLogout()">🔒 Lock</button>
</div>
</div>
<!-- Add Entry Form -->
<!-- <form onsubmit="return false" class="input-group" autocomplete="off"> -->
<!-- <input type="text" id="siteInput" placeholder="Website *" autocomplete="off"> -->
<!-- <input type="text" id="usernameInput" placeholder="Username (optional)" autocomplete="off"> -->
<!-- <div style="flex:1;min-width:130px;position:relative"> -->
<!-- <input type="password" id="passwordInput" placeholder="Password *" autocomplete="new-password" style="width:100%;padding-right:40px" oninput="checkStrength()"> -->
<!-- <button type="button" onclick="openGen()" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--text2);cursor:pointer;font-size:1.1rem;line-height:1" title="Generate password">🎲</button> -->
<!-- </div> -->
<!-- <div class="strength-bar s0" id="strengthBar" style="flex-basis:100%;margin-top:-0.3rem;margin-bottom:0.3rem"></div> -->
<!-- <select id="addFolderSelect"></select> -->
<!-- <button type="button" class="btn" id="addBtn" onclick="addEntry()"> Add</button> -->
<!-- </form> -->
<!-- Toolbar with search + trash + views -->
<div class="toolbar">
<div class="search-box">
<input type="text" id="searchInput" placeholder="🔍 Search..." oninput="searchEntries()" autocomplete="off">
<button type="button" class="clear-search-btn" id="clearSearchBtn" onclick="clearSearch()" title="Clear search"></button>
</div>
<div style="display:flex;gap:.4rem;align-items:center;flex-wrap:wrap">
<button class="btn btn-outline btn-sm" onclick="showShortcutsHelp()" title="Shortcuts">⌨️</button>
<div class="view-dropdown" id="viewDropdown">
<button class="btn btn-outline btn-sm" id="viewDropdownBtn">🟫 Grid ▾</button>
<div class="view-dropdown-menu hidden" id="viewDropdownMenu">
<button class="view-opt" data-view="grid">🟫 Grid</button>
<button class="view-opt" data-view="compact">📝 Compact</button>
<button class="view-opt" data-view="list">📋 List</button>
<button class="view-opt" data-view="table">📊 Table</button>
<button class="view-opt" data-view="card">🃏 Card</button>
<button class="view-opt" data-view="grouped">📂 Grouped</button>
<button class="view-opt" data-view="detail">🔍 Detail</button>
</div>
</div>
</div>
</div>
<div class="folders-bar" id="foldersBar"></div>
<div id="trashActions" class="hidden" style="margin-bottom:.5rem;text-align:right;">
<button class="empty-trash-btn" onclick="emptyTrash()">🗑️ Empty Trash</button>
</div>
<div id="entriesContainer" class="grid-view">
<div style="text-align:center;color:var(--text2);padding:2rem;grid-column:1/-1">📭 No entries</div>
</div>
<div style="margin-top:.6rem;text-align:right">
<button class="btn btn-outline btn-sm" onclick="showExportModal()">📤 Export</button>
</div>
</div>
</div>
<!-- Generator Modal -->
<div id="genModal" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);display:none;justify-content:center;align-items:center;z-index:2000">
<div style="background:var(--bg2);border-radius:1.5rem;padding:1.5rem;min-width:320px;max-width:90%">
<h3 style="margin-bottom:.8rem;color:var(--text)">🎲 Generator</h3>
<div style="background:var(--input);padding:.8rem;border-radius:1rem;font-family:monospace;text-align:center;color:#4ade80;margin:.6rem 0;word-break:break-all" id="genPreview">Click Generate</div>
<div style="display:flex;align-items:center;gap:.6rem;margin:.8rem 0">
<span>Length:</span>
<input type="range" id="pwdLen" min="8" max="64" value="16" oninput="onLenChange()" style="flex:1">
<span id="lenVal" style="background:var(--input);padding:.2rem .6rem;border-radius:1rem;min-width:30px;text-align:center">16</span>
</div>
<div style="display:flex;flex-wrap:wrap;gap:.5rem;margin:.5rem 0">
<label style="font-size:.8rem;color:var(--text2)"><input type="checkbox" id="useUpper" checked onchange="genPwd()"> A-Z</label>
<label style="font-size:.8rem;color:var(--text2)"><input type="checkbox" id="useLower" checked onchange="genPwd()"> a-z</label>
<label style="font-size:.8rem;color:var(--text2)"><input type="checkbox" id="useNum" checked onchange="genPwd()"> 0-9</label>
<label style="font-size:.8rem;color:var(--text2)"><input type="checkbox" id="useSym" checked onchange="genPwd()"> !@#$</label>
</div>
<div style="display:flex;gap:0.3rem;margin-top:0.8rem;flex-wrap:wrap">
<button class="btn btn-sm" onclick="genPreset(16,'upper+lower+num+sym')" style="flex:1;min-width:70px">🔒 Strong 16</button>
<button class="btn btn-sm" onclick="genPreset(20,'upper+lower+num+sym')" style="flex:1;min-width:70px">🔒 Strong 20</button>
<button class="btn btn-sm btn-danger" onclick="genPreset(32,'upper+lower+num+sym')" style="flex:1;min-width:70px">🛡️ Paranoid 32</button>
</div>
<div style="display:flex;gap:.4rem;margin-top:.6rem">
<button class="btn" onclick="genPwd()" style="flex:1">🔄</button>
<button class="btn" onclick="useGen()" style="flex:1">✅ Use</button>
<button class="btn btn-outline" onclick="closeGen()">Cancel</button>
</div>
</div>
</div>
<script src="js/app-legacy.js"></script>
</body>
</html>
+178 -13
View File
@@ -38,7 +38,11 @@
<symbol id="i-log-out" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></symbol>
<symbol id="i-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></symbol>
<symbol id="i-list" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></symbol>
<symbol id="i-table" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="1"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></symbol>
<symbol id="i-more" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1.4" fill="currentColor"/><circle cx="12" cy="12" r="1.4" fill="currentColor"/><circle cx="12" cy="19" r="1.4" fill="currentColor"/></symbol>
<symbol id="i-chevron-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></symbol>
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></symbol>
<symbol id="i-key" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-2 2 2 2-3 3-2-2"/></symbol>
<symbol id="i-user" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol>
<symbol id="i-empty-vault" viewBox="0 0 120 120" fill="none">
<circle cx="60" cy="60" r="50" fill="var(--accent-soft)"/>
@@ -99,7 +103,16 @@
</label>
<label class="field">
<span>Master password</span>
<input id="loginPassword" type="password" required>
<div class="input-with-action">
<input id="loginPassword" type="password" required>
<button type="button" class="icon-btn" id="loginPwToggle" title="Show/hide password">
<svg><use href="#i-eye"/></svg>
</button>
</div>
</label>
<label class="remember-row">
<input type="checkbox" id="loginRememberUser">
<span>Remember username on this device</span>
</label>
<button id="loginBtn" type="submit" class="btn btn-primary btn-block">
<svg><use href="#i-unlock"/></svg>
@@ -157,28 +170,51 @@
</button>
</nav>
<div class="sidebar-section">
<div class="sidebar-section" data-section="folders">
<div class="sidebar-section-header">
<span>Folders</span>
<button class="section-toggle" data-section-toggle="folders">
<svg class="section-chevron"><use href="#i-chevron-down"/></svg>
<span>Folders</span>
<span class="section-count" id="countFolders">0</span>
</button>
<button class="icon-btn icon-btn-sm" id="addFolderBtn" title="New folder">
<svg><use href="#i-plus"/></svg>
</button>
</div>
<nav class="sidebar-nav" id="foldersList"></nav>
<nav class="sidebar-nav section-body" id="foldersList"></nav>
</div>
<div class="sidebar-section">
<div class="sidebar-section-header"><span>Tags</span></div>
<nav class="sidebar-nav" id="tagsList"></nav>
<div class="sidebar-section" data-section="tags">
<div class="sidebar-section-header">
<button class="section-toggle" data-section-toggle="tags">
<svg class="section-chevron"><use href="#i-chevron-down"/></svg>
<span>Tags</span>
<span class="section-count" id="countTags">0</span>
</button>
</div>
<nav class="sidebar-nav section-body" id="tagsList"></nav>
</div>
<div class="sidebar-section">
<div class="sidebar-section-header"><span>Tools</span></div>
<nav class="sidebar-nav">
<div class="sidebar-section" data-section="tools">
<div class="sidebar-section-header">
<button class="section-toggle" data-section-toggle="tools">
<svg class="section-chevron"><use href="#i-chevron-down"/></svg>
<span>Tools</span>
</button>
</div>
<nav class="sidebar-nav section-body">
<button class="nav-item" id="sidebarGenBtn">
<svg><use href="#i-dice"/></svg>
<span>Generator</span>
</button>
<button class="nav-item" id="sidebarAuthenticatorBtn">
<svg><use href="#i-shield"/></svg>
<span>Authenticator</span>
</button>
<button class="nav-item" id="sidebarTotpToolBtn">
<svg><use href="#i-key"/></svg>
<span>TOTP generator</span>
</button>
<button class="nav-item" id="sidebarImportBtn">
<svg><use href="#i-log-in"/></svg>
<span>Import vault</span>
@@ -219,6 +255,9 @@
<button class="view-btn" id="viewListBtn" data-view="list" title="List view">
<svg><use href="#i-list"/></svg>
</button>
<button class="view-btn" id="viewTableBtn" data-view="table" title="Table view">
<svg><use href="#i-table"/></svg>
</button>
</div>
<button class="icon-btn" id="themeBtn" title="Toggle theme">
<svg class="theme-icon theme-icon-dark"><use href="#i-sun"/></svg>
@@ -233,6 +272,9 @@
<span id="userName">user</span>
</button>
<div class="user-dropdown is-hidden" id="userDropdown">
<button class="dropdown-item" id="dropdownSettingsBtn">
<svg><use href="#i-settings"/></svg> Settings
</button>
<button class="dropdown-item" id="lockBtn">
<svg><use href="#i-lock"/></svg> Lock vault
</button>
@@ -287,6 +329,17 @@
<option value="light">Light</option>
</select>
</div>
<div class="setting-row">
<span>Sort entries by</span>
<select id="settingSort">
<option value="name:asc">Name (A → Z)</option>
<option value="name:desc">Name (Z → A)</option>
<option value="updated:desc">Most recent first</option>
<option value="updated:asc">Oldest update first</option>
<option value="created:desc">Recently created</option>
<option value="created:asc">Oldest created</option>
</select>
</div>
<div class="setting-row">
<span>Compact action menu (⋯)</span>
<label class="toggle">
@@ -301,6 +354,16 @@
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-row">
<span>
Show site / URL
<small class="setting-hint">On cards: shown under the display name when the entry has a custom title. In table view: adds a Site column.</small>
</span>
<label class="toggle">
<input type="checkbox" id="settingShowSite">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="slideover-field">
@@ -333,6 +396,39 @@
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-row" id="settingAutofillRow">
<span>
Autofill with global hotkey
<small class="setting-hint">
Press your hotkey in any browser to fill the matching
login. The vault must be unlocked.
</small>
</span>
<label class="toggle">
<input type="checkbox" id="settingAutofill">
<span class="toggle-slider"></span>
</label>
</div>
<div id="settingAutofillHotkeysRow" style="margin-top:8px">
<div class="setting-row">
<span style="font-size:12px;color:var(--text-dim)">
Full fill (username + Tab + password)
</span>
<button class="btn btn-ghost btn-sm hotkey-capture-btn" id="settingAutofillFullCombo">Ctrl+Shift+L</button>
</div>
<div class="setting-row">
<span style="font-size:12px;color:var(--text-dim)">
Password only (for step-2 forms / unlock screens)
</span>
<button class="btn btn-ghost btn-sm hotkey-capture-btn" id="settingAutofillPwdCombo">Ctrl+Shift+P</button>
</div>
<p style="font-size:11px;color:var(--text-faint);margin:6px 0 0;line-height:1.4">
Click a button, then press your new combo. Needs Ctrl, Alt or Win + a letter / digit / F-key.
</p>
<button class="btn btn-ghost btn-sm" id="settingAutofillResetHotkeys" style="margin-top:6px">
Reset to defaults
</button>
</div>
</div>
<div class="slideover-field">
@@ -451,7 +547,7 @@
<div class="modal-backdrop" data-close></div>
<div class="modal-panel modal-panel-sm">
<header class="modal-header">
<h3>Change master password</h3>
<h3 id="changeMasterTitle">Change master password</h3>
<button class="icon-btn" data-close><svg><use href="#i-x"/></svg></button>
</header>
<form id="changeMasterForm" class="modal-body" autocomplete="off">
@@ -497,8 +593,12 @@
<form id="entryForm" class="modal-body" autocomplete="off">
<input type="hidden" id="entryId">
<label class="field">
<span>Website / App</span>
<input id="entrySite" type="text" required placeholder="example.com">
<span>Display name <small style="color:var(--text-faint);font-weight:400">— shown on cards, also used for autofill matching</small></span>
<input id="entryTitle" type="text" placeholder="GitHub (work account)">
</label>
<label class="field">
<span>Website / App <small style="color:var(--text-faint);font-weight:400">— hostname or brand, used for autofill matching</small></span>
<input id="entrySite" type="text" required placeholder="github.com">
</label>
<label class="field">
<span>Username / Email</span>
@@ -565,6 +665,49 @@
</div>
</div>
<!-- ============================================================ -->
<!-- MODAL: Standalone TOTP generator (paste secret → live code) -->
<!-- ============================================================ -->
<div id="totpToolModal" class="modal is-hidden" role="dialog" aria-modal="true">
<div class="modal-backdrop" data-close></div>
<div class="modal-panel modal-panel-sm">
<header class="modal-header">
<h3>TOTP generator</h3>
<button class="icon-btn" data-close><svg><use href="#i-x"/></svg></button>
</header>
<div class="modal-body">
<p style="margin:0 0 12px;color:var(--text-dim);font-size:13px;line-height:1.5">
Paste a base32 secret or an <code>otpauth://</code> URI to
generate a code on the fly. Nothing is saved.
</p>
<label class="field">
<span>Secret</span>
<input id="totpToolSecret" type="text" autocomplete="off"
placeholder="JBSWY3DPEHPK3PXP or otpauth://totp/...">
<div style="display:flex;gap:6px;margin-top:6px">
<button type="button" class="btn btn-ghost btn-sm" id="totpToolGen" style="flex:1">
<svg><use href="#i-dice"/></svg> Generate
</button>
<button type="button" class="btn btn-ghost btn-sm" id="totpToolCopySecret" style="flex:1">
<svg><use href="#i-copy"/></svg> Copy secret
</button>
</div>
</label>
<div class="totp-panel" style="margin-top:12px">
<div class="totp-code" id="totpToolCode">— — — — — —</div>
<button class="icon-btn icon-btn-sm" id="totpToolCopy" title="Copy code">
<svg><use href="#i-copy"/></svg>
</button>
</div>
<div class="totp-bar-wrap"><div class="totp-bar" id="totpToolBar"></div></div>
<p id="totpToolError" style="margin:8px 0 0;color:#dc2626;font-size:12px;display:none"></p>
</div>
<footer class="modal-footer">
<button type="button" class="btn btn-ghost" data-close>Close</button>
</footer>
</div>
</div>
<!-- ============================================================ -->
<!-- CONFIRM / PROMPT MODAL (replaces native confirm/prompt) -->
<!-- ============================================================ -->
@@ -592,6 +735,28 @@
</div>
</div>
<!-- ============================================================ -->
<!-- AUTOFILL PICKER — shown when multiple entries match hotkey -->
<!-- ============================================================ -->
<div id="autofillPickerModal" class="modal is-hidden" role="dialog" aria-modal="true">
<div class="modal-backdrop" data-close></div>
<div class="modal-panel modal-panel-sm">
<header class="modal-header">
<h3 id="autofillPickerTitle">Choose an entry to autofill</h3>
<button class="icon-btn" data-close><svg><use href="#i-x"/></svg></button>
</header>
<div class="modal-body" style="padding:8px 0">
<p style="margin:0 0 8px 16px;font-size:12px;color:var(--text-dim)">
Click an entry to type its credentials into the active window.
</p>
<div id="autofillPickerList"></div>
</div>
<footer class="modal-footer">
<button class="btn btn-ghost" data-close>Cancel</button>
</footer>
</div>
</div>
<!-- ============================================================ -->
<!-- COMMAND PALETTE (Cmd+K) — phase 3, shell only for now -->
<!-- ============================================================ -->
+1550
View File
File diff suppressed because it is too large Load Diff
+1824 -157
View File
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
<?php
// Diagnostic tool: compare what PHP's hash_pbkdf2 produces vs what Delphi stored.
// Usage: http://localhost/password-manager/test_pbkdf2.php?u=YOURUSER&p=YOURPASSWORD
//
// DELETE THIS FILE after diagnosis — it exposes hashes/salts in plaintext.
header('Content-Type: text/plain; charset=utf-8');
$u = $_GET['u'] ?? '';
$p = $_GET['p'] ?? '';
if (!$u || !$p) { echo "Provide ?u=USER&p=PASSWORD\n"; exit; }
$db = new SQLite3(__DIR__ . '/vault.db');
$db->busyTimeout(5000);
$st = $db->prepare('SELECT id, username, password_hash, salt, hash_algo FROM users WHERE username=:u');
$st->bindValue(':u', $u, SQLITE3_TEXT);
$row = $st->execute()->fetchArray(SQLITE3_ASSOC);
if (!$row) { echo "User '$u' not found.\n"; exit; }
echo "=== Stored in vault.db ===\n";
echo "id : " . $row['id'] . "\n";
echo "username : " . $row['username'] . "\n";
echo "hash_algo : " . ($row['hash_algo'] ?? '(null)') . "\n";
echo "salt : " . $row['salt'] . "\n";
echo "salt len : " . strlen($row['salt']) . "\n";
echo "stored hash : " . $row['password_hash'] . "\n";
echo "stored len : " . strlen($row['password_hash']) . "\n\n";
$algo = $row['hash_algo'] ?? 'pbkdf2';
if ($algo === 'pbkdf2') {
$computed = hash_pbkdf2('sha256', $p, $row['salt'], 100000);
echo "=== PHP hash_pbkdf2('sha256', '$p', salt, 100000) ===\n";
echo "computed : $computed\n";
echo "computed len : " . strlen($computed) . "\n\n";
$match = hash_equals($row['password_hash'], $computed);
echo "Match : " . ($match ? 'YES ✓ (PHP would let this user in)' : 'NO ✗ (Delphi & PHP disagree on PBKDF2)') . "\n";
if (!$match) {
echo "\nFirst chars side by side:\n";
echo "Stored : " . substr($row['password_hash'], 0, 32) . "\n";
echo "Computed : " . substr($computed, 0, 32) . "\n";
}
} else {
echo "Account uses BCRYPT, not PBKDF2. Cannot diagnose PBKDF2 mismatch here.\n";
}
$db->close();