-
Notifications
You must be signed in to change notification settings - Fork 268
Add fallbacks for import crypt #2156
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
2b42bbe
3617086
2a2a2ff
22ebf30
9c48a8a
e1de9a6
3e8b0d7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,33 +4,40 @@ | |
| import string | ||
| import hashlib | ||
| import sys | ||
|
|
||
| # crypt module was removed in Python 3.13 | ||
| # For Python < 3.11: use builtin crypt | ||
| # For Python >= 3.11: try crypt_r package, then ctypes fallback | ||
| if sys.version_info >= (3, 11): | ||
| try: | ||
| import crypt_r as crypt | ||
| except ImportError: | ||
| try: | ||
| from Utils import crypt_fallback as crypt | ||
| except ImportError: | ||
| crypt = None | ||
| else: | ||
| try: | ||
| import crypt | ||
| except ImportError: | ||
| try: | ||
| from Utils import crypt_fallback as crypt | ||
| except ImportError: | ||
| crypt = None | ||
|
|
||
| import platform | ||
| import re | ||
| import Utils.logger as logger | ||
| import Utils.extensionutils as ext_utils | ||
| import Utils.constants as constants | ||
|
|
||
| # crypt module was removed in Python 3.13 | ||
| # first we try to import the crypt module which covers crypt and crypt_r | ||
| # then we try legacyrypt and finally fallback to passlib | ||
|
|
||
| cryptImported = False | ||
| passLibImported = False | ||
|
|
||
| try: | ||
| from crypt import crypt as crypt | ||
| cryptImported = True | ||
| except ImportError: | ||
| pass | ||
|
|
||
| if cryptImported == False: | ||
| # checking for python version, greater than or equal to 3.13 | ||
| if (sys.version_info[0] == 3 and sys.version_info[1] >= 13) or (sys.version_info[0] > 3): | ||
| try: | ||
| from legacycrypt import crypt | ||
| cryptImported = True | ||
| except ImportError: | ||
| pass | ||
|
|
||
| if cryptImported == False: | ||
| try: | ||
| from passlib.hash import sha512_crypt | ||
| passLibImported = True | ||
| except ImportError: | ||
| pass | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If all 3 imports fail, do we need to throw an error here + log ?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This condition (all 3 imports failing) needs to be handled, but raising an exception here would require all modules depending on this module, directly or indirectly, to handle that exception, or the extension would crash. @norakoiralamsft - in the original PR, an exception is raised on usage of these functions if no module was imported. I see that you are handling this at line 192 by returning None. Do verify that this error handling is appropriate for your usage.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Decided to throw exception in the gen_passwsord_hash method instead of returning None and floating up the error message.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @norakoiralamsft - thanks; lgtm |
||
|
|
||
| def get_my_distro(config, os_name=None): | ||
| if 'FreeBSD' in platform.system(): | ||
|
|
@@ -174,7 +181,20 @@ def gen_password_hash(self, password, crypt_id, salt_len): | |
| collection = string.ascii_letters + string.digits | ||
| salt = ''.join(random.choice(collection) for _ in range(salt_len)) | ||
| salt = "${0}${1}".format(crypt_id, salt) | ||
| return crypt.crypt(password, salt) | ||
|
|
||
| if cryptImported: | ||
| # salt is randomly generated above | ||
| # default crypt_id is 6 (SHA-512), else Provisioning.PasswordCryptId is used, see change_password() for details | ||
| return crypt(password, salt) | ||
|
norakoiralamsft marked this conversation as resolved.
|
||
| elif passLibImported: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This shouldn't ignore crypt_id and salt. This could result in a bug depending on n distro configuration?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. +1, I think it would be a good idea to get the hashes for all scenarios/libraries first and confirming they all match prior to merging. (i.e. assuming they are using
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The crypt_id defines the hashing algorithm to use and is included as part of the salt parameter in the call to crypt.crypt (notice that it is prepended to the salt). For some reason, the WALinuxAgent allowed users to set the crypt_id in waagent.conf; I am assuming that VmAccess is using legacy code that reads this value from waagent.conf. @norakoiralamsft - For WALinuxAgent, we decided to make this change (see the README in the develop branch, marked with ******* below): I think that would be appropriate for VmAccess as well, but pointing it out in case you want to follow a different approach.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The call to passlib.hash() is not given a salt. One will be generated automatically and that is the recommended usage.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. +1 to everything Norberto mentioned, I've added comments to clarify the salt + crypt_id usage for the different hashing methods. Opting to not pass in salt for sha512_crypt(), as a random salt will be generated on our behalf and for crypt() we generate random salt I've also added unit tests to verifying hashing is working as expected. |
||
| # passlib auto-generates a cryptographically random salt | ||
| # no crypt id as this uses SHA-512, so crypt_id from Provisioning.PasswordCryptId will be ignored | ||
| return sha512_crypt.hash(password) | ||
| else: | ||
| raise ImportError( | ||
| "Password hashing is unavailable. Install one of: 'crypt' (Python < 3.13), " | ||
| "'legacycrypt', or 'passlib'." | ||
| ) | ||
|
|
||
| def create_account(self, user, password, expiration, thumbprint, enable_nopasswd): | ||
| """ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,194 @@ | ||
| #!/usr/bin/env python | ||
| # | ||
| # Copyright 2026 Microsoft Corporation | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
| # | ||
| # Tests for gen_password_hash in GenericDistro (distroutils.py). | ||
| # | ||
| # Strategy: | ||
| # - We cannot compare hashes across libraries because each generates a fresh | ||
| # random salt, so hashes will always differ. | ||
| # - Instead, we verify each library's hash *verifies* against the original | ||
| # password using that same library. | ||
| # - We also test that gen_password_hash raises ImportError when nothing is available. | ||
|
|
||
| import unittest | ||
| import unittest.mock as mock | ||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Minimal stub config so GenericDistro can be instantiated without the full | ||
| # extension environment. | ||
| # --------------------------------------------------------------------------- | ||
| class _StubConfig: | ||
| def get(self, key): | ||
| return None | ||
|
|
||
|
|
||
| def _make_distro(): | ||
| # Import after path manipulation if needed; distroutils expects Utils.* | ||
| # to be importable, so run tests from the repo root: | ||
| # python -m pytest Utils/test/test_distroutils_password_hash.py | ||
| import Utils.distroutils as du | ||
| return du.GenericDistro(_StubConfig()) | ||
|
|
||
|
|
||
| class TestGenPasswordHashWithCrypt(unittest.TestCase): | ||
| """Hash produced by the built-in 'crypt' (or 'legacycrypt') module.""" | ||
|
|
||
| def setUp(self): | ||
| # Skip if crypt / legacycrypt is not available in this environment. | ||
| try: | ||
| import crypt as _crypt | ||
| self._crypt = _crypt.crypt | ||
| except ImportError: | ||
| try: | ||
| from legacycrypt import crypt as _crypt | ||
| self._crypt = _crypt | ||
| except ImportError: | ||
| self.skipTest("Neither 'crypt' nor 'legacycrypt' is available") | ||
|
|
||
| def test_hash_verifies_with_crypt(self): | ||
| distro = _make_distro() | ||
| password = "TestP@ssw0rd!" | ||
| hash_val = distro.gen_password_hash(password, crypt_id=6, salt_len=10) | ||
|
|
||
| # A valid crypt hash starts with the salt prefix | ||
| self.assertTrue(hash_val.startswith("$6$"), "Expected SHA-512 hash prefix '$6$'") | ||
| # Verify: re-hashing with the produced hash as the salt must equal the hash | ||
| self.assertEqual(self._crypt(password, hash_val), hash_val, | ||
| "Hash does not verify against the original password") | ||
|
|
||
| def test_different_passwords_produce_different_hashes(self): | ||
|
narrieta marked this conversation as resolved.
|
||
| distro = _make_distro() | ||
| hash1 = distro.gen_password_hash("PasswordOne1!", crypt_id=6, salt_len=10) | ||
| hash2 = distro.gen_password_hash("PasswordTwo2!", crypt_id=6, salt_len=10) | ||
| self.assertNotEqual(hash1, hash2) | ||
|
|
||
| def test_same_password_produces_different_hashes_due_to_salting(self): | ||
| # Each call generates a fresh random salt, so the same password must | ||
| # produce a different hash every time — proving salting is in effect. | ||
| distro = _make_distro() | ||
| password = "TestP@ssw0rd!" | ||
| hash1 = distro.gen_password_hash(password, crypt_id=6, salt_len=10) | ||
| hash2 = distro.gen_password_hash(password, crypt_id=6, salt_len=10) | ||
| self.assertNotEqual(hash1, hash2, "Same password produced identical hashes — salt may not be random") | ||
|
|
||
| def test_crypt_id_5_produces_sha256_hash(self): | ||
| distro = _make_distro() | ||
| hash_val = distro.gen_password_hash("TestP@ssw0rd!", crypt_id=5, salt_len=10) | ||
| self.assertTrue(hash_val.startswith("$5$"), "Expected SHA-256 hash prefix '$5$'") | ||
|
|
||
|
|
||
| class TestGenPasswordHashWithPasslib(unittest.TestCase): | ||
| """Hash produced by passlib (fallback when crypt/legacycrypt unavailable).""" | ||
|
|
||
| def setUp(self): | ||
| try: | ||
| from passlib.hash import sha512_crypt as _sha512 | ||
| self._sha512 = _sha512 | ||
| except ImportError: | ||
| self.skipTest("'passlib' is not available") | ||
|
|
||
| def test_hash_verifies_with_passlib(self): | ||
| import Utils.distroutils as du | ||
|
|
||
| # Force the passlib path by temporarily patching the module-level flags. | ||
| with mock.patch.object(du, 'cryptImported', False), \ | ||
| mock.patch.object(du, 'passLibImported', True): | ||
| distro = du.GenericDistro(_StubConfig()) | ||
| password = "TestP@ssw0rd!" | ||
| hash_val = distro.gen_password_hash(password, crypt_id=6, salt_len=10) | ||
|
|
||
| self.assertTrue(hash_val.startswith("$6$"), | ||
| "Expected passlib SHA-512 hash prefix '$6$'") | ||
| self.assertTrue(self._sha512.verify(password, hash_val), | ||
| "Hash does not verify against the original password") | ||
|
|
||
| def test_passlib_different_passwords_produce_different_hashes(self): | ||
| import Utils.distroutils as du | ||
|
|
||
| with mock.patch.object(du, 'cryptImported', False), \ | ||
| mock.patch.object(du, 'passLibImported', True): | ||
| distro = du.GenericDistro(_StubConfig()) | ||
| hash1 = distro.gen_password_hash("PasswordOne1!", crypt_id=6, salt_len=10) | ||
| hash2 = distro.gen_password_hash("PasswordTwo2!", crypt_id=6, salt_len=10) | ||
|
|
||
| self.assertNotEqual(hash1, hash2) | ||
|
|
||
| def test_passlib_same_password_produces_different_hashes_due_to_salting(self): | ||
| import Utils.distroutils as du | ||
|
|
||
| with mock.patch.object(du, 'cryptImported', False), \ | ||
| mock.patch.object(du, 'passLibImported', True): | ||
| distro = du.GenericDistro(_StubConfig()) | ||
| password = "TestP@ssw0rd!" | ||
| hash1 = distro.gen_password_hash(password, crypt_id=6, salt_len=10) | ||
| hash2 = distro.gen_password_hash(password, crypt_id=6, salt_len=10) | ||
|
|
||
| self.assertNotEqual(hash1, hash2, "Same password produced identical hashes — passlib salt may not be random") | ||
|
|
||
| class TestCreateAccountPasswordHashFailure(unittest.TestCase): | ||
| """ | ||
| Verify behavior of create_account and change_password when no hashing | ||
| library is available. | ||
|
|
||
| gen_password_hash raises ImportError, which propagates through | ||
| chpasswd -> change_password -> create_account, causing vmaccess.py to | ||
| fail the extension operation via its general except block. | ||
| """ | ||
|
|
||
| def test_create_account_raises_when_password_hash_unavailable(self): | ||
| import Utils.distroutils as du | ||
|
|
||
| with mock.patch.object(du, 'cryptImported', False), \ | ||
| mock.patch.object(du, 'passLibImported', False), \ | ||
| mock.patch('pwd.getpwnam', side_effect=KeyError), \ | ||
| mock.patch('Utils.extensionutils.run', return_value=0), \ | ||
| mock.patch('os.path.isdir', return_value=True), \ | ||
| mock.patch('Utils.extensionutils.set_file_contents'), \ | ||
| mock.patch('os.chmod'): | ||
| distro = du.GenericDistro(_StubConfig()) | ||
| with self.assertRaises(ImportError): | ||
| distro.create_account( | ||
| user="testuser", | ||
| password="SomePassword1!", | ||
| expiration=None, | ||
| thumbprint=None, | ||
| enable_nopasswd=False | ||
| ) | ||
|
|
||
| class TestGenPasswordHashNoLibraryAvailable(unittest.TestCase): | ||
| """When no hashing library is importable, gen_password_hash raises ImportError.""" | ||
|
|
||
| def test_gen_password_hash_raises_when_nothing_importable(self): | ||
| import Utils.distroutils as du | ||
|
|
||
| with mock.patch.object(du, 'cryptImported', False), \ | ||
| mock.patch.object(du, 'passLibImported', False): | ||
| distro = du.GenericDistro(_StubConfig()) | ||
| with self.assertRaises(ImportError): | ||
| distro.gen_password_hash("SomePassword1!", crypt_id=6, salt_len=10) | ||
|
|
||
| def test_chpasswd_raises_when_nothing_importable(self): | ||
| import Utils.distroutils as du | ||
|
|
||
| with mock.patch.object(du, 'cryptImported', False), \ | ||
| mock.patch.object(du, 'passLibImported', False): | ||
| distro = du.GenericDistro(_StubConfig()) | ||
| with self.assertRaises(ImportError): | ||
| distro.chpasswd("someuser", "SomePassword1!") | ||
|
|
||
|
|
||
| if __name__ == '__main__': | ||
| unittest.main() | ||
Uh oh!
There was an error while loading. Please reload this page.