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

Commit

Permalink
Merge pull request #3998 from gratipay/symmetric-encryption
Browse files Browse the repository at this point in the history
implement symmetric encryption
  • Loading branch information
Paul Kuruvilla committed May 10, 2016
2 parents 279b7ca + 5ce0ff8 commit 1bb41d3
Show file tree
Hide file tree
Showing 14 changed files with 120 additions and 0 deletions.
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="

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):
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"}

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.

0 comments on commit 1bb41d3

Please sign in to comment.