On Tue, 2014-05-13 at 12:38 -0400, Nathaniel McCallum wrote:
> This patch adds support for importing tokens using RFC 6030 key
> container files. This includes decryption support. For sysadmin sanity,
> any tokens which fail to add will be written to the output file for
> examination. The main use case here is where a small subset of a large
> set of tokens fails to validate or add. Using the output file, the
> sysadmin can attempt to recover these specific tokens.
> 
> This code is implemented as a server-side script. However, it doesn't
> actually need to run on the server. This was done because importing is
> an odd fit for the IPA command framework:
> 1. We need to write an output file.
> 2. The operation may be long-running (thousands of tokens).
> 3. Only admins need to perform this task and it only happens
> infrequently.

I forgot to put the link to the ticket in the commit message. Fixed.
>From b7576825077b68ed68ff3a811c05cbc3eb4e4c12 Mon Sep 17 00:00:00 2001
From: Nathaniel McCallum <npmccal...@redhat.com>
Date: Thu, 8 May 2014 11:06:16 -0400
Subject: [PATCH] Implement OTP token importing

This patch adds support for importing tokens using RFC 6030 key container
files. This includes decryption support. For sysadmin sanity, any tokens
which fail to add will be written to the output file for examination. The
main use case here is where a small subset of a large set of tokens fails
to validate or add. Using the output file, the sysadmin can attempt to
recover these specific tokens.

This code is implemented as a server-side script. However, it doesn't
actually need to run on the server. This was done because importing is an
odd fit for the IPA command framework:
1. We need to write an output file.
2. The operation may be long-running (thousands of tokens).
3. Only admins need to perform this task and it only happens infrequently.

https://fedorahosted.org/freeipa/ticket/4261
---
 freeipa.spec.in                          |   2 +
 install/tools/Makefile.am                |   1 +
 install/tools/ipa-otptoken-import        |  29 +++
 ipaserver/install/ipa_otptoken_import.py | 419 +++++++++++++++++++++++++++++++
 4 files changed, 451 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..8184a6afe04e816727a34085edc458a4d90bf470
