diff --git a/tests/test_parse_authenticator_data.py b/tests/test_parse_authenticator_data.py index 232e6de..c6e284c 100644 --- a/tests/test_parse_authenticator_data.py +++ b/tests/test_parse_authenticator_data.py @@ -11,6 +11,8 @@ def _generate_auth_data( sign_count: int = 0, up: bool = True, uv: bool = False, + be: bool = False, + bs: bool = False, at: bool = False, ed: bool = False, ) -> Tuple[bytes, bytes, int, Optional[bytes], Optional[bytes], Optional[bytes]]: @@ -20,6 +22,8 @@ def _generate_auth_data( `sign_count`: How many times the authenticator has been used `up`: Whether user was present `uv`: Whether user was verified + `be`: Whether credential can be backed up + `bs`: Whether credential has been backed up `at`: Whether attested credential data is present `ed`: Whether extension data is present @@ -39,6 +43,10 @@ def _generate_auth_data( flags = flags | 1 << 0 if uv is True: flags = flags | 1 << 2 + if be is True: + flags = flags | 1 << 3 + if bs is True: + flags = flags | 1 << 4 if at is True: flags = flags | 1 << 6 if ed is True: @@ -90,6 +98,8 @@ def test_correctly_parses_simple(self) -> None: assert output.rp_id_hash == rp_id_hash assert output.flags.up is True assert output.flags.uv is True + assert output.flags.be is False + assert output.flags.be is False assert output.flags.at is False assert output.flags.ed is False assert output.sign_count == sign_count @@ -164,3 +174,11 @@ def test_parses_only_extension_data(self) -> None: 'example.extension': 'This is an example extension! If you read this message, you probably successfully passing conformance tests. Good job!', } ) + + def test_parses_backup_state_flags(self) -> None: + (auth_data, _, _, _, _, _) = _generate_auth_data(be=True, bs=True) + + output = parse_authenticator_data(auth_data) + + assert output.flags.be is True + assert output.flags.be is True diff --git a/tests/test_parse_backup_flags.py b/tests/test_parse_backup_flags.py new file mode 100644 index 0000000..7e23f93 --- /dev/null +++ b/tests/test_parse_backup_flags.py @@ -0,0 +1,56 @@ +from unittest import TestCase + +from webauthn.helpers import parse_backup_flags +from webauthn.helpers.structs import AuthenticatorDataFlags +from webauthn.helpers.exceptions import InvalidBackupFlags + + +class TestParseBackupFlags(TestCase): + flags: AuthenticatorDataFlags + + def setUp(self) -> None: + self.flags = AuthenticatorDataFlags( + up=True, + uv=False, + be=False, + bs=False, + at=False, + ed=False, + ) + + def test_returns_single_device_not_backed_up(self) -> None: + self.flags.be = False + self.flags.bs = False + + parsed = parse_backup_flags(self.flags) + + self.assertEqual(parsed.credential_device_type, 'single_device') + self.assertEqual(parsed.credential_backed_up, False) + + def test_returns_multi_device_not_backed_up(self) -> None: + self.flags.be = True + self.flags.bs = False + + parsed = parse_backup_flags(self.flags) + + self.assertEqual(parsed.credential_device_type, 'multi_device') + self.assertEqual(parsed.credential_backed_up, False) + + def test_returns_multi_device_backed_up(self) -> None: + self.flags.be = True + self.flags.bs = True + + parsed = parse_backup_flags(self.flags) + + self.assertEqual(parsed.credential_device_type, 'multi_device') + self.assertEqual(parsed.credential_backed_up, True) + + def test_raises_on_invalid_backup_state_flags(self) -> None: + self.flags.be = False + self.flags.bs = True + + with self.assertRaisesRegex( + InvalidBackupFlags, + "impossible", + ): + parse_backup_flags(self.flags) diff --git a/tests/test_verify_authentication_response.py b/tests/test_verify_authentication_response.py index 7d9cb8c..accaef0 100644 --- a/tests/test_verify_authentication_response.py +++ b/tests/test_verify_authentication_response.py @@ -44,6 +44,8 @@ def test_verify_authentication_response_with_EC2_public_key(self): "EDx9FfAbp4obx6oll2oC4-CZuDidRVV4gZhxC529ytlnqHyqCStDUwfNdm1SNHAe3X5KvueWQdAX3x9R1a2b9Q" ) assert verification.new_sign_count == 78 + assert verification.credential_backed_up == False + assert verification.credential_device_type == 'single_device' def test_verify_authentication_response_with_RSA_public_key(self): credential = AuthenticationCredential.parse_raw( diff --git a/tests/test_verify_registration_response.py b/tests/test_verify_registration_response.py index d9a0115..bb7dbd6 100644 --- a/tests/test_verify_registration_response.py +++ b/tests/test_verify_registration_response.py @@ -58,6 +58,8 @@ def test_verifies_none_attestation_response(self) -> None: ) assert verification.credential_type == PublicKeyCredentialType.PUBLIC_KEY assert verification.sign_count == 23 + assert verification.credential_backed_up == False + assert verification.credential_device_type == 'single_device' def test_raises_exception_on_unsupported_attestation_type(self) -> None: cred_json = { diff --git a/webauthn/authentication/verify_authentication_response.py b/webauthn/authentication/verify_authentication_response.py index eecf878..c16f0ff 100644 --- a/webauthn/authentication/verify_authentication_response.py +++ b/webauthn/authentication/verify_authentication_response.py @@ -8,6 +8,7 @@ decode_credential_public_key, decoded_public_key_to_cryptography, parse_authenticator_data, + parse_backup_flags, parse_client_data_json, verify_signature, ) @@ -15,6 +16,7 @@ from webauthn.helpers.structs import ( AuthenticationCredential, ClientDataType, + CredentialDeviceType, PublicKeyCredentialType, TokenBindingStatus, WebAuthnBaseModel, @@ -28,6 +30,8 @@ class VerifiedAuthentication(WebAuthnBaseModel): credential_id: bytes new_sign_count: int + credential_device_type: CredentialDeviceType + credential_backed_up: bool expected_token_binding_statuses = [ @@ -157,7 +161,11 @@ def verify_authentication_response( except InvalidSignature: raise InvalidAuthenticationResponse("Could not verify authentication signature") + parsed_backup_flags = parse_backup_flags(auth_data.flags) + return VerifiedAuthentication( credential_id=credential.raw_id, new_sign_count=auth_data.sign_count, + credential_device_type=parsed_backup_flags.credential_device_type, + credential_backed_up=parsed_backup_flags.credential_backed_up, ) diff --git a/webauthn/helpers/__init__.py b/webauthn/helpers/__init__.py index dcecb00..589a32d 100644 --- a/webauthn/helpers/__init__.py +++ b/webauthn/helpers/__init__.py @@ -12,6 +12,7 @@ from .options_to_json import options_to_json # noqa: F401 from .parse_attestation_object import parse_attestation_object # noqa: F401 from .parse_authenticator_data import parse_authenticator_data # noqa: F401 +from .parse_backup_flags import parse_backup_flags from .parse_client_data_json import parse_client_data_json # noqa: F401 from .validate_certificate_chain import validate_certificate_chain # noqa: F401 from .verify_safetynet_timestamp import verify_safetynet_timestamp # noqa: F401 diff --git a/webauthn/helpers/exceptions.py b/webauthn/helpers/exceptions.py index 394c627..b26fc9c 100644 --- a/webauthn/helpers/exceptions.py +++ b/webauthn/helpers/exceptions.py @@ -48,3 +48,7 @@ class InvalidTPMCertInfoStructure(Exception): class InvalidCertificateChain(Exception): pass + + +class InvalidBackupFlags(Exception): + pass diff --git a/webauthn/helpers/parse_authenticator_data.py b/webauthn/helpers/parse_authenticator_data.py index 2ab74c8..c9d12ea 100644 --- a/webauthn/helpers/parse_authenticator_data.py +++ b/webauthn/helpers/parse_authenticator_data.py @@ -33,6 +33,8 @@ def parse_authenticator_data(val: bytes) -> AuthenticatorData: flags = AuthenticatorDataFlags( up=flags_bytes & (1 << 0) != 0, uv=flags_bytes & (1 << 2) != 0, + be=flags_bytes & (1 << 3) != 0, + bs=flags_bytes & (1 << 4) != 0, at=flags_bytes & (1 << 6) != 0, ed=flags_bytes & (1 << 7) != 0, ) diff --git a/webauthn/helpers/parse_backup_flags.py b/webauthn/helpers/parse_backup_flags.py new file mode 100644 index 0000000..9ca0bf7 --- /dev/null +++ b/webauthn/helpers/parse_backup_flags.py @@ -0,0 +1,33 @@ +from enum import Enum +from pydantic import BaseModel + +from .structs import AuthenticatorDataFlags, CredentialDeviceType +from .exceptions import InvalidBackupFlags + + +class ParsedBackupFlags(BaseModel): + credential_device_type: CredentialDeviceType + credential_backed_up: bool + + +def parse_backup_flags(flags: AuthenticatorDataFlags) -> ParsedBackupFlags: + """Convert backup eligibility and backup state flags into more useful representations + + Raises: + `helpers.exceptions.InvalidBackupFlags` if an invalid backup state is detected + """ + credential_device_type = CredentialDeviceType.SINGLE_DEVICE + + # A credential that can be backed up can typically be used on multiple devices + if flags.be: + credential_device_type = CredentialDeviceType.MULTI_DEVICE + + if credential_device_type == CredentialDeviceType.SINGLE_DEVICE and flags.bs: + raise InvalidBackupFlags( + "Single-device credential indicated that it was backed up, which should be impossible." + ) + + return ParsedBackupFlags( + credential_device_type=credential_device_type, + credential_backed_up=flags.bs, + ) diff --git a/webauthn/helpers/structs.py b/webauthn/helpers/structs.py index 6d5bc60..23f12ed 100644 --- a/webauthn/helpers/structs.py +++ b/webauthn/helpers/structs.py @@ -56,6 +56,7 @@ def _validate_bytes_fields(cls, v, field: ModelField): # values return v + ################ # # Fundamental data structures @@ -411,13 +412,18 @@ class AuthenticatorDataFlags(WebAuthnBaseModel): Attributes: `up`: [U]ser was [P]resent `uv`: [U]ser was [V]erified + `be`: [B]ackup [E]ligible + `bs`: [B]ackup [S]tate `at`: [AT]tested credential is included `ed`: [E]xtension [D]ata is included + https://www.w3.org/TR/webauthn-2/#flags """ up: bool uv: bool + be: bool + bs: bool at: bool ed: bool @@ -540,3 +546,24 @@ class AuthenticationCredential(WebAuthnBaseModel): type: Literal[ PublicKeyCredentialType.PUBLIC_KEY ] = PublicKeyCredentialType.PUBLIC_KEY + + +################ +# +# Credential Backup State +# +################ + + +class CredentialDeviceType(str, Enum): + """A determination of the number of devices a credential can be used from + + Members: + `SINGLE_DEVICE`: A credential that is bound to a single device + `MULTI_DEVICE`: A credential that can be used from multiple devices (e.g. passkeys) + + https://w3c.github.io/webauthn/#sctn-credential-backup (L3 Draft) + """ + + SINGLE_DEVICE = "single_device" + MULTI_DEVICE = "multi_device" diff --git a/webauthn/registration/verify_registration_response.py b/webauthn/registration/verify_registration_response.py index c766712..46bd5f6 100644 --- a/webauthn/registration/verify_registration_response.py +++ b/webauthn/registration/verify_registration_response.py @@ -7,12 +7,14 @@ decode_credential_public_key, parse_attestation_object, parse_client_data_json, + parse_backup_flags, ) from webauthn.helpers.cose import COSEAlgorithmIdentifier from webauthn.helpers.exceptions import InvalidRegistrationResponse from webauthn.helpers.structs import ( AttestationFormat, ClientDataType, + CredentialDeviceType, PublicKeyCredentialType, RegistrationCredential, TokenBindingStatus, @@ -49,6 +51,8 @@ class VerifiedRegistration(WebAuthnBaseModel): credential_type: PublicKeyCredentialType user_verified: bool attestation_object: bytes + credential_device_type: CredentialDeviceType + credential_backed_up: bool expected_token_binding_statuses = [ @@ -262,6 +266,8 @@ def verify_registration_response( if not verified: raise InvalidRegistrationResponse("Attestation statement could not be verified") + parsed_backup_flags = parse_backup_flags(auth_data.flags) + return VerifiedRegistration( credential_id=attested_credential_data.credential_id, credential_public_key=attested_credential_data.credential_public_key, @@ -271,4 +277,6 @@ def verify_registration_response( credential_type=credential.type, user_verified=auth_data.flags.uv, attestation_object=response.attestation_object, + credential_device_type=parsed_backup_flags.credential_device_type, + credential_backed_up=parsed_backup_flags.credential_backed_up, )