Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prepare for next release #178

Merged
merged 10 commits into from
Sep 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@

# Authentication Response Verification
authentication_verification = verify_authentication_response(
# Demonstrating the ability to handle a stringified JSON version of the WebAuthn response
credential="""{
"id": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s",
"rawId": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s",
Expand Down
5 changes: 3 additions & 2 deletions examples/registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@

# Registration Response Verification
registration_verification = verify_registration_response(
credential="""{
# Demonstrating the ability to handle a plain dict version of the WebAuthn response
credential={
"id": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s",
"rawId": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s",
"response": {
Expand All @@ -67,7 +68,7 @@
"type": "public-key",
"clientExtensionResults": {},
"authenticatorAttachment": "platform"
}""",
},
expected_challenge=base64url_to_bytes(
"CeTWogmg0cchuiYuFrv8DXXdMZSIQRVZJOga_xayVVEcBj0Cw3y73yhD4FkGSe-RrP6hPJJAIm3LVien4hXELg"
),
Expand Down
8 changes: 4 additions & 4 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
annotated-types==0.5.0
asn1crypto==1.4.0
black==21.9b0
cbor2==5.4.2.post1
cbor2==5.4.6
cffi==1.15.0
click==8.0.3
cryptography==41.0.3
cryptography==41.0.4
mccabe==0.6.1
mypy==1.4.1
mypy-extensions==1.0.0
pathspec==0.9.0
platformdirs==2.4.0
pycodestyle==2.8.0
pycparser==2.20
pydantic==2.1.1
pydantic_core==2.4.0
pydantic==2.4.2
pydantic_core==2.10.1
pyflakes==2.4.0
pyOpenSSL==23.2.0
regex==2021.10.8
Expand Down
4 changes: 2 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ def find_version(*file_paths):
],
install_requires=[
'asn1crypto>=1.4.0',
'cbor2>=5.4.2.post1',
'cryptography>=41.0.1',
'cbor2>=5.4.6',
'cryptography>=41.0.4',
'pydantic>=1.10.11',
'pyOpenSSL>=23.2.0',
]
Expand Down
35 changes: 35 additions & 0 deletions tests/test_verify_authentication_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,38 @@ def test_supports_already_parsed_credential(self) -> None:
)

assert verification.new_sign_count == 1

def test_supports_dict_credential(self) -> None:
credential = {
"id": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s",
"rawId": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s",
"response": {
"authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAQ",
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiaVBtQWkxUHAxWEw2b0FncTNQV1p0WlBuWmExekZVRG9HYmFRMF9LdlZHMWxGMnMzUnRfM280dVN6Y2N5MHRtY1RJcFRUVDRCVTFULUk0bWFhdm5kalEiLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9",
"signature": "iOHKX3erU5_OYP_r_9HLZ-CexCE4bQRrxM8WmuoKTDdhAnZSeTP0sjECjvjfeS8MJzN1ArmvV0H0C3yy_FdRFfcpUPZzdZ7bBcmPh1XPdxRwY747OrIzcTLTFQUPdn1U-izCZtP_78VGw9pCpdMsv4CUzZdJbEcRtQuRS03qUjqDaovoJhOqEBmxJn9Wu8tBi_Qx7A33RbYjlfyLm_EDqimzDZhyietyop6XUcpKarKqVH0M6mMrM5zTjp8xf3W7odFCadXEJg-ERZqFM0-9Uup6kJNLbr6C5J4NDYmSm3HCSA6lp2iEiMPKU8Ii7QZ61kybXLxsX4w4Dm3fOLjmDw",
"userHandle": "T1RWa1l6VXdPRFV0WW1NNVlTMDBOVEkxTFRnd056Z3RabVZpWVdZNFpEVm1ZMk5p"
},
"type": "public-key",
"clientExtensionResults": {}
}
challenge = base64url_to_bytes(
"iPmAi1Pp1XL6oAgq3PWZtZPnZa1zFUDoGbaQ0_KvVG1lF2s3Rt_3o4uSzccy0tmcTIpTTT4BU1T-I4maavndjQ"
)
expected_rp_id = "localhost"
expected_origin = "http://localhost:5000"
credential_public_key = base64url_to_bytes(
"pAEDAzkBACBZAQDfV20epzvQP-HtcdDpX-cGzdOxy73WQEvsU7Dnr9UWJophEfpngouvgnRLXaEUn_d8HGkp_HIx8rrpkx4BVs6X_B6ZjhLlezjIdJbLbVeb92BaEsmNn1HW2N9Xj2QM8cH-yx28_vCjf82ahQ9gyAr552Bn96G22n8jqFRQKdVpO-f-bvpvaP3IQ9F5LCX7CUaxptgbog1SFO6FI6ob5SlVVB00lVXsaYg8cIDZxCkkENkGiFPgwEaZ7995SCbiyCpUJbMqToLMgojPkAhWeyktu7TlK6UBWdJMHc3FPAIs0lH_2_2hKS-mGI1uZAFVAfW1X-mzKL0czUm2P1UlUox7IUMBAAE"
)
sign_count = 0

verification = verify_authentication_response(
credential=credential,
expected_challenge=challenge,
expected_rp_id=expected_rp_id,
expected_origin=expected_origin,
credential_public_key=credential_public_key,
credential_current_sign_count=sign_count,
require_user_verification=True,
)

assert verification.new_sign_count == 1
30 changes: 30 additions & 0 deletions tests/test_verify_registration_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,3 +221,33 @@ def test_supports_already_parsed_credential(self) -> None:
)

assert verification.fmt == AttestationFormat.NONE

def test_supports_dict_credential(self) -> None:
credential = {
"id": "9y1xA8Tmg1FEmT-c7_fvWZ_uoTuoih3OvR45_oAK-cwHWhAbXrl2q62iLVTjiyEZ7O7n-CROOY494k7Q3xrs_w",
"rawId": "9y1xA8Tmg1FEmT-c7_fvWZ_uoTuoih3OvR45_oAK-cwHWhAbXrl2q62iLVTjiyEZ7O7n-CROOY494k7Q3xrs_w",
"response": {
"attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjESZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NFAAAAFwAAAAAAAAAAAAAAAAAAAAAAQPctcQPE5oNRRJk_nO_371mf7qE7qIodzr0eOf6ACvnMB1oQG165dqutoi1U44shGezu5_gkTjmOPeJO0N8a7P-lAQIDJiABIVggSFbUJF-42Ug3pdM8rDRFu_N5oiVEysPDB6n66r_7dZAiWCDUVnB39FlGypL-qAoIO9xWHtJygo2jfDmHl-_eKFRLDA",
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiVHdON240V1R5R0tMYzRaWS1xR3NGcUtuSE00bmdscXN5VjBJQ0psTjJUTzlYaVJ5RnRya2FEd1V2c3FsLWdrTEpYUDZmbkYxTWxyWjUzTW00UjdDdnciLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9"
},
"type": "public-key",
"clientExtensionResults": {},
"transports": [
"cable"
]
}

challenge = base64url_to_bytes(
"TwN7n4WTyGKLc4ZY-qGsFqKnHM4nglqsyV0ICJlN2TO9XiRyFtrkaDwUvsql-gkLJXP6fnF1MlrZ53Mm4R7Cvw"
)
rp_id = "localhost"
expected_origin = "http://localhost:5000"

verification = verify_registration_response(
credential=credential,
expected_challenge=challenge,
expected_origin=expected_origin,
expected_rp_id=rp_id,
)

assert verification.fmt == AttestationFormat.NONE
25 changes: 16 additions & 9 deletions webauthn/authentication/verify_authentication_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class VerifiedAuthentication(WebAuthnBaseModel):

def verify_authentication_response(
*,
credential: Union[str, AuthenticationCredential],
credential: Union[str, dict, AuthenticationCredential],
expected_challenge: bytes,
expected_rp_id: str,
expected_origin: Union[str, List[str]],
Expand All @@ -54,21 +54,28 @@ def verify_authentication_response(
"""Verify a response from navigator.credentials.get()

Args:
`credential`: The value returned from `navigator.credentials.get()`.
`expected_challenge`: The challenge passed to the authenticator within the preceding authentication options.
`expected_rp_id`: The Relying Party's unique identifier as specified in the precending authentication options.
`expected_origin`: The domain, with HTTP protocol (e.g. "https://domain.here"), on which the authentication ceremony should have occurred.
`credential_public_key`: The public key for the credential's ID as provided in a preceding authenticator registration ceremony.
`credential_current_sign_count`: The current known number of times the authenticator was used.
(optional) `require_user_verification`: Whether or not to require that the authenticator verified the user.
- `credential`: The value returned from `navigator.credentials.get()`. Can be either a
stringified JSON object, a plain dict, or an instance of RegistrationCredential
- `expected_challenge`: The challenge passed to the authenticator within the preceding
authentication options.
- `expected_rp_id`: The Relying Party's unique identifier as specified in the preceding
authentication options.
- `expected_origin`: The domain, with HTTP protocol (e.g. "https://domain.here"), on which
the authentication ceremony should have occurred.
- `credential_public_key`: The public key for the credential's ID as provided in a
preceding authenticator registration ceremony.
- `credential_current_sign_count`: The current known number of times the authenticator was
used.
- (optional) `require_user_verification`: Whether or not to require that the authenticator
verified the user.

Returns:
Information about the authenticator

Raises:
`helpers.exceptions.InvalidAuthenticationResponse` if the response cannot be verified
"""
if isinstance(credential, str):
if isinstance(credential, str) or isinstance(credential, dict):
credential = parse_authentication_credential_json(credential)

# FIDO-specific check
Expand Down
12 changes: 10 additions & 2 deletions webauthn/helpers/parse_authentication_credential_json.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
from typing import Callable
import json
from typing import Callable, Union
from pydantic import ValidationError

from .exceptions import InvalidAuthenticationResponse
from .structs import PYDANTIC_V2, AuthenticationCredential


def parse_authentication_credential_json(json_val: str) -> AuthenticationCredential:
def parse_authentication_credential_json(json_val: Union[str, dict]) -> AuthenticationCredential:
"""
Parse a JSON form of an authentication credential, as either a stringified JSON object or a
plain dict, into an instance of AuthenticationCredential
"""
if PYDANTIC_V2:
parsing_method: Callable = AuthenticationCredential.model_validate_json # type: ignore[attr-defined]
else: # assuming V1
parsing_method = AuthenticationCredential.parse_raw

if isinstance(json_val, dict):
json_val = json.dumps(json_val)

try:
authentication_credential = parsing_method(json_val)
except ValidationError as exc:
Expand Down
12 changes: 10 additions & 2 deletions webauthn/helpers/parse_registration_credential_json.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
from typing import Callable
import json
from typing import Callable, Union
from pydantic import ValidationError

from .exceptions import InvalidRegistrationResponse
from .structs import PYDANTIC_V2, RegistrationCredential


def parse_registration_credential_json(json_val: str) -> RegistrationCredential:
def parse_registration_credential_json(json_val: Union[str, dict]) -> RegistrationCredential:
"""
Parse a JSON form of a registration credential, as either a stringified JSON object or a
plain dict, into an instance of RegistrationCredential
"""
if PYDANTIC_V2:
parsing_method: Callable = RegistrationCredential.model_validate_json # type: ignore[attr-defined]
else: # assuming V1
parsing_method = RegistrationCredential.parse_raw

if isinstance(json_val, dict):
json_val = json.dumps(json_val)

try:
registration_credential = parsing_method(json_val)
except ValidationError as exc:
Expand Down
17 changes: 16 additions & 1 deletion webauthn/helpers/structs.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,23 @@ class WebAuthnBaseModel(BaseModel):

@field_validator("*", mode="before")
def _pydantic_v2_validate_bytes_fields(
cls, v: Any, info: FieldValidationInfo
cls, v: Any, info: FieldValidationInfo # type: ignore[valid-type]
) -> Any:
"""
`FieldValidationInfo` above is being deprecated for `ValidationInfo`, see the following:

- https://github.com/pydantic/pydantic-core/issues/994
- https://github.com/pydantic/pydantic/issues/7667

There are now docs for the new way to access `field_name` that's only available in
Pydantic v2.4+...

https://docs.pydantic.dev/latest/concepts/types/#access-to-field-name

This use of `FieldValidationInfo` will continue to work for now, but when it gets
removed from Pydantic the `info.field_name` below will need to get updated to
`info.data.field_name` after changing the type of `info` above to `ValidationInfo`
"""
field = cls.model_fields[info.field_name] # type: ignore[attr-defined]

if field.annotation != bytes:
Expand Down
24 changes: 16 additions & 8 deletions webauthn/registration/verify_registration_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ class VerifiedRegistration(WebAuthnBaseModel):

def verify_registration_response(
*,
credential: Union[str, RegistrationCredential],
credential: Union[str, dict, RegistrationCredential],
expected_challenge: bytes,
expected_rp_id: str,
expected_origin: Union[str, List[str]],
Expand All @@ -80,20 +80,28 @@ def verify_registration_response(
"""Verify an authenticator's response to navigator.credentials.create()

Args:
`credential`: The value returned from `navigator.credentials.create()`.
`expected_challenge`: The challenge passed to the authenticator within the preceding registration options.
`expected_rp_id`: The Relying Party's unique identifier as specified in the precending registration options.
`expected_origin`: The domain, with HTTP protocol (e.g. "https://domain.here"), on which the registration should have occurred. Can also be a list of expected origins.
(optional) `require_user_verification`: Whether or not to require that the authenticator verified the user.
(optional) `supported_pub_key_algs`: A list of public key algorithm IDs the RP chooses to restrict support to. Defaults to all supported algorithm IDs.
- `credential`: The value returned from `navigator.credentials.create()`. Can be either a
stringified JSON object, a plain dict, or an instance of RegistrationCredential
- `expected_challenge`: The challenge passed to the authenticator within the preceding
registration options.
- `expected_rp_id`: The Relying Party's unique identifier as specified in the precending
registration options.
- `expected_origin`: The domain, with HTTP protocol (e.g. "https://domain.here"), on which
the registration should have occurred. Can also be a list of expected origins.
- (optional) `require_user_verification`: Whether or not to require that the authenticator
verified the user.
- (optional) `supported_pub_key_algs`: A list of public key algorithm IDs the RP chooses to
restrict support to. Defaults to all supported algorithm IDs.
- (optional) `pem_root_certs_bytes_by_fmt`: A list of root certificates, in PEM format, to
be used to validate the certificate chains for specific attestation statement formats.

Returns:
Information about the authenticator and registration

Raises:
`helpers.exceptions.InvalidRegistrationResponse` if the response cannot be verified
"""
if isinstance(credential, str):
if isinstance(credential, str) or isinstance(credential, dict):
credential = parse_registration_credential_json(credential)

verified = False
Expand Down