diff --git a/archinstall/lib/applications/application_menu.py b/archinstall/lib/applications/application_menu.py index 7c07b84c87..20ab93e126 100644 --- a/archinstall/lib/applications/application_menu.py +++ b/archinstall/lib/applications/application_menu.py @@ -84,9 +84,14 @@ def select_bluetooth(preset: BluetoothConfiguration | None) -> BluetoothConfigur allow_skip=True, ).run() - enabled = result.item() == MenuItem.yes() - - return BluetoothConfiguration(enabled) + match result.type_: + case ResultType.Selection: + enabled = result.item() == MenuItem.yes() + return BluetoothConfiguration(enabled) + case ResultType.Skip: + return preset + case _: + raise ValueError('Unhandled result type') def select_audio(preset: AudioConfiguration | None = None) -> AudioConfiguration | None: diff --git a/archinstall/lib/args.py b/archinstall/lib/args.py index 5e2d9448a5..85d8914a3a 100644 --- a/archinstall/lib/args.py +++ b/archinstall/lib/args.py @@ -14,6 +14,7 @@ from archinstall.lib.crypt import decrypt from archinstall.lib.models.application import ApplicationConfiguration +from archinstall.lib.models.authentication import AuthenticationConfiguration from archinstall.lib.models.bootloader import Bootloader from archinstall.lib.models.device_model import DiskEncryption, DiskLayoutConfiguration from archinstall.lib.models.locale import LocaleConfiguration @@ -64,6 +65,7 @@ class ArchConfig: bootloader: Bootloader = field(default=Bootloader.get_default()) uki: bool = False app_config: ApplicationConfiguration | None = None + auth_config: AuthenticationConfiguration | None = None hostname: str = 'archlinux' kernels: list[str] = field(default_factory=lambda: ['linux']) ntp: bool = True @@ -107,6 +109,7 @@ def safe_json(self) -> dict[str, Any]: 'custom_commands': self.custom_commands, 'bootloader': self.bootloader.json(), 'app_config': self.app_config.json() if self.app_config else None, + 'auth_config': self.auth_config.json() if self.auth_config else None, } if self.locale_config: @@ -193,6 +196,9 @@ def from_config(cls, args_config: dict[str, Any]) -> 'ArchConfig': if audio_config_args is not None or app_config_args is not None: arch_config.app_config = ApplicationConfiguration.parse_arg(app_config_args, audio_config_args) + if auth_config_args := args_config.get('auth_config', None): + arch_config.auth_config = AuthenticationConfiguration.parse_arg(auth_config_args) + if hostname := args_config.get('hostname', ''): arch_config.hostname = hostname diff --git a/archinstall/lib/authentication/__init__.py b/archinstall/lib/authentication/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/archinstall/lib/authentication/authentication_handler.py b/archinstall/lib/authentication/authentication_handler.py new file mode 100644 index 0000000000..ab0b0ebfaf --- /dev/null +++ b/archinstall/lib/authentication/authentication_handler.py @@ -0,0 +1,132 @@ +import getpass +from pathlib import Path +from typing import TYPE_CHECKING + +from archinstall.lib.general import SysCommandWorker +from archinstall.lib.models.authentication import AuthenticationConfiguration, U2FLoginConfiguration, U2FLoginMethod +from archinstall.lib.models.users import User +from archinstall.lib.output import debug +from archinstall.lib.translationhandler import tr +from archinstall.tui.curses_menu import Tui + +if TYPE_CHECKING: + from archinstall.lib.installer import Installer + + +class AuthenticationHandler: + def __init__(self) -> None: + self._u2f_auth_file = Path('etc/u2f_mappings') + + def setup_auth( + self, + install_session: 'Installer', + auth_config: AuthenticationConfiguration, + users: list['User'], + hostname: str, + ) -> None: + if auth_config.u2f_config and users is not None: + self._setup_u2f_login(install_session, auth_config.u2f_config, users, hostname) + + def _setup_u2f_login(self, install_session: 'Installer', u2f_config: U2FLoginConfiguration, users: list[User], hostname: str) -> None: + self._configure_u2f_mapping(install_session, u2f_config, users, hostname) + self._update_pam_config(install_session, u2f_config) + + def _update_pam_config( + self, + install_session: 'Installer', + u2f_config: U2FLoginConfiguration, + ) -> None: + match u2f_config.u2f_login_method: + case U2FLoginMethod.Passwordless: + config_entry = 'auth sufficient pam_u2f.so authfile=/etc/u2f_mappings cue' + case U2FLoginMethod.SecondFactor: + config_entry = 'auth required pam_u2f.so authfile=/etc/u2f_mappings cue' + case _: + raise ValueError(f'Unknown U2F login method: {u2f_config.u2f_login_method}') + + debug(f'U2F PAM configuration: {config_entry}') + debug(f'Passwordless sudo enabled: {u2f_config.passwordless_sudo}') + + sudo_config = install_session.target / 'etc/pam.d/sudo' + sys_login = install_session.target / 'etc/pam.d/system-login' + + if u2f_config.passwordless_sudo: + self._add_u2f_entry(sudo_config, config_entry) + + self._add_u2f_entry(sys_login, config_entry) + + def _add_u2f_entry(self, file: Path, entry: str) -> None: + if not file.exists(): + debug(f'File does not exist: {file}') + return None + + content = file.read_text().splitlines() + + # remove any existing u2f auth entry + content = [line for line in content if 'pam_u2f.so' not in line] + + # add the u2f auth entry as the first one after comments + for i, line in enumerate(content): + if not line.startswith('#'): + content.insert(i, entry) + break + else: + content.append(entry) + + file.write_text('\n'.join(content) + '\n') + + def _configure_u2f_mapping( + self, + install_session: 'Installer', + u2f_config: U2FLoginConfiguration, + users: list[User], + hostname: str, + ) -> None: + debug(f'Setting up U2F login: {u2f_config.u2f_login_method.value}') + + install_session.pacman.strap('pam-u2f') + + Tui.print(tr(f'Setting up U2F login: {u2f_config.u2f_login_method.value}')) + + # https://developers.yubico.com/pam-u2f/ + u2f_auth_file = install_session.target / 'etc/u2f_mappings' + u2f_auth_file.touch() + existing_keys = u2f_auth_file.read_text() + + registered_keys: list[str] = [] + + for user in users: + Tui.print('') + Tui.print(tr('Setting up U2F device for user: {}').format(user.username)) + Tui.print(tr('You may need to enter the PIN and then touch your U2F device to register it')) + + cmd = ' '.join(['arch-chroot', str(install_session.target), 'pamu2fcfg', '-u', user.username, '-o', f'pam://{hostname}', '-i', f'pam://{hostname}']) + + debug(f'Enrolling U2F device: {cmd}') + + worker = SysCommandWorker(cmd, peek_output=True) + pin_inputted = False + + while worker.is_alive(): + if pin_inputted is False: + if bytes('enter pin for', 'UTF-8') in worker._trace_log.lower(): + worker.write(bytes(getpass.getpass(''), 'UTF-8')) + pin_inputted = True + + output = worker.decode().strip().splitlines() + debug(f'Output from pamu2fcfg: {output}') + + key = output[-1].strip() + registered_keys.append(key) + + all_keys = '\n'.join(registered_keys) + + if existing_keys: + existing_keys += f'\n{all_keys}' + else: + existing_keys = all_keys + + u2f_auth_file.write_text(existing_keys) + + +auth_handler = AuthenticationHandler() diff --git a/archinstall/lib/authentication/authentication_menu.py b/archinstall/lib/authentication/authentication_menu.py new file mode 100644 index 0000000000..e8852bbb74 --- /dev/null +++ b/archinstall/lib/authentication/authentication_menu.py @@ -0,0 +1,112 @@ +from typing import override + +from archinstall.lib.disk.fido import Fido2 +from archinstall.lib.menu.abstract_menu import AbstractSubMenu +from archinstall.lib.models.authentication import AuthenticationConfiguration, U2FLoginConfiguration, U2FLoginMethod +from archinstall.lib.translationhandler import tr +from archinstall.tui.curses_menu import SelectMenu +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType +from archinstall.tui.types import Alignment, FrameProperties, Orientation + + +class AuthenticationMenu(AbstractSubMenu[AuthenticationConfiguration]): + def __init__(self, preset: AuthenticationConfiguration | None = None): + if preset: + self._auth_config = preset + else: + self._auth_config = AuthenticationConfiguration() + + menu_optioons = self._define_menu_options() + self._item_group = MenuItemGroup(menu_optioons, checkmarks=True) + + super().__init__( + self._item_group, + config=self._auth_config, + allow_reset=True, + ) + + @override + def run(self, additional_title: str | None = None) -> AuthenticationConfiguration: + super().run(additional_title=additional_title) + return self._auth_config + + def _define_menu_options(self) -> list[MenuItem]: + return [ + MenuItem( + text=tr('U2F login setup'), + action=setup_u2f_login, + value=self._auth_config.u2f_config, + preview_action=self._prev_u2f_login, + dependencies=[self._depends_on_u2f], + key='u2f_config', + ), + ] + + def _depends_on_u2f(self) -> bool: + devices = Fido2.get_fido2_devices() + if not devices: + return False + return True + + def _prev_u2f_login(self, item: MenuItem) -> str | None: + if item.value is not None: + u2f_config: U2FLoginConfiguration = item.value + + login_method = u2f_config.u2f_login_method.display_value() + output = tr('U2F login method: ') + login_method + + output += '\n' + output += tr('Passwordless sudo: ') + (tr('Enabled') if u2f_config.passwordless_sudo else tr('Disabled')) + + return output + return None + + +def setup_u2f_login(preset: U2FLoginConfiguration) -> U2FLoginConfiguration | None: + items = [] + for method in U2FLoginMethod: + items.append(MenuItem(method.display_value(), value=method)) + + group = MenuItemGroup(items) + + if preset is not None: + group.set_selected_by_value(preset.u2f_login_method) + + result = SelectMenu[U2FLoginMethod]( + group, + alignment=Alignment.CENTER, + frame=FrameProperties.min(tr('U2F Login Method')), + allow_skip=True, + allow_reset=True, + ).run() + + match result.type_: + case ResultType.Selection: + u2f_method = result.get_value() + + group = MenuItemGroup.yes_no() + group.focus_item = MenuItem.no() + header = tr('Enable passwordless sudo?') + + result_sudo = SelectMenu[bool]( + group, + header=header, + alignment=Alignment.CENTER, + columns=2, + orientation=Orientation.HORIZONTAL, + allow_skip=True, + ).run() + + passwordless_sudo = result_sudo.item() == MenuItem.yes() + + return U2FLoginConfiguration( + u2f_login_method=u2f_method, + passwordless_sudo=passwordless_sudo, + ) + case ResultType.Skip: + return preset + case ResultType.Reset: + return None + case _: + raise ValueError('Unhandled result type') diff --git a/archinstall/lib/disk/encryption_menu.py b/archinstall/lib/disk/encryption_menu.py index 738fc019a6..34551c9a0d 100644 --- a/archinstall/lib/disk/encryption_menu.py +++ b/archinstall/lib/disk/encryption_menu.py @@ -268,7 +268,7 @@ def select_hsm(preset: Fido2Device | None = None) -> Fido2Device | None: header = tr('Select a FIDO2 device to use for HSM') + '\n' try: - fido_devices = Fido2.get_fido2_devices() + fido_devices = Fido2.get_cryptenroll_devices() except ValueError: return None diff --git a/archinstall/lib/disk/fido.py b/archinstall/lib/disk/fido.py index 7ce900d3f3..b6676db41d 100644 --- a/archinstall/lib/disk/fido.py +++ b/archinstall/lib/disk/fido.py @@ -13,11 +13,39 @@ class Fido2: - _loaded: bool = False - _fido2_devices: ClassVar[list[Fido2Device]] = [] + _loaded_cryptsetup: bool = False + _loaded_u2f: bool = False + _cryptenroll_devices: ClassVar[list[Fido2Device]] = [] + _u2f_devices: ClassVar[list[Fido2Device]] = [] @classmethod - def get_fido2_devices(cls, reload: bool = False) -> list[Fido2Device]: + def get_fido2_devices(cls) -> list[Fido2Device]: + """ + fido2-tool output example: + + /dev/hidraw4: vendor=0x1050, product=0x0407 (Yubico YubiKey OTP+FIDO+CCID) + """ + + if not cls._loaded_u2f: + cls._loaded_u2f = True + try: + ret = SysCommand('fido2-token -L').decode() + except SysCallError as e: + error(f'failed to read fido2 devices: {e}') + return [] + + fido_devices = clear_vt100_escape_codes_from_str(ret) + + for line in fido_devices.split('\r\n'): + path, details = line.replace(',', '').split(':', maxsplit=1) + _, product, manufacturer = details.strip().split(' ', maxsplit=2) + + cls._u2f_devices.append(Fido2Device(Path(path.strip()), manufacturer.strip(), product.strip().split('=')[1])) + + return cls._u2f_devices + + @classmethod + def get_cryptenroll_devices(cls, reload: bool = False) -> list[Fido2Device]: """ Uses systemd-cryptenroll to list the FIDO2 devices connected that supports FIDO2. @@ -38,7 +66,7 @@ def get_fido2_devices(cls, reload: bool = False) -> list[Fido2Device]: # to prevent continuous reloading which will slow # down moving the cursor in the menu - if not cls._loaded or reload: + if not cls._loaded_cryptsetup or reload: try: ret = SysCommand('systemd-cryptenroll --fido2-device=list').decode() except SysCallError: @@ -65,10 +93,10 @@ def get_fido2_devices(cls, reload: bool = False) -> list[Fido2Device]: Fido2Device(Path(path), manufacturer, product), ) - cls._loaded = True - cls._fido2_devices = devices + cls._loaded_cryptsetup = True + cls._cryptenroll_devices = devices - return cls._fido2_devices + return cls._cryptenroll_devices @classmethod def fido2_enroll( diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index 96f2ba703d..f0df3ef4c5 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -4,12 +4,14 @@ from archinstall.lib.disk.disk_menu import DiskLayoutConfigurationMenu from archinstall.lib.models.application import ApplicationConfiguration +from archinstall.lib.models.authentication import AuthenticationConfiguration from archinstall.lib.models.device_model import DiskLayoutConfiguration, DiskLayoutType, EncryptionType, FilesystemType, PartitionModification from archinstall.lib.packages import list_available_packages from archinstall.tui.menu_item import MenuItem, MenuItemGroup from .applications.application_menu import ApplicationMenu from .args import ArchConfig +from .authentication.authentication_menu import AuthenticationMenu from .configuration import save_config from .hardware import SysInfo from .interactions.general_conf import ( @@ -114,6 +116,13 @@ def _get_menu_options(self) -> list[MenuItem]: preview_action=self._prev_root_pwd, key='root_enc_password', ), + MenuItem( + text=tr('Authentication'), + action=self._select_authentication, + value=[], + preview_action=self._prev_authentication, + key='auth_config', + ), MenuItem( text=tr('User account'), action=self._create_user_account, @@ -257,6 +266,10 @@ def _select_applications(self, preset: ApplicationConfiguration | None) -> Appli app_config = ApplicationMenu(preset).run() return app_config + def _select_authentication(self, preset: AuthenticationConfiguration | None) -> AuthenticationConfiguration | None: + auth_config = AuthenticationMenu(preset).run() + return auth_config + def _update_lang_text(self) -> None: """ The options for the global menu are generated with a static text; @@ -296,6 +309,23 @@ def _prev_additional_pkgs(self, item: MenuItem) -> str | None: return output return None + def _prev_authentication(self, item: MenuItem) -> str | None: + if item.value: + auth_config: AuthenticationConfiguration = item.value + output = '' + + if auth_config.u2f_config: + u2f_config = auth_config.u2f_config + login_method = u2f_config.u2f_login_method.display_value() + output = tr('U2F login method: ') + login_method + + output += '\n' + output += tr('Passwordless sudo: ') + (tr('Enabled') if u2f_config.passwordless_sudo else tr('Disabled')) + + return output + + return None + def _prev_applications(self, item: MenuItem) -> str | None: if item.value: app_config: ApplicationConfiguration = item.value diff --git a/archinstall/lib/installer.py b/archinstall/lib/installer.py index ac861f022c..652482ed49 100644 --- a/archinstall/lib/installer.py +++ b/archinstall/lib/installer.py @@ -1506,7 +1506,7 @@ def _add_efistub_bootloader( parent_dev_path = device_handler.get_parent_device_path(boot_partition.safe_dev_path) - cmd_template = ( + cmd_template = [ 'efibootmgr', '--create', '--disk', @@ -1520,7 +1520,7 @@ def _add_efistub_bootloader( '--unicode', *cmdline, '--verbose', - ) + ] for kernel in self.kernels: # Setup the firmware entry diff --git a/archinstall/lib/models/authentication.py b/archinstall/lib/models/authentication.py new file mode 100644 index 0000000000..d260ddac80 --- /dev/null +++ b/archinstall/lib/models/authentication.py @@ -0,0 +1,78 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Any, NotRequired, Optional, TypedDict + +from archinstall.lib.translationhandler import tr + + +class U2FLoginConfigSerialization(TypedDict): + u2f_login_method: str + passwordless_sudo: bool + + +class AuthenticationSerialization(TypedDict): + u2f_config: NotRequired[U2FLoginConfigSerialization] + + +class U2FLoginMethod(Enum): + Passwordless = 'passwordless' + SecondFactor = 'second_factor' + + def display_value(self) -> str: + match self: + case U2FLoginMethod.Passwordless: + return tr('Passwordless login') + case U2FLoginMethod.SecondFactor: + return tr('Second factor login') + case _: + raise ValueError(f'Unknown type: {self}') + + +@dataclass +class U2FLoginConfiguration: + u2f_login_method: U2FLoginMethod + passwordless_sudo: bool = False + + def json(self) -> U2FLoginConfigSerialization: + return { + 'u2f_login_method': self.u2f_login_method.value, + 'passwordless_sudo': self.passwordless_sudo, + } + + @staticmethod + def parse_arg(args: dict[str, Any]) -> Optional['U2FLoginConfiguration']: + u2f_login_method = args.get('u2f_login_method') + + if u2f_login_method is None: + return None + + u2f_config = U2FLoginConfiguration(u2f_login_method=U2FLoginMethod(u2f_login_method)) + + u2f_config.u2f_login_method = U2FLoginMethod(u2f_login_method) + + if passwordless_sudo := args.get('passwordless_sudo') is not None: + u2f_config.passwordless_sudo = passwordless_sudo + + return u2f_config + + +@dataclass +class AuthenticationConfiguration: + u2f_config: U2FLoginConfiguration | None = None + + @staticmethod + def parse_arg(args: dict[str, Any]) -> 'AuthenticationConfiguration': + auth_config = AuthenticationConfiguration() + + if (u2f_config := args.get('u2f_config')) is not None: + auth_config.u2f_config = U2FLoginConfiguration.parse_arg(u2f_config) + + return auth_config + + def json(self) -> AuthenticationSerialization: + config: AuthenticationSerialization = {} + + if self.u2f_config: + config['u2f_config'] = self.u2f_config.json() + + return config diff --git a/archinstall/scripts/guided.py b/archinstall/scripts/guided.py index e2266e892b..2c2173a760 100644 --- a/archinstall/scripts/guided.py +++ b/archinstall/scripts/guided.py @@ -4,6 +4,7 @@ from archinstall import SysInfo from archinstall.lib.applications.application_handler import application_handler from archinstall.lib.args import arch_config_handler +from archinstall.lib.authentication.authentication_handler import auth_handler from archinstall.lib.configuration import ConfigurationOutput from archinstall.lib.disk.filesystem import FilesystemHandler from archinstall.lib.disk.utils import disk_layouts @@ -116,6 +117,9 @@ def perform_installation(mountpoint: Path) -> None: if users := config.users: installation.create_users(users) + if config.auth_config and config.users: + auth_handler.setup_auth(installation, config.auth_config, config.users, config.hostname) + if config.packages and config.packages[0] != '': installation.add_additional_packages(config.packages) diff --git a/tests/data/test_config.json b/tests/data/test_config.json index f469df954d..9b0c218d32 100644 --- a/tests/data/test_config.json +++ b/tests/data/test_config.json @@ -9,6 +9,12 @@ "audio": "pipewire" } }, + "auth_config": { + "u2f_config": { + "passwordless_sudo": true, + "u2f_login_method": "passwordless" + } + }, "audio_config": { "audio": "pipewire" }, diff --git a/tests/test_args.py b/tests/test_args.py index 2df7e4ad2a..5a6d61f0bb 100644 --- a/tests/test_args.py +++ b/tests/test_args.py @@ -7,6 +7,7 @@ from archinstall.lib.args import ArchConfig, ArchConfigHandler, Arguments from archinstall.lib.hardware import GfxDriver from archinstall.lib.models.application import ApplicationConfiguration, Audio, AudioConfiguration, BluetoothConfiguration +from archinstall.lib.models.authentication import AuthenticationConfiguration, U2FLoginConfiguration, U2FLoginMethod from archinstall.lib.models.bootloader import Bootloader from archinstall.lib.models.device_model import DiskLayoutConfiguration, DiskLayoutType from archinstall.lib.models.locale import LocaleConfiguration @@ -132,6 +133,12 @@ def test_config_file_parsing( bluetooth_config=BluetoothConfiguration(enabled=True), audio_config=AudioConfiguration(audio=Audio.PIPEWIRE), ), + auth_config=AuthenticationConfiguration( + u2f_config=U2FLoginConfiguration( + u2f_login_method=U2FLoginMethod.Passwordless, + passwordless_sudo=True, + ), + ), locale_config=LocaleConfiguration( kb_layout='us', sys_lang='en_US',