Repository: freedomofpress/securedrop

Issue:
Encrypt replies to application key as well
We currently only encrypt replies to the source's generated key, which means once they're sent only the source can read them. To help provide context for journalists, it would be helpful to also encrypt with the journalist's application key (the same key used to encrypt submissions). That way, they will be able to decrypt their replies at the same time they decrypt submissions, and will be able to read the whole back-and-forth at will.



Discussion:

Resolving patch:
diff --git a/securedrop/crypto_util.py b/securedrop/crypto_util.py
--- a/securedrop/crypto_util.py
+++ b/securedrop/crypto_util.py
@@ -139,21 +139,26 @@ def get_key_by_fingerprint(fingerprint):
     return matches[0] if matches else None
 
 
-def encrypt(fp, s, output=None):
-    r"""
-    >>> key = genkeypair('randomid', 'randomid')
-    >>> encrypt('randomid', "Goodbye, cruel world!")[:45]
-    '-----BEGIN PGP MESSAGE-----\nVersion: GnuPG v2'
-    """
+def encrypt(plaintext, fingerprints, output=None):
+    # Verify the output path
     if output:
         store.verify(output)
-    fp = fp.replace(' ', '')
-    if isinstance(s, unicode):
-        s = s.encode('utf8')
-    if isinstance(s, str):
-        out = gpg.encrypt(s, fp, output=output, always_trust=True)
-    else:
-        out = gpg.encrypt_file(s, fp, output=output, always_trust=True)
+
+    # Remove any spaces from provided fingerpints
+    # GPG outputs fingerprints with spaces for readability, but requires the
+    # spaces to be removed when using fingerprints to specify recipients.
+    if not isinstance(fingerprints, (list, tuple)):
+        fingerprints = [fingerprints,]
+    fingerprints = [ fpr.replace(' ', '') for fpr in fingerprints ]
+
+    if isinstance(plaintext, unicode):
+        plaintext = plaintext.encode('utf8')
+
+    encrypt_fn = gpg.encrypt if isinstance(plaintext, str) else gpg.encrypt_file
+    out = encrypt_fn(plaintext,
+                     *fingerprints,
+                     output=output,
+                     always_trust=True)
     if out.ok:
         return out.data
     else:
diff --git a/securedrop/journalist.py b/securedrop/journalist.py
--- a/securedrop/journalist.py
+++ b/securedrop/journalist.py
@@ -428,8 +428,9 @@ def reply():
     g.source.interaction_count += 1
     filename = "{0}-reply.gpg".format(g.source.interaction_count)
 
