Skip to content

Commit 55a3837

Browse files
authored
Add support for U2F authentication (#3638)
* Add U2F login support * Update * Update * Update * Update
1 parent b3b00aa commit 55a3837

13 files changed

Lines changed: 421 additions & 13 deletions

File tree

archinstall/lib/applications/application_menu.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,14 @@ def select_bluetooth(preset: BluetoothConfiguration | None) -> BluetoothConfigur
8484
allow_skip=True,
8585
).run()
8686

87-
enabled = result.item() == MenuItem.yes()
88-
89-
return BluetoothConfiguration(enabled)
87+
match result.type_:
88+
case ResultType.Selection:
89+
enabled = result.item() == MenuItem.yes()
90+
return BluetoothConfiguration(enabled)
91+
case ResultType.Skip:
92+
return preset
93+
case _:
94+
raise ValueError('Unhandled result type')
9095

9196

9297
def select_audio(preset: AudioConfiguration | None = None) -> AudioConfiguration | None:

archinstall/lib/args.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from archinstall.lib.crypt import decrypt
1616
from archinstall.lib.models.application import ApplicationConfiguration
17+
from archinstall.lib.models.authentication import AuthenticationConfiguration
1718
from archinstall.lib.models.bootloader import Bootloader
1819
from archinstall.lib.models.device_model import DiskEncryption, DiskLayoutConfiguration
1920
from archinstall.lib.models.locale import LocaleConfiguration
@@ -64,6 +65,7 @@ class ArchConfig:
6465
bootloader: Bootloader = field(default=Bootloader.get_default())
6566
uki: bool = False
6667
app_config: ApplicationConfiguration | None = None
68+
auth_config: AuthenticationConfiguration | None = None
6769
hostname: str = 'archlinux'
6870
kernels: list[str] = field(default_factory=lambda: ['linux'])
6971
ntp: bool = True
@@ -107,6 +109,7 @@ def safe_json(self) -> dict[str, Any]:
107109
'custom_commands': self.custom_commands,
108110
'bootloader': self.bootloader.json(),
109111
'app_config': self.app_config.json() if self.app_config else None,
112+
'auth_config': self.auth_config.json() if self.auth_config else None,
110113
}
111114

112115
if self.locale_config:
@@ -193,6 +196,9 @@ def from_config(cls, args_config: dict[str, Any]) -> 'ArchConfig':
193196
if audio_config_args is not None or app_config_args is not None:
194197
arch_config.app_config = ApplicationConfiguration.parse_arg(app_config_args, audio_config_args)
195198

199+
if auth_config_args := args_config.get('auth_config', None):
200+
arch_config.auth_config = AuthenticationConfiguration.parse_arg(auth_config_args)
201+
196202
if hostname := args_config.get('hostname', ''):
197203
arch_config.hostname = hostname
198204

archinstall/lib/authentication/__init__.py

