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 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, 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), } try: (db_name, new) = self.dbstore.account_add(data_dic) except BaseException as err_: self.logger.critical( 'Database error in Account._add(): {0}'.format( err_)) db_name = None new = False self.logger.debug('god 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 _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('_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 'Directory' in config_dic: if 'tos_url' in config_dic['Directory']: self.tos_url = config_dic['Directory']['tos_url'] 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) # 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, 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) 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 TestACMEHandler(unittest.TestCase): """ test class for ACMEHandler """ acme = None def setUp(self): """ setup unittest """ models_mock = MagicMock() models_mock.acme.db_handler.DBstore.return_value = FakeDBStore modules = {'acme.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.message import Message self.message = Message(False, 'http://tester.local', self.logger) @patch('acme.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.nonce.Nonce.check') @patch('acme.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.nonce.Nonce.check') @patch('acme.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.signature.Signature.check') @patch('acme.message.Message._name_get') @patch('acme.nonce.Nonce.check') @patch('acme.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.signature.Signature.check') @patch('acme.message.Message._name_get') @patch('acme.nonce.Nonce.check') @patch('acme.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.signature.Signature.check') @patch('acme.message.Message._name_get') @patch('acme.nonce.Nonce.check') @patch('acme.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.assertEqual((200, None, None, 'protected', 'payload', 'account_name'), self.message.check(message, skip_nonce_check=True)) @patch('acme.nonce.Nonce.generate_and_add') def test_007_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.error.Error.enrich_error') @patch('acme.nonce.Nonce.generate_and_add') def test_008_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.nonce.Nonce.generate_and_add') def test_009_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.nonce.Nonce.generate_and_add') def test_010_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.nonce.Nonce.generate_and_add') def test_011_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.error.Error.enrich_error') @patch('acme.nonce.Nonce.generate_and_add') def test_012_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_013_message__name_get(self): """ test Message.name_get() with empty content""" protected = {} self.assertFalse(self.message._name_get(protected)) def test_014_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_015_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_016_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_017_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_018_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_019_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_020_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_021_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_022_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_023_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)