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