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