Source code for privacyidea.lib.applications.offline

#  2015-04-08 Cornelius Kölbel <cornelius.koelbel@netknights.it>
#             Add options ROUNDS to avoid timeouts during OTP hash calculation
#  2015-04-03 Cornelius Kölbel <cornelius.koelbel@netknights.it>
#             Use pbkdf2 for OTP hashing
#  2015-03-13 Cornelius Kölbel, <cornelius@privacyidea.org>
#             initial writeup
#
# License:  AGPLv3
#  contact:  http://www.privacyidea.org
#
# This code is free software; you can redistribute it and/or
# modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
# License as published by the Free Software Foundation; either
# version 3 of the License, or any later version.
#
# This code is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU AFFERO GENERAL PUBLIC LICENSE for more details.
#
# You should have received a copy of the GNU Affero General Public
# License along with this program.  If not, see <http://www.gnu.org/licenses/>.
#

import logging

from passlib.hash import pbkdf2_sha512

from privacyidea.lib.applications import MachineApplicationBase
from privacyidea.lib.config import get_prepend_pin
from privacyidea.lib.crypto import geturandom
from privacyidea.lib.error import ValidateError, ParameterError
from privacyidea.lib.fido2.token_info import FIDO2TokenInfo
from privacyidea.lib.policy import TYPE
from privacyidea.lib.token import get_one_token
from privacyidea.lib.utils import get_computer_name_from_user_agent

log = logging.getLogger(__name__)
ROUNDS = 6549
REFILLTOKEN_LENGTH = 40


