Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ repos:
- pydantic
- pydantic-settings
- pytest
- cryptography
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.4
hooks:
Expand Down
1 change: 1 addition & 0 deletions PKGBUILD
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ depends=(
'ntfs-3g'
)
makedepends=(
'python-cryptography'
'python-setuptools'
'python-sphinx'
'python-build'
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 0 additions & 5 deletions archinstall/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,6 @@
_: 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]
Expand Down
74 changes: 71 additions & 3 deletions archinstall/lib/args.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -20,10 +22,19 @@
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
Expand All @@ -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'
Expand Down Expand Up @@ -274,6 +286,13 @@ def _define_arguments(self) -> ArgumentParser:
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",
Expand Down Expand Up @@ -370,6 +389,10 @@ def _parse_args(self) -> Arguments:
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]:
Expand All @@ -391,12 +414,57 @@ def _parse_config(self) -> dict[str, Any]:
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:
Expand Down
55 changes: 48 additions & 7 deletions archinstall/lib/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@
from archinstall.tui.types import Alignment, FrameProperties, Orientation, PreviewStyle, ResultType

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
Expand Down Expand Up @@ -99,19 +100,33 @@ def save_user_config(self, dest_path: Path) -> None:
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:
Expand Down Expand Up @@ -201,10 +216,36 @@ def preview(item: MenuItem) -> str | 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)
54 changes: 54 additions & 0 deletions archinstall/lib/crypt.py
Original file line number Diff line number Diff line change
@@ -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")
Expand Down Expand Up @@ -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')
6 changes: 2 additions & 4 deletions archinstall/lib/translationhandler.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import builtins
import gettext
import json
import os
Expand Down Expand Up @@ -190,10 +191,7 @@ def __add__(self, other) -> 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()
6 changes: 5 additions & 1 deletion archinstall/lib/utils/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions tests/data/test_encrypted_creds.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
$argon2id$9lv5DTin0wusAc0tFPxbkw==$Z0FBQUFBQm4temxTbzJnd09OQmVCbU1DTGg0akNBMXNoeFI1UTlHVGRiVzF0UUFDRW9rVDRpeDVaVENVb1NGMzhZc0RReEZ4MmNtdkc3dHctU3BlNXdXb01UVWJibnYwZmVYRXZkbk9TQlUxUTVkN3Z6NWRfUTNVYlVUS3lzckhpNERJeW5mOUcxdnJqU2loVl95dlRBdWdEZXlCOVZyRHZRaEk2NURUWGRROGpEeExpdWtGU3ZTQ0FqTFFEMEozMmJEQkxabW1Wcjg2cXdEVllfYXYzN0p0eE9PRHVkZnFNcWZnY2h3cVJhVjA3S1Q2MER5RWZrb0FUTnJobW5icERzYUNVZE5iT3VLLWFtLTFDZVdoMUFub3FGQzBHcGFFNVRVbTBZM2ZqXzRERGlvSEJndWFma25hYlpvOHllUEVOZUVmc3dCN215NjlrdHVYNElfWGl5Ny1xTVlRWWw3V0VTSGRONENWbTdPalMxY1BnUGs3eFZoRERnOXNGSW9zRGZub2xQSEZSODFKVGxtdzNyZlpfeHdMSkJZUUNPQUZUWHNEd25KVWpwZTV2LVNyb1pzSFF6SGg3c3RldEpwLUVnQ0w1US1mN2l6MmdVLTZSSHpvNVdtcWhTaHVXMGJUczBFN2s0VXctUEo1OUdzNUs4bW9sbnpfcmJmQzNxSVNPM2NVeWktek5QaFU=
Loading