Add to/from_json methods to WebAuthn dataclasses

This commit is contained in:
Dain Nilsson 2024-03-05 12:05:33 +01:00
parent 4c6f7b68a2
commit e983d4bc7d
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
8 changed files with 159 additions and 61 deletions

View File

@ -33,14 +33,16 @@ See the file README.adoc in this directory for details.
Navigate to https://localhost:5000 in a supported web browser.
"""
from fido2.webauthn import PublicKeyCredentialRpEntity, PublicKeyCredentialUserEntity
from fido2.webauthn import (
PublicKeyCredentialRpEntity,
PublicKeyCredentialUserEntity,
RegistrationResponse,
AuthenticationResponse,
)
from fido2.server import Fido2Server
from flask import Flask, session, request, redirect, abort, jsonify
import os
import fido2.features
fido2.features.webauthn_json_mapping.enabled = True
app = Flask(__name__, static_url_path="")
@ -78,12 +80,12 @@ def register_begin():
print(options)
print("\n\n\n\n")
return jsonify(dict(options))
return jsonify(options.to_json())
@app.route("/api/register/complete", methods=["POST"])
def register_complete():
response = request.json
response = RegistrationResponse.from_json(request.json)
print("RegistrationResponse:", response)
auth_data = server.register_complete(session["state"], response)
@ -100,7 +102,7 @@ def authenticate_begin():
options, state = server.authenticate_begin(credentials)
session["state"] = state
return jsonify(dict(options))
return jsonify(options.to_json())
@app.route("/api/authenticate/complete", methods=["POST"])
@ -108,7 +110,7 @@ def authenticate_complete():
if not credentials:
abort(404)
response = request.json
response = AuthenticationResponse.from_json(request.json)
print("AuthenticationResponse:", response)
server.authenticate_complete(
session.pop("state"),

View File

@ -144,6 +144,7 @@ class Fido2Server:
verify_attestation: Optional[VerifyAttestation] = None,
):
self.rp = PublicKeyCredentialRpEntity.from_dict(rp)
assert self.rp.id is not None # nosec
self._verify = verify_origin or _verify_origin_for_rp(self.rp.id)
self.timeout = None
self.attestation = AttestationConveyancePreference(attestation)

View File

@ -44,9 +44,11 @@ from typing import (
Sequence,
Mapping,
Any,
Type,
TypeVar,
Hashable,
get_type_hints,
overload,
)
import struct
import warnings
@ -207,14 +209,19 @@ def _parse_value(t, value):
return t.from_dict(value)
# Convert to enum values, other wrappers
return t(value)
try:
return t(value)
except Exception:
print("EXCEPTION", t, value)
raise
_T = TypeVar("_T", bound=Hashable)
_T2 = TypeVar("_T2", bound="_DataClassMapping")
class _DataClassMapping(Mapping[_T, Any]):
# TODO: This requires Python 3.9, and fixes the tpye errors we now ignore
# TODO: This requires Python 3.9, and fixes the type errors we now ignore
# __dataclass_fields__: ClassVar[Dict[str, Field[Any]]]
def __post_init__(self):
@ -233,7 +240,7 @@ class _DataClassMapping(Mapping[_T, Any]):
@classmethod
@abstractmethod
def _get_field_key(cls, field: Field) -> _T:
def _get_field_key(cls: Type[_T2], field: Field) -> _T:
raise NotImplementedError()
def __getitem__(self, key):
@ -262,8 +269,18 @@ class _DataClassMapping(Mapping[_T, Any]):
def __len__(self):
return len(list(iter(self)))
@overload
@classmethod
def from_dict(cls, data: Optional[Mapping[_T, Any]]):
def from_dict(cls: Type[_T2], data: None) -> None:
pass
@overload
@classmethod
def from_dict(cls: Type[_T2], data: Mapping[_T, Any]) -> _T2:
pass
@classmethod
def from_dict(cls: Type[_T2], data: Optional[Mapping[_T, Any]]) -> Optional[_T2]:
if data is None:
return None
if isinstance(data, cls):

View File

@ -38,8 +38,19 @@ from .utils import (
)
from .features import webauthn_json_mapping
from enum import Enum, EnumMeta, unique, IntFlag
from dataclasses import dataclass, field
from typing import Any, Mapping, Optional, Sequence, Tuple, Union, cast
from dataclasses import dataclass, field, fields
from typing import (
Any,
Mapping,
Optional,
Sequence,
Tuple,
Union,
Type,
TypeVar,
cast,
get_type_hints,
)
import struct
import json
@ -457,8 +468,81 @@ class PublicKeyCredentialType(_StringEnum):
PUBLIC_KEY = "public-key"
_T2 = TypeVar("_T2", bound="_JsonDataObject")
def _get_basetype(t):
if Optional[t] == t: # Optional, get the type
t = t.__args__[0]
# Handle list of values
if issubclass(getattr(t, "__origin__", object), Sequence):
t = t.__args__[0]
return t
def _dump_json(value):
if isinstance(value, _JsonDataObject):
return value.to_json()
if isinstance(value, bytes):
return websafe_encode(value)
if isinstance(value, list):
return [_dump_json(x) for x in value]
return value
def _load_json(hint, value):
t = _get_basetype(hint)
if isinstance(value, str):
if issubclass(t, bytes):
value = websafe_decode(value)
return t(value)
# Handle lists
if isinstance(value, Sequence):
return [_load_json(t, v) for v in value]
# Check for subclass of _JsonDataObject
try:
is_json = issubclass(t, _JsonDataObject)
except TypeError:
is_json = False
if is_json:
# Recursively call from_json for nested _JsonDataObject
return t.from_json(value)
return value
class _JsonDataObject(_CamelCaseDataObject):
def to_json(self) -> Mapping[str, Any]:
"""Returns a dict of the object which can be serialized to JSON."""
data = {}
for f in fields(self): # type: ignore
key = self._get_field_key(f)
value = getattr(self, f.name)
if value is not None:
data[key] = _dump_json(value)
return data
@classmethod
def from_json(cls: Type[_T2], data: Mapping[str, Any]) -> _T2:
"""Instantiates an object from a JSON-compatible dict representation."""
hints = get_type_hints(cls)
resp = {}
for f in fields(cls): # type: ignore
key = cls._get_field_key(f)
if key in data:
value = data[key]
hint = hints.get(f.name)
resp[key] = _load_json(hint, value)
return cls.from_dict(resp)
@dataclass(eq=False, frozen=True)
class PublicKeyCredentialRpEntity(_CamelCaseDataObject):
class PublicKeyCredentialRpEntity(_JsonDataObject):
name: str
id: Optional[str] = None
@ -469,14 +553,14 @@ class PublicKeyCredentialRpEntity(_CamelCaseDataObject):
@dataclass(eq=False, frozen=True)
class PublicKeyCredentialUserEntity(_CamelCaseDataObject):
class PublicKeyCredentialUserEntity(_JsonDataObject):
name: str
id: bytes = field(metadata=_b64_metadata)
display_name: Optional[str] = None
@dataclass(eq=False, frozen=True)
class PublicKeyCredentialParameters(_CamelCaseDataObject):
class PublicKeyCredentialParameters(_JsonDataObject):
type: PublicKeyCredentialType
alg: int
@ -489,7 +573,7 @@ class PublicKeyCredentialParameters(_CamelCaseDataObject):
@dataclass(eq=False, frozen=True)
class PublicKeyCredentialDescriptor(_CamelCaseDataObject):
class PublicKeyCredentialDescriptor(_JsonDataObject):
type: PublicKeyCredentialType
id: bytes = field(metadata=_b64_metadata)
transports: Optional[Sequence[AuthenticatorTransport]] = None
@ -503,7 +587,7 @@ class PublicKeyCredentialDescriptor(_CamelCaseDataObject):
@dataclass(eq=False, frozen=True)
class AuthenticatorSelectionCriteria(_CamelCaseDataObject):
class AuthenticatorSelectionCriteria(_JsonDataObject):
authenticator_attachment: Optional[AuthenticatorAttachment] = None
resident_key: Optional[ResidentKeyRequirement] = None
user_verification: Optional[UserVerificationRequirement] = None
@ -530,7 +614,7 @@ class AuthenticatorSelectionCriteria(_CamelCaseDataObject):
@dataclass(eq=False, frozen=True)
class PublicKeyCredentialCreationOptions(_CamelCaseDataObject):
class PublicKeyCredentialCreationOptions(_JsonDataObject):
rp: PublicKeyCredentialRpEntity
user: PublicKeyCredentialUserEntity
challenge: bytes = field(metadata=_b64_metadata)
@ -548,7 +632,7 @@ class PublicKeyCredentialCreationOptions(_CamelCaseDataObject):
@dataclass(eq=False, frozen=True)
class PublicKeyCredentialRequestOptions(_CamelCaseDataObject):
class PublicKeyCredentialRequestOptions(_JsonDataObject):
challenge: bytes = field(metadata=_b64_metadata)
timeout: Optional[int] = None
rp_id: Optional[str] = None
@ -561,7 +645,7 @@ class PublicKeyCredentialRequestOptions(_CamelCaseDataObject):
@dataclass(eq=False, frozen=True)
class AuthenticatorAttestationResponse(_CamelCaseDataObject):
class AuthenticatorAttestationResponse(_JsonDataObject):
client_data: CollectedClientData = field(
metadata=dict(
_b64_metadata,
@ -578,7 +662,7 @@ class AuthenticatorAttestationResponse(_CamelCaseDataObject):
@classmethod
def from_dict(cls, data: Optional[Mapping[str, Any]]):
if data is not None and not webauthn_json_mapping.enabled:
if data is not None and "clientData" in data:
value = dict(data)
value["clientDataJSON"] = value.pop("clientData", None)
data = value
@ -586,7 +670,7 @@ class AuthenticatorAttestationResponse(_CamelCaseDataObject):
@dataclass(eq=False, frozen=True)
class AuthenticatorAssertionResponse(_CamelCaseDataObject):
class AuthenticatorAssertionResponse(_JsonDataObject):
client_data: CollectedClientData = field(
metadata=dict(
_b64_metadata,
@ -606,7 +690,7 @@ class AuthenticatorAssertionResponse(_CamelCaseDataObject):
@classmethod
def from_dict(cls, data: Optional[Mapping[str, Any]]):
if data is not None and not webauthn_json_mapping.enabled:
if data is not None and "clientData" in data:
value = dict(data)
value["clientDataJSON"] = value.pop("clientData", None)
data = value
@ -614,36 +698,28 @@ class AuthenticatorAssertionResponse(_CamelCaseDataObject):
@dataclass(eq=False, frozen=True)
class RegistrationResponse(_CamelCaseDataObject):
class RegistrationResponse(_JsonDataObject):
id: bytes = field(metadata=_b64_metadata)
response: AuthenticatorAttestationResponse
authenticator_attachment: Optional[AuthenticatorAttachment] = None
client_extension_results: Optional[Mapping] = None
type: Optional[PublicKeyCredentialType] = None
def __post_init__(self):
webauthn_json_mapping.require()
super().__post_init__()
@dataclass(eq=False, frozen=True)
class AuthenticationResponse(_CamelCaseDataObject):
class AuthenticationResponse(_JsonDataObject):
id: bytes = field(metadata=_b64_metadata)
response: AuthenticatorAssertionResponse
authenticator_attachment: Optional[AuthenticatorAttachment] = None
client_extension_results: Optional[Mapping] = None
type: Optional[PublicKeyCredentialType] = None
def __post_init__(self):
webauthn_json_mapping.require()
super().__post_init__()
@dataclass(eq=False, frozen=True)
class CredentialCreationOptions(_CamelCaseDataObject):
class CredentialCreationOptions(_JsonDataObject):
public_key: PublicKeyCredentialCreationOptions
@dataclass(eq=False, frozen=True)
class CredentialRequestOptions(_CamelCaseDataObject):
class CredentialRequestOptions(_JsonDataObject):
public_key: PublicKeyCredentialRequestOptions

View File

@ -1,3 +0,0 @@
import fido2.features
fido2.features.webauthn_json_mapping.enabled = True

View File

@ -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 @@ REG_DATA = RegistrationData(
)
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

View File

@ -8,7 +8,6 @@ from fido2.webauthn import (
AttestedCredentialData,
AuthenticatorData,
)
from fido2.utils import websafe_encode
from .test_ctap2 import _ATT_CRED_DATA, _CRED_ID
from .utils import U2FDevice
@ -96,7 +95,7 @@ class TestFido2Server(unittest.TestCase):
challenge = b"1234567890123456"
request, state = server.register_begin(USER, challenge=challenge)
self.assertEqual(request["publicKey"]["challenge"], websafe_encode(challenge))
self.assertEqual(request["publicKey"]["challenge"], challenge)
def test_register_begin_custom_challenge_too_short(self):
rp = PublicKeyCredentialRpEntity("Example", "example.com")

View File

@ -37,7 +37,6 @@ from fido2.webauthn import (
PublicKeyCredentialCreationOptions,
PublicKeyCredentialRequestOptions,
)
from fido2.utils import websafe_encode
import unittest
import json
@ -187,7 +186,7 @@ class TestWebAuthnDataTypes(unittest.TestCase):
self.assertEqual(
o,
{
"id": websafe_encode(b"user"),
"id": b"user",
"name": "Example",
"displayName": "Display",
},
@ -219,9 +218,7 @@ class TestWebAuthnDataTypes(unittest.TestCase):
def test_descriptor(self):
o = PublicKeyCredentialDescriptor("public-key", b"credential_id")
self.assertEqual(
o, {"type": "public-key", "id": websafe_encode(b"credential_id")}
)
self.assertEqual(o, {"type": "public-key", "id": b"credential_id"})
self.assertEqual(o.type, "public-key")
self.assertEqual(o.id, b"credential_id")
self.assertIsNone(o.transports)
@ -233,7 +230,7 @@ class TestWebAuthnDataTypes(unittest.TestCase):
o,
{
"type": "public-key",
"id": websafe_encode(b"credential_id"),
"id": b"credential_id",
"transports": ["usb", "nfc"],
},
)
@ -257,7 +254,7 @@ class TestWebAuthnDataTypes(unittest.TestCase):
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",
@ -266,18 +263,21 @@ class TestWebAuthnDataTypes(unittest.TestCase):
"direct",
)
self.assertEqual(o.rp, {"id": "example.com", "name": "Example"})
self.assertEqual(o.user, {"id": websafe_encode(b"user_id"), "name": "A. User"})
self.assertEqual(o.user, {"id": b"user_id", "name": "A. User"})
self.assertIsNone(o.extensions)
js = json.dumps(dict(o))
o2 = PublicKeyCredentialCreationOptions.from_dict(json.loads(js))
o2 = PublicKeyCredentialCreationOptions.from_dict(dict(o))
self.assertEqual(o, o2)
js = json.dumps(o.to_json())
o2 = PublicKeyCredentialCreationOptions.from_json(json.loads(js))
self.assertEqual(o, o2)
o = PublicKeyCredentialCreationOptions.from_dict(
{
"rp": {"id": "example.com", "name": "Example"},
"user": {"id": websafe_encode(b"user_id"), "name": "A. User"},
"challenge": websafe_encode(b"request_challenge"),
"user": {"id": b"user_id", "name": "A. User"},
"challenge": b"request_challenge",
"pubKeyCredParams": [{"type": "public-key", "alg": -7}],
}
)
@ -290,15 +290,18 @@ class TestWebAuthnDataTypes(unittest.TestCase):
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",
).attestation
)
js = json.dumps(dict(o))
o2 = PublicKeyCredentialCreationOptions.from_dict(json.loads(js))
o2 = PublicKeyCredentialCreationOptions.from_dict(dict(o))
self.assertEqual(o, o2)
js = json.dumps(o.to_json())
o2 = PublicKeyCredentialCreationOptions.from_json(json.loads(js))
self.assertEqual(o, o2)
@ -315,8 +318,11 @@ class TestWebAuthnDataTypes(unittest.TestCase):
self.assertEqual(o.timeout, 10000)
self.assertIsNone(o.extensions)
js = json.dumps(dict(o))
o2 = PublicKeyCredentialRequestOptions.from_dict(json.loads(js))
o2 = PublicKeyCredentialRequestOptions.from_dict(dict(o))
self.assertEqual(o, o2)
js = json.dumps(o.to_json())
o2 = PublicKeyCredentialRequestOptions.from_json(json.loads(js))
self.assertEqual(o, o2)
o = PublicKeyCredentialRequestOptions(b"request_challenge")