feat(autofill): Ctrl+Shift+T types the 2FA code

Same workflow as the password-only hotkey: click the 2FA field, press the
combo, the current 6-digit code is typed in.

Reuses the whole autofill pipeline — no new native typing path. The code is
sent in the password slot with an empty username, so Delphi's existing
password-only branch types just the code.

- Bridge: akTotp kind, hotkey id 47, registered/torn down alongside the other
  two autofill combos; an unconfigured combo (vk=0) doesn't count as a
  registration failure.
- Matching filters on totp_secret BEFORE scoring, so the multi-match picker
  never offers an entry that has no code.
- Configurable + synced like the others (autofillHotkeyTotp), collision-checked
  against the other four slots, covered by Reset to defaults and the cheatsheet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-25 16:53:00 +01:00
parent 2d6ba98073
commit 7c86f35fce
7 changed files with 121 additions and 18 deletions
+43 -4
View File
@@ -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');
+17 -2
View File
@@ -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();
+1
View File
@@ -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' },
],