def create_pairing_response_by_serial(self, user_token_id, gda=None): """ Creates a base64-encoded pairing response that identifies the token by its serial :param user_token_id: the token id (primary key for the user token db) :returns base64 encoded pairing response """ if not gda: gda = self.gda token_serial = self.tokens[user_token_id]['serial'] server_public_key = self.tokens[user_token_id]['server_public_key'] partition = self.tokens[user_token_id]['partition'] # ------------------------------------------------------------------ -- # assemble header and plaintext header = struct.pack('<bI', PAIR_RESPONSE_VERSION, partition) pairing_response = b'' pairing_response += struct.pack('<bI', TYPE_PUSHTOKEN, user_token_id) pairing_response += self.public_key pairing_response += token_serial.encode('utf8') + b'\x00\x00' pairing_response += gda.encode('utf-8') + b'\x00' signature = crypto_sign_detached(pairing_response, self.secret_key) pairing_response += signature # ------------------------------------------------------------------ -- # create public diffie hellman component # (used to decrypt and verify the reponse) r = os.urandom(32) R = calc_dh_base(r) # ------------------------------------------------------------------ -- # derive encryption key and nonce server_public_key_dh = dsa_to_dh_public(server_public_key) ss = calc_dh(r, server_public_key_dh) U = SHA256.new(ss).digest() encryption_key = U[0:16] nonce = U[16:32] # ------------------------------------------------------------------ -- # encrypt in EAX mode cipher = AES.new(encryption_key, AES.MODE_EAX, nonce) cipher.update(header) ciphertext, tag = cipher.encrypt_and_digest(pairing_response) return encode_base64_urlsafe(header + R + ciphertext + tag)
def send_pairing_response(pairing_url): user_token_id = parse_pairing_url(pairing_url) serial = token_db[user_token_id]['serial'] # ---------------------------------------------------------------------- server_public_key = token_db[user_token_id]['server_public_key'] pairing_response = b'' pairing_response += struct.pack('<bbI', RESPONSE_VERSION, TYPE_QRTOKEN, user_token_id) pairing_response += public_key pairing_response += serial.encode('utf8') + b'\x00\x00' # ---------------------------------------------------------------------- # create public diffie hellman component # (used to decrypt and verify the reponse) r = os.urandom(32) R = calc_dh_base(r) # ---------------------------------------------------------------------- # derive encryption key and nonce ss = calc_dh(r, server_public_key) U = SHA256.new(ss).digest() encryption_key = U[0:16] nonce = U[16:32] # ---------------------------------------------------------------------- # encrypt in EAX mode cipher = AES.new(encryption_key, AES.MODE_EAX, nonce) ciphertext, tag = cipher.encrypt_and_digest(pairing_response) pairing_response = encode_base64_urlsafe(R + ciphertext + tag) params = { 'session': SESSION, 'pairing_response': pairing_response, 'type': 'qr' } r = requests.request('get', 'http://localhost:5001/admin/init', params=params, cookies=cookies) print((r.status_code)) print('----------------------------------') print((r.content))
def create_pairing_response(public_key, token_info, token_id=1): """ Creates a base64-encoded pairing response that identifies the token by its serial :param user_token_id: the token id (primary key for the user token db) :returns base64 encoded pairing response """ token_serial = token_info['serial'] server_public_key = token_info['server_public_key'] partition = token_info['partition'] header = struct.pack('<bI', PAIR_RESPONSE_VERSION, partition) pairing_response = b'' pairing_response += struct.pack('<bI', TYPE_QRTOKEN, token_id) pairing_response += public_key pairing_response += token_serial.encode('utf8') + b'\x00\x00' # ------------------------------------------------------------------- -- # create public diffie hellman component # (used to decrypt and verify the reponse) r = os.urandom(32) R = calc_dh_base(r) # ------------------------------------------------------------------- -- # derive encryption key and nonce ss = calc_dh(r, server_public_key) U = SHA256.new(ss).digest() encryption_key = U[0:16] nonce = U[16:32] # ------------------------------------------------------------------- -- # encrypt in EAX mode cipher = AES.new(encryption_key, AES.MODE_EAX, nonce) cipher.update(header) ciphertext, tag = cipher.encrypt_and_digest(pairing_response) return encode_base64_urlsafe(header + R + ciphertext + tag)
def create_challenge_url(self, transaction_id, content_type, callback_url='', message=None, login=None, host=None): """ creates a challenge url (looking like lseqr://push/<base64string>), returns the url and the unencrypted challenge data :param transaction_id: The transaction id generated by LinOTP :param content_type: One of the types CONTENT_TYPE_SIGNREQ, CONTENT_TYPE_PAIRING, CONTENT_TYPE_LOGIN :param callback_url: callback url (optional), default is empty string :param message: the transaction message, that should be signed by the client. Only for content type CONTENT_TYPE_SIGNREQ :param login: the login name of the user. Only for content type CONTENT_TYPE_LOGIN :param host: hostname of the user. Only for content type CONTENT_TYPE_LOGIN :returns: tuple (challenge_url, sig_base), with challenge_url being the push url and sig_base the message, that is used for the client signature """ serial = self.getSerial() # ------------------------------------------------------------------- -- # sanity/format checks if content_type not in [ CONTENT_TYPE_SIGNREQ, CONTENT_TYPE_PAIRING, CONTENT_TYPE_LOGIN ]: raise InvalidFunctionParameter( 'content_type', 'content_type must ' 'be CONTENT_TYPE_SIGNREQ, ' 'CONTENT_TYPE_PAIRING or ' 'CONTENT_TYPE_LOGIN.') # ------------------------------------------------------------------- -- # after the lseqr://push/ prefix the following data is encoded # in urlsafe base64: # --------------------------------------------------- # fields | version | user token id | R | ciphertext | sign | # --------------------------------------------------- # | header | body | # --------------------------------------------------- # size | 1 | 4 | 32 | ? | 64 | # --------------------------------------------------- # # create header user_token_id = self.getFromTokenInfo('user_token_id') data_header = struct.pack('<bI', CHALLENGE_URL_VERSION, user_token_id) # ------------------------------------------------------------------- -- # create body r = secrets.token_bytes(32) R = calc_dh_base(r) b64_user_dsa_public_key = self.getFromTokenInfo('user_dsa_public_key') user_dsa_public_key = b64decode(b64_user_dsa_public_key) user_dh_public_key = dsa_to_dh_public(user_dsa_public_key) ss = calc_dh(r, user_dh_public_key) U = SHA256.new(ss).digest() zerome(ss) sk = U[0:16] nonce = U[16:32] zerome(U) # ------------------------------------------------------------------- -- # create plaintext section # ------------------------------------------------------------------- -- # generate plaintext header # ------------------------------------------------ # fields | content_type | transaction_id | timestamp | .. # ------------------------------------------------ # size | 1 | 8 | 8 | ? # ------------------------------------------------- transaction_id = transaction_id_to_u64(transaction_id) plaintext = struct.pack('<bQQ', content_type, transaction_id, int(time.time())) # ------------------------------------------------------------------- -- utf8_callback_url = callback_url.encode('utf8') # enforce max url length as specified in protocol if len(utf8_callback_url) > 511: raise InvalidFunctionParameter( 'callback_url', 'max string ' 'length (encoded as utf8) is ' '511') # ------------------------------------------------------------------- -- # create data package depending on content type # ------------------------------------------------------------------- -- if content_type == CONTENT_TYPE_PAIRING: # ----------------------------------------- # fields | header | serial | NUL | callback | NUL | # ----------------------------------------- # size | 9 | ? | 1 | ? | 1 | # ----------------------------------------- utf8_serial = serial.encode('utf8') if len(utf8_serial) > 63: raise ValueError('serial (encoded as utf8) can only be 63 ' 'characters long') plaintext += utf8_serial + b'\00' + utf8_callback_url + b'\00' # ------------------------------------------------------------------- -- if content_type == CONTENT_TYPE_SIGNREQ: if message is None: raise InvalidFunctionParameter( 'message', 'message must be ' 'supplied for content type ' 'SIGNREQ') # ------------------------------------------ # fields | header | message | NUL | callback | NUL | # ------------------------------------------ # size | 9 | ? | 1 | ? | 1 | # ------------------------------------------ utf8_message = message.encode('utf8') # enforce max sizes specified by protocol if len(utf8_message) > 511: raise InvalidFunctionParameter( 'message', 'max string ' 'length (encoded as utf8) is ' '511') plaintext += utf8_message + b'\00' + utf8_callback_url + b'\00' # ------------------------------------------------------------------- -- if content_type == CONTENT_TYPE_LOGIN: if login is None: raise InvalidFunctionParameter( 'login', 'login must be ' 'supplied for content type ' 'LOGIN') if host is None: raise InvalidFunctionParameter( 'host', 'host must be ' 'supplied for content type ' 'LOGIN') # ----------------------------------------------------- # fields | header | login | NUL | host | NUL | callback | NUL | # ----------------------------------------------------- # size | 9 | ? | 1 | ? | 1 | ? | 1 | # ----------------------------------------------------- utf8_login = login.encode('utf8') utf8_host = host.encode('utf8') # enforce max sizes specified by protocol if len(utf8_login) > 127: raise InvalidFunctionParameter( 'login', 'max string ' 'length (encoded as utf8) is ' '127') if len(utf8_host) > 255: raise InvalidFunctionParameter( 'host', 'max string ' 'length (encoded as utf8) is ' '255') plaintext += utf8_login + b'\00' plaintext += utf8_host + b'\00' plaintext += utf8_callback_url + b'\00' # ------------------------------------------------------------------- -- # encrypt inner layer 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) ciphertext = cipher.encrypt(plaintext) unsigned_raw_data = data_header + R + ciphertext # ------------------------------------------------------------------- -- # create signature partition = self.getFromTokenInfo('partition') secret_key = get_secret_key(partition) signature = crypto_sign_detached(unsigned_raw_data, secret_key) raw_data = unsigned_raw_data + signature protocol_id = config.get('mobile_app_protocol_id', 'lseqr') url = protocol_id + '://push/' + encode_base64_urlsafe(raw_data) return url, (signature + plaintext)
def generate_pairing_url(token_type, partition=None, serial=None, callback_url=None, callback_sms_number=None, otp_pin_length=None, hash_algorithm=None, use_cert=False): """ Generates a pairing url that should be sent to the client. Mandatory parameters: :param: token_type The token type for which this url is generated as a string (currently supported is only 'qr') Optional parameters: :param partition: A partition id that should be used during pairing. Partitions identitify a subspace of tokens, that share a common key pair. This currently defaults to the enum id of the token type when set to None and is reserved for future use. :param serial: When a token for the client was already enrolled (e.g. manually in the manage interface) its serial has to be sent to the client. When serial is not specified the client will receive a so-called 'anonymous pairing url' with no token data inside it. The token will then be created after the server received a pairing response from the client. :param callback_url: A callback URL that should be used by the client to sent back the pairing reponse. Please note, that this url will be cached by the client and used in the challenge step, if the challenge doesn't provide a custom url :param callback_sms_number: A sms number that can be used by the client to send back the pairing response. Typically this is used as a fallback for offline pairing. As with the callback url please note, that the number will be cached by the client. If you want a different number in the challenge step you have to send it inside the challenge as specified in the challenge protocol :param otp_pin_length: The number of digits the otp has to consist of. :param hash_algorithm: A string value that signifies the hash algorithm used in calculating the hmac. Currently the values 'sha1', 'sha256', 'sha512' are supported. If the parameter is left out the default depends on the token type. qrtoken uses sha256 as default, while hotp/totp uses sha1. :param use_cert: A boolean, if a server certificate should be used in the pairing url The function can raise several exceptions: :raises InvalidFunctionParameter: If the string given in token_type doesn't match a supported token type :raises InvalidFunctionParameter: If the string given in hash_algorithm doesn't match a supported hash algorithm :raises InvalidFunctionParameter: If public key has a different size than 32 bytes :raises InvalidFunctionParameter: If otp_pin_length value is not between 1 and 127 :return: Pairing URL string """ # ---------------------------------------------------------------------- -- # check the token type try: TOKEN_TYPE = TOKEN_TYPES[token_type] except KeyError: allowed_types = ', '.join(TOKEN_TYPES.keys()) raise InvalidFunctionParameter( 'token_type', 'Unsupported token type %s. Supported ' 'types for pairing are: %s' % (token_type, allowed_types)) # ---------------------------------------------------------------------- -- # initialize the flag bitmap flags = 0 if not use_cert: flags |= FLAG_PAIR_PK if serial is not None: flags |= FLAG_PAIR_SERIAL if callback_url is not None: flags |= FLAG_PAIR_CBURL if callback_sms_number is not None: flags |= FLAG_PAIR_CBSMS if hash_algorithm is not None: flags |= FLAG_PAIR_HMAC if otp_pin_length is not None: flags |= FLAG_PAIR_DIGITS # ---------------------------------------------------------------------- -- # ---------------------------- -- # fields | version | type | flags | ... | # ---------------------------- -- # size | 1 | 1 | 4 | ? | # ---------------------------- -- data = struct.pack('<bbI', PAIR_URL_VERSION, TOKEN_TYPE, flags) # ---------------------------------------------------------------------- -- # --------------------- -- # fields | ... | partition | ... | # --------------------- -- # size | 6 | 4 | ? | # --------------------- -- data += struct.pack('<I', partition) # ---------------------------------------------------------------------- -- # ------------------------------ -- # fields | .... | server public key | ... | # ------------------------------ -- # size | 10 | 32 | ? | # ------------------------------ -- if flags & FLAG_PAIR_PK: server_public_key = get_public_key(partition) if len(server_public_key) != 32: raise InvalidFunctionParameter('server_public_key', 'Public key must be 32 bytes long') data += server_public_key # ---------------------------------------------------------------------- -- # Depending on flags additional data may be sent. If serial was provided # serial will be sent back. If callback url or callback sms was provided # the corresponding data will be added, too # ------------------------------------------------------- -- # fields | .... | serial | NUL | cb url | NUL | cb sms | NUL | ... | # ------------------------------------------------------- -- # size | 42 | ? | 1 | ? | 1 | ? | 1 | ? | # ------------------------------------------------------- -- if flags & FLAG_PAIR_SERIAL: data += serial.encode('utf8') + b'\x00' if flags & FLAG_PAIR_CBURL: data += callback_url.encode('utf8') + b'\x00' if flags & FLAG_PAIR_CBSMS: data += callback_sms_number.encode('utf8') + b'\x00' # ---------------------------------------------------------------------- -- # Other optional values: allowed pin length of otp (number of digits) # and custom hash algorithm # ------------------------------------- -- # fields | ... | otp pin length | hash_algorithm | # ------------------------------------- -- # size | ? | 1 | 1 | # ------------------------------------- -- if flags & FLAG_PAIR_DIGITS: if not (6 <= otp_pin_length <= 12): raise InvalidFunctionParameter( 'otp_pin_length', 'Pin length must ' 'be in the range 6..12') data += struct.pack('<b', otp_pin_length) if flags & FLAG_PAIR_HMAC: try: HASH_ALGO = hash_algorithms[hash_algorithm] except KeyError: allowed_values = ", ".join(hash_algorithms.keys()) raise InvalidFunctionParameter( 'hash_algorithm', 'Unsupported hash algorithm %s, ' 'allowed values are %s' % (hash_algorithm, allowed_values)) data += struct.pack('<b', HASH_ALGO) # ---------------------------------------------------------------------- -- # TODO missing token details for other protocols (hotp, hmac, etc) # * counter (u64le) # * tstart (u64le) # * tstep (u32le) if not (flags & FLAG_PAIR_PK): secret_key = get_secret_key(partition) server_sig = crypto_sign_detached(data, secret_key) data += server_sig protocol_id = config.get('mobile_app_protocol_id', 'lseqr') return protocol_id + '://pair/' + encode_base64_urlsafe(data)
def decrypt_and_verify_challenge(challenge_url, token_info, secret_key): """ Decrypts the data packed in the challenge url, verifies its content, returns the parsed data as a dictionary, calculates and returns the signature and TAN. The calling method must then send the signature/TAN 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 :returns: (challenge, signature, tan) challenge has the keys * message - the signed message sent from the server * content_type - one of the three values QRTOKEN_CT_PAIR, QRTOKEN_CT_FREE or QRTOKEN_CT_AUTH (all defined in this module * callback_url (optional) - the url to which the challenge response should be set * callback_sms (optional) - the sms number the challenge can be sent to (typicall used as a fallback) * transaction_id - used to identify the challenge on the server * user_token_id - used to identify the token in the user database for which this challenge was created signature is the generated user signature used to respond to the challenge tan is the TAN-Number used as a substitute if the signature cant' be sent be the server (is generated from signature) """ challenge_data_encoded = challenge_url[len(QR_Token_Validation.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) assert version == QRTOKEN_VERSION # ------------------------------------------------------------------- -- # get token from client token database # ------------------------------------------------------------------- -- # prepare decryption by seperating R from # ciphertext and tag R = challenge_data[5:5 + 32] ciphertext = challenge_data[5 + 32:-16] tag = challenge_data[-16:] # ------------------------------------------------------------------- -- # key derivation ss = calc_dh(secret_key, R) U1 = SHA256.new(ss).digest() U2 = SHA256.new(U1).digest() skA = U1[0:16] skB = U2[0:16] nonce = U2[16:32] # ------------------------------------------------------------------- -- # decrypt and verify challenge cipher = AES.new(skA, AES.MODE_EAX, nonce) cipher.update(header) plaintext = cipher.decrypt_and_verify(ciphertext, tag) # ------------------------------------------------------------------- -- # parse/check plaintext header pt_header = plaintext[0:10] content_type, flags, transaction_id = struct.unpack('<bbQ', pt_header) transaction_id = QR_Token_Validation.u64_to_transaction_id(transaction_id) # make sure a flag for the server signature is # present, if the content type is 'pairing' if content_type == QRTOKEN_CT_PAIR: assert flags & FLAG_QR_SRVSIG # ------------------------------------------------------------------- -- # retrieve plaintext data depending on flags if flags & FLAG_QR_SRVSIG: # plaintext has a server signature as a header # extract it and check if it is correct server_signature = plaintext[10:10 + 32] data = plaintext[10 + 32:] # calculate secret server_public_key = token_info['server_public_key'] secret = calc_dh(secret_key, server_public_key) # check hmac message = nonce + pt_header + data signed = HMAC.new(secret, msg=message, digestmod=SHA256).digest() assert server_signature == signed else: # no server signature found - just remove # the plaintext header data = plaintext[10:] # we have to define an empty server signature in # here because we need it later to create the # client signature server_signature = b'' # ------------------------------------------------------------------- -- # extract message and (optional) callback # parameters from data message, _, suffix = data.partition(b'\x00') callback_url = token_info.get('callback_url') if flags & FLAG_QR_HAVE_URL: callback_url, _, suffix = suffix.partition(b'\x00') callback_sms = token_info.get('callback_sms') if flags & FLAG_QR_HAVE_SMS: callback_sms, _, suffix = suffix.partition(b'\x00') # ------------------------------------------------------------------- -- # prepare the parsed challenge data challenge = {} challenge['message'] = message.decode('utf-8') challenge['content_type'] = content_type challenge['transaction_id'] = transaction_id challenge['user_token_id'] = user_token_id if callback_url: challenge['callback_url'] = callback_url.decode('utf-8') if callback_sms: challenge['callback_sms'] = callback_sms.decode('utf-8') # calculate signature and tan message_bin = nonce + pt_header + server_signature + data sig_hmac = HMAC.new(skB, message_bin, digestmod=SHA256) sig = sig_hmac.digest() tan = extract_tan(sig, QR_Token_Validation.tan_length) encoded_sig = encode_base64_urlsafe(sig) return challenge, encoded_sig, tan
def create_challenge_url(self, transaction_id, content_type, message, callback_url, callback_sms_number, use_compression=False, reset_url=False): """ creates a challenge url (looking like lseqr://chal/<base64string>) from a challenge dictionary as provided by Challanges.create_challenge in lib.challenge the version identifier of the challenge url is currently hardcoded to 1. """ serial = self.getSerial() if content_type is None: content_type = CONTENT_TYPE_FREE # ------------------------------------------------------------------- -- # sanity/format checks if content_type not in [ CONTENT_TYPE_PAIRING, CONTENT_TYPE_AUTH, CONTENT_TYPE_FREE ]: raise InvalidFunctionParameter( 'content_type', 'content_type must ' 'be CONTENT_TYPE_PAIRING, ' 'CONTENT_TYPE_AUTH or ' 'CONTENT_TYPE_FREE.') if content_type == CONTENT_TYPE_PAIRING and \ message != serial: raise InvalidFunctionParameter( 'message', 'message must be equal ' 'to serial in pairing mode') if content_type == CONTENT_TYPE_AUTH: if '@' not in message: raise InvalidFunctionParameter( 'message', 'For content type ' 'auth, message must have format ' '<login>@<server>') # ------------------------------------------------------------------- -- # after the lseqr://chal/ prefix the following data is encoded # in urlsafe base64: # --------------------------------------------------- # fields | version | user token id | R | ciphertext | MAC | # --------------------------------------------------- # | header | | EAX enc data | # --------------------------------------------------- # size | 1 | 4 | 32 | ? | 16 | # --------------------------------------------------- # r = secrets.token_bytes(32) R = calc_dh_base(r) user_token_id = self.getFromTokenInfo('user_token_id') data_header = struct.pack('<bI', QRTOKEN_VERSION, user_token_id) # the user public key is saved as base64 in # the token info since the byte format is # incompatible with the json backend. b64_user_public_key = self.getFromTokenInfo('user_public_key') user_public_key = b64decode(b64_user_public_key) ss = calc_dh(r, user_public_key) U1 = sha256(ss).digest() U2 = sha256(U1).digest() zerome(ss) skA = U1[0:16] skB = U2[0:16] nonce = U2[16:32] zerome(U1) zerome(U2) # ------------------------------------------------------------------- -- # create plaintext section # ------------------------------------------------------------------- -- # create the bitmap for flags flags = 0 if use_compression: flags |= CHALLENGE_HAS_COMPRESSION # FIXME: sizecheck for message, callback url, sms number # wiki specs are utf-8 byte length (without \0) if callback_url is not None: flags |= CHALLENGE_HAS_URL if callback_sms_number is not None: flags |= CHALLENGE_HAS_SMS_NUMBER if (content_type == CONTENT_TYPE_PAIRING): flags |= CHALLENGE_HAS_SIGNATURE if reset_url: flags |= CHALLENGE_SHOULD_RESET_URL flags |= CHALLENGE_HAS_SIGNATURE # ------------------------------------------------------------------- -- # generate plaintext header # ---------------------------------------------- # fields | content_type | flags | transaction_id | ... | # ---------------------------------------------- # size | 1 | 1 | 8 | ? | # ---------------------------------------------- transaction_id = transaction_id_to_u64(transaction_id) pt_header = struct.pack('<bbQ', content_type, flags, transaction_id) plaintext = pt_header # ------------------------------------------------------------------- -- # create data package # ------------------------------- # fields | header | message | NUL | ... | # ------------------------------- # size | 10 | ? | 1 | ? | # ------------------------------- data_package = b'' utf8_message = message.encode('utf8') # enforce max sizes specified by protocol if content_type == CONTENT_TYPE_FREE and len(utf8_message) > 511: raise ParameterError('message (encoded as utf8) can only be 511 ' 'characters long') elif content_type == CONTENT_TYPE_PAIRING and len(utf8_message) > 63: raise InvalidFunctionParameter( 'message', 'max string length ' '(encoded as utf8) is 511 for ' 'content type PAIRING') elif content_type == CONTENT_TYPE_AUTH and len(utf8_message) > 511: raise InvalidFunctionParameter( 'message', 'max string length ' '(encoded as utf8) is 511 for ' 'content type AUTH') data_package += utf8_message + b'\x00' # ------------------------------------------------------------------- -- # depending on function parameters add callback url # and/or callback sms number # ----------------------------------------------------- # fields | ... | callback url | NUL | callback sms | NUL | ... | # ----------------------------------------------------- # size | ? | ? | 1 | ? | 1 | ? | # ----------------------------------------------------- # ------------------------------------------------------------------- -- if callback_url is not None: utf8_callback_url = callback_url.encode('utf8') # enforce max url length as specified in protocol if len(utf8_callback_url) > 511: raise InvalidFunctionParameter( 'callback_url', 'max string ' 'length (encoded as utf8) is ' '511') data_package += utf8_callback_url + b'\x00' # ------------------------------------------------------------------- -- if callback_sms_number is not None: utf8_callback_sms_number = callback_sms_number.encode('utf8') if len(utf8_callback_sms_number) > 31: raise InvalidFunctionParameter( 'callback_sms_number', 'max string length (encoded ' 'as utf8) is 31') data_package += utf8_callback_sms_number + b'\x00' # ------------------------------------------------------------------- -- if use_compression: maybe_compressed_data_package = zlib.compress(data_package, 9) else: maybe_compressed_data_package = data_package # ------------------------------------------------------------------- -- # when content type is pairing the protocol specifies that # the server must send a hmac based signature with the # response sig = b'' sec_obj = self._get_secret_object() if flags & CHALLENGE_HAS_SIGNATURE: hmac_message = nonce + pt_header + maybe_compressed_data_package sig = sec_obj.hmac_digest(data_input=hmac_message, bkey=self.server_hmac_secret, hash_algo=sha256) plaintext += sig # ------------------------------------------------------------------- -- plaintext += maybe_compressed_data_package # ------------------------------------------------------------------- -- user_message = nonce + pt_header + sig + data_package user_sig = sec_obj.hmac_digest(data_input=user_message, bkey=skB, hash_algo=sha256) # the user sig will be given as urlsafe base64 in the # challenge response. for this reasons (and because we # need to serialize it into json) we convert the user_sig # into this format. user_sig = encode_base64_urlsafe(user_sig) # ------------------------------------------------------------------- -- cipher = AES.new(skA, AES.MODE_EAX, nonce) cipher.update(data_header) ciphertext, tag = cipher.encrypt_and_digest(plaintext) raw_data = data_header + R + ciphertext + tag protocol_id = config.get('mobile_app_protocol_id', 'lseqr') url = protocol_id + '://chal/' + encode_base64_urlsafe(raw_data) return url, user_sig
def create_pairing_response(public_key: bytes, secret_key: bytes, token_info: Dict, gda: str = 'DEADBEEF') -> str: """Creates a base64-encoded pairing response. :param public_key: the public key in bytes :param secret_key: the secret key in bytes :param token_info: the token_info dict :param user_token_id: the token id :param gda: the mobile device gda :returns base64 encoded pairing response """ token_serial = token_info['serial'] token_id = token_info.get('token_id', 1) server_public_key = token_info['server_public_key'] partition = token_info['partition'] # ------------------------------------------------------------------ -- # assemble header and plaintext header = struct.pack('<bI', PAIR_RESPONSE_VERSION, partition) pairing_response = b'' pairing_response += struct.pack('<bI', TYPE_PUSHTOKEN, token_id) pairing_response += public_key pairing_response += token_serial.encode('utf8') + b'\x00\x00' pairing_response += gda.encode('utf-8') + b'\x00' signature = crypto_sign_detached(pairing_response, secret_key) pairing_response += signature # ------------------------------------------------------------------ -- # create public diffie hellman component # (used to decrypt and verify the reponse) r = os.urandom(32) R = calc_dh_base(r) # ------------------------------------------------------------------ -- # derive encryption key and nonce server_public_key_dh = dsa_to_dh_public(server_public_key) ss = calc_dh(r, server_public_key_dh) U = SHA256.new(ss).digest() encryption_key = U[0:16] nonce = U[16:32] # ------------------------------------------------------------------ -- # encrypt in EAX mode cipher = AES.new(encryption_key, AES.MODE_EAX, nonce) cipher.update(header) ciphertext, tag = cipher.encrypt_and_digest(pairing_response) return encode_base64_urlsafe(header + R + ciphertext + tag)
def decrypt_and_verify_challenge(challenge_url: str, token_info: Dict, secret_key: bytes, action: str) -> Tuple[Dict, str]: """Decrypts the data packed in the challenge url, verifies the 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(Push_Token_Validation. uri_schema + '://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) assert version == CHALLENGE_URL_VERSION # ------------------------------------------------------------------ -- # get token from client token database server_public_key = token_info['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(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 = Push_Token_Validation.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(b'\x00') challenge['serial'] = serial.decode() elif content_type == CONTENT_TYPE_SIGNREQ: message, callback_url, __ = plaintext[offset:].split(b'\x00') challenge['message'] = message.decode() elif content_type == CONTENT_TYPE_LOGIN: login, host, callback_url, __ = plaintext[offset:].split(b'\x00') challenge['login'] = login.decode() challenge['host'] = host.decode() # ------------------------------------------------------------------ -- # prepare the parsed challenge data challenge['callback_url'] = callback_url.decode() 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.encode('utf-8') + server_signature + plaintext) sig = crypto_sign_detached(sig_base, secret_key) encoded_sig = encode_base64_urlsafe(sig) return challenge, encoded_sig
def parse_challenge_url(challenge_url): """ Parses a challenge url and prints its data """ challenge_data_encoded = challenge_url[len('lseqr://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) if not version == QRTOKEN_VERSION: raise Exception('wrong qrtoken version') # ---------------------------------------------------------------------- # get token from client token database token = token_db[user_token_id] # ---------------------------------------------------------------------- # prepare decryption by seperating R from # ciphertext and tag R = challenge_data[5:5 + 32] ciphertext = challenge_data[5 + 32:-16] tag = challenge_data[-16:] # ---------------------------------------------------------------------- # key derivation ss = calc_dh(secret_key, R) U1 = SHA256.new(ss).digest() U2 = SHA256.new(U1).digest() skA = U1[0:16] skB = U2[0:16] nonce = U2[16:32] # ---------------------------------------------------------------------- # decrypt and verify challenge cipher = AES.new(skA, AES.MODE_EAX, nonce) cipher.update(header) plaintext = cipher.decrypt_and_verify(ciphertext, tag) # ---------------------------------------------------------------------- # parse/check plaintext header pt_header = plaintext[0:10] content_type, flags, transaction_id = struct.unpack('<bbQ', pt_header) transaction_id = u64_to_transaction_id(transaction_id) # make sure a flag for the server signature is # present, if the content type is 'pairing' if content_type == QRTOKEN_CT_PAIR and not flags & FLAG_QR_SRVSIG: raise Exception('Ill formatted callenge url') # ---------------------------------------------------------------------- # retrieve plaintext data depending on flags if flags & FLAG_QR_SRVSIG: # plaintext has a server signature as a header # extract it and check if it is correct server_signature = plaintext[10:10 + 32] data = plaintext[10 + 32:] # calculate secret server_public_key = token['server_public_key'] secret = calc_dh(secret_key, server_public_key) # check hmac message = nonce + pt_header + data signed = HMAC.new(secret, msg=message, digestmod=SHA256).digest() if not server_signature == signed: raise Exception('HMAC signature check failed') else: # no server signature found - just remove # the plaintext header data = plaintext[10:] # we have to define an empty server signature in # here because we need it later to create the # client signature server_signature = b'' # ---------------------------------------------------------------------- # extract message and (optional) callback # parameters from data message, _, suffix = data.partition(b'\x00') callback_url = token['callback_url'] if flags & FLAG_QR_HAVE_URL: callback_url, _, suffix = suffix.partition(b'\x00') callback_sms = token['callback_sms'] if flags & FLAG_QR_HAVE_SMS: callback_sms, _, suffix = suffix.partition(b'\x00') # ---------------------------------------------------------------------- # prepare the parsed challenge data challenge = {} challenge['message'] = message challenge['content_type'] = content_type challenge['callback_url'] = callback_url challenge['callback_sms'] = callback_sms challenge['transaction_id'] = transaction_id challenge['user_token_id'] = user_token_id # calculate signature and tan message = nonce + pt_header + data sig_hmac = HMAC.new(skB, message, digestmod=SHA256) sig = sig_hmac.digest() encoded_sig = encode_base64_urlsafe(sig) print('Data in URL:') for key, value in list(challenge.items()): print(('%s\n %s\n' % (key, value))) return challenge, encoded_sig