Secure, extensible, forward-compatable passwords.
authorJeremy Stanley <fungi@yuggoth.org>
Thu, 17 Jun 2010 23:49:58 +0000 (23:49 +0000)
committerJeremy Stanley <fungi@yuggoth.org>
Thu, 17 Jun 2010 23:49:58 +0000 (23:49 +0000)
* 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
lib/mudpy/misc.py
lib/mudpy/password.py [new file with mode: 0644]

index d462528..4efd9b2 100644 (file)
@@ -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()
index 3b11a12..609e155 100644 (file)
@@ -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 (file)
index 0000000..258855a
--- /dev/null
@@ -0,0 +1,185 @@
+# -*- coding: utf-8 -*-
+u"""Password hashing functions and constants for the mudpy engine."""
+
+# Copyright (c) 2004-2010 Jeremy Stanley <fungi@yuggoth.org>. 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
+