1"""
2Use pycrypto to generate random passwords on the fly.
3"""
4import logging
5import random
6import re
7import string
8
9import salt.utils.platform
10import salt.utils.stringutils
11from salt.exceptions import CommandExecutionError, SaltInvocationError
12
13try:
14    try:
15        from M2Crypto.Rand import rand_bytes as get_random_bytes
16    except ImportError:
17        try:
18            from Cryptodome.Random import get_random_bytes
19        except ImportError:
20            from Crypto.Random import get_random_bytes  # nosec
21    HAS_RANDOM = True
22except ImportError:
23    HAS_RANDOM = False
24
25try:
26    import crypt
27
28    HAS_CRYPT = True
29except ImportError:
30    HAS_CRYPT = False
31
32try:
33    import passlib.context
34
35    HAS_PASSLIB = True
36except ImportError:
37    HAS_PASSLIB = False
38
39log = logging.getLogger(__name__)
40
41
42def secure_password(
43    length=20,
44    use_random=True,
45    chars=None,
46    lowercase=True,
47    uppercase=True,
48    digits=True,
49    punctuation=True,
50    whitespace=False,
51    printable=False,
52):
53    """
54    Generate a secure password.
55    """
56    chars = chars or ""
57    if printable:
58        # as printable includes all other string character classes
59        # the other checks can be skipped
60        chars = string.printable
61    if not chars:
62        if lowercase:
63            chars += string.ascii_lowercase
64        if uppercase:
65            chars += string.ascii_uppercase
66        if digits:
67            chars += string.digits
68        if punctuation:
69            chars += string.punctuation
70        if whitespace:
71            chars += string.whitespace
72    try:
73        length = int(length)
74        pw = ""
75        while len(pw) < length:
76            if HAS_RANDOM and use_random:
77                encoding = None
78                if salt.utils.platform.is_windows():
79                    encoding = "UTF-8"
80                while True:
81                    try:
82                        char = salt.utils.stringutils.to_str(
83                            get_random_bytes(1), encoding=encoding
84                        )
85                        break
86                    except UnicodeDecodeError:
87                        continue
88                pw += re.sub(
89                    salt.utils.stringutils.to_str(
90                        r"[^{}]".format(re.escape(chars)), encoding=encoding
91                    ),
92                    "",
93                    char,
94                )
95            else:
96                pw += random.SystemRandom().choice(chars)
97        return pw
98    except Exception as exc:  # pylint: disable=broad-except
99        log.exception("Failed to generate secure passsword")
100        raise CommandExecutionError(str(exc))
101
102
103if HAS_CRYPT:
104    methods = {m.name.lower(): m for m in crypt.methods}
105else:
106    methods = {}
107known_methods = ["sha512", "sha256", "blowfish", "md5", "crypt"]
108
109
110def _gen_hash_passlib(crypt_salt=None, password=None, algorithm=None):
111    """
112    Generate a /etc/shadow-compatible hash for a non-local system
113    """
114    # these are the passlib equivalents to the 'known_methods' defined in crypt
115    schemes = ["sha512_crypt", "sha256_crypt", "bcrypt", "md5_crypt", "des_crypt"]
116
117    ctx = passlib.context.CryptContext(schemes=schemes)
118
119    kwargs = {"secret": password, "scheme": schemes[known_methods.index(algorithm)]}
120    if crypt_salt and "$" in crypt_salt:
121        # this salt has a rounds specifier.
122        #  passlib takes it as a separate parameter, split it out
123        roundsstr, split_salt = crypt_salt.split("$")
124        rounds = int(roundsstr.split("=")[-1])
125        kwargs.update({"salt": split_salt, "rounds": rounds})
126    else:
127        # relaxed = allow salts that are too long
128        kwargs.update({"salt": crypt_salt, "relaxed": True})
129    return ctx.hash(**kwargs)
130
131
132def _gen_hash_crypt(crypt_salt=None, password=None, algorithm=None):
133    """
134    Generate /etc/shadow hash using the native crypt module
135    """
136    if crypt_salt is None:
137        # setting crypt_salt to the algorithm makes crypt generate
138        #  a salt compatible with the specified algorithm.
139        crypt_salt = methods[algorithm]
140    else:
141        if algorithm != "crypt":
142            # all non-crypt algorithms are specified as part of the salt
143            crypt_salt = "${}${}".format(methods[algorithm].ident, crypt_salt)
144
145    try:
146        ret = crypt.crypt(password, crypt_salt)
147    except OSError:
148        ret = None
149    return ret
150
151
152def gen_hash(crypt_salt=None, password=None, algorithm=None):
153    """
154    Generate /etc/shadow hash
155    """
156    if password is None:
157        password = secure_password()
158
159    if algorithm is None:
160        # prefer the most secure natively supported method
161        algorithm = crypt.methods[0].name.lower() if HAS_CRYPT else known_methods[0]
162
163    if algorithm == "crypt" and crypt_salt and len(crypt_salt) != 2:
164        log.warning("Hash salt is too long for 'crypt' hash.")
165
166    if HAS_CRYPT and algorithm in methods:
167        return _gen_hash_crypt(
168            crypt_salt=crypt_salt, password=password, algorithm=algorithm
169        )
170    elif HAS_PASSLIB and algorithm in known_methods:
171        return _gen_hash_passlib(
172            crypt_salt=crypt_salt, password=password, algorithm=algorithm
173        )
174    else:
175        raise SaltInvocationError(
176            "Cannot hash using '{}' hash algorithm. Natively supported "
177            "algorithms are: {}. If passlib is installed ({}), the supported "
178            "algorithms are: {}.".format(
179                algorithm, list(methods), HAS_PASSLIB, known_methods
180            )
181        )
182