--- /dev/null
+++ b/install/tools/ipa-otptoken-import
@@ -0,0 +1,29 @@
+#! /usr/bin/python2 -E
+# 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/>.
+#
+
+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..b8281a90a876cef0eff68396dd2c60f446e6ce98
--- /dev/null
+++ b/ipaserver/install/ipa_otptoken_import.py
@@ -0,0 +1,419 @@
+# 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 os
+import uuid
+import sys
+
+from lxml import etree
+import dateutil.parser
+import dateutil.tz
+import nss.nss as nss
+import krbV
+
+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 UnicodeConverter(NoOpConverter):
+    "Converts strings to unicode."
+
+    def __call__(self, value, decryptor=None):
+        return unicode(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": u"hotp",
+        "urn:ietf:params:xml:ns:keyprov:pskc#hotp": u"hotp",
+        "urn:ietf:params:xml:ns:keyprov:pskc:totp": u"totp",
+        "urn:ietf:params:xml:ns:keyprov:pskc#totp": u"totp",
+    }
+
+class HashConverter(BaseEnumConverter):
+    "Converts hash names to their canonical names."
+
+    _ENUM = {
+        "sha1"   : u"sha1",
+        "sha224" : u"sha224",
+        "sha256" : u"sha256",
+        "sha384" : u"sha384",
+        "sha512" : u"sha512",
+        "sha-1"  : u"sha1",
+        "sha-224": u"sha224",
+        "sha-256": u"sha256",
+        "sha-384": u"sha384",
+        "sha-512": u"sha512",
+    }
+
+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):
+    _XML = {
+        "pskc:DeviceInfo": {
+            'pskc:IssueNo/text()'     : ('issueno'     , UnicodeConverter()),
+            'pskc:ExpiryDate/text()'  : ('notafter.hw' , DateConverter()),
+            'pskc:Manufacturer/text()': ('vendor'      , UnicodeConverter()),
+            'pskc:Model/text()'       : ('model'       , UnicodeConverter()),
+            'pskc:SerialNo/text()'    : ('serial'      , UnicodeConverter()),
+            'pskc:StartDate/text()'   : ('notbefore.hw', DateConverter()),
+            'pskc:UserId/text()'      : ('owner'       , UnicodeConverter()),
+        },
+
+        "pskc:Key": {
+            '@Algorithm'              : ('type'       , TokenTypeConverter()),
+            '@Id'                     : ('id'         , UnicodeConverter()),
+            'pskc:FriendlyName/text()': ('description', UnicodeConverter()),
+            'pskc:Issuer/text()'      : ('issuer'     , UnicodeConverter()),
+
+            'pskc:AlgorithmParameters': {
+                'pskc:Suite/text()'              : ('algorithm' , HashConverter()),
+                'pskc:ResponseFormat/@CheckDigit': ('checkdigit', UnicodeConverter()),
+                'pskc:ResponseFormat/@Encoding'  : ('encoding'  , UnicodeConverter()),
+                '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'    , UnicodeConverter()),
+                'pskc:NumberOfTransactions': ('maxtransact' , NoOpConverter()),
+                'pskc:PINPolicy'           : ('pinpolicy'   , NoOpConverter()),
+                'pskc:StartDate/text()'    : ('notbefore.sw', DateConverter()),
+            },
+        },
+    }
+
+    _MAP = (
+        ('type',        'type',                    lambda v, o: v.strip()),
+        ('description', 'description',             lambda v, o: v.strip()),
+        ('vendor',      'ipatokenvendor',          lambda v, o: v.strip()),
+        ('model',       'ipatokenmodel',           lambda v, o: v.strip()),
+        ('serial',      'ipatokenserial',          lambda v, o: v.strip()),
+        ('issueno',     'ipatokenserial',          lambda v, o: o.get('ipatokenserial', '') + '-' + v.strip()),
+        ('owner',       'ipatokenowner',           lambda v, o: v.split(',')[0].split('=')[1].strip()),
+        ('key',         'ipatokenotpkey',          lambda v, o: unicode(base64.b32encode(v))),
+        ('digits',      'ipatokenotpdigits',       lambda v, o: v),
+        ('algorithm',   'ipatokenotpalgorithm',    lambda v, o: v),
+        ('counter',     'ipatokenhotpcounter',     lambda v, o: v),
+        ('interval',    'ipatokentotptimestep',    lambda v, o: v),
+        ('offset',      'ipatokentotpclockoffset', lambda v, o: o.get('ipatokentotptimestep', 30) * v),
+    )
+
+    def __init__(self, decryptor=None):
+        self.__decryptor = decryptor
+
+    def __parse(self, element, prefix, table):
+        "Recursively parses the xml from a 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['ipatoken' + key] = unicode(reducer(dates).strftime("%Y%m%d%H%M%SZ"))
+
+    def __call__(self, element):
+        data = self.__parse(element, ".", self._XML)
+        self.__validate(data)
+
+        # Copy values into output.
+        out = {}
+        for (dk, ok, f) in self._MAP:
+            if dk in data:
+                out[ok] = f(data[dk], out)
+
+        # Copy 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> <output 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])
+
+        # Get the output file.
+        if len(self.args) < 2:
+            raise admintool.ScriptError("Output file required!")
+        self.output = self.args[1]
+        if os.path.exists(self.output):
+            raise admintool.ScriptError("Output file already exists!")
+        self.doc.write(self.output) # Test writing.
+
+        # 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("Encryption 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()
+
+        conn = ldap2()
+
+        try:
+            ccache = krbV.default_context().default_ccache()
+            conn.connect(ccache=ccache)
+        except (krbV.Krb5Error, errors.ACIError):
+            raise admintool.ScriptError("Unable to connect to LDAP! Did you kinit?")
+
+        try:
+            # Parse tokens
+            parser = TokenParser(self.decryptor)
+            for keypkg in fetchAll(self.doc, "./pskc:KeyPackage"):
+                try:
+                    id, options = parser(keypkg)
+                    api.Command.otptoken_add(id, **options)
+                except:
+                    self.log.warn("Error adding token: " + str(sys.exc_info()[1]))
+                else:
+                    self.log.info("Added token: %s", id)
+                    keypkg.getparent().remove(keypkg)
+        finally:
+            conn.disconnect()
+
+        # Write out the XML file without the tokens that succeeded.
+        self.doc.write(self.output)
-- 
1.9.0

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

Reply via email to