summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSimo Sorce <simo@redhat.com>2015-05-08 13:39:29 -0400
committerSimo Sorce <simo@redhat.com>2015-10-01 16:20:48 -0400
commit4265c7e8759482b82ce60642e51a9d0c45867848 (patch)
tree1c44443b10b77857b99140c624071e957cad52bb
parente3cb6305cc39caf8323ed0d1b729369910c97505 (diff)
downloadfreeipa-4265c7e8759482b82ce60642e51a9d0c45867848.tar.gz
freeipa-4265c7e8759482b82ce60642e51a9d0c45867848.tar.xz
freeipa-4265c7e8759482b82ce60642e51a9d0c45867848.zip
Add ipa-custodia service
Add a customized Custodia daemon and enable it after installation. Generates server keys and loads them in LDAP autonomously on install or update. Provides client code classes too. Signed-off-by: Simo Sorce <simo@redhat.com>
-rw-r--r--freeipa.spec.in12
-rw-r--r--init/systemd/ipa-custodia.service13
-rw-r--r--install/conf/ipa.conf10
-rw-r--r--install/share/Makefile.am1
-rw-r--r--install/share/bootstrap-template.ldif6
-rw-r--r--install/share/custodia.conf.template28
-rw-r--r--install/updates/73-custodia.update4
-rw-r--r--ipaplatform/base/paths.py4
-rw-r--r--ipapython/secrets/__init__.py0
-rw-r--r--ipapython/secrets/client.py99
-rw-r--r--ipapython/secrets/common.py45
-rw-r--r--ipapython/secrets/kem.py205
-rw-r--r--ipapython/secrets/store.py199
-rwxr-xr-x[-rw-r--r--]ipapython/setup.py.in1
-rw-r--r--ipaserver/install/custodiainstance.py51
-rw-r--r--ipaserver/install/installutils.py8
-rw-r--r--ipaserver/install/server/install.py8
-rw-r--r--ipaserver/install/server/replicainstall.py9
-rw-r--r--ipaserver/install/server/upgrade.py6
-rw-r--r--ipaserver/install/service.py1
-rw-r--r--ipatests/test_ipapython/test_secrets.py55
21 files changed, 761 insertions, 4 deletions
diff --git a/freeipa.spec.in b/freeipa.spec.in
index 36179c5a8..9771b7cd0 100644
--- a/freeipa.spec.in
+++ b/freeipa.spec.in
@@ -97,6 +97,7 @@ BuildRequires: python-pytest-multihost >= 0.5
BuildRequires: python-pytest-sourceorder
BuildRequires: python-kdcproxy >= 0.3
BuildRequires: python-six
+BuildRequires: custodia
%description
IPA is an integrated solution to provide centrally managed Identity (machine,
@@ -157,6 +158,8 @@ Requires: p11-kit
Requires: systemd-python
Requires: %{etc_systemd_dir}
Requires: gzip
+Requires: python-gssapi >= 1.1.0
+Requires: custodia
Conflicts: %{alt_name}-server
Obsoletes: %{alt_name}-server < %{version}
@@ -506,6 +509,7 @@ mkdir -p %{buildroot}%{etc_systemd_dir}
install -m 644 init/systemd/ipa.service %{buildroot}%{_unitdir}/ipa.service
install -m 644 init/systemd/ipa_memcached.service %{buildroot}%{_unitdir}/ipa_memcached.service
install -m 644 init/systemd/httpd.service %{buildroot}%{etc_systemd_dir}/httpd.service
+install -m 644 init/systemd/ipa-custodia.service %{buildroot}%{_unitdir}/ipa-custodia.service
# END
mkdir -p %{buildroot}/%{_localstatedir}/lib/ipa/backup
%endif # ONLY_CLIENT
@@ -530,6 +534,10 @@ mkdir -p %{buildroot}%{_sysconfdir}/cron.d
(cd %{buildroot}/%{python_sitelib}/ipatests && find . -type f | \
sed -e 's,\.py.*$,.*,g' | sort -u | \
sed -e 's,\./,%%{python_sitelib}/ipatests/,g' ) >tests-python.list
+
+mkdir -p %{buildroot}%{_sysconfdir}/ipa/custodia
+
+
%endif # ONLY_CLIENT
%clean
@@ -733,6 +741,7 @@ fi
%attr(644,root,root) %{_unitdir}/ipa-dnskeysyncd.service
%attr(644,root,root) %{_unitdir}/ipa-ods-exporter.socket
%attr(644,root,root) %{_unitdir}/ipa-ods-exporter.service
+%attr(644,root,root) %{_unitdir}/ipa-custodia.service
%attr(644,root,root) %{etc_systemd_dir}/httpd.service
# END
%dir %{python_sitelib}/ipaserver
@@ -850,6 +859,7 @@ fi
%ghost %{_localstatedir}/lib/ipa/pki-ca/publish
%ghost %{_localstatedir}/named/dyndb-ldap/ipa
%attr(755,root,root) %{_libdir}/krb5/plugins/kdb/ipadb.so
+%dir %attr(0700,root,root) %{_sysconfdir}/ipa/custodia
%{_mandir}/man1/ipa-replica-conncheck.1.gz
%{_mandir}/man1/ipa-replica-install.1.gz
%{_mandir}/man1/ipa-replica-manage.1.gz
@@ -932,6 +942,8 @@ fi
%{python_sitelib}/ipapython/dnssec/*.py*
%dir %{python_sitelib}/ipapython/install
%{python_sitelib}/ipapython/install/*.py*
+%dir %{python_sitelib}/ipapython/secrets
+%{python_sitelib}/ipapython/secrets/*.py*
%dir %{python_sitelib}/ipalib
%{python_sitelib}/ipalib/*
%dir %{python_sitelib}/ipaplatform
diff --git a/init/systemd/ipa-custodia.service b/init/systemd/ipa-custodia.service
new file mode 100644
index 000000000..ff930fbbb
--- /dev/null
+++ b/init/systemd/ipa-custodia.service
@@ -0,0 +1,13 @@
+[Unit]
+Description=IPA Custodia Service
+
+[Service]
+Type=simple
+
+ExecStart=/usr/sbin/custodia /etc/ipa/custodia/custodia.conf
+PrivateTmp=yes
+Restart=on-failure
+RestartSec=60s
+
+[Install]
+WantedBy=multi-user.target
diff --git a/install/conf/ipa.conf b/install/conf/ipa.conf
index e2b602c85..af58e517b 100644
--- a/install/conf/ipa.conf
+++ b/install/conf/ipa.conf
@@ -1,5 +1,5 @@
#
-# VERSION 18 - DO NOT REMOVE THIS LINE
+# VERSION 19 - DO NOT REMOVE THIS LINE
#
# This file may be overwritten on upgrades.
#
@@ -103,6 +103,14 @@ WSGIScriptReloading Off
Allow from all
</Location>
+# Custodia stuff is redirected to the custodia daemon
+# after authentication
+<Location "/ipa/keys/">
+ ProxyPass "unix:/run/httpd/ipa-custodia.sock|http://localhost/keys/"
+ RequestHeader set GSS_NAME %{GSS_NAME}s
+ RequestHeader set REMOTE_USER %{REMOTE_USER}s
+</Location>
+
# This is where we redirect on failed auth
Alias /ipa/errors "/usr/share/ipa/html"
diff --git a/install/share/Makefile.am b/install/share/Makefile.am
index d68c40e69..d952679e6 100644
--- a/install/share/Makefile.am
+++ b/install/share/Makefile.am
@@ -28,6 +28,7 @@ app_DATA = \
anonymous-vlv.ldif \
bootstrap-template.ldif \
caJarSigningCert.cfg.template \
+ custodia.conf.template \
default-aci.ldif \
default-caacl.ldif \
default-hbac.ldif \
diff --git a/install/share/bootstrap-template.ldif b/install/share/bootstrap-template.ldif
index 2387f220f..357062780 100644
--- a/install/share/bootstrap-template.ldif
+++ b/install/share/bootstrap-template.ldif
@@ -167,6 +167,12 @@ objectClass: nsContainer
objectClass: top
cn: certificates
+dn: cn=custodia,cn=ipa,cn=etc,$SUFFIX
+changetype: add
+objectClass: nsContainer
+objectClass: top
+cn: custodia
+
dn: cn=s4u2proxy,cn=etc,$SUFFIX
changetype: add
objectClass: nsContainer
diff --git a/install/share/custodia.conf.template b/install/share/custodia.conf.template
new file mode 100644
index 000000000..688229a50
--- /dev/null
+++ b/install/share/custodia.conf.template
@@ -0,0 +1,28 @@
+[global]
+server_version = "IPAKeys/0.0.1"
+server_socket = $IPA_CUSTODIA_SOCKET
+auditlog = $IPA_CUSTODIA_AUDIT_LOG
+
+[auth:simple]
+handler = custodia.httpd.authenticators.SimpleCredsAuth
+uid = 48
+gid = 48
+
+[auth:header]
+handler = custodia.httpd.authenticators.SimpleHeaderAuth
+header = GSS_NAME
+
+[authz:kemkeys]
+handler = ipapython.secrets.kem.IPAKEMKeys
+paths = /keys
+store = ipa
+server_keys = $IPA_CUSTODIA_CONF_DIR/server.keys
+
+[store:ipa]
+handler = ipapython.secrets.store.iSecStore
+ldap_uri = $LDAP_URI
+
+[/keys]
+handler = custodia.secrets.Secrets
+allowed_keytypes = kem
+store = ipa
diff --git a/install/updates/73-custodia.update b/install/updates/73-custodia.update
new file mode 100644
index 000000000..f6520fb2e
--- /dev/null
+++ b/install/updates/73-custodia.update
@@ -0,0 +1,4 @@
+dn: cn=custodia,cn=ipa,cn=etc,$SUFFIX
+default: objectClass: top
+default: objectClass: nsContainer
+default: cn: custodia
diff --git a/ipaplatform/base/paths.py b/ipaplatform/base/paths.py
index 215caf90e..efbcc0f96 100644
--- a/ipaplatform/base/paths.py
+++ b/ipaplatform/base/paths.py
@@ -354,6 +354,10 @@ class BasePathNamespace(object):
DB2BAK = '/usr/sbin/db2bak'
KDCPROXY_CONFIG = '/etc/ipa/kdcproxy/kdcproxy.conf'
CERTMONGER = '/usr/sbin/certmonger'
+ IPA_CUSTODIA_CONF_DIR = '/etc/ipa/custodia'
+ IPA_CUSTODIA_CONF = '/etc/ipa/custodia/custodia.conf'
+ IPA_CUSTODIA_SOCKET = '/run/httpd/ipa-custodia.sock'
+ IPA_CUSTODIA_AUDIT_LOG = '/var/log/ipa-custodia.audit.log'
path_namespace = BasePathNamespace
diff --git a/ipapython/secrets/__init__.py b/ipapython/secrets/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ipapython/secrets/__init__.py
diff --git a/ipapython/secrets/client.py b/ipapython/secrets/client.py
new file mode 100644
index 000000000..81d066f84
--- /dev/null
+++ b/ipapython/secrets/client.py
@@ -0,0 +1,99 @@
+# Copyright (C) 2015 IPA Project Contributors, see COPYING for license
+
+from __future__ import print_function
+from custodia.message.kem import KEMClient, KEY_USAGE_SIG, KEY_USAGE_ENC
+from jwcrypto.common import json_decode
+from jwcrypto.jwk import JWK
+from ipapython.secrets.kem import IPAKEMKeys
+from ipapython.secrets.store import iSecStore
+from ipaplatform.paths import paths
+from base64 import b64encode
+import ldapurl
+import gssapi
+import os
+import requests
+
+
+class CustodiaClient(object):
+
+ def _client_keys(self):
+ return self.ikk.server_keys
+
+ def _server_keys(self, server, realm):
+ principal = 'host/%s@%s' % (server, realm)
+ sk = JWK(**json_decode(self.ikk.find_key(principal, KEY_USAGE_SIG)))
+ ek = JWK(**json_decode(self.ikk.find_key(principal, KEY_USAGE_ENC)))
+ return (sk, ek)
+
+ def _ldap_uri(self, realm):
+ dashrealm = '-'.join(realm.split('.'))
+ socketpath = paths.SLAPD_INSTANCE_SOCKET_TEMPLATE % (dashrealm,)
+ return 'ldapi://' + ldapurl.ldapUrlEscape(socketpath)
+
+ def _keystore(self, realm, ldap_uri, auth_type):
+ config = dict()
+ if ldap_uri is None:
+ config['ldap_uri'] = self._ldap_uri(realm)
+ else:
+ config['ldap_uri'] = ldap_uri
+ if auth_type is not None:
+ config['auth_type'] = auth_type
+
+ return iSecStore(config)
+
+ def __init__(self, client, server, realm, ldap_uri=None, auth_type=None):
+ self.client = client
+ self.creds = None
+
+ self.service_name = gssapi.Name('HTTP@%s' % (server,),
+ gssapi.NameType.hostbased_service)
+ self.server = server
+
+ keyfile = os.path.join(paths.IPA_CUSTODIA_CONF_DIR, 'server.keys')
+ self.ikk = IPAKEMKeys({'server_keys': keyfile})
+
+ self.kemcli = KEMClient(self._server_keys(server, realm),
+ self._client_keys())
+
+ self.keystore = self._keystore(realm, ldap_uri, auth_type)
+
+ # FIXME: Remove warnings about missig subjAltName
+ requests.packages.urllib3.disable_warnings()
+
+ def init_creds(self):
+ name = gssapi.Name('host@%s' % (self.client,),
+ gssapi.NameType.hostbased_service)
+ store = {'client_keytab': paths.KRB5_KEYTAB,
+ 'ccache': 'MEMORY:Custodia_%s' % b64encode(os.urandom(8))}
+ return gssapi.Credentials(name=name, store=store, usage='initiate')
+
+ def _auth_header(self):
+ if not self.creds or self.creds.lifetime < 300:
+ self.creds = self.init_creds()
+ ctx = gssapi.SecurityContext(name=self.service_name, creds=self.creds)
+ authtok = ctx.step()
+ return {'Authorization': 'Negotiate %s' % b64encode(authtok)}
+
+ def fetch_key(self, keyname):
+
+ # Prepare URL
+ url = 'https://%s/ipa/keys/%s' % (self.server, keyname)
+
+ # Prepare signed/encrypted request
+ encalg = ('RSA1_5', 'A256CBC-HS512')
+ request = self.kemcli.make_request(keyname, encalg=encalg)
+
+ # Prepare Authentication header
+ headers = self._auth_header()
+
+ # Perform request
+ r = requests.get(url, headers=headers,
+ params={'type': 'kem', 'value': request})
+ r.raise_for_status()
+ reply = r.json()
+
+ if 'type' not in reply or reply['type'] != 'kem':
+ raise RuntimeError('Invlid JSON response type')
+
+ value = self.kemcli.parse_reply(keyname, reply['value'])
+ self.keystore.set('keys/%s' % keyname, value)
diff --git a/ipapython/secrets/common.py b/ipapython/secrets/common.py
new file mode 100644
index 000000000..2b906b649
--- /dev/null
+++ b/ipapython/secrets/common.py
@@ -0,0 +1,45 @@
+# Copyright (C) 2015 IPA Project Contributors, see COPYING for license
+from __future__ import print_function
+import ldap
+import ldap.sasl
+import ldap.filter
+
+
+class iSecLdap(object):
+
+ def __init__(self, uri, auth_type=None):
+ self.uri = uri
+ if auth_type is not None:
+ self.auth_type = auth_type
+ else:
+ if uri.startswith('ldapi'):
+ self.auth_type = 'EXTERNAL'
+ else:
+ self.auth_type = 'GSSAPI'
+ self._basedn = None
+
+ @property
+ def basedn(self):
+ if self._basedn is None:
+ conn = self.connect()
+ r = conn.search_s('', ldap.SCOPE_BASE)
+ self._basedn = r[0][1]['defaultnamingcontext'][0]
+ return self._basedn
+
+ def connect(self):
+ conn = ldap.initialize(self.uri)
+ if self.auth_type == 'EXTERNAL':
+ auth_tokens = ldap.sasl.external(None)
+ elif self.auth_type == 'GSSAPI':
+ auth_tokens = ldap.sasl.sasl({}, 'GSSAPI')
+ else:
+ raise ValueError(
+ 'Invalid authentication type: %s' % self.auth_type)
+ conn.sasl_interactive_bind_s('', auth_tokens)
+ return conn
+
+ def build_filter(self, formatstr, args):
+ escaped_args = dict()
+ for key, value in args.iteritems():
+ escaped_args[key] = ldap.filter.escape_filter_chars(value)
+ return formatstr.format(**escaped_args)
diff --git a/ipapython/secrets/kem.py b/ipapython/secrets/kem.py
new file mode 100644
index 000000000..2a5f384a7
--- /dev/null
+++ b/ipapython/secrets/kem.py
@@ -0,0 +1,205 @@
+# Copyright (C) 2015 IPA Project Contributors, see COPYING for license
+
+from __future__ import print_function
+from ipaplatform.paths import paths
+import ConfigParser
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric import rsa, ec
+from custodia.message.kem import KEMKeysStore
+from custodia.message.kem import KEY_USAGE_SIG, KEY_USAGE_ENC, KEY_USAGE_MAP
+from jwcrypto.common import json_decode, json_encode
+from jwcrypto.common import base64url_encode
+from jwcrypto.jwk import JWK
+from ipapython.secrets.common import iSecLdap
+from binascii import unhexlify
+import ldap
+
+
+IPA_REL_BASE_DN = 'cn=custodia,cn=ipa,cn=etc'
+IPA_KEYS_QUERY = '(&(ipaKeyUsage={usage:s})(memberPrincipal={princ:s}))'
+RFC5280_USAGE_MAP = {KEY_USAGE_SIG: 'digitalSignature',
+ KEY_USAGE_ENC: 'dataEncipherment'}
+
+
+class KEMLdap(iSecLdap):
+
+ @property
+ def keysbase(self):
+ return '%s,%s' % (IPA_REL_BASE_DN, self.basedn)
+
+ def _encode_int(self, i):
+ I = hex(i).rstrip("L").lstrip("0x")
+ return base64url_encode(unhexlify((len(I) % 2) * '0' + I))
+
+ def _parse_public_key(self, ipa_public_key):
+ public_key = serialization.load_der_public_key(ipa_public_key,
+ default_backend())
+ num = public_key.public_numbers()
+ if isinstance(num, rsa.RSAPublicNumbers):
+ return {'kty': 'RSA',
+ 'e': self._encode_int(num.e),
+ 'n': self._encode_int(num.n)}
+ elif isinstance(num, ec.EllipticCurvePublicNumbers):
+ if num.curve.name == 'secp256r1':
+ curve = 'P-256'
+ elif num.curve.name == 'secp384r1':
+ curve = 'P-384'
+ elif num.curve.name == 'secp521r1':
+ curve = 'P-521'
+ else:
+ raise TypeError('Unsupported Elliptic Curve')
+ return {'kty': 'EC',
+ 'crv': curve,
+ 'x': self._encode_int(num.x),
+ 'y': self._encode_int(num.y)}
+ else:
+ raise TypeError('Unknown Public Key type')
+
+ def get_key(self, usage, principal):
+ conn = self.connect()
+ scope = ldap.SCOPE_SUBTREE
+
+ ldap_filter = self.build_filter(IPA_KEYS_QUERY,
+ {'usage': RFC5280_USAGE_MAP[usage],
+ 'princ': principal})
+ r = conn.search_s(self.keysbase, scope, ldap_filter)
+ if len(r) != 1:
+ raise ValueError("Incorrect number of results (%d) searching for"
+ "public key for %s" % (len(r), principal))
+ ipa_public_key = r[0][1]['ipaPublicKey'][0]
+ jwk = self._parse_public_key(ipa_public_key)
+ jwk['use'] = KEY_USAGE_MAP[usage]
+ return json_encode(jwk)
+
+ def _format_public_key(self, key):
+ if isinstance(key, str):
+ jwkey = json_decode(key)
+ if 'kty' not in jwkey:
+ raise ValueError('Invalid key, missing "kty" attribute')
+ if jwkey['kty'] == 'RSA':
+ pubnum = rsa.RSAPublicNumbers(jwkey['e'], jwkey['n'])
+ pubkey = pubnum.public_key(default_backend())
+ elif jwkey['kty'] == 'EC':
+ if jwkey['crv'] == 'P-256':
+ curve = ec.SECP256R1
+ elif jwkey['crv'] == 'P-384':
+ curve = ec.SECP384R1
+ elif jwkey['crv'] == 'P-521':
+ curve = ec.SECP521R1
+ else:
+ raise TypeError('Unsupported Elliptic Curve')
+ pubnum = ec.EllipticCurvePublicNumbers(
+ jwkey['x'], jwkey['y'], curve)
+ pubkey = pubnum.public_key(default_backend())
+ else:
+ raise ValueError('Unknown key type: %s' % jwkey['kty'])
+ elif isinstance(key, rsa.RSAPublicKey):
+ pubkey = key
+ elif isinstance(key, ec.EllipticCurvePublicKey):
+ pubkey = key
+ else:
+ raise TypeError('Unknown key type: %s' % type(key))
+
+ return pubkey.public_bytes(
+ encoding=serialization.Encoding.DER,
+ format=serialization.PublicFormat.SubjectPublicKeyInfo)
+
+ def set_key(self, usage, host, principal, key):
+ public_key = self._format_public_key(key)
+ conn = self.connect()
+ name = '%s/%s' % (KEY_USAGE_MAP[usage], host)
+ dn = 'cn=%s,%s' % (name, self.keysbase)
+ try:
+ mods = [('objectClass', ['nsContainer',
+ 'ipaKeyPolicy',
+ 'ipaPublicKeyObject',
+ 'groupOfPrincipals']),
+ ('cn', name),
+ ('ipaKeyUsage', RFC5280_USAGE_MAP[usage]),
+ ('memberPrincipal', principal),
+ ('ipaPublicKey', public_key)]
+ conn.add_s(dn, mods)
+ except Exception: # pylint: disable=broad-except
+ # This may fail if the entry already exists
+ mods = [(ldap.MOD_REPLACE, 'memberPrincipal', principal),
+ (ldap.MOD_REPLACE, 'ipaPublicKey', public_key)]
+ conn.modify_s(dn, mods)
+
+
+def newServerKeys(path, keyid):
+ skey = JWK(generate='RSA', use='sig', kid=keyid)
+ ekey = JWK(generate='RSA', use='enc', kid=keyid)
+ with open(path, 'w+') as f:
+ f.write('[%s,%s]' % (skey.export(), ekey.export()))
+ return [skey.get_op_key('verify'), ekey.get_op_key('encrypt')]
+
+
+class IPAKEMKeys(KEMKeysStore):
+ """A KEM Keys Store.
+
+ This is a store that holds public keys of registered
+ clients allowed to use KEM messages. It takes the form
+ of an authorizer merely for the purpose of attaching
+ itself to a 'request' so that later on the KEM Parser
+ can fetch the appropariate key to verify/decrypt an
+ incoming request and make the payload available.
+
+ The KEM Parser will actually perform additional
+ authorization checks in this case.
+
+ SimplePathAuthz is extended here as we want to attach the
+ store only to requests on paths we are configured to
+ manage.
+ """
+
+ def __init__(self, config=None, ipaconf=paths.IPA_DEFAULT_CONF):
+ super(IPAKEMKeys, self).__init__(config)
+ conf = ConfigParser.ConfigParser()
+ conf.read(ipaconf)
+ self.host = conf.get('global', 'host')
+ self.realm = conf.get('global', 'realm')
+ self.ldap_uri = config.get('ldap_uri', None)
+ if self.ldap_uri is None:
+ self.ldap_uri = conf.get('global', 'ldap_uri', None)
+ self._server_keys = None
+
+ def find_key(self, kid, usage):
+ if kid is None:
+ raise TypeError('Key ID is None, should be a SPN')
+ conn = KEMLdap(self.ldap_uri)
+ return conn.get_key(usage, kid)
+
+ def generate_server_keys(self):
+ principal = 'host/%s@%s' % (self.host, self.realm)
+ # Neutralize the key with read if any
+ self._server_keys = None
+ # Generate private key and store it
+ pubkeys = newServerKeys(self.config['server_keys'], principal)
+ # Store public key in LDAP
+ ldapconn = KEMLdap(self.ldap_uri)
+ ldapconn.set_key(KEY_USAGE_SIG, self.host, principal, pubkeys[0])
+ ldapconn.set_key(KEY_USAGE_ENC, self.host, principal, pubkeys[1])
+
+ @property
+ def server_keys(self):
+ if self._server_keys is None:
+ with open(self.config['server_keys']) as f:
+ jsonkeys = f.read()
+ dictkeys = json_decode(jsonkeys)
+ self._server_keys = (JWK(**dictkeys[KEY_USAGE_SIG]),
+ JWK(**dictkeys[KEY_USAGE_ENC]))
+ return self._server_keys
+
+
+# Manual testing
+if __name__ == '__main__':
+ IKK = IPAKEMKeys({'paths': '/',
+ 'server_keys': '/etc/ipa/custodia/server.keys'})
+ IKK.generate_server_keys()
+ print(('SIG', IKK.server_keys[0].export_public()))
+ print(('ENC', IKK.server_keys[1].export_public()))
+ print(IKK.find_key('host/%s@%s' % (IKK.host, IKK.realm),
+ usage=KEY_USAGE_SIG))
+ print(IKK.find_key('host/%s@%s' % (IKK.host, IKK.realm),
+ usage=KEY_USAGE_ENC))
diff --git a/ipapython/secrets/store.py b/ipapython/secrets/store.py
new file mode 100644
index 000000000..8f2d79826
--- /dev/null
+++ b/ipapython/secrets/store.py
@@ -0,0 +1,199 @@
+# Copyright (C) 2015 IPA Project Contributors, see COPYING for license
+
+from __future__ import print_function
+from base64 import b64encode, b64decode
+from custodia.store.interface import CSStore
+from jwcrypto.common import json_decode, json_encode
+from ipaplatform.paths import paths
+from ipapython import ipautil
+from ipapython.secrets.common import iSecLdap
+import ldap
+import os
+import shutil
+import sys
+import StringIO
+import tempfile
+
+
+class UnknownKeyName(Exception):
+ pass
+
+
+class DBMAPHandler(object):
+
+ def __init__(self, config, dbmap, nickname):
+ raise NotImplementedError
+
+ def export_key(self):
+ raise NotImplementedError
+
+ def import_key(self, value):
+ raise NotImplementedError
+
+
+def log_error(error):
+ print(error, file=sys.stderr)
+
+
+def PKI_TOMCAT_password_callback():
+ password = None
+ with open(paths.PKI_TOMCAT_PASSWORD_CONF) as f:
+ for line in f.readlines():
+ key, value = line.strip().split('=')
+ if key == 'internal':
+ password = value
+ break
+ return password
+
+
+def HTTPD_password_callback():
+ with open(paths.ALIAS_PWDFILE_TXT) as f:
+ password = f.read()
+ return password
+
+
+class NSSCertDB(DBMAPHandler):
+
+ def __init__(self, config, dbmap, nickname):
+ if 'type' not in dbmap or dbmap['type'] != 'NSSDB':
+ raise ValueError('Invalid type "%s",'
+ ' expected "NSSDB"' % (dbmap['type'],))
+ if 'path' not in dbmap:
+ raise ValueError('Configuration does not provide NSSDB path')
+ if 'pwcallback' not in dbmap:
+ raise ValueError('Configuration does not provide Password Calback')
+ self.nssdb_path = dbmap['path']
+ self.nickname = nickname
+ self.nssdb_password = dbmap['pwcallback']()
+
+ def export_key(self):
+ tdir = tempfile.mkdtemp(dir=paths.TMP)
+ try:
+ nsspwfile = os.path.join(tdir, 'nsspwfile')
+ with open(nsspwfile, 'w+') as f:
+ f.write(self.nssdb_password)
+ pk12pwfile = os.path.join(tdir, 'pk12pwfile')
+ password = b64encode(os.urandom(16))
+ with open(pk12pwfile, 'w+') as f:
+ f.write(password)
+ pk12file = os.path.join(tdir, 'pk12file')
+ ipautil.run([paths.PK12UTIL,
+ "-d", self.nssdb_path,
+ "-o", pk12file,
+ "-n", self.nickname,
+ "-k", nsspwfile,
+ "-w", pk12pwfile])
+ with open(pk12file, 'r') as f:
+ data = f.read()
+ finally:
+ shutil.rmtree(tdir)
+ return json_encode({'export password': password,
+ 'pkcs12 data': b64encode(data)})
+
+ def import_key(self, value):
+ v = json_decode(value)
+ tdir = tempfile.mkdtemp(dir=paths.TMP)
+ try:
+ nsspwfile = os.path.join(tdir, 'nsspwfile')
+ with open(nsspwfile, 'w+') as f:
+ f.write(self.nssdb_password)
+ pk12pwfile = os.path.join(tdir, 'pk12pwfile')
+ with open(pk12pwfile, 'w+') as f:
+ f.write(v['export password'])
+ pk12file = os.path.join(tdir, 'pk12file')
+ with open(pk12file, 'w+') as f:
+ f.write(b64decode(v['pkcs12 data']))
+ ipautil.run([paths.PK12UTIL,
+ "-d", self.nssdb_path,
+ "-i", pk12file,
+ "-n", self.nickname,
+ "-k", nsspwfile,
+ "-w", pk12pwfile])
+ finally:
+ shutil.rmtree(tdir)
+
+
+# Exfiltrate the DM password Hash so it can be set in replica's and this
+# way let a replica be install without knowing the DM password and yet
+# still keep the DM password synchronized across replicas
+class DMLDAP(DBMAPHandler):
+
+ def __init__(self, config, dbmap, nickname):
+ if 'type' not in dbmap or dbmap['type'] != 'DMLDAP':
+ raise ValueError('Invalid type "%s",'
+ ' expected "DMLDAP"' % (dbmap['type'],))
+ if nickname != 'DMHash':
+ raise UnknownKeyName("Unknown Key Named '%s'" % nickname)
+ self.ldap = iSecLdap(config['ldap_uri'],
+ config.get('auth_type', None))
+
+ def export_key(self):
+ conn = self.ldap.connect()
+ r = conn.search_s('cn=config', ldap.SCOPE_BASE,
+ attrlist=['nsslapd-rootpw'])
+ if len(r) != 1:
+ raise RuntimeError('DM Hash not found!')
+ return json_encode({'dmhash': r[0][1]['nsslapd-rootpw'][0]})
+
+ def import_key(self, value):
+ v = json_decode(value)
+ conn = self.ldap.connect()
+ mods = [(ldap.MOD_REPLACE, 'nsslapd-rootpw', str(v['dmhash']))]
+ conn.modify_s('cn=config', mods)
+
+
+NAME_DB_MAP = {
+ 'ca': {
+ 'type': 'NSSDB',
+ 'path': paths.PKI_TOMCAT_ALIAS_DIR,
+ 'handler': NSSCertDB,
+ 'pwcallback': PKI_TOMCAT_password_callback,
+ },
+ 'ra': {
+ 'type': 'NSSDB',
+ 'path': paths.HTTPD_ALIAS_DIR,
+ 'handler': NSSCertDB,
+ 'pwcallback': HTTPD_password_callback,
+ },
+ 'dm': {
+ 'type': 'DMLDAP',
+ 'handler': DMLDAP,
+ }
+}
+
+
+class iSecStore(CSStore):
+
+ def __init__(self, config=None):
+ self.config = config
+
+ def _get_handler(self, key):
+ path = key.split('/', 3)
+ if len(path) != 3 or path[0] != 'keys':
+ raise ValueError('Invalid name')
+ if path[1] not in NAME_DB_MAP:
+ raise UnknownKeyName("Unknown DB named '%s'" % path[1])
+ dbmap = NAME_DB_MAP[path[1]]
+ return dbmap['handler'](self.config, dbmap, path[2])
+
+ def get(self, key):
+ try:
+ key_handler = self._get_handler(key)
+ value = key_handler.export_key()
+ except Exception as e: # pylint: disable=broad-except
+ log_error('Error retrievieng key "%s": %s' % (key, str(e)))
+ value = None
+ return value
+
+ def set(self, key, value, replace=False):
+ try:
+ key_handler = self._get_handler(key)
+ key_handler.import_key(value)
+ except Exception as e: # pylint: disable=broad-except
+ log_error('Error storing key "%s": %s' % (key, str(e)))
+
+ def list(self, keyfilter=None):
+ raise NotImplementedError
+
+ def cut(self, key):
+ raise NotImplementedError
diff --git a/ipapython/setup.py.in b/ipapython/setup.py.in
index 882142e4b..bdefe7c9a 100644..100755
--- a/ipapython/setup.py.in
+++ b/ipapython/setup.py.in
@@ -67,6 +67,7 @@ def setup_package():
package_dir = {'ipapython': ''},
packages = ["ipapython",
"ipapython.dnssec",
+ "ipapython.secrets",
"ipapython.install"],
)
finally:
diff --git a/ipaserver/install/custodiainstance.py b/ipaserver/install/custodiainstance.py
new file mode 100644
index 000000000..c21b4537d
--- /dev/null
+++ b/ipaserver/install/custodiainstance.py
@@ -0,0 +1,51 @@
+# Copyright (C) 2015 FreeIPa Project Contributors, see 'COPYING' for license.
+
+from ipapython.secrets.kem import IPAKEMKeys
+from ipaplatform.paths import paths
+from service import SimpleServiceInstance
+from ipapython import ipautil
+from ipaserver.install import installutils
+import os
+
+
+class CustodiaInstance(SimpleServiceInstance):
+ def __init__(self):
+ super(CustodiaInstance, self).__init__("ipa-custodia")
+ self.config_file = paths.IPA_CUSTODIA_CONF
+ self.server_keys = os.path.join(paths.IPA_CUSTODIA_CONF_DIR,
+ 'server.keys')
+
+ def __config_file(self):
+ template_file = os.path.basename(self.config_file) + '.template'
+ template = os.path.join(ipautil.SHARE_DIR, template_file)
+ sub_dict = dict(IPA_CUSTODIA_CONF_DIR=paths.IPA_CUSTODIA_CONF_DIR,
+ IPA_CUSTODIA_SOCKET=paths.IPA_CUSTODIA_SOCKET,
+ IPA_CUSTODIA_AUDIT_LOG=paths.IPA_CUSTODIA_AUDIT_LOG,
+ LDAP_URI=installutils.realm_to_ldapi_uri(self.realm))
+ conf = ipautil.template_file(template, sub_dict)
+ fd = open(self.config_file, "w+")
+ fd.write(conf)
+ fd.flush()
+ fd.close()
+
+ def create_instance(self, *args, **kwargs):
+ self.step("Generating ipa-custodia config file", self.__config_file)
+ self.step("Generating ipa-custodia keys", self.__gen_keys)
+ super(CustodiaInstance, self).create_instance(*args, **kwargs)
+
+ def __gen_keys(self):
+ KeyStore = IPAKEMKeys({'server_keys': self.server_keys})
+ KeyStore.generate_server_keys()
+
+ def upgrade_instance(self, realm):
+ self.realm = realm
+ if not os.path.exists(self.config_file):
+ self.__config_file()
+ if not os.path.exists(self.server_keys):
+ self.__gen_keys()
+
+ def __start(self):
+ super(CustodiaInstance, self).__start()
+
+ def __enable(self):
+ super(CustodiaInstance, self).__enable()
diff --git a/ipaserver/install/installutils.py b/ipaserver/install/installutils.py
index ad79f8896..acf309e78 100644
--- a/ipaserver/install/installutils.py
+++ b/ipaserver/install/installutils.py
@@ -36,6 +36,7 @@ from contextlib import contextmanager
from dns import resolver, rdatatype
from dns.exception import DNSException
import ldap
+import ldapurl
from nss.error import NSPRError
import six
@@ -1097,6 +1098,13 @@ def check_version():
def realm_to_serverid(realm_name):
return "-".join(realm_name.split("."))
+
+def realm_to_ldapi_uri(realm_name):
+ serverid = realm_to_serverid(realm_name)
+ socketname = paths.SLAPD_INSTANCE_SOCKET_TEMPLATE % (serverid,)
+ return 'ldapi://' + ldapurl.ldapUrlEscape(socketname)
+
+
def enable_and_start_oddjobd(sstore):
oddjobd = services.service('oddjobd')
sstore.backup_state('oddjobd', 'running', oddjobd.is_running())
diff --git a/ipaserver/install/server/install.py b/ipaserver/install/server/install.py
index 72f6e4d8d..e936b6798 100644
--- a/ipaserver/install/server/install.py
+++ b/ipaserver/install/server/install.py
@@ -33,7 +33,7 @@ import ipaclient.ntpconf
from ipaserver.install import (
bindinstance, ca, cainstance, certs, dns, dsinstance, httpinstance,
installutils, kra, krbinstance, memcacheinstance, ntpinstance,
- otpdinstance, replication, service, sysupgrade)
+ otpdinstance, custodiainstance, replication, service, sysupgrade)
from ipaserver.install.installutils import (
IPA_MODULES, BadHostError, get_fqdn, get_server_ip_address,
is_ipa_configured, load_pkcs12, read_password, verify_fqdn,
@@ -814,6 +814,11 @@ def install(installer):
otpd.create_instance('OTPD', host_name, dm_password,
ipautil.realm_to_suffix(realm_name))
+ custodia = custodiainstance.CustodiaInstance()
+ custodia.create_instance('KEYS', host_name, dm_password,
+ ipautil.realm_to_suffix(realm_name),
+ realm_name)
+
# Create a HTTP instance
http = httpinstance.HTTPInstance(fstore)
if options.http_cert_files:
@@ -1078,6 +1083,7 @@ def uninstall(installer):
dsinstance.DsInstance(fstore=fstore).uninstall()
if _server_trust_ad_installed:
adtrustinstance.ADTRUSTInstance(fstore).uninstall()
+ custodiainstance.CustodiaInstance().uninstall()
memcacheinstance.MemcacheInstance().uninstall()
otpdinstance.OtpdInstance().uninstall()
tasks.restore_network_configuration(fstore, sstore)
diff --git a/ipaserver/install/server/replicainstall.py b/ipaserver/install/server/replicainstall.py
index 3087091e4..c0b0761eb 100644
--- a/ipaserver/install/server/replicainstall.py
+++ b/ipaserver/install/server/replicainstall.py
@@ -28,7 +28,7 @@ import ipaclient.ntpconf
from ipaserver.install import (
bindinstance, ca, cainstance, certs, dns, dsinstance, httpinstance,
installutils, kra, krbinstance, memcacheinstance, ntpinstance,
- otpdinstance, service)
+ otpdinstance, custodiainstance, service)
from ipaserver.install.installutils import create_replica_config
from ipaserver.install.replication import (
ReplicationManager, replica_conn_check)
@@ -596,6 +596,13 @@ def install(installer):
CA.import_ra_cert(config.dir + "/ra.p12")
CA.fix_ra_perms()
+ # FIXME: must be done earlier in replica to fetch keys for CA/ldap server
+ # before they are configured
+ custodia = custodiainstance.CustodiaInstance()
+ custodia.create_instance('KEYS', config.host_name,
+ config.dirman_password,
+ ipautil.realm_to_suffix(config.realm_name))
+
# The DS instance is created before the keytab, add the SSL cert we
# generated
ds.add_cert_to_service()
diff --git a/ipaserver/install/server/upgrade.py b/ipaserver/install/server/upgrade.py
index 4df621947..42c9cf0f5 100644
--- a/ipaserver/install/server/upgrade.py
+++ b/ipaserver/install/server/upgrade.py
@@ -36,6 +36,7 @@ from ipaserver.install import cainstance
from ipaserver.install import certs
from ipaserver.install import otpdinstance
from ipaserver.install import schemaupdate
+from ipaserver.install import custodiainstance
from ipaserver.install import sysupgrade
from ipaserver.install import dnskeysyncinstance
from ipaserver.install.upgradeinstance import IPAUpgrade
@@ -1465,7 +1466,7 @@ def upgrade_configuration():
service.ldapi = True
try:
if not service.is_configured():
- # 389-ds needs to be running to create the memcache instance
+ # 389-ds needs to be running to create the instances
# because we record the new service in cn=masters.
ds.start()
service.create_instance(ldap_name, fqdn, None,
@@ -1514,6 +1515,9 @@ def upgrade_configuration():
except ipautil.CalledProcessError as e:
root_logger.error("Failed to restart %s: %s", bind.service_name, e)
+ custodia = custodiainstance.CustodiaInstance()
+ custodia.upgrade_instance(api.env.realm)
+
ca_restart = any([
ca_restart,
ca_upgrade_schema(ca),
diff --git a/ipaserver/install/service.py b/ipaserver/install/service.py
index ac65f7b09..b2d111cdf 100644
--- a/ipaserver/install/service.py
+++ b/ipaserver/install/service.py
@@ -40,6 +40,7 @@ SERVICE_LIST = {
'DNS': ('named', 30),
'MEMCACHE': ('ipa_memcached', 39),
'HTTP': ('httpd', 40),
+ 'KEYS': ('ipa-custodia', 41),
'CA': ('%sd' % dogtag.configured_constants().PKI_INSTANCE_NAME, 50),
'KRA': ('%sd' % dogtag.configured_constants().PKI_INSTANCE_NAME, 51),
'ADTRUST': ('smb', 60),
diff --git a/ipatests/test_ipapython/test_secrets.py b/ipatests/test_ipapython/test_secrets.py
new file mode 100644
index 000000000..d88659e6f
--- /dev/null
+++ b/ipatests/test_ipapython/test_secrets.py
@@ -0,0 +1,55 @@
+# Copyright (C) 2015 FreeIPA Project Contributors - see LICENSE file
+
+from __future__ import print_function
+from ipapython.secrets.store import iSecStore, NAME_DB_MAP, NSSCertDB
+import os
+import shutil
+import subprocess
+import unittest
+
+
+def _test_password_callback():
+ with open('test-ipa-sec-store/pwfile') as f:
+ password = f.read()
+ return password
+
+
+class TestiSecStore(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ try:
+ shutil.rmtree('test-ipa-sec-store')
+ except Exception: # pylint: disable=broad-except
+ pass
+ testdir = 'test-ipa-sec-store'
+ pwfile = os.path.join(testdir, 'pwfile')
+ os.mkdir(testdir)
+ with open(pwfile, 'w') as f:
+ f.write('testpw')
+ cls.certdb = os.path.join(testdir, 'certdb')
+ os.mkdir(cls.certdb)
+ cls.cert2db = os.path.join(testdir, 'cert2db')
+ os.mkdir(cls.cert2db)
+ seedfile = os.path.join(testdir, 'seedfile')
+ with open(seedfile, 'w') as f:
+ seed = os.urandom(1024)
+ f.write(seed)
+ subprocess.call(['certutil', '-d', cls.certdb, '-N', '-f', pwfile])
+ subprocess.call(['certutil', '-d', cls.cert2db, '-N', '-f', pwfile])
+ subprocess.call(['certutil', '-d', cls.certdb, '-S', '-f', pwfile,
+ '-s', 'CN=testCA', '-n', 'testCACert', '-x',
+ '-t', 'CT,C,C', '-m', '1', '-z', seedfile])
+
+ def test_iSecStore(self):
+ iss = iSecStore({})
+
+ NAME_DB_MAP['test'] = {
+ 'type': 'NSSDB',
+ 'path': self.certdb,
+ 'handler': NSSCertDB,
+ 'pwcallback': _test_password_callback,
+ }
+ value = iss.get('keys/test/testCACert')
+
+ NAME_DB_MAP['test']['path'] = self.cert2db
+ iss.set('keys/test/testCACert', value)