Repository: freedomofpress/securedrop

Issue:
Bump 2FA secret bit length from 80 bits to 160bits as recommended by RFC4226
While evaluating a patch for https://github.com/freedomofpress/securedrop/issues/5613 i noticed that pyotp seems to be still generating secrets of 80 bits length that is actually discouraged by [RFC4226](https://tools.ietf.org/html/rfc4226):

```R6 - The algorithm MUST use a strong shared secret.  The length of the shared secret MUST be at least 128 bits. This document RECOMMENDs a shared secret length of 160 bits.```

The easily improve this I suggest we could just replace any call to pyotp.random_base32() in https://github.com/freedomofpress/securedrop/blob/fd2f5f86432005bdfc3c59119da98e442b7e4475/securedrop/models.py  with a call to a simple function:

```python
def generate_otp_secret():
    return base64.b32encode(os.urandom(20))
```

Debian Buster is now oldstable
## Description

`apt update` on Debian Buster will now fail as it is now `oldstable`. We will have to explicitly approve update commands.

I first noticed this on: https://app.circleci.com/pipelines/github/freedomofpress/securedrop/2847/workflows/cccc1ea9-3514-4138-a085-5074f12d3322/jobs/56132


## Steps to Reproduce

- [ ] `suod apt update`

## Expected Behavior

- Update should finish normally

## Actual Behavior

```
Get:1 http://deb.debian.org/debian buster InRelease [122 kB]
Get:2 http://security.debian.org/debian-security buster/updates InRelease [65.4 kB]
Get:3 http://deb.debian.org/debian buster-updates InRelease [51.9 kB]
Reading package lists... Done      
E: Repository 'http://security.debian.org/debian-security buster/updates InRelease' changed its 'Suite' value from 'stable' to 'oldstable'
N: This must be accepted explicitly before updates for this repository can be applied. See apt-secure(8) manpage for details.
N: Repository 'http://deb.debian.org/debian buster InRelease' changed its 'Version' value from '10.6' to '10.10'
E: Repository 'http://deb.debian.org/debian buster InRelease' changed its 'Suite' value from 'stable' to 'oldstable'
N: This must be accepted explicitly before updates for this repository can be applied. See apt-secure(8) manpage for details.
E: Repository 'http://deb.debian.org/debian buster-updates InRelease' changed its 'Suite' value from 'stable-updates' to 'oldstable-updates'
N: This must be accepted explicitly before updates for this repository can be applied. See apt-secure(8) manpage for details.

Exited with code exit status 100

```

## Comments

Suggestions to fix, any other relevant information.



Discussion:

Resolving patch:
diff --git a/securedrop/alembic/env.py b/securedrop/alembic/env.py
--- a/securedrop/alembic/env.py
+++ b/securedrop/alembic/env.py
@@ -47,7 +47,7 @@ def run_migrations_offline():
     """
     url = config.get_main_option("sqlalchemy.url")
     context.configure(
-        url=url, target_metadata=target_metadata, literal_binds=True)
+        url=url, target_metadata=target_metadata, compare_type=True, literal_binds=True)
 
     with context.begin_transaction():
         context.run_migrations()
@@ -69,6 +69,7 @@ def run_migrations_online():
         context.configure(
             connection=connection,
             target_metadata=target_metadata,
+            compare_type=True,
             render_as_batch=True
         )
 
diff --git a/securedrop/alembic/versions/de00920916bf_updates_journalists_otp_secret_length_.py b/securedrop/alembic/versions/de00920916bf_updates_journalists_otp_secret_length_.py
new file mode 100644
--- /dev/null
+++ b/securedrop/alembic/versions/de00920916bf_updates_journalists_otp_secret_length_.py
@@ -0,0 +1,38 @@
+"""Updates journalists.otp_secret length from 16 to 32
+
+Revision ID: de00920916bf
+Revises: 1ddb81fb88c2
+Create Date: 2021-05-21 15:51:39.202368
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'de00920916bf'
+down_revision = '1ddb81fb88c2'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    with op.batch_alter_table('journalists', schema=None) as batch_op:
+        batch_op.alter_column('otp_secret',
+               existing_type=sa.VARCHAR(length=16),
+               type_=sa.String(length=32),
+               existing_nullable=True)
+
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    with op.batch_alter_table('journalists', schema=None) as batch_op:
+        batch_op.alter_column('otp_secret',
+               existing_type=sa.String(length=32),
+               type_=sa.VARCHAR(length=16),
+               existing_nullable=True)
+
+    # ### end Alembic commands ###
diff --git a/securedrop/models.py b/securedrop/models.py
--- a/securedrop/models.py
+++ b/securedrop/models.py
@@ -27,7 +27,7 @@
 
 from typing import Callable, Optional, Union, Dict, List, Any
 from logging import Logger
