Repository: privacyidea/privacyidea

Issue:
_get_trust_anchors now actually returns a list of trusted attestation root certificates
Fixes #3216 


Discussion:
# [Codecov](https://codecov.io/gh/privacyidea/privacyidea/pull/3217?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=privacyidea) Report
> Merging [#3217](https://codecov.io/gh/privacyidea/privacyidea/pull/3217?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=privacyidea) (d4e205f) into [master](https://codecov.io/gh/privacyidea/privacyidea/commit/bbb4cf7d6e5efb0f404399be0a3ffbdff7f56515?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=privacyidea) (bbb4cf7) will **increase** coverage by `0.03%`.
> The diff coverage is `100.00%`.

```diff
@@            Coverage Diff             @@
##           master    #3217      +/-   ##
==========================================
+ Coverage   95.94%   95.98%   +0.03%     
==========================================
  Files         164      164              
  Lines       21579    21580       +1     
==========================================
+ Hits        20704    20713       +9     
+ Misses        875      867       -8     
```


| [Impacted Files](https://codecov.io/gh/privacyidea/privacyidea/pull/3217?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=privacyidea) | Coverage Δ | |
|---|---|---|
| [privacyidea/lib/tokens/webauthn.py](https://codecov.io/gh/privacyidea/privacyidea/pull/3217/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=privacyidea#diff-cHJpdmFjeWlkZWEvbGliL3Rva2Vucy93ZWJhdXRobi5weQ==) | `77.11% <100.00%> (+1.84%)` | :arrow_up: |
| [privacyidea/lib/tokens/certificatetoken.py](https://codecov.io/gh/privacyidea/privacyidea/pull/3217/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=privacyidea#diff-cHJpdmFjeWlkZWEvbGliL3Rva2Vucy9jZXJ0aWZpY2F0ZXRva2VuLnB5) | `94.47% <0.00%> (-0.51%)` | :arrow_down: |
| [privacyidea/lib/resolvers/LDAPIdResolver.py](https://codecov.io/gh/privacyidea/privacyidea/pull/3217/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=privacyidea#diff-cHJpdmFjeWlkZWEvbGliL3Jlc29sdmVycy9MREFQSWRSZXNvbHZlci5weQ==) | `92.96% <0.00%> (-0.19%)` | :arrow_down: |

:mega: Codecov can now indicate which changes are the most critical in Pull Requests. [Learn more](https://about.codecov.io/product/feature/runtime-insights/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=privacyidea)


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
@@ -59,6 +59,7 @@
 import struct
 
 import cbor2
+import cryptography.x509
 import six
 from OpenSSL import crypto
 from cryptography import x509
@@ -140,6 +141,7 @@ class ATTESTATION_FORMAT(object):
 
 
 # Only supporting 'fido-u2f', 'packed', and 'none' attestation formats for now.
+# TODO: implement android, apple and TPM authentication formats
 SUPPORTED_ATTESTATION_FORMATS = (
     ATTESTATION_FORMAT.FIDO_U2F,
     ATTESTATION_FORMAT.PACKED,
@@ -790,7 +792,7 @@ def __init__(self,
         """
         Create a new WebAuthnRegistrationResponse object.
 
-        :param rp_id: The relying party id.
+        :param rp_id: The Relying party id.
         :type rp_id: basestring
         :param origin: The origin of the user.
         :type origin: basestring
@@ -952,6 +954,9 @@ def verify_attestation_statement(fmt, att_stmt, auth_data, client_data_hash=b'',
             #
             # Verify the sig using verificationData and certificate public
             # key per [SEC1].
+            # TODO: we need to check here what happens if we do not have a
+            #  client_data_hash (this happens during the webauthntoken_allowed
+            #  pre-policy).
             if client_data_hash:
                 try:
                     _verify_signature(certificate_public_key, alg, verification_data, signature)
@@ -1024,6 +1029,7 @@ def verify_attestation_statement(fmt, att_stmt, auth_data, client_data_hash=b'',
             # If x5c is present, this indicates that the attestation
             # type is not ECDAA.
             if 'x5c' in att_stmt:
+                # TODO: this could be a certificate chain, we should treat it as such
                 att_cert = att_stmt['x5c'][0]
                 x509_att_cert = load_der_x509_certificate(att_cert, default_backend())
                 certificate_public_key = x509_att_cert.public_key()
@@ -1136,7 +1142,7 @@ def verify_attestation_statement(fmt, att_stmt, auth_data, client_data_hash=b'',
                     aaguid
                 )
             elif 'ecdaaKeyId' in att_stmt:
-                # We do not support this. If attestation is optional, have it go through anyways.
+                # We do not support this. If attestation is optional, have it go through anyway.
                 if none_attestation_permitted:
                     attestation_type = ATTESTATION_TYPE.ECDAA
                     trust_path = []
@@ -1309,7 +1315,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.
+            # in the IANA registry of the same name.
             if not _verify_attestation_statement_format(fmt):
                 raise RegistrationRejectedException('Unable to verify attestation statement format.')
 
@@ -1365,11 +1371,10 @@ def verify(self, existing_credential_ids=None):
             #       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_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:
@@ -1788,18 +1793,25 @@ def _get_trust_anchors(attestation_type, attestation_fmt, trust_anchor_dir):
     """
     Return a list of trusted attestation root certificates.
 
-    This will fetch all CA certificates from the given directory, silently skipping any invalid ones.
+    This will fetch all CA certificates from the given directory, silently
+    skipping any invalid ones.
 
-    :param attestation_type: The attestation type being used. If the type is unsupported, an empty list is returned.
-    :type attestation_type: basestring
-    :param attestation_fmt: The attestation format being used. If the format is unsupported, an empty list is returned.
-    :type attestation_fmt: basestring
+    :param attestation_type: The attestation type being used. If the type is
+                             unsupported, an empty list is returned.
+    :type attestation_type: str
+    :param attestation_fmt: The attestation format being used. If the format is
+                            unsupported, an empty list is returned.
+    :type attestation_fmt: str
     :param trust_anchor_dir: The path to the directory that contains the CA certificates.
-    :type trust_anchor_dir: basestring
+    :type trust_anchor_dir: str
     :return: The list of trust anchors.
+    :rtype: list
     """
 
-    if attestation_type not in SUPPORTED_ATTESTATION_TYPES or attestation_fmt not in SUPPORTED_ATTESTATION_FORMATS:
+    if attestation_type not in SUPPORTED_ATTESTATION_TYPES \
+            or attestation_fmt not in SUPPORTED_ATTESTATION_FORMATS:
+        log.debug('Unsupported attestation type ({0!s}) or attestation format '
+                  '({1!s}).'.format(attestation_type, attestation_fmt))
         return []
 
     trust_anchors = []
@@ -1808,24 +1820,32 @@ def _get_trust_anchors(attestation_type, attestation_fmt, trust_anchor_dir):
         for trust_anchor_name in os.listdir(trust_anchor_dir):
             trust_anchor_path = os.path.join(trust_anchor_dir, trust_anchor_name)
             if os.path.isfile(trust_anchor_path):
-                with open(trust_anchor_path, 'rb') as f:
-                    pem_data = f.read().strip()
-                    try:
-                        pem = crypto.load_certificate(crypto.FILETYPE_PEM, pem_data)
+                try:
+                    with open(trust_anchor_path, 'rb') as f:
+                        pem_data = f.read().strip()
+                        pem = cryptography.x509.load_pem_x509_certificate(pem_data.strip())
                         trust_anchors.append(pem)
-                    except Exception:
-                        pass
+                except Exception as e:
+                    log.info('Could not load certificate {0!s}: '
+                             '{1!s}'.format(trust_anchor_path, e))
+    else:
+        log.debug('Trust anchor directory ({0!s}) not available.'.format(trust_anchor_dir))
+
+    return trust_anchors
 
 
 def _is_trusted_x509_attestation_cert(trust_path, trust_anchors):
     if not trust_path or not isinstance(trust_path, list) or not trust_anchors or not isinstance(trust_anchors, list):
         return False
 
+    # TODO: this could be a certificate chain. We should treat it as such
     attestation_cert = trust_path[0]
     store = crypto.X509Store()
+    # Since the certificates are in pyca.cryptography format, we need to convert
+    # them to the OpenSSL.crypto format
     for i in trust_anchors:
-        store.add_cert(i)
-    store_ctx = crypto.X509StoreContext(store, attestation_cert)
+        store.add_cert(crypto.X509.from_cryptography(i))
+    store_ctx = crypto.X509StoreContext(store, crypto.X509.from_cryptography(attestation_cert))
 
     try:
         store_ctx.verify_certificate()
@@ -1837,8 +1857,8 @@ def _is_trusted_x509_attestation_cert(trust_path, trust_anchors):
 
 
 def _is_trusted_ecdaa_attestation_certificate(ecdaa_issuer_public_key, trust_anchors):
-    # Unsupported
-    return False
+    # TODO: implement
+    raise NotImplementedError
 
 
 def _verify_type(received_type, expected_type):
@@ -1864,7 +1884,8 @@ def _verify_origin(client_data, origin):
 
 def _verify_token_binding_id(client_data):
     """
-    Verify tokenBinding. Currently this is unimplemented, so it will simply return false if tokenBinding is required.
+    Verify tokenBinding. Currently, this is unimplemented, so it will simply
+    return false if tokenBinding is required.
 
     The tokenBinding member contains information about the state of the
     Token Binding protocol used when communicating with the Relying Party.


Test patch: