Merge pull request #65 from samueldg/feature/secure_password_storage

Feature/secure password storage
This commit is contained in:
Nicholas Charriere 2017-05-16 10:08:17 -07:00 committed by GitHub
commit 2b4a6a4b50
4 changed files with 116 additions and 17 deletions

View file

@ -36,6 +36,17 @@ Anyway, this took us very little time to write, but we figure we'd save you the
trouble of writing it yourself, because maybe you are busy and have other things trouble of writing it yourself, because maybe you are busy and have other things
to do. Enjoy. to do. Enjoy.
Security
--------
Passwords are encrypted using `Fernet`_ symmetric encryption, from the `cryptography`_ library.
A random unique key is generated for each password, and is never stored;
it is rather sent as part of the password link.
This means that even if someone has access to the Redis store, the passwords are still safe.
.. _Fernet: https://cryptography.io/en/latest/fernet/
.. _cryptography: https://cryptography.io/en/latest/
Requirements Requirements
------------ ------------

View file

@ -4,3 +4,4 @@ MarkupSafe==0.18
Werkzeug==0.9.4 Werkzeug==0.9.4
itsdangerous==0.23 itsdangerous==0.23
redis==2.8.0 redis==2.8.0
cryptography==1.8.1

View file

@ -4,9 +4,10 @@ import sys
import uuid import uuid
import redis import redis
from redis.exceptions import ConnectionError
from cryptography.fernet import Fernet
from flask import abort, Flask, render_template, request from flask import abort, Flask, render_template, request
from redis.exceptions import ConnectionError
SNEAKY_USER_AGENTS = ('Slackbot', 'facebookexternalhit', 'Twitterbot', SNEAKY_USER_AGENTS = ('Slackbot', 'facebookexternalhit', 'Twitterbot',
@ -14,6 +15,7 @@ SNEAKY_USER_AGENTS = ('Slackbot', 'facebookexternalhit', 'Twitterbot',
'Iframely') 'Iframely')
SNEAKY_USER_AGENTS_RE = re.compile('|'.join(SNEAKY_USER_AGENTS)) SNEAKY_USER_AGENTS_RE = re.compile('|'.join(SNEAKY_USER_AGENTS))
NO_SSL = os.environ.get('NO_SSL', False) NO_SSL = os.environ.get('NO_SSL', False)
TOKEN_SEPARATOR = '~'
app = Flask(__name__) app = Flask(__name__)
@ -50,20 +52,72 @@ def check_redis_alive(fn):
return inner return inner
def encrypt(password):
"""
Take a password string, encrypt it with Fernet symmetric encryption,
and return the result (bytes), with the decryption key (bytes)
"""
encryption_key = Fernet.generate_key()
fernet = Fernet(encryption_key)
encrypted_password = fernet.encrypt(password.encode('utf-8'))
return encrypted_password, encryption_key
def decrypt(password, decryption_key):
"""
Decrypt a password (bytes) using the provided key (bytes),
and return the plain-text password (bytes).
"""
fernet = Fernet(decryption_key)
return fernet.decrypt(password)
def parse_token(token):
token_fragments = token.split(TOKEN_SEPARATOR, 1) # Split once, not more.
storage_key = token_fragments[0]
try:
decryption_key = token_fragments[1].encode('utf-8')
except IndexError:
decryption_key = None
return storage_key, decryption_key
@check_redis_alive @check_redis_alive
def set_password(password, ttl): def set_password(password, ttl):
key = uuid.uuid4().hex """
redis_client.setex(key, ttl, password) Encrypt and store the password for the specified lifetime.
return key
Returns a token comprised of the key where the encrypted password
is stored, and the decryption key.
"""
storage_key = uuid.uuid4().hex
encrypted_password, encryption_key = encrypt(password)
redis_client.setex(storage_key, ttl, encrypted_password)
encryption_key = encryption_key.decode('utf-8')
token = TOKEN_SEPARATOR.join([storage_key, encryption_key])
return token
@check_redis_alive @check_redis_alive
def get_password(key): def get_password(token):
password = redis_client.get(key) """
From a given token, return the initial password.
If the token is tilde-separated, we decrypt the password fetched from Redis.
If not, the password is simply returned as is.
"""
storage_key, decryption_key = parse_token(token)
password = redis_client.get(storage_key)
redis_client.delete(storage_key)
if password is not None: if password is not None:
password = password.decode('utf-8')
redis_client.delete(key) if decryption_key is not None:
return password password = decrypt(password, decryption_key)
return password.decode('utf-8')
def empty(value): def empty(value):
@ -105,13 +159,13 @@ def index():
@app.route('/', methods=['POST']) @app.route('/', methods=['POST'])
def handle_password(): def handle_password():
ttl, password = clean_input() ttl, password = clean_input()
key = set_password(password, ttl) token = set_password(password, ttl)
if NO_SSL: if NO_SSL:
base_url = request.url_root base_url = request.url_root
else: else:
base_url = request.url_root.replace("http://", "https://") base_url = request.url_root.replace("http://", "https://")
link = base_url + key link = base_url + token
return render_template('confirm.html', password_link=link) return render_template('confirm.html', password_link=link)

View file

@ -1,7 +1,9 @@
import time import time
import unittest import unittest
import uuid
from unittest import TestCase from unittest import TestCase
from cryptography.fernet import Fernet
from werkzeug.exceptions import BadRequest from werkzeug.exceptions import BadRequest
# noinspection PyPep8Naming # noinspection PyPep8Naming
@ -12,11 +14,6 @@ __author__ = 'davedash'
class SnapPassTestCase(TestCase): class SnapPassTestCase(TestCase):
def test_set_password(self):
"""Ensure we return a 32-bit key."""
key = snappass.set_password("foo", 30)
self.assertEqual(32, len(key))
def test_get_password(self): def test_get_password(self):
password = "melatonin overdose 1337!$" password = "melatonin overdose 1337!$"
key = snappass.set_password(password, 30) key = snappass.set_password(password, 30)
@ -24,6 +21,42 @@ class SnapPassTestCase(TestCase):
# Assert that we can't look this up a second time. # Assert that we can't look this up a second time.
self.assertEqual(None, snappass.get_password(key)) self.assertEqual(None, snappass.get_password(key))
def test_password_is_not_stored_in_plaintext(self):
password = "trustno1"
token = snappass.set_password(password, 30)
redis_key = token.split(snappass.TOKEN_SEPARATOR)[0]
stored_password_text = snappass.redis_client.get(redis_key).decode('utf-8')
self.assertFalse(password in stored_password_text)
def test_returned_token_format(self):
password = "trustsome1"
token = snappass.set_password(password, 30)
token_fragments = token.split(snappass.TOKEN_SEPARATOR)
self.assertEqual(2, len(token_fragments))
redis_key, encryption_key = token_fragments
self.assertEqual(32, len(redis_key))
try:
Fernet(encryption_key.encode('utf-8'))
except ValueError:
self.fail('the encryption key is not valid')
def test_encryption_key_is_returned(self):
password = "trustany1"
token = snappass.set_password(password, 30)
token_fragments = token.split(snappass.TOKEN_SEPARATOR)
redis_key, encryption_key = token_fragments
stored_password = snappass.redis_client.get(redis_key)
fernet = Fernet(encryption_key.encode('utf-8'))
decrypted_password = fernet.decrypt(stored_password).decode('utf-8')
self.assertEqual(password, decrypted_password)
def test_unencrypted_passwords_still_work(self):
unencrypted_password = "trustevery1"
storage_key = uuid.uuid4().hex
snappass.redis_client.setex(storage_key, 30, unencrypted_password)
retrieved_password = snappass.get_password(storage_key)
self.assertEqual(unencrypted_password, retrieved_password)
def test_password_is_decoded(self): def test_password_is_decoded(self):
password = "correct horse battery staple" password = "correct horse battery staple"
key = snappass.set_password(password, 30) key = snappass.set_password(password, 30)
@ -92,7 +125,7 @@ class SnapPassRoutesTestCase(TestCase):
for ua in a_few_sneaky_bots: for ua in a_few_sneaky_bots:
rv = self.app.get('/{0}'.format(key), headers={ 'User-Agent': ua }) rv = self.app.get('/{0}'.format(key), headers={ 'User-Agent': ua })
self.assertEquals(rv.status_code, 404) self.assertEqual(rv.status_code, 404)
if __name__ == '__main__': if __name__ == '__main__':