diff --git a/.gitmodules b/.gitmodules index 638f4e89531a3909b695c61ba320129537b5d5b3..772abae58989a5f139eb8e3e46f891cdb2b981c6 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "privacyidea/static/contrib/js/webauthn-client"] path = privacyidea/static/contrib/js/webauthn-client - url = https://github.com/privacyidea/webauthn-client + url = https://github.com/privacyidea/webauthn-client.git diff --git a/privacyidea/api/lib/prepolicy.py b/privacyidea/api/lib/prepolicy.py index 8bf60614724b08405fcfc957cdcd84e4201d1a35..40f71062ae2cb92f20ce572a3484428a933dffbe 100644 --- a/privacyidea/api/lib/prepolicy.py +++ b/privacyidea/api/lib/prepolicy.py @@ -2026,67 +2026,6 @@ def webauthntoken_allowed(request, action): :return: :rtype: """ - - ttype = request.all_data.get("type") - - # Get the registration data of the 2nd step of enrolling a WebAuthn token - reg_data = request.all_data.get("regdata") - - # If a WebAuthn token is being enrolled. - if ttype and ttype.lower() == WebAuthnTokenClass.get_class_type() and reg_data: - serial = request.all_data.get("serial") - att_obj = WebAuthnRegistrationResponse.parse_attestation_object(reg_data) - ( - attestation_type, - trust_path, - credential_pub_key, - cred_id, - aaguid - ) = WebAuthnRegistrationResponse.verify_attestation_statement(fmt=att_obj.get('fmt'), - att_stmt=att_obj.get('attStmt'), - auth_data=att_obj.get('authData')) - - attestation_cert = crypto.X509.from_cryptography(trust_path[0]) if trust_path else None - allowed_certs_pols = Match\ - .user(g, - scope=SCOPE.ENROLL, - action=WEBAUTHNACTION.REQ, - user_object=request.User if hasattr(request, 'User') else None) \ - .action_values(unique=False) - - allowed_aaguids_pols = Match \ - .user(g, - scope=SCOPE.ENROLL, - action=WEBAUTHNACTION.AUTHENTICATOR_SELECTION_LIST, - user_object=request.User if hasattr(request, 'User') else None) \ - .action_values(unique=False, - allow_white_space_in_action=True) - allowed_aaguids = set( - aaguid - for allowed_aaguid_pol in allowed_aaguids_pols - for aaguid in allowed_aaguid_pol.split() - ) - - # attestation_cert is of type X509. If you get a warning from your IDE - # here, it is because your IDE mistakenly assumes it to be of type PKey, - # due to a bug in pyOpenSSL 18.0.0. This bug is – however – purely - # cosmetic (a wrongly hinted return type in X509.from_cryptography()), - # and can be safely ignored. - # - # See also: - # https://github.com/pyca/pyopenssl/commit/4121e2555d07bbba501ac237408a0eea1b41f467 - if allowed_certs_pols and not _attestation_certificate_allowed(attestation_cert, allowed_certs_pols): - log.warning( - "The WebAuthn token {0!s} is not allowed to be registered due to policy restriction {1!s}" - .format(serial, WEBAUTHNACTION.REQ)) - raise PolicyError("The WebAuthn token is not allowed to be registered due to a policy restriction.") - - if allowed_aaguids and aaguid not in [allowed_aaguid.replace("-", "") for allowed_aaguid in allowed_aaguids]: - log.warning( - "The WebAuthn token {0!s} is not allowed to be registered due to policy restriction {1!s}" - .format(serial, WEBAUTHNACTION.AUTHENTICATOR_SELECTION_LIST)) - raise PolicyError("The WebAuthn token is not allowed to be registered due to a policy restriction.") - return True diff --git a/privacyidea/config.py b/privacyidea/config.py index 15015c2622a43080baac177f681c3e0b3afbb903..9e388b86849daa06ffc783dd256f4b8d8741c9e2 100644 --- a/privacyidea/config.py +++ b/privacyidea/config.py @@ -1,14 +1,15 @@ # -*- coding: utf-8 -*- -import os import logging +import os import random import string +from sys import platform + log = logging.getLogger(__name__) basedir = os.path.abspath(os.path.dirname(__file__)) basedir = "/".join(basedir.split("/")[:-1]) + "/" - pubtest_key = b"""-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0stF+PVh/qR7I82ywgDW X2rPxKnlBugU33SAbxZjfNz+aKYYM+irKJ0IdTOLtQ85DzZaEhocqO7cg1Qw85Ac @@ -34,7 +35,8 @@ WQIDAQAB def _random_password(size): log.info("SECRET_KEY not set in config. Generating a random key.") passwd = [random.choice(string.ascii_lowercase + \ - string.ascii_uppercase + string.digits) for _x in range(size)] + string.ascii_uppercase + string.digits) for _x in + range(size)] # return shuffled password random.shuffle(passwd) return "".join(passwd) @@ -44,7 +46,8 @@ class Config(object): SECRET_KEY = os.environ.get('SECRET_KEY') # SQL_ALCHEMY_DATABASE_URI = "mysql://privacyidea:XmbSrlqy5d4IS08zjz" # "GG5HTt40Cpf5@localhost/privacyidea" - PI_ENCFILE = os.path.join(basedir, "tests/testdata/enckey") + # PI_ENCFILE = os.path.join(basedir, "tests/testdata/enckey") + PI_ENCFILE = os.path.join("tests/testdata/enckey") PI_HSM = "default" PI_AUDIT_MODULE = "privacyidea.lib.auditmodules.sqlaudit" PI_AUDIT_KEY_PRIVATE = os.path.join(basedir, "tests/testdata/private.pem") @@ -65,7 +68,8 @@ class DevelopmentConfig(Config): DEBUG = True SECRET_KEY = os.environ.get('SECRET_KEY') or 't0p s3cr3t' SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or \ - 'sqlite:///' + os.path.join(basedir, 'data-dev.sqlite') + 'sqlite:///' + os.path.join(basedir, + 'data-dev.sqlite') PI_LOGLEVEL = logging.DEBUG PI_TRANSLATION_WARNING = "[Missing]" @@ -75,8 +79,14 @@ class TestingConfig(Config): # This is used to encrypt the auth token SUPERUSER_REALM = ['adminrealm'] SECRET_KEY = 'secret' - SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or \ - 'sqlite:///' + os.path.join(basedir, 'data-test.sqlite') + SQLITE_DB_PATH = 'sqlite:///' + os.path.join(basedir, 'data-test.sqlite') + if platform == 'win32': + SQLITE_DB_PATH = 'sqlite:///' + os.path.join(basedir, 'Users', + os.getlogin(), + 'Documents', + 'data-test.sqlite') + SQLALCHEMY_DATABASE_URI = os.environ.get( + 'TEST_DATABASE_URL') or SQLITE_DB_PATH # This is used to encrypt the admin passwords PI_PEPPER = "" # This is only for testing encrypted files @@ -124,8 +134,9 @@ class AltUIConfig(TestingConfig): class ProductionConfig(Config): SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ - 'sqlite:///' + os.path.join(basedir, 'data.sqlite') - #SQLALCHEMY_DATABASE_URI = "mysql://pi2:pi2@localhost/pi2" + 'sqlite:///' + os.path.join(basedir, + 'data.sqlite') + # SQLALCHEMY_DATABASE_URI = "mysql://pi2:pi2@localhost/pi2" # This is used to encrypt the auth_token SECRET_KEY = os.environ.get('SECRET_KEY') or _random_password(24) # This is used to encrypt the admin passwords @@ -144,7 +155,7 @@ class HerokuConfig(Config): "wqy_btZE3CPPNWsmkfdmeorxy6@" \ "ec2-54-83-0-61.compute-1." \ "amazonaws.com:5432/d6fjidokoeilp6" - #SQLALCHEMY_DATABASE_URI = "mysql://pi2:pi2@localhost/pi2" + # SQLALCHEMY_DATABASE_URI = "mysql://pi2:pi2@localhost/pi2" # This is used to encrypt the auth_token SECRET_KEY = os.environ.get('SECRET_KEY') or 't0p s3cr3t' # This is used to encrypt the admin passwords diff --git a/privacyidea/lib/config.py b/privacyidea/lib/config.py index 819100544e4ba615c8011c99eaf85228a7e06bf6..10a948078a16e42f99cacbf69518da934a4b4d10 100644 --- a/privacyidea/lib/config.py +++ b/privacyidea/lib/config.py @@ -699,6 +699,7 @@ def get_token_list(): """ module_list = set() + module_list.add("privacyidea.lib.tokens.backupcodetoken") module_list.add("privacyidea.lib.tokens.daplugtoken") module_list.add("privacyidea.lib.tokens.hotptoken") module_list.add("privacyidea.lib.tokens.motptoken") diff --git a/privacyidea/lib/tokens/backupcodetoken.py b/privacyidea/lib/tokens/backupcodetoken.py new file mode 100644 index 0000000000000000000000000000000000000000..7d64dfbf7895e995776c0b65a532ea5fbb90bdec --- /dev/null +++ b/privacyidea/lib/tokens/backupcodetoken.py @@ -0,0 +1,265 @@ +# -*- coding: utf-8 -*- +# +# 2022-09-22 Peter Bolha <485456@mail.muni.cz> +# BUC token with to be randomly used BUC values +# +# (c) 2022 Peter Bolha - 485456@mail.muni.cz +# +# 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/>. +# +""" +This file contains the definition of the backupcode token class +It depends on the DB model, and the lib.tokenclass. +""" + +import ast +import logging +import secrets +import string + +from privacyidea.lib import _ +from privacyidea.lib.decorators import check_token_locked +from privacyidea.lib.log import log_with +from privacyidea.lib.policy import ACTION, GROUP, SCOPE +from privacyidea.lib.tokenclass import TokenClass + +log = logging.getLogger(__name__) + +# number of generated tokens per method call +DEFAULT_COUNT = 10 +# length of each token +DEFAULT_LENGTH = 16 +CHARACTER_POOL = list(string.digits) + list(string.ascii_letters) +SECRET_GENERATOR = secrets.SystemRandom() + + +class BACKUPCODEACTION(object): + BACKUPCODETOKEN_COUNT = "backupcodetoken_count" + BACKUPCODETOKEN_LENGTH = "backupcodetoken_length" + + +class BackupCodeTokenClass(TokenClass): + """ + The BUC token allows printing out valid OTP values. + This sheet of paper can be used to authenticate and strike out the used + OTP values. It works akin to TAN token (OTPs can be used in random order). + The difference is that the number and length of the generated OTPs can be + configured as well as the character pool for code generation. The default + settings: 10 codes, 16 characters long, generated from alphanumeric + charset. (a-zA-Z0-9) + """ + + @log_with(log) + def __init__(self, db_token): + """ + This creates a new Backup Code Token object from a DB token object. + + :param db_token: instance of the orm db object + :type db_token: orm object + """ + TokenClass.__init__(self, db_token) + self.set_type(u"backupcode") + self.hKeyRequired = False + + @staticmethod + def get_class_type(): + """ + Return the token type shortname. + + :return: 'backupcode' + :rtype: string + """ + return "backupcode" + + @staticmethod + def get_class_prefix(): + """ + Return the prefix, that is used as a prefix for the serial. + numbers + :return: BUC + """ + return "BUC" + + @staticmethod + @log_with(log) + def get_class_info(key=None, ret='all'): + """ + Returns a subtree of the token definition + + :param key: subsection identifier + :type key: string + :param ret: default return value, if nothing is found + :type ret: user defined + :return: subsection if key exists or user defined + :rtype: dict or scalar + """ + res = {'type': 'backupcode', + 'title': 'Backup Code Token', + 'description': 'BUC: Backup codes generated by user.', + 'init': {}, + 'config': {}, + 'user': ['enroll'], + 'ui_enroll': ["admin", "user"], + 'policy': { + SCOPE.ENROLL: { + BACKUPCODEACTION.BACKUPCODETOKEN_COUNT: { + "type": "int", + "desc": _("The number of OTP values, which are " + "generated by user.") + }, + BACKUPCODEACTION.BACKUPCODETOKEN_LENGTH: { + "type": "int", + "desc": _("Length of each generated token.") + }, + ACTION.MAXTOKENUSER: { + 'type': 'int', + 'desc': _( + "The user may only have this maximum number " + "of backup code tokens assigned."), + 'group': GROUP.TOKEN + }, + ACTION.MAXACTIVETOKENUSER: { + 'type': 'int', + 'desc': _( + "The user may only have this maximum number " + "of active backup code tokens assigned."), + 'group': GROUP.TOKEN + } + + } + } + } + + if key: + ret = res.get(key, {}) + else: + if ret == 'all': + ret = res + return ret + + def generate_codes(self, length, count): + """ + Generates a list with <count> number of alphanumeric codes with + length of <length> characters picked from CHARACTER_POOL. + + :param length: length of each generated token in characters + :type length: int + :param count: total number of tokens to generate + :type count: int + :return: list of tokens generated with preconfigured length, number and + character set + :rtype: list + """ + return ["".join(SECRET_GENERATOR.choices(CHARACTER_POOL, k=length)) for + _ in range(count)] + + def check_otp(self, otpval, counter=None, window=None, options=None): + """ + Check if the given OTP value is valid for this token. + + :param anOtpVal: the to be verified otpvalue + :type anOtpVal: string + :param counter: the counter state, that should be verified + :type counter: int + :param window: the counter +window, which should be checked + :type window: int + :param options: the dict, which could contain token specific + info + :type options: dict + :return: the counter state or -1 + :rtype: int + """ + token_info = self.get_tokeninfo() + all_user_codes = ast.literal_eval(token_info.get("otps", [])) + used_codes = ast.literal_eval(token_info.get("used_otps", [])) + + if otpval in used_codes: + return -1 + + if otpval in all_user_codes: + used_codes.append(otpval) + token_info["used_otps"] = used_codes + self.set_tokeninfo(token_info) + return 1 + + return -1 + + @check_token_locked + def authenticate(self, passw, user=None, options=None): + """ + High level interface which covers the check_pin and check_otp + This is the method that verifies single shot authentication + like they are done with push button tokens. + + It is automatically called for OTPs longer than 6 characters. + Passw can be either a plain OTP token or a pin concatenated with an + OTP. + Pin can be either prepended in front of the OTP (default) - like + pinOTP or + it can be appended after the OTP - like OTPpin. Appending or prepending + a pin can be configured in the TokenClass config. + + If the authentication succeeds an OTP counter needs to be + increased, + i.e. the OTP value that was used for this authentication is + invalidated! + + :param passw: the password which could be pin+otp value + :type passw: string + :param user: The authenticating user + :type user: User object + :param options: dictionary of additional request parameters + :type options: dict + + :return: returns tuple of + + 1. true or false for the pin match, + 2. the otpcounter (int) and the + 3. reply (dict) that will be added as additional + information in + the JSON response of ``/validate/check``. + + :rtype: tuple(bool, int, dict) + """ + is_pin_correct = False + otp_counter = -1 + reply = None + + (is_split_successfully, pin, otpval) = self.split_pin_pass(passw, + user=user, + options=options) + if is_split_successfully: + is_pin_correct = self.check_pin(pin, user=user, options=options) + if is_pin_correct: + otp_counter = self.check_otp(otpval, options=options) + + return is_pin_correct, otp_counter, reply + + def update(self, param, reset_failcount=True): + """ + Update the token object + + :param param: a dictionary with different params + like keysize,description, genkey, otpkey, pin + :type: param: dict + """ + token_count = int(param.get("backupcodetoken_count", DEFAULT_COUNT)) + token_length = int(param.get("backupcodetoken_length", DEFAULT_LENGTH)) + param["otplen"] = token_length + TokenClass.update(self, param, reset_failcount=reset_failcount) + backup_codes = self.generate_codes(length=token_length, + count=token_count) + self.add_init_details("otps", backup_codes) + self.add_tokeninfo("otps", backup_codes) + self.add_tokeninfo("used_otps", []) diff --git a/privacyidea/lib/tokens/webauthn.py b/privacyidea/lib/tokens/webauthn.py index 72423b1141f55ca96b7ea57708b57963a5a91ffe..0f79c87df0bf906c9c1265878b5720f93029a176 100644 --- a/privacyidea/lib/tokens/webauthn.py +++ b/privacyidea/lib/tokens/webauthn.py @@ -66,11 +66,19 @@ from cryptography import x509 from cryptography.exceptions import InvalidSignature from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import constant_time -from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicNumbers, SECP256R1, ECDSA -from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15, PSS, MGF1 +from cryptography.hazmat.primitives.asymmetric.ec import \ + EllipticCurvePublicNumbers, SECP256R1, ECDSA +from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15, PSS, \ + MGF1 from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers -from cryptography.hazmat.primitives.hashes import SHA256, SHA1 +from cryptography.hazmat.primitives.hashes import SHA256 from cryptography.x509 import load_der_x509_certificate +from webauthn import verify_registration_response, base64url_to_bytes +from webauthn.helpers import bytes_to_base64url +from webauthn.helpers.exceptions import InvalidRegistrationResponse +from webauthn.helpers.structs import RegistrationCredential +from webauthn.registration.verify_registration_response import \ + VerifiedRegistration from privacyidea.lib.tokens.u2f import url_encode, url_decode from privacyidea.lib.utils import to_bytes, to_unicode @@ -116,6 +124,7 @@ class ATTESTATION_TYPE(object): NONE = 'None' ATTESTATION_CA = 'AttCA' SELF_ATTESTATION = 'Self' + ANONYMIZATION_CA = 'AnonCA' # Only supporting 'None', 'Basic', and 'Self Attestation' attestation types for now. @@ -183,7 +192,6 @@ class COSE_ALGORITHM(object): ES256 = -7 PS256 = -37 RS256 = -257 - RS1 = -65535 # for tests, otherwise unsupported SUPPORTED_COSE_ALGORITHMS = ( @@ -426,9 +434,9 @@ class WebAuthnMakeCredentialOptions(object): :type rp_id: basestring :param user_id: The ID for the user credential being generated. This is the privacyIDEA token serial. :type user_id: basestring - :param user_name: The username the user logs in with. + :param user_name: The user name the user logs in with. :type user_name: basestring - :param user_display_name: The human-readable name of the user. + :param user_display_name: The human readable name of the user. :type user_display_name: basestring :param icon_url: An optional icon url. :type icon_url: basestring @@ -665,9 +673,9 @@ class WebAuthnUser(object): :param user_id: The ID for the user credential being stored. This is the privacyIDEA token serial. :type user_id: basestring - :param user_name: The username the user logs in with. + :param user_name: The user name the user logs in with. :type user_name: basestring - :param user_display_name: The human-readable name of the user. + :param user_display_name: The human readable name of the user. :type user_display_name: basestring :param icon_url: An optional icon url. :type icon_url: basestring @@ -700,8 +708,7 @@ class WebAuthnUser(object): self.rp_id = rp_id def __str__(self): - return '{!r} ({}, {}, {})'.format(self.user_id, self.user_name, - self.user_display_name, self.sign_count) + return '{} ({}, {}, {})'.format(self.user_id, self.user_name, self.user_display_name, self.sign_count) class WebAuthnCredential(object): @@ -772,9 +779,8 @@ class WebAuthnCredential(object): return not ATTESTATION_REQUIREMENT_LEVEL[self.attestation_level]['self_attestation_permitted'] - def __str__(self): - return '{!r} ({}, {}, {})'.format(self.credential_id, self.rp_id, - self.origin, self.sign_count) + def __str_(self): + return '{} ({}, {}, {})'.format(self.credential_id, self.rp_id, self.origin, self.sign_count) class WebAuthnRegistrationResponse(object): @@ -1168,56 +1174,13 @@ class WebAuthnRegistrationResponse(object): # * If successful, return attestation type ECDAA and # attestation trust path ecdaaKeyId. raise RegistrationRejectedException('ECDAA attestation type is not currently supported.') - else: - # self-attestation - # Step 1: - # Validate that alg matches the algorithm of the credentialPublicKey - # in authenticatorData. - try: - public_key_alg, credential_public_key = _load_cose_public_key(credential_pub_key) - except COSEKeyException as e: - raise RegistrationRejectedException(str(e)) - if alg != public_key_alg: - raise RegistrationRejectedException('credentialPublicKey algorithm {0!s} does ' - 'not match algorithm from attestation ' - 'statement {1!s}'.format(public_key_alg, alg)) - # Step 2: - # Verify that sig is a valid signature over the concatenation of authenticatorData - # and clientDataHash using the credential public key with alg. - try: - _verify_signature(credential_public_key, alg, verification_data, signature) - except InvalidSignature: - raise RegistrationRejectedException('Invalid signature received.') - except NotImplementedError: # pragma: no cover - log.warning('Unsupported algorithm ({0!s}) for signature ' - 'verification'.format(alg)) - # We do not support this algorithm. Treat as none attestation, if acceptable. - if none_attestation_permitted: - return ( - ATTESTATION_TYPE.NONE, - [], - credential_pub_key, - cred_id, - aaguid - ) - else: - raise RegistrationRejectedException('Unsupported algorithm ' - '({0!s}).'.format(alg)) - return ( - ATTESTATION_TYPE.SELF_ATTESTATION, - [], - credential_pub_key, - cred_id, - aaguid - ) else: # Attestation is either none, or unsupported. if not none_attestation_permitted: if fmt == ATTESTATION_FORMAT.NONE: raise RegistrationRejectedException('Authenticator attestation is required.') else: - raise RegistrationRejectedException( - 'Unsupported authenticator attestation format ({0!s})!'.format(fmt)) + raise RegistrationRejectedException('Unsupported authenticator attestation format.') # Treat as none attestation. # @@ -1234,256 +1197,64 @@ class WebAuthnRegistrationResponse(object): aaguid ) + def extract_credential_id(self): + attestation_object = self.registration_response["attObj"] + decoded_attestation_object = cbor2.loads(webauthn_b64_decode(attestation_object)) + auth_data = decoded_attestation_object["authData"] + attestation_data = auth_data[37:] + credential_id_len = struct.unpack('!H', attestation_data[16:18])[0] + credential_id = attestation_data[18:18 + credential_id_len] + + return credential_id + + def verify(self, existing_credential_ids=None): """ Verify the WebAuthnRegistrationResponse. This will perform the registration ceremony for the WebAuthnRegistrationResponse. It will only return on successful - verification. In any other case, an appropriate error will be raised. + verification. In any other case, an appropriate error will + be raised. - :param existing_credential_ids: A list of existing credential ids to check for duplicates. + :param existing_credential_ids: A list of existing + credential ids to check for duplicates. :type existing_credential_ids: basestring[] - :return: The WebAuthnCredential produced by the registration ceremony. - :rtype: WebAuthnCredential + :return: The VerifiedRegistration produced by the registration + ceremony. + :rtype: VerifiedRegistration """ try: - # As described in https://www.w3.org/TR/webauthn/#sctn-registering-a-new-credential - # In the docs it starts at step 5. - # - # Step 1. - # - # Let JSONtext be the result of running UTF-8 decode on the value of - # response.clientDataJSON - json_text = self.registration_response.get('clientData', '') - - # Step 2. - # - # Let C, the client data claimed as collected during the credential - # creation, be the result of running an implementation-specific JSON - # parser on JSONtext. - decoded_cd = webauthn_b64_decode(json_text) - c = json.loads(to_unicode(decoded_cd)) - - # Step 3. - # - # Verify that the value of C.type is webauthn.create. - if not _verify_type(c.get('type'), CLIENT_DATA_TYPE.CREATE): - raise RegistrationRejectedException('Invalid type.') + credential_raw_id = self.extract_credential_id() + credential_id = bytes_to_base64url(credential_raw_id) - # Step 4. - # - # Verify that the value of C.challenge matches the challenge that was sent - # to the authenticator in the create() call. - if not _verify_challenge(c.get('challenge'), self.challenge): - raise RegistrationRejectedException('Unable to verify challenge.') - - # Step 5. - # - # Verify that the value of C.origin matches the Relying Party's origin. - if not _verify_origin(c, self.origin): - raise RegistrationRejectedException('Unable to verify origin.') - - # Step 6. - # - # Verify that the value of C.tokenBinding.status matches the state of - # Token Binding for the TLS connection over which the assertion was - # obtained. If Token Binding was used on that TLS connection, also verify - # that C.tokenBinding.id matches the base64url encoding of the Token - # Binding ID for the connection. - - # Chrome does not currently supply token binding in the clientDataJSON - # if not _verify_token_binding_id(c): - # raise RegistrationRejectedException('Unable to verify token binding ID.') - - # Step 7. - # - # Compute the hash of response.clientDataJSON using SHA-256. - client_data_hash = _get_client_data_hash(decoded_cd) - - # Step 8. - # - # Perform CBOR decoding on the attestationObject field of - # the AuthenticatorAttestationResponse structure to obtain - # the attestation statement format fmt, the authenticator - # data authData, and the attestation statement attStmt. - att_obj = self.parse_attestation_object(self.registration_response.get('attObj')) - att_stmt = att_obj.get('attStmt') - auth_data = att_obj.get('authData') - fmt = att_obj.get('fmt') - if not auth_data or len(auth_data) < 37: - raise RegistrationRejectedException('Auth data must be at least 37 bytes.') - - # Step 9. - # - # Verify that the RP ID hash in authData is indeed the - # SHA-256 hash of the RP ID expected by the RP. - if not _verify_rp_id_hash(_get_auth_data_rp_id_hash(auth_data), self.rp_id): - raise RegistrationRejectedException('Unable to verify RP ID hash.') - - # Step 10. - # - # Verify that the User Present bit of the flags in authData - # is set. - if not AuthenticatorDataFlags(auth_data).user_present: - raise RegistrationRejectedException('Malformed request received.') - - # Step 11. - # - # If user verification is required for this registration, verify - # that the User Verified bit of the flags in authData is set. - if self.uv_required and not AuthenticatorDataFlags(auth_data).user_verified: - raise RegistrationRejectedException('Malformed request received.') + if existing_credential_ids and credential_id in existing_credential_ids: + raise RegistrationRejectedException('Credential already exists.') - # Step 12. - # - # Verify that the values of the client extension outputs in - # clientExtensionResults and the authenticator extension outputs - # in the extensions in authData are as expected, considering the - # client extension input values that were given as the extensions - # option in the create() call. In particular, any extension - # identifier values in the clientExtensionResults and the extensions - # in authData MUST be also be present as extension identifier values - # in the extensions member of options, i.e., no extensions are - # present that were not requested. In the general case, the meaning - # of "are as expected" is specific to the Relying Party and which - # extensions are in use. - if not _verify_authenticator_extensions(auth_data, self.expected_registration_authenticator_extensions): - raise RegistrationRejectedException('Unable to verify authenticator extensions.') - if not _verify_client_extensions( - self.registration_response.get('registrationClientExtensions'), - self.expected_registration_client_extensions - ): - raise RegistrationRejectedException('Unable to verify client extensions.') + response = { + "attestationObject": base64url_to_bytes(self.registration_response["attObj"]), + "clientDataJSON": base64url_to_bytes(self.registration_response["clientData"]) + } - # Step 13. - # - # Determine the attestation statement format by performing - # a USASCII case-sensitive match on fmt against the set of - # supported WebAuthn Attestation Statement Format Identifier - # values. The up-to-date list of registered WebAuthn - # Attestation Statement Format Identifier values is maintained - # in the IANA registry of the same name. - if not _verify_attestation_statement_format(fmt): - raise RegistrationRejectedException('Unable to verify attestation statement format.') + raw_registration_response = { + # need to make id == base64url(rawID) + "rawId": credential_raw_id, + "id": credential_id, + "response": response + } - # Step 14. - # - # Verify that attStmt is a correct attestation statement, conveying - # a valid attestation signature, by using the attestation statement - # format fmt's verification procedure given attStmt, authData and - # the hash of the serialized client data computed in step 7. - ( - attestation_type, - trust_path, - credential_public_key, - cred_id, - aaguid - ) = self.verify_attestation_statement( - fmt, - att_stmt, - auth_data, - client_data_hash, - self.none_attestation_permitted + verified_registration = verify_registration_response( + credential=RegistrationCredential.parse_obj(raw_registration_response), + expected_challenge=base64url_to_bytes(self.challenge), + expected_rp_id=self.rp_id, + expected_origin=self.origin, + require_user_verification=self.uv_required ) - b64_cred_id = webauthn_b64_encode(cred_id) - - # Step 15. - # - # If validation is successful, obtain a list of acceptable trust - # anchors (attestation root certificates or ECDAA-Issuer public - # keys) for that attestation type and attestation statement format - # fmt, from a trusted source or from policy. For example, the FIDO - # Metadata Service [FIDOMetadataService] provides one way to obtain - # such information, using the aaguid in the attestedCredentialData - # in authData. - trust_anchors = _get_trust_anchors(attestation_type, fmt, self.trust_anchor_dir) \ - if self.trust_anchor_dir \ - else None - if not trust_anchors and self.trusted_attestation_cert_required: - raise RegistrationRejectedException('No trust anchors available to verify attestation certificate.') - - # Step 16. - # - # Assess the attestation trustworthiness using the outputs of the - # verification procedure in step 14, as follows: - # - # * If self attestation was used, check if self attestation is - # acceptable under Relying Party policy. - # * If ECDAA was used, verify that the identifier of the - # ECDAA-Issuer public key used is included in the set of - # acceptable trust anchors obtained in step 15. - # * Otherwise, use the X.509 certificates returned by the - # verification procedure to verify that the attestation - # public key correctly chains up to an acceptable root - # certificate. - if attestation_type == ATTESTATION_TYPE.SELF_ATTESTATION and not self.self_attestation_permitted: - raise RegistrationRejectedException('Self attestation is not permitted.') - is_trusted_attestation_cert = ((attestation_type == ATTESTATION_TYPE.BASIC - and _is_trusted_x509_attestation_cert(trust_path, trust_anchors)) - or (attestation_type == ATTESTATION_TYPE.ECDAA - and _is_trusted_ecdaa_attestation_certificate(None, trust_anchors))) - is_signed_attestation_cert = attestation_type in SUPPORTED_ATTESTATION_TYPES - - if is_trusted_attestation_cert: - attestation_level = ATTESTATION_LEVEL.TRUSTED - elif is_signed_attestation_cert: - attestation_level = ATTESTATION_LEVEL.UNTRUSTED - else: - attestation_level = ATTESTATION_LEVEL.NONE - - # Step 17. - # - # Check that the credentialId is not yet registered to any other user. - # If registration is requested for a credential that is already registered - # to a different user, the Relying Party SHOULD fail this registration - # ceremony, or it MAY decide to accept the registration, e.g. while deleting - # the older registration. - if existing_credential_ids and b64_cred_id in existing_credential_ids: - raise RegistrationRejectedException('Credential already exists.') + return verified_registration - # Step 18. - # - # If the attestation statement attStmt verified successfully and is - # found to be trustworthy, then register the new credential with the - # account that was denoted in the options.user passed to create(), - # by associating it with the credentialId and credentialPublicKey in - # the attestedCredentialData in authData, as appropriate for the - # Relying Party's system. - credential = WebAuthnCredential(rp_id=self.rp_id, - origin=self.origin, - aaguid=aaguid, - credential_id=b64_cred_id, - public_key=webauthn_b64_encode(credential_public_key), - sign_count=struct.unpack('!I', auth_data[33:37])[0], - attestation_level=attestation_level, - attestation_cert=trust_path[0] if trust_path else None) - if is_trusted_attestation_cert: - return credential - - # Step 19. - # - # If the attestation statement attStmt successfully verified but is - # not trustworthy per step 16 above, the Relying Party SHOULD fail - # the registration ceremony. - # - # NOTE: However, if permitted by policy, the Relying Party MAY - # register the credential ID and credential public key but - # treat the credential as one with self attestation (see - # 6.3.3 Attestation Types). If doing so, the Relying Party - # is asserting there is no cryptographic proof that the - # public key credential has been generated by a particular - # authenticator model. See [FIDOSecRef] and [UAFProtocol] - # for a more detailed discussion. - if self.trusted_attestation_cert_required: - raise RegistrationRejectedException('Untrusted attestation certificate.') - if not is_signed_attestation_cert and not self.none_attestation_permitted: - raise RegistrationRejectedException('No (or unsupported) attestation certificate.') - return credential - - except Exception as e: - raise RegistrationRejectedException('Registration rejected. Error: {}'.format(e)) + except InvalidRegistrationResponse as ex: + raise RegistrationRejectedException from ex class WebAuthnAssertionResponse(object): @@ -1816,7 +1587,7 @@ def _load_cose_public_key(key_bytes): y = int(codecs.encode(cose_public_key[COSE_PUBLIC_KEY.Y], 'hex'), 16) return alg, EllipticCurvePublicNumbers(x, y, SECP256R1()).public_key(backend=default_backend()) - elif alg in (COSE_ALGORITHM.PS256, COSE_ALGORITHM.RS256, COSE_ALGORITHM.RS1): + elif alg in (COSE_ALGORITHM.PS256, COSE_ALGORITHM.RS256): required_keys = { COSE_PUBLIC_KEY.ALG, @@ -1835,7 +1606,6 @@ def _load_cose_public_key(key_bytes): return alg, RSAPublicNumbers(e, n).public_key(backend=default_backend()) else: - log.warning('Unsupported webAuthn COSE algorithm: {0!s}'.format(alg)) raise COSEKeyException('Unsupported algorithm.') @@ -2047,7 +1817,5 @@ def _verify_signature(public_key, alg, data, signature): elif alg == COSE_ALGORITHM.PS256: padding = PSS(mgf=MGF1(SHA256()), salt_length=PSS.MAX_LENGTH) public_key.verify(signature, data, padding, SHA256()) - elif alg == COSE_ALGORITHM.RS1: - public_key.verify(signature, data, PKCS1v15(), SHA1()) else: raise NotImplementedError() diff --git a/privacyidea/lib/tokens/webauthntoken.py b/privacyidea/lib/tokens/webauthntoken.py index b52f8a2b3dc80174f7d0a9fd7826b06e381a6c06..6f62b1994c25ba12d97af392f709e8702ab28dbc 100644 --- a/privacyidea/lib/tokens/webauthntoken.py +++ b/privacyidea/lib/tokens/webauthntoken.py @@ -22,28 +22,35 @@ # import binascii +import logging -from OpenSSL import crypto -from cryptography import x509 +from webauthn import verify_authentication_response, base64url_to_bytes +from webauthn.helpers import bytes_to_base64url +from webauthn.helpers.exceptions import InvalidAuthenticationResponse +from webauthn.helpers.structs import AuthenticationCredential -from privacyidea.api.lib.utils import getParam, attestation_certificate_allowed +from privacyidea.api.lib.utils import getParam +from privacyidea.lib import _ from privacyidea.lib.challenge import get_challenges from privacyidea.lib.config import get_from_config from privacyidea.lib.crypto import geturandom from privacyidea.lib.decorators import check_token_locked -from privacyidea.lib.error import ParameterError, EnrollmentError, PolicyError +from privacyidea.lib.error import ParameterError, RegistrationError +from privacyidea.lib.log import log_with +from privacyidea.lib.policy import SCOPE, GROUP, ACTION from privacyidea.lib.token import get_tokens from privacyidea.lib.tokenclass import TokenClass, CLIENTMODE, ROLLOUTSTATE -from privacyidea.lib.tokens.webauthn import (COSE_ALGORITHM, webauthn_b64_encode, WebAuthnRegistrationResponse, - ATTESTATION_REQUIREMENT_LEVEL, webauthn_b64_decode, - WebAuthnMakeCredentialOptions, WebAuthnAssertionOptions, WebAuthnUser, - WebAuthnAssertionResponse, AuthenticationRejectedException, - USER_VERIFICATION_LEVEL) from privacyidea.lib.tokens.u2ftoken import IMAGES -from privacyidea.lib.log import log_with -import logging -from privacyidea.lib import _ -from privacyidea.lib.policy import SCOPE, GROUP, ACTION +from privacyidea.lib.tokens.webauthn import (COSE_ALGORITHM, + webauthn_b64_encode, + WebAuthnRegistrationResponse, + ATTESTATION_REQUIREMENT_LEVEL, + webauthn_b64_decode, + WebAuthnMakeCredentialOptions, + WebAuthnAssertionOptions, + WebAuthnUser, + AuthenticationRejectedException, + USER_VERIFICATION_LEVEL) from privacyidea.lib.user import User from privacyidea.lib.utils import hexlify_and_unicode, is_true @@ -77,69 +84,44 @@ The enrollment/registering can be completely performed within privacyIDEA. But if you want to enroll the WebAuthn token via the REST API you need to do it in two steps: -**Step 1** +Step 1 +~~~~~~ .. sourcecode:: http POST /token/init HTTP/1.1 - Host: <privacyIDEA server> + Host: example.com Accept: application/json - + type=webauthn - user=<username> + +This step returns a nonce, a relying party (containing a name and an ID +generated from your domain), and a serial number, along with a transaction ID, +and a message to display to the user. It will also pass some additional options +regarding timeout, which authenticators are acceptable, and what key types are +acceptable to the server. -The request returns: +Step 2 +~~~~~~ .. sourcecode:: http - HTTP/1.1 200 OK - Content-Type: application/json - - { - "detail": { - "serial": "<serial number>", - "webAuthnRegisterRequest": { - "attestation": "direct", - "authenticatorSelection": { - "userVerification": "preferred" - }, - "displayName": "<user.resolver@realm>", - "message": "Please confirm with your WebAuthn token", - "name": "<username>", - "nonce": "<nonce>", - "pubKeyCredAlgorithms": [ - { - "alg": -7, - "type": "public-key" - }, - { - "alg": -37, - "type": "public-key" - } - ], - "relyingParty": { - "id": "<relying party ID>", - "name": "<relying party name>" - }, - "serialNumber": "<serial number>", - "timeout": 60000, - "transaction_id": "<transaction ID>" - } - }, - "result": { - "status": true, - "value": true - }, - "version": "<privacyIDEA version>" - } + POST /token/init HTTP/1.1 + Host: example.com + Accept: application/json + + type=webauthn + transaction_id=<transaction_id> + description=<description> + clientdata=<clientDataJSON> + regdata=<attestationObject> + registrationclientextensions=<registrationClientExtensions> -This step returns a *webAuthnRegisterRequest* which contains a nonce, a relying party (containing a -name and an ID generated from your domain), a serial number along with a transaction ID -and a message to display to the user. It will also contain some additional options -regarding timeout, which authenticators are acceptable, and what key types are -acceptable to the server. +*clientDataJSON* and *attestationObject* are the values returned by the +WebAuthn authenticator. *description* is an optional description string for +the new token. -With the received data You need to call the javascript function +You need to call the javascript function .. sourcecode:: javascript @@ -153,7 +135,16 @@ With the received data You need to call the javascript function name: <name>, displayName: <displayName> }, - pubKeyCredParams: <pubKeyCredAlgorithms>, + pubKeyCredParams: [ + { + alg: <preferredAlgorithm>, + type: "public-key" + }, + { + alg: <alternativeAlgorithm>, + type: "public-key" + } + ], authenticatorSelection: <authenticatorSelection>, timeout: <timeout>, attestation: <attestation>, @@ -164,11 +155,11 @@ With the received data You need to call the javascript function .then(function(credential) { <responseHandler> }) .catch(function(error) { <errorHandler> }); -Here *nonce*, *relyingParty*, *serialNumber*, *pubKeyCredAlgorithms*, -*authenticatorSelection*, *timeout*, *attestation*, +Here *nonce*, *relyingParty*, *serialNumber*, *preferredAlgorithm*, +*alternativeAlgorithm*, *authenticatorSelection*, *timeout*, *attestation*, *authenticatorSelectionList*, *name*, and *displayName* are the values provided by the server in the *webAuthnRegisterRequest* field in the response -from the first step. *authenticatorSelection*, +from the first step. *alternativeAlgorithm*, *authenticatorSelection*, *timeout*, *attestation*, and *authenticatorSelectionList* are optional. If *attestation* is not provided, the client should default to `direct` attestation. If *timeout* is not provided, it may be omitted, or a sensible @@ -185,38 +176,18 @@ company-provided token. The *responseHandler* needs to then send the *clientDataJSON*, *attestationObject*, and *registrationClientExtensions* contained in the -*response* field of the *credential* back to the server. If +*response* field of the *credential* (2. step) back to the server. If enrollment succeeds, the server will send a response with a *webAuthnRegisterResponse* field, containing a *subject* field with the description of the newly created token. - -**Step 2** - -.. sourcecode:: http - - POST /token/init HTTP/1.1 - Host: <privacyIDEA server> - Accept: application/json - - type=webauthn - transaction_id=<transaction_id> - description=<description> - clientdata=<clientDataJSON> - regdata=<attestationObject> - registrationclientextensions=<registrationClientExtensions> - -The values *clientDataJSON* and *attestationObject* are returned by the -WebAuthn authenticator. *description* is an optional description string for -the new token. - The server expects the *clientDataJSON* and *attestationObject* encoded as web-safe base64 as defined by the WebAuthn standard. This encoding is similar to standard base64, but '-' and '_' should be used in the alphabet instead of '+' and '/', respectively, and any padding should be omitted. The *registrationClientExtensions* are optional and should simply be omitted, -if the client does not provide them. If the *registrationClientExtensions* are +if the client does not provide them. It the *registrationClientExtensions* are available, they must be encoded as a utf-8 JSON string, then sent to the server as web-safe base64. @@ -243,62 +214,53 @@ Get the challenge (using /validate/check) The /validate/check endpoint can be used to trigger a challenge using the PIN for the token (without requiring any special permissions). -**Request:** +**Request** .. sourcecode:: http POST /validate/check HTTP/1.1 - Host: <privacyIDEA server> + Host: example.com Accept: application/json - + user=<username> pass=<password> - -**Response:** + +**Response** .. sourcecode:: http HTTP/1.1 200 OK Content-Type: application/json - + { "detail": { "attributes": { "hideResponseInput": true, - "img": "<image URL>", + "img": <imageUrl>, "webAuthnSignRequest": { - "allowCredentials": [ - { - "id": "<credential ID>", - "transports": [ - "<allowed transports>" - ], - "type": "<credential type>" - } - ], - "challenge": "<nonce>", - "rpId": "<relying party ID>", - "timeout": 60000, - "userVerification": "<user verification requirement>" + "challenge": <nonce>, + "allowCredentials": [{ + "id": <credentialId>, + "type": <credentialType>, + "transports": <allowedTransports>, + }], + "rpId": <relyingPartyId>, + "userVerification": <userVerificationRequirement>, + "timeout": <timeout> } }, - "client_mode": "webauthn", "message": "Please confirm with your WebAuthn token", - "serial": "<token serial>", - "transaction_id": "<transaction ID>", - "type": "webauthn" + "transaction_id": <transactionId> }, "id": 1, "jsonrpc": "2.0", "result": { - "authentication": "CHALLENGE", "status": true, "value": false }, - "version": "<privacyIDEA version>" + "versionnumber": <privacyIDEAversion> } - Get the challenge (using /validate/triggerchallenge) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -310,13 +272,13 @@ using a service account (without requiring the PIN for the token). .. sourcecode:: http POST /validate/triggerchallenge HTTP/1.1 - Host: <privacyIDEA server> + Host: example.com Accept: application/json PI-Authorization: <authToken> - + user=<username> serial=<tokenSerial> - + Providing the *tokenSerial* is optional. If just a user is provided, a challenge will be triggered for every challenge response token the user has. @@ -326,24 +288,23 @@ challenge will be triggered for every challenge response token the user has. HTTP/1.1 200 OK Content-Type: application/json - + + { "detail": { "attributes": { "hideResponseInput": true, - "img": "<image URL>", + "img": <imageUrl>, "webAuthnSignRequest": { - "challenge": "<nonce>", + "challenge": <nonce>, "allowCredentials": [{ - "id": "<credential ID>", - "transports": [ - "<allowed transports>" - ], - "type": "<credential type>", + "id": <credentialId>, + "type": <credentialType>, + "transports": <allowedTransports>, }], - "rpId": "<relying party ID>", - "userVerification": "<user verification requirement>", - "timeout": 60000 + "rpId": <relyingPartyId>, + "userVerification": <userVerificationRequirement>, + "timeout": <timeout> } }, "message": "Please confirm with your WebAuthn token", @@ -351,29 +312,28 @@ challenge will be triggered for every challenge response token the user has. "multi_challenge": [{ "attributes": { "hideResponseInput": true, - "img": "<image URL>", + "img": <imageUrl>, "webAuthnSignRequest": { - "challenge": "<nonce>", + "challenge": <nonce>, "allowCredentials": [{ - "id": "<credential ID>", - "transports": [ - "<allowedTransports>" - ], - "type": "<credential type>", + "id": <credentialId>, + "type": <credentialType>, + "transports": <allowedTransports>, }], - "rpId": "<relying party ID>", - "userVerification": "<user verification requirement>", - "timeout": 60000 + "rpId": <relyingPartyId>, + "userVerification": <userVerificationRequirement>, + "timeout": <timeout> } }, "message": "Please confirm with your WebAuthn token", - "serial": "<token serial>", - "transaction_id": "<transaction ID>", + "serial": <tokenSerial>, + "transaction_id": <transactionId>, "type": "webauthn" }], - "serial": "<token serial>", - "transaction_id": "<transaction ID>", - "transaction_ids": ["<transaction IDs>"], + "serial": <tokenSerial>, + "threadid": <threadId>, + "transaction_id": <transactionId>, + "transaction_ids": [<transactionId>], "type": "webauthn" }, "id": 1, @@ -382,15 +342,15 @@ challenge will be triggered for every challenge response token the user has. "status": true, "value": 1 }, - "version": "<privacyIDEA version>" + "versionnumber": <privacyIDEAversion> } - + Send the Response ~~~~~~~~~~~~~~~~~ The application now needs to call the javascript function -*navigator.credentials.get* with the *publicKeyCredentialRequestOptions* built -using the *nonce*, *credentialId*, *allowedTransports*, *userVerificationRequirement* +*navigator.credentials.get* with *publicKeyCredentialRequestOptions* built using +the *nonce*, *credentialId*, *allowedTransports*, *userVerificationRequirement* and *timeout* from the server. The timeout is optional and may be omitted, if not provided, the client may also pick a sensible default. Please note that the nonce will be a binary, encoded using the web-safe base64 algorithm specified by @@ -440,7 +400,7 @@ native encoding of the language (usually utf-16). POST /validate/check HTTP/1.1 Host: example.com Accept: application/json - + user=<user> pass= transaction_id=<transaction_id> @@ -533,6 +493,12 @@ class WEBAUTHNINFO(object): ATTESTATION_SUBJECT = "attestation_subject" RELYING_PARTY_ID = "relying_party_id" RELYING_PARTY_NAME = "relying_party_name" + FORMAT = "format" + CREDENTIAL_TYPE = "credential_type" + USER_VERIFIED = "user_verified" + ATTESTATION_OBJECT = "attestation_object" + CREDENTIAL_DEVICE_TYPE = "credential_device_type" + CREDENTIAL_BACKED_UP = "credential_backed_up" class WEBAUTHNGROUP(object): @@ -697,10 +663,15 @@ class WebAuthnTokenClass(TokenClass): }, WEBAUTHNACTION.PUBLIC_KEY_CREDENTIAL_ALGORITHMS: { 'type': 'str', - 'desc': _("Which algorithm are available to use for creating public key " - "credentials for WebAuthn tokens. (Default: [{0!s}], Order: " - "[{1!s}]".format(', '.join(DEFAULT_PUBLIC_KEY_CREDENTIAL_ALGORITHM_PREFERENCE), - ', '.join(PUBKEY_CRED_ALGORITHMS_ORDER))), + 'desc': _( + "Which algorithm are available to use for " + "creating public key " + "credentials for WebAuthn tokens. (Default: [{" + "0!s}], Order: " + "[{1!s}]".format(', '.join( + DEFAULT_PUBLIC_KEY_CREDENTIAL_ALGORITHM_PREFERENCE), + ', '.join( + PUBKEY_CRED_ALGORITHMS_ORDER))), 'group': WEBAUTHNGROUP.WEBAUTHN, 'multiple': True, 'value': list(PUBLIC_KEY_CREDENTIAL_ALGORITHMS.keys()) @@ -807,7 +778,7 @@ class WebAuthnTokenClass(TokenClass): user_display_name=str(user), icon_url=IMAGES.get(self.token.description.lower().split()[0], "") if self.token.description else "", credential_id=self.decrypt_otpkey(), - public_key=webauthn_b64_encode(binascii.unhexlify(self.get_tokeninfo(WEBAUTHNINFO.PUB_KEY))), + public_key=self.get_tokeninfo(WEBAUTHNINFO.PUB_KEY), sign_count=self.get_otp_count(), rp_id=self.get_tokeninfo(WEBAUTHNINFO.RELYING_PARTY_ID) ) @@ -826,7 +797,7 @@ class WebAuthnTokenClass(TokenClass): :rtype: basestring """ - return webauthn_b64_encode(binascii.unhexlify(self.token.get_otpkey().getKey())) + return webauthn_b64_encode(self.token.get_otpkey().getKey()) def update(self, param, reset_failcount=True): """ @@ -874,7 +845,7 @@ class WebAuthnTokenClass(TokenClass): # Since we are still enrolling the token, there should be exactly one challenge. if not len(challengeobject_list): - raise EnrollmentError( + raise RegistrationError( "The enrollment challenge does not exist or has timed out for {0!s}".format(serial)) challengeobject = challengeobject_list[0] challenge = binascii.unhexlify(challengeobject.challenge) @@ -883,55 +854,46 @@ class WebAuthnTokenClass(TokenClass): # # All data is parsed and verified. If any errors occur an exception # will be raised. - try: - webauthn_credential = WebAuthnRegistrationResponse( - rp_id=rp_id, - origin=http_origin, - registration_response={ - 'clientData': client_data, - 'attObj': reg_data, - 'registrationClientExtensions': - webauthn_b64_decode(registration_client_extensions) + verified_registration = WebAuthnRegistrationResponse( + rp_id=rp_id, + origin=http_origin, + registration_response={ + 'clientData': client_data, + 'attObj': reg_data, + 'registrationClientExtensions': + webauthn_b64_decode(registration_client_extensions) if registration_client_extensions else None - }, - challenge=webauthn_b64_encode(challenge), - attestation_requirement_level=ATTESTATION_REQUIREMENT_LEVEL[attestation_level], - trust_anchor_dir=get_from_config(WEBAUTHNCONFIG.TRUST_ANCHOR_DIR), - uv_required=uv_req == USER_VERIFICATION_LEVEL.REQUIRED - ).verify([ - # TODO: this might get slow when a lot of webauthn tokens are registered - token.decrypt_otpkey() for token in get_tokens(tokentype=self.type) if token.get_serial() != self.get_serial() - ]) - except Exception as e: - log.warning('Enrollment of {0!s} token failed: ' - '{1!s}!'.format(self.get_class_type(), e)) - raise EnrollmentError("Could not enroll {0!s} token!".format(self.get_class_type())) - - self.set_otpkey(hexlify_and_unicode(webauthn_b64_decode(webauthn_credential.credential_id))) - self.set_otp_count(webauthn_credential.sign_count) + }, + challenge=webauthn_b64_encode(challenge), + attestation_requirement_level=ATTESTATION_REQUIREMENT_LEVEL[attestation_level], + trust_anchor_dir=get_from_config(WEBAUTHNCONFIG.TRUST_ANCHOR_DIR), + uv_required=uv_req == USER_VERIFICATION_LEVEL.REQUIRED + ).verify([ + token.decrypt_otpkey() for token in get_tokens(tokentype=self.type) + ]) + + self.set_otpkey(webauthn_b64_decode(bytes_to_base64url(verified_registration.credential_id))) + self.set_otp_count(verified_registration.sign_count) + self.add_tokeninfo(WEBAUTHNINFO.ORIGIN, http_origin) + self.add_tokeninfo(WEBAUTHNINFO.RELYING_PARTY_ID, rp_id) self.add_tokeninfo(WEBAUTHNINFO.PUB_KEY, - hexlify_and_unicode(webauthn_b64_decode(webauthn_credential.public_key))) - self.add_tokeninfo(WEBAUTHNINFO.ORIGIN, - webauthn_credential.origin) - self.add_tokeninfo(WEBAUTHNINFO.ATTESTATION_LEVEL, - webauthn_credential.attestation_level) - + bytes_to_base64url( + verified_registration.credential_public_key)) self.add_tokeninfo(WEBAUTHNINFO.AAGUID, - hexlify_and_unicode(webauthn_credential.aaguid)) - - # Add attestation info. - if webauthn_credential.attestation_cert: - # attestation_cert is of type cryptography.x509.Certificate. - self.add_tokeninfo(WEBAUTHNINFO.ATTESTATION_ISSUER, - webauthn_credential.attestation_cert.issuer.rfc4514_string()) - self.add_tokeninfo(WEBAUTHNINFO.ATTESTATION_SUBJECT, - webauthn_credential.attestation_cert.subject.rfc4514_string()) - self.add_tokeninfo(WEBAUTHNINFO.ATTESTATION_SERIAL, - webauthn_credential.attestation_cert.serial_number) - - cn = webauthn_credential.attestation_cert.subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME) - automatic_description = cn[0].value if len(cn) else None + verified_registration.aaguid) + self.add_tokeninfo(WEBAUTHNINFO.FORMAT, + verified_registration.fmt) + self.add_tokeninfo(WEBAUTHNINFO.CREDENTIAL_TYPE, + verified_registration.credential_type) + self.add_tokeninfo(WEBAUTHNINFO.USER_VERIFIED, + verified_registration.user_verified) + self.add_tokeninfo(WEBAUTHNINFO.ATTESTATION_OBJECT, + bytes_to_base64url(verified_registration.attestation_object)) + self.add_tokeninfo(WEBAUTHNINFO.CREDENTIAL_DEVICE_TYPE, + verified_registration.credential_device_type) + self.add_tokeninfo(WEBAUTHNINFO.CREDENTIAL_BACKED_UP, + verified_registration.credential_device_type) # If no description has already been set, set the automatic description or the # description given in the 2nd request @@ -1040,6 +1002,9 @@ class WebAuthnTokenClass(TokenClass): "name": public_key_credential_creation_options["user"]["name"], "displayName": public_key_credential_creation_options["user"]["displayName"] } + if len(public_key_credential_creation_options.get("pubKeyCredParams")) > 1: + response_detail["webAuthnRegisterRequest"]["alternativeAlgorithm"] \ + = public_key_credential_creation_options["pubKeyCredParams"][1] if public_key_credential_creation_options.get("authenticatorSelection"): response_detail["webAuthnRegisterRequest"]["authenticatorSelection"] \ = public_key_credential_creation_options["authenticatorSelection"] @@ -1182,7 +1147,7 @@ class WebAuthnTokenClass(TokenClass): "image": user.icon_url} return True, message, db_challenge.transaction_id, reply_dict - + @check_token_locked def check_otp(self, otpval, counter=None, window=None, options=None): """ @@ -1204,6 +1169,8 @@ class WebAuthnTokenClass(TokenClass): :return: A numerical value where values larger than zero indicate success. :rtype: int """ + if self.get_tokeninfo("migrated") is not None: + return -1 if is_webauthn_assertion_response(options) and getParam(options, "challenge", optional): credential_id = getParam(options, "credentialid", required) @@ -1211,82 +1178,48 @@ class WebAuthnTokenClass(TokenClass): client_data = getParam(options, "clientdata", required) signature_data = getParam(options, "signaturedata", required) user_handle = getParam(options, "userhandle", optional) - assertion_client_extensions = getParam(options, "assertionclientextensions", optional) + uv_required = getParam(options, WEBAUTHNACTION.USER_VERIFICATION_REQUIREMENT, optional) try: user = self._get_webauthn_user(getParam(options, "user", required)) except ParameterError: raise ValueError("When performing WebAuthn authorization, options must contain user") - uv_req = getParam(options, WEBAUTHNACTION.USER_VERIFICATION_REQUIREMENT, optional) - - challenge = binascii.unhexlify(getParam(options, "challenge", required)) - try: - try: - http_origin = getParam(options, "HTTP_ORIGIN", required, allow_empty=False) - except ParameterError: - raise AuthenticationRejectedException('HTTP Origin header missing.') - - # This does the heavy lifting. - # - # All data is parsed and verified. If any errors occur, an exception - # will be raised. - self.set_otp_count(WebAuthnAssertionResponse( - webauthn_user=user, - assertion_response={ - 'id': credential_id, - 'userHandle': user_handle, - 'clientData': client_data, - 'authData': authenticator_data, - 'signature': signature_data, - 'assertionClientExtensions': - webauthn_b64_decode(assertion_client_extensions) - if assertion_client_extensions - else None - }, - challenge=webauthn_b64_encode(challenge), - origin=http_origin, - allow_credentials=[user.credential_id], - uv_required=uv_req - ).verify()) - except AuthenticationRejectedException as e: - # The authentication ceremony failed. - log.warning("Checking response for token {0!s} failed. {1!s}".format(self.token.serial, e)) - return -1 - - # At this point we can check, if the attestation certificate is - # authorized. If not, we can raise a policy exception. - if not attestation_certificate_allowed( - { - "attestation_issuer": self.get_tokeninfo(WEBAUTHNINFO.ATTESTATION_ISSUER), - "attestation_serial": self.get_tokeninfo(WEBAUTHNINFO.ATTESTATION_SERIAL), - "attestation_subject": self.get_tokeninfo(WEBAUTHNINFO.ATTESTATION_SUBJECT) - }, - getParam(options, WEBAUTHNACTION.REQ, optional) - ): - log.warning( - "The WebAuthn token {0!s} is not allowed to authenticate " - "due to policy restriction {1!s}".format(self.token.serial, WEBAUTHNACTION.REQ)) - raise PolicyError("The WebAuthn token is not allowed to " - "authenticate due to a policy restriction.") - - # Now we need to check, if a whitelist for AAGUIDs exists, and if - # so, if this device is whitelisted. If not, we again raise a - # policy exception. - allowed_aaguids = getParam(options, WEBAUTHNACTION.AUTHENTICATOR_SELECTION_LIST, optional) - if allowed_aaguids and self.get_tokeninfo(WEBAUTHNINFO.AAGUID) not in allowed_aaguids: - log.warning( - "The WebAuthn token {0!s} is not allowed to authenticate due to policy " - "restriction {1!s}".format(self.token.serial, WEBAUTHNACTION.AUTHENTICATOR_SELECTION_LIST)) - raise PolicyError("The WebAuthn token is not allowed to " - "authenticate due to a policy restriction.") - - # All clear? Nice! - return self.get_otp_count() + auth_id = credential_id + auth_raw_id = base64url_to_bytes(auth_id) + + response = { + "authenticatorData": webauthn_b64_decode(authenticator_data), + "clientDataJSON": webauthn_b64_decode(client_data), + "signature": webauthn_b64_decode(signature_data), + "userHandle": user_handle + } + raw_auth_response = { + "rawId": auth_raw_id, + "id": auth_id, + "response": response + } + verified_authentication = verify_authentication_response( + credential=AuthenticationCredential.parse_obj( + raw_auth_response), + expected_challenge=binascii.unhexlify(options["challenge"]), + expected_rp_id=user.rp_id, + expected_origin=options["HTTP_ORIGIN"], + credential_public_key=base64url_to_bytes(user.public_key), + credential_current_sign_count=user.sign_count, + require_user_verification=uv_required + ) + sign_count = verified_authentication.new_sign_count + self.set_otp_count(sign_count) + + return sign_count + + except InvalidAuthenticationResponse as ex: + log.warning(ex) + return -1 else: - # Not all necessary data provided. return -1 diff --git a/privacyidea/models.py b/privacyidea/models.py index 35dda42b38891023e64937484e198a88de3c5d82..3e78a7945dfbeaf4c954571e980c1f4c81b3b0ce 100644 --- a/privacyidea/models.py +++ b/privacyidea/models.py @@ -107,10 +107,10 @@ def save_config_timestamp(invalidate_config=True): """ c1 = Config.query.filter_by(Key=PRIVACYIDEA_TIMESTAMP).first() if c1: - c1.Value = datetime.now().strftime("%s") + c1.Value = datetime.now().strftime("%S") else: new_timestamp = Config(PRIVACYIDEA_TIMESTAMP, - datetime.now().strftime("%s"), + datetime.now().strftime("%S"), Description="config timestamp. last changed.") db.session.add(new_timestamp) if invalidate_config: diff --git a/privacyidea/static/components/token/views/token.enroll.backupcode.html b/privacyidea/static/components/token/views/token.enroll.backupcode.html new file mode 100644 index 0000000000000000000000000000000000000000..0fcc96e502ba9b231e0d65629bf7b1c398f1b3c9 --- /dev/null +++ b/privacyidea/static/components/token/views/token.enroll.backupcode.html @@ -0,0 +1,5 @@ +<p class="help-block" translate> + The TAN token will let you print a list of OTP values. + These OTP values can be used to authenticate. The values can be used in an + arbitrary order. +</p> \ No newline at end of file diff --git a/privacyidea/static/components/token/views/token.enrolled.backupcode.html b/privacyidea/static/components/token/views/token.enrolled.backupcode.html new file mode 100644 index 0000000000000000000000000000000000000000..7c1889a48fbb1db5ed3e7060a817ea87ef35bcdf --- /dev/null +++ b/privacyidea/static/components/token/views/token.enrolled.backupcode.html @@ -0,0 +1,58 @@ +<div class="row"> + <div class="col-sm-12"> + <uib-accordion close-others="oneAtATime"> + <div uib-accordion-group + class="panel-default" + heading="{{ 'The OTP values'|translate }}"> + <div id="paperOtpTable"> + <div ng-include="instanceUrl+'/'+piCustomization+ + '/views/includes/token.enrolled.tan.top.html'"></div> + <div class="table-responsive"> + <table class="table table-bordered table-striped + tantoken"> + <thead> + <tr> + <th translate>#</th> + <th>OTP</th> + <th translate>#</th> + <th>OTP</th> + <th translate>#</th> + <th>OTP</th> + <th translate>#</th> + <th>OTP</th> + <th translate>#</th> + <th>OTP</th> + </tr> + </thead> + <tbody> + <tr ng-repeat="key in otp_rows"> + <td>{{ $index }}</td> + <td>{{ enrolledToken.otps[$index] }}</td> + <td>{{ $index+(1*otp_row_count) }}</td> + <td>{{ enrolledToken.otps[$index+ + (1*otp_row_count)] }}</td> + <td>{{ $index+(2*otp_row_count) }}</td> + <td>{{ enrolledToken.otps[$index+ + (2*otp_row_count)] }}</td> + <td>{{ $index+(3*otp_row_count) }}</td> + <td>{{ enrolledToken.otps[$index+ + (3*otp_row_count)] }}</td> + <td>{{ $index+(4*otp_row_count) }}</td> + <td>{{ enrolledToken.otps[$index+ + (4*otp_row_count)] }}</td> + </tr> + </tbody> + </table> + </div> + <div ng-include="instanceUrl+'/'+piCustomization+ + '/views/includes/token.enrolled.tan.bottom.html'"></div> + </div> + </div> + </uib-accordion> + </div> +</div> +<button class="btn-default btn" + ng-click="printOtp()"> + <span class="glyphicon glyphicon-print"></span> + <span translate>Print the OTP list</span> +</button> \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 0eeb52bac6d6d14baa6d71a6c308fcc3998f7d6a..df3f0a48e4ea481589f51de0e7e2e4f0bde2ec9b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -106,3 +106,4 @@ zipp==3.8.0; python_version > '3.6' grpcio==1.46.0; python_version >= '3.7' grpcio-tools==1.46.0; python_version >= '3.7' protobuf==3.20.2; python_version >= '3.7' +webauthn~=1.6 diff --git a/setup.py b/setup.py index 2019498435c0e1a6367863286b4b0def52f6bd65..1a4d2b5dde1c2ba8b32ad2ea9050d1d88c990eec 100644 --- a/setup.py +++ b/setup.py @@ -62,6 +62,7 @@ install_requires = ["beautifulsoup4[lxml]>=4.3.2", "segno>=1.5", "smpplib>=2.0", "SQLAlchemy>=1.3.0,<1.4.0", + "webauthn~=1.6", "sqlsoup>=0.9.0"] diff --git a/tests/test_lib_tokens_backupcode.py b/tests/test_lib_tokens_backupcode.py new file mode 100644 index 0000000000000000000000000000000000000000..2e3103befc018f93ce4459f2719e87e761e8e693 --- /dev/null +++ b/tests/test_lib_tokens_backupcode.py @@ -0,0 +1,150 @@ +""" +This test file tests the lib.tokens.backupcodetoken +This depends on lib.tokenclass +""" +import ast + +from privacyidea.lib.token import init_token +from privacyidea.lib.tokens.backupcodetoken import BackupCodeTokenClass +from privacyidea.lib.tokens.backupcodetoken import DEFAULT_COUNT +from privacyidea.lib.tokens.backupcodetoken import DEFAULT_LENGTH +from privacyidea.models import Token +from .base import MyTestCase + +""" + Tokens need to be of configured length in order for pin-parsing to work. + Pin and otp value are passed as a single string which is then split based on + the length of the OTP. +""" +VALID_OTP_1 = "a" * DEFAULT_LENGTH +VALID_OTP_2 = "b" * DEFAULT_LENGTH +INVALID_OTP = "n" * DEFAULT_LENGTH + +VALID_PIN = "valid_pin" +INVALID_PIN = "invalid_pin" + +SUCCESS = 1 +FAILURE = -1 + + +class BackupCodeTokenTestCase(MyTestCase): + serial1 = "ser1" + + def get_fresh_token(self, set_pin=False): + token = init_token({"type": "backupcode"}) + token.set_tokeninfo( + {"otps": f"['{VALID_OTP_1}', '{VALID_OTP_2}']", "used_otps": "[]"}) + + if set_pin: + token.set_pin(VALID_PIN) + + return token + + def test_01_create_token(self): + db_token = Token(self.serial1, tokentype="backupcode") + db_token.save() + token = BackupCodeTokenClass(db_token) + token.update({}) + + self.assertEqual(token.token.serial, self.serial1) + self.assertEqual(token.token.tokentype, "backupcode") + self.assertEqual(token.type, "backupcode") + + class_prefix = token.get_class_prefix() + + self.assertEqual(class_prefix, "BUC") + self.assertEqual(token.get_class_type(), "backupcode") + + def test_02_class_methods(self): + db_token = Token.query.filter(Token.serial == self.serial1).first() + token = BackupCodeTokenClass(db_token) + + info = token.get_class_info() + self.assertEqual(info.get("title"), "Backup Code Token") + + info = token.get_class_info("title") + self.assertEqual(info, "Backup Code Token") + + def test_03_get_init_details(self): + db_token = Token.query.filter(Token.serial == self.serial1).first() + token = BackupCodeTokenClass(db_token) + token.update({}) + + # make sure OTPs were created + init_detail = token.get_init_detail() + self.assertTrue("otps" in init_detail) + + # make sure there's correct (configured) number of generated OTPs + frontend_otps = init_detail.get("otps") + backend_otps = ast.literal_eval(token.get_tokeninfo("otps")) + self.assertEqual(frontend_otps, backend_otps) + self.assertTrue(len(frontend_otps) == DEFAULT_COUNT) + + used_otps = ast.literal_eval(token.get_tokeninfo("used_otps")) + self.assertEqual(used_otps, []) + + # make sure the generated OTPs are of correct (configured) length + for otp in frontend_otps: + self.assertTrue(len(otp) == DEFAULT_LENGTH) + + def test_04_check_unused_existent_otp(self): + token = self.get_fresh_token() + valid_otp_check_result = token.check_otp(VALID_OTP_1) + used_otps = ast.literal_eval(token.get_tokeninfo("used_otps")) + + self.assertEquals(valid_otp_check_result, SUCCESS) + self.assertEquals(used_otps, [VALID_OTP_1]) + + def test_05_check_used_existent_otp(self): + token = self.get_fresh_token() + + self.assertEquals(token.check_otp(VALID_OTP_1), SUCCESS) + self.assertEquals(token.check_otp(VALID_OTP_1), FAILURE) + + def test_06_check_non_existent_otp(self): + token = self.get_fresh_token() + all_otps = ast.literal_eval(token.get_tokeninfo("otps")) + + self.assertTrue(INVALID_OTP not in all_otps) + self.assertEquals(token.check_otp(INVALID_OTP), FAILURE) + + def test_07_authenticate_with_correct_pin_and_correct_otp(self): + token = self.get_fresh_token(set_pin=True) + + auth_info = VALID_PIN + VALID_OTP_1 + is_pin_correct, otp_auth_result, _ = token.authenticate(auth_info) + + self.assertTrue(is_pin_correct) + self.assertEqual(otp_auth_result, SUCCESS) + + def test_08_authenticate_with_incorrect_pin_and_correct_otp(self): + token = self.get_fresh_token(set_pin=True) + + auth_info = INVALID_PIN + VALID_OTP_1 + is_pin_correct, otp_auth_result, _ = token.authenticate(auth_info) + + self.assertFalse(is_pin_correct) + self.assertEqual(otp_auth_result, FAILURE) + + def test_08_authenticate_with_correct_pin_and_incorrect_otp(self): + token = self.get_fresh_token(set_pin=True) + + auth_info = VALID_PIN + INVALID_OTP + is_pin_correct, otp_auth_result, _ = token.authenticate(auth_info) + + self.assertTrue(is_pin_correct) + self.assertEqual(otp_auth_result, FAILURE) + + def test_08_authenticate_without_pin_correct_otp(self): + token = self.get_fresh_token() + + is_pin_correct, otp_auth_result, _ = token.authenticate(VALID_OTP_1) + self.assertTrue(is_pin_correct) + self.assertEqual(otp_auth_result, SUCCESS) + + def test_08_authenticate_without_pin_incorrect_otp(self): + token = self.get_fresh_token() + + is_pin_correct, otp_auth_result, _ = token.authenticate(INVALID_OTP) + self.assertTrue(is_pin_correct) + self.assertEqual(otp_auth_result, FAILURE) diff --git a/tests/test_lib_tokens_webauthn.py b/tests/test_lib_tokens_webauthn.py index a795f87df61701722a2841a9950a83937eb1ef94..29b40ff693fb522c0806305323be649d16f2aa23 100644 --- a/tests/test_lib_tokens_webauthn.py +++ b/tests/test_lib_tokens_webauthn.py @@ -62,7 +62,6 @@ import struct import unittest from copy import copy from mock import patch -from testfixtures import log_capture from privacyidea.lib.user import User from privacyidea.lib.config import set_privacyidea_config @@ -80,7 +79,6 @@ from privacyidea.lib.tokens.webauthntoken import (WebAuthnTokenClass, WEBAUTHNAC from privacyidea.lib.token import init_token, check_user_pass, remove_token from privacyidea.lib.policy import set_policy, SCOPE, ACTION, delete_policy from privacyidea.lib.challenge import get_challenges -from privacyidea.lib.error import PolicyError, ParameterError, EnrollmentError TRUST_ANCHOR_DIR = "{}/testdata/trusted_attestation_roots".format(os.path.abspath(os.path.dirname(__file__))) REGISTRATION_RESPONSE_TMPL = { @@ -116,12 +114,11 @@ CRED_KEY = { 'alg': -7, 'type': 'public-key' } -CREDENTIAL_ID = b'ilNaaY5fYJoR1sg5IB7FL2Zoa-qBd_5Q95ZcyxNkmjkoDhiLCLgEKoKfCUElLt6_6Dmj_EUuOHZUI6x_gC32LQ' REGISTRATION_CHALLENGE = 'bPzpX3hHQtsp9evyKYkaZtVc9UN07PUdJ22vZUdDp94' ASSERTION_CHALLENGE = 'e-g-nXaRxMagEiqTJSyD82RsEc5if_6jyfJDy8bNKlw' RP_ID = "webauthn.io" ORIGIN = "https://webauthn.io" -USER_NAME = 'hans' +USER_NAME = 'testuser' ICON_URL = "https://example.com/icon.png" USER_DISPLAY_NAME = "A Test User" USER_ID = b'\x80\xf1\xdc\xec\xb5\x18\xb1\xc8b\x05\x886\xbc\xdfJ\xdf' @@ -171,47 +168,11 @@ NONE_ATTESTATION_CRED_ID = 'XyBvPatPc3biviiMX2Rkq5Vj-W6Pk6RtNi6r7v0dSgrLYaPxxWi0 NONE_ATTESTATION_PUB_KEY = 'a5010203262001215820d210be8ddfb5b0bc0be1ea8deaedec197e43e1fece4eb95791e97e1d9219491f22582'\ '0f80e408b103d424808474999556a4e2c76453cfd114295a39bc9325a83b84416' -SELF_ATTESTATION_REGISTRATION_RESPONSE_TMPL = { - "clientData": "eyJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjMwMDAiLCJjaGFsbGVuZ2Ui" - "OiJBWGtYV1hQUDNnTHg4T0xscGtKM2FSUmhGV250blNFTmdnbmpEcEJxbDFu" - "Z0tvbDd4V3dldlVZdnJwQkRQM0xFdmRyMkVPU3RPRnBHR3huTXZYay1WdyIs" - "InR5cGUiOiJ3ZWJhdXRobi5jcmVhdGUifQ", - "attObj": "o2NmbXRmcGFja2VkZ2F0dFN0bXSiY2FsZzn__mNzaWdZAQCPypMLXWqtCZ1sc5Qd" - "jhH-pAzm8-adpfbemd5zsym2krscwV0EeOdTrdUOdy3hWj5HuK9dIX_OpNro2jKr" - "HfUj_0Kp-u87iqJ3MPzs-D9zXOqkbWqcY94Zh52wrPwhGfJ8BiQp5T4Q97E042hY" - "QRDKmtv7N-BT6dywiuFHxfm1sDbUZ_yyEIN3jgttJzjp_wvk_RJmb78bLPTlym83" - "Y0Ws73K6FFeiqFNqLA_8a4V0I088hs_IEPlj8PWxW0wnIUhI9IcRf0GEmUwTBpbN" - "DGpIFGOudnl_C3YuXuzK3R6pv2r7m9-9cIIeeYXD9BhSMBQ0A8oxBbVF7j-0xXDN" - "rXHZaGF1dGhEYXRhWQFnSZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NB" - "AAAAOKjVmSRjt0nqud40p1PeHgEAIB-l9gZ544Ds7vzo_O76UZ8DCXiWFc8DN8LW" - "NZYQH0NepAEDAzn__iBZAQDAIqzybPPmgeL5OR6JKq9bWDiENJlN_LePQEnf1_sg" - "Om4FJ9kBTbOTtWplfoMXg40A7meMppiRqP72A3tmILwZ5xKIyY7V8Y2t8X1ilYJo" - "l2nCKOpAEqGLTRJjF64GQxen0uFpi1tA6l6N-ZboPxjky4aidBdUP22YZuEPCO8-" - "9ZTha8qwvTgZwMHhZ40TUPEJGGWOnHNlYmqnfFfk0P-UOZokI0rqtqqQGMwzV2Rr" - "H2kjKTZGfyskAQnrqf9PoJkye4KUjWkWnZzhkZbrDoLyTEX2oWvTTflnR5tAVMQc" - "h4UGgEHSZ00G5SFoc19nGx_UJcqezx5cLZsny-qQYDRjIUMBAAE" -} - -SELF_ATTESTATION_REGISTRATION_RESPONSE_BAD_COSE_ALG = { - 'clientData': SELF_ATTESTATION_REGISTRATION_RESPONSE_TMPL['clientData'], - 'attObj': SELF_ATTESTATION_REGISTRATION_RESPONSE_TMPL['attObj'][:529] - + 'y' + SELF_ATTESTATION_REGISTRATION_RESPONSE_TMPL['attObj'][530:] -} - -SELF_ATTESTATION_REGISTRATION_RESPONSE_ALG_MISMATCH = { - 'clientData': SELF_ATTESTATION_REGISTRATION_RESPONSE_TMPL['clientData'], - 'attObj': SELF_ATTESTATION_REGISTRATION_RESPONSE_TMPL['attObj'][:37] - + '2' + SELF_ATTESTATION_REGISTRATION_RESPONSE_TMPL['attObj'][38:] -} - -SELF_ATTESTATION_REGISTRATION_RESPONSE_BROKEN_SIG = { - 'clientData': SELF_ATTESTATION_REGISTRATION_RESPONSE_TMPL['clientData'], - 'attObj': SELF_ATTESTATION_REGISTRATION_RESPONSE_TMPL['attObj'][:46] - + 'AA' + SELF_ATTESTATION_REGISTRATION_RESPONSE_TMPL['attObj'][48:] -} - class WebAuthnTokenTestCase(MyTestCase): + USER_LOGIN = "testuser" + USER_REALM = "testrealm" + USER_RESOLVER = "testresolver" def _create_challenge(self): self.token.set_otpkey(hexlify_and_unicode(webauthn_b64_decode(CRED_ID))) @@ -220,37 +181,11 @@ class WebAuthnTokenTestCase(MyTestCase): (_, _, _, response_details) = self.token.create_challenge(options=self.challenge_options) return response_details - def _setup_token(self): - with patch('privacyidea.lib.tokens.webauthntoken.WebAuthnTokenClass._get_nonce') as mock_nonce: - mock_nonce.return_value = webauthn_b64_decode(REGISTRATION_CHALLENGE) - self.token.get_init_detail(self.init_params, self.user) - self.token.update({ - 'type': 'webauthn', - 'serial': self.token.token.serial, - 'regdata': REGISTRATION_RESPONSE_TMPL['attObj'], - 'clientdata': REGISTRATION_RESPONSE_TMPL['clientData'], - WEBAUTHNACTION.RELYING_PARTY_ID: RP_ID, - WEBAUTHNACTION.AUTHENTICATOR_ATTESTATION_LEVEL: ATTESTATION_LEVEL.NONE, - 'HTTP_ORIGIN': ORIGIN - }) - def setUp(self): - self.setUp_user_realms() - - set_policy(name="WebAuthn", - scope=SCOPE.ENROLL, - action=WEBAUTHNACTION.RELYING_PARTY_NAME + "=" + RP_NAME + "," - + WEBAUTHNACTION.RELYING_PARTY_ID + "=" + RP_ID) - set_privacyidea_config(WEBAUTHNCONFIG.TRUST_ANCHOR_DIR, TRUST_ANCHOR_DIR) - set_privacyidea_config(WEBAUTHNCONFIG.APP_ID, APP_ID) - - self.user = User(login=USER_NAME, realm=self.realm1, - resolver=self.resolvername1) - self.token = init_token({ 'type': 'webauthn', 'pin': '1234' - }, user=self.user) + }) self.init_params = { WEBAUTHNACTION.RELYING_PARTY_ID: RP_ID, @@ -258,9 +193,11 @@ class WebAuthnTokenTestCase(MyTestCase): WEBAUTHNACTION.TIMEOUT: TIMEOUT, WEBAUTHNACTION.AUTHENTICATOR_ATTESTATION_FORM: DEFAULT_AUTHENTICATOR_ATTESTATION_FORM, WEBAUTHNACTION.USER_VERIFICATION_REQUIREMENT: DEFAULT_USER_VERIFICATION_REQUIREMENT, - WEBAUTHNACTION.PUBLIC_KEY_CREDENTIAL_ALGORITHMS: PUBLIC_KEY_CREDENTIAL_ALGORITHM_PREFERENCE + WEBAUTHNACTION.PUBLIC_KEY_CREDENTIAL_ALGORITHM_PREFERENCE: PUBLIC_KEY_CREDENTIAL_ALGORITHM_PREFERENCE } + self.user = User(login=USER_NAME) + self.challenge_options = { "user": self.user, WEBAUTHNACTION.ALLOWED_TRANSPORTS: ALLOWED_TRANSPORTS, @@ -268,9 +205,15 @@ class WebAuthnTokenTestCase(MyTestCase): WEBAUTHNACTION.TIMEOUT: TIMEOUT } - def tearDown(self): - self.token.delete_token() - delete_policy("WebAuthn") + def test_00_users(self): + self.setUp_user_realms() + + set_policy(name="WebAuthn", + scope=SCOPE.ENROLL, + action=WEBAUTHNACTION.RELYING_PARTY_NAME + "=" + RP_NAME + "," + +WEBAUTHNACTION.RELYING_PARTY_ID + "=" + RP_ID) + set_privacyidea_config(WEBAUTHNCONFIG.TRUST_ANCHOR_DIR, TRUST_ANCHOR_DIR) + set_privacyidea_config(WEBAUTHNCONFIG.APP_ID, APP_ID) def test_01_create_token(self): self.assertEqual(self.token.type, "webauthn") @@ -280,8 +223,6 @@ class WebAuthnTokenTestCase(MyTestCase): self.assertTrue(self.token.token.serial.startswith("WAN")) with self.assertRaises(ValueError): self.token.get_init_detail() - with self.assertRaises(ParameterError): - self.token.get_init_detail(self.init_params) def test_02_token_init(self): web_authn_register_request = self\ @@ -294,23 +235,28 @@ class WebAuthnTokenTestCase(MyTestCase): self.assertIn('name', web_authn_register_request.get("relyingParty")) self.assertEqual(RP_ID, web_authn_register_request.get("relyingParty").get('id')) self.assertEqual(RP_NAME, web_authn_register_request.get("relyingParty").get('name')) - self.assertIn('alg', web_authn_register_request.get("pubKeyCredAlgorithms")[0], - web_authn_register_request) - self.assertIn('type', web_authn_register_request.get("pubKeyCredAlgorithms")[0], - web_authn_register_request) - self.assertEqual('public-key', - web_authn_register_request.get("pubKeyCredAlgorithms")[0].get("type"), - web_authn_register_request) - self.assertEqual(COSE_ALGORITHM.ES256, - web_authn_register_request.get("pubKeyCredAlgorithms")[0].get("alg"), - web_authn_register_request) + self.assertIn('alg', web_authn_register_request.get("preferredAlgorithm")) + self.assertIn('type', web_authn_register_request.get("preferredAlgorithm")) + self.assertEqual('public-key', web_authn_register_request.get("preferredAlgorithm").get("type")) + self.assertEqual(COSE_ALGORITHM.ES256, web_authn_register_request.get("preferredAlgorithm").get("alg")) self.assertEqual(USER_NAME, web_authn_register_request.get("name")) self.assertEqual(RP_ID, self.token.get_tokeninfo(WEBAUTHNINFO.RELYING_PARTY_ID)) self.assertEqual(RP_NAME, self.token.get_tokeninfo(WEBAUTHNINFO.RELYING_PARTY_NAME)) def test_03_token_update(self): - self._setup_token() + with patch('privacyidea.lib.tokens.webauthntoken.WebAuthnTokenClass._get_nonce') as mock_nonce: + mock_nonce.return_value = webauthn_b64_decode(REGISTRATION_CHALLENGE) + self.token.get_init_detail(self.init_params, self.user) + self.token.update({ + 'type': 'webauthn', + 'serial': self.token.token.serial, + 'regdata': REGISTRATION_RESPONSE_TMPL['attObj'], + 'clientdata': REGISTRATION_RESPONSE_TMPL['clientData'], + WEBAUTHNACTION.RELYING_PARTY_ID: RP_ID, + WEBAUTHNACTION.AUTHENTICATOR_ATTESTATION_LEVEL: ATTESTATION_LEVEL.NONE, + 'HTTP_ORIGIN': ORIGIN + }) web_authn_registration_response = self.token.get_init_detail().get("webAuthnRegisterResponse") self.assertTrue(web_authn_registration_response @@ -334,31 +280,24 @@ class WebAuthnTokenTestCase(MyTestCase): self.assertEqual(WebAuthnTokenClass.get_class_info('type'), "webauthn") self.assertTrue(self.token.token.serial.startswith("WAN")) - # we need to create an additional token for this to take effect - temp_token = init_token({ - 'type': 'webauthn', - 'pin': '1234' - }, user=self.user) - # No avoid double registration init_params = self.init_params - web_authn_register_request = temp_token \ + web_authn_register_request = self \ + .token \ .get_init_detail(init_params, self.user) \ .get("webAuthnRegisterRequest") - self.assertEqual(temp_token.token.serial, web_authn_register_request.get("serialNumber")) + self.assertEqual(self.token.token.serial, web_authn_register_request.get("serialNumber")) self.assertNotIn("excludeCredentials", web_authn_register_request) - self._setup_token() - # Set avoid double registration init_params[WEBAUTHNACTION.AVOID_DOUBLE_REGISTRATION] = True - web_authn_register_request = temp_token \ + web_authn_register_request = self \ + .token \ .get_init_detail(init_params, self.user) \ .get("webAuthnRegisterRequest") - self.assertEqual(temp_token.token.serial, web_authn_register_request.get("serialNumber")) + self.assertEqual(self.token.token.serial, web_authn_register_request.get("serialNumber")) # Now the excludeCredentials is contained self.assertIn("excludeCredentials", web_authn_register_request) - temp_token.delete_token() def test_04_authentication(self): reply_dict = self._create_challenge() @@ -386,27 +325,20 @@ class WebAuthnTokenTestCase(MyTestCase): }) self.assertTrue(sign_count > 0) - @log_capture() - def test_06_missing_origin(self, capture): + def test_06_missing_origin(self): + self.challenge_options['nonce'] = webauthn_b64_decode(ASSERTION_CHALLENGE) self._create_challenge() - sign_count = self.token.check_otp( - otpval=None, - options={ - "credentialid": CRED_ID, - "authenticatordata": ASSERTION_RESPONSE_TMPL['authData'], - "clientdata": ASSERTION_RESPONSE_TMPL['clientData'], - "signaturedata": ASSERTION_RESPONSE_TMPL['signature'], - "user": self.user, - "challenge": hexlify_and_unicode(webauthn_b64_decode(ASSERTION_CHALLENGE)), - "HTTP_ORIGIN": '', - }) + sign_count = self.token.check_otp(otpval=None, + options={ + "credentialid": CRED_ID, + "authenticatordata": ASSERTION_RESPONSE_TMPL['authData'], + "clientdata": ASSERTION_RESPONSE_TMPL['clientData'], + "signaturedata": ASSERTION_RESPONSE_TMPL['signature'], + "user": self.user, + "challenge": hexlify_and_unicode(self.challenge_options['nonce']), + "HTTP_ORIGIN": '', + }) self.assertTrue(sign_count == -1) - capture.check_present( - ('privacyidea.lib.tokens.webauthntoken', - 'WARNING', - 'Checking response for token {0!s} failed. HTTP Origin header ' - 'missing.'.format(self.token.get_serial())) - ) def test_07_none_attestation(self): with patch('privacyidea.lib.tokens.webauthntoken.WebAuthnTokenClass._get_nonce') as mock_nonce: @@ -433,7 +365,7 @@ class WebAuthnTokenTestCase(MyTestCase): self.user = User(login=NONE_ATTESTATION_USER_NAME) self.token.get_init_detail(self.init_params, self.user) - with self.assertRaises(EnrollmentError): + with self.assertRaises(RegistrationRejectedException): self.token.update({ 'type': 'webauthn', 'serial': self.token.token.serial, @@ -444,78 +376,10 @@ class WebAuthnTokenTestCase(MyTestCase): 'HTTP_ORIGIN': ORIGIN }) - def test_09a_attestation_subject_allowed(self): - self._setup_token() - options = { - "credentialid": CRED_ID, - "authenticatordata": ASSERTION_RESPONSE_TMPL['authData'], - "clientdata": ASSERTION_RESPONSE_TMPL['clientData'], - "signaturedata": ASSERTION_RESPONSE_TMPL['signature'], - "user": self.user, - "challenge": hexlify_and_unicode(webauthn_b64_decode(ASSERTION_CHALLENGE)), - "HTTP_ORIGIN": ORIGIN, - WEBAUTHNACTION.REQ: ['subject/.*Yubico.*/'] - } - res = self.token.check_otp(otpval=None, options=options) - self.assertGreaterEqual(res, 0) - - def test_09b_attestation_subject_not_allowed(self): - self._setup_token() - options = { - "credentialid": CRED_ID, - "authenticatordata": ASSERTION_RESPONSE_TMPL['authData'], - "clientdata": ASSERTION_RESPONSE_TMPL['clientData'], - "signaturedata": ASSERTION_RESPONSE_TMPL['signature'], - "user": self.user, - "challenge": hexlify_and_unicode(webauthn_b64_decode(ASSERTION_CHALLENGE)), - "HTTP_ORIGIN": ORIGIN, - WEBAUTHNACTION.REQ: ['subject/.*Feitian.*/'] - } - self.assertRaisesRegexp(PolicyError, - "The WebAuthn token is not allowed to authenticate " - "due to a policy restriction.", - self.token.check_otp, - **{'otpval': None, - 'options': options}) - - def test_10a_aaguid_allowed(self): - self._setup_token() - # check token with a valid aaguid - res = self.token.check_otp(otpval=None, options={ - "credentialid": CRED_ID, - "authenticatordata": ASSERTION_RESPONSE_TMPL['authData'], - "clientdata": ASSERTION_RESPONSE_TMPL['clientData'], - "signaturedata": ASSERTION_RESPONSE_TMPL['signature'], - "user": self.user, - "challenge": hexlify_and_unicode(webauthn_b64_decode(ASSERTION_CHALLENGE)), - "HTTP_ORIGIN": ORIGIN, - WEBAUTHNACTION.AUTHENTICATOR_SELECTION_LIST: ['00000000000000000000000000000000'] - }) - self.assertGreaterEqual(res, 0) - - def test_10b_aaguid_not_allowed(self): - self._setup_token() - # check token with an invalid aaguid - self.assertRaisesRegexp(PolicyError, - "The WebAuthn token is not allowed to authenticate " - "due to a policy restriction.", - self.token.check_otp, - **{'otpval': None, - 'options': { - "credentialid": CRED_ID, - "authenticatordata": ASSERTION_RESPONSE_TMPL['authData'], - "clientdata": ASSERTION_RESPONSE_TMPL['clientData'], - "signaturedata": ASSERTION_RESPONSE_TMPL['signature'], - "user": self.user, - "challenge": hexlify_and_unicode(webauthn_b64_decode(ASSERTION_CHALLENGE)), - "HTTP_ORIGIN": ORIGIN, - WEBAUTHNACTION.AUTHENTICATOR_SELECTION_LIST: ['ffff0000000000000000000000000000'] - }}) - class WebAuthnTestCase(unittest.TestCase): @staticmethod - def getWebAuthnCredential(): + def getVerifiedRegistration(): return WebAuthnRegistrationResponse( rp_id=RP_ID, origin=ORIGIN, @@ -529,16 +393,16 @@ class WebAuthnTestCase(unittest.TestCase): @staticmethod def getAssertionResponse(): - credential = WebAuthnTestCase.getWebAuthnCredential() + verified_registration = WebAuthnTestCase.getVerifiedRegistration() webauthn_user = WebAuthnUser( user_id=USER_ID, user_name=USER_NAME, user_display_name=USER_DISPLAY_NAME, icon_url=ICON_URL, - credential_id=credential.credential_id.decode(), - public_key=credential.public_key, - sign_count=credential.sign_count, - rp_id=credential.rp_id + credential_id=verified_registration.credential_id.decode(), + public_key=verified_registration.credential_public_key, + sign_count=verified_registration.sign_count, + rp_id=RP_ID ) webauthn_assertion_response = WebAuthnAssertionResponse( @@ -574,30 +438,9 @@ class WebAuthnTestCase(unittest.TestCase): self.assertTrue(CRED_KEY in registration_dict['pubKeyCredParams']) def test_01_validate_registration(self): - webauthn_credential = self.getWebAuthnCredential() - self.assertEqual(RP_ID, webauthn_credential.rp_id, webauthn_credential) - self.assertEqual(ORIGIN, webauthn_credential.origin, webauthn_credential) - self.assertTrue(webauthn_credential.has_signed_attestation, webauthn_credential) - self.assertTrue(webauthn_credential.has_trusted_attestation, webauthn_credential) - self.assertEqual(str(webauthn_credential), - '{0!r} ({1!s}, {2!s}, {3!s})'.format(CREDENTIAL_ID, - RP_ID, ORIGIN, 0), - webauthn_credential) - - def test_01b_validate_untrusted_registration(self): - webauthn_credential = WebAuthnRegistrationResponse( - rp_id=RP_ID, - origin=ORIGIN, - registration_response=copy(REGISTRATION_RESPONSE_TMPL), - challenge=REGISTRATION_CHALLENGE, - attestation_requirement_level=ATTESTATION_REQUIREMENT_LEVEL[ATTESTATION_LEVEL.NONE], - uv_required=False, - expected_registration_client_extensions=EXPECTED_REGISTRATION_CLIENT_EXTENSIONS, - ).verify() - self.assertEqual(RP_ID, webauthn_credential.rp_id, webauthn_credential) - self.assertEqual(ORIGIN, webauthn_credential.origin, webauthn_credential) - self.assertTrue(webauthn_credential.has_signed_attestation, webauthn_credential) - self.assertFalse(webauthn_credential.has_trusted_attestation, webauthn_credential) + verified_registration = self.getVerifiedRegistration() + self.assertEqual(CRED_ID, verified_registration.credential_id) + self.assertEqual(CRED_KEY, verified_registration.credential_public_key) def test_02_registration_invalid_user_verification(self): registration_response = WebAuthnRegistrationResponse( @@ -611,17 +454,11 @@ class WebAuthnTestCase(unittest.TestCase): expected_registration_client_extensions=EXPECTED_REGISTRATION_CLIENT_EXTENSIONS ) - with self.assertRaisesRegexp(RegistrationRejectedException, - 'Malformed request received.'): + with self.assertRaises(RegistrationRejectedException): registration_response.verify() def test_03_validate_assertion(self): webauthn_assertion_response = self.getAssertionResponse() - webauthn_user = webauthn_assertion_response.webauthn_user - self.assertEqual(str(webauthn_user), - '{0!r} ({1!s}, {2!s}, {3!s})'.format(USER_ID, USER_NAME, - USER_DISPLAY_NAME, 0), - webauthn_user) webauthn_assertion_response.verify() def test_04_invalid_signature_fail_assertion(self): @@ -666,76 +503,6 @@ class WebAuthnTestCase(unittest.TestCase): def test_07_webauthn_b64_decode(self): self.assertEqual(webauthn_b64_decode(URL_DECODE_TEST_STRING), URL_DECODE_EXPECTED_RESULT) - def test_08_registration_invalid_requirement_level(self): - with self.assertRaisesRegexp(ValueError, - 'Illegal attestation_requirement_level.'): - WebAuthnRegistrationResponse( - rp_id=RP_ID, - origin=ORIGIN, - registration_response=copy(REGISTRATION_RESPONSE_TMPL), - challenge=REGISTRATION_CHALLENGE, - attestation_requirement_level={'unknown level': False}, - trust_anchor_dir=TRUST_ANCHOR_DIR, - uv_required=True, - expected_registration_client_extensions=EXPECTED_REGISTRATION_CLIENT_EXTENSIONS - ) - - def test_09_registration_self_Attestation(self): - webauthn_credential = WebAuthnRegistrationResponse( - rp_id='localhost', - origin='http://localhost:3000', - registration_response=copy(SELF_ATTESTATION_REGISTRATION_RESPONSE_TMPL), - challenge='AXkXWXPP3gLx8OLlpkJ3aRRhFWntnSENggnjDpBql1ngKol7xWwevUYvrpBDP3LEvdr2EOStOFpGGxnMvXk-Vw', - attestation_requirement_level=ATTESTATION_REQUIREMENT_LEVEL[ATTESTATION_LEVEL.UNTRUSTED], - trust_anchor_dir=TRUST_ANCHOR_DIR, - expected_registration_client_extensions=EXPECTED_REGISTRATION_CLIENT_EXTENSIONS - ).verify() - self.assertEqual('localhost', webauthn_credential.rp_id, webauthn_credential) - self.assertEqual('http://localhost:3000', webauthn_credential.origin, webauthn_credential) - self.assertTrue(webauthn_credential.has_signed_attestation, webauthn_credential) - self.assertFalse(webauthn_credential.has_trusted_attestation, webauthn_credential) - - def test_09b_registration_self_Attestation_bad_cose_alg(self): - self.assertRaises( - RegistrationRejectedException, - WebAuthnRegistrationResponse( - rp_id='localhost', - origin='http://localhost:3000', - registration_response=copy(SELF_ATTESTATION_REGISTRATION_RESPONSE_BAD_COSE_ALG), - challenge='AXkXWXPP3gLx8OLlpkJ3aRRhFWntnSENggnjDpBql1ngKol7xWwevUYvrpBDP3LEvdr2EOStOFpGGxnMvXk-Vw', - attestation_requirement_level=ATTESTATION_REQUIREMENT_LEVEL[ATTESTATION_LEVEL.UNTRUSTED], - trust_anchor_dir=TRUST_ANCHOR_DIR, - expected_registration_client_extensions=EXPECTED_REGISTRATION_CLIENT_EXTENSIONS - ).verify) - - def test_09c_registration_self_Attestation_alg_mismatch(self): - self.assertRaisesRegexp( - RegistrationRejectedException, - 'does not match algorithm from attestation statement', - WebAuthnRegistrationResponse( - rp_id='localhost', - origin='http://localhost:3000', - registration_response=copy(SELF_ATTESTATION_REGISTRATION_RESPONSE_ALG_MISMATCH), - challenge='AXkXWXPP3gLx8OLlpkJ3aRRhFWntnSENggnjDpBql1ngKol7xWwevUYvrpBDP3LEvdr2EOStOFpGGxnMvXk-Vw', - attestation_requirement_level=ATTESTATION_REQUIREMENT_LEVEL[ATTESTATION_LEVEL.UNTRUSTED], - trust_anchor_dir=TRUST_ANCHOR_DIR, - expected_registration_client_extensions=EXPECTED_REGISTRATION_CLIENT_EXTENSIONS - ).verify) - - def test_09d_registration_self_Attestation_broken_signature(self): - self.assertRaisesRegexp( - RegistrationRejectedException, - 'Invalid signature received.', - WebAuthnRegistrationResponse( - rp_id='localhost', - origin='http://localhost:3000', - registration_response=copy(SELF_ATTESTATION_REGISTRATION_RESPONSE_BROKEN_SIG), - challenge='AXkXWXPP3gLx8OLlpkJ3aRRhFWntnSENggnjDpBql1ngKol7xWwevUYvrpBDP3LEvdr2EOStOFpGGxnMvXk-Vw', - attestation_requirement_level=ATTESTATION_REQUIREMENT_LEVEL[ATTESTATION_LEVEL.UNTRUSTED], - trust_anchor_dir=TRUST_ANCHOR_DIR, - expected_registration_client_extensions=EXPECTED_REGISTRATION_CLIENT_EXTENSIONS - ).verify) - class MultipleWebAuthnTokenTestCase(MyTestCase): rp_name = 'pitest' @@ -806,7 +573,7 @@ class MultipleWebAuthnTokenTestCase(MyTestCase): WEBAUTHNACTION.TIMEOUT: TIMEOUT, WEBAUTHNACTION.AUTHENTICATOR_ATTESTATION_FORM: DEFAULT_AUTHENTICATOR_ATTESTATION_FORM, WEBAUTHNACTION.USER_VERIFICATION_REQUIREMENT: DEFAULT_USER_VERIFICATION_REQUIREMENT, - WEBAUTHNACTION.PUBLIC_KEY_CREDENTIAL_ALGORITHMS: PUBLIC_KEY_CREDENTIAL_ALGORITHM_PREFERENCE + WEBAUTHNACTION.PUBLIC_KEY_CREDENTIAL_ALGORITHM_PREFERENCE: PUBLIC_KEY_CREDENTIAL_ALGORITHM_PREFERENCE } auth_options = { WEBAUTHNACTION.ALLOWED_TRANSPORTS: ALLOWED_TRANSPORTS,