Skip to content
This repository has been archived by the owner on Feb 8, 2018. It is now read-only.

implement symmetric encryption #3998

Merged
merged 8 commits into from
May 10, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions bin/keygen.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env python2
from __future__ import absolute_import, division, print_function, unicode_literals
from cryptography.fernet import Fernet
print(Fernet.generate_key())
10 changes: 10 additions & 0 deletions bin/rekey.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env python2
from __future__ import absolute_import, division, print_function, unicode_literals

from gratipay import wireup

env = wireup.env()
db = wireup.db(env)
wireup.crypto(env)

print("{} record(s) rekeyed.".format(0)) # stubbed until we have something to rekey
8 changes: 8 additions & 0 deletions defaults.env
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ PORT=8537
BASE_URL=http://localhost:8537
DATABASE_MAXCONN=10

# This is a space-separated list of keys for MultiFernet. The first key will be
# the one used for encryption. All specified keys can be used for decryption.
# For instructions on rotating keys, see:
#
# http://inside.gratipay.com/howto/keep-secrets
#
CRYPTO_KEYS="1YrzmaGBUeFrwD9SpBOqv33a2ElGns9mUtldAmoU7hs="
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe leave a comment here saying that extra keys should be separated by a space?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, in a space separated list - which is the latest key (the one with which new items will be encrypted)? The first or the last?


GRATIPAY_ASSET_URL=/assets/
GRATIPAY_CACHE_STATIC=no
GRATIPAY_COMPRESS_ASSETS=no
Expand Down
1 change: 1 addition & 0 deletions gratipay/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
tell_sentry = website.tell_sentry = gratipay.wireup.make_sentry_teller(env)
website.db = gratipay.wireup.db(env)
website.mailer = gratipay.wireup.mail(env, website.project_root)
gratipay.wireup.crypto(env)
gratipay.wireup.base_url(website, env)
gratipay.wireup.secure_cookies(env)
gratipay.wireup.billing(env)
Expand Down
47 changes: 47 additions & 0 deletions gratipay/security/crypto.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from __future__ import absolute_import, division, print_function, unicode_literals

import hashlib
import json
import random
import string
import time

from cryptography.fernet import Fernet, MultiFernet


# utils
# =====
Expand Down Expand Up @@ -61,3 +64,47 @@ def constant_time_compare(val1, val2):
for x, y in zip(val1, val2):
result |= ord(x) ^ ord(y)
return result == 0


# Encrypting Packer
# =================

class EncryptingPacker(object):
"""Implement conversion of Python objects to/from encrypted bytestrings.

:param str key: a `Fernet`_ key to use for encryption and decryption
:param list old_keys: additional `Fernet`_ keys to use for decryption

.. note::

Encrypted messages contain the timestamp at which they were generated
*in plaintext*. See `our audit`_ for discussion of this and other
considerations with `Fernet`_.

.. _Fernet: https://cryptography.io/en/latest/fernet/
.. _our audit: https://github.com/gratipay/gratipay.com/pull/3998#issuecomment-216227070

"""

def __init__(self, key, *old_keys):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't this be simplified to just take *keys?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, maybe this was done to emphasize the answer to https://github.com/gratipay/gratipay.com/pull/3998/files#r62548400?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that's more or less what I was thinking. I wanted to differentiate between the first key, which is the one used for encryption (and decryption), and the old keys, which can only be used for decryption.

keys = [key] + list(old_keys)
self.fernet = MultiFernet([Fernet(k) for k in keys])

def pack(self, obj):
"""Given a JSON-serializable object, return a `Fernet`_ token.
"""
obj = json.dumps(obj) # serialize to unicode
obj = obj.encode('utf8') # convert to bytes
obj = self.fernet.encrypt(obj) # encrypt
return obj

