class Account(object): """ ACME server class """ def __init__(self, debug=None, srv_name=None, logger=None): self.server_name = srv_name self.logger = logger self.dbstore = DBstore(debug, self.logger) self.message = Message(debug, self.server_name, self.logger) self.path_dic = {'acct_path' : '/acme/acct/'} self.ecc_only = False self.contact_check_disable = False self.tos_check_disable = False self.inner_header_nonce_allow = False self.tos_url = None self.eab_check = False self.eab_handler = None def __enter__(self): """ Makes ACMEHandler a Context Manager """ self._config_load() return self def __exit__(self, *args): """ cose the connection at the end of the context """ def _add(self, content, payload, contact): """ prepare db insert and call DBstore helper """ self.logger.debug('Account.account._add()') account_name = generate_random_string(self.logger, 12) # check request if 'alg' in content and 'jwk' in content: if not self.contact_check_disable and not contact: code = 400 message = 'urn:ietf:params:acme:error:malformed' detail = 'incomplete protected payload' else: # ecc_only check if self.ecc_only and not content['alg'].startswith('ES'): code = 403 message = 'urn:ietf:params:acme:error:badPublicKey' detail = 'Only ECC keys are supported' else: # check jwk data_dic = { 'name': account_name, 'alg': content['alg'], 'jwk': json.dumps(content['jwk']), 'contact': json.dumps(contact), } # add eab_kid to data_dic if eab_check is enabled and kid is part of the request if self.eab_check: if payload and 'externalaccountbinding' in payload and payload['externalaccountbinding']: if 'protected' in payload['externalaccountbinding']: eab_kid = self._eab_kid_get(payload['externalaccountbinding']['protected']) self.logger.info('add eab_kid: {0} to data_dic'.format(eab_kid)) if eab_kid: data_dic['eab_kid'] = eab_kid try: (db_name, new) = self.dbstore.account_add(data_dic) except BaseException as err_: self.logger.critical('Account.account._add(): Database error: {0}'.format(err_)) db_name = None new = False self.logger.debug('got account_name:{0} new:{1}'.format(db_name, new)) if new: code = 201 message = account_name else: code = 200 message = db_name detail = None else: code = 400 message = 'urn:ietf:params:acme:error:malformed' detail = 'incomplete protected payload' self.logger.debug('Account.account._add() ended with:{0}'.format(code)) return(code, message, detail) def _contact_check(self, content): """ check contact information from payload""" self.logger.debug('Account._contact_check()') code = 200 message = None detail = None if 'contact' in content: contact_check = validate_email(self.logger, content['contact']) if not contact_check: # invalidcontact message code = 400 message = 'urn:ietf:params:acme:error:invalidContact' detail = ', '.join(content['contact']) else: code = 400 message = 'urn:ietf:params:acme:error:invalidContact' detail = 'no contacts specified' self.logger.debug('Account._contact_check() ended with:{0}'.format(code)) return(code, message, detail) def _contacts_update(self, aname, payload): """ update account """ self.logger.debug('Account.update()') (code, message, detail) = self._contact_check(payload) if code == 200: data_dic = {'name' : aname, 'contact' : json.dumps(payload['contact'])} try: result = self.dbstore.account_update(data_dic) except BaseException as err_: self.logger.critical('acme2certifier database error in Account._contacts_update(): {0}'.format(err_)) result = None if result: code = 200 else: code = 400 message = 'urn:ietf:params:acme:error:accountDoesNotExist' detail = 'update failed' return(code, message, detail) def _delete(self, aname): """ delete account """ self.logger.debug('Account._delete({0})'.format(aname)) try: result = self.dbstore.account_delete(aname) except BaseException as err_: self.logger.critical('acme2certifier database error in Account._delete(): {0}'.format(err_)) result = None if result: code = 200 message = None detail = None else: code = 400 message = 'urn:ietf:params:acme:error:accountDoesNotExist' detail = 'deletion failed' self.logger.debug('Account._delete() ended with:{0}'.format(code)) return(code, message, detail) def _eab_jwk_compare(self, protected, payload): """ compare jwk from outer header with jwk in eab playload """ self.logger.debug('_eab_jwk_compare()') result = False if 'jwk' in protected: # convert outer jwk into string for better comparison if isinstance(protected, dict): jwk_outer = json.dumps(protected['jwk']) # decode inner jwk jwk_inner = b64decode_pad(self.logger, payload) jwk_inner = json.dumps(json.loads(jwk_inner)) if jwk_outer == jwk_inner: result = True self.logger.debug('_eab_jwk_compare() ended with: {0}'.format(result)) return result def _eab_kid_get(self, protected): """ get key identifier for eab validation """ self.logger.debug('_eab_kid_get()') # load protected into json format protected_dic = json.loads(b64decode_pad(self.logger, protected)) # extract kid if isinstance(protected_dic, dict): eab_key_id = protected_dic.get('kid', None) else: eab_key_id = None self.logger.debug('_eab_kid_get() ended with: {0}'.format(eab_key_id)) return eab_key_id def _eab_check(self, protected, payload): """" check for external account binding """ self.logger.debug('_eab_check()') if self.eab_handler and protected and payload and 'externalaccountbinding' in payload and payload['externalaccountbinding']: # compare JWK from protected (outer) header if jwk included in payload of external account binding jwk_compare = self._eab_jwk_compare(protected, payload['externalaccountbinding']['payload']) if jwk_compare and 'protected' in payload['externalaccountbinding']: # get key identifier eab_kid = self._eab_kid_get(payload['externalaccountbinding']['protected']) if eab_kid: # get eab_mac_key with self.eab_handler(self.logger) as eab_handler: eab_mac_key = eab_handler.mac_key_get(eab_kid) else: eab_mac_key = None if eab_mac_key: (result, error) = self._eab_signature_verify(payload['externalaccountbinding'], eab_mac_key) if result: code = 200 message = None detail = None else: code = 403 message = 'urn:ietf:params:acme:error:unauthorized' detail = 'eab signature verification failed' self.logger.error('Account._eab_check() returned error: {0}'.format(error)) else: code = 403 message = 'urn:ietf:params:acme:error:unauthorized' detail = 'eab kid lookup failed' else: code = 403 message = 'urn:ietf:params:acme:error:malformed' detail = 'Malformed request' else: # no external account binding key in payload - error code = 403 message = 'urn:ietf:params:acme:error:externalAccountRequired' detail = 'external account binding required' self.logger.debug('Account._eab_check() ended with:{0}'.format(code)) return (code, message, detail) def _eab_signature_verify(self, content, mac_key): """ verify inner signature """ self.logger.debug('Account._eab_signature_verify()') if content and mac_key: signature = Signature(None, self.server_name, self.logger) jwk_ = json.dumps({'k': mac_key, 'kty': 'oct'}) (sig_check, error) = signature.eab_check(json.dumps(content), jwk_) else: sig_check = False error = None self.logger.debug('Account._eab_signature_verify() ended with: {0}'.format(sig_check)) return (sig_check, error) def _inner_jws_check(self, outer_protected, inner_protected): """ RFC8655 7.3.5 checs of inner JWS """ self.logger.debug('Account._inner_jws_check()') # check for jwk header if 'jwk' in inner_protected: if 'url' in outer_protected and 'url' in inner_protected: # inner and outer JWS must have the same "url" header parameter if outer_protected['url'] == inner_protected['url']: if self.inner_header_nonce_allow: code = 200 message = None detail = None else: # inner JWS must omit nonce header if 'nonce' not in inner_protected: code = 200 message = None detail = None else: code = 400 message = 'urn:ietf:params:acme:error:malformed' detail = 'inner jws must omit nonce header' else: code = 400 message = 'urn:ietf:params:acme:error:malformed' detail = 'url parameter differ in inner and outer jws' else: code = 400 message = 'urn:ietf:params:acme:error:malformed' detail = 'inner or outer jws is missing url header parameter' else: code = 400 message = 'urn:ietf:params:acme:error:malformed' detail = 'inner jws is missing jwk' self.logger.debug('Account._inner_jws_check() ended with: {0}:{1}'.format(code, detail)) return(code, message, detail) def _inner_payload_check(self, aname, outer_protected, inner_payload): """ RFC8655 7.3.5 checs of inner payload """ self.logger.debug('Account._inner_payload_check()') if 'kid' in outer_protected: if 'account' in inner_payload: if outer_protected['kid'] == inner_payload['account']: if 'oldkey' in inner_payload: # compare oldkey with database (code, message, detail) = self._key_compare(aname, inner_payload['oldkey']) else: code = 400 message = 'urn:ietf:params:acme:error:malformed' detail = 'old key is missing' else: code = 400 message = 'urn:ietf:params:acme:error:malformed' detail = 'kid and account objects do not match' else: code = 400 message = 'urn:ietf:params:acme:error:malformed' detail = 'account object is missing on inner payload' else: code = 400 message = 'urn:ietf:params:acme:error:malformed' detail = 'kid is missing in outer header' self.logger.debug('Account._inner_payload_check() ended with: {0}:{1}'.format(code, detail)) return(code, message, detail) def _key_change_validate(self, aname, outer_protected, inner_protected, inner_payload): """ validate key_change before exectution """ self.logger.debug('Account._key_change_validate({0})'.format(aname)) if 'jwk' in inner_protected: # check if we already have the key stored in DB key_exists = self._lookup(json.dumps(inner_protected['jwk']), 'jwk') if not key_exists: (code, message, detail) = self._inner_jws_check(outer_protected, inner_protected) if code == 200: (code, message, detail) = self._inner_payload_check(aname, outer_protected, inner_payload) else: code = 400 message = 'urn:ietf:params:acme:error:badPublicKey' detail = 'public key does already exists' else: code = 400 message = 'urn:ietf:params:acme:error:malformed' detail = 'inner jws is missing jwk' self.logger.debug('Account._key_change_validate() ended with: {0}:{1}'.format(code, detail)) return(code, message, detail) def _key_change(self, aname, payload, protected): """ key change for a given account """ self.logger.debug('Account._key_change({0})'.format(aname)) if 'url' in protected: if 'key-change' in protected['url']: # check message (code, message, detail, inner_protected, inner_payload, _account_name) = self.message.check(json.dumps(payload), use_emb_key=True, skip_nonce_check=True) if code == 200: (code, message, detail) = self._key_change_validate(aname, protected, inner_protected, inner_payload) if code == 200: data_dic = {'name' : aname, 'jwk' : json.dumps(inner_protected['jwk'])} try: result = self.dbstore.account_update(data_dic) except BaseException as err_: self.logger.critical('acme2certifier database error in Account._key_change(): {0}'.format(err_)) result = None if result: code = 200 message = None detail = None else: code = 500 message = 'urn:ietf:params:acme:error:serverInternal' detail = 'key rollover failed' else: code = 400 message = 'urn:ietf:params:acme:error:malformed' detail = 'malformed request. not a key-change' else: code = 400 message = 'urn:ietf:params:acme:error:malformed' detail = 'malformed request' return(code, message, detail) def _key_compare(self, aname, old_key): """ compare key with the one stored in database """ self.logger.debug('Account._key_compare({0})'.format(aname)) # load current public key from database try: pub_key = self.dbstore.jwk_load(aname) except BaseException as err_: self.logger.critical('acme2certifier database error in Account._key_compare(): {0}'.format(err_)) pub_key = None if old_key and pub_key: # rewrite alg statement in pubkey statement if 'alg' in pub_key and 'alg' in old_key: if pub_key['alg'].startswith('ES') and old_key['alg'] == 'ECDSA': pub_key['alg'] = 'ECDSA' if old_key == pub_key: code = 200 message = None detail = None else: code = 401 message = 'urn:ietf:params:acme:error:unauthorized' detail = 'wrong public key' else: code = 401 message = 'urn:ietf:params:acme:error:unauthorized' detail = 'wrong public key' self.logger.debug('Account._key_compare() ended with: {0}'.format(code)) return(code, message, detail) def _config_load(self): """" load config from file """ self.logger.debug('Account._config_load()') config_dic = load_config() if 'Account' in config_dic: self.inner_header_nonce_allow = config_dic.getboolean('Account', 'inner_header_nonce_allow', fallback=False) self.ecc_only = config_dic.getboolean('Account', 'ecc_only', fallback=False) self.tos_check_disable = config_dic.getboolean('Account', 'tos_check_disable', fallback=False) self.contact_check_disable = config_dic.getboolean('Account', 'contact_check_disable', fallback=False) if 'EABhandler' in config_dic: self.logger.debug('Account._config.load(): loading eab_handler') if 'eab_handler_file' in config_dic['EABhandler']: # mandate eab check regardless if handler could get loaded or not self.eab_check = True try: eab_handler_module = importlib.import_module(ca_handler_get(self.logger, config_dic['EABhandler']['eab_handler_file'])) except BaseException as err_: self.logger.critical('Account._config_load(): loading EABHandler configured in cfg failed with err: {0}'.format(err_)) try: eab_handler_module = importlib.import_module('acme_srv.eab_handler') except BaseException as err_: eab_handler_module = None self.logger.critical('Account._config_load(): loading default EABHandler failed with err: {0}'.format(err_)) if eab_handler_module: # store handler in variable self.eab_handler = eab_handler_module.EABhandler else: self.logger.critical('Account._config_load(): EABHandler configuration is missing in config file') if 'Directory' in config_dic: if 'tos_url' in config_dic['Directory']: self.tos_url = config_dic['Directory']['tos_url'] if 'url_prefix' in config_dic['Directory']: self.path_dic = {k: config_dic['Directory']['url_prefix'] + v for k, v in self.path_dic.items()} self.logger.debug('Account._config_load() ended') def _lookup(self, value, field='name'): """ lookup account """ self.logger.debug('Account._lookup({0}:{1})'.format(field, value)) try: result = self.dbstore.account_lookup(field, value) except BaseException as err_: self.logger.critical('acme2certifier database error in Account._lookup(): {0}'.format(err_)) result = None return result # pylint: disable=W0212 def _name_get(self, content): """ get id for account depricated""" self.logger.debug('Account._name_get()') _deprecated = True return self.message._name_get(content) def _onlyreturnexisting(self, protected, payload): """ check onlyreturnexisting """ self.logger.debug('Account._onlyreturnexisting(}') if 'onlyreturnexisting' in payload: if payload['onlyreturnexisting']: code = None message = None detail = None if 'jwk' in protected: try: result = self.dbstore.account_lookup('jwk', json.dumps(protected['jwk'])) except BaseException as err_: self.logger.critical('acme2certifier database error in Account._onlyreturnexisting(): {0}'.format(err_)) result = None if result: code = 200 message = result['name'] detail = None else: code = 400 message = 'urn:ietf:params:acme:error:accountDoesNotExist' detail = None else: code = 400 message = 'urn:ietf:params:acme:error:malformed' detail = 'jwk structure missing' else: code = 400 message = 'urn:ietf:params:acme:error:userActionRequired' detail = 'onlyReturnExisting must be true' else: code = 500 message = 'urn:ietf:params:acme:error:serverInternal' detail = 'onlyReturnExisting without payload' self.logger.debug('Account.onlyreturnexisting() ended with:{0}'.format(code)) return(code, message, detail) def _tos_check(self, content): """ check terms of service """ self.logger.debug('Account._tos_check()') if 'termsofserviceagreed' in content: self.logger.debug('tos:{0}'.format(content['termsofserviceagreed'])) if content['termsofserviceagreed']: code = 200 message = None detail = None else: code = 403 message = 'urn:ietf:params:acme:error:userActionRequired' detail = 'tosfalse' else: self.logger.debug('no tos statement found.') code = 403 message = 'urn:ietf:params:acme:error:userActionRequired' detail = 'tosfalse' self.logger.debug('Account._tos_check() ended with:{0}'.format(code)) return(code, message, detail) def new(self, content): """ generate a new account """ self.logger.debug('Account.account_new()') response_dic = {} # check message but skip signature check as this is a new account (True) (code, message, detail, protected, payload, _account_name) = self.message.check(content, True) if code == 200: # onlyReturnExisting check if 'onlyreturnexisting' in payload: (code, message, detail) = self._onlyreturnexisting(protected, payload) else: # tos check if self.tos_url and not self.tos_check_disable: (code, message, detail) = self._tos_check(payload) # check for external account binding if code == 200 and self.eab_check: (code, message, detail) = self._eab_check(protected, payload) # contact check if code == 200 and not self.contact_check_disable: (code, message, detail) = self._contact_check(payload) # add account to database if code == 200: if 'contact' in payload: contact_list = payload['contact'] else: contact_list = [] (code, message, detail) = self._add(protected, payload, contact_list) if code in (200, 201): response_dic['data'] = {} if code == 201: response_dic['data'] = { 'status': 'valid', 'orders': '{0}{1}{2}/orders'.format(self.server_name, self.path_dic['acct_path'], message), } if 'contact' in payload: response_dic['data']['contact'] = payload['contact'] response_dic['header'] = {} response_dic['header']['Location'] = '{0}{1}{2}'.format(self.server_name, self.path_dic['acct_path'], message) # add exernal account binding if self.eab_check and 'externalaccountbinding' in payload: response_dic['data']['externalaccountbinding'] = payload['externalaccountbinding'] else: if detail == 'tosfalse': detail = 'Terms of service must be accepted' # prepare/enrich response status_dic = {'code': code, 'message' : message, 'detail' : detail} response_dic = self.message.prepare_response(response_dic, status_dic) self.logger.debug('Account.account_new() returns: {0}'.format(json.dumps(response_dic))) return response_dic def parse(self, content): """ parse message """ self.logger.debug('Account.parse()') response_dic = {} # check message (code, message, detail, protected, payload, account_name) = self.message.check(content) if code == 200: if 'status' in payload: # account deactivation if payload['status'].lower() == 'deactivated': # account_name = self.message.name_get(protected) (code, message, detail) = self._delete(account_name) if code == 200: response_dic['data'] = payload else: code = 400 message = 'urn:ietf:params:acme:error:malformed' detail = 'status attribute without sense' elif 'contact' in payload: (code, message, detail) = self._contacts_update(account_name, payload) if code == 200: account_obj = self._lookup(account_name) response_dic['data'] = {} response_dic['data']['status'] = 'valid' response_dic['data']['key'] = json.loads(account_obj['jwk']) response_dic['data']['contact'] = json.loads(account_obj['contact']) response_dic['data']['createdAt'] = date_to_datestr(account_obj['created_at']) else: code = 400 message = 'urn:ietf:params:acme:error:accountDoesNotExist' detail = 'update failed' elif 'payload' in payload: # this could be a key-change (code, message, detail) = self._key_change(account_name, payload, protected) if code == 200: response_dic['data'] = {} else: code = 400 message = 'urn:ietf:params:acme:error:malformed' detail = 'dont know what to do with this request' # prepare/enrich response status_dic = {'code': code, 'message' : message, 'detail' : detail} response_dic = self.message.prepare_response(response_dic, status_dic) self.logger.debug('Account.account_parse() returns: {0}'.format(json.dumps(response_dic))) return response_dic
class Challenge(object): """ Challenge handler """ def __init__(self, debug=None, srv_name=None, logger=None, expiry=3600): # self.debug = debug self.server_name = srv_name self.logger = logger self.dbstore = DBstore(debug, self.logger) self.message = Message(debug, self.server_name, self.logger) self.path_dic = { 'chall_path': '/acme/chall/', 'authz_path': '/acme/authz/' } self.expiry = expiry self.challenge_validation_disable = False self.tnauthlist_support = False self.dns_server_list = None self.proxy_server_list = {} def __enter__(self): """ Makes ACMEHandler a Context Manager """ self._config_load() return self def __exit__(self, *args): """ close the connection at the end of the context """ def _challengelist_search(self, key, value, vlist=('name', 'type', 'status__name', 'token')): """ get exsting challegnes for a given authorization """ self.logger.debug('Challenge._challengelist_search()') try: challenge_list = self.dbstore.challenges_search(key, value, vlist) except BaseException as err_: self.logger.critical( 'acme2certifier database error in Challenge._challengelist_search(): {0}' .format(err_)) challenge_list = [] challenge_dic = {} for challenge in challenge_list: if challenge['type'] not in challenge_dic: challenge_dic[challenge['type']] = {} challenge_dic[challenge['type']]['token'] = challenge['token'] challenge_dic[challenge['type']]['type'] = challenge['type'] challenge_dic[challenge['type']]['url'] = challenge['name'] challenge_dic[challenge['type']]['url'] = '{0}{1}{2}'.format( self.server_name, self.path_dic['chall_path'], challenge['name']) challenge_dic[challenge['type']]['name'] = challenge['name'] challenge_list = [] for challenge in challenge_dic: challenge_list.append(challenge_dic[challenge]) self.logger.debug( 'Challenge._challengelist_search() ended with: {0}'.format( challenge_list)) return challenge_list def _check(self, challenge_name, payload): """ challenge check """ self.logger.debug('Challenge._check({0})'.format(challenge_name)) try: challenge_dic = self.dbstore.challenge_lookup( 'name', challenge_name, [ 'type', 'status__name', 'token', 'authorization__name', 'authorization__type', 'authorization__value', 'authorization__token', 'authorization__order__account__name' ]) except BaseException as err_: self.logger.critical( 'acme2certifier database error in Challenge._check() lookup: {0}' .format(err_)) challenge_dic = {} if 'type' in challenge_dic and 'authorization__value' in challenge_dic and 'token' in challenge_dic and 'authorization__order__account__name' in challenge_dic: try: pub_key = self.dbstore.jwk_load( challenge_dic['authorization__order__account__name']) except BaseException as err_: self.logger.critical( 'acme2certifier database error in Challenge._check() jwk: {0}' .format(err_)) pub_key = None if pub_key: jwk_thumbprint = jwk_thumbprint_get(self.logger, pub_key) if challenge_dic['type'] == 'http-01' and jwk_thumbprint: (result, invalid) = self._validate_http_challenge( challenge_name, challenge_dic['authorization__value'], challenge_dic['token'], jwk_thumbprint) elif challenge_dic['type'] == 'dns-01' and jwk_thumbprint: (result, invalid) = self._validate_dns_challenge( challenge_name, challenge_dic['authorization__value'], challenge_dic['token'], jwk_thumbprint) elif challenge_dic['type'] == 'tls-alpn-01' and jwk_thumbprint: (result, invalid) = self._validate_alpn_challenge( challenge_name, challenge_dic['authorization__value'], challenge_dic['token'], jwk_thumbprint) elif challenge_dic[ 'type'] == 'tkauth-01' and jwk_thumbprint and self.tnauthlist_support: (result, invalid) = self._validate_tkauth_challenge( challenge_name, challenge_dic['authorization__value'], challenge_dic['token'], jwk_thumbprint, payload) else: self.logger.debug( 'unknown challenge type "{0}". Setting check result to False' .format(challenge_dic['type'])) result = False invalid = True else: result = False invalid = False else: result = False invalid = False self.logger.debug('challenge._check() ended with: {0}/{1}'.format( result, invalid)) return (result, invalid) def _existing_challenge_validate(self, challenge_list): """ validate an existing challenge set """ self.logger.debug('Challenge._existing_challenge_validate()') for challenge in challenge_list: _challenge_check = self._validate(challenge, {}) def _info(self, challenge_name): """ get challenge details """ self.logger.debug('Challenge._info({0})'.format(challenge_name)) try: challenge_dic = self.dbstore.challenge_lookup( 'name', challenge_name, vlist=('type', 'token', 'status__name', 'validated')) except BaseException as err_: self.logger.critical( 'acme2certifier database error in Challenge._info(): {0}'. format(err_)) challenge_dic = {} if 'status' in challenge_dic and challenge_dic['status'] == 'valid': if 'validated' in challenge_dic: # convert validated timestamp to RFC3339 format - if it fails remove key from dictionary try: challenge_dic['validated'] = uts_to_date_utc( challenge_dic['validated']) except BaseException: challenge_dic.pop('validated') else: if 'validated' in challenge_dic: challenge_dic.pop('validated') self.logger.debug('Challenge._info({0}) ended'.format(challenge_name)) return challenge_dic def _config_load(self): """" load config from file """ self.logger.debug('Challenge._config_load()') config_dic = load_config() if 'Challenge' in config_dic: self.challenge_validation_disable = config_dic.getboolean( 'Challenge', 'challenge_validation_disable', fallback=False) if 'dns_server_list' in config_dic['Challenge']: try: self.dns_server_list = json.loads( config_dic['Challenge']['dns_server_list']) except BaseException as err_: self.logger.warning( 'Challenge._config_load() dns_server_list failed with error: {0}' .format(err_)) if 'Order' in config_dic: self.tnauthlist_support = config_dic.getboolean( 'Order', 'tnauthlist_support', fallback=False) if 'Directory' in config_dic: if 'url_prefix' in config_dic['Directory']: self.path_dic = { k: config_dic['Directory']['url_prefix'] + v for k, v in self.path_dic.items() } if 'DEFAULT' in config_dic and 'proxy_server_list' in config_dic[ 'DEFAULT']: try: self.proxy_server_list = json.loads( config_dic['DEFAULT']['proxy_server_list']) except BaseException as err_: self.logger.warning( 'Challenge._config_load() proxy_server_list failed with error: {0}' .format(err_)) self.logger.debug('Challenge._config_load() ended.') def _name_get(self, url): """ get challenge """ self.logger.debug('Challenge.get_name({0})'.format(url)) url_dic = parse_url(self.logger, url) challenge_name = url_dic['path'].replace(self.path_dic['chall_path'], '') if '/' in challenge_name: (challenge_name, _sinin) = challenge_name.split('/', 1) return challenge_name def _new(self, authz_name, mtype, token): """ new challenge """ self.logger.debug('Challenge._new({0})'.format(mtype)) challenge_name = generate_random_string(self.logger, 12) data_dic = { 'name': challenge_name, 'expires': self.expiry, 'type': mtype, 'token': token, 'authorization': authz_name, 'status': 2 } try: chid = self.dbstore.challenge_add(data_dic) except BaseException as err_: self.logger.critical( 'acme2certifier database error in Challenge._new(): {0}'. format(err_)) chid = None challenge_dic = {} if chid: challenge_dic['type'] = mtype challenge_dic['url'] = '{0}{1}{2}'.format( self.server_name, self.path_dic['chall_path'], challenge_name) challenge_dic['token'] = token if mtype == 'tkauth-01': challenge_dic['tkauth-type'] = 'atc' return challenge_dic def _update(self, data_dic): """ update challenge """ self.logger.debug('Challenge._update({0})'.format(data_dic)) try: self.dbstore.challenge_update(data_dic) except BaseException as err_: self.logger.critical( 'acme2certifier database error in Challenge._update(): {0}'. format(err_)) self.logger.debug('Challenge._update() ended') def _update_authz(self, challenge_name, data_dic): """ update authorizsation based on challenge_name """ self.logger.debug( 'Challenge._update_authz({0})'.format(challenge_name)) try: # lookup autorization based on challenge_name authz_name = self.dbstore.challenge_lookup( 'name', challenge_name, ['authorization__name'])['authorization'] except BaseException as err_: self.logger.critical( 'acme2certifier database error in Challenge._update_authz() lookup: {0}' .format(err_)) authz_name = None if authz_name: data_dic['name'] = authz_name try: # update authorization self.dbstore.authorization_update(data_dic) except BaseException as err_: self.logger.critical( 'acme2certifier database error in Challenge._update_authz() upd: {0}' .format(err_)) self.logger.debug('Challenge._update_authz() ended') def _validate(self, challenge_name, payload): """ validate challenge""" self.logger.debug('Challenge._validate({0}: {1})'.format( challenge_name, payload)) if self.challenge_validation_disable: self.logger.debug( 'CHALLENGE VALIDATION DISABLED. SETTING challenge status to valid' ) challenge_check = True invalid = False else: (challenge_check, invalid) = self._check(challenge_name, payload) if invalid: self._update({'name': challenge_name, 'status': 'invalid'}) # authorization update to valid state self._update_authz(challenge_name, {'status': 'invalid'}) elif challenge_check: self._update({ 'name': challenge_name, 'status': 'valid', 'validated': uts_now() }) # authorization update to valid state self._update_authz(challenge_name, {'status': 'valid'}) if payload: if 'keyAuthorization' in payload: # update challenge to ready state data_dic = { 'name': challenge_name, 'keyauthorization': payload['keyAuthorization'] } self._update(data_dic) self.logger.debug( 'Challenge._validate() ended with:{0}'.format(challenge_check)) return challenge_check def _validate_alpn_challenge(self, challenge_name, fqdn, token, jwk_thumbprint): """ validate dns challenge """ self.logger.debug( 'Challenge._validate_alpn_challenge({0}:{1}:{2})'.format( challenge_name, fqdn, token)) # resolve name (response, invalid) = fqdn_resolve(fqdn, self.dns_server_list) self.logger.debug('fqdn_resolve() ended with: {0}/{1}'.format( response, invalid)) # we are expecting a certifiate extension which is the sha256 hexdigest of token in a byte structure # which is base64 encoded '0420' has been taken from acme_srv.sh sources sha256_digest = sha256_hash_hex( self.logger, '{0}.{1}'.format(token, jwk_thumbprint)) extension_value = b64_encode( self.logger, bytearray.fromhex('0420{0}'.format(sha256_digest))) self.logger.debug('computed value: {0}'.format(extension_value)) if not invalid: # check if we need to set a proxy if self.proxy_server_list: proxy_server = proxy_check(self.logger, fqdn, self.proxy_server_list) else: proxy_server = None cert = servercert_get(self.logger, fqdn, 443, proxy_server) if cert: san_list = cert_san_get(self.logger, cert, recode=False) fqdn_in_san = fqdn_in_san_check(self.logger, san_list, fqdn) if fqdn_in_san: extension_list = cert_extensions_get(self.logger, cert, recode=False) if extension_value in extension_list: self.logger.debug('alpn validation successful') result = True else: self.logger.debug('alpn validation not successful') result = False else: self.logger.debug('fqdn check against san failed') result = False else: self.logger.debug('no cert returned...') result = False else: result = False self.logger.debug( 'Challenge._validate_alpn_challenge() ended with: {0}/{1}'.format( result, invalid)) return (result, invalid) def _validate_dns_challenge(self, challenge_name, fqdn, token, jwk_thumbprint): """ validate dns challenge """ self.logger.debug( 'Challenge._validate_dns_challenge({0}:{1}:{2})'.format( challenge_name, fqdn, token)) # handle wildcard domain fqdn = self._wcd_manipulate(fqdn) # rewrite fqdn to resolve txt record fqdn = '_acme-challenge.{0}'.format(fqdn) # compute sha256 hash _hash = b64_url_encode( self.logger, sha256_hash(self.logger, '{0}.{1}'.format(token, jwk_thumbprint))) # query dns txt_list = txt_get(self.logger, fqdn, self.dns_server_list) # compare computed hash with result from DNS query self.logger.debug('response_got: {0} response_expected: {1}'.format( txt_list, _hash)) if _hash in txt_list: self.logger.debug('validation successful') result = True else: self.logger.debug('validation not successful') result = False self.logger.debug( 'Challenge._validate_dns_challenge() ended with: {0}'.format( result)) return (result, False) def _validate_http_challenge(self, challenge_name, fqdn, token, jwk_thumbprint): """ validate http challenge """ self.logger.debug( 'Challenge._validate_http_challenge({0}:{1}:{2})'.format( challenge_name, fqdn, token)) # resolve name (response, invalid) = fqdn_resolve(fqdn, self.dns_server_list) self.logger.debug('fqdn_resolve() ended with: {0}/{1}'.format( response, invalid)) if not invalid: # check if we need to set a proxy if self.proxy_server_list: proxy_server = proxy_check(self.logger, fqdn, self.proxy_server_list) else: proxy_server = None req = url_get(self.logger, 'http://{0}/.well-known/acme-challenge/{1}'.format( fqdn, token), dns_server_list=self.dns_server_list, proxy_server=proxy_server, verify=False) if req: response_got = req.splitlines()[0] response_expected = '{0}.{1}'.format(token, jwk_thumbprint) self.logger.debug( 'response_got: {0} response_expected: {1}'.format( response_got, response_expected)) if response_got == response_expected: self.logger.debug('validation successful') result = True else: self.logger.debug('validation not successful') result = False else: self.logger.debug( 'validation not successfull.. no request object') result = False else: result = False self.logger.debug( 'Challenge._validate_http_challenge() ended with: {0}/{1}'.format( result, invalid)) return (result, invalid) def _validate_tkauth_challenge(self, challenge_name, tnauthlist, _token, _jwk_thumbprint, payload): """ validate tkauth challenge """ self.logger.debug( 'Challenge._validate_tkauth_challenge({0}:{1}:{2})'.format( challenge_name, tnauthlist, payload)) result = True invalid = False self.logger.debug( 'Challenge._validate_tkauth_challenge() ended with: {0}/{1}'. format(result, invalid)) return (result, invalid) def _validate_tnauthlist_payload(self, payload, challenge_dic): """ check payload in cae tnauthlist option has been set """ self.logger.debug( 'Challenge._validate_tnauthlist_payload({0}:{1})'.format( payload, challenge_dic)) code = 400 message = None detail = None if 'type' in challenge_dic: if challenge_dic['type'] == 'tkauth-01': self.logger.debug('tkauth identifier found') # check if we havegot an atc claim in the challenge request if 'atc' in payload: # check if we got a SPC token in the challenge request if not bool(payload['atc']): code = 400 message = 'urn:ietf:params:acme:error:malformed' detail = 'SPC token is missing' else: code = 200 else: code = 400 message = 'urn:ietf:params:acme:error:malformed' detail = 'atc claim is missing' else: code = 200 else: message = 'urn:ietf:params:acme:error:malformed' detail = 'invalid challenge: {0}'.format(challenge_dic) self.logger.debug( 'Challenge._validate_tnauthlist_payload() ended with:{0}'.format( code)) return (code, message, detail) def _wcd_manipulate(self, fqdn): """ wildcard domain handling """ self.logger.debug( 'Challenge._wc_manipulate() for fqdn: {0}'.format(fqdn)) if fqdn.startswith('*.'): fqdn = fqdn[2:] self.logger.debug( 'Challenge._wc_manipulate() ended with: {0}'.format(fqdn)) return fqdn def challengeset_get(self, authz_name, auth_status, token, tnauth): """ get the challengeset for an authorization """ self.logger.debug( 'Challenge.challengeset_get() for auth: {0}'.format(authz_name)) # check database if there are exsting challenges for a particular authorization challenge_list = self._challengelist_search('authorization__name', authz_name) if challenge_list: self.logger.debug('Challenges found.') # trigger challenge validation challenge_name_list = [] for challenge in challenge_list: challenge_name_list.append(challenge.pop('name')) if auth_status == 'pending': self._existing_challenge_validate(challenge_name_list) else: # new challenges to be created self.logger.debug('Challenges not found. Create a new set.') challenge_list = self.new_set(authz_name, token, tnauth) return challenge_list def get(self, url): """ get challenge details based on get request """ self.logger.debug('Challenge.get({0})'.format(url)) challenge_name = self._name_get(url) response_dic = {} response_dic['code'] = 200 response_dic['data'] = self._info(challenge_name) return response_dic def new_set(self, authz_name, token, tnauth=False): """ net challenge set """ self.logger.debug('Challenge.new_set({0}, {1})'.format( authz_name, token)) challenge_list = [] if not tnauth: for challenge_type in ['http-01', 'dns-01', 'tls-alpn-01']: challenge_json = self._new(authz_name, challenge_type, token) if challenge_json: challenge_list.append(challenge_json) else: self.logger.error( 'ERROR: Empty challenge returned for {0}'.format( challenge_type)) else: challenge_list.append(self._new(authz_name, 'tkauth-01', token)) self.logger.debug( 'Challenge._new_set returned ({0})'.format(challenge_list)) return challenge_list def parse(self, content): """ new oder request """ self.logger.debug('Challenge.parse()') response_dic = {} # check message (code, message, detail, protected, payload, _account_name) = self.message.check(content) if code == 200: if 'url' in protected: challenge_name = self._name_get(protected['url']) if challenge_name: challenge_dic = self._info(challenge_name) if challenge_dic: # check tnauthlist payload if self.tnauthlist_support: (code, message, detail) = self._validate_tnauthlist_payload( payload, challenge_dic) if code == 200: # start validation if 'status' in challenge_dic: if challenge_dic['status'] != 'valid': _validation = self._validate( challenge_name, payload) # query challenge again (bcs. it could get updated by self._validate) challenge_dic = self._info(challenge_name) else: # rather unlikely that we run in this situation but you never know _validation = self._validate( challenge_name, payload) # query challenge again (bcs. it could get updated by self._validate) challenge_dic = self._info(challenge_name) response_dic['data'] = {} challenge_dic['url'] = protected['url'] code = 200 response_dic['data'] = {} response_dic['data'] = challenge_dic response_dic['header'] = {} response_dic['header'][ 'Link'] = '<{0}{1}>;rel="up"'.format( self.server_name, self.path_dic['authz_path']) else: code = 400 message = 'urn:ietf:params:acme:error:malformed' detail = 'invalid challenge: {0}'.format( challenge_name) else: code = 400 message = 'urn:ietf:params:acme:error:malformed' detail = 'could not get challenge' else: code = 400 message = 'urn:ietf:params:acme:error:malformed' detail = 'url missing in protected header' # prepare/enrich response status_dic = {'code': code, 'message': message, 'detail': detail} response_dic = self.message.prepare_response(response_dic, status_dic) self.logger.debug('challenge.parse() returns: {0}'.format( json.dumps(response_dic))) return response_dic
class Authorization(object): """ class for order handling """ def __init__(self, debug=None, srv_name=None, logger=None): self.server_name = srv_name self.debug = debug self.logger = logger self.dbstore = DBstore(debug, self.logger) self.message = Message(debug, self.server_name, self.logger) self.nonce = Nonce(debug, self.logger) self.validity = 86400 self.expiry_check_disable = False self.path_dic = {'authz_path': '/acme/authz/'} def __enter__(self): """ Makes ACMEHandler a Context Manager """ self._config_load() return self def __exit__(self, *args): """ cose the connection at the end of the context """ def _authz_info(self, url): """ return authzs information """ self.logger.debug('Authorization._authz_info({0})'.format(url)) authz_name = url.replace( '{0}{1}'.format(self.server_name, self.path_dic['authz_path']), '') expires = uts_now() + self.validity token = generate_random_string(self.logger, 32) authz_info_dic = {} # lookup authorization based on name try: authz = self.dbstore.authorization_lookup('name', authz_name) except BaseException as err_: self.logger.critical( 'acme2certifier database error in Authorization._authz_info(): {0}' .format(err_)) authz = None if authz: # update authorization with expiry date and token (just to be sure) try: self.dbstore.authorization_update({ 'name': authz_name, 'token': token, 'expires': expires }) except BaseException as err_: self.logger.error( 'acme2certifier database error in Authorization._authz_info(): {0}' .format(err_)) authz_info_dic['expires'] = uts_to_date_utc(expires) # get authorization information from db to be inserted in message tnauth = None try: auth_info = self.dbstore.authorization_lookup( 'name', authz_name, ['status__name', 'type', 'value']) except BaseException as err_: self.logger.error( 'acme2certifier database error in Authorization._authz_info(): {0}' .format(err_)) auth_info = {} if auth_info: if 'status__name' in auth_info[0]: authz_info_dic['status'] = auth_info[0]['status__name'] else: authz_info_dic['status'] = 'pending' if 'type' in auth_info[0] and 'value' in auth_info[0]: authz_info_dic['identifier'] = { 'type': auth_info[0]['type'], 'value': auth_info[0]['value'] } if auth_info[0]['type'] == 'TNAuthList': tnauth = True else: authz_info_dic['status'] = 'pending' with Challenge(self.debug, self.server_name, self.logger, expires) as challenge: # get challenge data (either existing or new ones) authz_info_dic['challenges'] = challenge.challengeset_get( authz_name, authz_info_dic['status'], token, tnauth) self.logger.debug('Authorization._authz_info() returns: {0}'.format( json.dumps(authz_info_dic))) return authz_info_dic def _config_load(self): """" load config from file """ self.logger.debug('Authorization._config_load()') config_dic = load_config() if 'Authorization' in config_dic: self.expiry_check_disable = config_dic.getboolean( 'Authorization', 'expiry_check_disable', fallback=False) if 'validity' in config_dic['Authorization']: try: self.validity = int( config_dic['Authorization']['validity']) except BaseException: self.logger.warning( 'Authorization._config_load(): failed to parse validity: {0}' .format(config_dic['Authorization']['validity'])) if 'Directory' in config_dic: if 'url_prefix' in config_dic['Directory']: self.path_dic = { k: config_dic['Directory']['url_prefix'] + v for k, v in self.path_dic.items() } self.logger.debug('Authorization._config_load() ended.') def invalidate(self, timestamp=None): """ invalidate authorizations """ self.logger.debug('Authorization.invalidate({0})'.format(timestamp)) if not timestamp: timestamp = uts_now() self.logger.debug( 'Authorization.invalidate(): set timestamp to {0}'.format( timestamp)) field_list = [ 'id', 'name', 'expires', 'value', 'created_at', 'token', 'status__id', 'status__name', 'order__id', 'order__name' ] try: authz_list = self.dbstore.authorizations_expired_search( 'expires', timestamp, vlist=field_list, operant='<=') except BaseException as err_: self.logger.critical( 'acme2certifier database error in Authorization.invalidate(): {0}' .format(err_)) authz_list = [] output_list = [] for authz in authz_list: # select all authz which are not invalid if 'name' in authz and 'status__name' in authz and authz[ 'status__name'] != 'expired': # skip corner cases where authz expiry is set to 0 if 'expires' not in authz or authz['expires'] > 0: # change status and add to output list output_list.append(authz) data_dic = {'name': authz['name'], 'status': 'expired'} try: self.dbstore.authorization_update(data_dic) except BaseException as err_: self.logger.critical( 'acme2certifier database error in Authorization.invalidate(): {0}' .format(err_)) self.logger.debug( 'Authorization.invalidate() ended: {0} authorizations identified'. format(len(output_list))) return (field_list, output_list) def new_get(self, url): """ challenge computation based on get request """ self.logger.debug('Authorization.new_get()') response_dic = {} response_dic['code'] = 200 response_dic['header'] = {} response_dic['data'] = self._authz_info(url) return response_dic def new_post(self, content): """ challenge computation based on post request """ self.logger.debug('Authorization.new_post()') # invalidate expired authorizations if not self.expiry_check_disable: self.invalidate() response_dic = {} # check message (code, message, detail, protected, _payload, _account_name) = self.message.check(content) if code == 200: if 'url' in protected: auth_info = self._authz_info(protected['url']) if auth_info: response_dic['data'] = auth_info else: code = 403 message = 'urn:ietf:params:acme:error:unauthorized' detail = 'authorizations lookup failed' else: code = 400 message = 'urn:ietf:params:acme:error:malformed' detail = 'url is missing in protected' # prepare/enrich response status_dic = {'code': code, 'message': message, 'detail': detail} response_dic = self.message.prepare_response(response_dic, status_dic) self.logger.debug('Authorization.new_post() returns: {0}'.format( json.dumps(response_dic))) return response_dic
class Certificate(object): """ CA handler """ def __init__(self, debug=None, srv_name=None, logger=None): self.debug = debug self.server_name = srv_name self.logger = logger self.cahandler = None self.dbstore = DBstore(self.debug, self.logger) self.message = Message(self.debug, self.server_name, self.logger) self.path_dic = {'cert_path': '/acme/cert/'} self.retry_after = 600 self.tnauthlist_support = False def __enter__(self): """ Makes ACMEHandler a Context Manager """ self._config_load() return self def __exit__(self, *args): """ cose the connection at the end of the context """ def _account_check(self, account_name, certificate): """ check account """ self.logger.debug('Certificate.issuer_check()') try: result = self.dbstore.certificate_account_check( account_name, b64_url_recode(self.logger, certificate)) except BaseException as err_: self.logger.critical( 'acme2certifier database error in Certificate._account_check(): {0}' .format(err_)) result = None return result def _authorization_check(self, order_name, certificate): """ check if an acount holds authorization for all identifiers = SANs in the certificate """ self.logger.debug('Certificate._authorization_check()') # empty list of statuses identifier_status = [] # get identifiers for order try: identifier_dic = self.dbstore.order_lookup('name', order_name, ['identifiers']) except BaseException as err_: self.logger.critical( 'acme2certifier database error in Certificate._authorization_check(): {0}' .format(err_)) identifier_dic = {} if identifier_dic and 'identifiers' in identifier_dic: # load identifiers try: identifiers = json.loads(identifier_dic['identifiers'].lower()) except BaseException: identifiers = [] # check if we have a tnauthlist identifier tnauthlist_identifer_in = self._tnauth_identifier_check( identifiers) if self.tnauthlist_support and tnauthlist_identifer_in: try: # get list of certextensions in base64 format and identifier status tnauthlist = cert_extensions_get(self.logger, certificate) identifier_status = self._identifer_tnauth_list( identifier_dic, tnauthlist) except BaseException as err_: # enough to set identifier_list as empty list identifier_status = [] self.logger.warning( 'Certificate._authorization_check() error while loading parsing certifcate. Error: {0}' .format(err_)) else: try: # get sans san_list = cert_san_get(self.logger, certificate) identifier_status = self._identifer_status_list( identifiers, san_list) except BaseException as err_: # enough to set identifier_list as empty list identifier_status = [] self.logger.warning( 'Certificate._authorization_check() error while loading parsing certifcate. Error: {0}' .format(err_)) result = False if identifier_status and False not in identifier_status: result = True self.logger.debug( 'Certificate._authorization_check() ended with {0}'.format(result)) return result def _config_load(self): """" load config from file """ self.logger.debug('Certificate._config_load()') config_dic = load_config() if 'Order' in config_dic: self.tnauthlist_support = config_dic.getboolean( 'Order', 'tnauthlist_support', fallback=False) if 'CAhandler' in config_dic and 'handler_file' in config_dic[ 'CAhandler']: try: ca_handler_module = importlib.import_module( ca_handler_get(self.logger, config_dic['CAhandler']['handler_file'])) except BaseException as err_: self.logger.critical( 'Certificate._config_load(): loading CAhandler configured in cfg failed with err: {0}' .format(err_)) try: ca_handler_module = importlib.import_module( 'acme_srv.ca_handler') except BaseException as err_: ca_handler_module = None self.logger.critical( 'Certificate._config_load(): loading default EABHandler failed with err: {0}' .format(err_)) else: if 'CAhandler' in config_dic: ca_handler_module = importlib.import_module( 'acme_srv.ca_handler') else: self.logger.error( 'Certificate._config_load(): CAhandler configuration missing in config file' ) ca_handler_module = None if ca_handler_module: # store handler in variable self.cahandler = ca_handler_module.CAhandler if 'Directory' in config_dic: if 'url_prefix' in config_dic['Directory']: self.path_dic = { k: config_dic['Directory']['url_prefix'] + v for k, v in self.path_dic.items() } self.logger.debug('ca_handler: {0}'.format(ca_handler_module)) self.logger.debug('Certificate._config_load() ended.') def _csr_check(self, certificate_name, csr): """ compare csr extensions against order """ self.logger.debug('Certificate._csr_check()') # fetch certificate dictionary from DB certificate_dic = self._info(certificate_name) self.logger.debug( 'Certificate._info() ended with:{0}'.format(certificate_dic)) # empty list of statuses identifier_status = [] if 'order' in certificate_dic: # get identifiers for order try: identifier_dic = self.dbstore.order_lookup( 'name', certificate_dic['order'], ['identifiers']) except BaseException as err_: self.logger.critical( 'acme2certifier database error in Certificate._csr_check(): {0}' .format(err_)) identifier_dic = {} if identifier_dic and 'identifiers' in identifier_dic: # load identifiers try: identifiers = json.loads( identifier_dic['identifiers'].lower()) except BaseException: identifiers = [] # do we need to check for tnauth tnauthlist_identifer_in = self._tnauth_identifier_check( identifiers) if self.tnauthlist_support and tnauthlist_identifer_in: # get list of certextensions in base64 format try: tnauthlist = csr_extensions_get(self.logger, csr) identifier_status = self._identifer_tnauth_list( identifier_dic, tnauthlist) except BaseException as err_: identifier_status = [] self.logger.warning( 'Certificate._csr_check() error while parsing csr.\nerror: {0}' .format(err_)) else: # get sans and compare identifiers against san try: san_list = csr_san_get(self.logger, csr) identifier_status = self._identifer_status_list( identifiers, san_list) except BaseException as err_: identifier_status = [] self.logger.warning( 'Certificate._csr_check() error while checking csr.\nerror: {0}' .format(err_)) csr_check_result = False if identifier_status and False not in identifier_status: csr_check_result = True self.logger.debug( 'Certificate._csr_check() ended with {0}'.format(csr_check_result)) return csr_check_result def _identifer_status_list(self, identifiers, san_list): """ compare identifiers and check if each san is in identifer list """ self.logger.debug('Certificate._identifer_status_list()') identifier_status = [] for san in san_list: san_is_in = False try: (cert_type, cert_value) = san.lower().split(':') except BaseException: cert_type = None cert_value = None if cert_type and cert_value: for identifier in identifiers: if 'type' in identifier: if (identifier['type'].lower() == cert_type and identifier['value'].lower() == cert_value): san_is_in = True break self.logger.debug( 'SAN check for {0} against identifiers returned {1}'.format( san.lower(), san_is_in)) identifier_status.append(san_is_in) if not identifier_status: identifier_status.append(False) self.logger.debug( 'Certificate._identifer_status_list() ended with {0}'.format( identifier_status)) return identifier_status def _identifer_tnauth_list(self, identifier_dic, tnauthlist): """ compare identifiers and check if each san is in identifer list """ self.logger.debug('Certificate._identifer_tnauth_list()') identifier_status = [] # reload identifiers (case senetive) try: identifiers = json.loads(identifier_dic['identifiers']) except BaseException: identifiers = [] if tnauthlist and not identifier_dic: identifier_status.append(False) elif identifiers and tnauthlist: for identifier in identifiers: # get the tnauthlist identifier if 'type' in identifier and identifier['type'].lower( ) == 'tnauthlist': # check if tnauthlist extension is in extension list if 'value' in identifier and identifier[ 'value'] in tnauthlist: identifier_status.append(True) else: identifier_status.append(False) else: identifier_status.append(False) else: identifier_status.append(False) self.logger.debug( 'Certificate._identifer_status_list() ended with {0}'.format( identifier_status)) return identifier_status def _info(self, certificate_name, flist=('name', 'csr', 'cert', 'order__name')): """ get certificate from database """ self.logger.debug('Certificate._info({0})'.format(certificate_name)) try: result = self.dbstore.certificate_lookup('name', certificate_name, flist) except BaseException as err_: self.logger.critical( 'acme2certifier database error in Certificate._info(): {0}'. format(err_)) result = None return result def _invalidation_check(self, cert, timestamp, purge=False): """ check if cert must be invalidated """ if 'name' in cert: self.logger.debug('Certificate._invalidation_check({0})'.format( cert['name'])) else: self.logger.debug('Certificate._invalidation_check()') to_be_cleared = False if cert and 'name' in cert: if 'cert' in cert and cert['cert'] and 'removed by' in cert[ 'cert'].lower(): if not purge: # skip entries which had been cleared before cert[cert] check is needed to cover corner cases to_be_cleared = False else: # purge entries to_be_cleared = True elif 'expire_uts' in cert: # in case cert_expiry in table is 0 try to get it from cert if cert['expire_uts'] == 0: if 'cert_raw' in cert and cert['cert_raw']: # get expiration from certificate (issue_uts, expire_uts) = cert_dates_get(self.logger, cert['cert_raw']) if 0 < expire_uts < timestamp: # returned date is other than 0 and lower than given timestamp cert['issue_uts'] = issue_uts cert['expire_uts'] = expire_uts to_be_cleared = True else: if 'csr' in cert and cert['csr']: # cover cases for enrollments in flight # we assume that a CSR should turn int a cert within two weeks if 'created_at' in cert: created_at_uts = date_to_uts_utc( cert['created_at']) if 0 < created_at_uts < timestamp - (14 * 86400): to_be_cleared = True else: # this scneario should never been happen so lets be careful and not clear it to_be_cleared = False else: # no csr and no cert - to be cleared to_be_cleared = True else: # expired based on expire_uts from db to_be_cleared = True else: # this scneario should never been happen so lets be careful and not clear it to_be_cleared = False else: # entries without a cert-name can be to_be_cleared to_be_cleared = True if 'name' in cert: self.logger.debug( 'Certificate._invalidation_check({0}) ended with {1}'.format( cert['name'], to_be_cleared)) else: self.logger.debug( 'Certificate._invalidation_check() ended with {0}'.format( to_be_cleared)) return (to_be_cleared, cert) def _revocation_reason_check(self, reason): """ check reason """ self.logger.debug( 'Certificate._revocation_reason_check({0})'.format(reason)) # taken from https://tools.ietf.org/html/rfc5280#section-5.3.1 allowed_reasons = { 0: 'unspecified', 1: 'keyCompromise', # 2 : 'cACompromise', 3: 'affiliationChanged', 4: 'superseded', 5: 'cessationOfOperation', 6: 'certificateHold', # 8 : 'removeFromCRL', # 9 : 'privilegeWithdrawn', # 10 : 'aACompromise' } result = allowed_reasons.get(reason, None) self.logger.debug( 'Certificate._revocation_reason_check() ended with {0}'.format( result)) return result def _revocation_request_validate(self, account_name, payload): """ check revocaton request for consistency""" self.logger.debug( 'Certificate._revocation_request_validate({0})'.format( account_name)) # set a value to avoid that we are returning none by accident code = 400 error = None if 'reason' in payload: # check revocatoin reason if we get one rev_reason = self._revocation_reason_check(payload['reason']) # successful if not rev_reason: error = 'urn:ietf:params:acme:error:badRevocationReason' else: # set revocation reason to unspecified rev_reason = 'unspecified' if rev_reason: # check if the account issued the certificate and return the order name if 'certificate' in payload: order_name = self._account_check(account_name, payload['certificate']) else: order_name = None error = rev_reason if order_name: # check if the account holds the authorization for the identifiers auth_chk = self._authorization_check(order_name, payload['certificate']) if auth_chk: # all good set code to 200 code = 200 else: error = 'urn:ietf:params:acme:error:unauthorized' self.logger.debug( 'Certificate._revocation_request_validate() ended with: {0}, {1}'. format(code, error)) return (code, error) def _store_cert(self, certificate_name, certificate, raw, issue_uts=0, expire_uts=0): """ get key for a specific account id """ self.logger.debug( 'Certificate._store_cert({0})'.format(certificate_name)) data_dic = { 'cert': certificate, 'name': certificate_name, 'cert_raw': raw, 'issue_uts': issue_uts, 'expire_uts': expire_uts } try: cert_id = self.dbstore.certificate_add(data_dic) except BaseException as err_: cert_id = None self.logger.critical( 'acme2certifier database error in Certificate._store_cert(): {0}' .format(err_)) self.logger.debug('Certificate._store_cert({0}) ended'.format(cert_id)) return cert_id def _store_cert_error(self, certificate_name, error, poll_identifier): """ get key for a specific account id """ self.logger.debug( 'Certificate._store_cert_error({0})'.format(certificate_name)) data_dic = { 'error': error, 'name': certificate_name, 'poll_identifier': poll_identifier } try: cert_id = self.dbstore.certificate_add(data_dic) except BaseException as err_: cert_id = None self.logger.critical( 'acme2certifier database error in Certificate._store_cert(): {0}' .format(err_)) self.logger.debug( 'Certificate._store_cert_error({0}) ended'.format(cert_id)) return cert_id def _tnauth_identifier_check(self, identifier_dic): """ check if we have an tnauthlist_identifier """ self.logger.debug('Certificate._tnauth_identifier_check()') # check if we have a tnauthlist identifier tnauthlist_identifer_in = False if identifier_dic: for identifier in identifier_dic: if 'type' in identifier: if identifier['type'].lower() == 'tnauthlist': tnauthlist_identifer_in = True self.logger.debug( 'Certificate._tnauth_identifier_check() ended with: {0}'.format( tnauthlist_identifer_in)) return tnauthlist_identifer_in def certlist_search(self, key, value, vlist=('name', 'csr', 'cert', 'order__name')): """ get certificate from database """ self.logger.debug('Certificate.certlist_search({0}: {1})'.format( key, value)) try: result = self.dbstore.certificates_search(key, value, vlist) except BaseException as err_: self.logger.critical( 'acme2certifier database error in Certificate.certlist_search(): {0}' .format(err_)) result = None return result def cleanup(self, timestamp=None, purge=False): """ cleanup routine to shrink table-size """ self.logger.debug('Certificate.cleanup({0},{1})'.format( timestamp, purge)) field_list = [ 'id', 'name', 'expire_uts', 'issue_uts', 'cert', 'cert_raw', 'csr', 'created_at', 'order__id', 'order__name' ] # get expired certificates try: certificate_list = self.dbstore.certificates_search( 'expire_uts', timestamp, field_list, '<=') except BaseException as err_: self.logger.critical( 'acme2certifier database error in Certificate.cleanup() search: {0}' .format(err_)) certificate_list = [] report_list = [] for cert in certificate_list: (to_be_cleared, cert) = self._invalidation_check(cert, timestamp, purge) if to_be_cleared: report_list.append(cert) if not purge: # we are just modifiying data for cert in report_list: data_dic = { 'name': cert['name'], 'expire_uts': cert['expire_uts'], 'issue_uts': cert['issue_uts'], 'cert': 'removed by certificates.cleanup() on {0} '.format( uts_to_date_utc(timestamp)), 'cert_raw': cert['cert_raw'] } try: self.dbstore.certificate_add(data_dic) except BaseException as err_: self.logger.critical( 'acme2certifier database error in Certificate.cleanup() add: {0}' .format(err_)) else: # delete entries from certificates table for cert in report_list: try: self.dbstore.certificate_delete('id', cert['id']) except BaseException as err_: self.logger.critical( 'acme2certifier database error in Certificate.cleanup() delete: {0}' .format(err_)) self.logger.debug('Certificate.cleanup() ended with: {0} certs'.format( len(report_list))) return (field_list, report_list) def dates_update(self): """ scan certificates and update issue/expiry date """ self.logger.debug('Certificate.certificate_dates_update()') with Certificate(self.debug, None, self.logger) as certificate: cert_list = certificate.certlist_search( 'issue_uts', 0, vlist=('id', 'name', 'cert', 'cert_raw', 'issue_uts', 'expire_uts')) self.logger.debug('Got {0} certificates to be updated...'.format( len(cert_list))) for cert in cert_list: if 'issue_uts' in cert and 'expire_uts' in cert: if cert['issue_uts'] == 0 and cert['expire_uts'] == 0: if cert['cert_raw']: (issue_uts, expire_uts) = cert_dates_get( self.logger, cert['cert_raw']) if issue_uts or expire_uts: self._store_cert(cert['name'], cert['cert'], cert['cert_raw'], issue_uts, expire_uts) # return None def enroll_and_store(self, certificate_name, csr): """ cenroll and store certificater """ self.logger.debug('Certificate.enroll_and_store({0},{1})'.format( certificate_name, csr)) # check csr against order csr_check_result = self._csr_check(certificate_name, csr) error = None detail = None # only continue if self.csr_check returned True if csr_check_result: with self.cahandler(self.debug, self.logger) as ca_handler: (error, certificate, certificate_raw, poll_identifier) = ca_handler.enroll(csr) if certificate: (issue_uts, expire_uts) = cert_dates_get(self.logger, certificate_raw) try: result = self._store_cert(certificate_name, certificate, certificate_raw, issue_uts, expire_uts) except BaseException as err_: result = None self.logger.critical( 'acme2certifier database error in Certificate.enroll_and_store(): {0}' .format(err_)) else: result = None self.logger.error( 'acme2certifier enrollment error: {0}'.format(error)) # store error message for later analysis try: self._store_cert_error(certificate_name, error, poll_identifier) except BaseException as err_: result = None self.logger.critical( 'acme2certifier database error in Certificate.enroll_and_store(): {0}' .format(err_)) # cover polling cases if poll_identifier: detail = poll_identifier else: error = 'urn:ietf:params:acme:error:serverInternal' else: result = None error = 'urn:ietf:params:acme:badCSR' detail = 'CSR validation failed' self.logger.debug( 'Certificate.enroll_and_store() ended with: {0}:{1}'.format( result, error)) return (error, detail) def new_get(self, url): """ get request """ self.logger.debug('Certificate.new_get({0})'.format(url)) certificate_name = url.replace( '{0}{1}'.format(self.server_name, self.path_dic['cert_path']), '') # fetch certificate dictionary from DB certificate_dic = self._info( certificate_name, ['name', 'csr', 'cert', 'order__name', 'order__status_id']) response_dic = {} if 'order__status_id' in certificate_dic: if certificate_dic['order__status_id'] == 5: # oder status is valid - download certificate if 'cert' in certificate_dic and certificate_dic['cert']: response_dic['code'] = 200 # filter certificate and decode it response_dic['data'] = certificate_dic['cert'] response_dic['header'] = {} response_dic['header'][ 'Content-Type'] = 'application/pem-certificate-chain' else: response_dic['code'] = 500 response_dic[ 'data'] = 'urn:ietf:params:acme:error:serverInternal' elif certificate_dic['order__status_id'] == 4: # order status is processing - ratelimiting response_dic['header'] = { 'Retry-After': '{0}'.format(self.retry_after) } response_dic['code'] = 403 response_dic['data'] = 'urn:ietf:params:acme:error:rateLimited' else: response_dic['code'] = 403 response_dic[ 'data'] = 'urn:ietf:params:acme:error:orderNotReady' else: response_dic['code'] = 500 response_dic['data'] = 'urn:ietf:params:acme:error:serverInternal' self.logger.debug('Certificate.new_get({0}) ended'.format( response_dic['code'])) return response_dic def new_post(self, content): """ post request """ self.logger.debug('Certificate.new_post({0})') response_dic = {} # check message (code, message, detail, protected, _payload, _account_name) = self.message.check(content) if code == 200: if 'url' in protected: response_dic = self.new_get(protected['url']) if response_dic['code'] in (400, 403, 400, 500): code = response_dic['code'] message = response_dic['data'] detail = None else: response_dic['code'] = code = 400 response_dic[ 'data'] = message = 'urn:ietf:params:acme:error:malformed' detail = 'url missing in protected header' # prepare/enrich response status_dic = {'code': code, 'message': message, 'detail': detail} response_dic = self.message.prepare_response(response_dic, status_dic) # depending on the response the content of responsedic['data'] can be either string or dict # data will get serialzed if isinstance(response_dic['data'], dict): response_dic['data'] = json.dumps(response_dic['data']) # cover cornercase - not sure if we ever run into such situation if 'code' in response_dic: result = response_dic['code'] else: result = 'no code found' self.logger.debug( 'Certificate.new_post() ended with: {0}'.format(result)) return response_dic def revoke(self, content): """ revoke request """ self.logger.debug('Certificate.revoke()') response_dic = {} # check message (code, message, detail, _protected, payload, account_name) = self.message.check(content) if code == 200: if 'certificate' in payload: (code, error) = self._revocation_request_validate( account_name, payload) if code == 200: # revocation starts here # revocation reason is stored in error variable rev_date = uts_to_date_utc(uts_now()) with self.cahandler(self.debug, self.logger) as ca_handler: (code, message, detail) = ca_handler.revoke(payload['certificate'], error, rev_date) else: message = error detail = None else: # message could not get decoded code = 400 message = 'urn:ietf:params:acme:error:malformed' detail = 'certificate not found' # prepare/enrich response status_dic = {'code': code, 'message': message, 'detail': detail} response_dic = self.message.prepare_response(response_dic, status_dic) self.logger.debug( 'Certificate.revoke() ended with: {0}'.format(response_dic)) return response_dic def poll(self, certificate_name, poll_identifier, csr, order_name): """ try to fetch a certificate from CA and store it into database """ self.logger.debug('Certificate.poll({0}: {1})'.format( certificate_name, poll_identifier)) with self.cahandler(self.debug, self.logger) as ca_handler: (error, certificate, certificate_raw, poll_identifier, rejected) = ca_handler.poll(certificate_name, poll_identifier, csr) if certificate: # get issuing and expiration date (issue_uts, expire_uts) = cert_dates_get(self.logger, certificate_raw) # update certificate record in database _result = self._store_cert(certificate_name, certificate, certificate_raw, issue_uts, expire_uts) # update order status to 5 (valid) try: self.dbstore.order_update({ 'name': order_name, 'status': 'valid' }) except BaseException as err_: self.logger.critical( 'acme2certifier database error in Certificate.poll(): {0}' .format(err_)) else: # store error message for later analysis self._store_cert_error(certificate_name, error, poll_identifier) _result = None if rejected: try: self.dbstore.order_update({ 'name': order_name, 'status': 'invalid' }) except BaseException as err_: self.logger.critical( 'acme2certifier database error in Certificate.poll(): {0}' .format(err_)) self.logger.debug('Certificate.poll({0}: {1})'.format( certificate_name, poll_identifier)) return _result def store_csr(self, order_name, csr): """ store csr into database """ self.logger.debug('Certificate.store_csr({0})'.format(order_name)) certificate_name = generate_random_string(self.logger, 12) data_dic = {'order': order_name, 'csr': csr, 'name': certificate_name} try: self.dbstore.certificate_add(data_dic) except BaseException as err_: self.logger.critical( 'Database error in Certificate.store_csr(): {0}'.format(err_)) self.logger.debug('Certificate.store_csr() ended') return certificate_name
class TestACMEHandler(unittest.TestCase): """ test class for ACMEHandler """ acme = None def setUp(self): """ setup unittest """ models_mock = MagicMock() models_mock.acme_srv.db_handler.DBstore.return_value = FakeDBStore modules = {'acme_srv.db_handler': models_mock} patch.dict('sys.modules', modules).start() import logging logging.basicConfig(level=logging.CRITICAL) self.logger = logging.getLogger('test_a2c') from acme_srv.message import Message self.message = Message(False, 'http://tester.local', self.logger) @patch('acme_srv.message.decode_message') def test_001_message_check(self, mock_decode): """ message_check failed bcs of decoding error """ message = '{"foo" : "bar"}' mock_decode.return_value = (False, 'detail', None, None, None) self.assertEqual((400, 'urn:ietf:params:acme:error:malformed', 'detail', None, None, None), self.message.check(message)) @patch('acme_srv.nonce.Nonce.check') @patch('acme_srv.message.decode_message') def test_002_message_check(self, mock_decode, mock_nonce_check): """ message_check nonce check failed """ message = '{"foo" : "bar"}' mock_decode.return_value = (True, None, 'protected', 'payload', 'signature') mock_nonce_check.return_value = (400, 'badnonce', None) self.assertEqual((400, 'badnonce', None, 'protected', 'payload', None), self.message.check(message)) @patch('acme_srv.nonce.Nonce.check') @patch('acme_srv.message.decode_message') def test_003_message_check(self, mock_decode, mock_nonce_check): """ message check failed bcs account id lookup failed """ mock_decode.return_value = (True, None, 'protected', 'payload', 'signature') mock_nonce_check.return_value = (200, None, None) message = '{"foo" : "bar"}' self.assertEqual((403, 'urn:ietf:params:acme:error:accountDoesNotExist', None, 'protected', 'payload', None), self.message.check(message)) @patch('acme_srv.signature.Signature.check') @patch('acme_srv.message.Message._name_get') @patch('acme_srv.nonce.Nonce.check') @patch('acme_srv.message.decode_message') def test_004_message_check(self, mock_decode, mock_nonce_check, mock_aname, mock_sig): """ message check failed bcs signature_check_failed """ mock_decode.return_value = (True, None, 'protected', 'payload', 'signature') mock_nonce_check.return_value = (200, None, None) mock_aname.return_value = 'account_name' mock_sig.return_value = (False, 'error', 'detail') message = '{"foo" : "bar"}' self.assertEqual((403, 'error', 'detail', 'protected', 'payload', 'account_name'), self.message.check(message)) @patch('acme_srv.signature.Signature.check') @patch('acme_srv.message.Message._name_get') @patch('acme_srv.nonce.Nonce.check') @patch('acme_srv.message.decode_message') def test_005_message_check(self, mock_decode, mock_nonce_check, mock_aname, mock_sig): """ message check successful """ mock_decode.return_value = (True, None, 'protected', 'payload', 'signature') mock_nonce_check.return_value = (200, None, None) mock_aname.return_value = 'account_name' mock_sig.return_value = (True, None, None) message = '{"foo" : "bar"}' self.assertEqual((200, None, None, 'protected', 'payload', 'account_name'), self.message.check(message)) @patch('acme_srv.signature.Signature.check') @patch('acme_srv.message.Message._name_get') @patch('acme_srv.nonce.Nonce.check') @patch('acme_srv.message.decode_message') def test_006_message_check(self, mock_decode, mock_nonce_check, mock_aname, mock_sig): """ message check successful as nonce check is disabled """ mock_decode.return_value = (True, None, 'protected', 'payload', 'signature') mock_nonce_check.return_value = (400, 'badnonce', None) mock_aname.return_value = 'account_name' mock_sig.return_value = (True, None, None) message = '{"foo" : "bar"}' self.message.disable_dic = {'nonce_check_disable': True, 'signature_check_disable': False} with self.assertLogs('test_a2c', level='INFO') as lcm: self.assertEqual((200, None, None, 'protected', 'payload', 'account_name'), self.message.check(message, skip_nonce_check=True)) self.assertIn('ERROR:test_a2c:**** NONCE CHECK DISABLED!!! Severe security issue ****', lcm.output) @patch('acme_srv.signature.Signature.check') @patch('acme_srv.message.Message._name_get') @patch('acme_srv.nonce.Nonce.check') @patch('acme_srv.message.decode_message') def test_007_message_check(self, mock_decode, mock_nonce_check, mock_aname, mock_sig): """ message check successful as nonce check is disabled """ mock_decode.return_value = (True, None, 'protected', 'payload', 'signature') mock_nonce_check.return_value = (400, 'badnonce', None) mock_aname.return_value = 'account_name' mock_sig.return_value = (True, None, None) message = '{"foo" : "bar"}' self.message.disable_dic = {'nonce_check_disable': True, 'signature_check_disable': True} with self.assertLogs('test_a2c', level='INFO') as lcm: self.assertEqual((200, None, None, 'protected', 'payload', None), self.message.check(message, skip_nonce_check=True)) self.assertIn('ERROR:test_a2c:**** SIGNATURE_CHECK_DISABLE!!! Severe security issue ****', lcm.output) self.assertIn('ERROR:test_a2c:**** NONCE CHECK DISABLED!!! Severe security issue ****', lcm.output) @patch('acme_srv.signature.Signature.check') @patch('acme_srv.message.Message._name_get') @patch('acme_srv.nonce.Nonce.check') @patch('acme_srv.message.decode_message') def test_008_message_check(self, mock_decode, mock_nonce_check, mock_aname, mock_sig): """ message check successful as nonce check is disabled """ mock_decode.return_value = (True, None, 'protected', 'payload', 'signature') mock_nonce_check.return_value = (400, 'badnonce', None) mock_aname.return_value = 'account_name' mock_sig.return_value = (True, None, None) message = '{"foo" : "bar"}' self.message.disable_dic = {'nonce_check_disable': False, 'signature_check_disable': False} with self.assertLogs('test_a2c', level='INFO') as lcm: self.assertEqual((200, None, None, 'protected', 'payload', 'account_name'), self.message.check(message, skip_nonce_check=True)) self.assertIn('INFO:test_a2c:skip nonce check of inner payload during keyrollover', lcm.output) @patch('acme_srv.nonce.Nonce.generate_and_add') def test_009_message_prepare_response(self, mock_nnonce): """ Message.prepare_respons for code 200 and complete data """ data_dic = {'data' : {'foo_data' : 'bar_bar'}, 'header': {'foo_header' : 'bar_header'}} mock_nnonce.return_value = 'new_nonce' config_dic = {'code' : 200, 'message' : 'message', 'detail' : 'detail'} self.assertEqual({'header': {'foo_header': 'bar_header', 'Replay-Nonce': 'new_nonce'}, 'code': 200, 'data': {'foo_data': 'bar_bar'}}, self.message.prepare_response(data_dic, config_dic)) @patch('acme_srv.error.Error.enrich_error') @patch('acme_srv.nonce.Nonce.generate_and_add') def test_010_message_prepare_response(self, mock_nnonce, mock_error): """ Message.prepare_respons for code 200 without header tag in response_dic """ data_dic = {'data' : {'foo_data' : 'bar_bar'},} mock_nnonce.return_value = 'new_nonce' mock_error.return_value = 'mock_error' config_dic = {'code' : 200, 'message' : 'message', 'detail' : 'detail'} self.assertEqual({'header': {'Replay-Nonce': 'new_nonce'}, 'code': 200, 'data': {'foo_data': 'bar_bar'}}, self.message.prepare_response(data_dic, config_dic)) @patch('acme_srv.nonce.Nonce.generate_and_add') def test_011_message_prepare_response(self, mock_nnonce): """ Message.prepare_response for config_dic without code key """ data_dic = {'data' : {'foo_data' : 'bar_bar'}, 'header': {'foo_header' : 'bar_header'}} mock_nnonce.return_value = 'new_nonce' # mock_error.return_value = 'mock_error' config_dic = {'message' : 'message', 'detail' : 'detail'} self.assertEqual({'header': {'foo_header': 'bar_header'}, 'code': 400, 'data': {'detail': 'http status code missing', 'message': 'urn:ietf:params:acme:error:serverInternal', 'status': 400}}, self.message.prepare_response(data_dic, config_dic)) @patch('acme_srv.nonce.Nonce.generate_and_add') def test_012_message_prepare_response(self, mock_nnonce): """ Message.prepare_response for config_dic without message key """ data_dic = {'data' : {'foo_data' : 'bar_bar'}, 'header': {'foo_header' : 'bar_header'}} mock_nnonce.return_value = 'new_nonce' # mock_error.return_value = 'mock_error' config_dic = {'code' : 400, 'detail' : 'detail'} self.assertEqual({'header': {'foo_header': 'bar_header'}, 'code': 400, 'data': {'detail': 'detail', 'message': 'urn:ietf:params:acme:error:serverInternal', 'status': 400}}, self.message.prepare_response(data_dic, config_dic)) @patch('acme_srv.nonce.Nonce.generate_and_add') def test_013_message_prepare_response(self, mock_nnonce): """ Message.repare_response for config_dic without detail key """ data_dic = {'data' : {'foo_data' : 'bar_bar'}, 'header': {'foo_header' : 'bar_header'}} mock_nnonce.return_value = 'new_nonce' config_dic = {'code' : 400, 'message': 'message'} self.assertEqual({'header': {'foo_header': 'bar_header'}, 'code': 400, 'data': {'message': 'message', 'status': 400}}, self.message.prepare_response(data_dic, config_dic)) @patch('acme_srv.error.Error.enrich_error') @patch('acme_srv.nonce.Nonce.generate_and_add') def test_014_message_prepare_response(self, mock_nnonce, mock_error): """ Message.prepare_response for response_dic without data key """ data_dic = {'header': {'foo_header' : 'bar_header'}} mock_nnonce.return_value = 'new_nonce' mock_error.return_value = 'mock_error' config_dic = {'code' : 400, 'message': 'message', 'detail' : 'detail'} self.assertEqual({'header': {'foo_header': 'bar_header'}, 'code': 400, 'data': {'detail': 'mock_error', 'message': 'message', 'status': 400}}, self.message.prepare_response(data_dic, config_dic)) def test_015_message__name_get(self): """ test Message.name_get() with empty content""" protected = {} self.assertFalse(self.message._name_get(protected)) def test_016_message__name_get(self): """ test Message.name_get() with kid with nonsens in content""" protected = {'kid' : 'foo'} self.assertEqual('foo', self.message._name_get(protected)) def test_017_message__name_get(self): """ test Message.name_get() with wrong kid in content""" protected = {'kid' : 'http://tester.local/acme/account/account_name'} self.assertEqual(None, self.message._name_get(protected)) def test_018_message__name_get(self): """ test Message.name_get() with correct kid in content""" protected = {'kid' : 'http://tester.local/acme/acct/account_name'} self.assertEqual('account_name', self.message._name_get(protected)) def test_019_message__name_get(self): """ test Message.name_get() with 'jwk' in content but without URL""" protected = {'jwk' : 'jwk'} self.assertEqual(None, self.message._name_get(protected)) def test_020_message__name_get(self): """ test Message.name_get() with 'jwk' and 'url' in content but url is wrong""" protected = {'jwk' : 'jwk', 'url' : 'url'} self.assertEqual(None, self.message._name_get(protected)) def test_021_message__name_get(self): """ test Message.name_get() with 'jwk' and correct 'url' in content but no 'n' in jwk """ protected = {'jwk' : 'jwk', 'url' : 'http://tester.local/acme/revokecert'} self.assertEqual(None, self.message._name_get(protected)) def test_022_message__name_get(self): """ test Message.name_get() with 'jwk' and correct 'url' but account lookup failed """ protected = {'jwk' : {'n' : 'n'}, 'url' : 'http://tester.local/acme/revokecert'} self.message.dbstore.account_lookup.return_value = {} self.assertEqual(None, self.message._name_get(protected)) def test_023_message__name_get(self): """ test Message.name_get() with 'jwk' and correct 'url' and wrong account lookup data""" protected = {'jwk' : {'n' : 'n'}, 'url' : 'http://tester.local/acme/revokecert'} self.message.dbstore.account_lookup.return_value = {'bar' : 'foo'} self.assertEqual(None, self.message._name_get(protected)) def test_024_message__name_get(self): """ test Message.name_get() with 'jwk' and correct 'url' and wrong account lookup data""" protected = {'jwk' : {'n' : 'n'}, 'url' : 'http://tester.local/acme/revokecert'} self.message.dbstore.account_lookup.return_value = {'name' : 'foo'} self.assertEqual('foo', self.message._name_get(protected)) def test_025_message__name_get(self): """ test Message.name_get() - dbstore.account_lookup raises an exception """ protected = {'jwk' : {'n' : 'n'}, 'url' : 'http://tester.local/acme/revokecert'} self.message.dbstore.account_lookup.side_effect = Exception('exc_mess__name_get') with self.assertLogs('test_a2c', level='INFO') as lcm: self.message._name_get(protected) self.assertIn('CRITICAL:test_a2c:acme2certifier database error in Message._name_get(): exc_mess__name_get', lcm.output) def test_026__enter__(self): """ test enter """ self.message.__enter__() @patch('acme_srv.message.load_config') def test_027_config_load(self, mock_load_cfg): """ test _config_load empty config """ parser = configparser.ConfigParser() # parser['Account'] = {'foo': 'bar'} mock_load_cfg.return_value = parser self.message._config_load() self.assertFalse(self.message.disable_dic['nonce_check_disable']) self.assertFalse(self.message.disable_dic['signature_check_disable']) @patch('acme_srv.message.load_config') def test_028_config_load(self, mock_load_cfg): """ test _config_load """ parser = configparser.ConfigParser() parser['Nonce'] = {'nonce_check_disable': False, 'signature_check_disable': False} mock_load_cfg.return_value = parser self.message._config_load() self.assertFalse(self.message.disable_dic['nonce_check_disable']) self.assertFalse(self.message.disable_dic['signature_check_disable']) @patch('acme_srv.message.load_config') def test_029_config_load(self, mock_load_cfg): """ test _config_load """ parser = configparser.ConfigParser() parser['Nonce'] = {'nonce_check_disable': True, 'signature_check_disable': False} mock_load_cfg.return_value = parser self.message._config_load() self.assertTrue(self.message.disable_dic['nonce_check_disable']) self.assertFalse(self.message.disable_dic['signature_check_disable']) @patch('acme_srv.message.load_config') def test_030_config_load(self, mock_load_cfg): """ test _config_load """ parser = configparser.ConfigParser() parser['Nonce'] = {'nonce_check_disable': False, 'signature_check_disable': True} mock_load_cfg.return_value = parser self.message._config_load() self.assertFalse(self.message.disable_dic['nonce_check_disable']) self.assertTrue(self.message.disable_dic['signature_check_disable']) @patch('acme_srv.message.load_config') def test_031_config_load(self, mock_load_cfg): """ test _config_load """ parser = configparser.ConfigParser() parser['Directory'] = {'url_prefix': 'url_prefix', 'foo': 'bar'} mock_load_cfg.return_value = parser self.message._config_load() self.assertFalse(self.message.disable_dic['nonce_check_disable']) self.assertFalse(self.message.disable_dic['signature_check_disable']) self.assertEqual({'acct_path': 'url_prefix/acme/acct/', 'revocation_path': 'url_prefix/acme/revokecert'}, self.message.path_dic)
class Order(object): """ class for order handling """ def __init__(self, debug=None, srv_name=None, logger=None): self.server_name = srv_name self.debug = debug self.logger = logger self.dbstore = DBstore(self.debug, self.logger) self.message = Message(self.debug, self.server_name, self.logger) self.validity = 86400 self.authz_validity = 86400 self.expiry_check_disable = False self.path_dic = { 'authz_path': '/acme/authz/', 'order_path': '/acme/order/', 'cert_path': '/acme/cert/' } self.retry_after = 600 self.tnauthlist_support = False def __enter__(self): """ Makes ACMEHandler a Context Manager """ self._config_load() return self def __exit__(self, *args): """ cose the connection at the end of the context """ def _add(self, payload, aname): """ add order request to database """ self.logger.debug('Order._add({0})'.format(aname)) error = None auth_dic = {} order_name = generate_random_string(self.logger, 12) expires = uts_now() + self.validity if 'identifiers' in payload: data_dic = {'status': 2, 'expires': expires, 'account': aname} data_dic['name'] = order_name data_dic['identifiers'] = json.dumps(payload['identifiers']) #if 'notBefore' in payload: # data_dic['notbefore'] = payload['notBefore'] #if 'notAfter' in payload: # data_dic['notafter'] = payload['notAfter'] # check identifiers error = self._identifiers_check(payload['identifiers']) # change order status if needed if error: data_dic['status'] = 1 try: # add order to db oid = self.dbstore.order_add(data_dic) except BaseException as err_: self.logger.critical( 'acme2certifier database error in Order._add() order: {0}'. format(err_)) oid = None if not error: if oid: error = None for auth in payload['identifiers']: # generate name auth_name = generate_random_string(self.logger, 12) # store to return to upper func auth_dic[auth_name] = auth.copy() auth['name'] = auth_name auth['order'] = oid auth['status'] = 'pending' auth['expires'] = uts_now() + self.authz_validity try: self.dbstore.authorization_add(auth) except BaseException as err_: self.logger.critical( 'acme2certifier database error in Order._add() authz: {0}' .format(err_)) else: error = 'urn:ietf:params:acme:error:malformed' else: error = 'urn:ietf:params:acme:error:unsupportedIdentifier' self.logger.debug('Order._add() ended') return (error, order_name, auth_dic, uts_to_date_utc(expires)) def _config_load(self): """" load config from file """ self.logger.debug('Order._config_load()') config_dic = load_config() if 'Order' in config_dic: self.tnauthlist_support = config_dic.getboolean( 'Order', 'tnauthlist_support', fallback=False) self.expiry_check_disable = config_dic.getboolean( 'Order', 'expiry_check_disable', fallback=False) if 'retry_after_timeout' in config_dic['Order']: try: self.retry_after = int( config_dic['Order']['retry_after_timeout']) except BaseException: self.logger.warning( 'Order._config_load(): failed to parse retry_after: {0}' .format(config_dic['Order']['retry_after_timeout'])) if 'validity' in config_dic['Order']: try: self.validity = int(config_dic['Order']['validity']) except BaseException: self.logger.warning( 'Order._config_load(): failed to parse validity: {0}'. format(config_dic['Order']['validity'])) if 'Authorization' in config_dic: if 'validity' in config_dic['Authorization']: try: self.authz_validity = int( config_dic['Authorization']['validity']) except BaseException: self.logger.warning( 'Order._config_load(): failed to parse authz validity: {0}' .format(config_dic['Authorization']['validity'])) if 'Directory' in config_dic: if 'url_prefix' in config_dic['Directory']: self.path_dic = { k: config_dic['Directory']['url_prefix'] + v for k, v in self.path_dic.items() } self.logger.debug('Order._config_load() ended.') def _name_get(self, url): """ get ordername """ self.logger.debug('Order._name_get({0})'.format(url)) url_dic = parse_url(self.logger, url) order_name = url_dic['path'].replace(self.path_dic['order_path'], '') if '/' in order_name: (order_name, _sinin) = order_name.split('/', 1) self.logger.debug('Order._name_get() ended') return order_name def _identifiers_check(self, identifiers_list): """ check validity of identifers in order """ self.logger.debug( 'Order._identifiers_check({0})'.format(identifiers_list)) error = None allowed_identifers = ['dns'] # add tnauthlist to list of supported identfiers if configured to do so if self.tnauthlist_support: allowed_identifers.append('tnauthlist') if identifiers_list and isinstance(identifiers_list, list): for identifier in identifiers_list: if 'type' in identifier: if identifier['type'].lower() not in allowed_identifers: error = 'urn:ietf:params:acme:error:unsupportedIdentifier' break else: error = 'urn:ietf:params:acme:error:malformed' else: error = 'urn:ietf:params:acme:error:malformed' self.logger.debug( 'Order._identifiers_check() done with {0}:'.format(error)) return error def _info(self, order_name): """ list details of an order """ self.logger.debug('Order._info({0})'.format(order_name)) try: result = self.dbstore.order_lookup('name', order_name) except BaseException as err_: self.logger.critical( 'acme2certifier database error in Order._info(): {0}'.format( err_)) result = None return result def _process(self, order_name, protected, payload): """ process order """ self.logger.debug('Order._process({0})'.format(order_name)) certificate_name = None message = None detail = None if 'url' in protected: if 'finalize' in protected['url']: self.logger.debug('finalize request()') # lookup order-status (must be ready to proceed) order_dic = self._info(order_name) if 'status' in order_dic and order_dic['status'] == 'ready': # update order_status / set to processing self._update({'name': order_name, 'status': 'processing'}) if 'csr' in payload: self.logger.debug('CSR found()') # this is a new request (code, certificate_name, detail) = self._csr_process(order_name, payload['csr']) # change status only if we do not have a poll_identifier (stored in detail variable) if code == 200: if not detail: # update order_status / set to valid self._update({ 'name': order_name, 'status': 'valid' }) else: message = certificate_name detail = 'enrollment failed' else: code = 400 message = 'urn:ietf:params:acme:error:badCSR' detail = 'csr is missing in payload' else: code = 403 message = 'urn:ietf:params:acme:error:orderNotReady' detail = 'Order is not ready' else: self.logger.debug('polling request()') code = 200 # this is a polling request; lookup certificate try: cert_dic = self.dbstore.certificate_lookup( 'order__name', order_name) except BaseException as err_: self.logger.critical( 'acme2certifier database error in Order._process(): {0}' .format(err_)) cert_dic = {} if cert_dic: # we found a cert in the database # pylint: disable=R1715 if 'name' in cert_dic: certificate_name = cert_dic['name'] else: code = 400 message = 'urn:ietf:params:acme:error:malformed' detail = 'url is missing in protected' self.logger.debug( 'Order._process() ended with order:{0} {1}:{2}:{3}'.format( order_name, code, message, detail)) return (code, message, detail, certificate_name) def _csr_process(self, order_name, csr): """ process certificate signing request """ self.logger.debug('Order._csr_process({0})'.format(order_name)) order_dic = self._info(order_name) if order_dic: # change decoding from b64url to b64 csr = b64_url_recode(self.logger, csr) with Certificate(self.debug, self.server_name, self.logger) as certificate: # certificate = Certificate(self.debug, self.server_name, self.logger) certificate_name = certificate.store_csr(order_name, csr) if certificate_name: (error, detail) = certificate.enroll_and_store( certificate_name, csr) if not error: code = 200 message = certificate_name # detail = None else: code = 400 message = error if message == 'urn:ietf:params:acme:error:serverInternal': code = 500 else: code = 500 message = 'urn:ietf:params:acme:error:serverInternal' detail = 'CSR processing failed' else: code = 400 message = 'urn:ietf:params:acme:error:unauthorized' detail = 'order: {0} not found'.format(order_name) self.logger.debug( 'Order._csr_process() ended with order:{0} {1}:{2}:{3}'.format( order_name, code, message, detail)) return (code, message, detail) def _update(self, data_dic): """ update order based on ordername """ self.logger.debug('Order._update({0})'.format(data_dic)) try: self.dbstore.order_update(data_dic) except BaseException as err_: self.logger.critical( 'acme2certifier database error in Order._update(): {0}'.format( err_)) def _lookup(self, order_name): """ sohw order details based on ordername """ self.logger.debug('Order._lookup({0})'.format(order_name)) order_dic = {} tmp_dic = self._info(order_name) if tmp_dic: if 'status' in tmp_dic: order_dic['status'] = tmp_dic['status'] if 'expires' in tmp_dic: order_dic['expires'] = uts_to_date_utc(tmp_dic['expires']) if 'notbefore' in tmp_dic: if tmp_dic['notbefore'] != 0: order_dic['notBefore'] = uts_to_date_utc( tmp_dic['notbefore']) if 'notafter' in tmp_dic: if tmp_dic['notafter'] != 0: order_dic['notAfter'] = uts_to_date_utc( tmp_dic['notafter']) if 'identifiers' in tmp_dic: try: order_dic['identifiers'] = json.loads( tmp_dic['identifiers']) except BaseException: self.logger.error( 'Order.lookup(): error while parsing the identifier {0}' .format(tmp_dic['identifiers'])) try: authz_list = self.dbstore.authorization_lookup( 'order__name', order_name, ['name', 'status__name']) except BaseException as err_: self.logger.critical( 'acme2certifier database error in Order._lookup(): {0}'. format(err_)) authz_list = [] if authz_list: order_dic["authorizations"] = [] # collect status of different authorizations in list validity_list = [] for authz in authz_list: if 'name' in authz: order_dic["authorizations"].append('{0}{1}{2}'.format( self.server_name, self.path_dic['authz_path'], authz['name'])) if 'status__name' in authz: if authz['status__name'] == 'valid': validity_list.append(True) else: validity_list.append(False) # update orders status from pending to ready if validity_list and 'status' in order_dic: if False not in validity_list and order_dic[ 'status'] == 'pending': self._update({'name': order_name, 'status': 'ready'}) self.logger.debug('Order._lookup() ended') return order_dic def invalidate(self, timestamp=None): """ invalidate orders """ self.logger.debug('Order.invalidate({0})'.format(timestamp)) if not timestamp: timestamp = uts_now() self.logger.debug( 'Order.invalidate(): set timestamp to {0}'.format(timestamp)) field_list = [ 'id', 'name', 'expires', 'identifiers', 'created_at', 'status__id', 'status__name', 'account__id', 'account__name', 'account__contact' ] try: order_list = self.dbstore.orders_invalid_search('expires', timestamp, vlist=field_list, operant='<=') except BaseException as err_: self.logger.critical( 'acme2certifier database error in Order._invalidate() search: {0}' .format(err_)) order_list = [] output_list = [] for order in order_list: # print(order['id']) # select all orders which are not invalid if 'name' in order and 'status__name' in order and order[ 'status__name'] != 'invalid': # change status and add to output list output_list.append(order) data_dic = {'name': order['name'], 'status': 'invalid'} try: self.dbstore.order_update(data_dic) except BaseException as err_: self.logger.critical( 'acme2certifier database error in Order._invalidate() upd: {0}' .format(err_)) self.logger.debug( 'Order.invalidate() ended: {0} orders identified'.format( len(output_list))) return (field_list, output_list) def new(self, content): """ new oder request """ self.logger.debug('Order.new()') response_dic = {} # check message (code, message, detail, _protected, payload, account_name) = self.message.check(content) if code == 200: (error, order_name, auth_dic, expires) = self._add(payload, account_name) if not error: code = 201 response_dic['header'] = {} response_dic['header']['Location'] = '{0}{1}{2}'.format( self.server_name, self.path_dic['order_path'], order_name) response_dic['data'] = {} response_dic['data']['identifiers'] = [] response_dic['data']['authorizations'] = [] response_dic['data']['status'] = 'pending' response_dic['data']['expires'] = expires response_dic['data']['finalize'] = '{0}{1}{2}/finalize'.format( self.server_name, self.path_dic['order_path'], order_name) for auth_name in auth_dic: response_dic['data']['authorizations'].append( '{0}{1}{2}'.format(self.server_name, self.path_dic['authz_path'], auth_name)) response_dic['data']['identifiers'].append( auth_dic[auth_name]) else: code = 400 message = error detail = 'could not process order' # prepare/enrich response status_dic = {'code': code, 'message': message, 'detail': detail} response_dic = self.message.prepare_response(response_dic, status_dic) self.logger.debug('Order.new() returns: {0}'.format( json.dumps(response_dic))) return response_dic def parse(self, content): """ new oder request """ self.logger.debug('Order.parse()') # invalidate expired orders if not self.expiry_check_disable: self.invalidate() response_dic = {} # check message (code, message, detail, protected, payload, _account_name) = self.message.check(content) if code == 200: if 'url' in protected: order_name = self._name_get(protected['url']) if order_name: order_dic = self._lookup(order_name) if order_dic: (code, message, detail, certificate_name) = self._process( order_name, protected, payload) else: code = 403 message = 'urn:ietf:params:acme:error:orderNotReady' detail = 'order not found' else: code = 400 message = 'urn:ietf:params:acme:error:malformed' detail = 'order name is missing' else: code = 400 message = 'urn:ietf:params:acme:error:malformed' detail = 'url is missing in protected' if code == 200: # create response response_dic['header'] = {} response_dic['header']['Location'] = '{0}{1}{2}'.format( self.server_name, self.path_dic['order_path'], order_name) response_dic['data'] = self._lookup(order_name) if 'status' in response_dic['data'] and response_dic['data'][ 'status'] == 'processing': # set retry header as cert issuane is not completed. response_dic['header']['Retry-After'] = '{0}'.format( self.retry_after) response_dic['data']['finalize'] = '{0}{1}{2}/finalize'.format( self.server_name, self.path_dic['order_path'], order_name) # add the path to certificate if order-status is ready # if certificate_name: if certificate_name and 'status' in response_dic[ 'data'] and response_dic['data']['status'] == 'valid': response_dic['data']['certificate'] = '{0}{1}{2}'.format( self.server_name, self.path_dic['cert_path'], certificate_name) # prepare/enrich response status_dic = {'code': code, 'message': message, 'detail': detail} response_dic = self.message.prepare_response(response_dic, status_dic) self.logger.debug('Order.parse() returns: {0}'.format( json.dumps(response_dic))) return response_dic