diff --git a/sssd_test_framework/hosts/client.py b/sssd_test_framework/hosts/client.py index 76ebee06..9f1733a3 100644 --- a/sssd_test_framework/hosts/client.py +++ b/sssd_test_framework/hosts/client.py @@ -61,6 +61,7 @@ def features(self) -> dict[str, bool]: echo "limited_enumeration" || : [ -f "/usr/bin/vicc" ] && echo "virtualsmartcard" || : [ -f "/usr/bin/umockdev-run" ] && echo "umockdev" || : + [ -f "/opt/test_venv/bin/vfido.py" ] && echo "vfido" || : """, log_level=ProcessLogLevel.Error, ) diff --git a/sssd_test_framework/misc/globals.py b/sssd_test_framework/misc/globals.py new file mode 100644 index 00000000..2b5973e4 --- /dev/null +++ b/sssd_test_framework/misc/globals.py @@ -0,0 +1,4 @@ +from __future__ import annotations + +test_venv = "/opt/test_venv" +test_venv_bin = f"{test_venv}/bin" diff --git a/sssd_test_framework/roles/client.py b/sssd_test_framework/roles/client.py index dd8ecd3f..1851daba 100644 --- a/sssd_test_framework/roles/client.py +++ b/sssd_test_framework/roles/client.py @@ -17,6 +17,7 @@ from ..utils.sss_override import SSSOverrideUtils from ..utils.sssctl import SSSCTLUtils from ..utils.sssd import SSSDUtils +from ..utils.vfido import Vfido from .base import BaseLinuxRole __all__ = [ @@ -104,6 +105,11 @@ def __init__(self, *args, **kwargs) -> None: Managing GDM interface from SCAutolib """ + self.vfido: Vfido = Vfido(self.host) + """ + Managing virtual passkey device and service + """ + def setup(self) -> None: """ Called before execution of each test. diff --git a/sssd_test_framework/roles/ipa.py b/sssd_test_framework/roles/ipa.py index 8215e3fa..3f912b14 100644 --- a/sssd_test_framework/roles/ipa.py +++ b/sssd_test_framework/roles/ipa.py @@ -25,6 +25,8 @@ to_list_of_strings, to_list_without_none, ) +from ..misc.globals import test_venv_bin +from ..roles.client import Client from ..utils.sssctl import SSSCTLUtils from ..utils.sssd import SSSDUtils from .base import BaseLinuxRole, BaseObject @@ -1026,7 +1028,15 @@ def passkey_add(self, passkey_mapping: str) -> IPAUser: self._exec("add-passkey", [passkey_mapping]) return self - def passkey_add_register( + def passkey_add_register(self, **kwargs) -> str: + """wrapper for passkey_add_register methods""" + if "virt_type" in kwargs and kwargs["virt_type"] == "vfido": + del kwargs["virt_type"] + return self.vfido_passkey_add_register(**kwargs) + else: + return self.umockdev_passkey_add_register(**kwargs) + + def umockdev_passkey_add_register( self, *, pin: str | int | None, @@ -1103,6 +1113,64 @@ def passkey_remove(self, passkey_mapping: str) -> IPAUser: self._exec("remove-passkey", [passkey_mapping]) return self + def vfido_passkey_add_register( + self, + *, + client: Client, + pin: str | int | None = None, + ) -> str: + """ + Register user passkey when using virtual-fido + """ + + if pin is None: + pin = "empty" + + client.host.conn.exec(["kinit", f"{self.host.adminuser}@{self.host.realm}"], input=self.host.adminpw) + + result = client.host.conn.expect( + f""" + set pin "{pin}" + set timeout 60 + + spawn ipa user-add-passkey {self.name} --register + set ID_reg $spawn_id + + if {{ ($pin ne "empty") }} {{ + expect {{ + -i $ID_reg -re "Enter PIN:*" {{}} + -i $ID_reg timeout {{puts "expect result: Unexpected output"; exit 201}} + -i $ID_reg eof {{puts "expect result: Unexpected end of file"; exit 202}} + }} + + puts "Entering PIN\n" + send -i $ID_reg "{pin}\r" + }} + + expect {{ + -i $ID_reg -re "Please touch the device.*" {{}} + -i $ID_reg timeout {{puts "expect result: Unexpected output"; exit 203}} + -i $ID_reg eof {{puts "expect result: Unexpected end of file"; exit 204}} + }} + + puts "Touching device" + spawn {test_venv_bin}/vfido_touch + set ID_touch $spawn_id + + expect {{ + -i $ID_reg -re "Added passkey mappings.*" {{}} + -i $ID_reg timeout {{puts "expect result: Unexpected output"; exit 205}} + -i $ID_reg eof {{puts "expect result: Unexpected end of file"; exit 206}} + }} + + expect -i $ID_reg eof + expect -i $ID_touch eof + """, + raise_on_error=True, + ) + + return result.stdout_lines[-1].strip() + def iduseroverride(self) -> IDUserOverride: """ Add override to the IPA user. diff --git a/sssd_test_framework/utils/authentication.py b/sssd_test_framework/utils/authentication.py index 158e9d5f..dc0804ef 100644 --- a/sssd_test_framework/utils/authentication.py +++ b/sssd_test_framework/utils/authentication.py @@ -11,6 +11,7 @@ from pytest_mh.utils.fs import LinuxFileSystem from ..misc.errors import ExpectScriptError +from ..misc.globals import test_venv_bin from .idp import IdpAuthenticationUtils __all__ = [ @@ -412,8 +413,28 @@ def password_expired(self, username: str, password: str, new_password: str) -> b def passkey_with_output( self, - username: str, + **kwargs, + ) -> tuple[int, int, str, str]: + """wrapper for passkey_with_output methods""" + if "virt_type" in kwargs and kwargs["virt_type"] == "vfido": + return self.vfido_passkey_with_output(**kwargs) + else: + return self.umockdev_passkey_with_output(**kwargs) + + def passkey( + self, + **kwargs, + ) -> bool: + """wrapper for passkey methods""" + if "virt_type" in kwargs and kwargs["virt_type"] == "vfido": + return self.vfido_passkey(**kwargs) + else: + return self.umockdev_passkey(**kwargs) + + def umockdev_passkey_with_output( + self, *, + username: str, device: str, ioctl: str, script: str, @@ -604,10 +625,10 @@ def passkey_with_output( return result.rc, cmdrc, stdout, result.stderr - def passkey( + def umockdev_passkey( self, - username: str, *, + username: str, device: str, ioctl: str, script: str, @@ -634,11 +655,195 @@ def passkey( :return: True if authentication was successful, False otherwise. :rtype: bool """ - rc, _, _, _ = self.passkey_with_output( + rc, _, _, _ = self.umockdev_passkey_with_output( username=username, pin=pin, device=device, ioctl=ioctl, script=script, command=command ) return rc == 0 + def vfido_passkey_with_output( + self, + *, + username: str, + pin: str | int | None, + interactive_prompt: str = "Insert your passkey device, then press ENTER.", + touch_prompt: str = "Please touch the device.", + command: str = "exit 0", + auth_method: PasskeyAuthenticationUseCases = PasskeyAuthenticationUseCases.PASSKEY_PIN, + ) -> tuple[int, int, str, str]: + """ + Call ``su - $username`` and authenticate the user with vfido passkey + + :param username: Username + :type username: str + :param pin: Passkey PIN, defaults to None + :type pin: str | int | None + :param interactive_prompt: Interactive prompt, defaults to "Insert your passkey device, then press ENTER." + :type interactive_prompt: str + :param touch_prompt: Touch prompt, defaults to "Please touch the device." + :type touch_prompt: str + :param command: Command executed after user is authenticated, defaults to "exit 0" + :type command: str + :param auth_method: Authentication method, defaults to PasskeyAuthenticationUseCases.PASSKEY_PIN + :type auth_method: PasskeyAuthenticationUseCases + :return: Tuple containing [return code, command code, stdout, stderr]. + :rtype: Tuple[int, int, str, str] + """ + + match auth_method: + case PasskeyAuthenticationUseCases.PASSKEY_PIN | PasskeyAuthenticationUseCases.PASSKEY_PIN_AND_PROMPTS: + if pin is None: + raise ValueError(f"PIN is required for {str(auth_method)}") + case ( + PasskeyAuthenticationUseCases.PASSKEY_PROMPTS_NO_PIN + | PasskeyAuthenticationUseCases.PASSKEY_FALLBACK_TO_PASSWORD + | PasskeyAuthenticationUseCases.PASSKEY_NO_PIN_NO_PROMPTS + ): + if pin is not None: + raise ValueError(f"PIN is not required for {str(auth_method)}") + + run_su = self.fs.mktmp( + rf""" + #!/bin/bash + set -ex + su --shell /bin/sh nobody -c "su - '{username}' -c '{command}'" + """, + mode="a=rx", + ) + + result = self.host.conn.expect( + rf""" + # Disable debug output + # exp_internal 0 + + proc exitmsg {{ msg code }} {{ + # Close spawned program, if we are in the prompt + catch close + + # Wait for the exit code + lassign [wait] pid spawnid os_error_flag rc + + puts "" + puts "expect result: $msg" + puts "expect exit code: $code" + puts "expect spawn exit code: $rc" + exit $code + }} + + # It takes some time to get authentication failure + set timeout {DEFAULT_AUTHENTICATION_TIMEOUT} + set prompt "\n.*\[#\$>\] $" + set command "{command}" + set auth_method "{auth_method}" + + spawn "{run_su}" + set ID_su $spawn_id + + # If the authentication method set without entering the PIN, it will directly ask + # prompt, if we set prompting options in sssd.conf it will ask interactive and touch prompt. + + if {{ ($auth_method eq "{PasskeyAuthenticationUseCases.PASSKEY_NO_PIN_NO_PROMPTS}") + || ($auth_method eq "{PasskeyAuthenticationUseCases.PASSKEY_PROMPTS_NO_PIN}") }} {{ + expect {{ + -i $ID_su -re "{interactive_prompt}*" {{ send -i $ID_su "\n" }} + -i $ID_su timeout {{exitmsg "Unexpected output" 201 }} + -i $ID_su eof {{exitmsg "Unexpected end of file" 202 }} + }} + # If prompt options are set + if {{ ($auth_method eq "{PasskeyAuthenticationUseCases.PASSKEY_PROMPTS_NO_PIN}") }} {{ + expect {{ + -i $ID_su -re "{touch_prompt}*" {{ }} + -i $ID_su timeout {{exitmsg "Unexpected output" 203 }} + -i $ID_su eof {{exitmsg "Unexpected end of file" 204 }} + }} + }} + }} + + # If authentication method set with PIN, after interactive prompt always ask to Enter the PIN. + # If PIN is correct with prompt options in sssd.conf it will ask interactive and touch prompt. + # If we press Enter key for PIN, sssd will fallback to next auth method, here it will ask + # for Password. + + if {{ ($auth_method eq "{PasskeyAuthenticationUseCases.PASSKEY_PIN}") + || ($auth_method eq "{PasskeyAuthenticationUseCases.PASSKEY_PIN_AND_PROMPTS}") + || ($auth_method eq "{PasskeyAuthenticationUseCases.PASSKEY_FALLBACK_TO_PASSWORD}")}} {{ + expect {{ + -i $ID_su -re "{interactive_prompt}*" {{ send -i $ID_su "\n" }} + -i $ID_su timeout {{exitmsg "Unexpected output" 205 }} + -i $ID_su eof {{exitmsg "Unexpected end of file" 206 }} + }} + expect {{ + -i $ID_su -re "Enter PIN:*" {{send -i $ID_su "{pin}\r"}} + -i $ID_su timeout {{exitmsg "Unexpected output" 207}} + -i $ID_su eof {{exitmsg "Unexpected end of file" 208}} + }} + if {{ ($auth_method eq "{PasskeyAuthenticationUseCases.PASSKEY_FALLBACK_TO_PASSWORD}") }} {{ + expect {{ + -i $ID_su -re "Password:*" {{send -i $ID_su "Secret123\r"}} + -i $ID_su timeout {{exitmsg "Unexpected output" 209}} + -i $ID_su eof {{exitmsg "Unexpected end of file" 210}} + }} + }} + if {{ ($auth_method eq "{PasskeyAuthenticationUseCases.PASSKEY_PIN_AND_PROMPTS}") }} {{ + expect {{ + -i $ID_su -re "{touch_prompt}*" {{ }} + -i $ID_su timeout {{exitmsg "Unexpected output" 211 }} + -i $ID_su eof {{exitmsg "Unexpected end of file" 212 }} + }} + }} + }} + + # Now simulate touch on vfido device + spawn {test_venv_bin}/vfido_touch + set ID_touch $spawn_id + + expect {{ + -i $ID_su -re "Authentication failure" {{exitmsg "Authentication failure" 1}} + -i $ID_su eof {{exitmsg "Passkey authentication successful" 0}} + -i $ID_su timeout {{exitmsg "Unexpected output" 213}} + }} + + expect -i $ID_touch eof + + exitmsg "Unexpected code path" 220 + """, + verbose=False, + ) + + if result.rc > 200: + raise ExpectScriptError(result.rc) + + expect_data = result.stdout_lines[-3:] + + # Get command exit code. + cmdrc = int(expect_data[2].split(":")[1].strip()) + + # Alter stdout, first line is spawned command, the last three are our expect output. + stdout = "\n".join(result.stdout_lines[1:-3]) + + return result.rc, cmdrc, stdout, result.stderr + + def vfido_passkey( + self, + *, + username: str, + pin: str | int | None = None, + command: str = "exit 0", + ) -> bool: + """ + Call ``su - $username`` and authenticate the user with passkey. + + :param username: Username + :type username: str + :param pin: Passkey PIN. + :type pin: str | int | None + :param command: Command executed after user is authenticated, defaults to "exit 0" + :type command: str + :return: True if authentication was successful, False otherwise. + :rtype: bool + """ + rc, _, _, _ = self.vfido_passkey_with_output(username=username, pin=pin, command=command) + return rc == 0 + class SSHAuthenticationUtils(MultihostUtility[MultihostHost]): """ diff --git a/sssd_test_framework/utils/gdm.py b/sssd_test_framework/utils/gdm.py index 6616dafd..c717ee06 100644 --- a/sssd_test_framework/utils/gdm.py +++ b/sssd_test_framework/utils/gdm.py @@ -27,6 +27,8 @@ def __init__(self, host): super().__init__(host) self.init_completed = False + self.cmd = ["/opt/test_venv/bin/scauto", "gui", "--wait-time", "1", "--no-screenshot"] + def teardown(self): if self.init_completed: self.done() @@ -36,7 +38,7 @@ def init(self) -> None: Initialize GDM for testing """ if not self.init_completed: - self.host.conn.exec(["/opt/test_venv/bin/scauto", "gui", "init"]) + self.host.conn.exec([*self.cmd, "init"]) self.init_completed = True def assert_text(self, word: str) -> bool: @@ -51,7 +53,7 @@ def assert_text(self, word: str) -> bool: if not self.init_completed: self.init() - result = self.host.conn.exec(["/opt/test_venv/bin/scauto", "gui", "assert-text", word]) + result = self.host.conn.exec([*self.cmd, "assert-text", word]) return result.rc == 0 def click_on(self, word: str) -> bool: @@ -66,7 +68,7 @@ def click_on(self, word: str) -> bool: if not self.init_completed: self.init() - result = self.host.conn.exec(["/opt/test_venv/bin/scauto", "gui", "click-on", word]) + result = self.host.conn.exec([*self.cmd, "click-on", word]) return result.rc == 0 def kb_write(self, word: str) -> bool: @@ -81,7 +83,7 @@ def kb_write(self, word: str) -> bool: if not self.init_completed: self.init() - result = self.host.conn.exec(["/opt/test_venv/bin/scauto", "gui", "kb-write", word]) + result = self.host.conn.exec([*self.cmd, "kb-write", word]) return result.rc == 0 def kb_send(self, word: str) -> bool: @@ -96,7 +98,7 @@ def kb_send(self, word: str) -> bool: if not self.init_completed: self.init() - result = self.host.conn.exec(["/opt/test_venv/bin/scauto", "gui", "kb-send", word]) + result = self.host.conn.exec([*self.cmd, "kb-send", word]) return result.rc == 0 def check_home_screen(self) -> bool: @@ -109,7 +111,8 @@ def check_home_screen(self) -> bool: if not self.init_completed: self.init() - result = self.host.conn.exec(["/opt/test_venv/bin/scauto", "gui", "check-home-screen"], raise_on_error=False) + cmd = ["/opt/test_venv/bin/scauto", "-v", "debug", "gui", "--wait-time", "5", "--no-screenshot"] + result = self.host.conn.exec([*cmd, "check-home-screen"], raise_on_error=False) return result.rc == 0 def done(self) -> bool: @@ -123,8 +126,9 @@ def done(self) -> bool: if not self.init_completed: return True - result = self.host.conn.exec(["/opt/test_venv/bin/scauto", "gui", "done"]) + result = self.host.conn.exec([*self.cmd, "done"]) self.host.conn.exec(["systemctl", "stop", "gdm"], raise_on_error=False) + self.init_completed = False return result.rc == 0 def login_idp(self, client: Client, username: str, password: str) -> bool: diff --git a/sssd_test_framework/utils/sssctl.py b/sssd_test_framework/utils/sssctl.py index a1e6aea6..8eb723c9 100644 --- a/sssd_test_framework/utils/sssctl.py +++ b/sssd_test_framework/utils/sssctl.py @@ -7,6 +7,8 @@ from pytest_mh.conn import ProcessResult from pytest_mh.utils.fs import LinuxFileSystem +from ..misc.globals import test_venv_bin + __all__ = [ "SSSCTLUtils", ] @@ -103,7 +105,73 @@ def cache_expire( self.host.conn.exec(["sssctl", "cache-expire"] + self.cli.args(args)) - def passkey_register( + def passkey_register(self, *args, **kwargs) -> str: + """wrapper for passkey_register methods""" + if "virt_type" in kwargs and kwargs["virt_type"] == "vfido": + del kwargs["virt_type"] + return self.vfido_passkey_register(*args, **kwargs) + else: + return self.umockdev_passkey_register(*args, **kwargs) + + def vfido_passkey_register( + self, + username: str, + domain: str, + *, + pin: str | int | None = None, + ) -> str: + """ + Register user passkey when using virtual-fido + """ + + if pin is None: + pin = "empty" + + result = self.host.conn.expect( + f""" + set pin "{pin}" + set timeout 60 + + spawn sssctl passkey-register --username {username} --domain {domain} + set ID_reg $spawn_id + + if {{ ($pin ne "empty") }} {{ + expect {{ + -i $ID_reg -re "Enter PIN:*" {{}} + -i $ID_reg timeout {{puts "expect result: Unexpected output"; exit 201}} + -i $ID_reg eof {{puts "expect result: Unexpected end of file"; exit 202}} + }} + + puts "Entering PIN\n" + send -i $ID_reg "{pin}\r" + }} + + expect {{ + -i $ID_reg -re "Please touch the device.*" {{}} + -i $ID_reg timeout {{puts "expect result: Unexpected output"; exit 203}} + -i $ID_reg eof {{puts "expect result: Unexpected end of file"; exit 204}} + }} + + puts "Touching device" + sleep 1 + spawn {test_venv_bin}/vfido_touch + set ID_touch $spawn_id + + expect {{ + -i $ID_reg -re "passkey:.*,.*" {{}} + -i $ID_reg timeout {{puts "expect result: Unexpected output"; exit 205}} + -i $ID_reg eof {{puts "expect result: Unexpected end of file"; exit 206}} + }} + + expect -i $ID_reg eof + expect -i $ID_touch eof + """, + raise_on_error=True, + ) + + return result.stdout_lines[-2].strip() + + def umockdev_passkey_register( self, username: str, domain: str, diff --git a/sssd_test_framework/utils/vfido.py b/sssd_test_framework/utils/vfido.py new file mode 100644 index 00000000..f2ce0312 --- /dev/null +++ b/sssd_test_framework/utils/vfido.py @@ -0,0 +1,52 @@ +"""Manage virtual FIDO device.""" + +from __future__ import annotations + +from pytest_mh import MultihostHost, MultihostUtility + +from ..misc.globals import test_venv_bin + +__all__ = [ + "Vfido", +] + + +class Vfido(MultihostUtility[MultihostHost]): + """ + Manage virtual passkey device and service + """ + + def __init__(self, host: MultihostHost): + super().__init__(host) + + def stop(self) -> None: + """Stop vfido service""" + self.host.conn.exec(["systemctl", "stop", "vfido"]) + + def start(self) -> None: + """Start vfido service""" + self.host.conn.exec(["systemctl", "start", "vfido"]) + + def reset(self) -> None: + """reset state of vfido service back to clean""" + self.stop() + self.host.conn.exec([f"{test_venv_bin}/vfido_reset"]) + + def touch(self) -> bool: + """ + send touch signal to virtual passkey + """ + result = self.host.conn.exec([f"{test_venv_bin}/vfido_touch"]) + return result.rc == 0 + + def pin_set(self, pin: str | int) -> None: + """Set pin on virtual passkey""" + self.host.conn.exec([f"{test_venv_bin}/vfido_pin_set", str(pin)]) + + def pin_enable(self) -> None: + """Enable pin on virtual passkey""" + self.host.conn.exec([f"{test_venv_bin}/vfido_pin_enable"]) + + def pin_disable(self) -> None: + """Disable pin on virtual passkey""" + self.host.conn.exec([f"{test_venv_bin}/vfido_pin_disable"])