Repository: privacyidea/privacyidea

Issue:
Treat unsupported attestation as none attestation
Currently we do not allow the user to roll out a token, if we do not understand the attestation statement format, even if the administrator has explicitly allowed rolling out a token with an attestation statement format of NONE. Instead we should treat unsupported attestation statement formats equivalent to NONE attestation.


Discussion:

Resolving patch:
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
@@ -127,11 +127,14 @@ class ATTESTATION_TYPE(object):
 
 class ATTESTATION_FORMAT(object):
     """
-    Attestation formats supported by this implementation.
+    Attestation format identifiers as registered in the IANA WebAuthn attestation statement format identifiers registry.
     """
 
-    FIDO_U2F = 'fido-u2f'
     PACKED = 'packed'
+    TPM = 'tpm'
+    ANDROID_KEY = 'android-key'
+    ANDROID_SAFETYNET = 'android-safetynet'
+    FIDO_U2F = 'fido-u2f'
     NONE = 'none'
 
 
@@ -143,6 +146,17 @@ class ATTESTATION_FORMAT(object):
 )
 
 
+# Still waiting for Apple Anonymous Attestation.
+REGISTERED_ATTESTATION_FORMATS = (
+    ATTESTATION_FORMAT.PACKED,
+    ATTESTATION_FORMAT.TPM,
+    ATTESTATION_FORMAT.ANDROID_KEY,
+    ATTESTATION_FORMAT.ANDROID_SAFETYNET,
+    ATTESTATION_FORMAT.FIDO_U2F,
+    ATTESTATION_FORMAT.NONE
+)
+
+
 class CLIENT_DATA_TYPE(object):
     """
     Client data types used by this implementation.
@@ -1136,28 +1150,28 @@ 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:
-                # 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.')
-
-                # Treat as none attestation.
-                #
-                # Step 1.
-                #
-                # Return attestation type None with an empty trust path.
-                attestation_type = ATTESTATION_TYPE.NONE
-                trust_path = []
-                return (
-                    attestation_type,
-                    trust_path,
-                    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.')
+
+            # Treat as none attestation.
+            #
+            # Step 1.
+            #
+            # Return attestation type None with an empty trust path.
+            attestation_type = ATTESTATION_TYPE.NONE
+            trust_path = []
+            return (
+                attestation_type,
+                trust_path,
+                credential_pub_key,
+                cred_id,
+                aaguid
+            )
 
     def verify(self, existing_credential_ids=None):
         """
@@ -1286,8 +1300,7 @@ def verify(self, existing_credential_ids=None):
             # supported WebAuthn Attestation Statement Format Identifier
             # values. The up-to-date list of registered WebAuthn
             # Attestation Statement Format Identifier values is maintained
-            # in the in the IANA registry of the same name
-            # [WebAuthn-Registries].
+            # in the in the IANA registry of the same name.
             if not _verify_attestation_statement_format(fmt):
                 raise RegistrationRejectedException('Unable to verify attestation statement format.')
 
@@ -1912,7 +1925,7 @@ def _verify_rp_id_hash(auth_data_rp_id_hash, rp_id):
 
 def _verify_attestation_statement_format(fmt):
     """
-    Verify that the attestation statement format is supported.
+    Verify that the attestation statement format identifier matches a registered attestation statement format.
 
     :param fmt: The attestation statement format.
     :type fmt: basestring
@@ -1920,7 +1933,7 @@ def _verify_attestation_statement_format(fmt):
     :rtype: bool
     """
 
-    return isinstance(fmt, six.string_types) and fmt in SUPPORTED_ATTESTATION_FORMATS
+    return isinstance(fmt, six.string_types) and fmt in REGISTERED_ATTESTATION_FORMATS
 
 
 def _get_auth_data_rp_id_hash(auth_data):
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
@@ -882,9 +882,9 @@ def update(self, param, reset_failcount=True):
 
                 if not description:
                     cn = web_authn_credential.attestation_cert.subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME)
-                    description = cn[0].value if len(cn) else DEFAULT_DESCRIPTION
+                    description = cn[0].value if len(cn) else None
 
-            self.set_description(description)
+            self.set_description(description or DEFAULT_DESCRIPTION)
 
             # Delete all challenges. We are still in enrollment, so there
             # *should* be only one, but it can't hurt to be thorough here.


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
@@ -143,6 +143,24 @@
 PUB_KEY = 'a401020326215820319ea01f1125ce6232947365800ae5d9ddc874247c55d1516bad3ca3ca32075c'\
           '22582059f1f07f3b2f86c0a51e0cfa13dc57e7c77a110e796f8a0b27741fe58663cb3a'
 
