mirror of
https://github.com/zebrajr/ansible.git
synced 2025-12-06 00:19:48 +01:00
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
This commit is contained in:
parent
f1f5b934c2
commit
d6051b18dd
2
changelogs/fragments/ctypes-crypt.yml
Normal file
2
changelogs/fragments/ctypes-crypt.yml
Normal file
|
|
@ -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``
|
||||
159
lib/ansible/_internal/_encryption/_crypt.py
Normal file
159
lib/ansible/_internal/_encryption/_crypt.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
shippable/posix/group4
|
||||
setup/always/setup_passlib_controller # required for setup_test_user
|
||||
needs/target/setup_libxcrypt
|
||||
destructive
|
||||
|
|
|
|||
24
test/integration/targets/filter_core/password_hash.yml
Normal file
24
test/integration/targets/filter_core/password_hash.yml
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
89
test/integration/targets/filter_core/tasks/password_hash.yml
Normal file
89
test/integration/targets/filter_core/tasks/password_hash.yml
Normal file
|
|
@ -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'
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
- name: libxcrypt was installed
|
||||
debug:
|
||||
msg: "LIBXCRYPT_WAS_INSTALLED"
|
||||
39
test/integration/targets/setup_libxcrypt/tasks/main.yml
Normal file
39
test/integration/targets/setup_libxcrypt/tasks/main.yml
Normal file
|
|
@ -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
|
||||
36
test/integration/targets/setup_libxcrypt/tasks/uninstall.yml
Normal file
36
test/integration/targets/setup_libxcrypt/tasks/uninstall.yml
Normal file
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
),
|
||||
],
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user