Commits

Marc-Alexandre Chan  committed 1188ad0

Reworked User class password hashing to PBKDF2 SHA512

  • Participants
  • Parent commits 3cf6237

Comments (0)

Files changed (8)

File minibot/README

 For The Daily Prompt <http://reddit.com/r/thedailyprompt>
         A Shut Up and Write Project <http://reddit.com/r/shutupandwrite>
 
+== Introduction ==
+
 This application is the first step to automation of daily fiction-writing
 prompts on The Daily Prompt reddit. It serves to check a reddit account's
 private (generally a dedicated bot account), messages collect writing prompts
 the specified date, and post queued writing prompts at midnight on the specified
 date.
 
+== Running ==
+
 Two scripts are provided in the root directory of this archive (NOT in the
 minibot directory) to execute this application.
 
 application may be safely stopped by typing Ctrl+C into the command-line or
 terminal window on both UNIX-like and Windows systems. It is possible to use
 pythonw.exe instead of python.exe to avoid spawning a command line window, but
-no method for stopping the process is provided.
+no method for stopping the process is provided.
+
+== Dependencies ==
+- Python 2.6 or 2.7 (recent older versions may work, but no guarantees;
+  Python 3.x is not supported due to lack of dependency support)
+- reddit (PRAW should also work; you'll need to do a search-and-replace for
+  'import reddit' and 'from reddit import' to 'import praw' and 'from praw
+  import'. TODO: UPDATE THIS.)
+- SQLAlchemy
+- passlib

File minibot/config.py

 class SMinibot(Section, DateParseMixin):
     """ Represents the [minibot] section of the configuration file. """
     def __init__(self, values_pairs):
-        defaults = {'salt' : 'Write a little every day!',
+        defaults = {'hash_salt' : 'Write a little every day!',
+                    'hash_rounds' : 16384,
                     'user_agent' : 'DailyPromptMinibot/Default (no operator)',\
                     'refresh_rate' : 10,
                     'queue_rate' : 60,
         super(self.__class__, self).__init__(values_pairs, defaults.items())
 
     @property
-    def salt(self): return self.get('salt')
+    def hash_salt(self): return self.get('salt')
+    @property
+    def hash_rounds(self): return self.get('hash_rounds', int)
     @property
     def user_agent(self): return self.get('user_agent')
 

File minibot/db.py

 from sqlalchemy.schema import CheckConstraint, ForeignKey
 from sqlalchemy.ext.declarative import declarative_base, DeclarativeMeta
 
-from hashlib import sha256
+from passlib.hash import pbkdf2_sha512
+import hmac
+from hashlib import sha512
 from datetime import datetime
 import logging
 
     email     = Column(String(64), nullable=True, default='')
 
     # use set_password() to hash and set the password in the DB
-    _password = Column('password', String(64),
-                    CheckConstraint('LENGTH(password) == 64'), nullable=False)
+    # should be stored output of passlib.pbkdf2_sha512
+    _password = Column('password', String(199),
+                    CheckConstraint('LENGTH(password) >= 190'), nullable=False)
+    # password hashing configuration
+    ROUNDS_DEFAULT = 16384
+    SALT_SIZE = 64
 
     # posts - from Prompt.approver relationship
 
-    def __init__(self, r_id, uname, plain_password=None, email=None, salt=''):
+    def __init__(self, r_id, uname, plain_password=None, email=None,
+        globsalt=None, rounds=ROUNDS_DEFAULT):
+        """ Create a new User object. If no password is provided, the user is
+        assumed unregistered. See set_password for ``globsalt`` and ``rounds``
+        parameter descriptions. """
         self.r_id = r_id
         self.uname = uname
         if plain_password:
             self.registered = True
-            self.set_password(plain_password, salt)
+            self.set_password(plain_password, salt, globsalt, rounds)
         else:
             self.registered = False
-            self.set_password('default') # won't be usable, registered=False
+            # won't be usable since registered=False
+            self.set_password('default', globsalt, rounds)
 
         if email:
             self.email = email
 
-    def set_password(self, password, salt=''):
-        self._password = sha256(salt + password).hexdigest()
+    def set_password(self, password, globsalt=None, rounds=ROUNDS_DEFAULT):
+        """ Hash a password and stores it. Optionally specify a global salt
+        string ``globsalt`` (recommended). Without a ``globsalt``, the password
+        is stored using PBKDF2 implemented using SHA512 and ``round`` rounds
+        (default is 12000), with a salt of 64 bytes. With a ``globsalt``, the
+        password is first keyed using HMAC with SHA512. ``globsalt`` is never
+        stored in the database, and should be made part of application
+        configuration separate from the database. """
+        if globsalt:
+            keyed_pass = hmac.new(globsalt, password, sha512).digest()
+        else:
+            keyed_pass = password
+        self._password = pbkdf2_sha512.encrypt(keyed_pass,
+                             salt_size=self.SALT_SIZE, rounds=rounds)
 
-    def check_password(self, password, salt=''):
-        pwhash = sha256(salt + password).hexdigest()
-        return (pwhash == self._password)
+    def check_password(self, password, globsalt=None, rounds=ROUNDS_DEFAULT):
+        """ Check whether a password is correct. """
+        if globsalt:
+            keyed_pass = hmac.new(globsalt, password, sha512).digest()
+        else:
+            keyed_pass = password
+        return pbkdf2_sha512.verify(keyed_pass, self._password,
+                    salt_size=self.SALT_SIZE, rounds=rounds)
 
     def __repr__(self):
         """ Return a human-readable representation of the User row. """

File minibot/events.py

     def handle_exception(self, e):
         """ Handle an exception thrown by the command. Return True if succesful,
         False otherwise (to propagate the exception upward in the system). """
-        return False # all recoverable exceptions are handled in the code
+        # expected recoverable exceptions are handled in the code
+        # and Reddit errors are handled by the scheduler
+        return False
 
     def _exc_msg_data(self, msg):
         """ Return info string on a message for use in exceptions. """
                     localtime(msg.created_utc)))
 
 
-
 class CheckPostQueueEvent(object):
     """ Event to check the current prompt queue for the next post.
 
     def end(self):
         pass
 
+    def handle_exception(self, e):
+        """ Handle an exception thrown by the command. Return True if succesful,
+        False otherwise (to propagate the exception upward in the system). """
+        return False # expected recoverable exceptions are handled in the code
+
 
 class CheckSuggestionQueueEvent(object):
     """ Event to check the current suggestion thread queue for the next post.
         db     = self.res['dbsession']
         default_time_str = self.res['config.minibot'].default_time
         target_reddit = self.res['config.reddit'].target
+        hash_opts = {'globsalt' : self.res['config.minibot'].hash_salt,
+                     'rounds' : self.res['config.minibot'].hash_rounds}
         self.log = self.res['logger']
 
         ### process default and inferred Prompt attributes ###
             self.log.info(
                 "%s: User %s not found. Adding to database `user` table.",
                 classname(self), approver_name)
-            new_user = User(r_approver_id, approver_name)
+            new_user = User(r_approver_id, approver_name, **hash_opts)
             db.add(new_user) # set as unregistered by default
         else:
             self.log.debug(
         db     = self.res['dbsession']
         default_time_str = self.res['config.minibot'].default_time
         target_reddit = self.res['config.reddit'].target
+        hash_opts = {'globsalt' : self.res['config.minibot'].hash_salt,
+                     'rounds' : self.res['config.minibot'].hash_rounds}
         self.log = self.res['logger']
 
         upd_prompt = db.query(Prompt).filter(Prompt.id==self.id).first()
         approver_name = self.msg.author.name
 
         if not db.query(User).filter(User.r_id==upd_prompt.r_approver_id).count():
-            new_user = User(upd_prompt.r_approver_id, approver_name)
+            new_user = User(upd_prompt.r_approver_id, approver_name, **hash_opts)
             db.add(new_user) # set as unregistered by default
 
         self.log.info(

File minibot/minibot.ini.default

 [minibot]
-# Salt used for hashing passwords. Currently not used.
-salt = Write a little every day!
+# System-wide salt for hashing passwords (user auth not yet implemented).
+hash_salt = Write a little every day!
+
+# Number of rounds for password hashing (using the PBKDF2 SHA512 algorithm).
+# Should be between 1 and 2147483648. A few thousand to tens of thousand is
+# typical. Affects how long one password check takes.
+hash_rounds = 16384
 
 # User agent for HTTP requests made to the Reddit API. Default:
 # 'DailyPromptMinibot/Default (no operator)'
 queue_rate = 60
 
 # How often the bot checks for new messages (in seconds). Default: 10.
-msg_rate     = 10
+msg_rate = 10
 
 # How many messages to process at once, at most. Default: 25
-msg_chunk    = 25
+msg_chunk = 25
 
 # Default time for new events, if the time is not specified. Format is HH:mm:ss
 # (24-hour format). Default: 00:00:00 (midnight).

File minibot/test/config.ini

 [minibot]
-salt = Write a little every day!
+hash_salt = Write a little every day!
+hash_rounds = 8096
 user_agent = DailyPromptMinibot/TestConfig
 refresh_rate = 2
 queue_rate = 32

File minibot/test/config.py

 class TestConfig(unittest.TestCase):
     expected = {
         'minibot' : {
-            'salt' : 'Write a little every day!',
+            'hash_salt' : 'Write a little every day!',
+            'hash_rounds' : 8096,
             'user_agent' : 'DailyPromptMinibot/TestConfig',
             'refresh_rate' : 2,
             'queue_rate' : 32,

File minibot/test/db.py

                            loglevel=self.loglevel)
         self.testdate  = datetime(2012, 8, 9, 6, 50)
         self.testdate2 = datetime(2013, 8, 9, 18, 0)
+        self.globsalt = 'awkenilrhalkrjhaerhaerh'
+        self.rounds = 4096
 
     def tearDown(self):
         self.db.engine.dispose()
     def _test_insert(self):
         s = self.db.get_new_session()
         users = [
-            User('abc', 'ABabyCow'),
-            User('def', 'Dragon', 'password', 'dragon@gmail.invalid', '1234')]
+            User('abc', 'ABabyCow', globsalt=self.globsalt, rounds=self.rounds),
+            User('def', 'Dragon', 'password', 'dragon@gmail.invalid',
+                self.globsalt, self.rounds)]
         prompts = [
             Prompt('title', 'text', 'aprusr', user='ABabyCow', user_id='tomw',
                     source_url='source_url', source_thread='ab3c',
         self.assertEqual(users[0].r_id, 'abc', 'User 1: wrong id')
         self.assertEqual(users[0].uname, 'ABabyCow', 'User 1: wrong uname')
         self.assertFalse(users[0].registered, 'User 1 should not be registered')
-        self.assertTrue(users[0].check_password('default'),
-                        "User 1 password should be 'default'")
+        self.assertTrue(
+            users[0].check_password('default', self.globsalt, self.rounds),
+            "User 1 password should verify against 'default'")
         self.assertFalse(users[0].email, 'User 1 email should be blank')
 
         self.assertEqual(users[1].r_id, 'def', 'User 2: wrong id')
         self.assertEqual(users[1].uname, 'Dragon', 'User 2: wrong uname')
         self.assertTrue(users[1].registered, 'User 2 should be registered')
-        self.assertTrue(users[1].check_password('password', '1234'),
-                        "User 2 password should be 'password', salt '1234'")
+        self.assertTrue(
+            users[1].check_password('password', self.globsalt, self.rounds),
+            "User 2 password should verify against 'password'")
         self.assertEqual(users[1].email, 'dragon@gmail.invalid',
                         "User 2 email should be 'dragon@gmail.invalid'")
         s.commit()