-from pyotp import OTP
+from pyotp import TOTP, HOTP
 
 
 LOGIN_HARDENING = True
@@ -404,7 +404,7 @@ class Journalist(db.Model):
     is_admin = Column(Boolean)  # type: Column[Optional[bool]]
     session_nonce = Column(Integer, nullable=False, default=0)
 
-    otp_secret = Column(String(16), default=pyotp.random_base32)
+    otp_secret = Column(String(32), default=pyotp.random_base32)
     is_totp = Column(Boolean, default=True)  # type: Column[Optional[bool]]
     hotp_counter = Column(Integer, default=0)  # type: Column[Optional[int]]
     last_token = Column(String(6))
@@ -556,6 +556,9 @@ def valid_password(self, passphrase: 'Optional[str]') -> bool:
                     "Should never happen: pw_salt is none for legacy Journalist {}".format(self.id)
                 )
 
+            # For type checking
+            assert isinstance(self.pw_hash, bytes)
+
             is_valid = pyotp.utils.compare_digest(
                 self._scrypt_hash(passphrase, self.pw_salt),
                 self.pw_hash)
@@ -584,14 +587,14 @@ def set_hotp_secret(self, otp_secret: str) -> None:
         self.hotp_counter = 0
 
     @property
-    def totp(self) -> 'OTP':
+    def totp(self) -> 'TOTP':
         if self.is_totp:
             return pyotp.TOTP(self.otp_secret)
         else:
             raise ValueError('{} is not using TOTP'.format(self))
 
     @property
-    def hotp(self) -> 'OTP':
+    def hotp(self) -> 'HOTP':
         if not self.is_totp:
             return pyotp.HOTP(self.otp_secret)
         else:
@@ -698,6 +701,8 @@ def login(cls,
 
             # Prevent TOTP token reuse
             if user.last_token is not None:
+                # For type checking
+                assert isinstance(token, str)
                 if pyotp.utils.compare_digest(token, user.last_token):
                     raise BadTokenException("previously used two-factor code "
                                             "{}".format(token))


Test patch:
diff --git a/securedrop/tests/migrations/migration_de00920916bf.py b/securedrop/tests/migrations/migration_de00920916bf.py
new file mode 100644
--- /dev/null
+++ b/securedrop/tests/migrations/migration_de00920916bf.py
@@ -0,0 +1,73 @@
+# -*- coding: utf-8 -*-
+import random
+import uuid
+
+from sqlalchemy import text
+
+from db import db
+from journalist_app import create_app
+from .helpers import random_chars
+
+random.seed('くコ:彡')
+
+
+class Helper:
+
+    def __init__(self):
+        self.journalist_id = None
+
+    def create_journalist(self, otp_secret="ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"):
+        if self.journalist_id is not None:
+            raise RuntimeError('Journalist already created')
+
+        params = {
+            'uuid': str(uuid.uuid4()),
+            'username': random_chars(50),
+            'session_nonce': 0,
+            'otp_secret': otp_secret
+        }
+        sql = '''INSERT INTO journalists (uuid, username, otp_secret, session_nonce)
+                 VALUES (:uuid, :username, :otp_secret, :session_nonce)
+              '''
+        self.journalist_id = db.engine.execute(text(sql), **params).lastrowid
+
+
+class UpgradeTester(Helper):
+    """
+    Checks schema to verify that the otp_secret varchar "length" has been updated.
+    Varchar specified length isn't enforced by sqlite but it's good to verify that
+    the migration worked as expected.
+    """
+
+    def __init__(self, config):
+        Helper.__init__(self)
+        self.config = config
+        self.app = create_app(config)
+
+    def load_data(self):
+        with self.app.app_context():
+            self.create_journalist()
+
+    def check_upgrade(self):
+        with self.app.app_context():
+            journalists_sql = "SELECT * FROM journalists"
+            journalist = db.engine.execute(text(journalists_sql)).first()
+            assert len(journalist['otp_secret']) == 32  # Varchar ignores length
+
+
+class DowngradeTester(Helper):
+
+    def __init__(self, config):
+        Helper.__init__(self)
+        self.config = config
+        self.app = create_app(config)
+
+    def load_data(self):
+        with self.app.app_context():
+            self.create_journalist()
+
+    def check_downgrade(self):
+        with self.app.app_context():
+            journalists_sql = "SELECT * FROM journalists"
+            journalist = db.engine.execute(text(journalists_sql)).first()
+            assert len(journalist['otp_secret']) == 32  # Varchar ignores length