# Copyrite IBM 2022, 2025
# IBM Confidential
# Assisted by watsonx Code Assistant

import base64, datetime, multiprocessing, os, sys, random, threading, time, secrets, typing, logging, math
import cbor2 as cbor
from enum import Enum, IntEnum
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import hashes, hmac
from cryptography.fernet import Fernet

try:
    from soft_fido2.message_queues import QueueMessageType, MessageQueue
    from soft_fido2.uhid_device import UserDevice, BaseStructure, bcolors, dump_bytes, colour_print
    from soft_fido2.key_pair import KeyPair, KeyUtils
    from soft_fido2.authenticator import Fido2Authenticator
    from soft_fido2.cert_utils import CertUtils
    from soft_fido2.symmetric_key import SymmetricKey
except ImportError:
    sys.path.append(os.path.dirname(os.path.abspath(__file__)))
    from message_queues import QueueMessageType, MessageQueue
    from uhid_device import UserDevice, BaseStructure, bcolors, dump_bytes, colour_print
    from key_pair import KeyPair, KeyUtils
    from authenticator import Fido2Authenticator
    from cert_utils import CertUtils
    from symmetric_key import SymmetricKey

#max usb data frame size
MAX_DATA_FRAME = 64

class AuthenticatorAPI(object):
    '''
    AuthenticatorAPI wraps the Fido2Authenticator with management for a persistent key/ca certificate stored in
    an encrypted file.

    The default location for this is in the ~/.fido2 directory for the user running this module, however this
    can be overwritten by environment properties.

    All keys generated by this authenticator are created so that the credential id is the encrypted key
    used to generate the attestation. This is done to allow this Authenticator to restore any key created with
    its ca key / AES key combination without the need to persist every individual private key used to 
    generate a new credential.
    '''

    _exp_time = 30

    _open_keys = {}

    _watchdog = None
    _lock = multiprocessing.Lock()

    _pin_token_kp: typing.Optional[KeyPair] = None
    _pin_retry = 5

    _quit = False

    def __new__(cls):
        cls._watchdog = threading.Thread(target=cls._token_expiry_check)
        cls._watchdog.start()
        cls._lock.acquire()
        if cls._pin_token_kp == None:
            cls._pin_token_kp = KeyPair.generate_ecdsa()#P-256 key, cose key type -25
        cls._lock.release()

    @classmethod
    def _token_expiry_check(cls):
        '''
        Expire in-memory passkeys handled by open CIDs after a short duration since use for authentication as they would no longer be used
        '''
        while not cls._quit:
            time.sleep(0.005)
            cls._lock.acquire()
            cid_list = list(cls._open_keys.keys())
            for cid in cid_list:
                if math.floor(time.time() - cls._open_keys[cid]["tStart"]) == cls._exp_time:
                    cls._open_keys.pop(cid)
                    colour_print(colour=bcolors.FAIL, component='Authenticator_token_expiry_check',
                                 msg='CID {} has expired!\nExisting tokens: {}'.format(cid, cls._open_keys))
            cls._lock.release()


    @classmethod
    def has_cached_up(cls, cid) -> bool:
        cls._lock.acquire()
        try:
            if cid in cls._open_keys:
                return bool(cls._open_keys[cid].get("up", False))
            return False
        finally:
            cls._lock.release()

    @classmethod
    def cache_up(cls, cid, up: bool):
        if cid in cls._open_keys:
            cls._lock.acquire()
            cls._open_keys[cid]["up"] = up
            cls._lock.release()

    @classmethod
    def get_pin_auth_token(cls, cid):
        cls._lock.acquire()
        try:
            # Find CID in open passkeys and then attempt to acquire pinUvAuthToken
            open_key = AuthenticatorAPI._open_keys.get(cid, {})
            return open_key.get('pinAuth')
        finally:
            cls._lock.release()

    @classmethod
    def _validate_pin(cls, pinHash: bytes, cid: bytes) -> typing.Optional[bytes]:
        """
        Validates a PIN by attempting to decrypt passkey files in the FIDO_HOME directory.
        
        If a valid passkey file is found, it loads the certificate and key pair,
        stores the information in the class's _open_keys dictionary with the channe
        id as the key, and returns a generated PIN authentication token.
        
        Args:
            pinHash: The hash of the PIN to validate
            cid: The channel ID to associate with the opened keys
            
        Returns:
            A PIN authentication token if validation succeeds, None otherwise
        """
        # Check if FIDO_HOME environment variable exists and directory is accessible
        if not cls._is_fido_home_valid():
            return None
        fido_home_dir = os.path.realpath(os.environ["FIDO_HOME"])
        for passkey_file in cls._get_passkey_files(fido_home_dir):
            try:
                # Attempt to load and validate the passkey
                auth_token = cls._process_passkey_file(passkey_file, pinHash, cid)
                if auth_token:
                    return auth_token
            except Exception as e:
                colour_print(
                    colour=bcolors.WARNING, 
                    component='Authenticator_validate_pin',
                    msg=f'Failed to process {os.path.basename(passkey_file)}:\n{e}'
                )
                continue
        
        # No valid passkey file found
        colour_print(
            colour=bcolors.FAIL, 
            component='Authenticator_validate_pin',
            msg='No valid pin found!'
        )
        return None

    @classmethod
    def _is_fido_home_valid(cls) -> bool:
        """
        Checks if the FIDO_HOME environment variable exists and points to a valid directory.
        
        Returns:
            bool: True if FIDO_HOME is valid, False otherwise
        """
        if "FIDO_HOME" not in os.environ:
            logging.debug("FIDO_HOME not set, can't do much . . .")
            return False
            
        fido_home_dir = os.path.realpath(os.environ["FIDO_HOME"])
        if not os.path.exists(fido_home_dir):
            logging.debug("FIDO_HOME directory not found, can't do much . . .")
            return False
            
        return True

    @classmethod
    def _get_passkey_files(cls, directory: str) -> typing.List[str]:
        """
        Returns a list of .passkey files in the specified directory.
        
        Args:
            directory: The directory to search for .passkey files
            
        Returns:
            A list of full paths to .passkey files
        """
        passkey_files = []
        for filename in os.listdir(directory):
            if filename.endswith('.passkey'):
                passkey_files.append(os.path.join(directory, filename))
            else:
                colour_print(
                    colour=bcolors.WARNING, 
                    component='Authenticator_validate_pin',
                    msg=f'{filename} has invalid file type'
                )
        return passkey_files

    @classmethod
    def _process_passkey_file(cls, passkey_file: str, pinHash: bytes, cid: bytes) -> typing.Optional[bytes]:
        """
        Attempts to decrypt and process a passkey file.
        
        Args:
            passkey_file: Path to the passkey file
            pinHash: The hash of the PIN to validate
            cid: The channel ID to associate with the opened keys
            
        Returns:
            A PIN authentication token if processing succeeds, None otherwise
            
        Raises:
            Various exceptions if file processing fails
        """
        # Load and decrypt the passkey file
        passkey = KeyUtils._load_passkey(pinHash, passkey_file)
        
        colour_print(
            colour=bcolors.OKPINK, 
            component='Authenticator_validate_pin',
            msg='Pin decrypted a .passkey file'
        )
        
        # Extract certificate and key pair
        ca_x5c = passkey.get('x5c')
        key = passkey.get('key')
        if not isinstance(key, ec.EllipticCurvePrivateKey):
            raise ValueError("Key must be an ECPrivateKey")
        key_pair = KeyPair(key, key.public_key())
        #seed = passkey.get('seed')
        
        # Reset PIN retry counter
        cls._pin_retry = 5
        
        # Generate authentication token
        pin_auth_token = secrets.token_bytes(32)
        
        # Store the opened keys
        cls._lock.acquire()
        try:
            cls._open_keys[cid] = {
                'x5c': ca_x5c,
                'kp': key_pair,
                'file': passkey_file,
                'ph': pinHash,
                'pinAuth': pin_auth_token,
                'tStart': time.time() # Get Unix timestamp to compare against for watchdog thread (expiry timer)
            }
            return pin_auth_token
        finally:
            cls._lock.release()


    @classmethod
    def get_pin_cose_key(cls, pin_req, cid):
        if cls._pin_token_kp == None:
            cls._pin_token_kp = KeyPair.generate_ecdsa()
        return {1: KeyUtils.get_cose_key(cls._pin_token_kp.get_public(), hashes.SHA256(), eckx=True)}

    @classmethod
    def get_pin_retries(cls, pin_req, cid):
        cls._pin_retry -= 1
        return {3: cls._pin_retry}

    @classmethod
    def decapsulate(cls, ecCoseKey):
        cose_type_to_curve_map = { #These are kind of made up, as per 
                    #https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-errata-20220621.html#pinProto1
                    -25: ec.SECP256R1,
                    -26: ec.SECP521R1
                }
        ec_pub_numbs = ec.EllipticCurvePublicNumbers(KeyUtils._bytes_to_long(ecCoseKey[-2]),
                            KeyUtils._bytes_to_long(ecCoseKey[-3]), 
                            cose_type_to_curve_map[ecCoseKey[3]]())
        pubkey = ec_pub_numbs.public_key()
        shared_point = cls._pin_token_kp.get_private().exchange(ec.ECDH(), pubkey) # type: ignore don't decapsulate without a key exchange
        hasher = hashes.Hash(hashes.SHA256())
        hasher.update(shared_point)
        return hasher.finalize()

    @classmethod
    def get_pin_token(cls, pin_req, cid):
        #https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-errata-20220621.html#getPinToken
        logging.debug(f"pin_req: {pin_req}")
        platform_cose_key = pin_req[3]
        pin_hash_enc = pin_req[6]
        colour_print(colour=bcolors.OKPINK, component='Authenticator.get_pin_token', 
                     msg='plat cose key: {}; pinHashEnc: {}'.format(platform_cose_key, pin_hash_enc))
        sharedSecret = cls.decapsulate(platform_cose_key)
        colour_print(colour=bcolors.OKPINK, component='Authenticator.get_pin_token',
                     msg='shared secret: {};'.format(sharedSecret))
        cipher = Cipher(algorithms.AES256(sharedSecret), modes.CBC(bytes([0] * 16))) # nosemgrep part of the CTAP2 spec
        decryptor = cipher.decryptor() # nosemgrep
        pin_hash = decryptor.update(pin_hash_enc) + decryptor.finalize()
        pinAuthToken = cls._validate_pin(pin_hash, cid)
        if pinAuthToken != None:
            encryptor = cipher.encryptor()
            pinAuthTokenEnc = encryptor.update(pinAuthToken) + encryptor.finalize()
            return {2: pinAuthTokenEnc}
        return None


    @classmethod
    def attestation_out(cls, clientDataHash, rp, user, pkCredsParams, excludeList, exts, cid):
        colour_print(colour=bcolors.OKPINK, component='Authenticator.attestation_out', 
                     msg='open keys: {}'.format(cls._open_keys))
        if not cid in cls._open_keys.keys():
            return CBORCommand.CBORStatusCode.CTAP2_ERR_OPERATION_DENIED, None, None
        passkey: dict = cls._open_keys[cid]
        ca_kp = passkey.get('kp')
        if not isinstance(ca_kp, KeyPair): # !panic
            colour_print(colour=bcolors.OKPINK, component='Authenticator.attestation_out', msg="panic!")
            return CBORCommand.CBORStatusCode.CTAP1_ERR_OTHER, None, None
        seed = KeyUtils.get_passkey_seed(rp['id'].encode(), ca_kp.get_private())
        #logging.debug(f"seed : {seed}")
        fkey = Fernet(seed)
        kp = KeyPair.generate_ecdsa()
        resCreds = KeyUtils._load_passkey(passkey['ph'], passkey['file']).get('res.creds') #
        #logging.debug(f"checking for existing registration for {rp} and {user} in {resCreds}")
        if resCreds: # Check for existing rpID:userID
            for cred in resCreds:
                if rp['id'] == cred['rp.id'] and user['id'] == cred['user.id']:
                    colour_print(colour=bcolors.FAIL, component='Authenticator.attestation_out',
                                 msg='existing rpID and userID found: {}, {}'.format(rp['id'], user['id']))
                    return CBORCommand.CBORStatusCode.CTAP2_ERR_CREDENTIAL_EXCLUDED, None, None
                #else:
                #    logging.debug(f"{rp['id']} != {cred['rp.id']} or {user['id']} != {cred['user.id']}")
        authenticator = Fido2Authenticator(keyPair=kp, caKeyPair=ca_kp, 
                                            caCert=passkey.get('x5c'), fKey=fkey)
        credId = authenticator._get_credential_id_bytes(kp)
        pk = {'rp': rp} # authenticator.py expects this structure
        authData = authenticator.build_authenticator_data(pk, 'packed', kp, True, up=True, be=False, bs=False)
        colour_print(colour=bcolors.OKPINK, component='Authenticator.attestation_out', 
                     msg='credId: {}; toSign: {}'.format(credId, 
                            base64.b64encode(bytes([*authData, *clientDataHash ])).decode()))
        attStmt = authenticator.build_packed_attestation_statement('packed', 
                                                    clientDataHash, authData, None, kp,)
        colour_print(colour=bcolors.OKPINK, component='Authenticator.attestation_out', 
                     msg='attStmt: {}'.format(attStmt))
        #Since we set UV we can make these resident keys
        KeyUtils.update_passkey({'cred.id': credId, 'user.id': user['id'], 'rp.id': rp['id']},
                                passkey['ph'], passkey['file'])
        return None, authData, attStmt


    @classmethod
    def _maybe_next_assertion(cls, rpId, ca_kp, ca_x5c, clientDataHash, cred): 
        seed = KeyUtils.get_passkey_seed(rpId.encode(), ca_kp.get_private())
        #logging.debug(f"seed : {seed}")
        fkey = Fernet(seed)
        #skey = SymmetricKey(seed.decode())
        #Create the authenticator from the raw id, creating the key pair will fail if we don't own the credId
        b64CredId = base64.urlsafe_b64encode(cred.get('id'))
        decryptedKp = Fido2Authenticator._get_key_pair_from_credential_id(b64CredId, fkey)
        colour_print(colour=bcolors.OKPINK, component='FIDO2Authenticator.assertion_outputs',
                        msg='We have a usable key, sign the challenge')
        _authenticator = Fido2Authenticator(keyPair=decryptedKp, credId=b64CredId, aaguid=[0] * 16, 
                                            caKeyPair=ca_kp, caCert=ca_x5c, fKey=fkey)
        credential = {
                "id": _authenticator._get_credential_id_bytes(_authenticator.kp), #Should be set by __init__()
                "type" : "public-key"
            }
        #Generate the assertion response data
        authData = _authenticator.build_authenticator_data({'rpId': rpId}, 'packed', _authenticator.kp,
                                                                 True, up=True, be=False, bs=False)
        #Sign it, authenticator method b64 encodeds the ba so we decode it right away
        sig = _authenticator.assertion_signature(authData, clientDataHash, _authenticator.kp)
        userHandle = cred.get("user")
        return None, credential, authData, sig, userHandle


    @classmethod
    def assertion_out(cls, rpId, clientDataHash, allowedList, exts, cid):
        if not cid in cls._open_keys.keys():
            return CBORCommand.CBORStatusCode.CTAP2_ERR_OPERATION_DENIED, None, None, None, None
        passkey: dict = cls._open_keys[cid]
        ca_x5c = passkey.get('x5c')
        ca_kp = passkey.get('kp')
        if not isinstance(ca_kp, KeyPair): # !panic
            return CBORCommand.CBORStatusCode.CTAP1_ERR_OTHER, None, None, None, None
        resCreds = KeyUtils._load_passkey(passkey['ph'], passkey['file']).get('res.creds')
        if resCreds != None:
            colour_print(colour=bcolors.OKPINK, component='FIDO2Authenticator.assertion_outputs',
                         msg='passkey has resident credentials, adding them to allowed list')
            for cred in resCreds:
                if cred.get('rp.id') == rpId:
                    allowedList += [{'id': cred.get('cred.id'), 
                                     'user': cred.get('user.id')}]
        for cred in allowedList:
            try:
                #logging.debug(f"Try {cred}")
                return cls._maybe_next_assertion(rpId, ca_kp, ca_x5c, clientDataHash, cred)
            except Exception as e:
                colour_print(colour=bcolors.FAIL, component='FIDO2Authenticator.assertion_outputs',
                             msg='Could not retrieve key pair from credential id {}'.format(cred))
                logging.exception(e, stack_info=True)
                continue
        return CBORCommand.CBORStatusCode.CTAP2_ERR_NO_CREDENTIALS, None, None, None, None

    @classmethod
    def quit(cls):
        cls._quit = True
        if cls._watchdog:
            cls._watchdog.join()

