From d6051b18ddc165f02e2cd6f35d8efc7156c874ae Mon Sep 17 00:00:00 2001 From: sivel / Matt Martz Date: Tue, 21 Oct 2025 10:24:57 -0500 Subject: [PATCH] Add support for crypt/libxcrypt via ctypes, as an alternative to passlib (#85970) * Add support for crypt/libxcrypt via ctypes, as an alternative to passlib * move verbosity message to BaseHash * Don't require DYLD_LIBRARY_PATH mods for standard homebrew installs on macos * improve crypt_gensalt error handling --- changelogs/fragments/ctypes-crypt.yml | 2 + lib/ansible/_internal/_encryption/_crypt.py | 159 ++++++++++++++++++ lib/ansible/utils/encrypt.py | 151 +++++++++++++++-- test/integration/targets/filter_core/aliases | 1 + .../targets/filter_core/password_hash.yml | 24 +++ test/integration/targets/filter_core/runme.sh | 29 ++++ .../targets/filter_core/tasks/main.yml | 11 +- .../filter_core/tasks/password_hash.yml | 89 ++++++++++ .../targets/setup_libxcrypt/handlers/main.yml | 3 + .../targets/setup_libxcrypt/tasks/main.yml | 39 +++++ .../setup_libxcrypt/tasks/uninstall.yml | 36 ++++ test/units/utils/test_encrypt.py | 71 +++++--- 12 files changed, 573 insertions(+), 42 deletions(-) create mode 100644 changelogs/fragments/ctypes-crypt.yml create mode 100644 lib/ansible/_internal/_encryption/_crypt.py create mode 100644 test/integration/targets/filter_core/password_hash.yml create mode 100644 test/integration/targets/filter_core/tasks/password_hash.yml create mode 100644 test/integration/targets/setup_libxcrypt/handlers/main.yml create mode 100644 test/integration/targets/setup_libxcrypt/tasks/main.yml create mode 100644 test/integration/targets/setup_libxcrypt/tasks/uninstall.yml diff --git a/changelogs/fragments/ctypes-crypt.yml b/changelogs/fragments/ctypes-crypt.yml new file mode 100644 index 00000000000..70c9f5e403b --- /dev/null +++ b/changelogs/fragments/ctypes-crypt.yml @@ -0,0 +1,2 @@ +minor_changes: +- password hashing - Add support back for using the ``crypt`` implmentation from the C library used to build Python, or with expanded functionality using ``libxcrypt`` diff --git a/lib/ansible/_internal/_encryption/_crypt.py b/lib/ansible/_internal/_encryption/_crypt.py new file mode 100644 index 00000000000..0e23ff142f1 --- /dev/null +++ b/lib/ansible/_internal/_encryption/_crypt.py @@ -0,0 +1,159 @@ +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +import ctypes +import ctypes.util +import os +import sys +from dataclasses import dataclass + +__all__ = ['CRYPT_NAME', 'crypt', 'crypt_gensalt', 'HAS_CRYPT_GENSALT'] + +_FAILURE_TOKENS = frozenset({b'*0', b'*1'}) + + +@dataclass(frozen=True) +class _CryptLib: + name: str | None + exclude_platforms: frozenset[str] = frozenset() + include_platforms: frozenset[str] = frozenset() + is_path: bool = False + + +_CRYPT_LIBS = ( + _CryptLib('crypt'), # libxcrypt + _CryptLib(None, exclude_platforms=frozenset({'darwin'})), # fallback to default libc + _CryptLib( # macOS Homebrew (Apple Silicon) + '/opt/homebrew/opt/libxcrypt/lib/libcrypt.dylib', + include_platforms=frozenset({'darwin'}), + is_path=True, + ), + _CryptLib( # macOS Homebrew (Intel) + '/usr/local/opt/libxcrypt/lib/libcrypt.dylib', + include_platforms=frozenset({'darwin'}), + is_path=True, + ), +) + +for _lib_config in _CRYPT_LIBS: + if sys.platform in _lib_config.exclude_platforms: + continue + if _lib_config.include_platforms and sys.platform not in _lib_config.include_platforms: + continue + + if _lib_config.name is None: + _lib_so = None + elif _lib_config.is_path: + if os.path.exists(_lib_config.name): + _lib_so = _lib_config.name + else: + continue + else: + _lib_so = ctypes.util.find_library(_lib_config.name) + if not _lib_so: + continue + + _lib = ctypes.cdll.LoadLibrary(_lib_so) + + _use_crypt_r = False + try: + _crypt_impl = _lib.crypt_r + _use_crypt_r = True + except AttributeError: + try: + _crypt_impl = _lib.crypt + except AttributeError: + continue + + if _use_crypt_r: + + class _crypt_data(ctypes.Structure): + _fields_ = [('_opaque', ctypes.c_char * 131072)] + + _crypt_impl.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.POINTER(_crypt_data)] + _crypt_impl.restype = ctypes.c_char_p + else: + _crypt_impl.argtypes = [ctypes.c_char_p, ctypes.c_char_p] + _crypt_impl.restype = ctypes.c_char_p + + # Try to load crypt_gensalt (available in libxcrypt) + _use_crypt_gensalt_rn = False + HAS_CRYPT_GENSALT = False + try: + _crypt_gensalt_impl = _lib.crypt_gensalt_rn + _crypt_gensalt_impl.argtypes = [ctypes.c_char_p, ctypes.c_ulong, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_int] + _crypt_gensalt_impl.restype = ctypes.c_char_p + _use_crypt_gensalt_rn = True + HAS_CRYPT_GENSALT = True + except AttributeError: + try: + _crypt_gensalt_impl = _lib.crypt_gensalt + _crypt_gensalt_impl.argtypes = [ctypes.c_char_p, ctypes.c_ulong, ctypes.c_char_p, ctypes.c_int] + _crypt_gensalt_impl.restype = ctypes.c_char_p + HAS_CRYPT_GENSALT = True + except AttributeError: + _crypt_gensalt_impl = None + + CRYPT_NAME = _lib_config.name + break +else: + raise ImportError('Cannot find crypt implementation') + + +def crypt(word: bytes, salt: bytes) -> bytes: + """Hash a password using the system's crypt function.""" + ctypes.set_errno(0) + + if _use_crypt_r: + data = _crypt_data() + ctypes.memset(ctypes.byref(data), 0, ctypes.sizeof(data)) + result = _crypt_impl(word, salt, ctypes.byref(data)) + else: + result = _crypt_impl(word, salt) + + errno = ctypes.get_errno() + if errno: + error_msg = os.strerror(errno) + raise OSError(errno, f'crypt failed: {error_msg}') + + if result is None: + raise ValueError('crypt failed: invalid salt or unsupported algorithm') + + if result in _FAILURE_TOKENS: + raise ValueError('crypt failed: invalid salt or unsupported algorithm') + + return result + + +def crypt_gensalt(prefix: bytes, count: int, rbytes: bytes) -> bytes: + """Generate a salt string for use with crypt.""" + if not HAS_CRYPT_GENSALT: + raise NotImplementedError('crypt_gensalt not available (requires libxcrypt)') + + ctypes.set_errno(0) + + if _use_crypt_gensalt_rn: + output = ctypes.create_string_buffer(256) + result = _crypt_gensalt_impl(prefix, count, rbytes, len(rbytes), output, len(output)) + if result is not None: + result = output.value + else: + result = _crypt_gensalt_impl(prefix, count, rbytes, len(rbytes)) + + errno = ctypes.get_errno() + if errno: + error_msg = os.strerror(errno) + raise OSError(errno, f'crypt_gensalt failed: {error_msg}') + + if result is None: + raise ValueError('crypt_gensalt failed: unable to generate salt') + + if result in _FAILURE_TOKENS: + raise ValueError('crypt_gensalt failed: invalid prefix or unsupported algorithm') + + return result + + +del _lib_config diff --git a/lib/ansible/utils/encrypt.py b/lib/ansible/utils/encrypt.py index b4034276404..24bfd052bc2 100644 --- a/lib/ansible/utils/encrypt.py +++ b/lib/ansible/utils/encrypt.py @@ -4,10 +4,11 @@ from __future__ import annotations import random +import secrets import string import warnings -from collections import namedtuple +from dataclasses import dataclass from ansible import constants as C from ansible.errors import AnsibleError, AnsibleAssertionError @@ -34,6 +35,14 @@ try: except Exception as e: PASSLIB_E = e +CRYPT_E = None +HAS_CRYPT = False +try: + from ansible._internal._encryption import _crypt + HAS_CRYPT = True +except Exception as e: + CRYPT_E = e + display = Display() @@ -59,26 +68,135 @@ def random_password(length=DEFAULT_PASSWORD_LENGTH, chars=C.DEFAULT_PASSWORD_CHA return u''.join(random_generator.choice(chars) for dummy in range(length)) +_SALT_CHARS = string.ascii_letters + string.digits + './' +_VALID_SALT_CHARS = frozenset(_SALT_CHARS) + + def random_salt(length=8): """Return a text string suitable for use as a salt for the hash functions we use to encrypt passwords. """ # Note passlib salt values must be pure ascii so we can't let the user # configure this - salt_chars = string.ascii_letters + string.digits + u'./' - return random_password(length=length, chars=salt_chars) + return random_password(length=length, chars=_SALT_CHARS) + + +@dataclass(frozen=True) +class _Algo: + crypt_id: str + salt_size: int + implicit_rounds: int | None = None + salt_exact: bool = False + implicit_ident: str | None = None + rounds_format: str | None = None + requires_gensalt: bool = False class BaseHash(object): - algo = namedtuple('algo', ['crypt_id', 'salt_size', 'implicit_rounds', 'salt_exact', 'implicit_ident']) algorithms = { - 'md5_crypt': algo(crypt_id='1', salt_size=8, implicit_rounds=None, salt_exact=False, implicit_ident=None), - 'bcrypt': algo(crypt_id='2b', salt_size=22, implicit_rounds=12, salt_exact=True, implicit_ident='2b'), - 'sha256_crypt': algo(crypt_id='5', salt_size=16, implicit_rounds=535000, salt_exact=False, implicit_ident=None), - 'sha512_crypt': algo(crypt_id='6', salt_size=16, implicit_rounds=656000, salt_exact=False, implicit_ident=None), + 'md5_crypt': _Algo(crypt_id='1', salt_size=8), + 'bcrypt': _Algo(crypt_id='2b', salt_size=22, implicit_rounds=12, salt_exact=True, implicit_ident='2b', rounds_format='cost'), + 'sha256_crypt': _Algo(crypt_id='5', salt_size=16, implicit_rounds=535000, rounds_format='rounds'), + 'sha512_crypt': _Algo(crypt_id='6', salt_size=16, implicit_rounds=656000, rounds_format='rounds'), } def __init__(self, algorithm): self.algorithm = algorithm + display.vv(f"Using {self.__class__.__name__} to hash input with {algorithm!r}") + + +class CryptHash(BaseHash): + algorithms = { + **BaseHash.algorithms, + 'yescrypt': _Algo(crypt_id='y', salt_size=16, implicit_rounds=5, rounds_format='cost', requires_gensalt=True, salt_exact=True), + } + + def __init__(self, algorithm: str) -> None: + super(CryptHash, self).__init__(algorithm) + + if not HAS_CRYPT: + raise AnsibleError("crypt cannot be used as the 'libxcrypt' library is not installed or is unusable.") from CRYPT_E + + if algorithm not in self.algorithms: + raise AnsibleError(f"crypt does not support {self.algorithm!r} algorithm") + + self.algo_data = self.algorithms[algorithm] + + if self.algo_data.requires_gensalt and not _crypt.HAS_CRYPT_GENSALT: + raise AnsibleError(f"{self.algorithm!r} algorithm requires libxcrypt") + + def hash(self, secret: str, salt: str | None = None, salt_size: int | None = None, rounds: int | None = None, ident: str | None = None) -> str: + rounds = self._rounds(rounds) + ident = self._ident(ident) + + if _crypt.HAS_CRYPT_GENSALT: + saltstring = self._gensalt(ident, rounds, salt, salt_size) + else: + saltstring = self._build_saltstring(ident, rounds, salt, salt_size) + + return self._hash(secret, saltstring) + + def _validate_salt_size(self, salt_size: int | None) -> int: + if salt_size is not None and not isinstance(salt_size, int): + raise TypeError('salt_size must be an integer') + salt_size = salt_size or self.algo_data.salt_size + if self.algo_data.salt_exact and salt_size != self.algo_data.salt_size: + raise AnsibleError(f"invalid salt size supplied ({salt_size}), expected {self.algo_data.salt_size}") + elif not self.algo_data.salt_exact and salt_size > self.algo_data.salt_size: + raise AnsibleError(f"invalid salt size supplied ({salt_size}), expected at most {self.algo_data.salt_size}") + return salt_size + + def _salt(self, salt: str | None, salt_size: int | None) -> str: + salt_size = self._validate_salt_size(salt_size) + ret = salt or random_salt(salt_size) + if not set(ret).issubset(_VALID_SALT_CHARS): + raise AnsibleError("invalid characters in salt") + if self.algo_data.salt_exact and len(ret) != self.algo_data.salt_size: + raise AnsibleError(f"invalid salt size supplied ({len(ret)}), expected {self.algo_data.salt_size}") + elif not self.algo_data.salt_exact and len(ret) > self.algo_data.salt_size: + raise AnsibleError(f"invalid salt size supplied ({len(ret)}), expected at most {self.algo_data.salt_size}") + return ret + + def _rounds(self, rounds: int | None) -> int | None: + return rounds or self.algo_data.implicit_rounds + + def _ident(self, ident: str | None) -> str | None: + return ident or self.algo_data.crypt_id + + def _gensalt(self, ident: str, rounds: int | None, salt: str | None, salt_size: int | None) -> str: + if salt is None: + salt_size = self._validate_salt_size(salt_size) + rbytes = secrets.token_bytes(salt_size) + else: + salt = self._salt(salt, salt_size) + rbytes = to_bytes(salt) + + prefix = f'${ident}$' + count = rounds or 0 + + try: + salt_bytes = _crypt.crypt_gensalt(to_bytes(prefix), count, rbytes) + return to_text(salt_bytes, errors='strict') + except (NotImplementedError, ValueError) as e: + raise AnsibleError(f"Failed to generate salt for {self.algorithm!r} algorithm") from e + + def _build_saltstring(self, ident: str, rounds: int | None, salt: str | None, salt_size: int | None) -> str: + salt = self._salt(salt, salt_size) + saltstring = f'${ident}' if ident else '' + if rounds: + if self.algo_data.rounds_format == 'cost': + saltstring += f'${rounds}' + else: + saltstring += f'$rounds={rounds}' + saltstring += f'${salt}' + return saltstring + + def _hash(self, secret: str, saltstring: str) -> str: + try: + result = _crypt.crypt(to_bytes(secret), to_bytes(saltstring)) + except (OSError, ValueError) as e: + raise AnsibleError(f"crypt does not support {self.algorithm!r} algorithm") from e + + return to_text(result, errors='strict') class PasslibHash(BaseHash): @@ -88,8 +206,6 @@ class PasslibHash(BaseHash): if not PASSLIB_AVAILABLE: raise AnsibleError(f"The passlib Python package must be installed to hash with the {algorithm!r} algorithm.") from PASSLIB_E - display.vv("Using passlib to hash input with '%s'" % algorithm) - try: self.crypt_algo = getattr(passlib.hash, algorithm) except Exception: @@ -176,8 +292,13 @@ class PasslibHash(BaseHash): return to_text(result, errors='strict') -def do_encrypt(result, encrypt, salt_size=None, salt=None, ident=None, rounds=None): - if PASSLIB_AVAILABLE: - return PasslibHash(encrypt).hash(result, salt=salt, salt_size=salt_size, rounds=rounds, ident=ident) - - raise AnsibleError("Unable to encrypt nor hash, passlib must be installed.") from PASSLIB_E +def do_encrypt(result, algorithm, salt_size=None, salt=None, ident=None, rounds=None): + if HAS_CRYPT and algorithm in CryptHash.algorithms: + return CryptHash(algorithm).hash(result, salt=salt, salt_size=salt_size, rounds=rounds, ident=ident) + elif PASSLIB_AVAILABLE: + # TODO: deprecate passlib + return PasslibHash(algorithm).hash(result, salt=salt, salt_size=salt_size, rounds=rounds, ident=ident) + elif not PASSLIB_AVAILABLE and algorithm not in CryptHash.algorithms: + # When passlib support is removed, this branch can be removed too + raise AnsibleError(f"crypt does not support {algorithm!r} algorithm") + raise AnsibleError("Unable to encrypt nor hash, either libxcrypt (recommended), crypt, or passlib must be installed.") from CRYPT_E diff --git a/test/integration/targets/filter_core/aliases b/test/integration/targets/filter_core/aliases index 4e6a0088ee8..96a762a01ce 100644 --- a/test/integration/targets/filter_core/aliases +++ b/test/integration/targets/filter_core/aliases @@ -1,3 +1,4 @@ shippable/posix/group4 setup/always/setup_passlib_controller # required for setup_test_user +needs/target/setup_libxcrypt destructive diff --git a/test/integration/targets/filter_core/password_hash.yml b/test/integration/targets/filter_core/password_hash.yml new file mode 100644 index 00000000000..29c0211fafa --- /dev/null +++ b/test/integration/targets/filter_core/password_hash.yml @@ -0,0 +1,24 @@ +- hosts: localhost + tasks: + - name: Setup libxcrypt if requested + include_role: + name: setup_libxcrypt + when: use_libxcrypt|default(false) + + - name: Skip macOS when not using libxcrypt (no usable crypt) + meta: end_host + when: + - not expect_libxcrypt|default(false) + - ansible_facts.distribution == 'MacOSX' + + - name: Skip Alpine and FreeBSD when libxcrypt is expected (not available) + meta: end_host + when: + - expect_libxcrypt | default(false) + - ansible_facts.distribution == 'Alpine' or ansible_facts.system == 'FreeBSD' + + - import_role: + name: filter_core + tasks_from: password_hash.yml + vars: + is_crypt: true diff --git a/test/integration/targets/filter_core/runme.sh b/test/integration/targets/filter_core/runme.sh index c055603b0f4..959cd2fd038 100755 --- a/test/integration/targets/filter_core/runme.sh +++ b/test/integration/targets/filter_core/runme.sh @@ -4,3 +4,32 @@ set -eux ANSIBLE_ROLES_PATH=../ ansible-playbook runme.yml "$@" ANSIBLE_ROLES_PATH=../ ansible-playbook handle_undefined_type_errors.yml "$@" + +# Remove passlib installed by setup_passlib_controller +source virtualenv.sh +SITE_PACKAGES=$(python -c "import sysconfig; print(sysconfig.get_path('purelib'))") +echo "raise ImportError('passlib')" > "${SITE_PACKAGES}/passlib.py" + +# Test with libc (without libxcrypt) +ANSIBLE_ROLES_PATH=../ ansible-playbook password_hash.yml "$@" + +# Install libxcrypt and capture output +INSTALL_OUTPUT=$(ANSIBLE_ROLES_PATH=../ ansible localhost -m include_role -a name=setup_libxcrypt 2>&1) +echo "$INSTALL_OUTPUT" + +# Check if libxcrypt was installed by looking for the handler output +if echo "$INSTALL_OUTPUT" | grep -q 'LIBXCRYPT_WAS_INSTALLED'; then + # Setup cleanup trap + cleanup_libxcrypt() { + echo "Cleaning up libxcrypt..." + ANSIBLE_ROLES_PATH=../ ansible localhost -m include_role -a 'name=setup_libxcrypt tasks_from=uninstall' || true + } + trap cleanup_libxcrypt EXIT +fi + +# Test with libxcrypt (new ansible-playbook process will discover it) +if echo "$INSTALL_OUTPUT" | grep -q 'LIBXCRYPT_WAS_INSTALLED'; then + ANSIBLE_ROLES_PATH=../ ansible-playbook password_hash.yml -e '{expect_libxcrypt: true}' "$@" +else + ANSIBLE_ROLES_PATH=../ ansible-playbook password_hash.yml "$@" +fi diff --git a/test/integration/targets/filter_core/tasks/main.yml b/test/integration/targets/filter_core/tasks/main.yml index 0d982a7736d..a438c458a02 100644 --- a/test/integration/targets/filter_core/tasks/main.yml +++ b/test/integration/targets/filter_core/tasks/main.yml @@ -546,12 +546,6 @@ ignore_errors: yes register: password_hash_2 -- name: Verify password_hash throws on weird rounds - set_fact: - foo: '{{ "hey" | password_hash(rounds=1) }}' - ignore_errors: yes - register: password_hash_3 - - name: test using passlib with an unsupported hash type set_fact: foo: '{{"hey"|password_hash("msdcc")}}' @@ -566,8 +560,6 @@ - "'salt_size must be an integer' in password_hash_1.msg" - password_hash_2 is failed - "'is not in the list of supported passlib algorithms' in password_hash_2.msg" - - password_hash_3 is failed - - "'Could not hash the secret' in password_hash_3.msg" - "'msdcc is not in the list of supported passlib algorithms' in unsupported_hash_type.msg" - name: Verify to_uuid throws on weird namespace @@ -845,6 +837,7 @@ - "1,2,3" - "4,5,6" + - name: test to_yaml and to_nice_yaml include_tasks: to_yaml.yml @@ -867,3 +860,5 @@ that: - '"/foo/bar" in commonpath_01.ansible_facts.msg' - "'|commonpath expects' in commonpath_02.msg" + +- include_tasks: password_hash.yml diff --git a/test/integration/targets/filter_core/tasks/password_hash.yml b/test/integration/targets/filter_core/tasks/password_hash.yml new file mode 100644 index 00000000000..2b607744e9c --- /dev/null +++ b/test/integration/targets/filter_core/tasks/password_hash.yml @@ -0,0 +1,89 @@ +- name: Verify password_hash throws on weird salt_size type + set_fact: + foo: '{{"hey"|password_hash(salt_size=[999])}}' + ignore_errors: yes + register: password_hash_1 + +- name: Verify password_hash throws on weird hashtype + set_fact: + foo: '{{"hey"|password_hash(hashtype="supersecurehashtype")}}' + ignore_errors: yes + register: password_hash_2 + +- name: Debug the output for the next assert + debug: + msg: "{{ 'what in the WORLD is up?'|password_hash }}" + +- name: Verify password_hash + assert: + that: + - "'what in the WORLD is up?'|password_hash|length in [crypt_len, passlib_len]" + - password_hash_1 is failed + - password_hash_2 is failed + - "'is not in the list of supported' in password_hash_2.msg or 'does not support' in password_hash_2.msg" + vars: + # omitted rounds + crypt_len: 106 + # included $rounds=656000 + passlib_len: 120 + +- name: test using an unsupported hash type + set_fact: + foo: '{{"hey"|password_hash("msdcc")}}' + ignore_errors: yes + register: unsupported_hash_type + +- assert: + that: + - "'is not in the list of supported' in unsupported_hash_type.msg or 'does not support' in unsupported_hash_type.msg" + +- name: Test if yescrypt is available + set_fact: + yescrypt_test: "{{ 'test' | password_hash('yescrypt') }}" + ignore_errors: yes + register: yescrypt_available + +- name: Determine if yescrypt is supported + set_fact: + yescrypt_supported: "{{ yescrypt_available is not failed }}" + +- name: Fail if libxcrypt was installed but yescrypt is not supported + when: expect_libxcrypt | default(false) + assert: + that: + - yescrypt_supported + fail_msg: "libxcrypt was installed but yescrypt is not supported" + +- name: Test yescrypt hashing + when: + - is_crypt|default(false) + - yescrypt_supported + block: + - name: Generate yescrypt hash with default cost + set_fact: + yescrypt_hash: "{{ 'test_password' | password_hash('yescrypt') }}" + + - name: Verify yescrypt hash format + assert: + that: + - yescrypt_hash.startswith('$y$') + - yescrypt_hash | length > 60 + + - name: Generate yescrypt hash with custom cost + set_fact: + yescrypt_hash_cost7: "{{ 'test_password' | password_hash('yescrypt', rounds=7) }}" + + - name: Verify yescrypt hash with custom cost + assert: + that: + - yescrypt_hash_cost7.startswith('$y$jBT$') + - yescrypt_hash_cost7 | length > 60 + + - name: Generate yescrypt hash with custom salt + set_fact: + yescrypt_hash_salt: "{{ 'test_password' | password_hash('yescrypt', salt='abcdefghijklmnop') }}" + + - name: Verify yescrypt hash with custom salt + assert: + that: + - yescrypt_hash_salt == '$y$j9T$V7qMYJaNbVKOeh4PhtqPk/$x7rTqZ.RpI07.dkBSxcg.jLM8ODUfx25rCN0cFsAUg0' diff --git a/test/integration/targets/setup_libxcrypt/handlers/main.yml b/test/integration/targets/setup_libxcrypt/handlers/main.yml new file mode 100644 index 00000000000..a39b5be5dae --- /dev/null +++ b/test/integration/targets/setup_libxcrypt/handlers/main.yml @@ -0,0 +1,3 @@ +- name: libxcrypt was installed + debug: + msg: "LIBXCRYPT_WAS_INSTALLED" diff --git a/test/integration/targets/setup_libxcrypt/tasks/main.yml b/test/integration/targets/setup_libxcrypt/tasks/main.yml new file mode 100644 index 00000000000..40f488dcee7 --- /dev/null +++ b/test/integration/targets/setup_libxcrypt/tasks/main.yml @@ -0,0 +1,39 @@ +- name: Gather facts + setup: + gather_subset: [min] + +- name: Install libxcrypt on RedHat/CentOS/Fedora + when: ansible_facts.os_family == 'RedHat' + become: yes + package: + name: libxcrypt + state: present + notify: libxcrypt was installed + +- name: Install libxcrypt on Debian/Ubuntu + when: ansible_facts.os_family == 'Debian' + become: yes + package: + name: libcrypt1 + state: present + notify: libxcrypt was installed + +- name: Install libxcrypt on macOS + when: ansible_facts.distribution == 'MacOSX' + block: + - name: MACOS | Find brew binary + command: which brew + register: brew_which + + - name: MACOS | Get owner of brew binary + stat: + path: "{{ brew_which.stdout }}" + register: brew_stat + + - name: MACOS | Install libxcrypt + command: brew install libxcrypt + notify: libxcrypt was installed + become: yes + become_user: "{{ brew_stat.stat.pw_name }}" + environment: + HOMEBREW_NO_AUTO_UPDATE: True diff --git a/test/integration/targets/setup_libxcrypt/tasks/uninstall.yml b/test/integration/targets/setup_libxcrypt/tasks/uninstall.yml new file mode 100644 index 00000000000..8f73d56dc6a --- /dev/null +++ b/test/integration/targets/setup_libxcrypt/tasks/uninstall.yml @@ -0,0 +1,36 @@ +- name: Gather facts + setup: + gather_subset: [min] + +- name: Uninstall libxcrypt on RedHat/CentOS/Fedora + when: ansible_facts.os_family == 'RedHat' + become: yes + package: + name: libxcrypt + state: absent + +- name: Uninstall libxcrypt on Debian/Ubuntu + when: ansible_facts.os_family == 'Debian' + become: yes + package: + name: libcrypt1 + state: absent + +- name: Uninstall libxcrypt on macOS + when: ansible_facts.distribution == 'MacOSX' + block: + - name: MACOS | Find brew binary + command: which brew + register: brew_which + + - name: MACOS | Get owner of brew binary + stat: + path: "{{ brew_which.stdout }}" + register: brew_stat + + - name: MACOS | Uninstall libxcrypt + command: brew uninstall libxcrypt + become: yes + become_user: "{{ brew_stat.stat.pw_name }}" + environment: + HOMEBREW_NO_AUTO_UPDATE: True diff --git a/test/units/utils/test_encrypt.py b/test/units/utils/test_encrypt.py index 4abc543c05e..dac175ce25d 100644 --- a/test/units/utils/test_encrypt.py +++ b/test/units/utils/test_encrypt.py @@ -14,8 +14,16 @@ from ansible.utils import encrypt def assert_hash(expected, secret, algorithm, **settings): - assert encrypt.do_encrypt(secret, algorithm, **settings) == expected - assert encrypt.PasslibHash(algorithm).hash(secret, **settings) == expected + if isinstance(expected, tuple): + expected_crypt, expected_passlib = expected + else: + expected_crypt = expected_passlib = expected + + if encrypt.HAS_CRYPT and algorithm in encrypt.CryptHash.algorithms: + assert encrypt.CryptHash(algorithm).hash(secret, **settings) == expected_crypt + + if encrypt.PASSLIB_AVAILABLE: + assert encrypt.PasslibHash(algorithm).hash(secret, **settings) == expected_passlib @pytest.mark.parametrize( @@ -26,7 +34,10 @@ def assert_hash(expected, secret, algorithm, **settings): None, "1234567890123456789012", None, - "$2b$12$123456789012345678901ugbM1PeTfRQ0t6dCJu5lQA8hwrZOYgDu", + ( + "$2b$12$KRGxLBS0Lxe3KBCwKxOzLe6odk8yM9lJBgNtLuDQxUkLDkpGI6twK", + "$2b$12$123456789012345678901ugbM1PeTfRQ0t6dCJu5lQA8hwrZOYgDu", + ), id="bcrypt_default", ), pytest.param( @@ -36,13 +47,17 @@ def assert_hash(expected, secret, algorithm, **settings): None, "$2$12$123456789012345678901ufd3hZRrev.WXCbemqGIV/gmWaTGLImm", id="bcrypt_ident_2", + marks=pytest.mark.xfail(reason="crypt_gensalt rejects old bcrypt ident '2', unlike passlib"), ), pytest.param( "bcrypt", "2y", "1234567890123456789012", None, - "$2y$12$123456789012345678901ugbM1PeTfRQ0t6dCJu5lQA8hwrZOYgDu", + ( + "$2y$12$KRGxLBS0Lxe3KBCwKxOzLe6odk8yM9lJBgNtLuDQxUkLDkpGI6twK", + "$2y$12$123456789012345678901ugbM1PeTfRQ0t6dCJu5lQA8hwrZOYgDu", + ), id="bcrypt_ident_2y", ), pytest.param( @@ -50,7 +65,10 @@ def assert_hash(expected, secret, algorithm, **settings): "2a", "1234567890123456789012", None, - "$2a$12$123456789012345678901ugbM1PeTfRQ0t6dCJu5lQA8hwrZOYgDu", + ( + "$2a$12$KRGxLBS0Lxe3KBCwKxOzLe6odk8yM9lJBgNtLuDQxUkLDkpGI6twK", + "$2a$12$123456789012345678901ugbM1PeTfRQ0t6dCJu5lQA8hwrZOYgDu", + ), id="bcrypt_ident_2a", ), pytest.param( @@ -58,7 +76,10 @@ def assert_hash(expected, secret, algorithm, **settings): "2b", "1234567890123456789012", None, - "$2b$12$123456789012345678901ugbM1PeTfRQ0t6dCJu5lQA8hwrZOYgDu", + ( + "$2b$12$KRGxLBS0Lxe3KBCwKxOzLe6odk8yM9lJBgNtLuDQxUkLDkpGI6twK", + "$2b$12$123456789012345678901ugbM1PeTfRQ0t6dCJu5lQA8hwrZOYgDu", + ), id="bcrypt_ident_2b", ), pytest.param( @@ -66,8 +87,9 @@ def assert_hash(expected, secret, algorithm, **settings): "invalid_ident", "12345678", 5000, - "$5$12345678$uAZsE3BenI2G.nA8DpTl.9Dc8JiqacI53pEqRr5ppT7", + "$5$l6nAoIXB$HtZrcvuIcvGwySXwnxGHuwyIha2FvzAt4QebHp43Wq4", id="sha256_crypt_invalid_ident", + marks=pytest.mark.xfail(reason="crypt_gensalt rejects invalid idents, unlike passlib"), ), pytest.param( "crypt16", @@ -84,44 +106,55 @@ def test_encrypt_with_ident(algorithm, ident, salt, rounds, expected): assert_hash(expected, secret="123", algorithm=algorithm, salt=salt, rounds=rounds, ident=ident) -# If passlib is not installed. this is identical to the test_encrypt_with_rounds_no_passlib() test @pytest.mark.parametrize( ("algorithm", "rounds", "expected"), [ pytest.param( "sha256_crypt", None, - "$5$rounds=535000$12345678$uy3TurUPaY71aioJi58HvUY8jkbhSQU8HepbyaNngv.", + ( + "$5$rounds=535000$l6nAoIXB$A2HBLoxGx60mfezwjZ6VFd9vI1.V4oKpd.iwYU4n776", + "$5$rounds=535000$12345678$uy3TurUPaY71aioJi58HvUY8jkbhSQU8HepbyaNngv.", + ), id="sha256_crypt_default_rounds", ), pytest.param( "sha256_crypt", 5000, - "$5$12345678$uAZsE3BenI2G.nA8DpTl.9Dc8JiqacI53pEqRr5ppT7", + ("$5$l6nAoIXB$HtZrcvuIcvGwySXwnxGHuwyIha2FvzAt4QebHp43Wq4", "$5$12345678$uAZsE3BenI2G.nA8DpTl.9Dc8JiqacI53pEqRr5ppT7"), id="sha256_crypt_rounds_5000", ), pytest.param( "sha256_crypt", 10000, - "$5$rounds=10000$12345678$JBinliYMFEcBeAXKZnLjenhgEhTmJBvZn3aR8l70Oy/", + ( + "$5$rounds=10000$l6nAoIXB$//KYvMXLmzwUUYyWJmAJAeZuP7rsroMayX9hUhpwRC.", + "$5$rounds=10000$12345678$JBinliYMFEcBeAXKZnLjenhgEhTmJBvZn3aR8l70Oy/", + ), id="sha256_crypt_rounds_10000", ), pytest.param( "sha512_crypt", None, - "$6$rounds=656000$12345678$InMy49UwxyCh2pGJU1NpOhVSElDDzKeyuC6n6E9O34BCUGVNYADnI.rcA3m.Vro9BiZpYmjEoNhpREqQcbvQ80", + ( + "$6$rounds=656000$l6nAoIXB$wDP2gGfore3TlBdPvi0wqot7zj8oodeHnEPerC1blJBRGEodsNewNfzxM5nYdfPMkvCh5Af/w82wvG2U3PpCT0", + "$6$rounds=656000$12345678$InMy49UwxyCh2pGJU1NpOhVSElDDzKeyuC6n6E9O34BCUGVNYADnI.rcA3m.Vro9BiZpYmjEoNhpREqQcbvQ80", + ), id="sha512_crypt_default_rounds", ), pytest.param( "sha512_crypt", 5000, - "$6$12345678$LcV9LQiaPekQxZ.OfkMADjFdSO2k9zfbDQrHPVcYjSLqSdjLYpsgqviYvTEP/R41yPmhH3CCeEDqVhW1VHr3L.", + ( + "$6$l6nAoIXB$Zva3RkTY95C0FM0fV9WhLyrQO7/jMt1sICo8bQZvpbRIhDYcgiNdL7IzdlGxG6j6CdkWdqeAk4/W49qkAuv/h/", + "$6$12345678$LcV9LQiaPekQxZ.OfkMADjFdSO2k9zfbDQrHPVcYjSLqSdjLYpsgqviYvTEP/R41yPmhH3CCeEDqVhW1VHr3L.", + ), id="sha512_crypt_rounds_5000", ), pytest.param( "md5_crypt", None, - "$1$12345678$tRy4cXc3kmcfRZVj4iFXr/", + ("$1$l6nAoIXB$FCE6sRsviKJtqJWkAK6ff0", "$1$12345678$tRy4cXc3kmcfRZVj4iFXr/"), id="md5_crypt_default_rounds", ), ], @@ -144,31 +177,31 @@ def test_password_hash_filter_passlib_with_exception(): pytest.param( "sha256", None, - "$5$rounds=535000$12345678$uy3TurUPaY71aioJi58HvUY8jkbhSQU8HepbyaNngv.", + "$5$rounds=535000$l6nAoIXB$A2HBLoxGx60mfezwjZ6VFd9vI1.V4oKpd.iwYU4n776", id="sha256_default_rounds", ), pytest.param( "sha256", 5000, - "$5$12345678$uAZsE3BenI2G.nA8DpTl.9Dc8JiqacI53pEqRr5ppT7", + "$5$l6nAoIXB$HtZrcvuIcvGwySXwnxGHuwyIha2FvzAt4QebHp43Wq4", id="sha256_rounds_5000", ), pytest.param( "sha256", 10000, - "$5$rounds=10000$12345678$JBinliYMFEcBeAXKZnLjenhgEhTmJBvZn3aR8l70Oy/", + "$5$rounds=10000$l6nAoIXB$//KYvMXLmzwUUYyWJmAJAeZuP7rsroMayX9hUhpwRC.", id="sha256_rounds_10000", ), pytest.param( "sha512", 5000, - "$6$12345678$LcV9LQiaPekQxZ.OfkMADjFdSO2k9zfbDQrHPVcYjSLqSdjLYpsgqviYvTEP/R41yPmhH3CCeEDqVhW1VHr3L.", + "$6$l6nAoIXB$Zva3RkTY95C0FM0fV9WhLyrQO7/jMt1sICo8bQZvpbRIhDYcgiNdL7IzdlGxG6j6CdkWdqeAk4/W49qkAuv/h/", id="sha512_rounds_5000", ), pytest.param( "sha512", 6000, - "$6$rounds=6000$12345678$l/fC67BdJwZrJ7qneKGP1b6PcatfBr0dI7W6JLBrsv8P1wnv/0pu4WJsWq5p6WiXgZ2gt9Aoir3MeORJxg4.Z/", + "$6$rounds=6000$l6nAoIXB$DMD7Me00a9FHa5hF22mKek6.Hf7dD6UvJBuRLKus7K//G2kRwcKx5pkp5vGDk5E/MEQgR0yaEsv6ooq3iGRqp/", id="sha512_rounds_6000", ), ],