Source code for fido2.ctap2.bio

# 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 annotations

from .. import cbor
from ..ctap import CtapError
from .base import Ctap2, Info
from .pin import PinProtocol

from enum import IntEnum, unique
from threading import Event
from typing import Optional, Callable, Mapping, Any, Tuple, Dict
import struct
import logging

logger = logging.getLogger(__name__)


[docs] class BioEnrollment:
[docs] @unique class RESULT(IntEnum): MODALITY = 0x01 FINGERPRINT_KIND = 0x02 MAX_SAMPLES_REQUIRED = 0x03 TEMPLATE_ID = 0x04 LAST_SAMPLE_STATUS = 0x05 REMAINING_SAMPLES = 0x06 TEMPLATE_INFOS = 0x07
[docs] @unique class TEMPLATE_INFO(IntEnum): ID = 0x01 NAME = 0x02
[docs] @unique class MODALITY(IntEnum): FINGERPRINT = 0x01
[docs] @staticmethod def is_supported(info: Info) -> bool: if "bioEnroll" in info.options: return True # We also support the Prototype command if ( "FIDO_2_1_PRE" in info.versions and "userVerificationMgmtPreview" in info.options ): return True return False
def __init__(self, ctap: Ctap2, modality: MODALITY): if not self.is_supported(ctap.info): raise ValueError("Authenticator does not support BioEnroll") self.ctap = ctap self.modality = self.get_modality() if modality != self.modality: raise ValueError(f"Device does not support {modality:s}")
[docs] def get_modality(self) -> int: """Get bio modality. :return: The type of modality supported by the authenticator. """ return self.ctap.bio_enrollment(get_modality=True)[ BioEnrollment.RESULT.MODALITY ]
[docs] class CaptureError(Exception): def __init__(self, code: int): self.code = code super().__init__(f"Fingerprint capture error: {code}")
[docs] class FPEnrollmentContext: """Helper object to perform fingerprint enrollment. :param bio: An instance of FPBioEnrollment. :param timeout: Optional timeout for fingerprint captures (ms). :ivar remaining: The number of (estimated) remaining samples needed. :ivar template_id: The ID of the new template (only available after the initial sample has been captured). """ def __init__(self, bio: "FPBioEnrollment", timeout: Optional[int] = None): self._bio = bio self.timeout = timeout self.template_id: Optional[bytes] = None self.remaining: Optional[int] = None
[docs] def capture( self, event: Optional[Event] = None, on_keepalive: Optional[Callable[[int], None]] = None, ) -> Optional[bytes]: """Capture a fingerprint sample. This call will block for up to timeout milliseconds (or indefinitely, if timeout not specified) waiting for the user to scan their fingerprint to collect one sample. :return: None, if more samples are needed, or the template ID if enrollment is completed. """ if self.template_id is None: self.template_id, status, self.remaining = self._bio.enroll_begin( self.timeout, event, on_keepalive ) else: status, self.remaining = self._bio.enroll_capture_next( self.template_id, self.timeout, event, on_keepalive ) if status != FPBioEnrollment.FEEDBACK.FP_GOOD: raise CaptureError(status) if self.remaining == 0: return self.template_id return None
[docs] def cancel(self) -> None: """Cancels ongoing enrollment.""" self._bio.enroll_cancel() self.template_id = None
[docs] class FPBioEnrollment(BioEnrollment): """Implementation of a draft specification of the bio enrollment API. WARNING: This specification is not final and this class is likely to change. NOTE: The get_fingerprint_sensor_info method does not require authentication, and can be used by setting pin_uv_protocol and pin_uv_token to None. :param ctap: An instance of a CTAP2 object. :param pin_uv_protocol: The PIN/UV protocol version used. :param pin_uv_token: A valid PIN/UV Auth Token for the current CTAP session. """
[docs] @unique class CMD(IntEnum): ENROLL_BEGIN = 0x01 ENROLL_CAPTURE_NEXT = 0x02 ENROLL_CANCEL = 0x03 ENUMERATE_ENROLLMENTS = 0x04 SET_NAME = 0x05 REMOVE_ENROLLMENT = 0x06 GET_SENSOR_INFO = 0x07
[docs] @unique class PARAM(IntEnum): TEMPLATE_ID = 0x01 TEMPLATE_NAME = 0x02 TIMEOUT_MS = 0x03
[docs] @unique class FEEDBACK(IntEnum): FP_GOOD = 0x00 FP_TOO_HIGH = 0x01 FP_TOO_LOW = 0x02 FP_TOO_LEFT = 0x03 FP_TOO_RIGHT = 0x04 FP_TOO_FAST = 0x05 FP_TOO_SLOW = 0x06 FP_POOR_QUALITY = 0x07 FP_TOO_SKEWED = 0x08 FP_TOO_SHORT = 0x09 FP_MERGE_FAILURE = 0x0A FP_EXISTS = 0x0B FP_DATABASE_FULL = 0x0C NO_USER_ACTIVITY = 0x0D NO_UP_TRANSITION = 0x0E
[docs] def __str__(self): return f"0x{self.value:02X} - {self.name}"
def __init__(self, ctap: Ctap2, pin_uv_protocol: PinProtocol, pin_uv_token: bytes): super().__init__(ctap, BioEnrollment.MODALITY.FINGERPRINT) self.pin_uv_protocol = pin_uv_protocol self.pin_uv_token = pin_uv_token def _call(self, sub_cmd, params=None, auth=True, event=None, on_keepalive=None): kwargs = { "modality": self.modality, "sub_cmd": sub_cmd, "sub_cmd_params": params, "event": event, "on_keepalive": on_keepalive, } if auth: msg = struct.pack(">BB", self.modality, sub_cmd) if params is not None: msg += cbor.encode(params) kwargs["pin_uv_protocol"] = self.pin_uv_protocol.VERSION kwargs["pin_uv_param"] = self.pin_uv_protocol.authenticate( self.pin_uv_token, msg ) return self.ctap.bio_enrollment(**kwargs)
[docs] def get_fingerprint_sensor_info(self) -> Mapping[int, Any]: """Get fingerprint sensor info. :return: A dict containing FINGERPRINT_KIND and MAX_SAMPLES_REQUIRES. """ return self._call(FPBioEnrollment.CMD.GET_SENSOR_INFO, auth=False)
[docs] def enroll_begin( self, timeout: Optional[int] = None, event: Optional[Event] = None, on_keepalive: Optional[Callable[[int], None]] = None, ) -> Tuple[bytes, FPBioEnrollment.FEEDBACK, int]: """Start fingerprint enrollment. Starts the process of enrolling a new fingerprint, and will wait for the user to scan their fingerprint once to provide an initial sample. :param timeout: Optional timeout in milliseconds. :return: A tuple containing the new template ID, the sample status, and the number of samples remaining to complete the enrollment. """ logger.debug(f"Starting fingerprint enrollment (timeout={timeout})") result = self._call( FPBioEnrollment.CMD.ENROLL_BEGIN, ( {FPBioEnrollment.PARAM.TIMEOUT_MS: timeout} if timeout is not None else None ), event=event, on_keepalive=on_keepalive, ) logger.debug(f"Sample capture result: {result}") return ( result[BioEnrollment.RESULT.TEMPLATE_ID], FPBioEnrollment.FEEDBACK(result[BioEnrollment.RESULT.LAST_SAMPLE_STATUS]), result[BioEnrollment.RESULT.REMAINING_SAMPLES], )
[docs] def enroll_capture_next( self, template_id: bytes, timeout: Optional[int] = None, event: Optional[Event] = None, on_keepalive: Optional[Callable[[int], None]] = None, ) -> Tuple[FPBioEnrollment.FEEDBACK, int]: """Continue fingerprint enrollment. Continues enrolling a new fingerprint and will wait for the user to scan their fingerpring once to provide a new sample. Once the number of samples remaining is 0, the enrollment is completed. :param template_id: The template ID returned by a call to `enroll_begin`. :param timeout: Optional timeout in milliseconds. :return: A tuple containing the sample status, and the number of samples remaining to complete the enrollment. """ logger.debug(f"Capturing next sample with (timeout={timeout})") params: Dict[int, Any] = {FPBioEnrollment.PARAM.TEMPLATE_ID: template_id} if timeout is not None: params[FPBioEnrollment.PARAM.TIMEOUT_MS] = timeout result = self._call( FPBioEnrollment.CMD.ENROLL_CAPTURE_NEXT, params, event=event, on_keepalive=on_keepalive, ) logger.debug(f"Sample capture result: {result}") return ( FPBioEnrollment.FEEDBACK(result[BioEnrollment.RESULT.LAST_SAMPLE_STATUS]), result[BioEnrollment.RESULT.REMAINING_SAMPLES], )
[docs] def enroll_cancel(self) -> None: """Cancel any ongoing fingerprint enrollment.""" logger.debug("Cancelling fingerprint enrollment.") self._call(FPBioEnrollment.CMD.ENROLL_CANCEL, auth=False)
[docs] def enroll(self, timeout: Optional[int] = None) -> FPEnrollmentContext: """Convenience wrapper for doing fingerprint enrollment. See FPEnrollmentContext for details. :return: An initialized FPEnrollmentContext. """ return FPEnrollmentContext(self, timeout)
[docs] def enumerate_enrollments(self) -> Mapping[bytes, Optional[str]]: """Get a dict of enrolled fingerprint templates which maps template ID's to their friendly names. :return: A dict of enrolled template_id -> name pairs. """ try: return { t[BioEnrollment.TEMPLATE_INFO.ID]: t[BioEnrollment.TEMPLATE_INFO.NAME] for t in self._call(FPBioEnrollment.CMD.ENUMERATE_ENROLLMENTS)[ BioEnrollment.RESULT.TEMPLATE_INFOS ] } except CtapError as e: if e.code == CtapError.ERR.INVALID_OPTION: return {} raise
[docs] def set_name(self, template_id: bytes, name: str) -> None: """Set/Change the friendly name of a previously enrolled fingerprint template. :param template_id: The ID of the template to change. :param name: A friendly name to give the template. """ logger.debug(f"Changing name of template: {template_id.hex()} to {name}") self._call( FPBioEnrollment.CMD.SET_NAME, { BioEnrollment.TEMPLATE_INFO.ID: template_id, BioEnrollment.TEMPLATE_INFO.NAME: name, }, ) logger.info("Fingerprint template renamed")
[docs] def remove_enrollment(self, template_id: bytes) -> None: """Remove a previously enrolled fingerprint template. :param template_id: The Id of the template to remove. """ logger.debug(f"Deleting template: {template_id.hex()}") self._call( FPBioEnrollment.CMD.REMOVE_ENROLLMENT, {BioEnrollment.TEMPLATE_INFO.ID: template_id}, ) logger.info("Fingerprint template deleted")