Repository: freedomofpress/securedrop

Issue:
determine post-upgrade failure-mode for a SHA-1-signed submission key
## Description

After #6948 (for #6399), redwood will refuse to encrypt to a submission key with a SHA-1 signature.

After #6928, `securedrop-admin sdconfig` will reject a submission key with a SHA-1 signature.  This check guarantees that new and reconfigured instances will comply with #6948.

What will happen to an instance with a SHA-1-signed signature after upgrading to v2.7.0?

## Possible approaches

| Option | Documentation changes | Code changes | Implication |
| --- | --- | --- | --- |
| Fail open, but log | optional | ✓ | Admin must monitor logs and/or OSSEC alerts. |
| Fail open, but document | ✓ | ✗ | Admin must monitor release notes or check documentation. |
| Fail closed | optional | ✓[1] | Admin can contact us for help. |

**Notes:**
1. @legoktm observes that, without a code change to handle this case, Apache will come back up after reboot even if the `postinst` script fails under `unattended-upgrades`.


Discussion:
For context, we expect this to be a pretty rare issue given that if people followed our documentation, they should not have generated a SHA-1 submission key.

The submission key is currently stored in the GPG keyring, so we export it to disk for Sequoia to read (and for SecureDrop to serve) in the postinst:
```bash
export_journalist_public_key() {
    # config.py is root-writable, so it's safe to run it as root to extract the fingerprint
    journalist_pub="/var/lib/securedrop/journalist.pub"
    # If the journalist.pub file doesn't exist
    if ! test -f $journalist_pub; then
        # And we have a config.py (during initial install it won't exist yet)
        if test -f /var/www/securedrop/config.py; then
            # n.b. based on sdconfig.py, this should work with very old config.py files.
            fingerprint=$(cd /var/www/securedrop; python3 -c "import config; print(config.JOURNALIST_KEY)")
            # Set up journalist.pub as root/www-data 640 before writing to it.
            touch $journalist_pub
            chown root:www-data $journalist_pub
            chmod 640 $journalist_pub
            # Export the GPG public key
            # shellcheck disable=SC2024
            sudo -u www-data gpg2 --homedir=/var/lib/securedrop/keys --export --armor "$fingerprint" > $journalist_pub
            # Verify integrity of what we just exported
            sudo -u www-data /var/www/securedrop/scripts/validate-pgp-key "$journalist_pub" "$fingerprint"
        fi
    fi

}
```

The final step is to validate the exported key file passes `validate-pgp-key`, which is a wrapper for `redwood.is_valid_public_key()`. However, if validation fails for whatever reason (e.g. GPG export fails, it's a SHA-1 key, etc.), then we abort, which interrupts the postinst, so, e.g. database upgrades won't be applied. apache will be down, though it should be restarted when the nightly instance reboot happens.

With that in mind, I think having the validation fail at package install time is bad, because it requires even more manual work to get an instance in a working state since you need to manually apply the updates after doing a key rotation.

One of the proposals in standup from @zenmonkeykstop  was to just have the JI exit if the submission key isn't valid, which will alert journalists and then their admin that something is wrong. I think this is reasonable and probably a better UX, and will let us remove the validate step in the postinst IMO.
Works for me (:)) - if we can just use is_valid_public_key() in app initialization then that sounds simple enough. 
Yeah, from trying this on my staging instance I was momentarily confused by the postinst validation/failure, and as you mentioned it has a bunch of unintended side-effects. I think it's better to have it fail in the JI. In this case we don't have to worry about feature parity with SDW.

Resolving patch:
diff --git a/securedrop/journalist.py b/securedrop/journalist.py
--- a/securedrop/journalist.py
+++ b/securedrop/journalist.py
@@ -1,9 +1,13 @@
+import sys
+
 from encryption import EncryptionManager, GpgKeyNotFoundError
 from execution import asynchronous
 from journalist_app import create_app
 from models import Source
 from sdconfig import SecureDropConfig
 
+import redwood
+
 config = SecureDropConfig.get_current()
 # app is imported by journalist.wsgi
 app = create_app(config)
@@ -21,10 +25,28 @@ def prime_keycache() -> None:
                 pass
 
 
-prime_keycache()
+def validate_journalist_key() -> None:
+    """Verify the journalist PGP key is valid"""
+    encryption_mgr = EncryptionManager.get_default()
+    # First check that we can read it
+    try:
+        journalist_key = encryption_mgr.get_journalist_public_key()
+    except Exception as e:
+        print(f"ERROR: Unable to read journalist public key: {e}", file=sys.stderr)
+        app.logger.error(f"ERROR: Unable to read journalist public key: {e}")
+        sys.exit(1)
+    # And then what we read is valid
+    try:
+        redwood.is_valid_public_key(journalist_key)
+    except redwood.RedwoodError as e:
+        print(f"ERROR: Journalist public key is not valid: {e}", file=sys.stderr)
+        app.logger.error(f"ERROR: Journalist public key is not valid: {e}")
+        sys.exit(1)
 
 
 if __name__ == "__main__":  # pragma: no cover
+    validate_journalist_key()
+    prime_keycache()
     debug = getattr(config, "env", "prod") != "prod"
     # nosemgrep: python.flask.security.audit.app-run-param-config.avoid_app_run_with_bad_host
     app.run(debug=debug, host="0.0.0.0", port=8081)


Test patch:
diff --git a/securedrop/tests/test_journalist.py b/securedrop/tests/test_journalist.py
--- a/securedrop/tests/test_journalist.py
+++ b/securedrop/tests/test_journalist.py
@@ -17,6 +17,7 @@
 from flaky import flaky
 from flask import escape, g, url_for
 from flask_babel import gettext, ngettext
+from journalist import validate_journalist_key
 from journalist_app.sessions import session
 from journalist_app.utils import mark_seen
 from models import (
@@ -51,6 +52,8 @@
 from tests.utils.instrument import InstrumentedApp
 from two_factor import TOTP
 
+from redwood import RedwoodError
+
 from .utils import create_legacy_gpg_key, login_journalist
 
 # Smugly seed the RNG for deterministic testing
@@ -3772,3 +3775,20 @@ def test_journalist_deletion(journalist_app, app_storage):
     assert len(SeenReply.query.filter_by(journalist_id=deleted.id).all()) == 2
     # And there are no login attempts
     assert JournalistLoginAttempt.query.all() == []
+
+
+def test_validate_journalist_key(config, capsys):
+    # The test key passes validation
+    validate_journalist_key()
+    # Reading the key file fails
+    with patch(
+        "encryption.EncryptionManager.get_journalist_public_key", side_effect=RuntimeError("err")
+    ):
+        with pytest.raises(SystemExit):
+            validate_journalist_key()
+    assert capsys.readouterr().err == "ERROR: Unable to read journalist public key: err\n"
+    # Key fails validation
+    with patch("redwood.is_valid_public_key", side_effect=RedwoodError("err")):
+        with pytest.raises(SystemExit):
+            validate_journalist_key()
+    assert capsys.readouterr().err == "ERROR: Journalist public key is not valid: err\n"