Repository: privacyidea/privacyidea

Issue:
PSKC: also export and import counter value for HOTP/TOTP
When exporting the HOTP and TOTP to PSKC, the counter value should also be exported.

Also the counter should be imported again.
I think there is in fact a counter field in RFC6030.

1. export counter values to PSKC
2. in the import script, allow to choose if counters, should be imported or not.


Actually, the counter is already exported:
https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/importotp.py#L655

and it is parsed:
https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/importotp.py#L506
(at least for HOTP).

We could also parse the counter for TOTP.

...and check: If the counter is set correct here: 
https://github.com/privacyidea/privacyidea/blob/master/tools/privacyidea-token-janitor#L482


Discussion:
RFC6030 also allows to provide the TimeDrift of TOTP tokens.

So we also should export and import this timedrift.
See https://tools.ietf.org/html/rfc6030#page-8


Resolving patch:
diff --git a/privacyidea/api/token.py b/privacyidea/api/token.py
--- a/privacyidea/api/token.py
+++ b/privacyidea/api/token.py
@@ -65,7 +65,8 @@
                          set_hashlib, set_max_failcount, set_realms,
                          copy_token_user, copy_token_pin, lost_token,
                          get_serial_by_otp, get_tokens,
-                         set_validity_period_end, set_validity_period_start, add_tokeninfo, delete_tokeninfo)
+                         set_validity_period_end, set_validity_period_start, add_tokeninfo,
+                         delete_tokeninfo, import_token)
 from werkzeug.datastructures import FileStorage
 from cgi import FieldStorage
 from privacyidea.lib.error import (ParameterError, TokenAdminError)
@@ -90,8 +91,6 @@
 from privacyidea.lib.event import event
 from privacyidea.api.auth import admin_required
 from privacyidea.lib.subscriptions import CheckSubscription
-from privacyidea.lib.tokenclass import TOKENKIND
-
 
 token_blueprint = Blueprint('token_blueprint', __name__)
 log = logging.getLogger(__name__)
@@ -884,27 +883,10 @@ def loadtokens_api(filename=None):
         log.info("initialize token. serial: {0!s}, realm: {1!s}".format(serial,
                                                               tokenrealms))
 
