diff --git a/app/page/requestcert.py b/app/page/requestcert.py index 1d5ef25..5e04afe 100644 --- a/app/page/requestcert.py +++ b/app/page/requestcert.py @@ -14,19 +14,24 @@ def __init__(self, mypkcs, myacme, parent=None): def initializePage(self): QTimer.singleShot(500, self.natimer) + def _get_yubikey_pin(self) -> str: + return self.wizard().property("yubikey_pin") + def natimer(self): self.setTitle("Request Certificate") layout = QVBoxLayout(self) label = QLabel("Certificate Requests") layout.addWidget(label) selectedYubiKeySlot, _, _ = self.wizard().property("selectedYubiKey") + yubikey_pin = self._get_yubikey_pin() + jwttoken = self.wizard().property("jwt_token")() f9crt = self.pkcs.getf9(selectedYubiKeySlot) for keynum in [4, 3, 2, 1]: hwattest = self.pkcs.getattest(selectedYubiKeySlot, keynum) self.acme.send_request(hwattest, jwttoken, keynum - 1, f9crt) self.acme.wait(keynum - 1) - csr = self.pkcs.getcsr(selectedYubiKeySlot, keynum) + csr = self.pkcs.getcsr(selectedYubiKeySlot, keynum, yubikey_pin) cert = self.acme.final(keynum, csr, jwttoken) self.pkcs.savecert(selectedYubiKeySlot, keynum, cert) self.wizard().next() # Programmatically trigger the Next button diff --git a/app/page/selectkey.py b/app/page/selectkey.py index f26061a..b2ed19f 100644 --- a/app/page/selectkey.py +++ b/app/page/selectkey.py @@ -6,11 +6,25 @@ QListWidgetItem, ) +from app.page.yubipin_widget import YubiPinWidget +from app.yubikey_details import YubikeyDetails + from .yubikeyitem import YubiKeyItemWidget +from app.pkcs import pkcs as InternalPKCSWrapper class SelectYubiKeyPage(QWizardPage): + _TITLE = "Selectie en authenticatie Yubikey" + _SUBTITLE = """ + Selecteer de desbetreffende Yubikey en vul vervolgens hiervan de PIN-code in. De standaard PIN-code is alvast ingevuld. + """ + + pkcs: InternalPKCSWrapper key_list_widget: QListWidget + _pin_input_widget: YubiPinWidget + + # This will be set when the user has succesfully authenticated + _pin_authenticated: bool def _prevent_backbutton_clicks(self): self.setCommitPage(True) @@ -28,6 +42,8 @@ def _get_yubikeys(self): def _build_yubikey_list_widget(self, yubikeys: list[Any]) -> QListWidget: widget = QListWidget() + widget.itemSelectionChanged.connect(self.on_yubikey_item_change) + widget.setSelectionMode(QListWidget.SelectionMode.SingleSelection) for name, serial, available, slot in yubikeys: itemWidget = YubiKeyItemWidget(name, serial, available, slot) @@ -39,19 +55,31 @@ def _build_yubikey_list_widget(self, yubikeys: list[Any]) -> QListWidget: return widget - def __init__(self, mypkcs, parent=None): - super().__init__(parent) - self.setTitle("Selecteer de te gebruiken yubikey") + def _setup_ui(self, pkcslib: InternalPKCSWrapper): + self.setTitle(self._TITLE) + self.setSubTitle(self._SUBTITLE.strip()) + self._prevent_backbutton_clicks() - self.pkcs = mypkcs yubikeys = self._get_yubikeys() yubikey_list_widget = self._build_yubikey_list_widget(yubikeys) layout = QVBoxLayout(self) layout.addWidget(yubikey_list_widget) + yubipin_widget = YubiPinWidget(pkcslib.pkcs11) + yubipin_widget.pin_authenticated_signal.connect(self._on_yubikey_authentication) + layout.addWidget(yubipin_widget) + self.key_list_widget = yubikey_list_widget + self._pin_input_widget = yubipin_widget + + def __init__(self, mypkcs: InternalPKCSWrapper, parent=None): + super().__init__(parent) + self.pkcs = mypkcs + self._pin_authenticated = False + + self._setup_ui(mypkcs) def _find_selected_widget_item(self) -> Optional[YubiKeyItemWidget]: selected_indexes = self.key_list_widget.selectedIndexes() @@ -68,20 +96,40 @@ def _find_selected_widget_item(self) -> Optional[YubiKeyItemWidget]: return widget + def _find_selected_yubikey_details_from_widget(self): + selected_yubikey_widget_item: Optional[YubiKeyItemWidget] = self._find_selected_widget_item() + + if selected_yubikey_widget_item is None: + return None + + slot, name, serial = selected_yubikey_widget_item.getYubiKeyDetails() + return YubikeyDetails( + slot, + name, + serial, + ) + def on_yubikey_item_change(self): - # The next button does not have to be enabled manually, just trigger the completion signal. - # This will re-check the isCompleted function - # https://doc.qt.io/qt-6/qwizardpage.html#completeChanged + selected_yubikey: Optional[YubikeyDetails] = self._find_selected_yubikey_details_from_widget() + + if not selected_yubikey: + self._pin_authenticated = False + + # Pass none if nothing is selected + self._pin_input_widget.toggle_input_field_ability(details=selected_yubikey) self.completeChanged.emit() - def isComplete(self) -> bool: + def has_selection(self) -> bool: return self._find_selected_widget_item() is not None + def isComplete(self) -> bool: + return self.has_selection() and self._pin_authenticated + def nextId(self): widget = self._find_selected_widget_item() # This should and can not happen, since we're disabling the button - if not widget: + if not widget or not self.isComplete(): return super().nextId() # Store the selected YubiKey information in the wizard @@ -89,12 +137,16 @@ def nextId(self): "selectedYubiKey", widget.getYubiKeyDetails(), ) - return super().nextId() + pin = self._pin_input_widget.get_value() + self.wizard().setProperty("yubikey_pin", pin) - def initializePage(self) -> None: - super().initializePage() + return super().nextId() - self.key_list_widget.itemSelectionChanged.connect(self.on_yubikey_item_change) + def _on_yubikey_authentication(self, authenticated: bool): + self._pin_authenticated = authenticated + self.completeChanged.emit() def cleanupPage(self) -> None: + self._pin_authenticated = False + self._pin_input_widget.cleanup() self.key_list_widget.clearSelection() diff --git a/app/page/yubipin_widget.py b/app/page/yubipin_widget.py new file mode 100644 index 0000000..302b228 --- /dev/null +++ b/app/page/yubipin_widget.py @@ -0,0 +1,150 @@ +from typing import Optional +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtWidgets import QLineEdit, QLabel, QHBoxLayout, QWidget, QPushButton + +from app.yubikey_details import YubikeyDetails + +from PyKCS11 import PyKCS11Lib + +from app.yubikey_pin_authenticator import YubikeyPINAuthenticator + + +class YubiPinWidget(QWidget): + _DEFAULT_YUBIKEY_PIN = "123456" + + _pin_label = QLabel + _input: QLineEdit + _authenticate_button: QPushButton + _notification_text: QLabel + + # We can't enforce this into a Optional[YubikeyDetails], so we have to do it like this + _selectedYubiKeySignal = pyqtSignal(object) + + # This is being called when the page above cleans up + _cleanup_signal = pyqtSignal() + + # This will get updated based on the incoming signal + _selected_yubikey: Optional[YubikeyDetails] + + # The lib used for authenticating + _pykcs_lib: PyKCS11Lib + + # This signal is conected from outside + pin_authenticated_signal = pyqtSignal(bool) + + def _build_label(self): + label = QLabel("PIN") + label.setEnabled(False) + + return label + + def _build_input(self): + i = QLineEdit(self._DEFAULT_YUBIKEY_PIN) + + # By default, the button is enabled, but should be enabled when a YubiKey is selected + i.setEnabled(False) + i.setEchoMode(QLineEdit.EchoMode.Password) + + i.textChanged.connect(self._on_pin_edit) + + return i + + def _build_auth_button(self): + button = QPushButton("Authenticate") + button.setEnabled(False) + button.clicked.connect(self._authenticate) + + return button + + def _setup_ui(self): + layout = QHBoxLayout() + + label = self._build_label() + layout.addWidget(label) + self._pin_label = label + + pin_input = self._build_input() + layout.addWidget(pin_input) + + button = self._build_auth_button() + layout.addWidget(button) + + notification_text = QLabel() + notification_text.hide() + layout.addWidget(notification_text) + self._notification_text = notification_text + + # Set this layout as the layout of the widget + self.setLayout(layout) + + self._selectedYubiKeySignal.connect(self._internal_select_yubikey) + self._cleanup_signal.connect(self._on_cleanup_signal) + + self._input = pin_input + self._authenticate_button = button + + def __init__(self, pykcs11lib: PyKCS11Lib) -> None: + super().__init__(None) + self._setup_ui() + self._pykcs_lib = pykcs11lib + self._selected_yubikey = None + + def get_value(self) -> str: + return self._input.text() + + def _notify_pin_ok(self): + self._notification_text.setText("OK") + self._notification_text.show() + self.pin_authenticated_signal.emit(True) + + def _notify_pin_incorrect(self): + self._notification_text.setText("PIN incorrect") + self._notification_text.show() + + self.pin_authenticated_signal.emit(False) + + def _authenticate(self): + if not self._selected_yubikey: + return + ok: bool = YubikeyPINAuthenticator(self._pykcs_lib).login( + self._selected_yubikey, + self.get_value(), + ) + + if not ok: + self._notify_pin_incorrect() + return + + self._notify_pin_ok() + + # Close all sessions so we can log back in on later moments + self._pykcs_lib.closeAllSessions(self._selected_yubikey.slot) + + def _on_pin_edit(self, value: str): + empty_text: bool = bool(not value.strip()) + disable_auth_button: bool = empty_text and self._authenticate_button.isEnabled() + + self._authenticate_button.setEnabled(not disable_auth_button) + + def toggle_input_field_ability(self, details: Optional[YubikeyDetails]): + self._selectedYubiKeySignal.emit(details) + + def cleanup(self): + self._cleanup_signal.emit() + + def _on_cleanup_signal(self): + self._notification_text.hide() + + def _internal_select_yubikey(self, details: Optional[YubikeyDetails]): + on = details is not None + + # Update the selected yubikey if any + self._selected_yubikey = details + + self._pin_label.setEnabled(on) + self._input.setEnabled(on) + + # We don't want to always enable the auth button. Text has to be in there too. + should_enable_auth_button: bool = on and self.get_value() != "" + + self._authenticate_button.setEnabled(should_enable_auth_button) diff --git a/app/pkcs.py b/app/pkcs.py index e421fa2..1c793d6 100644 --- a/app/pkcs.py +++ b/app/pkcs.py @@ -20,6 +20,8 @@ class AlgorithmIdentifier(Sequence): class pkcs: + _3DES_MANAGEMENT_KEY = "010203040506070801020304050607080102030405060708" + DEFAULT_LIB_LOCATION = "/usr/lib64/libykcs11.so.2" DEFAULT_HOMEBREW_LOCATION = "/opt/homebrew/lib/libykcs11.dylib" @@ -29,27 +31,26 @@ class pkcs: keys = {1: 1, 2: 2, 3: 3, 4: 4} pkcs11: PyKCS11.PyKCS11Lib - _yubikey_pin: str - def __init__(self, pykcs11lib: PyKCS11.PyKCS11Lib, yubikey_pin: str): + def __init__(self, pykcs11lib: PyKCS11.PyKCS11Lib): self.pkcs11 = pykcs11lib - self._yubikey_pin = yubikey_pin - def getusersession(self, slot): + def getusersession(self, slot, yubikey_pin: str): print("User Open", slot) if slot not in self.sessions: self.sessions[slot] = self.pkcs11.openSession(slot) - self.sessions[slot].login(self._yubikey_pin) + self.sessions[slot].login(yubikey_pin) return self.sessions[slot] def getsession(self, slot): print("Open", slot) + if slot not in self.sessions: - # self.sessions[slot] = self.pkcs11.openSession(slot) - # self.sessions[slot].login("123456") self.sessions[slot] = self.pkcs11.openSession(slot, PyKCS11.CKF_RW_SESSION) + + # This works because it's logging in with the 3DES management key self.sessions[slot].login( - "010203040506070801020304050607080102030405060708", + self._3DES_MANAGEMENT_KEY, user_type=PyKCS11.CKU_SO, ) return self.sessions[slot] @@ -59,7 +60,7 @@ def getadminsession(self, slot): if slot not in self.sessions: self.sessions[slot] = self.pkcs11.openSession(slot, PyKCS11.CKF_RW_SESSION) self.sessions[slot].login( - "010203040506070801020304050607080102030405060708", + self._3DES_MANAGEMENT_KEY, user_type=PyKCS11.CKU_SO, ) return self.sessions[slot] @@ -205,9 +206,9 @@ def makecsr(self, session, private_key, public_key): ) return csr.dump() - def getcsr(self, slot, keyid): + def getcsr(self, slot, keyid, yubikey_pin: str): csr = None - session = self.getusersession(slot) + session = self.getusersession(slot, yubikey_pin) privkey = session.findObjects([(PyKCS11.CKA_CLASS, PyKCS11.CKO_PRIVATE_KEY), (PyKCS11.CKA_ID, [keyid])]) pubkey = session.findObjects([(PyKCS11.CKA_CLASS, PyKCS11.CKO_PUBLIC_KEY), (PyKCS11.CKA_ID, [keyid])]) if privkey and pubkey: diff --git a/app/wizard.py b/app/wizard.py index fb6b81b..946f4e3 100644 --- a/app/wizard.py +++ b/app/wizard.py @@ -61,12 +61,9 @@ def __init__(self, mypkcs, myacme, oidc_provider_base_url: urllib.parse.ParseRes app = QApplication(sys.argv) - yubikey_pin = getenv( - "YUBIKEY_PIN", - ) # This will search default locations and fall back to the PYKCS11LIB environment variable pkcslib = PKCS11LibFinder().find() - pkcscls = pkcs(pykcs11lib=pkcslib, yubikey_pin=yubikey_pin) + pkcscls = pkcs(pykcs11lib=pkcslib) oidc_provider_url = urllib.parse.urlparse(getenv("OIDC_PROVIDER_BASE_URL", DEFAULT_PROEFTUIN_OIDC_LOGIN_URL)) print( diff --git a/app/yubikey_details.py b/app/yubikey_details.py new file mode 100644 index 0000000..029d6d4 --- /dev/null +++ b/app/yubikey_details.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + + +@dataclass +class YubikeyDetails: + slot: str + name: str + serial: str diff --git a/app/yubikey_pin_authenticator.py b/app/yubikey_pin_authenticator.py new file mode 100644 index 0000000..7ce316d --- /dev/null +++ b/app/yubikey_pin_authenticator.py @@ -0,0 +1,36 @@ +from app.yubikey_details import YubikeyDetails + +from PyKCS11 import PyKCS11Lib, PyKCS11Error + + +class YubikeyPINAuthenticator: + _pykcs11lib: PyKCS11Lib + + def __init__(self, pykcs11lib: PyKCS11Lib): + self._pykcs11lib = pykcs11lib + + def _selected_yubikey_slot_available(self, key: YubikeyDetails) -> bool: + return key.slot in self._pykcs11lib.getSlotList() + + def _key_available(self, key: YubikeyDetails) -> bool: + return self._selected_yubikey_slot_available(key) + + def _is_pin_valid_to_yubikey(self, slot: str, pin: str) -> bool: + try: + session = self._pykcs11lib.openSession(slot) + + # This will throw an exception if the pin is incorrect + session.login(pin) + except PyKCS11Error: + return False + + return True + + def login(self, key: YubikeyDetails, pin: str) -> bool: + # Currently, since it's just validation, we log the user out + self._pykcs11lib.closeAllSessions(key.slot) + + if not self._key_available(key): + return False + + return self._is_pin_valid_to_yubikey(key.slot, pin) diff --git a/docs/LOCALSETUP.md b/docs/LOCALSETUP.md index 74f27e1..522ca99 100644 --- a/docs/LOCALSETUP.md +++ b/docs/LOCALSETUP.md @@ -69,8 +69,7 @@ There are a few environmnent variables which need to be configured via the `.env | Variable | Default value | Type | | :-------------------------: | :------------------------------------------------------------: | :---: | -| `ACME_SERVER_DIRECTORY_URL` | `"https://acme.proeftuin.uzi-online.rdobeheer.nl/directory"` | `str` | -| `YUBIKEY_PIN` | `"123456"` | `str` | +| `ACME_SERVER_DIRECTORY_URL` | `"https://acme.proeftuin.uzi-online.irealisatie.nl/directory"` | `str` | | `OIDC_PROVIDER_BASE_URL` | `"https://proeftuin.uzi-online.irealisatie.nl"` | `str` | The `ACME_SERVER_DIRECTORY_URL` should be set to the the directory URL of the ACME server. For example, this can be `http://localhost:8080/acme/directory` when working with the local ACME server developed by iRealisatie. @@ -92,8 +91,8 @@ This will open up the initial screen, press continue. #### 2.2 Selecting the Yubikey -This screen allows you to select a YubiKey. Select yours and click continue. -![alt text](image-1.png) +This screen allows you to select a YubiKey. Select yours, fill in the PIN code and click authorize. When this is successful, click the commit button. +![alt text](yubikey-select.png) #### 2.3 Logging in diff --git a/docs/image-1.png b/docs/image-1.png deleted file mode 100644 index 8695d85..0000000 Binary files a/docs/image-1.png and /dev/null differ diff --git a/docs/yubikey-select.png b/docs/yubikey-select.png new file mode 100644 index 0000000..e766b51 Binary files /dev/null and b/docs/yubikey-select.png differ diff --git a/tests/ui/test_selectkey_page.py b/tests/ui/test_selectkey_page.py new file mode 100644 index 0000000..2a8598a --- /dev/null +++ b/tests/ui/test_selectkey_page.py @@ -0,0 +1,158 @@ +from unittest.mock import MagicMock +from pytestqt.qtbot import QtBot + +from app.page.selectkey import SelectYubiKeyPage + +from PyQt6.QtWidgets import QWizardPage, QWizard + +from PyKCS11 import CK_TOKEN_INFO + +from app.page.yubikeyitem import YubiKeyItemWidget + + +def _create_sample_token_info() -> CK_TOKEN_INFO: + info = CK_TOKEN_INFO() + info.model = "key" + info.serialNumber = "1234" + info.label = "1234" + + return info + + +def test_is_wizard_page(qtbot: QtBot): + pkcs_wrapper = MagicMock() + + page = SelectYubiKeyPage(pkcs_wrapper) + qtbot.addWidget(page) + + assert isinstance(page, QWizardPage) + + +def test_not_complete_yet(qtbot: QtBot): + pkcs_wrapper = MagicMock() + + page = SelectYubiKeyPage(pkcs_wrapper) + qtbot.addWidget(page) + + assert page._pin_authenticated is False + assert page.isComplete() is False + + +def test_with_key_on_page(qtbot: QtBot): + mock_token_info = _create_sample_token_info() + pkcs_wrapper = MagicMock() + pkcs_wrapper.pkcs11.getSlotList.return_value = ["123"] + pkcs_wrapper.pkcs11.getTokenInfo.return_value = mock_token_info + + page = SelectYubiKeyPage(pkcs_wrapper) + qtbot.addWidget(page) + + first_item = page.key_list_widget.item(0) + assert first_item is not None + + widget_item = page.key_list_widget.itemWidget(first_item) + assert isinstance(widget_item, YubiKeyItemWidget) + + slot, name, serial = widget_item.getYubiKeyDetails() + + assert slot == "123" + assert name == mock_token_info.model + assert serial == mock_token_info.serialNumber + + assert page._pin_input_widget._input.isEnabled() is False + + +def test_select_key_on_page(qtbot: QtBot): + mock_token_info = _create_sample_token_info() + + pkcs_wrapper = MagicMock() + pkcs_wrapper.pkcs11.getSlotList.return_value = ["123"] + pkcs_wrapper.pkcs11.getTokenInfo.return_value = mock_token_info + + page = SelectYubiKeyPage(pkcs_wrapper) + qtbot.addWidget(page) + + first_item = page.key_list_widget.item(0) + assert first_item is not None + + with qtbot.waitSignal(page.key_list_widget.itemSelectionChanged): + page.key_list_widget.setCurrentRow(0) + + assert page._pin_input_widget._input.isEnabled() + + +def test_deselect(qtbot: QtBot): + mock_token_info = _create_sample_token_info() + + pkcs_wrapper = MagicMock() + pkcs_wrapper.pkcs11.getSlotList.return_value = ["123"] + pkcs_wrapper.pkcs11.getTokenInfo.return_value = mock_token_info + + page = SelectYubiKeyPage(pkcs_wrapper) + qtbot.addWidget(page) + + first_item = page.key_list_widget.item(0) + assert first_item is not None + + # First, select the item + with qtbot.waitSignal(page.key_list_widget.itemSelectionChanged): + page.key_list_widget.setCurrentRow(0) + + assert page._pin_input_widget._input.isEnabled() + + # Then de-select and assert + with qtbot.waitSignal(page.key_list_widget.itemSelectionChanged): + page.key_list_widget.clearSelection() + + assert page._pin_input_widget._input.isEnabled() is False + + +def test_cleanup(qtbot: QtBot): + mock_token_info = _create_sample_token_info() + + pkcs_wrapper = MagicMock() + pkcs_wrapper.pkcs11.getSlotList.return_value = ["123"] + pkcs_wrapper.pkcs11.getTokenInfo.return_value = mock_token_info + + page = SelectYubiKeyPage(pkcs_wrapper) + qtbot.addWidget(page) + + first_item = page.key_list_widget.item(0) + assert first_item is not None + + # First, select the item + with qtbot.waitSignal(page.key_list_widget.itemSelectionChanged): + page.key_list_widget.setCurrentRow(0) + + page.cleanupPage() + + assert page._pin_authenticated is False + assert page.key_list_widget.selectedIndexes() == [] + + +def test_mock_next_page(qtbot: QtBot): + mock_token_info = _create_sample_token_info() + + pkcs_wrapper = MagicMock() + pkcs_wrapper.pkcs11.getSlotList.return_value = ["123"] + pkcs_wrapper.pkcs11.getTokenInfo.return_value = mock_token_info + + page = SelectYubiKeyPage(pkcs_wrapper) + + wizard = QWizard() + wizard.addPage(page) + + qtbot.addWidget(wizard) + assert page.key_list_widget.item(0) is not None + + with qtbot.waitSignal(page.key_list_widget.itemSelectionChanged): + page.key_list_widget.setCurrentRow(0) + + page._pin_authenticated = True + page.nextId() + + pin = wizard.property("yubikey_pin") + details = wizard.property("selectedYubiKey") + + assert pin == "123456" + assert details diff --git a/tests/ui/test_yubipin_widget.py b/tests/ui/test_yubipin_widget.py new file mode 100644 index 0000000..4e3f892 --- /dev/null +++ b/tests/ui/test_yubipin_widget.py @@ -0,0 +1,95 @@ +from unittest import mock +from unittest.mock import MagicMock +from pytestqt.qtbot import QtBot + +from app.page.yubipin_widget import YubiPinWidget + +from app.yubikey_details import YubikeyDetails + + +def _create_default_widget(): + pykcslib = MagicMock() + widget = YubiPinWidget(pykcs11lib=pykcslib) + return widget + + +def test_cant_authorize_yet(qtbot: QtBot): + widget = _create_default_widget() + + qtbot.addWidget(widget) + + button = widget._authenticate_button + passwordinput = widget._input + notification_text = widget._notification_text + + assert not button.isEnabled() + assert not passwordinput.isEnabled() + assert notification_text.isHidden() + + +def test_with_selected_key_no_action(qtbot: QtBot): + widget = _create_default_widget() + qtbot.addWidget(widget) + + selected_key = YubikeyDetails( + "testslot", + "testkey", + "123", + ) + widget.toggle_input_field_ability(selected_key) + + button = widget._authenticate_button + passwordinput = widget._input + notification_text = widget._notification_text + + assert widget.get_value() == "123456" + + assert passwordinput.isEnabled() + assert button.isEnabled() + assert notification_text.isHidden() + + +def test_auth_key_not_available(qtbot: QtBot): + widget = _create_default_widget() + qtbot.addWidget(widget) + + on_pin_auth_mock = mock.Mock(wraps=lambda _: ...) + + selected_key = YubikeyDetails( + "testslot", + "testkey", + "123", + ) + widget.toggle_input_field_ability(selected_key) + widget.pin_authenticated_signal.connect(on_pin_auth_mock) + + with qtbot.waitSignal(widget.pin_authenticated_signal, timeout=5000): + widget._authenticate_button.click() + + assert widget._notification_text.text() == "PIN incorrect" + on_pin_auth_mock.assert_called_once_with(False) + + +def test_auth_pin_ok(qtbot: QtBot): + selected_key = YubikeyDetails( + "testslot", + "testkey", + "123", + ) + pykcslib = MagicMock() + pykcslib.getSlotList.return_value = [selected_key.slot] + pykcslib.openSession.return_value.login.return_value = True + + widget = YubiPinWidget(pykcs11lib=pykcslib) + qtbot.addWidget(widget) + + on_pin_auth_mock = mock.Mock(wraps=lambda _: ...) + + widget.toggle_input_field_ability(selected_key) + widget.pin_authenticated_signal.connect(on_pin_auth_mock) + + with qtbot.waitSignal(widget.pin_authenticated_signal, timeout=5000): + widget._authenticate_button.click() + + assert widget._notification_text.text() == "OK" + on_pin_auth_mock.assert_called_once_with(True)