Add support for U2F authentication (#3638)

* Add U2F login support

* Update

* Update

* Update

* Update
This commit is contained in:
Daniel Girtler 2025-06-30 21:51:45 +10:00 committed by GitHub
parent b3b00aa00f
commit 55a383764f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 421 additions and 13 deletions

View File

@ -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:

View File

@ -14,6 +14,7 @@ from pydantic.dataclasses import dataclass as p_dataclass
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 @@ class ArchConfig:
'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 @@ class 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

View File

@ -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()

View File

@ -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')

View File

@ -290,7 +290,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

View File

@ -13,11 +13,39 @@ from ..output import error, info
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 @@ class Fido2:
# 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 @@ class Fido2:
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(

View File

@ -4,12 +4,14 @@ from typing import override
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 @@ class GlobalMenu(AbstractMenu[None]):
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 @@ class GlobalMenu(AbstractMenu[None]):
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 @@ class GlobalMenu(AbstractMenu[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

View File

@ -1506,7 +1506,7 @@ class Installer:
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 @@ class Installer:
'--unicode',
*cmdline,
'--verbose',
)
]
for kernel in self.kernels:
# Setup the firmware entry

View File

@ -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

View File

@ -4,6 +4,7 @@ from pathlib import Path
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)

View File

@ -9,6 +9,12 @@
"audio": "pipewire"
}
},
"auth_config": {
"u2f_config": {
"passwordless_sudo": true,
"u2f_login_method": "passwordless"
}
},
"audio_config": {
"audio": "pipewire"
},

View File

@ -7,6 +7,7 @@ from archinstall.default_profiles.profile import GreeterType
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',