From d3cc7ee9258306c3f843882f5516d2e304cf3cca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9leste=20Wouters?= Date: Tue, 9 Dec 2025 16:40:23 +0100 Subject: [PATCH] Fix TOTP fill missing digits on some sites (fixes #2215) Fixes Authelia and Epic Games OTP fill by addressing two underlying problems: 1. Incorrect DOM event ordering and data - So far only empty key events were sent, which may confuse OTP input implementations that rely on the text from those events instead of input field value. - The "beforeinput" event was missing altogether. - Reorder the events to comply to the UIEvents spec (https://w3c.github.io/uievents/#keypress-event-order) and change field.value at the expected moment in that order. 2. Focus events were not sent As described in https://github.com/keepassxreboot/keepassxc-browser/issues/2215#issuecomment-3629070227, the extension focuses fields sequentially when filling in the OTP digits, but this does not trigger focus/blur events as this is performed within the same window event loop cycle, throwing off certain implementations of multi-textfield OTP input; in the cause of Authelia filling an internal digit buffer starting from nonexistent index -1. Sending those events manually was attempted but did not yield satisfactory results as some frameworks like React use Promise objects to schedule their state update, resulting in the problem still being present. Instead, makes kpxcFill.setValueWithChange async so it can await on an empty Promise of its own, giving time for the user agent to trigger the appropriate focus events as well as for pages' scripts to settle. --- keepassxc-browser/content/fill.js | 57 ++++++++++++++++++------------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/keepassxc-browser/content/fill.js b/keepassxc-browser/content/fill.js index b6b9571..9198c40 100644 --- a/keepassxc-browser/content/fill.js +++ b/keepassxc-browser/content/fill.js @@ -18,7 +18,7 @@ kpxcFill.fillAttributeToActiveElementWith = async function(attr) { return; } - kpxcFill.setValue(el, value[0]); + await kpxcFill.setValue(el, value[0]); }; // Fill requested from the context menu. Active element is used for combination detection @@ -152,20 +152,20 @@ kpxcFill.fillTOTPFromUuid = async function(el, uuid) { return; } - kpxcFill.setTOTPValue(el, totp); + await kpxcFill.setTOTPValue(el, totp); } else if (user.stringFields?.length > 0) { const stringFields = user.stringFields; for (const s of stringFields) { const val = s['KPH: {TOTP}']; if (val) { - kpxcFill.setTOTPValue(el, val); + await kpxcFill.setTOTPValue(el, val); } } } }; // Set normal or segmented TOTP value -kpxcFill.setTOTPValue = function(elem, val) { +kpxcFill.setTOTPValue = async function(elem, val) { if (kpxc.credentials.length === 0) { logDebug('Error: Credential list is empty.'); return; @@ -173,22 +173,22 @@ kpxcFill.setTOTPValue = function(elem, val) { for (const comb of kpxc.combinations) { if (comb.totpInputs?.length > 0) { - kpxcFill.fillSegmentedTotp(elem, val, comb.totpInputs); + await kpxcFill.fillSegmentedTotp(elem, val, comb.totpInputs); return; } } - kpxcFill.setValue(elem, val); + await kpxcFill.setValue(elem, val); }; // Fill TOTP in parts -kpxcFill.fillSegmentedTotp = function(elem, val, totpInputs) { +kpxcFill.fillSegmentedTotp = async function(elem, val, totpInputs) { if (!totpInputs.includes(elem) || val.length < totpInputs.length) { return; } for (let i = 0; i < totpInputs.length; ++i) { - kpxcFill.setValue(totpInputs[i], val[i]); + await kpxcFill.setValue(totpInputs[i], val[i]); } }; @@ -271,7 +271,7 @@ kpxcFill.fillInCredentials = async function(combination, predefinedUsername, uui return; } - kpxcFill.setValueWithChange(combination.password, selectedCredentials.password); + await kpxcFill.setValueWithChange(combination.password, selectedCredentials.password); await kpxc.setPasswordFilled(true); } @@ -279,20 +279,20 @@ kpxcFill.fillInCredentials = async function(combination, predefinedUsername, uui if (combination.username && usernameValue && combination.username !== combination.password && (!combination.username.value || combination.username.value !== usernameValue)) { if (!passOnly) { - kpxcFill.setValueWithChange(combination.username, usernameValue); + await kpxcFill.setValueWithChange(combination.username, usernameValue); } } // Fill StringFields if (selectedCredentials.stringFields?.length > 0) { - kpxcFill.fillInStringFields(combination.fields, selectedCredentials.stringFields); + await kpxcFill.fillInStringFields(combination.fields, selectedCredentials.stringFields); } // Fill TOTP if (kpxc.settings.autoFillSingleTotp && kpxc.entryHasTotp(selectedCredentials)) { const totpCombination = combination?.totp || kpxc.combinations?.find(c => c.totp); if (totpCombination?.totp) { - kpxcFill.fillTOTPFromUuid(totpCombination.totp, selectedCredentials.uuid); + await kpxcFill.fillTOTPFromUuid(totpCombination.totp, selectedCredentials.uuid); } } @@ -306,7 +306,7 @@ kpxcFill.fillInCredentials = async function(combination, predefinedUsername, uui }; // Fills StringFields defined in Custom Fields -kpxcFill.fillInStringFields = function(fields, stringFields) { +kpxcFill.fillInStringFields = async function(fields, stringFields) { const filledInFields = []; if (fields && stringFields && fields?.length > 0 && stringFields?.length > 0) { for (let i = 0; i < fields.length; i++) { @@ -318,7 +318,7 @@ kpxcFill.fillInStringFields = function(fields, stringFields) { const currentField = fields[i]; if (currentField && stringFieldValue[0]) { - kpxcFill.setValue(currentField, stringFieldValue[0], true); + await kpxcFill.setValue(currentField, stringFieldValue[0], true); filledInFields.push(currentField); } } @@ -353,14 +353,14 @@ kpxcFill.performAutoSubmit = async function(combination, skipAutoSubmit) { }; // Special handling for setting value to select and checkbox elements -kpxcFill.setValue = function(field, value, forced = false) { +kpxcFill.setValue = async function(field, value, forced = false) { if (field.matches('select')) { value = value.toLowerCase().trim(); const options = field.querySelectorAll('option'); for (const o of options) { if (o.textContent.toLowerCase().trim() === value) { - kpxcFill.setValueWithChange(field, o.value); + await kpxcFill.setValueWithChange(field, o.value); return false; } } @@ -378,11 +378,11 @@ kpxcFill.setValue = function(field, value, forced = false) { } - kpxcFill.setValueWithChange(field, value, forced); + await kpxcFill.setValueWithChange(field, value, forced); }; // Sets a new value to input field and triggers necessary events -kpxcFill.setValueWithChange = function(field, value, forced = false) { +kpxcFill.setValueWithChange = async function(field, value, forced = false) { if (!field || (field?.readOnly && !forced)) { return; } @@ -401,12 +401,23 @@ kpxcFill.setValueWithChange = function(field, value, forced = false) { }; field.focus(); - field.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: false })); - field.dispatchEvent(new KeyboardEvent('keypress', { bubbles: true, cancelable: false })); - field.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, cancelable: false })); - field.dispatchEvent(new Event('input', { bubbles: true, cancelable: false })); - field.dispatchEvent(new Event('change', { bubbles: true, cancelable: false })); + + // Use a delay to allow focus events to trigger and to give some + // breathing room to frameworks that rely on promises to update their + // state, like React. Not doing so can break OTP input, see issue #2215. + await Promise.resolve(); + + field.dispatchEvent(new FocusEvent('focus', { bubbles: false, cancelable: false })); + field.dispatchEvent(new FocusEvent('focusin', { bubbles: true, cancelable: false })); + + // https://w3c.github.io/uievents/#keypress-event-order + field.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: false, key: value })); + field.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, cancelable: false, inputType: 'insertText', data: value })); + field.dispatchEvent(new KeyboardEvent('keypress', { bubbles: true, cancelable: false, key: value })); field.value = value; + field.dispatchEvent(new Event('input', { bubbles: true, cancelable: false })); + field.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, cancelable: false, key: value })); + field.dispatchEvent(new Event('change', { bubbles: true, cancelable: false })); // Some pages will not accept the value change without dispatching events directly to the document dispatchLegacyEvent(field, 'input');