1# Copyright (c) 2020 Yubico AB
2# All rights reserved.
3#
4#   Redistribution and use in source and binary forms, with or
5#   without modification, are permitted provided that the following
6#   conditions are met:
7#
8#    1. Redistributions of source code must retain the above copyright
9#       notice, this list of conditions and the following disclaimer.
10#    2. Redistributions in binary form must reproduce the above
11#       copyright notice, this list of conditions and the following
12#       disclaimer in the documentation and/or other materials provided
13#       with the distribution.
14#
15# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
16# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
17# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
18# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
19# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
20# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
21# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
22# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
23# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
24# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
25# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
26# POSSIBILITY OF SUCH DAMAGE.
27
28from .core import (
29    bytes2int,
30    int2bytes,
31    require_version,
32    Version,
33    Tlv,
34    AID,
35    TRANSPORT,
36    NotSupportedError,
37    BadResponseError,
38    ApplicationNotAvailableError,
39)
40from .core.otp import (
41    check_crc,
42    OtpConnection,
43    OtpProtocol,
44    STATUS_OFFSET_PROG_SEQ,
45    CommandRejectedError,
46)
47from .core.fido import FidoConnection
48from .core.smartcard import SmartCardConnection, SmartCardProtocol
49from fido2.hid import CAPABILITY as CTAP_CAPABILITY
50
51from enum import IntEnum, IntFlag, unique
52from dataclasses import dataclass
53from typing import Optional, Union, Mapping
54import abc
55import struct
56
57
58@unique
59class CAPABILITY(IntFlag):
60    """YubiKey Application identifiers."""
61
62    OTP = 0x01
63    U2F = 0x02
64    FIDO2 = 0x200
65    OATH = 0x20
66    PIV = 0x10
67    OPENPGP = 0x08
68    HSMAUTH = 0x100
69
70    def __str__(self):
71        if self == CAPABILITY.U2F:
72            return "FIDO U2F"
73        elif self == CAPABILITY.OPENPGP:
74            return "OpenPGP"
75        elif self == CAPABILITY.HSMAUTH:
76            return "YubiHSM Auth"
77        else:
78            return getattr(self, "name", super().__str__())
79
80
81@unique
82class USB_INTERFACE(IntFlag):
83    """YubiKey USB interface identifiers."""
84
85    OTP = 0x01
86    FIDO = 0x02
87    CCID = 0x04
88
89    def supports_connection(self, connection_type) -> bool:
90        if issubclass(connection_type, SmartCardConnection):
91            return USB_INTERFACE.CCID in self
92        if issubclass(connection_type, FidoConnection):
93            return USB_INTERFACE.FIDO in self
94        if issubclass(connection_type, OtpConnection):
95            return USB_INTERFACE.OTP in self
96        return False
97
98    @staticmethod
99    def for_capabilities(capabilities: CAPABILITY) -> "USB_INTERFACE":
100        ifaces = USB_INTERFACE(0)
101        if capabilities & CAPABILITY.OTP:
102            ifaces |= USB_INTERFACE.OTP
103        if capabilities & (CAPABILITY.U2F | CAPABILITY.FIDO2):
104            ifaces |= USB_INTERFACE.FIDO
105        if capabilities & (
106            CAPABILITY.OATH | CAPABILITY.PIV | CAPABILITY.OPENPGP | CAPABILITY.HSMAUTH
107        ):
108            ifaces |= USB_INTERFACE.CCID
109        return ifaces
110
111
112@unique
113class FORM_FACTOR(IntEnum):
114    """YubiKey device form factors."""
115
116    UNKNOWN = 0x00
117    USB_A_KEYCHAIN = 0x01
118    USB_A_NANO = 0x02
119    USB_C_KEYCHAIN = 0x03
120    USB_C_NANO = 0x04
121    USB_C_LIGHTNING = 0x05
122    USB_A_BIO = 0x06
123    USB_C_BIO = 0x07
124
125    def __str__(self):
126        if self == FORM_FACTOR.USB_A_KEYCHAIN:
127            return "Keychain (USB-A)"
128        elif self == FORM_FACTOR.USB_A_NANO:
129            return "Nano (USB-A)"
130        elif self == FORM_FACTOR.USB_C_KEYCHAIN:
131            return "Keychain (USB-C)"
132        elif self == FORM_FACTOR.USB_C_NANO:
133            return "Nano (USB-C)"
134        elif self == FORM_FACTOR.USB_C_LIGHTNING:
135            return "Keychain (USB-C, Lightning)"
136        elif self == FORM_FACTOR.USB_A_BIO:
137            return "Bio (USB-A)"
138        elif self == FORM_FACTOR.USB_C_BIO:
139            return "Bio (USB-C)"
140        else:
141            return "Unknown"
142
143    @classmethod
144    def from_code(cls, code: int) -> "FORM_FACTOR":
145        if code and not isinstance(code, int):
146            raise ValueError(f"Invalid form factor code: {code}")
147        code &= 0xF
148        return cls(code) if code in cls.__members__.values() else cls.UNKNOWN
149
150
151@unique
152class DEVICE_FLAG(IntFlag):
153    """Configuration flags."""
154
155    REMOTE_WAKEUP = 0x40
156    EJECT = 0x80
157
158
159TAG_USB_SUPPORTED = 0x01
160TAG_SERIAL = 0x02
161TAG_USB_ENABLED = 0x03
162TAG_FORM_FACTOR = 0x04
163TAG_VERSION = 0x05
164TAG_AUTO_EJECT_TIMEOUT = 0x06
165TAG_CHALRESP_TIMEOUT = 0x07
166TAG_DEVICE_FLAGS = 0x08
167TAG_APP_VERSIONS = 0x09
168TAG_CONFIG_LOCK = 0x0A
169TAG_UNLOCK = 0x0B
170TAG_REBOOT = 0x0C
171TAG_NFC_SUPPORTED = 0x0D
172TAG_NFC_ENABLED = 0x0E
173
174
175@dataclass
176class DeviceConfig:
177    """Management settings for YubiKey which can be configured by the user."""
178
179    enabled_capabilities: Mapping[TRANSPORT, CAPABILITY]
180    auto_eject_timeout: Optional[int]
181    challenge_response_timeout: Optional[int]
182    device_flags: Optional[DEVICE_FLAG]
183
184    def get_bytes(
185        self,
186        reboot: bool,
187        cur_lock_code: Optional[bytes] = None,
188        new_lock_code: Optional[bytes] = None,
189    ) -> bytes:
190        buf = b""
191        if reboot:
192            buf += Tlv(TAG_REBOOT)
193        if cur_lock_code:
194            buf += Tlv(TAG_UNLOCK, cur_lock_code)
195        usb_enabled = self.enabled_capabilities.get(TRANSPORT.USB)
196        if usb_enabled is not None:
197            buf += Tlv(TAG_USB_ENABLED, int2bytes(usb_enabled, 2))
198        nfc_enabled = self.enabled_capabilities.get(TRANSPORT.NFC)
199        if nfc_enabled is not None:
200            buf += Tlv(TAG_NFC_ENABLED, int2bytes(nfc_enabled, 2))
201        if self.auto_eject_timeout is not None:
202            buf += Tlv(TAG_AUTO_EJECT_TIMEOUT, int2bytes(self.auto_eject_timeout, 2))
203        if self.challenge_response_timeout is not None:
204            buf += Tlv(TAG_CHALRESP_TIMEOUT, int2bytes(self.challenge_response_timeout))
205        if self.device_flags is not None:
206            buf += Tlv(TAG_DEVICE_FLAGS, int2bytes(self.device_flags))
207        if new_lock_code:
208            buf += Tlv(TAG_CONFIG_LOCK, new_lock_code)
209        if len(buf) > 0xFF:
210            raise NotSupportedError("DeviceConfiguration too large")
211        return int2bytes(len(buf)) + buf
212
213
214@dataclass
215class DeviceInfo:
216    """Information about a YubiKey readable using the ManagementSession."""
217
218    config: DeviceConfig
219    serial: Optional[int]
220    version: Version
221    form_factor: FORM_FACTOR
222    supported_capabilities: Mapping[TRANSPORT, CAPABILITY]
223    is_locked: bool
224    is_fips: bool = False
225    is_sky: bool = False
226
227    def has_transport(self, transport: TRANSPORT) -> bool:
228        return transport in self.supported_capabilities
229
230    @classmethod
231    def parse(cls, encoded: bytes, default_version: Version) -> "DeviceInfo":
232        if len(encoded) - 1 != encoded[0]:
233            raise BadResponseError("Invalid length")
234        data = Tlv.parse_dict(encoded[1:])
235        locked = data.get(TAG_CONFIG_LOCK) == b"\1"
236        serial = bytes2int(data.get(TAG_SERIAL, b"\0")) or None
237        ff_value = bytes2int(data.get(TAG_FORM_FACTOR, b"\0"))
238        form_factor = FORM_FACTOR.from_code(ff_value)
239        fips = bool(ff_value & 0x80)
240        sky = bool(ff_value & 0x40)
241        if TAG_VERSION in data:
242            version = Version.from_bytes(data[TAG_VERSION])
243        else:
244            version = default_version
245        auto_eject_to = bytes2int(data.get(TAG_AUTO_EJECT_TIMEOUT, b"\0"))
246        chal_resp_to = bytes2int(data.get(TAG_CHALRESP_TIMEOUT, b"\0"))
247        flags = DEVICE_FLAG(bytes2int(data.get(TAG_DEVICE_FLAGS, b"\0")))
248
249        supported = {}
250        enabled = {}
251
252        if version == (4, 2, 4):  # Doesn't report correctly
253            supported[TRANSPORT.USB] = CAPABILITY(0x3F)
254        else:
255            supported[TRANSPORT.USB] = CAPABILITY(bytes2int(data[TAG_USB_SUPPORTED]))
256        if TAG_USB_ENABLED in data:  # From YK 5.0.0
257            if not ((4, 0, 0) <= version < (5, 0, 0)):  # Broken on YK4
258                enabled[TRANSPORT.USB] = CAPABILITY(bytes2int(data[TAG_USB_ENABLED]))
259        if TAG_NFC_SUPPORTED in data:  # YK with NFC
260            supported[TRANSPORT.NFC] = CAPABILITY(bytes2int(data[TAG_NFC_SUPPORTED]))
261            enabled[TRANSPORT.NFC] = CAPABILITY(bytes2int(data[TAG_NFC_ENABLED]))
262
263        return cls(
264            DeviceConfig(enabled, auto_eject_to, chal_resp_to, flags),
265            serial,
266            version,
267            form_factor,
268            supported,
269            locked,
270            fips,
271            sky,
272        )
273
274
275_MODES = [
276    USB_INTERFACE.OTP,  # 0x00
277    USB_INTERFACE.CCID,  # 0x01
278    USB_INTERFACE.OTP | USB_INTERFACE.CCID,  # 0x02
279    USB_INTERFACE.FIDO,  # 0x03
280    USB_INTERFACE.OTP | USB_INTERFACE.FIDO,  # 0x04
281    USB_INTERFACE.FIDO | USB_INTERFACE.CCID,  # 0x05
282    USB_INTERFACE.OTP | USB_INTERFACE.FIDO | USB_INTERFACE.CCID,  # 0x06
283]
284
285
286@dataclass(init=False, repr=False)
287class Mode:
288    """YubiKey USB Mode configuration for use with YubiKey NEO and 4."""
289
290    code: int
291    interfaces: USB_INTERFACE
292
293    def __init__(self, interfaces: USB_INTERFACE):
294        try:
295            self.code = _MODES.index(interfaces)
296            self.interfaces = USB_INTERFACE(interfaces)
297        except ValueError:
298            raise ValueError("Invalid mode!")
299
300    def __repr__(self):
301        return "+".join(t.name for t in USB_INTERFACE if t in self.interfaces)
302
303    @classmethod
304    def from_code(cls, code: int) -> "Mode":
305        code = code & 0b00000111
306        return cls(_MODES[code])
307
308
309SLOT_DEVICE_CONFIG = 0x11
310SLOT_YK4_CAPABILITIES = 0x13
311SLOT_YK4_SET_DEVICE_INFO = 0x15
312
313
314class _Backend(abc.ABC):
315    version: Version
316
317    @abc.abstractmethod
318    def close(self) -> None:
319        ...
320
321    @abc.abstractmethod
322    def set_mode(self, data: bytes) -> None:
323        ...
324
325    @abc.abstractmethod
326    def read_config(self) -> bytes:
327        ...
328
329    @abc.abstractmethod
330    def write_config(self, config: bytes) -> None:
331        ...
332
333
334class _ManagementOtpBackend(_Backend):
335    def __init__(self, otp_connection):
336        self.protocol = OtpProtocol(otp_connection)
337        self.version = self.protocol.version
338        if (1, 0, 0) <= self.version < (3, 0, 0):
339            raise ApplicationNotAvailableError()
340
341    def close(self):
342        self.protocol.close()
343
344    def set_mode(self, data):
345        empty = self.protocol.read_status()[STATUS_OFFSET_PROG_SEQ] == 0
346        try:
347            self.protocol.send_and_receive(SLOT_DEVICE_CONFIG, data)
348        except CommandRejectedError:
349            if empty:
350                return  # ProgSeq isn't updated by set mode when empty
351            raise
352
353    def read_config(self):
354        response = self.protocol.send_and_receive(SLOT_YK4_CAPABILITIES)
355        r_len = response[0]
356        if check_crc(response[: r_len + 1 + 2]):
357            return response[: r_len + 1]
358        raise BadResponseError("Invalid checksum")
359
360    def write_config(self, config):
361        self.protocol.send_and_receive(SLOT_YK4_SET_DEVICE_INFO, config)
362
363
364INS_READ_CONFIG = 0x1D
365INS_WRITE_CONFIG = 0x1C
366INS_SET_MODE = 0x16
367P1_DEVICE_CONFIG = 0x11
368
369
370class _ManagementSmartCardBackend(_Backend):
371    def __init__(self, smartcard_connection):
372        self.protocol = SmartCardProtocol(smartcard_connection)
373        select_bytes = self.protocol.select(AID.MANAGEMENT)
374        if select_bytes[-2:] == b"\x90\x00":
375            # YubiKey Edge incorrectly appends SW twice.
376            select_bytes = select_bytes[:-2]
377        select_str = select_bytes.decode()
378        self.version = Version.from_string(select_str)
379        # For YubiKey NEO, we use the OTP application for further commands
380        if self.version[0] == 3:
381            # Workaround to "de-select" on NEO, otherwise it gets stuck.
382            self.protocol.connection.send_and_receive(b"\xa4\x04\x00\x08")
383            self.protocol.select(AID.OTP)
384
385    def close(self):
386        self.protocol.close()
387
388    def set_mode(self, data):
389        if self.version[0] == 3:  # Using the OTP application
390            self.protocol.send_apdu(0, 0x01, SLOT_DEVICE_CONFIG, 0, data)
391        else:
392            self.protocol.send_apdu(0, INS_SET_MODE, P1_DEVICE_CONFIG, 0, data)
393
394    def read_config(self):
395        return self.protocol.send_apdu(0, INS_READ_CONFIG, 0, 0)
396
397    def write_config(self, config):
398        self.protocol.send_apdu(0, INS_WRITE_CONFIG, 0, 0, config)
399
400
401CTAP_VENDOR_FIRST = 0x40
402CTAP_YUBIKEY_DEVICE_CONFIG = CTAP_VENDOR_FIRST
403CTAP_READ_CONFIG = CTAP_VENDOR_FIRST + 2
404CTAP_WRITE_CONFIG = CTAP_VENDOR_FIRST + 3
405
406
407class _ManagementCtapBackend(_Backend):
408    def __init__(self, fido_connection):
409        self.ctap = fido_connection
410        version = fido_connection.device_version
411        if version[0] < 4:  # Prior to YK4 this was not firmware version
412            if not (
413                version[0] == 0 and fido_connection.capabilities & CTAP_CAPABILITY.CBOR
414            ):
415                version = (3, 0, 0)  # Guess that it's a NEO
416        self.version = Version(*version)
417
418    def close(self):
419        self.ctap.close()
420
421    def set_mode(self, data):
422        self.ctap.call(CTAP_YUBIKEY_DEVICE_CONFIG, data)
423
424    def read_config(self):
425        return self.ctap.call(CTAP_READ_CONFIG)
426
427    def write_config(self, config):
428        self.ctap.call(CTAP_WRITE_CONFIG, config)
429
430
431class ManagementSession:
432    def __init__(
433        self, connection: Union[OtpConnection, SmartCardConnection, FidoConnection]
434    ):
435        if isinstance(connection, OtpConnection):
436            self.backend: _Backend = _ManagementOtpBackend(connection)
437        elif isinstance(connection, SmartCardConnection):
438            self.backend = _ManagementSmartCardBackend(connection)
439        elif isinstance(connection, FidoConnection):
440            self.backend = _ManagementCtapBackend(connection)
441        else:
442            raise TypeError("Unsupported connection type")
443
444    def close(self) -> None:
445        self.backend.close()
446
447    @property
448    def version(self) -> Version:
449        return self.backend.version
450
451    def read_device_info(self) -> DeviceInfo:
452        require_version(self.version, (4, 1, 0))
453        return DeviceInfo.parse(self.backend.read_config(), self.version)
454
455    def write_device_config(
456        self,
457        config: Optional[DeviceConfig] = None,
458        reboot: bool = False,
459        cur_lock_code: Optional[bytes] = None,
460        new_lock_code: Optional[bytes] = None,
461    ) -> None:
462        require_version(self.version, (5, 0, 0))
463        if cur_lock_code is not None and len(cur_lock_code) != 16:
464            raise ValueError("Lock code must be 16 bytes")
465        if new_lock_code is not None and len(new_lock_code) != 16:
466            raise ValueError("Lock code must be 16 bytes")
467        config = config or DeviceConfig({}, None, None, None)
468        self.backend.write_config(
469            config.get_bytes(reboot, cur_lock_code, new_lock_code)
470        )
471
472    def set_mode(
473        self,
474        mode: Mode,
475        chalresp_timeout: int = 0,
476        auto_eject_timeout: Optional[int] = None,
477    ) -> None:
478        if self.version >= (5, 0, 0):
479            # Translate into DeviceConfig
480            usb_enabled = CAPABILITY(0)
481            if USB_INTERFACE.OTP in mode.interfaces:
482                usb_enabled |= CAPABILITY.OTP
483            if USB_INTERFACE.CCID in mode.interfaces:
484                usb_enabled |= CAPABILITY.OATH | CAPABILITY.PIV | CAPABILITY.OPENPGP
485            if USB_INTERFACE.FIDO in mode.interfaces:
486                usb_enabled |= CAPABILITY.U2F | CAPABILITY.FIDO2
487            self.write_device_config(
488                DeviceConfig(
489                    {TRANSPORT.USB: usb_enabled},
490                    auto_eject_timeout,
491                    chalresp_timeout,
492                    None,
493                )
494            )
495        else:
496            code = mode.code
497            if auto_eject_timeout is not None:
498                if mode.interfaces == USB_INTERFACE.CCID:
499                    code |= DEVICE_FLAG.EJECT
500                else:
501                    raise ValueError("Touch-eject only applicable for mode: CCID")
502            self.backend.set_mode(
503                # N.B. This is little endian!
504                struct.pack("<BBH", code, chalresp_timeout, auto_eject_timeout or 0)
505            )
506