Repository: freedomofpress/securedrop

Issue:
On-demand migration of sources' private keys to database-backed storage upon login
As part of our work to migrate to Sequoia, we will migrate sources' private keys on-demand when they log in to the database-backed storage.

As discussed in #6499, gpg stores keys in their own encrypted format, and we need the source's passphrase to unlock them, so we can't do it offline (like the public key migration). The plan is that when a source logs in, we'll see that they don't have a private key in the database, so we'll try to export their private key from gpg and then store it in the database in the armored and encrypted OpenPGP format.


Discussion:
Likely the main work here is that pretty_bad_protocol (AFAICT) doesn't already have a function to export private keys, so that will need to be done as part of this task, whether in p_b_p or just separately and directly calling GPG.
> Likely the main work here is that pretty_bad_protocol (AFAICT) doesn't already have a function to export private keys, so that will need to be done as part of this task, whether in p_b_p

This is implemented in #6907.

Resolving patch:
diff --git a/securedrop/encryption.py b/securedrop/encryption.py
--- a/securedrop/encryption.py
+++ b/securedrop/encryption.py
@@ -113,6 +113,12 @@ def get_source_key_fingerprint(self, source_filesystem_id: str) -> str:
         self._save_key_fingerprint_to_redis(source_filesystem_id, source_key_fingerprint)
         return source_key_fingerprint
 
+    def get_source_secret_key(self, fingerprint: str, passphrase: str) -> str:
+        secret_key = self._gpg.export_keys(fingerprint, secret=True, passphrase=passphrase)
+        if not secret_key:
+            raise GpgKeyNotFoundError()
+        return secret_key
+
     def encrypt_source_message(self, message_in: str, encrypted_message_path_out: Path) -> None:
         redwood.encrypt_message(
             # A submission is only encrypted for the journalist key
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
+from encryption import EncryptionManager, GpgKeyNotFoundError
 from flask import (
     Blueprint,
     abort,
@@ -376,7 +376,30 @@ def login() -> Union[str, werkzeug.Response]:
             source.pgp_fingerprint = fingerprint
             db.session.add(source)
             db.session.commit()
-        # TODO: Migrate GPG secret key here too
+        elif source.pgp_secret_key is None:
+            # Need to migrate the secret key out of GPG
+            encryption_mgr = EncryptionManager.get_default()
+            try:
+                secret_key = encryption_mgr.get_source_secret_key(
+                    source.fingerprint, source_user.gpg_secret
+                )
+            except GpgKeyNotFoundError:
+                # Don't fail here, but it's likely decryption of journalist
+                # messages will fail.
+                secret_key = None
+            if secret_key:
+                source.pgp_secret_key = secret_key
+                db.session.add(source)
+                db.session.commit()
+                # Let's optimistically delete the GPG key from the keyring
+                # if *everything* has been migrated. If this fails it's OK,
+                # since we still try to delete from the keyring on source
+                # deletion too.
+                if source.pgp_fingerprint and source.pgp_public_key:
+                    try:
+                        encryption_mgr.delete_source_key_pair(source.filesystem_id)
+                    except:  # noqa: E722
+                        pass
 
         return redirect(url_for(".lookup", from_login="1"))
 


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,6 +13,7 @@
 import pytest
 import version
 from db import db
+from encryption import EncryptionManager, GpgKeyNotFoundError
 from flask import escape, g, request, session, url_for
 from journalist_app.utils import delete_collection
 from models import InstanceConfig, Reply, Source
@@ -20,6 +21,7 @@
 from source_app import api as source_app_api
 from source_app import get_logo_url
 from source_app.session_manager import SessionManager
+from source_user import create_source_user
 
 import redwood
 
@@ -345,6 +347,62 @@ def test_login_with_missing_reply_files(source_app, app_storage):
         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
+    """
+    source, codename = utils.db_helper.init_source(app_storage)
+    source.pgp_fingerprint = None
+    source.pgp_public_key = None
+    source.pgp_secret_key = None
+    db.session.add(source)
+    db.session.commit()
+    # And there's no legacy GPG key hiding in the background
+    assert source.fingerprint is None
+    with source_app.test_client() as app:
+        resp = app.post(url_for("main.login"), data=dict(codename=codename), follow_redirects=True)
+        assert resp.status_code == 200
+        assert SessionManager.is_user_logged_in(db_session=db.session)
+    # Now check to see PGP fields are populated
+    assert len(source.pgp_fingerprint) == 40
+    assert redwood.is_valid_public_key(source.pgp_public_key)
+    assert source.pgp_secret_key.startswith("-----BEGIN PGP PRIVATE KEY BLOCK-----")
+
+
+def test_login_gpg_secret_key_migration(source_app, app_storage):
+    """
+    Test the on-demand migration of GPG secret keys to database-backed storage
+    """
+    # First create a source that is backed by a GPG key
+    passphrase = PassphraseGenerator.get_default().generate_passphrase()
+    source_user = create_source_user(
+        db_session=db.session,
+        source_passphrase=passphrase,
+        source_app_storage=app_storage,
+    )
+    source = source_user.get_db_record()
+    encryption_mgr = EncryptionManager.get_default()
+    utils.create_legacy_gpg_key(encryption_mgr, source_user, source)
+    # Copy the fingerprint and public key to DB storage, like the alembic migration does
+    source.pgp_fingerprint = source.fingerprint
+    source.pgp_public_key = source.public_key
+    source.pgp_secret_key = None
+    db.session.add(source)
+    db.session.commit()
+    # Now log in
+    with source_app.test_client() as app:
+        resp = app.post(
+            url_for("main.login"), data=dict(codename=passphrase), follow_redirects=True
+        )
+        assert resp.status_code == 200
+        assert SessionManager.is_user_logged_in(db_session=db.session)
+    # Now check to see PGP secret key is populated
+    assert source.pgp_secret_key.startswith("-----BEGIN PGP PRIVATE KEY BLOCK-----")
+    # And the GPG key has been deleted
+    with pytest.raises(GpgKeyNotFoundError):
+        encryption_mgr.get_source_key_fingerprint(source.filesystem_id)
+
+
 def _dummy_submission(app):
     """
     Helper to make a submission (content unimportant), mostly useful in