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' },
],