Repository: freedomofpress/securedrop

Issue:
[2.7.0] JI does not fail loudly when configured with a SHA-1 key
## Description

Weak GPG keys are no longer supported (https://sequoia-pgp.org/blog/2023/02/01/202302-happy-sha1-day/). The JI should fail to start when configured with one (via a potential upgrade from a SecureDrop version that did support them and that was configured with a weak key), emitting an error in its logs as it does so. Instead, it's starting successfully and then failing on all encryption operations.

## Steps to Reproduce

- install 2.6.0 with a weak (RSA 1024-bit) key
- upgrade to 2.7.0
- visit the JI and attempt to submit a reply

## Expected Behavior

- JI is unavailable, `journalist-error.log` records message about weak journalist key

## Actual Behavior

- JI is available
- replies fail with a 500 error
- SI is available (this is actually expected) and submissions fail with a 500 error (this is suboptimal)


Please provide screenshots where appropriate.

## Comments

Suggestions to fix, any other relevant information.



Discussion:

Resolving patch:
diff --git a/securedrop/journalist.py b/securedrop/journalist.py
--- a/securedrop/journalist.py
+++ b/securedrop/journalist.py
@@ -5,8 +5,7 @@
 from journalist_app import create_app
 from models import Source
 from sdconfig import SecureDropConfig
-
-import redwood
+from startup import validate_journalist_key
 
 config = SecureDropConfig.get_current()
 # app is imported by journalist.wsgi
@@ -25,28 +24,14 @@ def prime_keycache() -> None:
                 pass
 
 
-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)
+# This code cannot be nested under if name == main because
+# it needs to run when journalist.wsgi imports this file.
+if not validate_journalist_key(app):
+    sys.exit(1)
+prime_keycache()
 
 
 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)
diff --git a/securedrop/source_app/__init__.py b/securedrop/source_app/__init__.py
--- a/securedrop/source_app/__init__.py
+++ b/securedrop/source_app/__init__.py
@@ -8,7 +8,7 @@
 import version
 import werkzeug
 from db import db
-from flask import Flask, g, redirect, render_template, request, session, url_for
+from flask import Flask, g, make_response, redirect, render_template, request, session, url_for
 from flask_babel import gettext
 from flask_wtf.csrf import CSRFError, CSRFProtect
 from models import InstanceConfig
@@ -17,6 +17,7 @@
 from source_app import api, info, main
 from source_app.decorators import ignore_static
 from source_app.utils import clear_session_and_redirect_to_logged_out_page
+from startup import validate_journalist_key
 
 
 def get_logo_url(app: Flask) -> str:
@@ -61,6 +62,9 @@ def setup_i18n() -> None:
     app.config["SQLALCHEMY_DATABASE_URI"] = config.DATABASE_URI
     db.init_app(app)
 
+    # Check if the Submission Key is valid; if not, we'll disable the UI
+    app.config["SUBMISSION_KEY_VALID"] = validate_journalist_key()
+
     @app.errorhandler(CSRFError)
     def handle_csrf_error(e: CSRFError) -> werkzeug.Response:
         return clear_session_and_redirect_to_logged_out_page(flask_session=session)
@@ -106,6 +110,15 @@ def check_tor2web() -> Optional[werkzeug.Response]:
             return redirect(url_for("info.tor2web_warning"))
         return None
 
+    @app.before_request
+    @ignore_static
+    def check_submission_key() -> Optional[werkzeug.Response]:
+        if not app.config["SUBMISSION_KEY_VALID"]:
+            session.clear()
+            g.show_offline_message = True
+            return make_response(render_template("offline.html"), 503)
+        return None
+
     @app.errorhandler(404)
     def page_not_found(error: werkzeug.exceptions.HTTPException) -> Tuple[str, int]:
         return render_template("notfound.html"), 404
diff --git a/securedrop/startup.py b/securedrop/startup.py
new file mode 100644
--- /dev/null
+++ b/securedrop/startup.py
@@ -0,0 +1,30 @@
+import sys
+from typing import Optional
+
+from encryption import EncryptionManager
+from flask import Flask
+
+import redwood
+
+
+def validate_journalist_key(app: Optional[Flask] = None) -> bool:
+    """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:
+        if app:
+            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}")
+        return False
+    # And then what we read is valid
+    try:
+        redwood.is_valid_public_key(journalist_key)
+    except redwood.RedwoodError as e:
+        if app:
+            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}")
+        return False
+
+    return True


Test patch:
diff --git a/molecule/testinfra/app/test_smoke.py b/molecule/testinfra/app/test_smoke.py
--- a/molecule/testinfra/app/test_smoke.py
+++ b/molecule/testinfra/app/test_smoke.py
@@ -2,6 +2,7 @@
 Basic smoke tests that verify the apps are functioning as expected
 """
 import json
+from pathlib import Path
 
 import pytest
 import testutils
