The attached patch implements the OTP Token import script. However, it
doesn't work. Specifically, at the bottom of the file, when I call
otptoken-add, I get: Unknown option: digits

If I prefix "ipatoken" to "digits", I get: Unknown option:
ipatokendigits

If I remove "**options", I get: invalid 'ipatokenuniqueid':
Gettext('must be Unicode text', domain='ipa', localedir=None)

If I specify the id manually as u'foo', I get: no context.ldap2 in
thread 'MainThread'

What do I need to do in order to setup and call the otptoken-add command
properly?

Nathaniel
>From e5e6cf33d4215f89da60a51d86398d74fe16dffd Mon Sep 17 00:00:00 2001
From: Nathaniel McCallum <npmccal...@redhat.com>
Date: Thu, 8 May 2014 11:06:16 -0400
Subject: [PATCH] Import script

---
 freeipa.spec.in                          |   2 +
 install/tools/Makefile.am                |   1 +
 install/tools/ipa-otptoken-import        |  29 +++
 ipaserver/install/ipa_otptoken_import.py | 365 +++++++++++++++++++++++++++++++
 4 files changed, 397 insertions(+)
 create mode 100755 install/tools/ipa-otptoken-import
 create mode 100644 ipaserver/install/ipa_otptoken_import.py

diff --git a/freeipa.spec.in b/freeipa.spec.in
index 4e3fd7351757be773fae0b02c55549910c5b37ad..850cca85b6deb5ce4a5656fb2328c7a4d6bcc8cb 100644
--- a/freeipa.spec.in
+++ b/freeipa.spec.in
@@ -307,6 +307,7 @@ Requires: python-netaddr
 Requires: libipa_hbac-python
 Requires: python-qrcode
 Requires: python-pyasn1
+Requires: python-dateutil
 
 Obsoletes: ipa-python >= 1.0
 
@@ -660,6 +661,7 @@ fi
 %{_sbindir}/ipa-csreplica-manage
 %{_sbindir}/ipa-server-certinstall
 %{_sbindir}/ipa-ldap-updater
+%{_sbindir}/ipa-otptoken-import
 %{_sbindir}/ipa-compat-manage
 %{_sbindir}/ipa-nis-manage
 %{_sbindir}/ipa-managed-entries
diff --git a/install/tools/Makefile.am b/install/tools/Makefile.am
index 2cf66c6dfc1c272bb423253902e7339e7d159567..485be91b7bca2b0f3822a70d0f027793208918c1 100644
--- a/install/tools/Makefile.am
+++ b/install/tools/Makefile.am
@@ -20,6 +20,7 @@ sbin_SCRIPTS =			\
 	ipa-nis-manage		\
 	ipa-managed-entries     \
 	ipa-ldap-updater	\
+	ipa-otptoken-import	\
 	ipa-upgradeconfig	\
 	ipa-backup		\
 	ipa-restore		\