Whitespace-only changes.
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import getpass
2+
from pathlib import Path
3+
from typing import TYPE_CHECKING
4+
5+
from archinstall.lib.general import SysCommandWorker
6+
from archinstall.lib.models.authentication import AuthenticationConfiguration, U2FLoginConfiguration, U2FLoginMethod
7+
from archinstall.lib.models.users import User
8+
from archinstall.lib.output import debug
9+
from archinstall.lib.translationhandler import tr
10+
from archinstall.tui.curses_menu import Tui
11+
12+
if TYPE_CHECKING:
13+
from archinstall.lib.installer import Installer
14+
15+
16+
class AuthenticationHandler:
17+
def __init__(self) -> None:
18+
self._u2f_auth_file = Path('etc/u2f_mappings')
19+
20+
def setup_auth(
21+
self,
22+
install_session: 'Installer',
23+
auth_config: AuthenticationConfiguration,
24+
users: list['User'],
25+
hostname: str,
26+
) -> None:
27+
if auth_config.u2f_config and users is not None:
28+
self._setup_u2f_login(install_session, auth_config.u2f_config, users, hostname)
29+
30+
def _setup_u2f_login(self, install_session: 'Installer', u2f_config: U2FLoginConfiguration, users: list[User], hostname: str) -> None:
31+
self._configure_u2f_mapping(install_session, u2f_config, users, hostname)
32+
self._update_pam_config(install_session, u2f_config)
33+
34+
def _update_pam_config(
35+
self,
36+
install_session: 'Installer',
37+
u2f_config: U2FLoginConfiguration,
38+
) -> None:
39+
match u2f_config.u2f_login_method:
40+
case U2FLoginMethod.Passwordless:
41+
config_entry = 'auth sufficient pam_u2f.so authfile=/etc/u2f_mappings cue'
42+
case U2FLoginMethod.SecondFactor:
43+
config_entry = 'auth required pam_u2f.so authfile=/etc/u2f_mappings cue'
44+
case _:
45+
raise ValueError(f'Unknown U2F login method: {u2f_config.u2f_login_method}')
46+
47+
debug(f'U2F PAM configuration: {config_entry}')
48+
debug(f'Passwordless sudo enabled: {u2f_config.passwordless_sudo}')
49+
50+
sudo_config = install_session.target / 'etc/pam.d/sudo'
51+
sys_login = install_session.target / 'etc/pam.d/system-login'
52+
53+
if u2f_config.passwordless_sudo:
54+
self._add_u2f_entry(sudo_config, config_entry)
55+
56+
self._add_u2f_entry(sys_login, config_entry)
57+
58+
def _add_u2f_entry(self, file: Path, entry: str) -> None:
59+
if not file.exists():
60+
debug(f'File does not exist: {file}')
61+
return None
62+
63+
content = file.read_text().splitlines()
64+
65+
# remove any existing u2f auth entry
66+
content = [line for line in content if 'pam_u2f.so' not in line]
67+
68+
# add the u2f auth entry as the first one after comments
69+
for i, line in enumerate(content):
70+
if not line.startswith('#'):
71+
content.insert(i, entry)
72+
break
73+
else:
74+
content.append(entry)
75+
76+
file.write_text('\n'.join(content) + '\n')
77+
78+
def _configure_u2f_mapping(
79+
self,
80+
install_session: 'Installer',
81+
u2f_config: U2FLoginConfiguration,
82+
users: list[User],
83+
hostname: str,
84+
) -> None:
85+
debug(f'Setting up U2F login: {u2f_config.u2f_login_method.value}')
86+
87+
install_session.pacman.strap('pam-u2f')
88+
89+
Tui.print(tr(f'Setting up U2F login: {u2f_config.u2f_login_method.value}'))
90+
91+
# https://developers.yubico.com/pam-u2f/
92+
u2f_auth_file = install_session.target / 'etc/u2f_mappings'
93+
u2f_auth_file.touch()
94+
existing_keys = u2f_auth_file.read_text()
95+
96+
registered_keys: list[str] = []
97+
98+
for user in users:
99+
Tui.print('')
100+
Tui.print(tr('Setting up U2F device for user: {}').format(user.username))
101+
Tui.print(tr('You may need to enter the PIN and then touch your U2F device to register it'))
102+
103+
cmd = ' '.join(['arch-chroot', str(install_session.target), 'pamu2fcfg', '-u', user.username, '-o', f'pam://{hostname}', '-i', f'pam://{hostname}'])
104+
105+
debug(f'Enrolling U2F device: {cmd}')
106+
107+
worker = SysCommandWorker(cmd, peek_output=True)
108+
pin_inputted = False
109+
110+
while worker.is_alive():
111+
if pin_inputted is False:
112+
if bytes('enter pin for', 'UTF-8') in worker._trace_log.lower():
113+
worker.write(bytes(getpass.getpass(''), 'UTF-8'))
114+
pin_inputted = True
115+
116+
output = worker.decode().strip().splitlines()
117+
debug(f'Output from pamu2fcfg: {output}')
118+
119+
key = output[-1].strip()
120+
registered_keys.append(key)
121+
122+
all_keys = '\n'.join(registered_keys)
123+
124+
if existing_keys:
125+
existing_keys += f'\n{all_keys}'
126+
else:
127+
existing_keys = all_keys
128+
129+
u2f_auth_file.write_text(existing_keys)
130+
131+
132+
auth_handler = AuthenticationHandler()
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
from typing import override
2+
3+
from archinstall.lib.disk.fido import Fido2
4+
from archinstall.lib.menu.abstract_menu import AbstractSubMenu
5+
from archinstall.lib.models.authentication import AuthenticationConfiguration, U2FLoginConfiguration, U2FLoginMethod
6+
from archinstall.lib.translationhandler import tr
7+
from archinstall.tui.curses_menu import SelectMenu
8+
from archinstall.tui.menu_item import MenuItem, MenuItemGroup
9+
from archinstall.tui.result import ResultType
10+
from archinstall.tui.types import Alignment, FrameProperties, Orientation
11+
12+
13+
class AuthenticationMenu(AbstractSubMenu[AuthenticationConfiguration]):
14+
def __init__(self, preset: AuthenticationConfiguration | None = None):
15+
if preset:
16+
self._auth_config = preset
17+
else:
18+
self._auth_config = AuthenticationConfiguration()
19+
20+
menu_optioons = self._define_menu_options()
21+
self._item_group = MenuItemGroup(menu_optioons, checkmarks=True)
22+
23+
super().__init__(
24+
self._item_group,
25+
config=self._auth_config,
26+
allow_reset=True,
27+
)
28+
29+
@override
30+
def run(self, additional_title: str | None = None) -> AuthenticationConfiguration:
31+
super().run(additional_title=additional_title)
32+
return self._auth_config
33+
34+
def _define_menu_options(self) -> list[MenuItem]:
35+
return [
36+
MenuItem(
37+
text=tr('U2F login setup'),
38+
action=setup_u2f_login,
39+
value=self._auth_config.u2f_config,
40+
preview_action=self._prev_u2f_login,
41+
dependencies=[self._depends_on_u2f],
42+
key='u2f_config',
43+
),
44+
]
45+
46+
def _depends_on_u2f(self) -> bool:
47+
devices = Fido2.get_fido2_devices()
48+
if not devices:
49+
return False
50+
return True
51+
52+
def _prev_u2f_login(self, item: MenuItem) -> str | None:
53+
if item.value is not None:
54+
u2f_config: U2FLoginConfiguration = item.value
55+
56+
login_method = u2f_config.u2f_login_method.display_value()
57+
output = tr('U2F login method: ') + login_method
58+
59+
output += '\n'
60+
output += tr('Passwordless sudo: ') + (tr('Enabled') if u2f_config.passwordless_sudo else tr('Disabled'))
61+
62+
return output
63+
return None
64+
65+
66+
def setup_u2f_login(preset: U2FLoginConfiguration) -> U2FLoginConfiguration | None:
67+
items = []
68+
for method in U2FLoginMethod:
69+
items.append(MenuItem(method.display_value(), value=method))
70+
71+
group = MenuItemGroup(items)
72+
73+
if preset is not None:
74+
group.set_selected_by_value(preset.u2f_login_method)
75+
76+
result = SelectMenu[U2FLoginMethod](
77+
group,
78+
alignment=Alignment.CENTER,
79+
frame=FrameProperties.min(tr('U2F Login Method')),
80+
allow_skip=True,
81+
allow_reset=True,
82+
).run()
83+
84+
match result.type_:
85+
case ResultType.Selection:
86+
u2f_method = result.get_value()
87+
88+
group = MenuItemGroup.yes_no()
89+
group.focus_item = MenuItem.no()
90+
header = tr('Enable passwordless sudo?')
91+
92+
result_sudo = SelectMenu[bool](
93+
group,
94+
header=header,
95+
alignment=Alignment.CENTER,
96+
columns=2,
97+
orientation=Orientation.HORIZONTAL,
98+
allow_skip=True,
99+
).run()
100+
101+
passwordless_sudo = result_sudo.item() == MenuItem.yes()
102+
103+
return U2FLoginConfiguration(
104+
u2f_login_method=u2f_method,
105+
passwordless_sudo=passwordless_sudo,
106+
)
107+
case ResultType.Skip:
108+
return preset
109+
case ResultType.Reset:
110+
return None
111+
case _:
112+
raise ValueError('Unhandled result type')

