Repository: freedomofpress/securedrop

Issue:
db.Source.journalist_designation is not a unique field
# Bug

## Description

`db.Source.journalist_designation` provides a 2-word designation which is used in the JI by journalists as the primary mechanism to identify and distinguish between sources (whose real identity and codename remain secret). Since the uniqueness of the field is not being enforced, it's possible that two Sources might end up with the same journalist designation, causing confusion.

## Comments

The fix is pretty straightforward. We should set the column/ attribute with `unique=True` and changes to this attribute/ field should be done with something like this pseudo-Python:
```py
while True:
    try:
        journo.journalist_designation = gen_display_id()
        db_session.add(journo)
        db_session.commit()
    except UniquenessConstraintException:
        pass
    else:
       break
````


Discussion:
Note: adding a [unique constraint](http://docs.sqlalchemy.org/en/latest/core/metadata.html#sqlalchemy.schema.Column.params.unique) to the `journalist_designation` is blocked on #1419

Well, we could add a `while` loop in `crypto_util.display_id()` similar to [the loop in `source_app.utils.generate_unique_codename()`](https://github.com/freedomofpress/securedrop/blob/develop/securedrop/source_app/utils.py#L38) to make sure that we cycle the `journalist_designation` if a duplicate is generated, that _would_ work going forward. 

But - the right place to solve this is the database. Specifically we should add the unique constraint, and if a source with a duplicate `journalist_designation` is added to the database, we handle the `IntegrityError` thrown by rolling back the transaction and cycle the `journalist_designation`. Let's tackle after #1419. 
@redshiftzero I would like to work on this.
Hi @prateekj117 !

Thanks for reaching out, your help would be appreciated here. After some discussion with @rmol, we would recommend you adopt the strategy described in the strategy described in https://github.com/freedomofpress/securedrop/issues/2043#issuecomment-332990259 (mirroring `generate_unique_codename` function as opposed to adding a database constraint and creating a database migration)

 The proposed fix for setting `unique` attribute for `journalist_designation` column may be somewhat risky due to the auto-updating of the web application: Database migrations are automatically performed when the application is updated. To make sure we cover all cases, the Alembic migration, we would need to handle the (unlikely) event a duplicate journalist designation already exists in the database by:
- renaming journalist designation in the database (to ensure database integrity)
- Also rename files (submissions and messages) on disk.
@emkll Sounds right. Will make a PR by EOD.

Resolving patch:
diff --git a/securedrop/crypto_util.py b/securedrop/crypto_util.py
--- a/securedrop/crypto_util.py
+++ b/securedrop/crypto_util.py
@@ -16,6 +16,8 @@
 
 import rm
 
+from models import Source
+
 import typing
 # https://www.python.org/dev/peps/pep-0484/#runtime-or-type-checking
 if typing.TYPE_CHECKING:
@@ -172,8 +174,21 @@ def genrandomid(self,
                         for x in range(words_in_random_id))
 
     def display_id(self):
-        return ' '.join([random.choice(self.adjectives),
-                         random.choice(self.nouns)])
+        """Generate random journalist_designation until we get an unused one"""
+
+        tries = 0
+
+        while tries < 50:
+            new_designation = ' '.join([random.choice(self.adjectives),
+                                        random.choice(self.nouns)])
+
+            collisions = Source.query.filter(Source.journalist_designation == new_designation)
+            if collisions.count() == 0:
+                return new_designation
+
+            tries += 1
+
+        raise ValueError("Could not generate unique journalist designation for new source")
 
     def hash_codename(self, codename, salt=None):
         """Salts and hashes a codename using scrypt.
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
@@ -72,8 +72,18 @@ def create():
             del session['codenames']
 
             filesystem_id = current_app.crypto_util.hash_codename(codename)
+            try:
+                source = Source(filesystem_id, current_app.crypto_util.display_id())
+            except ValueError as e:
+                current_app.logger.error(e)
+                flash(
+                    gettext("There was a temporary problem creating your account. "
+                            "Please try again."
+                            ),
+                    'error'
+                )
+                return redirect(url_for('.index'))
 
-            source = Source(filesystem_id, current_app.crypto_util.display_id())
             db.session.add(source)
             try:
                 db.session.commit()


Test patch:
diff --git a/securedrop/tests/functional/functional_test.py b/securedrop/tests/functional/functional_test.py
--- a/securedrop/tests/functional/functional_test.py
+++ b/securedrop/tests/functional/functional_test.py
@@ -185,6 +185,11 @@ def disable_js_torbrowser_driver(self):
         if hasattr(self, 'torbrowser_driver'):
             disable_js(self.torbrowser_driver)
 
+    def start_source_server(self, source_port):
+        config.SESSION_EXPIRATION_MINUTES = self.session_expiration / 60.0
+
+        self.source_app.run(port=source_port, debug=True, use_reloader=False, threaded=True)
+
     @pytest.fixture(autouse=True)
     def set_default_driver(self):
         logging.info("Creating default web driver: %s", self.default_driver_name)
@@ -261,16 +266,11 @@ def sd_servers(self):
 
                     self.admin_user["totp"] = pyotp.TOTP(self.admin_user["secret"])
 
-                    def start_source_server(app):
-                        config.SESSION_EXPIRATION_MINUTES = self.session_expiration / 60.0
-
-                        app.run(port=source_port, debug=True, use_reloader=False, threaded=True)
-
                     def start_journalist_server(app):
                         app.run(port=journalist_port, debug=True, use_reloader=False, threaded=True)
 
                     self.source_process = Process(
-                        target=lambda: start_source_server(self.source_app)
+                        target=lambda: self.start_source_server(source_port)
                     )
 
                     self.journalist_process = Process(
diff --git a/securedrop/tests/functional/source_navigation_steps.py b/securedrop/tests/functional/source_navigation_steps.py
--- a/securedrop/tests/functional/source_navigation_steps.py
+++ b/securedrop/tests/functional/source_navigation_steps.py
@@ -123,6 +123,14 @@ def submit_page_loaded():
 
         self.wait_for(submit_page_loaded)
 
+    def _source_continues_to_submit_page_with_colliding_journalist_designation(self):
+        self.safe_click_by_id("continue-button")
+
+        self.wait_for(lambda: self.driver.find_element_by_css_selector(".error"))
+        flash_error = self.driver.find_element_by_css_selector(".error")
+        assert "There was a temporary problem creating your account. Please try again." \
+               == flash_error.text
+
     def _source_submits_a_file(self):
         with tempfile.NamedTemporaryFile() as file:
             file.write(self.secret_message.encode("utf-8"))
diff --git a/securedrop/tests/functional/test_source.py b/securedrop/tests/functional/test_source.py
--- a/securedrop/tests/functional/test_source.py
+++ b/securedrop/tests/functional/test_source.py
@@ -1,5 +1,28 @@
 from . import source_navigation_steps, journalist_navigation_steps
 from . import functional_test
+from sdconfig import config
+
+
+class TestSourceInterfaceDesignationCollision(
+        functional_test.FunctionalTest,
+        source_navigation_steps.SourceNavigationStepsMixin):
+
+    def start_source_server(self, source_port):
+        self.source_app.crypto_util.adjectives = \
+            self.source_app.crypto_util.adjectives[:1]
+        self.source_app.crypto_util.nouns = self.source_app.crypto_util.nouns[:1]
+        config.SESSION_EXPIRATION_MINUTES = self.session_expiration / 60.0
+
+        self.source_app.run(port=source_port, debug=True, use_reloader=False, threaded=True)
+
+    def test_display_id_designation_collisions(self):
+        self._source_visits_source_homepage()
+        self._source_chooses_to_submit_documents()
+        self._source_continues_to_submit_page()
+        self._source_logs_out()
+        self._source_visits_source_homepage()
+        self._source_chooses_to_submit_documents()
+        self._source_continues_to_submit_page_with_colliding_journalist_designation()
 
 
 class TestSourceInterface(
diff --git a/securedrop/tests/test_crypto_util.py b/securedrop/tests/test_crypto_util.py
--- a/securedrop/tests/test_crypto_util.py
+++ b/securedrop/tests/test_crypto_util.py
@@ -7,6 +7,8 @@
 import pytest
 import re
 
+from flask import url_for, session
+
 os.environ['SECUREDROP_ENV'] = 'test'  # noqa
 import crypto_util
 import models
@@ -198,6 +200,20 @@ def test_display_id(source_app):
     assert id_words[1] in source_app.crypto_util.nouns
 
 
+def test_display_id_designation_collisions(source_app):
+    with source_app.test_client() as app:
+        app.get(url_for('main.generate'))
+        source_app.crypto_util.adjectives = source_app.crypto_util.adjectives[:1]
+        source_app.crypto_util.nouns = source_app.crypto_util.nouns[:1]
+        tab_id = next(iter(session['codenames'].keys()))
+        app.post(url_for('main.create'), data={'tab_id': tab_id}, follow_redirects=True)
+
+        with pytest.raises(ValueError) as err:
+            source_app.crypto_util.display_id()
+
+        assert 'Could not generate unique journalist designation for new source' in str(err)
+
+
 def test_genkeypair(source_app):
     with source_app.app_context():
         codename = source_app.crypto_util.genrandomid()