def encrypt(self, data, iv=None, id=0): ''' security module methods: encrypt :param data: the to be encrypted data :type data:byte string :param iv: initialisation vector (salt) :type iv: random bytes :param id: slot of the key array :type id: int - slotid :return: encrypted data :rtype: byte string ''' if self.is_ready is False: raise Exception('setup of security module incomplete') key = self.getSecret(id) # convert input to ascii, so we can securely append bin data input = binascii.b2a_hex(data) input += '\x01\x02' padding = (16 - len(input) % 16) % 16 input += padding * "\0" aes = AES.new(key, AES.MODE_CBC, iv) res = aes.encrypt(input) if self.crypted is False: zerome(key) del key return res
def _setupKey_(self): if not hasattr(self, 'bkey'): self.bkey = None if self.bkey is None: akey = decrypt(self.val, self.iv, hsm=self.hsm) self.bkey = binascii.unhexlify(akey) zerome(akey) del akey
def _clearKey_(self, preserve=False): if preserve is False: if not hasattr(self, 'bkey'): self.bkey = None if self.bkey is not None: zerome(self.bkey) del self.bkey
def checkOtp(self, anOtpVal, window=10, options=None): ''' check a provided otp value :param anOtpVal: the to be tested otp value :param window: the +/- window around the test time :param options: generic container for additional values \ here only used for seltest: setting the initTime :return: -1 for fail else the identified counter/time ''' res = -1 window = window * 2 initTime = 0 if options is not None and type(options) == dict: initTime = int(options.get('initTime', 0)) if (initTime == 0): otime = int(time.time() / 10) else: otime = int(initTime) if self.secretObject is None: key = self.key pin = self.pin else: key = self.secretObject.getKey() pin = self.secPin.getKey() for i in range(otime - window, otime + window): otp = unicode(self.calcOtp(i, key, pin)) if unicode(anOtpVal) == otp: res = i break if self.secretObject is not None: zerome(key) zerome(pin) del key del pin ## prevent access twice with last motp if res <= self.oldtime: log.warning("[checkOtp] otpvalue %s checked once before (%r<=%r)" % (anOtpVal, res, self.oldtime)) res = -1 if res == -1: msg = 'checking motp failed' else: msg = 'checking motp sucess' return res
def checkOtp(self, anOtpVal): res = -1 key = self.secretObject.getKey() if key == anOtpVal: res = 0 zerome(key) del key return res
def verfiyMessageSignature(self, message, hex_mac, method=sha256, slot_id=DEFAULT_KEY): """ verify the hex mac is same for the message - the comparison is done in a constant time comparison :param message: the original message :param hex_mac: the to compared mac in hex :param method: the hash method - we use by default sha256 :param slot_id: which key should be used :return: boolean """ sign_key = None result = True try: sign_key = self.getSecret(slot_id) hmac_obj = hmac.new(sign_key, message.encode('utf-8'), method) sign_mac = hmac.new(sign_key, message.encode('utf-8'), method).hexdigest() res = 0 # as we compare on hex, we have to multiply by 2 digest_size = hmac_obj.digest_size * 2 for x, y in zip(hex_mac, sign_mac): res |= ord(x) ^ ord(y) if len(sign_mac) != digest_size: result = False if res: result = False except ValueError as err: log.exception("Signature check: Mac Comparison failed! %r", err) except Exception as exx: log.exception("Signature check: Unknown exception happened %r", exx) finally: if sign_key: zerome(sign_key) del sign_key return result
def checkOtp(self, anOtpVal, window=10, options=None): """ check a provided otp value :param anOtpVal: the to be tested otp value :param window: the +/- window around the test time :param options: generic container for additional values :return: -1 for fail else the identified counter/time """ res = -1 window = window * 2 otime = int(time.time() // 10) if self.secretObject is None: key = self.key pin = self.pin else: key = self.secretObject.getKey() pin = self.secPin.getKey() for i in range(otime - window, otime + window): otp = str(self.calcOtp(i, key, pin)) if anOtpVal == otp: res = i break if self.secretObject is not None: zerome(key) zerome(pin) del key del pin # prevent access twice with last motp if res <= self.oldtime: log.warning( "[checkOtp] otpvalue %s checked once before (%r<=%r)", anOtpVal, res, self.oldtime, ) res = -1 if res == -1: msg = "checking motp failed" else: msg = "checking motp sucess" return res
def calc_dh(self, partition, data): """ encapsulate the Diffi Helmann calculation as the server secret key is a sensitive data, we try to encapsulate it and care for the cleanup :param partition: the id of the server secret key :param : """ server_secret_key = get_dh_secret_key(partition) hmac_secret = calc_dh(server_secret_key, data) zerome(server_secret_key) return hmac_secret
def decrypt(self, input, iv=None, id=0): ''' security module methods: decrypt :param data: the to be decrypted data :type data:byte string :param iv: initialisation vector (salt) :type iv: random bytes :param id: slot of the key array :type id: int :return: decrypted data :rtype: byte string ''' if self.is_ready is False: raise Exception('setup of security module incomplete') key = self.getSecret(id) aes = AES.new(key, AES.MODE_CBC, iv) output = aes.decrypt(input) eof = len(output) - 1 if eof == -1: raise Exception('invalid encoded secret!') while output[eof] == '\0': eof -= 1 if output[eof - 1:eof + 1] != '\x01\x02': raise Exception('invalid encoded secret!') # convert output from ascii, back to bin data data = binascii.a2b_hex(output[:eof - 1]) if self.crypted is False: zerome(key) del key return data
def decrypt(self, value: bytes, iv: bytes, id: int = 0) -> bytes: ''' security module methods: decrypt :param data: the to be decrypted data :type data:byte string :param iv: initialisation vector (salt) :type iv: random bytes :param id: slot of the key array :type id: int :return: decrypted data :rtype: byte string ''' if self.is_ready is False: raise Exception('setup of security module incomplete') key = self.getSecret(id) aes = AES.new(key, AES.MODE_CBC, iv) output = aes.decrypt(value) eof = len(output) - 1 if eof == -1: raise Exception('invalid encoded secret!') while output[eof] == 0x00: eof -= 1 if not (output[eof - 1] == 0x01 and output[eof] == 0x02): raise Exception('invalid encoded secret!') data = output[:eof - 1] if self.crypted is False: zerome(key) del key return binascii.a2b_hex(data)
def encrypt(self, data: bytes, iv: bytes, id: int = 0) -> bytes: ''' security module methods: encrypt This module performs the following operations on the input data, which is a string: * convert data to hexidcimal representation * add termination string * pad with null to a multiple of 16 bytes * aes encrypt :param data: the to be encrypted data :type data:byte string :param iv: initialisation vector (salt) :type iv: random bytes :param id: slot of the key array :type id: int - slotid :return: encrypted data :rtype: byte string ''' if self.is_ready is False: raise Exception('setup of security module incomplete') key = self.getSecret(id) input_data = binascii.b2a_hex(data) input_data += b'\x01\x02' padding = (16 - len(input_data) % 16) % 16 input_data += padding * b'\0' aes = AES.new(key, AES.MODE_CBC, iv) res = aes.encrypt(input_data) if self.crypted is False: zerome(key) del key return res
def signMessage(self, message, method=sha256, slot_id=DEFAULT_KEY): """ create the hex mac for the message - :param message: the original message :param method: the hash method - we use by default sha256 :param slot_id: which key should be used :return: hex mac """ sign_key = None try: sign_key = self.getSecret(slot_id) hex_mac = hmac.new(sign_key, message, method).hexdigest() finally: if sign_key: zerome(sign_key) del sign_key return hex_mac
def main(): class DummySecLock(): def release(self): return def acquire_write(self): return # hook for local provider test sep = SecurityProvider(secLock=DummySecLock()) sep.load_config({}) sep.createHSMPool('default') sep.setupModule('default', {'passwd': 'test123'}) # runtime catch an hsm for session hsm = sep.getSecurityModule() passwo = 'password' encpass = hsm.encryptPassword(passwo) passw = hsm.decryptPassword(encpass) zerome(passw) hsm2 = sep.getSecurityModule(sessionId='session2') passwo = 'password' encpass = hsm2.encryptPassword(passwo) passw = hsm2.decryptPassword(encpass) zerome(passw) # session shutdown sep.dropSecurityModule(sessionId='session2') sep.dropSecurityModule() return True
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 cleanup(self): zerome(self.key) del self.key
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 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