mirror of https://github.com/Yubico/python-fido2
Refactor extensions.
- Fido2Client optionally takes extension_types (default: all) - Client make_credential and get_assertion now return objects containing extension outputs.
This commit is contained in:
parent
0912c31478
commit
d44a7d6d3e
9
NEWS
9
NEWS
|
@ -1,4 +1,13 @@
|
|||
* Version 0.9.0 (unreleased)
|
||||
** Client: API changes to better support extensions.
|
||||
*** Fido2Client can be configured with Ctap2Extensions to support.
|
||||
*** Client.make_credential now returns a AuthenticatorAttestationResponse,
|
||||
which holds the AttestationObject and ClientData, as well as any client
|
||||
extension results for the credential.
|
||||
*** Client.get_assertion now returns an AssertionSelection object, which is
|
||||
used to select between multiple assertions, resulting in an
|
||||
AuthenticatorAssertionResponse, which holds the ClientData, assertion
|
||||
values, as well as any client extension results for the assertion.
|
||||
** Renames: The CTAP1 and CTAP2 classes have been renamed to Ctap1 and Ctap2,
|
||||
respectively. The old names currently work, but will be removed in the
|
||||
future.
|
||||
|
|
|
@ -0,0 +1,124 @@
|
|||
# Copyright (c) 2020 Yubico AB
|
||||
# All rights reserved.
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or
|
||||
# without modification, are permitted provided that the following
|
||||
# conditions are met:
|
||||
#
|
||||
# 1. Redistributions of source code must retain the above copyright
|
||||
# notice, this list of conditions and the following disclaimer.
|
||||
# 2. Redistributions in binary form must reproduce the above
|
||||
# copyright notice, this list of conditions and the following
|
||||
# disclaimer in the documentation and/or other materials provided
|
||||
# with the distribution.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
|
||||
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||
# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
|
||||
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
||||
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
|
||||
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
"""
|
||||
Connects to the first FIDO device found which supports the CredBlob extension,
|
||||
creates a new credential for it with the extension enabled, and stores some data.
|
||||
"""
|
||||
from __future__ import print_function, absolute_import, unicode_literals
|
||||
|
||||
from fido2.hid import CtapHidDevice
|
||||
from fido2.client import Fido2Client
|
||||
from fido2.server import Fido2Server
|
||||
from getpass import getpass
|
||||
import sys
|
||||
import os
|
||||
|
||||
try:
|
||||
from fido2.pcsc import CtapPcscDevice
|
||||
except ImportError:
|
||||
CtapPcscDevice = None
|
||||
|
||||
|
||||
def enumerate_devices():
|
||||
for dev in CtapHidDevice.list_devices():
|
||||
yield dev
|
||||
if CtapPcscDevice:
|
||||
for dev in CtapPcscDevice.list_devices():
|
||||
yield dev
|
||||
|
||||
|
||||
# Locate a device
|
||||
for dev in enumerate_devices():
|
||||
client = Fido2Client(dev, "https://example.com")
|
||||
if "credBlob" in client.info.extensions:
|
||||
break
|
||||
else:
|
||||
print("No Authenticator with the CredBlob extension found!")
|
||||
sys.exit(1)
|
||||
|
||||
use_nfc = CtapPcscDevice and isinstance(dev, CtapPcscDevice)
|
||||
|
||||
# Prepare parameters for makeCredential
|
||||
server = Fido2Server({"id": "example.com", "name": "Example RP"})
|
||||
user = {"id": b"user_id", "name": "A. User"}
|
||||
create_options, state = server.register_begin(user, resident_key=True)
|
||||
|
||||
# Add CredBlob extension, attach data
|
||||
blob = os.urandom(32) # 32 random bytes
|
||||
create_options["publicKey"]["extensions"] = {"credBlob": blob}
|
||||
|
||||
# Prompt for PIN if needed
|
||||
pin = None
|
||||
if client.info.options.get("clientPin"):
|
||||
pin = getpass("Please enter PIN:")
|
||||
else:
|
||||
print("no pin")
|
||||
|
||||
# Create a credential
|
||||
if not use_nfc:
|
||||
print("\nTouch your authenticator device now...\n")
|
||||
|
||||
result = client.make_credential(create_options["publicKey"], pin=pin)
|
||||
|
||||
# Complete registration
|
||||
auth_data = server.register_complete(
|
||||
state, result.client_data, result.attestation_object
|
||||
)
|
||||
credentials = [auth_data.credential_data]
|
||||
|
||||
|
||||
# CredBlob result:
|
||||
if not auth_data.extensions.get("credBlob"):
|
||||
print("Credential was registered, but credBlob was NOT saved.")
|
||||
sys.exit(1)
|
||||
|
||||
print("New credential created, with the CredBlob extension.")
|
||||
|
||||
# Prepare parameters for getAssertion
|
||||
request_options, state = server.authenticate_begin()
|
||||
request_options["publicKey"]["extensions"] = {
|
||||
"getCredBlob": True,
|
||||
}
|
||||
|
||||
# Authenticate the credential
|
||||
if not use_nfc:
|
||||
print("\nTouch your authenticator device now...\n")
|
||||
|
||||
# Only one cred in allowCredentials, only one response.
|
||||
result = client.get_assertion(request_options["publicKey"], pin=pin).get_response(0)
|
||||
|
||||
blob_res = result.authenticator_data.extensions.get("credBlob")
|
||||
|
||||
if blob == blob_res:
|
||||
print("Authenticated, got correct blob:", blob.hex())
|
||||
else:
|
||||
print(
|
||||
"Authenticated, got incorrect blob! (was %s, expected %s)"
|
||||
% (blob_res.hex(), blob.hex())
|
||||
)
|
||||
sys.exit(1)
|
|
@ -93,18 +93,18 @@ create_options, state = server.register_begin(
|
|||
if use_prompt:
|
||||
print("\nTouch your authenticator device now...\n")
|
||||
|
||||
attestation_object, client_data = client.make_credential(
|
||||
create_options["publicKey"], pin=pin
|
||||
)
|
||||
result = client.make_credential(create_options["publicKey"], pin=pin)
|
||||
|
||||
# Complete registration
|
||||
auth_data = server.register_complete(state, client_data, attestation_object)
|
||||
auth_data = server.register_complete(
|
||||
state, result.client_data, result.attestation_object
|
||||
)
|
||||
credentials = [auth_data.credential_data]
|
||||
|
||||
print("New credential created!")
|
||||
|
||||
print("CLIENT DATA:", client_data)
|
||||
print("ATTESTATION OBJECT:", attestation_object)
|
||||
print("CLIENT DATA:", result.client_data)
|
||||
print("ATTESTATION OBJECT:", result.attestation_object)
|
||||
print()
|
||||
print("CREDENTIAL DATA:", auth_data.credential_data)
|
||||
|
||||
|
@ -116,21 +116,21 @@ request_options, state = server.authenticate_begin(credentials, user_verificatio
|
|||
if use_prompt:
|
||||
print("\nTouch your authenticator device now...\n")
|
||||
|
||||
assertions, client_data = client.get_assertion(request_options["publicKey"], pin=pin)
|
||||
assertion = assertions[0] # Only one cred in allowCredentials, only one response.
|
||||
# Only one cred in allowCredentials, only one response.
|
||||
result = client.get_assertion(request_options["publicKey"], pin=pin).get_response(0)
|
||||
|
||||
# Complete authenticator
|
||||
server.authenticate_complete(
|
||||
state,
|
||||
credentials,
|
||||
assertion.credential["id"],
|
||||
client_data,
|
||||
assertion.auth_data,
|
||||
assertion.signature,
|
||||
result.credential_id,
|
||||
result.client_data,
|
||||
result.authenticator_data,
|
||||
result.signature,
|
||||
)
|
||||
|
||||
print("Credential authenticated!")
|
||||
|
||||
print("CLIENT DATA:", client_data)
|
||||
print("CLIENT DATA:", result.client_data)
|
||||
print()
|
||||
print("ASSERTION DATA:", assertion)
|
||||
print("AUTH DATA:", result.authenticator_data)
|
||||
|
|
|
@ -34,7 +34,6 @@ from __future__ import print_function, absolute_import, unicode_literals
|
|||
|
||||
from fido2.hid import CtapHidDevice
|
||||
from fido2.client import Fido2Client
|
||||
from fido2.extensions import HmacSecretExtension
|
||||
from getpass import getpass
|
||||
from binascii import b2a_hex
|
||||
import sys
|
||||
|
@ -57,7 +56,7 @@ def enumerate_devices():
|
|||
# Locate a device
|
||||
for dev in enumerate_devices():
|
||||
client = Fido2Client(dev, "https://example.com")
|
||||
if HmacSecretExtension.NAME in client.info.extensions:
|
||||
if "hmac-secret" in client.info.extensions:
|
||||
break
|
||||
else:
|
||||
print("No Authenticator with the HmacSecret extension found!")
|
||||
|
@ -77,26 +76,26 @@ if client.info.options.get("clientPin"):
|
|||
else:
|
||||
print("no pin")
|
||||
|
||||
hmac_ext = HmacSecretExtension(client.client_pin)
|
||||
|
||||
# Create a credential
|
||||
# Create a credential with a HmacSecret
|
||||
if not use_nfc:
|
||||
print("\nTouch your authenticator device now...\n")
|
||||
attestation_object, client_data = client.make_credential(
|
||||
result = client.make_credential(
|
||||
{
|
||||
"rp": rp,
|
||||
"user": user,
|
||||
"challenge": challenge,
|
||||
"pubKeyCredParams": [{"type": "public-key", "alg": -7}],
|
||||
"extensions": hmac_ext.create_dict(),
|
||||
"extensions": {"hmacCreateSecret": True},
|
||||
},
|
||||
pin=pin,
|
||||
)
|
||||
|
||||
# HmacSecret result:
|
||||
hmac_result = hmac_ext.results_for(attestation_object.auth_data)
|
||||
if not result.extension_results.get("hmacCreateSecret"):
|
||||
print("Failed to create credential with HmacSecret")
|
||||
sys.exit(1)
|
||||
|
||||
credential = attestation_object.auth_data.credential_data
|
||||
credential = result.attestation_object.auth_data.credential_data
|
||||
print("New credential created, with the HmacSecret extension.")
|
||||
|
||||
# Prepare parameters for getAssertion
|
||||
|
@ -111,19 +110,20 @@ print("Authenticate with salt:", b2a_hex(salt))
|
|||
if not use_nfc:
|
||||
print("\nTouch your authenticator device now...\n")
|
||||
|
||||
assertions, client_data = client.get_assertion(
|
||||
result = client.get_assertion(
|
||||
{
|
||||
"rpId": rp["id"],
|
||||
"challenge": challenge,
|
||||
"allowCredentials": allow_list,
|
||||
"extensions": hmac_ext.get_dict(salt),
|
||||
"extensions": {"hmacGetSecret": {"salt1": salt}},
|
||||
},
|
||||
pin=pin,
|
||||
)
|
||||
).get_response(
|
||||
0
|
||||
) # Only one cred in allowList, only one response.
|
||||
|
||||
assertion = assertions[0] # Only one cred in allowList, only one response.
|
||||
hmac_res = hmac_ext.results_for(assertion.auth_data)
|
||||
print("Authenticated, secret:", b2a_hex(hmac_res[0]))
|
||||
output1 = result.extension_results["hmacGetSecret"]["output1"]
|
||||
print("Authenticated, secret:", b2a_hex(output1))
|
||||
|
||||
# Authenticate again, using two salts to generate two secrets:
|
||||
|
||||
|
@ -135,17 +135,18 @@ if not use_nfc:
|
|||
print("\nTouch your authenticator device now...\n")
|
||||
|
||||
# The first salt is reused, which should result in the same secret.
|
||||
assertions, client_data = client.get_assertion(
|
||||
result = client.get_assertion(
|
||||
{
|
||||
"rpId": rp["id"],
|
||||
"challenge": challenge,
|
||||
"allowCredentials": allow_list,
|
||||
"extensions": hmac_ext.get_dict(salt, salt2),
|
||||
"extensions": {"hmacGetSecret": {"salt1": salt, "salt2": salt2}},
|
||||
},
|
||||
pin=pin,
|
||||
)
|
||||
).get_response(
|
||||
0
|
||||
) # One cred in allowCredentials, single response.
|
||||
|
||||
assertion = assertions[0] # Only one cred in allowList, only one response.
|
||||
hmac_res = hmac_ext.results_for(assertion.auth_data)
|
||||
print("Old secret:", b2a_hex(hmac_res[0]))
|
||||
print("New secret:", b2a_hex(hmac_res[1]))
|
||||
output = result.extension_results["hmacGetSecret"]
|
||||
print("Old secret:", b2a_hex(output["output1"]))
|
||||
print("New secret:", b2a_hex(output["output2"]))
|
||||
|
|
|
@ -41,37 +41,38 @@ from getpass import getpass
|
|||
import sys
|
||||
|
||||
|
||||
try:
|
||||
from fido2.pcsc import CtapPcscDevice
|
||||
except ImportError:
|
||||
CtapPcscDevice = None
|
||||
|
||||
|
||||
def enumerate_devices():
|
||||
for dev in CtapHidDevice.list_devices():
|
||||
yield dev
|
||||
if CtapPcscDevice:
|
||||
for dev in CtapPcscDevice.list_devices():
|
||||
yield dev
|
||||
|
||||
|
||||
# Locate a device
|
||||
for dev in enumerate_devices():
|
||||
client = Fido2Client(dev, "https://example.com")
|
||||
if "largeBlobKey" in client.info.extensions:
|
||||
break
|
||||
else:
|
||||
print("No Authenticator with the largeBlobKey extension found!")
|
||||
sys.exit(1)
|
||||
|
||||
use_nfc = CtapPcscDevice and isinstance(dev, CtapPcscDevice)
|
||||
|
||||
pin = None
|
||||
uv = "discouraged"
|
||||
|
||||
# Locate a device
|
||||
dev = next(CtapHidDevice.list_devices(), None)
|
||||
if dev is not None:
|
||||
print("Use USB HID channel.")
|
||||
else:
|
||||
try:
|
||||
from fido2.pcsc import CtapPcscDevice
|
||||
|
||||
dev = next(CtapPcscDevice.list_devices(), None)
|
||||
print("Use NFC channel.")
|
||||
except Exception as e:
|
||||
print("NFC channel search error:", e)
|
||||
|
||||
if not dev:
|
||||
print("No FIDO device found")
|
||||
sys.exit(1)
|
||||
|
||||
# Set up a FIDO 2 client using the origin https://example.com
|
||||
client = Fido2Client(dev, "https://example.com")
|
||||
|
||||
if not client.info.options.get("largeBlobs"):
|
||||
print("Authenticator does not support large blobs!")
|
||||
sys.exit(1)
|
||||
|
||||
if "largeBlobKey" not in client.info.extensions:
|
||||
print("Authenticator does not support the largeBlobKey extension!")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Prefer UV token if supported
|
||||
if client.info.options.get("pinUvAuthToken") and client.info.options.get("uv"):
|
||||
|
@ -103,11 +104,13 @@ options.extensions = {"largeBlobKey": True}
|
|||
# Create a credential
|
||||
print("\nTouch your authenticator device now...\n")
|
||||
|
||||
attestation_object, client_data = client.make_credential(options, pin=pin)
|
||||
key = attestation_object.large_blob_key
|
||||
result = client.make_credential(options, pin=pin)
|
||||
key = result.attestation_object.large_blob_key
|
||||
|
||||
# Complete registration
|
||||
auth_data = server.register_complete(state, client_data, attestation_object)
|
||||
auth_data = server.register_complete(
|
||||
state, result.client_data, result.attestation_object
|
||||
)
|
||||
credentials = [auth_data.credential_data]
|
||||
|
||||
print("New credential created!")
|
||||
|
@ -134,8 +137,9 @@ options.extensions = {"largeBlobKey": True}
|
|||
# Authenticate the credential
|
||||
print("\nTouch your authenticator device now...\n")
|
||||
|
||||
assertions, client_data = client.get_assertion(options, pin=pin)
|
||||
assertion = assertions[0] # Only one cred in allowCredentials, only one response.
|
||||
selection = client.get_assertion(options, pin=pin)
|
||||
# Only one cred in allowCredentials, only one response.
|
||||
assertion = selection.get_assertions()[0]
|
||||
|
||||
# This should match the key from MakeCredential.
|
||||
key = assertion.large_blob_key
|
||||
|
|
|
@ -96,18 +96,19 @@ create_options, state = server.register_begin(
|
|||
if use_prompt:
|
||||
print("\nTouch your authenticator device now...\n")
|
||||
|
||||
attestation_object, client_data = client.make_credential(
|
||||
create_options["publicKey"], pin=pin
|
||||
)
|
||||
result = client.make_credential(create_options["publicKey"], pin=pin)
|
||||
|
||||
|
||||
# Complete registration
|
||||
auth_data = server.register_complete(state, client_data, attestation_object)
|
||||
auth_data = server.register_complete(
|
||||
state, result.client_data, result.attestation_object
|
||||
)
|
||||
credentials = [auth_data.credential_data]
|
||||
|
||||
print("New credential created!")
|
||||
|
||||
print("CLIENT DATA:", client_data)
|
||||
print("ATTESTATION OBJECT:", attestation_object)
|
||||
print("CLIENT DATA:", result.client_data)
|
||||
print("ATTESTATION OBJECT:", result.attestation_object)
|
||||
print()
|
||||
print("CREDENTIAL DATA:", auth_data.credential_data)
|
||||
|
||||
|
@ -119,21 +120,23 @@ request_options, state = server.authenticate_begin(user_verification=uv)
|
|||
if use_prompt:
|
||||
print("\nTouch your authenticator device now...\n")
|
||||
|
||||
assertions, client_data = client.get_assertion(request_options["publicKey"], pin=pin)
|
||||
assertion = assertions[0] # Only one cred in allowCredentials, only one response.
|
||||
selection = client.get_assertion(request_options["publicKey"], pin=pin)
|
||||
result = selection.get_response(0) # One cred in allowCredentials, single response.
|
||||
|
||||
print("CLIENT DATA %r" % result.client_data)
|
||||
|
||||
# Complete authenticator
|
||||
server.authenticate_complete(
|
||||
state,
|
||||
credentials,
|
||||
assertion.credential["id"],
|
||||
client_data,
|
||||
assertion.auth_data,
|
||||
assertion.signature,
|
||||
result.credential_id,
|
||||
result.client_data,
|
||||
result.authenticator_data,
|
||||
result.signature,
|
||||
)
|
||||
|
||||
print("Credential authenticated!")
|
||||
|
||||
print("CLIENT DATA:", client_data)
|
||||
print("CLIENT DATA:", result.client_data)
|
||||
print()
|
||||
print("ASSERTION DATA:", assertion)
|
||||
print("AUTHENTICATOR DATA:", result.authenticator_data)
|
||||
|
|
180
fido2/client.py
180
fido2/client.py
|
@ -37,11 +37,14 @@ from .ctap2 import (
|
|||
Info,
|
||||
ClientPin,
|
||||
)
|
||||
from .ctap2.extensions import Ctap2Extension
|
||||
from .webauthn import (
|
||||
PublicKeyCredentialCreationOptions,
|
||||
PublicKeyCredentialRequestOptions,
|
||||
AuthenticatorSelectionCriteria,
|
||||
UserVerificationRequirement,
|
||||
AuthenticatorAttestationResponse,
|
||||
AuthenticatorAssertionResponse,
|
||||
)
|
||||
from .cose import ES256
|
||||
from .rpid import verify_rp_id, verify_app_id
|
||||
|
@ -310,6 +313,55 @@ class _BaseClient(object):
|
|||
)
|
||||
|
||||
|
||||
class AssertionSelection(object):
|
||||
"""GetAssertion result holding one or more assertions.
|
||||
|
||||
Since multiple assertions may be retured by Fido2Client.get_assertion, this result
|
||||
is returned which can be used to select a specific response to get.
|
||||
"""
|
||||
|
||||
def __init__(self, client_data, assertions):
|
||||
self._client_data = client_data
|
||||
self._assertions = assertions
|
||||
|
||||
def get_assertions(self):
|
||||
return self._assertions
|
||||
|
||||
def _get_extension_results(self, assertion):
|
||||
return {}
|
||||
|
||||
def get_response(self, index):
|
||||
assertion = self._assertions[index]
|
||||
|
||||
return AuthenticatorAssertionResponse(
|
||||
self._client_data,
|
||||
assertion.auth_data,
|
||||
assertion.signature,
|
||||
assertion.user["id"] if assertion.user else None,
|
||||
assertion.credential["id"],
|
||||
self._get_extension_results(assertion),
|
||||
)
|
||||
|
||||
|
||||
class Fido2ClientAssertionSelection(AssertionSelection):
|
||||
def __init__(self, client_data, assertions, extensions):
|
||||
super(Fido2ClientAssertionSelection, self).__init__(client_data, assertions)
|
||||
self._extensions = extensions
|
||||
|
||||
def _get_extension_results(self, assertion):
|
||||
# Process extenstion outputs
|
||||
extension_outputs = {}
|
||||
for ext in self._extensions:
|
||||
output = ext.process_get_output(assertion.auth_data)
|
||||
if output is not None:
|
||||
extension_outputs.update(output)
|
||||
return extension_outputs
|
||||
|
||||
|
||||
def _default_extensions():
|
||||
return [cls for cls in Ctap2Extension.__subclasses__() if hasattr(cls, "NAME")]
|
||||
|
||||
|
||||
_CTAP1_INFO = Info.create(["U2F_V2"])
|
||||
|
||||
|
||||
|
@ -324,9 +376,10 @@ class Fido2Client(_BaseClient):
|
|||
:param verify: Function to verify an RP ID for a given origin.
|
||||
"""
|
||||
|
||||
def __init__(self, device, origin, verify=verify_rp_id):
|
||||
def __init__(self, device, origin, verify=verify_rp_id, extension_types=None):
|
||||
super(Fido2Client, self).__init__(origin, verify)
|
||||
|
||||
self.extensions = extension_types or _default_extensions()
|
||||
self.ctap1_poll_delay = 0.25
|
||||
try:
|
||||
self.ctap2 = Ctap2(device)
|
||||
|
@ -397,21 +450,21 @@ class Fido2Client(_BaseClient):
|
|||
selection = options.authenticator_selection or AuthenticatorSelectionCriteria()
|
||||
|
||||
try:
|
||||
return (
|
||||
self._do_make_credential(
|
||||
client_data,
|
||||
options.rp,
|
||||
options.user,
|
||||
options.pub_key_cred_params,
|
||||
options.exclude_credentials,
|
||||
options.extensions,
|
||||
selection.require_resident_key,
|
||||
self._get_ctap_uv(selection.user_verification, pin is not None),
|
||||
pin,
|
||||
event,
|
||||
kwargs.get("on_keepalive"),
|
||||
),
|
||||
att_obj, extension_outputs = self._do_make_credential(
|
||||
client_data,
|
||||
options.rp,
|
||||
options.user,
|
||||
options.pub_key_cred_params,
|
||||
options.exclude_credentials,
|
||||
options.extensions,
|
||||
selection.require_resident_key,
|
||||
self._get_ctap_uv(selection.user_verification, pin is not None),
|
||||
pin,
|
||||
event,
|
||||
kwargs.get("on_keepalive"),
|
||||
)
|
||||
return AuthenticatorAttestationResponse(
|
||||
client_data, att_obj, extension_outputs,
|
||||
)
|
||||
except CtapError as e:
|
||||
raise _ctap2client_err(e)
|
||||
|
@ -466,13 +519,24 @@ class Fido2Client(_BaseClient):
|
|||
if max_creds and len(exclude_list) > max_creds:
|
||||
raise ClientError.ERR.BAD_REQUEST("exclude_list too long")
|
||||
|
||||
return self.ctap2.make_credential(
|
||||
# Process extensions
|
||||
client_inputs = extensions or {}
|
||||
extension_inputs = {}
|
||||
used_extensions = []
|
||||
for ext in [cls(self.ctap2) for cls in self.extensions]:
|
||||
auth_input = ext.process_create_input(client_inputs)
|
||||
if auth_input is not None:
|
||||
used_extensions.append(ext)
|
||||
extension_inputs[ext.NAME] = auth_input
|
||||
# TODO: Passthrough extension data if key is supported extension?
|
||||
|
||||
att_obj = self.ctap2.make_credential(
|
||||
client_data.hash,
|
||||
rp,
|
||||
user,
|
||||
key_params,
|
||||
exclude_list if exclude_list else None,
|
||||
extensions,
|
||||
exclude_list or None,
|
||||
extension_inputs or None,
|
||||
options,
|
||||
pin_auth,
|
||||
pin_protocol,
|
||||
|
@ -480,6 +544,15 @@ class Fido2Client(_BaseClient):
|
|||
on_keepalive,
|
||||
)
|
||||
|
||||
# Process extenstion outputs
|
||||
extension_outputs = {}
|
||||
for ext in used_extensions:
|
||||
output = ext.process_create_output(att_obj.auth_data)
|
||||
if output is not None:
|
||||
extension_outputs.update(output)
|
||||
|
||||
return att_obj, extension_outputs
|
||||
|
||||
def _ctap1_make_credential(
|
||||
self,
|
||||
client_data,
|
||||
|
@ -517,16 +590,19 @@ class Fido2Client(_BaseClient):
|
|||
)
|
||||
raise ClientError.ERR.DEVICE_INELIGIBLE()
|
||||
|
||||
return AttestationObject.from_ctap1(
|
||||
app_param,
|
||||
_call_polling(
|
||||
self.ctap1_poll_delay,
|
||||
event,
|
||||
on_keepalive,
|
||||
self.ctap1.register,
|
||||
client_data.hash,
|
||||
return (
|
||||
AttestationObject.from_ctap1(
|
||||
app_param,
|
||||
_call_polling(
|
||||
self.ctap1_poll_delay,
|
||||
event,
|
||||
on_keepalive,
|
||||
self.ctap1.register,
|
||||
client_data.hash,
|
||||
app_param,
|
||||
),
|
||||
),
|
||||
{},
|
||||
)
|
||||
|
||||
def get_assertion(self, options, **kwargs):
|
||||
|
@ -553,18 +629,18 @@ class Fido2Client(_BaseClient):
|
|||
)
|
||||
|
||||
try:
|
||||
return (
|
||||
self._do_get_assertion(
|
||||
client_data,
|
||||
options.rp_id,
|
||||
options.allow_credentials,
|
||||
options.extensions,
|
||||
self._get_ctap_uv(options.user_verification, pin is not None),
|
||||
pin,
|
||||
event,
|
||||
kwargs.get("on_keepalive"),
|
||||
),
|
||||
assertions, used_extensions = self._do_get_assertion(
|
||||
client_data,
|
||||
options.rp_id,
|
||||
options.allow_credentials,
|
||||
options.extensions,
|
||||
self._get_ctap_uv(options.user_verification, pin is not None),
|
||||
pin,
|
||||
event,
|
||||
kwargs.get("on_keepalive"),
|
||||
)
|
||||
return Fido2ClientAssertionSelection(
|
||||
client_data, assertions, used_extensions,
|
||||
)
|
||||
except CtapError as e:
|
||||
raise _ctap2client_err(e)
|
||||
|
@ -606,11 +682,22 @@ class Fido2Client(_BaseClient):
|
|||
if max_creds and len(allow_list) > max_creds:
|
||||
raise ClientError.ERR.BAD_REQUEST("allow_list too long")
|
||||
|
||||
return self.ctap2.get_assertions(
|
||||
# Process extensions
|
||||
client_inputs = extensions or {}
|
||||
extension_inputs = {}
|
||||
used_extensions = []
|
||||
for ext in [cls(self.ctap2) for cls in self.extensions]:
|
||||
auth_input = ext.process_get_input(client_inputs)
|
||||
if auth_input is not None:
|
||||
used_extensions.append(ext)
|
||||
extension_inputs[ext.NAME] = auth_input
|
||||
# TODO: Passthrough extension data if key is supported extension?
|
||||
|
||||
assertions = self.ctap2.get_assertions(
|
||||
rp_id,
|
||||
client_data.hash,
|
||||
allow_list if allow_list else None,
|
||||
extensions,
|
||||
allow_list or None,
|
||||
extension_inputs or None,
|
||||
options,
|
||||
pin_auth,
|
||||
pin_protocol,
|
||||
|
@ -618,6 +705,8 @@ class Fido2Client(_BaseClient):
|
|||
on_keepalive,
|
||||
)
|
||||
|
||||
return assertions, used_extensions
|
||||
|
||||
def _ctap1_get_assertion(
|
||||
self, client_data, rp_id, allow_list, extensions, uv, pin, event, on_keepalive
|
||||
):
|
||||
|
@ -637,7 +726,8 @@ class Fido2Client(_BaseClient):
|
|||
app_param,
|
||||
cred["id"],
|
||||
)
|
||||
return [AssertionResponse.from_ctap1(app_param, cred, auth_resp)]
|
||||
assertions = [AssertionResponse.from_ctap1(app_param, cred, auth_resp)]
|
||||
return assertions, []
|
||||
except ClientError as e:
|
||||
if e.code == ClientError.ERR.TIMEOUT:
|
||||
raise # Other errors are ignored so we move to the next.
|
||||
|
@ -726,7 +816,9 @@ class WindowsClient(_BaseClient):
|
|||
except OSError as e:
|
||||
raise ClientError.ERR.OTHER_ERROR(e)
|
||||
|
||||
return AttestationObject(result), client_data
|
||||
return AuthenticatorAttestationResponse(
|
||||
client_data, AttestationObject(result), {}
|
||||
)
|
||||
|
||||
def get_assertion(self, options, **kwargs):
|
||||
"""Get assertion using Windows WebAuthN APIs.
|
||||
|
@ -760,7 +852,7 @@ class WindowsClient(_BaseClient):
|
|||
raise ClientError.ERR.OTHER_ERROR(e)
|
||||
|
||||
user = {"id": user_id} if user_id else None
|
||||
return (
|
||||
[AssertionResponse.create(credential, auth_data, signature, user)],
|
||||
return AssertionSelection(
|
||||
client_data,
|
||||
[AssertionResponse.create(credential, auth_data, signature, user)],
|
||||
)
|
||||
|
|
|
@ -0,0 +1,191 @@
|
|||
# Copyright (c) 2020 Yubico AB
|
||||
# All rights reserved.
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or
|
||||
# without modification, are permitted provided that the following
|
||||
# conditions are met:
|
||||
#
|
||||
# 1. Redistributions of source code must retain the above copyright
|
||||
# notice, this list of conditions and the following disclaimer.
|
||||
# 2. Redistributions in binary form must reproduce the above
|
||||
# copyright notice, this list of conditions and the following
|
||||
# disclaimer in the documentation and/or other materials provided
|
||||
# with the distribution.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
|
||||
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||
# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
|
||||
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
||||
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
|
||||
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from ..ctap import CtapError
|
||||
from .pin import ClientPin
|
||||
from enum import Enum, unique
|
||||
import abc
|
||||
|
||||
|
||||
class Ctap2Extension(abc.ABC):
|
||||
"""Base class for Ctap2 extensions.
|
||||
Subclasses are instantiated for a single request, if the Authenticator supports
|
||||
the extension.
|
||||
"""
|
||||
|
||||
def __init__(self, ctap):
|
||||
self.ctap = ctap
|
||||
|
||||
def is_supported(self):
|
||||
return self.NAME in self.ctap.info.extensions
|
||||
|
||||
def process_create_input(self, inputs):
|
||||
"""Returns a value to include in the authenticator extension input,
|
||||
or None.
|
||||
"""
|
||||
return None
|
||||
|
||||
def process_create_output(self, auth_data):
|
||||
"""Return client extension output given auth_data, or None."""
|
||||
return None
|
||||
|
||||
def process_get_input(self, inputs):
|
||||
"""Returns a value to include in the authenticator extension input,
|
||||
or None.
|
||||
"""
|
||||
return None
|
||||
|
||||
def process_get_output(self, auth_data):
|
||||
"""Return client extension output given auth_data, or None."""
|
||||
return None
|
||||
|
||||
|
||||
class HmacSecretExtension(Ctap2Extension):
|
||||
"""
|
||||
Implements the hmac-secret CTAP2 extension.
|
||||
"""
|
||||
|
||||
NAME = "hmac-secret"
|
||||
SALT_LEN = 32
|
||||
|
||||
def process_create_input(self, inputs):
|
||||
if self.is_supported() and inputs.get("hmacCreateSecret") is True:
|
||||
return True
|
||||
|
||||
def process_create_output(self, auth_data):
|
||||
if auth_data.extensions.get(self.NAME):
|
||||
return {"hmacCreateSecret": True}
|
||||
|
||||
def process_get_input(self, inputs):
|
||||
data = self.is_supported() and inputs.get("hmacGetSecret")
|
||||
if not data:
|
||||
return
|
||||
|
||||
salt1 = data["salt1"]
|
||||
salt2 = data.get("salt2", b"")
|
||||
if not (
|
||||
len(salt1) == HmacSecretExtension.SALT_LEN
|
||||
and (not salt2 or len(salt2) == HmacSecretExtension.SALT_LEN)
|
||||
):
|
||||
raise ValueError("Invalid salt length")
|
||||
|
||||
client_pin = ClientPin(self.ctap)
|
||||
key_agreement, self.shared_secret = client_pin._get_shared_secret()
|
||||
self.pin_protocol = client_pin.protocol
|
||||
|
||||
salt_enc = self.pin_protocol.encrypt(self.shared_secret, salt1 + salt2)
|
||||
salt_auth = self.pin_protocol.authenticate(self.shared_secret, salt_enc)
|
||||
|
||||
return {
|
||||
1: key_agreement,
|
||||
2: salt_enc,
|
||||
3: salt_auth,
|
||||
4: self.pin_protocol.VERSION,
|
||||
}
|
||||
|
||||
def process_get_output(self, auth_data):
|
||||
value = auth_data.extensions.get(self.NAME)
|
||||
|
||||
decrypted = self.pin_protocol.decrypt(self.shared_secret, value)
|
||||
output1 = decrypted[: HmacSecretExtension.SALT_LEN]
|
||||
output2 = decrypted[HmacSecretExtension.SALT_LEN :]
|
||||
outputs = {"output1": output1}
|
||||
if output2:
|
||||
outputs["output2"] = output2
|
||||
|
||||
return {"hmacGetSecret": outputs}
|
||||
|
||||
|
||||
class LargeBlobKeyExtension(Ctap2Extension):
|
||||
"""
|
||||
Implements the Large Blob Key CTAP2 extension.
|
||||
"""
|
||||
|
||||
NAME = "largeBlobKey"
|
||||
|
||||
def process_create_input(self, inputs):
|
||||
if self.is_supported() and inputs.get("largeBlobKey") is True:
|
||||
return True
|
||||
|
||||
def process_get_input(self, inputs):
|
||||
if self.is_supported() and inputs.get("largeBlobKey") is True:
|
||||
return True
|
||||
|
||||
|
||||
class CredBlobExtension(Ctap2Extension):
|
||||
"""
|
||||
Implements the Credential Blob CTAP2 extension.
|
||||
"""
|
||||
|
||||
NAME = "credBlob"
|
||||
|
||||
def process_create_input(self, inputs):
|
||||
blob = self.is_supported() and inputs.get("credBlob")
|
||||
if blob and len(blob) <= self.ctap.info.max_cred_blob_length:
|
||||
return blob
|
||||
|
||||
def process_get_input(self, inputs):
|
||||
if self.is_supported() and inputs.get("getCredBlob") is True:
|
||||
return True
|
||||
|
||||
|
||||
class CredProtectExtension(Ctap2Extension):
|
||||
"""
|
||||
Implements the Credential Protection CTAP2 extension.
|
||||
"""
|
||||
|
||||
@unique
|
||||
class POLICY(Enum):
|
||||
OPTIONAL = "userVerificationOptional"
|
||||
OPTIONAL_WITH_LIST = "userVerificationOptionalWithCredentialIDList"
|
||||
REQUIRED = "userVerificationRequired"
|
||||
|
||||
ALWAYS_RUN = True
|
||||
NAME = "credProtect"
|
||||
|
||||
def process_create_input(self, inputs):
|
||||
policy = inputs.get("credentialProtectionPolicy")
|
||||
if policy:
|
||||
index = list(CredProtectExtension.POLICY).index(policy)
|
||||
enforce = inputs.get("enforceCredentialProtectionPolicy", False)
|
||||
if enforce and not self.is_supported() and index > 0:
|
||||
raise CtapError(CtapError.ERR.UNSUPPORTED_EXTENSION)
|
||||
return index + 1
|
||||
|
||||
|
||||
class MinPinLengthExtension(Ctap2Extension):
|
||||
"""
|
||||
Implements the Minimum PIN Length CTAP2 extension.
|
||||
"""
|
||||
|
||||
NAME = "minPinLength"
|
||||
|
||||
def process_create_input(self, inputs):
|
||||
if self.is_supported() and inputs.get(self.NAME) is True:
|
||||
return True
|
|
@ -1,130 +0,0 @@
|
|||
# Copyright (c) 2018 Yubico AB
|
||||
# All rights reserved.
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or
|
||||
# without modification, are permitted provided that the following
|
||||
# conditions are met:
|
||||
#
|
||||
# 1. Redistributions of source code must retain the above copyright
|
||||
# notice, this list of conditions and the following disclaimer.
|
||||
# 2. Redistributions in binary form must reproduce the above
|
||||
# copyright notice, this list of conditions and the following
|
||||
# disclaimer in the documentation and/or other materials provided
|
||||
# with the distribution.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
|
||||
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||
# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
|
||||
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
||||
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
|
||||
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import abc
|
||||
|
||||
|
||||
class Extension(abc.ABC):
|
||||
"""
|
||||
Base class for CTAP2 extensions.
|
||||
"""
|
||||
|
||||
NAME = None
|
||||
|
||||
def results_for(self, auth_data):
|
||||
"""
|
||||
Get the parsed extension results from an AuthenticatorData object.
|
||||
"""
|
||||
data = auth_data.extensions.get(self.NAME)
|
||||
if auth_data.is_attested():
|
||||
return self.create_result(data)
|
||||
else:
|
||||
return self.get_result(data)
|
||||
|
||||
def create_dict(self, *args, **kwargs):
|
||||
"""
|
||||
Return extension dict for use with calls to make_credential.
|
||||
"""
|
||||
return {self.NAME: self.create_data(*args, **kwargs)}
|
||||
|
||||
def get_dict(self, *args, **kwargs):
|
||||
"""
|
||||
Return extension dict for use with calls to get_assertion.
|
||||
"""
|
||||
return {self.NAME: self.get_data(*args, **kwargs)}
|
||||
|
||||
@abc.abstractmethod
|
||||
def create_data(self, *args, **kwargs):
|
||||
"""
|
||||
Return extension data value for use with calls to make_credential.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def create_result(self, data):
|
||||
"""
|
||||
Process and return extension result from call to make_credential.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_data(self, *args, **kwargs):
|
||||
"""
|
||||
Return extension data value for use with calls to get_assertion.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_result(self, data):
|
||||
"""
|
||||
Process and return extension result from call to get_assertion.
|
||||
"""
|
||||
|
||||
|
||||
class HmacSecretExtension(Extension):
|
||||
"""
|
||||
Implements the hmac-secret CTAP2 extension.
|
||||
"""
|
||||
|
||||
NAME = "hmac-secret"
|
||||
SALT_LEN = 32
|
||||
|
||||
def __init__(self, client_pin):
|
||||
self._client_pin = client_pin
|
||||
|
||||
def create_data(self):
|
||||
return True
|
||||
|
||||
def create_result(self, data):
|
||||
if data is not True:
|
||||
raise ValueError("hmac-secret extension not supported")
|
||||
|
||||
def get_data(self, salt1, salt2=b""):
|
||||
if len(salt1) != self.SALT_LEN:
|
||||
raise ValueError("Wrong length for salt1")
|
||||
if salt2 and len(salt2) != self.SALT_LEN:
|
||||
raise ValueError("Wrong length for salt2")
|
||||
|
||||
key_agreement, shared_secret = self._client_pin._get_shared_secret()
|
||||
self._agreement = key_agreement
|
||||
self._secret = shared_secret
|
||||
|
||||
salt_enc = self._client_pin.protocol.encrypt(shared_secret, salt1 + salt2)
|
||||
salt_auth = self._client_pin.protocol.authenticate(shared_secret, salt_enc)
|
||||
|
||||
return {
|
||||
1: key_agreement,
|
||||
2: salt_enc,
|
||||
3: salt_auth,
|
||||
4: self._client_pin.protocol.VERSION,
|
||||
}
|
||||
|
||||
def get_result(self, data):
|
||||
salt = self._client_pin.protocol.decrypt(self._secret, data)
|
||||
return (
|
||||
salt[: HmacSecretExtension.SALT_LEN],
|
||||
salt[HmacSecretExtension.SALT_LEN :],
|
||||
)
|
|
@ -234,3 +234,32 @@ class PublicKeyCredentialRequestOptions(_DataObject):
|
|||
user_verification=UserVerificationRequirement._wrap(user_verification),
|
||||
extensions=extensions,
|
||||
)
|
||||
|
||||
|
||||
class AuthenticatorAttestationResponse(_DataObject):
|
||||
def __init__(self, client_data, attestation_object, extension_results=None):
|
||||
super(AuthenticatorAttestationResponse, self).__init__(
|
||||
client_data=client_data,
|
||||
attestation_object=attestation_object,
|
||||
extension_results=extension_results,
|
||||
)
|
||||
|
||||
|
||||
class AuthenticatorAssertionResponse(_DataObject):
|
||||
def __init__(
|
||||
self,
|
||||
client_data,
|
||||
authenticator_data,
|
||||
signature,
|
||||
user_handle,
|
||||
credential_id,
|
||||
extension_results=None,
|
||||
):
|
||||
super(AuthenticatorAssertionResponse, self).__init__(
|
||||
client_data=client_data,
|
||||
authenticator_data=authenticator_data,
|
||||
signature=signature,
|
||||
user_handle=user_handle,
|
||||
credential_id=credential_id,
|
||||
extension_results=extension_results,
|
||||
)
|
||||
|
|
|
@ -389,7 +389,7 @@ class TestFido2Client(unittest.TestCase):
|
|||
PatchedCtap2.return_value = ctap2
|
||||
client = Fido2Client(dev, APP_ID)
|
||||
|
||||
attestation, client_data = client.make_credential(
|
||||
response = client.make_credential(
|
||||
PublicKeyCredentialCreationOptions(
|
||||
rp,
|
||||
user,
|
||||
|
@ -400,11 +400,11 @@ class TestFido2Client(unittest.TestCase):
|
|||
)
|
||||
)
|
||||
|
||||
self.assertIsInstance(attestation, AttestationObject)
|
||||
self.assertIsInstance(client_data, ClientData)
|
||||
self.assertIsInstance(response.attestation_object, AttestationObject)
|
||||
self.assertIsInstance(response.client_data, ClientData)
|
||||
|
||||
ctap2.make_credential.assert_called_with(
|
||||
client_data.hash,
|
||||
response.client_data.hash,
|
||||
rp,
|
||||
user,
|
||||
[{"type": "public-key", "alg": -7}],
|
||||
|
@ -417,9 +417,9 @@ class TestFido2Client(unittest.TestCase):
|
|||
None,
|
||||
)
|
||||
|
||||
self.assertEqual(client_data.get("origin"), APP_ID)
|
||||
self.assertEqual(client_data.get("type"), "webauthn.create")
|
||||
self.assertEqual(client_data.challenge, challenge)
|
||||
self.assertEqual(response.client_data.get("origin"), APP_ID)
|
||||
self.assertEqual(response.client_data.get("type"), "webauthn.create")
|
||||
self.assertEqual(response.client_data.challenge, challenge)
|
||||
|
||||
def test_make_credential_ctap1(self):
|
||||
dev = mock.Mock()
|
||||
|
@ -430,14 +430,15 @@ class TestFido2Client(unittest.TestCase):
|
|||
client.ctap1.get_version.return_value = "U2F_V2"
|
||||
client.ctap1.register.return_value = REG_DATA
|
||||
|
||||
attestation, client_data = client.make_credential(
|
||||
response = client.make_credential(
|
||||
PublicKeyCredentialCreationOptions(
|
||||
rp, user, challenge, [{"type": "public-key", "alg": -7}]
|
||||
)
|
||||
)
|
||||
|
||||
self.assertIsInstance(attestation, AttestationObject)
|
||||
self.assertIsInstance(client_data, ClientData)
|
||||
self.assertIsInstance(response.attestation_object, AttestationObject)
|
||||
self.assertIsInstance(response.client_data, ClientData)
|
||||
client_data = response.client_data
|
||||
|
||||
client.ctap1.register.assert_called_with(
|
||||
client_data.hash, sha256(rp["id"].encode())
|
||||
|
@ -447,4 +448,4 @@ class TestFido2Client(unittest.TestCase):
|
|||
self.assertEqual(client_data.get("type"), "webauthn.create")
|
||||
self.assertEqual(client_data.challenge, challenge)
|
||||
|
||||
self.assertEqual(attestation.fmt, "fido-u2f")
|
||||
self.assertEqual(response.attestation_object.fmt, "fido-u2f")
|
||||
|
|
Loading…
Reference in New Issue