Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions archinstall/lib/applications/application_menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions archinstall/lib/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
Empty file.
126 changes: 126 additions & 0 deletions archinstall/lib/authentication/authentication_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
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'] | None = None,
) -> None:
if auth_config.u2f_config and users is not None:
self._setup_u2f_login(install_session, auth_config.u2f_config, users)

def _setup_u2f_login(
self,
install_session: 'Installer',
u2f_config: U2FLoginConfiguration,
users: list[User],
) -> None:
self._configure_u2f_mapping(install_session, u2f_config, users)
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 = f'auth sufficient pam_u2f.so authfile={self._u2f_auth_file} cue'
case U2FLoginMethod.SecondFactor:
config_entry = f'auth required pam_u2f.so authfile={self._u2f_auth_file} 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]) -> 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 / self._u2f_auth_file
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'))

worker = SysCommandWorker(f'arch-chroot {install_session.target} pamu2fcfg -u {user.username}', 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()
112 changes: 112 additions & 0 deletions archinstall/lib/authentication/authentication_menu.py
Original file line number Diff line number Diff line change
@@ -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')
2 changes: 1 addition & 1 deletion archinstall/lib/disk/encryption_menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
42 changes: 35 additions & 7 deletions archinstall/lib/disk/fido.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
vendor, 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.
Expand All @@ -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:
Expand All @@ -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(
Expand Down
Loading
Loading