class CBORCommand(object):

    class CommandByte(Enum):
        MAKE_CREDENTIAL = 0x1
        GET_NEXT_ASSERTION = 0x2
        GET_INFO = 0x4
        CLIENT_PIN = 0x6
        RESET = 0x7
        CREDENTIAL_MANAGEMENT = 0x9
        AUTHENTICATOR_SELECTION = 0xB
        AUTHENTICATOR_CONFIG = 0xD

        def __repr__(self):
            return str(self.value)

    class CBORStatusCode(IntEnum):
        CTAP2_OK = 0x0
        CTAP1_ERR_INVALID_COMMAND = 0x01
        CTAP1_ERR_TIMEOUT = 0x05
        CTAP2_ERR_INVALID_CBOR = 0x12
        CTAP2_ERR_MISSING_PARAMETER = 0x14
        CTAP2_ERR_CREDENTIAL_EXCLUDED = 0x19
        CTAP2_ERR_OPERATION_DENIED = 0x27
        CTAP2_ERR_NO_CREDENTIALS = 0x2E
        CTAP2_ERR_PIN_INVALID = 0x31
        CTAP2_ERR_PIN_AUTH_INVALID = 0x33
        CTAP2_ERR_PUAT_REQUIRED = 0x36
        CTAP1_ERR_OTHER = 0x7F


    cid = 0xFFFFFFFF
    request = []
    response: list = []
    response_segment = 0
    response_ready = False
    length = 0
    request_segment = 0
    cmd = None
    ctaphid_cmd = 0
    bcnt = 0
    _pending = None

    def __init__(self, cid, ba, skip_init=False):
        self.cid = cid
        if ba == None and skip_init == True:
            return #Create an empty command as we will directly set the response buffer later with the assigned CID.
        if len(ba) <= 1:
            colour_print(colour=bcolors.OKYELLOW, component='CBORCommand.__init__', 
                    msg="Byte Array must be at least one byte long")
        # Length of the incoming CBOR message (total).
        self.length = int.from_bytes(ba[0:2], 'big') - 1 # subtract CMD byte
        # Request buffer. This stores the incoming CBOR message and grows until all segments have been received
        self.request = ba[3:]
        # Track then number of response segments transmitted, the number transmitted in the continue sequence packet
        # should always be one less than this number
        self.response_segment = 0
        # Track the number of request segments received
        self.request_segment = 0
        # Response buffer. This stores the outgoing response to the received CBOR message and shrinks until the entire
        # response has been transmitted
        self.response = []
        # Command received in CTAPHID frame, this is likely 0x90 (CBOR_MSG) but might be different
        self.ctaphid_cmd = 0
        # Authenticator API command byte received in initial packet
        self.cmd = self.CommandByte(int.from_bytes(ba[2:3], 'big'))
        #Length of the payload bytes
        self.bcnt = 0
        # Signal that the response buffer is ready to be sent back to the client
        self.response_ready = False
        colour_print(colour=bcolors.OKPURPLE, component='CBORCommand.__init__', 
                msg="command {}; length {}; self.request[{}]".format(self.cmd, self.length, len(self.request)))
        if self.length >= len(self.request):
            colour_print(colour=bcolors.OKPURPLE, component='CBORCommand.__init__', 
                    msg="request is segmented, wait for the whole message")
        else: #We have the whole message
            self.unpack()
            dump_bytes(self.response, colour=bcolors.OKPINK,
                       component='CBORCommand.__init__', msg='CTAP response')

    def append_segment(self, seg_buf):
        self.request += seg_buf
        if len(self.request) >= self.length:
            self.unpack()
            dump_bytes(self.response, colour=bcolors.OKPINK,
                       component='CBORCommand.append_segment', msg='CTAP response')
        else:
            self.request_segment += 1

    def _error(self, ba):
        self.response = list(self.CBORStatusCode.CTAP1_ERR_INVALID_COMMAND.to_bytes(1, 'big'))
        self.bcnt = 0
        self.response_ready = True

    #Return CBOR response if entire command has been received or None if still 
    #waiting for segments
    def unpack(self):
        if self.cmd == None:
            return self._error(None)
        return {
            self.CommandByte.MAKE_CREDENTIAL: self._make_cred,
            self.CommandByte.GET_NEXT_ASSERTION: self._get_assertion,
            self.CommandByte.GET_INFO: self._get_info,
            self.CommandByte.CLIENT_PIN: self._client_pin
            }.get(self.cmd, self._error)(bytes(self.request))

    def _set_rsp_fields(self, rsp=[]):
        self.response = rsp
        self.bcnt = len(rsp)
        self.response_ready = True

    def get_rsp_seg(self, num_bytes):
        if not isinstance(num_bytes, int):
            raise RuntimeError("panic!")
        self.response_segment += 1
        #sequence is offset by two to account for init pkt and zero index start for continue sequence
        seg_num = max(self.response_segment - 2, 0)
        colour_print(colour=bcolors.WARNING, component='CBORCommand.get_rsp_seg', 
                msg='self.response_segment = {}'.format(self.response_segment))
        colour_print(colour=bcolors.WARNING, component='CBORCommand.get_rsp_seg', 
                msg='self.response_segment - 2 = {}'.format(self.response_segment - 2))
        colour_print(colour=bcolors.WARNING, component='CBORCommand.get_rsp_seg', 
                msg='segment number = {}'.format(seg_num))
        seg = self.response
        if num_bytes >= len(self.response):
            self.response = []
        else:
            seg = self.response[:num_bytes]
            self.response = self.response[num_bytes:]
        return seg, seg_num

    @classmethod
    def set_pending(cls, pending):
        cls._pending = pending

    def gather_user_presence(self):
        if os.environ.get('SOFT_FIDO2_SKIP_UP', 'False').lower() in ['y', 'yes', '1', 'true', 't']:
            colour_print(colour=bcolors.WARNING, component='Authenticator.gather_user_presence', 
                    msg='Skipping user presence check')
            return True
        elif AuthenticatorAPI.has_cached_up(self.cid):
            return True

        start_time = time.time()
        MessageQueue.notify_auth.queue.clear()
        MessageQueue.notify_sysapp.put(QueueMessageType.USER_REQUEST)
        msg = None
        worker = KeepAliveWorker(self._pending, self.cid)
        worker.start()
        current_time = time.time()
        while not msg and current_time - start_time < 60:
            time.sleep(0.002)
            current_time = time.time()
            if MessageQueue.notify_auth.qsize() > 0:
                msg = MessageQueue.notify_auth.get()
        worker.interrupt()
        worker.join()
        if msg == QueueMessageType.USER_RESPONSE_ACCEPT:
            AuthenticatorAPI.cache_up(self.cid, True)
            return True
        return False

    def _verify_pin_token(self, clientDataHash, pinUvAuthParam):
        if pinUvAuthParam not in [None, b'']:
            pinAuth = AuthenticatorAPI.get_pin_auth_token(self.cid)
            # Verify token using client data hash
            h = hmac.HMAC(pinAuth, hashes.SHA256())
            h.update(clientDataHash)
            sig = h.finalize()
            if pinUvAuthParam != sig[:16]: # token not valid if the first 16 bytes of signature doesn't match pinUvAuthParam
                return False
            return True
        else:
            return False

    def _client_pin(self, ba):
        # https://fidoalliance.org/specs/fido-v2.2-rd-20230321/fido-client-to-authenticator-protocol-v2.2-rd-20230321.html#authenticatorClientPIN
        pin_sub_cmds = { 
                      1: AuthenticatorAPI.get_pin_retries,
                      2: AuthenticatorAPI.get_pin_cose_key,
            #SET_PIN = 0x3
            #CHANGE_PIN = 0x4
                      5: AuthenticatorAPI.get_pin_token
                }
        req_data = cbor.loads(ba)
        colour_print(colour=bcolors.OKGREEN, component='CBORCommand._client_pin',
                     msg='Packet request: {}'.format(req_data))
        sub_cmd = req_data[2]
        colour_print(colour=bcolors.OKGREEN, component='CBORCommand._client_pin',
                     msg='pin sub_cmd: {}'.format(sub_cmd))
        rsp = pin_sub_cmds[sub_cmd](req_data, self.cid)
        result = (self.CBORStatusCode.CTAP2_ERR_PIN_INVALID).to_bytes(1, 'big')
        if rsp != None:
            result = (self.CBORStatusCode.CTAP2_OK).to_bytes(1, 'big') + cbor.dumps(rsp)
        return self._set_rsp_fields(list(result))

    # authenticatorGetInfo takes no inputs so return immediately
    def _get_info(self, ba):
        result = {
            0x01: ["U2F_V2", "FIDO_2_0"],
            0x02: ['hmac-secret'],
            #0x03: b"\x13\x37\xF1\xD0" * 4,
            0x03: b"\x00" * 16,
            0x04: {'rk': True, 'up': True, 'plat': False, 'clientPin': True},
            0x05: 1200,
            0x06: [1]
        }
        result = bytes( (self.CBORStatusCode.CTAP2_OK).to_bytes(1, 'big') + cbor.dumps(result) )
        logging.debug(f"len: {len(result)}")
        return self._set_rsp_fields(list(result))

    def _make_cred(self, ba):
        # https://fidoalliance.org/specs/fido-v2.2-rd-20230321/fido-client-to-authenticator-protocol-v2.2-rd-20230321.html#authenticatorMakeCredential
        if self.gather_user_presence() == False:
            return self._set_rsp_fields( list((self.CBORStatusCode.CTAP2_ERR_OPERATION_DENIED).to_bytes(1, 'big')) )
        req = cbor.loads(ba)
        colour_print(colour=bcolors.FAIL, component='CBORCommand._make_cred',
                     msg='CBOR request {}'.format(req))
        for prop in [(0x01, 'clientDataHash'), (0x02, 'rp'), (0x03, 'user'), (0x04, 'pubkeyCredParams'), (0x08, 'pinAuth')]:
            if not prop[0] in req.keys():
                colour_print(colour=bcolors.FAIL, component='CBORCommand._make_cred',
                             msg='{} missing from request:\n{}'.format(prop[1], cbor.dumps(req)))
                logging.debug("Missing required property %s" % prop[1])
                return self._set_rsp_fields( list((self.CBORStatusCode.CTAP2_ERR_MISSING_PARAMETER).to_bytes(1, 'big')) )
        result = (self.CBORStatusCode.CTAP2_ERR_PUAT_REQUIRED).to_bytes(1, 'big')
        # Request and validate pin-auth
        if not self._verify_pin_token(req.get(0x01), req.get(0x08)):
            if self.cid in AuthenticatorAPI._open_keys:
                result = (self.CBORStatusCode.CTAP2_ERR_PIN_AUTH_INVALID).to_bytes(1, 'big')
            return self._set_rsp_fields(list(result))
        error, authData, attStmt = AuthenticatorAPI.attestation_out(req.get(0x01), req.get(0x02), req.get(0x03),
                                            req.get(0x04), req.get(0x05), req.get(0x06), self.cid)
        result = (self.CBORStatusCode.CTAP1_ERR_OTHER).to_bytes(1, 'big')
        if error:
            result = error.to_bytes(1, 'big')
        if authData and attStmt:
            rsp = {
                0x01: 'packed', #fmt
                0x02: authData,
                0x03: attStmt
            }
            result = (self.CBORStatusCode.CTAP2_OK).to_bytes(1, 'big') + cbor.dumps(rsp)
        return self._set_rsp_fields(list(result))


    def _get_assertion(self, ba):
        # https://fidoalliance.org/specs/fido-v2.2-rd-20230321/fido-client-to-authenticator-protocol-v2.2-rd-20230321.html#authenticatorGetAssertion
        if self.gather_user_presence() == False:
            return self._set_rsp_fields( list((self.CBORStatusCode.CTAP2_ERR_OPERATION_DENIED).to_bytes(1, 'big')) )
        req = cbor.loads(ba)
        colour_print(colour=bcolors.FAIL, component='CBORCommand._get_assertion',
                     msg='CBOR request {}'.format(req))
        for prop in [(0x01, 'rpId'), (0x02, 'clientDataHash'), (0x06, 'pinAuth')]:
            if not prop[0] in req:
                colour_print(colour=bcolors.FAIL, component='CBORCommand._make_cred',
                             msg='{} missing from request:\n{}'.format(prop[1], cbor.dumps(req)))
                logging.debug("Missing required property %s" % prop[1])
                return self._set_rsp_fields( list((self.CBORStatusCode.CTAP2_ERR_MISSING_PARAMETER).to_bytes(1, 'big')) )
        result = (self.CBORStatusCode.CTAP1_ERR_OTHER).to_bytes(1, 'big')
        # Request and validate pin-auth
        if not self._verify_pin_token(req.get(0x02), req.get(0x06)):
            if self.cid in AuthenticatorAPI._open_keys:
                result = (self.CBORStatusCode.CTAP2_ERR_PIN_AUTH_INVALID).to_bytes(1, 'big')
            return self._set_rsp_fields(list(result))
        error, credential, authData, signature, userHandle = AuthenticatorAPI.assertion_out(req.get(0x01), 
                                                req.get(0x02), req.get(0x03, []), req.get(0x04, {}), self.cid)
        if error:
            result = error.to_bytes(1, 'big')
        elif credential and authData and signature:
            rsp = {
                    0x01: credential,
                    0x02: authData,
                    0x03: signature
            }
            if userHandle:
                rsp[0x04] = {'id': userHandle}
            result = (self.CBORStatusCode.CTAP2_OK).to_bytes(1, 'big') + cbor.dumps(rsp)
        return self._set_rsp_fields(list(result))


