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