def create(cls, client, identifier_type, identifier): ''' Create a new authorization for the given identifier. Return the authorization object of the new authorization https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.4 ''' new_authz = { "identifier": { "type": identifier_type, "value": identifier, }, } if client.version == 1: url = client.directory['new-authz'] new_authz["resource"] = "new-authz" else: if 'newAuthz' not in client.directory.directory: raise ACMEProtocolException( 'ACME endpoint does not support pre-authorization') url = client.directory['newAuthz'] result, info = client.send_signed_request( url, new_authz, error_msg='Failed to request challenges', expected_status_codes=[200, 201]) return cls.from_json(client, result, info['location'])
def raise_error(self, error_msg): ''' Aborts with a specific error for a challenge. ''' error_details = [] # multiple challenges could have failed at this point, gather error # details for all of them before failing for challenge in self.challenges: if challenge.status == 'invalid': msg = 'Challenge {type}'.format(type=challenge.type) if 'error' in challenge.data: msg = '{msg}: {problem}'.format( msg=msg, problem=format_error_problem( challenge.data['error'], subproblem_prefix='{0}.'.format(type)), ) error_details.append(msg) raise ACMEProtocolException( 'Failed to validate challenge for {identifier}: {error}. {details}' .format( identifier=self.combined_identifier, error=error_msg, details='; '.join(error_details), ), identifier=self.combined_identifier, authorization=self.data, )
def finalize(self, client, csr_der, wait=True): ''' Create a new certificate based on the csr. Return the certificate object as dict https://tools.ietf.org/html/rfc8555#section-7.4 ''' new_cert = { "csr": nopad_b64(csr_der), } result, info = client.send_signed_request( self.finalize_uri, new_cert, error_msg='Failed to finalizing order', expected_status_codes=[200]) # It is not clear from the RFC whether the finalize call returns the order object or not. # Instead of using the result, we call self.refresh(client) below. if wait: self.wait_for_finalization(client) else: self.refresh(client) if self.status not in ['procesing', 'valid', 'invalid']: raise ACMEProtocolException( client.module, 'Failed to finalize order; got status "{status}"'.format( status=self.status), info=info, content_json=result)
def wait_for_finalization(self, client): while True: self.refresh(client) if self.status in ['valid', 'invalid', 'pending', 'ready']: break time.sleep(2) if self.status != 'valid': raise ACMEProtocolException( 'Failed to wait for order to complete; got status "{status}"'.format(status=self.status), content_json=self.data)
def _assert_fetch_url_success(module, response, info, allow_redirect=False, allow_client_error=True, allow_server_error=True): if info['status'] < 0: raise NetworkException(msg="Failure downloading %s, %s" % (info['url'], info['msg'])) if (300 <= info['status'] < 400 and not allow_redirect) or \ (400 <= info['status'] < 500 and not allow_client_error) or \ (info['status'] >= 500 and not allow_server_error): raise ACMEProtocolException(module, info=info, response=response)
def test_acme_protocol_exception(input, from_json, msg, args): if from_json is None: module = None else: module = MagicMock() module.from_json = from_json with pytest.raises(ACMEProtocolException) as exc: raise ACMEProtocolException(module, **input) print(exc.value.msg) print(exc.value.module_fail_args) print(msg) print(args) assert exc.value.msg == msg assert exc.value.module_fail_args == args
def get_account_data(self): ''' Retrieve account information. Can only be called when the account URI is already known (such as after calling setup_account). Return None if the account was deactivated, or a dict otherwise. ''' if self.client.account_uri is None: raise ModuleFailException("Account URI unknown") if self.client.version == 1: data = {} data['resource'] = 'reg' result, info = self.client.send_signed_request( self.client.account_uri, data, fail_on_error=False) else: # try POST-as-GET first (draft-15 or newer) data = None result, info = self.client.send_signed_request( self.client.account_uri, data, fail_on_error=False) # check whether that failed with a malformed request error if info['status'] >= 400 and result.get( 'type') == 'urn:ietf:params:acme:error:malformed': # retry as a regular POST (with no changed data) for pre-draft-15 ACME servers data = {} result, info = self.client.send_signed_request( self.client.account_uri, data, fail_on_error=False) if info['status'] in (400, 403) and result.get( 'type') == 'urn:ietf:params:acme:error:unauthorized': # Returned when account is deactivated return None if info['status'] in (400, 404) and result.get( 'type') == 'urn:ietf:params:acme:error:accountDoesNotExist': # Returned when account does not exist return None if info['status'] < 200 or info['status'] >= 300: raise ACMEProtocolException(self.client.module, msg='Error retrieving account data', info=info, content_json=result) return result
def main(): argument_spec = get_default_argspec() argument_spec.update( dict( url=dict(type='str'), method=dict(type='str', choices=['get', 'post', 'directory-only'], default='get'), content=dict(type='str'), fail_on_acme_error=dict(type='bool', default=True), )) module = AnsibleModule( argument_spec=argument_spec, mutually_exclusive=(['account_key_src', 'account_key_content'], ), required_if=( ['method', 'get', ['url']], ['method', 'post', ['url', 'content']], [ 'method', 'get', ['account_key_src', 'account_key_content'], True ], [ 'method', 'post', ['account_key_src', 'account_key_content'], True ], ), ) backend = create_backend(module, False) result = dict() changed = False try: # Get hold of ACMEClient and ACMEAccount objects (includes directory) client = ACMEClient(module, backend) method = module.params['method'] result['directory'] = client.directory.directory # Do we have to do more requests? if method != 'directory-only': url = module.params['url'] fail_on_acme_error = module.params['fail_on_acme_error'] # Do request if method == 'get': data, info = client.get_request(url, parse_json_result=False, fail_on_error=False) elif method == 'post': changed = True # only POSTs can change data, info = client.send_signed_request( url, to_bytes(module.params['content']), parse_json_result=False, encode_payload=False, fail_on_error=False) # Update results result.update(dict( headers=info, output_text=to_native(data), )) # See if we can parse the result as JSON try: result['output_json'] = module.from_json(to_text(data)) except Exception as dummy: pass # Fail if error was returned if fail_on_acme_error and info['status'] >= 400: raise ACMEProtocolException(info=info, content_json=result) # Done! module.exit_json(changed=changed, **result) except ModuleFailException as e: e.do_fail(module, **result)
def _new_reg(self, contact=None, agreement=None, terms_agreed=False, allow_creation=True, external_account_binding=None): ''' Registers a new ACME account. Returns a pair ``(created, data)``. Here, ``created`` is ``True`` if the account was created and ``False`` if it already existed (e.g. it was not newly created), or does not exist. In case the account was created or exists, ``data`` contains the account data; otherwise, it is ``None``. If specified, ``external_account_binding`` should be a dictionary with keys ``kid``, ``alg`` and ``key`` (https://tools.ietf.org/html/rfc8555#section-7.3.4). https://tools.ietf.org/html/rfc8555#section-7.3 ''' contact = contact or [] if self.client.version == 1: new_reg = {'resource': 'new-reg', 'contact': contact} if agreement: new_reg['agreement'] = agreement else: new_reg['agreement'] = self.client.directory['meta'][ 'terms-of-service'] if external_account_binding is not None: raise ModuleFailException( 'External account binding is not supported for ACME v1') url = self.client.directory['new-reg'] else: if (external_account_binding is not None or self.client.directory['meta'].get( 'externalAccountRequired')) and allow_creation: # Some ACME servers such as ZeroSSL do not like it when you try to register an existing account # and provide external_account_binding credentials. Thus we first send a request with allow_creation=False # to see whether the account already exists. # Note that we pass contact here: ZeroSSL does not accept regisration calls without contacts, even # if onlyReturnExisting is set to true. created, data = self._new_reg(contact=contact, allow_creation=False) if data: # An account already exists! Return data return created, data # An account does not yet exist. Try to create one next. new_reg = {'contact': contact} if not allow_creation: # https://tools.ietf.org/html/rfc8555#section-7.3.1 new_reg['onlyReturnExisting'] = True if terms_agreed: new_reg['termsOfServiceAgreed'] = True url = self.client.directory['newAccount'] if external_account_binding is not None: new_reg['externalAccountBinding'] = self.client.sign_request( { 'alg': external_account_binding['alg'], 'kid': external_account_binding['kid'], 'url': url, }, self.client.account_jwk, self.client.backend.create_mac_key( external_account_binding['alg'], external_account_binding['key'])) elif self.client.directory['meta'].get( 'externalAccountRequired') and allow_creation: raise ModuleFailException( 'To create an account, an external account binding must be specified. ' 'Use the acme_account module with the external_account_binding option.' ) result, info = self.client.send_signed_request(url, new_reg, fail_on_error=False) if info['status'] in ([200, 201] if self.client.version == 1 else [201]): # Account did not exist if 'location' in info: self.client.set_account_uri(info['location']) return True, result elif info['status'] == (409 if self.client.version == 1 else 200): # Account did exist if result.get('status') == 'deactivated': # A bug in Pebble (https://github.com/letsencrypt/pebble/issues/179) and # Boulder (https://github.com/letsencrypt/boulder/issues/3971): this should # not return a valid account object according to # https://tools.ietf.org/html/rfc8555#section-7.3.6: # "Once an account is deactivated, the server MUST NOT accept further # requests authorized by that account's key." if not allow_creation: return False, None else: raise ModuleFailException("Account is deactivated") if 'location' in info: self.client.set_account_uri(info['location']) return False, result elif info['status'] == 400 and result[ 'type'] == 'urn:ietf:params:acme:error:accountDoesNotExist' and not allow_creation: # Account does not exist (and we didn't try to create it) return False, None elif info['status'] == 403 and result[ 'type'] == 'urn:ietf:params:acme:error:unauthorized' and 'deactivated' in ( result.get('detail') or ''): # Account has been deactivated; currently works for Pebble; hasn't been # implemented for Boulder (https://github.com/letsencrypt/boulder/issues/3971), # might need adjustment in error detection. if not allow_creation: return False, None else: raise ModuleFailException("Account is deactivated") else: raise ACMEProtocolException(self.client.module, msg='Registering ACME account failed', info=info, content_json=result)
def get_request(self, uri, parse_json_result=True, headers=None, get_only=False, fail_on_error=True, error_msg=None, expected_status_codes=None): ''' Perform a GET-like request. Will try POST-as-GET for ACMEv2, with fallback to GET if server replies with a status code of 405. ''' if not get_only and self.version != 1: # Try POST-as-GET content, info = self.send_signed_request(uri, None, parse_json_result=False, fail_on_error=False) if info['status'] == 405: # Instead, do unauthenticated GET get_only = True else: # Do unauthenticated GET get_only = True if get_only: # Perform unauthenticated GET resp, info = fetch_url(self.module, uri, method='GET', headers=headers) _assert_fetch_url_success(self.module, resp, info) try: content = resp.read() except AttributeError: content = info.pop('body', None) # Process result parsed_json_result = False if parse_json_result: result = {} if content: if info['content-type'].startswith('application/json'): try: result = self.module.from_json(content.decode('utf8')) parsed_json_result = True except ValueError: raise NetworkException( "Failed to parse the ACME response: {0} {1}". format(uri, content)) else: result = content else: result = content if fail_on_error and _is_failed( info, expected_status_codes=expected_status_codes): raise ACMEProtocolException( self.module, msg=error_msg, info=info, content=content, content_json=result if parsed_json_result else None) return result, info
def send_signed_request(self, url, payload, key_data=None, jws_header=None, parse_json_result=True, encode_payload=True, fail_on_error=True, error_msg=None, expected_status_codes=None): ''' Sends a JWS signed HTTP POST request to the ACME server and returns the response as dictionary (if parse_json_result is True) or in raw form (if parse_json_result is False). https://tools.ietf.org/html/rfc8555#section-6.2 If payload is None, a POST-as-GET is performed. (https://tools.ietf.org/html/rfc8555#section-6.3) ''' key_data = key_data or self.account_key_data jws_header = jws_header or self.account_jws_header failed_tries = 0 while True: protected = copy.deepcopy(jws_header) protected["nonce"] = self.directory.get_nonce() if self.version != 1: protected["url"] = url self._log('URL', url) self._log('protected', protected) self._log('payload', payload) data = self.sign_request(protected, payload, key_data, encode_payload=encode_payload) if self.version == 1: data["header"] = jws_header.copy() for k, v in protected.items(): dummy = data["header"].pop(k, None) self._log('signed request', data) data = self.module.jsonify(data) headers = { 'Content-Type': 'application/jose+json', } resp, info = fetch_url(self.module, url, data=data, headers=headers, method='POST') _assert_fetch_url_success(self.module, resp, info) result = {} try: content = resp.read() except AttributeError: content = info.pop('body', None) if content or not parse_json_result: if (parse_json_result and info['content-type'].startswith('application/json') ) or 400 <= info['status'] < 600: try: decoded_result = self.module.from_json( content.decode('utf8')) self._log('parsed result', decoded_result) # In case of badNonce error, try again (up to 5 times) # (https://tools.ietf.org/html/rfc8555#section-6.7) if all(( 400 <= info['status'] < 600, decoded_result.get('type') == 'urn:ietf:params:acme:error:badNonce', failed_tries <= 5, )): failed_tries += 1 continue if parse_json_result: result = decoded_result else: result = content except ValueError: raise NetworkException( "Failed to parse the ACME response: {0} {1}". format(url, content)) else: result = content if fail_on_error and _is_failed( info, expected_status_codes=expected_status_codes): raise ACMEProtocolException( self.module, msg=error_msg, info=info, content=content, content_json=result if parse_json_result else None) return result, info
def main(): argument_spec = get_default_argspec() argument_spec.update( dict( private_key_src=dict(type='path'), private_key_content=dict(type='str', no_log=True), private_key_passphrase=dict(type='str', no_log=True), certificate=dict(type='path', required=True), revoke_reason=dict(type='int'), )) module = AnsibleModule( argument_spec=argument_spec, required_one_of=([ 'account_key_src', 'account_key_content', 'private_key_src', 'private_key_content' ], ), mutually_exclusive=([ 'account_key_src', 'account_key_content', 'private_key_src', 'private_key_content' ], ), supports_check_mode=False, ) backend = create_backend(module, False) try: client = ACMEClient(module, backend) account = ACMEAccount(client) # Load certificate certificate = pem_to_der(module.params.get('certificate')) certificate = nopad_b64(certificate) # Construct payload payload = {'certificate': certificate} if module.params.get('revoke_reason') is not None: payload['reason'] = module.params.get('revoke_reason') # Determine endpoint if module.params.get('acme_version') == 1: endpoint = client.directory['revoke-cert'] payload['resource'] = 'revoke-cert' else: endpoint = client.directory['revokeCert'] # Get hold of private key (if available) and make sure it comes from disk private_key = module.params.get('private_key_src') private_key_content = module.params.get('private_key_content') # Revoke certificate if private_key or private_key_content: passphrase = module.params['private_key_passphrase'] # Step 1: load and parse private key try: private_key_data = client.parse_key(private_key, private_key_content, passphrase=passphrase) except KeyParsingError as e: raise ModuleFailException( "Error while parsing private key: {msg}".format(msg=e.msg)) # Step 2: sign revokation request with private key jws_header = { "alg": private_key_data['alg'], "jwk": private_key_data['jwk'], } result, info = client.send_signed_request( endpoint, payload, key_data=private_key_data, jws_header=jws_header, fail_on_error=False) else: # Step 1: get hold of account URI created, account_data = account.setup_account(allow_creation=False) if created: raise AssertionError('Unwanted account creation') if account_data is None: raise ModuleFailException( msg='Account does not exist or is deactivated.') # Step 2: sign revokation request with account key result, info = client.send_signed_request(endpoint, payload, fail_on_error=False) if info['status'] != 200: already_revoked = False # Standardized error from draft 14 on (https://tools.ietf.org/html/rfc8555#section-7.6) if result.get( 'type') == 'urn:ietf:params:acme:error:alreadyRevoked': already_revoked = True else: # Hack for Boulder errors if module.params.get('acme_version') == 1: error_type = 'urn:acme:error:malformed' else: error_type = 'urn:ietf:params:acme:error:malformed' if result.get('type') == error_type and result.get( 'detail') == 'Certificate already revoked': # Fallback: boulder returns this in case the certificate was already revoked. already_revoked = True # If we know the certificate was already revoked, we don't fail, # but successfully terminate while indicating no change if already_revoked: module.exit_json(changed=False) raise ACMEProtocolException('Failed to revoke certificate', info=info, content_json=result) module.exit_json(changed=True) except ModuleFailException as e: e.do_fail(module)