Repository: ansible/ansible

Issue:
unbreak password_hash('blowfish')
##### SUMMARY
The used salt buffer was of incorrect length (16 instead of 22). Also the
passlib documentation recommends against generating your own salt. So unless
the salt was provided, let passlib generate it.

Also set the default bcrypto algorithm to '2b' which is the recommended format.

##### ISSUE TYPE
 - Bugfix Pull Request

##### COMPONENT NAME
`password_hash` filter

##### ANSIBLE VERSION
<!--- Paste verbatim output from “ansible --version” between quotes below -->
```
ansible 2.3.0.0
  config file = /home/jasper/.ansible.cfg
  configured module search path = Default w/o overrides
  python version = 2.7.13 (default, May 24 2017, 16:43:59) [GCC 4.2.1 20070719 ]
```

##### ADDITIONAL INFORMATION
The upstream documentation for passlib can be found [here](http://passlib.readthedocs.io/en/stable/lib/passlib.hash.bcrypt.html?highlight=blowfish).

About salt it writes:
```
salt (str) – Optional salt string. If not specified, one will be autogenerated (this is recommended). If specified, it must be 22 characters, drawn from the regexp range [./0-9A-Za-z].
```

and about the algorithm:
```
"2a" - some implementations suffered from rare security flaws, replaced by 2b.
[..]
"2b" - latest revision of the official BCrypt algorithm, current default.
```

Before:
```
fatal: [192.168.2.1]: FAILED! => {"failed": true, "msg": "the field 'args' has an invalid value ([]), and could not be converted to an dict. Error was: salt too small (bcrypt
+requires exactly 22 chars)\n\nThe error appears to have been in '/home/jirib/ansible/test.yml': line 11, column 7, but may\nbe elsewhere in the file depending on the exact syntax
+problem.\n\nThe offending line appears to be:\n\n  tasks:\n    - name: Create users from secret.yml\n      ^ here\n"}
```

After:
```
changed: [192.168.2.1]
```

More informatively I've ran a `debug` task (`debug: msg="{{ 'emiel' | password_hash('blowfish') }}"`)  that used to return:
```
TASK [debug] ************************************************************************************************************************************************************************
fatal: [localhost]: FAILED! => {"failed": true, "msg": "the field 'args' has an invalid value ([]), and could not be converted to an dict. Error was: salt too small (bcrypt requires exactly 22 chars)\n\nThe error appears to have been in '/private/tmp/ansible-blowfish/users.yml': line 8, column 5, but may\nbe elsewhere in the file depending on the exact syntax problem.\n\nThe offending line appears to be:\n\n  tasks:\n  - debug: msg=\"{{ 'emiel' | password_hash('blowfish') }}\"\n    ^ here\nWe could be wrong, but this one looks like it might be an issue with\nmissing quotes.  Always quote template expression brackets when they\nstart a value. For instance:\n\n    with_items:\n      - {{ foo }}\n\nShould be written as:\n\n    with_items:\n      - \"{{ foo }}\"\n"}
```

and now returns:
```
ok: [localhost] => {
    "changed": false,
    "msg": "$2b$12$yhShz4JEDMGWCZO3AFpBq..gn6TIq9ydFcxOtqlFcIxdQFJc4zHk2"
}
```
unbreak password_hash('blowfish')
##### SUMMARY
The used salt buffer was of incorrect length (16 instead of 22). Also the
passlib documentation recommends against generating your own salt. So unless
the salt was provided, let passlib generate it.

Also set the default bcrypto algorithm to '2b' which is the recommended format.

##### ISSUE TYPE
 - Bugfix Pull Request

##### COMPONENT NAME
`password_hash` filter

##### ANSIBLE VERSION
<!--- Paste verbatim output from “ansible --version” between quotes below -->
```
ansible 2.3.0.0
  config file = /home/jasper/.ansible.cfg
  configured module search path = Default w/o overrides
  python version = 2.7.13 (default, May 24 2017, 16:43:59) [GCC 4.2.1 20070719 ]
```

##### ADDITIONAL INFORMATION
The upstream documentation for passlib can be found [here](http://passlib.readthedocs.io/en/stable/lib/passlib.hash.bcrypt.html?highlight=blowfish).

About salt it writes:
```
salt (str) – Optional salt string. If not specified, one will be autogenerated (this is recommended). If specified, it must be 22 characters, drawn from the regexp range [./0-9A-Za-z].
```

and about the algorithm:
```
"2a" - some implementations suffered from rare security flaws, replaced by 2b.
[..]
"2b" - latest revision of the official BCrypt algorithm, current default.
```

Before:
```
fatal: [192.168.2.1]: FAILED! => {"failed": true, "msg": "the field 'args' has an invalid value ([]), and could not be converted to an dict. Error was: salt too small (bcrypt
+requires exactly 22 chars)\n\nThe error appears to have been in '/home/jirib/ansible/test.yml': line 11, column 7, but may\nbe elsewhere in the file depending on the exact syntax
+problem.\n\nThe offending line appears to be:\n\n  tasks:\n    - name: Create users from secret.yml\n      ^ here\n"}
```

After:
```
changed: [192.168.2.1]
```

More informatively I've ran a `debug` task (`debug: msg="{{ 'emiel' | password_hash('blowfish') }}"`)  that used to return:
```
TASK [debug] ************************************************************************************************************************************************************************
fatal: [localhost]: FAILED! => {"failed": true, "msg": "the field 'args' has an invalid value ([]), and could not be converted to an dict. Error was: salt too small (bcrypt requires exactly 22 chars)\n\nThe error appears to have been in '/private/tmp/ansible-blowfish/users.yml': line 8, column 5, but may\nbe elsewhere in the file depending on the exact syntax problem.\n\nThe offending line appears to be:\n\n  tasks:\n  - debug: msg=\"{{ 'emiel' | password_hash('blowfish') }}\"\n    ^ here\nWe could be wrong, but this one looks like it might be an issue with\nmissing quotes.  Always quote template expression brackets when they\nstart a value. For instance:\n\n    with_items:\n      - {{ foo }}\n\nShould be written as:\n\n    with_items:\n      - \"{{ foo }}\"\n"}
```

and now returns:
```
ok: [localhost] => {
    "changed": false,
    "msg": "$2b$12$yhShz4JEDMGWCZO3AFpBq..gn6TIq9ydFcxOtqlFcIxdQFJc4zHk2"
}
```


Discussion:
I also noticed that the passlib import check we're doing is no longer sufficient... apparently there are now separate backend packages and passlib needs at least one of those installed to function.

Perhaps we need a try: except passlib.exc.MissingBackendError: in the passlib code and fallback to crypt if that fails as well.
Something like this perhaps:
``` python
encrypted = None

if HAS_PASSLIB:
    try:
        # passlib based code
    except passlib.exc.MissingBackendError:
        pass

if encrypted is None:
     if sys.platform.startswith('darwin'):
         raise errors.AnsibleFilterError('|password_hash requires the passlib python module to generate password hashes on Mac OS X/Darwin')
     saltstring = "$%s$%s" % (cryptmethod[hashtype], salt)
     encrypted = crypt.crypt(password, saltstring)
```
 Note: bcoca recommends a different error message if a passlib backend is missing rather than the same message as missing passlib altogether.
Passlib author here - I just incidentally ran across this thread, and wanted to point out that `crypt.crypt()` is one of the [backends](http://passlib.readthedocs.io/en/stable/lib/passlib.hash.bcrypt.html?highlight=blowfish#index-0) that passlib tests for when `bcrypt.hash()` is called.  

So if passlib throws MissingBackendError, it's reasonably certain that `crypt.crypt()` doesn't support bcrypt... or even worse, has broken support *[1]*.  Because of that, if passlib is present but throws MissingBackendError, I'd recommend not using crypt.crypt() as a bcrypt fallback.

*[1]* For example, in cases like linux/glibc, `crypt()` may not even throw an error, but fall back to the super-insecure des-crypt algorithm: `crypt.crypt("$2b$foo", "bar") --> "bac8SdYfKlb5."` 

Hmm... On my linux/glibc box, crypt.crypt does not fallback.  It returns None (Would be better to raise an exception but a sentinel like None can also be programmed against).


For reference, I'm using Linux Mint 18.1, python 3.5.2; but I'm not at all surprised you have different behavior.  

That's one of the things that started me coding passlib -- crypt()'s error/fallback behavior is hugely inconsistent at the C level, and python's wrapper just reflects that.  Given the same input of `crypt.crypt("$2b$foo", "bar")`, other platforms may even return things like "*0" or ":" to indicate an error, rather than None.  

Passlib's internal [safe_crypt()](https://bitbucket.org/ecollins/passlib/src/849ab1e6b5d4ace4c727a63d4adec928d6d72c13/passlib/utils/__init__.py?at=default&fileviewer=file-view-default#__init__.py-742) helper is an example of the number of edge cases I've had to normalize for :(

I also noticed that the passlib import check we're doing is no longer sufficient... apparently there are now separate backend packages and passlib needs at least one of those installed to function.

Perhaps we need a try: except passlib.exc.MissingBackendError: in the passlib code and fallback to crypt if that fails as well.
Something like this perhaps:
``` python
encrypted = None

if HAS_PASSLIB:
    try:
        # passlib based code
    except passlib.exc.MissingBackendError:
        pass

if encrypted is None:
     if sys.platform.startswith('darwin'):
         raise errors.AnsibleFilterError('|password_hash requires the passlib python module to generate password hashes on Mac OS X/Darwin')
     saltstring = "$%s$%s" % (cryptmethod[hashtype], salt)
     encrypted = crypt.crypt(password, saltstring)
```
 Note: bcoca recommends a different error message if a passlib backend is missing rather than the same message as missing passlib altogether.
Passlib author here - I just incidentally ran across this thread, and wanted to point out that `crypt.crypt()` is one of the [backends](http://passlib.readthedocs.io/en/stable/lib/passlib.hash.bcrypt.html?highlight=blowfish#index-0) that passlib tests for when `bcrypt.hash()` is called.  

So if passlib throws MissingBackendError, it's reasonably certain that `crypt.crypt()` doesn't support bcrypt... or even worse, has broken support *[1]*.  Because of that, if passlib is present but throws MissingBackendError, I'd recommend not using crypt.crypt() as a bcrypt fallback.

*[1]* For example, in cases like linux/glibc, `crypt()` may not even throw an error, but fall back to the super-insecure des-crypt algorithm: `crypt.crypt("$2b$foo", "bar") --> "bac8SdYfKlb5."` 

Hmm... On my linux/glibc box, crypt.crypt does not fallback.  It returns None (Would be better to raise an exception but a sentinel like None can also be programmed against).


For reference, I'm using Linux Mint 18.1, python 3.5.2; but I'm not at all surprised you have different behavior.  

That's one of the things that started me coding passlib -- crypt()'s error/fallback behavior is hugely inconsistent at the C level, and python's wrapper just reflects that.  Given the same input of `crypt.crypt("$2b$foo", "bar")`, other platforms may even return things like "*0" or ":" to indicate an error, rather than None.  

Passlib's internal [safe_crypt()](https://bitbucket.org/ecollins/passlib/src/849ab1e6b5d4ace4c727a63d4adec928d6d72c13/passlib/utils/__init__.py?at=default&fileviewer=file-view-default#__init__.py-742) helper is an example of the number of edge cases I've had to normalize for :(


Resolving patch:
diff --git a/lib/ansible/plugins/filter/core.py b/lib/ansible/plugins/filter/core.py
--- a/lib/ansible/plugins/filter/core.py
+++ b/lib/ansible/plugins/filter/core.py
@@ -41,19 +41,14 @@
 
 from jinja2.filters import environmentfilter, do_groupby as _do_groupby
 
-try:
-    import passlib.hash
-    HAS_PASSLIB = True
-except ImportError:
-    HAS_PASSLIB = False
-
-from ansible.errors import AnsibleFilterError
-from ansible.module_utils.six import iteritems, string_types, integer_types
+from ansible.errors import AnsibleError, AnsibleFilterError
+from ansible.module_utils.six import iteritems, string_types, integer_types, reraise
 from ansible.module_utils.six.moves import reduce, shlex_quote
 from ansible.module_utils._text import to_bytes, to_text
 from ansible.module_utils.common.collections import is_sequence
 from ansible.parsing.ajson import AnsibleJSONEncoder
 from ansible.parsing.yaml.dumper import AnsibleDumper
+from ansible.utils.encrypt import passlib_or_crypt
 from ansible.utils.hashing import md5s, checksum_s
 from ansible.utils.unicode import unicode_wrap
 from ansible.utils.vars import merge_hash
@@ -252,42 +247,19 @@ def get_hash(data, hashtype='sha1'):
     return h.hexdigest()
 
 
-def get_encrypted_password(password, hashtype='sha512', salt=None):
-
-    # TODO: find a way to construct dynamically from system
-    cryptmethod = {
-        'md5': '1',
-        'blowfish': '2a',
-        'sha256': '5',
-        'sha512': '6',
+def get_encrypted_password(password, hashtype='sha512', salt=None, salt_size=None, rounds=None):
+    passlib_mapping = {
+        'md5': 'md5_crypt',
+        'blowfish': 'bcrypt',
+        'sha256': 'sha256_crypt',
+        'sha512': 'sha512_crypt',
     }
 
-    if hashtype in cryptmethod:
-        if salt is None:
-            r = SystemRandom()
-            if hashtype in ['md5']:
-                saltsize = 8
-            else:
-                saltsize = 16
-            saltcharset = string.ascii_letters + string.digits + '/.'
-            salt = ''.join([r.choice(saltcharset) for _ in range(saltsize)])
-
-        if not HAS_PASSLIB:
-            if sys.platform.startswith('darwin'):
-                raise AnsibleFilterError('|password_hash requires the passlib python module to generate password hashes on macOS/Darwin')
-            saltstring = "$%s$%s" % (cryptmethod[hashtype], salt)
-            encrypted = crypt.crypt(password, saltstring)
-        else:
-            if hashtype == 'blowfish':
-                cls = passlib.hash.bcrypt
-            else:
-                cls = getattr(passlib.hash, '%s_crypt' % hashtype)
-
-            encrypted = cls.encrypt(password, salt=salt)
-
-        return encrypted
-
-    return None
+    hashtype = passlib_mapping.get(hashtype, hashtype)
+    try:
+        return passlib_or_crypt(password, hashtype, salt=salt, salt_size=salt_size, rounds=rounds)
+    except AnsibleError as e:
+        reraise(AnsibleFilterError, AnsibleFilterError(str(e), orig_exc=e), sys.exc_info()[2])
 
 
 def to_uuid(string):
diff --git a/lib/ansible/plugins/lookup/password.py b/lib/ansible/plugins/lookup/password.py
--- a/lib/ansible/plugins/lookup/password.py
+++ b/lib/ansible/plugins/lookup/password.py
@@ -100,7 +100,7 @@
 from ansible.module_utils._text import to_bytes, to_native, to_text
 from ansible.parsing.splitter import parse_kv
 from ansible.plugins.lookup import LookupBase
-from ansible.utils.encrypt import do_encrypt, random_password
+from ansible.utils.encrypt import do_encrypt, random_password, random_salt
 from ansible.utils.path import makedirs_safe
 
 
@@ -207,15 +207,6 @@ def _gen_candidate_chars(characters):
     return chars
 
 
-def _random_salt():
-    """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 = _gen_candidate_chars(['ascii_letters', 'digits', './'])
-    return random_password(length=8, chars=salt_chars)
-
-
 def _parse_content(content):
     '''parse our password data format into password and salt
 
@@ -329,7 +320,7 @@ def run(self, terms, variables, **kwargs):
 
             if params['encrypt'] and not salt:
                 changed = True
-                salt = _random_salt()
+                salt = random_salt()
 
             if changed and b_path != to_bytes('/dev/null'):
                 content = _format_content(plaintext_password, salt, encrypt=params['encrypt'])
diff --git a/lib/ansible/utils/encrypt.py b/lib/ansible/utils/encrypt.py
--- a/lib/ansible/utils/encrypt.py
+++ b/lib/ansible/utils/encrypt.py
@@ -4,18 +4,25 @@
 from __future__ import (absolute_import, division, print_function)
 __metaclass__ = type
 
+import crypt
 import multiprocessing
 import random
+import string
+import sys
+
+from collections import namedtuple
 
 from ansible import constants as C
 from ansible.errors import AnsibleError, AnsibleAssertionError
 from ansible.module_utils.six import text_type
 from ansible.module_utils._text import to_text, to_bytes
 
-
 PASSLIB_AVAILABLE = False
 try:
+    import passlib
     import passlib.hash
+    from passlib.utils.handlers import HasRawSalt
+
     PASSLIB_AVAILABLE = True
 except:
     pass
@@ -33,33 +40,6 @@
 DEFAULT_PASSWORD_LENGTH = 20
 
 
-def do_encrypt(result, encrypt, salt_size=None, salt=None):
-    if PASSLIB_AVAILABLE:
-        try:
-            crypt = getattr(passlib.hash, encrypt)
-        except:
-            raise AnsibleError("passlib does not support '%s' algorithm" % encrypt)
-
-        if salt_size:
-            result = crypt.encrypt(result, salt_size=salt_size)
-        elif salt:
-            if crypt._salt_is_bytes:
-                salt = to_bytes(salt, encoding='ascii', errors='strict')
-            else:
-                salt = to_text(salt, encoding='ascii', errors='strict')
-            result = crypt.encrypt(result, salt=salt)
-        else:
-            result = crypt.encrypt(result)
-    else:
-        raise AnsibleError("passlib must be installed to encrypt vars_prompt values")
-
-    # Hashes from passlib.hash should be represented as ascii strings of hex
-    # digits so this should not traceback.  If it's not representable as such
-    # we need to traceback and then blacklist such algorithms because it may
-    # impact calling code.
-    return to_text(result, errors='strict')
-
-
 def random_password(length=DEFAULT_PASSWORD_LENGTH, chars=C.DEFAULT_PASSWORD_CHARS):
     '''Return a random password string of length containing only chars
 
@@ -72,3 +52,149 @@ def random_password(length=DEFAULT_PASSWORD_LENGTH, chars=C.DEFAULT_PASSWORD_CHA
 
     random_generator = random.SystemRandom()
     return u''.join(random_generator.choice(chars) for dummy in range(length))
+
+
+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)
+
+
+class BaseHash(object):
+    algo = namedtuple('algo', ['crypt_id', 'salt_size', 'implicit_rounds'])
+    algorithms = {
+        'md5_crypt': algo(crypt_id='1', salt_size=8, implicit_rounds=None),
+        'bcrypt': algo(crypt_id='2a', salt_size=22, implicit_rounds=None),
+        'sha256_crypt': algo(crypt_id='5', salt_size=16, implicit_rounds=5000),
+        'sha512_crypt': algo(crypt_id='6', salt_size=16, implicit_rounds=5000),
+    }
+
+    def __init__(self, algorithm):
+        self.algorithm = algorithm
+
+
+class CryptHash(BaseHash):
+    def __init__(self, algorithm):
+        super(CryptHash, self).__init__(algorithm)
+
+        if sys.platform.startswith('darwin'):
+            raise AnsibleError("crypt.crypt not supported on Mac OS X/Darwin, install passlib python module")
+
+        if algorithm not in self.algorithms:
+            raise AnsibleError("crypt.crypt does not support '%s' algorithm" % self.algorithm)
+        self.algo_data = self.algorithms[algorithm]
+
+    def hash(self, secret, salt=None, salt_size=None, rounds=None):
+        salt = self._salt(salt, salt_size)
+        rounds = self._rounds(rounds)
+        return self._hash(secret, salt, rounds)
+
+    def _salt(self, salt, salt_size):
+        salt_size = salt_size or self.algo_data.salt_size
+        return salt or random_salt(salt_size)
+
+    def _rounds(self, rounds):
+        if rounds == self.algo_data.implicit_rounds:
+            # Passlib does not include the rounds if it is the same as implict_rounds.
+            # Make crypt lib behave the same, by not explicitly specifying the rounds in that case.
+            return None
+        else:
+            return rounds
+
+    def _hash(self, secret, salt, rounds):
+        if rounds is None:
+            saltstring = "$%s$%s" % (self.algo_data.crypt_id, salt)
+        else:
+            saltstring = "$%s$rounds=%d$%s" % (self.algo_data.crypt_id, rounds, salt)
+        result = crypt.crypt(secret, saltstring)
+
+        # crypt.crypt returns None if it cannot parse saltstring
+        # None as result would be interpreted by the some modules (user module)
+        # as no password at all.
+        if not result:
+            raise AnsibleError("crypt.crypt does not support '%s' algorithm" % self.algorithm)
+
+        return result
+
+
+class PasslibHash(BaseHash):
+    def __init__(self, algorithm):
+        super(PasslibHash, self).__init__(algorithm)
+
+        if not PASSLIB_AVAILABLE:
+            raise AnsibleError("passlib must be installed to hash with '%s'" % algorithm)
+
+        try:
+            self.crypt_algo = getattr(passlib.hash, algorithm)
+        except:
+            raise AnsibleError("passlib does not support '%s' algorithm" % algorithm)
+
+    def hash(self, secret, salt=None, salt_size=None, rounds=None):
+        salt = self._clean_salt(salt)
+        rounds = self._clean_rounds(rounds)
+        return self._hash(secret, salt=salt, salt_size=salt_size, rounds=rounds)
+
+    def _clean_salt(self, salt):
+        if not salt:
+            return None
+        elif issubclass(self.crypt_algo, HasRawSalt):
+            return to_bytes(salt, encoding='ascii', errors='strict')
+        else:
+            return to_text(salt, encoding='ascii', errors='strict')
+
+    def _clean_rounds(self, rounds):
+        algo_data = self.algorithms.get(self.algorithm)
+        if rounds:
+            return rounds
+        elif algo_data and algo_data.implicit_rounds:
+            # The default rounds used by passlib depend on the passlib version.
+            # For consistency ensure that passlib behaves the same as crypt in case no rounds were specified.
+            # Thus use the crypt defaults.
+            return algo_data.implicit_rounds
+        else:
+            return None
+
+    def _hash(self, secret, salt, salt_size, rounds):
+        # Not every hash algorithm supports every paramter.
+        # Thus create the settings dict only with set parameters.
+        settings = {}
+        if salt:
+            settings['salt'] = salt
+        if salt_size:
+            settings['salt_size'] = salt_size
+        if rounds:
+            settings['rounds'] = rounds
+
+        # starting with passlib 1.7 'using' and 'hash' should be used instead of 'encrypt'
+        if hasattr(self.crypt_algo, 'hash'):
+            result = self.crypt_algo.using(**settings).hash(secret)
+        elif hasattr(self.crypt_algo, 'encrypt'):
+            result = self.crypt_algo.encrypt(secret, **settings)
+        else:
+            raise AnsibleError("installed passlib version %s not supported" % passlib.__version__)
+
+        # passlib.hash should always return something or raise an exception.
+        # Still ensure that there is always a result.
+        # Otherwise an empty password might be assumed by some modules, like the user module.
+        if not result:
+            raise AnsibleError("failed to hash with algorithm '%s'" % self.algorithm)
+
+        # Hashes from passlib.hash should be represented as ascii strings of hex
+        # digits so this should not traceback.  If it's not representable as such
+        # we need to traceback and then blacklist such algorithms because it may
+        # impact calling code.
+        return to_text(result, errors='strict')
+
+
+def passlib_or_crypt(secret, algorithm, salt=None, salt_size=None, rounds=None):
+    if PASSLIB_AVAILABLE:
+        return PasslibHash(algorithm).hash(secret, salt=salt, salt_size=salt_size, rounds=rounds)
+    else:
+        return CryptHash(algorithm).hash(secret, salt=salt, salt_size=salt_size, rounds=rounds)
+
+
+def do_encrypt(result, encrypt, salt_size=None, salt=None):
+    return passlib_or_crypt(result, encrypt, salt_size=salt_size, salt=salt)


Test patch:
diff --git a/test/integration/targets/vars_prompt/test-vars_prompt.py b/test/integration/targets/vars_prompt/test-vars_prompt.py
--- a/test/integration/targets/vars_prompt/test-vars_prompt.py
+++ b/test/integration/targets/vars_prompt/test-vars_prompt.py
@@ -94,7 +94,7 @@ def run_test(playbook, test_spec, args=None, timeout=10, env=None):
      'test_spec': [
          [('password', 'Scenic-Improving-Payphone\r'),
           ('confirm password', 'Scenic-Improving-Payphone\r')],
-         r'"password": "\$6\$rounds=']},
+         r'"password": "\$6\$']},
 
     # Test variables in prompt field
     # https://github.com/ansible/ansible/issues/32723
diff --git a/test/units/plugins/lookup/test_password.py b/test/units/plugins/lookup/test_password.py
--- a/test/units/plugins/lookup/test_password.py
+++ b/test/units/plugins/lookup/test_password.py
@@ -292,15 +292,6 @@ def test_gen_password(self):
                               (char, candidate_chars, params['chars']))
 
 
-class TestRandomSalt(unittest.TestCase):
-    def test(self):
-        res = password._random_salt()
-        expected_salt_candidate_chars = u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789./'
-        self.assertEquals(len(res), 8)
-        for res_char in res:
-            self.assertIn(res_char, expected_salt_candidate_chars)
-
-
 class TestParseContent(unittest.TestCase):
     def test_empty_password_file(self):
         plaintext_password, salt = password._parse_content(u'')
diff --git a/test/units/utils/test_encrypt.py b/test/units/utils/test_encrypt.py
new file mode 100644
--- /dev/null
+++ b/test/units/utils/test_encrypt.py
@@ -0,0 +1,133 @@
+# (c) 2018, Matthias Fuchs <matthias.s.fuchs@gmail.com>
+#
+# This file is part of Ansible
+#
+# Ansible is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Ansible is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Ansible.  If not, see <http://www.gnu.org/licenses/>.
+
+import pytest
+import sys
+
+from ansible.errors import AnsibleError, AnsibleFilterError
+from ansible.plugins.filter.core import get_encrypted_password
+from ansible.utils import encrypt
+
+
+class passlib_off(object):
+    def __init__(self):
+        self.orig = encrypt.PASSLIB_AVAILABLE
+
+    def __enter__(self):
+        encrypt.PASSLIB_AVAILABLE = False
+        return self
+
+    def __exit__(self, exception_type, exception_value, traceback):
+        encrypt.PASSLIB_AVAILABLE = self.orig
+
+
+def assert_hash(expected, secret, algorithm, **settings):
+    assert encrypt.CryptHash(algorithm).hash(secret, **settings) == expected
+
+    if encrypt.PASSLIB_AVAILABLE:
+        assert encrypt.passlib_or_crypt(secret, algorithm, **settings) == expected
+        assert encrypt.PasslibHash(algorithm).hash(secret, **settings) == expected
+    else:
+        with pytest.raises(AnsibleFilterError):
+            encrypt.PasslibHash(algorithm).hash(secret, **settings)
+
+
+def test_encrypt_with_rounds():
+    assert_hash("$5$12345678$uAZsE3BenI2G.nA8DpTl.9Dc8JiqacI53pEqRr5ppT7",
+                secret="123", algorithm="sha256_crypt", salt="12345678", rounds=5000)
+    assert_hash("$5$rounds=10000$12345678$JBinliYMFEcBeAXKZnLjenhgEhTmJBvZn3aR8l70Oy/",
+                secret="123", algorithm="sha256_crypt", salt="12345678", rounds=10000)
+    assert_hash("$6$12345678$LcV9LQiaPekQxZ.OfkMADjFdSO2k9zfbDQrHPVcYjSLqSdjLYpsgqviYvTEP/R41yPmhH3CCeEDqVhW1VHr3L.",
+                secret="123", algorithm="sha512_crypt", salt="12345678", rounds=5000)
+
+
+def test_encrypt_default_rounds():
+    assert_hash("$1$12345678$tRy4cXc3kmcfRZVj4iFXr/",
+                secret="123", algorithm="md5_crypt", salt="12345678")
+    assert_hash("$5$12345678$uAZsE3BenI2G.nA8DpTl.9Dc8JiqacI53pEqRr5ppT7",
+                secret="123", algorithm="sha256_crypt", salt="12345678")
+    assert_hash("$6$12345678$LcV9LQiaPekQxZ.OfkMADjFdSO2k9zfbDQrHPVcYjSLqSdjLYpsgqviYvTEP/R41yPmhH3CCeEDqVhW1VHr3L.",
+                secret="123", algorithm="sha512_crypt", salt="12345678")
+
+    assert encrypt.CryptHash("md5_crypt").hash("123")
+
+
+def test_password_hash_filter_no_passlib():
+    with passlib_off():
+        assert not encrypt.PASSLIB_AVAILABLE
+        assert get_encrypted_password("123", "md5", salt="12345678") == "$1$12345678$tRy4cXc3kmcfRZVj4iFXr/"
+
+        with pytest.raises(AnsibleFilterError):
+            get_encrypted_password("123", "crypt16", salt="12")
+
+
+def test_password_hash_filter_passlib():
+    if not encrypt.PASSLIB_AVAILABLE:
+        pytest.skip("passlib not available")
+
+    with pytest.raises(AnsibleFilterError):
+        get_encrypted_password("123", "sha257", salt="12345678")
+
+    # Uses 5000 rounds by default for sha256 matching crypt behaviour
+    assert get_encrypted_password("123", "sha256", salt="12345678") == "$5$12345678$uAZsE3BenI2G.nA8DpTl.9Dc8JiqacI53pEqRr5ppT7"
+    assert get_encrypted_password("123", "sha256", salt="12345678", rounds=5000) == "$5$12345678$uAZsE3BenI2G.nA8DpTl.9Dc8JiqacI53pEqRr5ppT7"
+
+    assert (get_encrypted_password("123", "sha256", salt="12345678", rounds=10000) ==
+            "$5$rounds=10000$12345678$JBinliYMFEcBeAXKZnLjenhgEhTmJBvZn3aR8l70Oy/")
+
+    assert (get_encrypted_password("123", "sha512", salt="12345678", rounds=6000) ==
+            "$6$rounds=6000$12345678$l/fC67BdJwZrJ7qneKGP1b6PcatfBr0dI7W6JLBrsv8P1wnv/0pu4WJsWq5p6WiXgZ2gt9Aoir3MeORJxg4.Z/")
+
+    assert (get_encrypted_password("123", "sha512", salt="12345678", rounds=5000) ==
+            "$6$12345678$LcV9LQiaPekQxZ.OfkMADjFdSO2k9zfbDQrHPVcYjSLqSdjLYpsgqviYvTEP/R41yPmhH3CCeEDqVhW1VHr3L.")
+
+    assert get_encrypted_password("123", "crypt16", salt="12") == "12pELHK2ME3McUFlHxel6uMM"
+
+    # Try algorithm that uses a raw salt
+    assert get_encrypted_password("123", "pbkdf2_sha256")
+
+
+def test_do_encrypt_no_passlib():
+    with passlib_off():
+        assert not encrypt.PASSLIB_AVAILABLE
+        assert encrypt.do_encrypt("123", "md5_crypt", salt="12345678") == "$1$12345678$tRy4cXc3kmcfRZVj4iFXr/"
+
+        with pytest.raises(AnsibleError):
+            encrypt.do_encrypt("123", "crypt16", salt="12")
+
+
+def test_do_encrypt_passlib():
+    if not encrypt.PASSLIB_AVAILABLE:
+        pytest.skip("passlib not available")
+
+    with pytest.raises(AnsibleError):
+        encrypt.do_encrypt("123", "sha257_crypt", salt="12345678")
+
+    # Uses 5000 rounds by default for sha256 matching crypt behaviour.
+    assert encrypt.do_encrypt("123", "sha256_crypt", salt="12345678") == "$5$12345678$uAZsE3BenI2G.nA8DpTl.9Dc8JiqacI53pEqRr5ppT7"
+
+    assert encrypt.do_encrypt("123", "md5_crypt", salt="12345678") == "$1$12345678$tRy4cXc3kmcfRZVj4iFXr/"
+
+    assert encrypt.do_encrypt("123", "crypt16", salt="12") == "12pELHK2ME3McUFlHxel6uMM"
+
+
+def test_random_salt():
+    res = encrypt.random_salt()
+    expected_salt_candidate_chars = u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789./'
+    assert len(res) == 8
+    for res_char in res:
+        assert res_char in expected_salt_candidate_chars