Repository: freedomofpress/securedrop

Issue:
Journalist passphrases should be hashed with Argon2id instead of Argon2i
*Originally filed as https://github.com/freedomofpress/securedrop-security/issues/86*

We currently use Argon2i to encrypt journalist passphrases:
```
sqlite> select passphrase_hash from journalists;
$argon2i$v=19$m=65536,t=4,p=2$otS6V+rd29u79z6nFAJAaA$fWn/7La/sHiQAM7YPOLo5Q
$argon2i$v=19$m=65536,t=4,p=2$VAoBIMSY8957T6lVCsFYKw$Z8vGfVR/P87pNqmn0oz/yg
$argon2i$v=19$m=65536,t=4,p=2$Quj9H4PwntPau5fSOgdgjA$qzvLuOzgN86ITlpdepFiJQ
```

However, Argon2id is what's recommended these days, [this SO answer goes into depth and summarizes with](https://security.stackexchange.com/questions/193351/in-2018-what-is-the-recommended-hash-to-store-passwords-bcrypt-scrypt-argon2):
> In short: use Argon2id if you can, use Argon2d in almost every other case, consider Argon2i if you really do need memory side-channel attack resistance.

Part of the issue here is that we fell behind on upgrading passlib. The latest version of it uses Argon2id by default (our argon2-cffi version already defaults to id).


Discussion:
@lsd-cat wrote:
> The is definitely low priority, as our passphrase scheme is safe enough anyway, however of course we better keep updated with the latest standard. Did we do any hash migration in the past? If not how do we want to handle that?


