Skip to content

Commit 4f1d1b4

Browse files
authored
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
1 parent bae0e29 commit 4f1d1b4

28 files changed

Lines changed: 368 additions & 186 deletions

PKGBUILD

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ depends=(
1919
'e2fsprogs'
2020
'glibc'
2121
'kbd'
22+
'libcrypt.so'
23+
'libxcrypt'
2224
'pciutils'
2325
'procps-ng'
2426
'python'

archinstall/lib/args.py

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from archinstall.lib.models.network_configuration import NetworkConfiguration
2020
from archinstall.lib.models.packages import Repository
2121
from archinstall.lib.models.profile_model import ProfileConfiguration
22-
from archinstall.lib.models.users import User
22+
from archinstall.lib.models.users import Password, User
2323
from archinstall.lib.output import error, warn
2424
from archinstall.lib.plugins import load_plugin
2525
from archinstall.lib.storage import storage
@@ -71,16 +71,16 @@ class ArchConfig:
7171
# Special fields that should be handle with care due to security implications
7272
users: list[User] = field(default_factory=list)
7373
disk_encryption: DiskEncryption | None = None
74-
root_password: str | None = None
74+
root_enc_password: Password | None = None
7575

7676
def unsafe_json(self) -> dict[str, Any]:
7777
config = {
78-
'!users': [user.json() for user in self.users],
79-
'!root-password': self.root_password,
78+
'users': [user.json() for user in self.users],
79+
'root_enc_password': self.root_enc_password.enc_password if self.root_enc_password else None,
8080
}
8181

82-
if self.disk_encryption:
83-
config['encryption_password'] = self.disk_encryption.encryption_password
82+
if self.disk_encryption and self.disk_encryption.encryption_password:
83+
config['encryption_password'] = self.disk_encryption.encryption_password.plaintext
8484

8585
return config
8686

@@ -149,10 +149,12 @@ def from_config(cls, args_config: dict[str, Any]) -> 'ArchConfig':
149149
if net_config := args_config.get('network_config', None):
150150
arch_config.network_config = NetworkConfiguration.parse_arg(net_config)
151151

152-
users = args_config.get('!users', None)
153-
superusers = args_config.get('!superusers', None)
154-
if users is not None or superusers is not None:
155-
arch_config.users = User.parse_arguments(users, superusers)
152+
# DEPRECATED: backwards copatibility
153+
if users := args_config.get('!users', None):
154+
arch_config.users = User.parse_arguments(users)
155+
156+
if users := args_config.get('users', None):
157+
arch_config.users = User.parse_arguments(users)
156158

157159
if bootloader_config := args_config.get('bootloader', None):
158160
arch_config.bootloader = Bootloader.from_arg(bootloader_config)
@@ -167,7 +169,7 @@ def from_config(cls, args_config: dict[str, Any]) -> 'ArchConfig':
167169
arch_config.disk_encryption = DiskEncryption.parse_arg(
168170
arch_config.disk_config,
169171
args_config['disk_encryption'],
170-
args_config.get('encryption_password', '')
172+
Password(plaintext=args_config.get('encryption_password', ''))
171173
)
172174

173175
if hostname := args_config.get('hostname', ''):
@@ -192,8 +194,12 @@ def from_config(cls, args_config: dict[str, Any]) -> 'ArchConfig':
192194
if services := args_config.get('services', []):
193195
arch_config.services = services
194196

197+
# DEPRECATED: backwards compatibility
195198
if root_password := args_config.get('!root-password', None):
196-
arch_config.root_password = root_password
199+
arch_config.root_enc_password = Password(plaintext=root_password)
200+
201+
if enc_password := args_config.get('root_enc_password', None):
202+
arch_config.root_enc_password = Password(enc_password=enc_password)
197203

198204
if custom_commands := args_config.get('custom_commands', []):
199205
arch_config.custom_commands = custom_commands

archinstall/lib/crypt.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import ctypes
2+
import ctypes.util
3+
from pathlib import Path
4+
5+
from .output import debug
6+
7+
libcrypt = ctypes.CDLL("libcrypt.so")
8+
9+
libcrypt.crypt.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
10+
libcrypt.crypt.restype = ctypes.c_char_p
11+
12+
libcrypt.crypt_gensalt.argtypes = [ctypes.c_char_p, ctypes.c_ulong, ctypes.c_char_p, ctypes.c_int]
13+
libcrypt.crypt_gensalt.restype = ctypes.c_char_p
14+
15+
LOGIN_DEFS = Path('/etc/login.defs')
16+
17+
18+
def _search_login_defs(key: str) -> str | None:
19+
defs = LOGIN_DEFS.read_text()
20+
for line in defs.split('\n'):
21+
line = line.strip()
22+
23+
if line.startswith('#'):
24+
continue
25+
26+
if line.startswith(key):
27+
value = line.split(' ')[1]
28+
return value
29+
30+
return None
31+
32+
33+
def crypt_gen_salt(prefix: str | bytes, rounds: int) -> bytes:
34+
if isinstance(prefix, str):
35+
prefix = prefix.encode('utf-8')
36+
37+
setting = libcrypt.crypt_gensalt(prefix, rounds, None, 0)
38+
39+
if setting is None:
40+
raise ValueError(f'crypt_gensalt() returned NULL for prefix {prefix!r} and rounds {rounds}')
41+
42+
return setting
43+
44+
45+
def crypt_yescrypt(plaintext: str) -> str:
46+
"""
47+
By default chpasswd in Arch uses PAM to to hash the password with crypt_yescrypt
48+
the PAM code https://github.com/linux-pam/linux-pam/blob/master/modules/pam_unix/support.c
49+
shows that the hashing rounds are determined from YESCRYPT_COST_FACTOR in /etc/login.defs
50+
If no value was specified (or commented out) a default of 5 is choosen
51+
"""
52+
value = _search_login_defs('YESCRYPT_COST_FACTOR')
53+
if value is not None:
54+
rounds = int(value)
55+
if rounds < 3:
56+
rounds = 3
57+
elif rounds > 11:
58+
rounds = 11
59+
else:
60+
rounds = 5
61+
62+
debug(f'Creating yescrypt hash with rounds {rounds}')
63+
64+
enc_plaintext = plaintext.encode('utf-8')
65+
salt = crypt_gen_salt('$y$', rounds)
66+
67+
crypt_hash = libcrypt.crypt(enc_plaintext, salt)
68+
69+
if crypt_hash is None:
70+
raise ValueError('crypt() returned NULL')
71+
72+
return crypt_hash.decode('utf-8')

archinstall/lib/disk/device_handler.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
_DeviceInfo,
3939
_PartitionInfo,
4040
)
41+
from ..models.users import Password
4142
from ..output import debug, error, info, log
4243
from ..utils.util import is_subpath
4344
from .utils import (
@@ -307,7 +308,7 @@ def encrypt(
307308
self,
308309
dev_path: Path,
309310
mapper_name: str | None,
310-
enc_password: str,
311+
enc_password: Password | None,
311312
lock_after_create: bool = True
312313
) -> Luks2:
313314
luks_handler = Luks2(
@@ -338,6 +339,9 @@ def format_encrypted(
338339
fs_type: FilesystemType,
339340
enc_conf: DiskEncryption
340341
) -> None:
342+
if not enc_conf.encryption_password:
343+
raise ValueError('No encryption password provided')
344+
341345
luks_handler = Luks2(
342346
dev_path,
343347
mapper_name=mapper_name,
@@ -678,7 +682,12 @@ def create_btrfs_volumes(
678682
if luks_handler is not None and luks_handler.mapper_dev is not None:
679683
luks_handler.lock()
680684

681-
def unlock_luks2_dev(self, dev_path: Path, mapper_name: str, enc_password: str) -> Luks2:
685+
def unlock_luks2_dev(
686+
self,
687+
dev_path: Path,
688+
mapper_name: str,
689+
enc_password: Password | None
690+
) -> Luks2:
682691
luks_handler = Luks2(dev_path, mapper_name=mapper_name, password=enc_password)
683692

684693
if not luks_handler.is_unlocked():

archinstall/lib/disk/encryption_menu.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
from ..menu.abstract_menu import AbstractSubMenu
1919
from ..models.device_model import Fido2Device
20+
from ..models.users import Password
2021
from ..output import FormattedOutput
2122
from ..utils.util import get_password
2223
from .fido import Fido2
@@ -122,7 +123,7 @@ def run(self) -> DiskEncryption | None:
122123
super().run()
123124

124125
enc_type: EncryptionType | None = self._item_group.find_by_key('encryption_type').value
125-
enc_password: str | None = self._item_group.find_by_key('encryption_password').value
126+
enc_password: Password | None = self._item_group.find_by_key('encryption_password').value
126127
enc_partitions = self._item_group.find_by_key('partitions').value
127128
enc_lvm_vols = self._item_group.find_by_key('lvm_volumes').value
128129

@@ -183,8 +184,7 @@ def _prev_password(self) -> str | None:
183184
enc_pwd = self._item_group.find_by_key('encryption_password').value
184185

185186
if enc_pwd:
186-
pwd_text = '*' * len(enc_pwd)
187-
return f'{_("Encryption password")}: {pwd_text}'
187+
return f'{_("Encryption password")}: {enc_pwd.hidden()}'
188188

189189
return None
190190

@@ -249,7 +249,7 @@ def select_encryption_type(disk_config: DiskLayoutConfiguration, preset: Encrypt
249249
return result.get_value()
250250

251251

252-
def select_encrypted_password() -> str | None:
252+
def select_encrypted_password() -> Password | None:
253253
header = str(_('Enter disk encryption password (leave blank for no encryption)')) + '\n'
254254
password = get_password(
255255
text=str(_('Disk encryption password')),

archinstall/lib/disk/fido.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from ..exceptions import SysCallError
1010
from ..general import SysCommand, SysCommandWorker, clear_vt100_escape_codes_from_str
11+
from ..models.users import Password
1112
from ..output import error, info
1213

1314

@@ -74,7 +75,7 @@ def fido2_enroll(
7475
cls,
7576
hsm_device: Fido2Device,
7677
dev_path: Path,
77-
password: str
78+
password: Password
7879
) -> None:
7980
worker = SysCommandWorker(f"systemd-cryptenroll --fido2-device={hsm_device.path} {dev_path}", peek_output=True)
8081
pw_inputted = False
@@ -83,7 +84,7 @@ def fido2_enroll(
8384
while worker.is_alive():
8485
if pw_inputted is False:
8586
if bytes(f"please enter current passphrase for disk {dev_path}", 'UTF-8') in worker._trace_log.lower():
86-
worker.write(bytes(password, 'UTF-8'))
87+
worker.write(bytes(password.plaintext, 'UTF-8'))
8788
pw_inputted = True
8889
elif pin_inputted is False:
8990
if bytes("please enter security token pin", 'UTF-8') in worker._trace_log.lower():

archinstall/lib/general.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -474,8 +474,3 @@ def _pid_exists(pid: int) -> bool:
474474
return any(subprocess.check_output(['ps', '--no-headers', '-o', 'pid', '-p', str(pid)]).strip())
475475
except subprocess.CalledProcessError:
476476
return False
477-
478-
479-
def secret(x: str) -> str:
480-
""" return * with len equal to to the input string """
481-
return '*' * len(x)

archinstall/lib/global_menu.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99

1010
from .args import ArchConfig
1111
from .configuration import save_config
12-
from .general import secret
1312
from .hardware import SysInfo
1413
from .interactions.general_conf import (
1514
add_number_of_parallel_downloads,
@@ -31,7 +30,7 @@
3130
from .models.mirrors import MirrorConfiguration
3231
from .models.network_configuration import NetworkConfiguration, NicType
3332
from .models.profile_model import ProfileConfiguration
34-
from .models.users import User
33+
from .models.users import Password, User
3534
from .output import FormattedOutput
3635
from .translationhandler import Language, translation_handler
3736
from .utils.util import get_password
@@ -125,7 +124,7 @@ def _get_menu_options(self) -> list[MenuItem]:
125124
text=str(_('Root password')),
126125
action=self._set_root_password,
127126
preview_action=self._prev_root_pwd,
128-
key='root_password',
127+
key='root_enc_password',
129128
),
130129
MenuItem(
131130
text=str(_('User account')),
@@ -234,8 +233,8 @@ def has_superuser() -> bool:
234233
missing = set()
235234

236235
for item in self._item_group.items:
237-
if item.key in ['root_password', 'users']:
238-
if not check('root_password') and not has_superuser():
236+
if item.key in ['root_enc_password', 'users']:
237+
if not check('root_enc_password') and not has_superuser():
239238
missing.add(
240239
str(_('Either root-password or at least 1 user with sudo privileges must be specified'))
241240
)
@@ -364,7 +363,8 @@ def _prev_hostname(self, item: MenuItem) -> str | None:
364363

365364
def _prev_root_pwd(self, item: MenuItem) -> str | None:
366365
if item.value is not None:
367-
return f'{_("Root password")}: {secret(item.value)}'
366+
password: Password = item.value
367+
return f'{_("Root password")}: {password.hidden()}'
368368
return None
369369

370370
def _prev_audio(self, item: MenuItem) -> str | None:
@@ -399,7 +399,9 @@ def _prev_disk_encryption(self, item: MenuItem) -> str | None:
399399
if enc_config:
400400
enc_type = EncryptionType.type_to_text(enc_config.encryption_type)
401401
output = str(_('Encryption type')) + f': {enc_type}\n'
402-
output += str(_('Password')) + f': {secret(enc_config.encryption_password)}\n'
402+
403+
if enc_config.encryption_password:
404+
output += str(_('Password')) + f': {enc_config.encryption_password.hidden()}\n'
403405

404406
if enc_config.partitions:
405407
output += f'Partitions: {len(enc_config.partitions)} selected\n'
@@ -501,7 +503,7 @@ def _prev_profile(self, item: MenuItem) -> str | None:
501503

502504
return None
503505

504-
def _set_root_password(self, preset: str | None = None) -> str | None:
506+
def _set_root_password(self, preset: str | None = None) -> Password | None:
505507
password = get_password(text=str(_('Root password')), allow_skip=True)
506508
return password
507509

0 commit comments

Comments
 (0)