# 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 from .hid import CtapError from .u2f import CTAP1, APDU, ApduError from .fido2 import (CTAP2, PinProtocolV1, AttestedCredentialData, AuthenticatorData, AttestationObject, AssertionResponse) from .rpid import verify_rp_id, verify_app_id from .utils import Timeout, sha256, hmac_sha256, websafe_decode, websafe_encode from enum import IntEnum, unique import json class ClientData(bytes): def __init__(self, data): self.data = json.loads(data.decode()) self.origin = self.data['origin'] @property def b64(self): return websafe_encode(self) @property def hash(self): return sha256(self) @classmethod def build(cls, **kwargs): return cls(json.dumps(kwargs).encode()) @classmethod def from_b64(cls, data): return cls(websafe_decode(data)) def __repr__(self): return self.decode() def __str__(self): return self.decode() class ClientError(Exception): @unique class ERR(IntEnum): OTHER_ERROR = 1 BAD_REQUEST = 2 CONFIGURATION_UNSUPPORTED = 3 DEVICE_INELIGIBLE = 4 TIMEOUT = 5 def __call__(self): return ClientError(self) def __init__(self, code): self.code = ClientError.ERR(code) def __repr__(self): return 'U2F Client error: {0} - {0.name}'.format(self.code) def _call_polling(poll_delay, timeout, func, *args, **kwargs): with Timeout(timeout or 30) as event: while not event.is_set(): try: return func(*args, **kwargs) except ApduError as e: if e.code == APDU.USE_NOT_SATISFIED: event.wait(poll_delay) else: raise raise ClientError.ERR.TIMEOUT() class U2fClient(object): def __init__(self, device, origin, verify=verify_app_id): self.poll_delay = 0.25 self.ctap = CTAP1(device) self.origin = origin self._verify = verify_app_id def _verify_app_id(self, app_id): try: if self._verify(app_id, self.origin): return except Exception: pass # Fall through to ClientError raise ClientError.ERR.BAD_REQUEST() def register(self, app_id, register_requests, registered_keys, timeout=None): self._verify_app_id(app_id) version = self.ctap.get_version() dummy_param = b'\0'*32 for key in registered_keys: if key['version'] != version: continue key_app_id = key.get('appId', app_id) app_param = sha256(key_app_id.encode()) self._verify_app_id(key_app_id) key_handle = websafe_decode(key['keyHandle']) try: self.ctap.authenticate(dummy_param, app_param, key_handle, True) raise ClientError.ERR.DEVICE_INELIGIBLE() # Bad response except ApduError as e: if e.code == APDU.USE_NOT_SATISFIED: raise ClientError.ERR.DEVICE_INELIGIBLE() for request in register_requests: if request['version'] == version: challenge = request['challenge'] break else: raise ClientError.ERR.DEVICE_INELIGIBLE() client_data = ClientData.build( typ='navigator.id.finishEnrollment', challenge=challenge, origin=self.origin ) app_param = sha256(app_id.encode()) reg_data = _call_polling(self.poll_delay, timeout, self.ctap.register, client_data.hash, app_param) return { 'registrationData': reg_data.b64, 'clientData': client_data.b64 } def sign(self, app_id, challenge, registered_keys, timeout=None): client_data = ClientData.build( typ='navigator.id.getAssertion', challenge=challenge, origin=self.origin ) version = self.ctap.get_version() for key in registered_keys: if key['version'] == version: key_app_id = key.get('appId', app_id) self._verify_app_id(key_app_id) key_handle = websafe_decode(key['keyHandle']) app_param = sha256(key_app_id.encode()) try: signature_data = _call_polling( self.poll_delay, timeout, self.ctap.authenticate, client_data.hash, app_param, key_handle) break except ApduError: pass # Ignore and try next key else: raise ClientError.ERR.DEVICE_INELIGIBLE() return { 'clientData': client_data.b64, 'signatureData': signature_data.b64, 'keyHandle': key['keyHandle'] } @unique class CRED_ALGO(IntEnum): ES256 = -7 RS256 = -257 class Fido2Client(object): def __init__(self, device, origin, verify=verify_rp_id): self.ctap1_poll_delay = 0.25 self.origin = origin self._verify = verify try: self.ctap = CTAP2(device) self.pin_protocol = PinProtocolV1(self.ctap) self._do_make_credential = self._ctap2_make_credential self._do_get_assertion = self._ctap2_get_assertion except ValueError: self.ctap = CTAP1(device) self._do_make_credential = self._ctap1_make_credential self._do_get_assertion = self._ctap1_get_assertion def _verify_rp_id(self, rp_id): try: if self._verify(rp_id, self.origin): return except Exception: pass # Fall through to ClientError raise ClientError.ERR.BAD_REQUEST() def make_credential(self, rp, user, challenge, algos=[CRED_ALGO.ES256], exclude_list=None, extensions=None, rk=False, uv=False, pin=None, timeout=None): self._verify_rp_id(rp['id']) client_data = ClientData.build( type='webauthn.create', clientExtensions={}, challenge=challenge, origin=self.origin ) attestation = self._do_make_credential( client_data, rp, user, algos, exclude_list, extensions, rk, uv, pin, timeout) return attestation, client_data def _ctap2_make_credential(self, client_data, rp, user, algos, exclude_list, extensions, rk, uv, pin, timeout): key_params = [{'type': 'public-key', 'alg': alg} for alg in algos] info = self.ctap.get_info() pin_auth = None pin_protocol = None if pin: pin_protocol = self.pin_protocol.VERSION if pin_protocol not in info.pin_protocols: raise ValueError('Device does not support PIN protocol 1!') pin_token = self.pin_protocol.get_pin_token(pin) pin_auth = hmac_sha256(pin_token, client_data.hash)[:16] elif info.options.get('clientPin'): raise ValueError('PIN required!') if not (rk or uv): options = None else: options = {} if rk: options['rk'] = True if uv: options['uv'] = True return self.ctap.make_credential(client_data.hash, rp, user, key_params, exclude_list, extensions, options, pin_auth, pin_protocol, timeout) def _ctap1_make_credential(self, client_data, rp, user, algos, exclude_list, extensions, rk, uv, pin, timeout): if rk or uv: raise CtapError(CtapError.ERR.UNSUPPORTED_OPTION) app_param = sha256(rp['id'].encode()) dummy_param = b'\0'*32 for cred in exclude_list or []: key_handle = cred['id'] try: self.ctap.authenticate(dummy_param, app_param, key_handle, True) raise CtapError(CtapError.ERR.CREDENTIAL_EXCLUDED) # Invalid except ApduError as e: if e.code == APDU.USE_NOT_SATISFIED: raise CtapError(CtapError.ERR.CREDENTIAL_EXCLUDED) reg_resp = _call_polling(self.ctap1_poll_delay, timeout, self.ctap.register, client_data.hash, app_param) return AttestationObject.create( 'fido-u2f', AuthenticatorData.create( app_param, 0x41, 0, AttestedCredentialData.create( b'\0'*16, # aaguid reg_resp.key_handle, { # EC256 public key 1: 2, 3: -7, -1: 1, -2: reg_resp.public_key[1:1+32], -3: reg_resp.public_key[33:33+32] } ) ), { # att_statement 'x5c': [reg_resp.certificate], 'sig': reg_resp.signature } ) def get_assertion(self, rp_id, challenge, allow_list=None, extensions=None, rk=False, uv=False, pin=None, timeout=None): self._verify_rp_id(rp_id) client_data = ClientData.build( type='webauthn.get', clientExtensions={}, challenge=challenge, origin=self.origin ) assertions = self._do_get_assertion( client_data, rp_id, allow_list, extensions, rk, uv, pin, timeout) return assertions, client_data def _ctap2_get_assertion(self, client_data, rp_id, allow_list, extensions, rk, uv, pin, timeout): info = self.ctap.get_info() pin_auth = None pin_protocol = None if pin: pin_protocol = self.pin_protocol.VERSION if pin_protocol not in info.pin_protocols: raise ValueError('Device does not support PIN protocol 1!') pin_token = self.pin_protocol.get_pin_token(pin) pin_auth = hmac_sha256(pin_token, client_data.hash)[:16] elif info.options.get('clientPin'): raise ValueError('PIN required!') if not (rk or uv): options = None else: options = {} if rk: options['rk'] = True if uv: options['uv'] = True assertions = [self.ctap.get_assertion(rp_id, client_data.hash, allow_list, extensions, options, pin_auth, pin_protocol, timeout)] for _ in range((assertions[0].number_of_credentials or 1) - 1): assertions.append(self.ctap.get_next_assertion()) return assertions def _ctap1_get_assertion(self, client_data, rp_id, allow_list, extensions, rk, uv, pin, timeout): if rk or uv or not allow_list: raise CtapError(CtapError.ERR.UNSUPPORTED_OPTION) app_param = sha256(rp_id.encode()) client_param = client_data.hash for cred in allow_list: try: auth_resp = _call_polling(self.ctap1_poll_delay, timeout, self.ctap.authenticate, client_param, app_param, cred['id']) return [AssertionResponse.create( cred, AuthenticatorData.create( app_param, auth_resp.user_presence & 0x01, auth_resp.counter ), auth_resp.signature )] except ApduError as e: pass # Ignore this handle raise CtapError(CtapError.ERR.NO_CREDENTIALS)