diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2a11deef..edc3052b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -46,6 +46,7 @@ repos: - pydantic - pydantic-settings - pytest + - cryptography - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.11.7 hooks: diff --git a/PKGBUILD b/PKGBUILD index c74256f9..a7810724 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -34,6 +34,7 @@ depends=( 'ntfs-3g' ) makedepends=( + 'python-cryptography' 'python-setuptools' 'python-sphinx' 'python-build' diff --git a/README.md b/README.md index 05da0c87..b56caed0 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,18 @@ To load the configuration file into `archinstall` run the following command archinstall --config --creds ``` +### Credentials configuration file encryption +By default all user account credentials are hashed with `yescrypt` and only the hash is stored in the saved `user_credentials.json` file. +This is not possible for disk encryption password which needs to be stored in plaintext to be able to apply it. + +However, when selecting to save configuration files, `archinstall` will prompt for the option to encrypt the `user_credentials.json` file content. +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 ` +there are multiple ways to provide the decryption key: +* Provide the decryption key via the command line argument `--creds-decryption-key ` +* Store the encryption key in the environment variable `ARCHINSTALL_CREDS_DECRYPTION_KEY` which will be read automatically +* If none of the above is provided a prompt will be shown to enter the decryption key manually + + # Help or Issues If you come across any issues, kindly submit your issue here on Github or post your query in the diff --git a/archinstall/__init__.py b/archinstall/__init__.py index dbb5557b..edbb3d30 100644 --- a/archinstall/__init__.py +++ b/archinstall/__init__.py @@ -23,11 +23,6 @@ if TYPE_CHECKING: _: Callable[[str], DeferredTranslation] -# add the custom _ as a builtin, it can now be used anywhere in the -# project to mark strings as translatable with _('translate me') -DeferredTranslation.install() - - # @archinstall.plugin decorator hook to programmatically add # plugins in runtime. Useful in profiles_bck and other things. def plugin(f, *args, **kwargs) -> None: # type: ignore[no-untyped-def] diff --git a/archinstall/lib/args.py b/archinstall/lib/args.py index 2efa973f..041815e5 100644 --- a/archinstall/lib/args.py +++ b/archinstall/lib/args.py @@ -1,16 +1,18 @@ import argparse import json +import os import urllib.error import urllib.parse from argparse import ArgumentParser, Namespace from dataclasses import dataclass, field from importlib.metadata import version from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.request import Request, urlopen from pydantic.dataclasses import dataclass as p_dataclass +from archinstall.lib.crypt import decrypt from archinstall.lib.models.audio_configuration import AudioConfiguration from archinstall.lib.models.bootloader import Bootloader from archinstall.lib.models.device_model import DiskEncryption, DiskLayoutConfiguration @@ -20,10 +22,19 @@ from archinstall.lib.models.network_configuration import NetworkConfiguration from archinstall.lib.models.packages import Repository from archinstall.lib.models.profile_model import ProfileConfiguration from archinstall.lib.models.users import Password, User -from archinstall.lib.output import error, warn +from archinstall.lib.output import debug, error, warn from archinstall.lib.plugins import load_plugin from archinstall.lib.storage import storage from archinstall.lib.translationhandler import Language, translation_handler +from archinstall.lib.utils.util import get_password +from archinstall.tui.curses_menu import Tui + +if TYPE_CHECKING: + from collections.abc import Callable + + from archinstall.lib.translationhandler import DeferredTranslation + + _: Callable[[str], DeferredTranslation] @p_dataclass @@ -32,6 +43,7 @@ class Arguments: config_url: str | None = None creds: Path | None = None creds_url: str | None = None + creds_decryption_key: str | None = None silent: bool = False dry_run: bool = False script: str = 'guided' @@ -274,6 +286,13 @@ class ArchConfigHandler: default=None, help="Url to a JSON credentials configuration file" ) + parser.add_argument( + "--creds-decryption-key", + type=str, + nargs="?", + default=None, + help="Decryption key for credentials file" + ) parser.add_argument( "--silent", action="store_true", @@ -370,6 +389,10 @@ class ArchConfigHandler: plugin_path = Path(args.plugin) load_plugin(plugin_path) + if args.creds_decryption_key is None: + if os.environ.get('ARCHINSTALL_CREDS_DECRYPTION_KEY'): + args.creds_decryption_key = os.environ.get('ARCHINSTALL_CREDS_DECRYPTION_KEY') + return args def _parse_config(self) -> dict[str, Any]: @@ -391,12 +414,57 @@ class ArchConfigHandler: creds_data = self._fetch_from_url(self._args.creds_url) if creds_data is not None: - config.update(json.loads(creds_data)) + json_data = self._process_creds_data(creds_data) + if json_data is not None: + config.update(json_data) config = self._cleanup_config(config) return config + def _process_creds_data(self, creds_data: str) -> dict[str, Any] | None: + if creds_data.startswith('$'): # encrypted data + if self._args.creds_decryption_key is not None: + try: + creds_data = decrypt(creds_data, self._args.creds_decryption_key) + return json.loads(creds_data) + except ValueError as err: + if 'Invalid password' in str(err): + error(str(_('Incorrect credentials file decryption password'))) + exit(1) + else: + debug(f'Error decrypting credentials file: {err}') + raise err from err + else: + incorrect_password = False + + with Tui(): + while True: + header = str(_('Incorrect password')) if incorrect_password else None + + decryption_pwd = get_password( + text=str(_('Credentials file decryption password')), + header=header, + allow_skip=False, + skip_confirmation=True + ) + + if not decryption_pwd: + return None + + try: + creds_data = decrypt(creds_data, decryption_pwd.plaintext) + break + except ValueError as err: + if 'Invalid password' in str(err): + debug('Incorrect credentials file decryption password') + incorrect_password = True + else: + debug(f'Error decrypting credentials file: {err}') + raise err from err + + return json.loads(creds_data) + def _fetch_from_url(self, url: str) -> str: if urllib.parse.urlparse(url).scheme: try: diff --git a/archinstall/lib/configuration.py b/archinstall/lib/configuration.py index 07398813..5f790b17 100644 --- a/archinstall/lib/configuration.py +++ b/archinstall/lib/configuration.py @@ -10,10 +10,11 @@ from archinstall.tui.result import ResultType from archinstall.tui.types import Alignment, FrameProperties, Orientation, PreviewStyle from .args import ArchConfig +from .crypt import encrypt from .general import JSON, UNSAFE_JSON from .output import debug, warn from .storage import storage -from .utils.util import prompt_dir +from .utils.util import get_password, prompt_dir if TYPE_CHECKING: from collections.abc import Callable @@ -100,19 +101,33 @@ class ConfigurationOutput: target.write_text(self.user_config_to_json()) target.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP) - def save_user_creds(self, dest_path: Path) -> None: + def save_user_creds( + self, + dest_path: Path, + password: str | None = None + ) -> None: + data = self.user_credentials_to_json() + + if password: + data = encrypt(password, data) + if self._is_valid_path(dest_path): target = dest_path / self._user_creds_file - target.write_text(self.user_credentials_to_json()) + target.write_text(data) target.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP) - def save(self, dest_path: Path | None = None, creds: bool = False) -> None: + def save( + self, + dest_path: Path | None = None, + creds: bool = False, + password: str | None = None + ) -> None: save_path = dest_path or self._default_save_path if self._is_valid_path(save_path): self.save_user_config(save_path) if creds: - self.save_user_creds(save_path) + self.save_user_creds(save_path, password=password) def save_config(config: ArchConfig) -> None: @@ -202,10 +217,36 @@ def save_config(config: ArchConfig) -> None: debug(f"Saving configuration files to {dest_path.absolute()}") + header = str(_('Do you want to encrypt the user_credentials.json file?')) + + group = MenuItemGroup.yes_no() + group.focus_item = MenuItem.no() + + result = SelectMenu( + group, + header=header, + allow_skip=False, + alignment=Alignment.CENTER, + columns=2, + orientation=Orientation.HORIZONTAL + ).run() + + enc_password: str | None = None + match result.type_: + case ResultType.Selection: + if result.item() == MenuItem.yes(): + password = get_password( + text=str(_('Credentials file encryption password')), + allow_skip=True + ) + + if password: + enc_password = password.plaintext + match save_option: case "user_config": config_output.save_user_config(dest_path) case "user_creds": - config_output.save_user_creds(dest_path) + config_output.save_user_creds(dest_path, password=enc_password) case "all": - config_output.save(dest_path, creds=True) + config_output.save(dest_path, creds=True, password=enc_password) diff --git a/archinstall/lib/crypt.py b/archinstall/lib/crypt.py index 975938f8..1436bca3 100644 --- a/archinstall/lib/crypt.py +++ b/archinstall/lib/crypt.py @@ -1,6 +1,11 @@ +import base64 import ctypes +import os from pathlib import Path +from cryptography.fernet import Fernet, InvalidToken +from cryptography.hazmat.primitives.kdf.argon2 import Argon2id + from .output import debug libcrypt = ctypes.CDLL("libcrypt.so") @@ -69,3 +74,52 @@ def crypt_yescrypt(plaintext: str) -> str: raise ValueError('crypt() returned NULL') return crypt_hash.decode('utf-8') + + +def _get_fernet(salt: bytes, password: str) -> Fernet: + # https://cryptography.io/en/latest/hazmat/primitives/key-derivation-functions/#argon2id + kdf = Argon2id( + salt=salt, + length=32, + iterations=1, + lanes=4, + memory_cost=64 * 1024, + ad=None, + secret=None, + ) + + key = base64.urlsafe_b64encode( + kdf.derive( + password.encode('utf-8') + ) + ) + + return Fernet(key) + + +def encrypt(password: str, data: str) -> str: + salt = os.urandom(16) + f = _get_fernet(salt, password) + token = f.encrypt(data.encode('utf-8')) + + encoded_token = base64.urlsafe_b64encode(token).decode('utf-8') + encoded_salt = base64.urlsafe_b64encode(salt).decode('utf-8') + + return f'$argon2id${encoded_salt}${encoded_token}' + + +def decrypt(data: str, password: str): + _, algo, encoded_salt, encoded_token = data.split('$') + salt = base64.urlsafe_b64decode(encoded_salt) + token = base64.urlsafe_b64decode(encoded_token) + + if algo != 'argon2id': + raise ValueError(f'Unsupported algorithm {algo!r}') + + f = _get_fernet(salt, password) + try: + decrypted = f.decrypt(token) + except InvalidToken: + raise ValueError('Invalid password') + + return decrypted.decode('utf-8') diff --git a/archinstall/lib/translationhandler.py b/archinstall/lib/translationhandler.py index 195d9de0..5b5efe8e 100644 --- a/archinstall/lib/translationhandler.py +++ b/archinstall/lib/translationhandler.py @@ -1,5 +1,6 @@ from __future__ import annotations +import builtins import gettext import json import os @@ -190,10 +191,7 @@ class DeferredTranslation: def format(self, *args) -> str: return self.message.format(*args) - @classmethod - def install(cls) -> None: - import builtins - builtins._ = cls # type: ignore[attr-defined] +builtins._ = DeferredTranslation # type: ignore[attr-defined] translation_handler = TranslationHandler() diff --git a/archinstall/lib/utils/util.py b/archinstall/lib/utils/util.py index 7b96d2e4..9a6f5a86 100644 --- a/archinstall/lib/utils/util.py +++ b/archinstall/lib/utils/util.py @@ -19,7 +19,8 @@ def get_password( text: str, header: str | None = None, allow_skip: bool = False, - preset: str | None = None + preset: str | None = None, + skip_confirmation: bool = False ) -> Password | None: failure: str | None = None @@ -44,6 +45,9 @@ def get_password( password = Password(plaintext=result.text()) + if skip_confirmation: + return password + if header is not None: confirmation_header = f'{header}{_("Password")}: {password.hidden()}\n' else: diff --git a/pyproject.toml b/pyproject.toml index f68b6033..1c76a3d4 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.11.3" + "pydantic==2.11.3", + "cryptography>=44.0.2", ] [project.urls] diff --git a/tests/conftest.py b/tests/conftest.py index fe12b312..2f5a18e0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,11 @@ def creds_fixture() -> Path: return Path(__file__).parent / 'data' / 'test_creds.json' +@pytest.fixture(scope='session') +def encrypted_creds_fixture() -> Path: + return Path(__file__).parent / 'data' / 'test_encrypted_creds.json' + + @pytest.fixture(scope='session') def deprecated_creds_config() -> Path: return Path(__file__).parent / 'data' / 'test_deprecated_creds_config.json' diff --git a/tests/data/test_encrypted_creds.json b/tests/data/test_encrypted_creds.json new file mode 100644 index 00000000..5d37215e --- /dev/null +++ b/tests/data/test_encrypted_creds.json @@ -0,0 +1 @@ +$argon2id$9lv5DTin0wusAc0tFPxbkw==$Z0FBQUFBQm4temxTbzJnd09OQmVCbU1DTGg0akNBMXNoeFI1UTlHVGRiVzF0UUFDRW9rVDRpeDVaVENVb1NGMzhZc0RReEZ4MmNtdkc3dHctU3BlNXdXb01UVWJibnYwZmVYRXZkbk9TQlUxUTVkN3Z6NWRfUTNVYlVUS3lzckhpNERJeW5mOUcxdnJqU2loVl95dlRBdWdEZXlCOVZyRHZRaEk2NURUWGRROGpEeExpdWtGU3ZTQ0FqTFFEMEozMmJEQkxabW1Wcjg2cXdEVllfYXYzN0p0eE9PRHVkZnFNcWZnY2h3cVJhVjA3S1Q2MER5RWZrb0FUTnJobW5icERzYUNVZE5iT3VLLWFtLTFDZVdoMUFub3FGQzBHcGFFNVRVbTBZM2ZqXzRERGlvSEJndWFma25hYlpvOHllUEVOZUVmc3dCN215NjlrdHVYNElfWGl5Ny1xTVlRWWw3V0VTSGRONENWbTdPalMxY1BnUGs3eFZoRERnOXNGSW9zRGZub2xQSEZSODFKVGxtdzNyZlpfeHdMSkJZUUNPQUZUWHNEd25KVWpwZTV2LVNyb1pzSFF6SGg3c3RldEpwLUVnQ0w1US1mN2l6MmdVLTZSSHpvNVdtcWhTaHVXMGJUczBFN2s0VXctUEo1OUdzNUs4bW9sbnpfcmJmQzNxSVNPM2NVeWktek5QaFU= diff --git a/tests/test_args.py b/tests/test_args.py index 036c6399..dc5c783e 100644 --- a/tests/test_args.py +++ b/tests/test_args.py @@ -1,3 +1,4 @@ +import os from pathlib import Path from pytest import MonkeyPatch @@ -26,6 +27,8 @@ def test_default_args(monkeypatch: MonkeyPatch) -> None: config=None, config_url=None, creds=None, + creds_url=None, + creds_decryption_key=None, silent=False, dry_run=False, script='guided', @@ -260,3 +263,51 @@ def test_deprecated_creds_config_parsing( groups=['wheel'] ) ] + + +def test_encrypted_creds_with_arg( + monkeypatch: MonkeyPatch, + encrypted_creds_fixture: Path, +) -> None: + monkeypatch.setattr('sys.argv', [ + 'archinstall', + '--creds', str(encrypted_creds_fixture), + '--creds-decryption-key', 'master' + ]) + + handler = ArchConfigHandler() + arch_config = handler.config + + assert arch_config.root_enc_password == Password(enc_password='$y$j9T$FWCInXmSsS.8KV4i7O50H.$Hb6/g.Sw1ry888iXgkVgc93YNuVk/Rw94knDKdPVQw7') + assert arch_config.users == [ + User( + username='t', + password=Password(enc_password='$y$j9T$3KxMigAEnjtzbjalhLewE.$gmuoQtc9RNY/PmO/GxHHYvkZNO86Eeftg1Oc7L.QSO/'), + sudo=True, + groups=[] + ) + ] + + +def test_encrypted_creds_with_env_var( + monkeypatch: MonkeyPatch, + encrypted_creds_fixture: Path, +) -> None: + os.environ['ARCHINSTALL_CREDS_DECRYPTION_KEY'] = 'master' + monkeypatch.setattr('sys.argv', [ + 'archinstall', + '--creds', str(encrypted_creds_fixture), + ]) + + handler = ArchConfigHandler() + arch_config = handler.config + + assert arch_config.root_enc_password == Password(enc_password='$y$j9T$FWCInXmSsS.8KV4i7O50H.$Hb6/g.Sw1ry888iXgkVgc93YNuVk/Rw94knDKdPVQw7') + assert arch_config.users == [ + User( + username='t', + password=Password(enc_password='$y$j9T$3KxMigAEnjtzbjalhLewE.$gmuoQtc9RNY/PmO/GxHHYvkZNO86Eeftg1Oc7L.QSO/'), + sudo=True, + groups=[] + ) + ]