From dc5b3af72ad8d26c3aceedd9034a731494ab0204 Mon Sep 17 00:00:00 2001 From: Petr Spacek Date: Oct 21 2014 10:18:55 +0000 Subject: DNSSEC: add ipa dnssec daemons Tickets: https://fedorahosted.org/freeipa/ticket/3801 https://fedorahosted.org/freeipa/ticket/4417 Design: https://fedorahosted.org/bind-dyndb-ldap/wiki/BIND9/Design/DNSSEC Reviewed-By: Jan Cholasta Reviewed-By: David Kupka --- diff --git a/daemons/dnssec/ipa-dnskeysync-replica b/daemons/dnssec/ipa-dnskeysync-replica new file mode 100755 index 0000000..4d3e660 --- /dev/null +++ b/daemons/dnssec/ipa-dnskeysync-replica @@ -0,0 +1,164 @@ +#!/usr/bin/python +# +# Copyright (C) 2014 FreeIPA Contributors see COPYING for license +# +""" +Download keys from LDAP to local HSM. + +This program should be run only on replicas, not on DNSSEC masters. +""" + +from binascii import hexlify +from datetime import datetime +import dns.dnssec +import fcntl +import logging +import os +from pprint import pprint +import subprocess +import socket +import sys +import systemd.daemon +import systemd.journal +import time + +import ipalib +from ipapython.dn import DN +from ipapython.ipa_log_manager import root_logger, standard_logging_setup +from ipapython import ipaldap +from ipapython import ipautil +from ipaserver.plugins.ldap2 import ldap2 +from ipaplatform.paths import paths + +from ipapython.dnssec.abshsm import sync_pkcs11_metadata, ldap2p11helper_api_params, wrappingmech_name2id +from ipapython.dnssec.ldapkeydb import LdapKeyDB +from ipapython.dnssec.localhsm import LocalHSM +import _ipap11helper + +DAEMONNAME = 'ipa-dnskeysyncd' +PRINCIPAL = None # not initialized yet +WORKDIR = '/tmp' + +def hex_set(s): + out = set() + for i in s: + out.add("0x%s" % hexlify(i)) + return out + +def update_metadata_set(log, source_set, target_set): + """sync metadata from source key set to target key set + + Keys not present in both sets are left intact.""" + log = log.getChild('sync_metadata') + matching_keys = set(source_set.keys()).intersection(set(target_set.keys())) + log.info("keys in local HSM & LDAP: %s", hex_set(matching_keys)) + for key_id in matching_keys: + sync_pkcs11_metadata(log, source_set[key_id], target_set[key_id]) + + +def find_unwrapping_key(log, localhsm, wrapping_key_uri): + wrap_keys = localhsm.find_keys(uri=wrapping_key_uri) + # find usable unwrapping key with matching ID + for key_id, key in wrap_keys.iteritems(): + unwrap_keys = localhsm.find_keys(id=key_id, cka_unwrap=True) + if len(unwrap_keys) > 0: + return unwrap_keys.popitem()[1] + +def ldap2replica_master_keys_sync(log, ldapkeydb, localhsm): + ## LDAP -> replica master key synchronization + # import new master keys from LDAP + new_keys = set(ldapkeydb.master_keys.keys()) \ + - set(localhsm.master_keys.keys()) + log.debug("master keys in local HSM: %s", hex_set(localhsm.master_keys.keys())) + log.debug("master keys in LDAP HSM: %s", hex_set(ldapkeydb.master_keys.keys())) + log.debug("new master keys in LDAP HSM: %s", hex_set(new_keys)) + for mkey_id in new_keys: + mkey_ldap = ldapkeydb.master_keys[mkey_id] + for wrapped_ldap in mkey_ldap.wrapped_entries: + unwrapping_key = find_unwrapping_key(log, localhsm, + wrapped_ldap.single_value['ipaWrappingKey']) + if unwrapping_key: + break + + # TODO: Could it happen in normal cases? + assert unwrapping_key is not None, "Local HSM does not contain suitable unwrapping key for master key 0x%s" % hexlify(mkey_id) + + params = ldap2p11helper_api_params(mkey_ldap) + params['data'] = wrapped_ldap.single_value['ipaSecretKey'] + params['unwrapping_key'] = unwrapping_key.handle + params['wrapping_mech'] = wrappingmech_name2id[wrapped_ldap.single_value['ipaWrappingMech']] + log.debug('Importing new master key: 0x%s %s', hexlify(mkey_id), params) + localhsm.p11.import_wrapped_secret_key(**params) + + # synchronize metadata about master keys in LDAP + update_metadata_set(log, ldapkeydb.master_keys, localhsm.master_keys) + +def ldap2replica_zone_keys_sync(log, ldapkeydb, localhsm): + ## LDAP -> replica zone key synchronization + # import new zone keys from LDAP + new_keys = set(ldapkeydb.zone_keypairs.keys()) \ + - set(localhsm.zone_privkeys.keys()) + + log.debug("zone keys in local HSM: %s", hex_set(localhsm.master_keys.keys())) + log.debug("zone keys in LDAP HSM: %s", hex_set(ldapkeydb.master_keys.keys())) + log.debug("new zone keys in LDAP HSM: %s", hex_set(new_keys)) + for zkey_id in new_keys: + zkey_ldap = ldapkeydb.zone_keypairs[zkey_id] + log.debug('Looking for unwrapping key "%s" for zone key 0x%s', + zkey_ldap['ipaWrappingKey'], hexlify(zkey_id)) + unwrapping_key = find_unwrapping_key(log, localhsm, + zkey_ldap['ipaWrappingKey']) + assert unwrapping_key is not None, \ + "Local HSM does not contain suitable unwrapping key for ' \ + 'zone key 0x%s" % hexlify(zkey_id) + + log.debug('Importing zone key pair 0x%s', hexlify(zkey_id)) + localhsm.import_private_key(zkey_ldap, zkey_ldap['ipaPrivateKey'], + unwrapping_key) + localhsm.import_public_key(zkey_ldap, zkey_ldap['ipaPublicKey']) + + # synchronize metadata about zone keys in LDAP & local HSM + update_metadata_set(log, ldapkeydb.master_keys, localhsm.master_keys) + + # delete keys removed from LDAP + deleted_keys = set(localhsm.zone_privkeys.keys()) \ + - set(ldapkeydb.zone_keypairs.keys()) + + for zkey_id in deleted_keys: + localhsm.p11.delete_key(localhsm.zone_pubkeys[zkey_id].handle) + localhsm.p11.delete_key(localhsm.zone_privkeys[zkey_id].handle) + + +# IPA framework initialization +ipalib.api.bootstrap() +ipalib.api.finalize() +standard_logging_setup(verbose=True, debug = True) # debug=ipalib.api.env.debug) +log = root_logger +log.setLevel(level=logging.DEBUG) + +# Kerberos initialization +PRINCIPAL = str('%s/%s' % (DAEMONNAME, ipalib.api.env.host)) +log.debug('Kerberos principal: %s', PRINCIPAL) +ipautil.kinit_hostprincipal(paths.IPA_DNSKEYSYNCD_KEYTAB, WORKDIR, PRINCIPAL) +log.debug('Got TGT') + +# LDAP initialization +ldap = ipalib.api.Backend[ldap2] +# fixme +log.debug('Connecting to LDAP') +ldap.connect(ccache="%s/ccache" % WORKDIR) +log.debug('Connected') + + +### DNSSEC master: key synchronization +ldapkeydb = LdapKeyDB(log, ldap, + DN(ipalib.api.env.container_dnssec_keys, ipalib.api.env.basedn)) + +# TODO: slot number could be configurable +localhsm = LocalHSM(paths.LIBSOFTHSM2_SO, 0, + open(paths.DNSSEC_SOFTHSM_PIN).read()) + +ldap2replica_master_keys_sync(log, ldapkeydb, localhsm) +ldap2replica_zone_keys_sync(log, ldapkeydb, localhsm) + +sys.exit(0) diff --git a/daemons/dnssec/ipa-dnskeysyncd b/daemons/dnssec/ipa-dnskeysyncd new file mode 100755 index 0000000..c7475bd --- /dev/null +++ b/daemons/dnssec/ipa-dnskeysyncd @@ -0,0 +1,106 @@ +#!/usr/bin/python +# +# Copyright (C) 2014 FreeIPA Contributors see COPYING for license +# + +import sys +import ldap +import ldapurl +import logging +import os +import signal +import systemd.journal +import time + +from ipalib import api +from ipapython.dn import DN +from ipapython.ipa_log_manager import root_logger, standard_logging_setup +from ipapython import ipaldap +from ipapython import ipautil +from ipaserver.plugins.ldap2 import ldap2 +from ipaplatform.paths import paths + +from ipapython.dnssec.keysyncer import KeySyncer + +DAEMONNAME = 'ipa-dnskeysyncd' +PRINCIPAL = None # not initialized yet +WORKDIR = '/tmp' # private temp +KEYTAB_FB = paths.IPA_DNSKEYSYNCD_KEYTAB + +# Shutdown handler +def commenceShutdown(signum, stack): + # Declare the needed global variables + global watcher_running, ldap_connection, log + log.info('Signal %s received: Shutting down!', signum) + + # We are no longer running + watcher_running = False + + # Tear down the server connection + if ldap_connection: + ldap_connection.close_db() + del ldap_connection + + # Shutdown + sys.exit(0) + + +os.umask(007) + +# Global state +watcher_running = True +ldap_connection = False + +# Signal handlers +signal.signal(signal.SIGTERM, commenceShutdown) +signal.signal(signal.SIGINT, commenceShutdown) + +# IPA framework initialization +api.bootstrap() +api.finalize() +standard_logging_setup(verbose=True, debug=api.env.debug) +log = root_logger +#log.addHandler(systemd.journal.JournalHandler()) + +# Kerberos initialization +PRINCIPAL = str('%s/%s' % (DAEMONNAME, api.env.host)) +log.debug('Kerberos principal: %s', PRINCIPAL) +ipautil.kinit_hostprincipal(KEYTAB_FB, WORKDIR, PRINCIPAL) + +# LDAP initialization +basedn = DN(api.env.container_dns, api.env.basedn) +ldap_url = ldapurl.LDAPUrl(api.env.ldap_uri) +ldap_url.dn = str(basedn) +ldap_url.scope = ldapurl.LDAP_SCOPE_SUBTREE +ldap_url.filterstr = '(|(objectClass=idnsZone)(objectClass=idnsSecKey)(objectClass=ipk11PublicKey))' +log.debug('LDAP URL: %s', ldap_url.unparse()) + +# Real work +while watcher_running: + # Prepare the LDAP server connection (triggers the connection as well) + ldap_connection = KeySyncer(ldap_url.initializeUrl(), ipa_api=api) + + # Now we login to the LDAP server + try: + log.info('LDAP bind...') + ldap_connection.sasl_interactive_bind_s("", ipaldap.SASL_GSSAPI) + except ldap.INVALID_CREDENTIALS, e: + log.exception('Login to LDAP server failed: %s', e) + sys.exit(1) + except ldap.SERVER_DOWN, e: + log.exception('LDAP server is down, going to retry: %s', e) + time.sleep(5) + continue + + # Commence the syncing + log.info('Commencing sync process') + ldap_search = ldap_connection.syncrepl_search( + ldap_url.dn, + ldap_url.scope, + mode='refreshAndPersist', + attrlist=ldap_url.attrs, + filterstr=ldap_url.filterstr + ) + + while ldap_connection.syncrepl_poll(all=1, msgid=ldap_search): + pass diff --git a/daemons/dnssec/ipa-dnskeysyncd.service b/daemons/dnssec/ipa-dnskeysyncd.service new file mode 100644 index 0000000..ecd38a5 --- /dev/null +++ b/daemons/dnssec/ipa-dnskeysyncd.service @@ -0,0 +1,15 @@ +[Unit] +Description=IPA key daemon + +[Service] +EnvironmentFile=/etc/sysconfig/ipa-dnskeysyncd +ExecStart=/usr/libexec/ipa/ipa-dnskeysyncd +User=ods +Group=named +SupplementaryGroups=ods +PrivateTmp=yes +Restart=on-failure +RestartSec=60s + +[Install] +WantedBy=multi-user.target diff --git a/daemons/dnssec/ipa-ods-exporter b/daemons/dnssec/ipa-ods-exporter new file mode 100755 index 0000000..4ae0d99 --- /dev/null +++ b/daemons/dnssec/ipa-ods-exporter @@ -0,0 +1,501 @@ +#!/usr/bin/python +# +# Copyright (C) 2014 FreeIPA Contributors see COPYING for license +# +""" +This is FreeIPA's replacement for signerd from OpenDNSSEC suite version 1.4.x. + +This program uses the same socket and protocol as original signerd and should +be activated via systemd socket activation using "ods-signer" command line +utility. + +Purpose of this replacement is to upload keys generated by OpenDNSSEC to LDAP. +""" + +from binascii import hexlify +from datetime import datetime +import dns.dnssec +import fcntl +import logging +import os +import subprocess +import socket +import sys +import systemd.daemon +import systemd.journal +import sqlite3 +import time + +import ipalib +from ipapython.dn import DN +from ipapython.ipa_log_manager import root_logger, standard_logging_setup +from ipapython import ipaldap +from ipapython import ipautil +from ipaserver.plugins.ldap2 import ldap2 +from ipaplatform.paths import paths + +from ipapython.dnssec.abshsm import sync_pkcs11_metadata, wrappingmech_name2id +from ipapython.dnssec.ldapkeydb import LdapKeyDB +from ipapython.dnssec.localhsm import LocalHSM +import _ipap11helper + +DAEMONNAME = 'ipa-ods-exporter' +PRINCIPAL = None # not initialized yet +WORKDIR = os.path.join(paths.VAR_OPENDNSSEC_DIR ,'tmp') +KEYTAB_FB = paths.IPA_ODS_EXPORTER_KEYTAB + +ODS_SE_MAXLINE = 1024 # from ODS common/config.h +ODS_DB_LOCK_PATH = "%s%s" % (paths.OPENDNSSEC_KASP_DB, '.our_lock') + +# TODO: MECH_RSA_OAEP +SECRETKEY_WRAPPING_MECH = 'rsaPkcs' +PRIVKEY_WRAPPING_MECH = 'aesKeyWrapPad' + +# DNSKEY flag constants +dnskey_flag_by_value = { + 0x0001: 'SEP', + 0x0080: 'REVOKE', + 0x0100: 'ZONE' +} + +def dnskey_flags_to_text_set(flags): + """Convert a DNSKEY flags value to set texts + @rtype: set([string])""" + + flags_set = set() + mask = 0x1 + while mask <= 0x8000: + if flags & mask: + text = dnskey_flag_by_value.get(mask) + if not text: + text = hex(mask) + flags_set.add(text) + mask <<= 1 + return flags_set + +def datetime2ldap(dt): + return dt.strftime(ipalib.constants.LDAP_GENERALIZED_TIME_FORMAT) + +def sql2datetime(sql_time): + return datetime.strptime(sql_time, "%Y-%m-%d %H:%M:%S") + +def sql2datetimes(row): + row2key_map = {'generate': 'idnsSecKeyCreated', + 'publish': 'idnsSecKeyPublish', + 'active': 'idnsSecKeyActivate', + 'retire': 'idnsSecKeyInactive', + 'dead': 'idnsSecKeyDelete'} + times = {} + for column, key in row2key_map.iteritems(): + if row[column] is not None: + times[key] = sql2datetime(row[column]) + return times + +def sql2ldap_algorithm(sql_algorithm): + return {"idnsSecAlgorithm": dns.dnssec.algorithm_to_text(sql_algorithm)} + +def sql2ldap_flags(sql_flags): + dns_flags = dnskey_flags_to_text_set(sql_flags) + ldap_flags = {} + for flag in dns_flags: + attr = 'idnsSecKey%s' % flag + ldap_flags[attr] = 'TRUE' + return ldap_flags + +def sql2ldap_keyid(sql_keyid): + assert len(sql_keyid) % 2 == 0 + assert len(sql_keyid) > 0 + # TODO: this is huge hack. BIND has some problems with % notation in URIs. + # Workaround: OpenDNSSEC uses same value for ID also for label (but in hex). + uri = "pkcs11:object=%s" % sql_keyid + #uri += '%'.join(sql_keyid[i:i+2] for i in range(0, len(sql_keyid), 2)) + return {"idnsSecKeyRef": uri} + +class ods_db_lock(object): + def __enter__(self): + self.f = open(ODS_DB_LOCK_PATH, 'w') + fcntl.lockf(self.f, fcntl.LOCK_EX) + + def __exit__(self, *args): + fcntl.lockf(self.f, fcntl.LOCK_UN) + self.f.close() + +def get_ldap_zone(ldap, dns_base, name): + zone_names = ["%s." % name, name] + + # find zone object: name can optionally end with period + ldap_zone = None + for zone_name in zone_names: + zone_base = DN("idnsname=%s" % zone_name, dns_base) + try: + ldap_zone = ldap.get_entry(dn=zone_base, + attrs_list=["idnsname"]) + break + except ipalib.errors.NotFound: + continue + + assert ldap_zone is not None, 'DNS zone "%s" should exist in LDAP' % name + + return ldap_zone + +def get_ldap_keys_dn(zone_dn): + """Container DN""" + return DN("cn=keys", zone_dn) + +def get_ldap_keys(ldap, zone_dn): + """Keys objects""" + keys_dn = get_ldap_keys_dn(zone_dn) + ldap_filter = ldap.make_filter_from_attr('objectClass', 'idnsSecKey') + ldap_keys = ldap.get_entries(base_dn=keys_dn, filter=ldap_filter) + + return ldap_keys + +def get_ods_keys(zone_name): + # Open DB directly and read key timestamps etc. + with ods_db_lock(): + db = sqlite3.connect(paths.OPENDNSSEC_KASP_DB, + isolation_level="EXCLUSIVE") + db.row_factory = sqlite3.Row + db.execute('BEGIN') + + # get zone ID + cur = db.execute("SELECT id FROM zones WHERE LOWER(name)=LOWER(?)", + (zone_name,)) + rows = cur.fetchall() + assert len(rows) == 1, "exactly one DNS zone should exist in ODS DB" + zone_id = rows[0][0] + + # get all keys for given zone ID + cur = db.execute("SELECT kp.HSMkey_id, kp.generate, kp.algorithm, dnsk.publish, dnsk.active, dnsk.retire, dnsk.dead, dnsk.keytype " + "FROM keypairs AS kp JOIN dnsseckeys AS dnsk ON kp.id = dnsk.id " + "WHERE dnsk.zone_id = ?", (zone_id,)) + keys = {} + for row in cur: + key_data = sql2datetimes(row) + if 'idnsSecKeyDelete' in key_data \ + and key_data['idnsSecKeyDelete'] > datetime.now(): + continue # ignore deleted keys + + key_data.update(sql2ldap_flags(row['keytype'])) + log.debug("%s", key_data) + assert key_data.get('idnsSecKeyZONE', None) == 'TRUE', \ + 'unexpected key type 0x%x' % row['keytype'] + if key_data.get('idnsSecKeySEP', 'FALSE') == 'TRUE': + key_type = 'KSK' + else: + key_type = 'ZSK' + + key_data.update(sql2ldap_algorithm(row['algorithm'])) + key_id = "%s-%s-%s" % (key_type, + datetime2ldap(key_data['idnsSecKeyCreated']), + row['HSMkey_id']) + + key_data.update(sql2ldap_keyid(row['HSMkey_id'])) + keys[key_id] = key_data + + return keys + +def sync_set_metadata_2ldap(log, source_set, target_set): + """sync metadata from source key set to target key set in LDAP + + Keys not present in both sets are left intact.""" + log = log.getChild('sync_set_metadata_2ldap') + matching_keys = set(source_set.keys()).intersection(set(target_set.keys())) + log.info("keys in local HSM & LDAP: %s", hex_set(matching_keys)) + for key_id in matching_keys: + sync_pkcs11_metadata(log, source_set[key_id], target_set[key_id]) + +def ldap2master_replica_keys_sync(log, ldapkeydb, localhsm): + """LDAP=>master's local HSM replica key synchronization""" + # import new replica keys from LDAP + log = log.getChild('ldap2master_replica') + log.debug("replica pub keys in LDAP: %s", hex_set(ldapkeydb.replica_pubkeys_wrap)) + log.debug("replica pub keys in SoftHSM: %s", hex_set(localhsm.replica_pubkeys_wrap)) + new_replica_keys = set(ldapkeydb.replica_pubkeys_wrap.keys()) \ + - set(localhsm.replica_pubkeys_wrap.keys()) + log.info("new replica keys in LDAP: %s", hex_set(new_replica_keys)) + for key_id in new_replica_keys: + new_key_ldap = ldapkeydb.replica_pubkeys_wrap[key_id] + log.error('label=%s, id=%s, data=%s', + new_key_ldap['ipk11label'], + hexlify(new_key_ldap['ipk11id']), + hexlify(new_key_ldap['ipapublickey'])) + localhsm.import_public_key(new_key_ldap, new_key_ldap['ipapublickey']) + + # set CKA_WRAP = FALSE for all replica keys removed from LDAP + removed_replica_keys = set(localhsm.replica_pubkeys_wrap.keys()) \ + - set(ldapkeydb.replica_pubkeys_wrap.keys()) + log.info("obsolete replica keys in local HSM: %s", + hex_set(removed_replica_keys)) + for key_id in removed_replica_keys: + localhsm.replica_pubkeys_wrap[key_id]['ipk11wrap'] = False + + # synchronize replica key attributes from LDAP to local HSM + sync_set_metadata_2ldap(log, localhsm.replica_pubkeys_wrap, + ldapkeydb.replica_pubkeys_wrap) + +def master2ldap_master_keys_sync(log, ldapkeydb, localhsm): + ## master key -> LDAP synchronization + # export new master keys to LDAP + new_master_keys = set(localhsm.master_keys.keys()) \ + - set(ldapkeydb.master_keys.keys()) + log.debug("master keys in local HSM: %s", hex_set(localhsm.master_keys.keys())) + log.debug("master keys in LDAP HSM: %s", hex_set(ldapkeydb.master_keys.keys())) + log.debug("new master keys in local HSM: %s", hex_set(new_master_keys)) + for mkey_id in new_master_keys: + mkey = localhsm.master_keys[mkey_id] + ldapkeydb.import_master_key(mkey) + + # re-fill cache with keys we just added + ldapkeydb.flush() + log.debug('master keys in LDAP after flush: %s', hex_set(ldapkeydb.master_keys)) + + # synchronize master key metadata to LDAP + for mkey_id, mkey_local in localhsm.master_keys.iteritems(): + log.debug('synchronizing master key metadata: 0x%s', hexlify(mkey_id)) + sync_pkcs11_metadata(log, mkey_local, ldapkeydb.master_keys[mkey_id]) + + # re-wrap all master keys in LDAP with new replica keys (as necessary) + enabled_replica_key_ids = set(localhsm.replica_pubkeys_wrap.keys()) + log.debug('enabled replica key ids: %s', hex_set(enabled_replica_key_ids)) + + for mkey_id, mkey_ldap in ldapkeydb.master_keys.iteritems(): + log.debug('processing master key data: 0x%s', hexlify(mkey_id)) + + # check that all active replicas have own copy of master key + used_replica_keys = set() + for wrapped_entry in mkey_ldap.wrapped_entries: + matching_keys = localhsm.find_keys( + uri=wrapped_entry.single_value['ipaWrappingKey']) + for matching_key in matching_keys.itervalues(): + assert matching_key['ipk11label'].startswith(u'dnssec-replica:'), \ + 'Wrapped key "%s" refers to PKCS#11 URI "%s" which is ' \ + 'not a know DNSSEC replica key: label "%s" does not start ' \ + 'with "dnssec-replica:" prefix' % (wrapped_entry.dn, + wrapped_entry['ipaWrappingKey'], + matching_key['ipk11label']) + used_replica_keys.add(matching_key['ipk11id']) + + new_replica_keys = enabled_replica_key_ids - used_replica_keys + log.debug('master key 0x%s is not wrapped with replica keys %s', + hexlify(mkey_id), hex_set(new_replica_keys)) + + # wrap master key with new replica keys + mkey_local = localhsm.find_keys(id=mkey_id).popitem()[1] + for replica_key_id in new_replica_keys: + log.info('adding master key 0x%s wrapped with replica key 0x%s' % ( + hexlify(mkey_id), hexlify(replica_key_id))) + replica_key = localhsm.replica_pubkeys_wrap[replica_key_id] + keydata = localhsm.p11.export_wrapped_key(mkey_local.handle, + replica_key.handle, _ipap11helper.MECH_RSA_PKCS) + mkey_ldap.add_wrapped_data(keydata, SECRETKEY_WRAPPING_MECH, + replica_key_id) + + ldapkeydb.flush() + +def master2ldap_zone_keys_sync(log, ldapkeydb, localhsm): + # synchroniza zone keys + log = log.getChild('master2ldap_zone_keys') + keypairs_ldap = ldapkeydb.zone_keypairs + log.debug("zone keys in LDAP: %s", hex_set(keypairs_ldap)) + + pubkeys_local = localhsm.zone_pubkeys + privkeys_local = localhsm.zone_privkeys + log.debug("zone keys in local HSM: %s", hex_set(privkeys_local)) + + assert set(pubkeys_local) == set(privkeys_local), \ + "IDs of private and public keys for DNS zones in local HSM does " \ + "not match to key pairs: %s vs. %s" % \ + (hex_set(pubkeys_local), hex_set(privkeys_local)) + + new_keys = set(pubkeys_local) - set(keypairs_ldap) + log.debug("new zone keys in local HSM: %s", hex_set(new_keys)) + mkey = localhsm.active_master_key + # wrap each new zone key pair with selected master key + for zkey_id in new_keys: + pubkey = pubkeys_local[zkey_id] + pubkey_data = localhsm.p11.export_public_key(pubkey.handle) + + privkey = privkeys_local[zkey_id] + privkey_data = localhsm.p11.export_wrapped_key(privkey.handle, + wrapping_key=mkey.handle, + wrapping_mech=wrappingmech_name2id[PRIVKEY_WRAPPING_MECH]) + ldapkeydb.import_zone_key(pubkey, pubkey_data, privkey, privkey_data, + PRIVKEY_WRAPPING_MECH, mkey['ipk11id']) + + sync_set_metadata_2ldap(log, pubkeys_local, keypairs_ldap) + sync_set_metadata_2ldap(log, privkeys_local, keypairs_ldap) + ldapkeydb.flush() + + +def hex_set(s): + out = set() + for i in s: + out.add("0x%s" % hexlify(i)) + return out + +def receive_zone_name(log): + fds = systemd.daemon.listen_fds() + if len(fds) != 1: + raise KeyError('Exactly one socket is expected.') + + sck = socket.fromfd(fds[0], socket.AF_UNIX, socket.SOCK_STREAM) + + conn, addr = sck.accept() + log.debug('accepted new connection %s', repr(conn)) + + # this implements cmdhandler_handle_cmd() logic + cmd = conn.recv(ODS_SE_MAXLINE) + cmd = cmd.strip() + + try: + if cmd == 'ipa-hsm-update': + msg = 'HSM synchronization finished, exiting.' + conn.send('%s\n' % msg) + log.info(msg) + sys.exit(0) + + elif not cmd.startswith('update '): + conn.send('Command "%s" is not supported by IPA; ' \ + 'HSM synchronization was finished and the command ' \ + 'will be ignored.\n' % cmd) + log.info('Ignoring unsupported command "%s".', cmd) + sys.exit(0) + + else: + zone_name = cmd2ods_zone_name(cmd) + conn.send('Update request for zone "%s" queued.\n' % zone_name) + log.info('Processing command: "%s"', cmd) + + finally: + # Reply & close connection early. + # This is necessary to let Enforcer to unlock the ODS DB. + conn.shutdown(socket.SHUT_RDWR) + conn.close() + + return zone_name + +def cmd2ods_zone_name(cmd): + # ODS stores zone name without trailing period + zone_name = cmd[7:].strip() + if len(zone_name) > 1 and zone_name[-1] == '.': + zone_name = zone_name[:-1] + + return zone_name + +log = logging.getLogger('root') +# this service is socket-activated +log.addHandler(systemd.journal.JournalHandler()) +log.setLevel(level=logging.DEBUG) + +if len(sys.argv) != 1: + print __doc__ + sys.exit(1) + +# IPA framework initialization +ipalib.api.bootstrap() +ipalib.api.finalize() + +# Kerberos initialization +PRINCIPAL = str('%s/%s' % (DAEMONNAME, ipalib.api.env.host)) +log.debug('Kerberos principal: %s', PRINCIPAL) +ipautil.kinit_hostprincipal(paths.IPA_ODS_EXPORTER_KEYTAB, WORKDIR, PRINCIPAL) +log.debug('Got TGT') + +# LDAP initialization +dns_dn = DN(ipalib.api.env.container_dns, ipalib.api.env.basedn) +ldap = ipalib.api.Backend[ldap2] +# fixme +log.debug('Connecting to LDAP') +ldap.connect(ccache="%s/ccache" % WORKDIR) +log.debug('Connected') + + +### DNSSEC master: key synchronization +ldapkeydb = LdapKeyDB(log, ldap, DN(ipalib.api.env.container_dnssec_keys, + ipalib.api.env.basedn)) +localhsm = LocalHSM(paths.LIBSOFTHSM2_SO, 0, + open(paths.DNSSEC_SOFTHSM_PIN).read()) + +ldap2master_replica_keys_sync(log, ldapkeydb, localhsm) +master2ldap_master_keys_sync(log, ldapkeydb, localhsm) +master2ldap_zone_keys_sync(log, ldapkeydb, localhsm) + + +### DNSSEC master: DNSSEC key metadata upload +# command receive is delayed so the command will stay in socket queue until +# the problem with LDAP server or HSM is fixed +try: + zone_name = receive_zone_name(log) + +# Handle cases where somebody ran the program without systemd. +except KeyError as e: + print 'HSM (key material) sychronization is finished but ' \ + 'this program should be socket-activated by OpenDNSSEC.' + print 'Use "ods-signer" command line utility to synchronize ' \ + 'DNS zone keys and metadata.' + print 'Error: %s' % e + sys.exit(0) + +ods_keys = get_ods_keys(zone_name) +ods_keys_id = set(ods_keys.keys()) + +ldap_zone = get_ldap_zone(ldap, dns_dn, zone_name) +zone_dn = ldap_zone.dn + +keys_dn = get_ldap_keys_dn(zone_dn) +try: + ldap_keys = get_ldap_keys(ldap, zone_dn) +except ipalib.errors.NotFound: + # cn=keys container does not exist, create it + ldap_keys = [] + ldap_keys_container = ldap.make_entry(keys_dn, + objectClass=['nsContainer']) + try: + ldap.add_entry(ldap_keys_container) + except ipalib.errors.DuplicateEntry: + # ldap.get_entries() does not distinguish non-existent base DN + # from empty result set so addition can fail because container + # itself exists already + pass + +ldap_keys_dict = {} +for ldap_key in ldap_keys: + cn = ldap_key['cn'][0] + ldap_keys_dict[cn] = ldap_key + +ldap_keys = ldap_keys_dict # shorthand +ldap_keys_id = set(ldap_keys.keys()) + +new_keys_id = ods_keys_id - ldap_keys_id +log.info('new keys from ODS: %s', new_keys_id) +for key_id in new_keys_id: + cn = "cn=%s" % key_id + key_dn = DN(cn, keys_dn) + log.debug('adding key "%s" to LDAP', key_dn) + ldap_key = ldap.make_entry(key_dn, + objectClass=['idnsSecKey'], + **ods_keys[key_id]) + ldap.add_entry(ldap_key) + +deleted_keys_id = ldap_keys_id - ods_keys_id +log.info('deleted keys in LDAP: %s', deleted_keys_id) +for key_id in deleted_keys_id: + cn = "cn=%s" % key_id + key_dn = DN(cn, keys_dn) + log.debug('deleting key "%s" from LDAP', key_dn) + ldap.delete_entry(key_dn) + +update_keys_id = ldap_keys_id.intersection(ods_keys_id) +log.info('keys in LDAP & ODS: %s', update_keys_id) +for key_id in update_keys_id: + ldap_key = ldap_keys[key_id] + ods_key = ods_keys[key_id] + log.debug('updating key "%s" in LDAP', ldap_key.dn) + ldap_key.update(ods_key) + try: + ldap.update_entry(ldap_key) + except ipalib.errors.EmptyModlist: + continue + +log.debug('Done') diff --git a/daemons/dnssec/ipa-ods-exporter.service b/daemons/dnssec/ipa-ods-exporter.service new file mode 100644 index 0000000..0d917b8 --- /dev/null +++ b/daemons/dnssec/ipa-ods-exporter.service @@ -0,0 +1,15 @@ +[Unit] +Description=IPA OpenDNSSEC Signer replacement +Wants=ipa-ods-exporter.socket +After=ipa-ods-exporter.socket + +[Service] +EnvironmentFile=/etc/sysconfig/ipa-ods-exporter +ExecStart=/usr/libexec/ipa/ipa-ods-exporter +User=ods +PrivateTmp=yes +Restart=on-failure +RestartSec=60s + +[Install] +WantedBy=multi-user.target diff --git a/daemons/dnssec/ipa-ods-exporter.socket b/daemons/dnssec/ipa-ods-exporter.socket new file mode 100644 index 0000000..1499f18 --- /dev/null +++ b/daemons/dnssec/ipa-ods-exporter.socket @@ -0,0 +1,5 @@ +[Socket] +ListenStream=/var/run/opendnssec/engine.sock + +[Install] +WantedBy=sockets.target diff --git a/freeipa.spec.in b/freeipa.spec.in index a389359..be13e69 100644 --- a/freeipa.spec.in +++ b/freeipa.spec.in @@ -422,6 +422,17 @@ mkdir -p %{buildroot}%{_usr}/share/ipa/html/ mkdir -p %{buildroot}%{_initrddir} mkdir %{buildroot}%{_sysconfdir}/sysconfig/ install -m 644 init/ipa_memcached.conf %{buildroot}%{_sysconfdir}/sysconfig/ipa_memcached +install -m 644 init/ipa-dnskeysyncd.conf %{buildroot}%{_sysconfdir}/sysconfig/ipa-dnskeysyncd +install -m 644 init/ipa-ods-exporter.conf %{buildroot}%{_sysconfdir}/sysconfig/ipa-ods-exporter +install -m 644 daemons/dnssec/ipa-ods-exporter.socket %{buildroot}%{_unitdir}/ipa-ods-exporter.socket +install -m 644 daemons/dnssec/ipa-ods-exporter.service %{buildroot}%{_unitdir}/ipa-ods-exporter.service +install -m 644 daemons/dnssec/ipa-dnskeysyncd.service %{buildroot}%{_unitdir}/ipa-dnskeysyncd.service + +# dnssec daemons +mkdir -p %{buildroot}%{_libexecdir}/ipa/ +install daemons/dnssec/ipa-dnskeysyncd %{buildroot}%{_libexecdir}/ipa/ipa-dnskeysyncd +install daemons/dnssec/ipa-dnskeysync-replica %{buildroot}%{_libexecdir}/ipa/ipa-dnskeysync-replica +install daemons/dnssec/ipa-ods-exporter %{buildroot}%{_libexecdir}/ipa/ipa-ods-exporter # Web UI plugin dir mkdir -p %{buildroot}%{_usr}/share/ipa/ui/js/plugins @@ -640,7 +651,13 @@ fi %{_sbindir}/ipa-cacert-manage %{_libexecdir}/certmonger/dogtag-ipa-ca-renew-agent-submit %{_libexecdir}/ipa-otpd +%dir %{_libexecdir}/ipa +%{_libexecdir}/ipa/ipa-dnskeysyncd +%{_libexecdir}/ipa/ipa-dnskeysync-replica +%{_libexecdir}/ipa/ipa-ods-exporter %config(noreplace) %{_sysconfdir}/sysconfig/ipa_memcached +%config(noreplace) %{_sysconfdir}/sysconfig/ipa-dnskeysyncd +%config(noreplace) %{_sysconfdir}/sysconfig/ipa-ods-exporter %dir %attr(0700,apache,apache) %{_localstatedir}/run/ipa_memcached/ %dir %attr(0700,root,root) %{_localstatedir}/run/ipa/ # NOTE: systemd specific section @@ -649,6 +666,9 @@ fi %attr(644,root,root) %{_unitdir}/ipa_memcached.service %attr(644,root,root) %{_unitdir}/ipa-otpd.socket %attr(644,root,root) %{_unitdir}/ipa-otpd@.service +%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 # END %dir %{python_sitelib}/ipaserver %dir %{python_sitelib}/ipaserver/install @@ -823,6 +843,8 @@ fi %doc COPYING README Contributors.txt %dir %{python_sitelib}/ipapython %{python_sitelib}/ipapython/*.py* +%dir %{python_sitelib}/ipapython/dnssec +%{python_sitelib}/ipapython/dnssec/*.py* %dir %{python_sitelib}/ipalib %{python_sitelib}/ipalib/* %dir %{python_sitelib}/ipaplatform diff --git a/init/ipa-dnskeysyncd.conf b/init/ipa-dnskeysyncd.conf new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/init/ipa-dnskeysyncd.conf diff --git a/init/ipa-ods-exporter.conf b/init/ipa-ods-exporter.conf new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/init/ipa-ods-exporter.conf diff --git a/ipapython/dnssec/__init__.py b/ipapython/dnssec/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/ipapython/dnssec/__init__.py diff --git a/ipapython/dnssec/abshsm.py b/ipapython/dnssec/abshsm.py new file mode 100644 index 0000000..cc81198 --- /dev/null +++ b/ipapython/dnssec/abshsm.py @@ -0,0 +1,187 @@ +#!/usr/bin/python +# +# Copyright (C) 2014 FreeIPA Contributors see COPYING for license +# + +import _ipap11helper + +attrs_id2name = { + #_ipap11helper.CKA_ALLOWED_MECHANISMS: 'ipk11allowedmechanisms', + _ipap11helper.CKA_ALWAYS_AUTHENTICATE: 'ipk11alwaysauthenticate', + _ipap11helper.CKA_ALWAYS_SENSITIVE: 'ipk11alwayssensitive', + #_ipap11helper.CKA_CHECK_VALUE: 'ipk11checkvalue', + _ipap11helper.CKA_COPYABLE: 'ipk11copyable', + _ipap11helper.CKA_DECRYPT: 'ipk11decrypt', + _ipap11helper.CKA_DERIVE: 'ipk11derive', + #_ipap11helper.CKA_DESTROYABLE: 'ipk11destroyable', + _ipap11helper.CKA_ENCRYPT: 'ipk11encrypt', + #_ipap11helper.CKA_END_DATE: 'ipk11enddate', + _ipap11helper.CKA_EXTRACTABLE: 'ipk11extractable', + _ipap11helper.CKA_ID: 'ipk11id', + #_ipap11helper.CKA_KEY_GEN_MECHANISM: 'ipk11keygenmechanism', + _ipap11helper.CKA_KEY_TYPE: 'ipk11keytype', + _ipap11helper.CKA_LABEL: 'ipk11label', + _ipap11helper.CKA_LOCAL: 'ipk11local', + _ipap11helper.CKA_MODIFIABLE: 'ipk11modifiable', + _ipap11helper.CKA_NEVER_EXTRACTABLE: 'ipk11neverextractable', + _ipap11helper.CKA_PRIVATE: 'ipk11private', + #_ipap11helper.CKA_PUBLIC_KEY_INFO: 'ipapublickey', + #_ipap11helper.CKA_PUBLIC_KEY_INFO: 'ipk11publickeyinfo', + _ipap11helper.CKA_SENSITIVE: 'ipk11sensitive', + _ipap11helper.CKA_SIGN: 'ipk11sign', + _ipap11helper.CKA_SIGN_RECOVER: 'ipk11signrecover', + #_ipap11helper.CKA_START_DATE: 'ipk11startdate', + #_ipap11helper.CKA_SUBJECT: 'ipk11subject', + _ipap11helper.CKA_TRUSTED: 'ipk11trusted', + _ipap11helper.CKA_UNWRAP: 'ipk11unwrap', + #_ipap11helper.CKA_UNWRAP_TEMPLATE: 'ipk11unwraptemplate', + _ipap11helper.CKA_VERIFY: 'ipk11verify', + _ipap11helper.CKA_VERIFY_RECOVER: 'ipk11verifyrecover', + _ipap11helper.CKA_WRAP: 'ipk11wrap', + #_ipap11helper.CKA_WRAP_TEMPLATE: 'ipk11wraptemplate', + _ipap11helper.CKA_WRAP_WITH_TRUSTED: 'ipk11wrapwithtrusted', +} + +attrs_name2id = dict(zip(attrs_id2name.values(), attrs_id2name.keys())) + +# attribute: +# http://www.freeipa.org/page/V4/PKCS11_in_LDAP/Schema#ipk11KeyType +# +# mapping table: +# http://www.freeipa.org/page/V4/PKCS11_in_LDAP/Schema#CK_MECHANISM_TYPE +keytype_name2id = { + "rsa": _ipap11helper.KEY_TYPE_RSA, + "aes": _ipap11helper.KEY_TYPE_AES, + } + +keytype_id2name = dict(zip(keytype_name2id.values(), keytype_name2id.keys())) + +wrappingmech_name2id = { + "rsaPkcs": _ipap11helper.MECH_RSA_PKCS, + "rsaPkcsOaep": _ipap11helper.MECH_RSA_PKCS_OAEP, + "aesKeyWrap": _ipap11helper.MECH_AES_KEY_WRAP, + "aesKeyWrapPad": _ipap11helper.MECH_AES_KEY_WRAP_PAD + } + +wrappingmech_id2name = dict(zip(wrappingmech_name2id.values(), + wrappingmech_name2id.keys())) + + +bool_attr_names = set([ + 'ipk11alwaysauthenticate', + 'ipk11alwayssensitive', + 'ipk11copyable', + 'ipk11decrypt', + 'ipk11derive', + 'ipk11encrypt', + 'ipk11extractable', + 'ipk11local', + 'ipk11modifiable', + 'ipk11neverextractable', + 'ipk11private', + 'ipk11sensitive', + 'ipk11sign', + 'ipk11signrecover', + 'ipk11trusted', + 'ipk11unwrap', + 'ipk11verify', + 'ipk11verifyrecover', + 'ipk11wrap', + 'ipk11wrapwithtrusted', +]) + +modifiable_attrs_id2name = { + _ipap11helper.CKA_DECRYPT: 'ipk11decrypt', + _ipap11helper.CKA_DERIVE: 'ipk11derive', + _ipap11helper.CKA_ENCRYPT: 'ipk11encrypt', + _ipap11helper.CKA_EXTRACTABLE: 'ipk11extractable', + _ipap11helper.CKA_ID: 'ipk11id', + _ipap11helper.CKA_LABEL: 'ipk11label', + _ipap11helper.CKA_SENSITIVE: 'ipk11sensitive', + _ipap11helper.CKA_SIGN: 'ipk11sign', + _ipap11helper.CKA_SIGN_RECOVER: 'ipk11signrecover', + _ipap11helper.CKA_UNWRAP: 'ipk11unwrap', + _ipap11helper.CKA_VERIFY: 'ipk11verify', + _ipap11helper.CKA_VERIFY_RECOVER: 'ipk11verifyrecover', + _ipap11helper.CKA_WRAP: 'ipk11wrap', +} + +modifiable_attrs_name2id = dict(zip(modifiable_attrs_id2name.values(), + modifiable_attrs_id2name.keys())) + +def sync_pkcs11_metadata(log, source, target): + """sync ipk11 metadata from source object to target object""" + + # iterate over list of modifiable PKCS#11 attributes - this prevents us + # from attempting to set read-only attributes like CKA_LOCAL + for attr in modifiable_attrs_name2id: + if attr in source: + if source[attr] != target[attr]: + log.debug('Updating attribute %s from "%s" to "%s"', attr, repr(source[attr]), repr(target[attr])) + target[attr] = source[attr] + +def populate_pkcs11_metadata(source, target): + """populate all ipk11 metadata attributes in target object from source object""" + for attr in attrs_name2id: + if attr in source: + target[attr] = source[attr] + +def ldap2p11helper_api_params(ldap_key): + """prepare dict with metadata parameters suitable for key unwrapping""" + unwrap_params = {} + + # some attributes are just renamed + direct_param_map = { + "ipk11label": "label", + "ipk11id": "id", + "ipk11copyable": "cka_copyable", + "ipk11decrypt": "cka_decrypt", + "ipk11derive": "cka_derive", + "ipk11encrypt": "cka_encrypt", + "ipk11extractable": "cka_extractable", + "ipk11modifiable": "cka_modifiable", + "ipk11private": "cka_private", + "ipk11sensitive": "cka_sensitive", + "ipk11sign": "cka_sign", + "ipk11unwrap": "cka_unwrap", + "ipk11verify": "cka_verify", + "ipk11wrap": "cka_wrap", + "ipk11wrapwithtrusted": "cka_wrap_with_trusted" + } + + for ldap_name, p11h_name in direct_param_map.iteritems(): + if ldap_name in ldap_key: + unwrap_params[p11h_name] = ldap_key[ldap_name] + + # and some others needs conversion + + indirect_param_map = { + "ipk11keytype": ("key_type", keytype_name2id), + "ipawrappingmech": ("wrapping_mech", wrappingmech_name2id), + } + + for ldap_name, rules in indirect_param_map.iteritems(): + p11h_name, mapping = rules + if ldap_name in ldap_key: + unwrap_params[p11h_name] = mapping[ldap_key[ldap_name]] + + return unwrap_params + + +class AbstractHSM(object): + def _filter_replica_keys(self, all_keys): + replica_keys = {} + for key_id, key in all_keys.iteritems(): + if not key['ipk11label'].startswith('dnssec-replica:'): + continue + replica_keys[key_id] = key + return replica_keys + + def _filter_zone_keys(self, all_keys): + zone_keys = {} + for key_id, key in all_keys.iteritems(): + if key['ipk11label'] == u'dnssec-master' \ + or key['ipk11label'].startswith('dnssec-replica:'): + continue + zone_keys[key_id] = key + return zone_keys diff --git a/ipapython/dnssec/bindmgr.py b/ipapython/dnssec/bindmgr.py new file mode 100644 index 0000000..55765e1 --- /dev/null +++ b/ipapython/dnssec/bindmgr.py @@ -0,0 +1,176 @@ +#!/usr/bin/python +# +# Copyright (C) 2014 FreeIPA Contributors see COPYING for license +# + +from datetime import datetime +import dns.name +import errno +import os +import logging +import shutil +import stat +import subprocess + +from ipalib import api +import ipalib.constants +from ipapython.dn import DN +from ipapython import ipa_log_manager, ipautil +from ipaplatform.paths import paths + +from temp import TemporaryDirectory + +time_bindfmt = '%Y%m%d%H%M%S' + +# this daemon should run under ods:named user:group +# user has to be ods because ODSMgr.py sends signal to ods-enforcerd +FILE_PERM = (stat.S_IRUSR | stat.S_IRGRP | stat.S_IWGRP | stat.S_IWUSR) +DIR_PERM = (stat.S_IRWXU | stat.S_IRWXG) + +class BINDMgr(object): + """BIND key manager. It does LDAP->BIND key files synchronization. + + One LDAP object with idnsSecKey object class will produce + single pair of BIND key files. + """ + def __init__(self, api): + self.api = api + self.log = ipa_log_manager.log_mgr.get_logger(self) + self.ldap_keys = {} + self.modified_zones = set() + + def notify_zone(self, zone): + cmd = ['rndc', 'sign', zone.to_text()] + output = ipautil.run(cmd)[0] + self.log.info(output) + + def dn2zone_name(self, dn): + """cn=KSK-20140813162153Z-cede9e182fc4af76c4bddbc19123a565,cn=keys,idnsname=test,cn=dns,dc=ipa,dc=example""" + # verify that metadata object is under DNS sub-tree + dn = DN(dn) + container = DN(self.api.env.container_dns, self.api.env.basedn) + idx = dn.rfind(container) + assert idx != -1, 'Metadata object %s is not inside %s' % (dn, container) + assert len(dn[idx - 1]) == 1, 'Multi-valued RDN as zone name is not supported' + return dns.name.from_text(dn[idx - 1]['idnsname']) + + def time_ldap2bindfmt(self, str_val): + dt = datetime.strptime(str_val, ipalib.constants.LDAP_GENERALIZED_TIME_FORMAT) + return dt.strftime(time_bindfmt) + + def dates2params(self, ldap_attrs): + attr2param = {'idnsseckeypublish': '-P', + 'idnsseckeyactivate': '-A', + 'idnsseckeyinactive': '-I', + 'idnsseckeydelete': '-D'} + + params = [] + for attr, param in attr2param.items(): + if attr in ldap_attrs: + params.append(param) + assert len(ldap_attrs[attr]) == 1, 'Timestamp %s is expected to be single-valued' % attr + params.append(self.time_ldap2bindfmt(ldap_attrs[attr][0])) + + return params + + def ldap_event(self, op, uuid, attrs): + """Record single LDAP event - key addition, deletion or modification. + + Change is only recorded to memory. + self.sync() has to be called to synchronize change to BIND.""" + assert op == 'add' or op == 'del' or op == 'mod' + zone = self.dn2zone_name(attrs['dn']) + self.modified_zones.add(zone) + zone_keys = self.ldap_keys.setdefault(zone, {}) + if op == 'add': + self.log.info('Key metadata %s added to zone %s' % (attrs['dn'], zone)) + zone_keys[uuid] = attrs + + elif op == 'del': + self.log.info('Key metadata %s deleted from zone %s' % (attrs['dn'], zone)) + zone_keys.pop(uuid) + + elif op == 'mod': + self.log.info('Key metadata %s updated in zone %s' % (attrs['dn'], zone)) + zone_keys[uuid] = attrs + + def install_key(self, zone, uuid, attrs, workdir): + """Run dnssec-keyfromlabel on given LDAP object. + :returns: base file name of output files, e.g. Kaaa.test.+008+19719""" + self.log.info('attrs: %s', attrs) + assert attrs.get('idnsseckeyzone', ['FALSE'])[0] == 'TRUE', \ + 'object %s is not a DNS zone key' % attrs['dn'] + + uri = "%s;pin-source=%s" % (attrs['idnsSecKeyRef'][0], paths.DNSSEC_SOFTHSM_PIN) + cmd = [paths.DNSSEC_KEYFROMLABEL, '-K', workdir, '-a', attrs['idnsSecAlgorithm'][0], '-l', uri] + cmd += self.dates2params(attrs) + if attrs.get('idnsSecKeySep', ['FALSE'])[0].upper() == 'TRUE': + cmd += ['-f', 'KSK'] + if attrs.get('idnsSecKeyRevoke', ['FALSE'])[0].upper() == 'TRUE': + cmd += ['-R', datetime.now().strftime(time_bindfmt)] + cmd.append(zone.to_text()) + + # keys has to be readable by ODS & named + basename = ipautil.run(cmd)[0].strip() + private_fn = "%s/%s.private" % (workdir, basename) + os.chmod(private_fn, FILE_PERM) + # this is useful mainly for debugging + with open("%s/%s.uuid" % (workdir, basename), 'w') as uuid_file: + uuid_file.write(uuid) + with open("%s/%s.dn" % (workdir, basename), 'w') as dn_file: + dn_file.write(attrs['dn']) + + def sync_zone(self, zone): + self.log.info('Synchronizing zone %s' % zone) + zone_path = os.path.join(paths.BIND_LDAP_DNS_ZONE_WORKDIR, + zone.to_text(omit_final_dot=True)) + try: + os.makedirs(zone_path) + except OSError as e: + if e.errno != errno.EEXIST: + raise e + + # fix HSM permissions + # TODO: move out + for prefix, dirs, files in os.walk(paths.DNSSEC_TOKENS_DIR, topdown=True): + for name in dirs: + fpath = os.path.join(prefix, name) + self.log.debug('Fixing directory permissions: %s', fpath) + os.chmod(fpath, DIR_PERM | stat.S_ISGID) + for name in files: + fpath = os.path.join(prefix, name) + self.log.debug('Fixing file permissions: %s', fpath) + os.chmod(fpath, FILE_PERM) + # TODO: move out + + with TemporaryDirectory(zone_path) as tempdir: + for uuid, attrs in self.ldap_keys[zone].items(): + self.install_key(zone, uuid, attrs, tempdir) + # keys were generated in a temporary directory, swap directories + target_dir = "%s/keys" % zone_path + try: + shutil.rmtree(target_dir) + except OSError as e: + if e.errno != errno.ENOENT: + raise e + shutil.move(tempdir, target_dir) + os.chmod(target_dir, DIR_PERM) + + self.notify_zone(zone) + + def sync(self): + """Synchronize list of zones in LDAP with BIND.""" + self.log.debug('Key metadata in LDAP: %s' % self.ldap_keys) + for zone in self.modified_zones: + self.sync_zone(zone) + + self.modified_zones = set() + + def diff_zl(self, s1, s2): + """Compute zones present in s1 but not present in s2. + + Returns: List of (uuid, name) tuples with zones present only in s1.""" + s1_extra = s1.uuids - s2.uuids + removed = [(uuid, name) for (uuid, name) in s1.mapping.items() + if uuid in s1_extra] + return removed diff --git a/ipapython/dnssec/keysyncer.py b/ipapython/dnssec/keysyncer.py new file mode 100644 index 0000000..1b27573 --- /dev/null +++ b/ipapython/dnssec/keysyncer.py @@ -0,0 +1,181 @@ +#!/usr/bin/python +# +# Copyright (C) 2014 FreeIPA Contributors see COPYING for license +# + +import logging +import ldap.dn +import os + +from ipaplatform.paths import paths +from ipapython import ipautil + +from syncrepl import SyncReplConsumer +from odsmgr import ODSMgr +from bindmgr import BINDMgr + +SIGNING_ATTR = 'idnsSecInlineSigning' +OBJCLASS_ATTR = 'objectClass' + + +class KeySyncer(SyncReplConsumer): + def __init__(self, *args, **kwargs): + # hack + self.api = kwargs['ipa_api'] + del kwargs['ipa_api'] + + # DNSSEC master should have OpenDNSSEC installed + # TODO: Is this the best way? + if os.environ.get('ISMASTER', '0') == '1': + self.ismaster = True + self.odsmgr = ODSMgr() + else: + self.ismaster = False + + self.bindmgr = BINDMgr(self.api) + self.init_done = False + SyncReplConsumer.__init__(self, *args, **kwargs) + + def _get_objclass(self, attrs): + """Get object class. + + Given set of attributes has to have exactly one supported object class. + """ + supported_objclasses = set(['idnszone', 'idnsseckey', 'ipk11publickey']) + present_objclasses = set([o.lower() for o in attrs[OBJCLASS_ATTR]]).intersection(supported_objclasses) + assert len(present_objclasses) == 1, attrs[OBJCLASS_ATTR] + return present_objclasses.pop() + + def __get_signing_attr(self, attrs): + """Get SIGNING_ATTR from dictionary with LDAP zone attributes. + + Returned value is normalized to TRUE or FALSE, defaults to FALSE.""" + values = attrs.get(SIGNING_ATTR, ['FALSE']) + assert len(values) == 1, '%s is expected to be single-valued' \ + % SIGNING_ATTR + return values[0].upper() + + def __is_dnssec_enabled(self, attrs): + """Test if LDAP DNS zone with given attributes is DNSSEC enabled.""" + return self.__get_signing_attr(attrs) == 'TRUE' + + def __is_replica_pubkey(self, attrs): + vals = attrs.get('ipk11label', []) + if len(vals) != 1: + return False + return vals[0].startswith('dnssec-replica:') + + def application_add(self, uuid, dn, newattrs): + objclass = self._get_objclass(newattrs) + if objclass == 'idnszone': + self.zone_add(uuid, dn, newattrs) + elif objclass == 'idnsseckey': + self.key_meta_add(uuid, dn, newattrs) + elif objclass == 'ipk11publickey' and \ + self.__is_replica_pubkey(newattrs): + self.hsm_master_sync() + + def application_del(self, uuid, dn, oldattrs): + objclass = self._get_objclass(oldattrs) + if objclass == 'idnszone': + self.zone_del(uuid, dn, oldattrs) + elif objclass == 'idnsseckey': + self.key_meta_del(uuid, dn, oldattrs) + elif objclass == 'ipk11publickey' and \ + self.__is_replica_pubkey(oldattrs): + self.hsm_master_sync() + + def application_sync(self, uuid, dn, newattrs, oldattrs): + objclass = self._get_objclass(oldattrs) + if objclass == 'idnszone': + olddn = ldap.dn.str2dn(oldattrs['dn']) + newdn = ldap.dn.str2dn(newattrs['dn']) + assert olddn == newdn, 'modrdn operation is not supported' + + oldval = self.__get_signing_attr(oldattrs) + newval = self.__get_signing_attr(newattrs) + if oldval != newval: + if self.__is_dnssec_enabled(newattrs): + self.zone_add(uuid, olddn, newattrs) + else: + self.zone_del(uuid, olddn, oldattrs) + + elif objclass == 'idnsseckey': + self.key_metadata_sync(uuid, dn, oldattrs, newattrs) + + elif objclass == 'ipk11publickey' and \ + self.__is_replica_pubkey(newattrs): + self.hsm_master_sync() + + def syncrepl_refreshdone(self): + self.log.info('Initial LDAP dump is done, sychronizing with ODS and BIND') + self.init_done = True + self.ods_sync() + self.hsm_replica_sync() + self.hsm_master_sync() + self.bindmgr.sync() + + # idnsSecKey wrapper + # Assumption: metadata points to the same key blob all the time, + # i.e. it is not necessary to re-download blobs because of change in DNSSEC + # metadata - DNSSEC flags or timestamps. + def key_meta_add(self, uuid, dn, newattrs): + self.hsm_replica_sync() + self.bindmgr.ldap_event('add', uuid, newattrs) + self.bindmgr_sync() + + def key_meta_del(self, uuid, dn, oldattrs): + self.bindmgr.ldap_event('del', uuid, oldattrs) + self.bindmgr_sync() + self.hsm_replica_sync() + + def key_metadata_sync(self, uuid, dn, oldattrs, newattrs): + self.bindmgr.ldap_event('mod', uuid, newattrs) + self.bindmgr_sync() + + def bindmgr_sync(self): + if self.init_done: + self.bindmgr.sync() + + # idnsZone wrapper + def zone_add(self, uuid, dn, newattrs): + if not self.ismaster: + return + + if self.__is_dnssec_enabled(newattrs): + self.odsmgr.ldap_event('add', uuid, newattrs) + self.ods_sync() + + def zone_del(self, uuid, dn, oldattrs): + if not self.ismaster: + return + + if self.__is_dnssec_enabled(oldattrs): + self.odsmgr.ldap_event('del', uuid, oldattrs) + self.ods_sync() + + def ods_sync(self): + if not self.ismaster: + return + + if self.init_done: + self.odsmgr.sync() + + # triggered by modification to idnsSecKey objects + def hsm_replica_sync(self): + """Download keys from LDAP to local HSM.""" + if self.ismaster: + return + if not self.init_done: + return + ipautil.run([paths.IPA_DNSKEYSYNCD_REPLICA]) + + # triggered by modification to ipk11PublicKey objects + def hsm_master_sync(self): + """Download replica keys from LDAP to local HSM + & upload master and zone keys to LDAP.""" + if not self.ismaster: + return + if not self.init_done: + return + ipautil.run([paths.ODS_SIGNER]) diff --git a/ipapython/dnssec/ldapkeydb.py b/ipapython/dnssec/ldapkeydb.py new file mode 100644 index 0000000..e2e58f8 --- /dev/null +++ b/ipapython/dnssec/ldapkeydb.py @@ -0,0 +1,351 @@ +#!/usr/bin/python +# +# Copyright (C) 2014 FreeIPA Contributors see COPYING for license +# + +from binascii import hexlify +import collections +import sys +import time + +import ipalib +from ipapython.dn import DN +from ipapython import ipaldap +from ipapython import ipautil +from ipaserver.plugins.ldap2 import ldap2 +from ipaplatform.paths import paths + +from abshsm import attrs_name2id, attrs_id2name, bool_attr_names, populate_pkcs11_metadata, AbstractHSM +import _ipap11helper +import uuid + +def uri_escape(val): + """convert val to %-notation suitable for ID component in URI""" + assert len(val) > 0, "zero-length URI component detected" + hexval = hexlify(val) + out = '%' + out += '%'.join(hexval[i:i+2] for i in range(0, len(hexval), 2)) + return out + +def ldap_bool(val): + if val == 'TRUE' or val is True: + return True + elif val == 'FALSE' or val is False: + return False + else: + raise AssertionError('invalid LDAP boolean "%s"' % val) + +def get_default_attrs(object_classes): + # object class -> default attribute values mapping + defaults = { + u'ipk11publickey': { + 'ipk11copyable': True, + 'ipk11derive': False, + 'ipk11encrypt': False, + 'ipk11local': True, + 'ipk11modifiable': True, + 'ipk11private': True, + 'ipk11trusted': False, + 'ipk11verify': True, + 'ipk11verifyrecover': True, + 'ipk11wrap': False + }, + u'ipk11privatekey': { + 'ipk11alwaysauthenticate': False, + 'ipk11alwayssensitive': True, + 'ipk11copyable': True, + 'ipk11decrypt': False, + 'ipk11derive': False, + 'ipk11extractable': True, + 'ipk11local': True, + 'ipk11modifiable': True, + 'ipk11neverextractable': False, + 'ipk11private': True, + 'ipk11sensitive': True, + 'ipk11sign': True, + 'ipk11signrecover': True, + 'ipk11unwrap': False, + 'ipk11wrapwithtrusted': False + }, + u'ipk11secretkey': { + 'ipk11alwaysauthenticate': False, + 'ipk11alwayssensitive': True, + 'ipk11copyable': True, + 'ipk11decrypt': False, + 'ipk11derive': False, + 'ipk11encrypt': False, + 'ipk11extractable': True, + 'ipk11local': True, + 'ipk11modifiable': True, + 'ipk11neverextractable': False, + 'ipk11private': True, + 'ipk11sensitive': True, + 'ipk11sign': False, + 'ipk11trusted': False, + 'ipk11unwrap': True, + 'ipk11verify': False, + 'ipk11wrap': True, + 'ipk11wrapwithtrusted': False + } + } + + # get set of supported object classes + present_clss = set() + for cls in object_classes: + present_clss.add(cls.lower()) + present_clss.intersection_update(set(defaults.keys())) + if len(present_clss) <= 0: + raise AssertionError('none of "%s" object classes are supported' % + object_classes) + + result = {} + for cls in present_clss: + result.update(defaults[cls]) + return result + +class Key(collections.MutableMapping): + """abstraction to hide LDAP entry weirdnesses: + - non-normalized attribute names + - boolean attributes returned as strings + """ + def __init__(self, entry, ldap, ldapkeydb): + self.entry = entry + self.ldap = ldap + self.ldapkeydb = ldapkeydb + self.log = ldap.log.getChild(__name__) + + def __getitem__(self, key): + val = self.entry.single_value[key] + if key.lower() in bool_attr_names: + val = ldap_bool(val) + return val + + def __setitem__(self, key, value): + self.entry[key] = value + + def __delitem__(self, key): + del self.entry[key] + + def __iter__(self): + """generates list of ipa names of all PKCS#11 attributes present in the object""" + for ipa_name in self.entry.keys(): + lowercase = ipa_name.lower() + if lowercase in attrs_name2id: + yield lowercase + + def __len__(self): + return len(self.entry) + + def __str__(self): + return str(self.entry) + + def _cleanup_key(self): + """remove default values from LDAP entry""" + default_attrs = get_default_attrs(self.entry['objectclass']) + empty = object() + for attr in default_attrs: + if self.get(attr, empty) == default_attrs[attr]: + del self[attr] + +class ReplicaKey(Key): + # TODO: object class assert + def __init__(self, entry, ldap, ldapkeydb): + super(ReplicaKey, self).__init__(entry, ldap, ldapkeydb) + +class MasterKey(Key): + # TODO: object class assert + def __init__(self, entry, ldap, ldapkeydb): + super(MasterKey, self).__init__(entry, ldap, ldapkeydb) + + @property + def wrapped_entries(self): + """LDAP entires with wrapped data + + One entry = one blob + ipaWrappingKey pointer to unwrapping key""" + + keys = [] + if 'ipaSecretKeyRef' not in self.entry: + return keys + + for dn in self.entry['ipaSecretKeyRef']: + try: + obj = self.ldap.get_entry(dn) + keys.append(obj) + except ipalib.errors.NotFound: + continue + + return keys + + def add_wrapped_data(self, data, wrapping_mech, replica_key_id): + wrapping_key_uri = 'pkcs11:id=%s;type=public' \ + % uri_escape(replica_key_id) + # TODO: replace this with 'autogenerate' to prevent collisions + uuid_rdn = DN('ipk11UniqueId=%s' % uuid.uuid1()) + entry_dn = DN(uuid_rdn, self.ldapkeydb.base_dn) + # TODO: add ipaWrappingMech attribute + entry = self.ldap.make_entry(entry_dn, + objectClass=['ipaSecretKeyObject', 'ipk11Object'], + ipaSecretKey=data, + ipaWrappingKey=wrapping_key_uri, + ipaWrappingMech=wrapping_mech) + + self.log.info('adding master key 0x%s wrapped with replica key 0x%s to %s', + hexlify(self['ipk11id']), + hexlify(replica_key_id), + entry_dn) + self.ldap.add_entry(entry) + if 'ipaSecretKeyRef' not in self.entry: + self.entry['objectClass'] += ['ipaSecretKeyRefObject'] + self.entry.setdefault('ipaSecretKeyRef', []).append(entry_dn) + + +class LdapKeyDB(AbstractHSM): + def __init__(self, log, ldap, base_dn): + self.ldap = ldap + self.base_dn = base_dn + self.log = log + self.cache_replica_pubkeys_wrap = None + self.cache_masterkeys = None + self.cache_zone_keypairs = None + + def _get_key_dict(self, key_type, ldap_filter): + try: + objs = self.ldap.get_entries(base_dn=self.base_dn, + filter=ldap_filter) + except ipalib.errors.NotFound: + return {} + + keys = {} + for o in objs: + # add default values not present in LDAP + key = key_type(o, self.ldap, self) + default_attrs = get_default_attrs(key.entry['objectclass']) + for attr in default_attrs: + key.setdefault(attr, default_attrs[attr]) + + assert 'ipk11id' in o, 'key is missing ipk11Id in %s' % key.entry.dn + key_id = key['ipk11id'] + assert key_id not in keys, 'duplicate ipk11Id=0x%s in "%s" and "%s"' % (hexlify(key_id), key.entry.dn, keys[key_id].entry.dn) + assert 'ipk11label' in key, 'key "%s" is missing ipk11Label' % key.entry.dn + assert 'objectclass' in key.entry, 'key "%s" is missing objectClass attribute' % key.entry.dn + + keys[key_id] = key + + self._update_keys() + return keys + + def _update_key(self, key): + """remove default values from LDAP entry and write back changes""" + key._cleanup_key() + + try: + self.ldap.update_entry(key.entry) + except ipalib.errors.EmptyModlist: + pass + + def _update_keys(self): + for cache in [self.cache_masterkeys, self.cache_replica_pubkeys_wrap, + self.cache_zone_keypairs]: + if cache: + for key in cache.itervalues(): + self._update_key(key) + + def flush(self): + """write back content of caches to LDAP""" + self._update_keys() + self.cache_masterkeys = None + self.cache_replica_pubkeys_wrap = None + self.cache_zone_keypairs = None + + def _import_keys_metadata(self, source_keys): + """import key metadata from Key-compatible objects + + metadata from multiple source keys can be imported into single LDAP + object + + :param: source_keys is iterable of (Key object, PKCS#11 object class)""" + + entry_dn = DN('ipk11UniqueId=autogenerate', self.base_dn) + entry = self.ldap.make_entry(entry_dn, objectClass=['ipk11Object']) + new_key = Key(entry, self.ldap, self) + + for source_key, pkcs11_class in source_keys: + if pkcs11_class == _ipap11helper.KEY_CLASS_SECRET_KEY: + entry['objectClass'].append('ipk11SecretKey') + elif pkcs11_class == _ipap11helper.KEY_CLASS_PUBLIC_KEY: + entry['objectClass'].append('ipk11PublicKey') + elif pkcs11_class == _ipap11helper.KEY_CLASS_PRIVATE_KEY: + entry['objectClass'].append('ipk11PrivateKey') + else: + raise AssertionError('unsupported object class %s' % pkcs11_class) + + populate_pkcs11_metadata(source_key, new_key) + new_key._cleanup_key() + return new_key + + def import_master_key(self, mkey): + new_key = self._import_keys_metadata( + [(mkey, _ipap11helper.KEY_CLASS_SECRET_KEY)]) + self.ldap.add_entry(new_key.entry) + self.log.debug('imported master key metadata: %s', new_key.entry) + + def import_zone_key(self, pubkey, pubkey_data, privkey, + privkey_wrapped_data, wrapping_mech, master_key_id): + new_key = self._import_keys_metadata( + [(pubkey, _ipap11helper.KEY_CLASS_PUBLIC_KEY), + (privkey, _ipap11helper.KEY_CLASS_PRIVATE_KEY)]) + + new_key.entry['objectClass'].append('ipaPrivateKeyObject') + new_key.entry['ipaPrivateKey'] = privkey_wrapped_data + new_key.entry['ipaWrappingKey'] = 'pkcs11:id=%s;type=secret-key' \ + % uri_escape(master_key_id) + new_key.entry['ipaWrappingMech'] = wrapping_mech + + new_key.entry['objectClass'].append('ipaPublicKeyObject') + new_key.entry['ipaPublicKey'] = pubkey_data + + self.ldap.add_entry(new_key.entry) + self.log.debug('imported zone key id: 0x%s', hexlify(new_key['ipk11id'])) + + @property + def replica_pubkeys_wrap(self): + if self.cache_replica_pubkeys_wrap: + return self.cache_replica_pubkeys_wrap + + keys = self._filter_replica_keys( + self._get_key_dict(ReplicaKey, + '(&(objectClass=ipk11PublicKey)(ipk11Wrap=TRUE)(objectClass=ipaPublicKeyObject))')) + + self.cache_replica_pubkeys_wrap = keys + return keys + + @property + def master_keys(self): + if self.cache_masterkeys: + return self.cache_masterkeys + + keys = self._get_key_dict(MasterKey, + '(&(objectClass=ipk11SecretKey)(|(ipk11UnWrap=TRUE)(!(ipk11UnWrap=*)))(ipk11Label=dnssec-master))') + for key in keys.itervalues(): + prefix = 'dnssec-master' + assert key['ipk11label'] == prefix, \ + 'secret key dn="%s" ipk11id=0x%s ipk11label="%s" with ipk11UnWrap = TRUE does not have '\ + '"%s" key label' % ( + key.entry.dn, + hexlify(key['ipk11id']), + str(key['ipk11label']), + prefix) + + self.cache_masterkeys = keys + return keys + + @property + def zone_keypairs(self): + if self.cache_zone_keypairs: + return self.cache_zone_keypairs + + self.cache_zone_keypairs = self._filter_zone_keys( + self._get_key_dict(Key, + '(&(objectClass=ipk11PrivateKey)(objectClass=ipaPrivateKeyObject)(objectClass=ipk11PublicKey)(objectClass=ipaPublicKeyObject))')) + + return self.cache_zone_keypairs diff --git a/ipapython/dnssec/localhsm.py b/ipapython/dnssec/localhsm.py new file mode 100755 index 0000000..de49641 --- /dev/null +++ b/ipapython/dnssec/localhsm.py @@ -0,0 +1,229 @@ +#!/usr/bin/python +# +# Copyright (C) 2014 FreeIPA Contributors see COPYING for license +# + +from binascii import hexlify +import collections +import logging +import os +from pprint import pprint +import sys +import time + +from ipaplatform.paths import paths + +import _ipap11helper +from abshsm import attrs_name2id, attrs_id2name, AbstractHSM, keytype_id2name, keytype_name2id, ldap2p11helper_api_params + +private_key_api_params = set(["label", "id", "data", "unwrapping_key", + "wrapping_mech", "key_type", "cka_always_authenticate", "cka_copyable", + "cka_decrypt", "cka_derive", "cka_extractable", "cka_modifiable", + "cka_private", "cka_sensitive", "cka_sign", "cka_sign_recover", + "cka_unwrap", "cka_wrap_with_trusted"]) + +public_key_api_params = set(["label", "id", "data", "cka_copyable", + "cka_derive", "cka_encrypt", "cka_modifiable", "cka_private", + "cka_trusted", "cka_verify", "cka_verify_recover", "cka_wrap"]) + +class Key(collections.MutableMapping): + def __init__(self, p11, handle): + self.p11 = p11 + self.handle = handle + # sanity check CKA_ID and CKA_LABEL + try: + cka_id = self.p11.get_attribute(handle, _ipap11helper.CKA_ID) + assert len(cka_id) != 0, 'ipk11id length should not be 0' + except _ipap11helper.NotFound: + raise _ipap11helper.NotFound('key without ipk11id: handle %s' % handle) + + try: + cka_label = self.p11.get_attribute(handle, _ipap11helper.CKA_LABEL) + assert len(cka_label) != 0, 'ipk11label length should not be 0' + + except _ipap11helper.NotFound: + raise _ipap11helper.NotFound('key without ipk11label: id 0x%s' + % hexlify(cka_id)) + + def __getitem__(self, key): + key = key.lower() + try: + value = self.p11.get_attribute(self.handle, attrs_name2id[key]) + if key == 'ipk11keytype': + value = keytype_id2name[value] + return value + except _ipap11helper.NotFound: + raise KeyError() + + def __setitem__(self, key, value): + key = key.lower() + if key == 'ipk11keytype': + value = keytype_name2id[value] + + return self.p11.set_attribute(self.handle, attrs_name2id[key], value) + + def __delitem__(self, key): + raise _ipap11helper.Exception('__delitem__ is not supported') + + def __iter__(self): + """generates list of ipa names of all attributes present in the object""" + for pkcs11_id, ipa_name in attrs_id2name.iteritems(): + try: + self.p11.get_attribute(self.handle, pkcs11_id) + except _ipap11helper.NotFound: + continue + + yield ipa_name + + def __len__(self): + cnt = 0 + for attr in self: + cnt += 1 + return cnt + + def __str__(self): + d = {} + for ipa_name, value in self.iteritems(): + d[ipa_name] = value + + return str(d) + + def __repr__(self): + return self.__str__() + +class LocalHSM(AbstractHSM): + def __init__(self, library, slot, pin): + self.cache_replica_pubkeys = None + self.p11 = _ipap11helper.P11_Helper(slot, pin, library) + self.log = logging.getLogger() + + def __del__(self): + self.p11.finalize() + + def find_keys(self, **kwargs): + """Return dict with Key objects matching given criteria. + + CKA_ID is used as key so all matching objects have to have unique ID.""" + + # this is a hack for old p11-kit URI parser + # see https://bugs.freedesktop.org/show_bug.cgi?id=85057 + if 'uri' in kwargs: + kwargs['uri'] = kwargs['uri'].replace('type=', 'object-type=') + + handles = self.p11.find_keys(**kwargs) + keys = {} + for h in handles: + key = Key(self.p11, h) + o_id = key['ipk11id'] + assert o_id not in keys, 'duplicate ipk11Id = 0x%s; keys = %s' % ( + hexlify(o_id), keys) + keys[o_id] = key + + return keys + + @property + def replica_pubkeys(self): + return self._filter_replica_keys( + self.find_keys(objclass=_ipap11helper.KEY_CLASS_PUBLIC_KEY)) + + @property + def replica_pubkeys_wrap(self): + return self._filter_replica_keys( + self.find_keys(objclass=_ipap11helper.KEY_CLASS_PUBLIC_KEY, + cka_wrap=True)) + + @property + def master_keys(self): + """Get all usable DNSSEC master keys""" + keys = self.find_keys(objclass=_ipap11helper.KEY_CLASS_SECRET_KEY, label=u'dnssec-master', cka_unwrap=True) + + for key in keys.itervalues(): + prefix = 'dnssec-master' + assert key['ipk11label'] == prefix, \ + 'secret key ipk11id=0x%s ipk11label="%s" with ipk11UnWrap = TRUE does not have '\ + '"%s" key label' % (hexlify(key['ipk11id']), + str(key['ipk11label']), prefix) + + return keys + + @property + def active_master_key(self): + """Get one active DNSSEC master key suitable for key wrapping""" + keys = self.find_keys(objclass=_ipap11helper.KEY_CLASS_SECRET_KEY, + label=u'dnssec-master', cka_wrap=True, cka_unwrap=True) + assert len(keys) > 0, "DNSSEC master key with UN/WRAP = TRUE not found" + return keys.popitem()[1] + + @property + def zone_pubkeys(self): + return self._filter_zone_keys( + self.find_keys(objclass=_ipap11helper.KEY_CLASS_PUBLIC_KEY)) + + @property + def zone_privkeys(self): + return self._filter_zone_keys( + self.find_keys(objclass=_ipap11helper.KEY_CLASS_PRIVATE_KEY)) + + + def import_public_key(self, source, data): + params = ldap2p11helper_api_params(source) + # filter out params inappropriate for public keys + for par in set(params.keys()).difference(public_key_api_params): + del params[par] + params['data'] = data + + h = self.p11.import_public_key(**params) + return Key(self.p11, h) + + def import_private_key(self, source, data, unwrapping_key): + params = ldap2p11helper_api_params(source) + # filter out params inappropriate for private keys + for par in set(params.keys()).difference(private_key_api_params): + del params[par] + params['data'] = data + params['unwrapping_key'] = unwrapping_key.handle + + h = self.p11.import_wrapped_private_key(**params) + return Key(self.p11, h) + + + +if __name__ == '__main__': + if 'SOFTHSM2_CONF' not in os.environ: + os.environ['SOFTHSM2_CONF'] = paths.DNSSEC_SOFTHSM2_CONF + localhsm = LocalHSM(paths.LIBSOFTHSM2_SO, 0, + open(paths.DNSSEC_SOFTHSM_PIN).read()) + + print 'replica public keys: CKA_WRAP = TRUE' + print '====================================' + for pubkey_id, pubkey in localhsm.replica_pubkeys_wrap.iteritems(): + print hexlify(pubkey_id) + pprint(pubkey) + + print '' + print 'replica public keys: all' + print '========================' + for pubkey_id, pubkey in localhsm.replica_pubkeys.iteritems(): + print hexlify(pubkey_id) + pprint(pubkey) + + print '' + print 'master keys' + print '===========' + for mkey_id, mkey in localhsm.master_keys.iteritems(): + print hexlify(mkey_id) + pprint(mkey) + + print '' + print 'zone public keys' + print '================' + for key_id, key in localhsm.zone_pubkeys.iteritems(): + print hexlify(key_id) + pprint(key) + + print '' + print 'zone private keys' + print '=================' + for key_id, key in localhsm.zone_privkeys.iteritems(): + print hexlify(key_id) + pprint(key) diff --git a/ipapython/dnssec/odsmgr.py b/ipapython/dnssec/odsmgr.py new file mode 100644 index 0000000..a91b6c5 --- /dev/null +++ b/ipapython/dnssec/odsmgr.py @@ -0,0 +1,194 @@ +#!/usr/bin/python +# +# Copyright (C) 2014 FreeIPA Contributors see COPYING for license +# + +import logging +from lxml import etree +import dns.name +import subprocess + +from ipapython import ipa_log_manager, ipautil + +# hack: zone object UUID is stored as path to imaginary zone file +ENTRYUUID_PREFIX = "/var/lib/ipa/dns/zone/entryUUID/" +ENTRYUUID_PREFIX_LEN = len(ENTRYUUID_PREFIX) + + +class ZoneListReader(object): + def __init__(self): + self.names = set() # dns.name + self.uuids = set() # UUID strings + self.mapping = dict() # {UUID: dns.name} + self.log = ipa_log_manager.log_mgr.get_logger(self) + + def _add_zone(self, name, zid): + """Add zone & UUID to internal structures. + + Zone with given name and UUID must not exist.""" + # detect duplicate zone names + name = dns.name.from_text(name) + assert name not in self.names, \ + 'duplicate name (%s, %s) vs. %s' % (name, zid, self.mapping) + # duplicate non-None zid is not allowed + assert not zid or zid not in self.uuids, \ + 'duplicate UUID (%s, %s) vs. %s' % (name, zid, self.mapping) + + self.names.add(name) + self.uuids.add(zid) + self.mapping[zid] = name + + def _del_zone(self, name, zid): + """Remove zone & UUID from internal structures. + + Zone with given name and UUID must exist. + """ + name = dns.name.from_text(name) + assert zid is not None + assert name in self.names, \ + 'name (%s, %s) does not exist in %s' % (name, zid, self.mapping) + assert zid in self.uuids, \ + 'UUID (%s, %s) does not exist in %s' % (name, zid, self.mapping) + assert zid in self.mapping and name == self.mapping[zid], \ + 'pair {%s: %s} does not exist in %s' % (zid, name, self.mapping) + + self.names.remove(name) + self.uuids.remove(zid) + del self.mapping[zid] + + +class ODSZoneListReader(ZoneListReader): + """One-shot parser for ODS zonelist.xml.""" + def __init__(self, zonelist_text): + super(ODSZoneListReader, self).__init__() + xml = etree.fromstring(zonelist_text) + self._parse_zonelist(xml) + + def _parse_zonelist(self, xml): + """iterate over Zone elements with attribute 'name' and + add IPA zones to self.zones""" + for zone_xml in xml.xpath('/ZoneList/Zone[@name]'): + name, zid = self._parse_ipa_zone(zone_xml) + self._add_zone(name, zid) + + def _parse_ipa_zone(self, zone_xml): + """Extract zone name, input adapter and detect IPA zones. + + IPA zones have contains Adapters/Input/Adapter element with + attribute type = "File" and with value prefixed with ENTRYUUID_PREFIX. + + Returns: + tuple (zone name, ID) + """ + name = zone_xml.get('name') + in_adapters = zone_xml.xpath( + 'Adapters/Input/Adapter[@type="File" ' + 'and starts-with(text(), "%s")]' % ENTRYUUID_PREFIX) + assert len(in_adapters) == 1, 'only IPA zones are supported: %s' \ + % etree.tostring(zone_xml) + + path = in_adapters[0].text + # strip prefix from path + zid = path[ENTRYUUID_PREFIX_LEN:] + return (name, zid) + + +class LDAPZoneListReader(ZoneListReader): + def __init__(self): + super(LDAPZoneListReader, self).__init__() + + def process_ipa_zone(self, op, uuid, zone_ldap): + assert (op == 'add' or op == 'del'), 'unsupported op %s' % op + assert uuid is not None + assert 'idnsname' in zone_ldap, \ + 'LDAP zone UUID %s without idnsName' % uuid + assert len(zone_ldap['idnsname']) == 1, \ + 'LDAP zone UUID %s with len(idnsname) != 1' % uuid + + if op == 'add': + self._add_zone(zone_ldap['idnsname'][0], uuid) + elif op == 'del': + self._del_zone(zone_ldap['idnsname'][0], uuid) + + +class ODSMgr(object): + """OpenDNSSEC zone manager. It does LDAP->ODS synchronization. + + Zones with idnsSecInlineSigning attribute = TRUE in LDAP are added + or deleted from ODS as necessary. ODS->LDAP key synchronization + has to be solved seperatelly. + """ + def __init__(self): + self.log = ipa_log_manager.log_mgr.get_logger(self) + self.zl_ldap = LDAPZoneListReader() + + def ksmutil(self, params): + """Call ods-ksmutil with given parameters and return stdout. + + Raises CalledProcessError if returncode != 0. + """ + cmd = ['ods-ksmutil'] + params + return ipautil.run(cmd)[0] + + def get_ods_zonelist(self): + stdout = self.ksmutil(['zonelist', 'export']) + reader = ODSZoneListReader(stdout) + return reader + + def add_ods_zone(self, uuid, name): + zone_path = '%s%s' % (ENTRYUUID_PREFIX, uuid) + cmd = ['zone', 'add', '--zone', str(name), '--input', zone_path] + output = self.ksmutil(cmd) + self.log.info(output) + self.notify_enforcer() + + def del_ods_zone(self, name): + # ods-ksmutil blows up if zone name has period at the end + name = name.relativize(dns.name.root) + cmd = ['zone', 'delete', '--zone', str(name)] + output = self.ksmutil(cmd) + self.log.info(output) + self.notify_enforcer() + + def notify_enforcer(self): + cmd = ['notify'] + output = self.ksmutil(cmd) + self.log.info(output) + + def ldap_event(self, op, uuid, attrs): + """Record single LDAP event - zone addition or deletion. + + Change is only recorded to memory. + self.sync() have to be called to synchronize change to ODS.""" + assert op == 'add' or op == 'del' + self.zl_ldap.process_ipa_zone(op, uuid, attrs) + self.log.debug("LDAP zones: %s", self.zl_ldap.mapping) + + def sync(self): + """Synchronize list of zones in LDAP with ODS.""" + zl_ods = self.get_ods_zonelist() + self.log.debug("ODS zones: %s", zl_ods.mapping) + removed = self.diff_zl(zl_ods, self.zl_ldap) + self.log.info("Zones removed from LDAP: %s", removed) + added = self.diff_zl(self.zl_ldap, zl_ods) + self.log.info("Zones added to LDAP: %s", added) + for (uuid, name) in removed: + self.del_ods_zone(name) + for (uuid, name) in added: + self.add_ods_zone(uuid, name) + + def diff_zl(self, s1, s2): + """Compute zones present in s1 but not present in s2. + + Returns: List of (uuid, name) tuples with zones present only in s1.""" + s1_extra = s1.uuids - s2.uuids + removed = [(uuid, name) for (uuid, name) in s1.mapping.items() + if uuid in s1_extra] + return removed + + +if __name__ == '__main__': + ipa_log_manager.standard_logging_setup(debug=True) + ods = ODSMgr() + reader = ods.get_ods_zonelist() + ipa_log_manager.root_logger.info('ODS zones: %s', reader.mapping) diff --git a/ipapython/dnssec/syncrepl.py b/ipapython/dnssec/syncrepl.py new file mode 100644 index 0000000..2f657f5 --- /dev/null +++ b/ipapython/dnssec/syncrepl.py @@ -0,0 +1,123 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2014 FreeIPA Contributors see COPYING for license +# +""" +This script implements a syncrepl consumer which syncs data from server +to a local dict. +""" + +# Import the python-ldap modules +import ldap +import ldapurl +# Import specific classes from python-ldap +from ldap.cidict import cidict +from ldap.ldapobject import ReconnectLDAPObject +from ldap.syncrepl import SyncreplConsumer + +# Import modules from Python standard lib +import signal +import time +import sys +import logging + +from ipapython import ipa_log_manager + + +class SyncReplConsumer(ReconnectLDAPObject, SyncreplConsumer): + """ + Syncrepl Consumer interface + """ + + def __init__(self, *args, **kwargs): + self.log = ipa_log_manager.log_mgr.get_logger(self) + # Initialise the LDAP Connection first + ldap.ldapobject.ReconnectLDAPObject.__init__(self, *args, **kwargs) + # Now prepare the data store + self.__data = cidict() + self.__data['uuids'] = cidict() + # We need this for later internal use + self.__presentUUIDs = cidict() + + def close_db(self): + # This is useless for dict + pass + + def syncrepl_get_cookie(self): + if 'cookie' in self.__data: + cookie = self.__data['cookie'] + self.log.debug('Current cookie is: %s', cookie) + return cookie + else: + self.log.debug('Current cookie is: None (not received yet)') + + def syncrepl_set_cookie(self, cookie): + self.log.debug('New cookie is: %s', cookie) + self.__data['cookie'] = cookie + + def syncrepl_entry(self, dn, attributes, uuid): + attributes = cidict(attributes) + # First we determine the type of change we have here + # (and store away the previous data for later if needed) + previous_attributes = cidict() + if uuid in self.__data['uuids']: + change_type = 'modify' + previous_attributes = self.__data['uuids'][uuid] + else: + change_type = 'add' + # Now we store our knowledge of the existence of this entry + # (including the DN as an attribute for convenience) + attributes['dn'] = dn + self.__data['uuids'][uuid] = attributes + # Debugging + self.log.debug('Detected %s of entry: %s %s', change_type, dn, uuid) + if change_type == 'modify': + self.application_sync(uuid, dn, attributes, previous_attributes) + else: + self.application_add(uuid, dn, attributes) + + def syncrepl_delete(self, uuids): + # Make sure we know about the UUID being deleted, just in case... + uuids = [uuid for uuid in uuids if uuid in self.__data['uuids']] + # Delete all the UUID values we know of + for uuid in uuids: + attributes = self.__data['uuids'][uuid] + dn = attributes['dn'] + self.log.debug('Detected deletion of entry: %s %s', dn, uuid) + self.application_del(uuid, dn, attributes) + del self.__data['uuids'][uuid] + + def syncrepl_present(self, uuids, refreshDeletes=False): + # If we have not been given any UUID values, + # then we have recieved all the present controls... + if uuids is None: + # We only do things if refreshDeletes is false + # as the syncrepl extension will call syncrepl_delete instead + # when it detects a delete notice + if refreshDeletes is False: + deletedEntries = [uuid for uuid in self.__data['uuids'].keys() + if uuid not in self.__presentUUIDs] + self.syncrepl_delete(deletedEntries) + # Phase is now completed, reset the list + self.__presentUUIDs = {} + else: + # Note down all the UUIDs we have been sent + for uuid in uuids: + self.__presentUUIDs[uuid] = True + + def application_add(self, uuid, dn, attributes): + self.log.info('Performing application add for: %s %s', dn, uuid) + self.log.debug('New attributes: %s', attributes) + return True + + def application_sync(self, uuid, dn, attributes, previous_attributes): + self.log.info('Performing application sync for: %s %s', dn, uuid) + self.log.debug('Old attributes: %s', previous_attributes) + self.log.debug('New attributes: %s', attributes) + return True + + def application_del(self, uuid, dn, previous_attributes): + self.log.info('Performing application delete for: %s %s', dn, uuid) + self.log.debug('Old attributes: %s', previous_attributes) + return True diff --git a/ipapython/dnssec/temp.py b/ipapython/dnssec/temp.py new file mode 100644 index 0000000..23ee377 --- /dev/null +++ b/ipapython/dnssec/temp.py @@ -0,0 +1,23 @@ +#!/usr/bin/python +# +# Copyright (C) 2014 FreeIPA Contributors see COPYING for license +# + +import errno +import shutil +import tempfile + +class TemporaryDirectory(object): + def __init__(self, root): + self.root = root + + def __enter__(self): + self.name = tempfile.mkdtemp(dir=self.root) + return self.name + + def __exit__(self, exc_type, exc_value, traceback): + try: + shutil.rmtree(self.name) + except OSError as e: + if e.errno != errno.ENOENT: + raise diff --git a/ipapython/setup.py.in b/ipapython/setup.py.in index a839f09..6caf179 100644 --- a/ipapython/setup.py.in +++ b/ipapython/setup.py.in @@ -65,7 +65,7 @@ def setup_package(): classifiers=filter(None, CLASSIFIERS.split('\n')), platforms = ["Linux", "Solaris", "Unix"], package_dir = {'ipapython': ''}, - packages = [ "ipapython" ], + packages = [ "ipapython", "ipapython.dnssec" ], ) finally: del sys.path[0]