diff --git a/bin/keygen.py b/bin/keygen.py new file mode 100755 index 0000000000..071454b8be --- /dev/null +++ b/bin/keygen.py @@ -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()) diff --git a/bin/rekey.py b/bin/rekey.py new file mode 100755 index 0000000000..61ceb0b4cd --- /dev/null +++ b/bin/rekey.py @@ -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 diff --git a/defaults.env b/defaults.env index 6bdd8319c2..cd08ea16fa 100644 --- a/defaults.env +++ b/defaults.env @@ -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 diff --git a/gratipay/main.py b/gratipay/main.py index 31618d59d5..eaebc89818 100644 --- a/gratipay/main.py +++ b/gratipay/main.py @@ -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) diff --git a/gratipay/security/crypto.py b/gratipay/security/crypto.py index e4adf0fe78..6644ff6c41 100644 --- a/gratipay/security/crypto.py +++ b/gratipay/security/crypto.py @@ -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 # ===== @@ -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 diff --git a/gratipay/wireup.py b/gratipay/wireup.py index b944633db3..26da247865 100644 --- a/gratipay/wireup.py +++ b/gratipay/wireup.py @@ -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 ( @@ -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.") @@ -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, diff --git a/requirements.txt b/requirements.txt index ef08117ba2..0edcfef5ad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tests/py/test_security.py b/tests/py/test_security.py index 88f0402b14..c02cf669cd 100644 --- a/tests/py/test_security.py +++ b/tests/py/test_security.py @@ -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 @@ -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') diff --git a/vendor/cffi-1.6.0.tar.gz b/vendor/cffi-1.6.0.tar.gz new file mode 100644 index 0000000000..941a17aa37 Binary files /dev/null and b/vendor/cffi-1.6.0.tar.gz differ diff --git a/vendor/cryptography-1.3.2.tar.gz b/vendor/cryptography-1.3.2.tar.gz new file mode 100644 index 0000000000..ce3fb6bc0b Binary files /dev/null and b/vendor/cryptography-1.3.2.tar.gz differ diff --git a/vendor/enum34-1.1.4.tar.gz b/vendor/enum34-1.1.4.tar.gz new file mode 100644 index 0000000000..0112aca9c4 Binary files /dev/null and b/vendor/enum34-1.1.4.tar.gz differ diff --git a/vendor/idna-2.1.tar.gz b/vendor/idna-2.1.tar.gz new file mode 100644 index 0000000000..c028c715d2 Binary files /dev/null and b/vendor/idna-2.1.tar.gz differ diff --git a/vendor/ipaddress-1.0.16.tar.gz b/vendor/ipaddress-1.0.16.tar.gz new file mode 100644 index 0000000000..5f8428d496 Binary files /dev/null and b/vendor/ipaddress-1.0.16.tar.gz differ diff --git a/vendor/pycparser-2.14.tar.gz b/vendor/pycparser-2.14.tar.gz new file mode 100644 index 0000000000..6cdaab19aa Binary files /dev/null and b/vendor/pycparser-2.14.tar.gz differ