Skip to content

Commit

Permalink
Merge pull request #234 from duo-labs/feat/reg-hints
Browse files Browse the repository at this point in the history
Add hints support to registration
  • Loading branch information
MasterKale authored Dec 5, 2024
2 parents 07aca0d + 39e4589 commit b04dcec
Show file tree
Hide file tree
Showing 7 changed files with 125 additions and 4 deletions.
2 changes: 2 additions & 0 deletions examples/registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
AuthenticatorAttachment,
AuthenticatorSelectionCriteria,
PublicKeyCredentialDescriptor,
PublicKeyCredentialHint,
ResidentKeyRequirement,
)

Expand Down Expand Up @@ -47,6 +48,7 @@
],
supported_pub_key_algs=[COSEAlgorithmIdentifier.ECDSA_SHA_512],
timeout=12000,
hints=[PublicKeyCredentialHint.CLIENT_DEVICE],
)

print("\n[Registration Options - Complex]")
Expand Down
7 changes: 7 additions & 0 deletions tests/test_options_to_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
AuthenticatorSelectionCriteria,
AuthenticatorTransport,
PublicKeyCredentialDescriptor,
PublicKeyCredentialHint,
ResidentKeyRequirement,
UserVerificationRequirement,
)
Expand Down Expand Up @@ -36,6 +37,11 @@ def test_converts_registration_options_to_JSON(self) -> None:
],
supported_pub_key_algs=[COSEAlgorithmIdentifier.ECDSA_SHA_512],
timeout=120000,
hints=[
PublicKeyCredentialHint.SECURITY_KEY,
PublicKeyCredentialHint.CLIENT_DEVICE,
PublicKeyCredentialHint.HYBRID,
],
)

output = options_to_json(options)
Expand All @@ -60,6 +66,7 @@ def test_converts_registration_options_to_JSON(self) -> None:
"userVerification": "preferred",
},
"attestation": "direct",
"hints": ["security-key", "client-device", "hybrid"],
},
)

Expand Down
69 changes: 69 additions & 0 deletions tests/test_parse_registration_options_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
AuthenticatorSelectionCriteria,
PublicKeyCredentialDescriptor,
ResidentKeyRequirement,
PublicKeyCredentialHint,
PublicKeyCredentialRpEntity,
PublicKeyCredentialUserEntity,
UserVerificationRequirement,
Expand Down Expand Up @@ -104,6 +105,7 @@ def test_returns_parsed_options_full(self) -> None:
"userVerification": "discouraged",
},
"attestation": "direct",
"hints": ["security-key", "client-device", "hybrid"],
}
)

Expand Down Expand Up @@ -180,6 +182,14 @@ def test_returns_parsed_options_full(self) -> None:
],
)
self.assertEqual(parsed.timeout, 12000)
self.assertEqual(
parsed.hints,
[
PublicKeyCredentialHint.SECURITY_KEY,
PublicKeyCredentialHint.CLIENT_DEVICE,
PublicKeyCredentialHint.HYBRID,
],
)

def test_supports_json_string(self) -> None:
parsed = parse_registration_options_json(
Expand Down Expand Up @@ -250,6 +260,11 @@ def test_supports_options_to_json_output(self) -> None:
],
supported_pub_key_algs=[COSEAlgorithmIdentifier.ECDSA_SHA_512],
timeout=12000,
hints=[
PublicKeyCredentialHint.CLIENT_DEVICE,
PublicKeyCredentialHint.SECURITY_KEY,
PublicKeyCredentialHint.HYBRID,
],
)

opts_json = options_to_json(opts)
Expand All @@ -264,6 +279,7 @@ def test_supports_options_to_json_output(self) -> None:
self.assertEqual(parsed_opts_json.exclude_credentials, opts.exclude_credentials)
self.assertEqual(parsed_opts_json.pub_key_cred_params, opts.pub_key_cred_params)
self.assertEqual(parsed_opts_json.timeout, opts.timeout)
self.assertEqual(parsed_opts_json.hints, opts.hints)

def test_raises_on_non_dict_json(self) -> None:
with self.assertRaisesRegex(InvalidJSONStructure, "not a JSON object"):
Expand Down Expand Up @@ -499,3 +515,56 @@ def test_supports_missing_timeout(self) -> None:
)

self.assertIsNone(opts.timeout)

def test_supports_empty_hints(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}],
"hints": [],
}
)

self.assertEqual(opts.hints, [])

def test_raises_on_invalid_hints_assignment(self) -> None:
with self.assertRaisesRegex(InvalidJSONStructure, "hints 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",
"pubKeyCredParams": [{"alg": -7}],
"hints": "security-key",
}
)

def test_raises_on_invalid_hints_entry(self) -> None:
with self.assertRaisesRegex(InvalidJSONStructure, "hints 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}],
"hints": ["platform"],
}
)

