feat: quick-search fill modes + editable custom-field combobox + JS build gate
Quick search (Ctrl+Shift+Q fill mode) - Enter / left-click → full autofill (username + Tab + password), like Ctrl+Shift+L. - Shift+Enter / right-click → username only (new Delphi username-only SendInput path via field=user; ExecuteAutofill AUsernameOnly param). - Ctrl+Enter / Ctrl+click → password only. - Copy mode (tray / palette) unchanged: Enter/left = password, Shift+Enter/right = username. - Clipboard fix: copy-then-minimise no longer wipes the just-copied password — MinimizeToTray takes an AClearClipboard flag (False on the quick-search copy path, driven by app/minimize?keepclip=1). The 30s auto-clear still guards it. - Right-click on a result row suppresses the native/custom context menu (preventDefault + stopPropagation). Editable custom-field combobox - Option-backed custom fields (card brand, expiry year/month, etc.) now render a custom editable combobox instead of a locked <select>: an arrow drops a menu of ALL options (a native <datalist> filtered to the typed text, which confused users), while the input stays freely typeable for values not in the list. Storage shape unchanged. - Outside-click closes the menu via the existing slideover mousedown handler; item mousedown + preventDefault so blur doesn't race the pick. Build safety - BuildAssets.ps1 runs `node --check` on every embedded .js before generating assets.res. A syntax error now aborts the asset build (exit 1, file + line logged) instead of shipping a dead bundle that only surfaces after a full Delphi rebuild. Node is optional: absent → warn and continue. Docs - CODE_AUDIT.md: full static-analysis report (security, latent bugs, maintainability, future features, prioritized action plan). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+277
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.')
|
||||
|
||||
Binary file not shown.
@@ -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 <select> when the field declares an `options` array (set
|
||||
// by entry templates for things like card brand or expiration month).
|
||||
// Falls back to a plain <input> 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 <datalist>, 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user