Repository: freedomofpress/securedrop

Issue:
Poor input validation on Yubikey HOTP setup
# Bug

## Description

When configuring a Yubikey for HOTP 2FA, the `./manage.py` script does not valid input properly. 
## Steps to Reproduce

For instance, a hardware Yubikey with secret key:

> cb a0 5f ad 41 a2 ff 4e eb 53 56 3a 1b f7 23 2e ce fc dc d7

that entire secret should be pasted as-is into the prompt **Please configure your YubiKey and enter the secret:**. However, if you make a mistake copy/pasting, and truncate the string, e.g.:

> cb a0 5f ad 41 a2 ff 4e eb 53 56 3a 1b f7

Then the script will still accept the bad value, and logins will be impossible with the newly created account. 
## Expected Behavior

`./manage.py` script fails if HOTP secret input is bad, forcing re-entry.

## Actual Behavior

`./manage.py` shows a lackadaisical attitude toward HOTP secret structure, then staunchly refuses logins using the Yubikey.

## Comments

Logs show, predictably, a bad token error:

>  ERROR in journalist: Login for 'conor4' failed: invalid token
Poor input validation on Yubikey HOTP setup
# Bug

## Description

When configuring a Yubikey for HOTP 2FA, the `./manage.py` script does not valid input properly. 
## Steps to Reproduce

For instance, a hardware Yubikey with secret key:

> cb a0 5f ad 41 a2 ff 4e eb 53 56 3a 1b f7 23 2e ce fc dc d7

that entire secret should be pasted as-is into the prompt **Please configure your YubiKey and enter the secret:**. However, if you make a mistake copy/pasting, and truncate the string, e.g.:

> cb a0 5f ad 41 a2 ff 4e eb 53 56 3a 1b f7

Then the script will still accept the bad value, and logins will be impossible with the newly created account. 
## Expected Behavior

`./manage.py` script fails if HOTP secret input is bad, forcing re-entry.

## Actual Behavior

`./manage.py` shows a lackadaisical attitude toward HOTP secret structure, then staunchly refuses logins using the Yubikey.

## Comments

Logs show, predictably, a bad token error:

>  ERROR in journalist: Login for 'conor4' failed: invalid token


Discussion:
Intriguingly we do have oddly specific input validation if the string is "odd-length", meaning not-even:

```
Please configure your YubiKey and enter the secret: a0 4a 39 4d ff 7c cd 54 87 7b f2 63 d4 5c e
['Traceback (most recent call last):\n', '  File "./manage.py", line 162, in _add_user\n    otp_secret=otp_secret)\n', '  File "<string>", line 4, in __init__\n', '  File "/usr/local/lib/python2.7/dist-packages/sqlalchemy/orm/state.py", line 414, in _initialize_instance\n    manager.dispatch.init_failure(self, args, kwargs)\n', '  File "/usr/local/lib/python2.7/dist-packages/sqlalchemy/util/langhelpers.py", line 66, in __exit__\n    compat.reraise(exc_type, exc_value, exc_tb)\n', '  File "/usr/local/lib/python2.7/dist-packages/sqlalchemy/orm/state.py", line 411, in _initialize_instance\n    return manager.original_init(*mixed[1:], **kwargs)\n', '  File "/var/www/securedrop/db.py", line 277, in __init__\n    self.set_hotp_secret(otp_secret)\n', '  File "/var/www/securedrop/db.py", line 347, in set_hotp_secret\n    "")))\n', 'TypeError: Odd-length string\n']
```

That exception should be caught and a readable error message displayed.

Picking this one.
Intriguingly we do have oddly specific input validation if the string is "odd-length", meaning not-even:

```
Please configure your YubiKey and enter the secret: a0 4a 39 4d ff 7c cd 54 87 7b f2 63 d4 5c e
['Traceback (most recent call last):\n', '  File "./manage.py", line 162, in _add_user\n    otp_secret=otp_secret)\n', '  File "<string>", line 4, in __init__\n', '  File "/usr/local/lib/python2.7/dist-packages/sqlalchemy/orm/state.py", line 414, in _initialize_instance\n    manager.dispatch.init_failure(self, args, kwargs)\n', '  File "/usr/local/lib/python2.7/dist-packages/sqlalchemy/util/langhelpers.py", line 66, in __exit__\n    compat.reraise(exc_type, exc_value, exc_tb)\n', '  File "/usr/local/lib/python2.7/dist-packages/sqlalchemy/orm/state.py", line 411, in _initialize_instance\n    return manager.original_init(*mixed[1:], **kwargs)\n', '  File "/var/www/securedrop/db.py", line 277, in __init__\n    self.set_hotp_secret(otp_secret)\n', '  File "/var/www/securedrop/db.py", line 347, in set_hotp_secret\n    "")))\n', 'TypeError: Odd-length string\n']
```

That exception should be caught and a readable error message displayed.

Picking this one.

Resolving patch:
diff --git a/securedrop/manage.py b/securedrop/manage.py
--- a/securedrop/manage.py
+++ b/securedrop/manage.py
@@ -158,6 +158,13 @@ def _add_user(is_admin=False):
         while True:
             otp_secret = raw_input(
                 "Please configure this user's YubiKey and enter the secret: ")
+            if otp_secret:
+                tmp_str = otp_secret.replace(" ", "")
+                if len(tmp_str) != 40:
+                    print("The length of the secret is not correct. "
+                          "Expected 40 characters, but received {0}. "
+                          "Try again.".format(len(tmp_str)))
+                    continue
             if otp_secret:
                 break
 


Test patch:
diff --git a/securedrop/tests/test_manage.py b/securedrop/tests/test_manage.py
--- a/securedrop/tests/test_manage.py
+++ b/securedrop/tests/test_manage.py
@@ -21,6 +21,10 @@
 from db import Journalist, db_session
 
 
+YUBIKEY_HOTP = ['cb a0 5f ad 41 a2 ff 4e eb 53 56 3a 1b f7 23 2e ce fc dc',
+                'cb a0 5f ad 41 a2 ff 4e eb 53 56 3a 1b f7 23 2e ce fc dc d7']
+
+
 class TestManagePy(object):
     def test_parse_args(self):
         # just test that the arg parser is stable
@@ -63,6 +67,20 @@ def test_get_yubikey_usage_yes(self, mock_stdin):
     def test_get_yubikey_usage_no(self, mock_stdin):
         assert not manage._get_yubikey_usage()
 
+    @mock.patch("manage._get_username", return_value='ntoll')
+    @mock.patch("manage._get_yubikey_usage", return_value=True)
+    @mock.patch("__builtin__.raw_input", side_effect=YUBIKEY_HOTP)
+    @mock.patch("sys.stdout", new_callable=StringIO)
+    def test_handle_invalid_secret(self, mock_username, mock_yubikey,
+                                   mock_htop, mock_stdout):
+        """Regression test for bad secret logic in manage.py"""
+
+        # We will try to provide one invalid and one valid secret
+        return_value = manage._add_user()
+        self.assertEqual(return_value, 0)
+        self.assertIn('Try again.', sys.stdout.getvalue())
+        self.assertIn('successfully added', sys.stdout.getvalue())
+
     @mock.patch("manage._get_username", return_value='foo-bar-baz')
     @mock.patch("manage._get_yubikey_usage", return_value=False)
     @mock.patch("sys.stdout", new_callable=StringIO)