From 28b07fbc6a1b889f785df8e025f909d2dabd8c34 Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Mon, 23 Sep 2024 11:45:09 +0200 Subject: [PATCH] Fix dataclass constructors with webauthn_json_mapping --- fido2/client.py | 27 +++++++++++++++++++-------- fido2/utils.py | 22 +++++++++++++++++----- tests/test_client.py | 4 ++-- tests/test_webauthn.py | 4 ++-- 4 files changed, 40 insertions(+), 17 deletions(-) diff --git a/fido2/client.py b/fido2/client.py index b921abd..12c4e8b 100644 --- a/fido2/client.py +++ b/fido2/client.py @@ -47,10 +47,10 @@ ) from .cose import ES256 from .rpid import verify_rp_id -from .utils import sha256 +from .utils import sha256, _DataClassMapping from enum import IntEnum, unique from urllib.parse import urlparse -from dataclasses import replace +from dataclasses import replace, asdict from threading import Timer, Event from typing import ( Type, @@ -69,6 +69,17 @@ logger = logging.getLogger(__name__) +def _as_cbor(data): + if data is None: + return None + if isinstance(data, Sequence): + return [_as_cbor(d) for d in data] + if isinstance(data, _DataClassMapping): + # Remove empty values and do not serialize value + return {k: v for k, v in asdict(data).items() if v is not None} # type: ignore + return data + + class ClientError(Exception): @unique class ERR(IntEnum): @@ -337,7 +348,7 @@ def do_make_credential( if ( rk or user_verification == UserVerificationRequirement.REQUIRED - or ES256.ALGORITHM not in [p.alg for p in key_params] + or ES256.ALGORITHM not in [p["alg"] for p in key_params] or enterprise_attestation ): raise CtapError(CtapError.ERR.UNSUPPORTED_OPTION) @@ -803,10 +814,10 @@ def make_credential( try: return self._backend.do_make_credential( client_data, - rp, - options.user, - options.pub_key_cred_params, - options.exclude_credentials, + _as_cbor(rp), + _as_cbor(options.user), + _as_cbor(options.pub_key_cred_params), + _as_cbor(options.exclude_credentials), options.extensions, selection.require_resident_key, selection.user_verification, @@ -848,7 +859,7 @@ def get_assertion( return self._backend.do_get_assertion( client_data, options.rp_id, - options.allow_credentials, + _as_cbor(options.allow_credentials), options.extensions, options.user_verification, event, diff --git a/fido2/utils.py b/fido2/utils.py index 0039c49..5e42869 100644 --- a/fido2/utils.py +++ b/fido2/utils.py @@ -171,7 +171,7 @@ def _snake2camel(name: str) -> str: return parts[0] + "".join(p.title() for p in parts[1:]) -def _parse_value(t, value): +def _parse_value(t, value, from_dict): if value is None: return None @@ -181,7 +181,7 @@ def _parse_value(t, value): # Handle list of values if issubclass(getattr(t, "__origin__", object), Sequence): t = t.__args__[0] - return [_parse_value(t, v) for v in value] + return [_parse_value(t, v, from_dict) for v in value] # Handle Mappings if issubclass(getattr(t, "__origin__", object), Mapping) and isinstance( @@ -196,6 +196,10 @@ def _parse_value(t, value): except TypeError: pass + # If called from "from_dict", recurse using the same + if from_dict and hasattr(t, "from_dict"): + return t.from_dict(value) + # Check for subclass of _DataClassMapping try: is_dataclass = issubclass(t, _DataClassMapping) @@ -203,8 +207,13 @@ def _parse_value(t, value): is_dataclass = False if is_dataclass: - # Recursively call from_dict for nested _DataClassMappings - return t.from_dict(value) + # Recursively call constructor for nested _DataClassMappings + kwargs = {} + for f in fields(t): # type: ignore + key = t._get_field_key(f) + if key in value: + kwargs[f.name] = value[key] + return t(**kwargs) # Convert to enum values, other wrappers return t(value) @@ -224,7 +233,7 @@ def __post_init__(self): if value is None: continue try: - value = _parse_value(hints[f.name], value) + value = _parse_value(hints[f.name], value, False) except (TypeError, KeyError, ValueError): raise ValueError( f"Error parsing field {f.name} for {self.__class__.__name__}" @@ -275,6 +284,7 @@ def from_dict(cls, data: Optional[Mapping[_T, Any]]): ) kwargs = {} + hints = get_type_hints(cls) for f in fields(cls): # type: ignore key = cls._get_field_key(f) if key in data: @@ -283,6 +293,8 @@ def from_dict(cls, data: Optional[Mapping[_T, Any]]): deserialize = f.metadata.get("deserialize") if deserialize: value = deserialize(value) + else: + value = _parse_value(hints[f.name], value, True) kwargs[f.name] = value return cls(**kwargs) diff --git a/tests/test_client.py b/tests/test_client.py index 0bc4d49..f705c31 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -30,7 +30,7 @@ import unittest from unittest import mock from fido2 import cbor -from fido2.utils import sha256, websafe_encode +from fido2.utils import sha256 from fido2.hid import CAPABILITY from fido2.ctap import CtapError from fido2.ctap1 import RegistrationData @@ -51,7 +51,7 @@ ) rp = {"id": "example.com", "name": "Example RP"} -user = {"id": websafe_encode(b"user_id"), "name": "A. User"} +user = {"id": b"user_id", "name": "A. User"} challenge = b"Y2hhbGxlbmdl" _INFO_NO_PIN = bytes.fromhex( "a60182665532465f5632684649444f5f325f3002826375766d6b686d61632d7365637265740350f8a011f38c0a4d15800617111f9edc7d04a462726bf5627570f564706c6174f469636c69656e7450696ef4051904b0068101" # noqa E501 diff --git a/tests/test_webauthn.py b/tests/test_webauthn.py index 13a1cd9..27c879e 100644 --- a/tests/test_webauthn.py +++ b/tests/test_webauthn.py @@ -257,7 +257,7 @@ def test_creation_options(self): b"request_challenge", [{"type": "public-key", "alg": -7}], 10000, - [{"type": "public-key", "id": websafe_encode(b"credential_id")}], + [{"type": "public-key", "id": b"credential_id"}], { "authenticatorAttachment": "platform", "residentKey": "required", @@ -290,7 +290,7 @@ def test_creation_options(self): self.assertIsNone( PublicKeyCredentialCreationOptions( {"id": "example.com", "name": "Example"}, - {"id": websafe_encode(b"user_id"), "name": "A. User"}, + {"id": b"user_id", "name": "A. User"}, b"request_challenge", [{"type": "public-key", "alg": -7}], attestation="invalid",