From 765d53a6f402bf60ccf591131076820f1509fd4a Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Wed, 27 Mar 2024 22:14:00 -0700 Subject: [PATCH 01/12] Add parse_registration_options_json --- tests/test_parse_registration_options_json.py | 188 ++++++++++++++++ webauthn/helpers/exceptions.py | 4 + .../parse_registration_options_json.py | 213 ++++++++++++++++++ 3 files changed, 405 insertions(+) create mode 100644 tests/test_parse_registration_options_json.py create mode 100644 webauthn/helpers/parse_registration_options_json.py diff --git a/tests/test_parse_registration_options_json.py b/tests/test_parse_registration_options_json.py new file mode 100644 index 0000000..6bca933 --- /dev/null +++ b/tests/test_parse_registration_options_json.py @@ -0,0 +1,188 @@ +from unittest import TestCase + +from webauthn.helpers import base64url_to_bytes, options_to_json +from webauthn.helpers.exceptions import InvalidJSONStructure, InvalidRegistrationResponse +from webauthn.helpers.structs import ( + AuthenticatorTransport, + AuthenticatorAttachment, + AttestationConveyancePreference, + AuthenticatorSelectionCriteria, + PublicKeyCredentialDescriptor, + ResidentKeyRequirement, + PublicKeyCredentialCreationOptions, + PublicKeyCredentialCreationOptions, + PublicKeyCredentialRpEntity, + PublicKeyCredentialUserEntity, + UserVerificationRequirement, + PublicKeyCredentialParameters, +) +from webauthn.helpers.cose import COSEAlgorithmIdentifier +from webauthn.helpers.parse_registration_options_json import parse_registration_options_json +from webauthn.registration.generate_registration_options import generate_registration_options + + +class TestParseRegistrationOptionsJSON(TestCase): + maxDiff = None + + def test_raises_on_non_dict_json(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "not a JSON object"): + parse_registration_options_json("[0]") + + def test_returns_parsed_options_simple(self) -> None: + parsed = parse_registration_options_json( + { + "rp": {"name": "Example Co", "id": "example.com"}, + "user": { + "id": "vEC5nFXSxpc_W68bX59JeD3c_-1XDJ5RblcWjY3Tx7RvfC0rkB19UWadf6wDEWG8T1ztksOYMim0sJIn6z_5tw", + "name": "bob", + "displayName": "bob", + }, + "challenge": "scb_z5GweYijAT2ppsB0HAklsw96fPs_tOWh-myqkOeb9rcvhWBwUZ56J3t3eocgjHkS4Mf3XeXTOQc1ySvk5w", + "pubKeyCredParams": [ + {"type": "public-key", "alg": -7}, + {"type": "public-key", "alg": -8}, + {"type": "public-key", "alg": -36}, + {"type": "public-key", "alg": -37}, + {"type": "public-key", "alg": -38}, + {"type": "public-key", "alg": -39}, + {"type": "public-key", "alg": -257}, + {"type": "public-key", "alg": -258}, + {"type": "public-key", "alg": -259}, + ], + "timeout": 60000, + "excludeCredentials": [], + "attestation": "none", + } + ) + + self.assertEqual( + parsed.rp, PublicKeyCredentialRpEntity(id="example.com", name="Example Co") + ) + self.assertEqual( + parsed.user, + PublicKeyCredentialUserEntity( + id=base64url_to_bytes( + "vEC5nFXSxpc_W68bX59JeD3c_-1XDJ5RblcWjY3Tx7RvfC0rkB19UWadf6wDEWG8T1ztksOYMim0sJIn6z_5tw" + ), + name="bob", + display_name="bob", + ), + ) + self.assertEqual(parsed.attestation, AttestationConveyancePreference.NONE) + self.assertEqual(parsed.authenticator_selection, None) + self.assertEqual( + parsed.challenge, + base64url_to_bytes( + "scb_z5GweYijAT2ppsB0HAklsw96fPs_tOWh-myqkOeb9rcvhWBwUZ56J3t3eocgjHkS4Mf3XeXTOQc1ySvk5w" + ), + ) + self.assertEqual(parsed.exclude_credentials, []) + self.assertEqual( + parsed.pub_key_cred_params, + [ + PublicKeyCredentialParameters( + alg=COSEAlgorithmIdentifier.ECDSA_SHA_256, + type="public-key", + ), + PublicKeyCredentialParameters( + alg=COSEAlgorithmIdentifier.EDDSA, + type="public-key", + ), + PublicKeyCredentialParameters( + alg=COSEAlgorithmIdentifier.ECDSA_SHA_512, + type="public-key", + ), + PublicKeyCredentialParameters( + alg=COSEAlgorithmIdentifier.RSASSA_PSS_SHA_256, + type="public-key", + ), + PublicKeyCredentialParameters( + alg=COSEAlgorithmIdentifier.RSASSA_PSS_SHA_384, + type="public-key", + ), + PublicKeyCredentialParameters( + alg=COSEAlgorithmIdentifier.RSASSA_PSS_SHA_512, + type="public-key", + ), + PublicKeyCredentialParameters( + alg=COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256, + type="public-key", + ), + PublicKeyCredentialParameters( + alg=COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_384, + type="public-key", + ), + PublicKeyCredentialParameters( + alg=COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_512, + type="public-key", + ), + ], + ) + self.assertEqual(parsed.timeout, 60000) + + def test_returns_parsed_options_full(self) -> None: + parsed = parse_registration_options_json( + { + "rp": {"name": "Example Co", "id": "example.com"}, + "user": {"id": "AQIDBA", "name": "lee", "displayName": "Lee"}, + "challenge": "AQIDBAUGBwgJAA", + "pubKeyCredParams": [{"type": "public-key", "alg": -36}], + "timeout": 12000, + "excludeCredentials": [ + { + "id": "MTIzNDU2Nzg5MA", + "type": "public-key", + "transports": ["internal", "hybrid"], + } + ], + "authenticatorSelection": { + "authenticatorAttachment": "platform", + "residentKey": "required", + "requireResidentKey": True, + "userVerification": "discouraged", + }, + "attestation": "direct", + } + ) + + self.assertEqual( + parsed.rp, PublicKeyCredentialRpEntity(id="example.com", name="Example Co") + ) + self.assertEqual( + parsed.user, + PublicKeyCredentialUserEntity( + id=base64url_to_bytes("AQIDBA"), + name="lee", + display_name="Lee", + ), + ) + self.assertEqual(parsed.attestation, AttestationConveyancePreference.DIRECT) + self.assertEqual( + parsed.authenticator_selection, + AuthenticatorSelectionCriteria( + authenticator_attachment=AuthenticatorAttachment.PLATFORM, + resident_key=ResidentKeyRequirement.REQUIRED, + require_resident_key=True, + user_verification=UserVerificationRequirement.DISCOURAGED, + ), + ) + self.assertEqual(parsed.challenge, base64url_to_bytes("AQIDBAUGBwgJAA")) + self.assertEqual( + parsed.exclude_credentials, + [ + PublicKeyCredentialDescriptor( + id=base64url_to_bytes("MTIzNDU2Nzg5MA"), + transports=[AuthenticatorTransport.INTERNAL, AuthenticatorTransport.HYBRID], + ) + ], + ) + self.assertEqual( + parsed.pub_key_cred_params, + [ + PublicKeyCredentialParameters( + alg=COSEAlgorithmIdentifier.ECDSA_SHA_512, + type="public-key", + ) + ], + ) + self.assertEqual(parsed.timeout, 12000) diff --git a/webauthn/helpers/exceptions.py b/webauthn/helpers/exceptions.py index 7776fcd..3c6cb9c 100644 --- a/webauthn/helpers/exceptions.py +++ b/webauthn/helpers/exceptions.py @@ -1,3 +1,7 @@ +class InvalidRegistrationOptions(Exception): + pass + + class InvalidRegistrationResponse(Exception): pass diff --git a/webauthn/helpers/parse_registration_options_json.py b/webauthn/helpers/parse_registration_options_json.py new file mode 100644 index 0000000..38878f6 --- /dev/null +++ b/webauthn/helpers/parse_registration_options_json.py @@ -0,0 +1,213 @@ +import json +from json.decoder import JSONDecodeError +from typing import Union, Optional, List + + +from .structs import ( + PublicKeyCredentialCreationOptions, + PublicKeyCredentialRpEntity, + PublicKeyCredentialUserEntity, + AttestationConveyancePreference, + AuthenticatorSelectionCriteria, + AuthenticatorAttachment, + ResidentKeyRequirement, + UserVerificationRequirement, + PublicKeyCredentialParameters, + PublicKeyCredentialDescriptor, + AuthenticatorTransport, +) +from .cose import COSEAlgorithmIdentifier +from .exceptions import InvalidJSONStructure, InvalidRegistrationOptions +from .base64url_to_bytes import base64url_to_bytes + + +def parse_registration_options_json( + json_val: Union[str, dict] +) -> PublicKeyCredentialCreationOptions: + """ + Parse a JSON form of registration options, as either a stringified JSON object or a + plain dict, into an instance of PublicKeyCredentialCreationOptions + """ + if isinstance(json_val, str): + try: + json_val = json.loads(json_val) + except JSONDecodeError: + raise InvalidJSONStructure("Unable to decode credential as JSON") + + if not isinstance(json_val, dict): + raise InvalidJSONStructure("Credential was not a JSON object") + + """ + Check rp + """ + options_rp = json_val.get("rp") + if not isinstance(options_rp, dict): + raise InvalidJSONStructure("Options missing required rp") + + options_rp_id = options_rp.get("id") + if options_rp_id is not None and not isinstance(options_rp_id, str): + raise InvalidJSONStructure("Options rp.id present but not string") + + options_rp_name = options_rp.get("name") + if not isinstance(options_rp_name, str): + raise InvalidJSONStructure("Options rp missing required name") + + """ + Check user + """ + options_user = json_val.get("user") + if not isinstance(options_user, dict): + raise InvalidJSONStructure("Options missing required user") + + options_user_id = options_user.get("id") + if not isinstance(options_user_id, str): + raise InvalidJSONStructure("Options user missing required id") + + options_user_name = options_user.get("name") + if not isinstance(options_user_name, str): + raise InvalidJSONStructure("Options user missing required name") + + options_user_display_name = options_user.get("displayName") + if not isinstance(options_user_display_name, str): + raise InvalidJSONStructure("Options user missing required displayName") + + """ + Check attestation + """ + options_attestation = json_val.get("attestation") + if not isinstance(options_attestation, str): + raise InvalidJSONStructure("Options missing required attestation") + + try: + mapped_attestation = AttestationConveyancePreference(options_attestation) + except ValueError as exc: + raise InvalidJSONStructure("Options attestation was invalid value") from exc + + """ + Check authenticatorSelection + """ + options_authr_selection = json_val.get("authenticatorSelection") + mapped_authenticator_selection: Optional[AuthenticatorSelectionCriteria] = None + if isinstance(options_authr_selection, dict): + options_authr_selection_attachment = options_authr_selection.get("authenticatorAttachment") + mapped_attachment = None + if options_authr_selection_attachment is not None: + try: + mapped_attachment = AuthenticatorAttachment(options_authr_selection_attachment) + except ValueError as exc: + raise InvalidJSONStructure( + "Options authenticatorSelection attachment was invalid value" + ) from exc + + options_authr_selection_rkey = options_authr_selection.get("residentKey") + mapped_rkey = None + if options_authr_selection_rkey is not None: + try: + mapped_rkey = ResidentKeyRequirement(options_authr_selection_rkey) + except ValueError as exc: + raise InvalidJSONStructure( + "Options authenticatorSelection residentKey was invalid value" + ) from exc + + options_authr_selection_require_rkey = options_authr_selection.get("requireResidentKey") + mapped_require_rkey = False + if options_authr_selection_require_rkey is not None: + if not isinstance(options_authr_selection_require_rkey, bool): + raise InvalidJSONStructure( + "Options authenticatorSelection requireResidentKey was invalid boolean" + ) + + mapped_require_rkey = options_authr_selection_require_rkey + + options_authr_selection_uv = options_authr_selection.get("userVerification") + mapped_user_verification = UserVerificationRequirement.PREFERRED + if options_authr_selection_uv is not None: + try: + mapped_user_verification = UserVerificationRequirement(options_authr_selection_uv) + except ValueError as exc: + raise InvalidJSONStructure( + "Options authenticatorSelection userVerification was invalid value" + ) from exc + + mapped_authenticator_selection = AuthenticatorSelectionCriteria( + authenticator_attachment=mapped_attachment, + resident_key=mapped_rkey, + require_resident_key=mapped_require_rkey, + user_verification=mapped_user_verification, + ) + + """ + Check challenge is present + """ + options_challenge = json_val.get("challenge") + if not isinstance(options_challenge, str): + raise InvalidJSONStructure("Options missing required challenge") + + """ + Check pubKeyCredParams + """ + options_pub_key_cred_params = json_val.get("pubKeyCredParams") + if not isinstance(options_pub_key_cred_params, list): + raise InvalidJSONStructure("Options pubKeyCredParams was invalid value") + + mapped_pub_key_cred_params = [ + PublicKeyCredentialParameters(alg=COSEAlgorithmIdentifier(param["alg"]), type="public-key") + for param in options_pub_key_cred_params + ] + + """ + Check excludeCredentials + """ + options_exclude_credentials = json_val.get("excludeCredentials") + mapped_exclude_credentials: Optional[List[PublicKeyCredentialDescriptor]] = None + if isinstance(options_exclude_credentials, list): + mapped_exclude_credentials = [] + for cred in options_exclude_credentials: + _mapped = PublicKeyCredentialDescriptor( + id=base64url_to_bytes(cred["id"]), + ) + + _transports = cred.get("transports") + if _transports is not None: + if not isinstance(_transports, list): + raise InvalidJSONStructure( + "Options excludeCredentials entry transports were invalid" + ) + _mapped.transports = [ + AuthenticatorTransport(_transport) for _transport in _transports + ] + + mapped_exclude_credentials.append(_mapped) + + """ + Check timeout + """ + options_timeout = json_val.get("timeout") + mapped_timeout = None + if isinstance(options_timeout, int): + mapped_timeout = options_timeout + + try: + registration_options = PublicKeyCredentialCreationOptions( + rp=PublicKeyCredentialRpEntity( + id=options_rp_id, + name=options_rp_name, + ), + user=PublicKeyCredentialUserEntity( + id=base64url_to_bytes(options_user_id), + name=options_user_name, + display_name=options_user_display_name, + ), + attestation=mapped_attestation, + authenticator_selection=mapped_authenticator_selection, + challenge=base64url_to_bytes(options_challenge), + pub_key_cred_params=mapped_pub_key_cred_params, + exclude_credentials=mapped_exclude_credentials, + timeout=mapped_timeout, + ) + except Exception as exc: + raise InvalidRegistrationOptions( + "Could not parse registration options from JSON data" + ) from exc + + return registration_options From 6e768fd4e689aea9c67295e5bb55c600eda12872 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Thu, 28 Mar 2024 09:02:37 -0700 Subject: [PATCH 02/12] Fix misnamed test class --- tests/test_parse_registration_credential_json.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_parse_registration_credential_json.py b/tests/test_parse_registration_credential_json.py index ece8f32..f2829e0 100644 --- a/tests/test_parse_registration_credential_json.py +++ b/tests/test_parse_registration_credential_json.py @@ -6,7 +6,7 @@ from webauthn.helpers.parse_registration_credential_json import parse_registration_credential_json -class TestParseClientDataJSON(TestCase): +class TestParseRegistrationCredentialJSON(TestCase): def test_raises_on_non_dict_json(self) -> None: with self.assertRaisesRegex(InvalidJSONStructure, "not a JSON object"): parse_registration_credential_json("[0]") From fba4acc7252271e9c5351712a006ae71e6f4eafc Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Thu, 28 Mar 2024 09:49:17 -0700 Subject: [PATCH 03/12] Add some utests --- tests/test_parse_registration_options_json.py | 246 +++++++++++++++++- .../parse_registration_options_json.py | 42 ++- 2 files changed, 265 insertions(+), 23 deletions(-) diff --git a/tests/test_parse_registration_options_json.py b/tests/test_parse_registration_options_json.py index 6bca933..046f679 100644 --- a/tests/test_parse_registration_options_json.py +++ b/tests/test_parse_registration_options_json.py @@ -1,7 +1,7 @@ from unittest import TestCase -from webauthn.helpers import base64url_to_bytes, options_to_json -from webauthn.helpers.exceptions import InvalidJSONStructure, InvalidRegistrationResponse +from webauthn.helpers import base64url_to_bytes +from webauthn.helpers.exceptions import InvalidJSONStructure from webauthn.helpers.structs import ( AuthenticatorTransport, AuthenticatorAttachment, @@ -9,8 +9,6 @@ AuthenticatorSelectionCriteria, PublicKeyCredentialDescriptor, ResidentKeyRequirement, - PublicKeyCredentialCreationOptions, - PublicKeyCredentialCreationOptions, PublicKeyCredentialRpEntity, PublicKeyCredentialUserEntity, UserVerificationRequirement, @@ -18,16 +16,11 @@ ) from webauthn.helpers.cose import COSEAlgorithmIdentifier from webauthn.helpers.parse_registration_options_json import parse_registration_options_json -from webauthn.registration.generate_registration_options import generate_registration_options class TestParseRegistrationOptionsJSON(TestCase): maxDiff = None - def test_raises_on_non_dict_json(self) -> None: - with self.assertRaisesRegex(InvalidJSONStructure, "not a JSON object"): - parse_registration_options_json("[0]") - def test_returns_parsed_options_simple(self) -> None: parsed = parse_registration_options_json( { @@ -186,3 +179,238 @@ def test_returns_parsed_options_full(self) -> None: ], ) self.assertEqual(parsed.timeout, 12000) + + def test_raises_on_non_dict_json(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "not a JSON object"): + parse_registration_options_json("[0]") + + def test_raises_on_missing_rp(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "missing required rp"): + parse_registration_options_json({}) + + def test_raises_on_malformed_rp_id(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "id present but not string"): + parse_registration_options_json( + { + "rp": {"id": 0}, + } + ) + + def test_raises_on_missing_missing_rp_name(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "missing required name"): + parse_registration_options_json( + { + "rp": {"id": "example.com"}, + } + ) + + def test_raises_on_missing_user(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "missing required user"): + parse_registration_options_json( + { + "rp": {"id": "example.com", "name": "Example Co"}, + } + ) + + def test_raises_on_missing_user_id(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "missing required id"): + parse_registration_options_json( + { + "rp": {"id": "example.com", "name": "Example Co"}, + "user": {}, + } + ) + + def test_raises_on_missing_user_name(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "missing required name"): + parse_registration_options_json( + { + "rp": {"id": "example.com", "name": "Example Co"}, + "user": {"id": "aaaa"}, + } + ) + + def test_raises_on_missing_user_display_name(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "missing required displayName"): + parse_registration_options_json( + { + "rp": {"id": "example.com", "name": "Example Co"}, + "user": {"id": "aaaa", "name": "Lee"}, + } + ) + + def test_raises_on_missing_attestation(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "missing required attestation"): + parse_registration_options_json( + { + "rp": {"id": "example.com", "name": "Example Co"}, + "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"}, + } + ) + + def test_raises_on_unrecognized_attestation(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "attestation was invalid value"): + parse_registration_options_json( + { + "rp": {"id": "example.com", "name": "Example Co"}, + "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"}, + "attestation": "if_you_feel_like_it", + } + ) + + def test_supports_optional_authenticator_selection(self) -> None: + opts = parse_registration_options_json( + { + "rp": {"id": "example.com", "name": "Example Co"}, + "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"}, + "attestation": "none", + "challenge": "AAAA", + "pubKeyCredParams": [{"type": "public-key", "alg": -7}], + } + ) + + self.assertIsNone(opts.authenticator_selection) + + def test_raises_on_invalid_authenticator_selection_authenticator_attachment(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "attachment was invalid value"): + parse_registration_options_json( + { + "rp": {"id": "example.com", "name": "Example Co"}, + "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"}, + "attestation": "none", + "authenticatorSelection": {"authenticatorAttachment": "pcie"}, + } + ) + + def test_raises_on_invalid_authenticator_selection_resident_key(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "residentKey was invalid value"): + parse_registration_options_json( + { + "rp": {"id": "example.com", "name": "Example Co"}, + "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"}, + "attestation": "none", + "authenticatorSelection": {"residentKey": "yes_please"}, + } + ) + + def test_raises_on_invalid_authenticator_selection_require_resident_key(self) -> None: + with self.assertRaisesRegex( + InvalidJSONStructure, "requireResidentKey was invalid boolean" + ): + parse_registration_options_json( + { + "rp": {"id": "example.com", "name": "Example Co"}, + "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"}, + "attestation": "none", + "authenticatorSelection": {"requireResidentKey": "always"}, + } + ) + + def test_raises_on_invalid_authenticator_selection_user_verification(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "userVerification was invalid value"): + parse_registration_options_json( + { + "rp": {"id": "example.com", "name": "Example Co"}, + "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"}, + "attestation": "none", + "authenticatorSelection": {"userVerification": "when_inconvenient"}, + } + ) + + def test_raises_on_missing_challenge(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "missing required challenge"): + parse_registration_options_json( + { + "rp": {"id": "example.com", "name": "Example Co"}, + "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"}, + "attestation": "none", + } + ) + + def test_raises_on_missing_pub_key_cred_params(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "pubKeyCredParams was invalid value"): + parse_registration_options_json( + { + "rp": {"id": "example.com", "name": "Example Co"}, + "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"}, + "attestation": "none", + "challenge": "aaaa", + } + ) + + def test_raises_on_pub_key_cred_params_entry_with_invalid_alg(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "entry had invalid alg"): + parse_registration_options_json( + { + "rp": {"id": "example.com", "name": "Example Co"}, + "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"}, + "attestation": "none", + "challenge": "aaaa", + "pubKeyCredParams": [{"alg": 0}], + } + ) + + def test_supports_optional_exclude_credentials(self) -> None: + opts = parse_registration_options_json( + { + "rp": {"id": "example.com", "name": "Example Co"}, + "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"}, + "attestation": "none", + "challenge": "aaaa", + "pubKeyCredParams": [{"alg": -7}], + } + ) + + self.assertIsNone(opts.exclude_credentials) + + def test_raises_on_exclude_credentials_entry_missing_id(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "missing required id"): + parse_registration_options_json( + { + "rp": {"id": "example.com", "name": "Example Co"}, + "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"}, + "attestation": "none", + "challenge": "aaaa", + "pubKeyCredParams": [{"alg": -7}], + "excludeCredentials": [{}], + } + ) + + def test_raises_on_exclude_credentials_entry_invalid_transports(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "transports was not list"): + parse_registration_options_json( + { + "rp": {"id": "example.com", "name": "Example Co"}, + "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"}, + "attestation": "none", + "challenge": "aaaa", + "pubKeyCredParams": [{"alg": -7}], + "excludeCredentials": [{"id": "aaaa", "transports": ""}], + } + ) + + def test_raises_on_exclude_credentials_entry_invalid_transports_entry(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "entry transports had invalid value"): + parse_registration_options_json( + { + "rp": {"id": "example.com", "name": "Example Co"}, + "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"}, + "attestation": "none", + "challenge": "aaaa", + "pubKeyCredParams": [{"alg": -7}], + "excludeCredentials": [{"id": "aaaa", "transports": ["pcie"]}], + } + ) + + def test_supports_missing_timeout(self) -> None: + opts = parse_registration_options_json( + { + "rp": {"id": "example.com", "name": "Example Co"}, + "user": {"id": "aaaa", "name": "lee", "displayName": "Lee"}, + "attestation": "none", + "challenge": "aaaa", + "pubKeyCredParams": [{"alg": -7}], + } + ) + + self.assertIsNone(opts.timeout) diff --git a/webauthn/helpers/parse_registration_options_json.py b/webauthn/helpers/parse_registration_options_json.py index 38878f6..07b4902 100644 --- a/webauthn/helpers/parse_registration_options_json.py +++ b/webauthn/helpers/parse_registration_options_json.py @@ -26,16 +26,18 @@ def parse_registration_options_json( ) -> PublicKeyCredentialCreationOptions: """ Parse a JSON form of registration options, as either a stringified JSON object or a - plain dict, into an instance of PublicKeyCredentialCreationOptions + plain dict, into an instance of PublicKeyCredentialCreationOptions. Typically useful in + mapping output from `generate_registration_options()`, that's been persisted as JSON via + Redis/etc... back into structured data. """ if isinstance(json_val, str): try: json_val = json.loads(json_val) except JSONDecodeError: - raise InvalidJSONStructure("Unable to decode credential as JSON") + raise InvalidJSONStructure("Unable to decode options as JSON") if not isinstance(json_val, dict): - raise InvalidJSONStructure("Credential was not a JSON object") + raise InvalidJSONStructure("Options were not a JSON object") """ Check rp @@ -150,10 +152,15 @@ def parse_registration_options_json( if not isinstance(options_pub_key_cred_params, list): raise InvalidJSONStructure("Options pubKeyCredParams was invalid value") - mapped_pub_key_cred_params = [ - PublicKeyCredentialParameters(alg=COSEAlgorithmIdentifier(param["alg"]), type="public-key") - for param in options_pub_key_cred_params - ] + try: + mapped_pub_key_cred_params = [ + PublicKeyCredentialParameters( + alg=COSEAlgorithmIdentifier(param["alg"]), type="public-key" + ) + for param in options_pub_key_cred_params + ] + except ValueError as exc: + raise InvalidJSONStructure("Options pubKeyCredParams entry had invalid alg") from exc """ Check excludeCredentials @@ -163,19 +170,26 @@ def parse_registration_options_json( if isinstance(options_exclude_credentials, list): mapped_exclude_credentials = [] for cred in options_exclude_credentials: - _mapped = PublicKeyCredentialDescriptor( - id=base64url_to_bytes(cred["id"]), - ) + _cred_id = cred.get("id") + if not isinstance(_cred_id, str): + raise InvalidJSONStructure("Options excludeCredentials entry missing required id") + + _mapped = PublicKeyCredentialDescriptor(id=base64url_to_bytes(_cred_id)) _transports = cred.get("transports") if _transports is not None: if not isinstance(_transports, list): raise InvalidJSONStructure( - "Options excludeCredentials entry transports were invalid" + "Options excludeCredentials entry transports was not list" ) - _mapped.transports = [ - AuthenticatorTransport(_transport) for _transport in _transports - ] + try: + _mapped.transports = [ + AuthenticatorTransport(_transport) for _transport in _transports + ] + except ValueError as exc: + raise InvalidJSONStructure( + "Options excludeCredentials entry transports had invalid value" + ) from exc mapped_exclude_credentials.append(_mapped) From f018e9e6f5f0b1263ac31fcbbc41a3a8cd5f5976 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Thu, 28 Mar 2024 09:57:15 -0700 Subject: [PATCH 04/12] Document some commands for code quality --- README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/README.md b/README.md index bfdd883..b2dc8e7 100644 --- a/README.md +++ b/README.md @@ -85,3 +85,32 @@ Python's unittest module can be used to execute everything in the **tests/** dir ```sh venv $> python -m unittest ``` + +Auto-watching unittests can be achieved with a tool like nodemon. + +**All tests:** +```sh +venv $> nodemon --exec "python -m unittest" --ext py +``` + +**An individual test file:** +```sh +venv $> nodemon --exec "python -m unittest tests/test_aaguid_to_string.py" --ext py +``` + +### Linting and Formatting + +Linting is handled via `mypy`: + +```sh +venv $> python -m mypy webauthn +Success: no issues found in 52 source files +``` + +The entire library is formatted using `black`: + +```sh +venv $> python -m black webauthn --line-length=99 +All done! ✨ 🍰 ✨ +52 files left unchanged. +``` From 8f5b56a1f4b13e212d2687c515e08fcb86dc5916 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Thu, 28 Mar 2024 10:09:59 -0700 Subject: [PATCH 05/12] Move test input complexity around --- tests/test_parse_registration_options_json.py | 95 ++++++++++--------- 1 file changed, 49 insertions(+), 46 deletions(-) diff --git a/tests/test_parse_registration_options_json.py b/tests/test_parse_registration_options_json.py index 046f679..c6da6bb 100644 --- a/tests/test_parse_registration_options_json.py +++ b/tests/test_parse_registration_options_json.py @@ -31,17 +31,7 @@ def test_returns_parsed_options_simple(self) -> None: "displayName": "bob", }, "challenge": "scb_z5GweYijAT2ppsB0HAklsw96fPs_tOWh-myqkOeb9rcvhWBwUZ56J3t3eocgjHkS4Mf3XeXTOQc1ySvk5w", - "pubKeyCredParams": [ - {"type": "public-key", "alg": -7}, - {"type": "public-key", "alg": -8}, - {"type": "public-key", "alg": -36}, - {"type": "public-key", "alg": -37}, - {"type": "public-key", "alg": -38}, - {"type": "public-key", "alg": -39}, - {"type": "public-key", "alg": -257}, - {"type": "public-key", "alg": -258}, - {"type": "public-key", "alg": -259}, - ], + "pubKeyCredParams": [{"type": "public-key", "alg": -36}], "timeout": 60000, "excludeCredentials": [], "attestation": "none", @@ -73,42 +63,10 @@ def test_returns_parsed_options_simple(self) -> None: self.assertEqual( parsed.pub_key_cred_params, [ - PublicKeyCredentialParameters( - alg=COSEAlgorithmIdentifier.ECDSA_SHA_256, - type="public-key", - ), - PublicKeyCredentialParameters( - alg=COSEAlgorithmIdentifier.EDDSA, - type="public-key", - ), PublicKeyCredentialParameters( alg=COSEAlgorithmIdentifier.ECDSA_SHA_512, type="public-key", - ), - PublicKeyCredentialParameters( - alg=COSEAlgorithmIdentifier.RSASSA_PSS_SHA_256, - type="public-key", - ), - PublicKeyCredentialParameters( - alg=COSEAlgorithmIdentifier.RSASSA_PSS_SHA_384, - type="public-key", - ), - PublicKeyCredentialParameters( - alg=COSEAlgorithmIdentifier.RSASSA_PSS_SHA_512, - type="public-key", - ), - PublicKeyCredentialParameters( - alg=COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256, - type="public-key", - ), - PublicKeyCredentialParameters( - alg=COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_384, - type="public-key", - ), - PublicKeyCredentialParameters( - alg=COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_512, - type="public-key", - ), + ) ], ) self.assertEqual(parsed.timeout, 60000) @@ -119,7 +77,17 @@ def test_returns_parsed_options_full(self) -> None: "rp": {"name": "Example Co", "id": "example.com"}, "user": {"id": "AQIDBA", "name": "lee", "displayName": "Lee"}, "challenge": "AQIDBAUGBwgJAA", - "pubKeyCredParams": [{"type": "public-key", "alg": -36}], + "pubKeyCredParams": [ + {"type": "public-key", "alg": -7}, + {"type": "public-key", "alg": -8}, + {"type": "public-key", "alg": -36}, + {"type": "public-key", "alg": -37}, + {"type": "public-key", "alg": -38}, + {"type": "public-key", "alg": -39}, + {"type": "public-key", "alg": -257}, + {"type": "public-key", "alg": -258}, + {"type": "public-key", "alg": -259}, + ], "timeout": 12000, "excludeCredentials": [ { @@ -172,14 +140,49 @@ def test_returns_parsed_options_full(self) -> None: self.assertEqual( parsed.pub_key_cred_params, [ + PublicKeyCredentialParameters( + alg=COSEAlgorithmIdentifier.ECDSA_SHA_256, + type="public-key", + ), + PublicKeyCredentialParameters( + alg=COSEAlgorithmIdentifier.EDDSA, + type="public-key", + ), PublicKeyCredentialParameters( alg=COSEAlgorithmIdentifier.ECDSA_SHA_512, type="public-key", - ) + ), + PublicKeyCredentialParameters( + alg=COSEAlgorithmIdentifier.RSASSA_PSS_SHA_256, + type="public-key", + ), + PublicKeyCredentialParameters( + alg=COSEAlgorithmIdentifier.RSASSA_PSS_SHA_384, + type="public-key", + ), + PublicKeyCredentialParameters( + alg=COSEAlgorithmIdentifier.RSASSA_PSS_SHA_512, + type="public-key", + ), + PublicKeyCredentialParameters( + alg=COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256, + type="public-key", + ), + PublicKeyCredentialParameters( + alg=COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_384, + type="public-key", + ), + PublicKeyCredentialParameters( + alg=COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_512, + type="public-key", + ), ], ) self.assertEqual(parsed.timeout, 12000) + def test_supports_json_string(self) -> None: + pass + def test_raises_on_non_dict_json(self) -> None: with self.assertRaisesRegex(InvalidJSONStructure, "not a JSON object"): parse_registration_options_json("[0]") From e592e911316d6c54b8c499f04702ebc92332f4c1 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Thu, 28 Mar 2024 10:16:34 -0700 Subject: [PATCH 06/12] Add test for JSON string support --- tests/test_parse_registration_options_json.py | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/tests/test_parse_registration_options_json.py b/tests/test_parse_registration_options_json.py index c6da6bb..3a2d2c6 100644 --- a/tests/test_parse_registration_options_json.py +++ b/tests/test_parse_registration_options_json.py @@ -181,7 +181,45 @@ def test_returns_parsed_options_full(self) -> None: self.assertEqual(parsed.timeout, 12000) def test_supports_json_string(self) -> None: - pass + parsed = parse_registration_options_json( + '{"rp": {"name": "Example Co", "id": "example.com"}, "user": {"id": "vEC5nFXSxpc_W68bX59JeD3c_-1XDJ5RblcWjY3Tx7RvfC0rkB19UWadf6wDEWG8T1ztksOYMim0sJIn6z_5tw", "name": "bob", "displayName": "bob"}, "challenge": "scb_z5GweYijAT2ppsB0HAklsw96fPs_tOWh-myqkOeb9rcvhWBwUZ56J3t3eocgjHkS4Mf3XeXTOQc1ySvk5w", "authenticatorSelection": {"userVerification": "required"}, "pubKeyCredParams": [{"type": "public-key", "alg": -36}], "timeout": 60000, "excludeCredentials": [], "attestation": "none"}' + ) + + self.assertEqual( + parsed.rp, PublicKeyCredentialRpEntity(id="example.com", name="Example Co") + ) + self.assertEqual( + parsed.user, + PublicKeyCredentialUserEntity( + id=base64url_to_bytes( + "vEC5nFXSxpc_W68bX59JeD3c_-1XDJ5RblcWjY3Tx7RvfC0rkB19UWadf6wDEWG8T1ztksOYMim0sJIn6z_5tw" + ), + name="bob", + display_name="bob", + ), + ) + self.assertEqual(parsed.attestation, AttestationConveyancePreference.NONE) + self.assertEqual( + parsed.authenticator_selection, + AuthenticatorSelectionCriteria(user_verification=UserVerificationRequirement.REQUIRED), + ) + self.assertEqual( + parsed.challenge, + base64url_to_bytes( + "scb_z5GweYijAT2ppsB0HAklsw96fPs_tOWh-myqkOeb9rcvhWBwUZ56J3t3eocgjHkS4Mf3XeXTOQc1ySvk5w" + ), + ) + self.assertEqual(parsed.exclude_credentials, []) + self.assertEqual( + parsed.pub_key_cred_params, + [ + PublicKeyCredentialParameters( + alg=COSEAlgorithmIdentifier.ECDSA_SHA_512, + type="public-key", + ) + ], + ) + self.assertEqual(parsed.timeout, 60000) def test_raises_on_non_dict_json(self) -> None: with self.assertRaisesRegex(InvalidJSONStructure, "not a JSON object"): From d29688a7d730d9d6693ef994d7ffda1f0078dedf Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Thu, 28 Mar 2024 10:54:08 -0700 Subject: [PATCH 07/12] Tweak docstring --- webauthn/helpers/parse_registration_options_json.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/webauthn/helpers/parse_registration_options_json.py b/webauthn/helpers/parse_registration_options_json.py index 07b4902..96b5ffb 100644 --- a/webauthn/helpers/parse_registration_options_json.py +++ b/webauthn/helpers/parse_registration_options_json.py @@ -25,10 +25,10 @@ def parse_registration_options_json( json_val: Union[str, dict] ) -> PublicKeyCredentialCreationOptions: """ - Parse a JSON form of registration options, as either a stringified JSON object or a - plain dict, into an instance of PublicKeyCredentialCreationOptions. Typically useful in - mapping output from `generate_registration_options()`, that's been persisted as JSON via - Redis/etc... back into structured data. + Parse a JSON form of registration options, as either stringified JSON or a plain dict, into an + instance of `PublicKeyCredentialCreationOptions`. Typically useful in mapping output from + `generate_registration_options()`, that's been persisted as JSON via Redis/etc... back into + structured data. """ if isinstance(json_val, str): try: From 047a69d38cb26469f608fd94bb424c55ab15dd6a Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Thu, 28 Mar 2024 11:55:44 -0700 Subject: [PATCH 08/12] Add parse_authentication_options_json --- tests/test_parse_authentication_options.py | 169 ++++++++++++++++++ webauthn/helpers/exceptions.py | 4 + .../parse_authentication_options_json.py | 113 ++++++++++++ 3 files changed, 286 insertions(+) create mode 100644 tests/test_parse_authentication_options.py create mode 100644 webauthn/helpers/parse_authentication_options_json.py diff --git a/tests/test_parse_authentication_options.py b/tests/test_parse_authentication_options.py new file mode 100644 index 0000000..98a500a --- /dev/null +++ b/tests/test_parse_authentication_options.py @@ -0,0 +1,169 @@ +from email.mime import base +from unittest import TestCase + +from webauthn.helpers import base64url_to_bytes +from webauthn.helpers.exceptions import InvalidJSONStructure +from webauthn.helpers.structs import ( + AuthenticatorTransport, + PublicKeyCredentialDescriptor, + UserVerificationRequirement, +) +from webauthn.helpers.parse_authentication_options_json import parse_authentication_options_json + + +class TestParseRegistrationOptionsJSON(TestCase): + maxDiff = None + + def test_returns_parsed_options_simple(self) -> None: + opts = parse_authentication_options_json( + { + "challenge": "skxyhJljbw-ZQn-g1i87FBWeJ8_8B78oihdtSmVYaI2mArvHxI7WyTEW3gIeIRamDPlh8PJOK-ThcQc3xPNYTQ", + "timeout": 60000, + "rpId": "example.com", + "allowCredentials": [], + "userVerification": "preferred", + } + ) + + self.assertEqual( + opts.challenge, + base64url_to_bytes( + "skxyhJljbw-ZQn-g1i87FBWeJ8_8B78oihdtSmVYaI2mArvHxI7WyTEW3gIeIRamDPlh8PJOK-ThcQc3xPNYTQ" + ), + ) + self.assertEqual(opts.timeout, 60000) + self.assertEqual(opts.rp_id, "example.com") + self.assertEqual(opts.allow_credentials, []) + self.assertEqual(opts.user_verification, UserVerificationRequirement.PREFERRED) + + def test_returns_parsed_options_full(self) -> None: + opts = parse_authentication_options_json( + { + "challenge": "MTIzNDU2Nzg5MA", + "timeout": 12000, + "rpId": "example.com", + "allowCredentials": [ + { + "id": "MTIzNDU2Nzg5MA", + "type": "public-key", + "transports": ["internal", "hybrid"], + } + ], + "userVerification": "required", + } + ) + + self.assertEqual(opts.challenge, base64url_to_bytes("MTIzNDU2Nzg5MA")) + self.assertEqual(opts.timeout, 12000) + self.assertEqual(opts.rp_id, "example.com") + self.assertEqual( + opts.allow_credentials, + [ + PublicKeyCredentialDescriptor( + id=base64url_to_bytes("MTIzNDU2Nzg5MA"), + transports=[AuthenticatorTransport.INTERNAL, AuthenticatorTransport.HYBRID], + ) + ], + ) + self.assertEqual(opts.user_verification, UserVerificationRequirement.REQUIRED) + + def test_supports_json_string(self) -> None: + opts = parse_authentication_options_json( + '{"challenge": "skxyhJljbw-ZQn-g1i87FBWeJ8_8B78oihdtSmVYaI2mArvHxI7WyTEW3gIeIRamDPlh8PJOK-ThcQc3xPNYTQ", "timeout": 60000, "rpId": "example.com", "allowCredentials": [], "userVerification": "preferred"}' + ) + + self.assertEqual( + opts.challenge, + base64url_to_bytes( + "skxyhJljbw-ZQn-g1i87FBWeJ8_8B78oihdtSmVYaI2mArvHxI7WyTEW3gIeIRamDPlh8PJOK-ThcQc3xPNYTQ" + ), + ) + self.assertEqual(opts.timeout, 60000) + self.assertEqual(opts.rp_id, "example.com") + self.assertEqual(opts.allow_credentials, []) + self.assertEqual(opts.user_verification, UserVerificationRequirement.PREFERRED) + + def test_raises_on_non_dict_json(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "not a JSON object"): + parse_authentication_options_json("[0]") + + def test_raises_on_missing_challenge(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "missing required challenge"): + parse_authentication_options_json({}) + + def test_supports_optional_timeout(self) -> None: + opts = parse_authentication_options_json( + { + "challenge": "aaa", + "userVerification": "required", + } + ) + + self.assertIsNone(opts.timeout) + + def test_supports_optional_rp_id(self) -> None: + opts = parse_authentication_options_json( + { + "challenge": "aaa", + "userVerification": "required", + } + ) + + self.assertIsNone(opts.rp_id) + + def test_raises_on_missing_user_verification(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "missing required userVerification"): + parse_authentication_options_json( + { + "challenge": "aaaa", + } + ) + + def test_raises_on_invalid_user_verification(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "userVerification was invalid"): + parse_authentication_options_json( + { + "challenge": "aaaa", + "userVerification": "when_inconvenient", + } + ) + + def test_supports_optional_allow_credentials(self) -> None: + opts = parse_authentication_options_json( + { + "challenge": "aaa", + "userVerification": "required", + } + ) + + self.assertIsNone(opts.allow_credentials) + + def test_raises_on_allow_credentials_entry_missing_id(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "missing required id"): + parse_authentication_options_json( + { + "challenge": "aaa", + "userVerification": "required", + "allowCredentials": [{}], + } + ) + + def test_raises_on_allow_credentials_entry_invalid_transports(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "transports was not list"): + parse_authentication_options_json( + { + "challenge": "aaa", + "userVerification": "required", + "allowCredentials": [{"id": "aaaa", "transports": ""}], + } + ) + + def test_raises_on_allow_credentials_entry_invalid_transports_entry(self) -> None: + with self.assertRaisesRegex(InvalidJSONStructure, "entry transports had invalid value"): + parse_authentication_options_json( + { + "challenge": "aaa", + "userVerification": "required", + "allowCredentials": [{"id": "aaaa", "transports": ["pcie"]}], + } + ) diff --git a/webauthn/helpers/exceptions.py b/webauthn/helpers/exceptions.py index 3c6cb9c..af5db41 100644 --- a/webauthn/helpers/exceptions.py +++ b/webauthn/helpers/exceptions.py @@ -6,6 +6,10 @@ class InvalidRegistrationResponse(Exception): pass +class InvalidAuthenticationOptions(Exception): + pass + + class InvalidAuthenticationResponse(Exception): pass diff --git a/webauthn/helpers/parse_authentication_options_json.py b/webauthn/helpers/parse_authentication_options_json.py new file mode 100644 index 0000000..2fa51a9 --- /dev/null +++ b/webauthn/helpers/parse_authentication_options_json.py @@ -0,0 +1,113 @@ +import json +from json import JSONDecodeError +from typing import List, Optional, Union + +from webauthn.helpers import base64url_to_bytes + +from .exceptions import InvalidJSONStructure, InvalidAuthenticationOptions +from .structs import ( + AuthenticatorTransport, + PublicKeyCredentialDescriptor, + PublicKeyCredentialRequestOptions, + UserVerificationRequirement, +) + + +def parse_authentication_options_json( + json_val: Union[str, dict] +) -> PublicKeyCredentialRequestOptions: + """ + Parse a JSON form of authentication options, as either stringified JSON or a plain dict, into an + instance of `PublicKeyCredentialRequestOptions`. Typically useful in mapping output from + `generate_authentication_options()`, that's been persisted as JSON via Redis/etc... back into + structured data. + """ + if isinstance(json_val, str): + try: + json_val = json.loads(json_val) + except JSONDecodeError: + raise InvalidJSONStructure("Unable to decode options as JSON") + + if not isinstance(json_val, dict): + raise InvalidJSONStructure("Options were not a JSON object") + + """ + Check challenge + """ + options_challenge = json_val.get("challenge") + if not isinstance(options_challenge, str): + raise InvalidJSONStructure("Options missing required challenge") + + """ + Check timeout + """ + options_timeout = json_val.get("timeout") + mapped_timeout = None + if isinstance(options_timeout, int): + mapped_timeout = options_timeout + + """ + Check rpId + """ + options_rp_id = json_val.get("rpId") + mapped_rp_id = None + if isinstance(options_rp_id, str): + mapped_rp_id = options_rp_id + + """ + Check userVerification + """ + options_user_verification = json_val.get("userVerification") + if not isinstance(options_user_verification, str): + raise InvalidJSONStructure("Options missing required userVerification") + + try: + mapped_user_verification = UserVerificationRequirement(options_user_verification) + except ValueError as exc: + raise InvalidJSONStructure("Options userVerification was invalid value") from exc + + """ + Check allowCredentials + """ + options_allow_credentials = json_val.get("allowCredentials") + mapped_allow_credentials: Optional[List[PublicKeyCredentialDescriptor]] = None + if isinstance(options_allow_credentials, list): + mapped_allow_credentials = [] + for cred in options_allow_credentials: + _cred_id = cred.get("id") + if not isinstance(_cred_id, str): + raise InvalidJSONStructure("Options excludeCredentials entry missing required id") + + _mapped = PublicKeyCredentialDescriptor(id=base64url_to_bytes(_cred_id)) + + _transports = cred.get("transports") + if _transports is not None: + if not isinstance(_transports, list): + raise InvalidJSONStructure( + "Options excludeCredentials entry transports was not list" + ) + try: + _mapped.transports = [ + AuthenticatorTransport(_transport) for _transport in _transports + ] + except ValueError as exc: + raise InvalidJSONStructure( + "Options excludeCredentials entry transports had invalid value" + ) from exc + + mapped_allow_credentials.append(_mapped) + + try: + authentication_options = PublicKeyCredentialRequestOptions( + challenge=base64url_to_bytes(options_challenge), + timeout=mapped_timeout, + rp_id=mapped_rp_id, + user_verification=mapped_user_verification, + allow_credentials=mapped_allow_credentials, + ) + except Exception as exc: + raise InvalidAuthenticationOptions( + "Could not parse authentication options from JSON data" + ) from exc + + return authentication_options From 65b08075ad2ba8b0dd0f48c89a36b546d2e07f69 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Thu, 28 Mar 2024 11:57:05 -0700 Subject: [PATCH 09/12] Export new methods from helpers --- webauthn/helpers/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/webauthn/helpers/__init__.py b/webauthn/helpers/__init__.py index 751d5d5..443d1b0 100644 --- a/webauthn/helpers/__init__.py +++ b/webauthn/helpers/__init__.py @@ -11,11 +11,13 @@ from .options_to_json import options_to_json from .parse_attestation_object import parse_attestation_object from .parse_authentication_credential_json import parse_authentication_credential_json +from .parse_authentication_options_json import parse_authentication_options_json from .parse_authenticator_data import parse_authenticator_data from .parse_backup_flags import parse_backup_flags from .parse_cbor import parse_cbor from .parse_client_data_json import parse_client_data_json from .parse_registration_credential_json import parse_registration_credential_json +from .parse_registration_options_json import parse_registration_options_json from .validate_certificate_chain import validate_certificate_chain from .verify_safetynet_timestamp import verify_safetynet_timestamp from .verify_signature import verify_signature @@ -35,10 +37,12 @@ "parse_attestation_object", "parse_authenticator_data", "parse_authentication_credential_json", + "parse_authentication_options_json", "parse_backup_flags", "parse_cbor", "parse_client_data_json", "parse_registration_credential_json", + "parse_registration_options_json", "validate_certificate_chain", "verify_safetynet_timestamp", "verify_signature", From b7087305a9dbb96ad7da2226c6d91a3f7d45157a Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Thu, 28 Mar 2024 11:57:18 -0700 Subject: [PATCH 10/12] Tweak .vscode config to auto-import Python --- .vscode/settings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index c20a0fa..1346add 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,5 +13,6 @@ "gitlens.advanced.blame.customArguments": [ "--ignore-revs-file", ".git-blame-ignore-revs" - ] + ], + "python.analysis.autoImportCompletions": true } From e783064f82b0dc59eca981cb469a68e4572a5e27 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Thu, 28 Mar 2024 12:02:23 -0700 Subject: [PATCH 11/12] Why didn't mypy catch this locally? --- webauthn/helpers/parse_authentication_options_json.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/webauthn/helpers/parse_authentication_options_json.py b/webauthn/helpers/parse_authentication_options_json.py index 2fa51a9..2f5db3b 100644 --- a/webauthn/helpers/parse_authentication_options_json.py +++ b/webauthn/helpers/parse_authentication_options_json.py @@ -2,8 +2,7 @@ from json import JSONDecodeError from typing import List, Optional, Union -from webauthn.helpers import base64url_to_bytes - +from .base64url_to_bytes import base64url_to_bytes from .exceptions import InvalidJSONStructure, InvalidAuthenticationOptions from .structs import ( AuthenticatorTransport, From 981e2f8dda8e17d92b4181b9bbff4156e1ad4b56 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Thu, 28 Mar 2024 12:04:49 -0700 Subject: [PATCH 12/12] Fix copy-paste naming mistake --- tests/test_parse_authentication_options.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_parse_authentication_options.py b/tests/test_parse_authentication_options.py index 98a500a..ba84be3 100644 --- a/tests/test_parse_authentication_options.py +++ b/tests/test_parse_authentication_options.py @@ -11,7 +11,7 @@ from webauthn.helpers.parse_authentication_options_json import parse_authentication_options_json -class TestParseRegistrationOptionsJSON(TestCase): +class TestParseAuthenticationOptionsJSON(TestCase): maxDiff = None def test_returns_parsed_options_simple(self) -> None: