diff --git a/CLAUDE.md b/CLAUDE.md index fa4428e..be379ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -148,6 +148,12 @@ Retour Delphi → JS via `WebBrowser.ExecuteJavaScript('Bridge.onX(...)')`. - `Ctrl+Shift+L` : autofill complet (user + Tab + password) - `Ctrl+Shift+P` : autofill password seul (forms step-2, unlock screens) +- `Ctrl+Shift+T` : autofill du **code 2FA** (cliquer le champ 2FA d'abord). + ID hotkey 47 / `akTotp`. Ne matche que les entrées avec `totp_secret` (filtre + appliqué **avant** le scoring → le picker ne propose jamais un candidat sans + TOTP). JS génère le code et l'envoie **dans le slot password avec un username + vide** → réutilise le chemin password-only de `ExecuteAutofill`, aucun code + natif en plus. - `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) diff --git a/delphi-backend/Source/PM.Bridge.pas b/delphi-backend/Source/PM.Bridge.pas index 2638f36..40bc648 100644 --- a/delphi-backend/Source/PM.Bridge.pas +++ b/delphi-backend/Source/PM.Bridge.pas @@ -62,7 +62,10 @@ type // 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); + // akTotp reuses the password-only typing path: JS sends the generated + // 6-digit code in the password slot with an empty username, so Delphi + // types just the code into the focused 2FA field. + TAutofillKind = (akFull, akPasswordOnly, akTotp); // Fired when an autofill hotkey is pressed. Args are the foreground // window HWND and its title (captured before any focus change), plus @@ -117,6 +120,7 @@ type FAutofillRegistered: Boolean; FAutofillFullActive: Boolean; FAutofillPwdActive: Boolean; + FAutofillTotpActive: Boolean; FOnAutofillRequest: TAutofillRequestEvent; FDebugHotkeyRegistered: Boolean; FOnDebugHotkey: TProc; @@ -162,8 +166,10 @@ type // Returns True if both hotkeys registered successfully. If one or both // failed (clash with another app), best-effort: whichever succeeded // stays active. + // ATotpVk = 0 means "no 2FA hotkey configured" — it is then skipped and + // does NOT count against the returned all-registered flag. function SetAutofillHotkeys(AFullMods, AFullVk, - APwdMods, APwdVk: Word): Boolean; + APwdMods, APwdVk, ATotpMods, ATotpVk: Word): Boolean; // Re-register the Ctrl+Shift+Q (default) quick-search hotkey with a // user-chosen combo. True on success. function SetQuickSearchHotkey(AMods, AVk: Word): Boolean; @@ -287,6 +293,7 @@ const DEBUG_HOTKEY_ID = 44; // Ctrl+Shift+D → toggle debug panel NEW_ENTRY_HOTKEY_ID = 45; // Ctrl+Shift+A → quick-add from window title QUICK_SEARCH_HOTKEY_ID = 46; // Ctrl+Shift+Q → quick-search-and-fill + AUTOFILL_HOTKEY_ID_TOTP = 47; // Ctrl+Shift+T → type the 2FA code AF_MOD_CONTROL = $0002; // same value as MOD_CONTROL AF_MOD_SHIFT = $0004; // same value as MOD_SHIFT @@ -895,13 +902,16 @@ begin else if (AMsg.Msg = WM_HOTKEY) and Assigned(FOnAutofillRequest) and ((AMsg.WParam = AUTOFILL_HOTKEY_ID_FULL) or - (AMsg.WParam = AUTOFILL_HOTKEY_ID_PWDONLY)) then + (AMsg.WParam = AUTOFILL_HOTKEY_ID_PWDONLY) or + (AMsg.WParam = AUTOFILL_HOTKEY_ID_TOTP)) 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 if AMsg.WParam = AUTOFILL_HOTKEY_ID_TOTP then + LKind := akTotp else LKind := akFull; var LTarget := GetForegroundWindow; @@ -920,7 +930,7 @@ end; // ============================================================================= function TPMBridge.SetAutofillHotkeys(AFullMods, AFullVk, - APwdMods, APwdVk: Word): Boolean; + APwdMods, APwdVk, ATotpMods, ATotpVk: Word): Boolean; begin // Tear down whatever is currently registered before installing the new // combos. RegisterHotKey would fail if the same ID is already taken. @@ -934,6 +944,11 @@ begin UnregisterHotKey(FMsgWindow, AUTOFILL_HOTKEY_ID_PWDONLY); FAutofillPwdActive := False; end; + if FAutofillTotpActive then + begin + UnregisterHotKey(FMsgWindow, AUTOFILL_HOTKEY_ID_TOTP); + FAutofillTotpActive := 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. @@ -943,9 +958,15 @@ begin if (APwdVk <> 0) and (APwdMods <> 0) then FAutofillPwdActive := RegisterHotKey(FMsgWindow, AUTOFILL_HOTKEY_ID_PWDONLY, APwdMods, APwdVk); + if (ATotpVk <> 0) and (ATotpMods <> 0) then + FAutofillTotpActive := RegisterHotKey(FMsgWindow, + AUTOFILL_HOTKEY_ID_TOTP, ATotpMods, ATotpVk); - FAutofillRegistered := FAutofillFullActive or FAutofillPwdActive; - Result := FAutofillFullActive and FAutofillPwdActive; + FAutofillRegistered := FAutofillFullActive or FAutofillPwdActive + or FAutofillTotpActive; + // An unconfigured 2FA combo (vk=0) must not report a registration failure. + Result := FAutofillFullActive and FAutofillPwdActive + and (FAutofillTotpActive or (ATotpVk = 0)); end; function TPMBridge.SetQuickSearchHotkey(AMods, AVk: Word): Boolean; @@ -1041,10 +1062,12 @@ end; procedure TPMBridge.RegisterAutofillHotkey; begin - // Convenience default — Ctrl+Shift+L (full) + Ctrl+Shift+P (password). + // Convenience defaults — Ctrl+Shift+L (full), Ctrl+Shift+P (password), + // Ctrl+Shift+T (2FA code). // 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')); + AF_MOD_CONTROL or AF_MOD_SHIFT, Ord('P'), + AF_MOD_CONTROL or AF_MOD_SHIFT, Ord('T')); end; procedure TPMBridge.UnregisterAutofillHotkey; @@ -1059,6 +1082,11 @@ begin UnregisterHotKey(FMsgWindow, AUTOFILL_HOTKEY_ID_PWDONLY); FAutofillPwdActive := False; end; + if FAutofillTotpActive then + begin + UnregisterHotKey(FMsgWindow, AUTOFILL_HOTKEY_ID_TOTP); + FAutofillTotpActive := False; + end; FAutofillRegistered := False; end; diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas index c612ccf..d870104 100644 --- a/delphi-backend/UMainForm.pas +++ b/delphi-backend/UMainForm.pas @@ -895,10 +895,14 @@ begin 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'))); + // 2FA combo — vk 0 when the user cleared it, which skips registration. + var LTotpMods := Word(StrToIntDef(GetParam('totp_mods'), 6)); + var LTotpVk := Word(StrToIntDef(GetParam('totp_vk'), Ord('T'))); 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)])); + LPwdMods, LPwdVk, + LTotpMods, LTotpVk); + LogLine(Format('Autofill hotkeys set — full=mods:%d vk:%d pwd=mods:%d vk:%d totp=mods:%d vk:%d (all_ok=%s)', + [LFullMods, LFullVk, LPwdMods, LPwdVk, LTotpMods, LTotpVk, 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( @@ -1542,7 +1546,11 @@ begin LTitle := LTitle.Replace('\', '\\'); LTitle := LTitle.Replace('"', '\"'); - if AKind = akPasswordOnly then LKind := 'password' else LKind := 'full'; + case AKind of + akPasswordOnly: LKind := 'password'; + akTotp: LKind := 'totp'; + else LKind := 'full'; + end; WebBrowser.ExecuteJavaScript( 'if(window.Bridge&&typeof Bridge.onAutofillRequest==="function")' + diff --git a/index.html b/index.html index f47523f..b9a49f7 100644 --- a/index.html +++ b/index.html @@ -675,6 +675,12 @@ +
+ + 2FA code only (click the 2FA field first) + + +
Quick search picker (find entry, fill from anywhere) diff --git a/js/app.autofill.js b/js/app.autofill.js index 5d43d8f..9d25ca6 100644 --- a/js/app.autofill.js +++ b/js/app.autofill.js @@ -81,12 +81,14 @@ function autofillCaptureFromEvent(e) { function autofillPushHotkeys() { localStorage.setItem('autofillHotkeyFull', JSON.stringify(state.autofillHotkeyFull)); localStorage.setItem('autofillHotkeyPwd', JSON.stringify(state.autofillHotkeyPwd)); + localStorage.setItem('autofillHotkeyTotp', JSON.stringify(state.autofillHotkeyTotp)); localStorage.setItem('quickSearchHotkey', JSON.stringify(state.quickSearchHotkey)); localStorage.setItem('newEntryHotkey', JSON.stringify(state.newEntryHotkey)); if (Bridge.active) { Bridge.setAutofillHotkeys(state.autofillEnabled, { full: state.autofillHotkeyFull, password: state.autofillHotkeyPwd, + totp: state.autofillHotkeyTotp, quickSearch: state.quickSearchHotkey, newEntry: state.newEntryHotkey, }); @@ -209,13 +211,22 @@ async function autofillHandleRequest(windowTitle, kind) { } const titleLower = autofillStripBrowserSuffix(windowTitle).toLowerCase(); - const scored = state.entries + // The 2FA hotkey can only serve entries that actually carry a TOTP secret + // — filtering here (not after scoring) also means the picker never lists a + // candidate that would fail once chosen. + const pool = (kind === 'totp') + ? state.entries.filter(e => e.totp_secret && e.totp_iv) + : state.entries; + const scored = pool .map(e => ({ entry: e, score: autofillScore(e, titleLower) })) .filter(x => x.score > 0) .sort((a, b) => b.score - a.score); if (scored.length === 0) { - toast('Autofill: no match for "' + windowTitle.slice(0, 40) + '"', 'warning'); + toast(kind === 'totp' + ? 'No 2FA code stored for "' + windowTitle.slice(0, 40) + '"' + : 'Autofill: no match for "' + windowTitle.slice(0, 40) + '"', + 'warning'); Bridge.cancelAutofill(); return; } @@ -230,8 +241,34 @@ async function autofillHandleRequest(windowTitle, kind) { openAutofillPicker(scored.map(x => x.entry), windowTitle, kind); } -// Decrypt and type an entry. kind = 'full' or 'password'. +// Decrypt and type an entry. kind = 'full' | 'password' | 'totp'. async function autofillFillEntry(entry, kind) { + // 2FA: type the current 6-digit code instead of the password. Sent in the + // password slot with an empty username, so Delphi's password-only path + // types it as-is — no new native code path. + if (kind === 'totp') { + const secret = await decryptTotpSecret(entry.totp_secret, entry.totp_iv); + if (!secret || secret === '[ERROR]') { + toast('2FA: cannot decrypt the secret', 'error'); + Bridge.cancelAutofill(); + return; + } + let code; + try { code = (await generateTOTP(secret)).code; } + catch (e) { + toast('2FA: invalid secret for ' + entryDisplayName(entry), 'error'); + Bridge.cancelAutofill(); + return; + } + autofillPendingToast = '2FA code filled: ' + entryDisplayName(entry); + Bridge.executeAutofill('', code); + fetch('' + '/audit', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ action: 'autofill_totp', site: entry.site }), + }).catch(() => {}); + return; + } const password = await decryptPwd(entry.encrypted_password, entry.iv); if (password === '[ERROR]') { toast('Autofill: decryption error', 'error'); @@ -285,7 +322,9 @@ function openAutofillPicker(entries, windowTitle, kind) { } list.appendChild(btn); }); - const head = (kind === 'password' ? 'Pick entry (password only) — ' : 'Pick entry — ') + const head = (kind === 'totp' ? 'Pick entry (2FA code) — ' + : kind === 'password' ? 'Pick entry (password only) — ' + : 'Pick entry — ') + entries.length + ' match "' + windowTitle.slice(0, 30) + '…"'; $('#autofillPickerTitle').textContent = head; $('#autofillPickerModal').classList.remove('is-hidden'); diff --git a/js/app.js b/js/app.js index 7a094f7..b723650 100644 --- a/js/app.js +++ b/js/app.js @@ -205,9 +205,14 @@ const Bridge = (() => { const n = autofillComboToWin32(combos.newEntry); ne = '&ne_mods=' + n.mods + '&ne_vk=' + n.vk; } + let tp = ''; + if (combos.totp) { + const t = autofillComboToWin32(combos.totp); + tp = '&totp_mods=' + t.mods + '&totp_vk=' + t.vk; + } cmd('cmd://autofill/hotkeys?enabled=' + (enabled ? '1' : '0') + '&full_mods=' + f.mods + '&full_vk=' + f.vk + - '&pwd_mods=' + p.mods + '&pwd_vk=' + p.vk + qs + ne); + '&pwd_mods=' + p.mods + '&pwd_vk=' + p.vk + qs + ne + tp); }, // Called by Delphi after a setAutofillHotkeys request, with true if @@ -673,6 +678,8 @@ const state = { '{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"L"}'), autofillHotkeyPwd: JSON.parse(localStorage.getItem('autofillHotkeyPwd') || '{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"P"}'), + autofillHotkeyTotp: JSON.parse(localStorage.getItem('autofillHotkeyTotp') || + '{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"T"}'), quickSearchHotkey: JSON.parse(localStorage.getItem('quickSearchHotkey') || '{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"Q"}'), newEntryHotkey: JSON.parse(localStorage.getItem('newEntryHotkey') || @@ -6302,6 +6309,7 @@ function openSettings() { // Hotkey capture buttons — labels reflect current combos. $('#settingAutofillFullCombo').textContent = autofillComboLabel(state.autofillHotkeyFull); $('#settingAutofillPwdCombo').textContent = autofillComboLabel(state.autofillHotkeyPwd); + $('#settingAutofillTotpCombo').textContent = autofillComboLabel(state.autofillHotkeyTotp); $('#settingQuickSearchCombo').textContent = autofillComboLabel(state.quickSearchHotkey); $('#settingNewEntryCombo').textContent = autofillComboLabel(state.newEntryHotkey); $('#settingAutofillHotkeysRow').style.display = Bridge.active ? '' : 'none'; @@ -6690,7 +6698,8 @@ const SYNCED_SETTING_KEYS = [ // Hotkey combos are user preferences — values are portable. The // registration itself is Windows-only, so non-Windows clients just // ignore them. - 'autofillHotkeyFull', 'autofillHotkeyPwd', 'quickSearchHotkey', 'newEntryHotkey', + 'autofillHotkeyFull', 'autofillHotkeyPwd', 'autofillHotkeyTotp', + 'quickSearchHotkey', 'newEntryHotkey', // Sidebar section collapsed state. Object of { folders, tags, tools } // booleans. Synced so the user gets the same fold state across devices. 'sidebarCollapsed', @@ -6764,6 +6773,7 @@ async function loadServerSettings() { case 'pageSize': localStorage.setItem('pageSize', String(v)); break; case 'autofillHotkeyFull': case 'autofillHotkeyPwd': + case 'autofillHotkeyTotp': case 'quickSearchHotkey': case 'newEntryHotkey': case 'sidebarCollapsed': @@ -7702,6 +7712,7 @@ async function init() { const others = [ kind !== 'full' ? state.autofillHotkeyFull : null, kind !== 'password' ? state.autofillHotkeyPwd : null, + kind !== 'totp' ? state.autofillHotkeyTotp : null, kind !== 'quickSearch' ? state.quickSearchHotkey : null, kind !== 'newEntry' ? state.newEntryHotkey : null, ].filter(Boolean); @@ -7714,6 +7725,7 @@ async function init() { // Commit. if (kind === 'full') state.autofillHotkeyFull = captured; else if (kind === 'password') state.autofillHotkeyPwd = captured; + else if (kind === 'totp') state.autofillHotkeyTotp = captured; else if (kind === 'quickSearch') state.quickSearchHotkey = captured; else /* newEntry */ state.newEntryHotkey = captured; btn.textContent = autofillComboLabel(captured); @@ -7726,16 +7738,19 @@ async function init() { } bindHotkeyCapture('#settingAutofillFullCombo', 'full'); bindHotkeyCapture('#settingAutofillPwdCombo', 'password'); + bindHotkeyCapture('#settingAutofillTotpCombo', 'totp'); bindHotkeyCapture('#settingQuickSearchCombo', 'quickSearch'); bindHotkeyCapture('#settingNewEntryCombo', 'newEntry'); $('#settingAutofillResetHotkeys').addEventListener('click', () => { state.autofillHotkeyFull = { ctrl: true, shift: true, alt: false, win: false, key: 'L' }; state.autofillHotkeyPwd = { ctrl: true, shift: true, alt: false, win: false, key: 'P' }; + state.autofillHotkeyTotp = { ctrl: true, shift: true, alt: false, win: false, key: 'T' }; state.quickSearchHotkey = { ctrl: true, shift: true, alt: false, win: false, key: 'Q' }; state.newEntryHotkey = { ctrl: true, shift: true, alt: false, win: false, key: 'A' }; $('#settingAutofillFullCombo').textContent = autofillComboLabel(state.autofillHotkeyFull); $('#settingAutofillPwdCombo').textContent = autofillComboLabel(state.autofillHotkeyPwd); + $('#settingAutofillTotpCombo').textContent = autofillComboLabel(state.autofillHotkeyTotp); $('#settingQuickSearchCombo').textContent = autofillComboLabel(state.quickSearchHotkey); $('#settingNewEntryCombo').textContent = autofillComboLabel(state.newEntryHotkey); autofillPushHotkeys(); diff --git a/js/app.overlays.js b/js/app.overlays.js index 73eec8a..8d314d9 100644 --- a/js/app.overlays.js +++ b/js/app.overlays.js @@ -187,6 +187,7 @@ const CHEATSHEET_GROUPS = [ items: [ { keys: ['Ctrl', 'Shift', 'L'], desc: 'Autofill username + password into the active window' }, { keys: ['Ctrl', 'Shift', 'P'], desc: 'Autofill password only (step-2 forms, unlock screens)' }, + { keys: ['Ctrl', 'Shift', 'T'], desc: 'Autofill the 2FA code (click the 2FA field first)' }, { keys: ['Ctrl', 'Shift', 'Q'], desc: 'Quick search → SendInput password into the active window' }, { keys: ['Ctrl', 'Shift', 'A'], desc: 'Quick-add a new entry pre-filled with the foreground window title' }, ],