def test_supports_optional_hints(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.hints)
9 changes: 6 additions & 3 deletions webauthn/helpers/options_to_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,9 @@ def options_to_json(
json_selection: Dict[str, Any] = {}

if _selection.authenticator_attachment is not None:
json_selection[
"authenticatorAttachment"
] = _selection.authenticator_attachment.value
json_selection["authenticatorAttachment"] = (
_selection.authenticator_attachment.value
)

if _selection.resident_key is not None:
json_selection["residentKey"] = _selection.resident_key.value
Expand All @@ -84,6 +84,9 @@ def options_to_json(
if options.attestation is not None:
reg_to_return["attestation"] = options.attestation.value

if options.hints is not None:
reg_to_return["hints"] = [hint.value for hint in options.hints]

return json.dumps(reg_to_return)

if isinstance(options, PublicKeyCredentialRequestOptions):
Expand Down
16 changes: 16 additions & 0 deletions webauthn/helpers/parse_registration_options_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from .structs import (
PublicKeyCredentialCreationOptions,
PublicKeyCredentialHint,
PublicKeyCredentialRpEntity,
PublicKeyCredentialUserEntity,
AttestationConveyancePreference,
Expand Down Expand Up @@ -201,6 +202,20 @@ def parse_registration_options_json(
if isinstance(options_timeout, int):
mapped_timeout = options_timeout

"""
Check hints
"""
options_hints = json_val.get("hints")
mapped_hints = None
if options_hints is not None:
if not isinstance(options_hints, list):
raise InvalidJSONStructure("Options hints was invalid value")

try:
mapped_hints = [PublicKeyCredentialHint(hint) for hint in options_hints]
except ValueError as exc:
raise InvalidJSONStructure("Options hints had invalid value") from exc

try:
registration_options = PublicKeyCredentialCreationOptions(
rp=PublicKeyCredentialRpEntity(
Expand All @@ -218,6 +233,7 @@ def parse_registration_options_json(
pub_key_cred_params=mapped_pub_key_cred_params,
exclude_credentials=mapped_exclude_credentials,
timeout=mapped_timeout,
hints=mapped_hints,
)
except Exception as exc:
raise InvalidRegistrationOptions(
Expand Down
23 changes: 22 additions & 1 deletion webauthn/helpers/structs.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from enum import Enum
from dataclasses import dataclass, field
from typing import List, Literal, Optional, Union
from typing import List, Literal, Optional

from .cose import COSEAlgorithmIdentifier

Expand Down Expand Up @@ -158,6 +158,25 @@ class TokenBindingStatus(str, Enum):
SUPPORTED = "supported"


class PublicKeyCredentialHint(str, Enum):
"""Categories of authenticators that Relying Parties can pass along to browsers during
registration. Browsers that understand these values can optimize their modal experience to
start the user off in a particular registration flow. These values are less strict than
`authenticatorAttachment` (see `webauthn.helpers.strucAuthenticatorAttachment`)
Members:
`SECURITY_KEY`: A portable FIDO2 authenticator capable of being used on multiple devices via a USB or NFC connection
`CLIENT_DEVICE`: The device that WebAuthn is being called on. Typically synonymous with platform authenticators
`HYBRID`: A platform authenticator on a mobile device
https://w3c.github.io/webauthn/#enumdef-publickeycredentialhint
"""

SECURITY_KEY = "security-key"
CLIENT_DEVICE = "client-device"
HYBRID = "hybrid"


@dataclass
class TokenBinding:
"""
Expand Down Expand Up @@ -293,6 +312,7 @@ class PublicKeyCredentialCreationOptions:
(optional) `timeout`: How long the client/browser should give the user to interact with an authenticator
(optional) `exclude_credentials`: A list of credentials associated with the user to prevent them from re-enrolling one of them
(optional) `authenticator_selection`: Additional qualities about the authenticators the user can use to complete registration
(optional) `hints`: Suggestions to the browser about the type of authenticator the user should try and register. Multiple values should be ordered by decreasing preference
(optional) `attestation`: The Relying Party's desire for a declaration of an authenticator's provenance via attestation statement
https://www.w3.org/TR/webauthn-2/#dictdef-publickeycredentialcreationoptions
Expand All @@ -305,6 +325,7 @@ class PublicKeyCredentialCreationOptions:
timeout: Optional[int] = None
exclude_credentials: Optional[List[PublicKeyCredentialDescriptor]] = None
authenticator_selection: Optional[AuthenticatorSelectionCriteria] = None
hints: Optional[List[PublicKeyCredentialHint]] = None
attestation: AttestationConveyancePreference = AttestationConveyancePreference.NONE


Expand Down
3 changes: 3 additions & 0 deletions webauthn/registration/generate_registration_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
PublicKeyCredentialRpEntity,
PublicKeyCredentialUserEntity,
ResidentKeyRequirement,
PublicKeyCredentialHint,
)


Expand Down Expand Up @@ -52,6 +53,7 @@ def generate_registration_options(
authenticator_selection: Optional[AuthenticatorSelectionCriteria] = None,
exclude_credentials: Optional[List[PublicKeyCredentialDescriptor]] = None,
supported_pub_key_algs: Optional[List[COSEAlgorithmIdentifier]] = None,
hints: Optional[List[PublicKeyCredentialHint]] = None,
) -> PublicKeyCredentialCreationOptions:
"""Generate options for registering a credential via navigator.credentials.create()
Expand Down Expand Up @@ -123,6 +125,7 @@ def generate_registration_options(
timeout=timeout,
exclude_credentials=exclude_credentials,
attestation=attestation,
hints=hints,
)

########
Expand Down

0 comments on commit b04dcec

Please sign in to comment.