Blob Blame History Raw
From a38601968ccd8c8dfdce60c8d66b220eefb344b0 Mon Sep 17 00:00:00 2001
From: Christian Heimes <cheimes@redhat.com>
Date: Tue, 28 Mar 2017 18:41:08 +0200
Subject: [PATCH 4/4] Vendor custodia.ipa

---
 README.custodia.ipa      | 137 ++++++++++++++++++++++
 custodia/ipa/__init__.py |   1 +
 custodia/ipa/vault.py    | 291 +++++++++++++++++++++++++++++++++++++++++++++++
 setup.py                 |   8 +-
 tests/test_ipa.py        | 195 +++++++++++++++++++++++++++++++
 6 files changed, 638 insertions(+), 2 deletions(-)
 create mode 100644 README.custodia.ipa
 create mode 100644 custodia/ipa/__init__.py
 create mode 100644 custodia/ipa/vault.py
 create mode 100644 tests/test_ipa.py

diff --git a/README.custodia.ipa b/README.custodia.ipa
new file mode 100644
index 0000000..a952ef8
--- /dev/null
+++ b/README.custodia.ipa
@@ -0,0 +1,137 @@
+.. WARNING: AUTO-GENERATED FILE. DO NOT EDIT.
+
+custodia.ipa — IPA vault plugin for Custodia
+============================================
+
+**WARNING** *custodia.ipa is a tech preview with a provisional API.*
+
+custodia.ipa is a storage plugin for
+`Custodia <https://custodia.readthedocs.io/>`__. It provides integration
+with `FreeIPA <http://www.freeipa.org>`__'s
+`vault <https://www.freeipa.org/page/V4/Password_Vault>`__ facility.
+Secrets are encrypted and stored in
+`Dogtag <http://www.dogtagpki.org>`__'s Key Recovery Agent.
+
+Requirements
+------------
+
+Installation
+~~~~~~~~~~~~
+
+-  pip
+-  setuptools >= 18.0
+
+Runtime
+~~~~~~~
+
+-  custodia >= 0.3.1
+-  ipalib >= 4.5.0
+-  ipaclient >= 4.5.0
+-  Python 2.7 (Python 3 support in IPA vault is unstable.)
+
+custodia.ipa requires an IPA-enrolled host and a Kerberos TGT for
+authentication. It is recommended to provide credentials with a keytab
+file or GSS-Proxy.
+
+Testing and development
+~~~~~~~~~~~~~~~~~~~~~~~
+
+-  wheel
+-  tox
+
+virtualenv requirements
+~~~~~~~~~~~~~~~~~~~~~~~
+
+custodia.ipa depends on several binary extensions and shared libraries
+for e.g. python-cryptography, python-gssapi, python-ldap, and
+python-nss. For installation in a virtual environment, a C compiler and
+several development packages are required.
+
+::
+
+    $ virtualenv venv
+    $ venv/bin/pip install --upgrade custodia.ipa
+
+Fedora
+^^^^^^
+
+::
+
+    $ sudo dnf install python2 python-pip python-virtualenv python-devel \
+        gcc redhat-rpm-config krb5-workstation krb5-devel libffi-devel \
+        nss-devel openldap-devel cyrus-sasl-devel openssl-devel
+
+Debian / Ubuntu
+^^^^^^^^^^^^^^^
+
+::
+
+    $ sudo apt-get update
+    $ sudo apt-get install -y python2.7 python-pip python-virtualenv python-dev \
+        gcc krb5-user libkrb5-dev libffi-dev libnss3-dev libldap2-dev \
+        libsasl2-dev libssl-dev
+
+--------------
+
+Example configuration
+---------------------
+
+Create directories
+
+::
+
+    $ sudo mkdir /etc/custodia /var/lib/custodia /var/log/custodia /var/run/custodia
+    $ sudo chown USER:GROUP /var/lib/custodia /var/log/custodia /var/run/custodia
+    $ sudo chmod 750 /var/lib/custodia /var/log/custodia
+
+Create service account and keytab
+
+::
+
+    $ kinit admin
+    $ ipa service-add custodia/client1.ipa.example
+    $ ipa service-allow-create-keytab custodia/client1.ipa.example --users=admin
+    $ mkdir -p /etc/custodia
+    $ ipa-getkeytab -p custodia/client1.ipa.example -k /etc/custodia/custodia.keytab
+
+Create ``/etc/custodia/custodia.conf``
+
+::
+
+    [DEFAULT]
+    confdir = /etc/custodia
+    libdir = /var/lib/custodia
+    logdir = /var/log/custodia
+    rundir = /var/run/custodia
+
+    [global]
+    debug = true
+    server_socket = ${rundir}/custodia.sock
+    auditlog = ${logdir}/audit.log
+
+    [store:vault]
+    handler = IPAVault
+    keytab = {confdir}/custodia.keytab
+    ccache = FILE:{rundir}/ccache
+
+    [auth:creds]
+    handler = SimpleCredsAuth
+    uid = root
+    gid = root
+
+    [authz:paths]
+    handler = SimplePathAuthz
+    paths = /. /secrets
+
+    [/]
+    handler = Root
+
+    [/secrets]
+    handler = Secrets
+    store = vault
+
+Run Custodia server
+
+::
+
+    $ custodia /etc/custodia/custodia.conf
diff --git a/custodia/ipa/__init__.py b/custodia/ipa/__init__.py
new file mode 100644
index 0000000..ef1bb9d
--- /dev/null
+++ b/custodia/ipa/__init__.py
@@ -0,0 +1 @@
+# Copyright (C) 2016  Custodia Project Contributors - see LICENSE file
diff --git a/custodia/ipa/vault.py b/custodia/ipa/vault.py
new file mode 100644
index 0000000..f681c54
--- /dev/null
+++ b/custodia/ipa/vault.py
@@ -0,0 +1,291 @@
+# Copyright (C) 2016  Custodia Project Contributors - see LICENSE file
+"""FreeIPA vault store (PoC)
+"""
+import os
+
+import ipalib
+from ipalib.errors import DuplicateEntry, NotFound
+from ipalib.krb_utils import get_principal
+
+import six
+
+from custodia.plugin import CSStore, CSStoreError, CSStoreExists, PluginOption
+
+
+def krb5_unparse_principal_name(name):
+    """Split a Kerberos principal name into parts
+
+    Returns:
+       * ('host', hostname, realm) for a host principal
+       * (servicename, hostname, realm) for a service principal
+       * (None, username, realm) for a user principal
+
+    :param text name: Kerberos principal name
+    :return: (service, host, realm) or (None, username, realm)
+    """
+    prefix, realm = name.split(u'@')
+    if u'/' in prefix:
+        service, host = prefix.rsplit(u'/', 1)
+        return service, host, realm
+    else:
+        return None, prefix, realm
+
+
+class FreeIPA(object):
+    """FreeIPA wrapper
+
+    Custodia uses a forking server model. We can bootstrap FreeIPA API in
+    the main process. Connections must be created in the client process.
+    """
+    def __init__(self, api=None, ipa_context='cli', ipa_confdir=None,
+                 ipa_debug=False):
+        if api is None:
+            self._api = ipalib.api
+        else:
+            self._api = api
+        self._ipa_config = dict(
+            context=ipa_context,
+            debug=ipa_debug,
+            log=None,  # disable logging to file
+        )
+        if ipa_confdir is not None:
+            self._ipa_config['confdir'] = ipa_confdir
+        self._bootstrap()
+
+    @property
+    def Command(self):
+        return self._api.Command  # pylint: disable=no-member
+
+    @property
+    def env(self):
+        return self._api.env  # pylint: disable=no-member
+
+    def _bootstrap(self):
+        if not self._api.isdone('bootstrap'):
+            # TODO: bandaid for "A PKCS #11 module returned CKR_DEVICE_ERROR"
+            # https://github.com/avocado-framework/avocado/issues/1112#issuecomment-206999400
+            os.environ['NSS_STRICT_NOFORK'] = 'DISABLED'
+            self._api.bootstrap(**self._ipa_config)
+            self._api.finalize()
+
+    def __enter__(self):
+        # pylint: disable=no-member
+        if not self._api.Backend.rpcclient.isconnected():
+            self._api.Backend.rpcclient.connect()
+        # pylint: enable=no-member
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        # pylint: disable=no-member
+        if self._api.Backend.rpcclient.isconnected():
+            self._api.Backend.rpcclient.disconnect()
+        # pylint: enable=no-member
+
+
+class IPAVault(CSStore):
+    # vault arguments
+    principal = PluginOption(
+        str, None,
+        "Service principal for service vault (auto-discovered from GSSAPI)"
+    )
+    user = PluginOption(
+        str, None,
+        "User name for user vault (auto-discovered from GSSAPI)"
+    )
+    vault_type = PluginOption(
+        str, None,
+        "vault type, one of 'user', 'service', 'shared', or "
+        "auto-discovered from GSSAPI"
+    )
+
+    # Kerberos flags
+    krb5config = PluginOption(str, None, "Kerberos krb5.conf override")
+    keytab = PluginOption(str, None, "Kerberos keytab for auth")
+    ccache = PluginOption(
+        str, None, "Kerberos ccache, e,g. FILE:/path/to/ccache")
+
+    # ipalib.api arguments
+    ipa_confdir = PluginOption(str, None, "IPA confdir override")
+    ipa_context = PluginOption(str, "cli", "IPA bootstrap context")
+    ipa_debug = PluginOption(bool, False, "debug mode for ipalib")
+
+    def __init__(self, config, section=None):
+        super(IPAVault, self).__init__(config, section)
+        # configure Kerberos / GSSAPI and acquire principal
+        gssapi_principal = self._gssapi()
+        self.logger.info(u"Vault uses Kerberos principal '%s'",
+                         gssapi_principal)
+
+        # bootstrap FreeIPA API
+        self.ipa = FreeIPA(
+            ipa_confdir=self.ipa_confdir,
+            ipa_debug=self.ipa_debug,
+            ipa_context=self.ipa_context,
+        )
+        # connect
+        with self.ipa:
+            self.logger.info("IPA server '%s': %s",
+                             self.ipa.env.server,
+                             self.ipa.Command.ping()[u'summary'])
+            # retrieve and cache KRA transport cert
+            response = self.ipa.Command.vaultconfig_show()
+            servers = response[u'result'][u'kra_server_server']
+            self.logger.info("KRA server(s) %s", ', '.join(servers))
+
+        service, user_host, realm = krb5_unparse_principal_name(
+            gssapi_principal)
+        self._init_vault_args(service, user_host, realm)
+
+    def _gssapi(self):
+        # set client keytab env var for authentication
+        if self.keytab is not None:
+            os.environ['KRB5_CLIENT_KTNAME'] = self.keytab
+        if self.ccache is not None:
+            os.environ['KRB5CCNAME'] = self.ccache
+        if self.krb5config is not None:
+            os.environ['KRB5_CONFIG'] = self.krb5config
+        try:
+            return get_principal()
+        except Exception:
+            self.logger.error(
+                "Unable to get principal from GSSAPI. Are you missing a "
+                "TGT or valid Kerberos keytab?"
+            )
+            raise
+
+    def _init_vault_args(self, service, user_host, realm):
+        if self.vault_type is None:
+            self.vault_type = 'user' if service is None else 'service'
+            self.logger.info("Setting vault type to '%s' from Kerberos",
+                             self.vault_type)
+
+        if self.vault_type == 'shared':
+            self._vault_args = {'shared': True}
+        elif self.vault_type == 'user':
+            if self.user is None:
+                if service is not None:
+                    msg = "{!r}: User vault requires 'user' parameter"
+                    raise ValueError(msg.format(self))
+                else:
+                    self.user = user_host
+                    self.logger.info(u"Setting username '%s' from Kerberos",
+                                     self.user)
+            if six.PY2 and isinstance(self.user, str):
+                self.user = self.user.decode('utf-8')
+            self._vault_args = {'username': self.user}
+        elif self.vault_type == 'service':
+            if self.principal is None:
+                if service is None:
+                    msg = "{!r}: Service vault requires 'principal' parameter"
+                    raise ValueError(msg.format(self))
+                else:
+                    self.principal = u'/'.join((service, user_host))
+                    self.logger.info(u"Setting principal '%s' from Kerberos",
+                                     self.principal)
+            if six.PY2 and isinstance(self.principal, str):
+                self.principal = self.principal.decode('utf-8')
+            self._vault_args = {'service': self.principal}
+        else:
+            msg = '{!r}: Invalid vault type {}'
+            raise ValueError(msg.format(self, self.vault_type))
+
+    def _mangle_key(self, key):
+        if '__' in key:
+            raise ValueError
+        key = key.replace('/', '__')
+        if isinstance(key, bytes):
+            key = key.decode('utf-8')
+        return key
+
+    def get(self, key):
+        key = self._mangle_key(key)
+        with self.ipa as ipa:
+            try:
+                result = ipa.Command.vault_retrieve(
+                    key, **self._vault_args)
+            except NotFound as e:
+                self.logger.info(str(e))
+                return None
+            except Exception:
+                msg = "Failed to retrieve entry {}".format(key)
+                self.logger.exception(msg)
+                raise CSStoreError(msg)
+            else:
+                return result[u'result'][u'data']
+
+    def set(self, key, value, replace=False):
+        key = self._mangle_key(key)
+        if not isinstance(value, bytes):
+            value = value.encode('utf-8')
+        with self.ipa as ipa:
+            try:
+                ipa.Command.vault_add(
+                    key, ipavaulttype=u"standard", **self._vault_args)
+            except DuplicateEntry:
+                if not replace:
+                    raise CSStoreExists(key)
+            except Exception:
+                msg = "Failed to add entry {}".format(key)
+                self.logger.exception(msg)
+                raise CSStoreError(msg)
+            try:
+                ipa.Command.vault_archive(
+                    key, data=value, **self._vault_args)
+            except Exception:
+                msg = "Failed to archive entry {}".format(key)
+                self.logger.exception(msg)
+                raise CSStoreError(msg)
+
+    def span(self, key):
+        raise CSStoreError("span is not implemented")
+
+    def list(self, keyfilter=None):
+        with self.ipa as ipa:
+            try:
+                result = ipa.Command.vault_find(
+                    ipavaulttype=u"standard", **self._vault_args)
+            except Exception:
+                msg = "Failed to list entries"
+                self.logger.exception(msg)
+                raise CSStoreError(msg)
+
+        names = []
+        for entry in result[u'result']:
+            cn = entry[u'cn'][0]
+            key = cn.replace('__', '/')
+            if keyfilter is not None and not key.startswith(keyfilter):
+                continue
+            names.append(key.rsplit('/', 1)[-1])
+        return names
+
+    def cut(self, key):
+        key = self._mangle_key(key)
+        with self.ipa as ipa:
+            try:
+                ipa.Command.vault_del(key, **self._vault_args)
+            except NotFound:
+                return False
+            except Exception:
+                msg = "Failed to delete entry {}".format(key)
+                self.logger.exception(msg)
+                raise CSStoreError(msg)
+            else:
+                return True
+
+
+if __name__ == '__main__':
+    from custodia.compat import configparser
+
+    parser = configparser.ConfigParser(
+        interpolation=configparser.ExtendedInterpolation()
+    )
+    parser.read_string(u"""
+    [store:ipa_vault]
+    """)
+
+    v = IPAVault(parser, "store:ipa_vault")
+    v.set('foo', 'bar', replace=True)
+    print(v.get('foo'))
+    print(v.list())
+    v.cut('foo')
+    print(v.list())
diff --git a/setup.py b/setup.py
index c8f270d..4bf096c 100755
--- a/setup.py
+++ b/setup.py
@@ -15,10 +15,14 @@ requirements = [
     'requests'
 ]
 
