Source code for yubikit.core

# 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 enum import Enum, IntEnum, IntFlag, unique
from typing import (
    Type,
    List,
    Dict,
    Tuple,
    TypeVar,
    Union,
    Optional,
    Hashable,
    NamedTuple,
    Callable,
    ClassVar,
)
import re
import abc


_VERSION_STRING_PATTERN = re.compile(r"\b(?P<major>\d+).(?P<minor>\d).(?P<patch>\d)\b")


[docs] class Version(NamedTuple): """3-digit version tuple.""" major: int minor: int patch: int def __str__(self): return "%d.%d.%d" % self def __bool__(self): return any(self)
[docs] @classmethod def from_bytes(cls, data: bytes) -> "Version": return cls(*data)
[docs] @classmethod def from_string(cls, data: str) -> "Version": m = _VERSION_STRING_PATTERN.search(data) if m: return cls( int(m.group("major")), int(m.group("minor")), int(m.group("patch")) ) raise ValueError("No version found in string")
[docs] @unique class TRANSPORT(str, Enum): """YubiKey physical connection transports.""" USB = "usb" NFC = "nfc" def __str__(self): return super().__str__().upper()
[docs] @unique class USB_INTERFACE(IntFlag): """YubiKey USB interface identifiers.""" OTP = 0x01 FIDO = 0x02 CCID = 0x04
[docs] @unique class YUBIKEY(Enum): """YubiKey hardware platforms.""" YKS = "YubiKey Standard" NEO = "YubiKey NEO" SKY = "Security Key by Yubico" YKP = "YubiKey Plus" YK4 = "YubiKey" # This includes YubiKey 5
[docs] class Connection(abc.ABC): """A connection to a YubiKey""" usb_interface: ClassVar[USB_INTERFACE] = USB_INTERFACE(0)
[docs] def close(self) -> None: """Close the device, releasing any held resources."""
def __enter__(self): return self def __exit__(self, typ, value, traceback): self.close()
[docs] @unique class PID(IntEnum): """USB Product ID values for YubiKey devices.""" YKS_OTP = 0x0010 NEO_OTP = 0x0110 NEO_OTP_CCID = 0x0111 NEO_CCID = 0x0112 NEO_FIDO = 0x0113 NEO_OTP_FIDO = 0x0114 NEO_FIDO_CCID = 0x0115 NEO_OTP_FIDO_CCID = 0x0116 SKY_FIDO = 0x0120 YK4_OTP = 0x0401 YK4_FIDO = 0x0402 YK4_OTP_FIDO = 0x0403 YK4_CCID = 0x0404 YK4_OTP_CCID = 0x0405 YK4_FIDO_CCID = 0x0406 YK4_OTP_FIDO_CCID = 0x0407 YKP_OTP_FIDO = 0x0410 @property def yubikey_type(self) -> YUBIKEY: return YUBIKEY[self.name.split("_", 1)[0]] @property def usb_interfaces(self) -> USB_INTERFACE: return USB_INTERFACE(sum(USB_INTERFACE[x] for x in self.name.split("_")[1:]))
[docs] @classmethod def of(cls, key_type: YUBIKEY, interfaces: USB_INTERFACE) -> "PID": suffix = "_".join(t.name or str(t) for t in USB_INTERFACE if t in interfaces) return cls[key_type.name + "_" + suffix]
[docs] def supports_connection(self, connection_type: Type[Connection]) -> bool: return connection_type.usb_interface in self.usb_interfaces
T_Connection = TypeVar("T_Connection", bound=Connection)
[docs] class YubiKeyDevice(abc.ABC): """YubiKey device reference""" def __init__(self, transport: TRANSPORT, fingerprint: Hashable): self._transport = transport self._fingerprint = fingerprint @property def transport(self) -> TRANSPORT: """Get the transport used to communicate with this YubiKey""" return self._transport
[docs] def supports_connection(self, connection_type: Type[Connection]) -> bool: """Check if a YubiKeyDevice supports a specific Connection type""" return False
# mypy will not accept abstract types in Type[T_Connection]
[docs] def open_connection( self, connection_type: Union[Type[T_Connection], Callable[..., T_Connection]] ) -> T_Connection: """Opens a connection to the YubiKey""" raise ValueError("Unsupported Connection type")
@property def fingerprint(self) -> Hashable: """Used to identify that device references from different enumerations represent the same physical YubiKey. This fingerprint is not stable between sessions, or after un-plugging, and re-plugging a device.""" return self._fingerprint def __eq__(self, other): return isinstance(other, type(self)) and self.fingerprint == other.fingerprint def __hash__(self): return hash(self.fingerprint) def __repr__(self): return f"{type(self).__name__}(fingerprint={self.fingerprint!r})"
[docs] class CommandError(Exception): """An error response from a YubiKey"""
[docs] class BadResponseError(CommandError): """Invalid response data from the YubiKey"""
[docs] class TimeoutError(CommandError): """An operation timed out waiting for something"""
[docs] class ApplicationNotAvailableError(CommandError): """The application is either disabled or not supported on this YubiKey"""
[docs] class NotSupportedError(ValueError): """Attempting an action that is not supported on this YubiKey"""
[docs] class InvalidPinError(CommandError, ValueError): """An incorrect PIN/PUK was used, with the number of attempts now remaining. WARNING: This exception currently inherits from ValueError for backwards-compatibility reasons. This will no longer be the case with the next major version of the library. """ def __init__(self, attempts_remaining: int, message: Optional[str] = None): super().__init__(message or f"Invalid PIN/PUK, {attempts_remaining} remaining") self.attempts_remaining = attempts_remaining
[docs] def require_version( my_version: Version, min_version: Tuple[int, int, int], message=None ): """Ensure a version is at least min_version.""" # Skip version checks for major == 0, used for development builds. if my_version < min_version and my_version[0] != 0: if not message: message = "This action requires YubiKey %d.%d.%d or later" % min_version raise NotSupportedError(message)
[docs] def int2bytes(value: int, min_len: int = 0) -> bytes: buf = [] while value > 0xFF: buf.append(value & 0xFF) value >>= 8 buf.append(value) return bytes(reversed(buf)).rjust(min_len, b"\0")
[docs] def bytes2int(data: bytes) -> int: return int.from_bytes(data, "big")
def _tlv_parse(data, offset=0): try: tag = data[offset] offset += 1 if tag & 0x1F == 0x1F: # Long form tag = tag << 8 | data[offset] offset += 1 while tag & 0x80 == 0x80: # Additional bytes tag = tag << 8 | data[offset] offset += 1 ln = data[offset] offset += 1 if ln == 0x80: # Indefinite length end = offset while data[end] or data[end + 1]: # Run until 0x0000 end = _tlv_parse(data, end)[3] # Skip over TLV ln = end - offset end += 2 # End after 0x0000 else: if ln > 0x80: # Length spans multiple bytes n_bytes = ln - 0x80 ln = bytes2int(data[offset : offset + n_bytes]) offset += n_bytes end = offset + ln return tag, offset, ln, end except IndexError: raise ValueError("Invalid encoding of tag/length") T_Tlv = TypeVar("T_Tlv", bound="Tlv")
[docs] class Tlv(bytes): @property def tag(self) -> int: return self._tag @property def length(self) -> int: return self._value_ln @property def value(self) -> bytes: return self[self._value_offset : self._value_offset + self._value_ln] def __new__(cls, tag_or_data: Union[int, bytes], value: Optional[bytes] = None): """This allows creation by passing either binary data, or tag and value.""" if isinstance(tag_or_data, int): # Tag and (optional) value tag = tag_or_data # Pack into Tlv buf = bytearray() buf.extend(int2bytes(tag)) value = value or b"" length = len(value) if length < 0x80: buf.append(length) else: ln_bytes = int2bytes(length) buf.append(0x80 | len(ln_bytes)) buf.extend(ln_bytes) buf.extend(value) data = bytes(buf) else: # Binary TLV data if value is not None: raise ValueError("value can only be provided if tag_or_data is a tag") data = tag_or_data # mypy thinks this is wrong return super(Tlv, cls).__new__(cls, data) # type: ignore def __init__(self, tag_or_data: Union[int, bytes], value: Optional[bytes] = None): self._tag, self._value_offset, self._value_ln, end = _tlv_parse(self) if len(self) != end: raise ValueError("Incorrect TLV length") def __repr__(self): return f"Tlv(tag=0x{self.tag:02x}, value={self.value.hex()})"
[docs] @classmethod def parse_from(cls: Type[T_Tlv], data: bytes) -> Tuple[T_Tlv, bytes]: tag, offs, ln, end = _tlv_parse(data) return cls(data[:end]), data[end:]
[docs] @classmethod def parse_list(cls: Type[T_Tlv], data: bytes) -> List[T_Tlv]: res = [] while data: tlv, data = cls.parse_from(data) res.append(tlv) return res
[docs] @classmethod def parse_dict(cls: Type[T_Tlv], data: bytes) -> Dict[int, bytes]: return dict((tlv.tag, tlv.value) for tlv in cls.parse_list(data))
[docs] @classmethod def unpack(cls: Type[T_Tlv], tag: int, data: bytes) -> bytes: tlv = cls(data) if tlv.tag != tag: raise ValueError(f"Wrong tag, got 0x{tlv.tag:02x} expected 0x{tag:02x}") return tlv.value