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:
Dain Nilsson 2020-10-30 14:58:47 +01:00
parent 0912c31478
commit d44a7d6d3e
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
11 changed files with 588 additions and 264 deletions

9
NEWS
View File

@ -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.

124
examples/cred_blob.py Normal file
View File

@ -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)

View File

@ -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)

View File

@ -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"]))

View File

@ -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

View File

@ -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)

View File

@ -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)],
)

191
fido2/ctap2/extensions.py Normal file
View File

@ -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

View File

@ -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 :],
)

View File

@ -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,
)

View File

@ -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")