Store password as hash instead of plaintext (#3276)

* Rework user password to be hash

* Update

* Update

* Update

* Update

* Update

* Update

* Update

* Update

* Generate yescrypt hash

* Update

* Update

* Update

* Update
This commit is contained in:
Daniel Girtler 2025-04-08 16:53:18 +10:00 committed by GitHub
parent bae0e29e18
commit 4f1d1b4739
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 369 additions and 187 deletions

View File

@ -19,6 +19,8 @@ depends=(
'e2fsprogs' 'e2fsprogs'
'glibc' 'glibc'
'kbd' 'kbd'
'libcrypt.so'
'libxcrypt'
'pciutils' 'pciutils'
'procps-ng' 'procps-ng'
'python' 'python'

View File

@ -19,7 +19,7 @@ from archinstall.lib.models.mirrors import MirrorConfiguration
from archinstall.lib.models.network_configuration import NetworkConfiguration from archinstall.lib.models.network_configuration import NetworkConfiguration
from archinstall.lib.models.packages import Repository from archinstall.lib.models.packages import Repository
from archinstall.lib.models.profile_model import ProfileConfiguration 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.output import error, warn
from archinstall.lib.plugins import load_plugin from archinstall.lib.plugins import load_plugin
from archinstall.lib.storage import storage from archinstall.lib.storage import storage
@ -71,16 +71,16 @@ class ArchConfig:
# Special fields that should be handle with care due to security implications # Special fields that should be handle with care due to security implications
users: list[User] = field(default_factory=list) users: list[User] = field(default_factory=list)
disk_encryption: DiskEncryption | None = None disk_encryption: DiskEncryption | None = None
root_password: str | None = None root_enc_password: Password | None = None
def unsafe_json(self) -> dict[str, Any]: def unsafe_json(self) -> dict[str, Any]:
config = { config = {
'!users': [user.json() for user in self.users], 'users': [user.json() for user in self.users],
'!root-password': self.root_password, 'root_enc_password': self.root_enc_password.enc_password if self.root_enc_password else None,
} }
if self.disk_encryption: if self.disk_encryption and self.disk_encryption.encryption_password:
config['encryption_password'] = self.disk_encryption.encryption_password config['encryption_password'] = self.disk_encryption.encryption_password.plaintext
return config return config
@ -149,10 +149,12 @@ class ArchConfig:
if net_config := args_config.get('network_config', None): if net_config := args_config.get('network_config', None):
arch_config.network_config = NetworkConfiguration.parse_arg(net_config) arch_config.network_config = NetworkConfiguration.parse_arg(net_config)
users = args_config.get('!users', None) # DEPRECATED: backwards copatibility
superusers = args_config.get('!superusers', None) if users := args_config.get('!users', None):
if users is not None or superusers is not None: arch_config.users = User.parse_arguments(users)
arch_config.users = User.parse_arguments(users, superusers)
if users := args_config.get('users', None):
arch_config.users = User.parse_arguments(users)
if bootloader_config := args_config.get('bootloader', None): if bootloader_config := args_config.get('bootloader', None):
arch_config.bootloader = Bootloader.from_arg(bootloader_config) arch_config.bootloader = Bootloader.from_arg(bootloader_config)
@ -167,7 +169,7 @@ class ArchConfig:
arch_config.disk_encryption = DiskEncryption.parse_arg( arch_config.disk_encryption = DiskEncryption.parse_arg(
arch_config.disk_config, arch_config.disk_config,
args_config['disk_encryption'], args_config['disk_encryption'],
args_config.get('encryption_password', '') Password(plaintext=args_config.get('encryption_password', ''))
) )
if hostname := args_config.get('hostname', ''): if hostname := args_config.get('hostname', ''):
@ -192,8 +194,12 @@ class ArchConfig:
if services := args_config.get('services', []): if services := args_config.get('services', []):
arch_config.services = services arch_config.services = services
# DEPRECATED: backwards compatibility
if root_password := args_config.get('!root-password', None): 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', []): if custom_commands := args_config.get('custom_commands', []):
arch_config.custom_commands = custom_commands arch_config.custom_commands = custom_commands

72
archinstall/lib/crypt.py Normal file
View File

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

View File

@ -38,6 +38,7 @@ from ..models.device_model import (
_DeviceInfo, _DeviceInfo,
_PartitionInfo, _PartitionInfo,
) )
from ..models.users import Password
from ..output import debug, error, info, log from ..output import debug, error, info, log
from ..utils.util import is_subpath from ..utils.util import is_subpath
from .utils import ( from .utils import (
@ -307,7 +308,7 @@ class DeviceHandler:
self, self,
dev_path: Path, dev_path: Path,
mapper_name: str | None, mapper_name: str | None,
enc_password: str, enc_password: Password | None,
lock_after_create: bool = True lock_after_create: bool = True
) -> Luks2: ) -> Luks2:
luks_handler = Luks2( luks_handler = Luks2(
@ -338,6 +339,9 @@ class DeviceHandler:
fs_type: FilesystemType, fs_type: FilesystemType,
enc_conf: DiskEncryption enc_conf: DiskEncryption
) -> None: ) -> None:
if not enc_conf.encryption_password:
raise ValueError('No encryption password provided')
luks_handler = Luks2( luks_handler = Luks2(
dev_path, dev_path,
mapper_name=mapper_name, mapper_name=mapper_name,
@ -678,7 +682,12 @@ class DeviceHandler:
if luks_handler is not None and luks_handler.mapper_dev is not None: if luks_handler is not None and luks_handler.mapper_dev is not None:
luks_handler.lock() 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) luks_handler = Luks2(dev_path, mapper_name=mapper_name, password=enc_password)
if not luks_handler.is_unlocked(): if not luks_handler.is_unlocked():

View File

@ -17,6 +17,7 @@ from archinstall.tui.types import Alignment, FrameProperties, ResultType
from ..menu.abstract_menu import AbstractSubMenu from ..menu.abstract_menu import AbstractSubMenu
from ..models.device_model import Fido2Device from ..models.device_model import Fido2Device
from ..models.users import Password
from ..output import FormattedOutput from ..output import FormattedOutput
from ..utils.util import get_password from ..utils.util import get_password
from .fido import Fido2 from .fido import Fido2
@ -122,7 +123,7 @@ class DiskEncryptionMenu(AbstractSubMenu):
super().run() super().run()
enc_type: EncryptionType | None = self._item_group.find_by_key('encryption_type').value 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_partitions = self._item_group.find_by_key('partitions').value
enc_lvm_vols = self._item_group.find_by_key('lvm_volumes').value enc_lvm_vols = self._item_group.find_by_key('lvm_volumes').value
@ -183,8 +184,7 @@ class DiskEncryptionMenu(AbstractSubMenu):
enc_pwd = self._item_group.find_by_key('encryption_password').value enc_pwd = self._item_group.find_by_key('encryption_password').value
if enc_pwd: if enc_pwd:
pwd_text = '*' * len(enc_pwd) return f'{_("Encryption password")}: {enc_pwd.hidden()}'
return f'{_("Encryption password")}: {pwd_text}'
return None return None
@ -249,7 +249,7 @@ def select_encryption_type(disk_config: DiskLayoutConfiguration, preset: Encrypt
return result.get_value() 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' header = str(_('Enter disk encryption password (leave blank for no encryption)')) + '\n'
password = get_password( password = get_password(
text=str(_('Disk encryption password')), text=str(_('Disk encryption password')),

View File

@ -8,6 +8,7 @@ from archinstall.lib.models.device_model import Fido2Device
from ..exceptions import SysCallError from ..exceptions import SysCallError
from ..general import SysCommand, SysCommandWorker, clear_vt100_escape_codes_from_str from ..general import SysCommand, SysCommandWorker, clear_vt100_escape_codes_from_str
from ..models.users import Password
from ..output import error, info from ..output import error, info
@ -74,7 +75,7 @@ class Fido2:
cls, cls,
hsm_device: Fido2Device, hsm_device: Fido2Device,
dev_path: Path, dev_path: Path,
password: str password: Password
) -> None: ) -> None:
worker = SysCommandWorker(f"systemd-cryptenroll --fido2-device={hsm_device.path} {dev_path}", peek_output=True) worker = SysCommandWorker(f"systemd-cryptenroll --fido2-device={hsm_device.path} {dev_path}", peek_output=True)
pw_inputted = False pw_inputted = False
@ -83,7 +84,7 @@ class Fido2:
while worker.is_alive(): while worker.is_alive():
if pw_inputted is False: if pw_inputted is False:
if bytes(f"please enter current passphrase for disk {dev_path}", 'UTF-8') in worker._trace_log.lower(): 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 pw_inputted = True
elif pin_inputted is False: elif pin_inputted is False:
if bytes("please enter security token pin", 'UTF-8') in worker._trace_log.lower(): if bytes("please enter security token pin", 'UTF-8') in worker._trace_log.lower():

View File

@ -474,8 +474,3 @@ def _pid_exists(pid: int) -> bool:
return any(subprocess.check_output(['ps', '--no-headers', '-o', 'pid', '-p', str(pid)]).strip()) return any(subprocess.check_output(['ps', '--no-headers', '-o', 'pid', '-p', str(pid)]).strip())
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
return False return False
def secret(x: str) -> str:
""" return * with len equal to to the input string """
return '*' * len(x)

View File

@ -9,7 +9,6 @@ from archinstall.tui.menu_item import MenuItem, MenuItemGroup
from .args import ArchConfig from .args import ArchConfig
from .configuration import save_config from .configuration import save_config
from .general import secret
from .hardware import SysInfo from .hardware import SysInfo
from .interactions.general_conf import ( from .interactions.general_conf import (
add_number_of_parallel_downloads, add_number_of_parallel_downloads,
@ -31,7 +30,7 @@ from .models.locale import LocaleConfiguration
from .models.mirrors import MirrorConfiguration from .models.mirrors import MirrorConfiguration
from .models.network_configuration import NetworkConfiguration, NicType from .models.network_configuration import NetworkConfiguration, NicType
from .models.profile_model import ProfileConfiguration from .models.profile_model import ProfileConfiguration
from .models.users import User from .models.users import Password, User
from .output import FormattedOutput from .output import FormattedOutput
from .translationhandler import Language, translation_handler from .translationhandler import Language, translation_handler
from .utils.util import get_password from .utils.util import get_password
@ -125,7 +124,7 @@ class GlobalMenu(AbstractMenu):
text=str(_('Root password')), text=str(_('Root password')),
action=self._set_root_password, action=self._set_root_password,
preview_action=self._prev_root_pwd, preview_action=self._prev_root_pwd,
key='root_password', key='root_enc_password',
), ),
MenuItem( MenuItem(
text=str(_('User account')), text=str(_('User account')),
@ -234,8 +233,8 @@ class GlobalMenu(AbstractMenu):
missing = set() missing = set()
for item in self._item_group.items: for item in self._item_group.items:
if item.key in ['root_password', 'users']: if item.key in ['root_enc_password', 'users']:
if not check('root_password') and not has_superuser(): if not check('root_enc_password') and not has_superuser():
missing.add( missing.add(
str(_('Either root-password or at least 1 user with sudo privileges must be specified')) str(_('Either root-password or at least 1 user with sudo privileges must be specified'))
) )
@ -364,7 +363,8 @@ class GlobalMenu(AbstractMenu):
def _prev_root_pwd(self, item: MenuItem) -> str | None: def _prev_root_pwd(self, item: MenuItem) -> str | None:
if item.value is not 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 return None
def _prev_audio(self, item: MenuItem) -> str | None: def _prev_audio(self, item: MenuItem) -> str | None:
@ -399,7 +399,9 @@ class GlobalMenu(AbstractMenu):
if enc_config: if enc_config:
enc_type = EncryptionType.type_to_text(enc_config.encryption_type) enc_type = EncryptionType.type_to_text(enc_config.encryption_type)
output = str(_('Encryption type')) + f': {enc_type}\n' 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: if enc_config.partitions:
output += f'Partitions: {len(enc_config.partitions)} selected\n' output += f'Partitions: {len(enc_config.partitions)} selected\n'
@ -501,7 +503,7 @@ class GlobalMenu(AbstractMenu):
return 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) password = get_password(text=str(_('Root password')), allow_skip=True)
return password return password

View File

@ -82,7 +82,10 @@ class Installer:
self.init_time = time.strftime('%Y-%m-%d_%H-%M-%S') self.init_time = time.strftime('%Y-%m-%d_%H-%M-%S')
self.milliseconds = int(str(time.time()).split('.')[1]) 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: for kernel in self.kernels:
self._base_packages.append(kernel) self._base_packages.append(kernel)
@ -415,11 +418,12 @@ class Installer:
if part_mod.is_root() and not gen_enc_file: if part_mod.is_root() and not gen_enc_file:
if self._disk_encryption.hsm_device: if self._disk_encryption.hsm_device:
Fido2.fido2_enroll( if self._disk_encryption.encryption_password:
self._disk_encryption.hsm_device, Fido2.fido2_enroll(
part_mod.safe_dev_path, self._disk_encryption.hsm_device,
self._disk_encryption.encryption_password part_mod.safe_dev_path,
) self._disk_encryption.encryption_password
)
def _generate_key_file_lvm_volumes(self) -> None: def _generate_key_file_lvm_volumes(self) -> None:
for vol in self._disk_encryption.lvm_volumes: for vol in self._disk_encryption.lvm_volumes:
@ -437,16 +441,17 @@ class Installer:
if vol.is_root() and not gen_enc_file: if vol.is_root() and not gen_enc_file:
if self._disk_encryption.hsm_device: if self._disk_encryption.hsm_device:
Fido2.fido2_enroll( if self._disk_encryption.encryption_password:
self._disk_encryption.hsm_device, Fido2.fido2_enroll(
vol.safe_dev_path, self._disk_encryption.hsm_device,
self._disk_encryption.encryption_password vol.safe_dev_path,
) self._disk_encryption.encryption_password
)
def sync_log_to_install_medium(self) -> bool: def sync_log_to_install_medium(self) -> bool:
# Copy over the install log (if there is one) to the install medium if # 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. # 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): if filename := storage.get('LOG_FILE', None):
absolute_logfile = os.path.join(storage.get('LOG_PATH', './'), filename) absolute_logfile = os.path.join(storage.get('LOG_PATH', './'), filename)
@ -480,7 +485,7 @@ class Installer:
self._kernel_params.append(f'resume_offset={resume_offset}') self._kernel_params.append(f'resume_offset={resume_offset}')
def post_install_check(self, *args: str, **kwargs: str) -> list[str]: 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( def set_mirrors(
self, self,
@ -690,7 +695,7 @@ class Installer:
if enable_services: if enable_services:
# If we haven't installed the base yet (function called pre-maturely) # 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') self._base_packages.append('iwd')
# This function will be called after minimal_installation() # This function will be called after minimal_installation()
@ -719,7 +724,7 @@ class Installer:
if enable_services: if enable_services:
# If we haven't installed the base yet (function called pre-maturely) # 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: def post_install_enable_networkd_resolved(*args: str, **kwargs: str) -> None:
self.enable_service(['systemd-networkd', 'systemd-resolved']) self.enable_service(['systemd-networkd', 'systemd-resolved'])
@ -847,7 +852,7 @@ class Installer:
pacman_conf.apply() pacman_conf.apply()
self.pacman.strap(self._base_packages) self.pacman.strap(self._base_packages)
self.helper_flags['base-strapped'] = True self._helper_flags['base-strapped'] = True
pacman_conf.persist() pacman_conf.persist()
@ -878,7 +883,7 @@ class Installer:
if mkinitcpio and not self.mkinitcpio(['-P']): if mkinitcpio and not self.mkinitcpio(['-P']):
error('Error generating initramfs (continuing anyway)') error('Error generating initramfs (continuing anyway)')
self.helper_flags['base'] = True self._helper_flags['base'] = True
# Run registered post-install hooks # Run registered post-install hooks
for function in self.post_base_install: for function in self.post_base_install:
@ -1144,7 +1149,7 @@ class Installer:
loader_conf.write_text('\n'.join(loader_data) + '\n') loader_conf.write_text('\n'.join(loader_data) + '\n')
self.helper_flags['bootloader'] = 'systemd' self._helper_flags['bootloader'] = 'systemd'
def _add_grub_bootloader( def _add_grub_bootloader(
self, self,
@ -1229,7 +1234,7 @@ class Installer:
except SysCallError as err: except SysCallError as err:
raise DiskError(f"Could not configure GRUB: {err}") raise DiskError(f"Could not configure GRUB: {err}")
self.helper_flags['bootloader'] = "grub" self._helper_flags['bootloader'] = "grub"
def _add_limine_bootloader( def _add_limine_bootloader(
self, self,
@ -1389,7 +1394,7 @@ class Installer:
config_path.write_text(config_contents) config_path.write_text(config_contents)
self.helper_flags['bootloader'] = "limine" self._helper_flags['bootloader'] = "limine"
def _add_efistub_bootloader( def _add_efistub_bootloader(
self, self,
@ -1439,7 +1444,7 @@ class Installer:
cmd = [arg.format(kernel=kernel) for arg in cmd_template] cmd = [arg.format(kernel=kernel) for arg in cmd_template]
SysCommand(cmd) SysCommand(cmd)
self.helper_flags['bootloader'] = "efistub" self._helper_flags['bootloader'] = "efistub"
def _config_uki( def _config_uki(
self, self,
@ -1573,12 +1578,9 @@ class Installer:
users = [users] users = [users]
for user in users: for user in users:
self.user_create(user.username, user.password, user.groups, user.sudo) self._create_user(user)
def user_create(self, user: str, password: str | None = None, groups: list[str] | None = None, sudo: bool = False) -> None:
if groups is None:
groups = []
def _create_user(self, user: User) -> None:
# This plugin hook allows for the plugin to handle the creation of the user. # This plugin hook allows for the plugin to handle the creation of the user.
# Password and Group management is still handled by user_create() # Password and Group management is still handled by user_create()
handled_by_plugin = False handled_by_plugin = False
@ -1588,14 +1590,14 @@ class Installer:
handled_by_plugin = result handled_by_plugin = result
if not handled_by_plugin: if not handled_by_plugin:
info(f'Creating user {user}') info(f'Creating user {user.username}')
cmd = f'arch-chroot {self.target} useradd -m' cmd = f'arch-chroot {self.target} useradd -m'
if sudo: if user.sudo:
cmd += ' -G wheel' cmd += ' -G wheel'
cmd += f' {user}' cmd += f' {user.username}'
try: try:
SysCommand(cmd) SysCommand(cmd)
@ -1607,29 +1609,29 @@ class Installer:
if result := plugin.on_user_created(self, user): if result := plugin.on_user_created(self, user):
handled_by_plugin = result handled_by_plugin = result
if password: if user.password:
self.user_set_pw(user, password) self.set_user_password(user)
if groups: for group in user.groups:
for group in groups: SysCommand(f'arch-chroot {self.target} gpasswd -a {user.username} {group}')
SysCommand(f'arch-chroot {self.target} gpasswd -a {user} {group}')
if sudo and self.enable_sudo(user): def set_user_password(self, user: User) -> bool:
self.helper_flags['user'] = True info(f'Setting password for {user.username}')
def user_set_pw(self, user: str, password: str) -> bool: enc_password = user.password.enc_password if user.password else None
info(f'Setting password for {user}')
if user == 'root': if not enc_password:
# This means the root account isn't locked/disabled with * in /etc/passwd debug('User password is empty')
self.helper_flags['user'] = True 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: try:
run(cmd, input_data=f'{user}:{password}'.encode()) run(cmd, input_data=input_data)
return True return True
except CalledProcessError: except CalledProcessError as err:
debug(f'Error setting user password: {err}')
return False return False
def user_set_shell(self, user: str, shell: str) -> bool: def user_set_shell(self, user: str, shell: str) -> bool:

View File

@ -7,7 +7,6 @@ from archinstall.tui.curses_menu import EditMenu, SelectMenu
from archinstall.tui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.menu_item import MenuItem, MenuItemGroup
from archinstall.tui.types import Alignment, Orientation, ResultType from archinstall.tui.types import Alignment, Orientation, ResultType
from ..general import secret
from ..menu.list_manager import ListManager from ..menu.list_manager import ListManager
from ..models.users import User from ..models.users import User
from ..utils.util import get_password from ..utils.util import get_password
@ -91,7 +90,7 @@ class UserList(ListManager):
if not password: if not password:
return None 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) header += str(_('Should "{}" be a superuser (sudo)?\n')).format(username)
group = MenuItemGroup.yes_no() group = MenuItemGroup.yes_no()

View File

@ -9,6 +9,7 @@ from archinstall.lib.disk.utils import get_lsblk_info
from .exceptions import DiskError, SysCallError from .exceptions import DiskError, SysCallError
from .general import SysCommand, SysCommandWorker, generate_password, run from .general import SysCommand, SysCommandWorker, generate_password, run
from .models.users import Password
from .output import debug, info from .output import debug, info
@ -16,7 +17,7 @@ from .output import debug, info
class Luks2: class Luks2:
luks_dev_path: Path luks_dev_path: Path
mapper_name: str | None = None mapper_name: str | None = None
password: str | None = None password: Password | None = None
key_file: Path | None = None key_file: Path | None = None
auto_unmount: bool = False auto_unmount: bool = False
@ -57,7 +58,7 @@ class Luks2:
if isinstance(self.password, bytes): if isinstance(self.password, bytes):
return self.password return self.password
else: else:
return bytes(self.password, 'UTF-8') return bytes(self.password.plaintext, 'UTF-8')
def _get_passphrase_args( def _get_passphrase_args(
self, self,

View File

@ -12,6 +12,7 @@ from parted import Disk, Geometry, Partition
from pydantic import BaseModel, Field, ValidationInfo, field_serializer, field_validator from pydantic import BaseModel, Field, ValidationInfo, field_serializer, field_validator
from ..hardware import SysInfo from ..hardware import SysInfo
from ..models.users import Password
from ..output import debug from ..output import debug
if TYPE_CHECKING: if TYPE_CHECKING:
@ -1422,7 +1423,7 @@ class _DiskEncryptionSerialization(TypedDict):
@dataclass @dataclass
class DiskEncryption: class DiskEncryption:
encryption_type: EncryptionType = EncryptionType.NoEncryption encryption_type: EncryptionType = EncryptionType.NoEncryption
encryption_password: str = '' encryption_password: Password | None = None
partitions: list[PartitionModification] = field(default_factory=list) partitions: list[PartitionModification] = field(default_factory=list)
lvm_volumes: list[LvmVolume] = field(default_factory=list) lvm_volumes: list[LvmVolume] = field(default_factory=list)
hsm_device: Fido2Device | None = None hsm_device: Fido2Device | None = None
@ -1472,12 +1473,12 @@ class DiskEncryption:
cls, cls,
disk_config: DiskLayoutConfiguration, disk_config: DiskLayoutConfiguration,
disk_encryption: _DiskEncryptionSerialization, disk_encryption: _DiskEncryptionSerialization,
password: str = '' password: Password | None = None
) -> 'DiskEncryption | None': ) -> 'DiskEncryption | None':
if not cls.validate_enc(disk_config): if not cls.validate_enc(disk_config):
return None return None
if len(password) < 1: if not password:
return None return None
enc_partitions = [] enc_partitions = []

View File

@ -1,6 +1,8 @@
from dataclasses import dataclass from dataclasses import dataclass, field
from enum import Enum 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: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
@ -103,72 +105,117 @@ class PasswordStrength(Enum):
return PasswordStrength.VERY_WEAK 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
}
)
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 plaintext(self) -> str:
return self._plaintext
@plaintext.setter
def plaintext(self, value: str):
self._plaintext = value
self.enc_password = crypt_yescrypt(value)
@override
def __eq__(self, other: object) -> bool:
if not isinstance(other, Password):
return NotImplemented
if self._plaintext and other._plaintext:
return self._plaintext == other._plaintext
return self.enc_password == other.enc_password
def hidden(self) -> str:
if self._plaintext:
return '*' * len(self._plaintext)
else:
return '*' * 8
@dataclass @dataclass
class User: class User:
username: str username: str
password: str password: Password
sudo: bool sudo: bool
groups: list[str] = field(default_factory=list)
@property @override
def groups(self) -> list[str]: def __str__(self) -> str:
# this property should be transferred into a class attr instead # safety overwrite to make sure password is not leaked
# if it's every going to be used 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: def json(self) -> _UserSerialization:
return { return {
'username': self.username, 'username': self.username,
'!password': self.password, 'enc_password': self.password.enc_password,
'sudo': self.sudo 'sudo': self.sudo,
'groups': self.groups
} }
@classmethod
def _parse(cls, config_users: list[_UserSerialization]) -> list['User']:
users = []
for entry in config_users:
username = entry.get('username', None)
password = entry.get('!password', '')
sudo = entry.get('sudo', False)
if username is None:
continue
user = User(username, password, sudo)
users.append(user)
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']
if password:
return [User(username, password, sudo)]
return []
@classmethod @classmethod
def parse_arguments( def parse_arguments(
cls, cls,
config_users: list[_UserSerialization] | dict[str, dict[str, str]], args: list[_UserSerialization]
config_superusers: dict[str, dict[str, str]] | None
) -> list['User']: ) -> list['User']:
users = [] users: list[User] = []
# backwards compatibility for entry in args:
if isinstance(config_users, dict): username = entry.get('username')
users += cls._parse_backwards_compatible(config_users, False) password: Password | None = None
else: groups = entry.get('groups', [])
users += cls._parse(config_users) plaintext = entry.get('!password')
enc_password = entry.get('enc_password')
# backwards compatibility # DEPRECATED: backwards compatibility
if isinstance(config_superusers, dict): if plaintext:
users += cls._parse_backwards_compatible(config_superusers, True) password = Password(plaintext=plaintext)
elif enc_password:
password = Password(enc_password=enc_password)
if username is None or password is None:
continue
user = User(
username=username,
password=password,
sudo=entry.get('sudo', False) is True,
groups=groups
)
users.append(user)
return users return users

View File

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

View File

@ -4,7 +4,7 @@ from typing import TYPE_CHECKING
from archinstall.tui.curses_menu import EditMenu from archinstall.tui.curses_menu import EditMenu
from archinstall.tui.types import Alignment from archinstall.tui.types import Alignment
from ..general import secret from ..models.users import Password
from ..output import FormattedOutput from ..output import FormattedOutput
if TYPE_CHECKING: if TYPE_CHECKING:
@ -20,7 +20,7 @@ def get_password(
header: str | None = None, header: str | None = None,
allow_skip: bool = False, allow_skip: bool = False,
preset: str | None = None preset: str | None = None
) -> str | None: ) -> Password | None:
failure: str | None = None failure: str | None = None
while True: while True:
@ -42,13 +42,12 @@ def get_password(
if allow_skip and not result.has_item(): if allow_skip and not result.has_item():
return None return None
password = result.text() password = Password(plaintext=result.text())
hidden = secret(password)
if header is not None: if header is not None:
confirmation_header = f'{header}{_("Password")}: {hidden}\n' confirmation_header = f'{header}{_("Password")}: {password.hidden()}\n'
else: else:
confirmation_header = f'{_("Password")}: {hidden}\n' confirmation_header = f'{_("Password")}: {password.hidden()}\n'
result = EditMenu( result = EditMenu(
str(_('Confirm password')), str(_('Confirm password')),
@ -58,7 +57,7 @@ def get_password(
hide_input=True hide_input=True
).input() ).input()
if password == result.text(): if password._plaintext == result.text():
return password return password
failure = str(_('The confirmation password did not match, please try again')) failure = str(_('The confirmation password did not match, please try again'))

View File

@ -15,6 +15,7 @@ from archinstall.lib.models.device_model import (
EncryptionType, EncryptionType,
) )
from archinstall.lib.models.network_configuration import NetworkConfiguration 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.output import debug, error, info
from archinstall.lib.profile.profiles_handler import profile_handler from archinstall.lib.profile.profiles_handler import profile_handler
from archinstall.tui import Tui from archinstall.tui import Tui
@ -128,8 +129,9 @@ def perform_installation(mountpoint: Path) -> None:
if accessibility_tools_in_use(): if accessibility_tools_in_use():
installation.enable_espeakup() installation.enable_espeakup()
if (root_pw := config.root_password) and len(root_pw): if root_pw := config.root_enc_password:
installation.user_set_pw('root', root_pw) root_user = User('root', root_pw, False)
installation.set_user_password(root_user)
if (profile_config := config.profile_config) and profile_config.profile: if (profile_config := config.profile_config) and profile_config.profile:
profile_config.profile.post_install(installation) profile_config.profile.post_install(installation)

View File

@ -7,14 +7,15 @@ from archinstall.lib.disk.disk_menu import DiskLayoutConfigurationMenu
from archinstall.lib.disk.encryption_menu import DiskEncryptionMenu from archinstall.lib.disk.encryption_menu import DiskEncryptionMenu
from archinstall.lib.disk.filesystem import FilesystemHandler from archinstall.lib.disk.filesystem import FilesystemHandler
from archinstall.lib.installer import Installer 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 ( from archinstall.lib.models.device_model import (
DiskLayoutConfiguration, DiskLayoutConfiguration,
) )
from archinstall.lib.models.network_configuration import NetworkConfiguration from archinstall.lib.models.network_configuration import NetworkConfiguration
from archinstall.lib.models.profile_model import ProfileConfiguration 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.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 from archinstall.tui import Tui
@ -53,7 +54,7 @@ def perform_installation(mountpoint: Path) -> None:
profile_config = ProfileConfiguration(MinimalProfile()) profile_config = ProfileConfiguration(MinimalProfile())
profile_handler.install_profile_config(installation, profile_config) profile_handler.install_profile_config(installation, profile_config)
user = User('devel', 'devel', False) user = User('devel', Password(plaintext='devel'), False)
installation.create_users(user) installation.create_users(user)
# Once this is done, we output some useful information to the user # Once this is done, we output some useful information to the user

View File

@ -247,7 +247,7 @@ Below is an example of how to set the root password and below that are descripti
.. code-block:: json .. code-block:: json
{ {
"!root-password" : "SecretSanta2022" "root_enc_password" : "SecretSanta2022"
} }
.. list-table:: ``--creds`` options .. 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`` - ``str``
- Password to encrypt disk, not encrypted if password not provided - Password to encrypt disk, not encrypted if password not provided
- No - No
* - ``!root-password`` * - ``root_enc_password``
- ``str`` - ``str``
- The root account password - The root account password
- No - No
* - ``!users`` * - ``users``
- .. code-block:: json - .. code-block:: json
{ {
"username": "<USERNAME>", "username": "<USERNAME>",
"!password": "<PASSWORD>", "enc_password": "<PASSWORD_HASH>",
"sudo": false "sudo": false
} }
- List of regular user credentials, see configuration for reference - 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:: .. 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:: .. 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 .. _scripts: https://github.com/archlinux/archinstall/tree/master/archinstall/scripts
.. _Guided Installer: https://github.com/archlinux/archinstall/blob/master/archinstall/scripts/guided.py .. _Guided Installer: https://github.com/archlinux/archinstall/blob/master/archinstall/scripts/guided.py

View File

@ -1,9 +1,11 @@
{ {
"!users": [ "users": [
{ {
"sudo": true, "sudo": true,
"username": "archinstall" "username": "archinstall",
"enc_password": "password_hash"
} }
], ],
"encryption_password": "..." "root_enc_password": "password_hash"
} }

View File

@ -19,8 +19,8 @@ from archinstall.lib.models.device_model import (
Unit, Unit,
) )
from archinstall.lib.models.profile_model import ProfileConfiguration 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 import profile_handler from archinstall.lib.profile.profiles_handler import profile_handler
# we're creating a new ext4 filesystem installation # we're creating a new ext4 filesystem installation
fs_type = FilesystemType('ext4') fs_type = FilesystemType('ext4')
@ -81,7 +81,7 @@ disk_config = DiskLayoutConfiguration(
# disk encryption configuration (Optional) # disk encryption configuration (Optional)
disk_encryption = DiskEncryption( disk_encryption = DiskEncryption(
encryption_password="enc_password", encryption_password=Password(plaintext="enc_password"),
encryption_type=EncryptionType.Luks, encryption_type=EncryptionType.Luks,
partitions=[home_partition], partitions=[home_partition],
hsm_device=None hsm_device=None
@ -111,5 +111,5 @@ with Installer(
profile_config = ProfileConfiguration(MinimalProfile()) profile_config = ProfileConfiguration(MinimalProfile())
profile_handler.install_profile_config(installation, profile_config) profile_handler.install_profile_config(installation, profile_config)
user = User('archinstall', 'password', True) user = User('archinstall', Password(plaintext='password'), True)
installation.create_users(user) installation.create_users(user)

View File

@ -15,6 +15,7 @@ from archinstall.lib.models.device_model import (
EncryptionType, EncryptionType,
) )
from archinstall.lib.models.network_configuration import NetworkConfiguration 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.lib.profile.profiles_handler import profile_handler
from archinstall.tui import Tui from archinstall.tui import Tui
@ -127,8 +128,9 @@ def perform_installation(mountpoint: Path) -> None:
if accessibility_tools_in_use(): if accessibility_tools_in_use():
installation.enable_espeakup() installation.enable_espeakup()
if (root_pw := config.root_password) and len(root_pw): if root_pw := config.root_enc_password:
installation.user_set_pw('root', root_pw) root_user = User('root', root_pw, False)
installation.set_user_password(root_user)
if (profile_config := config.profile_config) and profile_config.profile: if (profile_config := config.profile_config) and profile_config.profile:
profile_config.profile.post_install(installation) profile_config.profile.post_install(installation)

View File

@ -1,7 +1,7 @@
import time import time
from archinstall.lib.output import info 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.lib.storage import storage
from archinstall.tui import Tui from archinstall.tui import Tui

View File

@ -7,12 +7,13 @@ from archinstall.lib.disk.disk_menu import DiskLayoutConfigurationMenu
from archinstall.lib.disk.encryption_menu import DiskEncryptionMenu from archinstall.lib.disk.encryption_menu import DiskEncryptionMenu
from archinstall.lib.disk.filesystem import FilesystemHandler from archinstall.lib.disk.filesystem import FilesystemHandler
from archinstall.lib.installer import Installer 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.device_model import DiskLayoutConfiguration
from archinstall.lib.models.network_configuration import NetworkConfiguration from archinstall.lib.models.network_configuration import NetworkConfiguration
from archinstall.lib.models.profile_model import ProfileConfiguration 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.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 from archinstall.tui import Tui
@ -52,7 +53,7 @@ def perform_installation(mountpoint: Path) -> None:
profile_config = ProfileConfiguration(MinimalProfile()) profile_config = ProfileConfiguration(MinimalProfile())
profile_handler.install_profile_config(installation, profile_config) profile_handler.install_profile_config(installation, profile_config)
user = User('devel', 'devel', False) user = User('devel', Password(plaintext='devel'), False)
installation.create_users(user) installation.create_users(user)
# Once this is done, we output some useful information to the user # Once this is done, we output some useful information to the user

View File

@ -14,8 +14,13 @@ def creds_fixture() -> Path:
@pytest.fixture(scope='session') @pytest.fixture(scope='session')
def mirror_backwards_config() -> Path: def deprecated_creds_config() -> Path:
return Path(__file__).parent / 'data' / 'test_config_mirror_backwards.json' 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') @pytest.fixture(scope='session')

View File

@ -1,10 +1,11 @@
{ {
"!root-password": "super_pwd", "root_enc_password": "password_hash",
"!users": [ "users": [
{ {
"!password": "user_pwd", "enc_password": "password_hash",
"sudo": true, "sudo": true,
"username": "user_name" "username": "user_name",
"groups": ["wheel"]
} }
] ]
} }

View File

@ -0,0 +1,11 @@
{
"!root-password": "rootPwd",
"!users": [
{
"!password": "userPwd",
"sudo": true,
"username": "user_name",
"groups": ["wheel"]
}
]
}

View File

@ -13,7 +13,7 @@ from archinstall.lib.models.mirrors import CustomRepository, CustomServer, Mirro
from archinstall.lib.models.network_configuration import NetworkConfiguration, Nic, NicType from archinstall.lib.models.network_configuration import NetworkConfiguration, Nic, NicType
from archinstall.lib.models.packages import Repository from archinstall.lib.models.packages import Repository
from archinstall.lib.models.profile_model import ProfileConfiguration 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.profile.profiles_handler import profile_handler
from archinstall.lib.translationhandler import translation_handler from archinstall.lib.translationhandler import translation_handler
@ -189,22 +189,29 @@ def test_config_file_parsing(
parallel_downloads=66, parallel_downloads=66,
swap=False, swap=False,
timezone='UTC', 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, disk_encryption=None,
services=['service_1', 'service_2'], services=['service_1', 'service_2'],
root_password='super_pwd', root_enc_password=Password(enc_password='password_hash'),
custom_commands=["echo 'Hello, World!'"] custom_commands=["echo 'Hello, World!'"]
) )
def test_mirror_backwards_config_file_parsing( def test_deprecated_mirror_config_parsing(
monkeypatch: MonkeyPatch, monkeypatch: MonkeyPatch,
mirror_backwards_config: Path, deprecated_mirror_config: Path,
) -> None: ) -> None:
monkeypatch.setattr('sys.argv', [ monkeypatch.setattr('sys.argv', [
'archinstall', 'archinstall',
'--config', '--config',
str(mirror_backwards_config), str(deprecated_mirror_config),
]) ])
handler = ArchConfigHandler() 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']
)
]