Implement Fido2 User Verification (close #8533)

This commit is contained in:
Olli 2025-07-14 01:55:57 +02:00
parent b417f2a23b
commit fae2db9147
8 changed files with 219 additions and 23 deletions

View File

@ -10,7 +10,7 @@ import functools
import dataclasses
import re
import html as html_utils
from typing import cast, Union, Optional
from typing import cast, Union, Optional, Any
from qutebrowser.qt.core import (pyqtSignal, pyqtSlot, Qt, QPoint, QPointF, QUrl,
QObject, QByteArray, QTimer)
@ -28,6 +28,15 @@ from qutebrowser.utils import (usertypes, qtutils, log, javascript, utils,
from qutebrowser.qt import sip, machinery
from qutebrowser.misc import objects, miscwidgets
if machinery.IS_QT6:
try:
from qutebrowser.qt.webenginecore import QWebEngineWebAuthUxRequest
except ImportError:
# Added in Qt 6.7
QWebEngineWebAuthUxRequest: None = None # type: ignore[no-redef]
else:
QWebEngineWebAuthUxRequest: Any = None
# Mapping worlds from usertypes.JsWorld to QWebEngineScript world IDs.
_JS_WORLD_MAP = {
@ -1241,6 +1250,152 @@ class _WebEngineScripts(QObject):
)
class _WebEngineWebAuth(QObject):
"""Handling of Webauth events.
Signals:
request_cancelled: Emitted when a Webauth request was cancelled.
"""
request_cancelled = pyqtSignal()
def __init__(self, tab, parent=None):
super().__init__(parent)
self._tab = tab
self._request = None
def on_ux_requested(self, request):
"""Handle a Webauth UX request."""
log.webview.debug("Asking for Webauth user verification for "
f"{request.relyingPartyId()}")
self._request = request
request.stateChanged.connect(self._on_ux_state_changed)
self._on_ux_state_changed(request.state())
def _on_ux_state_changed(self, state):
log.webview.debug(f"Webauth UX state: {state}")
if state == QWebEngineWebAuthUxRequest.WebAuthUxState.CollectPin:
self._ux_pin_request()
elif state == QWebEngineWebAuthUxRequest.WebAuthUxState.FinishTokenCollection:
self._ux_finish_token_collection()
elif state == QWebEngineWebAuthUxRequest.WebAuthUxState.SelectAccount:
self._ux_account_selection()
elif state == QWebEngineWebAuthUxRequest.WebAuthUxState.Completed:
self._ux_request_completed()
elif state == QWebEngineWebAuthUxRequest.WebAuthUxState.Cancelled:
self._ux_request_cancelled()
elif state == QWebEngineWebAuthUxRequest.WebAuthUxState.RequestFailed:
self._ux_request_failed()
else:
raise utils.Unreachable(state)
def _ux_pin_request(self):
assert self._request is not None
log.webview.debug("Collect Webauth pin for "
f"{self._request.relyingPartyId()}")
answer = self._verification_required(self._request.relyingPartyId())
if answer is not None:
log.webview.debug("User verification accepted by user")
self._request.setPin(answer)
else:
log.webview.debug("User verification aborted by user")
self._request.cancel()
def _ux_finish_token_collection(self):
log.webview.debug("Finish Webauth token collection")
message.info("Please touch your device now.")
def _ux_account_selection(self):
assert self._request is not None
log.webview.debug("Select Webauth account for "
f"{self._request.relyingPartyId()}")
answer = self._select_account(self._request.relyingPartyId(),
self._request.userNames())
if answer is not None:
log.webview.debug(f"Username selection accepted by user: {answer}")
self._request.setSelectedAccount(answer)
else:
log.webview.debug("Username selection aborted by user")
self._request.cancel()
def _ux_request_completed(self):
log.webview.debug("Webauth request completed")
message.info("User verification completed.")
self._request = None
def _ux_request_cancelled(self):
log.webview.debug("Webauth verification cancelled")
message.info("User verification cancelled.")
self.request_cancelled.emit()
def _ux_request_failed(self):
assert self._request is not None
log.webview.debug("Webauth request failed for "
f"{self._request.relyingPartyId()}: "
f"{self._request.requestFailureReason()}")
reason_text = self._get_failure_reason_text(
self._request.requestFailureReason()
)
message.info(f"User verification failed: {reason_text}")
def _get_failure_reason_text(self, reason):
texts = {
QWebEngineWebAuthUxRequest.RequestFailureReason.Timeout:
"The request timed out.",
QWebEngineWebAuthUxRequest.RequestFailureReason.KeyNotRegistered:
"The device is not registered.",
QWebEngineWebAuthUxRequest.RequestFailureReason.KeyAlreadyRegistered:
"You already registered this device.",
QWebEngineWebAuthUxRequest.RequestFailureReason.SoftPinBlock:
"The device is soft-locked because the wrong PIN was entered "
"too many times.",
QWebEngineWebAuthUxRequest.RequestFailureReason.HardPinBlock:
"The device is hard-locked because the wrong PIN was entered "
"too many times.",
QWebEngineWebAuthUxRequest.RequestFailureReason.AuthenticatorRemovedDuringPinEntry:
"The device was removed during verification. Please re-insert "
"and try again",
QWebEngineWebAuthUxRequest.RequestFailureReason.AuthenticatorMissingResidentKeys:
"The device does not have resident key support.",
QWebEngineWebAuthUxRequest.RequestFailureReason.AuthenticatorMissingUserVerification:
"The device is missing user verification.",
QWebEngineWebAuthUxRequest.RequestFailureReason.AuthenticatorMissingLargeBlob:
"No common algorithms.",
QWebEngineWebAuthUxRequest.RequestFailureReason.NoCommonAlgorithms:
"No common algorithms",
QWebEngineWebAuthUxRequest.RequestFailureReason.StorageFull:
"The storage on the device is full.",
QWebEngineWebAuthUxRequest.RequestFailureReason.UserConsentDenied:
"User consent was denied.",
QWebEngineWebAuthUxRequest.RequestFailureReason.WinUserCancelled:
"User cancelled the request.",
}
if reason not in texts:
raise utils.Unreachable(reason)
return texts[reason]
def _verification_required(self, url: str) -> Any:
"""Ask a prompt for a webauth user verification request."""
return message.ask(
title=f"User Verification for {url}",
text="Please enter the PIN for your device:",
mode=usertypes.PromptMode.pwd,
abort_on=[self._tab.abort_questions, self.request_cancelled])
def _select_account(self, url: str, usernames: list[str]) -> Any:
"""Ask a prompt for a webauth account selection."""
usernames_html = "".join(f"<li>{html_utils.escape(name)}</li>"
for name in usernames)
text = f"Please select an account:<br><ul>{usernames_html}</ul>"
return message.ask(
title=f"Account Selection for {url}", text=text,
choices=usernames, mode=usertypes.PromptMode.select,
abort_on=[self._tab.abort_questions, self.request_cancelled])
class WebEngineTabPrivate(browsertab.AbstractTabPrivate):
"""QtWebEngine-related methods which aren't part of the public API."""
@ -1308,6 +1463,8 @@ class WebEngineTab(browsertab.AbstractTab):
tab=self)
self._permissions = _WebEnginePermissions(tab=self, parent=self)
self._scripts = _WebEngineScripts(tab=self, parent=self)
if QWebEngineWebAuthUxRequest is not None:
self._webauth = _WebEngineWebAuth(tab=self, parent=self)
# We're assigning settings in _set_widget
self.settings = webenginesettings.WebEngineSettings(settings=None)
self._set_widget(widget)
@ -1735,6 +1892,8 @@ class WebEngineTab(browsertab.AbstractTab):
page.loadStarted.connect(self._on_load_started)
page.certificate_error.connect(self._on_ssl_errors)
page.authenticationRequired.connect(self._on_authentication_required)
if machinery.IS_QT6 and QWebEngineWebAuthUxRequest is not None:
page.webAuthUxRequested.connect(self._webauth.on_ux_requested)
page.proxyAuthenticationRequired.connect(
self._on_proxy_authentication_required)
page.contentsSizeChanged.connect(self.contents_size_changed)

View File

@ -317,6 +317,8 @@ class PromptContainer(QWidget):
usertypes.PromptMode.user_pwd: AuthenticationPrompt,
usertypes.PromptMode.download: DownloadFilenamePrompt,
usertypes.PromptMode.alert: AlertPrompt,
usertypes.PromptMode.pwd: PasswordPrompt,
usertypes.PromptMode.select: SelectPrompt,
}
klass = classes[question.mode]
prompt = klass(question)
@ -1046,6 +1048,28 @@ class AlertPrompt(_BasePrompt):
return [('prompt-accept', "Hide")]
class PasswordPrompt(LineEditPrompt):
"""A prompt for a password/pin."""
def __init__(self, question, parent=None):
super().__init__(question, parent)
self._lineedit.setEchoMode(QLineEdit.EchoMode.Password)
class SelectPrompt(LineEditPrompt):
"""A prompt for selecting one out of multiple choices."""
def accept(self, value=None, save=False):
self._check_save_support(save)
text = value if value is not None else self._lineedit.text()
if text in self.question.choices:
self.question.answer = text
return True
return False
def init():
"""Initialize global prompt objects."""
global prompt_queue

