Repository: privacyidea/privacyidea

Issue:
TOTP not calculated correctly
The algorithm we use for calculating TOTP values differs slightly from the standard:
https://github.com/privacyidea/privacyidea/blob/6f446f67a3c1395f4bdb2ad7f6e490a57371e04c/privacyidea/lib/tokens/totptoken.py#L266
We round to the nearest integer while the RFC explicitly states that `floor` should be used:
https://datatracker.ietf.org/doc/html/rfc6238#section-4.2
This wasn't noticed because of the default time-window used during checks.
 @frankmer pointed out the discrepancy while implementing the *DayPasswordToken* which doesn't use a time-window.


Discussion:

Resolving patch:
diff --git a/privacyidea/lib/tokens/daypasswordtoken.py b/privacyidea/lib/tokens/daypasswordtoken.py
--- a/privacyidea/lib/tokens/daypasswordtoken.py
+++ b/privacyidea/lib/tokens/daypasswordtoken.py
@@ -290,8 +290,7 @@ def get_otp(self, current_time=None, do_truncation=True,
         if current_time:
             time_seconds = self._time2float(current_time)
 
-        # we don't need to round here as we have already float
-        counter = int((time_seconds / self.timestep) + 0.5)
+        counter = int(time_seconds / self.timestep)
         otpval = hmac2Otp.generate(counter=counter,
                                    inc_counter=False,
                                    do_truncation=do_truncation,
@@ -316,7 +315,7 @@ def get_multi_otp(self, count=0, epoch_start=0, epoch_end=0,
         :param epoch_start: not implemented
         :param epoch_end: not implemented
         :param curTime: Simulate the servertime
-        :type curTime: datetime
+        :type curTime: datetime.datetime
         :param timestamp: Simulate the servertime
         :type timestamp: epoch time
         :return: tuple of status: boolean, error: text and the OTP dictionary
@@ -340,10 +339,9 @@ def get_multi_otp(self, count=0, epoch_start=0, epoch_end=0,
             tCounter = int(timestamp)
         else:
             # use the current server time
-            tCounter = self._time2float(datetime.datetime.now())
+            tCounter = int(time.time())
 
-        # we don't need to round here as we have already float
-        counter = int((tCounter / self.timestep))
+        counter = int(tCounter / self.timestep)
 
         if count > 0:
             error = "OK"
diff --git a/privacyidea/lib/tokens/totptoken.py b/privacyidea/lib/tokens/totptoken.py
--- a/privacyidea/lib/tokens/totptoken.py
+++ b/privacyidea/lib/tokens/totptoken.py
@@ -239,7 +239,7 @@ def check_otp_exist(self, otp, window=None, options=None, symetric=True,
         checks if the given OTP value is/are values of this very token at all.
         This is used to autoassign and to determine the serial number of
         a token.
-        In fact it is a check_otp with an enhanced window.
+        In fact, it is a check_otp with an enhanced window.
 
         :param otp: the to be verified otp value
         :type otp: string
@@ -262,42 +262,37 @@ def check_otp_exist(self, otp, window=None, options=None, symetric=True,
 
     @staticmethod
     def _time2counter(T0, timeStepping=60):
-        rnd = 0.5
-        counter = int((T0 / timeStepping) + rnd)
+        counter = int(T0 / timeStepping)
         return counter
 
     @staticmethod
     def _counter2time(counter, timeStepping=60):
-        rnd = 0.5
-        T0 = (float(counter) - rnd) * int(timeStepping)
+        T0 = float(counter * int(timeStepping))
         return T0
 
-    @staticmethod
-    def _getTimeFromCounter(counter, timeStepping=30, rnd=1):
-        idate = int(counter - rnd) * timeStepping
-        ddate = datetime.datetime.fromtimestamp(idate / 1.0)
-        return ddate
-
     @staticmethod
     @log_with(log)
     def _time2float(curtime):
         """
-        convert a datetime object into a float (POSIX timestamp).
-
-        TODO: handle timezone-aware datetime objects
+        Convert a datetime object into a float (POSIX timestamp).
+        Timezone-naive datetime objects will be interpreted as UTC.
+        To determine if a datetime object is timezone-aware see:
+        https://docs.python.org/3.10/library/datetime.html#determining-if-an-object-is-aware-or-naive
 
         :param curtime: time in datetime format
         :type curtime: datetime.datetime
         :return: seconds since 1.1.1970
         :rtype: float
         """
-        if type(curtime) == datetime.datetime:
-            dt = curtime
-        else:
-            dt = datetime.datetime.now()
-
-        td = (dt - datetime.datetime(1970, 1, 1))
-        return td.total_seconds()
+        if curtime:
+            if curtime.tzinfo and curtime.tzinfo.utcoffset(curtime):
+                # curtime is timezone aware
+                return curtime.timestamp()
+            else:
+                # curtime is naive
+                return curtime.replace(tzinfo=datetime.timezone.utc).timestamp()
+        # return the current timestamp
+        return datetime.datetime.now(tz=datetime.timezone.utc).timestamp()
 
     @check_token_locked
     def check_otp(self, anOtpVal, counter=None, window=None, options=None):
@@ -570,7 +565,7 @@ def get_otp(self, current_time=None, do_truncation=True,
         combined = "{0!s}{1!s}".format(otpval, pin)
         if get_from_config("PrependPin") == "True":
             combined = "{0!s}{1!s}".format(pin, otpval)
-            
+
         return 1, pin, otpval, combined
 
     @log_with(log)
@@ -623,13 +618,13 @@ def get_multi_otp(self, count=0, epoch_start=0, epoch_end=0,
                 otpval = hmac2Otp.generate(counter=counter + i,
                                            inc_counter=False)
                 timeCounter = ((counter + i) * self.timestep) + self.timeshift
-                
+
                 val_time = datetime.datetime.\
                     fromtimestamp(timeCounter).strftime("%Y-%m-%d %H:%M:%S")
                 otp_dict["otp"][counter + i] = {'otpval': otpval,
                                                 'time': val_time}
             ret = True
-            
+
         return ret, error, otp_dict
 
     @staticmethod


Test patch:
diff --git a/tests/test_lib_tokens_daypassword.py b/tests/test_lib_tokens_daypassword.py
--- a/tests/test_lib_tokens_daypassword.py
+++ b/tests/test_lib_tokens_daypassword.py
@@ -3,6 +3,7 @@
 """
 import datetime
 import binascii
+import logging
 import time
 from unittest import mock
 from .base import MyTestCase, FakeAudit, FakeFlaskG
@@ -199,14 +200,6 @@ def test_05_get_set_realms(self):
         realms = token.get_realms()
         self.assertTrue(len(realms) == 2, realms)
 
-    def test_99_delete_token(self):
-        db_token = Token.query.filter_by(serial=self.serial1).first()
-        token = DayPasswordTokenClass(db_token)
-        token.delete_token()
-
-        db_token = Token.query.filter_by(serial=self.serial1).first()
-        self.assertTrue(db_token is None, db_token)
-
     def test_08_info(self):
         db_token = Token.query.filter_by(serial=self.serial1).first()
         token = DayPasswordTokenClass(db_token)
@@ -277,19 +270,6 @@ def test_12_inc_otp_counter(self):
         token.inc_otp_counter(counter=20)
         self.assertTrue(token.token.count == 21, token.token.count)
 
-    def test_98_get_otp(self):
-        db_token = Token.query.filter_by(serial=self.serial1).first()
-        token = DayPasswordTokenClass(db_token)
-        token.update({"otpkey": self.otpkey,
-                      "pin": "test",
-                      "otplen": 6})
-        counter = token._time2counter(time.time(), timeStepping=30)
-        otp_now = token._calc_otp(counter)
-        otp = token.get_otp()
-        res = token.check_otp_exist(otp[2])
-        self.assertEqual(res, counter)
-        self.assertEqual(otp[2], otp_now)
-
     def test_13_check_otp(self):
         db_token = Token.query.filter_by(serial=self.serial1).first()
         token = DayPasswordTokenClass(db_token)
@@ -297,14 +277,18 @@ def test_13_check_otp(self):
                       "pin": "test",
                       "otplen": 6,
                       "timeStep": "1h"})
-        token.set_otp_count(47251644)
+        token.set_otp_count(470184)  # 2023-08-22T00:05:23+00:00
         self.assertEqual(token.get_tokeninfo("timeStep"), "1h", token.get_tokeninfo())
-        # OTP does not exist
-        self.assertEqual(token.check_otp_exist("222333"), -1)
-        # OTP does exist
-        res = token.check_otp_exist('063321', options={"initTime": 47251645 * 3600})
-        # Found the counter 47251645
-        self.assertEqual(47251645, res)
+        with mock.patch('time.time') as MockTime:
+            MockTime.return_value = 1692662723.0  # 2023-08-22T00:55:23+00:00
+            # The previous OTP does not work
+            self.assertEqual(token.check_otp_exist("819480"), -1)
+            # The next OTP does not work
+            self.assertEqual(token.check_otp_exist("795010"), -1)
+            # Current OTP works
+            res = token.check_otp_exist("079551")
+            # Found the counter 47251645
+            self.assertEqual(res, 470184)
 
     def test_14_split_pin_pass(self):
         db_token = Token.query.filter_by(serial=self.serial1).first()
@@ -414,97 +398,82 @@ def test_18_challenges(self):
         self.assertTrue(resp, resp)
 
         # test if challenge is valid
-        chal.is_valid()
+        self.assertTrue(chal.is_valid())
+        chal.delete()
 
-    @mock.patch('time.time', mock.MagicMock(return_value=1686902767))
     def test_19_pin_otp_functions(self):
         db_token = Token.query.filter_by(serial=self.serial1).first()
-        db_token.set_pin("test")
         token = DayPasswordTokenClass(db_token)
-        # check OTP according to RFC 4226
-        token.update({"otpkey": self.otpkey})
-        self.assertTrue(db_token.otplen == 6, 6)
+        token.update({"otpkey": self.otpkey,
+                      "pin": "test",
+                      "otplen": 6,
+                      "timeStep": "1h"})
         set_prepend_pin()
-        res, pin, otp = token.split_pin_pass("test123456")
-        self.assertTrue(pin == "test", pin)
-        self.assertTrue(otp == "123456", otp)
-        self.assertTrue(token.check_pin(pin), pin)
-        # get the OTP value for counter 1417549521
-        res = token.get_otp(time_seconds=1417549521)
-        self.assertTrue(res[0] == 1, res)
-#        self.assertTrue(res[2] == "128352", res)
-
-        check = token.check_otp("722053", counter=47251647)
-        # The OTP 722053 is of counter 47251647
-        self.assertTrue(check == 47251647, check)
-        # The tokenclass saves the counter to the database
-        self.assertTrue(token.token.count == 47251647, token.token.count)
+        self.assertTrue(token.check_pin('test'))
+        # get the OTP value for time at 1692785442 (2023-08-23T10:10:42+00:00)
+        with mock.patch('time.time') as MockTime:
+            MockTime.return_value = 1692785442.0
+            res = token.get_otp()
+            self.assertEqual(res[2], "165753", res)
+
+        # Check that we get the same OTP during the hour
+        with mock.patch('time.time') as MockTime:
+            MockTime.return_value = 1692784902.0  # 2023-08-23T10:01:42+00:00
+            res = token.get_otp()
+            self.assertEqual(res[2], "165753", res)
+
+        with mock.patch('time.time') as MockTime:
+            MockTime.return_value = 1692788382.0  # 2023-08-23T10:59:42+00:00
+            res = token.get_otp()
+            self.assertEqual(res[2], "165753", res)
+
+        # Previous OTP
+        with mock.patch('time.time') as MockTime:
+            MockTime.return_value = 1692784782.0  # 2023-08-23T09:59:42+00:00
+            res = token.get_otp()
+            self.assertEqual(res[2], "403777", res)
+
+        # Next OTP
+        with mock.patch('time.time') as MockTime:
+            MockTime.return_value = 1692788502.0  # 2023-08-23T11:01:42+00:00
+            res = token.get_otp()
+            self.assertEqual(res[2], "708916", res)
+
+        token.set_otp_count(470219)
+        self.assertEqual(token.token.count, 470219, token.token)
 
         # successful authentication
-        res = token.authenticate("test864141")
-        # This is the OTP value of the counter=47251650
-        self.assertEqual((True, 56230092, None), res)
-
-        # try the same OTP value again will not fail!
-        res = token.authenticate("test864141")
-        # This is the OTP value of the counter=47251650
-        self.assertEqual((True, 56230092, None), res)
-
+        with mock.patch('time.time') as MockTime:
+            MockTime.return_value = 1692792000.0  # 2023-08-23T14:00:00+00:00
+            res = token.authenticate("test758203")
+            # This is the OTP value of the counter=470220
+            self.assertEqual((True, 470220, None), res)
+
+        # try the same OTP value again some time later, and it should not fail!
+        with mock.patch('time.time') as MockTime:
+            MockTime.return_value = 1692793000.0  # 2023-08-23T14:16:40+00:00
+            res = token.authenticate("test758203")
+            # This is the OTP value of the counter=47251650
+            self.assertEqual((True, 470220, None), res)
+
+        # Fail since no amount was given
         res = token.get_multi_otp()
-        self.assertTrue(res[0] is False, res)
-        token.update({"otpkey": self.otpkey,
-                      "otplen": 6})
-        token.token.count = 0
-        res = token.get_multi_otp(count=5)
-        self.assertTrue(res[0], res)
-        self.assertTrue(res[1] == "OK", res)
-        self.assertTrue(len(res[2].get("otp")) == 5, res[2].get("otp"))
-
-        # Simulate the server time
-        res = token.get_multi_otp(count=5, timestamp=47251644 * 30)
-        self.assertTrue(res[0], res)
-        self.assertTrue(res[1] == "OK", res)
-        self.assertTrue(len(res[2].get("otp")) == 5, res[2].get("otp"))
-        self.assertTrue(47251648 in res[2].get("otp"), res[2].get("otp"))
-        self.assertTrue(47251647 in res[2].get("otp"), res[2].get("otp"))
-        self.assertTrue(47251646 in res[2].get("otp"), res[2].get("otp"))
-        self.assertTrue(47251645 in res[2].get("otp"), res[2].get("otp"))
-        self.assertTrue(47251644 in res[2].get("otp"), res[2].get("otp"))
-
-        # Simulate the server time
-        res = token.get_multi_otp(count=5, curTime=datetime.datetime(2014, 12, 12))
-        self.assertTrue(res[0], res)
-        self.assertTrue(res[1] == "OK", res)
-        self.assertTrue(len(res[2].get("otp")) == 5, res[2].get("otp"))
-        self.assertTrue(47278080 in res[2].get("otp"), res[2].get("otp"))
-        self.assertTrue(47278081 in res[2].get("otp"), res[2].get("otp"))
-        self.assertTrue(47278082 in res[2].get("otp"), res[2].get("otp"))
-        self.assertTrue(47278083 in res[2].get("otp"), res[2].get("otp"))
-        self.assertTrue(47278084 in res[2].get("otp"), res[2].get("otp"))
+        self.assertFalse(res[0], res)
+
+        with mock.patch('time.time') as MockTime:
+            MockTime.return_value = 1692799300.0  # 2023-08-23T16:01:40+00:00
+            res = token.get_multi_otp(count=5)
+            self.assertTrue(res[0], res)
+            self.assertEqual(res[1], "OK", res)
+            for count, value in [(470222, '001659'), (470223, '006788'),
+                                 (470224, '506071'), (470225, '554912'), (470226, '756301')]:
+                self.assertEqual(res[2].get("otp").get(count).get('otpval'), value, res[2].get("otp"))
 
         # do some failing otp checks
         token.token.otplen = "invalid otp counter"
         self.assertRaises(Exception, token.check_otp, "123456")
         token.token.otplen = 0
 
-        # Previous OTP value used again
-        token.token.otplen = 6
-        # token.token.count = 47251640
-        # The OTP for this counter was already presented to the server
-        token.token.count = 47251648
-        # 47251647 -> 722053
-        res = token.check_otp('705493', options={"initTime": 47251649 * 30})
-        # self.assertTrue(res == 47251647, res)
-        self.assertEqual(47251649, res)
-
-        # simple OTPs of current time
-        ret, _, dct = token.get_multi_otp(1)
-        self.assertTrue(ret)
-        self.assertGreater(list(dct["otp"].keys())[0], 47251648)
-        ret, _, dct = token.get_multi_otp(1, curTime=datetime.datetime.now())
-        self.assertTrue(ret)
-        self.assertGreater(list(dct["otp"].keys())[0], 47251648)
-
     def test_20_check_challenge_response(self):
         db_token = Token.query.filter_by(serial=self.serial1).first()
         db_token.set_pin("test")
@@ -687,3 +656,11 @@ def test_27_get_default_settings(self):
         p = DayPasswordTokenClass.get_default_settings(g, params)
         self.assertEqual(p, {})
         delete_policy("pol1")
+
+    def test_99_delete_token(self):
+        db_token = Token.query.filter_by(serial=self.serial1).first()
+        token = DayPasswordTokenClass(db_token)
+        token.delete_token()
+
+        db_token = Token.query.filter_by(serial=self.serial1).first()
+        self.assertTrue(db_token is None, db_token)