# Send this for every new message frame. Response contains
class CTAPHIDInitPkt(BaseStructure):
    _fields_ = [
        ('cid', 'I'),
        ('cmd', 'B'),
        ('bcnt', 'H', 0)
    ]

    def __init__(self, **kwargs):
        self.base_pack_format = '>'
        if 'data' in kwargs:
            index = None
            for i, field in enumerate(self._fields_):
                if field[0] == 'data':
                    index = i
                    break
            if index == None:
                colour_print(colour=bcolors.OKGREEN, component='CTAPHIDInitPkt.__init__', msg='setting data field')
                self._fields_ += [('data', '%ds' % len(kwargs['data']))]
            else:
                colour_print(colour=bcolors.OKGREEN, component='CTAPHIDInitPkt.__init__', 
                             msg='data already exists as a field, updating it')
                self._fields_[index] = ('data', '%ds' % len(kwargs['data']))
            logging.debug(f"{self._fields_}")
        super().__init__(**kwargs)

class CTAPHIDSeqPkt(BaseStructure):

    _fields_ = [
        ('cid', 'I'),
        ('seq', 'B'),
    ]

    def __init__(self, **kwargs):
        self.base_pack_format = '>'
        #logging.debug(kwargs)
        if 'data' in kwargs:
            index = None
            for i, field in enumerate(self._fields_):
                if field[0] == 'data':
                    index = i
                    break
            if index == None:
                logging.debug("setting data field")
                colour_print(colour=bcolors.OKPINK, component='CTAPHIDSeqPkt.__init__', msg='setting data field')
                self._fields_ += [('data', '%ds' % len(kwargs['data']))]
            else:
                colour_print(colour=bcolors.OKPINK, component='CTAPHIDSeqPkt.__init__', 
                             msg='data already exists as a field, updating it')
                self._fields_[index] = ('data', '%ds' % len(kwargs['data']))
            logging.debug(f"{self._fields_}")
        super(CTAPHIDSeqPkt, self).__init__(**kwargs)


