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:
+43
-4
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user