From df1bd39a43f30138cf55e0e7720fa3dec1d912e0 Mon Sep 17 00:00:00 2001 From: "Endi S. Dewata" Date: Fri, 5 Jun 2015 08:49:39 +0000 Subject: Added vault-archive and vault-retrieve commands. New commands have been added to archive and retrieve data into and from a vault, also to retrieve the transport certificate. https://fedorahosted.org/freeipa/ticket/3872 Reviewed-By: Jan Cholasta --- API.txt | 65 ++++ VERSION | 4 +- ipalib/plugins/vault.py | 496 +++++++++++++++++++++++++++++- ipatests/test_xmlrpc/test_vault_plugin.py | 72 ++++- make-lint | 1 + 5 files changed, 634 insertions(+), 4 deletions(-) diff --git a/API.txt b/API.txt index eca4e3020..9e3f223b7 100644 --- a/API.txt +++ b/API.txt @@ -5146,6 +5146,36 @@ option: Str('version?', exclude='webui') output: Entry('result', , Gettext('A dictionary representing an LDAP entry', domain='ipa', localedir=None)) output: Output('summary', (, ), None) output: PrimaryKey('value', None, None) +command: vault_archive +args: 1,8,3 +arg: Str('cn', attribute=True, cli_name='name', maxlength=255, multivalue=False, pattern='^[a-zA-Z0-9_.-]+$', primary_key=True, query=True, required=True) +option: Flag('all', autofill=True, cli_name='all', default=False, exclude='webui') +option: Bytes('data?') +option: Str('in?') +option: Flag('raw', autofill=True, cli_name='raw', default=False, exclude='webui') +option: Str('service?') +option: Flag('shared?', autofill=True, default=False) +option: Str('user?') +option: Str('version?', exclude='webui') +output: Entry('result', , Gettext('A dictionary representing an LDAP entry', domain='ipa', localedir=None)) +output: Output('summary', (, ), None) +output: PrimaryKey('value', None, None) +command: vault_archive_encrypted +args: 1,10,3 +arg: Str('cn', attribute=True, cli_name='name', maxlength=255, multivalue=False, pattern='^[a-zA-Z0-9_.-]+$', primary_key=True, query=True, required=True) +option: Flag('all', autofill=True, cli_name='all', default=False, exclude='webui') +option: Str('description', attribute=True, autofill=False, cli_name='desc', multivalue=False, required=False) +option: Bytes('nonce') +option: Flag('raw', autofill=True, cli_name='raw', default=False, exclude='webui') +option: Str('service?') +option: Bytes('session_key') +option: Flag('shared?', autofill=True, default=False) +option: Str('user?') +option: Bytes('vault_data') +option: Str('version?', exclude='webui') +output: Entry('result', , Gettext('A dictionary representing an LDAP entry', domain='ipa', localedir=None)) +output: Output('summary', (, ), None) +output: PrimaryKey('value', None, None) command: vault_del args: 1,5,3 arg: Str('cn', attribute=True, cli_name='name', maxlength=255, multivalue=True, pattern='^[a-zA-Z0-9_.-]+$', primary_key=True, query=True, required=True) @@ -5192,6 +5222,32 @@ option: Str('version?', exclude='webui') output: Entry('result', , Gettext('A dictionary representing an LDAP entry', domain='ipa', localedir=None)) output: Output('summary', (, ), None) output: PrimaryKey('value', None, None) +command: vault_retrieve +args: 1,7,3 +arg: Str('cn', attribute=True, cli_name='name', maxlength=255, multivalue=False, pattern='^[a-zA-Z0-9_.-]+$', primary_key=True, query=True, required=True) +option: Flag('all', autofill=True, cli_name='all', default=False, exclude='webui') +option: Str('out?') +option: Flag('raw', autofill=True, cli_name='raw', default=False, exclude='webui') +option: Str('service?') +option: Flag('shared?', autofill=True, default=False) +option: Str('user?') +option: Str('version?', exclude='webui') +output: Entry('result', , Gettext('A dictionary representing an LDAP entry', domain='ipa', localedir=None)) +output: Output('summary', (, ), None) +output: PrimaryKey('value', None, None) +command: vault_retrieve_encrypted +args: 1,7,3 +arg: Str('cn', attribute=True, cli_name='name', maxlength=255, multivalue=False, pattern='^[a-zA-Z0-9_.-]+$', primary_key=True, query=True, required=True) +option: Flag('all', autofill=True, cli_name='all', default=False, exclude='webui') +option: Flag('raw', autofill=True, cli_name='raw', default=False, exclude='webui') +option: Str('service?') +option: Bytes('session_key') +option: Flag('shared?', autofill=True, default=False) +option: Str('user?') +option: Str('version?', exclude='webui') +output: Entry('result', , Gettext('A dictionary representing an LDAP entry', domain='ipa', localedir=None)) +output: Output('summary', (, ), None) +output: PrimaryKey('value', None, None) command: vault_show args: 1,7,3 arg: Str('cn', attribute=True, cli_name='name', maxlength=255, multivalue=False, pattern='^[a-zA-Z0-9_.-]+$', primary_key=True, query=True, required=True) @@ -5205,6 +5261,15 @@ option: Str('version?', exclude='webui') output: Entry('result', , Gettext('A dictionary representing an LDAP entry', domain='ipa', localedir=None)) output: Output('summary', (, ), None) output: PrimaryKey('value', None, None) +command: vaultconfig_show +args: 0,4,3 +option: Flag('all', autofill=True, cli_name='all', default=False, exclude='webui') +option: Flag('raw', autofill=True, cli_name='raw', default=False, exclude='webui') +option: Str('transport_out?') +option: Str('version?', exclude='webui') +output: Entry('result', , Gettext('A dictionary representing an LDAP entry', domain='ipa', localedir=None)) +output: Output('summary', (, ), None) +output: PrimaryKey('value', None, None) capability: messages 2.52 capability: optional_uid_params 2.54 capability: permissions2 2.69 diff --git a/VERSION b/VERSION index fe746a7f5..535b3e228 100644 --- a/VERSION +++ b/VERSION @@ -90,5 +90,5 @@ IPA_DATA_VERSION=20100614120000 # # ######################################################## IPA_API_VERSION_MAJOR=2 -IPA_API_VERSION_MINOR=125 -# Last change: derny - migration now accepts scope as argument +IPA_API_VERSION_MINOR=126 +# Last change: edewata - added vault-archive and vault-retrieve diff --git a/ipalib/plugins/vault.py b/ipalib/plugins/vault.py index ebb9f9fd3..e1e64aa40 100644 --- a/ipalib/plugins/vault.py +++ b/ipalib/plugins/vault.py @@ -17,16 +17,33 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import base64 +import json +import os +import sys +import tempfile + +import nss.nss as nss +import krbV + +from ipalib.frontend import Command, Object, Local from ipalib import api, errors -from ipalib import Str, Flag +from ipalib import Bytes, Str, Flag from ipalib import output +from ipalib.crud import PKQuery, Retrieve, Update from ipalib.plugable import Registry from ipalib.plugins.baseldap import LDAPObject, LDAPCreate, LDAPDelete,\ LDAPSearch, LDAPUpdate, LDAPRetrieve from ipalib.request import context from ipalib.plugins.user import split_principal from ipalib import _, ngettext +from ipaplatform.paths import paths from ipapython.dn import DN +from ipapython.nsslib import current_dbdir + +if api.env.in_server: + import pki.account + import pki.key __doc__ = _(""" Vaults @@ -94,6 +111,33 @@ EXAMPLES: """) + _(""" Delete a user vault: ipa vault-del --user +""") + _(""" + Display vault configuration: + ipa vault-config +""") + _(""" + Archive data into private vault: + ipa vault-archive --in +""") + _(""" + Archive data into service vault: + ipa vault-archive --service --in +""") + _(""" + Archive data into shared vault: + ipa vault-archive --shared --in +""") + _(""" + Archive data into user vault: + ipa vault-archive --user --in +""") + _(""" + Retrieve data from private vault: + ipa vault-retrieve --out +""") + _(""" + Retrieve data from service vault: + ipa vault-retrieve --service --out +""") + _(""" + Retrieve data from shared vault: + ipa vault-retrieve --shared --out +""") + _(""" + Retrieve data from user vault: + ipa vault-retrieve --user --out """) register = Registry() @@ -243,6 +287,26 @@ class vault(LDAPObject): for entry in entries: self.backend.add_entry(entry) + def get_key_id(self, dn): + """ + Generates a client key ID to archive/retrieve data in KRA. + """ + + # TODO: create container_dn after object initialization then reuse it + container_dn = DN(self.container_dn, self.api.env.basedn) + + # make sure the DN is a vault DN + if not dn.endswith(container_dn, 1): + raise ValueError('Invalid vault DN: %s' % dn) + + # construct the vault ID from the bottom up + id = u'' + for rdn in dn[:-len(container_dn)]: + name = rdn['cn'] + id = u'/' + name + id + + return 'ipa:' + id + @register() class vault_add(LDAPCreate): @@ -256,6 +320,10 @@ class vault_add(LDAPCreate): **options): assert isinstance(dn, DN) + if not self.api.env.enable_kra: + raise errors.InvocationError( + format=_('KRA service is not enabled')) + try: parent_dn = DN(*dn[1:]) self.obj.create_container(parent_dn) @@ -273,6 +341,38 @@ class vault_del(LDAPDelete): msg_summary = _('Deleted vault "%(value)s"') + def pre_callback(self, ldap, dn, *keys, **options): + assert isinstance(dn, DN) + + if not self.api.env.enable_kra: + raise errors.InvocationError( + format=_('KRA service is not enabled')) + + return dn + + def post_callback(self, ldap, dn, *args, **options): + assert isinstance(dn, DN) + + kra_client = self.api.Backend.kra.get_client() + + kra_account = pki.account.AccountClient(kra_client.connection) + kra_account.login() + + client_key_id = self.obj.get_key_id(dn) + + # deactivate vault record in KRA + response = kra_client.keys.list_keys( + client_key_id, pki.key.KeyClient.KEY_STATUS_ACTIVE) + + for key_info in response.key_infos: + kra_client.keys.modify_key_status( + key_info.get_key_id(), + pki.key.KeyClient.KEY_STATUS_INACTIVE) + + kra_account.logout() + + return True + @register() class vault_find(LDAPSearch): @@ -290,6 +390,10 @@ class vault_find(LDAPSearch): **options): assert isinstance(base_dn, DN) + if not self.api.env.enable_kra: + raise errors.InvocationError( + format=_('KRA service is not enabled')) + base_dn = self.obj.get_dn(*args, **options) return (filter, base_dn, scope) @@ -313,9 +417,399 @@ class vault_mod(LDAPUpdate): msg_summary = _('Modified vault "%(value)s"') + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, + *keys, **options): + + assert isinstance(dn, DN) + + if not self.api.env.enable_kra: + raise errors.InvocationError( + format=_('KRA service is not enabled')) + + return dn + @register() class vault_show(LDAPRetrieve): __doc__ = _('Display information about a vault.') takes_options = LDAPRetrieve.takes_options + vault_options + + def pre_callback(self, ldap, dn, attrs_list, *keys, **options): + assert isinstance(dn, DN) + + if not self.api.env.enable_kra: + raise errors.InvocationError( + format=_('KRA service is not enabled')) + + return dn + + +@register() +class vaultconfig(Object): + __doc__ = _('Vault configuration') + + takes_params = ( + Bytes( + 'transport_cert', + label=_('Transport Certificate'), + ), + ) + + +@register() +class vaultconfig_show(Retrieve): + __doc__ = _('Show vault configuration.') + + takes_options = ( + Str( + 'transport_out?', + doc=_('Output file to store the transport certificate'), + ), + ) + + def forward(self, *args, **options): + + file = options.get('transport_out') + + # don't send these parameters to server + if 'transport_out' in options: + del options['transport_out'] + + response = super(vaultconfig_show, self).forward(*args, **options) + + if file: + with open(file, 'w') as f: + f.write(response['result']['transport_cert']) + + return response + + def execute(self, *args, **options): + + if not self.api.env.enable_kra: + raise errors.InvocationError( + format=_('KRA service is not enabled')) + + kra_client = self.api.Backend.kra.get_client() + transport_cert = kra_client.system_certs.get_transport_cert() + return { + 'result': { + 'transport_cert': transport_cert.binary + }, + 'value': None, + } + + +@register() +class vault_archive(PKQuery, Local): + __doc__ = _('Archive data into a vault.') + + takes_options = vault_options + ( + Bytes( + 'data?', + doc=_('Binary data to archive'), + ), + Str( # TODO: use File parameter + 'in?', + doc=_('File containing data to archive'), + ), + ) + + has_output = output.standard_entry + + msg_summary = _('Archived data into vault "%(value)s"') + + def forward(self, *args, **options): + + data = options.get('data') + input_file = options.get('in') + + # don't send these parameters to server + if 'data' in options: + del options['data'] + if 'in' in options: + del options['in'] + + # get data + if data and input_file: + raise errors.MutuallyExclusiveError( + reason=_('Input data specified multiple times')) + + if input_file: + with open(input_file, 'rb') as f: + data = f.read() + + elif not data: + data = '' + + if self.api.env.in_server: + backend = self.api.Backend.ldap2 + else: + backend = self.api.Backend.rpcclient + if not backend.isconnected(): + backend.connect(ccache=krbV.default_context().default_ccache()) + + # initialize NSS database + current_dbdir = paths.IPA_NSSDB_DIR + nss.nss_init(current_dbdir) + + # retrieve transport certificate + config = self.api.Command.vaultconfig_show() + transport_cert_der = config['result']['transport_cert'] + nss_transport_cert = nss.Certificate(transport_cert_der) + + # generate session key + mechanism = nss.CKM_DES3_CBC_PAD + slot = nss.get_best_slot(mechanism) + key_length = slot.get_best_key_length(mechanism) + session_key = slot.key_gen(mechanism, None, key_length) + + # wrap session key with transport certificate + public_key = nss_transport_cert.subject_public_key_info.public_key + wrapped_session_key = nss.pub_wrap_sym_key(mechanism, + public_key, + session_key) + + options['session_key'] = wrapped_session_key.data + + nonce_length = nss.get_iv_length(mechanism) + nonce = nss.generate_random(nonce_length) + options['nonce'] = nonce + + vault_data = {} + vault_data[u'data'] = base64.b64encode(data).decode('utf-8') + + json_vault_data = json.dumps(vault_data) + + # wrap vault_data with session key + iv_si = nss.SecItem(nonce) + iv_param = nss.param_from_iv(mechanism, iv_si) + + encoding_ctx = nss.create_context_by_sym_key(mechanism, + nss.CKA_ENCRYPT, + session_key, + iv_param) + + wrapped_vault_data = encoding_ctx.cipher_op(json_vault_data)\ + + encoding_ctx.digest_final() + + options['vault_data'] = wrapped_vault_data + + response = self.api.Command.vault_archive_encrypted(*args, **options) + + response['result'] = {} + del response['summary'] + + return response + + +@register() +class vault_archive_encrypted(Update): + NO_CLI = True + + takes_options = vault_options + ( + Bytes( + 'session_key', + doc=_('Session key wrapped with transport certificate'), + ), + Bytes( + 'vault_data', + doc=_('Vault data encrypted with session key'), + ), + Bytes( + 'nonce', + doc=_('Nonce'), + ), + ) + + def execute(self, *args, **options): + + if not self.api.env.enable_kra: + raise errors.InvocationError( + format=_('KRA service is not enabled')) + + wrapped_vault_data = options.pop('vault_data') + nonce = options.pop('nonce') + wrapped_session_key = options.pop('session_key') + + # retrieve vault info + result = self.api.Command.vault_show(*args, **options) + vault = result['result'] + + # connect to KRA + kra_client = self.api.Backend.kra.get_client() + + kra_account = pki.account.AccountClient(kra_client.connection) + kra_account.login() + + client_key_id = self.obj.get_key_id(vault['dn']) + + # deactivate existing vault record in KRA + response = kra_client.keys.list_keys( + client_key_id, + pki.key.KeyClient.KEY_STATUS_ACTIVE) + + for key_info in response.key_infos: + kra_client.keys.modify_key_status( + key_info.get_key_id(), + pki.key.KeyClient.KEY_STATUS_INACTIVE) + + # forward wrapped data to KRA + kra_client.keys.archive_encrypted_data( + client_key_id, + pki.key.KeyClient.PASS_PHRASE_TYPE, + wrapped_vault_data, + wrapped_session_key, + None, + nonce, + ) + + kra_account.logout() + + return result + + +@register() +class vault_retrieve(PKQuery, Local): + __doc__ = _('Retrieve a data from a vault.') + + takes_options = vault_options + ( + Str( + 'out?', + doc=_('File to store retrieved data'), + ), + ) + + has_output = output.standard_entry + has_output_params = ( + Bytes( + 'data', + label=_('Data'), + ), + ) + + msg_summary = _('Retrieved data from vault "%(value)s"') + + def forward(self, *args, **options): + + output_file = options.get('out') + + # don't send these parameters to server + if 'out' in options: + del options['out'] + + if self.api.env.in_server: + backend = self.api.Backend.ldap2 + else: + backend = self.api.Backend.rpcclient + if not backend.isconnected(): + backend.connect(ccache=krbV.default_context().default_ccache()) + + # initialize NSS database + current_dbdir = paths.IPA_NSSDB_DIR + nss.nss_init(current_dbdir) + + # retrieve transport certificate + config = self.api.Command.vaultconfig_show() + transport_cert_der = config['result']['transport_cert'] + nss_transport_cert = nss.Certificate(transport_cert_der) + + # generate session key + mechanism = nss.CKM_DES3_CBC_PAD + slot = nss.get_best_slot(mechanism) + key_length = slot.get_best_key_length(mechanism) + session_key = slot.key_gen(mechanism, None, key_length) + + # wrap session key with transport certificate + public_key = nss_transport_cert.subject_public_key_info.public_key + wrapped_session_key = nss.pub_wrap_sym_key(mechanism, + public_key, + session_key) + + # send retrieval request to server + options['session_key'] = wrapped_session_key.data + + response = self.api.Command.vault_retrieve_encrypted(*args, **options) + + result = response['result'] + nonce = result['nonce'] + + # unwrap data with session key + wrapped_vault_data = result['vault_data'] + + iv_si = nss.SecItem(nonce) + iv_param = nss.param_from_iv(mechanism, iv_si) + + decoding_ctx = nss.create_context_by_sym_key(mechanism, + nss.CKA_DECRYPT, + session_key, + iv_param) + + json_vault_data = decoding_ctx.cipher_op(wrapped_vault_data)\ + + decoding_ctx.digest_final() + + vault_data = json.loads(json_vault_data) + data = base64.b64decode(vault_data[u'data'].encode('utf-8')) + + if output_file: + with open(output_file, 'w') as f: + f.write(data) + + response['result'] = {'data': data} + del response['summary'] + + return response + + +@register() +class vault_retrieve_encrypted(Retrieve): + NO_CLI = True + + takes_options = vault_options + ( + Bytes( + 'session_key', + doc=_('Session key wrapped with transport certificate'), + ), + ) + + def execute(self, *args, **options): + + if not self.api.env.enable_kra: + raise errors.InvocationError( + format=_('KRA service is not enabled')) + + wrapped_session_key = options.pop('session_key') + + # retrieve vault info + result = self.api.Command.vault_show(*args, **options) + vault = result['result'] + + # connect to KRA + kra_client = self.api.Backend.kra.get_client() + + kra_account = pki.account.AccountClient(kra_client.connection) + kra_account.login() + + client_key_id = self.obj.get_key_id(vault['dn']) + + # find vault record in KRA + response = kra_client.keys.list_keys( + client_key_id, + pki.key.KeyClient.KEY_STATUS_ACTIVE) + + if not len(response.key_infos): + raise errors.NotFound(reason=_('No archived data.')) + + key_info = response.key_infos[0] + + # retrieve encrypted data from KRA + key = kra_client.keys.retrieve_key( + key_info.get_key_id(), + wrapped_session_key) + + vault['vault_data'] = key.encrypted_data + vault['nonce'] = key.nonce_data + + kra_account.logout() + + return result diff --git a/ipatests/test_xmlrpc/test_vault_plugin.py b/ipatests/test_xmlrpc/test_vault_plugin.py index 44d397c58..4b18672c1 100644 --- a/ipatests/test_xmlrpc/test_vault_plugin.py +++ b/ipatests/test_xmlrpc/test_vault_plugin.py @@ -22,12 +22,15 @@ Test the `ipalib/plugins/vault.py` module. """ from ipalib import api, errors -from xmlrpc_test import Declarative, fuzzy_string +from xmlrpc_test import Declarative vault_name = u'test_vault' service_name = u'HTTP/server.example.com' user_name = u'testuser' +# binary data from \x00 to \xff +secret = ''.join(map(chr, xrange(0, 256))) + class test_vault_plugin(Declarative): @@ -442,4 +445,71 @@ class test_vault_plugin(Declarative): }, }, + { + 'desc': 'Create vault for archival', + 'command': ( + 'vault_add', + [vault_name], + {}, + ), + 'expected': { + 'value': vault_name, + 'summary': 'Added vault "%s"' % vault_name, + 'result': { + 'dn': u'cn=%s,cn=admin,cn=users,cn=vaults,%s' + % (vault_name, api.env.basedn), + 'objectclass': [u'top', u'ipaVault'], + 'cn': [vault_name], + }, + }, + }, + + { + 'desc': 'Archive secret', + 'command': ( + 'vault_archive', + [vault_name], + { + 'data': secret, + }, + ), + 'expected': { + 'value': vault_name, + 'summary': 'Archived data into vault "%s"' % vault_name, + 'result': {}, + }, + }, + + { + 'desc': 'Retrieve secret', + 'command': ( + 'vault_retrieve', + [vault_name], + {}, + ), + 'expected': { + 'value': vault_name, + 'summary': 'Retrieved data from vault "%s"' % vault_name, + 'result': { + 'data': secret, + }, + }, + }, + + { + 'desc': 'Delete vault for archival', + 'command': ( + 'vault_del', + [vault_name], + {}, + ), + 'expected': { + 'value': [vault_name], + 'summary': u'Deleted vault "%s"' % vault_name, + 'result': { + 'failed': (), + }, + }, + }, + ] diff --git a/make-lint b/make-lint index 40dceff6e..044798530 100755 --- a/make-lint +++ b/make-lint @@ -62,6 +62,7 @@ class IPATypeChecker(TypeChecker): 'unittest.case': ['assertEqual', 'assertRaises'], 'nose.tools': ['assert_equal', 'assert_raises'], 'datetime.tzinfo': ['houroffset', 'minoffset', 'utcoffset', 'dst'], + 'nss.nss.subject_public_key_info': ['public_key'], # IPA classes 'ipalib.base.NameSpace': ['add', 'mod', 'del', 'show', 'find'], -- cgit