def unpack(self, token):
"""Given a `Fernet`_ token with JSON in the ciphertext, return a Python object.
"""
obj = token
if not type(obj) is bytes:
raise TypeError("need bytes, got {}".format(type(obj)))
obj = self.fernet.decrypt(obj) # decrypt
obj = obj.decode('utf8') # convert to unicode
obj = json.loads(obj) # deserialize from unicode
return obj
6 changes: 6 additions & 0 deletions gratipay/wireup.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from gratipay.models.participant import Participant
from gratipay.models.team import Team
from gratipay.models import GratipayDB
from gratipay.security.crypto import EncryptingPacker
from gratipay.utils.emails import compile_email_spt, ConsoleMailer
from gratipay.utils.http_caching import asset_etag
from gratipay.utils.i18n import (
Expand All @@ -59,6 +60,10 @@ def db(env):

return db

def crypto(env):
keys = [k.encode('ASCII') for k in env.crypto_keys.split()]
Participant.encrypting_packer = EncryptingPacker(*keys)

def mail(env, project_root='.'):
if env.aws_ses_access_key_id and env.aws_ses_secret_access_key and env.aws_ses_default_region:
aspen.log_dammit("AWS SES is configured! We'll send mail through SES.")
Expand Down Expand Up @@ -377,6 +382,7 @@ def env():
BASE_URL = unicode,
DATABASE_URL = unicode,
DATABASE_MAXCONN = int,
CRYPTO_KEYS = unicode,
GRATIPAY_ASSET_URL = unicode,
GRATIPAY_CACHE_STATIC = is_yesish,
GRATIPAY_COMPRESS_ASSETS = is_yesish,
Expand Down
6 changes: 6 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,9 @@

./vendor/boto3-1.3.0.tar.gz

./vendor/pycparser-2.14.tar.gz
./vendor/cffi-1.6.0.tar.gz
./vendor/enum34-1.1.4.tar.gz
./vendor/idna-2.1.tar.gz
./vendor/ipaddress-1.0.16.tar.gz
./vendor/cryptography-1.3.2.tar.gz
38 changes: 38 additions & 0 deletions tests/py/test_security.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
from __future__ import absolute_import, division, print_function, unicode_literals

import struct
import datetime

from aspen import Response
from aspen.http.request import Request
from base64 import urlsafe_b64decode
from cryptography.fernet import Fernet, InvalidToken
from gratipay import security
from gratipay.models.participant import Participant
from gratipay.security.crypto import EncryptingPacker
from gratipay.testing import Harness
from pytest import raises

Expand Down Expand Up @@ -42,3 +49,34 @@ def test_ahtr_sets_x_content_type_options(self):
def test_ahtr_sets_x_xss_protection(self):
headers = self.client.GET('/about/').headers
assert headers['X-XSS-Protection'] == '1; mode=block'


# ep - EncryptingPacker

packed = b'gAAAAABXJMbdriJ984uMCMKfQ5p2UUNHB1vG43K_uJyzUffbu2Uwy0d71kAnqOKJ7Ww_FEQz9Dliw87UpM'\
b'5TdyoJsll5nMAicg=='

def test_ep_packs_encryptingly(self):
packed = Participant.encrypting_packer.pack({"foo": "bar"})
assert urlsafe_b64decode(packed)[0] == b'\x80' # Fernet version

def test_ep_unpacks_decryptingly(self):
assert Participant.encrypting_packer.unpack(self.packed) == {"foo": "bar"}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth checking whether encrypting_packer decrypts data encrypted with old keys too?


def test_ep_fails_to_unpack_old_data_with_a_new_key(self):
encrypting_packer = EncryptingPacker(Fernet.generate_key())
raises(InvalidToken, encrypting_packer.unpack, self.packed)

def test_ep_can_unpack_if_old_key_is_provided(self):
old_key = str(self.client.website.env.crypto_keys)
encrypting_packer = EncryptingPacker(Fernet.generate_key(), old_key)
assert encrypting_packer.unpack(self.packed) == {"foo": "bar"}

def test_ep_leaks_timestamp_derp(self):
# https://github.com/pyca/cryptography/issues/2714
timestamp, = struct.unpack(">Q", urlsafe_b64decode(self.packed)[1:9]) # unencrypted!
assert datetime.datetime.fromtimestamp(timestamp).year == 2016

def test_ep_demands_bytes(self):
raises(TypeError, Participant.encrypting_packer.unpack, buffer('buffer'))
raises(TypeError, Participant.encrypting_packer.unpack, 'unicode')
Binary file added vendor/cffi-1.6.0.tar.gz
Binary file not shown.
Binary file added vendor/cryptography-1.3.2.tar.gz
Binary file not shown.
Binary file added vendor/enum34-1.1.4.tar.gz
Binary file not shown.
Binary file added vendor/idna-2.1.tar.gz
Binary file not shown.
Binary file added vendor/ipaddress-1.0.16.tar.gz
Binary file not shown.
Binary file added vendor/pycparser-2.14.tar.gz
Binary file not shown.