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