Repository: mozmeao/basket

Issue:
Encrypt task messages in the queue
Using a technique similar to one of [these](https://blog.adimian.com/2018/07/18/securing-celery-and-redis-without-ssl/) [posts](https://blog.heroku.com/securing-celery) we should be able to seamlessly use symmetric encryption to ensure data on the queue is safely unreadable without the key.

The technique involves the use of [Fernet](https://cryptography.readthedocs.io/en/latest/fernet/) from the Python Cryptography library, which is already installed in basket.


Discussion:

Resolving patch:
diff --git a/basket/news/celery.py b/basket/news/celery.py
--- a/basket/news/celery.py
+++ b/basket/news/celery.py
@@ -5,11 +5,59 @@
 # set the default Django settings module for the 'celery' program.
 os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'basket.settings')
 
+from django.conf import settings
+from django.utils.encoding import force_bytes
+
 from celery import Celery as CeleryBase
+from cryptography.fernet import Fernet, MultiFernet, InvalidToken
+from django_statsd.clients import statsd
+from kombu import serialization
+from kombu.utils import json
 from raven.contrib.celery import register_signal, register_logger_signal
 from raven.contrib.django.raven_compat.models import client
 
 
+FERNET = None
+
+if settings.KOMBU_FERNET_KEY:
+    FERNET = Fernet(settings.KOMBU_FERNET_KEY)
+    if settings.KOMBU_FERNET_KEY_PREVIOUS:
+        # this will try both keys. for key rotation.
+        FERNET = MultiFernet([FERNET, Fernet(settings.KOMBU_FERNET_KEY_PREVIOUS)])
+
+
+def fernet_dumps(message):
+    statsd.incr('basket.news.celery.fernet_dumps')
+    message = json.dumps(message)
+    if FERNET:
+        statsd.incr('basket.news.celery.fernet_dumps.encrypted')
+        return FERNET.encrypt(force_bytes(message))
+
+    statsd.incr('basket.news.celery.fernet_dumps.unencrypted')
+    return message
+
+
+def fernet_loads(encoded_message):
+    statsd.incr('basket.news.celery.fernet_loads')
+    if FERNET:
+        try:
+            encoded_message = FERNET.decrypt(force_bytes(encoded_message))
+        except InvalidToken:
+            statsd.incr('basket.news.celery.fernet_loads.unencrypted')
+        else:
+            statsd.incr('basket.news.celery.fernet_loads.encrypted')
+    else:
+        statsd.incr('basket.news.celery.fernet_loads.unencrypted')
+
+    return json.loads(encoded_message)
+
+
+serialization.unregister('json')
+serialization.register('json', fernet_dumps, fernet_loads,
+                       content_type='application/json',
+                       content_encoding='utf-8')
+
+
 class Celery(CeleryBase):
     def on_configure(self):
         # register a custom filter to filter out duplicate logs
diff --git a/basket/settings.py b/basket/settings.py
--- a/basket/settings.py
+++ b/basket/settings.py
@@ -238,7 +238,12 @@ def path(*args):
 # view rate limiting
 RATELIMIT_VIEW = 'basket.news.views.ratelimited'
 
+KOMBU_FERNET_KEY = config('KOMBU_FERNET_KEY', None)
+# for key rotation
+KOMBU_FERNET_KEY_PREVIOUS = config('KOMBU_FERNET_KEY_PREVIOUS', None)
 CELERY_TASK_ALWAYS_EAGER = config('CELERY_TASK_ALWAYS_EAGER', DEBUG, cast=bool)
+CELERY_TASK_SERIALIZER = 'json'
+CELERY_ACCEPT_CONTENT = ['json']
 CELERY_BROKER_URL = config('CELERY_BROKER_URL', None)
 CELERY_REDIS_MAX_CONNECTIONS = config('CELERY_REDIS_MAX_CONNECTIONS', 2, cast=int)
 CELERY_WORKER_DISABLE_RATE_LIMITS = True


Test patch: