diff --git a/fido2/attestation/base.py b/fido2/attestation/base.py index 73bd865a..aee223b8 100644 --- a/fido2/attestation/base.py +++ b/fido2/attestation/base.py @@ -41,22 +41,24 @@ class InvalidAttestation(Exception): - pass + """Base exception for attestation-related errors.""" class InvalidData(InvalidAttestation): - pass + """Attestation contains invalid data.""" class InvalidSignature(InvalidAttestation): - pass + """The signature of the attestation could not be verified.""" class UntrustedAttestation(InvalidAttestation): - pass + """The CA of the attestation is not trusted.""" class UnsupportedType(InvalidAttestation): + """The attestation format is not supported.""" + def __init__(self, auth_data, fmt=None): super().__init__( f'Attestation format "{fmt}" is not supported' @@ -69,6 +71,8 @@ def __init__(self, auth_data, fmt=None): @unique class AttestationType(IntEnum): + """Supported attestation types.""" + BASIC = 1 SELF = 2 ATT_CA = 3 @@ -78,11 +82,15 @@ class AttestationType(IntEnum): @dataclass class AttestationResult: + """The result of verifying an attestation.""" + attestation_type: AttestationType trust_path: List[bytes] def catch_builtins(f): + """Utility decoractor to wrap common exceptions related to InvalidData.""" + @wraps(f) def inner(*args, **kwargs): try: @@ -129,6 +137,8 @@ def verify_x509_chain(chain: List[bytes]) -> None: class Attestation(abc.ABC): + """Implements verification of a specific attestation type.""" + @abc.abstractmethod def verify( self, @@ -143,6 +153,7 @@ def verify( @staticmethod def for_type(fmt: str) -> Type[Attestation]: + """Get an Attestation subclass type for the given format.""" for cls in Attestation.__subclasses__(): if getattr(cls, "FORMAT", None) == fmt: return cls diff --git a/fido2/cbor.py b/fido2/cbor.py index 9b0e59eb..7fb21ad1 100644 --- a/fido2/cbor.py +++ b/fido2/cbor.py @@ -29,6 +29,12 @@ """ Minimal CBOR implementation supporting a subset of functionality and types required for FIDO 2 CTAP. + +Use the :func:`encode`, :func:`decode` and :func:`decode_from` functions to encode +and decode objects to/from CBOR. + +DO NOT use the dump_x/load_x functions directly, these will be made private in +python-fido2 2.0. """ from __future__ import annotations @@ -40,6 +46,7 @@ CborType = Union[int, bool, str, bytes, Sequence[Any], Mapping[Any, Any]] +# TODO 2.0: Make dump_x/load_x functions private def dump_int(data: int, mt: int = 0) -> bytes: if data < 0: mt = 1 @@ -97,13 +104,6 @@ def dump_text(data: str) -> bytes: ] -def encode(data: CborType) -> bytes: - for k, v in _SERIALIZERS: - if isinstance(data, k): - return v(data) - raise ValueError(f"Unsupported value: {data!r}") - - def load_int(ai: int, data: bytes) -> Tuple[int, bytes]: if ai < 24: return ai, data @@ -167,12 +167,29 @@ def load_map(ai: int, data: bytes) -> Tuple[Mapping[CborType, CborType], bytes]: } +def encode(data: CborType) -> bytes: + """Encodes data to a CBOR byte string.""" + for k, v in _SERIALIZERS: + if isinstance(data, k): + return v(data) + raise ValueError(f"Unsupported value: {data!r}") + + def decode_from(data: bytes) -> Tuple[Any, bytes]: + """Decodes a CBOR-encoded value from the start of a byte string. + + Additional data after a valid CBOR object is returned as well. + + :return: The decoded object, and any remaining data.""" fb = data[0] return _DESERIALIZERS[fb >> 5](fb & 0b11111, data[1:]) def decode(data) -> CborType: + """Decodes data from a CBOR-encoded byte string. + + Also validates that no extra data follows the encoded object. + """ value, rest = decode_from(data) if rest != b"": raise ValueError("Extraneous data") diff --git a/fido2/client.py b/fido2/client.py index 57f1806a..ac57fd4a 100644 --- a/fido2/client.py +++ b/fido2/client.py @@ -80,8 +80,12 @@ class ClientError(Exception): + """Base error raised by clients.""" + @unique class ERR(IntEnum): + """Error codes for ClientError.""" + OTHER_ERROR = 1 BAD_REQUEST = 2 CONFIGURATION_UNSUPPORTED = 3 @@ -142,6 +146,8 @@ def _ctap2client_err(e, err_cls=ClientError): class PinRequiredError(ClientError): + """Raised when a call cannot be completed without providing PIN.""" + def __init__( self, code=ClientError.ERR.BAD_REQUEST, cause="PIN required but not provided" ): @@ -228,6 +234,8 @@ def get_response(self, index: int) -> AuthenticatorAssertionResponse: class WebAuthnClient(abc.ABC): + """Base class for a WebAuthn client, supporting registration and authentication.""" + @abc.abstractmethod def make_credential( self, diff --git a/fido2/ctap.py b/fido2/ctap.py index 9ef2f077..3e060be9 100644 --- a/fido2/ctap.py +++ b/fido2/ctap.py @@ -36,14 +36,18 @@ @unique class STATUS(IntEnum): + """Status code for CTAP keep-alive message.""" + PROCESSING = 1 UPNEEDED = 2 class CtapDevice(abc.ABC): """ - CTAP-capable device. Subclasses of this should implement call, as well as - list_devices, which should return a generator over discoverable devices. + CTAP-capable device. + + Subclasses of this should implement :func:`call`, as well as :func:`list_devices`, + which should return a generator over discoverable devices. """ @property @@ -57,7 +61,7 @@ def call( cmd: int, data: bytes = b"", event: Optional[Event] = None, - on_keepalive: Optional[Callable[[int], None]] = None, + on_keepalive: Optional[Callable[[STATUS], None]] = None, ) -> bytes: """Sends a command to the authenticator, and reads the response. @@ -87,7 +91,11 @@ def list_devices(cls) -> Iterator[CtapDevice]: class CtapError(Exception): + """Error returned from the Authenticator when a command fails.""" + class UNKNOWN_ERR(int): + """CTAP error status code that is not recognized.""" + name = "UNKNOWN_ERR" @property @@ -102,6 +110,11 @@ def __str__(self): @unique class ERR(IntEnum): + """CTAP status codes. + + https://fidoalliance.org/specs/fido-v2.1-rd-20201208/fido-client-to-authenticator-protocol-v2.1-rd-20201208.html#error-responses + """ + SUCCESS = 0x00 INVALID_COMMAND = 0x01 INVALID_PARAMETER = 0x02 diff --git a/fido2/hid/__init__.py b/fido2/hid/__init__.py index 9903e029..f1687c2a 100644 --- a/fido2/hid/__init__.py +++ b/fido2/hid/__init__.py @@ -164,7 +164,7 @@ def call( cmd: int, data: bytes = b"", event: Optional[Event] = None, - on_keepalive: Optional[Callable[[int], None]] = None, + on_keepalive: Optional[Callable[[STATUS], None]] = None, ) -> bytes: event = event or Event() @@ -218,13 +218,12 @@ def _do_call(self, cmd, data, event, on_keepalive): if r_cmd == TYPE_INIT | cmd: pass # first data packet elif r_cmd == TYPE_INIT | CTAPHID.KEEPALIVE: - ka_status = struct.unpack_from(">B", recv)[0] - logger.debug(f"Got keepalive status: {ka_status:02x}") + try: + ka_status = STATUS(struct.unpack_from(">B", recv)[0]) + logger.debug(f"Got keepalive status: {ka_status:02x}") + except ValueError: + raise ConnectionFailure("Invalid keepalive status") if on_keepalive and ka_status != last_ka: - try: - ka_status = STATUS(ka_status) - except ValueError: - pass # Unknown status value last_ka = ka_status on_keepalive(ka_status) continue diff --git a/fido2/mds3.py b/fido2/mds3.py index 0be9456a..1352c12f 100644 --- a/fido2/mds3.py +++ b/fido2/mds3.py @@ -142,6 +142,8 @@ class EcdaaTrustAnchor(_JsonDataObject): @unique class AuthenticatorStatus(str, Enum): + """Status of an Authenitcator.""" + NOT_FIDO_CERTIFIED = "NOT_FIDO_CERTIFIED" FIDO_CERTIFIED = "FIDO_CERTIFIED" USER_VERIFICATION_BYPASS = "USER_VERIFICATION_BYPASS" diff --git a/fido2/pcsc.py b/fido2/pcsc.py index 7ebb59ab..a151449f 100644 --- a/fido2/pcsc.py +++ b/fido2/pcsc.py @@ -189,7 +189,7 @@ def _call_cbor( self, data: bytes = b"", event: Optional[Event] = None, - on_keepalive: Optional[Callable[[int], None]] = None, + on_keepalive: Optional[Callable[[STATUS], None]] = None, ) -> bytes: event = event or Event() # NFCCTAP_MSG @@ -198,12 +198,8 @@ def _call_cbor( while not event.is_set(): while (sw1, sw2) == SW_UPDATE: - ka_status = resp[0] + ka_status = STATUS(resp[0]) if on_keepalive and last_ka != ka_status: - try: - ka_status = STATUS(ka_status) - except ValueError: - pass # Unknown status value last_ka = ka_status on_keepalive(ka_status) @@ -222,7 +218,7 @@ def call( cmd: int, data: bytes = b"", event: Optional[Event] = None, - on_keepalive: Optional[Callable[[int], None]] = None, + on_keepalive: Optional[Callable[[STATUS], None]] = None, ) -> bytes: if cmd == CTAPHID.CBOR: return self._call_cbor(data, event, on_keepalive) diff --git a/fido2/utils.py b/fido2/utils.py index 971e008e..eab0249a 100644 --- a/fido2/utils.py +++ b/fido2/utils.py @@ -174,7 +174,9 @@ def read(self, size: Optional[int] = -1) -> bytes: class _DataClassMapping(Mapping[_T, Any]): - # TODO: This requires Python 3.9, and fixes the tpye errors we now ignore + """A data class with members also accessible as a Mapping.""" + + # TODO: This requires Python 3.9, and fixes the type errors we now ignore # __dataclass_fields__: ClassVar[Dict[str, Field[Any]]] def __post_init__(self): @@ -299,6 +301,8 @@ def from_dict(cls, data): class _JsonDataObject(_DataClassMapping[str]): + """A data class with members also accessible as a JSON-serializable Mapping.""" + @classmethod def _get_field_key(cls, field: Field) -> str: name = field.metadata.get("name")