archinstall/lib/disk/encryption_menu.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ def select_hsm(preset: Fido2Device | None = None) -> Fido2Device | None:
290290
header = tr('Select a FIDO2 device to use for HSM') + '\n'
291291

292292
try:
293-
fido_devices = Fido2.get_fido2_devices()
293+
fido_devices = Fido2.get_cryptenroll_devices()
294294
except ValueError:
295295
return None
296296

archinstall/lib/disk/fido.py

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,39 @@
1313

1414

1515
class Fido2:
16-
_loaded: bool = False
17-
_fido2_devices: ClassVar[list[Fido2Device]] = []
16+
_loaded_cryptsetup: bool = False
17+
_loaded_u2f: bool = False
18+
_cryptenroll_devices: ClassVar[list[Fido2Device]] = []
19+
_u2f_devices: ClassVar[list[Fido2Device]] = []
1820

1921
@classmethod
20-
def get_fido2_devices(cls, reload: bool = False) -> list[Fido2Device]:
22+
def get_fido2_devices(cls) -> list[Fido2Device]:
23+
"""
24+
fido2-tool output example:
25+
26+
/dev/hidraw4: vendor=0x1050, product=0x0407 (Yubico YubiKey OTP+FIDO+CCID)
27+
"""
28+
29+
if not cls._loaded_u2f:
30+
cls._loaded_u2f = True
31+
try:
32+
ret = SysCommand('fido2-token -L').decode()
33+
except SysCallError as e:
34+
error(f'failed to read fido2 devices: {e}')
35+
return []
36+
37+
fido_devices = clear_vt100_escape_codes_from_str(ret)
38+
39+
for line in fido_devices.split('\r\n'):
40+
path, details = line.replace(',', '').split(':', maxsplit=1)
41+
_, product, manufacturer = details.strip().split(' ', maxsplit=2)
42+
43+
cls._u2f_devices.append(Fido2Device(Path(path.strip()), manufacturer.strip(), product.strip().split('=')[1]))
44+
45+
return cls._u2f_devices
46+
47+
@classmethod
48+
def get_cryptenroll_devices(cls, reload: bool = False) -> list[Fido2Device]:
2149
"""
2250
Uses systemd-cryptenroll to list the FIDO2 devices
2351
connected that supports FIDO2.
@@ -38,7 +66,7 @@ def get_fido2_devices(cls, reload: bool = False) -> list[Fido2Device]:
3866

3967
# to prevent continuous reloading which will slow
4068
# down moving the cursor in the menu
41-
if not cls._loaded or reload:
69+
if not cls._loaded_cryptsetup or reload:
4270
try:
4371
ret = SysCommand('systemd-cryptenroll --fido2-device=list').decode()
4472
except SysCallError:
@@ -65,10 +93,10 @@ def get_fido2_devices(cls, reload: bool = False) -> list[Fido2Device]:
6593
Fido2Device(Path(path), manufacturer, product),
6694
)
6795

68-
cls._loaded = True
69-
cls._fido2_devices = devices
96+
cls._loaded_cryptsetup = True
97+
cls._cryptenroll_devices = devices
7098

71-
return cls._fido2_devices
99+
return cls._cryptenroll_devices
72100

73101
@classmethod
74102
def fido2_enroll(

0 commit comments

Comments
 (0)