Repository: privacyidea/privacyidea

Issue:
WebAuthn attestation format "packed" is only partially supported (even though it's in SUPPORTED_ATTESTATION_FORMATS)
### Top-level intent

Register Windows Hello as WebAuthn token (which uses attestation format "packed" with self-attestation syntax)

### Steps to reproduce

1. Add token
2. choose WebAuthn
3. use Windows Hello (e.g. fingerprint or PIN)

### Expected outcome

Token is registered and attestation is ideally saved, or at least ignored.

### Actual outcome

Server error, token is not registered.

### Configuration

* **privacyIDEA version**: 3.7
* **Installation method**: pypi
* **Python version**: 3.9
* **Operating system**: debian bullseye (docker)
* **Webserver**: uswgi
* **Token database**: PostgreSQL

### Log file

```
Exception on /token/init [POST]
Traceback (most recent call last):
  File "/usr/local/lib/python3.9/site-packages/flask/app.py", line 2447, in wsgi_app
    response = self.full_dispatch_request()
  File "/usr/local/lib/python3.9/site-packages/flask/app.py", line 1952, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/usr/local/lib/python3.9/site-packages/flask/app.py", line 1821, in handle_user_exception
    reraise(exc_type, exc_value, tb)
  File "/usr/local/lib/python3.9/site-packages/flask/_compat.py", line 39, in reraise
    raise value
  File "/usr/local/lib/python3.9/site-packages/flask/app.py", line 1950, in full_dispatch_request
    rv = self.dispatch_request()
  File "/usr/local/lib/python3.9/site-packages/flask/app.py", line 1936, in dispatch_request
    return self.view_functions[rule.endpoint](**req.view_args)
  File "/usr/local/lib/python3.9/site-packages/privacyidea/api/lib/prepolicy.py", line 155, in policy_wrapper
    return wrapped_function(*args, **kwds)
  File "/usr/local/lib/python3.9/site-packages/privacyidea/api/lib/prepolicy.py", line 155, in policy_wrapper
    return wrapped_function(*args, **kwds)
  File "/usr/local/lib/python3.9/site-packages/privacyidea/api/lib/prepolicy.py", line 155, in policy_wrapper
    return wrapped_function(*args, **kwds)
  [Previous line repeated 17 more times]
  File "/usr/local/lib/python3.9/site-packages/privacyidea/api/lib/prepolicy.py", line 153, in policy_wrapper
    self.function(request=self.request,
  File "/usr/local/lib/python3.9/site-packages/privacyidea/api/lib/prepolicy.py", line 2027, in webauthntoken_allowed
    (
TypeError: cannot unpack non-iterable NoneType object

```

The problem is around here:
https://github.com/privacyidea/privacyidea/blob/92e105dfbbc394a3640d9756bb237b37e135896a/privacyidea/lib/tokens/webauthn.py#L1127
For attestation format "packed", there are three syntaxes - basic, ecdsa and self-attestation:
* basic is implemented
* ecdsa is treated as none
* there is no "elif" or "else" branch for self-attestation, so the `verify_attestation_statement` returns `None` which causes the exception above

Suggested fix: treat self-attestation the same way as ecdsa (ignore it) for now, and later add support for all standard attestation formats and syntaxes, e.g. by using the [py_webauthn](https://github.com/duo-labs/py_webauthn) library.


Discussion:
also related to #3142 

Resolving patch:
diff --git a/privacyidea/lib/error.py b/privacyidea/lib/error.py
--- a/privacyidea/lib/error.py
+++ b/privacyidea/lib/error.py
@@ -49,6 +49,7 @@ class ERROR:
     AUTHENTICATE_DECODING_ERROR = 4304
     AUTHENTICATE_TOKEN_EXPIRED = 4305
     AUTHENTICATE_MISSING_RIGHT = 4306
+    ENROLLMENT = 404
     CA = 503
     CA_CSR_INVALID = 504
     CA_CSR_PENDING = 505
@@ -135,7 +136,12 @@ def __init__(self, description="validation error!", id=ERROR.VALIDATE):
 
 
 class RegistrationError(privacyIDEAError):
-    def __init__(self, description="registraion error!", id=ERROR.REGISTRATION):
+    def __init__(self, description="registration error!", id=ERROR.REGISTRATION):
+        privacyIDEAError.__init__(self, description=description, id=id)
+
+
+class EnrollmentError(privacyIDEAError):
+    def __init__(self, description="enrollment error!", id=ERROR.ENROLLMENT):
         privacyIDEAError.__init__(self, description=description, id=id)
 
 
diff --git a/privacyidea/lib/tokens/webauthn.py b/privacyidea/lib/tokens/webauthn.py
--- a/privacyidea/lib/tokens/webauthn.py
+++ b/privacyidea/lib/tokens/webauthn.py
@@ -69,7 +69,7 @@
 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
+from cryptography.hazmat.primitives.hashes import SHA256, SHA1
 from cryptography.x509 import load_der_x509_certificate
 
 from privacyidea.lib.tokens.u2f import url_encode, url_decode
@@ -183,6 +183,7 @@ class COSE_ALGORITHM(object):
     ES256 = -7
     PS256 = -37
     RS256 = -257
+    RS1 = -65535  # for tests, otherwise unsupported
 
 
 SUPPORTED_COSE_ALGORITHMS = (
@@ -1167,6 +1168,48 @@ def verify_attestation_statement(fmt, att_stmt, auth_data, client_data_hash=b'',
                 #   * 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:
@@ -1773,7 +1816,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):
+    elif alg in (COSE_ALGORITHM.PS256, COSE_ALGORITHM.RS256, COSE_ALGORITHM.RS1):
 
         required_keys = {
             COSE_PUBLIC_KEY.ALG,
@@ -1792,6 +1835,7 @@ 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.')
 
 
@@ -2003,5 +2047,7 @@ 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
--- a/privacyidea/lib/tokens/webauthntoken.py
+++ b/privacyidea/lib/tokens/webauthntoken.py
@@ -31,7 +31,7 @@
 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, RegistrationError, PolicyError
+from privacyidea.lib.error import ParameterError, EnrollmentError, PolicyError
 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,
@@ -874,7 +874,7 @@ def update(self, param, reset_failcount=True):
 
             # Since we are still enrolling the token, there should be exactly one challenge.
             if not len(challengeobject_list):
-                raise RegistrationError(
+                raise EnrollmentError(
                     "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,25 +883,30 @@ def update(self, param, reset_failcount=True):
             #
             # All data is parsed and verified. If any errors occur an exception
             # will be raised.
-            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)
+            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)
                             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)
-            ])
+                    },
+                    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)
+                ])
+            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)


