From 0b870694f62701534a32fdb4cbdd5c06a3ea4559 Mon Sep 17 00:00:00 2001 From: Rob Crittenden Date: Aug 28 2023 17:40:39 +0000 Subject: Use the PKI REST API wherever possible instead of XML The XML API is already deprecated and will be removed in some future release. All but the updateCRL API has an equivalent in REST. The upstream dogtag project documents most of the API at https://github.com/dogtagpki/pki/wiki/REST-API . I say most because not every API includes sample input/output. The pki ca-cert command is a good substitute for seeing how the API is used by their own tooling. This changes no pre-existing conventions. All serial numbers are converted to decimal prior to transmission and are treated as strings to avoid previous limitations with sizing (which would have been exacerbated by random serial numbers). Fixes: https://pagure.io/freeipa/issue/9345 Signed-off-by: Rob Crittenden Reviewed-By: Florence Blanc-Renaud --- diff --git a/ipaserver/plugins/dogtag.py b/ipaserver/plugins/dogtag.py index ff4d890..1c2c518 100644 --- a/ipaserver/plugins/dogtag.py +++ b/ipaserver/plugins/dogtag.py @@ -35,6 +35,14 @@ Overview of interacting with CMS: CMS stands for "Certificate Management System". It has been released under a variety of names, the open source version is called "dogtag". +IPA now uses the REST API provided by dogtag, as documented at +https://github.com/dogtagpki/pki/wiki/REST-API + +The below is still relevant in places, particularly with data handling. +This history of Javascript parsing and using the optional XML is left +for historical purposes and for the last-used xml-based call that +IPA makes (updateCRL). + CMS consists of a number of servlets which in rough terms can be thought of as RPC commands. A servlet is invoked by making an HTTP request to a specific URL and passing URL arguments. Normally CMS responds with an HTTP response consisting @@ -298,92 +306,6 @@ def cms_request_status_to_string(request_status): 7 : 'EXCEPTION', }.get(request_status, "unknown(%d)" % request_status) -def cms_error_code_to_string(error_code): - ''' - :param error_code: The integral error code value - :return: String name of the error code - ''' - return { - 0 : 'SUCCESS', - 1 : 'FAILURE', - 2 : 'AUTH_FAILURE', - }.get(error_code, "unknown(%d)" % error_code) - -def parse_and_set_boolean_xml(node, response, response_name): - ''' - :param node: xml node object containing value to parse for boolean result - :param response: response dict to set boolean result in - :param response_name: name of the response value to set - :except ValueError: - - Read the value out of a xml text node and interpret it as a boolean value. - The text values are stripped of whitespace and converted to lower case - prior to interpretation. - - If the value is recognized the response dict is updated using the - request_name as the key and the value is set to the bool value of either - True or False depending on the interpretation of the text value. If the text - value is not recognized a ValueError exception is thrown. - - Text values which result in True: - - - true - - yes - - on - - Text values which result in False: - - - false - - no - - off - ''' - value = node.text.strip().lower() - if value in ('true', 'yes'): - value = True - elif value in ('false', 'no'): - value = False - else: - raise ValueError('expected true|false|yes|no|on|off for "%s", but got "%s"' % \ - (response_name, value)) - response[response_name] = value - -def get_error_code_xml(doc): - ''' - :param doc: The root node of the xml document to parse - :returns: error code as an integer or None if not found - - Returns the error code when the servlet replied with - CMSServlet.outputError() - - The possible error code values are: - - - CMS_SUCCESS = 0 - - CMS_FAILURE = 1 - - CMS_AUTH_FAILURE = 2 - - However, profileSubmit sometimes also returns these values: - - - EXCEPTION = 1 - - DEFERRED = 2 - - REJECTED = 3 - - ''' - - error_code = doc.xpath('//XMLResponse/Status[1]') - if len(error_code) == 1: - error_code = int(error_code[0].text) - else: - # If error code wasn't present, but error string was - # then it's an error. - error_string = doc.xpath('//XMLResponse/Error[1]') - if len(error_string) == 1: - error_code = CMS_FAILURE - else: - # no status and no error string, assume success - error_code = CMS_SUCCESS - - return error_code - def get_request_status_xml(doc): ''' :param doc: The root node of the xml document to parse @@ -513,529 +435,6 @@ def parse_error_template_xml(doc): return response -def parse_error_response_xml(doc): - ''' - :param doc: The root node of the xml document to parse - :returns: result dict - - CMS currently returns errors via XML as either a "template" document - (generated by CMSServlet.outputXML() or a "response" document (generated by - CMSServlet.outputError()). - - This routine is used to parse a "response" style error document. - - +---------------+---------------+---------------+---------------+ - |cms name |cms type |result name |result type | - +===============+===============+===============+===============+ - |Status |int |error_code |int [1]_ | - +---------------+---------------+---------------+---------------+ - |Error |string |error_string |unicode | - +---------------+---------------+---------------+---------------+ - |RequestID |string |request_id |string | - +---------------+---------------+---------------+---------------+ - - .. [1] error code may be one of: - - - CMS_SUCCESS = 0 - - CMS_FAILURE = 1 - - CMS_AUTH_FAILURE = 2 - - However, profileSubmit sometimes also returns these values: - - - EXCEPTION = 1 - - DEFERRED = 2 - - REJECTED = 3 - - ''' - - response = {} - response['error_code'] = CMS_FAILURE # assume error - - error_code = doc.xpath('//XMLResponse/Status[1]') - if len(error_code) == 1: - error_code = int(error_code[0].text) - response['error_code'] = error_code - - error_string = doc.xpath('//XMLResponse/Error[1]') - if len(error_string) == 1: - error_string = etree.tostring(error_string[0], method='text', - encoding=unicode).strip() - response['error_string'] = error_string - - request_id = doc.xpath('//XMLResponse/RequestId[1]') - if len(request_id) == 1: - request_id = etree.tostring(request_id[0], method='text', - encoding=unicode).strip() - response['request_id'] = request_id - - return response - - -def parse_check_request_result_xml(doc): - ''' - :param doc: The root node of the xml document to parse - :returns: result dict - :except ValueError: - - After parsing the results are returned in a result dict. The following - table illustrates the mapping from the CMS data item to what may be found in - the result dict. If a CMS data item is absent it will also be absent in the - result dict. - - If the requestStatus is not SUCCESS then the response dict will have the - contents described in `parse_error_template_xml`. - - +-------------------------+---------------+-------------------+-----------------+ - |cms name |cms type |result name |result type | - +=========================+===============+===================+=================+ - |authority |string |authority |unicode | - +-------------------------+---------------+-------------------+-----------------+ - |requestId |string |request_id |string | - +-------------------------+---------------+-------------------+-----------------+ - |status |string |cert_request_status|unicode [1]_ | - +-------------------------+---------------+-------------------+-----------------+ - |createdOn |long, timestamp|created_on |datetime.datetime| - +-------------------------+---------------+-------------------+-----------------+ - |updatedOn |long, timestamp|updated_on |datetime.datetime| - +-------------------------+---------------+-------------------+-----------------+ - |requestNotes |string |request_notes |unicode | - +-------------------------+---------------+-------------------+-----------------+ - |pkcs7ChainBase64 |string |pkcs7_chain |unicode [2]_ | - +-------------------------+---------------+-------------------+-----------------+ - |cmcFullEnrollmentResponse|string |full_response |unicode [2]_ | - +-------------------------+---------------+-------------------+-----------------+ - |records[].serialNumber |BigInteger |serial_numbers |[int|long] | - +-------------------------+---------------+-------------------+-----------------+ - - .. [1] cert_request_status may be one of: - - - "begin" - - "pending" - - "approved" - - "svc_pending" - - "canceled" - - "rejected" - - "complete" - - .. [2] Base64 encoded - - ''' - request_status = get_request_status_xml(doc) - - if request_status != CMS_STATUS_SUCCESS: - response = parse_error_template_xml(doc) - return response - - response = {} - response['request_status'] = request_status - - cert_request_status = doc.xpath('//xml/header/status[1]') - if len(cert_request_status) == 1: - cert_request_status = etree.tostring(cert_request_status[0], method='text', - encoding=unicode).strip() - response['cert_request_status'] = cert_request_status - - request_id = doc.xpath('//xml/header/requestId[1]') - if len(request_id) == 1: - request_id = etree.tostring(request_id[0], method='text', - encoding=unicode).strip() - response['request_id'] = request_id - - authority = doc.xpath('//xml/header/authority[1]') - if len(authority) == 1: - authority = etree.tostring(authority[0], method='text', - encoding=unicode).strip() - response['authority'] = authority - - updated_on = doc.xpath('//xml/header/updatedOn[1]') - if len(updated_on) == 1: - updated_on = ipautil.datetime_from_utctimestamp( - int(updated_on[0].text), units=1) - response['updated_on'] = updated_on - - created_on = doc.xpath('//xml/header/createdOn[1]') - if len(created_on) == 1: - created_on = ipautil.datetime_from_utctimestamp( - int(created_on[0].text), units=1) - response['created_on'] = created_on - - request_notes = doc.xpath('//xml/header/requestNotes[1]') - if len(request_notes) == 1: - request_notes = etree.tostring(request_notes[0], method='text', - encoding=unicode).strip() - response['request_notes'] = request_notes - - pkcs7_chain = doc.xpath('//xml/header/pkcs7ChainBase64[1]') - if len(pkcs7_chain) == 1: - pkcs7_chain = etree.tostring(pkcs7_chain[0], method='text', - encoding=unicode).strip() - response['pkcs7_chain'] = pkcs7_chain - - full_response = doc.xpath('//xml/header/cmcFullEnrollmentResponse[1]') - if len(full_response) == 1: - full_response = etree.tostring(full_response[0], method='text', - encoding=unicode).strip() - response['full_response'] = full_response - - serial_numbers = [] - response['serial_numbers'] = serial_numbers - for serial_number in doc.xpath('//xml/records[*]/record/serialNumber'): - serial_number = int(serial_number.text, 16) # parse as hex - serial_numbers.append(serial_number) - - return response - - -def parse_revoke_cert_xml(doc): - ''' - :param doc: The root node of the xml document to parse - :returns: result dict - :except ValueError: - - After parsing the results are returned in a result dict. The following - table illustrates the mapping from the CMS data item to what may be found in - the result dict. If a CMS data item is absent it will also be absent in the - result dict. - - If the requestStatus is not SUCCESS then the response dict will have the - contents described in `parse_error_template_xml`. - - +----------------------+----------------+-----------------------+---------------+ - |cms name |cms type |result name |result type | - +======================+================+=======================+===============+ - |dirEnabled |string [1]_ |dir_enabled |bool | - +----------------------+----------------+-----------------------+---------------+ - |certsUpdated |int |certs_updated |int | - +----------------------+----------------+-----------------------+---------------+ - |certsToUpdate |int |certs_to_update |int | - +----------------------+----------------+-----------------------+---------------+ - |error |string [2]_ |error_string |unicode | - +----------------------+----------------+-----------------------+---------------+ - |revoked |string [3]_ |revoked |unicode | - +----------------------+----------------+-----------------------+---------------+ - |totalRecordCount |int |total_record_count |int | - +----------------------+----------------+-----------------------+---------------+ - |updateCRL |string [1]_ [4]_|update_crl |bool | - +----------------------+----------------+-----------------------+---------------+ - |updateCRLSuccess |string [1]_ [4]_|update_crl_success |bool | - +----------------------+----------------+-----------------------+---------------+ - |updateCRLError |string [4]_ |update_crl_error |unicode | - +----------------------+----------------+-----------------------+---------------+ - |publishCRLSuccess |string [1]_[4]_ |publish_crl_success |bool | - +----------------------+----------------+-----------------------+---------------+ - |publishCRLError |string [4]_ |publish_crl_error |unicode | - +----------------------+----------------+-----------------------+---------------+ - |crlUpdateStatus |string [1]_ [5]_|crl_update_status |bool | - +----------------------+----------------+-----------------------+---------------+ - |crlUpdateError |string [5]_ |crl_update_error |unicode | - +----------------------+----------------+-----------------------+---------------+ - |crlPublishStatus |string [1]_ [5]_|crl_publish_status |bool | - +----------------------+----------------+-----------------------+---------------+ - |crlPublishError |string [5]_ |crl_publish_error |unicode | - +----------------------+----------------+-----------------------+---------------+ - |records[].serialNumber|BigInteger |records[].serial_number|int|long | - +----------------------+----------------+-----------------------+---------------+ - |records[].error |string [2]_ |records[].error_string |unicode | - +----------------------+----------------+-----------------------+---------------+ - - .. [1] String value is either "yes" or "no" - .. [2] Sometimes the error string is empty (null) - .. [3] revoked may be one of: - - - "yes" - - "no" - - "begin" - - "pending" - - "approved" - - "svc_pending" - - "canceled" - - "rejected" - - "complete" - - .. [4] Only sent if CRL update information is available. - If sent it's only value is "yes". - If sent then the following values may also be sent, - otherwise they will be absent: - - - updateCRLSuccess - - updateCRLError - - publishCRLSuccess - - publishCRLError - - .. [5] The cms name varies depending on whether the issuing point is MasterCRL - or not. If the issuing point is not the MasterCRL then the cms name - will be appended with an underscore and the issuing point name. - Thus for example the cms name crlUpdateStatus will be crlUpdateStatus - if the issuing point is the MasterCRL. However if the issuing point - is "foobar" then crlUpdateStatus will be crlUpdateStatus_foobar. - When we return the response dict the key will always be the "base" - name without the _issuing_point suffix. Thus crlUpdateStatus_foobar - will appear in the response dict under the key 'crl_update_status' - - ''' - - request_status = get_request_status_xml(doc) - - if request_status != CMS_STATUS_SUCCESS: - response = parse_error_template_xml(doc) - return response - - response = {} - response['request_status'] = request_status - - records = [] - response['records'] = records - - dir_enabled = doc.xpath('//xml/header/dirEnabled[1]') - if len(dir_enabled) == 1: - parse_and_set_boolean_xml(dir_enabled[0], response, 'dir_enabled') - - certs_updated = doc.xpath('//xml/header/certsUpdated[1]') - if len(certs_updated) == 1: - certs_updated = int(certs_updated[0].text) - response['certs_updated'] = certs_updated - - certs_to_update = doc.xpath('//xml/header/certsToUpdate[1]') - if len(certs_to_update) == 1: - certs_to_update = int(certs_to_update[0].text) - response['certs_to_update'] = certs_to_update - - error_string = doc.xpath('//xml/header/error[1]') - if len(error_string) == 1: - error_string = etree.tostring(error_string[0], method='text', - encoding=unicode).strip() - response['error_string'] = error_string - - revoked = doc.xpath('//xml/header/revoked[1]') - if len(revoked) == 1: - revoked = etree.tostring(revoked[0], method='text', - encoding=unicode).strip() - response['revoked'] = revoked - - total_record_count = doc.xpath('//xml/header/totalRecordCount[1]') - if len(total_record_count) == 1: - total_record_count = int(total_record_count[0].text) - response['total_record_count'] = total_record_count - - update_crl = doc.xpath('//xml/header/updateCRL[1]') - if len(update_crl) == 1: - parse_and_set_boolean_xml(update_crl[0], response, 'update_crl') - - update_crl_success = doc.xpath('//xml/header/updateCRLSuccess[1]') - if len(update_crl_success) == 1: - parse_and_set_boolean_xml(update_crl_success[0], response, 'update_crl_success') - - update_crl_error = doc.xpath('//xml/header/updateCRLError[1]') - if len(update_crl_error) == 1: - update_crl_error = etree.tostring(update_crl_error[0], method='text', - encoding=unicode).strip() - response['update_crl_error'] = update_crl_error - - publish_crl_success = doc.xpath('//xml/header/publishCRLSuccess[1]') - if len(publish_crl_success) == 1: - parse_and_set_boolean_xml(publish_crl_success[0], response, 'publish_crl_success') - - publish_crl_error = doc.xpath('//xml/header/publishCRLError[1]') - if len(publish_crl_error) == 1: - publish_crl_error = etree.tostring(publish_crl_error[0], method='text', - encoding=unicode).strip() - response['publish_crl_error'] = publish_crl_error - - crl_update_status = doc.xpath("//xml/header/*[starts-with(name(), 'crlUpdateStatus')][1]") - if len(crl_update_status) == 1: - parse_and_set_boolean_xml(crl_update_status[0], response, 'crl_update_status') - - crl_update_error = doc.xpath("//xml/header/*[starts-with(name(), 'crlUpdateError')][1]") - if len(crl_update_error) == 1: - crl_update_error = etree.tostring(crl_update_error[0], method='text', - encoding=unicode).strip() - response['crl_update_error'] = crl_update_error - - crl_publish_status = doc.xpath("//xml/header/*[starts-with(name(), 'crlPublishStatus')][1]") - if len(crl_publish_status) == 1: - parse_and_set_boolean_xml(crl_publish_status[0], response, 'crl_publish_status') - - crl_publish_error = doc.xpath("//xml/header/*[starts-with(name(), 'crlPublishError')][1]") - if len(crl_publish_error) == 1: - crl_publish_error = etree.tostring(crl_publish_error[0], method='text', - encoding=unicode).strip() - response['crl_publish_error'] = crl_publish_error - - for record in doc.xpath('//xml/records[*]/record'): - response_record = {} - records.append(response_record) - - serial_number = record.xpath('serialNumber[1]') - if len(serial_number) == 1: - serial_number = int(serial_number[0].text, 16) # parse as hex - response_record['serial_number'] = serial_number - response['serial_number_hex'] = u'0x%X' % serial_number - - error_string = record.xpath('error[1]') - if len(error_string) == 1: - error_string = etree.tostring(error_string[0], method='text', - encoding=unicode).strip() - response_record['error_string'] = error_string - - return response - -def parse_unrevoke_cert_xml(doc): - ''' - :param doc: The root node of the xml document to parse - :returns: result dict - :except ValueError: - - After parsing the results are returned in a result dict. The following - table illustrates the mapping from the CMS data item to what may be found in - the result dict. If a CMS data item is absent it will also be absent in the - result dict. - - If the requestStatus is not SUCCESS then the response dict will have the - contents described in `parse_error_template_xml`. - - +----------------------+----------------+-----------------------+---------------+ - |cms name |cms type |result name |result type | - +======================+================+=======================+===============+ - |dirEnabled |string [1]_ |dir_enabled |bool | - +----------------------+----------------+-----------------------+---------------+ - |dirUpdated |string [1]_ |dir_updated |bool | - +----------------------+----------------+-----------------------+---------------+ - |error |string |error_string |unicode | - +----------------------+----------------+-----------------------+---------------+ - |unrevoked |string [3]_ |unrevoked |unicode | - +----------------------+----------------+-----------------------+---------------+ - |updateCRL |string [1]_ [4]_|update_crl |bool | - +----------------------+----------------+-----------------------+---------------+ - |updateCRLSuccess |string [1]_ [4]_|update_crl_success |bool | - +----------------------+----------------+-----------------------+---------------+ - |updateCRLError |string [4]_ |update_crl_error |unicode | - +----------------------+----------------+-----------------------+---------------+ - |publishCRLSuccess |string [1]_ [4]_|publish_crl_success |bool | - +----------------------+----------------+-----------------------+---------------+ - |publishCRLError |string [4]_ |publish_crl_error |unicode | - +----------------------+----------------+-----------------------+---------------+ - |crlUpdateStatus |string [1]_ [5]_|crl_update_status |bool | - +----------------------+----------------+-----------------------+---------------+ - |crlUpdateError |string [5]_ |crl_update_error |unicode | - +----------------------+----------------+-----------------------+---------------+ - |crlPublishStatus |string [1]_ [5]_|crl_publish_status |bool | - +----------------------+----------------+-----------------------+---------------+ - |crlPublishError |string [5]_ |crl_publish_error |unicode | - +----------------------+----------------+-----------------------+---------------+ - |serialNumber |BigInteger |serial_number |int|long | - +----------------------+----------------+-----------------------+---------------+ - - .. [1] String value is either "yes" or "no" - .. [3] unrevoked may be one of: - - - "yes" - - "no" - - "pending" - - .. [4] Only sent if CRL update information is available. - If sent it's only value is "yes". - If sent then the following values may also be sent, - otherwise they will be absent: - - - updateCRLSuccess - - updateCRLError - - publishCRLSuccess - - publishCRLError - - .. [5] The cms name varies depending on whether the issuing point is MasterCRL - or not. If the issuing point is not the MasterCRL then the cms name - will be appended with an underscore and the issuing point name. - Thus for example the cms name crlUpdateStatus will be crlUpdateStatus - if the issuing point is the MasterCRL. However if the issuing point - is "foobar" then crlUpdateStatus will be crlUpdateStatus_foobar. - When we return the response dict the key will always be the "base" - name without the _issuing_point suffix. Thus crlUpdateStatus_foobar - will appear in the response dict under the key 'crl_update_status' - - ''' - - request_status = get_request_status_xml(doc) - - if request_status != CMS_STATUS_SUCCESS: - response = parse_error_template_xml(doc) - return response - - response = {} - response['request_status'] = request_status - - dir_enabled = doc.xpath('//xml/header/dirEnabled[1]') - if len(dir_enabled) == 1: - parse_and_set_boolean_xml(dir_enabled[0], response, 'dir_enabled') - - dir_updated = doc.xpath('//xml/header/dirUpdated[1]') - if len(dir_updated) == 1: - parse_and_set_boolean_xml(dir_updated[0], response, 'dir_updated') - - error_string = doc.xpath('//xml/header/error[1]') - if len(error_string) == 1: - error_string = etree.tostring(error_string[0], method='text', - encoding=unicode).strip() - response['error_string'] = error_string - - unrevoked = doc.xpath('//xml/header/unrevoked[1]') - if len(unrevoked) == 1: - unrevoked = etree.tostring(unrevoked[0], method='text', - encoding=unicode).strip() - response['unrevoked'] = unrevoked - - update_crl = doc.xpath('//xml/header/updateCRL[1]') - if len(update_crl) == 1: - parse_and_set_boolean_xml(update_crl[0], response, 'update_crl') - - update_crl_success = doc.xpath('//xml/header/updateCRLSuccess[1]') - if len(update_crl_success) == 1: - parse_and_set_boolean_xml(update_crl_success[0], response, 'update_crl_success') - - update_crl_error = doc.xpath('//xml/header/updateCRLError[1]') - if len(update_crl_error) == 1: - update_crl_error = etree.tostring(update_crl_error[0], method='text', - encoding=unicode).strip() - response['update_crl_error'] = update_crl_error - - publish_crl_success = doc.xpath('//xml/header/publishCRLSuccess[1]') - if len(publish_crl_success) == 1: - parse_and_set_boolean_xml(publish_crl_success[0], response, 'publish_crl_success') - - publish_crl_error = doc.xpath('//xml/header/publishCRLError[1]') - if len(publish_crl_error) == 1: - publish_crl_error = etree.tostring(publish_crl_error[0], method='text', - encoding=unicode).strip() - response['publish_crl_error'] = publish_crl_error - - crl_update_status = doc.xpath("//xml/header/*[starts-with(name(), 'crlUpdateStatus')][1]") - if len(crl_update_status) == 1: - parse_and_set_boolean_xml(crl_update_status[0], response, 'crl_update_status') - - crl_update_error = doc.xpath("//xml/header/*[starts-with(name(), 'crlUpdateError')][1]") - if len(crl_update_error) == 1: - crl_update_error = etree.tostring(crl_update_error[0], method='text', - encoding=unicode).strip() - response['crl_update_error'] = crl_update_error - - crl_publish_status = doc.xpath("//xml/header/*[starts-with(name(), 'crlPublishStatus')][1]") - if len(crl_publish_status) == 1: - parse_and_set_boolean_xml(crl_publish_status[0], response, 'crl_publish_status') - - crl_publish_error = doc.xpath("//xml/header/*[starts-with(name(), 'crlPublishError')][1]") - if len(crl_publish_error) == 1: - crl_publish_error = etree.tostring(crl_publish_error[0], method='text', - encoding=unicode).strip() - response['crl_publish_error'] = crl_publish_error - - serial_number = doc.xpath('//xml/header/serialNumber[1]') - if len(serial_number) == 1: - serial_number = int(serial_number[0].text, 16) # parse as hex - response['serial_number'] = serial_number - response['serial_number_hex'] = u'0x%X' % serial_number - - return response - - def parse_updateCRL_xml(doc): ''' :param doc: The root node of the xml document to parse @@ -1367,15 +766,16 @@ class ra(rabase.rabase, RestClient): +===================+===============+===============+ |serial_number |unicode [1]_ | | +-------------------+---------------+---------------+ - |request_id |unicode | | + |request_id |unicode [1]_ | | +-------------------+---------------+---------------+ |cert_request_status|unicode [2]_ | | +-------------------+---------------+---------------+ - .. [1] Passed through XMLRPC as decimal string. Can convert to - optimal integer type (int or long) via int(serial_number) + .. [1] The certID and requestId values are returned in + JSON as hex regardless of what the request contains. + They are converted to decimal in the return value. - .. [2] cert_request_status may be one of: + .. [2] cert_request_status, requestStatus, may be one of: - "begin" - "pending" @@ -1385,41 +785,72 @@ class ra(rabase.rabase, RestClient): - "rejected" - "complete" + The REST API responds with JSON in the form of: + + { + "requestID": "0x3", + "requestType": "enrollment", + "requestStatus": "complete", + "requestURL": "https://ipa.example.test:8443/ca/rest/certrequests/3", + "certId": "0x3", + "certURL": "https://ipa.example.test:8443/ca/rest/certs/3", + "certRequestType": "pkcs10", + "operationResult": "success", + "requestId": "0x3" + } """ logger.debug('%s.check_request_status()', type(self).__name__) # Call CMS - http_status, _http_headers, http_body = ( - self._request('/ca/ee/ca/checkRequest', - self.env.ca_port, - requestId=request_id, - xml='true') - ) + path = 'certrequests/{}'.format(request_id) + try: + http_status, _http_headers, http_body = self._ssldo( + 'GET', path, use_session=False, + headers={ + 'Accept': 'application/json', + }, + ) + except errors.HTTPRequestError as e: + self.raise_certificate_operation_error( + 'check_request_status', + err_msg=e.msg, + detail=e.status # pylint: disable=no-member + ) # Parse and handle errors if http_status != 200: + # Note: this is a bit of an API change in that the error + # returned contains the hex value of the certificate + # but it's embedded in the 404. I doubt anything relies + # on it. self.raise_certificate_operation_error('check_request_status', detail=http_status) - parse_result = self.get_parse_result_xml(http_body, parse_check_request_result_xml) - request_status = parse_result['request_status'] - if request_status != CMS_STATUS_SUCCESS: - self.raise_certificate_operation_error('check_request_status', - cms_request_status_to_string(request_status), - parse_result.get('error_string')) + try: + parse_result = json.loads(ipautil.decode_json(http_body)) + except ValueError: + logger.debug("Response from CA was not valid JSON: %s", e) + raise errors.RemoteRetrieveError( + reason=_("Response from CA was not valid JSON") + ) + operation_result = parse_result['operationResult'] + if operation_result != "success": + self.raise_certificate_operation_error( + 'check_request_status', + cms_request_status_to_string(operation_result), + parse_result.get('errorMessage')) # Return command result cmd_result = {} - if 'serial_numbers' in parse_result and len(parse_result['serial_numbers']) > 0: - # see module documentation concerning serial numbers and XMLRPC - cmd_result['serial_number'] = unicode(parse_result['serial_numbers'][0]) + if 'certId' in parse_result: + cmd_result['serial_number'] = int(parse_result['certId'], 16) - if 'request_id' in parse_result: - cmd_result['request_id'] = parse_result['request_id'] + if 'requestID' in parse_result: + cmd_result['request_id'] = int(parse_result['requestID'], 16) - if 'cert_request_status' in parse_result: - cmd_result['cert_request_status'] = parse_result['cert_request_status'] + if 'requestStatus' in parse_result: + cmd_result['cert_request_status'] = parse_result['requestStatus'] return cmd_result @@ -1447,7 +878,7 @@ class ra(rabase.rabase, RestClient): .. [1] Base64 encoded - .. [2] Passed through XMLRPC as decimal string. Can convert to + .. [2] Passed through RPC as decimal string. Can convert to optimal integer type (int or long) via int(serial_number) .. [3] revocation reason may be one of: @@ -1486,8 +917,10 @@ class ra(rabase.rabase, RestClient): try: resp = json.loads(ipautil.decode_json(http_body)) except ValueError: + logger.debug("Response from CA was not valid JSON: %s", e) raise errors.RemoteRetrieveError( - reason=_("Response from CA was not valid JSON")) + reason=_("Response from CA was not valid JSON") + ) # Return command result cmd_result = {} @@ -1537,24 +970,45 @@ class ra(rabase.rabase, RestClient): logger.debug('%s.request_certificate()', type(self).__name__) # Call CMS - template = u''' - - {profile} - - certReqInputImpl - - {req_type} - - - {req} - - - ''' + template = ''' + {{ + "ProfileID" : "{profile}", + "Renewal" : false, + "RemoteHost" : "", + "RemoteAddress" : "", + "Input" : [ {{ + "id" : "i1", + "ClassID" : "certReqInputImpl", + "Name" : "Certificate Request Input", + "ConfigAttribute" : [ ], + "Attribute" : [ {{ + "name" : "cert_request_type", + "Value" : "{req_type}", + "Descriptor" : {{ + "Syntax" : "cert_request_type", + "Description" : "Certificate Request Type" + }} + }}, {{ + "name" : "cert_request", + "Value" : "{req}", + "Descriptor" : {{ + "Syntax" : "cert_request", + "Description" : "Certificate Request" + }} + }} ] + }} ], + "Output" : [ ], + "Attributes" : {{ + "Attribute" : [ ] + }} + }} + ''' data = template.format( profile=profile_id, req_type=request_type, req=csr, ) + data = data.replace('\n', '') path = 'certrequests' if ca_id: @@ -1563,7 +1017,7 @@ class ra(rabase.rabase, RestClient): _http_status, _http_headers, http_body = self._ssldo( 'POST', path, headers={ - 'Content-Type': 'application/xml', + 'Content-Type': 'application/json', 'Accept': 'application/json', }, body=data, @@ -1572,8 +1026,11 @@ class ra(rabase.rabase, RestClient): try: resp_obj = json.loads(ipautil.decode_json(http_body)) - except ValueError: - raise errors.RemoteRetrieveError(reason=_("Response from CA was not valid JSON")) + except ValueError as e: + logger.debug("Response from CA was not valid JSON: %s", e) + raise errors.RemoteRetrieveError( + reason=_("Response from CA was not valid JSON") + ) # Return command result cmd_result = {} @@ -1606,10 +1063,11 @@ class ra(rabase.rabase, RestClient): :param serial_number: Certificate serial number. Must be a string value because serial numbers may be of any magnitude and XMLRPC cannot handle integers larger than 64-bit. - The string value should be decimal, but may optionally - be prefixed with a hex radix prefix if the integral value - is represented as hexadecimal. If no radix prefix is - supplied the string will be interpreted as decimal. + The string value should be decimal, but may + optionally be prefixed with a hex radix prefix + if the integral value is represented as + hexadecimal. If no radix prefix is supplied + the string will be interpreted as decimal. :param revocation_reason: Integer code of revocation reason. Revoke a certificate. @@ -1623,54 +1081,106 @@ class ra(rabase.rabase, RestClient): |revoked |bool | | +---------------+---------------+---------------+ + The REST API responds with JSON in the form of: + + { + "requestID": "0x17", + "requestType": "revocation", + "requestStatus": "complete", + "requestURL": "https://ipa.example.test:8443/ca/rest/certrequests/23", + "certId": "0x12", + "certURL": "https://ipa.example.test:8443/ca/rest/certs/18", + "operationResult": "success", + "requestId": "0x17" + } + + requestID appears to be deprecated in favor of requestId. + + The Ids are in hex. IPA has traditionally returned these as + decimal. The REST API raises exceptions using hex which + will be a departure from previous behavior but unless we + scrape it out of the message there isn't much we can do. """ + reasons = ["Unspecified", + "Key_Compromise", + "CA_Compromise", + "Affiliation_Changed", + "Superseded", + "Cessation_of_Operation", + "Certificate_Hold", + "NOTUSED", # value 7 is not used + "Remove_from_CRL", + "Privilege_Withdrawn", + "AA_Compromise"] + logger.debug('%s.revoke_certificate()', type(self).__name__) if type(revocation_reason) is not int: raise TypeError(TYPE_ERROR % ('revocation_reason', int, revocation_reason, type(revocation_reason))) + if revocation_reason == 7: + self.raise_certificate_operation_error( + 'revoke_certificate', + detail='7 is not a valid revocation reason' + ) + # Convert serial number to integral type from string to properly handle - # radix issues. Note: the int object constructor will properly handle large - # magnitude integral values by returning a Python long type when necessary. + # radix issues. Note: the int object constructor will properly handle + # large magnitude integral values by returning a Python long type + # when necessary. serial_number = int(serial_number, 0) - # Call CMS - http_status, _http_headers, http_body = \ - self._sslget('/ca/agent/ca/doRevoke', - self.env.ca_agent_port, - op='revoke', - revocationReason=revocation_reason, - revokeAll='(certRecordId=%s)' % str(serial_number), - totalRecordCount=1, - xml='true') + path = 'agent/certs/{}/revoke'.format(serial_number) + data = '{{"reason":"{}"}}'.format(reasons[revocation_reason]) - # Parse and handle errors + http_status, _http_headers, http_body = self._ssldo( + 'POST', path, + headers={ + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body=data, + use_session=False, + ) if http_status != 200: self.raise_certificate_operation_error('revoke_certificate', detail=http_status) - parse_result = self.get_parse_result_xml(http_body, parse_revoke_cert_xml) - request_status = parse_result['request_status'] - if request_status != CMS_STATUS_SUCCESS: - self.raise_certificate_operation_error('revoke_certificate', - cms_request_status_to_string(request_status), - parse_result.get('error_string')) + try: + response = json.loads(ipautil.decode_json(http_body)) + except ValueError as e: + logger.debug("Response from CA was not valid JSON: %s", e) + raise errors.RemoteRetrieveError( + reason=_("Response from CA was not valid JSON") + ) + + request_status = response['operationResult'] + if request_status != 'success': + self.raise_certificate_operation_error( + 'revoke_certificate', + request_status, + response.get('errorMessage') + ) # Return command result cmd_result = {} - cmd_result['revoked'] = parse_result.get('revoked') == 'yes' + # We can assume the revocation was successful because if it failed + # then REST will return a non-200 or operationalResult will not + # be 'success'. + cmd_result['revoked'] = True return cmd_result def take_certificate_off_hold(self, serial_number): """ :param serial_number: Certificate serial number. Must be a string value - because serial numbers may be of any magnitude and - XMLRPC cannot handle integers larger than 64-bit. - The string value should be decimal, but may optionally - be prefixed with a hex radix prefix if the integral value - is represented as hexadecimal. If no radix prefix is - supplied the string will be interpreted as decimal. + because serial numbers may be of any magnitude + and XMLRPC cannot handle integers larger than + 64-bit. The string value should be decimal, but + may optionally be prefixed with a hex radix + prefix if the integral value is represented as + hexadecimal. If no radix prefix is supplied + the string will be interpreted as decimal. Take revoked certificate off hold. @@ -1680,47 +1190,84 @@ class ra(rabase.rabase, RestClient): +---------------+---------------+---------------+ |result name |result type |comments | +===============+===============+===============+ - |unrevoked |bool | | - +---------------+---------------+---------------+ - |error_string |unicode | | + |requestStatus |unicode | | + |errorMessage |unicode | | +---------------+---------------+---------------+ + + The REST API responds with JSON in the form of: + + { + "requestID":"0x19", + "requestType":"unrevocation", + "requestStatus":"complete", + "requestURL":"https://ipa.example.test:8443/ca/rest/certrequests/25", + "operationResult":"success", + "requestId":"0x19" + } + + Being REST, some errors are returned as HTTP codes. Like + not being authenticated (401) or un-revoking a non-revoked + certificate (404). + + For removing hold, unrevoking a non-revoked certificate will + return errorMessage. + + requestID appears to be deprecated in favor of requestId. + + The Ids are in hex. IPA has traditionally returned these as + decimal. The REST API raises exceptions using hex which + will be a departure from previous behavior but unless we + scrape it out of the message there isn't much we can do. """ logger.debug('%s.take_certificate_off_hold()', type(self).__name__) # Convert serial number to integral type from string to properly handle - # radix issues. Note: the int object constructor will properly handle large - # magnitude integral values by returning a Python long type when necessary. + # radix issues. Note: the int object constructor will properly handle + # large magnitude integral values by returning a Python long type when + # necessary. serial_number = int(serial_number, 0) - # Call CMS - http_status, _http_headers, http_body = ( - self._sslget('/ca/agent/ca/doUnrevoke', - self.env.ca_agent_port, - serialNumber=str(serial_number), - xml='true') - ) + path = 'agent/certs/{}/unrevoke'.format(serial_number) - # Parse and handle errors + http_status, _http_headers, http_body = self._ssldo( + 'POST', path, + headers={ + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + use_session=False, + ) if http_status != 200: - self.raise_certificate_operation_error('take_certificate_off_hold', - detail=http_status) + self.raise_certificate_operation_error( + 'take_certificate_off_hold', + detail=http_status) + try: + response = json.loads(ipautil.decode_json(http_body)) + except ValueError as e: + logger.debug("Response from CA was not valid JSON: %s", e) + raise errors.RemoteRetrieveError( + reason=_("Response from CA was not valid JSON") + ) - parse_result = self.get_parse_result_xml(http_body, parse_unrevoke_cert_xml) - request_status = parse_result['request_status'] - if request_status != CMS_STATUS_SUCCESS: - self.raise_certificate_operation_error('take_certificate_off_hold', - cms_request_status_to_string(request_status), - parse_result.get('error_string')) + request_status = response['operationResult'] + if request_status != 'success': + self.raise_certificate_operation_error( + 'take_certificate_off_hold', + request_status, + response.get('errorMessage')) # Return command result cmd_result = {} - if 'error_string' in parse_result: - cmd_result['error_string'] = parse_result['error_string'] + if 'errorMessage' in response: + cmd_result['error_string'] = response['errorMessage'] - cmd_result['unrevoked'] = parse_result.get('unrevoked') == 'yes' + # We can assume the un-revocation was successful because if it failed + # then REST will return a non-200 or operationalResult will not + # be 'success'. + cmd_result['unrevoked'] = True return cmd_result @@ -1740,11 +1287,7 @@ class ra(rabase.rabase, RestClient): logger.debug('%s.find()', type(self).__name__) - # Create the root element - page = etree.Element('CertSearchRequest') - - # Make a new document tree - doc = etree.ElementTree(page) + cert_search_request = dict() # This matches the default configuration of the pki tool. booloptions = {'serialNumberRangeInUse': True, @@ -1764,30 +1307,27 @@ class ra(rabase.rabase, RestClient): booloptions['matchExactly'] = True if 'subject' in options: - node = etree.SubElement(page, 'commonName') - node.text = options['subject'] + cert_search_request['commonName'] = options['subject'] booloptions['subjectInUse'] = True if 'issuer' in options: - node = etree.SubElement(page, 'issuerDN') - node.text = options['issuer'] + cert_search_request['issuerDN'] = options['issuer'] if 'revocation_reason' in options: - node = etree.SubElement(page, 'revocationReason') - node.text = unicode(options['revocation_reason']) + cert_search_request['revocationReason'] = unicode( + options['revocation_reason']) booloptions['revocationReasonInUse'] = True if 'min_serial_number' in options: - node = etree.SubElement(page, 'serialFrom') - node.text = unicode(options['min_serial_number']) + cert_search_request['serialFrom'] = unicode( + options['min_serial_number']) if 'max_serial_number' in options: - node = etree.SubElement(page, 'serialTo') - node.text = unicode(options['max_serial_number']) + cert_search_request['serialTo'] = unicode( + options['max_serial_number']) if 'status' in options: - node = etree.SubElement(page, 'status') - node.text = unicode(options['status']) + cert_search_request['status'] = options['status'] # date_types is a tuple that consists of: # 1. attribute name passed from IPA API @@ -1808,17 +1348,14 @@ class ra(rabase.rabase, RestClient): for (attr, dattr, battr) in date_types: if attr in options: epoch = convert_time(options[attr]) - node = etree.SubElement(page, dattr) - node.text = unicode(epoch) + cert_search_request[dattr] = unicode(epoch) booloptions[battr] = True # Add the boolean options to our XML document for opt, value in booloptions.items(): - e = etree.SubElement(page, opt) - e.text = str(value).lower() + cert_search_request[opt] = str(value).lower() - payload = etree.tostring(doc, pretty_print=False, - xml_declaration=True, encoding='UTF-8') + payload = json.dumps(cert_search_request, sort_keys=True) logger.debug('%s.find(): request: %s', type(self).__name__, payload) url = '/ca/rest/certs/search?size=%d' % ( @@ -1833,18 +1370,24 @@ class ra(rabase.rabase, RestClient): method='POST', headers={'Accept-Encoding': 'gzip, deflate', 'User-Agent': 'IPA', - 'Content-Type': 'application/xml', - 'Accept': 'application/xml'}, + 'Content-Type': 'application/json', + 'Accept': 'application/json'}, body=payload ) - parser = etree.XMLParser() if status != 200: + try: + response = json.loads(ipautil.decode_json(data)) + except ValueError as e: + logger.debug("Response from CA was not valid JSON: %s", e) + self.raise_certificate_operation_error( + 'find', + detail='Failed to parse error response') + # Try to parse out the returned error. If this fails then # raise the generic certificate operations error. try: - doc = etree.fromstring(data, parser) - msg = doc.xpath('//PKIException/Message')[0].text + msg = response.get('Message') msg = msg.split(':', 1)[0] except etree.XMLSyntaxError as e: self.raise_certificate_operation_error('find', @@ -1859,42 +1402,44 @@ class ra(rabase.rabase, RestClient): logger.debug('%s.find(): response: %s', type(self).__name__, data) try: - doc = etree.fromstring(data, parser) - except etree.XMLSyntaxError as e: + data = json.loads(data) + except TypeError as e: self.raise_certificate_operation_error('find', - detail=e.msg) + detail=str(e)) # Grab all the certificates - certs = doc.xpath('//CertDataInfo') + certs = data['entries'] results = [] for cert in certs: response_request = {} - response_request['serial_number'] = int(cert.get('id'), 16) # parse as hex - response_request['serial_number_hex'] = u'0x%X' % response_request['serial_number'] + response_request['serial_number'] = int( + cert.get('id'), 16) # parse as hex + response_request["serial_number_hex"] = ( + "0x%X" % response_request["serial_number"] + ) - dn = cert.xpath('SubjectDN') - if len(dn) == 1: - response_request['subject'] = unicode(dn[0].text) + dn = cert.get('SubjectDN') + if dn: + response_request['subject'] = dn - issuer_dn = cert.xpath('IssuerDN') - if len(issuer_dn) == 1: - response_request['issuer'] = unicode(issuer_dn[0].text) + issuer_dn = cert.get('IssuerDN') + if issuer_dn: + response_request['issuer'] = issuer_dn - not_valid_before = cert.xpath('NotValidBefore') - if len(not_valid_before) == 1: + not_valid_before = cert.get('NotValidBefore') + if not_valid_before: response_request['valid_not_before'] = ( - unicode(not_valid_before[0].text)) + not_valid_before) - not_valid_after = cert.xpath('NotValidAfter') - if len(not_valid_after) == 1: - response_request['valid_not_after'] = ( - unicode(not_valid_after[0].text)) + not_valid_after = cert.get('NotValidAfter') + if not_valid_after: + response_request['valid_not_after'] = (not_valid_after) - status = cert.xpath('Status') - if len(status) == 1: - response_request['status'] = unicode(status[0].text) + status = cert.get('Status') + if status: + response_request['status'] = status results.append(response_request) return results @@ -2095,7 +1640,8 @@ class ra_lightweight_ca(RestClient): except Exception as e: logger.debug('%s', e, exc_info=True) raise errors.RemoteRetrieveError( - reason=_("Response from CA was not valid JSON")) + reason=_("Response from CA was not valid JSON") + ) def read_ca(self, ca_id): _status, _resp_headers, resp_body = self._ssldo(