Repository: freedomofpress/securedrop

Issue:
When a source's reply secret key is unavailable, but there are replies encrypted with it, the source can't log in
## Description



## Steps to Reproduce

- create a source in the SI and submit a file, then log out
- reply to the source from the JI
- delete the source's reply keypair
- attempt to log in as the source

## Expected Behavior
- source logs in but encrypted replies are not available

## Actual Behavior
- the source gets an error page and the following errors are thrown in source logs:
```
[Wed Oct 25 02:27:52.095767 2023] [wsgi:error] [pid 134751:tid 132485121509120] [remote 127.0.0.1:52012]   File "/var/www/securedrop/encryption.py", line 210, in decrypt_journalist_reply
[Wed Oct 25 02:27:52.095848 2023] [wsgi:error] [pid 134751:tid 132485121509120] [remote 127.0.0.1:52012]     raise GpgDecryptError(out.stderr)
[Wed Oct 25 02:27:52.095906 2023] [wsgi:error] [pid 134751:tid 132485121509120] [remote 127.0.0.1:52012] encryption.GpgDecryptError: [GNUPG:] ENC_TO 311FF89D40A5FAC9 1 0
[Wed Oct 25 02:27:52.095963 2023] [wsgi:error] [pid 134751:tid 132485121509120] [remote 127.0.0.1:52012] [GNUPG:] ENC_TO 2FB6D22BADC72E83 1 0
[Wed Oct 25 02:27:52.096057 2023] [wsgi:error] [pid 134751:tid 132485121509120] [remote 127.0.0.1:52012] [GNUPG:] KEY_CONSIDERED 7ECA271BA6894A7EBAE6414C4EE9B0B8EF362114 0
[Wed Oct 25 02:27:52.096117 2023] [wsgi:error] [pid 134751:tid 132485121509120] [remote 127.0.0.1:52012] gpg: encrypted with 4096-bit RSA key, ID 2FB6D22BADC72E83, created 2022-09-23
[Wed Oct 25 02:27:52.096183 2023] [wsgi:error] [pid 134751:tid 132485121509120] [remote 127.0.0.1:52012]       "SD test submission key"
[Wed Oct 25 02:27:52.096268 2023] [wsgi:error] [pid 134751:tid 132485121509120] [remote 127.0.0.1:52012] [GNUPG:] NO_SECKEY 2FB6D22BADC72E83
[Wed Oct 25 02:27:52.096327 2023] [wsgi:error] [pid 134751:tid 132485121509120] [remote 127.0.0.1:52012] gpg: encrypted with RSA key, ID 311FF89D40A5FAC9
[Wed Oct 25 02:27:52.096384 2023] [wsgi:error] [pid 134751:tid 132485121509120] [remote 127.0.0.1:52012] [GNUPG:] NO_SECKEY 311FF89D40A5FAC9
[Wed Oct 25 02:27:52.096475 2023] [wsgi:error] [pid 134751:tid 132485121509120] [remote 127.0.0.1:52012] [GNUPG:] BEGIN_DECRYPTION
[Wed Oct 25 02:27:52.096534 2023] [wsgi:error] [pid 134751:tid 132485121509120] [remote 127.0.0.1:52012] [GNUPG:] DECRYPTION_FAILED
[Wed Oct 25 02:27:52.096601 2023] [wsgi:error] [pid 134751:tid 132485121509120] [remote 127.0.0.1:52012] gpg: decryption failed: No secret key
[Wed Oct 25 02:27:52.096679 2023] [wsgi:error] [pid 134751:tid 132485121509120] [remote 127.0.0.1:52012] [GNUPG:] END_DECRYPTION
[Wed Oct 25 02:27:52.096737 2023] [wsgi:error] [pid 134751:tid 132485121509120] [remote 127.0.0.1:52012] 
``` 

Please provide screenshots where appropriate.

## Comments

Suggestions to fix, any other relevant information.



Discussion:
This should be an impossible state to end up in - if there's no key pair, you shouldn't have been able to leave a reply since there's no key to encrypt the message to. This is also an irrecoverable state, because even if we generate a new key pair, the message is encrypted to the old key pair so it can never be decrypted by the source.

So, we could display a nicer error page or just hide the replies upon decryption failure, but I think letting the error bubble up is appropriate for an impossible state in normal operation. Plus I think if decryption fails in general, that is bad and we should be erroring out instead of trying to recover.

Unless I'm missing something, I suggest wontfix.
The repro involves the keypair being unavailable *after* the reply was created, so it's very unlikely but not impossible. The replies are not recoverable but the source's passphrase would still be valid otherwise.

Also, as it stands this is a silent error - a source just gets a message that there was a problem with no indication of what went wrong and no obvious next step (the only link on the error page asks them to "look up another codename", which makes no sense in this context). If the instance is configured correctly SI logging is disabled, which means admins aren't alerted either. 