[docs] class MachineApplication(MachineApplicationBase): """ This is the application for Offline authentication with PAM or the privacyIDEA credential provider. The machine application returns a list of salted OTP hashes to be used with offline authentication. The token then is disabled, so that it can not be used for online authentication anymore, to avoid reusing a fished OTP value. The server stores the information, which OTP values were issued. options: * user: a username. * count: is the number of OTP values returned """ application_name = "offline"
[docs] @staticmethod def generate_new_refilltoken(token, user_agent=None): """ Generate new refill token and store it in the tokeninfo of the token. :param token: token in question :param user_agent: name of the machine, taken from the user-agent header :return: a string """ log.debug(f"Generating refilltoken for {token.get_serial()} with user agent {user_agent}") # If the token is a FIDO2 token, we need to store the machine name with the refill token, because # the token can be on multiple machines, which need to be managed separately # TODO improve where the information about offline capabilities is stored if token.type.lower() in ["webauthn", "passkey"]: computer_name = get_computer_name_from_user_agent(user_agent) if not computer_name: log.warning(f"No computer name found in user agent {user_agent}. Unable to generate refill token.") raise ParameterError("Unable to generate refill token without computer name.") key = "refilltoken_" + computer_name else: key = "refilltoken" new_refilltoken = geturandom(REFILLTOKEN_LENGTH, hex=True) token.add_tokeninfo(key, new_refilltoken) return new_refilltoken
[docs] @staticmethod def get_offline_otps(token, otppin, amount, rounds=ROUNDS): """ Retrieve the desired number of passwords (= PIN + OTP), hash them and return them in a dictionary. Increase the token counter. :param token: token in question :param otppin: The OTP PIN to prepend in the passwords. The PIN is not validated! :param amount: Number of OTP values (non-negative!) :param rounds: Number of PBKDF2 rounds :return: dictionary """ if amount < 0: raise ParameterError(f"Invalid refill amount: {amount!r}") (res, err, otp_dict) = token.get_multi_otp(count=amount, counter_index=True) otps = otp_dict.get("otp") prepend_pin = get_prepend_pin() for key, otp in otps.items(): # Return the hash of OTP PIN and OTP values otp_with_pin = otppin + otp if prepend_pin else otp + otppin otps[key] = pbkdf2_sha512.using(rounds=rounds, salt_size=10).hash(otp_with_pin) # We do not disable the token, so if all offline OTP values # are used, the token can be used to authenticate online again. # token_obj.enable(False) # increase the counter by the consumed values and # also store it in tokeninfo. token.inc_otp_counter(increment=amount) return otps
[docs] @staticmethod def get_refill(token, password, options=None): """ Returns new authentication OTPs to refill the client To do so we also verify the password, which may consist of PIN + OTP. :param token: Token object :param password: PIN + OTP :param options: dict that might contain "count" and "rounds" :return: a dictionary of auth items """ options = options or {} otps = {} if token.type.lower() == "hotp": count = int(options.get("count", 100)) rounds = int(options.get("rounds", ROUNDS)) _r, otppin, otpval = token.split_pin_pass(password) if not _r: raise ParameterError("Could not split password") current_token_counter = token.token.count first_offline_counter = current_token_counter - count if first_offline_counter < 0: first_offline_counter = 0 # find the value in the offline OTP values! This resets the token.count! matching_count = token.check_otp(otpval, first_offline_counter, count) token.set_otp_count(current_token_counter) # Raise an exception *after* we reset the token counter if matching_count < 0: raise ValidateError("You provided a wrong OTP value.") # We have to add 1 here: Assume *first_offline_counter* is the counter value of the first offline OTP # we sent to the client. Assume the client then requests a refill with that exact OTP value. # Then, we need to respond with a refill of one OTP value, as the client has consumed one OTP value. counter_diff = matching_count - first_offline_counter + 1 otps = MachineApplication.get_offline_otps(token, otppin, counter_diff, rounds) token.add_tokeninfo(key="offline_counter", value=count) elif token.type.lower() in ["webauthn", "passkey"]: pass return otps
[docs] @staticmethod def get_authentication_item(token_type, serial, challenge=None, options=None, filter_param=None, user_agent=None): """ :param token_type: the type of the token. At the moment we support HOTP and WebAuthn/Passkey tokens. :param serial: the serial number of the token. :param challenge: this can contain the password (otp pin + otp value) so that we can put the OTP PIN into the hashed response. :type challenge: basestring :param options: options :param filter_param: parameters :param user_agent: The user agent of the request :return auth_item: A list of hashed OTP values or pubKey, rpId and credentialId for WebAuthn token """ ret = {} options = options or {} password = challenge token_type = token_type.lower() # TODO improve where the information about offline capabilities is stored if token_type in ["hotp", "webauthn", "passkey"]: ret["response"] = {} token = get_one_token(serial=serial) user = token.user if user: user_info = user.get_specific_info(["username", FIDO2TokenInfo.USER_ID]) if "username" in user_info: ret["user"] = ret["username"] = user_info.get("username") if token_type in ["webauthn", "passkey"] and FIDO2TokenInfo.USER_ID in user_info: ret["response"].update({"userId": user_info[FIDO2TokenInfo.USER_ID]}) ret["refilltoken"] = MachineApplication.generate_new_refilltoken(token, user_agent) # Gather offline data depending on the token type if token_type == "webauthn": # return the pubKey, rpId and the credentialId (contained in the otpkey) to allow the machine to # verify the WebAuthn assertions signed with the token ret["response"].update({ "pubKey": token.get_tokeninfo("pubKey"), "credentialId": token.decrypt_otpkey(), "rpId": token.get_tokeninfo("relying_party_id") }) elif token_type == "hotp": if password: success, otppin, _ = token.split_pin_pass(password) if not success: raise ParameterError("Could not split password") else: otppin = "" ret["response"] = MachineApplication.get_offline_otps(token, otppin, int(options.get("count", 100)), int(options.get("rounds", ROUNDS))) elif token_type == "passkey": ret["response"].update({ "pubKey": token.get_tokeninfo("public_key"), "rpId": token.get_tokeninfo("relying_party_id"), "credentialId": token.token.get_otpkey().getKey().decode("utf-8") }) else: log.info(f"Token {serial} of type {token_type} is not supported by OFFLINE application module") return ret
[docs] @staticmethod def get_options(): """ Returns a dictionary with a list of required and optional options """ options = { "hotp": { "count": {"type": TYPE.STRING}, "rounds": {"type": TYPE.STRING} }, "webauthn": {}, "passkey": {} } return options