+# extra requirements
+ipa_requires = ['ipalib >= 4.5.0', 'ipaclient >= 4.5.0']
+
 # test requirements
-test_requires = ['coverage', 'pytest']
+test_requires = ['coverage', 'pytest', 'mock'] + ipa_requires
 
 extras_require = {
+    'ipa': ipa_requires,
     'test': test_requires,
     'test_docs': ['docutils', 'markdown'],
     'test_pep8': ['flake8', 'flake8-import-order', 'pep8-naming'],
@@ -66,6 +70,7 @@ custodia_consumers = [
 custodia_stores = [
     'EncryptedOverlay = custodia.store.encgen:EncryptedOverlay',
     'EncryptedStore = custodia.store.enclite:EncryptedStore',
+    'IPAVault = custodia.ipa.vault:IPAVault',
     'SqliteStore = custodia.store.sqlite:SqliteStore',
 ]
 
@@ -84,6 +89,7 @@ setup(
         'custodia',
         'custodia.cli',
         'custodia.httpd',
+        'custodia.ipa',
         'custodia.message',
         'custodia.server',
         'custodia.store',
diff --git a/tests/test_ipa.py b/tests/test_ipa.py
new file mode 100644
index 0000000..eb4d7fa
--- /dev/null
+++ b/tests/test_ipa.py
@@ -0,0 +1,195 @@
+# Copyright (C) 2017  Custodia project Contributors, for licensee see COPYING
+
+import os
+
+import ipalib
+
+import mock
+
+import pytest
+
+from custodia.compat import configparser
+from custodia.ipa.vault import FreeIPA, IPAVault, krb5_unparse_principal_name
+
+
+CONFIG = u"""
+[store:ipa_service]
+vault_type = service
+principal = custodia/ipa.example
+
+[store:ipa_user]
+vault_type = user
+user = john
+
+[store:ipa_shared]
+vault_type = shared
+
+[store:ipa_invalid]
+vault_type = invalid
+
+[store:ipa_autodiscover]
+
+[store:ipa_environ]
+krb5config = /path/to/krb5.conf
+keytab = /path/to/custodia.keytab
+ccache = FILE:/path/to/ccache
+"""
+
+vault_parametrize = pytest.mark.parametrize(
+    'plugin,vault_type,vault_args',
+    [
+        ('store:ipa_service', 'service', {'service': 'custodia/ipa.example'}),
+        ('store:ipa_user', 'user', {'username': 'john'}),
+        ('store:ipa_shared', 'shared', {'shared': True}),
+    ]
+)
+
+
+class TestCustodiaIPA:
+    def setup_method(self, method):
+        self.parser = configparser.ConfigParser(
+            interpolation=configparser.ExtendedInterpolation(),
+        )
+        self.parser.read_string(CONFIG)
+        # mocked ipalib.api
+        self.p_api = mock.patch('ipalib.api', autospec=ipalib.api)
+        self.m_api = self.p_api.start()
+        self.m_api.isdone.return_value = False
+        self.m_api.env = mock.Mock()
+        self.m_api.env.server = 'server.ipa.example'
+        self.m_api.Backend = mock.Mock()
+        self.m_api.Command = mock.Mock()
+        self.m_api.Command.ping.return_value = {
+            u'summary': u'IPA server version 4.4.3. API version 2.215',
+        }
+        self.m_api.Command.vaultconfig_show.return_value = {
+            u'result': {
+                u'kra_server_server': [u'ipa.example'],
+            }
+        }
+        # mocked get_principal
+        self.p_get_principal = mock.patch('custodia.ipa.vault.get_principal')
+        self.m_get_principal = self.p_get_principal.start()
+        self.m_get_principal.return_value = 'custodia/ipa.example@IPA.EXAMPLE'
+        # mocked environ (empty dict)
+        self.p_env = mock.patch.dict('os.environ', clear=True)
+        self.p_env.start()
+
+    def teardown_method(self, method):
+        self.p_api.stop()
+        self.p_get_principal.stop()
+        self.p_env.stop()
+
+    def test_api_init(self):
+        m_api = self.m_api
+        freeipa = FreeIPA(api=m_api)
+        m_api.isdone.assert_called_once_with('bootstrap')
+        m_api.bootstrap.assert_called_once_with(
+            context='cli',
+            debug=False,
+            log=None,
+        )
+
+        m_api.Backend.rpcclient.isconnected.return_value = False
+        with freeipa:
+            m_api.Backend.rpcclient.connect.assert_called_once()
+            m_api.Backend.rpcclient.isconnected.return_value = True
+        m_api.Backend.rpcclient.disconnect.assert_called_once()
+
+        assert os.environ == dict(NSS_STRICT_NOFORK='DISABLED')
+
+    def test_api_environ(self):
+        assert os.environ == {}
+        IPAVault(self.parser, 'store:ipa_environ')
+        assert os.environ == dict(
+            NSS_STRICT_NOFORK='DISABLED',
+            KRB5_CONFIG='/path/to/krb5.conf',
+            KRB5_CLIENT_KTNAME='/path/to/custodia.keytab',
+            KRB5CCNAME='FILE:/path/to/ccache',
+        )
+
+    def test_invalid_vault_type(self):
+        pytest.raises(ValueError, IPAVault, self.parser, 'store:ipa_invalid')
+
+    def test_vault_autodiscover_service(self):
+        self.m_get_principal.return_value = 'custodia/ipa.example@IPA.EXAMPLE'
+        ipa = IPAVault(self.parser, 'store:ipa_autodiscover')
+        assert ipa.vault_type == 'service'
+        assert ipa.principal == 'custodia/ipa.example'
+        assert ipa.user is None
+
+    def test_vault_autodiscover_user(self):
+        self.m_get_principal.return_value = 'john@IPA.EXAMPLE'
+        ipa = IPAVault(self.parser, 'store:ipa_autodiscover')
+        assert ipa.vault_type == 'user'
+        assert ipa.principal is None
+        assert ipa.user == 'john'
+
+    @vault_parametrize
+    def test_vault_set(self, plugin, vault_type, vault_args):
+        ipa = IPAVault(self.parser, plugin)
+        assert ipa.vault_type == vault_type
+        self.m_api.Command.ping.assert_called_once()
+        ipa.set('directory/testkey', 'testvalue')
+        self.m_api.Command.vault_add.assert_called_once_with(
+            'directory__testkey',
+            ipavaulttype=u'standard',
+            **vault_args
+        )
+        self.m_api.Command.vault_archive.assert_called_once_with(
+            'directory__testkey',
+            data=b'testvalue',
+            **vault_args
+        )
+
+    @vault_parametrize
+    def test_vault_get(self, plugin, vault_type, vault_args):
+        ipa = IPAVault(self.parser, plugin)
+        assert ipa.vault_type == vault_type
+        self.m_api.Command.vault_retrieve.return_value = {
+            u'result': {
+                u'data': b'testvalue',
+            }
+        }
+        assert ipa.get('directory/testkey') == b'testvalue'
+        self.m_api.Command.vault_retrieve.assert_called_once_with(
+            'directory__testkey',
+            **vault_args
+        )
+
+    @vault_parametrize
+    def test_vault_list(self, plugin, vault_type, vault_args):
+        ipa = IPAVault(self.parser, plugin)
+        assert ipa.vault_type == vault_type
+        self.m_api.Command.vault_find.return_value = {
+            u'result': [{'cn': [u'directory__testkey']}]
+        }
+        assert ipa.list('directory') == ['testkey']
+        self.m_api.Command.vault_find.assert_called_once_with(
+            ipavaulttype=u'standard',
+            **vault_args
+        )
+
+    @vault_parametrize
+    def test_vault_cut(self, plugin, vault_type, vault_args):
+        ipa = IPAVault(self.parser, plugin)
+        assert ipa.vault_type == vault_type
+        ipa.cut('directory/testkey')
+        self.m_api.Command.vault_del.assert_called_once_with(
+            'directory__testkey',
+            **vault_args
+        )
+
+
+@pytest.mark.parametrize('principal,result', [
+    ('john@IPA.EXAMPLE',
+     (None, 'john', 'IPA.EXAMPLE')),
+    ('host/host.invalid@IPA.EXAMPLE',
+     ('host', 'host.invalid', 'IPA.EXAMPLE')),
+    ('custodia/host.invalid@IPA.EXAMPLE',
+     ('custodia', 'host.invalid', 'IPA.EXAMPLE')),
+    ('whatever/custodia/host.invalid@IPA.EXAMPLE',
+     ('whatever/custodia', 'host.invalid', 'IPA.EXAMPLE')),
+])
+def test_unparse(principal, result):
+    assert krb5_unparse_principal_name(principal) == result
-- 
2.9.3