Test patch:
diff --git a/tests/test_lib_tokens_webauthn.py b/tests/test_lib_tokens_webauthn.py
--- a/tests/test_lib_tokens_webauthn.py
+++ b/tests/test_lib_tokens_webauthn.py
@@ -62,6 +62,7 @@
 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
@@ -79,7 +80,7 @@
 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
+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 = {
@@ -170,6 +171,45 @@
 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):
 
@@ -240,6 +280,8 @@ def test_01_create_token(self):
         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\
@@ -344,20 +386,27 @@ def test_05_authorization(self):
                                           })
         self.assertTrue(sign_count > 0)
 
-    def test_06_missing_origin(self):
-        self.challenge_options['nonce'] = webauthn_b64_decode(ASSERTION_CHALLENGE)
+    @log_capture()
+    def test_06_missing_origin(self, capture):
         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(self.challenge_options['nonce']),
-                                              "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(webauthn_b64_decode(ASSERTION_CHALLENGE)),
+                "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:
@@ -384,7 +433,7 @@ def test_08_missing_attestation(self):
         self.user = User(login=NONE_ATTESTATION_USER_NAME)
         self.token.get_init_detail(self.init_params, self.user)
 
-        with self.assertRaises(RegistrationRejectedException):
+        with self.assertRaises(EnrollmentError):
             self.token.update({
                 'type': 'webauthn',
                 'serial': self.token.token.serial,
@@ -397,15 +446,13 @@ def test_08_missing_attestation(self):
 
     def test_09a_attestation_subject_allowed(self):
         self._setup_token()
-        self.challenge_options['nonce'] = webauthn_b64_decode(ASSERTION_CHALLENGE)
-        self._create_challenge()
         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']),
+            "challenge": hexlify_and_unicode(webauthn_b64_decode(ASSERTION_CHALLENGE)),
             "HTTP_ORIGIN": ORIGIN,
             WEBAUTHNACTION.REQ: ['subject/.*Yubico.*/']
         }
@@ -414,15 +461,13 @@ def test_09a_attestation_subject_allowed(self):
 
     def test_09b_attestation_subject_not_allowed(self):
         self._setup_token()
-        self.challenge_options['nonce'] = webauthn_b64_decode(ASSERTION_CHALLENGE)
-        self._create_challenge()
         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']),
+            "challenge": hexlify_and_unicode(webauthn_b64_decode(ASSERTION_CHALLENGE)),
             "HTTP_ORIGIN": ORIGIN,
             WEBAUTHNACTION.REQ: ['subject/.*Feitian.*/']
         }
