Skip to content

Commit fae210d

Browse files
authored
Add optional file encryption for user credentials configuration (#3391)
* Optional encryption of user credentials configuration file * Update README * Update * Update
1 parent b892380 commit fae210d

13 files changed

Lines changed: 253 additions & 21 deletions

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ repos:
4646
- pydantic
4747
- pydantic-settings
4848
- pytest
49+
- cryptography
4950
- repo: https://github.com/astral-sh/ruff-pre-commit
5051
rev: v0.11.7
5152
hooks:

PKGBUILD

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ depends=(
3434
'ntfs-3g'
3535
)
3636
makedepends=(
37+
'python-cryptography'
3738
'python-setuptools'
3839
'python-sphinx'
3940
'python-build'

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,18 @@ To load the configuration file into `archinstall` run the following command
5656
archinstall --config <path to user config file or URL> --creds <path to user credentials config file or URL>
5757
```
5858

59+
### Credentials configuration file encryption
60+
By default all user account credentials are hashed with `yescrypt` and only the hash is stored in the saved `user_credentials.json` file.
61+
This is not possible for disk encryption password which needs to be stored in plaintext to be able to apply it.
62+
63+
However, when selecting to save configuration files, `archinstall` will prompt for the option to encrypt the `user_credentials.json` file content.
64+
A prompt will require to enter a encryption password to encrypt the file. When providing an encrypted `user_configuration.json` as a argument with `--creds <user_credentials.json>`
65+
there are multiple ways to provide the decryption key:
66+
* Provide the decryption key via the command line argument `--creds-decryption-key <password>`
67+
* Store the encryption key in the environment variable `ARCHINSTALL_CREDS_DECRYPTION_KEY` which will be read automatically
68+
* If none of the above is provided a prompt will be shown to enter the decryption key manually
69+
70+
5971
# Help or Issues
6072

6173
If you come across any issues, kindly submit your issue here on Github or post your query in the

archinstall/__init__.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,6 @@
2323
_: Callable[[str], DeferredTranslation]
2424

2525

26-
# add the custom _ as a builtin, it can now be used anywhere in the
27-
# project to mark strings as translatable with _('translate me')
28-
DeferredTranslation.install()
29-
30-
3126
# @archinstall.plugin decorator hook to programmatically add
3227
# plugins in runtime. Useful in profiles_bck and other things.
3328
def plugin(f, *args, **kwargs) -> None: # type: ignore[no-untyped-def]

archinstall/lib/args.py

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import argparse
22
import json
3+
import os
34
import urllib.error
45
import urllib.parse
56
from argparse import ArgumentParser, Namespace
67
from dataclasses import dataclass, field
78
from importlib.metadata import version
89
from pathlib import Path
9-
from typing import Any
10+
from typing import TYPE_CHECKING, Any
1011
from urllib.request import Request, urlopen
1112

1213
from pydantic.dataclasses import dataclass as p_dataclass
1314

15+
from archinstall.lib.crypt import decrypt
1416
from archinstall.lib.models.audio_configuration import AudioConfiguration
1517
from archinstall.lib.models.bootloader import Bootloader
1618
from archinstall.lib.models.device_model import DiskEncryption, DiskLayoutConfiguration
@@ -20,10 +22,19 @@
2022
from archinstall.lib.models.packages import Repository
2123
from archinstall.lib.models.profile_model import ProfileConfiguration
2224
from archinstall.lib.models.users import Password, User
23-
from archinstall.lib.output import error, warn
25+
from archinstall.lib.output import debug, error, warn
2426
from archinstall.lib.plugins import load_plugin
2527
from archinstall.lib.storage import storage
2628
from archinstall.lib.translationhandler import Language, translation_handler
29+
from archinstall.lib.utils.util import get_password
30+
from archinstall.tui.curses_menu import Tui
31+
32+
if TYPE_CHECKING:
33+
from collections.abc import Callable
34+
35+
from archinstall.lib.translationhandler import DeferredTranslation
36+
37+
_: Callable[[str], DeferredTranslation]
2738

2839

2940
@p_dataclass
@@ -32,6 +43,7 @@ class Arguments:
3243
config_url: str | None = None
3344
creds: Path | None = None
3445
creds_url: str | None = None
46+
creds_decryption_key: str | None = None
3547
silent: bool = False
3648
dry_run: bool = False
3749
script: str = 'guided'
@@ -274,6 +286,13 @@ def _define_arguments(self) -> ArgumentParser:
274286
default=None,
275287
help="Url to a JSON credentials configuration file"
276288
)
289+
parser.add_argument(
290+
"--creds-decryption-key",
291+
type=str,
292+
nargs="?",
293+
default=None,
294+
help="Decryption key for credentials file"
295+
)
277296
parser.add_argument(
278297
"--silent",
279298
action="store_true",
@@ -370,6 +389,10 @@ def _parse_args(self) -> Arguments:
370389
plugin_path = Path(args.plugin)
371390
load_plugin(plugin_path)
372391

392+
if args.creds_decryption_key is None:
393+
if os.environ.get('ARCHINSTALL_CREDS_DECRYPTION_KEY'):
394+
args.creds_decryption_key = os.environ.get('ARCHINSTALL_CREDS_DECRYPTION_KEY')
395+
373396
return args
374397

375398
def _parse_config(self) -> dict[str, Any]:
@@ -391,12 +414,57 @@ def _parse_config(self) -> dict[str, Any]:
391414
creds_data = self._fetch_from_url(self._args.creds_url)
392415

393416
if creds_data is not None:
394-
config.update(json.loads(creds_data))
417+
json_data = self._process_creds_data(creds_data)
418+
if json_data is not None:
419+
config.update(json_data)
395420

396421
config = self._cleanup_config(config)
397422

398423
return config
399424

425+
def _process_creds_data(self, creds_data: str) -> dict[str, Any] | None:
426+
if creds_data.startswith('$'): # encrypted data
427+
if self._args.creds_decryption_key is not None:
428+
try:
429+
creds_data = decrypt(creds_data, self._args.creds_decryption_key)
430+
return json.loads(creds_data)
431+
except ValueError as err:
432+
if 'Invalid password' in str(err):
433+
error(str(_('Incorrect credentials file decryption password')))
434+
exit(1)
435+
else:
436+
debug(f'Error decrypting credentials file: {err}')
437+
raise err from err
438+
else:
439+
incorrect_password = False
440+
441+
with Tui():
442+
while True:
443+
header = str(_('Incorrect password')) if incorrect_password else None
444+
445+
decryption_pwd = get_password(
446+
text=str(_('Credentials file decryption password')),
447+
header=header,
448+
allow_skip=False,
449+
skip_confirmation=True
450+
)
451+
452+
if not decryption_pwd:
453+
return None
454+
455+
try:
456+
creds_data = decrypt(creds_data, decryption_pwd.plaintext)
457+
break
458+
except ValueError as err:
459+
if 'Invalid password' in str(err):
460+
debug('Incorrect credentials file decryption password')
461+
incorrect_password = True
462+
else:
463+
debug(f'Error decrypting credentials file: {err}')
464+
raise err from err
465+
466+
return json.loads(creds_data)
467+
400468
def _fetch_from_url(self, url: str) -> str:
401469
if urllib.parse.urlparse(url).scheme:
402470
try:

archinstall/lib/configuration.py

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@
1010
from archinstall.tui.types import Alignment, FrameProperties, Orientation, PreviewStyle
1111

1212
from .args import ArchConfig
13+
from .crypt import encrypt
1314
from .general import JSON, UNSAFE_JSON
1415
from .output import debug, warn
1516
from .storage import storage
16-
from .utils.util import prompt_dir
17+
from .utils.util import get_password, prompt_dir
1718

1819
if TYPE_CHECKING:
1920
from collections.abc import Callable
@@ -100,19 +101,33 @@ def save_user_config(self, dest_path: Path) -> None:
100101
target.write_text(self.user_config_to_json())
101102
target.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP)
102103

103-
def save_user_creds(self, dest_path: Path) -> None:
104+
def save_user_creds(
105+
self,
106+
dest_path: Path,
107+
password: str | None = None
108+
) -> None:
109+
data = self.user_credentials_to_json()
110+
111+
if password:
112+
data = encrypt(password, data)
113+
104114
if self._is_valid_path(dest_path):
105115
target = dest_path / self._user_creds_file
106-
target.write_text(self.user_credentials_to_json())
116+
target.write_text(data)
107117
target.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP)
108118

109-
def save(self, dest_path: Path | None = None, creds: bool = False) -> None:
119+
def save(
120+
self,
121+
dest_path: Path | None = None,
122+
creds: bool = False,
123+
password: str | None = None
124+
) -> None:
110125
save_path = dest_path or self._default_save_path
111126

112127
if self._is_valid_path(save_path):
113128
self.save_user_config(save_path)
114129
if creds:
115-
self.save_user_creds(save_path)
130+
self.save_user_creds(save_path, password=password)
116131

117132

118133
def save_config(config: ArchConfig) -> None:
@@ -202,10 +217,36 @@ def preview(item: MenuItem) -> str | None:
202217

203218
debug(f"Saving configuration files to {dest_path.absolute()}")
204219

220+
header = str(_('Do you want to encrypt the user_credentials.json file?'))
221+
222+
group = MenuItemGroup.yes_no()
223+
group.focus_item = MenuItem.no()
224+
225+
result = SelectMenu(
226+
group,
227+
header=header,
228+
allow_skip=False,
229+
alignment=Alignment.CENTER,
230+
columns=2,
231+
orientation=Orientation.HORIZONTAL
232+
).run()
233+
234+
enc_password: str | None = None
235+
match result.type_:
236+
case ResultType.Selection:
237+
if result.item() == MenuItem.yes():
238+
password = get_password(
239+
text=str(_('Credentials file encryption password')),
240+
allow_skip=True
241+
)
242+
243+
if password:
244+
enc_password = password.plaintext
245+
205246
match save_option:
206247
case "user_config":
207248
config_output.save_user_config(dest_path)
208249
case "user_creds":
209-
config_output.save_user_creds(dest_path)
250+
config_output.save_user_creds(dest_path, password=enc_password)
210251
case "all":
211-
config_output.save(dest_path, creds=True)
252+
config_output.save(dest_path, creds=True, password=enc_password)

archinstall/lib/crypt.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
import base64
12
import ctypes
3+
import os
24
from pathlib import Path
35

6+
from cryptography.fernet import Fernet, InvalidToken
7+
from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
8+
49
from .output import debug
510

611
libcrypt = ctypes.CDLL("libcrypt.so")
@@ -69,3 +74,52 @@ def crypt_yescrypt(plaintext: str) -> str:
6974
raise ValueError('crypt() returned NULL')
7075

7176
return crypt_hash.decode('utf-8')
77+
78+
79+
def _get_fernet(salt: bytes, password: str) -> Fernet:
80+
# https://cryptography.io/en/latest/hazmat/primitives/key-derivation-functions/#argon2id
81+
kdf = Argon2id(
82+
salt=salt,
83+
length=32,
84+
iterations=1,
85+
lanes=4,
86+
memory_cost=64 * 1024,
87+
ad=None,
88+
secret=None,
89+
)
90+
91+
key = base64.urlsafe_b64encode(
92+
kdf.derive(
93+
password.encode('utf-8')
94+
)
95+
)
96+
97+
return Fernet(key)
98+
99+
100+
def encrypt(password: str, data: str) -> str:
101+
salt = os.urandom(16)
102+
f = _get_fernet(salt, password)
103+
token = f.encrypt(data.encode('utf-8'))
104+
105+
encoded_token = base64.urlsafe_b64encode(token).decode('utf-8')
106+
encoded_salt = base64.urlsafe_b64encode(salt).decode('utf-8')
107+
108+
return f'$argon2id${encoded_salt}${encoded_token}'
109+
110+
111+
def decrypt(data: str, password: str):
112+
_, algo, encoded_salt, encoded_token = data.split('$')
113+
salt = base64.urlsafe_b64decode(encoded_salt)
114+
token = base64.urlsafe_b64decode(encoded_token)
115+
116+
if algo != 'argon2id':
117+
raise ValueError(f'Unsupported algorithm {algo!r}')
118+
119+
f = _get_fernet(salt, password)
120+
try:
121+
decrypted = f.decrypt(token)
122+
except InvalidToken:
123+
raise ValueError('Invalid password')
124+
125+
return decrypted.decode('utf-8')

archinstall/lib/translationhandler.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import builtins
34
import gettext
45
import json
56
import os
@@ -190,10 +191,7 @@ def __add__(self, other) -> DeferredTranslation:
190191
def format(self, *args) -> str:
191192
return self.message.format(*args)
192193

193-
@classmethod
194-
def install(cls) -> None:
195-
import builtins
196-
builtins._ = cls # type: ignore[attr-defined]
197194

195+
builtins._ = DeferredTranslation # type: ignore[attr-defined]
198196

199197
translation_handler = TranslationHandler()

archinstall/lib/utils/util.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ def get_password(
1919
text: str,
2020
header: str | None = None,
2121
allow_skip: bool = False,
22-
preset: str | None = None
22+
preset: str | None = None,
23+
skip_confirmation: bool = False
2324
) -> Password | None:
2425
failure: str | None = None
2526

@@ -44,6 +45,9 @@ def get_password(
4445

4546
password = Password(plaintext=result.text())
4647

48+
if skip_confirmation:
49+
return password
50+
4751
if header is not None:
4852
confirmation_header = f'{header}{_("Password")}: {password.hidden()}\n'
4953
else:

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ classifiers = [
1919
]
2020
dependencies = [
2121
"pyparted @ https://github.com//dcantrell/pyparted/archive/v3.13.0.tar.gz#sha512=26819e28d73420937874f52fda03eb50ab1b136574ea9867a69d46ae4976d38c4f26a2697fa70597eed90dd78a5ea209bafcc3227a17a7a5d63cff6d107c2b11",
22-
"pydantic==2.11.3"
22+
"pydantic==2.11.3",
23+
"cryptography>=44.0.2",
2324
]
2425

2526
[project.urls]

0 commit comments

Comments
 (0)