class KeepAliveWorker(threading.Thread):

    cid = b'0xFFFFFFFF'
    not_alive = False
    uhid = None

    def __init__(self, pending, cid):
        super().__init__()
        self.pending = pending
        self.cid = cid

    def run(self):
        while self.not_alive == False:
            time.sleep(0.1)
            colour_print(colour=bcolors.FAIL, component='KeepAliveWorker.reply_with_keepalive',
                            msg='Thread reached timeout of 100ms before response buffer was recieved . . . sending heartbeat response')
            #We need to reply with keep alive after 50ms this gives us some tolerance
            #Our status is always still processing
            rsp = CTAPHIDInitPkt(cid=int.from_bytes(self.cid, 'big'),
                                  cmd=0xBB,
                                  bcnt=0x01,
                                  data=b'\x02').pack()
            self.pending.put(rsp)

    def interrupt(self):
        self.not_alive = True


class CTAP2HIDevice(UserDevice):
    #This will contain the current set of channel id's and associated state
    cids = {}

    def __init__(self, devpath):
        super().__init__(devPath=devpath)
        self.start_time = datetime.datetime.now()
        CBORCommand.set_pending(self.pending)

    def _bytes_to_str(self, b):
        return ''.join("%02X" % x for x in b)


    def send_response_segment(self, cid, cbor_cmd):
        rsp_data = None
        if cbor_cmd.response_segment == 0: #We send the init pkt
            logging.debug(f"bcnt from init pkt: {cbor_cmd.bcnt}")
            data, _ = cbor_cmd.get_rsp_seg(57)
            logging.debug(f"bcnt: {cbor_cmd.bcnt}")
            rsp_data = CTAPHIDInitPkt(cid=int.from_bytes(cid, 'big'), 
                                    cmd=cbor_cmd.ctaphid_cmd,
                                    bcnt=cbor_cmd.bcnt,
                                    data=bytes(data)).pack()
        else: #We send the continue sequence pkt
            data, seq_num = cbor_cmd.get_rsp_seg(59)
            colour_print(colour=bcolors.WARNING, component='send_response_segment', 
                    msg='Sequence number {}'.format(seq_num))
            rsp_data = CTAPHIDSeqPkt(cid=int.from_bytes(cid, 'big'),
                                     seq=seq_num,
                                     data=bytes(data)).pack()
        colour_print(colour=bcolors.WARNING, component='send_response_segment', 
                msg='pad with {} 0 bytes'.format(MAX_DATA_FRAME - len(rsp_data)))
        rsp_data += b'\00' * (MAX_DATA_FRAME - len(rsp_data)) # pad the 64 byte frame with 0x00 if required
        dump_bytes(rsp_data, colour=bcolors.OKGREEN, component='CTAP2HIDevice.send_response_segment', 
                   msg='Packed response: ')
        self.pending.put(rsp_data)


    def send_response_segments(self, cid, cbor_cmd):
        while len(cbor_cmd.response) > 0:
            self.send_response_segment(cid, cbor_cmd)
            logging.debug(f"response left: {cbor_cmd}")


    def ctaphid_ping(self, usb_req):
        cid = usb_req.data[0:4]
        cborCmd = CBORCommand(cid, None, skip_init=True)
        cborCmd.ctaphid_cmd = 0x01
        cborCmd.response = list(b'U2F_V2')
        self.send_response_segment(cid, cborCmd)

    def ctaphid_msg(self, usb_req):

        class U2FCommand(Enum):
            U2F_VERSION = 0x0
            U2F_REGISTER = 0x1
            U2F_AUTHENTICATE = 0x2
            U2F_VER = 0x03

        #Only supporting extended length encoding, section 3.1.3
        #https://fidoalliance.org/specs/fido-u2f-v1.2-ps-20170411/fido-u2f-raw-message-formats-v1.2-ps-20170411.html
        cid = usb_req.data[0:4]
        if not cid in self.cids:
            colour_print(colour=bcolors.FAIL, component='CTAP2HIDevice.ctaphid_msg', 
                         msg='Unknown CID {}'.format(cid))
        cmd = usb_req.data[4:5]
        bcnt = usb_req.data[5:7]
        apdu = usb_req.data[7:]
        print("ctaphid_msg", int.from_bytes(cmd, 'big'))
        colour_print(colour=bcolors.OKGREEN, component='CTAP2HIDevice.ctaphid_msg', 
                    msg='cmd = {}; bcnt = {}; apdu = {}'.format(
                        self._bytes_to_str(cmd), self._bytes_to_str(bcnt), apdu))
        u2f_cla = apdu[:1]
        u2f_ins = apdu[1:2]
        u2f_p1 = apdu[2:3]
        u2f_p2 = apdu[3:4]
        u2f_lc = apdu[4:7]
        u2f_data = apdu[7:]
        colour_print(colour=bcolors.OKGREEN, component='CTAP2HIDevice.ctaphid_msg', 
                    msg='U2F Raw Message CLA = {}; INS = {}; P1 = {}; P2 = {}; Lc = {}'.format(
                        u2f_cla, u2f_ins, u2f_p1, u2f_p2, u2f_lc))
        if u2f_cla == b'\x00' and u2f_ins == b'\x03':
            #U2F_VERSION request, send the expected response
            cborCmd = CBORCommand(cid, None, skip_init=True)
            cborCmd.ctaphid_cmd = int.from_bytes(cmd, 'big')
            cborCmd.bcnt = 6
            cborCmd.response = list(b'U2F_V2')
            self.send_response_segment(cid, self.cids[cid]['cborCmd'])

    def ctaphid_init(self, usb_req):
        cid = usb_req.data[0:4]
        cmd = usb_req.data[4:5]
        bcnt = usb_req.data[5:7]
        nonce = usb_req.data[7:15]
        colour_print(colour=bcolors.OKGREEN, component='CTAP2HIDevice.ctaphid_init', 
                msg='Nonce {}'.format(self._bytes_to_str(nonce)))
        assignedCID = bytes([0, random.randint(0, 255), 0, random.randint(0, 255)])
        colour_print(colour=bcolors.OKGREEN, component='CTAP2HIDevice.ctaphid_init', 
                    msg='Assigning a new CID to {}'.format(self._bytes_to_str(assignedCID)))
        data = nonce + assignedCID
        # protocol == 2; major version == 5; minor version = 1; build version = 2; flags === CAPABILITY_WINK | CAPABILITY_CBOR | CAPABILITY_NMSG
        for i in [2, 5, 1, 2, 0x01 | 0x04 | 0x08]:
            data += i.to_bytes(1, 'big')
        dump_bytes(data, colour=bcolors.OKGREEN, component='CTAP2HIDevice.ctaphid_init', msg='Response data')
        data += b'\00' * (57 - len(data)) # 64 - 4 (CID) - 1 (cmd) - 2 (bcnt) - len of response
        initCmd = CBORCommand(cid, None, skip_init=True)
        self.cids[assignedCID] = { }
        initCmd.response = data
        initCmd.ctaphid_cmd = int.from_bytes(cmd, 'big')
        initCmd.bcnt = 17
        self.send_response_segment(cid, initCmd)

    def ctaphid_cbor(self, usb_req):
        cid = usb_req.data[0:4]
        colour_print(colour=bcolors.OKGREEN, component='CTAP2HIDevice.ctaphid_cbor', 
                    msg='CBOR message recieved on channel {}'.format(self._bytes_to_str(cid)))
        cmd = usb_req.data[4:5]
        bcnt = usb_req.data[5:7]
        ctap_cmd = usb_req.data[7:8]
        logging.debug(f"CBOR bcnt: {int.from_bytes(bcnt, 'big') - 1}")
        cbor_data = usb_req.data[8: 7 + int.from_bytes(bcnt, 'big')]
        colour_print(colour=bcolors.OKGREEN, component='CTAP2HIDevice.ctaphid_cbor', 
                     msg='CBOR msg frame cmd: {}; bcnt: {}'.format(self._bytes_to_str(ctap_cmd),
                                                                   self._bytes_to_str(bcnt)))
        dump_bytes(cbor_data, colour=bcolors.OKGREEN, component='CTAP2HIDevice.ctaphid_cbor', 
                    msg='CBOR encoded bytes: ')
        cbor_cmd = CBORCommand(cid, usb_req.data[5:MAX_DATA_FRAME])
        cbor_cmd.ctaphid_cmd = int.from_bytes(cmd, 'big')
        if cbor_cmd.response_ready == True: #We can respond immediatly
            dump_bytes(cbor_cmd.response, colour=bcolors.OKGREEN, 
                       component='CTAP2HIDevice.ctaphid_cbor', msg='CBOR response: ')
            return self.send_response_segments(cid, cbor_cmd)
        else:
            self.cids[cid]['cborCmd'] = cbor_cmd
            colour_print(colour=bcolors.OKYELLOW, component='CTAP2HIDevice.ctaphid_cbor', 
                         msg="Waiting for rest of command to arrive . . .")
            return

    def _ctap_ack(self, usb_req):
        cid = usb_req.data[0:4]
        rsp = CBORCommand(cid, None, skip_init=True)
        rsp.response = []
        #if cid in self.cids:
        #    self.cids[cid]['cborCmd'] = rsp
        self.send_response_segment(cid, rsp)

    def ctaphid_cancel(self, usb_req):
        return self._ctap_ack(usb_req)

    def ctaphid_keepalive(self, usb_req):
        return

    def ctaphid_wink(self, usb_req):
        return self._ctap_ack(usb_req)

    def ctaphid_error(self, usb_req):
        return self._ctap_ack(usb_req)

    def ctaphid_unknown(self, usb_req):
        colour_print(colour=bcolors.FAIL, component='CTAP2HIDevice.ctaphid_unknown', msg='Unkown request recieved')
        self._ctap_ack(usb_req)

    def _handle_incoming_cmd(self, cmd, usb_req):
        ctapCmd = int.from_bytes(cmd, 'big') & 0x7F
        colour_print(colour=bcolors.OKGREEN, component='CTAP2HIDevice._handle_incoming_cmd', 
                    msg='recieved command {}'.format(ctapCmd))
        return {
            1: self.ctaphid_ping,
            3: self.ctaphid_msg,
            6: self.ctaphid_init,
            8: self.ctaphid_wink,
            16: self.ctaphid_cbor,
            17: self.ctaphid_cancel,
            59: self.ctaphid_keepalive,
            63: self.ctaphid_error,
        }.get(ctapCmd, self.ctaphid_unknown)(usb_req)

    def _handle_incoming_sequence(self, cid, usb_req):
        seqNum = int.from_bytes(usb_req.data[4:5], 'big')
        context = self.cids.get(cid)
        if context != None:
            transaction = context.get("cborCmd")
            if transaction != None and seqNum == transaction.request_segment:
                transaction.append_segment(usb_req.data[5:MAX_DATA_FRAME])
                if transaction.response_ready == True:
                    del self.cids[cid]['cborCmd']
                    self.send_response_segments(cid, transaction)
                else:
                    colour_print(colour=bcolors.OKPURPLE, component='CTAP2HIDevice._handle_incoming_sequence', 
                                 msg='Sequence number [{}] not the last expected sequence'.format(seqNum))
            else:
                colour_print(colour=bcolors.FAIL, component='CTAP2HIDevice._handle_incoming_sequence', 
                             msg='Sequence number [{}] not the next expected sequence [{}]'.format(
                                 seqNum, transaction.request_segment))
        else:
            colour_print(colour=bcolors.FAIL, component='CTAP2HIDevice._handle_incoming_sequence', 
                         msg='CID not found in device context, don\'t know what to do')

    def process_output(self, event):
        ep = event.data[0]
        event.data = event.data[1:]
        cid = event.data[0:4]
        cmd = event.data[4:5]
        dump_bytes(event.data[:event.ev_len])

        colour_print(colour=bcolors.OKGREEN, component='CTAP2HIDevice._handle_incoming', 
                    msg='EP: {} CID: {}; CMD/SEQ {}; DATA: {}'.format(
                        ep, cid, cmd, self._bytes_to_str(event.data[:event.ev_len])))

        if(int.from_bytes(cmd, 'big') & 0x80) > 0:
            colour_print(colour=bcolors.FAIL, component='CTAP2HIDevice._handle_incoming', 
                        msg='bit 8 set we got a command msg')
            return self._handle_incoming_cmd(cmd, event)
        else:
            colour_print(colour=bcolors.OKPURPLE, component='CTAP2HIDevice._handle_incoming',
                         msg='Recieved a sequence segment, appending it to the current msg context')
            return self._handle_incoming_sequence(cid, event)

    def join(self, timeout=None):
        super().join()
        AuthenticatorAPI.quit()
