diff --git a/PKGBUILD b/PKGBUILD index df730f3fc5..c74256f900 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -19,6 +19,8 @@ depends=( 'e2fsprogs' 'glibc' 'kbd' + 'libcrypt.so' + 'libxcrypt' 'pciutils' 'procps-ng' 'python' diff --git a/archinstall/lib/args.py b/archinstall/lib/args.py index b29541ae89..2efa973fab 100644 --- a/archinstall/lib/args.py +++ b/archinstall/lib/args.py @@ -19,7 +19,7 @@ from archinstall.lib.models.network_configuration import NetworkConfiguration from archinstall.lib.models.packages import Repository from archinstall.lib.models.profile_model import ProfileConfiguration -from archinstall.lib.models.users import User +from archinstall.lib.models.users import Password, User from archinstall.lib.output import error, warn from archinstall.lib.plugins import load_plugin from archinstall.lib.storage import storage @@ -71,16 +71,16 @@ class ArchConfig: # Special fields that should be handle with care due to security implications users: list[User] = field(default_factory=list) disk_encryption: DiskEncryption | None = None - root_password: str | None = None + root_enc_password: Password | None = None def unsafe_json(self) -> dict[str, Any]: config = { - '!users': [user.json() for user in self.users], - '!root-password': self.root_password, + 'users': [user.json() for user in self.users], + 'root_enc_password': self.root_enc_password.enc_password if self.root_enc_password else None, } - if self.disk_encryption: - config['encryption_password'] = self.disk_encryption.encryption_password + if self.disk_encryption and self.disk_encryption.encryption_password: + config['encryption_password'] = self.disk_encryption.encryption_password.plaintext return config @@ -149,10 +149,12 @@ def from_config(cls, args_config: dict[str, Any]) -> 'ArchConfig': if net_config := args_config.get('network_config', None): arch_config.network_config = NetworkConfiguration.parse_arg(net_config) - users = args_config.get('!users', None) - superusers = args_config.get('!superusers', None) - if users is not None or superusers is not None: - arch_config.users = User.parse_arguments(users, superusers) + # DEPRECATED: backwards copatibility + if users := args_config.get('!users', None): + arch_config.users = User.parse_arguments(users) + + if users := args_config.get('users', None): + arch_config.users = User.parse_arguments(users) if bootloader_config := args_config.get('bootloader', None): arch_config.bootloader = Bootloader.from_arg(bootloader_config) @@ -167,7 +169,7 @@ def from_config(cls, args_config: dict[str, Any]) -> 'ArchConfig': arch_config.disk_encryption = DiskEncryption.parse_arg( arch_config.disk_config, args_config['disk_encryption'], - args_config.get('encryption_password', '') + Password(plaintext=args_config.get('encryption_password', '')) ) if hostname := args_config.get('hostname', ''): @@ -192,8 +194,12 @@ def from_config(cls, args_config: dict[str, Any]) -> 'ArchConfig': if services := args_config.get('services', []): arch_config.services = services + # DEPRECATED: backwards compatibility if root_password := args_config.get('!root-password', None): - arch_config.root_password = root_password + arch_config.root_enc_password = Password(plaintext=root_password) + + if enc_password := args_config.get('root_enc_password', None): + arch_config.root_enc_password = Password(enc_password=enc_password) if custom_commands := args_config.get('custom_commands', []): arch_config.custom_commands = custom_commands diff --git a/archinstall/lib/crypt.py b/archinstall/lib/crypt.py new file mode 100644 index 0000000000..0b9c1134c9 --- /dev/null +++ b/archinstall/lib/crypt.py @@ -0,0 +1,72 @@ +import ctypes +import ctypes.util +from pathlib import Path + +from .output import debug + +libcrypt = ctypes.CDLL("libcrypt.so") + +libcrypt.crypt.argtypes = [ctypes.c_char_p, ctypes.c_char_p] +libcrypt.crypt.restype = ctypes.c_char_p + +libcrypt.crypt_gensalt.argtypes = [ctypes.c_char_p, ctypes.c_ulong, ctypes.c_char_p, ctypes.c_int] +libcrypt.crypt_gensalt.restype = ctypes.c_char_p + +LOGIN_DEFS = Path('/etc/login.defs') + + +def _search_login_defs(key: str) -> str | None: + defs = LOGIN_DEFS.read_text() + for line in defs.split('\n'): + line = line.strip() + + if line.startswith('#'): + continue + + if line.startswith(key): + value = line.split(' ')[1] + return value + + return None + + +def crypt_gen_salt(prefix: str | bytes, rounds: int) -> bytes: + if isinstance(prefix, str): + prefix = prefix.encode('utf-8') + + setting = libcrypt.crypt_gensalt(prefix, rounds, None, 0) + + if setting is None: + raise ValueError(f'crypt_gensalt() returned NULL for prefix {prefix!r} and rounds {rounds}') + + return setting + + +def crypt_yescrypt(plaintext: str) -> str: + """ + By default chpasswd in Arch uses PAM to to hash the password with crypt_yescrypt + the PAM code https://github.com/linux-pam/linux-pam/blob/master/modules/pam_unix/support.c + shows that the hashing rounds are determined from YESCRYPT_COST_FACTOR in /etc/login.defs + If no value was specified (or commented out) a default of 5 is choosen + """ + value = _search_login_defs('YESCRYPT_COST_FACTOR') + if value is not None: + rounds = int(value) + if rounds < 3: + rounds = 3 + elif rounds > 11: + rounds = 11 + else: + rounds = 5 + + debug(f'Creating yescrypt hash with rounds {rounds}') + + enc_plaintext = plaintext.encode('utf-8') + salt = crypt_gen_salt('$y$', rounds) + + crypt_hash = libcrypt.crypt(enc_plaintext, salt) + + if crypt_hash is None: + raise ValueError('crypt() returned NULL') + + return crypt_hash.decode('utf-8') diff --git a/archinstall/lib/disk/device_handler.py b/archinstall/lib/disk/device_handler.py index ad78153ebe..bce5776782 100644 --- a/archinstall/lib/disk/device_handler.py +++ b/archinstall/lib/disk/device_handler.py @@ -38,6 +38,7 @@ _DeviceInfo, _PartitionInfo, ) +from ..models.users import Password from ..output import debug, error, info, log from ..utils.util import is_subpath from .utils import ( @@ -307,7 +308,7 @@ def encrypt( self, dev_path: Path, mapper_name: str | None, - enc_password: str, + enc_password: Password | None, lock_after_create: bool = True ) -> Luks2: luks_handler = Luks2( @@ -338,6 +339,9 @@ def format_encrypted( fs_type: FilesystemType, enc_conf: DiskEncryption ) -> None: + if not enc_conf.encryption_password: + raise ValueError('No encryption password provided') + luks_handler = Luks2( dev_path, mapper_name=mapper_name, @@ -678,7 +682,12 @@ def create_btrfs_volumes( if luks_handler is not None and luks_handler.mapper_dev is not None: luks_handler.lock() - def unlock_luks2_dev(self, dev_path: Path, mapper_name: str, enc_password: str) -> Luks2: + def unlock_luks2_dev( + self, + dev_path: Path, + mapper_name: str, + enc_password: Password | None + ) -> Luks2: luks_handler = Luks2(dev_path, mapper_name=mapper_name, password=enc_password) if not luks_handler.is_unlocked(): diff --git a/archinstall/lib/disk/encryption_menu.py b/archinstall/lib/disk/encryption_menu.py index a715173cd3..6cd15492b1 100644 --- a/archinstall/lib/disk/encryption_menu.py +++ b/archinstall/lib/disk/encryption_menu.py @@ -17,6 +17,7 @@ from ..menu.abstract_menu import AbstractSubMenu from ..models.device_model import Fido2Device +from ..models.users import Password from ..output import FormattedOutput from ..utils.util import get_password from .fido import Fido2 @@ -122,7 +123,7 @@ def run(self) -> DiskEncryption | None: super().run() enc_type: EncryptionType | None = self._item_group.find_by_key('encryption_type').value - enc_password: str | None = self._item_group.find_by_key('encryption_password').value + enc_password: Password | None = self._item_group.find_by_key('encryption_password').value enc_partitions = self._item_group.find_by_key('partitions').value enc_lvm_vols = self._item_group.find_by_key('lvm_volumes').value @@ -183,8 +184,7 @@ def _prev_password(self) -> str | None: enc_pwd = self._item_group.find_by_key('encryption_password').value if enc_pwd: - pwd_text = '*' * len(enc_pwd) - return f'{_("Encryption password")}: {pwd_text}' + return f'{_("Encryption password")}: {enc_pwd.hidden()}' return None @@ -249,7 +249,7 @@ def select_encryption_type(disk_config: DiskLayoutConfiguration, preset: Encrypt return result.get_value() -def select_encrypted_password() -> str | None: +def select_encrypted_password() -> Password | None: header = str(_('Enter disk encryption password (leave blank for no encryption)')) + '\n' password = get_password( text=str(_('Disk encryption password')), diff --git a/archinstall/lib/disk/fido.py b/archinstall/lib/disk/fido.py index ed604807e9..c689c5d369 100644 --- a/archinstall/lib/disk/fido.py +++ b/archinstall/lib/disk/fido.py @@ -8,6 +8,7 @@ from ..exceptions import SysCallError from ..general import SysCommand, SysCommandWorker, clear_vt100_escape_codes_from_str +from ..models.users import Password from ..output import error, info @@ -74,7 +75,7 @@ def fido2_enroll( cls, hsm_device: Fido2Device, dev_path: Path, - password: str + password: Password ) -> None: worker = SysCommandWorker(f"systemd-cryptenroll --fido2-device={hsm_device.path} {dev_path}", peek_output=True) pw_inputted = False @@ -83,7 +84,7 @@ def fido2_enroll( while worker.is_alive(): if pw_inputted is False: if bytes(f"please enter current passphrase for disk {dev_path}", 'UTF-8') in worker._trace_log.lower(): - worker.write(bytes(password, 'UTF-8')) + worker.write(bytes(password.plaintext, 'UTF-8')) pw_inputted = True elif pin_inputted is False: if bytes("please enter security token pin", 'UTF-8') in worker._trace_log.lower(): diff --git a/archinstall/lib/general.py b/archinstall/lib/general.py index 12ec596959..c9fd3004dd 100644 --- a/archinstall/lib/general.py +++ b/archinstall/lib/general.py @@ -474,8 +474,3 @@ def _pid_exists(pid: int) -> bool: return any(subprocess.check_output(['ps', '--no-headers', '-o', 'pid', '-p', str(pid)]).strip()) except subprocess.CalledProcessError: return False - - -def secret(x: str) -> str: - """ return * with len equal to to the input string """ - return '*' * len(x) diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index d633047d4e..8d1a729b88 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -9,7 +9,6 @@ from .args import ArchConfig from .configuration import save_config -from .general import secret from .hardware import SysInfo from .interactions.general_conf import ( add_number_of_parallel_downloads, @@ -31,7 +30,7 @@ from .models.mirrors import MirrorConfiguration from .models.network_configuration import NetworkConfiguration, NicType from .models.profile_model import ProfileConfiguration -from .models.users import User +from .models.users import Password, User from .output import FormattedOutput from .translationhandler import Language, translation_handler from .utils.util import get_password @@ -125,7 +124,7 @@ def _get_menu_options(self) -> list[MenuItem]: text=str(_('Root password')), action=self._set_root_password, preview_action=self._prev_root_pwd, - key='root_password', + key='root_enc_password', ), MenuItem( text=str(_('User account')), @@ -234,8 +233,8 @@ def has_superuser() -> bool: missing = set() for item in self._item_group.items: - if item.key in ['root_password', 'users']: - if not check('root_password') and not has_superuser(): + if item.key in ['root_enc_password', 'users']: + if not check('root_enc_password') and not has_superuser(): missing.add( str(_('Either root-password or at least 1 user with sudo privileges must be specified')) ) @@ -364,7 +363,8 @@ def _prev_hostname(self, item: MenuItem) -> str | None: def _prev_root_pwd(self, item: MenuItem) -> str | None: if item.value is not None: - return f'{_("Root password")}: {secret(item.value)}' + password: Password = item.value + return f'{_("Root password")}: {password.hidden()}' return None def _prev_audio(self, item: MenuItem) -> str | None: @@ -399,7 +399,9 @@ def _prev_disk_encryption(self, item: MenuItem) -> str | None: if enc_config: enc_type = EncryptionType.type_to_text(enc_config.encryption_type) output = str(_('Encryption type')) + f': {enc_type}\n' - output += str(_('Password')) + f': {secret(enc_config.encryption_password)}\n' + + if enc_config.encryption_password: + output += str(_('Password')) + f': {enc_config.encryption_password.hidden()}\n' if enc_config.partitions: output += f'Partitions: {len(enc_config.partitions)} selected\n' @@ -501,7 +503,7 @@ def _prev_profile(self, item: MenuItem) -> str | None: return None - def _set_root_password(self, preset: str | None = None) -> str | None: + def _set_root_password(self, preset: str | None = None) -> Password | None: password = get_password(text=str(_('Root password')), allow_skip=True) return password diff --git a/archinstall/lib/installer.py b/archinstall/lib/installer.py index 9088ab0040..2a0da7284e 100644 --- a/archinstall/lib/installer.py +++ b/archinstall/lib/installer.py @@ -82,7 +82,10 @@ def __init__( self.init_time = time.strftime('%Y-%m-%d_%H-%M-%S') self.milliseconds = int(str(time.time()).split('.')[1]) - self.helper_flags: dict[str, str | bool | None] = {'base': False, 'bootloader': None} + self._helper_flags: dict[str, str | bool | None] = { + 'base': False, + 'bootloader': None + } for kernel in self.kernels: self._base_packages.append(kernel) @@ -415,11 +418,12 @@ def _generate_key_files_partitions(self) -> None: if part_mod.is_root() and not gen_enc_file: if self._disk_encryption.hsm_device: - Fido2.fido2_enroll( - self._disk_encryption.hsm_device, - part_mod.safe_dev_path, - self._disk_encryption.encryption_password - ) + if self._disk_encryption.encryption_password: + Fido2.fido2_enroll( + self._disk_encryption.hsm_device, + part_mod.safe_dev_path, + self._disk_encryption.encryption_password + ) def _generate_key_file_lvm_volumes(self) -> None: for vol in self._disk_encryption.lvm_volumes: @@ -437,16 +441,17 @@ def _generate_key_file_lvm_volumes(self) -> None: if vol.is_root() and not gen_enc_file: if self._disk_encryption.hsm_device: - Fido2.fido2_enroll( - self._disk_encryption.hsm_device, - vol.safe_dev_path, - self._disk_encryption.encryption_password - ) + if self._disk_encryption.encryption_password: + Fido2.fido2_enroll( + self._disk_encryption.hsm_device, + vol.safe_dev_path, + self._disk_encryption.encryption_password + ) def sync_log_to_install_medium(self) -> bool: # Copy over the install log (if there is one) to the install medium if # at least the base has been strapped in, otherwise we won't have a filesystem/structure to copy to. - if self.helper_flags.get('base-strapped', False) is True: + if self._helper_flags.get('base-strapped', False) is True: if filename := storage.get('LOG_FILE', None): absolute_logfile = os.path.join(storage.get('LOG_PATH', './'), filename) @@ -480,7 +485,7 @@ def add_swapfile(self, size: str = '4G', enable_resume: bool = True, file: str = self._kernel_params.append(f'resume_offset={resume_offset}') def post_install_check(self, *args: str, **kwargs: str) -> list[str]: - return [step for step, flag in self.helper_flags.items() if flag is False] + return [step for step, flag in self._helper_flags.items() if flag is False] def set_mirrors( self, @@ -690,7 +695,7 @@ def copy_iso_network_config(self, enable_services: bool = False) -> bool: if enable_services: # If we haven't installed the base yet (function called pre-maturely) - if self.helper_flags.get('base', False) is False: + if self._helper_flags.get('base', False) is False: self._base_packages.append('iwd') # This function will be called after minimal_installation() @@ -719,7 +724,7 @@ def post_install_enable_iwd_service(*args: str, **kwargs: str) -> None: if enable_services: # If we haven't installed the base yet (function called pre-maturely) - if self.helper_flags.get('base', False) is False: + if self._helper_flags.get('base', False) is False: def post_install_enable_networkd_resolved(*args: str, **kwargs: str) -> None: self.enable_service(['systemd-networkd', 'systemd-resolved']) @@ -847,7 +852,7 @@ def minimal_installation( pacman_conf.apply() self.pacman.strap(self._base_packages) - self.helper_flags['base-strapped'] = True + self._helper_flags['base-strapped'] = True pacman_conf.persist() @@ -878,7 +883,7 @@ def minimal_installation( if mkinitcpio and not self.mkinitcpio(['-P']): error('Error generating initramfs (continuing anyway)') - self.helper_flags['base'] = True + self._helper_flags['base'] = True # Run registered post-install hooks for function in self.post_base_install: @@ -1144,7 +1149,7 @@ def _add_systemd_bootloader( loader_conf.write_text('\n'.join(loader_data) + '\n') - self.helper_flags['bootloader'] = 'systemd' + self._helper_flags['bootloader'] = 'systemd' def _add_grub_bootloader( self, @@ -1229,7 +1234,7 @@ def _add_grub_bootloader( except SysCallError as err: raise DiskError(f"Could not configure GRUB: {err}") - self.helper_flags['bootloader'] = "grub" + self._helper_flags['bootloader'] = "grub" def _add_limine_bootloader( self, @@ -1389,7 +1394,7 @@ def _add_limine_bootloader( config_path.write_text(config_contents) - self.helper_flags['bootloader'] = "limine" + self._helper_flags['bootloader'] = "limine" def _add_efistub_bootloader( self, @@ -1439,7 +1444,7 @@ def _add_efistub_bootloader( cmd = [arg.format(kernel=kernel) for arg in cmd_template] SysCommand(cmd) - self.helper_flags['bootloader'] = "efistub" + self._helper_flags['bootloader'] = "efistub" def _config_uki( self, @@ -1573,12 +1578,9 @@ def create_users(self, users: User | list[User]) -> None: users = [users] for user in users: - self.user_create(user.username, user.password, user.groups, user.sudo) - - def user_create(self, user: str, password: str | None = None, groups: list[str] | None = None, sudo: bool = False) -> None: - if groups is None: - groups = [] + self._create_user(user) + def _create_user(self, user: User) -> None: # This plugin hook allows for the plugin to handle the creation of the user. # Password and Group management is still handled by user_create() handled_by_plugin = False @@ -1588,14 +1590,14 @@ def user_create(self, user: str, password: str | None = None, groups: list[str] handled_by_plugin = result if not handled_by_plugin: - info(f'Creating user {user}') + info(f'Creating user {user.username}') cmd = f'arch-chroot {self.target} useradd -m' - if sudo: + if user.sudo: cmd += ' -G wheel' - cmd += f' {user}' + cmd += f' {user.username}' try: SysCommand(cmd) @@ -1607,29 +1609,29 @@ def user_create(self, user: str, password: str | None = None, groups: list[str] if result := plugin.on_user_created(self, user): handled_by_plugin = result - if password: - self.user_set_pw(user, password) + if user.password: + self.set_user_password(user) - if groups: - for group in groups: - SysCommand(f'arch-chroot {self.target} gpasswd -a {user} {group}') + for group in user.groups: + SysCommand(f'arch-chroot {self.target} gpasswd -a {user.username} {group}') - if sudo and self.enable_sudo(user): - self.helper_flags['user'] = True + def set_user_password(self, user: User) -> bool: + info(f'Setting password for {user.username}') - def user_set_pw(self, user: str, password: str) -> bool: - info(f'Setting password for {user}') + enc_password = user.password.enc_password if user.password else None - if user == 'root': - # This means the root account isn't locked/disabled with * in /etc/passwd - self.helper_flags['user'] = True + if not enc_password: + debug('User password is empty') + return False - cmd = ['arch-chroot', str(self.target), 'chpasswd'] + input_data = f'{user.username}:{enc_password}'.encode() + cmd = ['arch-chroot', str(self.target), 'chpasswd', '--encrypted'] try: - run(cmd, input_data=f'{user}:{password}'.encode()) + run(cmd, input_data=input_data) return True - except CalledProcessError: + except CalledProcessError as err: + debug(f'Error setting user password: {err}') return False def user_set_shell(self, user: str, shell: str) -> bool: diff --git a/archinstall/lib/interactions/manage_users_conf.py b/archinstall/lib/interactions/manage_users_conf.py index a5c4e1a3e7..e333f4fe6b 100644 --- a/archinstall/lib/interactions/manage_users_conf.py +++ b/archinstall/lib/interactions/manage_users_conf.py @@ -7,7 +7,6 @@ from archinstall.tui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.types import Alignment, Orientation, ResultType -from ..general import secret from ..menu.list_manager import ListManager from ..models.users import User from ..utils.util import get_password @@ -91,7 +90,7 @@ def _add_user(self) -> User | None: if not password: return None - header += f'{_("Password")}: {secret(password)}\n\n' + header += f'{_("Password")}: {password.hidden()}\n\n' header += str(_('Should "{}" be a superuser (sudo)?\n')).format(username) group = MenuItemGroup.yes_no() diff --git a/archinstall/lib/luks.py b/archinstall/lib/luks.py index a31f84ecc2..ad6c06f991 100644 --- a/archinstall/lib/luks.py +++ b/archinstall/lib/luks.py @@ -9,6 +9,7 @@ from .exceptions import DiskError, SysCallError from .general import SysCommand, SysCommandWorker, generate_password, run +from .models.users import Password from .output import debug, info @@ -16,7 +17,7 @@ class Luks2: luks_dev_path: Path mapper_name: str | None = None - password: str | None = None + password: Password | None = None key_file: Path | None = None auto_unmount: bool = False @@ -57,7 +58,7 @@ def _password_bytes(self) -> bytes: if isinstance(self.password, bytes): return self.password else: - return bytes(self.password, 'UTF-8') + return bytes(self.password.plaintext, 'UTF-8') def _get_passphrase_args( self, diff --git a/archinstall/lib/models/device_model.py b/archinstall/lib/models/device_model.py index 47f79b9c56..40e8134ef7 100644 --- a/archinstall/lib/models/device_model.py +++ b/archinstall/lib/models/device_model.py @@ -12,6 +12,7 @@ from pydantic import BaseModel, Field, ValidationInfo, field_serializer, field_validator from ..hardware import SysInfo +from ..models.users import Password from ..output import debug if TYPE_CHECKING: @@ -1422,7 +1423,7 @@ class _DiskEncryptionSerialization(TypedDict): @dataclass class DiskEncryption: encryption_type: EncryptionType = EncryptionType.NoEncryption - encryption_password: str = '' + encryption_password: Password | None = None partitions: list[PartitionModification] = field(default_factory=list) lvm_volumes: list[LvmVolume] = field(default_factory=list) hsm_device: Fido2Device | None = None @@ -1472,12 +1473,12 @@ def parse_arg( cls, disk_config: DiskLayoutConfiguration, disk_encryption: _DiskEncryptionSerialization, - password: str = '' + password: Password | None = None ) -> 'DiskEncryption | None': if not cls.validate_enc(disk_config): return None - if len(password) < 1: + if not password: return None enc_partitions = [] diff --git a/archinstall/lib/models/users.py b/archinstall/lib/models/users.py index 6037ec2675..ca720e2a25 100644 --- a/archinstall/lib/models/users.py +++ b/archinstall/lib/models/users.py @@ -1,6 +1,8 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import Enum -from typing import TYPE_CHECKING, TypedDict, override +from typing import TYPE_CHECKING, NotRequired, TypedDict, override + +from ..crypt import crypt_yescrypt if TYPE_CHECKING: from collections.abc import Callable @@ -103,72 +105,117 @@ def _check_password_strength( return PasswordStrength.VERY_WEAK -_UserSerialization = TypedDict('_UserSerialization', {'username': str, '!password': str, 'sudo': bool}) +_UserSerialization = TypedDict( + '_UserSerialization', + { + 'username': str, + '!password': NotRequired[str], + 'sudo': bool, + 'groups': list[str], + 'enc_password': str | None + } +) -@dataclass -class User: - username: str - password: str - sudo: bool +class Password: + def __init__( + self, + plaintext: str = '', + enc_password: str | None = None + ): + if plaintext: + enc_password = crypt_yescrypt(plaintext) + + if not plaintext and not enc_password: + raise ValueError('Either plaintext or enc_password must be provided') + + self._plaintext = plaintext + self.enc_password = enc_password @property - def groups(self) -> list[str]: - # this property should be transferred into a class attr instead - # if it's every going to be used - return [] + def plaintext(self) -> str: + return self._plaintext - def json(self) -> _UserSerialization: - return { - 'username': self.username, - '!password': self.password, - 'sudo': self.sudo - } + @plaintext.setter + def plaintext(self, value: str): + self._plaintext = value + self.enc_password = crypt_yescrypt(value) - @classmethod - def _parse(cls, config_users: list[_UserSerialization]) -> list['User']: - users = [] + @override + def __eq__(self, other: object) -> bool: + if not isinstance(other, Password): + return NotImplemented - for entry in config_users: - username = entry.get('username', None) - password = entry.get('!password', '') - sudo = entry.get('sudo', False) + if self._plaintext and other._plaintext: + return self._plaintext == other._plaintext - if username is None: - continue + return self.enc_password == other.enc_password - user = User(username, password, sudo) - users.append(user) + def hidden(self) -> str: + if self._plaintext: + return '*' * len(self._plaintext) + else: + return '*' * 8 - return users - @classmethod - def _parse_backwards_compatible(cls, config_users: dict[str, dict[str, str]], sudo: bool) -> list['User']: - if len(config_users.keys()) > 0: - username = list(config_users.keys())[0] - password = config_users[username]['!password'] +@dataclass +class User: + username: str + password: Password + sudo: bool + groups: list[str] = field(default_factory=list) - if password: - return [User(username, password, sudo)] + @override + def __str__(self) -> str: + # safety overwrite to make sure password is not leaked + return f'User({self.username=}, {self.sudo=}, {self.groups=})' - return [] + def table_data(self) -> dict[str, str | bool | list[str]]: + return { + 'username': self.username, + 'password': self.password.hidden(), + 'sudo': self.sudo, + 'groups': self.groups + } + + def json(self) -> _UserSerialization: + return { + 'username': self.username, + 'enc_password': self.password.enc_password, + 'sudo': self.sudo, + 'groups': self.groups + } @classmethod def parse_arguments( cls, - config_users: list[_UserSerialization] | dict[str, dict[str, str]], - config_superusers: dict[str, dict[str, str]] | None + args: list[_UserSerialization] ) -> list['User']: - users = [] + users: list[User] = [] + + for entry in args: + username = entry.get('username') + password: Password | None = None + groups = entry.get('groups', []) + plaintext = entry.get('!password') + enc_password = entry.get('enc_password') + + # DEPRECATED: backwards compatibility + if plaintext: + password = Password(plaintext=plaintext) + elif enc_password: + password = Password(enc_password=enc_password) + + if username is None or password is None: + continue - # backwards compatibility - if isinstance(config_users, dict): - users += cls._parse_backwards_compatible(config_users, False) - else: - users += cls._parse(config_users) + user = User( + username=username, + password=password, + sudo=entry.get('sudo', False) is True, + groups=groups + ) - # backwards compatibility - if isinstance(config_superusers, dict): - users += cls._parse_backwards_compatible(config_superusers, True) + users.append(user) return users diff --git a/archinstall/lib/profile/__init__.py b/archinstall/lib/profile/__init__.py index b4e378d79c..e69de29bb2 100644 --- a/archinstall/lib/profile/__init__.py +++ b/archinstall/lib/profile/__init__.py @@ -1,9 +0,0 @@ -from .profile_menu import ProfileMenu, select_greeter, select_profile -from .profiles_handler import profile_handler - -__all__ = [ - 'ProfileMenu', - 'profile_handler', - 'select_greeter', - 'select_profile', -] diff --git a/archinstall/lib/utils/util.py b/archinstall/lib/utils/util.py index 1234588106..7b96d2e4a5 100644 --- a/archinstall/lib/utils/util.py +++ b/archinstall/lib/utils/util.py @@ -4,7 +4,7 @@ from archinstall.tui.curses_menu import EditMenu from archinstall.tui.types import Alignment -from ..general import secret +from ..models.users import Password from ..output import FormattedOutput if TYPE_CHECKING: @@ -20,7 +20,7 @@ def get_password( header: str | None = None, allow_skip: bool = False, preset: str | None = None -) -> str | None: +) -> Password | None: failure: str | None = None while True: @@ -42,13 +42,12 @@ def get_password( if allow_skip and not result.has_item(): return None - password = result.text() - hidden = secret(password) + password = Password(plaintext=result.text()) if header is not None: - confirmation_header = f'{header}{_("Password")}: {hidden}\n' + confirmation_header = f'{header}{_("Password")}: {password.hidden()}\n' else: - confirmation_header = f'{_("Password")}: {hidden}\n' + confirmation_header = f'{_("Password")}: {password.hidden()}\n' result = EditMenu( str(_('Confirm password')), @@ -58,7 +57,7 @@ def get_password( hide_input=True ).input() - if password == result.text(): + if password._plaintext == result.text(): return password failure = str(_('The confirmation password did not match, please try again')) diff --git a/archinstall/scripts/guided.py b/archinstall/scripts/guided.py index db6a2d8948..8a23583314 100644 --- a/archinstall/scripts/guided.py +++ b/archinstall/scripts/guided.py @@ -15,6 +15,7 @@ EncryptionType, ) from archinstall.lib.models.network_configuration import NetworkConfiguration +from archinstall.lib.models.users import User from archinstall.lib.output import debug, error, info from archinstall.lib.profile.profiles_handler import profile_handler from archinstall.tui import Tui @@ -128,8 +129,9 @@ def perform_installation(mountpoint: Path) -> None: if accessibility_tools_in_use(): installation.enable_espeakup() - if (root_pw := config.root_password) and len(root_pw): - installation.user_set_pw('root', root_pw) + if root_pw := config.root_enc_password: + root_user = User('root', root_pw, False) + installation.set_user_password(root_user) if (profile_config := config.profile_config) and profile_config.profile: profile_config.profile.post_install(installation) diff --git a/archinstall/scripts/minimal.py b/archinstall/scripts/minimal.py index 2b3bd8e6a3..957199f61b 100644 --- a/archinstall/scripts/minimal.py +++ b/archinstall/scripts/minimal.py @@ -7,14 +7,15 @@ from archinstall.lib.disk.encryption_menu import DiskEncryptionMenu from archinstall.lib.disk.filesystem import FilesystemHandler from archinstall.lib.installer import Installer -from archinstall.lib.models import Bootloader, User +from archinstall.lib.models import Bootloader from archinstall.lib.models.device_model import ( DiskLayoutConfiguration, ) from archinstall.lib.models.network_configuration import NetworkConfiguration from archinstall.lib.models.profile_model import ProfileConfiguration +from archinstall.lib.models.users import Password, User from archinstall.lib.output import debug, error, info -from archinstall.lib.profile import profile_handler +from archinstall.lib.profile.profiles_handler import profile_handler from archinstall.tui import Tui @@ -53,7 +54,7 @@ def perform_installation(mountpoint: Path) -> None: profile_config = ProfileConfiguration(MinimalProfile()) profile_handler.install_profile_config(installation, profile_config) - user = User('devel', 'devel', False) + user = User('devel', Password(plaintext='devel'), False) installation.create_users(user) # Once this is done, we output some useful information to the user diff --git a/docs/installing/guided.rst b/docs/installing/guided.rst index 73dde4ae08..696ca20209 100644 --- a/docs/installing/guided.rst +++ b/docs/installing/guided.rst @@ -247,7 +247,7 @@ Below is an example of how to set the root password and below that are descripti .. code-block:: json { - "!root-password" : "SecretSanta2022" + "root_enc_password" : "SecretSanta2022" } .. list-table:: ``--creds`` options @@ -262,16 +262,16 @@ Below is an example of how to set the root password and below that are descripti - ``str`` - Password to encrypt disk, not encrypted if password not provided - No - * - ``!root-password`` + * - ``root_enc_password`` - ``str`` - The root account password - No - * - ``!users`` + * - ``users`` - .. code-block:: json { "username": "", - "!password": "", + "enc_password": "", "sudo": false } - List of regular user credentials, see configuration for reference @@ -280,11 +280,9 @@ Below is an example of how to set the root password and below that are descripti .. note:: - ``!users`` is optional only if ``!root-password`` was set. ``!users`` will be enforced otherwise and the minimum amount of users with sudo privileges required will be set to 1. + ``users`` is optional only if ``root_enc_password`` was set. ``users`` will be enforced otherwise and the minimum amount of users with sudo privileges required will be set to 1. .. note:: - The keys start with ``!`` because internal log functions will mask any keys starting with exclamation marks from logs and unrestricted configurations. - .. _scripts: https://github.com/archlinux/archinstall/tree/master/archinstall/scripts .. _Guided Installer: https://github.com/archlinux/archinstall/blob/master/archinstall/scripts/guided.py diff --git a/examples/creds-sample.json b/examples/creds-sample.json index 70645fd8ac..efcdbe23a2 100644 --- a/examples/creds-sample.json +++ b/examples/creds-sample.json @@ -1,9 +1,11 @@ { - "!users": [ + "users": [ { "sudo": true, - "username": "archinstall" + "username": "archinstall", + "enc_password": "password_hash" + } ], - "encryption_password": "..." + "root_enc_password": "password_hash" } diff --git a/examples/full_automated_installation.py b/examples/full_automated_installation.py index ec70dc6a6f..2cc0975523 100644 --- a/examples/full_automated_installation.py +++ b/examples/full_automated_installation.py @@ -19,8 +19,8 @@ Unit, ) from archinstall.lib.models.profile_model import ProfileConfiguration -from archinstall.lib.models.users import User -from archinstall.lib.profile import profile_handler +from archinstall.lib.models.users import Password, User +from archinstall.lib.profile.profiles_handler import profile_handler # we're creating a new ext4 filesystem installation fs_type = FilesystemType('ext4') @@ -81,7 +81,7 @@ # disk encryption configuration (Optional) disk_encryption = DiskEncryption( - encryption_password="enc_password", + encryption_password=Password(plaintext="enc_password"), encryption_type=EncryptionType.Luks, partitions=[home_partition], hsm_device=None @@ -111,5 +111,5 @@ profile_config = ProfileConfiguration(MinimalProfile()) profile_handler.install_profile_config(installation, profile_config) -user = User('archinstall', 'password', True) +user = User('archinstall', Password(plaintext='password'), True) installation.create_users(user) diff --git a/examples/interactive_installation.py b/examples/interactive_installation.py index 3f9f4bc40a..613cacfaa2 100644 --- a/examples/interactive_installation.py +++ b/examples/interactive_installation.py @@ -15,6 +15,7 @@ EncryptionType, ) from archinstall.lib.models.network_configuration import NetworkConfiguration +from archinstall.lib.models.users import User from archinstall.lib.profile.profiles_handler import profile_handler from archinstall.tui import Tui @@ -127,8 +128,9 @@ def perform_installation(mountpoint: Path) -> None: if accessibility_tools_in_use(): installation.enable_espeakup() - if (root_pw := config.root_password) and len(root_pw): - installation.user_set_pw('root', root_pw) + if root_pw := config.root_enc_password: + root_user = User('root', root_pw, False) + installation.set_user_password(root_user) if (profile_config := config.profile_config) and profile_config.profile: profile_config.profile.post_install(installation) diff --git a/examples/mac_address_installation.py b/examples/mac_address_installation.py index f569477b0a..4582353580 100644 --- a/examples/mac_address_installation.py +++ b/examples/mac_address_installation.py @@ -1,7 +1,7 @@ import time from archinstall.lib.output import info -from archinstall.lib.profile import profile_handler +from archinstall.lib.profile.profiles_handler import profile_handler from archinstall.lib.storage import storage from archinstall.tui import Tui diff --git a/examples/minimal_installation.py b/examples/minimal_installation.py index a7171a3060..d648c6e393 100644 --- a/examples/minimal_installation.py +++ b/examples/minimal_installation.py @@ -7,12 +7,13 @@ from archinstall.lib.disk.encryption_menu import DiskEncryptionMenu from archinstall.lib.disk.filesystem import FilesystemHandler from archinstall.lib.installer import Installer -from archinstall.lib.models import Bootloader, User +from archinstall.lib.models import Bootloader from archinstall.lib.models.device_model import DiskLayoutConfiguration from archinstall.lib.models.network_configuration import NetworkConfiguration from archinstall.lib.models.profile_model import ProfileConfiguration +from archinstall.lib.models.users import Password, User from archinstall.lib.output import debug, error, info -from archinstall.lib.profile import profile_handler +from archinstall.lib.profile.profiles_handler import profile_handler from archinstall.tui import Tui @@ -52,7 +53,7 @@ def perform_installation(mountpoint: Path) -> None: profile_config = ProfileConfiguration(MinimalProfile()) profile_handler.install_profile_config(installation, profile_config) - user = User('devel', 'devel', False) + user = User('devel', Password(plaintext='devel'), False) installation.create_users(user) # Once this is done, we output some useful information to the user diff --git a/tests/conftest.py b/tests/conftest.py index bbdf8a7be8..fe12b31229 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,8 +14,13 @@ def creds_fixture() -> Path: @pytest.fixture(scope='session') -def mirror_backwards_config() -> Path: - return Path(__file__).parent / 'data' / 'test_config_mirror_backwards.json' +def deprecated_creds_config() -> Path: + return Path(__file__).parent / 'data' / 'test_deprecated_creds_config.json' + + +@pytest.fixture(scope='session') +def deprecated_mirror_config() -> Path: + return Path(__file__).parent / 'data' / 'test_deprecated_mirror_config.json' @pytest.fixture(scope='session') diff --git a/tests/data/test_creds.json b/tests/data/test_creds.json index 2f52f67ccf..66e6082f22 100644 --- a/tests/data/test_creds.json +++ b/tests/data/test_creds.json @@ -1,10 +1,11 @@ { - "!root-password": "super_pwd", - "!users": [ + "root_enc_password": "password_hash", + "users": [ { - "!password": "user_pwd", + "enc_password": "password_hash", "sudo": true, - "username": "user_name" + "username": "user_name", + "groups": ["wheel"] } ] } diff --git a/tests/data/test_deprecated_creds_config.json b/tests/data/test_deprecated_creds_config.json new file mode 100644 index 0000000000..36633d3972 --- /dev/null +++ b/tests/data/test_deprecated_creds_config.json @@ -0,0 +1,11 @@ +{ + "!root-password": "rootPwd", + "!users": [ + { + "!password": "userPwd", + "sudo": true, + "username": "user_name", + "groups": ["wheel"] + } + ] +} diff --git a/tests/data/test_config_mirror_backwards.json b/tests/data/test_deprecated_mirror_config.json similarity index 100% rename from tests/data/test_config_mirror_backwards.json rename to tests/data/test_deprecated_mirror_config.json diff --git a/tests/test_args.py b/tests/test_args.py index 0a21413c81..036c639985 100644 --- a/tests/test_args.py +++ b/tests/test_args.py @@ -13,7 +13,7 @@ from archinstall.lib.models.network_configuration import NetworkConfiguration, Nic, NicType from archinstall.lib.models.packages import Repository from archinstall.lib.models.profile_model import ProfileConfiguration -from archinstall.lib.models.users import User +from archinstall.lib.models.users import Password, User from archinstall.lib.profile.profiles_handler import profile_handler from archinstall.lib.translationhandler import translation_handler @@ -189,22 +189,29 @@ def test_config_file_parsing( parallel_downloads=66, swap=False, timezone='UTC', - users=[User(username='user_name', password='user_pwd', sudo=True)], + users=[ + User( + username='user_name', + password=Password(enc_password='password_hash'), + sudo=True, + groups=['wheel'] + ) + ], disk_encryption=None, services=['service_1', 'service_2'], - root_password='super_pwd', + root_enc_password=Password(enc_password='password_hash'), custom_commands=["echo 'Hello, World!'"] ) -def test_mirror_backwards_config_file_parsing( +def test_deprecated_mirror_config_parsing( monkeypatch: MonkeyPatch, - mirror_backwards_config: Path, + deprecated_mirror_config: Path, ) -> None: monkeypatch.setattr('sys.argv', [ 'archinstall', '--config', - str(mirror_backwards_config), + str(deprecated_mirror_config), ]) handler = ArchConfigHandler() @@ -228,3 +235,28 @@ def test_mirror_backwards_config_file_parsing( ) ] ) + + +def test_deprecated_creds_config_parsing( + monkeypatch: MonkeyPatch, + deprecated_creds_config: Path, +) -> None: + monkeypatch.setattr('sys.argv', [ + 'archinstall', + '--creds', + str(deprecated_creds_config), + ]) + + handler = ArchConfigHandler() + arch_config = handler.config + + assert arch_config.root_enc_password == Password(plaintext='rootPwd') + + assert arch_config.users == [ + User( + username='user_name', + password=Password(plaintext='userPwd'), + sudo=True, + groups=['wheel'] + ) + ]