Given that the next release starts the process of removing GPG keypairs in favour of Sequioa and DB storage, the remote chance of the keychain getting into a broken state and a source's private key not being available for migration seems like it would be higher. Improving the error page is the most basic response in this case - I don't agree that WONTFIX is justified.
With an upgrade to 2.7.0-rc2 this gets even more fun. Since there's no public key to migrate the sources' relevant db fields are null, which means that if they try to log in a new key is generated for them. With that key in place, the JI displays a reply box for the source and replies can be sent. But the source still can't log in, so they can't read the replies encrypted with the new key.
I think my mental model is that if a source's key pair goes missing it's equivalent to half of their DB row being zeroed out, which is just irrecoverable corruption IMO. No objection to adding/improving a dedicated error page.

> Given that the next release starts the process of removing GPG keypairs in favour of Sequioa and DB storage, the remote chance of the keychain getting into a broken state and a source's private key not being available for migration seems like it would be higher.

Yeah, that seems right. Though we'd hope that people are regularly deleting sources, so the act of deleting a GPG key pair isn't an aberration. I do think we could use with more guards around the secret key export (some equivalent to `is_valid_public_key()` and verifying the passphrase can unlock it), which I can sketch out.

I think the scenario that's bugging me here is if there are incomplete or borked keyrings in prod instances right now, or if (somehow!) the Sequoia migration leaves the keyring in the state above. Once we're on 2.7.0 and all keys are migrated then yep, the only way this happens is if the db is corrupted or manually edited to drop keys. This is less about data consistency than it is about UX for sources, though. Through that lens, a message that (some) replies can't be decrypted seems better than an opaque error that cuts them off from using an existing passphrase.

Resolving patch:
diff --git a/securedrop/source_app/main.py b/securedrop/source_app/main.py
--- a/securedrop/source_app/main.py
+++ b/securedrop/source_app/main.py
@@ -7,7 +7,7 @@
 import store
 import werkzeug
 from db import db
-from encryption import EncryptionManager, GpgKeyNotFoundError
+from encryption import EncryptionManager, GpgDecryptError, GpgKeyNotFoundError
 from flask import (
     Blueprint,
     abort,
@@ -44,6 +44,7 @@
 from store import Storage
 
 import redwood
+from redwood import RedwoodError
 
 
 def make_blueprint(config: SecureDropConfig) -> Blueprint:
@@ -171,6 +172,8 @@ def lookup(logged_in_source: SourceUser) -> str:
                 current_app.logger.error("Could not decode reply %s" % reply.filename)
             except FileNotFoundError:
                 current_app.logger.error("Reply file missing: %s" % reply.filename)
+            except (GpgDecryptError, RedwoodError) as e:
+                current_app.logger.error(f"Could not decrypt reply {reply.filename}: {str(e)}")
             else:
                 reply.date = datetime.utcfromtimestamp(os.stat(reply_path).st_mtime)
                 replies.append(reply)


Test patch:
diff --git a/securedrop/tests/test_source.py b/securedrop/tests/test_source.py
--- a/securedrop/tests/test_source.py
+++ b/securedrop/tests/test_source.py
@@ -13,7 +13,7 @@
 import pytest
 import version
 from db import db
-from encryption import EncryptionManager, GpgKeyNotFoundError
+from encryption import EncryptionManager, GpgDecryptError, GpgKeyNotFoundError
 from flask import escape, g, request, session, url_for
 from journalist_app.utils import delete_collection
 from models import InstanceConfig, Reply, Source
@@ -347,6 +347,34 @@ def test_login_with_missing_reply_files(source_app, app_storage):
         assert SessionManager.is_user_logged_in(db_session=db.session)
 
 
+def test_login_with_undecryptable_reply_files(source_app, app_storage):
+    """
+    Test that source can log in when replies are present but cannot be decrypted
+    """
+    source, codename = utils.db_helper.init_source(app_storage)
+    journalist, _ = utils.db_helper.init_journalist()
+    replies = utils.db_helper.reply(app_storage, journalist, source, 1)
+    assert len(replies) > 0
+    reply_file_path = Path(app_storage.path(source.filesystem_id, replies[0].filename))
+    assert reply_file_path.exists()
+
+    with mock.patch("encryption.EncryptionManager.decrypt_journalist_reply") as repMock:
+        repMock.side_effect = GpgDecryptError()
+        with source_app.test_client() as app:
+            resp = app.get(url_for("main.login"))
+            assert resp.status_code == 200
+            text = resp.data.decode("utf-8")
+            assert "Enter Codename" in text
+
+            resp = app.post(
+                url_for("main.login"), data=dict(codename=codename), follow_redirects=True
+            )
+            assert resp.status_code == 200
+            text = resp.data.decode("utf-8")
+            assert "Submit Files" in text
+            assert SessionManager.is_user_logged_in(db_session=db.session)
+
+
 def test_login_no_pgp_keypair(source_app, app_storage):
     """
     Test the on-demand creation of a keypair when none exists