From 78c337b82796bc2cedf6fba49d60b3d1a040c0c9 Mon Sep 17 00:00:00 2001 From: Abhijeet Kasurde Date: Tue, 30 Jun 2026 07:08:31 -0700 Subject: [PATCH] encrypt: fix bcrypt salt string formatting (#87184) * fix bcrypt salt string formatting on musl libc by ensuring it is always zero-padded to 2 digits Fixes: #87180 Signed-off-by: Abhijeet Kasurde --- changelogs/fragments/87180-crypt-fixes.yml | 2 ++ lib/ansible/_internal/_encryption/_crypt.py | 10 +++------- lib/ansible/utils/encrypt.py | 3 ++- test/units/_internal/_encryption/test_crypt.py | 9 +++++---- test/units/utils/test_encrypt.py | 10 ++++++++++ 5 files changed, 22 insertions(+), 12 deletions(-) create mode 100644 changelogs/fragments/87180-crypt-fixes.yml diff --git a/changelogs/fragments/87180-crypt-fixes.yml b/changelogs/fragments/87180-crypt-fixes.yml new file mode 100644 index 00000000000..290519a468a --- /dev/null +++ b/changelogs/fragments/87180-crypt-fixes.yml @@ -0,0 +1,2 @@ +bugfixes: + - encrypt - fix bcrypt salt string formatting on musl libc by ensuring it is always zero-padded to 2 digits (https://github.com/ansible/ansible/issues/87180). diff --git a/lib/ansible/_internal/_encryption/_crypt.py b/lib/ansible/_internal/_encryption/_crypt.py index 3326a4350d8..59184877e1b 100644 --- a/lib/ansible/_internal/_encryption/_crypt.py +++ b/lib/ansible/_internal/_encryption/_crypt.py @@ -12,7 +12,7 @@ from dataclasses import dataclass __all__ = ['CryptFacade'] -_FAILURE_TOKENS = frozenset({b'*0', b'*1'}) +_FAILURE_TOKENS = frozenset({b'*', b'*0', b'*1'}) @dataclass(frozen=True) @@ -93,10 +93,9 @@ class CryptFacade: if self._use_crypt_r: self._crypt_impl.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.POINTER(self._CryptData)] - self._crypt_impl.restype = ctypes.c_char_p else: self._crypt_impl.argtypes = [ctypes.c_char_p, ctypes.c_char_p] - self._crypt_impl.restype = ctypes.c_char_p + self._crypt_impl.restype = ctypes.c_char_p # Try to load crypt_gensalt (available in libxcrypt) try: @@ -133,10 +132,7 @@ class CryptFacade: 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: + if result is None or result in _FAILURE_TOKENS: raise ValueError('crypt failed: invalid salt or unsupported algorithm') return result diff --git a/lib/ansible/utils/encrypt.py b/lib/ansible/utils/encrypt.py index 1f272b18327..fd18e34226c 100644 --- a/lib/ansible/utils/encrypt.py +++ b/lib/ansible/utils/encrypt.py @@ -181,7 +181,8 @@ class CryptHash(BaseHash): saltstring = f'${ident}' if ident else '' if rounds: if self.algo_data.rounds_format == 'cost': - saltstring += f'${rounds}' + # musl libc requires a 2-digit bcrypt cost ($2b$05$, not $2b$5$). + saltstring += f'${rounds:02d}' else: saltstring += f'$rounds={rounds}' saltstring += f'${salt}' diff --git a/test/units/_internal/_encryption/test_crypt.py b/test/units/_internal/_encryption/test_crypt.py index 661e2c66a66..70e63cccee8 100644 --- a/test/units/_internal/_encryption/test_crypt.py +++ b/test/units/_internal/_encryption/test_crypt.py @@ -82,7 +82,7 @@ class TestCryptFacade: def test_crypt_fail_errno(self, mocker: MockerFixture) -> None: """Test crypt() setting failure errno raises OSError.""" - mocker.patch('ctypes.get_errno', return_value=errno.EBADFD) + mocker.patch('ctypes.get_errno', return_value=errno.EBADF) crypt_facade = CryptFacade() with pytest.raises(OSError, match=r'crypt failed:'): @@ -96,10 +96,11 @@ class TestCryptFacade: with pytest.raises(ValueError, match=r'crypt failed: invalid salt or unsupported algorithm'): crypt_facade.crypt(b"test", b"123") - def test_crypt_result_failure(self, mocker: MockerFixture) -> None: + @pytest.mark.parametrize('failure_token', sorted(list(_FAILURE_TOKENS))) + def test_crypt_result_failure(self, mocker: MockerFixture, failure_token: bytes) -> None: """Test crypt() implementation returning failure token raises ValueError.""" crypt_facade = CryptFacade() - mocker.patch.object(crypt_facade, '_crypt_impl', return_value=list(_FAILURE_TOKENS)[0]) + mocker.patch.object(crypt_facade, '_crypt_impl', return_value=failure_token) with pytest.raises(ValueError, match=r'crypt failed: invalid salt or unsupported algorithm'): crypt_facade.crypt(b"test", b"123") @@ -124,7 +125,7 @@ class TestCryptFacade: def test_crypt_gensalt_fail_errno(self, mocker: MockerFixture) -> None: """Test crypt_gensalt() setting failure errno raises OSError.""" - mocker.patch('ctypes.get_errno', return_value=errno.EBADFD) + mocker.patch('ctypes.get_errno', return_value=errno.EBADF) crypt_facade = CryptFacade() with pytest.raises(OSError, match=r'crypt_gensalt failed:'): diff --git a/test/units/utils/test_encrypt.py b/test/units/utils/test_encrypt.py index 848cf362f97..8d79a934646 100644 --- a/test/units/utils/test_encrypt.py +++ b/test/units/utils/test_encrypt.py @@ -288,6 +288,16 @@ class TestCryptHash: with pytest.raises(AnsibleError, match=r"crypt does not support 'sha256_crypt' algorithm"): crypt_hash.hash("123", salt="12345678") + def test_build_saltstring_bcrypt_cost_padding(self, mocker: MockerFixture) -> None: + """musl libc requires a 2-digit bcrypt cost in the salt string.""" + mocker.patch('ansible.utils.encrypt.HAS_CRYPT', True) + mocker.patch('ansible._internal._encryption._crypt.CryptFacade.has_crypt_gensalt', False) + + crypt_hash = encrypt.CryptHash("bcrypt") + saltstring = crypt_hash._build_saltstring('2b', 5, '1234567890123456789012', None) + + assert saltstring == '$2b$05$1234567890123456789012' + class TestPasslibHash: """