Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 42 additions & 22 deletions Utils/distroutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
norakoiralamsft marked this conversation as resolved.
passLibImported = True
except ImportError:
pass
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 ?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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():
Expand Down Expand Up @@ -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)
Comment thread
norakoiralamsft marked this conversation as resolved.
elif passLibImported:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor

@dylanbun dylanbun Apr 16, 2026

Choose a reason for hiding this comment

The 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 sha512_crypt and passing in salt).

Copy link
Copy Markdown
Member

@narrieta narrieta Apr 17, 2026

Choose a reason for hiding this comment

The 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):

Provisioning.PasswordCryptId
Type: String
Default: 6

Algorithm used by crypt when generating password hash. 

******This parameter, as well as PasswordCryptSaltLength, is used only on Python versions previous to 3.13, and SHA-512 is the default for most distros. From Python 3.13 onwards, SHA-512 is always used, regardless of the value of this parameter.*******

1 - MD5
2a - Blowfish
5 - SHA-256
6 - SHA-512

I think that would be appropriate for VmAccess as well, but pointing it out in case you want to follow a different approach.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor Author

@norakoiralamsft norakoiralamsft Apr 21, 2026

Choose a reason for hiding this comment

The 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):
"""
Expand Down
194 changes: 194 additions & 0 deletions Utils/test/test_distroutils_password_hash.py
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):
Comment thread
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()