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 = urandom(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 url = 'lseqr://push/' + encode_base64_urlsafe(raw_data) return url, (signature + plaintext)
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 = urandom(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 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_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