@legoktm wrote:
> The last password migration we did added a new column and did the rehashing whenever the user logged in ([freedomofpress/securedrop#3506](https://github.com/freedomofpress/securedrop/pull/3506))
> 
> I think can do roughly the same thing by just looking at the hash prefix, I wrote some pseudo-code at [freedomofpress/securedrop#6631](https://github.com/freedomofpress/securedrop/issues/6631) (public ticket for dropping the passlib dependency). I think it makes sense to tackle both tickets at the same time.
> 
> Given that this is low priority, is it OK to disclose and patch publicly? Or keep it private until close-to-release time? (not 2.5.0 to be clear)


@lsd-cat wrote:
> I will double check the migration code this week, ok from me to go publicly with it :)


@lsd-cat wrote:
> +1 in dropping passlib given our use case!


@l3th3 wrote:
> Also +1 for moving towards argon2id and argon2-cffi. The passlib issues section made me cry :sob:



Resolving patch:
diff --git a/securedrop/alembic/versions/2e24fc7536e8_make_journalist_id_non_nullable.py b/securedrop/alembic/versions/2e24fc7536e8_make_journalist_id_non_nullable.py
--- a/securedrop/alembic/versions/2e24fc7536e8_make_journalist_id_non_nullable.py
+++ b/securedrop/alembic/versions/2e24fc7536e8_make_journalist_id_non_nullable.py
@@ -8,10 +8,10 @@
 import os
 import uuid
 
+import argon2
 import pyotp
 import sqlalchemy as sa
 from alembic import op
-from passlib.hash import argon2
 
 # raise the errors if we're not in production
 raise_errors = os.environ.get("SECUREDROP_ENV", "prod") != "prod"
@@ -33,7 +33,7 @@
 
 def generate_passphrase_hash() -> str:
     passphrase = PassphraseGenerator.get_default().generate_passphrase()
-    return argon2.using(**ARGON2_PARAMS).hash(passphrase)
+    return argon2.PasswordHasher(**ARGON2_PARAMS).hash(passphrase)
 
 
 def create_deleted() -> int:
diff --git a/securedrop/models.py b/securedrop/models.py
--- a/securedrop/models.py
+++ b/securedrop/models.py
@@ -8,6 +8,7 @@
 from logging import Logger
 from typing import Any, Callable, Dict, List, Optional, Union
 
+import argon2
 import pyotp
 import qrcode
 
@@ -20,7 +21,6 @@
 from flask import url_for
 from flask_babel import gettext, ngettext
 from markupsafe import Markup
-from passlib.hash import argon2
 from passphrases import PassphraseGenerator
 from pyotp import HOTP, TOTP
 from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, LargeBinary, String
@@ -35,7 +35,7 @@
 if os.environ.get("SECUREDROP_ENV") == "test":
     LOGIN_HARDENING = False
 
-ARGON2_PARAMS = dict(memory_cost=2**16, rounds=4, parallelism=2)
+ARGON2_PARAMS = {"memory_cost": 2**16, "time_cost": 4, "parallelism": 2, "type": argon2.Type.ID}
 
 # Required length for hex-format HOTP secrets as input by users
 HOTP_SECRET_LENGTH = 40  # 160 bits == 40 hex digits (== 32 ascii-encoded chars in db)
@@ -479,10 +479,11 @@ def set_password(self, passphrase: "Optional[str]") -> None:
 
         self.check_password_acceptable(passphrase)
 
+        hasher = argon2.PasswordHasher(**ARGON2_PARAMS)
         # "migrate" from the legacy case
         if not self.passphrase_hash:
-            self.passphrase_hash = argon2.using(**ARGON2_PARAMS).hash(passphrase)
-            # passlib creates one merged field that embeds randomly generated
+            self.passphrase_hash = hasher.hash(passphrase)
+            # argon2 creates one merged field that embeds randomly generated
             # salt in the output like $alg$salt$hash
             self.pw_hash = None
             self.pw_salt = None
@@ -491,7 +492,7 @@ def set_password(self, passphrase: "Optional[str]") -> None:
         if self.passphrase_hash and self.valid_password(passphrase):
             return
 
-        self.passphrase_hash = argon2.using(**ARGON2_PARAMS).hash(passphrase)
+        self.passphrase_hash = hasher.hash(passphrase)
 
     def set_name(self, first_name: Optional[str], last_name: Optional[str]) -> None:
         if first_name:
@@ -550,9 +551,13 @@ def valid_password(self, passphrase: "Optional[str]") -> bool:
         # No check on minimum password length here because some passwords
         # may have been set prior to setting the mininum password length.
 
+        hasher = argon2.PasswordHasher(**ARGON2_PARAMS)
         if self.passphrase_hash:
             # default case
-            is_valid = argon2.verify(passphrase, self.passphrase_hash)
+            try:
+                is_valid = hasher.verify(self.passphrase_hash, passphrase)
+            except argon2.exceptions.VerificationError:
+                is_valid = False
         else:
             # legacy support
             if self.pw_salt is None:
@@ -567,13 +572,28 @@ def valid_password(self, passphrase: "Optional[str]") -> bool:
                 self._scrypt_hash(passphrase, self.pw_salt), self.pw_hash
             )
 
-        # migrate new passwords
-        if is_valid and not self.passphrase_hash:
-            self.passphrase_hash = argon2.using(**ARGON2_PARAMS).hash(passphrase)
-            # passlib creates one merged field that embeds randomly generated
-            # salt in the output like $alg$salt$hash
+        # If the passphrase isn't valid, bail out now
+        if not is_valid:
+            return False
+        # From here on we can assume the passphrase was valid
+
+        # Perform migration checks
+        needs_update = False
+        if self.passphrase_hash:
+            # Check if the hash needs an update
+            if hasher.check_needs_rehash(self.passphrase_hash):
+                self.passphrase_hash = hasher.hash(passphrase)
+                needs_update = True
+        else:
+            # Migrate to an argon2 hash, which creates one merged field
+            # that embeds randomly generated salt in the output like
+            # $alg$salt$hash
+            self.passphrase_hash = hasher.hash(passphrase)
             self.pw_salt = None
             self.pw_hash = None
+            needs_update = True
+
+        if needs_update:
             db.session.add(self)
             db.session.commit()
 


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
@@ -2617,6 +2617,7 @@ def test_passphrase_migration_on_verification(journalist_app):
 
     # check that the migration happened
     assert journalist.passphrase_hash is not None
+    assert journalist.passphrase_hash.startswith("$argon2")
     assert journalist.pw_salt is None
     assert journalist.pw_hash is None
 
@@ -2639,6 +2640,7 @@ def test_passphrase_migration_on_reset(journalist_app):
 
     # check that the migration happened
     assert journalist.passphrase_hash is not None
+    assert journalist.passphrase_hash.startswith("$argon2")
     assert journalist.pw_salt is None
     assert journalist.pw_hash is None
 
@@ -2646,6 +2648,19 @@ def test_passphrase_migration_on_reset(journalist_app):
     assert journalist.valid_password(VALID_PASSWORD)
 
 
+def test_passphrase_argon2i_migration(test_journo):
+    """verify argon2i hashes work and then are migrated to argon2id"""
+    journalist = test_journo["journalist"]
+    # But use our password hash
+    journalist.passphrase_hash = (
+        "$argon2i$v=19$m=65536,t=4,p=2$JfFkLIJ2ogPUDI19XiBzHA$kaKNVckLLQNNBnmllMWqXg"
+    )
+    db.session.add(journalist)
+    db.session.commit()
+    assert journalist.valid_password("correct horse battery staple profanity oil chewy")
+    assert journalist.passphrase_hash.startswith("$argon2id$")
+
+
 def test_journalist_reply_view(journalist_app, test_source, test_journo, app_storage):
     source, _ = utils.db_helper.init_source(app_storage)
     journalist, _ = utils.db_helper.init_journalist()
@@ -3054,7 +3069,7 @@ def db_assertion():
 
 
 def test_login_with_invalid_password_doesnt_call_argon2(mocker, test_journo):
-    mock_argon2 = mocker.patch("models.argon2.verify")
+    mock_argon2 = mocker.patch("models.argon2.PasswordHasher")
     invalid_pw = "a" * (Journalist.MAX_PASSWORD_LEN + 1)
 
     with pytest.raises(InvalidPasswordLength):
@@ -3062,16 +3077,6 @@ def test_login_with_invalid_password_doesnt_call_argon2(mocker, test_journo):
     assert not mock_argon2.called
 
 
-def test_valid_login_calls_argon2(mocker, test_journo):
-    mock_argon2 = mocker.patch("models.argon2.verify")
-    Journalist.login(
-        test_journo["username"],
-        test_journo["password"],
-        TOTP(test_journo["otp_secret"]).now(),
-    )
-    assert mock_argon2.called
-
-
 def test_render_locales(config, journalist_app, test_journo, test_source):
     """the locales.html template must collect both request.args (l=XX) and
     request.view_args (/<filesystem_id>) to build the URL to