From 463dda30679da9ac5eea5683984002989965e2a5 Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Fri, 8 May 2015 13:39:29 -0400 Subject: 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 Reviewed-By: Jan Cholasta --- freeipa.spec.in | 14 ++ init/systemd/ipa-custodia.service | 13 ++ install/conf/ipa.conf | 10 +- install/share/Makefile.am | 1 + install/share/bootstrap-template.ldif | 6 + install/share/custodia.conf.template | 28 ++++ install/updates/73-custodia.update | 4 + ipaplatform/base/paths.py | 4 + ipapython/secrets/__init__.py | 0 ipapython/secrets/client.py | 99 ++++++++++++++ ipapython/secrets/common.py | 45 +++++++ ipapython/secrets/kem.py | 205 +++++++++++++++++++++++++++++ ipapython/secrets/store.py | 199 ++++++++++++++++++++++++++++ ipapython/setup.py.in | 1 + ipaserver/install/custodiainstance.py | 51 +++++++ ipaserver/install/installutils.py | 8 ++ ipaserver/install/server/install.py | 8 +- ipaserver/install/server/replicainstall.py | 9 +- ipaserver/install/server/upgrade.py | 6 +- ipaserver/install/service.py | 1 + ipatests/test_ipapython/test_secrets.py | 55 ++++++++ 21 files changed, 763 insertions(+), 4 deletions(-) create mode 100644 init/systemd/ipa-custodia.service create mode 100644 install/share/custodia.conf.template create mode 100644 install/updates/73-custodia.update create mode 100644 ipapython/secrets/__init__.py create mode 100644 ipapython/secrets/client.py create mode 100644 ipapython/secrets/common.py create mode 100644 ipapython/secrets/kem.py create mode 100644 ipapython/secrets/store.py mode change 100644 => 100755 ipapython/setup.py.in create mode 100644 ipaserver/install/custodiainstance.py create mode 100644 ipatests/test_ipapython/test_secrets.py diff --git a/freeipa.spec.in b/freeipa.spec.in index 38c76ce7f..db0e15018 100644 --- a/freeipa.spec.in +++ b/freeipa.spec.in @@ -97,6 +97,8 @@ BuildRequires: python-pytest-multihost >= 0.5 BuildRequires: python-pytest-sourceorder BuildRequires: python-kdcproxy >= 0.3 BuildRequires: python-six +BuildRequires: python-jwcrypto +BuildRequires: custodia %description IPA is an integrated solution to provide centrally managed Identity (users, @@ -158,6 +160,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} @@ -322,6 +326,7 @@ Requires: wget Requires: dbus-python Requires: python-setuptools Requires: python-six +Requires: python-jwcrypto Conflicts: %{alt_name}-python Obsoletes: %{alt_name}-python < %{version} @@ -512,6 +517,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 @@ -536,6 +542,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 @@ -739,6 +749,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 @@ -856,6 +867,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 @@ -938,6 +950,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 +# Custodia stuff is redirected to the custodia daemon +# after authentication + + ProxyPass "unix:/run/httpd/ipa-custodia.sock|http://localhost/keys/" + RequestHeader set GSS_NAME %{GSS_NAME}s + RequestHeader set REMOTE_USER %{REMOTE_USER}s + + # 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 0d2c4c177..38b6c6eec 100644 --- a/ipaplatform/base/paths.py +++ b/ipaplatform/base/paths.py @@ -356,5 +356,9 @@ class BasePathNamespace(object): KDCPROXY_CONFIG = '/etc/ipa/kdcproxy/kdcproxy.conf' CERTMONGER = '/usr/sbin/certmonger' NETWORK_MANAGER_CONFIG_DIR = '/etc/NetworkManager/conf.d' + 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 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 old mode 100644 new mode 100755 index 882142e4b..bdefe7c9a --- 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 58be9f233..ff7ac00e0 100644 --- a/ipaserver/install/installutils.py +++ b/ipaserver/install/installutils.py @@ -35,6 +35,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 from six.moves.configparser import SafeConfigParser, NoOptionError @@ -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 32ea31b1b..6f1da19aa 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 import krainstance @@ -1490,7 +1491,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, @@ -1539,6 +1540,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) -- cgit