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 = urandom(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 = '' 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 url = 'lseqr://chal/' + encode_base64_urlsafe(raw_data) return url, user_sig
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)