@@ -435,8 +480,6 @@ def test_09b_attestation_subject_not_allowed(self):
 
     def test_10a_aaguid_allowed(self):
         self._setup_token()
-        self.challenge_options['nonce'] = webauthn_b64_decode(ASSERTION_CHALLENGE)
-        self._create_challenge()
         # check token with a valid aaguid
         res = self.token.check_otp(otpval=None, options={
             "credentialid": CRED_ID,
@@ -444,7 +487,7 @@ def test_10a_aaguid_allowed(self):
             "clientdata": ASSERTION_RESPONSE_TMPL['clientData'],
             "signaturedata": ASSERTION_RESPONSE_TMPL['signature'],
             "user": self.user,
-            "challenge": hexlify_and_unicode(self.challenge_options['nonce']),
+            "challenge": hexlify_and_unicode(webauthn_b64_decode(ASSERTION_CHALLENGE)),
             "HTTP_ORIGIN": ORIGIN,
             WEBAUTHNACTION.AUTHENTICATOR_SELECTION_LIST: ['00000000000000000000000000000000']
         })
@@ -452,8 +495,6 @@ def test_10a_aaguid_allowed(self):
 
     def test_10b_aaguid_not_allowed(self):
         self._setup_token()
-        self.challenge_options['nonce'] = webauthn_b64_decode(ASSERTION_CHALLENGE)
-        self._create_challenge()
         # check token with an invalid aaguid
         self.assertRaisesRegexp(PolicyError,
                                 "The WebAuthn token is not allowed to authenticate "
@@ -466,7 +507,7 @@ def test_10b_aaguid_not_allowed(self):
                                        "clientdata": ASSERTION_RESPONSE_TMPL['clientData'],
                                        "signaturedata": ASSERTION_RESPONSE_TMPL['signature'],
                                        "user": self.user,
-                                       "challenge": hexlify_and_unicode(self.challenge_options['nonce']),
+                                       "challenge": hexlify_and_unicode(webauthn_b64_decode(ASSERTION_CHALLENGE)),
                                        "HTTP_ORIGIN": ORIGIN,
                                        WEBAUTHNACTION.AUTHENTICATOR_SELECTION_LIST: ['ffff0000000000000000000000000000']
                                    }})
@@ -625,6 +666,76 @@ def test_06_duplicate_authentication_fail_assertion(self):
     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'