From dc6054f09cb4dd950321c8b7415cbef2033d826a Mon Sep 17 00:00:00 2001 From: Samuel Dion-Girardeau Date: Wed, 10 May 2017 22:53:50 -0400 Subject: [PATCH] Encrypt passwords stored in Redis Using symmetric encryption in the `cryptography`'s `Fernet` class, we can ensure that no one can snoop the passwords simply by having access to the Redis store. An encryption key is sent to the secret receiver, along with the 32 character Redis key that identifies the secret, which is needed to decrypt the password. --- requirements.txt | 1 + snappass/main.py | 75 +++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/requirements.txt b/requirements.txt index 2de30ad..eb11347 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ MarkupSafe==0.18 Werkzeug==0.9.4 itsdangerous==0.23 redis==2.8.0 +cryptography==1.8.1 diff --git a/snappass/main.py b/snappass/main.py index f272d41..8604981 100644 --- a/snappass/main.py +++ b/snappass/main.py @@ -8,11 +8,14 @@ from redis.exceptions import ConnectionError from flask import abort, Flask, render_template, request +from cryptography.fernet import Fernet + SNEAKY_USER_AGENTS = ('Slackbot', 'facebookexternalhit', 'Twitterbot', 'Facebot', 'WhatsApp', 'SkypeUriPreview') SNEAKY_USER_AGENTS_RE = re.compile('|'.join(SNEAKY_USER_AGENTS)) NO_SSL = os.environ.get('NO_SSL', False) +TOKEN_SEPARATOR = '~' app = Flask(__name__) @@ -49,20 +52,72 @@ def check_redis_alive(fn): 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 def set_password(password, ttl): - key = uuid.uuid4().hex - redis_client.setex(key, ttl, password) - return key + """ + Encrypt and store the password for the specified lifetime. + + 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 -def get_password(key): - password = redis_client.get(key) +def get_password(token): + """ + 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: - password = password.decode('utf-8') - redis_client.delete(key) - return password + + if decryption_key is not None: + password = decrypt(password, decryption_key) + + return password.decode('utf-8') def empty(value): @@ -104,13 +159,13 @@ def index(): @app.route('/', methods=['POST']) def handle_password(): ttl, password = clean_input() - key = set_password(password, ttl) + token = set_password(password, ttl) if NO_SSL: base_url = request.url_root else: base_url = request.url_root.replace("http://", "https://") - link = base_url + key + link = base_url + token return render_template('confirm.html', password_link=link)