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:
parent
b8923807c7
commit
fae210dfea
|
|
@ -46,6 +46,7 @@ repos:
|
|||
- pydantic
|
||||
- pydantic-settings
|
||||
- pytest
|
||||
- cryptography
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.11.7
|
||||
hooks:
|
||||
|
|
|
|||
1
PKGBUILD
1
PKGBUILD
|
|
@ -34,6 +34,7 @@ depends=(
|
|||
'ntfs-3g'
|
||||
)
|
||||
makedepends=(
|
||||
'python-cryptography'
|
||||
'python-setuptools'
|
||||
'python-sphinx'
|
||||
'python-build'
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
$argon2id$9lv5DTin0wusAc0tFPxbkw==$Z0FBQUFBQm4temxTbzJnd09OQmVCbU1DTGg0akNBMXNoeFI1UTlHVGRiVzF0UUFDRW9rVDRpeDVaVENVb1NGMzhZc0RReEZ4MmNtdkc3dHctU3BlNXdXb01UVWJibnYwZmVYRXZkbk9TQlUxUTVkN3Z6NWRfUTNVYlVUS3lzckhpNERJeW5mOUcxdnJqU2loVl95dlRBdWdEZXlCOVZyRHZRaEk2NURUWGRROGpEeExpdWtGU3ZTQ0FqTFFEMEozMmJEQkxabW1Wcjg2cXdEVllfYXYzN0p0eE9PRHVkZnFNcWZnY2h3cVJhVjA3S1Q2MER5RWZrb0FUTnJobW5icERzYUNVZE5iT3VLLWFtLTFDZVdoMUFub3FGQzBHcGFFNVRVbTBZM2ZqXzRERGlvSEJndWFma25hYlpvOHllUEVOZUVmc3dCN215NjlrdHVYNElfWGl5Ny1xTVlRWWw3V0VTSGRONENWbTdPalMxY1BnUGs3eFZoRERnOXNGSW9zRGZub2xQSEZSODFKVGxtdzNyZlpfeHdMSkJZUUNPQUZUWHNEd25KVWpwZTV2LVNyb1pzSFF6SGg3c3RldEpwLUVnQ0w1US1mN2l6MmdVLTZSSHpvNVdtcWhTaHVXMGJUczBFN2s0VXctUEo1OUdzNUs4bW9sbnpfcmJmQzNxSVNPM2NVeWktek5QaFU=
|
||||
|
|
@ -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=[]
|
||||
)
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in New Issue