Clean up imports
[mudpy.git] / lib / mudpy / password.py
1 # -*- coding: utf-8 -*-
2 """Password hashing functions and constants for the mudpy engine."""
3
4 # Copyright (c) 2004-2013 Jeremy Stanley <fungi@yuggoth.org>. Permission
5 # to use, copy, modify, and distribute this software is granted under
6 # terms provided in the LICENSE file distributed with this software.
7
8 import base64
9 import hashlib
10 import math
11 import random
12 import re
13 import struct
14
15 # convenience constants for indexing the supported hashing algorithms,
16 # guaranteed a stable part of the interface
17 MD5 = 0  # hashlib.md5
18 SHA1 = 1  # hashlib.sha1
19 SHA224 = 2  # hashlib.sha224
20 SHA256 = 3  # hashlib.sha256
21 SHA384 = 4  # hashlib.sha385
22 SHA512 = 5  # hashlib.sha512
23
24
25 def _pack_bytes(numbers):
26     """
27     This is a wrapper around struct.pack, used to turn a list of integers
28     between 0 and 255 into a packed sequence akin to a C-style string.
29     """
30     packed = b""
31     for number in numbers:
32         number = int(number)
33         assert 0 <= number <= 255
34         packed += struct.pack("B", number)
35     return packed
36
37
38 def _bytes_to_text(byte_sequence):
39     """
40     This is a wrapper around base64.b64encode with preferences
41     appropriate for encoding Unix-style passwd hash strings.
42     """
43     return base64.b64encode(
44         byte_sequence,
45         b"./"
46     ).decode("ascii").rstrip("=")
47
48
49 def _generate_salt(salt_len=2):
50     """
51     This simply generates a sequence of pseudo-random characters (with
52     6-bits of effective entropy per character). Since it relies on base64
53     encoding (which operates on 6-bit chunks of data), we only generate
54     0.75 times as many bytes (rounded up) as the number of characters we
55     need and discard any excess characters over the specified length.
56     This ensures full distribution over each character of the salt.
57     """
58     salt = []
59     for i in range(int(math.ceil(salt_len * 0.75))):
60         salt.append(random.randint(0, 255))
61     return _bytes_to_text(_pack_bytes(salt))[:salt_len]
62
63
64 def upgrade_legacy_hash(legacy_hash, salt, sep="$"):
65     """
66     This utility function is meant to provide a migration path for users
67     of mudpy's legacy account-name-salted MD5 hexdigest password hashes.
68     By passing the old passhash (as legacy_hash) and name (as salt)
69     facets to this function, a conforming new-style password hash will be
70     returned.
71     """
72     assert re.match("^[0-9a-f]{32}$",
73                     legacy_hash), "Not a valid MD5 hexdigest"
74     collapsed = b""
75     for i in range(16):
76         # this needs to become a byte() call in 2to3
77         collapsed += bytes(legacy_hash[2 * i:2 * i + 2].decode("ascii"))
78     return "%s%s%s%s%s%s%s%s" % (
79         sep,
80         MD5,
81         sep,
82         0,  # 2**0 provides one round of hashing
83         sep,
84         salt,
85         sep,
86         _bytes_to_text(collapsed)
87     )
88
89
90 def create(
91     password,
92     salt=None,
93     algorithm=SHA1,
94     rounds=4,
95     salt_len=2,
96     sep="$"
97 ):
98     """
99     The meat of the module, this function takes a provided password and
100     generates a Unix-like passwd hash suitable for storage in portable,
101     text-based data files. The password is prepended with a salt (which
102     can also be specified explicitly, if the output needs to be
103     repeatable) and then hashed with the requested algorithm iterated as
104     many times as 2 raised to the power of the rounds parameter.
105
106     The first character of the text returned by this function denotes the
107     separator character used to identify subsequent fields. The fields in
108     order are:
109
110        1. the decimal index number indicating which algorithm was used,
111           also mapped as convenience constants at the beginning of this
112           module
113
114        2. the number of times (as an exponent of 2) which the algorithm
115           was iterated, represented by a decimal value between 0 and 16
116           inclusive (0 results in one round, 16 results in 65536 rounds,
117           and anything higher than that is a potential resource
118           consumption denial of service on the application anyway)
119
120        3. the plain-text salt with which the password was prepended
121           before hashing
122
123        4. the resulting password hash itself, base64-encoded using . and
124           / as the two non-alpha-numeric characters required to reach 64
125
126     The defaults provided should be safe for everyday use, but something
127     more heavy-duty may be in order for admin users, such as::
128
129        create(password, algorithm=SHA256, rounds=12, salt_len=16)
130     """
131
132     # if a specific salt wasn't specified, we need to generate one
133     if not salt:
134         salt = _generate_salt(salt_len=salt_len)
135
136     # make sure the algorithm index number is coerced into integer form,
137     # since it could also be passed as text (in decimal) for convenience
138     algorithm = int(algorithm)
139
140     # the list of algorithms supported by this function corresponds to
141     # the convenience constants defined at the beginning of the module
142     algorithms = {
143         MD5: hashlib.md5,
144         SHA1: hashlib.sha1,
145         SHA224: hashlib.sha224,
146         SHA256: hashlib.sha256,
147         SHA384: hashlib.sha384,
148         SHA512: hashlib.sha512,
149     }
150
151     # make sure the rounds exponent is coerced into integer form, since
152     # it could also be passed as text (in decimal) for convenience
153     rounds = int(rounds)
154
155     # to avoid a potential resource consumption denial of service attack,
156     # only consider values in the range of 0-16
157     assert 0 <= rounds <= 16
158
159     # here is where the salt is prepended to the provided password text
160     hashed = salt + password
161
162     # iterate the hashing algorithm over its own digest the specified
163     # number of times
164     for i in range(2 ** rounds):
165         hashed = algorithms[algorithm](hashed.encode("utf-8")).digest()
166         # TODO: remove this check after the switch to py3k
167         try:
168             hashed = "".join(format(x, "02x") for x in bytes(hashed))
169         except ValueError:
170             hashed = "".join(format(ord(x), "02x") for x in bytes(hashed))
171
172     # concatenate the output fields, coercing into text form as needed
173     return "%s%s%s%s%s%s%s%s" % (
174         sep, algorithm, sep, rounds, sep, salt, sep,
175         _bytes_to_text(hashed.encode("ascii"))
176     )
177
178
179 def verify(password, encoded_hash):
180     """
181     This simple function requires a text password and a mudpy-format
182     password hash (as generated by the create function). It returns True
183     if the password, hashed with the parameters from the encoded_hash,
184     comes out the same as the encoded_hash.
185     """
186     sep = encoded_hash[0]
187     algorithm, rounds, salt, hashed = encoded_hash.split(sep)[1:]
188     if encoded_hash == create(
189        password=password,
190        salt=salt,
191        sep=sep,
192        algorithm=algorithm,
193        rounds=rounds
194        ):
195         return True
196     else:
197         return False