Add optional file encryption for user credentials configuration (#3391)

* Optional encryption of user credentials configuration file

* Update README

* Update

* Update
This commit is contained in:
Daniel Girtler 2025-04-26 20:25:43 +10:00 committed by GitHub
parent b8923807c7
commit fae210dfea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 253 additions and 21 deletions

View File

@ -46,6 +46,7 @@ repos:
- pydantic
- pydantic-settings
- pytest
- cryptography
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.7
hooks:

View File

@ -34,6 +34,7 @@ depends=(
'ntfs-3g'
)
makedepends=(
'python-cryptography'
'python-setuptools'
'python-sphinx'
'python-build'

View File

@ -56,6 +56,18 @@ To load the configuration file into `archinstall` run the following command
archinstall --config <path to user config file or URL> --creds <path to user credentials config file or URL>
```
### 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 <user_credentials.json>`
there are multiple ways to provide the decryption key:
* Provide the decryption key via the command line argument `--creds-decryption-key <password>`
* 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

View File

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

View File

@ -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:

View File

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

View File

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

View File

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

View File

@ -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:

View File

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

View File

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

View File

@ -0,0 +1 @@
$argon2id$9lv5DTin0wusAc0tFPxbkw==$Z0FBQUFBQm4temxTbzJnd09OQmVCbU1DTGg0akNBMXNoeFI1UTlHVGRiVzF0UUFDRW9rVDRpeDVaVENVb1NGMzhZc0RReEZ4MmNtdkc3dHctU3BlNXdXb01UVWJibnYwZmVYRXZkbk9TQlUxUTVkN3Z6NWRfUTNVYlVUS3lzckhpNERJeW5mOUcxdnJqU2loVl95dlRBdWdEZXlCOVZyRHZRaEk2NURUWGRROGpEeExpdWtGU3ZTQ0FqTFFEMEozMmJEQkxabW1Wcjg2cXdEVllfYXYzN0p0eE9PRHVkZnFNcWZnY2h3cVJhVjA3S1Q2MER5RWZrb0FUTnJobW5icERzYUNVZE5iT3VLLWFtLTFDZVdoMUFub3FGQzBHcGFFNVRVbTBZM2ZqXzRERGlvSEJndWFma25hYlpvOHllUEVOZUVmc3dCN215NjlrdHVYNElfWGl5Ny1xTVlRWWw3V0VTSGRONENWbTdPalMxY1BnUGs3eFZoRERnOXNGSW9zRGZub2xQSEZSODFKVGxtdzNyZlpfeHdMSkJZUUNPQUZUWHNEd25KVWpwZTV2LVNyb1pzSFF6SGg3c3RldEpwLUVnQ0w1US1mN2l6MmdVLTZSSHpvNVdtcWhTaHVXMGJUczBFN2s0VXctUEo1OUdzNUs4bW9sbnpfcmJmQzNxSVNPM2NVeWktek5QaFU=

View File

@ -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=[]
)
]