+NONE_ATTESTATION_REGISTRATION_RESPONSE_TMPL = {
+    'clientData': b'eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiZkszN3hLZGhXSmhVNmQyVEFH'
+                  b'VllESEttZHNwb3JsanNSb1daMDlaaVRYTSIsIm9yaWdpbiI6Imh0dHBzOi8vd2ViYXV0aG4uaW8i'
+                  b'LCJjcm9zc09yaWdpbiI6ZmFsc2V9',
+    'attObj': b'o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjEdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fykl'
+              b'C2CEHvBBAAAAtQAAAAAAAAAAAAAAAAAAAAAAQF8gbz2rT3N24r4ojF9kZKuVY_luj5OkbTYuq-79'
+              b'HUoKy2Gj8cVotKsYQG6zAUGvh2DNRWCplAwAJI93pAqCExOlAQIDJiABIVgg0hC-jd-1sLwL4eqN'
+              b'6u3sGX5D4f7OTrlXkel-HZIZSR8iWCD4DkCLED1CSAhHSZlVak4sdkU8_RFClaObyTJag7hEFg'
+}
+NONE_ATTESTATION_REGISTRATION_CHALLENGE = 'fK37xKdhWJhU6d2TAGVYDHKmdsporljsRoWZ09ZiTXM'
+NONE_ATTESTATION_USER_NAME = 'john.doe'
+NONE_ATTESTATION_USER_DISPLAY_NAME = '<john.doe.resolver1@foorealm>'
+NONE_ATTESTATION_USER_ID = b'WAN000136AE'
+NONE_ATTESTATION_ATTESTATION_FORM = 'none'
+NONE_ATTESTATION_CRED_ID = 'XyBvPatPc3biviiMX2Rkq5Vj-W6Pk6RtNi6r7v0dSgrLYaPxxWi0qxhAbrMBQa-HYM1FYKmUDAAkj3ekCoITEw'
+NONE_ATTESTATION_PUB_KEY = 'a5010203262001215820d210be8ddfb5b0bc0be1ea8deaedec197e43e1fece4eb95791e97e1d9219491f22582'\
+                           '0f80e408b103d424808474999556a4e2c76453cfd114295a39bc9325a83b84416'
+
 
 class WebAuthnTokenTestCase(MyTestCase):
     USER_LOGIN = "testuser"
@@ -286,6 +304,42 @@ def test_06_missing_origin(self):
                                           })
         self.assertTrue(sign_count == -1)
 
+    def test_07_none_attestation(self):
+        self.init_params['nonce'] = webauthn_b64_decode(NONE_ATTESTATION_REGISTRATION_CHALLENGE)
+        self.init_params[WEBAUTHNACTION.AUTHENTICATOR_ATTESTATION_FORM] = 'none'
+        self.user = User(login=NONE_ATTESTATION_USER_NAME)
+        self.token.get_init_detail(self.init_params, self.user)
+        self.token.update({
+            'type': 'webauthn',
+            'serial': self.token.token.serial,
+            'regdata': NONE_ATTESTATION_REGISTRATION_RESPONSE_TMPL['attObj'],
+            'clientdata': NONE_ATTESTATION_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.assertEqual(NONE_ATTESTATION_CRED_ID, self.token.decrypt_otpkey())
+        self.assertEqual(NONE_ATTESTATION_PUB_KEY, self.token.get_tokeninfo(WEBAUTHNINFO.PUB_KEY))
+
+    def test_08_missing_attestation(self):
+        self.init_params['nonce'] = webauthn_b64_decode(NONE_ATTESTATION_REGISTRATION_CHALLENGE)
+        self.user = User(login=NONE_ATTESTATION_USER_NAME)
+        self.token.get_init_detail(self.init_params, self.user)
+
+        with self.assertRaises(RegistrationRejectedException):
+            self.token.update({
+                'type': 'webauthn',
+                'serial': self.token.token.serial,
+                'regdata': NONE_ATTESTATION_REGISTRATION_RESPONSE_TMPL['attObj'],
+                'clientdata': NONE_ATTESTATION_REGISTRATION_RESPONSE_TMPL['clientData'],
+                WEBAUTHNACTION.RELYING_PARTY_ID: RP_ID,
+                WEBAUTHNACTION.AUTHENTICATOR_ATTESTATION_LEVEL: ATTESTATION_LEVEL.UNTRUSTED,
+                'HTTP_ORIGIN': ORIGIN
+            })
+
+
 class WebAuthnTestCase(unittest.TestCase):
     @staticmethod
     def getWebAuthnCredential():