From c904171b41a9af580c8f3137e1d1aac24421883c Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Thu, 20 Mar 2025 19:51:56 +1100 Subject: [PATCH 01/14] Rework user password to be hash --- archinstall/lib/args.py | 30 ++-- archinstall/lib/disk/device_handler.py | 13 +- archinstall/lib/disk/encryption_menu.py | 8 +- archinstall/lib/disk/fido.py | 5 +- archinstall/lib/general.py | 5 - archinstall/lib/global_menu.py | 20 +-- archinstall/lib/installer.py | 94 ++++++----- .../lib/interactions/manage_users_conf.py | 3 +- archinstall/lib/luks.py | 5 +- archinstall/lib/models/device_model.py | 7 +- archinstall/lib/models/users.py | 148 ++++++++++++------ archinstall/lib/utils/util.py | 13 +- archinstall/scripts/guided.py | 6 +- docs/installing/guided.rst | 12 +- examples/creds-sample.json | 8 +- examples/interactive_installation.py | 6 +- tests/conftest.py | 9 +- tests/data/test_creds.json | 9 +- tests/data/test_deprecated_creds_config.json | 11 ++ ...son => test_deprecated_mirror_config.json} | 0 tests/test_args.py | 44 +++++- 21 files changed, 291 insertions(+), 165 deletions(-) create mode 100644 tests/data/test_deprecated_creds_config.json rename tests/data/{test_config_mirror_backwards.json => test_deprecated_mirror_config.json} (100%) diff --git a/archinstall/lib/args.py b/archinstall/lib/args.py index a827afa158..69adca4364 100644 --- a/archinstall/lib/args.py +++ b/archinstall/lib/args.py @@ -19,7 +19,7 @@ from archinstall.lib.models.mirrors import MirrorConfiguration from archinstall.lib.models.network_configuration import NetworkConfiguration 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.enc_password 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(enc_password=args_config.get('encryption_password', '')) ) if hostname := args_config.get('hostname', ''): @@ -193,8 +195,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/disk/device_handler.py b/archinstall/lib/disk/device_handler.py index 4486f662bb..83fbc7358f 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( @@ -336,6 +337,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, @@ -674,7 +678,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 4221be5f19..4b4b247537 100644 --- a/archinstall/lib/general.py +++ b/archinstall/lib/general.py @@ -514,8 +514,3 @@ def json_stream_to_structure(configuration_identifier: str, stream: str, target: return False target.update(structure) return True - - -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 3e1fcf842f..ad23741979 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')), @@ -223,7 +222,7 @@ def check(s) -> bool: return item.has_value() def has_superuser() -> bool: - item = self._item_group.find_by_key('!users') + item = self._item_group.find_by_key('users') if item.has_value(): users = item.value @@ -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')) ) @@ -363,7 +362,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: @@ -398,7 +398,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' @@ -481,7 +483,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 63b43cab23..08770195d9 100644 --- a/archinstall/lib/installer.py +++ b/archinstall/lib/installer.py @@ -83,7 +83,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) @@ -416,11 +419,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: @@ -438,16 +442,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) @@ -481,7 +486,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, @@ -689,7 +694,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() @@ -718,7 +723,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']) @@ -846,7 +851,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() @@ -877,7 +882,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: @@ -1141,7 +1146,7 @@ def _add_systemd_bootloader( entry_conf = entries_dir / name entry_conf.write_text('\n'.join(entry) + '\n') - self.helper_flags['bootloader'] = 'systemd' + self._helper_flags['bootloader'] = 'systemd' def _add_grub_bootloader( self, @@ -1226,7 +1231,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, @@ -1328,7 +1333,7 @@ def _add_limine_bootloader( config_path = self.target / 'boot' / 'limine.conf' config_path.write_text(config_contents) - self.helper_flags['bootloader'] = "limine" + self._helper_flags['bootloader'] = "limine" def _add_efistub_bootloader( self, @@ -1378,7 +1383,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, @@ -1512,12 +1517,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 @@ -1527,9 +1529,17 @@ 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' + + if user.sudo: + cmd += ' -G wheel' + + cmd += f' {user.username}' + try: - SysCommand(f'arch-chroot {self.target} useradd -m -G wheel {user}') + SysCommand(cmd) except SysCallError as err: raise SystemError(f"Could not create user inside installation: {err}") @@ -1538,29 +1548,27 @@ 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 - combo = f'{user}:{password}' - echo = shlex.join(['echo', combo]) + echo = shlex.join(['echo', f'{user.username}:{enc_password}']) sh = shlex.join(['sh', '-c', echo]) + chpasswd = "chpasswd --encrypted --crypt-method YESCRYPT" try: - SysCommand(f"arch-chroot {self.target} " + sh[:-1] + " | chpasswd'") + SysCommand(f"arch-chroot {self.target} " + sh[:-1] + f" | {chpasswd}'") return True except SysCallError: return False 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 8673280bca..39e0de9a77 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 +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_key_file(self, key_file: Path | None = None) -> Path: if key_file: diff --git a/archinstall/lib/models/device_model.py b/archinstall/lib/models/device_model.py index 0595ab2b8e..53dc0e8625 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: @@ -1444,7 +1445,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 @@ -1494,12 +1495,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 3a83d16165..074a2a6b82 100644 --- a/archinstall/lib/models/users.py +++ b/archinstall/lib/models/users.py @@ -1,6 +1,7 @@ -from dataclasses import dataclass +import shlex +from dataclasses import dataclass, field from enum import Enum -from typing import TYPE_CHECKING, Any, override +from typing import TYPE_CHECKING, override if TYPE_CHECKING: from collections.abc import Callable @@ -103,69 +104,120 @@ def _check_password_strength( return PasswordStrength.VERY_WEAK -@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 = self._encrypt(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) -> dict[str, str | bool]: - return { - 'username': self.username, - '!password': self.password, - 'sudo': self.sudo - } + @plaintext.setter + def plaintext(self, value: str): + self._plaintext = value + self.enc_password = self._encrypt(value) - @classmethod - def _parse(cls, config_users: list[dict[str, Any]]) -> 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 + def _encrypt(self, plaintext: str) -> str: + from ..general import SysCommand - @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'] + echo = shlex.join(['echo', plaintext]) + sh = shlex.join(['sh', '-c', echo]) + + cmd = sh[:-1] + " | mkpasswd --method=yescrypt --stdin'" + yescrypt_hash = SysCommand(cmd).decode() - if password: - return [User(username, password, sudo)] + return yescrypt_hash - return [] + +@dataclass +class User: + username: str + password: Password + sudo: bool + groups: list[str] = field(default_factory=list) + + 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) -> dict[str, str | bool | list[str] | None]: + return { + 'username': self.username, + 'enc_password': self.password.enc_password, + 'sudo': self.sudo, + 'groups': self.groups + } @classmethod def parse_arguments( cls, - config_users: list[dict[str, str]] | dict[str, dict[str, str]], - config_superusers: list[dict[str, str]] | dict[str, dict[str, str]] + args: list[dict[str, str | bool | list[str]]] ) -> 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') + + if not isinstance(username, str): + raise ValueError('username must be a string') + if not isinstance(groups, list): + raise ValueError('groups must be a list') + if not isinstance(plaintext, str): + raise ValueError('password must be a string') + if not isinstance(enc_password, str): + raise ValueError('encryption password must be a string') + + # 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/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 70b8c6056b..7fce168c7d 100644 --- a/archinstall/scripts/guided.py +++ b/archinstall/scripts/guided.py @@ -16,6 +16,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 @@ -129,8 +130,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/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/interactive_installation.py b/examples/interactive_installation.py index 90e9054829..42f608505f 100644 --- a/examples/interactive_installation.py +++ b/examples/interactive_installation.py @@ -16,6 +16,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 @@ -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/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 56b8db24ac..722231d3f6 100644 --- a/tests/test_args.py +++ b/tests/test_args.py @@ -13,7 +13,7 @@ from archinstall.lib.models.mirrors import CustomRepository, CustomServer, MirrorConfiguration, MirrorRegion, SignCheck, SignOption from archinstall.lib.models.network_configuration import NetworkConfiguration, Nic, NicType 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'] + ) + ] From fc26728cc74b11befde6acc16803a0bcbaa4d31a Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Thu, 20 Mar 2025 20:23:24 +1100 Subject: [PATCH 02/14] Update --- archinstall/lib/models/users.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/archinstall/lib/models/users.py b/archinstall/lib/models/users.py index 074a2a6b82..3b157af682 100644 --- a/archinstall/lib/models/users.py +++ b/archinstall/lib/models/users.py @@ -163,6 +163,10 @@ class User: sudo: bool groups: list[str] = field(default_factory=list) + def __str__(self) -> str: + # safety overwrite to make sure password is not leaked + return f'User({self.username=}, {self.sudo=}, {self.groups=})' + def table_data(self) -> dict[str, str | bool | list[str]]: return { 'username': self.username, @@ -197,9 +201,9 @@ def parse_arguments( raise ValueError('username must be a string') if not isinstance(groups, list): raise ValueError('groups must be a list') - if not isinstance(plaintext, str): + if plaintext and not isinstance(plaintext, str): raise ValueError('password must be a string') - if not isinstance(enc_password, str): + if enc_password and not isinstance(enc_password, str): raise ValueError('encryption password must be a string') # DEPRECATED: backwards compatibility From a06d0cd0a9524605ab196ab42aa6d66145119758 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Thu, 20 Mar 2025 21:43:18 +1100 Subject: [PATCH 03/14] Update --- archinstall/lib/installer.py | 2 +- archinstall/lib/profile/__init__.py | 9 --------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/archinstall/lib/installer.py b/archinstall/lib/installer.py index 66e2bfda4a..612b7b84d9 100644 --- a/archinstall/lib/installer.py +++ b/archinstall/lib/installer.py @@ -1607,7 +1607,7 @@ def set_user_password(self, user: User) -> bool: echo = shlex.join(['echo', f'{user.username}:{enc_password}']) sh = shlex.join(['sh', '-c', echo]) - chpasswd = "chpasswd --encrypted --crypt-method YESCRYPT" + chpasswd = 'chpasswd --encrypted --crypt-method YESCRYPT' try: SysCommand(f"arch-chroot {self.target} " + sh[:-1] + f" | {chpasswd}'") 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', -] From 98578ec5937c8e030b7b270443e83e2eafc4eaa7 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Thu, 20 Mar 2025 21:46:31 +1100 Subject: [PATCH 04/14] Update --- archinstall/lib/args.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/archinstall/lib/args.py b/archinstall/lib/args.py index 69adca4364..c91e70c4b8 100644 --- a/archinstall/lib/args.py +++ b/archinstall/lib/args.py @@ -80,7 +80,7 @@ def unsafe_json(self) -> dict[str, Any]: } if self.disk_encryption and self.disk_encryption.encryption_password: - config['encryption_password'] = self.disk_encryption.encryption_password.enc_password + config['encryption_password'] = self.disk_encryption.encryption_password.plaintext return config @@ -169,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'], - Password(enc_password=args_config.get('encryption_password', '')) + Password(plaintext=args_config.get('encryption_password', '')) ) if hostname := args_config.get('hostname', ''): From 08d7e6d2d4ebc5a42bffad24f65eeb096c4e8925 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Thu, 20 Mar 2025 21:52:34 +1100 Subject: [PATCH 05/14] Update --- archinstall/scripts/minimal.py | 7 ++++--- examples/full_automated_installation.py | 8 ++++---- examples/mac_address_installation.py | 2 +- examples/minimal_installation.py | 7 ++++--- 4 files changed, 13 insertions(+), 11 deletions(-) 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/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/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 From 3ea16499ea4310118233eb50b3da2f6ec1b14c6a Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Sun, 23 Mar 2025 17:39:29 +1100 Subject: [PATCH 06/14] Update --- .pre-commit-config.yaml | 1 + PKGBUILD | 1 + archinstall/lib/installer.py | 9 ++++----- archinstall/lib/models/users.py | 14 ++++---------- pyproject.toml | 3 ++- 5 files changed, 12 insertions(+), 16 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 69c739a8ca..ed80795e08 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -46,6 +46,7 @@ repos: - pydantic - pydantic-settings - pytest + - bcrypt - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.11.2 hooks: diff --git a/PKGBUILD b/PKGBUILD index 449395169d..5c5aa8c530 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -22,6 +22,7 @@ depends=( 'pciutils' 'procps-ng' 'python' + 'python-bcrypt' 'python-pydantic' 'python-pyparted' 'systemd' diff --git a/archinstall/lib/installer.py b/archinstall/lib/installer.py index aa3024ae97..c68c0b66a4 100644 --- a/archinstall/lib/installer.py +++ b/archinstall/lib/installer.py @@ -32,7 +32,7 @@ from .args import arch_config_handler from .exceptions import DiskError, HardwareIncompatibilityError, RequirementError, ServiceException, SysCallError -from .general import SysCommand +from .general import SysCommand, run from .hardware import SysInfo from .locale.utils import verify_keyboard_layout, verify_x11_keyboard_layout from .luks import Luks2 @@ -1614,12 +1614,11 @@ def set_user_password(self, user: User) -> bool: debug('User password is empty') return False - echo = shlex.join(['echo', f'{user.username}:{enc_password}']) - sh = shlex.join(['sh', '-c', echo]) - chpasswd = 'chpasswd --encrypted --crypt-method YESCRYPT' + input_data = f'{user.username}:{enc_password}'.encode() + cmd = ['arch-chroot', str(self.target), 'chpasswd --encrypted'] try: - SysCommand(f"arch-chroot {self.target} " + sh[:-1] + f" | {chpasswd}'") + run(cmd, input_data=input_data) return True except CalledProcessError: return False diff --git a/archinstall/lib/models/users.py b/archinstall/lib/models/users.py index 8a8bb37724..4ea70db9fa 100644 --- a/archinstall/lib/models/users.py +++ b/archinstall/lib/models/users.py @@ -1,8 +1,9 @@ -import shlex from dataclasses import dataclass, field from enum import Enum from typing import TYPE_CHECKING, NotRequired, TypedDict, override +import bcrypt + if TYPE_CHECKING: from collections.abc import Callable @@ -157,15 +158,8 @@ def hidden(self) -> str: return '*' * 8 def _encrypt(self, plaintext: str) -> str: - from ..general import SysCommand - - echo = shlex.join(['echo', plaintext]) - sh = shlex.join(['sh', '-c', echo]) - - cmd = sh[:-1] + " | mkpasswd --method=yescrypt --stdin'" - yescrypt_hash = SysCommand(cmd).decode() - - return yescrypt_hash + bcrypt_hash = bcrypt.hashpw(plaintext.encode(), bcrypt.gensalt()) + return bcrypt_hash.decode() @dataclass diff --git a/pyproject.toml b/pyproject.toml index da24eda3af..4cbb79292b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,8 @@ classifiers = [ ] dependencies = [ "pyparted @ https://github.com//dcantrell/pyparted/archive/v3.13.0.tar.gz#sha512=26819e28d73420937874f52fda03eb50ab1b136574ea9867a69d46ae4976d38c4f26a2697fa70597eed90dd78a5ea209bafcc3227a17a7a5d63cff6d107c2b11", - "pydantic==2.10.6" + "pydantic==2.10.6", + "bcrypt>=4.3.0", ] [project.urls] From 6212071e98b2f7910287bf0625d9787ed033e969 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Sun, 23 Mar 2025 17:50:03 +1100 Subject: [PATCH 07/14] Update --- archinstall/lib/installer.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/archinstall/lib/installer.py b/archinstall/lib/installer.py index c68c0b66a4..b6a734670a 100644 --- a/archinstall/lib/installer.py +++ b/archinstall/lib/installer.py @@ -1582,15 +1582,8 @@ def _create_user(self, user: User) -> None: if not handled_by_plugin: info(f'Creating user {user.username}') - cmd = f'arch-chroot {self.target} useradd' - - if user.sudo: - cmd += ' -G wheel' - - cmd += f' {user.username}' - try: - SysCommand(cmd) + SysCommand(f'arch-chroot {self.target} useradd -m -G wheel {user.username}') except SysCallError as err: raise SystemError(f"Could not create user inside installation: {err}") From 9f2e546f0ccd12462fe01e1bb05339d4e6272d26 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Sun, 23 Mar 2025 17:54:52 +1100 Subject: [PATCH 08/14] Update --- .github/workflows/python-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-build.yml b/.github/workflows/python-build.yml index a246e4aa69..48ad7c232e 100644 --- a/.github/workflows/python-build.yml +++ b/.github/workflows/python-build.yml @@ -17,7 +17,7 @@ jobs: pacman-key --init pacman --noconfirm -Sy archlinux-keyring pacman --noconfirm -Syyu - pacman --noconfirm -Sy python-pip python-pydantic python-pyparted pkgconfig gcc + pacman --noconfirm -Sy python-pip python-pydantic python-pyparted python-bcrypt pkgconfig gcc - name: Install build dependencies run: | python -m pip install --break-system-packages --upgrade pip From 8cad1bdd09d0ccb1f73b348c69ca88e337ea0a6a Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Sun, 23 Mar 2025 22:08:27 +1100 Subject: [PATCH 09/14] Update --- archinstall/lib/installer.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/archinstall/lib/installer.py b/archinstall/lib/installer.py index b6a734670a..73d03cfa94 100644 --- a/archinstall/lib/installer.py +++ b/archinstall/lib/installer.py @@ -1608,12 +1608,13 @@ def set_user_password(self, user: User) -> bool: return False input_data = f'{user.username}:{enc_password}'.encode() - cmd = ['arch-chroot', str(self.target), 'chpasswd --encrypted'] + cmd = ['arch-chroot', str(self.target), 'chpasswd', '--encrypted'] try: 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: From f5ad89c92130faa41742c77d16020522fae2a261 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Fri, 4 Apr 2025 20:25:27 +1100 Subject: [PATCH 10/14] Generate yescrypt hash --- PKGBUILD | 1 - .../default_profiles/desktops/awesome.py | 2 +- archinstall/lib/models/users.py | 45 ++++++++++++++++--- pyproject.toml | 1 - 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/PKGBUILD b/PKGBUILD index 5c5aa8c530..449395169d 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -22,7 +22,6 @@ depends=( 'pciutils' 'procps-ng' 'python' - 'python-bcrypt' 'python-pydantic' 'python-pyparted' 'systemd' diff --git a/archinstall/default_profiles/desktops/awesome.py b/archinstall/default_profiles/desktops/awesome.py index 9e47ecb0fd..e690f0c705 100644 --- a/archinstall/default_profiles/desktops/awesome.py +++ b/archinstall/default_profiles/desktops/awesome.py @@ -9,7 +9,7 @@ class AwesomeProfile(XorgProfile): def __init__(self) -> None: - super().__init__('Awesome', ProfileType.WindowMgr, description='') + super().__init__('Awesome', ProfileType.WindowMgr, description='TETESTSKJTLKJLKTJLKJ') @property @override diff --git a/archinstall/lib/models/users.py b/archinstall/lib/models/users.py index 4ea70db9fa..6baec49a54 100644 --- a/archinstall/lib/models/users.py +++ b/archinstall/lib/models/users.py @@ -1,9 +1,11 @@ +import ctypes +import ctypes.util +import os from dataclasses import dataclass, field from enum import Enum +from functools import cached_property from typing import TYPE_CHECKING, NotRequired, TypedDict, override -import bcrypt - if TYPE_CHECKING: from collections.abc import Callable @@ -124,7 +126,7 @@ def __init__( enc_password: str | None = None ): if plaintext: - enc_password = self._encrypt(plaintext) + enc_password = self._gen_yescrypt_hash(plaintext) if not plaintext and not enc_password: raise ValueError('Either plaintext or enc_password must be provided') @@ -139,7 +141,7 @@ def plaintext(self) -> str: @plaintext.setter def plaintext(self, value: str): self._plaintext = value - self.enc_password = self._encrypt(value) + self.enc_password = self._gen_yescrypt_hash(value) @override def __eq__(self, other: object) -> bool: @@ -157,9 +159,38 @@ def hidden(self) -> str: else: return '*' * 8 - def _encrypt(self, plaintext: str) -> str: - bcrypt_hash = bcrypt.hashpw(plaintext.encode(), bcrypt.gensalt()) - return bcrypt_hash.decode() + @cached_property + def _libcrypt(self): + libc = ctypes.CDLL("libcrypt.so") + + libc.crypt.argtypes = [ctypes.c_char_p, ctypes.c_char_p] + libc.crypt.restype = ctypes.c_char_p + + libc.crypt_gensalt.argtypes = [ctypes.c_char_p, ctypes.c_int, ctypes.c_void_p, ctypes.c_size_t] + libc.crypt_gensalt.restype = ctypes.c_char_p + + return libc + + def _gen_yescrypt_salt(self) -> bytes: + salt_prefix = b'$y$' + rounds = 9 + entropy = os.urandom(64) + + salt = self._libcrypt.crypt_gensalt(salt_prefix, rounds, entropy, 64) + return salt + + def _gen_yescrypt_hash(self, plaintext: str) -> str: + """ + Generation of the yescrypt hash was used from mkpasswd included + in the whois package which seems to be one of the few tools + that actually provides the functionality + https://github.com/rfc1036/whois/blob/next/mkpasswd.c + """ + enc_plaintext = plaintext.encode('UTF-8') + salt = self._gen_yescrypt_salt() + + yescrypt_hash = self._libcrypt.crypt(enc_plaintext, salt) + return yescrypt_hash.decode('UTF-8') @dataclass diff --git a/pyproject.toml b/pyproject.toml index 4cbb79292b..ab913c2ac1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,6 @@ classifiers = [ dependencies = [ "pyparted @ https://github.com//dcantrell/pyparted/archive/v3.13.0.tar.gz#sha512=26819e28d73420937874f52fda03eb50ab1b136574ea9867a69d46ae4976d38c4f26a2697fa70597eed90dd78a5ea209bafcc3227a17a7a5d63cff6d107c2b11", "pydantic==2.10.6", - "bcrypt>=4.3.0", ] [project.urls] From 3dd243497737fccbc1e286818fe04ab24f7301d2 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Fri, 4 Apr 2025 20:49:54 +1100 Subject: [PATCH 11/14] Update --- .github/workflows/python-build.yml | 2 +- .pre-commit-config.yaml | 1 - archinstall/default_profiles/desktops/awesome.py | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-build.yml b/.github/workflows/python-build.yml index 48ad7c232e..a246e4aa69 100644 --- a/.github/workflows/python-build.yml +++ b/.github/workflows/python-build.yml @@ -17,7 +17,7 @@ jobs: pacman-key --init pacman --noconfirm -Sy archlinux-keyring pacman --noconfirm -Syyu - pacman --noconfirm -Sy python-pip python-pydantic python-pyparted python-bcrypt pkgconfig gcc + pacman --noconfirm -Sy python-pip python-pydantic python-pyparted pkgconfig gcc - name: Install build dependencies run: | python -m pip install --break-system-packages --upgrade pip diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c9447218a0..c1b9e51a28 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -46,7 +46,6 @@ repos: - pydantic - pydantic-settings - pytest - - bcrypt - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.11.3 hooks: diff --git a/archinstall/default_profiles/desktops/awesome.py b/archinstall/default_profiles/desktops/awesome.py index e690f0c705..9e47ecb0fd 100644 --- a/archinstall/default_profiles/desktops/awesome.py +++ b/archinstall/default_profiles/desktops/awesome.py @@ -9,7 +9,7 @@ class AwesomeProfile(XorgProfile): def __init__(self) -> None: - super().__init__('Awesome', ProfileType.WindowMgr, description='TETESTSKJTLKJLKTJLKJ') + super().__init__('Awesome', ProfileType.WindowMgr, description='') @property @override From 57ef8ccee7f3a9ddaa50f86f93030c0b843520e3 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Mon, 7 Apr 2025 21:11:19 +1000 Subject: [PATCH 12/14] Update --- PKGBUILD | 2 ++ archinstall/lib/crypt.py | 44 +++++++++++++++++++++++++++++++++ archinstall/lib/models/users.py | 43 +++----------------------------- 3 files changed, 50 insertions(+), 39 deletions(-) create mode 100644 archinstall/lib/crypt.py diff --git a/PKGBUILD b/PKGBUILD index df730f3fc5..96f4977b87 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/crypt.py b/archinstall/lib/crypt.py new file mode 100644 index 0000000000..c39be825e7 --- /dev/null +++ b/archinstall/lib/crypt.py @@ -0,0 +1,44 @@ +import ctypes +import ctypes.util + +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 + + +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, given that the default + /etc/login.defs file from the ISO has the variable commented out 5 would be the default value + determined by the PAM in chpasswd + """ + rounds = 5 + + 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/models/users.py b/archinstall/lib/models/users.py index 6baec49a54..ca720e2a25 100644 --- a/archinstall/lib/models/users.py +++ b/archinstall/lib/models/users.py @@ -1,11 +1,9 @@ -import ctypes -import ctypes.util -import os from dataclasses import dataclass, field from enum import Enum -from functools import cached_property from typing import TYPE_CHECKING, NotRequired, TypedDict, override +from ..crypt import crypt_yescrypt + if TYPE_CHECKING: from collections.abc import Callable @@ -126,7 +124,7 @@ def __init__( enc_password: str | None = None ): if plaintext: - enc_password = self._gen_yescrypt_hash(plaintext) + enc_password = crypt_yescrypt(plaintext) if not plaintext and not enc_password: raise ValueError('Either plaintext or enc_password must be provided') @@ -141,7 +139,7 @@ def plaintext(self) -> str: @plaintext.setter def plaintext(self, value: str): self._plaintext = value - self.enc_password = self._gen_yescrypt_hash(value) + self.enc_password = crypt_yescrypt(value) @override def __eq__(self, other: object) -> bool: @@ -159,39 +157,6 @@ def hidden(self) -> str: else: return '*' * 8 - @cached_property - def _libcrypt(self): - libc = ctypes.CDLL("libcrypt.so") - - libc.crypt.argtypes = [ctypes.c_char_p, ctypes.c_char_p] - libc.crypt.restype = ctypes.c_char_p - - libc.crypt_gensalt.argtypes = [ctypes.c_char_p, ctypes.c_int, ctypes.c_void_p, ctypes.c_size_t] - libc.crypt_gensalt.restype = ctypes.c_char_p - - return libc - - def _gen_yescrypt_salt(self) -> bytes: - salt_prefix = b'$y$' - rounds = 9 - entropy = os.urandom(64) - - salt = self._libcrypt.crypt_gensalt(salt_prefix, rounds, entropy, 64) - return salt - - def _gen_yescrypt_hash(self, plaintext: str) -> str: - """ - Generation of the yescrypt hash was used from mkpasswd included - in the whois package which seems to be one of the few tools - that actually provides the functionality - https://github.com/rfc1036/whois/blob/next/mkpasswd.c - """ - enc_plaintext = plaintext.encode('UTF-8') - salt = self._gen_yescrypt_salt() - - yescrypt_hash = self._libcrypt.crypt(enc_plaintext, salt) - return yescrypt_hash.decode('UTF-8') - @dataclass class User: From db79b4d20c00bfede9ddc3428f08d0ca6862809a Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Mon, 7 Apr 2025 21:12:19 +1000 Subject: [PATCH 13/14] Update --- PKGBUILD | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PKGBUILD b/PKGBUILD index 96f4977b87..c74256f900 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -19,8 +19,8 @@ depends=( 'e2fsprogs' 'glibc' 'kbd' - libcrypt.so - libxcrypt + 'libcrypt.so' + 'libxcrypt' 'pciutils' 'procps-ng' 'python' From 04dd2d2bd99d369e5900841a28d78301abf37333 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Tue, 8 Apr 2025 15:47:20 +1000 Subject: [PATCH 14/14] Update --- archinstall/lib/crypt.py | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/archinstall/lib/crypt.py b/archinstall/lib/crypt.py index c39be825e7..0b9c1134c9 100644 --- a/archinstall/lib/crypt.py +++ b/archinstall/lib/crypt.py @@ -1,5 +1,8 @@ import ctypes import ctypes.util +from pathlib import Path + +from .output import debug libcrypt = ctypes.CDLL("libcrypt.so") @@ -9,6 +12,23 @@ 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): @@ -27,11 +47,19 @@ 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, given that the default - /etc/login.defs file from the ISO has the variable commented out 5 would be the default value - determined by the PAM in chpasswd + If no value was specified (or commented out) a default of 5 is choosen """ - rounds = 5 + 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)