Repository: privacyidea/privacyidea

Issue:
Support importing AES mode Yubikeys from PSKC written by Yubico Personalization tool
The yubico personalization tool can export pskc files. In a mass import scenario, it appends to a single file and this PSCK file can be imported in privacyIDEA via the WebUI.
This works for HOTP mode but fails for "Yubico OTP" mode because privacyidea cannot determine the token type from the file.

```xml
<?xml version="1.0" encoding="UTF-8"?>
<KeyContainer Version="1.0" xmlns="urn:ietf:params:xml:ns:keyprov:pskc">
<KeyPackage><DeviceInfo><Manufacturer>Yubico</Manufacturer><SerialNo>10944003</SerialNo></DeviceInfo><CryptoModuleInfo><Id>1</Id></CryptoModuleInfo><Key Id="10944003:1" Algorithm="http://www.yubico.com/#yubikey-aes"><AlgorithmParameters><ResponseFormat Length="44" Encoding="ALPHANUMERIC"/></AlgorithmParameters><Data><Secret><PlainValue>Q3F9rS7jNBpxAvar2A0bYQ==</PlainValue></Secret></Data><UserId>CN=rdcltgehthjg, UID=1f6c45875762</UserId></Key></KeyPackage>
```

If one exchanges `http://www.yubico.com/#yubikey-aes` with `yubikey`, the import works and results in a usable token.

To support this, we have to set the type in this special case in importotp.py
https://github.com/privacyidea/privacyidea/blob/4f83cc48c6d22b9c78fe52cdcbb286ebe1ec1c93/privacyidea/lib/importotp.py#L511

In this case also the ID should be prepended with UBAM.


Discussion:
The second slot configured looks like this:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<KeyContainer Version="1.0"
    xmlns="urn:ietf:params:xml:ns:keyprov:pskc">
    <KeyPackage>
        <DeviceInfo>
            <Manufacturer>Yubico</Manufacturer>
            <SerialNo>10944003</SerialNo>
        </DeviceInfo>
        <CryptoModuleInfo>
            <Id>2</Id>
        </CryptoModuleInfo>
        <Key Id="10944003:2" Algorithm="http://www.yubico.com/#yubikey-aes">
            <AlgorithmParameters>
                <ResponseFormat Length="44" Encoding="ALPHANUMERIC"/>
            </AlgorithmParameters>
            <Data>
                <Secret>
                    <PlainValue>MueRkgTdEbfhBzkRMXgsug==</PlainValue>
                </Secret>
            </Data>
            <UserId>CN=vvdbcihejbhd, UID=3a230e05b9b8</UserId>
        </Key>
    </KeyPackage>
```

`key['id']` should hold the yubikey serial number and the slot id like `10944003:2`. `key['algorithm']` should hold the configured slot id.

The resulting serial should be `UBAM10944003_2` where 2 is the slot ID, see in yubikey CSV import
https://github.com/privacyidea/privacyidea/blob/4f83cc48c6d22b9c78fe52cdcbb286ebe1ec1c93/privacyidea/lib/importotp.py#L328

The tokeninfo "hardware" is already correctly set if the URL `http://www.yubico.com/#yubikey-aes` is replaced with `yubikey` In this case, the import results in working yubikey tokens.

**Important**:
Change the parsing ONLY for AES-mode Yubikeys. So, check first if there is this URL before changing the serial
The serial adjustment also applies for Yubikeys in HOTP mode (prepend "UBOM", append slot ID). The xml looks like this.

``` xml
<KeyContainer Version="1.0"
    xmlns="urn:ietf:params:xml:ns:keyprov:pskc">
    <KeyPackage>
        <DeviceInfo>
            <Manufacturer>Yubico</Manufacturer>
            <SerialNo>10944003</SerialNo>
        </DeviceInfo>
        <CryptoModuleInfo>
            <Id>1</Id>
        </CryptoModuleInfo>
        <Key Id="10944003:1" Algorithm="urn:ietf:params:xml:ns:keyprov:pskc:hotp">
            <AlgorithmParameters>
                <ResponseFormat Length="6" Encoding="DECIMAL"/>
            </AlgorithmParameters>
            <Data>
                <Secret>
                    <PlainValue>Sl+TPnYomz6VZSlbtDq7BxBNv0U=</PlainValue>
                </Secret>
                <Counter>
                    <PlainValue>0</PlainValue>
                </Counter>
            </Data>
        </Key>
    </KeyPackage>
```