-    crypto_util.encrypt(crypto_util.getkey(g.sid), msg, output=
-    store.path(g.sid, filename))
+    crypto_util.encrypt(msg,
+                        [ crypto_util.getkey(g.sid), config.JOURNALIST_KEY ],
+                        output=store.path(g.sid, filename))
 
     db_session.commit()
     return render_template('reply.html', sid=g.sid,
diff --git a/securedrop/store.py b/securedrop/store.py
--- a/securedrop/store.py
+++ b/securedrop/store.py
@@ -84,18 +84,18 @@ def save_file_submission(sid, count, journalist_filename, filename, stream):
     s = StringIO()
     with zipfile.ZipFile(s, 'w') as zf:
         zf.writestr(sanitized_filename, stream.read())
-    s.reset()
+    s.seek(0)
 
     filename = "{0}-{1}-doc.zip.gpg".format(count, journalist_filename)
     file_loc = path(sid, filename)
-    crypto_util.encrypt(config.JOURNALIST_KEY, s, file_loc)
+    crypto_util.encrypt(s, config.JOURNALIST_KEY, file_loc)
     return filename
 
 
 def save_message_submission(sid, count, journalist_filename, message):
     filename = "{0}-{1}-msg.gpg".format(count, journalist_filename)
     msg_loc = path(sid, filename)
-    crypto_util.encrypt(config.JOURNALIST_KEY, message, msg_loc)
+    crypto_util.encrypt(message, config.JOURNALIST_KEY, msg_loc)
     return filename
 
 


Test patch:
diff --git a/securedrop/tests/test_unit_integration.py b/securedrop/tests/test_unit_integration.py
--- a/securedrop/tests/test_unit_integration.py
+++ b/securedrop/tests/test_unit_integration.py
@@ -6,6 +6,8 @@
 from cStringIO import StringIO
 import zipfile
 from time import sleep
+import tempfile
+import shutil
 
 import mock
 
@@ -221,6 +223,37 @@ def test_reply_normal(self):
     def test_reply_unicode(self):
         self.helper_test_reply("Teşekkürler", True)
 
+    def _can_decrypt_with_key(self, msg, key_fpr, passphrase=None):
+        """
+        Test that the given GPG message can be decrypted with the given key
+        (identified by its fingerprint).
+        """
+        # GPG does not provide a way to specify which key to use to decrypt a
+        # message. Since the default keyring that we use has both the
+        # `config.JOURNALIST_KEY` and all of the reply keypairs, there's no way
+        # to use it to test whether a message is decryptable with a specific
+        # key.
+        gpg_tmp_dir = tempfile.mkdtemp()
+        gpg = gnupg.GPG(homedir=gpg_tmp_dir)
+
+        # Export the key of interest from the application's keyring
+        pubkey = self.gpg.export_keys(key_fpr)
+        seckey = self.gpg.export_keys(key_fpr, secret=True)
+        # Import it into our isolated temporary GPG directory
+        for key in (pubkey, seckey):
+            gpg.import_keys(key)
+
+        # Attempt decryption with the given key
+        if passphrase:
+            passphrase = crypto_util.hash_codename(passphrase,
+                    salt=crypto_util.SCRYPT_GPG_PEPPER)
+        decrypted_data = gpg.decrypt(msg, passphrase=passphrase)
+        self.assertTrue(decrypted_data.ok,
+                "Could not decrypt msg with key, gpg says: {}".format(decrypted_data.status))
+
+        # We have to clean up the temporary GPG dir
+        shutil.rmtree(gpg_tmp_dir)
+
     def helper_test_reply(self, test_reply, expected_success=True):
         test_msg = "This is a test message."
 
@@ -289,10 +322,24 @@ def helper_test_reply(self, test_reply, expected_success=True):
             rv = journalist_app.get(col_url)
             self.assertIn("reply-", rv.data)
 
-        _block_on_reply_keypair_gen(codename)
+        soup = BeautifulSoup(rv.data)
+
+        # Download the reply and verify that it can be decrypted with the
+        # journalist's key as well as the source's reply key
+        sid = soup.select('input[name="sid"]')[0]['value']
+        checkbox_values = [ soup.select('input[name="doc_names_selected"]')[1]['value'] ]
+        rv = self.journalist_app.post('/bulk', data=dict(
+            sid=sid,
+            action='download',
+            doc_names_selected=checkbox_values
+        ), follow_redirects=True)
+        self.assertEqual(rv.status_code, 200)
+        pgp_msg_re = r'-----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE-----'
+        pgp_msg = re.search(pgp_msg_re, rv.data, re.MULTILINE|re.DOTALL).group(0)
+        self._can_decrypt_with_key(pgp_msg, config.JOURNALIST_KEY)
+        self._can_decrypt_with_key(pgp_msg, crypto_util.getkey(sid), codename)
 
         # Test deleting reply on the journalist interface
-        soup = BeautifulSoup(rv.data)
         last_reply_number = len(soup.select('input[name="doc_names_selected"]')) - 1
         self.helper_filenames_delete(soup, last_reply_number)
 
@@ -305,7 +352,7 @@ def helper_test_reply(self, test_reply, expected_success=True):
 
             if not expected_success:
                 # there should be no reply
-                self.assertTrue("You have received a reply." not in rv.data)
+                self.assertNotIn("You have received a reply.", rv.data)
             else:
                 self.assertIn(
                     "You have received a reply. For your security, please delete all replies when you're done with them.", rv.data)