Repository: privacyidea/privacyidea

Issue:
Check result of splitting PIN and OTP
In some cases (multiple tokens for a user, one is a Yubikey in AES-mode) it happens that the split of PIN and OTP returns empty values which in turn create errors when binding to the LDAP-server with an empty password.
We could check whether the split was successful and return some kind of status value here:
https://github.com/privacyidea/privacyidea/blob/3f6ad403462d8147fbfe030210907fa911ad9438/privacyidea/lib/tokenclass.py#L1383



Discussion:
Well,  the pin splitting works as expected.
I think we first would have to define, what a correct/expected behaviour would be.
This happens when:
- PIN and OTP are entered together (could be mitigated using challenge-response) and
- the user has tokens with different OTP length (TOTP/HOTP with 6/8 and/or yubikey in AES mode)

This may lead to a wrong password being checked for the user.
Since the behaviour is correct, we should check if we should either check for empty passwords in the LDAP resolver password_check or maybe catch the specific exception to avoid data revelation.

We could also check within the AES-Mode token if the length of the password is to short for the OTP-length.

In both cases this should be treated as an invalid-PIN token.

I am adding a check for the length of the given password, so that the given password is not shorter than the expected OTP length of the token. This would be the case for

HOTP with otplen 6 and Yubikey with otplen 44/48.

So in this case we would have an early exit in case the user enters the 6 digit otp and the yubikey would exit with a wrong PIN from pin splitting and thus the PIN (AD password) would not be verified. Unfortunately I can not manage to find a testcase, that would fail without the modification, while in debugging I can verify that the yubikey is not verified anymore due to the wrong otplen.

Resolving patch:
diff --git a/privacyidea/lib/tokenclass.py b/privacyidea/lib/tokenclass.py
--- a/privacyidea/lib/tokenclass.py
+++ b/privacyidea/lib/tokenclass.py
@@ -511,7 +511,8 @@ def authenticate(self, passw, user=None, options=None):
 
         (res, pin, otpval) = self.split_pin_pass(passw, user=user,
                                                  options=options)
-        if res != -1:
+        if res:
+            # If the otpvalue is too short, we do not check the PIN at all, since res is False
             pin_match = self.check_pin(pin, user=user, options=options)
             if pin_match is True:
                 otp_counter = self.check_otp(otpval, options=options)
@@ -1392,7 +1393,8 @@ def split_pin_pass(self, passw, user=None, options=None):
         otplen = self.token.otplen
         log.debug("Splitting the an OTP value of length {0!s} from the password.".format(otplen))
         pin, otpval = split_pin_pass(passw, otplen, get_prepend_pin())
-        return True, pin, otpval
+        # If the provided passw is shorter than the expected otplen, we return the status False
+        return len(passw) >= otplen, pin, otpval
 
     def status_validation_fail(self):
         """
diff --git a/privacyidea/lib/tokens/daplugtoken.py b/privacyidea/lib/tokens/daplugtoken.py
--- a/privacyidea/lib/tokens/daplugtoken.py
+++ b/privacyidea/lib/tokens/daplugtoken.py
@@ -247,7 +247,6 @@ def resync(self, otp1, otp2, options=None):
         return res
 
     def split_pin_pass(self, passw, user=None, options=None):
-        res = 0
         try:
             otplen = int(self.token.otplen)
         except ValueError:  # pragma: no cover
@@ -264,4 +263,4 @@ def split_pin_pass(self, passw, user=None, options=None):
             pin = passw[otplen:]
             otpval = passw[0:otplen]
 
-        return res, pin, otpval
+        return len(passw) >= otplen, pin, otpval
diff --git a/privacyidea/lib/tokens/radiustoken.py b/privacyidea/lib/tokens/radiustoken.py
--- a/privacyidea/lib/tokens/radiustoken.py
+++ b/privacyidea/lib/tokens/radiustoken.py
@@ -362,7 +362,7 @@ def split_pin_pass(self, passw, user=None, options=None):
         Split the PIN and the OTP value.
         Only if it is locally checked and not remotely.
         """
-        res = 0
+        res = True
         pin = ""
         otpval = passw
         if self.check_pin_local:


Test patch:
diff --git a/tests/test_api_validate.py b/tests/test_api_validate.py
--- a/tests/test_api_validate.py
+++ b/tests/test_api_validate.py
@@ -12,6 +12,7 @@
 from .base import MyApiTestCase
 from privacyidea.lib.user import (User)
 from privacyidea.lib.tokens.totptoken import HotpTokenClass
+from privacyidea.lib.tokens.yubikeytoken import YubikeyTokenClass
 from privacyidea.lib.tokens.registrationtoken import RegistrationTokenClass
 from privacyidea.lib.tokenclass import DATE_FORMAT
 from privacyidea.models import (Token, Policy, Challenge, AuthCache, db, TokenOwner)
@@ -5743,3 +5744,70 @@ def test_04_enroll_SMS(self):
         delete_policy("pol_passthru")
         delete_policy("pol_multienroll")
         remove_token(serial)
+
+
+class ValidateShortPasswordTestCase(MyApiTestCase):
+
+    yubi_otpkey = "9163508031b20d2fbb1868954e041729"
+
+    public_uid = "ecebeeejedecebeg"
+    valid_yubi_otps = [
+        public_uid + "fcniufvgvjturjgvinhebbbertjnihit",
+        public_uid + "tbkfkdhnfjbjnkcbtbcckklhvgkljifu",
+        public_uid + "ktvkekfgufndgbfvctgfrrkinergbtdj",
+        public_uid + "jbefledlhkvjjcibvrdfcfetnjdjitrn",
+        public_uid + "druecevifbfufgdegglttghghhvhjcbh",
+        public_uid + "nvfnejvhkcililuvhntcrrulrfcrukll",
+        public_uid + "kttkktdergcenthdredlvbkiulrkftuk",
+        public_uid + "hutbgchjucnjnhlcnfijckbniegbglrt",
+        public_uid + "vneienejjnedbfnjnnrfhhjudjgghckl",
+    ]
+
+    def test_00_setup_tokens(self):
+        self.setUp_user_realms()
+
+        pin = ""
+        # create a token and assign it to the user
+        db_token = Token(self.serials[0], tokentype="hotp")
+        db_token.update_otpkey(self.otpkey)
+        db_token.save()
+        token = HotpTokenClass(db_token)
+        self.assertEqual(self.serials[0], token.token.serial)
+        self.assertEqual(6, token.token.otplen)
+        token.add_user(User("cornelius", self.realm1))
+        token.set_pin(pin)
+
+        # create a yubikey and assign it to the user
+        db_token = Token(self.serials[1], tokentype="yubikey")
+        db_token.update_otpkey(self.yubi_otpkey)
+        db_token.otplen = 48
+        db_token.save()
+        token = YubikeyTokenClass(db_token)
+        self.assertEqual(self.serials[1], token.token.serial)
+        self.assertEqual(len(self.valid_yubi_otps[0]), token.token.otplen)
+        token.add_user(User("cornelius", self.realm1))
+        token.set_pin(pin)
+
+        # Successful authentication with HOTP
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "pass": "{0!s}{1!s}".format(pin, self.valid_otp_values[0])}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            detail = res.json.get("detail")
+            self.assertTrue(result.get("status"))
+            self.assertTrue(result.get("value"))
+
+        # verify the Yubikey AES mode
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "pass": "{0!s}{1!s}".format(pin, self.valid_yubi_otps[0])}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            detail = res.json.get("detail")
+            self.assertTrue(result.get("status"))
+            self.assertTrue(result.get("value"))
\ No newline at end of file
diff --git a/tests/test_lib_tokenclass.py b/tests/test_lib_tokenclass.py
--- a/tests/test_lib_tokenclass.py
+++ b/tests/test_lib_tokenclass.py
@@ -475,6 +475,11 @@ def test_14_split_pin_pass(self):
         res, pin, value = token.split_pin_pass("test123456")
         self.assertTrue(pin == "test", pin)
         self.assertTrue(value == "123456", value)
+        # too short
+        res, pin, value = token.split_pin_pass("12345")
+        self.assertFalse(res)
+        self.assertEqual("12345", value)
+        self.assertEqual("", pin)
         
     def test_15_check_pin(self):
         db_token = Token.query.filter_by(serial=self.serial1).first()