View File

@ -111,7 +111,8 @@ def _build_question(title: str,
default: Union[None, bool, str] = None,
abort_on: Iterable[pyqtBoundSignal] = (),
url: str = None,
option: bool = None) -> usertypes.Question:
option: bool = None,
choices: list[str] = None) -> usertypes.Question:
"""Common function for ask/ask_async."""
question = usertypes.Question()
question.title = title
@ -127,6 +128,10 @@ def _build_question(title: str,
raise ValueError("Need 'url' given when 'option' is given")
question.option = option
if choices is not None and mode != usertypes.PromptMode.select:
raise ValueError("Can only use 'choices' with PromptMode.select")
question.choices = choices
for sig in abort_on:
sig.connect(question.abort)
return question
@ -142,6 +147,8 @@ def ask(*args: Any, **kwargs: Any) -> Any:
text: Additional text to show
option: The option for always/never question answers.
Only available with PromptMode.yesno.
choices: The choices for options question answers.
Only available with PromptMode.selection.
abort_on: A list of signals which abort the question if emitted.
Return:
@ -190,6 +197,7 @@ def confirm_async(*, yes_action: _ActionType,
question.
default: True/False to set a default value, or None.
option: The option for always/never question answers.
choices: The choices for options question answers.
text: Additional text to show.
Return:

View File

@ -227,6 +227,8 @@ class PromptMode(enum.Enum):
user_pwd = enum.auto()
alert = enum.auto()
download = enum.auto()
pwd = enum.auto()
select = enum.auto()
class ClickTarget(enum.Enum):
@ -359,6 +361,8 @@ class Question(QObject):
yesno: A question which can be answered with yes/no.
text: A question which requires a free text answer.
user_pwd: A question for a username and password.
pwd: A question for a password or PIN.
select: A question for selecting an option.
default: The default value.
For yesno, None (no default), True or False.
For text, a default text as string.
@ -399,6 +403,7 @@ class Question(QObject):
self.text: Optional[str] = None
self.url: Optional[str] = None
self.option: Optional[bool] = None
self.choices: Optional[list[str]] = None
self.answer: Union[str, bool, None] = None
self.is_aborted = False
self.interrupted = False
@ -406,7 +411,7 @@ class Question(QObject):
def __repr__(self) -> str:
return utils.get_repr(self, title=self.title, text=self.text,
mode=self.mode, default=self.default,
option=self.option)
option=self.option, choices=self.choices)
@pyqtSlot()
def done(self) -> None:

View File

@ -77,7 +77,7 @@ Feature: Downloading things from a website.
And I set downloads.location.prompt to true
And I open data/downloads/issue1243.html
And I hint with args "links download" and follow a
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='qutebrowser-download' mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default='qutebrowser-download' mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
Then the error "Download error: Invalid host (from path): ''" should be shown
And "UrlInvalidError while handling qute://* URL" should be logged
@ -86,7 +86,7 @@ Feature: Downloading things from a website.
And I set downloads.location.prompt to true
And I open data/data_link.html
And I hint with args "links download" and follow s
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='download.pdf' mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default='download.pdf' mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
And I run :mode-leave
Then no crash should happen
@ -94,7 +94,7 @@ Feature: Downloading things from a website.
When I set downloads.location.suggestion to filename
And I set downloads.location.prompt to true
And I open data/downloads/download.bin in a new window without waiting
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default='*' mode=<PromptMode.download: 5> *" in the log
And I run :window-only
And I run :mode-leave
Then no crash should happen
@ -132,7 +132,7 @@ Feature: Downloading things from a website.
Scenario: Shutting down with a download question
When I set downloads.location.prompt to true
And I open data/downloads/download.bin without waiting
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> option=None text='Please enter a location for <b>http://localhost:*/data/downloads/download.bin</b>' title='Save file to:'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default='*' mode=<PromptMode.download: 5> option=None text='Please enter a location for <b>http://localhost:*/data/downloads/download.bin</b>' title='Save file to:'>, *" in the log
And I run :close
Then qutebrowser should quit
# (and no crash should happen)
@ -162,7 +162,7 @@ Feature: Downloading things from a website.
Scenario: Downloading a file to a reserved path
When I set downloads.location.prompt to true
And I open data/downloads/download.bin without waiting
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> option=None text='Please enter a location for <b>http://localhost:*/data/downloads/download.bin</b>' title='Save file to:'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default='*' mode=<PromptMode.download: 5> option=None text='Please enter a location for <b>http://localhost:*/data/downloads/download.bin</b>' title='Save file to:'>, *" in the log
And I run :prompt-accept COM1
And I run :mode-leave
Then the error "Invalid filename" should be shown
@ -171,7 +171,7 @@ Feature: Downloading things from a website.
Scenario: Downloading a file to a drive-relative working directory
When I set downloads.location.prompt to true
And I open data/downloads/download.bin without waiting
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> option=None text='Please enter a location for <b>http://localhost:*/data/downloads/download.bin</b>' title='Save file to:'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default='*' mode=<PromptMode.download: 5> option=None text='Please enter a location for <b>http://localhost:*/data/downloads/download.bin</b>' title='Save file to:'>, *" in the log
And I run :prompt-accept C:foobar
And I run :mode-leave
Then the error "Invalid filename" should be shown
@ -251,14 +251,14 @@ Feature: Downloading things from a website.
Scenario: :download with a filename and directory which doesn't exist
When I run :download --dest (tmpdir)(dirsep)downloads(dirsep)somedir(dirsep)file http://localhost:(port)/data/downloads/download.bin
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default=None mode=<PromptMode.yesno: 1> option=None text='<b>*</b> does not exist. Create it?' title='Create directory?'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default=None mode=<PromptMode.yesno: 1> option=None text='<b>*</b> does not exist. Create it?' title='Create directory?'>, *" in the log
And I run :prompt-accept yes
And I wait until the download is finished
Then the downloaded file somedir/file should exist
Scenario: :download with a directory which doesn't exist
When I run :download --dest (tmpdir)(dirsep)downloads(dirsep)somedir(dirsep) http://localhost:(port)/data/downloads/download.bin
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default=None mode=<PromptMode.yesno: 1> option=None text='<b>*</b> does not exist. Create it?' title='Create directory?'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default=None mode=<PromptMode.yesno: 1> option=None text='<b>*</b> does not exist. Create it?' title='Create directory?'>, *" in the log
And I run :prompt-accept yes
And I wait until the download is finished
Then the downloaded file somedir/download.bin should exist
@ -283,13 +283,13 @@ Feature: Downloading things from a website.
When I set downloads.location.prompt to true
And I open data/title.html
And I run :download --mhtml
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> option=None text='Please enter a location for <b>http://localhost:*/data/title.html</b>' title='Save file to:'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default='*' mode=<PromptMode.download: 5> option=None text='Please enter a location for <b>http://localhost:*/data/title.html</b>' title='Save file to:'>, *" in the log
And I run :prompt-accept
And I wait for "File successfully written." in the log
And I run :download --mhtml
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> option=None text='Please enter a location for <b>http://localhost:*/data/title.html</b>' title='Save file to:'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default='*' mode=<PromptMode.download: 5> option=None text='Please enter a location for <b>http://localhost:*/data/title.html</b>' title='Save file to:'>, *" in the log
And I run :prompt-accept
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default=None mode=<PromptMode.yesno: 1> option=None text='<b>*</b> already exists. Overwrite?' title='Overwrite existing file?'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default=None mode=<PromptMode.yesno: 1> option=None text='<b>*</b> already exists. Overwrite?' title='Overwrite existing file?'>, *" in the log
And I run :prompt-accept yes
And I wait for "File successfully written." in the log
Then the downloaded file Test title.mhtml should exist
@ -668,9 +668,9 @@ Feature: Downloading things from a website.
Scenario: Answering a question for a cancelled download (#415)
When I set downloads.location.prompt to true
And I run :download http://localhost:(port)/data/downloads/download.bin
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default='*' mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
And I run :download http://localhost:(port)/data/downloads/download2.bin
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default='*' mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
And I run :download-cancel with count 2
And I run :prompt-accept
And I wait until the download is finished
@ -681,11 +681,11 @@ Feature: Downloading things from a website.
Scenario: Nested download prompts (#8674)
When I set downloads.location.prompt to true
And I open data/downloads/download.bin without waiting
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default='*' mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
And I open data/downloads/download.bin without waiting
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default='*' mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
And I open data/downloads/download.bin without waiting
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default='*' mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
And I run :prompt-accept
And I run :mode-leave
And I run :mode-leave

View File

@ -203,7 +203,7 @@ Feature: Special qute:// pages
And I open data/misc/test.pdf without waiting
And I wait until PDF.js is ready
And I run :jseval (document.getElementById("downloadButton") || document.getElementById("download")).click()
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default=* mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default=* mode=<PromptMode.download: 5> option=None text=* title='Save file to:'>, *" in the log
And I run :mode-leave
Then no crash should happen

View File

@ -364,7 +364,7 @@ Feature: Saving and loading sessions
Scenario: Saving session with an empty download tab
When I open data/downloads/downloads.html
And I run :click-element --force-event -t tab id download
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='*' mode=<PromptMode.download: 5> *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question choices=None default='*' mode=<PromptMode.download: 5> *" in the log
And I run :mode-leave
And I run :session-save current
And I run :session-load --clear current

View File

@ -13,8 +13,8 @@ bdd.scenarios('downloads.feature')
PROMPT_MSG = ("Asking question <qutebrowser.utils.usertypes.Question "
"default={!r} mode=<PromptMode.download: 5> option=None "
"text=* title='Save file to:'>, *")
"choices=None default={!r} mode=<PromptMode.download: 5> "
"option=None text=* title='Save file to:'>, *")
@pytest.fixture