def create_user_token_by_pairing_url(self, pairing_url, pin='1234'): """ parses the pairing url and saves the extracted data in the fake token database of this test class. :param pairing_url: the pairing url received from the server :param pin: the pin of the token (default: '1234') :returns: user_token_id of newly created token """ # extract metadata and the public key data_encoded = pairing_url[len(self.uri + '://pair/'):] data = decode_base64_urlsafe(data_encoded) version, token_type, flags = struct.unpack('<bbI', data[0:6]) partition = struct.unpack('<I', data[6:10])[0] server_public_key = data[10:10 + 32] # validate protocol versions and type id self.assertEqual(token_type, TYPE_PUSHTOKEN) self.assertEqual(version, PAIRING_URL_VERSION) # ------------------------------------------------------------------ -- # extract custom data that may or may not be present # (depending on flags) custom_data = data[10 + 32:] token_serial = None if flags & FLAG_PAIR_SERIAL: token_serial, __, custom_data = custom_data.partition(b'\x00') callback_url = None if flags & FLAG_PAIR_CBURL: callback_url, __, custom_data = custom_data.partition(b'\x00') else: raise NotImplementedError( 'Callback URL is mandatory for PushToken') # ------------------------------------------------------------------ -- # save token data for later use user_token_id = len(self.tokens) self.tokens[user_token_id] = { 'serial': token_serial, 'server_public_key': server_public_key, 'partition': partition, 'callback_url': callback_url, 'pin': pin } # ------------------------------------------------------------------ -- return user_token_id
def create_user_token_by_pairing_url(self, pairing_url, pin='1234'): """ parses the pairing url and saves the extracted data in the fake token database of this test class. :param pairing_url: the pairing url received from the server :param pin: the pin of the token (default: '1234') :returns: user_token_id of newly created token """ # extract metadata and the public key data_encoded = pairing_url[len(self.uri + '://pair/'):] data = decode_base64_urlsafe(data_encoded) version, token_type, flags = struct.unpack('<bbI', data[0:6]) partition = struct.unpack('<I', data[6:10])[0] server_public_key = data[10:10 + 32] # validate protocol versions and type id self.assertEqual(token_type, TYPE_PUSHTOKEN) self.assertEqual(version, PAIRING_URL_VERSION) # ------------------------------------------------------------------ -- # extract custom data that may or may not be present # (depending on flags) custom_data = data[10 + 32:] token_serial = None if flags & FLAG_PAIR_SERIAL: token_serial, __, custom_data = custom_data.partition(b'\x00') callback_url = None if flags & FLAG_PAIR_CBURL: callback_url, __, custom_data = custom_data.partition(b'\x00') else: raise NotImplementedError( 'Callback URL is mandatory for PushToken') # ------------------------------------------------------------------ -- # save token data for later use user_token_id = len(self.tokens) self.tokens[user_token_id] = {'serial': token_serial, 'server_public_key': server_public_key, 'partition': partition, 'callback_url': callback_url, 'pin': pin} # ------------------------------------------------------------------ -- return user_token_id
def checkOtp(self, passwd, counter, window, options=None): """ checks if the supplied challenge response is correct. :param passwd: The challenge response :param options: A dictionary of parameters passed by the upper layer (used for transaction_id in this context) :param counter: legacy API (unused) :param window: legacy API (unused) :raises TokenStateError: If token state is not 'active' or 'pairing_challenge_sent' :returns: -1 for failure, 1 for success """ valid_states = ['pairing_challenge_sent', 'active'] self.ensure_state_is_in(valid_states) # ------------------------------------------------------------------- -- filtered_challenges = [] serial = self.getSerial() if options is None: options = {} max_fail = int(getFromConfig('PushMaxChallenges', '3')) # ------------------------------------------------------------------- -- if 'transactionid' in options: # --------------------------------------------------------------- -- # fetch all challenges that match the transaction id or serial transaction_id = options.get('transaction_id') challenges = Challenges.lookup_challenges(serial, transaction_id) # --------------------------------------------------------------- -- # filter into filtered_challenges for challenge in challenges: (received_tan, tan_is_valid) = challenge.getTanStatus() fail_counter = challenge.getTanCount() # if we iterate over matching challenges (that is: challenges # with the correct transaction id) we either find a fresh # challenge, that didn't receive a TAN at all (first case) # or a challenge, that already received a number of wrong # TANs but still has tries left (second case). if not received_tan: filtered_challenges.append(challenge) elif not tan_is_valid and fail_counter <= max_fail: filtered_challenges.append(challenge) # ------------------------------------------------------------------- -- if not filtered_challenges: return -1 for challenge in filtered_challenges: # client verifies the challenge by signing the challenge # plaintext. we retrieve the original plaintext (saved # in createChallenge) and check for a match b64_dsa_public_key = self.getFromTokenInfo('user_dsa_public_key') user_dsa_public_key = b64decode(b64_dsa_public_key) data = challenge.getData() sig_base = data['sig_base'] passwd_as_bytes = decode_base64_urlsafe(passwd) sig_base_as_bytes = b64decode(sig_base) try: verify_sig(passwd_as_bytes, sig_base_as_bytes, user_dsa_public_key) return 1 except ValueError: # signature mismatch return -1 return -1
def decrypt_and_verify_challenge(self, challenge_url, action): """ Decrypts the data packed in the challenge url, verifies its content, returns the parsed data as a dictionary, calculates and returns the signature. The calling method must then send the signature back to the server. (The reason for this control flow is that the challenge data must be checked in different scenarios, e.g. when we have a pairing the data must be checked by the method that simulates the pairing) :param challenge_url: the challenge url as sent by the server :param action: a string identifier for the verification action (at the moment 'ACCEPT' or 'DENY') :returns: (challenge, signature) challenge has the keys * content_type - one of the three values CONTENT_TYPE_SIGNREQ, CONTENT_TYPE_PAIRING or CONTENT_TYPE_LOGIN) (all defined in this module) * transaction_id - used to identify the challenge on the server * callback_url (optional) - the url to which the challenge response should be set * user_token_id - used to identify the token in the user database for which this challenge was created depending on the content type additional keys are present * for CONTENT_TYPE_PAIRING: serial * for CONTENT_TYPE_SIGNREQ: message * for CONTENT_TYPE_LOGIN: login, host signature is the generated user signature used to respond to the challenge """ challenge_data_encoded = challenge_url[len(self.uri + '://chal/'):] challenge_data = decode_base64_urlsafe(challenge_data_encoded) # ------------------------------------------------------------------ -- # parse and verify header information in the # encrypted challenge data header = challenge_data[0:5] version, user_token_id = struct.unpack('<bI', header) self.assertEqual(version, CHALLENGE_URL_VERSION) # ------------------------------------------------------------------ -- # get token from client token database token = self.tokens[user_token_id] server_public_key = token['server_public_key'] # ------------------------------------------------------------------ -- # prepare decryption by seperating R from # ciphertext and server signature R = challenge_data[5:5 + 32] ciphertext = challenge_data[5 + 32:-64] server_signature = challenge_data[-64:] # check signature data = challenge_data[0:-64] crypto_sign_verify_detached(server_signature, data, server_public_key) # ------------------------------------------------------------------ -- # key derivation secret_key_dh = dsa_to_dh_secret(self.secret_key) ss = calc_dh(secret_key_dh, R) U = SHA256.new(ss).digest() sk = U[0:16] nonce = U[16:32] # ------------------------------------------------------------------ -- # decrypt and verify challenge nonce_as_int = int_from_bytes(nonce, byteorder='big') ctr = Counter.new(128, initial_value=nonce_as_int) cipher = AES.new(sk, AES.MODE_CTR, counter=ctr) plaintext = cipher.decrypt(ciphertext) # ------------------------------------------------------------------ -- # parse/check plaintext header # 1 - for content type # 8 - for transaction id # 8 - for time stamp offset = 1 + 8 + 8 pt_header = plaintext[0:offset] (content_type, transaction_id, _time_stamp) = struct.unpack('<bQQ', pt_header) transaction_id = u64_to_transaction_id(transaction_id) # ------------------------------------------------------------------ -- # prepare the parsed challenge data challenge = {} challenge['content_type'] = content_type # ------------------------------------------------------------------ -- # retrieve plaintext data depending on content_type if content_type == CONTENT_TYPE_PAIRING: serial, callback_url, __ = plaintext[offset:].split('\x00') challenge['serial'] = serial elif content_type == CONTENT_TYPE_SIGNREQ: message, callback_url, __ = plaintext[offset:].split('\x00') challenge['message'] = message elif content_type == CONTENT_TYPE_LOGIN: login, host, callback_url, __ = plaintext[offset:].split('\x00') challenge['login'] = login challenge['host'] = host # ------------------------------------------------------------------ -- # prepare the parsed challenge data challenge['callback_url'] = callback_url challenge['transaction_id'] = transaction_id challenge['user_token_id'] = user_token_id # calculate signature sig_base = (struct.pack('<b', CHALLENGE_URL_VERSION) + b'%s\0' % action + server_signature + plaintext) sig = crypto_sign_detached(sig_base, self.secret_key) encoded_sig = encode_base64_urlsafe(sig) return challenge, encoded_sig
def checkOtp(self, passwd, counter, window, options=None): valid_states = ['pairing_challenge_sent', 'pairing_complete'] self.ensure_state_is_in(valid_states) # ------------------------------------------------------------------- -- filtered_challenges = [] serial = self.getSerial() if options is None: options = {} max_fail = int(getFromConfig('QRMaxChallenges', '3')) # ------------------------------------------------------------------- -- # TODO: from which point is checkOtp called, when there # is no challenge response in the request? if 'transactionid' in options: # --------------------------------------------------------------- -- # fetch all challenges that match the transaction id or serial transaction_id = options.get('transaction_id') challenges = Challenges.lookup_challenges(serial, transaction_id) # --------------------------------------------------------------- -- # filter into filtered_challenges for challenge in challenges: (received_tan, tan_is_valid) = challenge.getTanStatus() fail_counter = challenge.getTanCount() # if we iterate over matching challenges (that is: challenges # with the correct transaction id) we either find a fresh # challenge, that didn't receive a TAN at all (first case) # or a challenge, that already received a number of wrong # TANs but still has tries left (second case). if not received_tan: filtered_challenges.append(challenge) elif not tan_is_valid and fail_counter <= max_fail: filtered_challenges.append(challenge) # --------------------------------------------------------------- -- if not filtered_challenges: return -1 for challenge in filtered_challenges: data = challenge.getData() correct_passwd = data['user_sig'] # compare values with python's native constant # time comparison if compare_digest(correct_passwd, passwd): return 1 else: # maybe we got a tan instead of a signature correct_passwd_as_bytes = decode_base64_urlsafe(correct_passwd) tan_length = 8 # TODO fetch from policy correct_tan = extract_tan(correct_passwd_as_bytes, tan_length) # TODO PYLONS-HACK pylons silently converts integers in # incoming json to unicode. since extract_tan returns # an integer, we have to convert it here correct_tan = unicode(correct_tan) if compare_digest(correct_tan, passwd): return 1 return -1 # TODO: ??? semantics of this ret val?
def checkOtp(self, passwd, counter, window, options=None): """ checks if the supplied challenge response is correct. :param passwd: The challenge response :param options: A dictionary of parameters passed by the upper layer (used for transaction_id in this context) :param counter: legacy API (unused) :param window: legacy API (unused) :raises TokenStateError: If token state is not 'active' or 'pairing_challenge_sent' :returns: -1 for failure, 1 for success """ valid_states = ['pairing_challenge_sent', 'active'] self.ensure_state_is_in(valid_states) # ------------------------------------------------------------------ -- # new pushtoken protocoll supports the keyword based accept or deny. # the old 'passwd' argument is not supported anymore try: signature_accept = passwd.get('accept', None) signature_reject = passwd.get('reject', None) except AttributeError: # will be raised with a get() on a str object raise Exception('Pushtoken version %r requires "accept" or' ' "reject" as parameter' % CHALLENGE_URL_VERSION) if signature_accept is not None and signature_reject is not None: raise Exception('Pushtoken version %r requires "accept" or' ' "reject" as parameter' % CHALLENGE_URL_VERSION) # ------------------------------------------------------------------ -- filtered_challenges = [] serial = self.getSerial() if options is None: options = {} max_fail = int(getFromConfig('PushMaxChallenges', '3')) # ------------------------------------------------------------------ -- if 'transactionid' in options: # -------------------------------------------------------------- -- # fetch all challenges that match the transaction id or serial transaction_id = options.get('transaction_id') challenges = Challenges.lookup_challenges(serial, transaction_id) # -------------------------------------------------------------- -- # filter into filtered_challenges for challenge in challenges: (received_tan, tan_is_valid) = challenge.getTanStatus() fail_counter = challenge.getTanCount() # if we iterate over matching challenges (that is: challenges # with the correct transaction id) we either find a fresh # challenge, that didn't receive a TAN at all (first case) # or a challenge, that already received a number of wrong # TANs but still has tries left (second case). if not received_tan: filtered_challenges.append(challenge) elif not tan_is_valid and fail_counter <= max_fail: filtered_challenges.append(challenge) # ------------------------------------------------------------------ -- if not filtered_challenges: return -1 for challenge in filtered_challenges: # client verifies the challenge by signing the challenge # plaintext. we retrieve the original plaintext (saved # in createChallenge) and check for a match data = challenge.getData() data_to_verify = b64decode(data['sig_base']) b64_dsa_public_key = self.getFromTokenInfo('user_dsa_public_key') user_dsa_public_key = b64decode(b64_dsa_public_key) # -------------------------------------------------------------- -- # handle the accept case if signature_accept is not None: accept_signature_as_bytes = decode_base64_urlsafe( signature_accept) accept_data_to_verify_as_bytes = ( struct.pack('<b', CHALLENGE_URL_VERSION) + b'ACCEPT\0' + data_to_verify) try: verify_sig(accept_signature_as_bytes, accept_data_to_verify_as_bytes, user_dsa_public_key) challenge.add_session_info({'accept': True}) return 1 except ValueError: # accept signature mismatch challenge.add_session_info({'accept': False}) log.error("accept signature mismatch!") return -1 # -------------------------------------------------------------- -- # handle the reject case elif signature_reject is not None: reject_signature_as_bytes = decode_base64_urlsafe( signature_reject) reject_data_to_verify_as_bytes = ( struct.pack('<b', CHALLENGE_URL_VERSION) + b'DENY\0' + data_to_verify) try: verify_sig(reject_signature_as_bytes, reject_data_to_verify_as_bytes, user_dsa_public_key) challenge.add_session_info({'reject': True}) return 1 except ValueError: # reject signature mismatch challenge.add_session_info({'reject': False}) log.error("reject signature mismatch!") return -1 return -1
def checkOtp(self, passwd, counter, window, options=None): """ checks if the supplied challenge response is correct. :param passwd: The challenge response :param options: A dictionary of parameters passed by the upper layer (used for transaction_id in this context) :param counter: legacy API (unused) :param window: legacy API (unused) :raises TokenStateError: If token state is not 'active' or 'pairing_challenge_sent' :returns: -1 for failure, 1 for success """ valid_states = ['pairing_challenge_sent', 'active'] self.ensure_state_is_in(valid_states) # ------------------------------------------------------------------ -- # new pushtoken protocoll supports the keyword based accept or deny. # the old 'passwd' argument is not supported anymore try: signature_accept = passwd.get('accept', None) signature_reject = passwd.get('reject', None) except AttributeError: # will be raised with a get() on a str object raise Exception('Pushtoken version %r requires "accept" or' ' "reject" as parameter' % CHALLENGE_URL_VERSION) if signature_accept is not None and signature_reject is not None: raise Exception('Pushtoken version %r requires "accept" or' ' "reject" as parameter' % CHALLENGE_URL_VERSION) # ------------------------------------------------------------------ -- filtered_challenges = [] serial = self.getSerial() if options is None: options = {} max_fail = int(getFromConfig('PushMaxChallenges', '3')) # ------------------------------------------------------------------ -- if 'transactionid' in options: # -------------------------------------------------------------- -- # fetch all challenges that match the transaction id or serial transaction_id = options.get('transactionid') challenges = Challenges.lookup_challenges(serial=serial, transid=transaction_id, filter_open=True) # -------------------------------------------------------------- -- # filter into filtered_challenges for challenge in challenges: (received_tan, tan_is_valid) = challenge.getTanStatus() fail_counter = challenge.getTanCount() # if we iterate over matching challenges (that is: challenges # with the correct transaction id) we either find a fresh # challenge, that didn't receive a TAN at all (first case) # or a challenge, that already received a number of wrong # TANs but still has tries left (second case). if not received_tan: filtered_challenges.append(challenge) elif not tan_is_valid and fail_counter <= max_fail: filtered_challenges.append(challenge) # ------------------------------------------------------------------ -- if not filtered_challenges: return -1 if len(filtered_challenges) > 1: log.error('multiple challenges for one transaction and for one' ' token found!') return -1 # for the serial and the transaction id there could always be only # at max one challenge matching. This is even true for sub transactions challenge = filtered_challenges[0] # client verifies the challenge by signing the challenge # plaintext. we retrieve the original plaintext (saved # in createChallenge) and check for a match data = challenge.getData() data_to_verify = b64decode(data['sig_base']) b64_dsa_public_key = self.getFromTokenInfo('user_dsa_public_key') user_dsa_public_key = b64decode(b64_dsa_public_key) # -------------------------------------------------------------- -- # handle the accept case if signature_accept is not None: accept_signature_as_bytes = decode_base64_urlsafe( signature_accept) accept_data_to_verify_as_bytes = ( struct.pack('<b', CHALLENGE_URL_VERSION) + b'ACCEPT\0' + data_to_verify) try: verify_sig(accept_signature_as_bytes, accept_data_to_verify_as_bytes, user_dsa_public_key) challenge.add_session_info({'accept': True}) return 1 except ValueError: challenge.add_session_info({'accept': False}) log.error("accept signature mismatch!") return -1 # -------------------------------------------------------------- -- # handle the reject case elif signature_reject is not None: reject_signature_as_bytes = decode_base64_urlsafe( signature_reject) reject_data_to_verify_as_bytes = ( struct.pack('<b', CHALLENGE_URL_VERSION) + b'DENY\0' + data_to_verify) try: verify_sig(reject_signature_as_bytes, reject_data_to_verify_as_bytes, user_dsa_public_key) challenge.add_session_info({'reject': True}) return 1 except ValueError: challenge.add_session_info({'reject': False}) log.error("reject signature mismatch!") return -1 return -1
def decrypt_and_verify_challenge(self, challenge_url, action): """ Decrypts the data packed in the challenge url, verifies its content, returns the parsed data as a dictionary, calculates and returns the signature. The calling method must then send the signature back to the server. (The reason for this control flow is that the challenge data must be checked in different scenarios, e.g. when we have a pairing the data must be checked by the method that simulates the pairing) :param challenge_url: the challenge url as sent by the server :param action: a string identifier for the verification action (at the moment 'ACCEPT' or 'DENY') :returns: (challenge, signature) challenge has the keys * content_type - one of the three values CONTENT_TYPE_SIGNREQ, CONTENT_TYPE_PAIRING or CONTENT_TYPE_LOGIN) (all defined in this module) * transaction_id - used to identify the challenge on the server * callback_url (optional) - the url to which the challenge response should be set * user_token_id - used to identify the token in the user database for which this challenge was created depending on the content type additional keys are present * for CONTENT_TYPE_PAIRING: serial * for CONTENT_TYPE_SIGNREQ: message * for CONTENT_TYPE_LOGIN: login, host signature is the generated user signature used to respond to the challenge """ challenge_data_encoded = challenge_url[len(self.uri + '://chal/'):] challenge_data = decode_base64_urlsafe(challenge_data_encoded) # ------------------------------------------------------------------ -- # parse and verify header information in the # encrypted challenge data header = challenge_data[0:5] version, user_token_id = struct.unpack('<bI', header) self.assertEqual(version, CHALLENGE_URL_VERSION) # ------------------------------------------------------------------ -- # get token from client token database token = self.tokens[user_token_id] server_public_key = token['server_public_key'] # ------------------------------------------------------------------ -- # prepare decryption by seperating R from # ciphertext and server signature R = challenge_data[5:5 + 32] ciphertext = challenge_data[5 + 32:-64] server_signature = challenge_data[-64:] # check signature data = challenge_data[0:-64] crypto_sign_verify_detached(server_signature, data, server_public_key) # ------------------------------------------------------------------ -- # key derivation secret_key_dh = dsa_to_dh_secret(self.secret_key) ss = calc_dh(secret_key_dh, R) U = SHA256.new(ss).digest() sk = U[0:16] nonce = U[16:32] # ------------------------------------------------------------------ -- # decrypt and verify challenge nonce_as_int = int_from_bytes(nonce, byteorder='big') ctr = Counter.new(128, initial_value=nonce_as_int) cipher = AES.new(sk, AES.MODE_CTR, counter=ctr) plaintext = cipher.decrypt(ciphertext) # ------------------------------------------------------------------ -- # parse/check plaintext header # 1 - for content type # 8 - for transaction id # 8 - for time stamp offset = 1 + 8 + 8 pt_header = plaintext[0:offset] (content_type, transaction_id, _time_stamp) = struct.unpack('<bQQ', pt_header) transaction_id = u64_to_transaction_id(transaction_id) # ------------------------------------------------------------------ -- # prepare the parsed challenge data challenge = {} challenge['content_type'] = content_type # ------------------------------------------------------------------ -- # retrieve plaintext data depending on content_type if content_type == CONTENT_TYPE_PAIRING: serial, callback_url, __ = plaintext[offset:].split('\x00') challenge['serial'] = serial elif content_type == CONTENT_TYPE_SIGNREQ: message, callback_url, __ = plaintext[offset:].split('\x00') challenge['message'] = message elif content_type == CONTENT_TYPE_LOGIN: login, host, callback_url, __ = plaintext[offset:].split('\x00') challenge['login'] = login challenge['host'] = host # ------------------------------------------------------------------ -- # prepare the parsed challenge data challenge['callback_url'] = callback_url challenge['transaction_id'] = transaction_id challenge['user_token_id'] = user_token_id # calculate signature sig_base = ( struct.pack('<b', CHALLENGE_URL_VERSION) + b'%s\0' % action + server_signature + plaintext) sig = crypto_sign_detached(sig_base, self.secret_key) encoded_sig = encode_base64_urlsafe(sig) return challenge, encoded_sig
def decrypt_pairing_response(enc_pairing_response): """ Parses and decrypts a pairing response into a named tuple PairingResponse consisting of * user_public_key - the user's public key * user_token_id - an id for the client to uniquely identify the token. this id is necessary, because the client could communicate with more than one linotp, so serials could overlap. * serial - the serial identifying the token in linotp * user_login - the user login name It is possible that either user_login or serial is None. Both being None is a valid response according to this function but will be considered an error in the calling method. The following parameters are needed: :param enc_pairing_response: The urlsafe-base64 encoded string received from the client The following exceptions can be raised: :raises ParameterError: If the pairing response has an invalid format :raises ValueError: If the pairing response has a different version than this implementation (currently hardcoded) :raises ValueError: If the pairing response indicates a different token type than QRToken (also hardcoded) :raises ValueError: If the pairing response field "partition" is not identical to the field "token_type" ("partition" is currently used for the token type id. It is reserved for multiple key usage in a future implementation.) :raises ValueError: If the MAC of the response didn't match :return: Parsed/decrypted PairingReponse """ data = decode_base64_urlsafe(enc_pairing_response) # ---------------------------------------------------------------------- -- # ------------------------------------------- -- # fields | version | partition | R | ciphertext | MAC | # ------------------------------------------- -- # size | 1 | 4 | 32 | ? | 16 | # ------------------------------------------- -- if len(data) < 1 + 4 + 32 + 16: raise ParameterError('Malformed pairing response') # ---------------------------------------------------------------------- -- # parse header header = data[0:5] version, partition = struct.unpack('<bI', header) if version != PAIR_RESPONSE_VERSION: raise ValueError('Unexpected pair-response version, ' 'expected: %d, got: %d' % (PAIR_RESPONSE_VERSION, version)) # ---------------------------------------------------------------------- -- R = data[5:32 + 5] ciphertext = data[32 + 5:-16] mac = data[-16:] # ---------------------------------------------------------------------- -- # calculate the shared secret # - -- secret_key = get_dh_secret_key(partition) ss = calc_dh(secret_key, R) # derive encryption key and nonce from the shared secret # zero the values from memory when they are not longer needed U = SHA256.new(ss).digest() zerome(ss) encryption_key = U[0:16] nonce = U[16:32] zerome(U) # decrypt response cipher = AES.new(encryption_key, AES.MODE_EAX, nonce) cipher.update(header) plaintext = cipher.decrypt_and_verify(ciphertext, mac) zerome(encryption_key) # ---------------------------------------------------------------------- -- # check format boundaries for type peaking # (token type specific length boundaries are checked # in the appropriate functions) plaintext_min_length = 1 if len(data) < plaintext_min_length: raise ParameterError('Malformed pairing response') # ---------------------------------------------------------------------- -- # get token type and parse decrypted response # -------------------- -- # fields | token type | ... | # -------------------- -- # size | 1 | ? | # -------------------- -- token_type = struct.unpack('<b', plaintext[0])[0] if token_type not in SUPPORTED_TOKEN_TYPES: raise ValueError('unsupported token type %d, supported types ' 'are %s' % (token_type, SUPPORTED_TOKEN_TYPES)) # ---------------------------------------------------------------------- -- # delegate the data parsing of the plaintext # to the appropriate function and return the result data_parser = get_pairing_data_parser(token_type) pairing_data = data_parser(plaintext) zerome(plaintext) # get the appropriate high level type try: token_type_as_str = INV_TOKEN_TYPES[token_type] except KeyError: raise ProgrammingError( 'token_type %d is in SUPPORTED_TOKEN_TYPES', 'however an appropriate mapping entry in ' 'TOKEN_TYPES is missing' % token_type) return PairingResponse(token_type_as_str, pairing_data)
def decrypt_pairing_response(enc_pairing_response): """ Parses and decrypts a pairing response into a named tuple PairingResponse consisting of * user_public_key - the user's public key * user_token_id - an id for the client to uniquely identify the token. this id is necessary, because the client could communicate with more than one linotp, so serials could overlap. * serial - the serial identifying the token in linotp * user_login - the user login name It is possible that either user_login or serial is None. Both being None is a valid response according to this function but will be considered an error in the calling method. The following parameters are needed: :param enc_pairing_response: The urlsafe-base64 encoded string received from the client The following exceptions can be raised: :raises ParameterError: If the pairing response has an invalid format :raises ValueError: If the pairing response has a different version than this implementation (currently hardcoded) :raises ValueError: If the pairing response indicates a different token type than QRToken (also hardcoded) :raises ValueError: If the pairing response field "partition" is not identical to the field "token_type" ("partition" is currently used for the token type id. It is reserved for multiple key usage in a future implementation.) :raises ValueError: If the MAC of the response didn't match :return: Parsed/decrypted PairingReponse """ data = decode_base64_urlsafe(enc_pairing_response) # ---------------------------------------------------------------------- -- # ------------------------------------------- -- # fields | version | partition | R | ciphertext | MAC | # ------------------------------------------- -- # size | 1 | 4 | 32 | ? | 16 | # ------------------------------------------- -- if len(data) < 1 + 4 + 32 + 16: raise ParameterError('Malformed pairing response') # ---------------------------------------------------------------------- -- # parse header header = data[0:5] version, partition = struct.unpack('<bI', header) if version != PAIR_RESPONSE_VERSION: raise ValueError('Unexpected pair-response version, ' 'expected: %d, got: %d' % (PAIR_RESPONSE_VERSION, version)) # ---------------------------------------------------------------------- -- R = data[5:32+5] ciphertext = data[32+5:-16] mac = data[-16:] # ---------------------------------------------------------------------- -- # calculate the shared secret # - -- secret_key = get_dh_secret_key(partition) ss = calc_dh(secret_key, R) # derive encryption key and nonce from the shared secret # zero the values from memory when they are not longer needed U = SHA256.new(ss).digest() zerome(ss) encryption_key = U[0:16] nonce = U[16:32] zerome(U) # decrypt response cipher = AES.new(encryption_key, AES.MODE_EAX, nonce) cipher.update(header) plaintext = cipher.decrypt_and_verify(ciphertext, mac) zerome(encryption_key) # ---------------------------------------------------------------------- -- # check format boundaries for type peaking # (token type specific length boundaries are checked # in the appropriate functions) plaintext_min_length = 1 if len(data) < plaintext_min_length: raise ParameterError('Malformed pairing response') # ---------------------------------------------------------------------- -- # get token type and parse decrypted response # -------------------- -- # fields | token type | ... | # -------------------- -- # size | 1 | ? | # -------------------- -- token_type = struct.unpack('<b', plaintext[0])[0] if token_type not in SUPPORTED_TOKEN_TYPES: raise ValueError('unsupported token type %d, supported types ' 'are %s' % (token_type, SUPPORTED_TOKEN_TYPES)) # ---------------------------------------------------------------------- -- # delegate the data parsing of the plaintext # to the appropriate function and return the result data_parser = get_pairing_data_parser(token_type) pairing_data = data_parser(plaintext) zerome(plaintext) # get the appropriate high level type try: token_type_as_str = INV_TOKEN_TYPES[token_type] except KeyError: raise ProgrammingError('token_type %d is in SUPPORTED_TOKEN_TYPES', 'however an appropriate mapping entry in ' 'TOKEN_TYPES is missing' % token_type) return PairingResponse(token_type_as_str, pairing_data)