The identifier looks differently `urn:ietf:params:xml:ns:keyprov:pskc:hotp`. You also see why we split the type at `:` in line 511.

So the thing should be to check for URL or urn and set type and serial correctly in both cases.

Resolving patch:
diff --git a/privacyidea/lib/importotp.py b/privacyidea/lib/importotp.py
--- a/privacyidea/lib/importotp.py
+++ b/privacyidea/lib/importotp.py
@@ -1,5 +1,7 @@
 # -*- coding: utf-8 -*-
 #
+#  2021-02-04 Timo Sturm <timo.sturm@netknights.it>
+#             Fix import of yubikeys from yubico
 #  2020-11-11 Timo Sturm <timo.sturm@netknights.it>
 #             Select how to validate PSKC imports
 #  2018-05-10 Cornelius Kölbel <cornelius.koelbel@netknights.it>
@@ -502,13 +504,29 @@ def parsePSKCdata(xml_data,
             token["description"] = key_package.deviceinfo.manufacturer.string
         except Exception as exx:
             log.debug("Can not get manufacturer string {0!s}".format(exx))
-        serial = key["id"]
-        try:
-            serial = key_package.deviceinfo.serialno.string.strip()
-        except Exception as exx:
-            log.debug("Can not get serial string from device info {0!s}".format(exx))
+
         algo = key["algorithm"]
-        token["type"] = algo.split(":")[-1].lower()
+        serial = key["id"]
+
+        # Special treatment for pskc files exported from Yubico
+        yubi_mapping = {"http://www.yubico.com/#yubikey-aes": ("yubikey", "UBAM"),
+                        "urn:ietf:params:xml:ns:keyprov:pskc:hotp": ("hotp", "UBOM")}
+        if algo in yubi_mapping.keys() and re.match(r"\d+:\d+",
+                                                    serial):  # check if the serial fits the pattern "<SerialNo>:<Slot>
+            t_type = yubi_mapping[algo][0]
+            serial_split = serial.split(":")
+            serial_no = serial_split[0]
+            slot = serial_split[1]
+            serial = "{!s}{!s}_{!s}".format(yubi_mapping[algo][1], serial_no, slot)
+        else:
+            try:
+                serial = key_package.deviceinfo.serialno.string.strip()
+            except Exception as exx:
+                log.debug("Can not get serial string from device info {0!s}".format(exx))
+            t_type = algo.split(":")[-1].lower()
+
+        token["type"] = t_type
+
         parameters = key.algorithmparameters
         token["otplen"] = parameters.responseformat["length"] or 6
         try:


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
@@ -14,7 +14,6 @@
 from privacyidea.lib.importotp import export_pskc
 from privacyidea.lib.utils import hexlify_and_unicode
 
-
 XML_PSKC_PASSWORD_PREFIX = """<?xml version="1.0" encoding="UTF-8"?>
   <KeyContainer
     xmlns="urn:ietf:params:xml:ns:keyprov:pskc"
@@ -337,7 +336,7 @@
 sccTokenData: sccKey=535CC2CB9DEA0B55B0A2D585EAB648EBCE73AC8B;sccMode=T;sccPwLen=6;sccVer=6.20;sccTick=30;sccPrTime=2013/03/12 00:00:00
 sccSignature: MC4CFQDju23MCRqmkWC7Z9sVDB0y0TeEOwIVAOIibmqMFxhPiY7mLlkt5qmRT/xn        '''
 
-YUBIKEYCSV = '''
+YUBIKEY_CSV = '''
         Static Password: Scan Code,17.04.12 12:25,1,051212172c092728,,,,,0,0,0,0,0,0,0,0,0,0
         Static Password: Scan Code,17.04.12 12:27,1,282828051212172c092728,,,,,0,0,0,0,0,0,0,0,0,0
         LOGGING START,17.04.12 12:29
@@ -377,6 +376,61 @@
 508329,,,9e2fd386224a7f77e9b5aee775464033,,2013-12-12T08:44:34,
         '''
 
+# see https://github.com/privacyidea/privacyidea/issues/2594
+YUBIKEY_PSKC_TOTP = '''
+<?xml version="1.0" encoding="UTF-8"?>
+<KeyContainer Version="1.0"
+    xmlns="urn:ietf:params:xml:ns:keyprov:pskc">
+    <KeyPackage>
+        <DeviceInfo>
+            <Manufacturer>Yubico</Manufacturer>
+            <SerialNo>10944003</SerialNo>
+        </DeviceInfo>
+        <CryptoModuleInfo>
+            <Id>2</Id>
+        </CryptoModuleInfo>
+        <Key Id="10944003:2" Algorithm="http://www.yubico.com/#yubikey-aes">
+            <AlgorithmParameters>
+                <ResponseFormat Length="44" Encoding="ALPHANUMERIC"/>
+            </AlgorithmParameters>
+            <Data>
+                <Secret>
+                    <PlainValue>MueRkgTdEbfhBzkRMXgsug==</PlainValue>
+                </Secret>
+            </Data>
+            <UserId>CN=vvdbcihejbhd, UID=3a230e05b9b8</UserId>
+        </Key>
+    </KeyPackage>
+'''
+
+# see https://github.com/privacyidea/privacyidea/issues/2594
+YUBIKEY_PSKC_HOTP = '''
+<KeyContainer Version="1.0"
+    xmlns="urn:ietf:params:xml:ns:keyprov:pskc">
+    <KeyPackage>
+        <DeviceInfo>
+            <Manufacturer>Yubico</Manufacturer>
+            <SerialNo>10944003</SerialNo>
+        </DeviceInfo>
+        <CryptoModuleInfo>
+            <Id>1</Id>
+        </CryptoModuleInfo>
+        <Key Id="10944003:1" Algorithm="urn:ietf:params:xml:ns:keyprov:pskc:hotp">
+            <AlgorithmParameters>
+                <ResponseFormat Length="6" Encoding="DECIMAL"/>
+            </AlgorithmParameters>
+            <Data>
+                <Secret>
+                    <PlainValue>Sl+TPnYomz6VZSlbtDq7BxBNv0U=</PlainValue>
+                </Secret>
+                <Counter>
+                    <PlainValue>0</PlainValue>
+                </Counter>
+            </Data>
+        </Key>
+    </KeyPackage>
+'''
+
 ALADDINXML = '''
         <Tokens>
         <Token serial="00040008CFA5">
@@ -555,10 +609,20 @@ def test_01_import_aladdin_xml(self):
                           ALADDINXML_WITHOUT_TOKENS)
 
     def test_02_import_yubikey(self):
-        tokens = parseYubicoCSV(YUBIKEYCSV)
+        tokens = parseYubicoCSV(YUBIKEY_CSV)
         self.assertTrue(len(tokens) == 7, len(tokens))
         self.assertTrue("UBAM00508326_1" in tokens, tokens)
 
+        tokens, _ = parsePSKCdata(YUBIKEY_PSKC_TOTP)
+        self.assertTrue(len(tokens) == 1, len(tokens))
+        self.assertTrue("UBAM10944003_2" in tokens, tokens)
+        self.assertEqual(tokens["UBAM10944003_2"]["type"], "yubikey")
+
+        tokens, _ = parsePSKCdata(YUBIKEY_PSKC_HOTP)
+        self.assertTrue(len(tokens) == 1, len(tokens))
+        self.assertTrue("UBOM10944003_1" in tokens, tokens)
+        self.assertEqual(tokens["UBOM10944003_1"]["type"], "hotp")
+
     def test_03_import_pskc(self):
         self.assertRaises(ImportException, parsePSKCdata, 'not xml')