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