Send patches - preferably formatted by git format-patch - to patches at archlinux32 dot org.
summaryrefslogtreecommitdiff
path: root/archinstall/lib
diff options
context:
space:
mode:
authorAnton Hvornum <anton@hvornum.se>2022-05-18 11:28:59 +0200
committerGitHub <noreply@github.com>2022-05-18 11:28:59 +0200
commit493cccc18fa8c77c362b6abee2c3dc89d331c792 (patch)
tree5778ffbf361ecf80360b4848bc683c8387965d9a /archinstall/lib
parent561ea7e8f5c326312cc61c03d1b2329111f7634b (diff)
Added a HSM menu entry (#1196)
* Added a HSM menu entry, but also a safety check to make sure a FIDO device is connected * flake8 complaints * Adding FIDO lookup using cryptenroll listing * Added systemd-cryptenroll --fido2-device=list * Removed old _select_hsm call * Fixed flake8 complaints * Added support for locking and unlocking with a HSM * Removed hardcoded paths in favor of PR merge * Removed hardcoded paths in favor of PR merge * Fixed mypy complaint * Flake8 issue * Added sd-encrypt for HSM and revert back to encrypt when HSM is not used (stability reason) * Added /etc/vconsole.conf and tweaked fido2_enroll() to use the proper paths * Spelling error * Using UUID instead of PARTUUID when using HSM. I can't figure out how to get sd-encrypt to use PARTUUID instead. Added a Partition().part_uuid function. Actually renamed .uuid to .part_uuid and created a .uuid instead. * Adding missing package libfido2 and removed tpm2-device=auto as it overrides everything and forces password prompt to be used over FIDO2, no matter the order of the options. * Added some notes to clarify some choices. * Had to move libfido2 package install to later in the chain, as there's not even a base during mounting :P
Diffstat (limited to 'archinstall/lib')
-rw-r--r--archinstall/lib/configuration.py27
-rw-r--r--archinstall/lib/disk/blockdevice.py2
-rw-r--r--archinstall/lib/disk/filesystem.py8
-rw-r--r--archinstall/lib/disk/partition.py44
-rw-r--r--archinstall/lib/general.py2
-rw-r--r--archinstall/lib/hsm/__init__.py4
-rw-r--r--archinstall/lib/hsm/fido.py47
-rw-r--r--archinstall/lib/installer.py67
-rw-r--r--archinstall/lib/menu/global_menu.py6
-rw-r--r--archinstall/lib/menu/selection_menu.py24
-rw-r--r--archinstall/lib/udev/__init__.py1
-rw-r--r--archinstall/lib/udev/udevadm.py17
12 files changed, 223 insertions, 26 deletions
diff --git a/archinstall/lib/configuration.py b/archinstall/lib/configuration.py
index c971768f..f3fe1e1c 100644
--- a/archinstall/lib/configuration.py
+++ b/archinstall/lib/configuration.py
@@ -1,12 +1,23 @@
import json
import logging
-from pathlib import Path
+import pathlib
from typing import Optional, Dict
from .storage import storage
from .general import JSON, UNSAFE_JSON
from .output import log
-
+from .exceptions import RequirementError
+from .hsm import get_fido2_devices
+
+def configuration_sanity_check():
+ if storage['arguments'].get('HSM'):
+ if not get_fido2_devices():
+ raise RequirementError(
+ f"In order to use HSM to pair with the disk encryption,"
+ + f" one needs to be accessible through /dev/hidraw* and support"
+ + f" the FIDO2 protocol. You can check this by running"
+ + f" 'systemd-cryptenroll --fido2-device=list'."
+ )
class ConfigurationOutput:
def __init__(self, config: Dict):
@@ -21,7 +32,7 @@ class ConfigurationOutput:
self._user_credentials = {}
self._disk_layout = None
self._user_config = {}
- self._default_save_path = Path(storage.get('LOG_PATH', '.'))
+ self._default_save_path = pathlib.Path(storage.get('LOG_PATH', '.'))
self._user_config_file = 'user_configuration.json'
self._user_creds_file = "user_credentials.json"
self._disk_layout_file = "user_disk_layout.json"
@@ -84,7 +95,7 @@ class ConfigurationOutput:
print()
- def _is_valid_path(self, dest_path :Path) -> bool:
+ def _is_valid_path(self, dest_path :pathlib.Path) -> bool:
if (not dest_path.exists()) or not (dest_path.is_dir()):
log(
'Destination directory {} does not exist or is not a directory,\n Configuration files can not be saved'.format(dest_path.resolve()),
@@ -93,26 +104,26 @@ class ConfigurationOutput:
return False
return True
- def save_user_config(self, dest_path :Path = None):
+ def save_user_config(self, dest_path :pathlib.Path = None):
if self._is_valid_path(dest_path):
with open(dest_path / self._user_config_file, 'w') as config_file:
config_file.write(self.user_config_to_json())
- def save_user_creds(self, dest_path :Path = None):
+ def save_user_creds(self, dest_path :pathlib.Path = None):
if self._is_valid_path(dest_path):
if user_creds := self.user_credentials_to_json():
target = dest_path / self._user_creds_file
with open(target, 'w') as config_file:
config_file.write(user_creds)
- def save_disk_layout(self, dest_path :Path = None):
+ def save_disk_layout(self, dest_path :pathlib.Path = None):
if self._is_valid_path(dest_path):
if disk_layout := self.disk_layout_to_json():
target = dest_path / self._disk_layout_file
with target.open('w') as config_file:
config_file.write(disk_layout)
- def save(self, dest_path :Path = None):
+ def save(self, dest_path :pathlib.Path = None):
if not dest_path:
dest_path = self._default_save_path
diff --git a/archinstall/lib/disk/blockdevice.py b/archinstall/lib/disk/blockdevice.py
index 4978f19c..995ca355 100644
--- a/archinstall/lib/disk/blockdevice.py
+++ b/archinstall/lib/disk/blockdevice.py
@@ -275,7 +275,7 @@ class BlockDevice:
count = 0
while count < 5:
for partition_uuid, partition in self.partitions.items():
- if partition.uuid.lower() == uuid.lower():
+ if partition.part_uuid.lower() == uuid.lower():
return partition
else:
log(f"uuid {uuid} not found. Waiting for {count +1} time",level=logging.DEBUG)
diff --git a/archinstall/lib/disk/filesystem.py b/archinstall/lib/disk/filesystem.py
index db97924f..31929b63 100644
--- a/archinstall/lib/disk/filesystem.py
+++ b/archinstall/lib/disk/filesystem.py
@@ -150,7 +150,7 @@ class Filesystem:
if partition.get('boot', False):
log(f"Marking partition {partition['device_instance']} as bootable.")
- self.set(self.partuuid_to_index(partition['device_instance'].uuid), 'boot on')
+ self.set(self.partuuid_to_index(partition['device_instance'].part_uuid), 'boot on')
prev_partition = partition
@@ -193,7 +193,7 @@ class Filesystem:
def add_partition(self, partition_type :str, start :str, end :str, partition_format :Optional[str] = None) -> Partition:
log(f'Adding partition to {self.blockdevice}, {start}->{end}', level=logging.INFO)
- previous_partition_uuids = {partition.uuid for partition in self.blockdevice.partitions.values()}
+ previous_partition_uuids = {partition.part_uuid for partition in self.blockdevice.partitions.values()}
if self.mode == MBR:
if len(self.blockdevice.partitions) > 3:
@@ -210,7 +210,7 @@ class Filesystem:
count = 0
while count < 10:
new_uuid = None
- new_uuid_set = (previous_partition_uuids ^ {partition.uuid for partition in self.blockdevice.partitions.values()})
+ new_uuid_set = (previous_partition_uuids ^ {partition.part_uuid for partition in self.blockdevice.partitions.values()})
if len(new_uuid_set) > 0:
new_uuid = new_uuid_set.pop()
@@ -236,7 +236,7 @@ class Filesystem:
# TODO: This should never be able to happen
log(f"Could not find the new PARTUUID after adding the partition.", level=logging.ERROR, fg="red")
log(f"Previous partitions: {previous_partition_uuids}", level=logging.ERROR, fg="red")
- log(f"New partitions: {(previous_partition_uuids ^ {partition.uuid for partition in self.blockdevice.partitions.values()})}", level=logging.ERROR, fg="red")
+ log(f"New partitions: {(previous_partition_uuids ^ {partition.part_uuid for partition in self.blockdevice.partitions.values()})}", level=logging.ERROR, fg="red")
raise DiskError(f"Could not add partition using: {parted_string}")
def set_name(self, partition: int, name: str) -> bool:
diff --git a/archinstall/lib/disk/partition.py b/archinstall/lib/disk/partition.py
index e7568258..c52ca434 100644
--- a/archinstall/lib/disk/partition.py
+++ b/archinstall/lib/disk/partition.py
@@ -184,7 +184,7 @@ class Partition:
return device['pttype']
@property
- def uuid(self) -> Optional[str]:
+ def part_uuid(self) -> Optional[str]:
"""
Returns the PARTUUID as returned by lsblk.
This is more reliable than relying on /dev/disk/by-partuuid as
@@ -197,6 +197,26 @@ class Partition:
time.sleep(max(0.1, storage['DISK_TIMEOUTS'] * i))
+ partuuid = self._safe_part_uuid
+ if partuuid:
+ return partuuid
+
+ raise DiskError(f"Could not get PARTUUID for {self.path} using 'blkid -s PARTUUID -o value {self.path}'")
+
+ @property
+ def uuid(self) -> Optional[str]:
+ """
+ Returns the UUID as returned by lsblk for the **partition**.
+ This is more reliable than relying on /dev/disk/by-uuid as
+ it doesn't seam to be able to detect md raid partitions.
+ For bind mounts all the subvolumes share the same uuid
+ """
+ for i in range(storage['DISK_RETRY_ATTEMPTS']):
+ if not self.partprobe():
+ raise DiskError(f"Could not perform partprobe on {self.device_path}")
+
+ time.sleep(max(0.1, storage['DISK_TIMEOUTS'] * i))
+
partuuid = self._safe_uuid
if partuuid:
return partuuid
@@ -217,6 +237,28 @@ class Partition:
log(f"Could not reliably refresh PARTUUID of partition {self.device_path} due to partprobe error.", level=logging.DEBUG)
try:
+ return SysCommand(f'blkid -s UUID -o value {self.device_path}').decode('UTF-8').strip()
+ except SysCallError as error:
+ if self.block_device.info.get('TYPE') == 'iso9660':
+ # Parent device is a Optical Disk (.iso dd'ed onto a device for instance)
+ return None
+
+ log(f"Could not get PARTUUID of partition using 'blkid -s UUID -o value {self.device_path}': {error}")
+
+ @property
+ def _safe_part_uuid(self) -> Optional[str]:
+ """
+ A near copy of self.uuid but without any delays.
+ This function should only be used where uuid is not crucial.
+ For instance when you want to get a __repr__ of the class.
+ """
+ if not self.partprobe():
+ if self.block_device.info.get('TYPE') == 'iso9660':
+ return None
+
+ log(f"Could not reliably refresh PARTUUID of partition {self.device_path} due to partprobe error.", level=logging.DEBUG)
+
+ try:
return SysCommand(f'blkid -s PARTUUID -o value {self.device_path}').decode('UTF-8').strip()
except SysCallError as error:
if self.block_device.info.get('TYPE') == 'iso9660':
diff --git a/archinstall/lib/general.py b/archinstall/lib/general.py
index a4e2a365..44b78777 100644
--- a/archinstall/lib/general.py
+++ b/archinstall/lib/general.py
@@ -135,6 +135,8 @@ class JsonEncoder:
return obj.isoformat()
elif isinstance(obj, (list, set, tuple)):
return [json.loads(json.dumps(item, cls=JSON)) for item in obj]
+ elif isinstance(obj, (pathlib.Path)):
+ return str(obj)
else:
return obj
diff --git a/archinstall/lib/hsm/__init__.py b/archinstall/lib/hsm/__init__.py
new file mode 100644
index 00000000..c0888b04
--- /dev/null
+++ b/archinstall/lib/hsm/__init__.py
@@ -0,0 +1,4 @@
+from .fido import (
+ get_fido2_devices,
+ fido2_enroll
+) \ No newline at end of file
diff --git a/archinstall/lib/hsm/fido.py b/archinstall/lib/hsm/fido.py
new file mode 100644
index 00000000..69f42890
--- /dev/null
+++ b/archinstall/lib/hsm/fido.py
@@ -0,0 +1,47 @@
+import typing
+import pathlib
+from ..general import SysCommand, SysCommandWorker, clear_vt100_escape_codes
+from ..disk.partition import Partition
+
+def get_fido2_devices() -> typing.Dict[str, typing.Dict[str, str]]:
+ """
+ Uses systemd-cryptenroll to list the FIDO2 devices
+ connected that supports FIDO2.
+ Some devices might show up in udevadm as FIDO2 compliant
+ when they are in fact not.
+
+ The drawback of systemd-cryptenroll is that it uses human readable format.
+ That means we get this weird table like structure that is of no use.
+
+ So we'll look for `MANUFACTURER` and `PRODUCT`, we take their index
+ and we split each line based on those positions.
+ """
+ worker = clear_vt100_escape_codes(SysCommand(f"systemd-cryptenroll --fido2-device=list").decode('UTF-8'))
+
+ MANUFACTURER_POS = 0
+ PRODUCT_POS = 0
+ devices = {}
+ for line in worker.split('\r\n'):
+ if '/dev' not in line:
+ MANUFACTURER_POS = line.find('MANUFACTURER')
+ PRODUCT_POS = line.find('PRODUCT')
+ continue
+
+ path = line[:MANUFACTURER_POS].rstrip()
+ manufacturer = line[MANUFACTURER_POS:PRODUCT_POS].rstrip()
+ product = line[PRODUCT_POS:]
+
+ devices[path] = {
+ 'manufacturer' : manufacturer,
+ 'product' : product
+ }
+
+ return devices
+
+def fido2_enroll(hsm_device_path :pathlib.Path, partition :Partition, password :str) -> bool:
+ worker = SysCommandWorker(f"systemd-cryptenroll --fido2-device={hsm_device_path} {partition.real_device}", peak_output=True)
+ pw_inputted = False
+ while worker.is_alive():
+ if pw_inputted is False and bytes(f"please enter current passphrase for disk {partition.real_device}", 'UTF-8') in worker._trace_log.lower():
+ worker.write(bytes(password, 'UTF-8'))
+ pw_inputted = True
diff --git a/archinstall/lib/installer.py b/archinstall/lib/installer.py
index e94a00c4..292b2c8e 100644
--- a/archinstall/lib/installer.py
+++ b/archinstall/lib/installer.py
@@ -23,6 +23,7 @@ from .profiles import Profile
from .disk.btrfs import manage_btrfs_subvolumes
from .disk.partition import get_mount_fs_type
from .exceptions import DiskError, ServiceException, RequirementError, HardwareIncompatibilityError, SysCallError
+from .hsm import fido2_enroll
if TYPE_CHECKING:
_: Any
@@ -126,7 +127,9 @@ class Installer:
self.MODULES = []
self.BINARIES = []
self.FILES = []
- self.HOOKS = ["base", "udev", "autodetect", "keyboard", "keymap", "modconf", "block", "filesystems", "fsck"]
+ # systemd, sd-vconsole and sd-encrypt will be replaced by udev, keymap and encrypt
+ # if HSM is not used to encrypt the root volume. Check mkinitcpio() function for that override.
+ self.HOOKS = ["base", "systemd", "autodetect", "keyboard", "sd-vconsole", "modconf", "block", "filesystems", "fsck"]
self.KERNEL_PARAMS = []
self._zram_enabled = False
@@ -241,10 +244,10 @@ class Installer:
# open the luks device and all associate stuff
if not (password := partition.get('!password', None)):
raise RequirementError(f"Missing partition {partition['device_instance'].path} encryption password in layout: {partition}")
- # i change a bit the naming conventions for the loop device
loopdev = f"{storage.get('ENC_IDENTIFIER', 'ai')}{pathlib.Path(partition['mountpoint']).name}loop"
else:
loopdev = f"{storage.get('ENC_IDENTIFIER', 'ai')}{pathlib.Path(partition['device_instance'].path).name}"
+
# note that we DON'T auto_unmount (i.e. close the encrypted device so it can be used
with (luks_handle := luks2(partition['device_instance'], loopdev, password, auto_unmount=False)) as unlocked_device:
if partition.get('generate-encryption-key-file',False) and not self._has_root(partition):
@@ -252,6 +255,10 @@ class Installer:
# this way all the requesrs will be to the dm_crypt device and not to the physical partition
partition['device_instance'] = unlocked_device
+ if self._has_root(partition) and partition.get('generate-encryption-key-file', False) is False:
+ hsm_device_path = storage['arguments']['HSM']
+ fido2_enroll(hsm_device_path, partition['device_instance'], password)
+
# we manage the btrfs partitions
for partition in [entry for entry in list_part if entry.get('btrfs', {}).get('subvolumes', {})]:
if partition.get('filesystem',{}).get('mount_options',[]):
@@ -609,6 +616,15 @@ class Installer:
mkinit.write(f"MODULES=({' '.join(self.MODULES)})\n")
mkinit.write(f"BINARIES=({' '.join(self.BINARIES)})\n")
mkinit.write(f"FILES=({' '.join(self.FILES)})\n")
+
+ if not storage['arguments']['HSM']:
+ # For now, if we don't use HSM we revert to the old
+ # way of setting up encryption hooks for mkinitcpio.
+ # This is purely for stability reasons, we're going away from this.
+ # * systemd -> udev
+ # * sd-vconsole -> keymap
+ self.HOOKS = [hook.replace('systemd', 'udev').replace('sd-vconsole', 'keymap') for hook in self.HOOKS]
+
mkinit.write(f"HOOKS=({' '.join(self.HOOKS)})\n")
return SysCommand(f'/usr/bin/arch-chroot {self.target} mkinitcpio {" ".join(flags)}').exit_code == 0
@@ -643,8 +659,15 @@ class Installer:
self.HOOKS.remove('fsck')
if self.detect_encryption(partition):
- if 'encrypt' not in self.HOOKS:
- self.HOOKS.insert(self.HOOKS.index('filesystems'), 'encrypt')
+ if storage['arguments']['HSM']:
+ # Required bby mkinitcpio to add support for fido2-device options
+ self.pacstrap('libfido2')
+
+ if 'sd-encrypt' not in self.HOOKS:
+ self.HOOKS.insert(self.HOOKS.index('filesystems'), 'sd-encrypt')
+ else:
+ if 'encrypt' not in self.HOOKS:
+ self.HOOKS.insert(self.HOOKS.index('filesystems'), 'encrypt')
if not has_uefi():
self.base_packages.append('grub')
@@ -700,6 +723,14 @@ class Installer:
# TODO: Use python functions for this
SysCommand(f'/usr/bin/arch-chroot {self.target} chmod 700 /root')
+ if storage['arguments']['HSM']:
+ # TODO:
+ # A bit of a hack, but we need to get vconsole.conf in there
+ # before running `mkinitcpio` because it expects it in HSM mode.
+ if (vconsole := pathlib.Path(f"{self.target}/etc/vconsole.conf")).exists() is False:
+ with vconsole.open('w') as fh:
+ fh.write(f"KEYMAP={storage['arguments']['keyboard-layout']}\n")
+
self.mkinitcpio('-P')
self.helper_flags['base'] = True
@@ -814,11 +845,23 @@ class Installer:
if real_device := self.detect_encryption(root_partition):
# TODO: We need to detect if the encrypted device is a whole disk encryption,
# or simply a partition encryption. Right now we assume it's a partition (and we always have)
- log(f"Identifying root partition by PART-UUID on {real_device}: '{real_device.uuid}'.", level=logging.DEBUG)
- entry.write(f'options cryptdevice=PARTUUID={real_device.uuid}:luksdev root=/dev/mapper/luksdev {options_entry}')
+ log(f"Identifying root partition by PART-UUID on {real_device}: '{real_device.uuid}/{real_device.part_uuid}'.", level=logging.DEBUG)
+
+ kernel_options = f"options"
+
+ if storage['arguments']['HSM']:
+ # Note: lsblk UUID must be used, not PARTUUID for sd-encrypt to work
+ kernel_options += f" rd.luks.name={real_device.uuid}=luksdev"
+ # Note: tpm2-device and fido2-device don't play along very well:
+ # https://github.com/archlinux/archinstall/pull/1196#issuecomment-1129715645
+ kernel_options += f" rd.luks.options=fido2-device=auto,password-echo=no"
+ else:
+ kernel_options += f" cryptdevice=PARTUUID={real_device.part_uuid}:luksdev"
+
+ entry.write(f'{kernel_options} root=/dev/mapper/luksdev {options_entry}')
else:
- log(f"Identifying root partition by PART-UUID on {root_partition}, looking for '{root_partition.uuid}'.", level=logging.DEBUG)
- entry.write(f'options root=PARTUUID={root_partition.uuid} {options_entry}')
+ log(f"Identifying root partition by PARTUUID on {root_partition}, looking for '{root_partition.part_uuid}'.", level=logging.DEBUG)
+ entry.write(f'options root=PARTUUID={root_partition.part_uuid} {options_entry}')
self.helper_flags['bootloader'] = "systemd"
@@ -903,11 +946,11 @@ class Installer:
if real_device := self.detect_encryption(root_partition):
# TODO: We need to detect if the encrypted device is a whole disk encryption,
# or simply a partition encryption. Right now we assume it's a partition (and we always have)
- log(f"Identifying root partition by PART-UUID on {real_device}: '{real_device.uuid}'.", level=logging.DEBUG)
- kernel_parameters.append(f'cryptdevice=PARTUUID={real_device.uuid}:luksdev root=/dev/mapper/luksdev rw intel_pstate=no_hwp rootfstype={root_fs_type} {" ".join(self.KERNEL_PARAMS)}')
+ log(f"Identifying root partition by PART-UUID on {real_device}: '{real_device.part_uuid}'.", level=logging.DEBUG)
+ kernel_parameters.append(f'cryptdevice=PARTUUID={real_device.part_uuid}:luksdev root=/dev/mapper/luksdev rw intel_pstate=no_hwp rootfstype={root_fs_type} {" ".join(self.KERNEL_PARAMS)}')
else:
- log(f"Identifying root partition by PART-UUID on {root_partition}, looking for '{root_partition.uuid}'.", level=logging.DEBUG)
- kernel_parameters.append(f'root=PARTUUID={root_partition.uuid} rw intel_pstate=no_hwp rootfstype={root_fs_type} {" ".join(self.KERNEL_PARAMS)}')
+ log(f"Identifying root partition by PART-UUID on {root_partition}, looking for '{root_partition.part_uuid}'.", level=logging.DEBUG)
+ kernel_parameters.append(f'root=PARTUUID={root_partition.part_uuid} rw intel_pstate=no_hwp rootfstype={root_fs_type} {" ".join(self.KERNEL_PARAMS)}')
SysCommand(f'efibootmgr --disk {boot_partition.path[:-1]} --part {boot_partition.path[-1]} --create --label "{label}" --loader {loader} --unicode \'{" ".join(kernel_parameters)}\' --verbose')
diff --git a/archinstall/lib/menu/global_menu.py b/archinstall/lib/menu/global_menu.py
index 13d385ef..d807433c 100644
--- a/archinstall/lib/menu/global_menu.py
+++ b/archinstall/lib/menu/global_menu.py
@@ -85,6 +85,12 @@ class GlobalMenu(GeneralMenu):
lambda x: self._select_encrypted_password(),
display_func=lambda x: secret(x) if x else 'None',
dependencies=['harddrives'])
+ self._menu_options['HSM'] = Selector(
+ description=_('Use HSM to unlock encrypted drive'),
+ func=lambda preset: self._select_hsm(preset),
+ dependencies=['!encryption-password'],
+ default=None
+ )
self._menu_options['swap'] = \
Selector(
_('Swap'),
diff --git a/archinstall/lib/menu/selection_menu.py b/archinstall/lib/menu/selection_menu.py
index 35057e9c..26be4cc7 100644
--- a/archinstall/lib/menu/selection_menu.py
+++ b/archinstall/lib/menu/selection_menu.py
@@ -2,12 +2,14 @@ from __future__ import annotations
import logging
import sys
+import pathlib
from typing import Callable, Any, List, Iterator, Tuple, Optional, Dict, TYPE_CHECKING
from .menu import Menu, MenuSelectionType
from ..locale_helpers import set_keyboard_language
from ..output import log
from ..translation import Translation
+from ..hsm.fido import get_fido2_devices
if TYPE_CHECKING:
_: Any
@@ -466,3 +468,25 @@ class GeneralMenu:
return language
return preset_value
+
+ def _select_hsm(self, preset :Optional[pathlib.Path] = None) -> Optional[pathlib.Path]:
+ title = _('Select which partitions to mark for formatting:')
+ title += '\n'
+
+ fido_devices = get_fido2_devices()
+
+ indexes = []
+ for index, path in enumerate(fido_devices.keys()):
+ title += f"{index}: {path} ({fido_devices[path]['manufacturer']} - {fido_devices[path]['product']})"
+ indexes.append(f"{index}|{fido_devices[path]['product']}")
+
+ title += '\n'
+
+ choice = Menu(title, indexes, multi=False).run()
+
+ match choice.type_:
+ case MenuSelectionType.Esc: return preset
+ case MenuSelectionType.Selection:
+ return pathlib.Path(list(fido_devices.keys())[int(choice.value.split('|',1)[0])])
+
+ return None \ No newline at end of file
diff --git a/archinstall/lib/udev/__init__.py b/archinstall/lib/udev/__init__.py
new file mode 100644
index 00000000..86c8cc29
--- /dev/null
+++ b/archinstall/lib/udev/__init__.py
@@ -0,0 +1 @@
+from .udevadm import udevadm_info \ No newline at end of file
diff --git a/archinstall/lib/udev/udevadm.py b/archinstall/lib/udev/udevadm.py
new file mode 100644
index 00000000..84ec9cfd
--- /dev/null
+++ b/archinstall/lib/udev/udevadm.py
@@ -0,0 +1,17 @@
+import typing
+import pathlib
+from ..general import SysCommand
+
+def udevadm_info(path :pathlib.Path) -> typing.Dict[str, str]:
+ if path.resolve().exists() is False:
+ return {}
+
+ result = SysCommand(f"udevadm info {path.resolve()}")
+ data = {}
+ for line in result:
+ if b': ' in line and b'=' in line:
+ _, obj = line.split(b': ', 1)
+ key, value = obj.split(b'=', 1)
+ data[key.decode('UTF-8').lower()] = value.decode('UTF-8').strip()
+
+ return data \ No newline at end of file