From 520cbd3c71a1c3a90fc4425c400f4bb4572890a8 Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Thu, 17 Jun 2010 23:49:58 +0000 Subject: [PATCH] Secure, extensible, forward-compatable passwords. * lib/mudpy/__init__.py (modules): Added the new password module to the list. * lib/mudpy/misc.py (handler_checking_password) (handler_entering_new_password, handler_verifying_new_password): Replaced existing md5 usage with calls to the new password functions. * lib/mudpy/password.py: Implemented a new module to handle creating and verifying account password hashes. The functions and format are forward-compatable to new hashing algorithms, and can be scaled to allow tuning for CPU utilization/brute-force mitigation trade-offs. The new functions are not directly backward-compatable with the old format, but a utility function (upgrade_legacy_hash) is included to upgrade those hexdigests if needed. --- lib/mudpy/__init__.py | 2 +- lib/mudpy/misc.py | 27 ++------ lib/mudpy/password.py | 185 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 22 deletions(-) create mode 100644 lib/mudpy/password.py diff --git a/lib/mudpy/__init__.py b/lib/mudpy/__init__.py index d462528..4efd9b2 100644 --- a/lib/mudpy/__init__.py +++ b/lib/mudpy/__init__.py @@ -21,5 +21,5 @@ def load(): except NameError: exec(u"import %s" % module) # load the modules contained in this package -modules = [ u"misc", u"telnet" ] +modules = [ u"misc", u"password", u"telnet" ] load() diff --git a/lib/mudpy/misc.py b/lib/mudpy/misc.py index 3b11a12..609e155 100644 --- a/lib/mudpy/misc.py +++ b/lib/mudpy/misc.py @@ -1783,17 +1783,13 @@ def handler_entering_account_name(user): def handler_checking_password(user): u"""Handle the login account password.""" - import md5 + import password # get the next waiting line of input input_data = user.input_queue.pop(0) # does the hashed input equal the stored hash? - if unicode( - md5.new( - ( user.account.get(u"name") + input_data ).encode(u"utf-8") - ).hexdigest() - ) == user.account.get(u"passhash"): + if password.verify( input_data, user.account.get(u"passhash") ): # if so, set the username and load from cold storage if not user.replace_old_connections(): @@ -1820,7 +1816,7 @@ def handler_checking_password(user): def handler_entering_new_password(user): u"""Handle a new password entry.""" - import md5 + import password # get the next waiting line of input input_data = user.input_queue.pop(0) @@ -1836,14 +1832,7 @@ def handler_entering_new_password(user): ): # hash and store it, then move on to verification - user.account.set( - u"passhash", - unicode( - md5.new( - ( user.account.get(u"name") + input_data ).encode(u"utf-8") - ).hexdigest() - ) - ) + user.account.set( u"passhash", password.create(input_data) ) user.state = u"verifying_new_password" # the password was weak, try again if you haven't tried too many times @@ -1867,17 +1856,13 @@ def handler_entering_new_password(user): def handler_verifying_new_password(user): u"""Handle the re-entered new password for verification.""" - import md5 + import password # get the next waiting line of input input_data = user.input_queue.pop(0) # hash the input and match it to storage - if unicode( - md5.new( - ( user.account.get(u"name") + input_data ).encode(u"utf-8") - ).hexdigest() - ) == user.account.get(u"passhash"): + if password.verify( input_data, user.account.get(u"passhash") ): user.authenticate() # the hashes matched, so go active diff --git a/lib/mudpy/password.py b/lib/mudpy/password.py new file mode 100644 index 0000000..258855a --- /dev/null +++ b/lib/mudpy/password.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +u"""Password hashing functions and constants for the mudpy engine.""" + +# Copyright (c) 2004-2010 Jeremy Stanley . Permission +# to use, copy, modify, and distribute this software is granted under +# terms provided in the LICENSE file distributed with this software. + +# convenience constants for indexing the supported hashing algorithms, +# guaranteed a stable part of the interface +MD5 = 0 # hashlib.md5 +SHA1 = 1 # hashlib.sha1 +SHA224 = 2 # hashlib.sha224 +SHA256 = 3 # hashlib.sha256 +SHA384 = 4 # hashlib.sha385 +SHA512 = 5 # hashlib.sha512 + +def _pack_bytes(numbers): + """ + This is a wrapper around struct.pack, used to turn a list of integers + between 0 and 255 into a packed sequence akin to a C-style string. + """ + import struct + # this will need to be declared as b"" during 2to3 migration + packed = "" + for number in numbers: + number = int(number) + assert 0 <= number <= 255 + # need to use b"B" during 2to3 migration + packed += struct.pack("B", number) + return packed + +def _bytes_to_text(byte_sequence): + """ + This is a wrapper around base64.b64encode with preferences + appropriate for encoding Unix-style passwd hash strings. + """ + import base64 + return base64.b64encode( + byte_sequence, + u"./".encode(u"ascii") + ).rstrip(u"=") + +def _generate_salt(salt_len=2): + """ + This simply generates a sequence of pseudo-random characters (with + 6-bits of effective entropy per character). Since it relies on base64 + encoding (which operates on 6-bit chunks of data), we only generate + 0.75 times as many bytes (rounded up) as the number of characters we + need and discard any excess characters over the specified length. + This ensures full distribution over each character of the salt. + """ + import math, random + salt = [] + for i in xrange(int(math.ceil(salt_len*0.75))): + salt.append( random.randint(0,255) ) + return _bytes_to_text( _pack_bytes(salt) )[:salt_len] + +def upgrade_legacy_hash(legacy_hash, salt, sep=u"$"): + """ + This utility function is meant to provide a migration path for users + of mudpy's legacy account-name-salted MD5 hexdigest password hashes. + By passing the old passhash (as legacy_hash) and name (as salt) + facets to this function, a conforming new-style password hash will be + returned. + """ + import re + assert re.match(u"^[0-9a-f]{32}$", legacy_hash), "Not a valid MD5 hexdigest" + # this needs to be declared as b"" in 2to3 + collapsed = "" + for i in xrange(16): + # this needs to become a byte() call in 2to3 + collapsed += chr( int(legacy_hash[2*i:2*i+2], 16) ) + return u"%s%s%s%s%s%s%s%s" % ( + sep, + MD5, + sep, + 0, # 2**0 provides one round of hashing + sep, + salt, + sep, + _bytes_to_text(collapsed) + ) + +def create( + password, + salt=None, + algorithm=SHA1, + rounds=4, + salt_len=2, + sep=u"$" +): + """ + The meat of the module, this function takes a provided password and + generates a Unix-like passwd hash suitable for storage in portable, + text-based data files. The password is prepended with a salt (which + can also be specified explicitly, if the output needs to be + repeatable) and then hashed with the requested algorithm iterated as + many times as 2 raised to the power of the rounds parameter. + + The first character of the text returned by this function denotes the + separator character used to identify subsequent fields. The fields in + order are: + + 1. the decimal index number denoting which algorithm was used, also + mapped as convenience constants at the beginning of this module + + 2. the number of times (as an exponent of 2) which the algorithm was + iterated, represented by a decimal value between 0 and 16 + inclusive (0 results in one round, 16 results in 65536 rounds, and + anything higher than that is a potential resource consumption + denial of service on the application anyway) + + 3. the plain-text salt with which the password was prepended before + hashing + + 4. the resulting password hash itself, base64-encoded using . and / + as the two non-alpha-numeric characters required to reach 64 + + The defaults provided should be safe for everyday use, but for + something more heavy-duty may be in order for admin users, such as: + + create(password, algorithm=SHA256, rounds=12, salt_len=16) + """ + import hashlib + + # if a specific salt wasn't specified, we need to generate one + if not salt: + salt = _generate_salt(salt_len=salt_len) + + # make sure the algorithm index number is coerced into integer form, + # since it could also be passed as text (in decimal) for convenience + algorithm = int(algorithm) + + # the list of algorithms supported by this function corresponds to + # the convenience constants defined at the beginning of the module + algorithms = { + MD5: hashlib.md5, + SHA1: hashlib.sha1, + SHA224: hashlib.sha224, + SHA256: hashlib.sha256, + SHA384: hashlib.sha384, + SHA512: hashlib.sha512, + } + + # make sure the rounds exponent is coerced into integer form, since + # it could also be passed as text (in decimal) for convenience + rounds = int(rounds) + + # to avoid a potential resource consumption denial of service attack, + # only consider values in the range of 0-16 + assert 0 <= rounds <= 16 + + # here is where the salt is prepended to the provided password text + hashed = salt+password + + # iterate the hashing algorithm over its own digest the specified + # number of times + for i in xrange(2**rounds): + hashed = algorithms[algorithm](hashed).digest() + + # concatenate the output fields, coercing into text form as needed + return u"%s%s%s%s%s%s%s%s" % ( + sep, algorithm, sep, rounds, sep, salt, sep, _bytes_to_text(hashed) + ) + +def verify(password, encoded_hash): + """ + This simple function requires a text password and a mudpy-format + password hash (as generated by the create function). It returns True + if the password, hashed with the parameters from the encoded_hash, + comes out the same as the encoded_hash. + """ + sep = encoded_hash[0] + algorithm, rounds, salt, hashed = encoded_hash[1:].split(sep) + if encoded_hash == create( + password=password, + salt=salt, + sep=sep, + algorithm=algorithm, + rounds=rounds + ): + return True + else: + return False + -- 2.11.0