@@ -10,6 +11,12 @@
 testinfra_hosts = [sdvars.app_hostname]
 
 
+JOURNALIST_PUB = "/var/lib/securedrop/journalist.pub"
+WEAK_KEY_CONTENTS = (
+    Path(__file__).parent.parent.parent.parent / "redwood/res/weak_sample_key.asc"
+).read_text()
+
+
 @pytest.mark.parametrize(
     ("name", "url", "curl_flags", "expected"),
     [
@@ -50,3 +57,39 @@ def test_redwood(host):
     assert "-----BEGIN PGP PUBLIC KEY BLOCK-----" in parsed[0]
     assert "-----BEGIN PGP PRIVATE KEY BLOCK-----" in parsed[1]
     assert len(parsed[2]) == 40
+
+
+@pytest.mark.skip_in_prod()
+def test_weak_submission_key(host):
+    """
+    If the Submission Key is weak (e.g. has a SHA-1 signature),
+    the JI should be down (500) and SI will return a 503.
+    """
+    with host.sudo():
+        old_public_key = host.file(JOURNALIST_PUB).content_string
+        try:
+            # Install a weak key
+            set_public_key(host, WEAK_KEY_CONTENTS)
+            assert host.run("systemctl restart apache2").rc == 0
+            # Now try to hit the JI
+            response = host.run("curl -Li http://localhost:8080/").stdout
+            assert "HTTP/1.1 500 Internal Server Error" in response
+            # Now hit the SI
+            response = host.run("curl -i http://localhost:80/").stdout
+            assert "HTTP/1.1 503 SERVICE UNAVAILABLE" in response  # Flask shouts
+            assert "We're sorry, our SecureDrop is currently offline." in response
+
+        finally:
+            set_public_key(host, old_public_key)
+            assert host.run("systemctl restart apache2").rc == 0
+
+
+def set_public_key(host, pubkey: str) -> None:
+    """apparently testinfra doesn't provide a function to write a file?"""
+    res = host.run(
+        f"/usr/bin/python3 -c "
+        f'\'import pathlib; pathlib.Path("{JOURNALIST_PUB}")'
+        f'.write_text("""{pubkey}""".strip())\''
+    )
+    print(res.stderr)
+    assert res.rc == 0
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,7 +17,6 @@
 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 (
@@ -52,8 +51,6 @@
 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
@@ -3775,20 +3772,3 @@ 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"
diff --git a/securedrop/tests/test_startup.py b/securedrop/tests/test_startup.py
new file mode 100644
--- /dev/null
+++ b/securedrop/tests/test_startup.py
@@ -0,0 +1,20 @@
+from unittest.mock import patch
+
+from startup import validate_journalist_key
+
+from redwood import RedwoodError
+
+
+def test_validate_journalist_key(config, journalist_app, capsys):
+    # The test key passes validation
+    assert validate_journalist_key(journalist_app) is True
+    # Reading the key file fails
+    with patch(
+        "encryption.EncryptionManager.get_journalist_public_key", side_effect=RuntimeError("err")
+    ):
+        assert validate_journalist_key(journalist_app) is False
+    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")):
+        assert validate_journalist_key(journalist_app) is False
+    assert capsys.readouterr().err == "ERROR: Journalist public key is not valid: err\n"
diff --git a/securedrop/tests/test_wsgi.py b/securedrop/tests/test_wsgi.py
--- a/securedrop/tests/test_wsgi.py
+++ b/securedrop/tests/test_wsgi.py
@@ -1,4 +1,5 @@
 import os
+import shutil
 import subprocess
 import sys
 from pathlib import Path
@@ -6,8 +7,27 @@
 import pytest
 
 
+@pytest.fixture()
+def _journalist_pubkey():
+    """provision a valid public key for the JI startup check"""
+    path = Path("/tmp/securedrop/journalist.pub")
+    if not path.exists():
+        created_dir = False
+        try:
+            if not path.parent.exists():
+                path.parent.mkdir()
+                created_dir = True
+            shutil.copy(Path(__file__).parent / "files/test_journalist_key.pub", path)
+            yield
+        finally:
+            if created_dir:
+                shutil.rmtree(path.parent)
+            else:
+                path.unlink()
+
+
 @pytest.mark.parametrize("filename", ["journalist.wsgi", "source.wsgi"])
-def test_wsgi(filename):
+def test_wsgi(filename, _journalist_pubkey):
     """
     Verify that all setup code and imports in the wsgi files work
 
@@ -18,5 +38,6 @@ def test_wsgi(filename):
     wsgi = sd_dir / "debian/app-code/var/www" / filename
     python_path = os.getenv("PYTHONPATH", "")
     subprocess.check_call(
-        [sys.executable, str(wsgi)], env={"PYTHONPATH": f"{python_path}:{sd_dir}"}
+        [sys.executable, str(wsgi)],
+        env={"PYTHONPATH": f"{python_path}:{sd_dir}", "SECUREDROP_ENV": "test"},
     )