diff --git a/install/tools/ipa-otptoken-import b/install/tools/ipa-otptoken-import
new file mode 100755
index 0000000000000000000000000000000000000000..f4fc00d40ceb6eea91bec0804b2a8d2e833f4fd4
--- /dev/null
+++ b/install/tools/ipa-otptoken-import
@@ -0,0 +1,29 @@
+#! /usr/bin/python2 -E
+# Authors: Nathaniel McCallum <npmccal...@redhat.com>
+#
+# Copyright (C) 2013  Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+
+from ipaserver.install.ipa_otptoken_import import OTPTokenImport
+import nss.nss as nss
+
+nss.nss_init_nodb()
+
+try:
+    OTPTokenImport.run_cli()
+finally:
+    nss.nss_shutdown()
diff --git a/ipaserver/install/ipa_otptoken_import.py b/ipaserver/install/ipa_otptoken_import.py
new file mode 100644
index 0000000000000000000000000000000000000000..c21bc26da327206c515c9698a8ba49750ab83179
--- /dev/null
+++ b/ipaserver/install/ipa_otptoken_import.py
@@ -0,0 +1,365 @@
+# Authors: Nathaniel McCallum <npmccal...@redhat.com>
+#
+# Copyright (C) 2014  Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+
+import base64
+import datetime
+import hashlib
+import hmac
+import uuid
+
+from lxml import etree
+import dateutil.parser
+import dateutil.tz
+import nss.nss as nss
+
+from ipapython import admintool
+from ipapython.dn import DN
+from ipapython.ipautil import user_input, write_tmp_file
+from ipalib import api, errors
+from ipalib.constants import CACERT
+from ipaserver.install import certs, dsinstance, httpinstance, installutils
+from ipaserver.plugins.ldap2 import ldap2
+
+class ValidationError(Exception):
+    pass
+
+class NoOpConverter(object):
+    "Base class for other conversion classes. Performs no conversion."
+    
+    def __call__(self, value, decryptor=None):
+        return value
+
+class IntegerConverter(NoOpConverter):
+    "Converts strings to integers."
+
+    def __call__(self, value, decryptor=None):
+        return int(value)
+
+class DateConverter(NoOpConverter):
+    "Converts an ISO 8601 string into a UTC datetime object."
+
+    def __call__(self, value, decryptor=None):
+        dt = dateutil.parser.parse(value)
+    
+        if dt.tzinfo is None:
+            dt = datetime.datetime(*dt.timetuple()[0:6],
+                                   tzinfo=dateutil.tz.tzlocal())
+    
+        return dt.astimezone(dateutil.tz.tzutc())
+
+class BaseEnumConverter(NoOpConverter):
+    "Base class for enumerated conversions. Does nothing."
+
+    _DEFAULT = None
+    _ENUM = {}
+    
+    def __call__(self, value, decryptor=None):
+        return self._ENUM.get(value.lower(), self._DEFAULT)
+
+class TokenTypeConverter(BaseEnumConverter):
+    "Converts token algorithm URI to token type string."
+
+    _ENUM = {
+        "urn:ietf:params:xml:ns:keyprov:pskc:hotp": "hotp",
+        "urn:ietf:params:xml:ns:keyprov:pskc#hotp": "hotp",
+        "urn:ietf:params:xml:ns:keyprov:pskc:totp": "totp",
+        "urn:ietf:params:xml:ns:keyprov:pskc#totp": "totp",
+    }
+
+class HMACConverter(BaseEnumConverter):
+    "Converts HMAC URI to hashlib object."
+
+    _ENUM = {
+        "http://www.w3.org/2000/09/xmldsig#hmac-sha1";       : hashlib.sha1,
+        "http://www.w3.org/2001/04/xmldsig-more#hmac-sha224": hashlib.sha224,
+        "http://www.w3.org/2001/04/xmldsig-more#hmac-sha256": hashlib.sha256,
+        "http://www.w3.org/2001/04/xmldsig-more#hmac-sha384": hashlib.sha384,
+        "http://www.w3.org/2001/04/xmldsig-more#hmac-sha512": hashlib.sha512,
+    }
+
+class AlgorithmConverter(BaseEnumConverter):
+    "Converts encryption URI to (mech, ivlen)."
+
+    _DEFAULT = (None, None)
+    _ENUM =  {
+        "http://www.w3.org/2001/04/xmlenc#aes128-cbc";          : (nss.CKM_AES_CBC_PAD, 128),
+        "http://www.w3.org/2001/04/xmlenc#aes192-cbc";          : (nss.CKM_AES_CBC_PAD, 192),
+        "http://www.w3.org/2001/04/xmlenc#aes256-cbc";          : (nss.CKM_AES_CBC_PAD, 256),
+        "http://www.w3.org/2001/04/xmlenc#tripledes-cbc";       : (nss.CKM_DES3_CBC_PAD, 64),
+        "http://www.w3.org/2001/04/xmldsig-more#camellia128";   : (nss.CKM_CAMELLIA_CBC_PAD, 128),
+        "http://www.w3.org/2001/04/xmldsig-more#camellia192";   : (nss.CKM_CAMELLIA_CBC_PAD, 192),
+        "http://www.w3.org/2001/04/xmldsig-more#camellia256";   : (nss.CKM_CAMELLIA_CBC_PAD, 256),
+
+        # TODO: add support for these formats.
+        #"http://www.w3.org/2001/04/xmlenc#kw-aes128";           : "kw-aes128",
+        #"http://www.w3.org/2001/04/xmlenc#kw-aes192";           : "kw-aes192",
+        #"http://www.w3.org/2001/04/xmlenc#kw-aes256";           : "kw-aes256",
+        #"http://www.w3.org/2001/04/xmlenc#kw-tripledes";        : "kw-tripledes",
+        #"http://www.w3.org/2001/04/xmldsig-more#kw-camellia128": "kw-camellia128",
+        #"http://www.w3.org/2001/04/xmldsig-more#kw-camellia192": "kw-camellia192",
+        #"http://www.w3.org/2001/04/xmldsig-more#kw-camellia256": "kw-camellia256",
+    }
+
+def fetchAll(element, xpath, conv=NoOpConverter()):
+    return map(conv, element.xpath(xpath, namespaces={
+        "pskc": "urn:ietf:params:xml:ns:keyprov:pskc",
+        "xenc": "http://www.w3.org/2001/04/xmlenc#";,
+        "ds": "http://www.w3.org/2000/09/xmldsig#";,
+    }))
+    
+def fetch(element, xpath, conv=NoOpConverter()):
+    result = fetchAll(element, xpath, conv)
+    return result[0] if result else None
+
+class XMLDecryptor(object):
+    """This decrypts values from XML as specified in:
+        * http://www.w3.org/TR/xmlenc-core/
+        * RFC 6931"""
+
+    def __init__(self, key, hmac=None):
+        self.__key = nss.SecItem(key)
+        self.__hmac = hmac
+
+    def __call__(self, element, mac=None):
+        (mech, ivlen) = fetch(element, "./xenc:EncryptionMethod/@Algorithm",
+                              AlgorithmConverter())
+        data = fetch(element, "./xenc:CipherData/xenc:CipherValue/text()",
+                     base64.b64decode)
+        
+        # If a MAC is present, perform validation.
+        if mac:
+            tmp = self.__hmac.copy()
+            tmp.update(data)
+            if tmp.digest() != mac:
+                raise ValidationError("MAC validation failed!")
+
+        # Decrypt the data.
+        slot = nss.get_best_slot(mech)
+        key = nss.import_sym_key(slot, mech, nss.PK11_OriginUnwrap,
+                                 nss.CKA_ENCRYPT, self.__key)
+        iv = nss.param_from_iv(mech, nss.SecItem(data[0:ivlen/8]))
+        ctx = nss.create_context_by_sym_key(mech, nss.CKA_DECRYPT, key, iv)
+        out  = ctx.cipher_op(data[ivlen/8:])
+        out += ctx.digest_final()
+        return out
+
+class BaseDataTypeConverter(NoOpConverter):
+    "Converts a value element, decrypting if necessary. See RFC 6030."
+
+    _PV_CONV = NoOpConverter()
+    _EV_CONV = NoOpConverter()
+
+    def __call__(self, value, decryptor=None):
+        v = fetch(value, "./pskc:PlainValue/text()", self.__class__._PV_CONV)
+        if v is not None:
+            return v
+
+        mac = fetch(value, "./pskc:ValueMAC/text()", base64.b64decode)
+        ev = fetch(value, "./pskc:EncryptedValue")
+        if ev is not None and decryptor is not None:
+            return self.__class__._EV_CONV(decryptor(ev, mac))
+
+        return None
+
+class BinaryDataTypeConverter(BaseDataTypeConverter):
+    _PV_CONV = staticmethod(base64.b64decode)
+
+class IntegerDataTypeConverter(BaseDataTypeConverter):
+    _PV_CONV = int
+    _EV_CONV = int
+
+class LongDataTypeConverter(BaseDataTypeConverter):
+    _PV_CONV = long
+    _EV_CONV = long
+
+class TokenParser(object):
+    _TABLE = {
+        "pskc:DeviceInfo": {
+            'pskc:IssueNo/text()'     : ('issueno'     , NoOpConverter()),
+            'pskc:ExpiryDate/text()'  : ('notafter.hw' , DateConverter()),
+            'pskc:Manufacturer/text()': ('vendor'      , NoOpConverter()),
+            'pskc:Model/text()'       : ('model'       , NoOpConverter()),
+            'pskc:SerialNo/text()'    : ('serial'      , NoOpConverter()),
+            'pskc:StartDate/text()'   : ('notbefore.hw', DateConverter()),
+            'pskc:UserId/text()'      : ('owner'       , NoOpConverter()),
+        },
+        
+        "pskc:Key": {
+            '@Algorithm'              : ('type'       , TokenTypeConverter()),
+            '@Id'                     : ('id'         , NoOpConverter()),
+            'pskc:FriendlyName/text()': ('description', NoOpConverter()),
+            'pskc:Issuer/text()'      : ('issuer'     , NoOpConverter()),
+        
+            'pskc:AlgorithmParameters': {
+                'pskc:ResponseFormat/@CheckDigit': ('checkdigit', NoOpConverter()),
+                'pskc:ResponseFormat/@Encoding'  : ('encoding'  , NoOpConverter()),
+                'pskc:ResponseFormat/@Length'    : ('digits'    , IntegerConverter()),
+            },
+
+            'pskc:Data': {
+                'pskc:Counter'     : ('counter' , LongDataTypeConverter()),
+                'pskc:Secret'      : ('key'     , BinaryDataTypeConverter()),
+                'pskc:Time'        : ('time'    , IntegerDataTypeConverter()),
+                'pskc:TimeDrift'   : ('offset'  , IntegerDataTypeConverter()),
+                'pskc:TimeInterval': ('interval', IntegerDataTypeConverter()),
+            },
+
+            'pskc:Policy': {
+                'pskc:ExpiryDate/text()'   : ('notafter.sw' , DateConverter()),
+                'pskc:KeyUsage/text()'     : ('keyusage'    , NoOpConverter()),
+                'pskc:NumberOfTransactions': ('maxtransact' , NoOpConverter()),
+                'pskc:PINPolicy'           : ('pinpolicy'   , NoOpConverter()),
+                'pskc:StartDate/text()'    : ('notbefore.sw', DateConverter()),
+            },
+        },
+    }
+    
+    def __init__(self, decryptor=None):
+        self.__decryptor = decryptor
+    
+    def __parse(self, element, prefix, table):
+        "Recursively parses the xml from the table."
+
+        data = {}
+        for k, v in table.items():
+            path = prefix + "/" + k
+            
+            if isinstance(v, dict):
+                data.update(self.__parse(element, path, v))
+                continue
+
+            result = fetch(element, path)
+            if result is not None:
+                data[v[0]] = v[1](result, self.__decryptor)
+
+        return data
+    
+    def __validate(self, data):
+        "Validates the parsed data."
+        
+        if 'key' not in data:
+            raise ValidationError("Key not found in token!")
+
+        if data.get('checkdigit', 'FALSE').upper() != 'FALSE':
+            raise ValidationError("CheckDigit not supported!")
+
+        if data.get('maxtransact', None):
+            raise ValidationError('NumberOfTransactions policy not supported!')
+        
+        if data.get('pinpolicy', None):
+            raise ValidationError('PINPolicy policy not supported!')
+        
+        if data.get('time', 0) != 0:
+            raise ValidationError('Specified time is not supported!')
+        
+        encoding = data.get('encoding', 'DECIMAL').upper()
+        if encoding != 'DECIMAL':
+            raise ValidationError('Unsupported encoding (%s)!' % encoding)
+
+        usage = data.get('keyusage', 'OTP')
+        if usage != 'OTP':
+            raise ValidationError('Unsupported key usage: %s' % usage)
+
+    def __dates(self, out, data, key, reducer):
+        dates = (data.get(key + '.sw', None), data.get(key + '.hw', None))
+        dates = filter(lambda x: x is not None, dates)
+        if dates:
+            out[key] = reducer(dates).strftime("%Y%m%d%H%M%SZ")
+
+    def __call__(self, element):
+        data = self.__parse(element, ".", self._TABLE)
+        self.__validate(data)
+        
+        # Copy standard values into output (key is required).
+        out = {'key': base64.b32encode(data['key'])}
+        for key in ('vendor', 'model', 'serial', 'description', 'type',
+                    'digits', 'counter', 'offset', 'interval'):
+            if key in data:
+                out[key] = data[key]
+
+        # If owner is specified, parse the DN to the short name.
+        if 'owner' in data:
+            owner = data['owner'].split(',')[0]
+            if '=' in owner:
+                owner = owner.split('=')[1]
+            out['owner'] = owner.strip()
+
+        # If issueno is specified, append it to the serial.
+        if 'issueno' in data:
+            out['serial'] += '-' + data['issueno']
+
+        # The offset is specified in intervals in the XML. Convert it.
+        if 'offset' in out:
+            out['offset'] *= out.get('interval', 30)
+
+        # Process validity dates.
+        self.__dates(out, data, 'notbefore', max)
+        self.__dates(out, data, 'notafter', min)
+
+        return (data.get('id', uuid.uuid4()), out)
+
+class OTPTokenImport(admintool.AdminTool):
+    command_name = 'ipa-otptoken-import'
+    description = "Import OTP tokens."
+    usage = "%prog [options] <PSKC file>"
+
+    @classmethod
+    def add_options(cls, parser):
+        super(OTPTokenImport, cls).add_options(parser)
+
+        parser.add_option("-k", "--keyfile", dest="keyfile",
+                          help="File containing the key used to decrypt token secrets")
+
+    def validate_options(self):
+        super(OTPTokenImport, self).validate_options()
+        
+        # Load the keyfile
+        self.key = None
+        if self.safe_options.keyfile is not None:
+            with open(self.safe_options.keyfile) as f:
+                self.key = f.read()
+        
+        # Parse the file
+        if len(self.args) < 1:
+            raise admintool.ScriptError("Import file required!")
+        self.doc = etree.parse(self.args[0])
+        
+        # Load the decryptor
+        self.decryptor = None
+        keyname = fetch(self.doc, "./pskc:EncryptionKey/ds:KeyName/text()")
+        if keyname is not None:
+            if self.key is None:
+                raise admintool.ScriptError("Key required: %s!" % keyname)
+            
+            self.decryptor = XMLDecryptor(self.key)
+            mkey = fetch(self.doc, "./pskc:MACMethod/pskc:MACKey")
+            algo = fetch(self.doc, "./pskc:MACMethod/@Algorithm", HMACConverter())
+            if mkey is not None and algo is not None:
+                tmp = hmac.HMAC(self.decryptor(mkey), digestmod=algo)
+                self.decryptor = XMLDecryptor(self.key, tmp)
+
+    def run(self):
+        api.bootstrap(in_server=True)
+        api.finalize()
+        
+        # Parse tokens
+        parser = TokenParser(self.decryptor)
+        for keypkg in fetchAll(self.doc, "./pskc:KeyPackage"):
+            id, options = parser(keypkg)
+            api.Command.otptoken_add(id, **options)
-- 
1.9.0

_______________________________________________
Freeipa-devel mailing list
Freeipa-devel@redhat.com
https://www.redhat.com/mailman/listinfo/freeipa-devel

Reply via email to