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