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
This commit is contained in:
Anton Hvornum 2022-05-18 11:28:59 +02:00 committed by GitHub
parent 561ea7e8f5
commit 493cccc18f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 241 additions and 26 deletions

View File

@ -1,7 +1,7 @@
[flake8]
count = True
# Several of the following could be autofixed or improved by running the code through psf/black
ignore = E123,E126,E128,E203,E231,E261,E302,E402,E722,F541,W191,W292,W293
ignore = E123,E126,E128,E203,E231,E261,E302,E402,E722,F541,W191,W292,W293,W503
max-complexity = 40
max-line-length = 236
show-source = True

View File

@ -45,6 +45,11 @@ from .lib.menu.selection_menu import (
from .lib.translation import Translation, DeferredTranslation
from .lib.plugins import plugins, load_plugin # This initiates the plugin loading ceremony
from .lib.configuration import *
from .lib.udev import udevadm_info
from .lib.hsm import (
get_fido2_devices,
fido2_enroll
)
parser = ArgumentParser()
__version__ = "2.4.2"

View File

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

View File

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

View File

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

View File

@ -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
@ -216,6 +236,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:

View File

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

View File

@ -0,0 +1,4 @@
from .fido import (
get_fido2_devices,
fido2_enroll
)

View File

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

View File

@ -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,6 +659,13 @@ class Installer:
self.HOOKS.remove('fsck')
if self.detect_encryption(partition):
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')
@ -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:
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}')
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 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')

View File

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

View File

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

View File

@ -0,0 +1 @@
from .udevadm import udevadm_info

View File

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

View File

@ -1,8 +1,15 @@
msgid ""
msgstr ""
"Project-Id-Version: \n"
"POT-Creation-Date: \n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: en\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 3.0.1\n"
msgid "[!] A log file has been created here: {} {}"
msgstr ""

View File

@ -57,6 +57,10 @@ def ask_user_questions():
# Get disk encryption password (or skip if blank)
global_menu.enable('!encryption-password')
if archinstall.arguments.get('advanced', False) or archinstall.arguments.get('HSM', None):
# Enables the use of HSM
global_menu.enable('HSM')
# Ask which boot-loader to use (will only ask if we're in UEFI mode, otherwise will default to GRUB)
global_menu.enable('bootloader')
@ -130,6 +134,7 @@ def perform_installation(mountpoint):
Only requirement is that the block devices are
formatted and setup prior to entering this function.
"""
with archinstall.Installer(mountpoint, kernels=archinstall.arguments.get('kernels', ['linux'])) as installation:
# Mount all the drives to the desired mountpoint
# This *can* be done outside of the installation, but the installer can deal with it.
@ -301,5 +306,6 @@ if archinstall.arguments.get('dry_run'):
if not archinstall.arguments.get('silent'):
input(str(_('Press Enter to continue.')))
archinstall.configuration_sanity_check()
perform_filesystem_operations()
perform_installation(archinstall.storage.get('MOUNT_POINT', '/mnt'))