mirror of https://github.com/ansible/ansible.git
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 handlingpull/85939/head
parent
f1f5b934c2
commit
d6051b18dd
@ -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``
|
||||||
@ -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
|
||||||
@ -1,3 +1,4 @@
|
|||||||
shippable/posix/group4
|
shippable/posix/group4
|
||||||
setup/always/setup_passlib_controller # required for setup_test_user
|
setup/always/setup_passlib_controller # required for setup_test_user
|
||||||
|
needs/target/setup_libxcrypt
|
||||||
destructive
|
destructive
|
||||||
|
|||||||
@ -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
|
||||||
@ -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"
|
||||||
@ -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
|
||||||
@ -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
|
||||||
Loading…
Reference in New Issue