Skip to content

Commit

Permalink
Merge pull request #178 from duo-labs/prepare-for-next-release
Browse files Browse the repository at this point in the history
Prepare for next release
  • Loading branch information
MasterKale authored Sep 29, 2023
2 parents 6ac0f9d + e8f9ed4 commit ead0833
Show file tree
Hide file tree
Showing 11 changed files with 143 additions and 30 deletions.
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

0 comments on commit ead0833

Please sign in to comment.