diff --git a/CODE_AUDIT.md b/CODE_AUDIT.md new file mode 100644 index 0000000..fcbedb9 --- /dev/null +++ b/CODE_AUDIT.md @@ -0,0 +1,277 @@ +# Audit de code — PMServer (Password Manager) + +> Analyse statique + revue architecture au 2026-07-03. +> Portée : crypto, auth, HTTP lockdown, sync, frontend, robustesse. +> Basé sur la lecture du code, pas sur un pentest dynamique. + +Légende sévérité : 🔴 critique · 🟠 important · 🟡 moyen · 🔵 mineur / cosmétique + +--- + +## 0. Résumé exécutif + +Le produit est **solide pour un gestionnaire perso offline** : chiffrement +client-side AES-GCM, zero-knowledge login (le serveur ne voit jamais le +master pw), défense en profondeur sur le serveur loopback (token + PID +check + CSP stricte), DPAPI pour la persistance device-bound. La majorité +des "bugs" trouvés cette session ont été corrigés. + +Les points qui méritent attention avant de considérer le produit "durci" : + +1. 🔴 **Le verifier envoyé au serveur EST la clé de chiffrement** (en hex). +2. 🟠 **Sync WebDAV sans concurrence atomique** → lost update possible. +3. 🟠 **Arbitrage tombstone sensible au décalage d'horloge** entre devices. +4. 🟠 **PBKDF2-SHA256** (GPU-friendly) au lieu d'Argon2id. +5. 🟡 **`app.js` monofichier ~14k lignes** + aucun test automatisé. + +--- + +## 1. Sécurité + +### 1.1 🔴 Verifier = matériel de clé (couplage clé/authentifiant) + +`deriveKeyAndVerifier()` ([js/app.js](js/app.js)) dérive **un seul** output +PBKDF2 et l'utilise pour DEUX rôles : + +- `cryptoKey` = les 32 octets bruts importés en AES-GCM (chiffre les entries) +- `verifier` = **ces mêmes 32 octets en hex**, envoyés au serveur à `/login` + +Le serveur stocke `SHA-256(verifier)`, donc une fuite de la **DB** ne donne +pas la clé (préimage SHA-256). MAIS : + +- Le `verifier` transite en clair vers `/login` (loopback HTTP). Toute + fuite du **corps de requête** (log, proxy de debug, extension, futur + bug XSS contournant la CSP) expose **directement la clé du vault**. +- Conceptuellement, l'authentifiant et le secret de chiffrement ne + devraient jamais être le même matériel. + +**Recommandation** : dériver le verifier dans un **domaine séparé** — +p.ex. `verifier = HKDF(keyBytes, info="auth")` ou une 2ᵉ dérivation +PBKDF2 avec un `info`/salt distinct. Ainsi le verifier transmis n'est +pas la clé. Migration possible sans re-chiffrer les entries (seul le +verifier stocké côté serveur change). + +### 1.2 🟠 PBKDF2-SHA256 vs Argon2id + +600k itérations SHA-256 pour les nouveaux comptes (correct, conforme +OWASP 2023 ≥ 600k). Mais PBKDF2-SHA256 reste **GPU/ASIC-friendly**. +Un master pw faible tombe vite sur du matériel dédié si la DB fuit. + +**Recommandation** : migrer vers **Argon2id** (memory-hard). Coût : +WebAssembly côté JS (argon2-browser) + implémentation Delphi, avec +migration progressive (comme le passage 100k→600k déjà en place). + +### 1.3 🟡 Métadonnées en clair + +Documenté mais à rappeler pour un futur modèle de menace : + +- `entry_attachments` : `filename`, `mime`, `size_bytes` **non chiffrés** +- `users.avatar_b64` : image **non chiffrée** (cosmétique, assumé) +- `vault_entries` : `site`, `title`, `username`, `folder`, `tags`, `kind`, + `template` **non chiffrés** (nécessaire pour recherche/tri sans déchiffrer) + +Un attaquant avec accès disque voit la liste des sites et usernames. Pour +un vault perso c'est un compromis acceptable (recherche instantanée), mais +à documenter clairement pour l'utilisateur. + +### 1.4 🟡 Snapshot de sync = tout le vault en clair sous le sync password + +`buildSyncSnapshot()` déchiffre chaque entry puis re-chiffre le tout sous +`syncEncPwd`. Si l'utilisateur choisit un sync password faible, le fichier +WebDAV distant devient le maillon faible (le master pw fort ne protège +plus rien sur le remote). **Recommandation** : imposer une politique de +force minimale sur le sync password (déjà ≥ 6 chars — trop peu ; viser +≥ 12 + zxcvbn). + +### 1.5 🔵 Points positifs (à conserver) + +- ✅ AES-GCM 256 (AEAD, intégrité incluse) — pas de mode ECB/CBC nu +- ✅ IV aléatoire par blob (`crypto.getRandomValues(12)`) — pas de réutilisation +- ✅ Zero-knowledge : le master pw plaintext ne quitte jamais le client +- ✅ CSP stricte (`default-src 'self'; img-src 'self' data:`) — bloque + l'exfiltration même en cas d'injection +- ✅ Serveur loopback + token + PID-on-socket check (defense in depth) +- ✅ DevTools / menu contextuel Edge désactivés +- ✅ Rendu via `textContent` / `el()` (pas d'`innerHTML` sur données user) + → surface XSS minimale +- ✅ Clipboard sécurisé (exclusion clipboard history + auto-clear 30s) +- ✅ Recovery code à usage limité (5), consommé, supprimé après rotation + +--- + +## 2. Bugs / risques latents (non encore observés) + +### 2.1 🟠 Sync : lost update (pas de concurrence atomique) + +`runSyncNow()` fait `GET` → merge → `PUT`. Deux devices qui syncent +**en même temps** sur le même fichier WebDAV : le 2ᵉ `PUT` écrase le 1ᵉʳ +sans détecter le conflit → perte de la fenêtre de merge de l'un des deux. + +**Recommandation** : concurrence optimiste via **ETag / If-Match** WebDAV. +Récupérer l'ETag au `GET`, l'envoyer en `If-Match` au `PUT` ; si 412 +Precondition Failed → re-pull + re-merge + re-push. La plupart des serveurs +WebDAV (Nextcloud, Apache mod_dav) supportent les ETags. + +### 2.2 🟠 Arbitrage tombstone sensible à l'horloge + +`applyRemoteSnapshot()` compare `entry.updated_at > tombstone.deleted_at` +pour décider résurrection vs suppression. Ces timestamps viennent de +`FormatDateTime('yyyy-mm-dd hh:nn:ss', Now)` — **heure locale du device +qui a écrit**. Entre deux machines avec des horloges décalées (ou fuseaux +différents), l'arbitrage last-write-wins peut se tromper : + +- Device A (horloge en avance) supprime → deleted_at "futur" +- Device B édite (horloge correcte) → updated_at "passé" vs deleted_at A +- B croit que la suppression est plus récente → tue l'édition de B + +**Recommandation** : stocker les timestamps en **UTC ISO 8601** partout +(serveur ET snapshot), et idéalement un compteur logique (Lamport) en +complément pour les cas d'égalité. À minima, documenter que les horloges +des devices doivent être synchronisées (NTP). + +### 2.3 🟡 `catch (_) {}` silencieux en cascade + +Nombreux `catch (_) {}` dans `applyRemoteSnapshot`, `buildSyncSnapshot`, +restauration d'attachments, création de folders. Une erreur réseau/serveur +transitoire est avalée → l'entry/folder/attachment est silencieusement +sauté. Le garde-fou "failed count → abort push" atténue la perte côté +entries, mais **pas** côté attachments ni folders. + +**Recommandation** : logger chaque `catch` (au moins `console.warn` + +compteur), et étendre le garde-fou "n échecs → warn utilisateur" aux +attachments. + +### 2.4 🟡 Pas de pagination serveur sur `GET /entries` + +Tout le vault est chargé + déchiffré à chaque unlock. Sur un gros vault +(5k-10k entries), `buildSyncSnapshot` (déchiffre chaque entry + fetch +attachments un par un) et `computeHealthCache` deviennent lents (O(n) +séquentiel, `await` en boucle). Acceptable pour usage perso (< 500 +entries), problématique au-delà. + +**Recommandation** : batcher les déchiffrements (Promise.all par lots), +et si besoin paginer côté serveur pour la vue grille. + +### 2.5 🟡 500 au lieu de 401 sur session expirée + +`Authenticate` écrit 401 puis `raise ESessionRejected` → le `try/except` +global de `PM.HTTPServer` réécrit un **500**. Pré-existant, tous les +handlers concernés. Cosmétique (le client voit un non-2xx) mais brouille +le debug. + +**Recommandation** : attraper `ESessionRejected` spécifiquement dans le +dispatcher et ne pas réécrire la réponse déjà envoyée. + +### 2.6 🔵 CSV re-import crée des doublons + +Documenté/assumé (les lignes CSV sans uuid mint un nouvel uuid à chaque +import). Le dedup uuid ne couvre que le JSON. Acceptable si documenté à +l'utilisateur. + +### 2.7 🔵 `navigator.clipboard` fallback échoue silencieusement si caché + +Quand le Bridge Delphi est absent, le fallback `navigator.clipboard. +writeText` échoue si le document n'a pas le focus (WebView caché). Cas +rare (le Bridge est quasi toujours actif en prod) mais le `catch (_) {}` +masque l'échec → l'utilisateur croit avoir copié. + +--- + +## 3. Qualité de code / maintenabilité + +### 3.1 🟡 `app.js` monofichier ~14 000 lignes + +Tout le frontend en un seul fichier. Difficile à naviguer, à tester, et +un edit malencontreux casse tout le parse (déjà arrivé cette session : une +déclaration de fonction supprimée → app entièrement morte, découvert +seulement au runtime). + +**Recommandations** : +- Découper en modules ES (`crypto.js`, `sync.js`, `slideover.js`, + `quicksearch.js`, `settings.js`…) + un bundle simple (esbuild) dans + `BuildAssets.cmd`. +- **Ajouter `node --check` (ou eslint) en pré-étape de `BuildAssets.cmd`** + → aurait attrapé le syntax error avant le rebuild. Gain immédiat, coût + quasi nul. + +### 3.2 🟡 Aucun test automatisé + +Toute la validation est manuelle (TEST_PLAN.md, TEST_REGRESSION.md). Les +zones à haut risque de régression (crypto round-trip, merge de sync, +arbitrage tombstone, dirty-check) sont exactement celles qui bénéficieraient +de tests unitaires. + +**Recommandations** : +- Tests unitaires JS (Vitest/Jest) sur : `encryptPwd`/`decryptPwd` round-trip, + `deriveKeyAndVerifier` (vecteurs connus), `applyRemoteSnapshot` (merge + + résurrection), `isSoDirty`, `parseEntriesFromCSV/JSON`. +- Tests Delphi (DUnitX) sur les handlers critiques (bulk-import + tombstone + purge, rotation master pw). + +### 3.3 🔵 Duplication de constantes cross-langage + +`600000`, formats de timestamp, noms de colonnes, shapes JSON sont +dupliqués entre JS et Delphi. Un changement d'un côté sans l'autre = +bug silencieux (déjà vu avec les champs oubliés dans la rotation/duplicate, +cf. la checklist "Entry payload" de CLAUDE.md). + +--- + +## 4. Améliorations UX (rapides) + +- 🔵 **Force du sync password** : afficher un indicateur zxcvbn au réglage, + refuser < 12 chars. +- 🔵 **Indicateur de force du master pw** à l'enregistrement (zxcvbn). +- 🔵 **Feedback d'échec sync partiel** : "3 attachments non synchronisés" + au lieu d'un silence. +- 🔵 **Combobox custom fields** : navigation clavier (flèches ↑↓ + Enter) + dans le menu déroulant, pas seulement souris. +- 🔵 **Auto-lock** : afficher le temps restant avant lock dans un coin. +- 🔵 **Avatar** : l'inclure dans le sync snapshot + auto-backup (actuellement + seulement dans l'export manuel) pour cohérence multi-device. + +--- + +## 5. Fonctionnalités futures (par valeur / effort) + +| Feature | Valeur | Effort | Note | +|---|---|---|---| +| **Argon2id** (KDF memory-hard) | 🔴 Haute | Moyen | argon2-browser + Delphi, migration progressive | +| **ETag/If-Match sur sync** | 🟠 Haute | Faible | Évite les lost updates multi-device | +| **Extension navigateur** | 🟠 Haute | Élevé | Autofill in-page, feature #1 demandée | +| **Windows Hello (biométrie)** | 🟠 Moyenne | Moyen | `Windows.Security.Credentials` — déverrouillage biométrique | +| **Tests automatisés** | 🟠 Haute | Moyen | Filet de sécurité contre les régressions | +| **Import KeePass XML / 1PUX** | 🟡 Moyenne | Moyen | Complète l'écosystème d'import | +| **Password strength par entry** | 🟡 Moyenne | Faible | zxcvbn dans le slideover + vault health | +| **Verifier découplé de la clé** | 🔴 Haute | Faible | Cf. §1.1 — fix de sécurité prioritaire | +| **i18n (FR/EN propre)** | 🔵 Basse | Moyen | Actuellement FR/EN mélangés dans l'UI | +| **Timestamps UTC + Lamport** | 🟠 Moyenne | Moyen | Fiabilise l'arbitrage sync (§2.2) | +| **Emergency access / partage** | 🔵 Basse | Élevé | Hors scope "perso offline" | + +--- + +## 6. Plan d'action recommandé (ordre) + +1. **`node --check` dans `BuildAssets.cmd`** — 5 min, évite les JS cassés en prod. +2. **Découpler verifier ↔ clé** (§1.1) — fix sécu prioritaire, effort faible. +3. **ETag/If-Match sur sync** (§2.1) — évite la perte de données multi-device. +4. **Timestamps UTC partout** (§2.2) — fiabilise l'arbitrage tombstone. +5. **Tests unitaires crypto + merge** (§3.2) — filet avant d'ajouter des features. +6. **Argon2id** (§1.2) — durcissement KDF, migration progressive. +7. Découpage `app.js` en modules (§3.1) — maintenabilité long terme. + +--- + +## 7. Verdict + +Pour un usage **personnel, local, mono-utilisateur**, le produit est +**sûr et fonctionnel** aujourd'hui. Les défenses en profondeur (loopback, +token, PID check, CSP, zero-knowledge, AES-GCM) sont bien pensées et +au-dessus de la moyenne des projets perso. + +Les axes de durcissement (verifier découplé, Argon2id, sync atomique, +timestamps UTC) deviennent importants **dès qu'on vise le multi-device +sérieux ou un modèle de menace où la DB / le fichier de sync peut fuiter**. +Aucun de ces points n'est un trou béant immédiat en usage loopback solo, +mais §1.1 (verifier=clé) est celui que je corrigerais en premier car il +est peu coûteux et supprime un couplage dangereux. diff --git a/css/style.css b/css/style.css index cda7190..66a2db9 100644 --- a/css/style.css +++ b/css/style.css @@ -1591,6 +1591,39 @@ input[type="range"]::-webkit-slider-thumb { padding: 4px 8px; font-size: 12px; } +/* Editable combobox for option-backed custom fields (card brand, expiry + year…). Input fills the cell; the arrow drops a menu of ALL options. */ +.so-combo { position: relative; display: flex; align-items: center; } +.so-combo-input { flex: 1; padding-right: 24px !important; width: 100%; } +.so-combo-arrow { + position: absolute; right: 2px; top: 50%; + transform: translateY(-50%); + display: flex; align-items: center; justify-content: center; + width: 20px; height: 20px; + background: transparent; border: 0; padding: 0; + color: var(--text-faint); cursor: pointer; +} +.so-combo-arrow svg { width: 14px; height: 14px; } +.so-combo-arrow:hover { color: var(--text); } +.so-combo-menu { + position: absolute; top: calc(100% + 2px); left: 0; right: 0; + z-index: 20; + max-height: 200px; overflow-y: auto; + background: var(--bg-elev-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow); + padding: 4px; +} +.so-combo-menu.is-hidden { display: none; } +.so-combo-item { + padding: 6px 10px; + font-size: 12px; + border-radius: 4px; + cursor: pointer; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.so-combo-item:hover { background: var(--accent); color: #fff; } .so-custom-secret-toggle { background: var(--bg); border: 1px solid var(--border); diff --git a/delphi-backend/Source/PM.Bridge.pas b/delphi-backend/Source/PM.Bridge.pas index ee599bb..b3be6e7 100644 --- a/delphi-backend/Source/PM.Bridge.pas +++ b/delphi-backend/Source/PM.Bridge.pas @@ -129,8 +129,11 @@ type public constructor Create(AMainForm: TForm); destructor Destroy; override; - // Hide main window and show tray icon. - procedure MinimizeToTray; + // Hide main window and show tray icon. AClearClipboard defaults to + // True (a manual minimise wipes any copied password immediately); + // the quick-search "copy then hide so I can paste" flow passes False + // so it doesn't nuke the password it just placed on the clipboard. + procedure MinimizeToTray(AClearClipboard: Boolean = True); // Restore main window and remove tray icon. procedure RestoreFromTray; // Apply Windows dark-mode title bar to the main form. Win10 19044+ @@ -160,8 +163,10 @@ type // 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. + // AUsernameOnly = True → type ONLY the username (no Tab, no password); + // used by the quick-search "autofill username" action. procedure ExecuteAutofill(ATargetHWND: HWND; - const AUsername, APassword: string); + const AUsername, APassword: string; AUsernameOnly: Boolean = False); property SecureClipboard: TSecureClipboard read FSecureClipboard; property TrayAdded: Boolean read FTrayAdded; property AutofillRegistered: Boolean read FAutofillRegistered; @@ -551,7 +556,7 @@ begin until LWnd = 0; end; -procedure TPMBridge.MinimizeToTray; +procedure TPMBridge.MinimizeToTray(AClearClipboard: Boolean = True); var LFormHwnd, LAppHwnd: HWND; begin @@ -561,8 +566,12 @@ begin // Extra safety: clear the clipboard immediately when the user minimizes, // rather than waiting for the 30s auto-clear timer to fire. A password // the user just copied shouldn't sit in the clipboard while the app is - // out of sight. - FSecureClipboard.Clear; + // out of sight. SKIPPED when AClearClipboard=False — the quick-search + // copy-then-hide flow deliberately keeps the password on the clipboard + // (the 30s auto-clear timer still guards it) so the user can paste it + // into their target app after we minimise. + if AClearClipboard then + FSecureClipboard.Clear; LFormHwnd := MainFormHWND(FMainForm); LAppHwnd := FindFMXAppWindow; @@ -1115,7 +1124,7 @@ begin end; procedure TPMBridge.ExecuteAutofill(ATargetHWND: HWND; - const AUsername, APassword: string); + const AUsername, APassword: string; AUsernameOnly: Boolean = False); const MinimizeSettleMs = 80; FocusSettleDelayMs = 120; @@ -1135,6 +1144,16 @@ begin WaitForModifierRelease(1000); Sleep(FocusSettleDelayMs); + // Username-only: type just the username into the focused field, no Tab, + // no password. Used by the quick-search right-click / Shift+Enter path. + if AUsernameOnly then + begin + SendSelectAllAndDelete; + Sleep(60); + SendUnicodeString(AUsername); + Exit; + end; + if AUsername = '' then begin SendSelectAllAndDelete; diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas index b736f6c..04a2128 100644 --- a/delphi-backend/UMainForm.pas +++ b/delphi-backend/UMainForm.pas @@ -99,6 +99,9 @@ type // SendInput because Win10/11 anti-focus-stealing rules then refuse to // hand focus to the target window. FAutofillPendingHide: Boolean; + // True when the pending autofill should type ONLY the username (quick + // search "autofill username" — right-click / Shift+Enter in fill mode). + FAutofillPendingUserOnly: Boolean; // Created dynamically in FormCreate so the directive can pick either // TTMSFNCWebBrowser or TTMSFNCEdgeWebBrowser at compile time without // needing two .fmx variants. Aligned to Client to fill the remaining @@ -788,6 +791,7 @@ begin FAutofillPendingPass := GetParam('password'); FAutofillPendingHWND := FAutofillTargetHWND; FAutofillPendingHide := GetParam('hide_after') = '1'; + FAutofillPendingUserOnly := GetParam('field') = 'user'; FAutofillTargetHWND := 0; // Small timer so SetForegroundWindow has time to take effect before @@ -823,7 +827,9 @@ begin // from the tray menu, hide it again so the user can paste straight // into the target app without alt-tabbing. else if ACmd = 'app/minimize' then - FBridge.MinimizeToTray + // keepclip=1 → don't wipe the clipboard on minimise (quick-search + // copy-then-hide flow). Default clears it as before. + FBridge.MinimizeToTray(GetParam('keepclip') <> '1') // Tray balloon notifications on/off. JS pushes the user setting at // startup (settings_json sync) and whenever they flip the toggle. @@ -1323,22 +1329,24 @@ procedure TMainForm.AutofillTimerTick(Sender: TObject); var TargetHwnd: HWND; PendingUser, PendingPass: string; - HideAfter: Boolean; + HideAfter, UserOnly: Boolean; ForegroundAfter: HWND; begin TargetHwnd := FAutofillPendingHWND; PendingUser := FAutofillPendingUser; PendingPass := FAutofillPendingPass; HideAfter := FAutofillPendingHide; + UserOnly := FAutofillPendingUserOnly; FAutofillPendingHWND := 0; FAutofillPendingUser := ''; FAutofillPendingPass := ''; FAutofillPendingHide := False; + FAutofillPendingUserOnly := False; TTimer(Sender).Enabled := False; TTimer(Sender).Free; - FBridge.ExecuteAutofill(TargetHwnd, PendingUser, PendingPass); + FBridge.ExecuteAutofill(TargetHwnd, PendingUser, PendingPass, UserOnly); ForegroundAfter := GetForegroundWindow; LogLine(Format('Autofill executed — target=%s, foreground_after=%s, match=%s', [IntToHex(TargetHwnd, 8), IntToHex(ForegroundAfter, 8), diff --git a/delphi-backend/assets/BuildAssets.ps1 b/delphi-backend/assets/BuildAssets.ps1 index 6ecad89..883e6c4 100644 --- a/delphi-backend/assets/BuildAssets.ps1 +++ b/delphi-backend/assets/BuildAssets.ps1 @@ -77,6 +77,33 @@ if ($files.Count -eq 0) { Log "Embedding $($files.Count) file(s):" $files | ForEach-Object { Log (" " + $_.UrlPath + " -> " + $_.ResName) } +# --- JS syntax gate ----------------------------------------------------------- +# A syntax error in app.js parses fine here but kills the whole frontend at +# runtime (no event handlers → dead UI), and it's only caught after a full +# Delphi rebuild. Run `node --check` on every embedded .js so a broken bundle +# never makes it into assets.res. Node is optional: if it isn't installed we +# warn and continue rather than blocking the build on a machine without it. +$node = Get-Command node.exe -ErrorAction SilentlyContinue +if (-not $node) { $node = Get-Command node -ErrorAction SilentlyContinue } +$jsFiles = $files | Where-Object { $_.Relative -match '\.js$' } +if ($jsFiles) { + if ($node) { + foreach ($jf in $jsFiles) { + Log "Syntax check: $($jf.Relative)" + # --check prints errors to stderr and returns non-zero on failure. + $out = & $node.Source --check $jf.FullPath 2>&1 + if ($LASTEXITCODE -ne 0) { + Log "JS SYNTAX ERROR in $($jf.Relative):" + Log ($out | Out-String) + throw "JS syntax check failed for $($jf.Relative) - aborting asset build." + } + } + Log "JS syntax OK." + } else { + Log "WARNING: node not found - skipping JS syntax check. Install Node to enable it." + } +} + # --- Generate assets.rc ------------------------------------------------------- $rc = New-Object System.Text.StringBuilder [void]$rc.AppendLine('// Auto-generated by BuildAssets.ps1 - do not edit by hand.') diff --git a/delphi-backend/assets/assets.res b/delphi-backend/assets/assets.res index 0591097..268d6f7 100644 Binary files a/delphi-backend/assets/assets.res and b/delphi-backend/assets/assets.res differ diff --git a/js/app.js b/js/app.js index ee07380..6c02074 100644 --- a/js/app.js +++ b/js/app.js @@ -99,11 +99,13 @@ const Bridge = (() => { // back to the tray AFTER SendInput completes — necessary for the // Ctrl+Shift+Q-from-tray flow (we cannot hide before SendInput or // Win10/11 anti-focus-stealing rules block the target). - executeAutofill(username, password, hideAfter) { + executeAutofill(username, password, hideAfter, field) { if (!active) return; + // field='user' → type only the username (no Tab / password). cmd('cmd://autofill/execute?username=' + encodeURIComponent(username) + '&password=' + encodeURIComponent(password) + - (hideAfter ? '&hide_after=1' : '')); + (hideAfter ? '&hide_after=1' : '') + + (field === 'user' ? '&field=user' : '')); }, // Ask Delphi to bring the main window to front (used when the @@ -403,9 +405,11 @@ const Bridge = (() => { // Hide the window back to the tray icon. Used by Quick search to // restore "was in tray" state after a password copy. - minimizeToTray() { + minimizeToTray(keepClipboard) { if (!active) return; - cmd('cmd://app/minimize'); + // keepClipboard=true → the just-copied password survives the + // minimise (quick-search copy-then-hide). Default clears it. + cmd('cmd://app/minimize' + (keepClipboard ? '?keepclip=1' : '')); }, // Push the "show tray notifications" preference to Delphi so the @@ -1017,54 +1021,79 @@ function quickSearchRender() { main.appendChild(el('div', { class: 'quick-search-sub' }, e.username)); row.appendChild(avatar); row.appendChild(main); - row.addEventListener('click', () => quickSearchPickEntry(e, false)); + // Left click → full (user + Tab + password); Ctrl+click → password + // only (step-2 forms / unlock screens). + row.addEventListener('click', ev => + quickSearchPickEntry(e, (ev.ctrlKey || ev.metaKey) ? 'pwd' : 'full')); + // Right click → username only. preventDefault + stopPropagation so + // the custom context menu (installCustomContextMenu) doesn't pop. + row.addEventListener('contextmenu', ev => { + ev.preventDefault(); + ev.stopPropagation(); + quickSearchPickEntry(e, 'user'); + }); box.appendChild(row); }); } -async function quickSearchPickEntry(entry, copyUsername) { - // Fill mode (Ctrl+Shift+Q hotkey): SendInput the password directly into - // the HWND Delphi saved when the hotkey fired. No clipboard touch. - if (quickSearchFillMode && !copyUsername) { - const pwd = await decryptPwd(entry.encrypted_password, entry.iv); - if (pwd === '[ERROR]') { - toast('Decryption error', 'error'); - if (Bridge.active) Bridge.cancelAutofill(); - return; +// mode: 'full' (user + Tab + password), 'user' (username only) or 'pwd' +// (password only). In fill mode each maps to a SendInput variant; in copy +// mode 'full' has no meaning so it falls back to copying the password. +async function quickSearchPickEntry(entry, mode) { + mode = mode || 'full'; + + // Fill mode (Ctrl+Shift+Q hotkey): SendInput directly into the HWND + // Delphi saved when the hotkey fired. No clipboard touch. + if (quickSearchFillMode) { + if (mode === 'user') { + const u = entry.username || ''; + if (!u) { toast('No username on this entry', 'warning'); return; } + if (Bridge.active) Bridge.executeAutofill(u, '', quickSearchHideAfter, 'user'); + toast(entryDisplayName(entry) + ' · username sent'); + } else { + const pwd = await decryptPwd(entry.encrypted_password, entry.iv); + if (pwd === '[ERROR]') { + toast('Decryption error', 'error'); + if (Bridge.active) Bridge.cancelAutofill(); + return; + } + // 'full' → user + Tab + password (needs a username to make sense); + // 'pwd' (or 'full' on an entry without a username) → password only. + const u = (mode === 'full') ? (entry.username || '') : ''; + // Single command — Delphi defers the SendInput by 60 ms then, + // if hide_after=1, MinimizeToTray's AFTER the keystrokes land. + // Hiding before SendInput would tip the Win10/11 anti-focus- + // stealing rules into refusing to hand focus to the target. + if (Bridge.active) Bridge.executeAutofill(u, pwd, quickSearchHideAfter); + toast(entryDisplayName(entry) + + (u ? ' · username + password sent' : ' · password sent')); } - // Single command — Delphi defers the SendInput by 60 ms then, - // if hide_after=1, MinimizeToTray's AFTER the keystrokes land. - // Hiding before SendInput would tip the Win10/11 anti-focus-stealing - // rules into refusing to hand focus to the target window. - if (Bridge.active) Bridge.executeAutofill('', pwd, quickSearchHideAfter); - toast(entryDisplayName(entry) + ' · password sent'); - // Both flags consumed — closeQuickSearchModal must not re-trigger. + // Flags consumed — closeQuickSearchModal must not re-trigger. quickSearchFillMode = false; quickSearchHideAfter = false; closeQuickSearchModal(); return; } - if (copyUsername) { + // Copy mode (tray / palette): no target window, so we can only place a + // single value on the clipboard. 'user' copies the username, everything + // else copies the password. + if (mode === 'user') { const u = entry.username || ''; - if (!u) { - toast('No username on this entry', 'warning'); - return; - } + if (!u) { toast('No username on this entry', 'warning'); return; } if (Bridge.active) Bridge.copySecure(u, 30000); else { try { await navigator.clipboard.writeText(u); } catch (_) {} } toast('Username copied · clears in 30s'); } else { const pwd = await decryptPwd(entry.encrypted_password, entry.iv); - if (pwd === '[ERROR]') { - toast('Decryption error', 'error'); - return; - } + if (pwd === '[ERROR]') { toast('Decryption error', 'error'); return; } if (Bridge.active) Bridge.copySecure(pwd, 30000); else { try { await navigator.clipboard.writeText(pwd); } catch (_) {} } toast(entryDisplayName(entry) + ' · password copied'); } - closeQuickSearchModal(); + // keepClipboard=true — we just copied, so minimising back to the tray + // must NOT clear the clipboard (the 30s auto-clear still applies). + closeQuickSearchModal(true); } // ============================================================ @@ -1264,14 +1293,14 @@ function openQuickSearchModal(hideAfter, forFill) { const hintEl = modal.querySelector('.quick-search-hint'); if (hintEl) { hintEl.textContent = forFill - ? 'Enter = type password into the active window · Esc = cancel' - : 'Enter = copy password · Shift+Enter = copy username · Esc = close'; + ? 'Enter = fill user+password · Shift+Enter = username · Ctrl+Enter = password · Esc = cancel' + : 'Enter / click = copy password · Shift+Enter / right-click = copy username · Esc = close'; } quickSearchRender(); setTimeout(() => input.focus(), 50); } -function closeQuickSearchModal() { +function closeQuickSearchModal(keepClipboard) { document.getElementById('quickSearchModal').classList.add('is-hidden'); // Fill-mode cancel: tell Delphi to drop the saved HWND so the next // /execute (e.g. an unrelated Ctrl+Shift+L) doesn't accidentally @@ -1284,11 +1313,13 @@ function closeQuickSearchModal() { // If the modal was opened from the tray (window was hidden), restore // the previous "in tray" state so the user can paste straight into // the target app. Cancel (Esc / close X) also triggers this — they - // came from the tray, they should go back to the tray. + // came from the tray, they should go back to the tray. keepClipboard + // is set by the copy path so minimising doesn't wipe the password we + // just placed on the clipboard. if (quickSearchHideAfter) { quickSearchHideAfter = false; if (Bridge.active && typeof Bridge.minimizeToTray === 'function') - Bridge.minimizeToTray(); + Bridge.minimizeToTray(!!keepClipboard); } } @@ -4765,36 +4796,63 @@ function buildCustomFieldRow(field, idx, rerender) { soDirtyCheck(); }); - // Render a otherwise. Storage shape unchanged — - // `field.value` still holds the chosen string. + // Value field. When the field declares an `options` array (entry + // templates: card brand, expiry year/month, etc.) we wrap the input in + // a CUSTOM editable combobox: an arrow button that drops a menu of ALL + // options (unlike a native , which filters to what's typed), + // while the input stays freely typeable for a value not in the list. + // Storage shape unchanged — `field.value` holds the string either way. let valueInput; - if (Array.isArray(field.options) && field.options.length > 0) { - valueInput = el('select', { class: 'so-input so-custom-value' }); - valueInput.appendChild(el('option', { value: '' }, '-- Select --')); + let valueSlot; // what actually goes into the row (input or combo wrap) + const hasOptions = Array.isArray(field.options) && field.options.length > 0; + valueInput = el('input', { + type: field.is_secret ? 'password' : 'text', + class: 'so-input so-custom-value', + placeholder: hasOptions ? 'Pick or type…' : 'Value', + autocomplete: field.is_secret ? 'new-password' : 'off', + spellcheck: 'false', + }); + valueInput.value = field.value || ''; + valueInput.addEventListener('input', () => { + field.value = valueInput.value; + soDirtyCheck(); + }); + if (hasOptions && !field.is_secret) { + const combo = el('div', { class: 'so-combo' }); + valueInput.classList.add('so-combo-input'); + const arrow = el('button', { + class: 'so-combo-arrow', type: 'button', tabindex: '-1', + title: 'Show options', + }); + arrow.appendChild(icon('i-chevron-down')); + const menu = el('div', { class: 'so-combo-menu is-hidden' }); field.options.forEach(opt => { - const o = el('option', { value: opt }, opt); - if (opt === (field.value || '')) o.selected = true; - valueInput.appendChild(o); + const item = el('div', { class: 'so-combo-item' }, opt); + // mousedown (not click) + preventDefault so the input doesn't + // blur-close the menu before we read the choice. + item.addEventListener('mousedown', ev => { + ev.preventDefault(); + ev.stopPropagation(); + valueInput.value = opt; + field.value = opt; + soDirtyCheck(); + menu.classList.add('is-hidden'); + }); + menu.appendChild(item); }); - valueInput.addEventListener('change', () => { - field.value = valueInput.value; - soDirtyCheck(); + arrow.addEventListener('click', ev => { + ev.stopPropagation(); + // Close any other open combo first, then toggle this one. + document.querySelectorAll('.so-combo-menu:not(.is-hidden)') + .forEach(m => { if (m !== menu) m.classList.add('is-hidden'); }); + menu.classList.toggle('is-hidden'); }); + combo.appendChild(valueInput); + combo.appendChild(arrow); + combo.appendChild(menu); + valueSlot = combo; } else { - valueInput = el('input', { - type: field.is_secret ? 'password' : 'text', - class: 'so-input so-custom-value', - placeholder: 'Value', - autocomplete: field.is_secret ? 'new-password' : 'off', - spellcheck: 'false', - }); - valueInput.value = field.value || ''; - valueInput.addEventListener('input', () => { - field.value = valueInput.value; - soDirtyCheck(); - }); + valueSlot = valueInput; } // Reveal eye — only meaningful for secret fields. @@ -4850,7 +4908,7 @@ function buildCustomFieldRow(field, idx, rerender) { }); row.appendChild(labelInput); - row.appendChild(valueInput); + row.appendChild(valueSlot); row.appendChild(eye); row.appendChild(secretBtn); row.appendChild(copyBtn); @@ -10804,6 +10862,13 @@ async function init() { let _slideoverMouseDownInside = false; document.addEventListener('mousedown', e => { _slideoverMouseDownInside = !!(e.target.closest && e.target.closest('.slideover')); + // Close any open custom combobox menu when the click lands outside + // a combo (the arrow toggle + item mousedown both stopPropagation, + // so this only fires for genuine outside clicks). + if (!(e.target.closest && e.target.closest('.so-combo'))) { + document.querySelectorAll('.so-combo-menu:not(.is-hidden)') + .forEach(m => m.classList.add('is-hidden')); + } }, true); document.addEventListener('click', e => { if (!$('#slideover').classList.contains('is-open')) return; @@ -11496,7 +11561,12 @@ async function init() { if (!sel) return; const id = parseInt(sel.dataset.id, 10); const entry = state.entries.find(x => x.id === id); - if (entry) quickSearchPickEntry(entry, e.shiftKey); + // Shift+Enter → username, Ctrl+Enter → password only, + // plain Enter → full (user + Tab + password). + const mode = e.shiftKey ? 'user' + : (e.ctrlKey || e.metaKey) ? 'pwd' + : 'full'; + if (entry) quickSearchPickEntry(entry, mode); } }); }