-        init_param = {'serial': serial,
-                      'type': TOKENS[serial]['type'],
-                      'description': TOKENS[serial].get("description",
-                                                        "imported"),
-                      'otpkey': TOKENS[serial]['otpkey'],
-                      'otplen': TOKENS[serial].get('otplen'),
-                      'timeStep': TOKENS[serial].get('timeStep'),
-                      'hashlib': TOKENS[serial].get('hashlib')}
-
-        if hashlib and hashlib != "auto":
-            init_param['hashlib'] = hashlib
-
-        #if tokenrealm:
-        #    self.Policy.checkPolicyPre('admin', 'loadtokens',
-        #                   {'tokenrealm': tokenrealm })
-
-        # Imported tokens are usually hardware tokens
-        token = init_token(init_param, tokenrealms=tokenrealms,
-                           tokenkind=TOKENKIND.HARDWARE)
-        if TOKENS[serial].get("counter"):
-            token.set_otp_count(TOKENS[serial].get("counter"))
+        import_token(serial,
+                     TOKENS[serial],
+                     tokenrealms=tokenrealms,
+                     default_hashlib=hashlib)
 
     g.audit_object.log({'info': u"{0!s}, {1!s} (imported: {2:d})".format(file_type,
                                                            token_file,
diff --git a/privacyidea/lib/importotp.py b/privacyidea/lib/importotp.py
--- a/privacyidea/lib/importotp.py
+++ b/privacyidea/lib/importotp.py
@@ -44,11 +44,11 @@
 It is used for importing SafeNet (former Aladdin)
 XML files, that hold the OTP secrets for eToken PASS.
 '''
-
 import defusedxml.ElementTree as etree
 import re
 import binascii
 import base64
+import cgi
 from privacyidea.lib.utils import modhex_decode
 from privacyidea.lib.utils import modhex_encode
 from privacyidea.lib.log import log_with
@@ -502,10 +502,13 @@ def parsePSKCdata(xml_data,
             log.debug(traceback.format_exc())
             raise ImportException("Failed to import tokendata. Wrong "
                                   "encryption key? %s" % exx)
-        if token["type"] == "hotp" and key.data.counter:
-                token["counter"] = key.data.counter.text.strip()
-        elif token["type"] == "totp" and key.data.timeinterval:
+        if token["type"] in ["hotp", "totp"] and key.data.counter:
+            token["counter"] = key.data.counter.text.strip()
+        if token["type"] == "totp":
+            if key.data.timeinterval:
                 token["timeStep"] = key.data.timeinterval.text.strip()
+            if key.data.timedrift:
+                token["timeShift"] = key.data.timedrift.text.strip()
 
         tokens[serial] = token
     return tokens
@@ -614,8 +617,10 @@ def export_pskc(tokenobj_list, psk=None):
         suite = tokenobj.get_tokeninfo("hashlib", default="sha1")
         if type == "totp":
             timestep = tokenobj.get_tokeninfo("timeStep")
+            timedrift = tokenobj.get_tokeninfo("timeShift")
         else:
             timestep = 0
+            timedrift = 0
         otpkey = tokenobj.token.get_otpkey().getKey()
         try:
             encrypted_otpkey = aes_encrypt_b64(psk, binascii.unhexlify(otpkey))
@@ -652,14 +657,18 @@ def export_pskc(tokenobj_list, psk=None):
                         <PlainValue>{timestep}</PlainValue>
                     </TimeInterval>
                     <Counter>
-                        {counter}
+                        <PlainValue>{counter}</PlainValue>
                     </Counter>
+                    <TimeDrift>
+                        <PlainValue>{timedrift}</PlainValue>
+                    </TimeDrift>
                 </Data>
         </Key>
-        </KeyPackage>""".format(serial=serial, type=type, otplen=otplen,
-                                issuer=issuer, manufacturer=manufacturer,
+        </KeyPackage>""".format(serial=cgi.escape(serial), type=cgi.escape(type), otplen=otplen,
+                                issuer=cgi.escape(issuer), manufacturer=cgi.escape(manufacturer),
                                 counter=counter, timestep=timestep, encrypted_otpkey=encrypted_otpkey,
-                                suite=suite), "html.parser")
+                                timedrift=timedrift,
+                                suite=cgi.escape(suite)), "html.parser")
 
             soup.macmethod.insert_after(kp2)
             number_of_exported_tokens += 1
diff --git a/privacyidea/lib/token.py b/privacyidea/lib/token.py
--- a/privacyidea/lib/token.py
+++ b/privacyidea/lib/token.py
@@ -846,6 +846,43 @@ def _gen_serial(_prefix, _tokennum):
     return serial
 
 
+@log_with(log)
+def import_token(serial, token_dict, default_hashlib=None, tokenrealms=None):
+    """
+    This function is used during the import of a PSKC file.
+
+    :param serial: The serial number of the token
+    :param token_dict: A dictionary describing the token like:
+        {"type": ...,
+         "description": ...,
+         "otpkey": ...,
+         "counter: ...,
+         "timeShift": ...}
+    :param default_hashlib:
+    :return: the token object
+    """
+    init_param = {'serial': serial,
+                  'type': token_dict['type'],
+                      'description': token_dict.get("description",
+                                                        "imported"),
+                      'otpkey': token_dict['otpkey'],
+                      'otplen': token_dict.get('otplen'),
+                      'timeStep': token_dict.get('timeStep'),
+                      'hashlib': token_dict.get('hashlib')}
+
+    if default_hashlib and default_hashlib != "auto":
+        init_param['hashlib'] = default_hashlib
+
+    # Imported tokens are usually hardware tokens
+    token = init_token(init_param, tokenrealms=tokenrealms,
+                       tokenkind=TOKENKIND.HARDWARE)
+    if token_dict.get("counter"):
+        token.set_otp_count(token_dict.get("counter"))
+    if token_dict.get("timeShift"):
+        token.add_tokeninfo("timeShift", token_dict.get("timeShift"))
+    return token
+
+
 @log_with(log)
 def init_token(param, user=None, tokenrealms=None,
                tokenkind=None):


Test patch:
diff --git a/tests/test_lib_importotp.py b/tests/test_lib_importotp.py
--- a/tests/test_lib_importotp.py
+++ b/tests/test_lib_importotp.py
@@ -1,3 +1,4 @@
+# coding: utf-8
 """
 This test file tests the lib.importotp
 
@@ -231,6 +232,12 @@
         <TimeInterval>
           <PlainValue>60</PlainValue>
         </TimeInterval>
+        <Counter>
+          <PlainValue>121212</PlainValue>
+        </Counter>
+        <TimeDrift>
+            <PlainValue>-122</PlainValue>
+        </TimeDrift>
       </Data>
       <Policy>
         <StartDate>2012-08-01T00:00:00Z</StartDate>
@@ -520,6 +527,9 @@ def test_03_import_pskc(self):
         self.assertEqual(len(tokens), 6)
         self.assertEqual(tokens["1000133508267"].get("type"), "hotp")
         self.assertEqual(tokens["2600135004013"].get("type"), "totp")
+        # Check the TOTP counter...
+        self.assertEqual(tokens["2600135004013"].get("counter"), "121212")
+        self.assertEqual(tokens["2600135004013"].get("timeShift"), "-122")
         self.assertEqual(tokens["2600135004013"].get("timeStep"), "60")
 
     def test_04_import_pskc_aes(self):
@@ -550,8 +560,10 @@ def test_05_import_pskc_password(self):
 
     def test_06_export_pskc(self):
         # create three tokens
-        t1 = init_token({"serial": "t1", "type": "hotp", "otpkey": "123456"})
-        t2 = init_token({"serial": "t2", "type": "totp", "otpkey": "123456"})
+        t1 = init_token({"serial": "t1", "type": "hotp", "otpkey": "123456",
+                         "description": u"söme ünicøde"})
+        t2 = init_token({"serial": "t2", "type": "totp", "otpkey": "123456",
+                         "description": "something <with> xml!"})
         t3 = init_token({"serial": "t3", "type": "spass", "otpkey": "123456"})
         tlist = [t1, t2, t3]
         # export the tokens
@@ -569,8 +581,11 @@ def test_06_export_pskc(self):
         self.assertEqual(len(tokens), 2)
         self.assertEqual(tokens.get("t1").get("type"), "hotp")
         self.assertEqual(tokens.get("t1").get("otpkey"), "123456")
+        # unicode does not get exported
+        self.assertEqual(tokens.get("t1").get("description"), "deleted during export")
         self.assertEqual(tokens.get("t2").get("type"), "totp")
         self.assertEqual(tokens.get("t2").get("timeStep"), "30")
+        self.assertEqual(tokens.get("t2").get("description"), "something <with> xml!")
 
 
 class GPGTestCase(MyTestCase):
diff --git a/tests/test_lib_token.py b/tests/test_lib_token.py
--- a/tests/test_lib_token.py
+++ b/tests/test_lib_token.py
@@ -55,7 +55,8 @@
                                    get_dynamic_policy_definitions,
                                    get_tokens_paginate,
                                    set_validity_period_end,
-                                   set_validity_period_start, remove_token, delete_tokeninfo)
+                                   set_validity_period_start, remove_token, delete_tokeninfo,
+                                   import_token)
 
 from privacyidea.lib.error import (TokenAdminError, ParameterError,
                                    privacyIDEAError)
@@ -1314,6 +1315,18 @@ def test_51_tokenkind(self):
         self.assertEqual(kind, TOKENKIND.VIRTUAL)
         tok.delete_token()
 
+    def test_52_import_token(self):
+        tok = import_token("IMP001",
+                           {"type": "totp",
+                            "timeShift": "-122",
+                            "timeStep": "30",
+                            "otpkey": self.otpkey,
+                            "counter": "121212"})
+        self.assertEqual(tok.get_tokeninfo("timeShift"), "-122")
+        self.assertEqual(tok.get_tokeninfo("timeStep"), "30")
+        self.assertEqual(tok.get_otp_count(), 121212)
+        remove_token("IMP001")
+
 
 class TokenFailCounterTestCase(MyTestCase):
     """