diff --git a/content/projects/yubikey-manager/API_Documentation/.placeholder b/content/projects/yubikey-manager/API_Documentation/.placeholder new file mode 100644 index 000000000..e69de29bb diff --git a/static/yubikey-manager/API_Documentation/.buildinfo b/static/yubikey-manager/API_Documentation/.buildinfo new file mode 100644 index 000000000..385fc831a --- /dev/null +++ b/static/yubikey-manager/API_Documentation/.buildinfo @@ -0,0 +1,4 @@ +# Sphinx build info version 1 +# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done. +config: f1887cf7e51dccc0e099636455825edb +tags: 645f666f9bcd5a90fca523b33c5a78b7 diff --git a/static/yubikey-manager/API_Documentation/_modules/index.html b/static/yubikey-manager/API_Documentation/_modules/index.html new file mode 100644 index 000000000..8be8e214a --- /dev/null +++ b/static/yubikey-manager/API_Documentation/_modules/index.html @@ -0,0 +1,123 @@ + + +
+ + +
+# Copyright (c) 2015-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 yubikit.core import TRANSPORT, PID, YubiKeyDevice
+from typing import Optional, Hashable
+
+
+[docs]class YkmanDevice(YubiKeyDevice):
+ """YubiKey device reference, with optional PID"""
+
+ def __init__(self, transport: TRANSPORT, fingerprint: Hashable, pid: Optional[PID]):
+ super(YkmanDevice, self).__init__(transport, fingerprint)
+ self._pid = pid
+
+ @property
+ def pid(self) -> Optional[PID]:
+ """Return the PID of the YubiKey, if available."""
+ return self._pid
+
+ def __repr__(self):
+ return "%s(pid=%04x, fingerprint=%r)" % (
+ type(self).__name__,
+ self.pid or 0,
+ self.fingerprint,
+ )
+
+# Copyright (c) 2015-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 yubikit.core import Connection, PID, TRANSPORT, YUBIKEY
+from yubikit.core.otp import OtpConnection
+from yubikit.core.fido import FidoConnection
+from yubikit.core.smartcard import SmartCardConnection
+from yubikit.management import (
+ DeviceInfo,
+ USB_INTERFACE,
+)
+from yubikit.support import read_info
+from .base import YkmanDevice
+from .hid import (
+ list_otp_devices as _list_otp_devices,
+ list_ctap_devices as _list_ctap_devices,
+)
+from .pcsc import list_devices as _list_ccid_devices
+from smartcard.pcsc.PCSCExceptions import EstablishContextException
+from smartcard.Exceptions import NoCardException
+
+from time import sleep, time
+from collections import Counter
+from typing import (
+ Dict,
+ Mapping,
+ List,
+ Tuple,
+ Iterable,
+ Type,
+ Hashable,
+ Set,
+)
+import sys
+import ctypes
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def _warn_once(message, e_type=Exception):
+ warned: List[bool] = []
+
+ def outer(f):
+ def inner():
+ try:
+ return f()
+ except e_type:
+ if not warned:
+ logger.warning(message)
+ warned.append(True)
+ raise
+
+ return inner
+
+ return outer
+
+
+[docs]@_warn_once(
+ "PC/SC not available. Smart card (CCID) protocols will not function.",
+ EstablishContextException,
+)
+def list_ccid_devices():
+ """List CCID devices."""
+ return _list_ccid_devices()
+
+
+[docs]@_warn_once("No CTAP HID backend available. FIDO protocols will not function.")
+def list_ctap_devices():
+ """List CTAP devices."""
+ return _list_ctap_devices()
+
+
+[docs]@_warn_once("No OTP HID backend available. OTP protocols will not function.")
+def list_otp_devices():
+ """List OTP devices."""
+ return _list_otp_devices()
+
+
+_CONNECTION_LIST_MAPPING = {
+ SmartCardConnection: list_ccid_devices,
+ OtpConnection: list_otp_devices,
+ FidoConnection: list_ctap_devices,
+}
+
+
+[docs]def scan_devices() -> Tuple[Mapping[PID, int], int]:
+ """Scan USB for attached YubiKeys, without opening any connections.
+
+ :return: A dict mapping PID to device count, and a state object which can be used to
+ detect changes in attached devices.
+ """
+ fingerprints = set()
+ merged: Dict[PID, int] = {}
+ for list_devs in _CONNECTION_LIST_MAPPING.values():
+ try:
+ devs = list_devs()
+ except Exception:
+ logger.debug("Device listing error", exc_info=True)
+ devs = []
+ merged.update(Counter(d.pid for d in devs if d.pid is not None))
+ fingerprints.update({d.fingerprint for d in devs})
+ if sys.platform == "win32" and not bool(ctypes.windll.shell32.IsUserAnAdmin()):
+ from .hid.windows import list_paths
+
+ counter: Counter[PID] = Counter()
+ for pid, path in list_paths():
+ if pid not in merged:
+ try:
+ counter[PID(pid)] += 1
+ fingerprints.add(path)
+ except ValueError: # Unsupported PID
+ logger.debug(f"Unsupported Yubico device with PID: {pid:02x}")
+ merged.update(counter)
+ return merged, hash(tuple(fingerprints))
+
+
+class _PidGroup:
+ def __init__(self, pid):
+ self._pid = pid
+ self._infos: Dict[Hashable, DeviceInfo] = {}
+ self._resolved: Dict[Hashable, Dict[USB_INTERFACE, YkmanDevice]] = {}
+ self._unresolved: Dict[USB_INTERFACE, List[YkmanDevice]] = {}
+ self._devcount: Dict[USB_INTERFACE, int] = Counter()
+ self._fingerprints: Set[Hashable] = set()
+ self._ctime = time()
+
+ def _key(self, info):
+ return (
+ info.serial,
+ info.version,
+ info.form_factor,
+ str(info.supported_capabilities),
+ info.config.get_bytes(False),
+ info.is_locked,
+ info.is_fips,
+ info.is_sky,
+ )
+
+ def add(self, conn_type, dev, force_resolve=False):
+ logger.debug(f"Add device for {conn_type}: {dev}")
+ iface = conn_type.usb_interface
+ self._fingerprints.add(dev.fingerprint)
+ self._devcount[iface] += 1
+ if force_resolve or len(self._resolved) < max(self._devcount.values()):
+ try:
+ with dev.open_connection(conn_type) as conn:
+ info = read_info(conn, dev.pid)
+ key = self._key(info)
+ self._infos[key] = info
+ self._resolved.setdefault(key, {})[iface] = dev
+ logger.debug(f"Resolved device {info.serial}")
+ return
+ except Exception:
+ logger.warning("Failed opening device", exc_info=True)
+ self._unresolved.setdefault(iface, []).append(dev)
+
+ def supports_connection(self, conn_type):
+ return conn_type.usb_interface in self._devcount
+
+ def connect(self, key, conn_type):
+ iface = conn_type.usb_interface
+
+ resolved = self._resolved[key].get(iface)
+ if resolved:
+ return resolved.open_connection(conn_type)
+
+ devs = self._unresolved.get(iface, [])
+ failed = []
+ try:
+ while devs:
+ dev = devs.pop()
+ try:
+ conn = dev.open_connection(conn_type)
+ info = read_info(conn, dev.pid)
+ dev_key = self._key(info)
+ if dev_key in self._infos:
+ self._resolved.setdefault(dev_key, {})[iface] = dev
+ logger.debug(f"Resolved device {info.serial}")
+ if dev_key == key:
+ return conn
+ elif self._pid.yubikey_type == YUBIKEY.NEO and not devs:
+ self._resolved.setdefault(key, {})[iface] = dev
+ logger.debug("Resolved last NEO device without serial")
+ return conn
+ conn.close()
+ except Exception:
+ logger.warning("Failed opening device", exc_info=True)
+ failed.append(dev)
+ finally:
+ devs.extend(failed)
+
+ if self._devcount[iface] < len(self._infos):
+ logger.debug(f"Checking for more devices over {iface!s}")
+ for dev in _CONNECTION_LIST_MAPPING[conn_type]():
+ if self._pid == dev.pid and dev.fingerprint not in self._fingerprints:
+ self.add(conn_type, dev, True)
+
+ resolved = self._resolved[key].get(iface)
+ if resolved:
+ return resolved.open_connection(conn_type)
+
+ # Retry if we are within a 5 second period after creation,
+ # as not all USB interface become usable at the exact same time.
+ if time() < self._ctime + 5:
+ logger.debug("Device not found, retry in 1s")
+ sleep(1.0)
+ return self.connect(key, conn_type)
+
+ raise ValueError("Failed to connect to the device")
+
+ def get_devices(self):
+ results = []
+ for key, info in self._infos.items():
+ dev = next(iter(self._resolved[key].values()))
+ results.append(
+ (_UsbCompositeDevice(self, key, dev.fingerprint, dev.pid), info)
+ )
+ return results
+
+
+class _UsbCompositeDevice(YkmanDevice):
+ def __init__(self, group, key, fingerprint, pid):
+ super().__init__(TRANSPORT.USB, fingerprint, pid)
+ self._group = group
+ self._key = key
+
+ def supports_connection(self, connection_type):
+ return self._group.supports_connection(connection_type)
+
+ def open_connection(self, connection_type):
+ if not self.supports_connection(connection_type):
+ raise ValueError("Unsupported Connection type")
+
+ # Allow for ~3s reclaim time on NEO for CCID
+ assert self.pid # nosec
+ if self.pid.yubikey_type == YUBIKEY.NEO and issubclass(
+ connection_type, SmartCardConnection
+ ):
+ for _ in range(6):
+ try:
+ return self._group.connect(self._key, connection_type)
+ except (NoCardException, ValueError):
+ sleep(0.5)
+
+ return self._group.connect(self._key, connection_type)
+
+
+[docs]def list_all_devices(
+ connection_types: Iterable[Type[Connection]] = _CONNECTION_LIST_MAPPING.keys(),
+) -> List[Tuple[YkmanDevice, DeviceInfo]]:
+ """Connect to all attached YubiKeys and read device info from them.
+
+ :param connection_types: An iterable of YubiKey connection types.
+ :return: A list of (device, info) tuples for each connected device.
+ """
+ groups: Dict[PID, _PidGroup] = {}
+
+ for connection_type in connection_types:
+ for base_type in _CONNECTION_LIST_MAPPING:
+ if issubclass(connection_type, base_type):
+ connection_type = base_type
+ break
+ else:
+ raise ValueError("Invalid connection type")
+ try:
+ for dev in _CONNECTION_LIST_MAPPING[connection_type]():
+ group = groups.setdefault(dev.pid, _PidGroup(dev.pid))
+ group.add(connection_type, dev)
+ except Exception:
+ logger.exception("Unable to list devices for connection")
+ devices = []
+ for group in groups.values():
+ devices.extend(group.get_devices())
+ return devices
+
+# Copyright (c) 2018 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.
+
+import time
+import struct
+from yubikit.core.fido import FidoConnection
+from yubikit.core.smartcard import SW
+from fido2.ctap1 import Ctap1, ApduError
+
+from typing import Optional
+
+
+U2F_VENDOR_FIRST = 0x40
+
+# FIPS specific INS values
+INS_FIPS_VERIFY_PIN = U2F_VENDOR_FIRST + 3
+INS_FIPS_SET_PIN = U2F_VENDOR_FIRST + 4
+INS_FIPS_RESET = U2F_VENDOR_FIRST + 5
+INS_FIPS_VERIFY_FIPS_MODE = U2F_VENDOR_FIRST + 6
+
+
+[docs]def is_in_fips_mode(fido_connection: FidoConnection) -> bool:
+ """Check if a YubiKey FIPS is in FIPS approved mode.
+
+ :param fido_connection: A FIDO connection.
+ """
+ try:
+ ctap = Ctap1(fido_connection)
+ ctap.send_apdu(ins=INS_FIPS_VERIFY_FIPS_MODE)
+ return True
+ except ApduError as e:
+ # 0x6a81: Function not supported (PIN not set - not FIPS Mode)
+ if e.code == SW.FUNCTION_NOT_SUPPORTED:
+ return False
+ raise
+
+
+[docs]def fips_change_pin(
+ fido_connection: FidoConnection, old_pin: Optional[str], new_pin: str
+):
+ """Change the PIN on a YubiKey FIPS.
+
+ If no PIN is set, pass None or an empty string as old_pin.
+
+ :param fido_connection: A FIDO connection.
+ :param old_pin: The old PIN.
+ :param new_pin: The new PIN.
+ """
+ ctap = Ctap1(fido_connection)
+
+ old_pin_bytes = old_pin.encode() if old_pin else b""
+ new_pin_bytes = new_pin.encode()
+ new_length = len(new_pin_bytes)
+
+ data = struct.pack("B", new_length) + old_pin_bytes + new_pin_bytes
+
+ ctap.send_apdu(ins=INS_FIPS_SET_PIN, data=data)
+
+
+[docs]def fips_verify_pin(fido_connection: FidoConnection, pin: str):
+ """Unlock the YubiKey FIPS U2F module for credential creation.
+
+ :param fido_connection: A FIDO connection.
+ :param pin: The FIDO PIN.
+ """
+ ctap = Ctap1(fido_connection)
+ ctap.send_apdu(ins=INS_FIPS_VERIFY_PIN, data=pin.encode())
+
+
+[docs]def fips_reset(fido_connection: FidoConnection):
+ """Reset the FIDO module of a YubiKey FIPS.
+
+ Note: This action is only permitted immediately after YubiKey FIPS power-up. It
+ also requires the user to touch the flashing button on the YubiKey, and will halt
+ until that happens, or the command times out.
+
+ :param fido_connection: A FIDO connection.
+ """
+ ctap = Ctap1(fido_connection)
+ while True:
+ try:
+ ctap.send_apdu(ins=INS_FIPS_RESET)
+ return
+ except ApduError as e:
+ if e.code == SW.CONDITIONS_NOT_SATISFIED:
+ time.sleep(0.5)
+ else:
+ raise e
+
+# Copyright (c) 2023 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 yubikit.hsmauth import HsmAuthSession, INITIAL_RETRY_COUNTER
+
+import os
+
+
+[docs]def get_hsmauth_info(session: HsmAuthSession):
+ """Get information about the YubiHSM Auth application."""
+ retries = session.get_management_key_retries()
+ info = {
+ "YubiHSM Auth version": session.version,
+ "Management key retries remaining": f"{retries}/{INITIAL_RETRY_COUNTER}",
+ }
+
+ return info
+
+
+[docs]def generate_random_management_key() -> bytes:
+ """Generate a new random management key."""
+ return os.urandom(16)
+
+# Copyright (c) 2015 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 yubikit.oath import OATH_TYPE
+from time import time
+import struct
+
+
+STEAM_CHAR_TABLE = "23456789BCDFGHJKMNPQRTVWXY"
+
+
+
+
+
+[docs]def is_steam(credential):
+ """Check if OATH credential is steam."""
+ return credential.oath_type == OATH_TYPE.TOTP and credential.issuer == "Steam"
+
+
+[docs]def calculate_steam(app, credential, timestamp=None):
+ """Calculate steam codes."""
+ timestamp = int(timestamp or time())
+ resp = app.calculate(credential.id, struct.pack(">q", timestamp // 30))
+ offset = resp[-1] & 0x0F
+ code = struct.unpack(">I", resp[offset : offset + 4])[0] & 0x7FFFFFFF
+ chars = []
+ for i in range(5):
+ chars.append(STEAM_CHAR_TABLE[code % len(STEAM_CHAR_TABLE)])
+ code //= len(STEAM_CHAR_TABLE)
+ return "".join(chars)
+
+
+[docs]def is_in_fips_mode(app):
+ """Check if OATH application is in FIPS mode."""
+ return app.locked
+
+# Copyright (c) 2015 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 yubikit.openpgp import OpenPgpSession, KEY_REF
+
+
+[docs]def get_openpgp_info(session: OpenPgpSession):
+ """Get human readable information about the OpenPGP configuration.
+
+ :param session: The OpenPGP session.
+ """
+ data = session.get_application_related_data()
+ discretionary = data.discretionary
+ retries = discretionary.pw_status
+ info = {
+ "OpenPGP version": "%d.%d" % data.aid.version,
+ "Application version": "%d.%d.%d" % session.version,
+ "PIN tries remaining": retries.attempts_user,
+ "Reset code tries remaining": retries.attempts_reset,
+ "Admin PIN tries remaining": retries.attempts_admin,
+ "Require PIN for signature": retries.pin_policy_user,
+ }
+
+ # Touch only available on YK4 and later
+ if session.version >= (4, 2, 6):
+ touch = {
+ "Signature key": session.get_uif(KEY_REF.SIG),
+ "Encryption key": session.get_uif(KEY_REF.DEC),
+ "Authentication key": session.get_uif(KEY_REF.AUT),
+ }
+ if discretionary.attributes_att is not None:
+ touch["Attestation key"] = session.get_uif(KEY_REF.ATT)
+ info["Touch policies"] = touch
+
+ return info
+
+# Copyright (c) 2017 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 yubikit.core import Tlv, BadResponseError, NotSupportedError
+from yubikit.core.smartcard import ApduError, SW
+from yubikit.piv import (
+ PivSession,
+ SLOT,
+ OBJECT_ID,
+ KEY_TYPE,
+ MANAGEMENT_KEY_TYPE,
+ ALGORITHM,
+ TAG_LRC,
+)
+
+from cryptography import x509
+from cryptography.exceptions import InvalidSignature
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives.asymmetric import rsa, ec, padding
+from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
+from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
+from cryptography.hazmat.backends import default_backend
+from cryptography.x509.oid import NameOID
+from collections import OrderedDict
+from datetime import datetime
+import logging
+import struct
+import os
+import re
+
+from typing import Union, Mapping, Optional, List, Dict, Type, Any, cast
+
+
+logger = logging.getLogger(__name__)
+
+
+OBJECT_ID_PIVMAN_DATA = 0x5FFF00
+OBJECT_ID_PIVMAN_PROTECTED_DATA = OBJECT_ID.PRINTED # Use slot for printed information.
+
+
+_NAME_ATTRIBUTES = {
+ "CN": NameOID.COMMON_NAME,
+ "L": NameOID.LOCALITY_NAME,
+ "ST": NameOID.STATE_OR_PROVINCE_NAME,
+ "O": NameOID.ORGANIZATION_NAME,
+ "OU": NameOID.ORGANIZATIONAL_UNIT_NAME,
+ "C": NameOID.COUNTRY_NAME,
+ "STREET": NameOID.STREET_ADDRESS,
+ "DC": NameOID.DOMAIN_COMPONENT,
+ "UID": NameOID.USER_ID,
+}
+
+
+_ESCAPED = "\\\"+,'<> #="
+
+
+def _parse(value: str) -> List[List[str]]:
+ remaining = list(value)
+ name = []
+ entry = []
+ buf = ""
+ hexbuf = b""
+ while remaining:
+ c = remaining.pop(0)
+ if c == "\\":
+ c1 = remaining.pop(0)
+ if c1 in _ESCAPED:
+ c = c1
+ else:
+ c2 = remaining.pop(0)
+ hexbuf += bytes.fromhex(c1 + c2)
+ try:
+ c = hexbuf.decode()
+ hexbuf = b""
+ except UnicodeDecodeError:
+ continue # Possibly multi-byte, expect more hex
+ elif c in ",+":
+ entry.append(buf)
+ buf = ""
+ if c == ",":
+ name.append(entry)
+ entry = []
+ continue
+ if hexbuf:
+ raise ValueError("Invalid UTF-8 data")
+ buf += c
+ entry.append(buf)
+ name.append(entry)
+ return name
+
+
+_DOTTED_STRING_RE = re.compile(r"\d(\.\d+)+")
+
+
+[docs]def parse_rfc4514_string(value: str) -> x509.Name:
+ """Parse an RFC 4514 string into a x509.Name.
+
+ See: https://tools.ietf.org/html/rfc4514.html
+
+ :param value: An RFC 4514 string.
+ """
+ name = _parse(value)
+ attributes: List[x509.RelativeDistinguishedName] = []
+ for entry in name:
+ parts = []
+ for part in entry:
+ if "=" not in part:
+ raise ValueError("Invalid RFC 4514 string")
+ k, v = part.split("=", 1)
+ if k in _NAME_ATTRIBUTES:
+ attr = _NAME_ATTRIBUTES[k]
+ elif _DOTTED_STRING_RE.fullmatch(k):
+ attr = x509.ObjectIdentifier(k)
+ else:
+ raise ValueError(f"Unsupported attribute: '{k}'")
+ parts.append(x509.NameAttribute(attr, v))
+ attributes.insert(0, x509.RelativeDistinguishedName(parts))
+
+ return x509.Name(attributes)
+
+
+def _dummy_key(algorithm):
+ if algorithm == KEY_TYPE.RSA1024:
+ return rsa.generate_private_key(65537, 1024, default_backend()) # nosec
+ if algorithm == KEY_TYPE.RSA2048:
+ return rsa.generate_private_key(65537, 2048, default_backend())
+ if algorithm == KEY_TYPE.ECCP256:
+ return ec.generate_private_key(ec.SECP256R1(), default_backend())
+ if algorithm == KEY_TYPE.ECCP384:
+ return ec.generate_private_key(ec.SECP384R1(), default_backend())
+ raise ValueError("Invalid algorithm")
+
+
+[docs]def derive_management_key(pin: str, salt: bytes) -> bytes:
+ """Derive a management key from the users PIN and a salt.
+
+ NOTE: This method of derivation is deprecated! Protect the management key using
+ PivmanProtectedData instead.
+
+ :param pin: The PIN.
+ :param salt: The salt.
+ """
+ kdf = PBKDF2HMAC(hashes.SHA1(), 24, salt, 10000, default_backend()) # nosec
+ return kdf.derive(pin.encode("utf-8"))
+
+
+[docs]def generate_random_management_key(algorithm: MANAGEMENT_KEY_TYPE) -> bytes:
+ """Generate a new random management key.
+
+ :param algorithm: The algorithm for the management key.
+ """
+ return os.urandom(algorithm.key_len)
+
+
+[docs]class PivmanData:
+ def __init__(self, raw_data: bytes = Tlv(0x80)):
+ data = Tlv.parse_dict(Tlv(raw_data).value)
+ self._flags = struct.unpack(">B", data[0x81])[0] if 0x81 in data else None
+ self.salt = data.get(0x82)
+ self.pin_timestamp = struct.unpack(">I", data[0x83]) if 0x83 in data else None
+
+ def _get_flag(self, mask: int) -> bool:
+ return bool((self._flags or 0) & mask)
+
+ def _set_flag(self, mask: int, value: bool) -> None:
+ if value:
+ self._flags = (self._flags or 0) | mask
+ elif self._flags is not None:
+ self._flags &= ~mask
+
+ @property
+ def puk_blocked(self) -> bool:
+ return self._get_flag(0x01)
+
+ @puk_blocked.setter
+ def puk_blocked(self, value: bool) -> None:
+ self._set_flag(0x01, value)
+
+ @property
+ def mgm_key_protected(self) -> bool:
+ return self._get_flag(0x02)
+
+ @mgm_key_protected.setter
+ def mgm_key_protected(self, value: bool) -> None:
+ self._set_flag(0x02, value)
+
+ @property
+ def has_protected_key(self) -> bool:
+ return self.has_derived_key or self.has_stored_key
+
+ @property
+ def has_derived_key(self) -> bool:
+ return self.salt is not None
+
+ @property
+ def has_stored_key(self) -> bool:
+ return self.mgm_key_protected
+
+[docs] def get_bytes(self) -> bytes:
+ data = b""
+ if self._flags is not None:
+ data += Tlv(0x81, struct.pack(">B", self._flags))
+ if self.salt is not None:
+ data += Tlv(0x82, self.salt)
+ if self.pin_timestamp is not None:
+ data += Tlv(0x83, struct.pack(">I", self.pin_timestamp))
+ return Tlv(0x80, data)
+
+
+[docs]class PivmanProtectedData:
+ def __init__(self, raw_data: bytes = Tlv(0x88)):
+ data = Tlv.parse_dict(Tlv(raw_data).value)
+ self.key = data.get(0x89)
+
+[docs] def get_bytes(self) -> bytes:
+ data = b""
+ if self.key is not None:
+ data += Tlv(0x89, self.key)
+ return Tlv(0x88, data)
+
+
+[docs]def get_pivman_data(session: PivSession) -> PivmanData:
+ """Read out the Pivman data from a YubiKey.
+
+ :param session: The PIV session.
+ """
+ logger.debug("Reading pivman data")
+ try:
+ return PivmanData(session.get_object(OBJECT_ID_PIVMAN_DATA))
+ except ApduError as e:
+ if e.sw == SW.FILE_NOT_FOUND:
+ # No data there, initialise a new object.
+ logger.debug("No data, initializing blank")
+ return PivmanData()
+ raise
+
+
+[docs]def get_pivman_protected_data(session: PivSession) -> PivmanProtectedData:
+ """Read out the Pivman protected data from a YubiKey.
+
+ This function requires PIN verification prior to being called.
+
+ :param session: The PIV session.
+ """
+ logger.debug("Reading protected pivman data")
+ try:
+ return PivmanProtectedData(session.get_object(OBJECT_ID_PIVMAN_PROTECTED_DATA))
+ except ApduError as e:
+ if e.sw == SW.FILE_NOT_FOUND:
+ # No data there, initialise a new object.
+ logger.debug("No data, initializing blank")
+ return PivmanProtectedData()
+ raise
+
+
+[docs]def pivman_set_mgm_key(
+ session: PivSession,
+ new_key: bytes,
+ algorithm: MANAGEMENT_KEY_TYPE,
+ touch: bool = False,
+ store_on_device: bool = False,
+) -> None:
+ """Set a new management key, while keeping PivmanData in sync.
+
+ :param session: The PIV session.
+ :param new_key: The new management key.
+ :param algorithm: The algorithm for the management key.
+ :param touch: If set, touch is required.
+ :param store_on_device: If set, the management key is stored on device.
+ """
+ pivman = get_pivman_data(session)
+ pivman_prot = None
+
+ if store_on_device or (not store_on_device and pivman.has_stored_key):
+ # Ensure we have access to protected data before overwriting key
+ try:
+ pivman_prot = get_pivman_protected_data(session)
+ except Exception:
+ logger.debug("Failed to initialize protected pivman data", exc_info=True)
+ if store_on_device:
+ raise
+
+ # Set the new management key
+ session.set_management_key(algorithm, new_key, touch)
+
+ if pivman.has_derived_key:
+ # Clear salt for old derived keys.
+ logger.debug("Clearing salt in pivman data")
+ pivman.salt = None
+
+ # Set flag for stored or not stored key.
+ pivman.mgm_key_protected = store_on_device
+
+ # Update readable pivman data
+ session.put_object(OBJECT_ID_PIVMAN_DATA, pivman.get_bytes())
+
+ if pivman_prot is not None:
+ if store_on_device:
+ # Store key in protected pivman data
+ logger.debug("Storing key in protected pivman data")
+ pivman_prot.key = new_key
+ session.put_object(OBJECT_ID_PIVMAN_PROTECTED_DATA, pivman_prot.get_bytes())
+ elif pivman_prot.key:
+ # If new key should not be stored and there is an old stored key,
+ # try to clear it.
+ logger.debug("Clearing old key in protected pivman data")
+ try:
+ pivman_prot.key = None
+ session.put_object(
+ OBJECT_ID_PIVMAN_PROTECTED_DATA,
+ pivman_prot.get_bytes(),
+ )
+ except ApduError:
+ logger.debug("No PIN provided, can't clear key...", exc_info=True)
+
+
+[docs]def pivman_change_pin(session: PivSession, old_pin: str, new_pin: str) -> None:
+ """Change the PIN, while keeping PivmanData in sync.
+
+ :param session: The PIV session.
+ :param old_pin: The old PIN.
+ :param new_pin: The new PIN.
+ """
+ session.change_pin(old_pin, new_pin)
+
+ pivman = get_pivman_data(session)
+ if pivman.has_derived_key:
+ logger.debug("Has derived management key, update for new PIN")
+ session.authenticate(
+ MANAGEMENT_KEY_TYPE.TDES,
+ derive_management_key(old_pin, cast(bytes, pivman.salt)),
+ )
+ session.verify_pin(new_pin)
+ new_salt = os.urandom(16)
+ new_key = derive_management_key(new_pin, new_salt)
+ session.set_management_key(MANAGEMENT_KEY_TYPE.TDES, new_key)
+ pivman.salt = new_salt
+ session.put_object(OBJECT_ID_PIVMAN_DATA, pivman.get_bytes())
+
+
+[docs]def list_certificates(session: PivSession) -> Mapping[SLOT, Optional[x509.Certificate]]:
+ """Read out and parse stored certificates.
+
+ Only certificates which are successfully parsed are returned.
+
+ :param session: The PIV session.
+ """
+ certs = OrderedDict()
+ for slot in set(SLOT) - {SLOT.ATTESTATION}:
+ try:
+ certs[slot] = session.get_certificate(slot)
+ except ApduError:
+ pass
+ except BadResponseError:
+ certs[slot] = None # type: ignore
+
+ return certs
+
+
+[docs]def check_key(
+ session: PivSession,
+ slot: SLOT,
+ public_key: Union[rsa.RSAPublicKey, ec.EllipticCurvePublicKey],
+) -> bool:
+ """Check that a given public key corresponds to the private key in a slot.
+
+ This will create a signature using the private key, so the PIN must be verified
+ prior to calling this function if the PIN policy requires it.
+
+ :param session: The PIV session.
+ :param slot: The slot.
+ :param public_key: The public key.
+ """
+ try:
+ test_data = b"test"
+ logger.debug(
+ "Testing private key by creating a test signature, and verifying it"
+ )
+
+ test_sig = session.sign(
+ slot,
+ KEY_TYPE.from_public_key(public_key),
+ test_data,
+ hashes.SHA256(),
+ padding.PKCS1v15(), # Only used for RSA
+ )
+
+ if isinstance(public_key, rsa.RSAPublicKey):
+ public_key.verify(
+ test_sig,
+ test_data,
+ padding.PKCS1v15(),
+ hashes.SHA256(),
+ )
+ elif isinstance(public_key, ec.EllipticCurvePublicKey):
+ public_key.verify(test_sig, test_data, ec.ECDSA(hashes.SHA256()))
+ else:
+ raise ValueError("Unknown key type: " + type(public_key))
+ return True
+
+ except ApduError as e:
+ if e.sw in (SW.INCORRECT_PARAMETERS, SW.WRONG_PARAMETERS_P1P2):
+ logger.debug(f"Couldn't create signature: SW={e.sw:04x}")
+ return False
+ raise
+
+ except InvalidSignature:
+ logger.debug("Signature verification failed")
+ return False
+
+
+[docs]def generate_chuid() -> bytes:
+ """Generate a CHUID (Cardholder Unique Identifier)."""
+ # Non-Federal Issuer FASC-N
+ # [9999-9999-999999-0-1-0000000000300001]
+ FASC_N = (
+ b"\xd4\xe7\x39\xda\x73\x9c\xed\x39\xce\x73\x9d\x83\x68"
+ + b"\x58\x21\x08\x42\x10\x84\x21\xc8\x42\x10\xc3\xeb"
+ )
+ # Expires on: 2030-01-01
+ EXPIRY = b"\x32\x30\x33\x30\x30\x31\x30\x31"
+
+ return (
+ Tlv(0x30, FASC_N)
+ + Tlv(0x34, os.urandom(16))
+ + Tlv(0x35, EXPIRY)
+ + Tlv(0x3E)
+ + Tlv(TAG_LRC)
+ )
+
+
+[docs]def generate_ccc() -> bytes:
+ """Generate a CCC (Card Capability Container)."""
+ return (
+ Tlv(0xF0, b"\xa0\x00\x00\x01\x16\xff\x02" + os.urandom(14))
+ + Tlv(0xF1, b"\x21")
+ + Tlv(0xF2, b"\x21")
+ + Tlv(0xF3)
+ + Tlv(0xF4, b"\x00")
+ + Tlv(0xF5, b"\x10")
+ + Tlv(0xF6)
+ + Tlv(0xF7)
+ + Tlv(0xFA)
+ + Tlv(0xFB)
+ + Tlv(0xFC)
+ + Tlv(0xFD)
+ + Tlv(TAG_LRC)
+ )
+
+
+[docs]def get_piv_info(session: PivSession):
+ """Get human readable information about the PIV configuration.
+
+ :param session: The PIV session.
+ """
+ pivman = get_pivman_data(session)
+ info: Dict[str, Any] = {
+ "PIV version": session.version,
+ }
+ lines: List[Any] = [info]
+
+ try:
+ pin_data = session.get_pin_metadata()
+ if pin_data.default_value:
+ lines.append("WARNING: Using default PIN!")
+ tries_str = "%d/%d" % (pin_data.attempts_remaining, pin_data.total_attempts)
+ except NotSupportedError:
+ # Largest possible number of PIN tries to get back is 15
+ tries = session.get_pin_attempts()
+ tries_str = "15 or more" if tries == 15 else str(tries)
+ info["PIN tries remaining"] = tries_str
+ if pivman.puk_blocked:
+ lines.append("PUK is blocked")
+ else:
+ try:
+ puk_data = session.get_puk_metadata()
+ if puk_data.default_value:
+ lines.append("WARNING: Using default PUK!")
+ tries_str = "%d/%d" % (
+ puk_data.attempts_remaining,
+ puk_data.total_attempts,
+ )
+ info["PUK tries remaining"] = tries_str
+ except NotSupportedError:
+ pass
+
+ try:
+ metadata = session.get_management_key_metadata()
+ if metadata.default_value:
+ lines.append("WARNING: Using default Management key!")
+ key_type = metadata.key_type
+ except NotSupportedError:
+ key_type = MANAGEMENT_KEY_TYPE.TDES
+ info["Management key algorithm"] = key_type.name
+
+ if pivman.has_derived_key:
+ lines.append("Management key is derived from PIN.")
+ if pivman.has_stored_key:
+ lines.append("Management key is stored on the YubiKey, protected by PIN.")
+
+ objects: Dict[str, Any] = {}
+ lines.append(objects)
+ try:
+ objects["CHUID"] = session.get_object(OBJECT_ID.CHUID)
+ except ApduError as e:
+ if e.sw == SW.FILE_NOT_FOUND:
+ objects["CHUID"] = "No data available"
+
+ try:
+ objects["CCC"] = session.get_object(OBJECT_ID.CAPABILITY)
+ except ApduError as e:
+ if e.sw == SW.FILE_NOT_FOUND:
+ objects["CCC"] = "No data available"
+
+ for slot, cert in list_certificates(session).items():
+ cert_data: Dict[str, Any] = {}
+ objects[f"Slot {slot}"] = cert_data
+ if cert:
+ try:
+ # Try to read out full DN, fallback to only CN.
+ # Support for DN was added in crytography 2.5
+ subject_dn = cert.subject.rfc4514_string()
+ issuer_dn = cert.issuer.rfc4514_string()
+ print_dn = True
+ except AttributeError:
+ print_dn = False
+ logger.debug("Failed to read DN, falling back to only CNs")
+ cn = cert.subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME)
+ subject_cn = cn[0].value if cn else "None"
+ cn = cert.issuer.get_attributes_for_oid(x509.NameOID.COMMON_NAME)
+ issuer_cn = cn[0].value if cn else "None"
+ except ValueError as e:
+ # Malformed certificates may throw ValueError
+ logger.debug("Failed parsing certificate", exc_info=True)
+ cert_data["Error"] = f"Malformed certificate: {e}"
+ continue
+
+ fingerprint = cert.fingerprint(hashes.SHA256()).hex()
+ try:
+ key_algo = KEY_TYPE.from_public_key(cert.public_key()).name
+ except ValueError:
+ key_algo = "Unsupported"
+ serial = cert.serial_number
+ try:
+ not_before: Optional[datetime] = cert.not_valid_before
+ except ValueError:
+ logger.debug("Failed reading not_valid_before", exc_info=True)
+ not_before = None
+ try:
+ not_after: Optional[datetime] = cert.not_valid_after
+ except ValueError:
+ logger.debug("Failed reading not_valid_after", exc_info=True)
+ not_after = None
+
+ # Print out everything
+ cert_data["Algorithm"] = key_algo
+ if print_dn:
+ cert_data["Subject DN"] = subject_dn
+ cert_data["Issuer DN"] = issuer_dn
+ else:
+ cert_data["Subject CN"] = subject_cn
+ cert_data["Issuer CN"] = issuer_cn
+ cert_data["Serial"] = serial
+ cert_data["Fingerprint"] = fingerprint
+ if not_before:
+ cert_data["Not before"] = not_before.isoformat()
+ if not_after:
+ cert_data["Not after"] = not_after.isoformat()
+ else:
+ cert_data["Error"] = "Failed to parse certificate"
+
+ return lines
+
+
+_AllowedHashTypes = Union[
+ hashes.SHA224,
+ hashes.SHA256,
+ hashes.SHA384,
+ hashes.SHA512,
+ hashes.SHA3_224,
+ hashes.SHA3_256,
+ hashes.SHA3_384,
+ hashes.SHA3_512,
+]
+
+
+[docs]def sign_certificate_builder(
+ session: PivSession,
+ slot: SLOT,
+ key_type: KEY_TYPE,
+ builder: x509.CertificateBuilder,
+ hash_algorithm: Type[_AllowedHashTypes] = hashes.SHA256,
+) -> x509.Certificate:
+ """Sign a Certificate.
+
+ :param session: The PIV session.
+ :param slot: The slot.
+ :param key_type: The key type.
+ :param builder: The x509 certificate builder object.
+ :param hash_algorithm: The hash algorithm.
+ """
+ logger.debug("Signing a certificate")
+ dummy_key = _dummy_key(key_type)
+ cert = builder.sign(dummy_key, hash_algorithm(), default_backend())
+
+ sig = session.sign(
+ slot,
+ key_type,
+ cert.tbs_certificate_bytes,
+ hash_algorithm(),
+ padding.PKCS1v15(), # Only used for RSA
+ )
+
+ seq = Tlv.parse_list(Tlv.unpack(0x30, cert.public_bytes(Encoding.DER)))
+ # Replace signature, add unused bits = 0
+ seq[2] = Tlv(seq[2].tag, b"\0" + sig)
+ # Re-assemble sequence
+ der = Tlv(0x30, b"".join(seq))
+
+ return x509.load_der_x509_certificate(der, default_backend())
+
+
+[docs]def sign_csr_builder(
+ session: PivSession,
+ slot: SLOT,
+ public_key: Union[rsa.RSAPublicKey, ec.EllipticCurvePublicKey],
+ builder: x509.CertificateSigningRequestBuilder,
+ hash_algorithm: Type[_AllowedHashTypes] = hashes.SHA256,
+) -> x509.CertificateSigningRequest:
+ """Sign a CSR.
+
+ :param session: The PIV session.
+ :param slot: The slot.
+ :param public_key: The public key.
+ :param builder: The x509 certificate signing request builder
+ object.
+ :param hash_algorithm: The hash algorithm.
+ """
+ logger.debug("Signing a CSR")
+ key_type = KEY_TYPE.from_public_key(public_key)
+ dummy_key = _dummy_key(key_type)
+ csr = builder.sign(dummy_key, hash_algorithm(), default_backend())
+ seq = Tlv.parse_list(Tlv.unpack(0x30, csr.public_bytes(Encoding.DER)))
+
+ # Replace public key
+ pub_format = (
+ PublicFormat.PKCS1
+ if key_type.algorithm == ALGORITHM.RSA
+ else PublicFormat.SubjectPublicKeyInfo
+ )
+ dummy_bytes = dummy_key.public_key().public_bytes(Encoding.DER, pub_format)
+ pub_bytes = public_key.public_bytes(Encoding.DER, pub_format)
+ seq[0] = Tlv(seq[0].replace(dummy_bytes, pub_bytes))
+
+ sig = session.sign(
+ slot,
+ key_type,
+ seq[0],
+ hash_algorithm(),
+ padding.PKCS1v15(), # Only used for RSA
+ )
+
+ # Replace signature, add unused bits = 0
+ seq[2] = Tlv(seq[2].tag, b"\0" + sig)
+ # Re-assemble sequence
+ der = Tlv(0x30, b"".join(seq))
+
+ return x509.load_der_x509_csr(der, default_backend())
+
+
+[docs]def generate_self_signed_certificate(
+ session: PivSession,
+ slot: SLOT,
+ public_key: Union[rsa.RSAPublicKey, ec.EllipticCurvePublicKey],
+ subject_str: str,
+ valid_from: datetime,
+ valid_to: datetime,
+ hash_algorithm: Type[_AllowedHashTypes] = hashes.SHA256,
+) -> x509.Certificate:
+ """Generate a self-signed certificate using a private key in a slot.
+
+ :param session: The PIV session.
+ :param slot: The slot.
+ :param public_key: The public key.
+ :param subject_str: The subject RFC 4514 string.
+ :param valid_from: The date from when the certificate is valid.
+ :param valid_to: The date when the certificate expires.
+ :param hash_algorithm: The hash algorithm.
+ """
+ logger.debug("Generating a self-signed certificate")
+ key_type = KEY_TYPE.from_public_key(public_key)
+
+ subject = parse_rfc4514_string(subject_str)
+ builder = (
+ x509.CertificateBuilder()
+ .public_key(public_key)
+ .subject_name(subject)
+ .issuer_name(subject) # Same as subject on self-signed certificate.
+ .serial_number(x509.random_serial_number())
+ .not_valid_before(valid_from)
+ .not_valid_after(valid_to)
+ )
+
+ return sign_certificate_builder(session, slot, key_type, builder, hash_algorithm)
+
+
+[docs]def generate_csr(
+ session: PivSession,
+ slot: SLOT,
+ public_key: Union[rsa.RSAPublicKey, ec.EllipticCurvePublicKey],
+ subject_str: str,
+ hash_algorithm: Type[_AllowedHashTypes] = hashes.SHA256,
+) -> x509.CertificateSigningRequest:
+ """Generate a CSR using a private key in a slot.
+
+ :param session: The PIV session.
+ :param slot: The slot.
+ :param public_key: The public key.
+ :param subject_str: The subject RFC 4514 string.
+ :param hash_algorithm: The hash algorithm.
+ """
+ logger.debug("Generating a CSR")
+ builder = x509.CertificateSigningRequestBuilder().subject_name(
+ parse_rfc4514_string(subject_str)
+ )
+
+ return sign_csr_builder(session, slot, public_key, builder, hash_algorithm)
+
+# Copyright (c) 2021 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 .base import YkmanDevice
+from .device import list_all_devices, scan_devices
+from .pcsc import list_devices as list_ccid
+
+from yubikit.core import TRANSPORT
+from yubikit.core.otp import OtpConnection
+from yubikit.core.smartcard import SmartCardConnection
+from yubikit.core.fido import FidoConnection
+from yubikit.management import DeviceInfo
+from yubikit.support import get_name, read_info
+from smartcard.Exceptions import NoCardException, CardConnectionException
+
+from time import sleep
+from typing import Generator, Optional, Set
+
+
+"""
+Various helpers intended to simplify scripting.
+
+Add an import to your script:
+
+ from ykman import scripting as s
+
+Example usage:
+
+ yubikey = s.single()
+ print("Here is a YubiKey:", yubikey)
+
+
+ print("Insert multiple YubiKeys")
+ for yubikey in s.multi():
+ print("You inserted {yubikey}")
+ print("You pressed Ctrl+C, end of script")
+
+"""
+
+
+[docs]class ScriptingDevice:
+ """Scripting-friendly proxy for YkmanDevice.
+
+ This wrapper adds some helpful utility methods useful for scripting.
+ """
+
+ def __init__(self, wrapped, info):
+ self._wrapped = wrapped
+ self._info = info
+ self._name = get_name(info, self.pid.yubikey_type if self.pid else None)
+
+ def __getattr__(self, attr):
+ return getattr(self._wrapped, attr)
+
+ def __str__(self):
+ serial = self._info.serial
+ return f"{self._name} ({serial})" if serial else self._name
+
+ @property
+ def info(self) -> DeviceInfo:
+ return self._info
+
+ @property
+ def name(self) -> str:
+ return self._name
+
+[docs] def otp(self) -> OtpConnection:
+ """Establish a OTP connection."""
+ return self.open_connection(OtpConnection)
+
+[docs] def smart_card(self) -> SmartCardConnection:
+ """Establish a Smart Card connection."""
+ return self.open_connection(SmartCardConnection)
+
+[docs] def fido(self) -> FidoConnection:
+ """Establish a FIDO connection."""
+ return self.open_connection(FidoConnection)
+
+
+YkmanDevice.register(ScriptingDevice)
+
+
+[docs]def single(*, prompt=True) -> ScriptingDevice:
+ """Connect to a YubiKey.
+
+ :param prompt: When set, you will be prompted to
+ insert a YubiKey.
+ """
+ pids, state = scan_devices()
+ n_devs = sum(pids.values())
+ if prompt and n_devs == 0:
+ print("Insert YubiKey...")
+ while n_devs == 0:
+ sleep(1.0)
+ pids, new_state = scan_devices()
+ n_devs = sum(pids.values())
+ devs = list_all_devices()
+ if len(devs) == 1:
+ return ScriptingDevice(*devs[0])
+ raise ValueError("Failed to get single YubiKey")
+
+
+[docs]def multi(
+ *, ignore_duplicates: bool = True, allow_initial: bool = False, prompt: bool = True
+) -> Generator[ScriptingDevice, None, None]:
+ """Connect to multiple YubiKeys.
+
+
+ :param ignore_duplicates: When set, duplicates are ignored.
+ :param allow_initial: When set, YubiKeys can be connected
+ at the start of the function call.
+ :param prompt: When set, you will be prompted to
+ insert a YubiKey.
+ """
+ state = None
+ handled_serials: Set[Optional[int]] = set()
+ pids, _ = scan_devices()
+ n_devs = sum(pids.values())
+ if n_devs == 0:
+ if prompt:
+ print("Insert YubiKeys, one at a time...")
+ elif not allow_initial:
+ raise ValueError("YubiKeys must not be present initially.")
+
+ while True: # Run this until we stop the script with Ctrl+C
+ pids, new_state = scan_devices()
+ if new_state != state:
+ state = new_state # State has changed
+ serials = set()
+ if len(pids) == 0 and None in handled_serials:
+ handled_serials.remove(None) # Allow one key without serial at a time
+ for device, info in list_all_devices():
+ serials.add(info.serial)
+ if info.serial not in handled_serials:
+ handled_serials.add(info.serial)
+ yield ScriptingDevice(device, info)
+ if not ignore_duplicates: # Reset handled serials to currently connected
+ handled_serials = serials
+ else:
+ try:
+ sleep(1.0) # No change, sleep for 1 second.
+ except KeyboardInterrupt:
+ return # Stop waiting
+
+
+def _get_reader(reader) -> YkmanDevice:
+ readers = [d for d in list_ccid(reader) if d.transport == TRANSPORT.NFC]
+ if not readers:
+ raise ValueError(f"No NFC reader found matching filter: '{reader}'")
+ elif len(readers) > 1:
+ names = [r.fingerprint for r in readers]
+ raise ValueError(f"Multiple NFC readers matching filter: '{reader}' {names}")
+ return readers[0]
+
+
+[docs]def single_nfc(reader="", *, prompt=True) -> ScriptingDevice:
+ """Connect to a YubiKey over NFC.
+
+ :param reader: The name of the NFC reader.
+ :param prompt: When set, you will prompted to place
+ a YubiKey on NFC reader.
+ """
+ device = _get_reader(reader)
+ while True:
+ try:
+ with device.open_connection(SmartCardConnection) as connection:
+ info = read_info(connection)
+ return ScriptingDevice(device, info)
+ except NoCardException:
+ if prompt:
+ print("Place YubiKey on NFC reader...")
+ prompt = False
+ sleep(1.0)
+
+
+[docs]def multi_nfc(
+ reader="", *, ignore_duplicates=True, allow_initial=False, prompt=True
+) -> Generator[ScriptingDevice, None, None]:
+ """Connect to multiple YubiKeys over NFC.
+
+ :param reader: The name of the NFC reader.
+ :param ignore_duplicates: When set, duplicates are ignored.
+ :param allow_initial: When set, YubiKeys can be connected
+ at the start of the function call.
+ :param prompt: When set, you will be prompted to place
+ YubiKeys on the NFC reader.
+ """
+ device = _get_reader(reader)
+ prompted = False
+
+ try:
+ with device.open_connection(SmartCardConnection) as connection:
+ if not allow_initial:
+ raise ValueError("YubiKey must not be present initially.")
+ except NoCardException:
+ if prompt:
+ print("Place YubiKey on NFC reader...")
+ prompted = True
+ sleep(1.0)
+
+ handled_serials: Set[Optional[int]] = set()
+ current: Optional[int] = -1
+ while True: # Run this until we stop the script with Ctrl+C
+ try:
+ with device.open_connection(SmartCardConnection) as connection:
+ info = read_info(connection)
+ if info.serial in handled_serials or current == info.serial:
+ if prompt and not prompted:
+ print("Remove YubiKey from NFC reader.")
+ prompted = True
+ else:
+ current = info.serial
+ if ignore_duplicates:
+ handled_serials.add(current)
+ yield ScriptingDevice(device, info)
+ prompted = False
+ except NoCardException:
+ if None in handled_serials:
+ handled_serials.remove(None) # Allow one key without serial at a time
+ current = -1
+ if prompt and not prompted:
+ print("Place YubiKey on NFC reader...")
+ prompted = True
+ except CardConnectionException:
+ pass
+ try:
+ sleep(1.0) # No change, sleep for 1 second.
+ except KeyboardInterrupt:
+ return # Stop waiting
+
+# 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
+
+
+
+[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)
+
+
+
+ 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 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")
+
+
+
+
+
+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
+
+# 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 . import Connection, CommandError, TimeoutError, Version, USB_INTERFACE
+from yubikit.logging import LOG_LEVEL
+
+from time import sleep
+from threading import Event
+from typing import Optional, Callable
+import abc
+import struct
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+MODHEX_ALPHABET = "cbdefghijklnrtuv"
+
+
+[docs]class CommandRejectedError(CommandError):
+ """The issues command was rejected by the YubiKey"""
+
+
+[docs]class OtpConnection(Connection, metaclass=abc.ABCMeta):
+ usb_interface = USB_INTERFACE.OTP
+
+
+
+[docs] @abc.abstractmethod
+ def send(self, data: bytes) -> None:
+ """Writes an 8 byte feature report"""
+
+
+CRC_OK_RESIDUAL = 0xF0B8
+
+
+[docs]def calculate_crc(data: bytes) -> int:
+ crc = 0xFFFF
+ for index in range(len(data)):
+ crc ^= data[index]
+ for i in range(8):
+ j = crc & 1
+ crc >>= 1
+ if j == 1:
+ crc ^= 0x8408
+ return crc & 0xFFFF
+
+
+
+
+
+[docs]def modhex_encode(data: bytes) -> str:
+ """Encode a bytes-like object using Modhex (modified hexadecimal) encoding."""
+ return "".join(MODHEX_ALPHABET[b >> 4] + MODHEX_ALPHABET[b & 0xF] for b in data)
+
+
+[docs]def modhex_decode(string: str) -> bytes:
+ """Decode the Modhex (modified hexadecimal) string."""
+ if len(string) % 2:
+ raise ValueError("Length must be a multiple of 2")
+
+ return bytes(
+ MODHEX_ALPHABET.index(string[i]) << 4 | MODHEX_ALPHABET.index(string[i + 1])
+ for i in range(0, len(string), 2)
+ )
+
+
+FEATURE_RPT_SIZE = 8
+FEATURE_RPT_DATA_SIZE = FEATURE_RPT_SIZE - 1
+
+SLOT_DATA_SIZE = 64
+FRAME_SIZE = SLOT_DATA_SIZE + 6
+
+RESP_PENDING_FLAG = 0x40 # Response pending flag
+SLOT_WRITE_FLAG = 0x80 # Write flag - set by app - cleared by device
+RESP_TIMEOUT_WAIT_FLAG = 0x20 # Waiting for timeout operation
+DUMMY_REPORT_WRITE = 0x8F # Write a dummy report to force update or abort
+
+SEQUENCE_MASK = 0x1F
+
+STATUS_OFFSET_PROG_SEQ = 0x4
+STATUS_OFFSET_TOUCH_LOW = 0x5
+CONFIG_STATUS_MASK = 0x1F
+
+STATUS_PROCESSING = 1
+STATUS_UPNEEDED = 2
+
+
+def _should_send(packet, seq):
+ """All-zero packets are skipped, except for the very first and last packets"""
+ return seq in (0, 9) or any(packet)
+
+
+def _format_frame(slot, payload):
+ return payload + struct.pack("<BH", slot, calculate_crc(payload)) + b"\0\0\0"
+
+
+[docs]class OtpProtocol:
+ """An implementation of the OTP protocol."""
+
+ def __init__(self, otp_connection: OtpConnection):
+ self.connection = otp_connection
+ report = self._receive()
+ self.version = Version.from_bytes(report[1:4])
+ if self.version[0] == 3: # NEO, may have cached pgmSeq in arbitrator
+ try: # Force communication with applet to refresh pgmSeq
+ # Write an invalid scan map, does nothing
+ self.send_and_receive(0x12, b"c" * 51)
+ except CommandRejectedError:
+ pass # This is expected
+
+
+
+[docs] def send_and_receive(
+ self,
+ slot: int,
+ data: Optional[bytes] = None,
+ event: Optional[Event] = None,
+ on_keepalive: Optional[Callable[[int], None]] = None,
+ ) -> bytes:
+ """Sends a command to the YubiKey, and reads the response.
+
+ If the command results in a configuration update, the programming sequence
+ number is verified and the updated status bytes are returned.
+
+ :param slot: The slot to send to.
+ :param data: The data payload to send.
+ :param state: Optional CommandState for listening for user presence requirement
+ and for cancelling a command.
+ :return: Response data (including CRC) in the case of data, or an updated status
+ struct.
+ """
+ payload = (data or b"").ljust(SLOT_DATA_SIZE, b"\0")
+ if len(payload) > SLOT_DATA_SIZE:
+ raise ValueError("Payload too large for HID frame")
+ if not on_keepalive:
+ on_keepalive = lambda x: None # noqa
+ frame = _format_frame(slot, payload)
+
+ logger.log(LOG_LEVEL.TRAFFIC, "SEND: %s", frame.hex())
+ response = self._read_frame(
+ self._send_frame(frame), event or Event(), on_keepalive
+ )
+ logger.log(LOG_LEVEL.TRAFFIC, "RECV: %s", response.hex())
+ return response
+
+ def _receive(self):
+ report = self.connection.receive()
+ if len(report) != FEATURE_RPT_SIZE:
+ raise Exception(
+ f"Incorrect reature report size (was {len(report)}, "
+ f"expected {FEATURE_RPT_SIZE})"
+ )
+ return report
+
+[docs] def read_status(self) -> bytes:
+ """Receive status bytes from YubiKey.
+
+ :return: Status bytes (first 3 bytes are the firmware version).
+ :raises IOException: in case of communication error.
+ """
+ return self._receive()[1:-1]
+
+ def _await_ready_to_write(self):
+ """Sleep for up to ~1s waiting for the WRITE flag to be unset"""
+ for _ in range(20):
+ if (self._receive()[FEATURE_RPT_DATA_SIZE] & SLOT_WRITE_FLAG) == 0:
+ return
+ sleep(0.05)
+ raise Exception("Timeout waiting for YubiKey to become ready to receive")
+
+ def _send_frame(self, buf):
+ """Sends a 70 byte frame"""
+ prog_seq = self._receive()[STATUS_OFFSET_PROG_SEQ]
+ seq = 0
+ while buf:
+ report, buf = buf[:FEATURE_RPT_DATA_SIZE], buf[FEATURE_RPT_DATA_SIZE:]
+ if _should_send(report, seq):
+ report += struct.pack(">B", 0x80 | seq)
+ self._await_ready_to_write()
+ self.connection.send(report)
+ seq += 1
+
+ return prog_seq
+
+ def _read_frame(self, prog_seq, event, on_keepalive):
+ """Reads one frame"""
+ response = b""
+ seq = 0
+ needs_touch = False
+
+ try:
+ while True:
+ report = self._receive()
+ status_byte = report[FEATURE_RPT_DATA_SIZE]
+ if (status_byte & RESP_PENDING_FLAG) != 0: # Response packet
+ if seq == (status_byte & SEQUENCE_MASK):
+ # Correct sequence
+ response += report[:FEATURE_RPT_DATA_SIZE]
+ seq += 1
+ elif 0 == (status_byte & SEQUENCE_MASK):
+ # Transmission complete
+ self._reset_state()
+ return response
+ elif status_byte == 0: # Status response
+ next_prog_seq = report[STATUS_OFFSET_PROG_SEQ]
+ if response:
+ raise Exception("Incomplete transfer")
+ elif next_prog_seq == prog_seq + 1 or (
+ prog_seq > 0
+ and next_prog_seq == 0
+ and report[STATUS_OFFSET_TOUCH_LOW] & CONFIG_STATUS_MASK == 0
+ ): # Note: If no valid configurations exist, prog_seq resets to 0.
+ # Sequence updated, return status.
+ return report[1:-1]
+ elif needs_touch:
+ raise TimeoutError("Timed out waiting for touch")
+ else:
+ raise CommandRejectedError("No data")
+ else: # Need to wait
+ if (status_byte & RESP_TIMEOUT_WAIT_FLAG) != 0:
+ on_keepalive(STATUS_UPNEEDED)
+ needs_touch = True
+ timeout = 0.1
+ else:
+ on_keepalive(STATUS_PROCESSING)
+ timeout = 0.02
+ sleep(timeout)
+ if event.wait(timeout):
+ self._reset_state()
+ raise TimeoutError("Command cancelled by Event")
+ except KeyboardInterrupt:
+ logger.debug("Keyboard interrupt, reset state...")
+ self._reset_state()
+ raise
+
+ def _reset_state(self):
+ """Reset the state of YubiKey from reading"""
+ self.connection.send(b"\xff".rjust(FEATURE_RPT_SIZE, b"\0"))
+
+# 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 . import (
+ Version,
+ TRANSPORT,
+ USB_INTERFACE,
+ Connection,
+ CommandError,
+ ApplicationNotAvailableError,
+)
+from time import time
+from enum import Enum, IntEnum, unique
+from typing import Tuple
+import abc
+import struct
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+[docs]class ApduError(CommandError):
+ """Thrown when an APDU response has the wrong SW code"""
+
+ def __init__(self, data: bytes, sw: int):
+ self.data = data
+ self.sw = sw
+
+ def __str__(self):
+ return f"APDU error: SW=0x{self.sw:04x}"
+
+
+[docs]@unique
+class ApduFormat(str, Enum):
+ """APDU encoding format"""
+
+ SHORT = "short"
+ EXTENDED = "extended"
+
+
+[docs]@unique
+class AID(bytes, Enum):
+ """YubiKey Application smart card AID values."""
+
+ OTP = bytes.fromhex("a0000005272001")
+ MANAGEMENT = bytes.fromhex("a000000527471117")
+ OPENPGP = bytes.fromhex("d27600012401")
+ OATH = bytes.fromhex("a0000005272101")
+ PIV = bytes.fromhex("a000000308")
+ FIDO = bytes.fromhex("a0000006472f0001")
+ HSMAUTH = bytes.fromhex("a000000527210701")
+
+
+[docs]@unique
+class SW(IntEnum):
+ NO_INPUT_DATA = 0x6285
+ VERIFY_FAIL_NO_RETRY = 0x63C0
+ WRONG_LENGTH = 0x6700
+ SECURITY_CONDITION_NOT_SATISFIED = 0x6982
+ AUTH_METHOD_BLOCKED = 0x6983
+ DATA_INVALID = 0x6984
+ CONDITIONS_NOT_SATISFIED = 0x6985
+ COMMAND_NOT_ALLOWED = 0x6986
+ INCORRECT_PARAMETERS = 0x6A80
+ FUNCTION_NOT_SUPPORTED = 0x6A81
+ FILE_NOT_FOUND = 0x6A82
+ NO_SPACE = 0x6A84
+ REFERENCE_DATA_NOT_FOUND = 0x6A88
+ APPLET_SELECT_FAILED = 0x6999
+ WRONG_PARAMETERS_P1P2 = 0x6B00
+ INVALID_INSTRUCTION = 0x6D00
+ COMMAND_ABORTED = 0x6F00
+ OK = 0x9000
+
+
+[docs]class SmartCardConnection(Connection, metaclass=abc.ABCMeta):
+ usb_interface = USB_INTERFACE.CCID
+
+ @property
+ @abc.abstractmethod
+ def transport(self) -> TRANSPORT:
+ """Get the transport type of the connection (USB or NFC)"""
+
+[docs] @abc.abstractmethod
+ def send_and_receive(self, apdu: bytes) -> Tuple[bytes, int]:
+ """Sends a command APDU and returns the response"""
+
+
+INS_SELECT = 0xA4
+P1_SELECT = 0x04
+P2_SELECT = 0x00
+
+INS_SEND_REMAINING = 0xC0
+SW1_HAS_MORE_DATA = 0x61
+
+SHORT_APDU_MAX_CHUNK = 0xFF
+
+
+def _encode_short_apdu(cla, ins, p1, p2, data, le=0):
+ buf = struct.pack(">BBBBB", cla, ins, p1, p2, len(data)) + data
+ if le:
+ buf += struct.pack(">B", le)
+ return buf
+
+
+def _encode_extended_apdu(cla, ins, p1, p2, data, le=0):
+ buf = struct.pack(">BBBBBH", cla, ins, p1, p2, 0, len(data)) + data
+ if le:
+ buf += struct.pack(">H", le)
+ return buf
+
+
+[docs]class SmartCardProtocol:
+ """An implementation of the Smart Card protocol."""
+
+ def __init__(
+ self,
+ smartcard_connection: SmartCardConnection,
+ ins_send_remaining: int = INS_SEND_REMAINING,
+ ):
+ self.apdu_format = ApduFormat.SHORT
+ self.connection = smartcard_connection
+ self._ins_send_remaining = ins_send_remaining
+ self._touch_workaround = False
+ self._last_long_resp = 0.0
+
+
+
+[docs] def enable_touch_workaround(self, version: Version) -> None:
+ self._touch_workaround = self.connection.transport == TRANSPORT.USB and (
+ (4, 2, 0) <= version <= (4, 2, 6)
+ )
+ logger.debug(f"Touch workaround enabled={self._touch_workaround}")
+
+[docs] def select(self, aid: bytes) -> bytes:
+ """Perform a SELECT instruction.
+
+ :param aid: The YubiKey application AID value.
+ """
+ try:
+ return self.send_apdu(0, INS_SELECT, P1_SELECT, P2_SELECT, aid)
+ except ApduError as e:
+ if e.sw in (
+ SW.FILE_NOT_FOUND,
+ SW.APPLET_SELECT_FAILED,
+ SW.INVALID_INSTRUCTION,
+ SW.WRONG_PARAMETERS_P1P2,
+ ):
+ raise ApplicationNotAvailableError()
+ raise
+
+[docs] def send_apdu(
+ self, cla: int, ins: int, p1: int, p2: int, data: bytes = b"", le: int = 0
+ ) -> bytes:
+ """Send APDU message.
+
+ :param cla: The instruction class.
+ :param ins: The instruction code.
+ :param p1: The instruction parameter.
+ :param p2: The instruction parameter.
+ :param data: The command data in bytes.
+ :param le: The maximum number of bytes in the data
+ field of the response.
+ """
+ if (
+ self._touch_workaround
+ and self._last_long_resp > 0
+ and time() - self._last_long_resp < 2
+ ):
+ logger.debug("Sending dummy APDU as touch workaround")
+ self.connection.send_and_receive(
+ _encode_short_apdu(0, 0, 0, 0, b"")
+ ) # Dummy APDU, returns error
+ self._last_long_resp = 0
+
+ if self.apdu_format is ApduFormat.SHORT:
+ while len(data) > SHORT_APDU_MAX_CHUNK:
+ chunk, data = data[:SHORT_APDU_MAX_CHUNK], data[SHORT_APDU_MAX_CHUNK:]
+ response, sw = self.connection.send_and_receive(
+ _encode_short_apdu(0x10 | cla, ins, p1, p2, chunk, le)
+ )
+ if sw != SW.OK:
+ raise ApduError(response, sw)
+ response, sw = self.connection.send_and_receive(
+ _encode_short_apdu(cla, ins, p1, p2, data, le)
+ )
+ get_data = _encode_short_apdu(0, self._ins_send_remaining, 0, 0, b"")
+ elif self.apdu_format is ApduFormat.EXTENDED:
+ response, sw = self.connection.send_and_receive(
+ _encode_extended_apdu(cla, ins, p1, p2, data, le)
+ )
+ get_data = _encode_extended_apdu(0, self._ins_send_remaining, 0, 0, b"")
+ else:
+ raise TypeError("Invalid ApduFormat set")
+
+ # Read chained response
+ buf = b""
+ while sw >> 8 == SW1_HAS_MORE_DATA:
+ buf += response
+ response, sw = self.connection.send_and_receive(get_data)
+
+ if sw != SW.OK:
+ raise ApduError(response, sw)
+ buf += response
+
+ if self._touch_workaround and len(buf) > 54:
+ self._last_long_resp = time()
+ else:
+ self._last_long_resp = 0
+
+ return buf
+
+# Copyright (c) 2023 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 .core import (
+ int2bytes,
+ bytes2int,
+ require_version,
+ Version,
+ Tlv,
+ InvalidPinError,
+)
+from .core.smartcard import AID, SmartCardConnection, SmartCardProtocol, ApduError, SW
+
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
+from cryptography.hazmat.primitives.asymmetric import ec
+
+
+from functools import total_ordering
+from enum import IntEnum, unique
+from dataclasses import dataclass
+from typing import Optional, List, Union, Tuple, NamedTuple
+import struct
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+# TLV tags for credential data
+TAG_LABEL = 0x71
+TAG_LABEL_LIST = 0x72
+TAG_CREDENTIAL_PASSWORD = 0x73
+TAG_ALGORITHM = 0x74
+TAG_KEY_ENC = 0x75
+TAG_KEY_MAC = 0x76
+TAG_CONTEXT = 0x77
+TAG_RESPONSE = 0x78
+TAG_VERSION = 0x79
+TAG_TOUCH = 0x7A
+TAG_MANAGEMENT_KEY = 0x7B
+TAG_PUBLIC_KEY = 0x7C
+TAG_PRIVATE_KEY = 0x7D
+
+# Instruction bytes for commands
+INS_PUT = 0x01
+INS_DELETE = 0x02
+INS_CALCULATE = 0x03
+INS_GET_CHALLENGE = 0x04
+INS_LIST = 0x05
+INS_RESET = 0x06
+INS_GET_VERSION = 0x07
+INS_PUT_MANAGEMENT_KEY = 0x08
+INS_GET_MANAGEMENT_KEY_RETRIES = 0x09
+INS_GET_PUBLIC_KEY = 0x0A
+
+# Lengths for paramters
+MANAGEMENT_KEY_LEN = 16
+CREDENTIAL_PASSWORD_LEN = 16
+MIN_LABEL_LEN = 1
+MAX_LABEL_LEN = 64
+
+DEFAULT_MANAGEMENT_KEY = (
+ b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
+)
+
+INITIAL_RETRY_COUNTER = 8
+
+
+[docs]@unique
+class ALGORITHM(IntEnum):
+ """Algorithms for YubiHSM Auth credentials."""
+
+ AES128_YUBICO_AUTHENTICATION = 38
+ EC_P256_YUBICO_AUTHENTICATION = 39
+
+ @property
+ def key_len(self):
+ if self.name.startswith("AES128"):
+ return 16
+ elif self.name.startswith("EC_P256"):
+ return 32
+
+ @property
+ def pubkey_len(self):
+ if self.name.startswith("EC_P256"):
+ return 64
+
+
+def _parse_credential_password(credential_password: Union[bytes, str]) -> bytes:
+ if isinstance(credential_password, str):
+ pw = credential_password.encode().ljust(CREDENTIAL_PASSWORD_LEN, b"\0")
+ else:
+ pw = bytes(credential_password)
+
+ if len(pw) != CREDENTIAL_PASSWORD_LEN:
+ raise ValueError(
+ "Credential password must be %d bytes long" % CREDENTIAL_PASSWORD_LEN
+ )
+ return pw
+
+
+def _parse_label(label: str) -> bytes:
+ try:
+ parsed_label = label.encode()
+ except Exception:
+ raise ValueError(label)
+
+ if len(parsed_label) < MIN_LABEL_LEN or len(parsed_label) > MAX_LABEL_LEN:
+ raise ValueError(
+ "Label must be between %d and %d bytes long"
+ % (MIN_LABEL_LEN, MAX_LABEL_LEN)
+ )
+ return parsed_label
+
+
+def _parse_select(response):
+ data = Tlv.unpack(TAG_VERSION, response)
+ return Version.from_bytes(data)
+
+
+def _password_to_key(password: str) -> Tuple[bytes, bytes]:
+ """Derive encryption and MAC key from a password.
+
+ :return: A tuple containing the encryption key, and MAC key.
+ """
+ pw_bytes = password.encode()
+
+ key = PBKDF2HMAC(
+ algorithm=hashes.SHA256(),
+ length=32,
+ salt=b"Yubico",
+ iterations=10000,
+ backend=default_backend(),
+ ).derive(pw_bytes)
+ key_enc, key_mac = key[:16], key[16:]
+ return key_enc, key_mac
+
+
+def _retries_from_sw(sw):
+ if sw & 0xFFF0 == SW.VERIFY_FAIL_NO_RETRY:
+ return sw & ~0xFFF0
+ return None
+
+
+[docs]@total_ordering
+@dataclass(order=False, frozen=True)
+class Credential:
+ """A YubiHSM Auth credential object."""
+
+ label: str
+ algorithm: ALGORITHM
+ counter: int
+ touch_required: Optional[bool]
+
+ def __lt__(self, other):
+ a = self.label.lower()
+ b = other.label.lower()
+ return a < b
+
+ def __eq__(self, other):
+ return self.label == other.label
+
+ def __hash__(self) -> int:
+ return hash(self.label)
+
+
+[docs]class SessionKeys(NamedTuple):
+ """YubiHSM Session Keys."""
+
+ key_senc: bytes
+ key_smac: bytes
+ key_srmac: bytes
+
+[docs] @classmethod
+ def parse(cls, response: bytes) -> "SessionKeys":
+ key_senc = response[:16]
+ key_smac = response[16:32]
+ key_srmac = response[32:48]
+
+ return cls(
+ key_senc=key_senc,
+ key_smac=key_smac,
+ key_srmac=key_srmac,
+ )
+
+
+[docs]class HsmAuthSession:
+ """A session with the YubiHSM Auth application."""
+
+ def __init__(self, connection: SmartCardConnection) -> None:
+ self.protocol = SmartCardProtocol(connection)
+ self._version = _parse_select(self.protocol.select(AID.HSMAUTH))
+
+ @property
+ def version(self) -> Version:
+ """The YubiHSM Auth application version."""
+ return self._version
+
+[docs] def reset(self) -> None:
+ """Perform a factory reset on the YubiHSM Auth application."""
+ self.protocol.send_apdu(0, INS_RESET, 0xDE, 0xAD)
+ logger.info("YubiHSM Auth application data reset performed")
+
+[docs] def list_credentials(self) -> List[Credential]:
+ """List YubiHSM Auth credentials on YubiKey"""
+
+ creds = []
+ for tlv in Tlv.parse_list(self.protocol.send_apdu(0, INS_LIST, 0, 0)):
+ data = Tlv.unpack(TAG_LABEL_LIST, tlv)
+ algorithm = ALGORITHM(data[0])
+ touch_required = bool(data[1])
+ label_length = tlv.length - 3
+ label = data[2 : 2 + label_length].decode()
+ counter = data[-1]
+
+ creds.append(Credential(label, algorithm, counter, touch_required))
+ return creds
+
+ def _put_credential(
+ self,
+ management_key: bytes,
+ label: str,
+ key: bytes,
+ algorithm: ALGORITHM,
+ credential_password: Union[bytes, str],
+ touch_required: bool = False,
+ ) -> Credential:
+
+ if len(management_key) != MANAGEMENT_KEY_LEN:
+ raise ValueError(
+ "Management key must be %d bytes long" % MANAGEMENT_KEY_LEN
+ )
+
+ data = (
+ Tlv(TAG_MANAGEMENT_KEY, management_key)
+ + Tlv(TAG_LABEL, _parse_label(label))
+ + Tlv(TAG_ALGORITHM, int2bytes(algorithm))
+ )
+
+ if algorithm == ALGORITHM.AES128_YUBICO_AUTHENTICATION:
+ data += Tlv(TAG_KEY_ENC, key[:16]) + Tlv(TAG_KEY_MAC, key[16:])
+ elif algorithm == ALGORITHM.EC_P256_YUBICO_AUTHENTICATION:
+ data += Tlv(TAG_PRIVATE_KEY, key)
+
+ data += Tlv(
+ TAG_CREDENTIAL_PASSWORD, _parse_credential_password(credential_password)
+ )
+
+ if touch_required:
+ data += Tlv(TAG_TOUCH, int2bytes(1))
+ else:
+ data += Tlv(TAG_TOUCH, int2bytes(0))
+
+ logger.debug(
+ f"Importing YubiHSM Auth credential (label={label}, algo={algorithm}, "
+ f"touch_required={touch_required})"
+ )
+ try:
+ self.protocol.send_apdu(0, INS_PUT, 0, 0, data)
+ logger.info("Credential imported")
+ except ApduError as e:
+ retries = _retries_from_sw(e.sw)
+ if retries is None:
+ raise
+ raise InvalidPinError(
+ attempts_remaining=retries,
+ message=f"Invalid management key, {retries} attempts remaining",
+ )
+
+ return Credential(label, algorithm, INITIAL_RETRY_COUNTER, touch_required)
+
+[docs] def put_credential_symmetric(
+ self,
+ management_key: bytes,
+ label: str,
+ key_enc: bytes,
+ key_mac: bytes,
+ credential_password: Union[bytes, str],
+ touch_required: bool = False,
+ ) -> Credential:
+ """Import a symmetric YubiHSM Auth credential.
+
+ :param management_key: The management key.
+ :param label: The label of the credential.
+ :param key_enc: The static K-ENC.
+ :param key_mac: The static K-MAC.
+ :param credential_password: The password used to protect
+ access to the credential.
+ :param touch_required: The touch requirement policy.
+ """
+
+ aes128_key_len = ALGORITHM.AES128_YUBICO_AUTHENTICATION.key_len
+ if len(key_enc) != aes128_key_len or len(key_mac) != aes128_key_len:
+ raise ValueError(
+ "Encryption and MAC key must be %d bytes long", aes128_key_len
+ )
+
+ return self._put_credential(
+ management_key,
+ label,
+ key_enc + key_mac,
+ ALGORITHM.AES128_YUBICO_AUTHENTICATION,
+ credential_password,
+ touch_required,
+ )
+
+[docs] def put_credential_derived(
+ self,
+ management_key: bytes,
+ label: str,
+ derivation_password: str,
+ credential_password: Union[bytes, str],
+ touch_required: bool = False,
+ ) -> Credential:
+ """Import a symmetric YubiHSM Auth credential derived from password.
+
+ :param management_key: The management key.
+ :param label: The label of the credential.
+ :param derivation_password: The password used to derive the keys from.
+ :param credential_password: The password used to protect
+ access to the credential.
+ :param touch_required: The touch requirement policy.
+ """
+
+ key_enc, key_mac = _password_to_key(derivation_password)
+
+ return self.put_credential_symmetric(
+ management_key, label, key_enc, key_mac, credential_password, touch_required
+ )
+
+[docs] def put_credential_asymmetric(
+ self,
+ management_key: bytes,
+ label: str,
+ private_key: ec.EllipticCurvePrivateKeyWithSerialization,
+ credential_password: Union[bytes, str],
+ touch_required: bool = False,
+ ) -> Credential:
+ """Import an asymmetric YubiHSM Auth credential.
+
+ :param management_key: The management key.
+ :param label: The label of the credential.
+ :param private_key: Private key corresponding to the public
+ authentication key object on the YubiHSM.
+ :param credential_password: The password used to protect
+ access to the credential.
+ :param touch_required: The touch requirement policy.
+ """
+
+ require_version(self.version, (5, 6, 0))
+ if not isinstance(private_key.curve, ec.SECP256R1):
+ raise ValueError("Unsupported curve")
+
+ ln = ALGORITHM.EC_P256_YUBICO_AUTHENTICATION.key_len
+ numbers = private_key.private_numbers()
+
+ return self._put_credential(
+ management_key,
+ label,
+ int2bytes(numbers.private_value, ln),
+ ALGORITHM.EC_P256_YUBICO_AUTHENTICATION,
+ credential_password,
+ touch_required,
+ )
+
+[docs] def generate_credential_asymmetric(
+ self,
+ management_key: bytes,
+ label: str,
+ credential_password: Union[bytes, str],
+ touch_required: bool = False,
+ ) -> Credential:
+ """Generate an asymmetric YubiHSM Auth credential.
+
+ Generates a private key on the YubiKey, whose corresponding
+ public key can be retrieved using `get_public_key`.
+
+ :param management_key: The management key.
+ :param label: The label of the credential.
+ :param credential_password: The password used to protect
+ access to the credential.
+ :param touch_required: The touch requirement policy.
+ """
+
+ require_version(self.version, (5, 6, 0))
+ return self._put_credential(
+ management_key,
+ label,
+ b"", # Emtpy byte will generate key
+ ALGORITHM.EC_P256_YUBICO_AUTHENTICATION,
+ credential_password,
+ touch_required,
+ )
+
+[docs] def get_public_key(self, label: str) -> ec.EllipticCurvePublicKey:
+ """Get the public key for an asymmetric credential.
+
+ This will return the long-term public key "PK-OCE" for an
+ asymmetric credential.
+
+ :param label: The label of the credential.
+ """
+ require_version(self.version, (5, 6, 0))
+ data = Tlv(TAG_LABEL, _parse_label(label))
+ res = self.protocol.send_apdu(0, INS_GET_PUBLIC_KEY, 0, 0, data)
+
+ return ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), res)
+
+[docs] def delete_credential(self, management_key: bytes, label: str) -> None:
+ """Delete a YubiHSM Auth credential.
+
+ :param management_key: The management key.
+ :param label: The label of the credential.
+ """
+
+ if len(management_key) != MANAGEMENT_KEY_LEN:
+ raise ValueError(
+ "Management key must be %d bytes long" % MANAGEMENT_KEY_LEN
+ )
+
+ data = Tlv(TAG_MANAGEMENT_KEY, management_key) + Tlv(
+ TAG_LABEL, _parse_label(label)
+ )
+
+ try:
+ self.protocol.send_apdu(0, INS_DELETE, 0, 0, data)
+ logger.info("Credential deleted")
+ except ApduError as e:
+ retries = _retries_from_sw(e.sw)
+ if retries is None:
+ raise
+ raise InvalidPinError(
+ attempts_remaining=retries,
+ message=f"Invalid management key, {retries} attempts remaining",
+ )
+
+[docs] def put_management_key(
+ self,
+ management_key: bytes,
+ new_management_key: bytes,
+ ) -> None:
+ """Change YubiHSM Auth management key
+
+ :param management_key: The current management key.
+ :param new_management_key: The new management key.
+ """
+
+ if (
+ len(management_key) != MANAGEMENT_KEY_LEN
+ or len(new_management_key) != MANAGEMENT_KEY_LEN
+ ):
+ raise ValueError(
+ "Management key must be %d bytes long" % MANAGEMENT_KEY_LEN
+ )
+
+ data = Tlv(TAG_MANAGEMENT_KEY, management_key) + Tlv(
+ TAG_MANAGEMENT_KEY, new_management_key
+ )
+
+ try:
+ self.protocol.send_apdu(0, INS_PUT_MANAGEMENT_KEY, 0, 0, data)
+ logger.info("New management key set")
+ except ApduError as e:
+ retries = _retries_from_sw(e.sw)
+ if retries is None:
+ raise
+ raise InvalidPinError(
+ attempts_remaining=retries,
+ message=f"Invalid management key, {retries} attempts remaining",
+ )
+
+[docs] def get_management_key_retries(self) -> int:
+ """Get retries remaining for Management key"""
+
+ res = self.protocol.send_apdu(0, INS_GET_MANAGEMENT_KEY_RETRIES, 0, 0)
+ return bytes2int(res)
+
+ def _calculate_session_keys(
+ self,
+ label: str,
+ context: bytes,
+ credential_password: Union[bytes, str],
+ card_crypto: Optional[bytes] = None,
+ public_key: Optional[bytes] = None,
+ ) -> bytes:
+
+ data = Tlv(TAG_LABEL, _parse_label(label)) + Tlv(TAG_CONTEXT, context)
+
+ if public_key:
+ data += Tlv(TAG_PUBLIC_KEY, public_key)
+
+ if card_crypto:
+ data += Tlv(TAG_RESPONSE, card_crypto)
+
+ data += Tlv(
+ TAG_CREDENTIAL_PASSWORD, _parse_credential_password(credential_password)
+ )
+
+ try:
+ res = self.protocol.send_apdu(0, INS_CALCULATE, 0, 0, data)
+ logger.info("Session keys calculated")
+ except ApduError as e:
+ retries = _retries_from_sw(e.sw)
+ if retries is None:
+ raise
+ raise InvalidPinError(
+ attempts_remaining=retries,
+ message=f"Invalid credential password, {retries} attempts remaining",
+ )
+
+ return res
+
+[docs] def calculate_session_keys_symmetric(
+ self,
+ label: str,
+ context: bytes,
+ credential_password: Union[bytes, str],
+ card_crypto: Optional[bytes] = None,
+ ) -> SessionKeys:
+ """Calculate session keys from a symmetric YubiHSM Auth credential.
+
+ :param label: The label of the credential.
+ :param context: The context (host challenge + hsm challenge).
+ :param credential_password: The password used to protect
+ access to the credential.
+ :param card_crypto: The card cryptogram.
+ """
+
+ return SessionKeys.parse(
+ self._calculate_session_keys(
+ label=label,
+ context=context,
+ credential_password=credential_password,
+ card_crypto=card_crypto,
+ )
+ )
+
+[docs] def calculate_session_keys_asymmetric(
+ self,
+ label: str,
+ context: bytes,
+ public_key: ec.EllipticCurvePublicKey,
+ credential_password: Union[bytes, str],
+ card_crypto: bytes,
+ ) -> SessionKeys:
+ """Calculate session keys from an asymmetric YubiHSM Auth credential.
+
+ :param label: The label of the credential.
+ :param context: The context (EPK.OCE + EPK.SD).
+ :param public_key: The YubiHSM device's public key.
+ :param credential_password: The password used to protect
+ access to the credential.
+ :param card_crypto: The card cryptogram.
+ """
+
+ require_version(self.version, (5, 6, 0))
+ if not isinstance(public_key.curve, ec.SECP256R1):
+ raise ValueError("Unsupported curve")
+
+ numbers = public_key.public_numbers()
+
+ public_key_data = (
+ struct.pack("!B", 4)
+ + int.to_bytes(numbers.x, public_key.key_size // 8, "big")
+ + int.to_bytes(numbers.y, public_key.key_size // 8, "big")
+ )
+
+ return SessionKeys.parse(
+ self._calculate_session_keys(
+ label=label,
+ context=context,
+ credential_password=credential_password,
+ card_crypto=card_crypto,
+ public_key=public_key_data,
+ )
+ )
+
+[docs] def get_challenge(self, label: str) -> bytes:
+ """Get the Host Challenge.
+
+ For symmetric credentials this is Host Challenge, a random
+ 8 byte value. For asymmetric credentials this is EPK-OCE.
+
+ :param label: The label of the credential.
+ """
+ require_version(self.version, (5, 6, 0))
+ data = Tlv(TAG_LABEL, _parse_label(label))
+ return self.protocol.send_apdu(0, INS_GET_CHALLENGE, 0, 0, data)
+
+# Copyright (c) 2022 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 IntEnum, unique
+import logging
+
+
+[docs]@unique
+class LOG_LEVEL(IntEnum):
+ ERROR = logging.ERROR
+ WARNING = logging.WARNING
+ INFO = logging.INFO
+ DEBUG = logging.DEBUG
+ TRAFFIC = 5 # Used for logging YubiKey traffic
+ NOTSET = logging.NOTSET
+
+# 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 .core import (
+ bytes2int,
+ int2bytes,
+ require_version,
+ Version,
+ Tlv,
+ TRANSPORT,
+ USB_INTERFACE,
+ NotSupportedError,
+ BadResponseError,
+ ApplicationNotAvailableError,
+)
+from .core.otp import (
+ check_crc,
+ OtpConnection,
+ OtpProtocol,
+ STATUS_OFFSET_PROG_SEQ,
+ CommandRejectedError,
+)
+from .core.fido import FidoConnection
+from .core.smartcard import AID, SmartCardConnection, SmartCardProtocol
+from fido2.hid import CAPABILITY as CTAP_CAPABILITY
+
+from enum import IntEnum, IntFlag, unique
+from dataclasses import dataclass
+from typing import Optional, Union, Mapping
+import abc
+import struct
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+[docs]@unique
+class CAPABILITY(IntFlag):
+ """YubiKey Application identifiers."""
+
+ OTP = 0x01
+ U2F = 0x02
+ FIDO2 = 0x200
+ OATH = 0x20
+ PIV = 0x10
+ OPENPGP = 0x08
+ HSMAUTH = 0x100
+
+ def __str__(self):
+ name = "|".join(c.name or str(c) for c in CAPABILITY if c in self)
+ return f"{name}: {hex(self)}"
+
+ @property
+ def display_name(self):
+ if self == CAPABILITY.U2F:
+ return "FIDO U2F"
+ elif self == CAPABILITY.OPENPGP:
+ return "OpenPGP"
+ elif self == CAPABILITY.HSMAUTH:
+ return "YubiHSM Auth"
+ return self.name or ", ".join(c.display_name for c in CAPABILITY if c in self)
+
+ @property
+ def usb_interfaces(self) -> USB_INTERFACE:
+ ifaces = USB_INTERFACE(0)
+ if self & CAPABILITY.OTP:
+ ifaces |= USB_INTERFACE.OTP
+ if self & (CAPABILITY.U2F | CAPABILITY.FIDO2):
+ ifaces |= USB_INTERFACE.FIDO
+ if self & (
+ CAPABILITY.OATH | CAPABILITY.PIV | CAPABILITY.OPENPGP | CAPABILITY.HSMAUTH
+ ):
+ ifaces |= USB_INTERFACE.CCID
+ return ifaces
+
+
+[docs]@unique
+class FORM_FACTOR(IntEnum):
+ """YubiKey device form factors."""
+
+ UNKNOWN = 0x00
+ USB_A_KEYCHAIN = 0x01
+ USB_A_NANO = 0x02
+ USB_C_KEYCHAIN = 0x03
+ USB_C_NANO = 0x04
+ USB_C_LIGHTNING = 0x05
+ USB_A_BIO = 0x06
+ USB_C_BIO = 0x07
+
+ def __str__(self):
+ if self == FORM_FACTOR.USB_A_KEYCHAIN:
+ return "Keychain (USB-A)"
+ elif self == FORM_FACTOR.USB_A_NANO:
+ return "Nano (USB-A)"
+ elif self == FORM_FACTOR.USB_C_KEYCHAIN:
+ return "Keychain (USB-C)"
+ elif self == FORM_FACTOR.USB_C_NANO:
+ return "Nano (USB-C)"
+ elif self == FORM_FACTOR.USB_C_LIGHTNING:
+ return "Keychain (USB-C, Lightning)"
+ elif self == FORM_FACTOR.USB_A_BIO:
+ return "Bio (USB-A)"
+ elif self == FORM_FACTOR.USB_C_BIO:
+ return "Bio (USB-C)"
+ else:
+ return "Unknown"
+
+[docs] @classmethod
+ def from_code(cls, code: int) -> "FORM_FACTOR":
+ if code and not isinstance(code, int):
+ raise ValueError(f"Invalid form factor code: {code}")
+ code &= 0xF
+ return cls(code) if code in cls.__members__.values() else cls.UNKNOWN
+
+
+[docs]@unique
+class DEVICE_FLAG(IntFlag):
+ """Configuration flags."""
+
+ REMOTE_WAKEUP = 0x40
+ EJECT = 0x80
+
+
+TAG_USB_SUPPORTED = 0x01
+TAG_SERIAL = 0x02
+TAG_USB_ENABLED = 0x03
+TAG_FORM_FACTOR = 0x04
+TAG_VERSION = 0x05
+TAG_AUTO_EJECT_TIMEOUT = 0x06
+TAG_CHALRESP_TIMEOUT = 0x07
+TAG_DEVICE_FLAGS = 0x08
+TAG_APP_VERSIONS = 0x09
+TAG_CONFIG_LOCK = 0x0A
+TAG_UNLOCK = 0x0B
+TAG_REBOOT = 0x0C
+TAG_NFC_SUPPORTED = 0x0D
+TAG_NFC_ENABLED = 0x0E
+
+
+[docs]@dataclass
+class DeviceConfig:
+ """Management settings for YubiKey which can be configured by the user."""
+
+ enabled_capabilities: Mapping[TRANSPORT, CAPABILITY]
+ auto_eject_timeout: Optional[int]
+ challenge_response_timeout: Optional[int]
+ device_flags: Optional[DEVICE_FLAG]
+
+[docs] def get_bytes(
+ self,
+ reboot: bool,
+ cur_lock_code: Optional[bytes] = None,
+ new_lock_code: Optional[bytes] = None,
+ ) -> bytes:
+ buf = b""
+ if reboot:
+ buf += Tlv(TAG_REBOOT)
+ if cur_lock_code:
+ buf += Tlv(TAG_UNLOCK, cur_lock_code)
+ usb_enabled = self.enabled_capabilities.get(TRANSPORT.USB)
+ if usb_enabled is not None:
+ buf += Tlv(TAG_USB_ENABLED, int2bytes(usb_enabled, 2))
+ nfc_enabled = self.enabled_capabilities.get(TRANSPORT.NFC)
+ if nfc_enabled is not None:
+ buf += Tlv(TAG_NFC_ENABLED, int2bytes(nfc_enabled, 2))
+ if self.auto_eject_timeout is not None:
+ buf += Tlv(TAG_AUTO_EJECT_TIMEOUT, int2bytes(self.auto_eject_timeout, 2))
+ if self.challenge_response_timeout is not None:
+ buf += Tlv(TAG_CHALRESP_TIMEOUT, int2bytes(self.challenge_response_timeout))
+ if self.device_flags is not None:
+ buf += Tlv(TAG_DEVICE_FLAGS, int2bytes(self.device_flags))
+ if new_lock_code:
+ buf += Tlv(TAG_CONFIG_LOCK, new_lock_code)
+ if len(buf) > 0xFF:
+ raise NotSupportedError("DeviceConfiguration too large")
+ return int2bytes(len(buf)) + buf
+
+
+[docs]@dataclass
+class DeviceInfo:
+ """Information about a YubiKey readable using the ManagementSession."""
+
+ config: DeviceConfig
+ serial: Optional[int]
+ version: Version
+ form_factor: FORM_FACTOR
+ supported_capabilities: Mapping[TRANSPORT, CAPABILITY]
+ is_locked: bool
+ is_fips: bool = False
+ is_sky: bool = False
+
+[docs] def has_transport(self, transport: TRANSPORT) -> bool:
+ return transport in self.supported_capabilities
+
+[docs] @classmethod
+ def parse(cls, encoded: bytes, default_version: Version) -> "DeviceInfo":
+ if len(encoded) - 1 != encoded[0]:
+ raise BadResponseError("Invalid length")
+ data = Tlv.parse_dict(encoded[1:])
+ locked = data.get(TAG_CONFIG_LOCK) == b"\1"
+ serial = bytes2int(data.get(TAG_SERIAL, b"\0")) or None
+ ff_value = bytes2int(data.get(TAG_FORM_FACTOR, b"\0"))
+ form_factor = FORM_FACTOR.from_code(ff_value)
+ fips = bool(ff_value & 0x80)
+ sky = bool(ff_value & 0x40)
+ if TAG_VERSION in data:
+ version = Version.from_bytes(data[TAG_VERSION])
+ else:
+ version = default_version
+ auto_eject_to = bytes2int(data.get(TAG_AUTO_EJECT_TIMEOUT, b"\0"))
+ chal_resp_to = bytes2int(data.get(TAG_CHALRESP_TIMEOUT, b"\0"))
+ flags = DEVICE_FLAG(bytes2int(data.get(TAG_DEVICE_FLAGS, b"\0")))
+
+ supported = {}
+ enabled = {}
+
+ if version == (4, 2, 4): # Doesn't report correctly
+ supported[TRANSPORT.USB] = CAPABILITY(0x3F)
+ else:
+ supported[TRANSPORT.USB] = CAPABILITY(bytes2int(data[TAG_USB_SUPPORTED]))
+ if TAG_USB_ENABLED in data: # From YK 5.0.0
+ if not ((4, 0, 0) <= version < (5, 0, 0)): # Broken on YK4
+ enabled[TRANSPORT.USB] = CAPABILITY(bytes2int(data[TAG_USB_ENABLED]))
+ if TAG_NFC_SUPPORTED in data: # YK with NFC
+ supported[TRANSPORT.NFC] = CAPABILITY(bytes2int(data[TAG_NFC_SUPPORTED]))
+ enabled[TRANSPORT.NFC] = CAPABILITY(bytes2int(data[TAG_NFC_ENABLED]))
+
+ return cls(
+ DeviceConfig(enabled, auto_eject_to, chal_resp_to, flags),
+ serial,
+ version,
+ form_factor,
+ supported,
+ locked,
+ fips,
+ sky,
+ )
+
+
+_MODES = [
+ USB_INTERFACE.OTP, # 0x00
+ USB_INTERFACE.CCID, # 0x01
+ USB_INTERFACE.OTP | USB_INTERFACE.CCID, # 0x02
+ USB_INTERFACE.FIDO, # 0x03
+ USB_INTERFACE.OTP | USB_INTERFACE.FIDO, # 0x04
+ USB_INTERFACE.FIDO | USB_INTERFACE.CCID, # 0x05
+ USB_INTERFACE.OTP | USB_INTERFACE.FIDO | USB_INTERFACE.CCID, # 0x06
+]
+
+
+[docs]@dataclass(init=False, repr=False)
+class Mode:
+ """YubiKey USB Mode configuration for use with YubiKey NEO and 4."""
+
+ code: int
+ interfaces: USB_INTERFACE
+
+ def __init__(self, interfaces: USB_INTERFACE):
+ try:
+ self.code = _MODES.index(interfaces)
+ self.interfaces = USB_INTERFACE(interfaces)
+ except ValueError:
+ raise ValueError("Invalid mode!")
+
+ def __repr__(self):
+ return "+".join(t.name or str(t) for t in USB_INTERFACE if t in self.interfaces)
+
+[docs] @classmethod
+ def from_code(cls, code: int) -> "Mode":
+ # Mode is determined from the lowest 3 bits
+ try:
+ return cls(_MODES[code & 0b00000111])
+ except IndexError:
+ raise ValueError("Invalid mode code")
+
+
+SLOT_DEVICE_CONFIG = 0x11
+SLOT_YK4_CAPABILITIES = 0x13
+SLOT_YK4_SET_DEVICE_INFO = 0x15
+
+
+class _Backend(abc.ABC):
+ version: Version
+
+ @abc.abstractmethod
+ def close(self) -> None:
+ ...
+
+ @abc.abstractmethod
+ def set_mode(self, data: bytes) -> None:
+ ...
+
+ @abc.abstractmethod
+ def read_config(self) -> bytes:
+ ...
+
+ @abc.abstractmethod
+ def write_config(self, config: bytes) -> None:
+ ...
+
+
+class _ManagementOtpBackend(_Backend):
+ def __init__(self, otp_connection):
+ self.protocol = OtpProtocol(otp_connection)
+ self.version = self.protocol.version
+ if (1, 0, 0) <= self.version < (3, 0, 0):
+ raise ApplicationNotAvailableError()
+
+ def close(self):
+ self.protocol.close()
+
+ def set_mode(self, data):
+ empty = self.protocol.read_status()[STATUS_OFFSET_PROG_SEQ] == 0
+ try:
+ self.protocol.send_and_receive(SLOT_DEVICE_CONFIG, data)
+ except CommandRejectedError:
+ if empty:
+ return # ProgSeq isn't updated by set mode when empty
+ raise
+
+ def read_config(self):
+ response = self.protocol.send_and_receive(SLOT_YK4_CAPABILITIES)
+ r_len = response[0]
+ if check_crc(response[: r_len + 1 + 2]):
+ return response[: r_len + 1]
+ raise BadResponseError("Invalid checksum")
+
+ def write_config(self, config):
+ self.protocol.send_and_receive(SLOT_YK4_SET_DEVICE_INFO, config)
+
+
+INS_READ_CONFIG = 0x1D
+INS_WRITE_CONFIG = 0x1C
+INS_SET_MODE = 0x16
+P1_DEVICE_CONFIG = 0x11
+
+
+class _ManagementSmartCardBackend(_Backend):
+ def __init__(self, smartcard_connection):
+ self.protocol = SmartCardProtocol(smartcard_connection)
+ try:
+ select_bytes = self.protocol.select(AID.MANAGEMENT)
+ if select_bytes[-2:] == b"\x90\x00":
+ # YubiKey Edge incorrectly appends SW twice.
+ select_bytes = select_bytes[:-2]
+ select_str = select_bytes.decode()
+ self.version = Version.from_string(select_str)
+ # For YubiKey NEO, we use the OTP application for further commands
+ if self.version[0] == 3:
+ # Workaround to "de-select" on NEO, otherwise it gets stuck.
+ self.protocol.connection.send_and_receive(b"\xa4\x04\x00\x08")
+ self.protocol.select(AID.OTP)
+ except ApplicationNotAvailableError:
+ if smartcard_connection.transport == TRANSPORT.NFC:
+ # Probably NEO over NFC
+ status = self.protocol.select(AID.OTP)
+ self.version = Version.from_bytes(status[:3])
+ else:
+ raise
+
+ def close(self):
+ self.protocol.close()
+
+ def set_mode(self, data):
+ if self.version[0] == 3: # Using the OTP application
+ self.protocol.send_apdu(0, 0x01, SLOT_DEVICE_CONFIG, 0, data)
+ else:
+ self.protocol.send_apdu(0, INS_SET_MODE, P1_DEVICE_CONFIG, 0, data)
+
+ def read_config(self):
+ return self.protocol.send_apdu(0, INS_READ_CONFIG, 0, 0)
+
+ def write_config(self, config):
+ self.protocol.send_apdu(0, INS_WRITE_CONFIG, 0, 0, config)
+
+
+CTAP_VENDOR_FIRST = 0x40
+CTAP_YUBIKEY_DEVICE_CONFIG = CTAP_VENDOR_FIRST
+CTAP_READ_CONFIG = CTAP_VENDOR_FIRST + 2
+CTAP_WRITE_CONFIG = CTAP_VENDOR_FIRST + 3
+
+
+class _ManagementCtapBackend(_Backend):
+ def __init__(self, fido_connection):
+ self.ctap = fido_connection
+ version = fido_connection.device_version
+ if version[0] < 4: # Prior to YK4 this was not firmware version
+ if not (
+ version[0] == 0 and fido_connection.capabilities & CTAP_CAPABILITY.CBOR
+ ):
+ version = (3, 0, 0) # Guess that it's a NEO
+ self.version = Version(*version)
+
+ def close(self):
+ self.ctap.close()
+
+ def set_mode(self, data):
+ self.ctap.call(CTAP_YUBIKEY_DEVICE_CONFIG, data)
+
+ def read_config(self):
+ return self.ctap.call(CTAP_READ_CONFIG)
+
+ def write_config(self, config):
+ self.ctap.call(CTAP_WRITE_CONFIG, config)
+
+
+[docs]class ManagementSession:
+ def __init__(
+ self, connection: Union[OtpConnection, SmartCardConnection, FidoConnection]
+ ):
+ if isinstance(connection, OtpConnection):
+ self.backend: _Backend = _ManagementOtpBackend(connection)
+ elif isinstance(connection, SmartCardConnection):
+ self.backend = _ManagementSmartCardBackend(connection)
+ elif isinstance(connection, FidoConnection):
+ self.backend = _ManagementCtapBackend(connection)
+ else:
+ raise TypeError("Unsupported connection type")
+ logger.debug(
+ "Management session initialized for "
+ f"connection={type(connection).__name__}, version={self.version}"
+ )
+
+
+
+ @property
+ def version(self) -> Version:
+ return self.backend.version
+
+[docs] def read_device_info(self) -> DeviceInfo:
+ """Get detailed information about the YubiKey."""
+ require_version(self.version, (4, 1, 0))
+ return DeviceInfo.parse(self.backend.read_config(), self.version)
+
+[docs] def write_device_config(
+ self,
+ config: Optional[DeviceConfig] = None,
+ reboot: bool = False,
+ cur_lock_code: Optional[bytes] = None,
+ new_lock_code: Optional[bytes] = None,
+ ) -> None:
+ """Write configuration settings for YubiKey.
+
+ :pararm config: The device configuration.
+ :param reboot: If True the YubiKey will reboot.
+ :param cur_lock_code: Current lock code.
+ :param new_lock_code: New lock code.
+ """
+ require_version(self.version, (5, 0, 0))
+ if cur_lock_code is not None and len(cur_lock_code) != 16:
+ raise ValueError("Lock code must be 16 bytes")
+ if new_lock_code is not None and len(new_lock_code) != 16:
+ raise ValueError("Lock code must be 16 bytes")
+ config = config or DeviceConfig({}, None, None, None)
+ logger.debug(
+ f"Writing device config: {config}, reboot: {reboot}, "
+ f"current lock code: {cur_lock_code is not None}, "
+ f"new lock code: {new_lock_code is not None}"
+ )
+ self.backend.write_config(
+ config.get_bytes(reboot, cur_lock_code, new_lock_code)
+ )
+ logger.info("Device config written")
+
+[docs] def set_mode(
+ self,
+ mode: Mode,
+ chalresp_timeout: int = 0,
+ auto_eject_timeout: Optional[int] = None,
+ ) -> None:
+ """Write connection modes (USB interfaces) for YubiKey.
+
+ :param mode: The connection modes (USB interfaces).
+ :param chalresp_timeout: The timeout when waiting for touch
+ for challenge response.
+ :param auto_eject_timeout: When set, the smartcard will
+ automatically eject after the given time.
+ """
+ logger.debug(
+ f"Set mode: {mode}, chalresp_timeout: {chalresp_timeout}, "
+ f"auto_eject_timeout: {auto_eject_timeout}"
+ )
+ if self.version >= (5, 0, 0):
+ # Translate into DeviceConfig
+ usb_enabled = CAPABILITY(0)
+ if USB_INTERFACE.OTP in mode.interfaces:
+ usb_enabled |= CAPABILITY.OTP
+ if USB_INTERFACE.CCID in mode.interfaces:
+ usb_enabled |= CAPABILITY.OATH | CAPABILITY.PIV | CAPABILITY.OPENPGP
+ if USB_INTERFACE.FIDO in mode.interfaces:
+ usb_enabled |= CAPABILITY.U2F | CAPABILITY.FIDO2
+ logger.debug(f"Delegating to DeviceConfig with usb_enabled: {usb_enabled}")
+ # N.B: reboot=False, since we're using the older set_mode command
+ self.write_device_config(
+ DeviceConfig(
+ {TRANSPORT.USB: usb_enabled},
+ auto_eject_timeout,
+ chalresp_timeout,
+ None,
+ )
+ )
+ else:
+ code = mode.code
+ if auto_eject_timeout is not None:
+ if mode.interfaces == USB_INTERFACE.CCID:
+ code |= DEVICE_FLAG.EJECT
+ else:
+ raise ValueError("Touch-eject only applicable for mode: CCID")
+ self.backend.set_mode(
+ # N.B. This is little endian!
+ struct.pack("<BBH", code, chalresp_timeout, auto_eject_timeout or 0)
+ )
+ logger.info("Mode configuration written")
+
+from .core import (
+ int2bytes,
+ bytes2int,
+ require_version,
+ Version,
+ Tlv,
+ BadResponseError,
+)
+from .core.smartcard import AID, SmartCardConnection, SmartCardProtocol
+
+from urllib.parse import unquote, urlparse, parse_qs
+from functools import total_ordering
+from enum import IntEnum, unique
+from dataclasses import dataclass
+from base64 import b64encode, b32decode
+from time import time
+from typing import Optional, List, Mapping
+
+import hmac
+import hashlib
+import struct
+import os
+import re
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+# TLV tags for credential data
+TAG_NAME = 0x71
+TAG_NAME_LIST = 0x72
+TAG_KEY = 0x73
+TAG_CHALLENGE = 0x74
+TAG_RESPONSE = 0x75
+TAG_TRUNCATED = 0x76
+TAG_HOTP = 0x77
+TAG_PROPERTY = 0x78
+TAG_VERSION = 0x79
+TAG_IMF = 0x7A
+TAG_TOUCH = 0x7C
+
+# Instruction bytes for commands
+INS_LIST = 0xA1
+INS_PUT = 0x01
+INS_DELETE = 0x02
+INS_SET_CODE = 0x03
+INS_RESET = 0x04
+INS_RENAME = 0x05
+INS_CALCULATE = 0xA2
+INS_VALIDATE = 0xA3
+INS_CALCULATE_ALL = 0xA4
+INS_SEND_REMAINING = 0xA5
+
+TOTP_ID_PATTERN = re.compile(r"^((\d+)/)?(([^:]+):)?(.+)$")
+
+MASK_ALGO = 0x0F
+MASK_TYPE = 0xF0
+
+DEFAULT_PERIOD = 30
+DEFAULT_DIGITS = 6
+DEFAULT_IMF = 0
+CHALLENGE_LEN = 8
+HMAC_MINIMUM_KEY_SIZE = 14
+
+
+
+
+
+
+
+
+PROP_REQUIRE_TOUCH = 0x02
+
+
+[docs]def parse_b32_key(key: str):
+ """Parse Base32 encoded key.
+
+ :param key: The Base32 encoded key.
+ """
+ key = key.upper().replace(" ", "")
+ key += "=" * (-len(key) % 8) # Support unpadded
+ return b32decode(key)
+
+
+def _parse_select(response):
+ data = Tlv.parse_dict(response)
+ return (
+ Version.from_bytes(data[TAG_VERSION]),
+ data.get(TAG_NAME),
+ data.get(TAG_CHALLENGE),
+ )
+
+
+[docs]@dataclass
+class CredentialData:
+ """An object holding OATH credential data."""
+
+ name: str
+ oath_type: OATH_TYPE
+ hash_algorithm: HASH_ALGORITHM
+ secret: bytes
+ digits: int = DEFAULT_DIGITS
+ period: int = DEFAULT_PERIOD
+ counter: int = DEFAULT_IMF
+ issuer: Optional[str] = None
+
+[docs] @classmethod
+ def parse_uri(cls, uri: str) -> "CredentialData":
+ """Parse OATH credential data from URI.
+
+ :param uri: The URI to parse from.
+ """
+ parsed = urlparse(uri.strip())
+ if parsed.scheme != "otpauth":
+ raise ValueError("Invalid URI scheme")
+
+ if parsed.hostname is None:
+ raise ValueError("Missing OATH type")
+ oath_type = OATH_TYPE[parsed.hostname.upper()]
+
+ params = dict((k, v[0]) for k, v in parse_qs(parsed.query).items())
+ issuer = None
+ name = unquote(parsed.path)[1:] # Unquote and strip leading /
+ if ":" in name:
+ issuer, name = name.split(":", 1)
+
+ return cls(
+ name=name,
+ oath_type=oath_type,
+ hash_algorithm=HASH_ALGORITHM[params.get("algorithm", "SHA1").upper()],
+ secret=parse_b32_key(params["secret"]),
+ digits=int(params.get("digits", DEFAULT_DIGITS)),
+ period=int(params.get("period", DEFAULT_PERIOD)),
+ counter=int(params.get("counter", DEFAULT_IMF)),
+ issuer=params.get("issuer", issuer),
+ )
+
+[docs] def get_id(self) -> bytes:
+ return _format_cred_id(self.issuer, self.name, self.oath_type, self.period)
+
+
+[docs]@dataclass
+class Code:
+ """An OATH code object."""
+
+ value: str
+ valid_from: int
+ valid_to: int
+
+
+[docs]@total_ordering
+@dataclass(order=False, frozen=True)
+class Credential:
+ """An OATH credential object."""
+
+ device_id: str
+ id: bytes
+ issuer: Optional[str]
+ name: str
+ oath_type: OATH_TYPE
+ period: int
+ touch_required: Optional[bool]
+
+ def __lt__(self, other):
+ a = ((self.issuer or self.name).lower(), self.name.lower())
+ b = ((other.issuer or other.name).lower(), other.name.lower())
+ return a < b
+
+ def __eq__(self, other):
+ return (
+ isinstance(other, type(self))
+ and self.device_id == other.device_id
+ and self.id == other.id
+ )
+
+ def __hash__(self):
+ return hash((self.device_id, self.id))
+
+
+def _format_cred_id(issuer, name, oath_type, period=DEFAULT_PERIOD):
+ cred_id = ""
+ if oath_type == OATH_TYPE.TOTP and period != DEFAULT_PERIOD:
+ cred_id += "%d/" % period
+ if issuer:
+ cred_id += issuer + ":"
+ cred_id += name
+ return cred_id.encode()
+
+
+def _parse_cred_id(cred_id, oath_type):
+ data = cred_id.decode()
+ if oath_type == OATH_TYPE.TOTP:
+ match = TOTP_ID_PATTERN.match(data)
+ if match:
+ period_str = match.group(2)
+ return (
+ match.group(4),
+ match.group(5),
+ int(period_str) if period_str else DEFAULT_PERIOD,
+ )
+ else:
+ return None, data, DEFAULT_PERIOD
+ else:
+ if ":" in data:
+ issuer, data = data.split(":", 1)
+ else:
+ issuer = None
+ return issuer, data, 0
+
+
+def _get_device_id(salt):
+ d = hashlib.sha256(salt).digest()[:16]
+ return b64encode(d).replace(b"=", b"").decode()
+
+
+def _hmac_sha1(key, message):
+ return hmac.new(key, message, "sha1").digest()
+
+
+def _derive_key(salt, passphrase):
+ return hashlib.pbkdf2_hmac("sha1", passphrase.encode(), salt, 1000, 16)
+
+
+def _hmac_shorten_key(key, algo):
+ h = hashlib.new(algo.name)
+
+ if len(key) > h.block_size:
+ h.update(key)
+ key = h.digest()
+ return key
+
+
+def _get_challenge(timestamp, period):
+ time_step = timestamp // period
+ return struct.pack(">q", time_step)
+
+
+def _format_code(credential, timestamp, truncated):
+ if credential.oath_type == OATH_TYPE.TOTP:
+ time_step = timestamp // credential.period
+ valid_from = time_step * credential.period
+ valid_to = (time_step + 1) * credential.period
+ else: # HOTP
+ valid_from = timestamp
+ valid_to = 0x7FFFFFFFFFFFFFFF
+ digits = truncated[0]
+
+ return Code(
+ str((bytes2int(truncated[1:]) & 0x7FFFFFFF) % 10**digits).rjust(digits, "0"),
+ valid_from,
+ valid_to,
+ )
+
+
+[docs]class OathSession:
+ """A session with the OATH application."""
+
+ def __init__(self, connection: SmartCardConnection):
+ self.protocol = SmartCardProtocol(connection, INS_SEND_REMAINING)
+ self._version, self._salt, self._challenge = _parse_select(
+ self.protocol.select(AID.OATH)
+ )
+ self._has_key = self._challenge is not None
+ self._device_id = _get_device_id(self._salt)
+ self.protocol.enable_touch_workaround(self._version)
+ self._neo_unlock_workaround = self.version < (3, 0, 0)
+ logger.debug(
+ f"OATH session initialized (version={self.version}, "
+ f"has_key={self._has_key})"
+ )
+
+ @property
+ def version(self) -> Version:
+ """The OATH application version."""
+ return self._version
+
+ @property
+ def device_id(self) -> str:
+ """The device ID."""
+ return self._device_id
+
+ @property
+ def has_key(self) -> bool:
+ """If True, the YubiKey has an access key."""
+ return self._has_key
+
+ @property
+ def locked(self) -> bool:
+ """If True, the OATH application is password protected."""
+ return self._challenge is not None
+
+[docs] def reset(self) -> None:
+ """Perform a factory reset on the OATH application."""
+ self.protocol.send_apdu(0, INS_RESET, 0xDE, 0xAD)
+ _, self._salt, self._challenge = _parse_select(self.protocol.select(AID.OATH))
+ logger.info("OATH application data reset performed")
+ self._has_key = False
+ self._device_id = _get_device_id(self._salt)
+
+[docs] def derive_key(self, password: str) -> bytes:
+ """Derive a key from password.
+
+ :param password: The derivation password.
+ """
+ return _derive_key(self._salt, password)
+
+[docs] def validate(self, key: bytes) -> None:
+ """Validate authentication with access key.
+
+ :param key: The access key.
+ """
+ logger.debug("Unlocking session")
+ response = _hmac_sha1(key, self._challenge)
+ challenge = os.urandom(8)
+ data = Tlv(TAG_RESPONSE, response) + Tlv(TAG_CHALLENGE, challenge)
+ resp = self.protocol.send_apdu(0, INS_VALIDATE, 0, 0, data)
+ verification = _hmac_sha1(key, challenge)
+ if not hmac.compare_digest(Tlv.unpack(TAG_RESPONSE, resp), verification):
+ raise BadResponseError(
+ "Response from validation does not match verification!"
+ )
+ self._challenge = None
+ self._neo_unlock_workaround = False
+
+[docs] def set_key(self, key: bytes) -> None:
+ """Set access key for authentication.
+
+ :param key: The access key.
+ """
+ challenge = os.urandom(8)
+ response = _hmac_sha1(key, challenge)
+ self.protocol.send_apdu(
+ 0,
+ INS_SET_CODE,
+ 0,
+ 0,
+ (
+ Tlv(TAG_KEY, int2bytes(OATH_TYPE.TOTP | HASH_ALGORITHM.SHA1) + key)
+ + Tlv(TAG_CHALLENGE, challenge)
+ + Tlv(TAG_RESPONSE, response)
+ ),
+ )
+ logger.info("New access code set")
+ self._has_key = True
+ if self._neo_unlock_workaround:
+ logger.debug("Performing NEO workaround, re-select and unlock")
+ self._challenge = _parse_select(self.protocol.select(AID.OATH))[2]
+ self.validate(key)
+
+[docs] def unset_key(self) -> None:
+ """Remove access code.
+
+ WARNING: This removes authentication.
+ """
+ self.protocol.send_apdu(0, INS_SET_CODE, 0, 0, Tlv(TAG_KEY))
+ logger.info("Access code removed")
+ self._has_key = False
+
+[docs] def put_credential(
+ self, credential_data: CredentialData, touch_required: bool = False
+ ) -> Credential:
+ """Add a OATH credential.
+
+ :param credential_data: The credential data.
+ :param touch_required: The touch policy.
+ """
+ d = credential_data
+ cred_id = d.get_id()
+ secret = _hmac_shorten_key(d.secret, d.hash_algorithm)
+ secret = secret.ljust(HMAC_MINIMUM_KEY_SIZE, b"\0")
+ data = Tlv(TAG_NAME, cred_id) + Tlv(
+ TAG_KEY,
+ struct.pack(">BB", d.oath_type | d.hash_algorithm, d.digits) + secret,
+ )
+
+ if touch_required:
+ data += struct.pack(">BB", TAG_PROPERTY, PROP_REQUIRE_TOUCH)
+
+ if d.counter > 0:
+ data += Tlv(TAG_IMF, struct.pack(">I", d.counter))
+
+ logger.debug(
+ f"Importing credential (type={d.oath_type!r}, hash={d.hash_algorithm!r}, "
+ f"digits={d.digits}, period={d.period}, imf={d.counter}, "
+ f"touch_required={touch_required})"
+ )
+ self.protocol.send_apdu(0, INS_PUT, 0, 0, data)
+ logger.info("Credential imported")
+
+ return Credential(
+ self.device_id,
+ cred_id,
+ d.issuer,
+ d.name,
+ d.oath_type,
+ d.period,
+ touch_required,
+ )
+
+[docs] def rename_credential(
+ self, credential_id: bytes, name: str, issuer: Optional[str] = None
+ ) -> bytes:
+ """Rename a OATH credential.
+
+ :param credential_id: The id of the credential.
+ :param name: The new name of the credential.
+ :param issuer: The credential issuer.
+ """
+ require_version(self.version, (5, 3, 1))
+ _, _, period = _parse_cred_id(credential_id, OATH_TYPE.TOTP)
+ new_id = _format_cred_id(issuer, name, OATH_TYPE.TOTP, period)
+ self.protocol.send_apdu(
+ 0, INS_RENAME, 0, 0, Tlv(TAG_NAME, credential_id) + Tlv(TAG_NAME, new_id)
+ )
+ logger.info("Credential renamed")
+ return new_id
+
+[docs] def list_credentials(self) -> List[Credential]:
+ """List OATH credentials."""
+ creds = []
+ for tlv in Tlv.parse_list(self.protocol.send_apdu(0, INS_LIST, 0, 0)):
+ data = Tlv.unpack(TAG_NAME_LIST, tlv)
+ oath_type = OATH_TYPE(MASK_TYPE & data[0])
+ cred_id = data[1:]
+ issuer, name, period = _parse_cred_id(cred_id, oath_type)
+ creds.append(
+ Credential(
+ self.device_id, cred_id, issuer, name, oath_type, period, None
+ )
+ )
+ return creds
+
+[docs] def calculate(self, credential_id: bytes, challenge: bytes) -> bytes:
+ """Perform a calculate for an OATH credential.
+
+ :param credential_id: The id of the credential.
+ :param challenge: The challenge.
+ """
+ resp = Tlv.unpack(
+ TAG_RESPONSE,
+ self.protocol.send_apdu(
+ 0,
+ INS_CALCULATE,
+ 0,
+ 0,
+ Tlv(TAG_NAME, credential_id) + Tlv(TAG_CHALLENGE, challenge),
+ ),
+ )
+ return resp[1:]
+
+[docs] def delete_credential(self, credential_id: bytes) -> None:
+ """Delete an OATH credential.
+
+ :param credential_id: The id of the credential.
+ """
+ self.protocol.send_apdu(0, INS_DELETE, 0, 0, Tlv(TAG_NAME, credential_id))
+ logger.info("Credential deleted")
+
+[docs] def calculate_all(
+ self, timestamp: Optional[int] = None
+ ) -> Mapping[Credential, Optional[Code]]:
+ """Calculate codes for all OATH credentials on the YubiKey.
+
+ :param timestamp: A timestamp.
+ """
+ timestamp = int(timestamp or time())
+ challenge = _get_challenge(timestamp, DEFAULT_PERIOD)
+ logger.debug(f"Calculating all codes for time={timestamp}")
+
+ entries = {}
+ data = Tlv.parse_list(
+ self.protocol.send_apdu(
+ 0, INS_CALCULATE_ALL, 0, 1, Tlv(TAG_CHALLENGE, challenge)
+ )
+ )
+ while data:
+ cred_id = Tlv.unpack(TAG_NAME, data.pop(0))
+ tlv = data.pop(0)
+ resp_tag = tlv.tag
+ oath_type = OATH_TYPE.HOTP if resp_tag == TAG_HOTP else OATH_TYPE.TOTP
+ touch = resp_tag == TAG_TOUCH
+ issuer, name, period = _parse_cred_id(cred_id, oath_type)
+
+ credential = Credential(
+ self.device_id, cred_id, issuer, name, oath_type, period, touch
+ )
+
+ code = None # Will be None for HOTP and touch
+ if resp_tag == TAG_TRUNCATED: # Only TOTP, no-touch here
+ if period == DEFAULT_PERIOD:
+ code = _format_code(credential, timestamp, tlv.value)
+ else:
+ # Non-standard period, recalculate
+ logger.debug(f"Recalculating code for period={period}")
+ code = self.calculate_code(credential, timestamp)
+ entries[credential] = code
+
+ return entries
+
+[docs] def calculate_code(
+ self, credential: Credential, timestamp: Optional[int] = None
+ ) -> Code:
+ """Calculate code for an OATH credential.
+
+ :param credential: The credential object.
+ :param timestamp: The timestamp.
+ """
+ if credential.device_id != self.device_id:
+ raise ValueError("Credential does not belong to this YubiKey")
+
+ timestamp = int(timestamp or time())
+ if credential.oath_type == OATH_TYPE.TOTP:
+ logger.debug(
+ f"Calculating TOTP code for time={timestamp}, "
+ f"period={credential.period}"
+ )
+ challenge = _get_challenge(timestamp, credential.period)
+ else: # HOTP
+ logger.debug("Calculating HOTP code")
+ challenge = b""
+
+ response = Tlv.unpack(
+ TAG_TRUNCATED,
+ self.protocol.send_apdu(
+ 0,
+ INS_CALCULATE,
+ 0,
+ 0x01, # Truncate
+ Tlv(TAG_NAME, credential.id) + Tlv(TAG_CHALLENGE, challenge),
+ ),
+ )
+ return _format_code(credential, timestamp, response)
+
+# Copyright (c) 2023 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 .core import (
+ Tlv,
+ Version,
+ NotSupportedError,
+ InvalidPinError,
+ require_version,
+ int2bytes,
+ bytes2int,
+)
+from .core.smartcard import (
+ SmartCardConnection,
+ SmartCardProtocol,
+ ApduFormat,
+ ApduError,
+ AID,
+ SW,
+)
+
+from cryptography import x509
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives.serialization import (
+ Encoding,
+ PrivateFormat,
+ PublicFormat,
+ NoEncryption,
+)
+from cryptography.hazmat.primitives.asymmetric import rsa, ec, ed25519, x25519
+from cryptography.hazmat.primitives.asymmetric.utils import (
+ Prehashed,
+ encode_dss_signature,
+)
+
+import os
+import abc
+from enum import Enum, IntEnum, IntFlag, unique
+from dataclasses import dataclass
+from typing import (
+ Optional,
+ Tuple,
+ ClassVar,
+ Mapping,
+ Sequence,
+ SupportsBytes,
+ Union,
+ Dict,
+ List,
+)
+import struct
+import logging
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_USER_PIN = "123456"
+DEFAULT_ADMIN_PIN = "12345678"
+
+
+[docs]@unique
+class UIF(IntEnum): # noqa: N801
+ OFF = 0x00
+ ON = 0x01
+ FIXED = 0x02
+ CACHED = 0x03
+ CACHED_FIXED = 0x04
+
+
+
+ def __bytes__(self) -> bytes:
+ return struct.pack(">BB", self, GENERAL_FEATURE_MANAGEMENT.BUTTON)
+
+ @property
+ def is_fixed(self) -> bool:
+ return self in (UIF.FIXED, UIF.CACHED_FIXED)
+
+ @property
+ def is_cached(self) -> bool:
+ return self in (UIF.CACHED, UIF.CACHED_FIXED)
+
+ def __str__(self):
+ if self == UIF.FIXED:
+ return "On (fixed)"
+ if self == UIF.CACHED_FIXED:
+ return "Cached (fixed)"
+ return self.name[0] + self.name[1:].lower()
+
+
+[docs]@unique
+class PIN_POLICY(IntEnum): # noqa: N801
+ ALWAYS = 0x00
+ ONCE = 0x01
+
+ def __str__(self):
+ return self.name[0] + self.name[1:].lower()
+
+
+[docs]@unique
+class INS(IntEnum): # noqa: N801
+ VERIFY = 0x20
+ CHANGE_PIN = 0x24
+ RESET_RETRY_COUNTER = 0x2C
+ PSO = 0x2A
+ ACTIVATE = 0x44
+ GENERATE_ASYM = 0x47
+ GET_CHALLENGE = 0x84
+ INTERNAL_AUTHENTICATE = 0x88
+ SELECT_DATA = 0xA5
+ GET_DATA = 0xCA
+ PUT_DATA = 0xDA
+ PUT_DATA_ODD = 0xDB
+ TERMINATE = 0xE6
+ GET_VERSION = 0xF1
+ SET_PIN_RETRIES = 0xF2
+ GET_ATTESTATION = 0xFB
+
+
+_INVALID_PIN = b"\0" * 8
+
+
+TAG_DISCRETIONARY = 0x73
+TAG_EXTENDED_CAPABILITIES = 0xC0
+TAG_FINGERPRINTS = 0xC5
+TAG_CA_FINGERPRINTS = 0xC6
+TAG_GENERATION_TIMES = 0xCD
+TAG_SIGNATURE_COUNTER = 0x93
+TAG_KEY_INFORMATION = 0xDE
+TAG_PUBLIC_KEY = 0x7F49
+
+
+
+
+
+[docs]@unique
+class DO(IntEnum):
+ PRIVATE_USE_1 = 0x0101
+ PRIVATE_USE_2 = 0x0102
+ PRIVATE_USE_3 = 0x0103
+ PRIVATE_USE_4 = 0x0104
+ AID = 0x4F
+ NAME = 0x5B
+ LOGIN_DATA = 0x5E
+ LANGUAGE = 0xEF2D
+ SEX = 0x5F35
+ URL = 0x5F50
+ HISTORICAL_BYTES = 0x5F52
+ EXTENDED_LENGTH_INFO = 0x7F66
+ GENERAL_FEATURE_MANAGEMENT = 0x7F74
+ CARDHOLDER_RELATED_DATA = 0x65
+ APPLICATION_RELATED_DATA = 0x6E
+ ALGORITHM_ATTRIBUTES_SIG = 0xC1
+ ALGORITHM_ATTRIBUTES_DEC = 0xC2
+ ALGORITHM_ATTRIBUTES_AUT = 0xC3
+ ALGORITHM_ATTRIBUTES_ATT = 0xDA
+ PW_STATUS_BYTES = 0xC4
+ FINGERPRINT_SIG = 0xC7
+ FINGERPRINT_DEC = 0xC8
+ FINGERPRINT_AUT = 0xC9
+ FINGERPRINT_ATT = 0xDB
+ CA_FINGERPRINT_1 = 0xCA
+ CA_FINGERPRINT_2 = 0xCB
+ CA_FINGERPRINT_3 = 0xCC
+ CA_FINGERPRINT_4 = 0xDC
+ GENERATION_TIME_SIG = 0xCE
+ GENERATION_TIME_DEC = 0xCF
+ GENERATION_TIME_AUT = 0xD0
+ GENERATION_TIME_ATT = 0xDD
+ RESETTING_CODE = 0xD3
+ UIF_SIG = 0xD6
+ UIF_DEC = 0xD7
+ UIF_AUT = 0xD8
+ UIF_ATT = 0xD9
+ SECURITY_SUPPORT_TEMPLATE = 0x7A
+ CARDHOLDER_CERTIFICATE = 0x7F21
+ KDF = 0xF9
+ ALGORITHM_INFORMATION = 0xFA
+ ATT_CERTIFICATE = 0xFC
+
+
+def _bcd(value: int) -> int:
+ return 10 * (value >> 4) + (value & 0xF)
+
+
+[docs]class OpenPgpAid(bytes):
+ """OpenPGP Application Identifier (AID)
+
+ The OpenPGP AID is a string of bytes identifying the OpenPGP application.
+ It also embeds some values which are accessible though properties.
+ """
+
+ @property
+ def version(self) -> Tuple[int, int]:
+ """OpenPGP version (tuple of 2 integers: main version, secondary version)."""
+ return (_bcd(self[6]), _bcd(self[7]))
+
+ @property
+ def manufacturer(self) -> int:
+ """16-bit integer value identifying the manufacturer of the device.
+
+ This should be 6 for Yubico devices.
+ """
+ return bytes2int(self[8:10])
+
+ @property
+ def serial(self) -> int:
+ """The serial number of the YubiKey.
+
+ NOTE: This value is encoded in BCD. In the event of an invalid value (hex A-F)
+ the entire 4 byte value will instead be decoded as an unsigned integer,
+ and negated.
+ """
+ try:
+ return int(self[10:14].hex())
+ except ValueError:
+ # Not valid BCD, treat as an unsigned integer, and return a negative value
+ return -struct.unpack(">I", self[10:14])[0]
+
+
+[docs]@unique
+class EXTENDED_CAPABILITY_FLAGS(IntFlag):
+ KDF = 1 << 0
+ PSO_DEC_ENC_AES = 1 << 1
+ ALGORITHM_ATTRIBUTES_CHANGEABLE = 1 << 2
+ PRIVATE_USE = 1 << 3
+ PW_STATUS_CHANGEABLE = 1 << 4
+ KEY_IMPORT = 1 << 5
+ GET_CHALLENGE = 1 << 6
+ SECURE_MESSAGING = 1 << 7
+
+
+[docs]@dataclass
+class CardholderRelatedData:
+ name: bytes
+ language: bytes
+ sex: int
+
+[docs] @classmethod
+ def parse(cls, encoded) -> "CardholderRelatedData":
+ data = Tlv.parse_dict(Tlv.unpack(DO.CARDHOLDER_RELATED_DATA, encoded))
+ return cls(
+ data[DO.NAME],
+ data[DO.LANGUAGE],
+ data[DO.SEX][0],
+ )
+
+
+[docs]@dataclass
+class ExtendedLengthInfo:
+ request_max_bytes: int
+ response_max_bytes: int
+
+[docs] @classmethod
+ def parse(cls, encoded) -> "ExtendedLengthInfo":
+ data = Tlv.parse_list(encoded)
+ return cls(
+ bytes2int(Tlv.unpack(0x02, data[0])),
+ bytes2int(Tlv.unpack(0x02, data[1])),
+ )
+
+
+[docs]@unique
+class GENERAL_FEATURE_MANAGEMENT(IntFlag):
+ TOUCHSCREEN = 1 << 0
+ MICROPHONE = 1 << 1
+ LOUDSPEAKER = 1 << 2
+ LED = 1 << 3
+ KEYPAD = 1 << 4
+ BUTTON = 1 << 5
+ BIOMETRIC = 1 << 6
+ DISPLAY = 1 << 7
+
+
+[docs]@dataclass
+class ExtendedCapabilities:
+ flags: EXTENDED_CAPABILITY_FLAGS
+ sm_algorithm: int
+ challenge_max_length: int
+ certificate_max_length: int
+ special_do_max_length: int
+ pin_block_2_format: bool
+ mse_command: bool
+
+[docs] @classmethod
+ def parse(cls, encoded: bytes) -> "ExtendedCapabilities":
+ return cls(
+ EXTENDED_CAPABILITY_FLAGS(encoded[0]),
+ encoded[1],
+ bytes2int(encoded[2:4]),
+ bytes2int(encoded[4:6]),
+ bytes2int(encoded[6:8]),
+ encoded[8] == 1,
+ encoded[9] == 1,
+ )
+
+
+[docs]@dataclass
+class PwStatus:
+ pin_policy_user: PIN_POLICY
+ max_len_user: int
+ max_len_reset: int
+ max_len_admin: int
+ attempts_user: int
+ attempts_reset: int
+ attempts_admin: int
+
+
+
+
+
+[docs] @classmethod
+ def parse(cls, encoded: bytes) -> "PwStatus":
+ try:
+ policy = PIN_POLICY(encoded[0])
+ except ValueError:
+ policy = PIN_POLICY.ONCE
+ return cls(
+ policy,
+ encoded[1],
+ encoded[2],
+ encoded[3],
+ encoded[4],
+ encoded[5],
+ encoded[6],
+ )
+
+
+[docs]@unique
+class CRT(bytes, Enum):
+ """Control Reference Template values."""
+
+ SIG = Tlv(0xB6)
+ DEC = Tlv(0xB8)
+ AUT = Tlv(0xA4)
+ ATT = Tlv(0xB6, Tlv(0x84, b"\x81"))
+
+
+[docs]@unique
+class KEY_REF(IntEnum): # noqa: N801
+ SIG = 0x01
+ DEC = 0x02
+ AUT = 0x03
+ ATT = 0x81
+
+ @property
+ def algorithm_attributes_do(self) -> DO:
+ return getattr(DO, f"ALGORITHM_ATTRIBUTES_{self.name}")
+
+ @property
+ def uif_do(self) -> DO:
+ return getattr(DO, f"UIF_{self.name}")
+
+ @property
+ def generation_time_do(self) -> DO:
+ return getattr(DO, f"GENERATION_TIME_{self.name}")
+
+ @property
+ def fingerprint_do(self) -> DO:
+ return getattr(DO, f"FINGERPRINT_{self.name}")
+
+ @property
+ def crt(self) -> CRT:
+ return getattr(CRT, self.name)
+
+
+
+
+
+KeyInformation = Mapping[KEY_REF, KEY_STATUS]
+Fingerprints = Mapping[KEY_REF, bytes]
+GenerationTimes = Mapping[KEY_REF, int]
+EcPublicKey = Union[
+ ec.EllipticCurvePublicKey,
+ ed25519.Ed25519PublicKey,
+ x25519.X25519PublicKey,
+]
+PublicKey = Union[EcPublicKey, rsa.RSAPublicKey]
+EcPrivateKey = Union[
+ ec.EllipticCurvePrivateKeyWithSerialization,
+ ed25519.Ed25519PrivateKey,
+ x25519.X25519PrivateKey,
+]
+PrivateKey = Union[
+ rsa.RSAPrivateKeyWithSerialization,
+ EcPrivateKey,
+]
+
+
+# mypy doesn't handle abstract dataclasses well
+[docs]@dataclass # type: ignore[misc]
+class AlgorithmAttributes(abc.ABC):
+ """OpenPGP key algorithm attributes."""
+
+ _supported_ids: ClassVar[Sequence[int]]
+ algorithm_id: int
+
+[docs] @classmethod
+ def parse(cls, encoded: bytes) -> "AlgorithmAttributes":
+ algorithm_id = encoded[0]
+ for sub_cls in cls.__subclasses__():
+ if algorithm_id in sub_cls._supported_ids:
+ return sub_cls._parse_data(algorithm_id, encoded[1:])
+ raise ValueError("Unsupported algorithm ID")
+
+ @abc.abstractmethod
+ def __bytes__(self) -> bytes:
+ raise NotImplementedError()
+
+ @classmethod
+ @abc.abstractmethod
+ def _parse_data(cls, alg: int, encoded: bytes) -> "AlgorithmAttributes":
+ raise NotImplementedError()
+
+
+
+
+
+[docs]@unique
+class RSA_IMPORT_FORMAT(IntEnum):
+ STANDARD = 0
+ STANDARD_W_MOD = 1
+ CRT = 2
+ CRT_W_MOD = 3
+
+
+[docs]@dataclass
+class RsaAttributes(AlgorithmAttributes):
+ _supported_ids = [0x01]
+
+ n_len: int
+ e_len: int
+ import_format: RSA_IMPORT_FORMAT
+
+[docs] @classmethod
+ def create(
+ cls,
+ n_len: RSA_SIZE,
+ import_format: RSA_IMPORT_FORMAT = RSA_IMPORT_FORMAT.STANDARD,
+ ) -> "RsaAttributes":
+ return cls(0x01, n_len, 17, import_format)
+
+ @classmethod
+ def _parse_data(cls, alg, encoded) -> "RsaAttributes":
+ n, e, f = struct.unpack(">HHB", encoded)
+ return cls(alg, n, e, RSA_IMPORT_FORMAT(f))
+
+ def __bytes__(self) -> bytes:
+ return struct.pack(
+ ">BHHB", self.algorithm_id, self.n_len, self.e_len, self.import_format
+ )
+
+
+[docs]class CurveOid(bytes):
+ def _get_name(self) -> str:
+ for oid in OID:
+ if self.startswith(oid):
+ return oid.name
+ return "Unknown Curve"
+
+ def __str__(self) -> str:
+ return self._get_name()
+
+ def __repr__(self) -> str:
+ name = self._get_name()
+ return f"{name}({self.hex()})"
+
+
+class OID(CurveOid, Enum):
+ SECP256R1 = CurveOid(b"\x2a\x86\x48\xce\x3d\x03\x01\x07")
+ SECP256K1 = CurveOid(b"\x2b\x81\x04\x00\x0a")
+ SECP384R1 = CurveOid(b"\x2b\x81\x04\x00\x22")
+ SECP521R1 = CurveOid(b"\x2b\x81\x04\x00\x23")
+ BrainpoolP256R1 = CurveOid(b"\x2b\x24\x03\x03\x02\x08\x01\x01\x07")
+ BrainpoolP384R1 = CurveOid(b"\x2b\x24\x03\x03\x02\x08\x01\x01\x0b")
+ BrainpoolP512R1 = CurveOid(b"\x2b\x24\x03\x03\x02\x08\x01\x01\x0d")
+ X25519 = CurveOid(b"\x2b\x06\x01\x04\x01\x97\x55\x01\x05\x01")
+ Ed25519 = CurveOid(b"\x2b\x06\x01\x04\x01\xda\x47\x0f\x01")
+
+ @classmethod
+ def _from_key(cls, private_key: EcPrivateKey) -> CurveOid:
+ name = ""
+ if isinstance(private_key, ec.EllipticCurvePrivateKey):
+ name = private_key.curve.name.lower()
+ else:
+ if isinstance(private_key, ed25519.Ed25519PrivateKey):
+ name = "ed25519"
+ elif isinstance(private_key, x25519.X25519PrivateKey):
+ name = "x25519"
+ for oid in cls:
+ if oid.name.lower() == name:
+ return oid
+ raise ValueError("Unsupported private key")
+
+ def __repr__(self) -> str:
+ return repr(self.value)
+
+ def __str__(self) -> str:
+ return str(self.value)
+
+
+
+
+
+[docs]@dataclass
+class EcAttributes(AlgorithmAttributes):
+ _supported_ids = [0x12, 0x13, 0x16]
+
+ oid: CurveOid
+ import_format: EC_IMPORT_FORMAT
+
+[docs] @classmethod
+ def create(cls, key_ref: KEY_REF, oid: CurveOid) -> "EcAttributes":
+ if oid == OID.Ed25519:
+ alg = 0x16 # EdDSA
+ elif key_ref == KEY_REF.DEC:
+ alg = 0x12 # ECDH
+ else:
+ alg = 0x13 # ECDSA
+ return cls(alg, oid, EC_IMPORT_FORMAT.STANDARD)
+
+ @classmethod
+ def _parse_data(cls, alg, encoded) -> "EcAttributes":
+ if encoded[-1] == 0xFF:
+ f = EC_IMPORT_FORMAT.STANDARD_W_PUBKEY
+ oid = encoded[:-1]
+ else: # Standard is defined as "format byte not present"
+ f = EC_IMPORT_FORMAT.STANDARD
+ oid = encoded
+
+ return cls(alg, CurveOid(oid), f)
+
+ def __bytes__(self) -> bytes:
+ buf = struct.pack(">B", self.algorithm_id) + self.oid
+ if self.import_format == EC_IMPORT_FORMAT.STANDARD_W_PUBKEY:
+ buf += struct.pack(">B", self.import_format)
+ return buf
+
+
+def _parse_key_information(encoded: bytes) -> KeyInformation:
+ return {
+ KEY_REF(encoded[i]): KEY_STATUS(encoded[i + 1])
+ for i in range(0, len(encoded), 2)
+ }
+
+
+def _parse_fingerprints(encoded: bytes) -> Fingerprints:
+ slots = list(KEY_REF)
+ return {
+ slots[i]: encoded[o : o + 20] for i, o in enumerate(range(0, len(encoded), 20))
+ }
+
+
+def _parse_timestamps(encoded: bytes) -> GenerationTimes:
+ slots = list(KEY_REF)
+ return {
+ slots[i]: bytes2int(encoded[o : o + 4])
+ for i, o in enumerate(range(0, len(encoded), 4))
+ }
+
+
+[docs]@dataclass
+class DiscretionaryDataObjects:
+ extended_capabilities: ExtendedCapabilities
+ attributes_sig: AlgorithmAttributes
+ attributes_dec: AlgorithmAttributes
+ attributes_aut: AlgorithmAttributes
+ attributes_att: Optional[AlgorithmAttributes]
+ pw_status: PwStatus
+ fingerprints: Fingerprints
+ ca_fingerprints: Fingerprints
+ generation_times: GenerationTimes
+ key_information: KeyInformation
+ uif_sig: Optional[UIF]
+ uif_dec: Optional[UIF]
+ uif_aut: Optional[UIF]
+ uif_att: Optional[UIF]
+
+[docs] @classmethod
+ def parse(cls, encoded: bytes) -> "DiscretionaryDataObjects":
+ data = Tlv.parse_dict(encoded)
+ return cls(
+ ExtendedCapabilities.parse(data[TAG_EXTENDED_CAPABILITIES]),
+ AlgorithmAttributes.parse(data[DO.ALGORITHM_ATTRIBUTES_SIG]),
+ AlgorithmAttributes.parse(data[DO.ALGORITHM_ATTRIBUTES_DEC]),
+ AlgorithmAttributes.parse(data[DO.ALGORITHM_ATTRIBUTES_AUT]),
+ (
+ AlgorithmAttributes.parse(data[DO.ALGORITHM_ATTRIBUTES_ATT])
+ if DO.ALGORITHM_ATTRIBUTES_ATT in data
+ else None
+ ),
+ PwStatus.parse(data[DO.PW_STATUS_BYTES]),
+ _parse_fingerprints(data[TAG_FINGERPRINTS]),
+ _parse_fingerprints(data[TAG_CA_FINGERPRINTS]),
+ _parse_timestamps(data[TAG_GENERATION_TIMES]),
+ _parse_key_information(data.get(TAG_KEY_INFORMATION, b"")),
+ (UIF.parse(data[DO.UIF_SIG]) if DO.UIF_SIG in data else None),
+ (UIF.parse(data[DO.UIF_DEC]) if DO.UIF_DEC in data else None),
+ (UIF.parse(data[DO.UIF_AUT]) if DO.UIF_AUT in data else None),
+ (UIF.parse(data[DO.UIF_ATT]) if DO.UIF_ATT in data else None),
+ )
+
+[docs] def get_algorithm_attributes(self, key_ref: KEY_REF) -> AlgorithmAttributes:
+ return getattr(self, f"attributes_{key_ref.name.lower()}")
+
+
+[docs]@dataclass
+class ApplicationRelatedData:
+ """OpenPGP related data."""
+
+ aid: OpenPgpAid
+ historical: bytes
+ extended_length_info: Optional[ExtendedLengthInfo]
+ general_feature_management: Optional[GENERAL_FEATURE_MANAGEMENT]
+ discretionary: DiscretionaryDataObjects
+
+[docs] @classmethod
+ def parse(cls, encoded: bytes) -> "ApplicationRelatedData":
+ outer = Tlv.unpack(DO.APPLICATION_RELATED_DATA, encoded)
+ data = Tlv.parse_dict(outer)
+ return cls(
+ OpenPgpAid(data[DO.AID]),
+ data[DO.HISTORICAL_BYTES],
+ (
+ ExtendedLengthInfo.parse(data[DO.EXTENDED_LENGTH_INFO])
+ if DO.EXTENDED_LENGTH_INFO in data
+ else None
+ ),
+ (
+ GENERAL_FEATURE_MANAGEMENT(
+ Tlv.unpack(0x81, data[DO.GENERAL_FEATURE_MANAGEMENT])[0]
+ )
+ if DO.GENERAL_FEATURE_MANAGEMENT in data
+ else None
+ ),
+ # Older keys have data in outer dict
+ DiscretionaryDataObjects.parse(data[TAG_DISCRETIONARY] or outer),
+ )
+
+
+[docs]@dataclass
+class SecuritySupportTemplate:
+ signature_counter: int
+
+[docs] @classmethod
+ def parse(cls, encoded: bytes) -> "SecuritySupportTemplate":
+ data = Tlv.parse_dict(Tlv.unpack(DO.SECURITY_SUPPORT_TEMPLATE, encoded))
+ return cls(bytes2int(data[TAG_SIGNATURE_COUNTER]))
+
+
+# mypy doesn't handle abstract dataclasses well
+[docs]@dataclass # type: ignore[misc]
+class Kdf(abc.ABC):
+ algorithm: ClassVar[int]
+
+[docs] @abc.abstractmethod
+ def process(self, pin: str, pw: PW) -> bytes:
+ """Run the KDF on the input PIN."""
+
+ @classmethod
+ @abc.abstractmethod
+ def _parse_data(cls, data: Mapping[int, bytes]) -> "Kdf":
+ raise NotImplementedError()
+
+[docs] @classmethod
+ def parse(cls, encoded: bytes) -> "Kdf":
+ data = Tlv.parse_dict(encoded)
+ try:
+ algorithm = bytes2int(data[0x81])
+ for sub in cls.__subclasses__():
+ if sub.algorithm == algorithm:
+ return sub._parse_data(data)
+ except KeyError:
+ pass # Fall though to KdfNone
+ return KdfNone()
+
+ @abc.abstractmethod
+ def __bytes__(self) -> bytes:
+ raise NotImplementedError()
+
+
+[docs]@dataclass
+class KdfNone(Kdf):
+ algorithm = 0
+
+ @classmethod
+ def _parse_data(cls, data) -> "KdfNone":
+ return cls()
+
+
+
+ def __bytes__(self):
+ return Tlv(0x81, struct.pack(">B", self.algorithm))
+
+
+[docs]@unique
+class HASH_ALGORITHM(IntEnum):
+ SHA256 = 0x08
+ SHA512 = 0x0A
+
+[docs] def create_digest(self):
+ algorithm = getattr(hashes, self.name)
+ return hashes.Hash(algorithm(), default_backend())
+
+
+[docs]@dataclass
+class KdfIterSaltedS2k(Kdf):
+ algorithm = 3
+
+ hash_algorithm: HASH_ALGORITHM
+ iteration_count: int
+ salt_user: bytes
+ salt_reset: bytes
+ salt_admin: bytes
+ initial_hash_user: Optional[bytes]
+ initial_hash_admin: Optional[bytes]
+
+ @staticmethod
+ def _do_process(hash_algorithm, iteration_count, data):
+ # Although the field is called "iteration count", it's actually
+ # the number of bytes to be passed to the hash function, which
+ # is called only once. Go figure!
+ data_count, trailing_bytes = divmod(iteration_count, len(data))
+ digest = hash_algorithm.create_digest()
+ for _ in range(data_count):
+ digest.update(data)
+ digest.update(data[:trailing_bytes])
+ return digest.finalize()
+
+[docs] @classmethod
+ def create(
+ cls,
+ hash_algorithm: HASH_ALGORITHM = HASH_ALGORITHM.SHA256,
+ iteration_count: int = 0x780000,
+ ) -> "KdfIterSaltedS2k":
+ salt_user = os.urandom(8)
+ salt_admin = os.urandom(8)
+ return cls(
+ hash_algorithm,
+ iteration_count,
+ salt_user,
+ os.urandom(8),
+ salt_admin,
+ cls._do_process(
+ hash_algorithm, iteration_count, salt_user + DEFAULT_USER_PIN.encode()
+ ),
+ cls._do_process(
+ hash_algorithm, iteration_count, salt_admin + DEFAULT_ADMIN_PIN.encode()
+ ),
+ )
+
+ @classmethod
+ def _parse_data(cls, data) -> "KdfIterSaltedS2k":
+ return cls(
+ HASH_ALGORITHM(bytes2int(data[0x82])),
+ bytes2int(data[0x83]),
+ data[0x84],
+ data.get(0x85),
+ data.get(0x86),
+ data.get(0x87),
+ data.get(0x88),
+ )
+
+
+
+[docs] def process(self, pw, pin):
+ salt = self.get_salt(pw) or self.salt_user
+ data = salt + pin.encode()
+ return self._do_process(self.hash_algorithm, self.iteration_count, data)
+
+ def __bytes__(self):
+ return (
+ Tlv(0x81, struct.pack(">B", self.algorithm))
+ + Tlv(0x82, struct.pack(">B", self.hash_algorithm))
+ + Tlv(0x83, struct.pack(">I", self.iteration_count))
+ + Tlv(0x84, self.salt_user)
+ + (Tlv(0x85, self.salt_reset) if self.salt_reset else b"")
+ + (Tlv(0x86, self.salt_admin) if self.salt_admin else b"")
+ + (Tlv(0x87, self.initial_hash_user) if self.initial_hash_user else b"")
+ + (Tlv(0x88, self.initial_hash_admin) if self.initial_hash_admin else b"")
+ )
+
+
+# mypy doesn't handle abstract dataclasses well
+[docs]@dataclass # type: ignore[misc]
+class PrivateKeyTemplate(abc.ABC):
+ crt: CRT
+
+ def _get_template(self) -> Sequence[Tlv]:
+ raise NotImplementedError()
+
+ def __bytes__(self) -> bytes:
+ tlvs = self._get_template()
+ return Tlv(
+ 0x4D,
+ self.crt
+ + Tlv(0x7F48, b"".join(tlv[: -tlv.length] for tlv in tlvs))
+ + Tlv(0x5F48, b"".join(tlv.value for tlv in tlvs)),
+ )
+
+
+[docs]@dataclass
+class RsaKeyTemplate(PrivateKeyTemplate):
+ e: bytes
+ p: bytes
+ q: bytes
+
+ def _get_template(self):
+ return (
+ Tlv(0x91, self.e),
+ Tlv(0x92, self.p),
+ Tlv(0x93, self.q),
+ )
+
+
+[docs]@dataclass
+class RsaCrtKeyTemplate(RsaKeyTemplate):
+ iqmp: bytes
+ dmp1: bytes
+ dmq1: bytes
+ n: bytes
+
+ def _get_template(self):
+ return (
+ *super()._get_template(),
+ Tlv(0x94, self.iqmp),
+ Tlv(0x95, self.dmp1),
+ Tlv(0x96, self.dmq1),
+ Tlv(0x97, self.n),
+ )
+
+
+[docs]@dataclass
+class EcKeyTemplate(PrivateKeyTemplate):
+ private_key: bytes
+ public_key: Optional[bytes]
+
+ def _get_template(self):
+ tlvs: Tuple[Tlv, ...] = (Tlv(0x92, self.private_key),)
+ if self.public_key:
+ tlvs = (*tlvs, Tlv(0x99, self.public_key))
+
+ return tlvs
+
+
+def _get_key_attributes(
+ private_key: PrivateKey, key_ref: KEY_REF, version: Version
+) -> AlgorithmAttributes:
+ if isinstance(private_key, rsa.RSAPrivateKeyWithSerialization):
+ if private_key.private_numbers().public_numbers.e != 65537:
+ raise ValueError("RSA keys with e != 65537 are not supported!")
+ return RsaAttributes.create(
+ RSA_SIZE(private_key.key_size),
+ RSA_IMPORT_FORMAT.CRT_W_MOD
+ if version < (4, 0, 0)
+ else RSA_IMPORT_FORMAT.STANDARD,
+ )
+ return EcAttributes.create(key_ref, OID._from_key(private_key))
+
+
+def _get_key_template(
+ private_key: PrivateKey, key_ref: KEY_REF, use_crt: bool = False
+) -> PrivateKeyTemplate:
+ if isinstance(private_key, rsa.RSAPrivateKeyWithSerialization):
+ rsa_numbers = private_key.private_numbers()
+ ln = (private_key.key_size // 8) // 2
+
+ e = b"\x01\x00\x01" # e=65537
+ p = int2bytes(rsa_numbers.p, ln)
+ q = int2bytes(rsa_numbers.q, ln)
+ if not use_crt:
+ return RsaKeyTemplate(key_ref.crt, e, p, q)
+ else:
+ dp = int2bytes(rsa_numbers.dmp1, ln)
+ dq = int2bytes(rsa_numbers.dmq1, ln)
+ qinv = int2bytes(rsa_numbers.iqmp, ln)
+ n = int2bytes(rsa_numbers.public_numbers.n, 2 * ln)
+ return RsaCrtKeyTemplate(key_ref.crt, e, p, q, qinv, dp, dq, n)
+
+ elif isinstance(private_key, ec.EllipticCurvePrivateKeyWithSerialization):
+ ec_numbers = private_key.private_numbers()
+ ln = private_key.key_size // 8
+ return EcKeyTemplate(key_ref.crt, int2bytes(ec_numbers.private_value, ln), None)
+
+ elif isinstance(private_key, (ed25519.Ed25519PrivateKey, x25519.X25519PrivateKey)):
+ pkb = private_key.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
+ if isinstance(private_key, x25519.X25519PrivateKey):
+ pkb = pkb[::-1] # byte order needs to be reversed
+ return EcKeyTemplate(
+ key_ref.crt,
+ pkb,
+ None,
+ )
+
+ raise ValueError("Unsupported key type")
+
+
+def _parse_rsa_key(data: Mapping[int, bytes]) -> rsa.RSAPublicKey:
+ numbers = rsa.RSAPublicNumbers(bytes2int(data[0x82]), bytes2int(data[0x81]))
+ return numbers.public_key(default_backend())
+
+
+def _parse_ec_key(oid: CurveOid, data: Mapping[int, bytes]) -> EcPublicKey:
+ pubkey_enc = data[0x86]
+ if oid == OID.X25519:
+ return x25519.X25519PublicKey.from_public_bytes(pubkey_enc)
+ if oid == OID.Ed25519:
+ return ed25519.Ed25519PublicKey.from_public_bytes(pubkey_enc)
+
+ curve = getattr(ec, oid._get_name())
+ return ec.EllipticCurvePublicKey.from_encoded_point(curve(), pubkey_enc)
+
+
+_pkcs1v15_headers = {
+ hashes.MD5: bytes.fromhex("3020300C06082A864886F70D020505000410"),
+ hashes.SHA1: bytes.fromhex("3021300906052B0E03021A05000414"),
+ hashes.SHA224: bytes.fromhex("302D300D06096086480165030402040500041C"),
+ hashes.SHA256: bytes.fromhex("3031300D060960864801650304020105000420"),
+ hashes.SHA384: bytes.fromhex("3041300D060960864801650304020205000430"),
+ hashes.SHA512: bytes.fromhex("3051300D060960864801650304020305000440"),
+ hashes.SHA512_224: bytes.fromhex("302D300D06096086480165030402050500041C"),
+ hashes.SHA512_256: bytes.fromhex("3031300D060960864801650304020605000420"),
+}
+
+
+def _pad_message(attributes, message, hash_algorithm):
+ if attributes.algorithm_id == 0x16: # EdDSA, never hash
+ return message
+
+ if isinstance(hash_algorithm, Prehashed):
+ hashed = message
+ else:
+ h = hashes.Hash(hash_algorithm, default_backend())
+ h.update(message)
+ hashed = h.finalize()
+
+ if isinstance(attributes, EcAttributes):
+ return hashed
+ if isinstance(attributes, RsaAttributes):
+ try:
+ return _pkcs1v15_headers[type(hash_algorithm)] + hashed
+ except KeyError:
+ raise ValueError(f"Unsupported hash algorithm for RSA: {hash_algorithm}")
+
+
+[docs]class OpenPgpSession:
+ """A session with the OpenPGP application."""
+
+ def __init__(self, connection: SmartCardConnection):
+ self.protocol = SmartCardProtocol(connection)
+ try:
+ self.protocol.select(AID.OPENPGP)
+ except ApduError as e:
+ if e.sw in (SW.NO_INPUT_DATA, SW.CONDITIONS_NOT_SATISFIED):
+ # Not activated, activate
+ logger.warning("Application not active, sending ACTIVATE")
+ self.protocol.send_apdu(0, INS.ACTIVATE, 0, 0)
+ self.protocol.select(AID.OPENPGP)
+ else:
+ raise
+ self._version = self._read_version()
+
+ self.protocol.enable_touch_workaround(self.version)
+ if self.version >= (4, 0, 0):
+ self.protocol.apdu_format = ApduFormat.EXTENDED
+
+ # Note: This value is cached!
+ # Do not rely on contained information that can change!
+ self._app_data = self.get_application_related_data()
+ logger.debug(f"OpenPGP session initialized (version={self.version})")
+
+ def _read_version(self) -> Version:
+ logger.debug("Getting version number")
+ bcd = self.protocol.send_apdu(0, INS.GET_VERSION, 0, 0)
+ return Version(*(_bcd(x) for x in bcd))
+
+ @property
+ def aid(self) -> OpenPgpAid:
+ """Get the AID used to select the applet."""
+ return self._app_data.aid
+
+ @property
+ def version(self) -> Version:
+ """Get the firmware version of the key.
+
+ For YubiKey NEO this is the PGP applet version.
+ """
+ return self._version
+
+ @property
+ def extended_capabilities(self) -> ExtendedCapabilities:
+ """Get the Extended Capabilities from the YubiKey."""
+ return self._app_data.discretionary.extended_capabilities
+
+[docs] def get_challenge(self, length: int) -> bytes:
+ """Get random data from the YubiKey.
+
+ :param length: Length of the returned data.
+ """
+ e = self.extended_capabilities
+ if EXTENDED_CAPABILITY_FLAGS.GET_CHALLENGE not in e.flags:
+ raise NotSupportedError("GET_CHALLENGE is not supported")
+ if not 0 < length <= e.challenge_max_length:
+ raise NotSupportedError("Unsupported challenge length")
+
+ logger.debug(f"Getting {length} random bytes")
+ return self.protocol.send_apdu(0, INS.GET_CHALLENGE, 0, 0, le=length)
+
+[docs] def get_data(self, do: DO) -> bytes:
+ """Get a Data Object from the YubiKey.
+
+ :param do: The Data Object to get.
+ """
+ logger.debug(f"Reading Data Object {do.name} ({do:X})")
+ return self.protocol.send_apdu(0, INS.GET_DATA, do >> 8, do & 0xFF)
+
+[docs] def put_data(self, do: DO, data: Union[bytes, SupportsBytes]) -> None:
+ """Write a Data Object to the YubiKey.
+
+ :param do: The Data Object to write to.
+ :param data: The data to write.
+ """
+ self.protocol.send_apdu(0, INS.PUT_DATA, do >> 8, do & 0xFF, bytes(data))
+ logger.info(f"Wrote Data Object {do.name} ({do:X})")
+
+[docs] def get_pin_status(self) -> PwStatus:
+ """Get the current status of PINS."""
+ return PwStatus.parse(self.get_data(DO.PW_STATUS_BYTES))
+
+[docs] def get_signature_counter(self) -> int:
+ """Get the number of times the signature key has been used."""
+ s = SecuritySupportTemplate.parse(self.get_data(DO.SECURITY_SUPPORT_TEMPLATE))
+ return s.signature_counter
+
+
+
+[docs] def set_signature_pin_policy(self, pin_policy: PIN_POLICY) -> None:
+ """Set signature PIN policy.
+
+ Requires Admin PIN verification.
+
+ :param pin_policy: The PIN policy.
+ """
+ logger.debug(f"Setting Signature PIN policy to {pin_policy}")
+ data = struct.pack(">B", pin_policy)
+ self.put_data(DO.PW_STATUS_BYTES, data)
+ logger.info("Signature PIN policy set")
+
+[docs] def reset(self) -> None:
+ """Perform a factory reset on the OpenPGP application.
+
+ WARNING: This will delete all stored keys, certificates and other data.
+ """
+ require_version(self.version, (1, 0, 6))
+ logger.debug("Preparing OpenPGP reset")
+
+ # Ensure the User and Admin PINs are blocked
+ status = self.get_pin_status()
+ for pw in (PW.USER, PW.ADMIN):
+ logger.debug(f"Verify {pw.name} PIN with invalid attempts until blocked")
+ for _ in range(status.get_attempts(pw)):
+ try:
+ self.protocol.send_apdu(0, INS.VERIFY, 0, pw, _INVALID_PIN)
+ except ApduError:
+ pass
+
+ # Reset the application
+ logger.debug("Sending TERMINATE, then ACTIVATE")
+ self.protocol.send_apdu(0, INS.TERMINATE, 0, 0)
+ self.protocol.send_apdu(0, INS.ACTIVATE, 0, 0)
+
+ logger.info("OpenPGP application data reset performed")
+
+[docs] def set_pin_attempts(
+ self, user_attempts: int, reset_attempts: int, admin_attempts: int
+ ) -> None:
+ """Set the number of PIN attempts to allow before blocking.
+
+ WARNING: On YubiKey NEO this will reset the PINs to their default values.
+
+ Requires Admin PIN verification.
+
+ :param user_attempts: The User PIN attempts.
+ :param reset_attempts: The Reset Code attempts.
+ :param admin_attempts: The Admin PIN attempts.
+ """
+ if self.version[0] == 1:
+ # YubiKey NEO
+ require_version(self.version, (1, 0, 7))
+ else:
+ require_version(self.version, (4, 3, 1))
+
+ attempts = (user_attempts, reset_attempts, admin_attempts)
+ logger.debug(f"Setting PIN attempts to {attempts}")
+ self.protocol.send_apdu(
+ 0,
+ INS.SET_PIN_RETRIES,
+ 0,
+ 0,
+ struct.pack(">BBB", *attempts),
+ )
+ logger.info("Number of PIN attempts has been changed")
+
+[docs] def get_kdf(self):
+ """Get the Key Derivation Function data object."""
+ if EXTENDED_CAPABILITY_FLAGS.KDF not in self.extended_capabilities.flags:
+ return KdfNone()
+ return Kdf.parse(self.get_data(DO.KDF))
+
+[docs] def set_kdf(self, kdf: Kdf) -> None:
+ """Set up a PIN Key Derivation Function.
+
+ This enables (or disables) the use of a KDF for PIN verification, as well
+ as resetting the User and Admin PINs to their default (initial) values.
+
+ If a Reset Code is present, it will be invalidated.
+
+ This command requires Admin PIN verification.
+
+ :param kdf: The key derivation function.
+ """
+ e = self._app_data.discretionary.extended_capabilities
+ if EXTENDED_CAPABILITY_FLAGS.KDF not in e.flags:
+ raise NotSupportedError("KDF is not supported")
+
+ logger.debug(f"Setting PIN KDF to algorithm: {kdf.algorithm}")
+ self.put_data(DO.KDF, kdf)
+ logger.info("KDF settings changed")
+
+ def _verify(self, pw: PW, pin: str, mode: int = 0) -> None:
+ pin_enc = self.get_kdf().process(pw, pin)
+ try:
+ self.protocol.send_apdu(0, INS.VERIFY, 0, pw + mode, pin_enc)
+ except ApduError as e:
+ if e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED:
+ attempts = self.get_pin_status().get_attempts(pw)
+ raise InvalidPinError(attempts)
+ raise e
+
+[docs] def verify_pin(self, pin, extended: bool = False):
+ """Verify the User PIN.
+
+ This will unlock functionality that requires User PIN verification.
+ Note that with `extended=False` (default) only sign operations are allowed.
+ Inversely, with `extended=True` sign operations are NOT allowed.
+
+ :param pin: The User PIN.
+ :param extended: If `False` only sign operations are allowed,
+ otherwise sign operations are NOT allowed.
+ """
+ logger.debug(f"Verifying User PIN in mode {'82' if extended else '81'}")
+ self._verify(PW.USER, pin, 1 if extended else 0)
+
+[docs] def verify_admin(self, admin_pin):
+ """Verify the Admin PIN.
+
+ This will unlock functionality that requires Admin PIN verification.
+
+ :param admin_pin: The Admin PIN.
+ """
+ logger.debug("Verifying Admin PIN")
+ self._verify(PW.ADMIN, admin_pin)
+
+[docs] def unverify_pin(self, pw: PW) -> None:
+ """Reset verification for PIN.
+
+ :param pw: The User, Admin or Reset PIN
+ """
+ require_version(self.version, (5, 6, 0))
+ logger.debug(f"Resetting verification for {pw.name} PIN")
+ self.protocol.send_apdu(0, INS.VERIFY, 0xFF, pw)
+
+ def _change(self, pw: PW, pin: str, new_pin: str) -> None:
+ logger.debug(f"Changing {pw.name} PIN")
+ kdf = self.get_kdf()
+ try:
+ self.protocol.send_apdu(
+ 0,
+ INS.CHANGE_PIN,
+ 0,
+ pw,
+ kdf.process(pw, pin) + kdf.process(pw, new_pin),
+ )
+ except ApduError as e:
+ if e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED:
+ attempts = self.get_pin_status().get_attempts(pw)
+ raise InvalidPinError(attempts)
+ raise e
+
+ logger.info(f"New {pw.name} PIN set")
+
+[docs] def change_pin(self, pin: str, new_pin: str) -> None:
+ """Change the User PIN.
+
+ :param pin: The current User PIN.
+ :param new_pin: The new User PIN.
+ """
+ self._change(PW.USER, pin, new_pin)
+
+[docs] def change_admin(self, admin_pin: str, new_admin_pin: str) -> None:
+ """Change the Admin PIN.
+
+ :param admin_pin: The current Admin PIN.
+ :param new_admin_pin: The new Admin PIN.
+ """
+ self._change(PW.ADMIN, admin_pin, new_admin_pin)
+
+[docs] def set_reset_code(self, reset_code: str) -> None:
+ """Set the Reset Code for User PIN.
+
+ The Reset Code can be used to set a new User PIN if it is lost or becomes
+ blocked, using the reset_pin method.
+
+ This command requires Admin PIN verification.
+
+ :param reset_code: The Reset Code for User PIN.
+ """
+ logger.debug("Setting a new PIN Reset Code")
+ data = self.get_kdf().process(PW.RESET, reset_code)
+ self.put_data(DO.RESETTING_CODE, data)
+ logger.info("New Reset Code has been set")
+
+[docs] def reset_pin(self, new_pin: str, reset_code: Optional[str] = None) -> None:
+ """Reset the User PIN to a new value.
+
+ This command requires Admin PIN verification, or the Reset Code.
+
+ :param new_pin: The new user PIN.
+ :param reset_code: The Reset Code.
+ """
+ logger.debug("Resetting User PIN")
+ p1 = 2
+ kdf = self.get_kdf()
+ data = kdf.process(PW.USER, new_pin)
+ if reset_code:
+ logger.debug("Using Reset Code")
+ data = kdf.process(PW.RESET, reset_code) + data
+ p1 = 0
+
+ try:
+ self.protocol.send_apdu(0, INS.RESET_RETRY_COUNTER, p1, PW.USER, data)
+ except ApduError as e:
+ if e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED and not reset_code:
+ attempts = self.get_pin_status().attempts_reset
+ raise InvalidPinError(
+ attempts, f"Invalid Reset Code, {attempts} remaining"
+ )
+ raise e
+ logger.info("New User PIN has been set")
+
+[docs] def get_algorithm_attributes(self, key_ref: KEY_REF) -> AlgorithmAttributes:
+ """Get the algorithm attributes for one of the key slots.
+
+ :param key_ref: The key slot.
+ """
+ logger.debug(f"Getting Algorithm Attributes for {key_ref.name}")
+ data = self.get_application_related_data()
+ return data.discretionary.get_algorithm_attributes(key_ref)
+
+[docs] def get_algorithm_information(
+ self,
+ ) -> Mapping[KEY_REF, Sequence[AlgorithmAttributes]]:
+ """Get the list of supported algorithm attributes for each key.
+
+ The return value is a mapping of KEY_REF to a list of supported algorithm
+ attributes, which can be set using set_algorithm_attributes.
+ """
+ if (
+ EXTENDED_CAPABILITY_FLAGS.ALGORITHM_ATTRIBUTES_CHANGEABLE
+ not in self.extended_capabilities.flags
+ ):
+ raise NotSupportedError("Writing Algorithm Attributes is not supported")
+
+ if self.version < (5, 2, 0):
+ sizes = [RSA_SIZE.RSA2048]
+ if self.version < (4, 0, 0): # Neo needs CRT
+ fmt = RSA_IMPORT_FORMAT.CRT_W_MOD
+ else:
+ fmt = RSA_IMPORT_FORMAT.STANDARD
+ if self.version[:2] != (4, 4): # Non-FIPS
+ sizes.extend([RSA_SIZE.RSA3072, RSA_SIZE.RSA4096])
+ return {
+ KEY_REF.SIG: [RsaAttributes.create(size, fmt) for size in sizes],
+ KEY_REF.DEC: [RsaAttributes.create(size, fmt) for size in sizes],
+ KEY_REF.AUT: [RsaAttributes.create(size, fmt) for size in sizes],
+ }
+
+ logger.debug("Getting supported Algorithm Information")
+ buf = self.get_data(DO.ALGORITHM_INFORMATION)
+ try:
+ buf = Tlv.unpack(DO.ALGORITHM_INFORMATION, buf)
+ except ValueError:
+ buf = Tlv.unpack(DO.ALGORITHM_INFORMATION, buf + b"\0\0")[:-2]
+
+ slots = {slot.algorithm_attributes_do: slot for slot in KEY_REF}
+ data: Dict[KEY_REF, List[AlgorithmAttributes]] = {}
+ for tlv in Tlv.parse_list(buf):
+ data.setdefault(slots[DO(tlv.tag)], []).append(
+ AlgorithmAttributes.parse(tlv.value)
+ )
+
+ if self.version < (5, 6, 1):
+ # Fix for invalid Curve25519 entries:
+ # Remove X25519 with EdDSA from all keys
+ invalid_x25519 = EcAttributes(0x16, OID.X25519, EC_IMPORT_FORMAT.STANDARD)
+ for values in data.values():
+ values.remove(invalid_x25519)
+ x25519 = EcAttributes(0x12, OID.X25519, EC_IMPORT_FORMAT.STANDARD)
+ # Add X25519 ECDH for DEC
+ if x25519 not in data[KEY_REF.DEC]:
+ data[KEY_REF.DEC].append(x25519)
+ # Remove EdDSA from DEC, ATT
+ ed25519_attr = EcAttributes(0x16, OID.Ed25519, EC_IMPORT_FORMAT.STANDARD)
+ data[KEY_REF.DEC].remove(ed25519_attr)
+ data[KEY_REF.ATT].remove(ed25519_attr)
+
+ return data
+
+[docs] def set_algorithm_attributes(
+ self, key_ref: KEY_REF, attributes: AlgorithmAttributes
+ ) -> None:
+ """Set the algorithm attributes for a key slot.
+
+ WARNING: This will delete any key already stored in the slot if the attributes
+ are changed!
+
+ This command requires Admin PIN verification.
+
+ :param key_ref: The key slot.
+ :param attributes: The algorithm attributes to set.
+ """
+ logger.debug("Setting Algorithm Attributes for {key_ref.name}")
+ supported = self.get_algorithm_information()
+ if key_ref not in supported:
+ raise NotSupportedError("Key slot not supported")
+ if attributes not in supported[key_ref]:
+ raise NotSupportedError("Algorithm attributes not supported")
+
+ self.put_data(key_ref.algorithm_attributes_do, attributes)
+ logger.info("Algorithm Attributes have been changed")
+
+[docs] def get_uif(self, key_ref: KEY_REF) -> UIF:
+ """Get the User Interaction Flag (touch requirement) for a key.
+
+ :param key_ref: The key slot.
+ """
+ try:
+ return UIF.parse(self.get_data(key_ref.uif_do))
+ except ApduError as e:
+ if e.sw == SW.WRONG_PARAMETERS_P1P2:
+ # Not supported
+ return UIF.OFF
+ raise
+
+[docs] def set_uif(self, key_ref: KEY_REF, uif: UIF) -> None:
+ """Set the User Interaction Flag (touch requirement) for a key.
+
+ Requires Admin PIN verification.
+
+ :param key_ref: The key slot.
+ :param uif: The User Interaction Flag.
+ """
+ require_version(self.version, (4, 2, 0))
+ if key_ref == KEY_REF.ATT:
+ require_version(
+ self.version,
+ (5, 2, 1),
+ "Attestation key requires YubiKey 5.2.1 or later.",
+ )
+ if uif.is_cached:
+ require_version(
+ self.version,
+ (5, 2, 1),
+ "Cached UIF values require YubiKey 5.2.1 or later.",
+ )
+
+ logger.debug(f"Setting UIF for {key_ref.name} to {uif.name}")
+ if self.get_uif(key_ref).is_fixed:
+ raise ValueError("Cannot change UIF when set to FIXED.")
+
+ self.put_data(key_ref.uif_do, uif)
+ logger.info(f"UIF changed for {key_ref.name}")
+
+[docs] def get_key_information(self) -> KeyInformation:
+ """Get the status of the keys."""
+ logger.debug("Getting Key Information")
+ return self.get_application_related_data().discretionary.key_information
+
+[docs] def get_generation_times(self) -> GenerationTimes:
+ """Get timestamps for when keys were generated."""
+ logger.debug("Getting key generation timestamps")
+ return self.get_application_related_data().discretionary.generation_times
+
+[docs] def set_generation_time(self, key_ref: KEY_REF, timestamp: int) -> None:
+ """Set the generation timestamp for a key.
+
+ Requires Admin PIN verification.
+
+ :param key_ref: The key slot.
+ :param timestamp: The timestamp.
+ """
+ logger.debug(f"Setting key generation timestamp for {key_ref.name}")
+ self.put_data(key_ref.generation_time_do, struct.pack(">I", timestamp))
+ logger.info(f"Key generation timestamp set for {key_ref.name}")
+
+[docs] def get_fingerprints(self) -> Fingerprints:
+ """Get key fingerprints."""
+ logger.debug("Getting key fingerprints")
+ return self.get_application_related_data().discretionary.fingerprints
+
+[docs] def set_fingerprint(self, key_ref: KEY_REF, fingerprint: bytes) -> None:
+ """Set the fingerprint for a key.
+
+ Requires Admin PIN verification.
+
+ :param key_ref: The key slot.
+ :param fingerprint: The fingerprint.
+ """
+ logger.debug(f"Setting key fingerprint for {key_ref.name}")
+ self.put_data(key_ref.fingerprint_do, fingerprint)
+ logger.info("Key fingerprint set for {key_ref.name}")
+
+[docs] def get_public_key(self, key_ref: KEY_REF) -> PublicKey:
+ """Get the public key from a slot.
+
+ :param key_ref: The key slot.
+ """
+ logger.debug(f"Getting public key for {key_ref.name}")
+ resp = self.protocol.send_apdu(0, INS.GENERATE_ASYM, 0x81, 0x00, key_ref.crt)
+ data = Tlv.parse_dict(Tlv.unpack(TAG_PUBLIC_KEY, resp))
+ attributes = self.get_algorithm_attributes(key_ref)
+ if isinstance(attributes, EcAttributes):
+ return _parse_ec_key(attributes.oid, data)
+ else: # RSA
+ return _parse_rsa_key(data)
+
+[docs] def generate_rsa_key(
+ self, key_ref: KEY_REF, key_size: RSA_SIZE
+ ) -> rsa.RSAPublicKey:
+ """Generate an RSA key in the given slot.
+
+ Requires Admin PIN verification.
+
+ :param key_ref: The key slot.
+ :param key_size: The size of the RSA key.
+ """
+ if (4, 2, 0) <= self.version < (4, 3, 5):
+ raise NotSupportedError("RSA key generation not supported on this YubiKey")
+
+ logger.debug(f"Generating RSA private key for {key_ref.name}")
+ if (
+ EXTENDED_CAPABILITY_FLAGS.ALGORITHM_ATTRIBUTES_CHANGEABLE
+ in self.extended_capabilities.flags
+ ):
+ attributes = RsaAttributes.create(key_size)
+ self.set_algorithm_attributes(key_ref, attributes)
+ elif key_size != RSA_SIZE.RSA2048:
+ raise NotSupportedError("Algorithm attributes not supported")
+
+ resp = self.protocol.send_apdu(0, INS.GENERATE_ASYM, 0x80, 0x00, key_ref.crt)
+ data = Tlv.parse_dict(Tlv.unpack(TAG_PUBLIC_KEY, resp))
+ logger.info(f"RSA key generated for {key_ref.name}")
+ return _parse_rsa_key(data)
+
+[docs] def generate_ec_key(self, key_ref: KEY_REF, curve_oid: CurveOid) -> EcPublicKey:
+ """Generate an EC key in the given slot.
+
+ Requires Admin PIN verification.
+
+ :param key_ref: The key slot.
+ :param curve_oid: The curve OID.
+ """
+
+ require_version(self.version, (5, 2, 0))
+
+ if curve_oid not in OID:
+ raise ValueError("Curve OID is not recognized")
+
+ logger.debug(f"Generating EC private key for {key_ref.name}")
+ attributes = EcAttributes.create(key_ref, curve_oid)
+ self.set_algorithm_attributes(key_ref, attributes)
+
+ resp = self.protocol.send_apdu(0, INS.GENERATE_ASYM, 0x80, 0x00, key_ref.crt)
+ data = Tlv.parse_dict(Tlv.unpack(TAG_PUBLIC_KEY, resp))
+ logger.info(f"EC key generated for {key_ref.name}")
+ return _parse_ec_key(curve_oid, data)
+
+[docs] def put_key(self, key_ref: KEY_REF, private_key: PrivateKey) -> None:
+ """Import a private key into the given slot.
+
+ Requires Admin PIN verification.
+
+ :param key_ref: The key slot.
+ :param private_key: The private key to import.
+ """
+
+ logger.debug(f"Importing a private key for {key_ref.name}")
+ attributes = _get_key_attributes(private_key, key_ref, self.version)
+ if (
+ EXTENDED_CAPABILITY_FLAGS.ALGORITHM_ATTRIBUTES_CHANGEABLE
+ in self.extended_capabilities.flags
+ ):
+ self.set_algorithm_attributes(key_ref, attributes)
+ else:
+ if not (
+ isinstance(attributes, RsaAttributes)
+ and attributes.n_len == RSA_SIZE.RSA2048
+ ):
+ raise NotSupportedError("This YubiKey only supports RSA 2048 keys")
+
+ template = _get_key_template(private_key, key_ref, self.version < (4, 0, 0))
+ self.protocol.send_apdu(0, INS.PUT_DATA_ODD, 0x3F, 0xFF, bytes(template))
+ logger.info(f"Private key imported for {key_ref.name}")
+
+[docs] def delete_key(self, key_ref: KEY_REF) -> None:
+ """Delete the contents of a key slot.
+
+ Requires Admin PIN verification.
+
+ :param key_ref: The key slot.
+ """
+ if self.version < (4, 0, 0):
+ # Import over the key
+ self.put_key(
+ key_ref, rsa.generate_private_key(65537, 2048, default_backend())
+ )
+ else:
+ # Delete key by changing the key attributes twice.
+ self.put_data( # Use put_data to avoid checking for RSA 4096 support
+ key_ref.algorithm_attributes_do, RsaAttributes.create(RSA_SIZE.RSA4096)
+ )
+ self.set_algorithm_attributes(
+ key_ref, RsaAttributes.create(RSA_SIZE.RSA2048)
+ )
+
+ def _select_certificate(self, key_ref: KEY_REF) -> None:
+ logger.debug(f"Selecting certificate for key {key_ref.name}")
+ try:
+ require_version(self.version, (5, 2, 0))
+ data: bytes = Tlv(0x60, Tlv(0x5C, int2bytes(DO.CARDHOLDER_CERTIFICATE)))
+ if self.version <= (5, 4, 3):
+ # These use a non-standard byte in the command.
+ data = b"\x06" + data # 6 is the length of the data.
+ self.protocol.send_apdu(
+ 0,
+ INS.SELECT_DATA,
+ 3 - key_ref,
+ 0x04,
+ data,
+ )
+ except NotSupportedError:
+ if key_ref == KEY_REF.AUT:
+ return # Older version still support AUT, which is the default slot.
+ raise
+
+[docs] def get_certificate(self, key_ref: KEY_REF) -> x509.Certificate:
+ """Get a certificate from a slot.
+
+ :param key_ref: The slot.
+ """
+ logger.debug(f"Getting certificate for key {key_ref.name}")
+ if key_ref == KEY_REF.ATT:
+ require_version(self.version, (5, 2, 0))
+ data = self.get_data(DO.ATT_CERTIFICATE)
+ else:
+ self._select_certificate(key_ref)
+ data = self.get_data(DO.CARDHOLDER_CERTIFICATE)
+ if not data:
+ raise ValueError("No certificate found!")
+ return x509.load_der_x509_certificate(data, default_backend())
+
+[docs] def put_certificate(self, key_ref: KEY_REF, certificate: x509.Certificate) -> None:
+ """Import a certificate into a slot.
+
+ Requires Admin PIN verification.
+
+ :param key_ref: The slot.
+ :param certificate: The X.509 certificate to import.
+ """
+ cert_data = certificate.public_bytes(Encoding.DER)
+ logger.debug(f"Importing certificate for key {key_ref.name}")
+ if key_ref == KEY_REF.ATT:
+ require_version(self.version, (5, 2, 0))
+ self.put_data(DO.ATT_CERTIFICATE, cert_data)
+ else:
+ self._select_certificate(key_ref)
+ self.put_data(DO.CARDHOLDER_CERTIFICATE, cert_data)
+ logger.info(f"Certificate imported for key {key_ref.name}")
+
+[docs] def delete_certificate(self, key_ref: KEY_REF) -> None:
+ """Delete a certificate in a slot.
+
+ Requires Admin PIN verification.
+
+ :param key_ref: The slot.
+ """
+ logger.debug(f"Deleting certificate for key {key_ref.name}")
+ if key_ref == KEY_REF.ATT:
+ require_version(self.version, (5, 2, 0))
+ self.put_data(DO.ATT_CERTIFICATE, b"")
+ else:
+ self._select_certificate(key_ref)
+ self.put_data(DO.CARDHOLDER_CERTIFICATE, b"")
+ logger.info(f"Certificate deleted for key {key_ref.name}")
+
+[docs] def attest_key(self, key_ref: KEY_REF) -> x509.Certificate:
+ """Create an attestation certificate for a key.
+
+ The certificte is written to the certificate slot for the key, and its
+ content is returned.
+
+ Requires User PIN verification.
+
+ :param key_ref: The key slot.
+ """
+ require_version(self.version, (5, 2, 0))
+ logger.debug(f"Attesting key {key_ref.name}")
+ self.protocol.send_apdu(0x80, INS.GET_ATTESTATION, key_ref, 0)
+ logger.info(f"Attestation certificate created for {key_ref.name}")
+ return self.get_certificate(key_ref)
+
+[docs] def sign(self, message: bytes, hash_algorithm: hashes.HashAlgorithm) -> bytes:
+ """Sign a message using the SIG key.
+
+ Requires User PIN verification.
+
+ :param message: The message to sign.
+ :param hash_algorithm: The pre-signature hash algorithm.
+ """
+ attributes = self.get_algorithm_attributes(KEY_REF.SIG)
+ padded = _pad_message(attributes, message, hash_algorithm)
+ logger.debug(f"Signing a message with {attributes}")
+ response = self.protocol.send_apdu(0, INS.PSO, 0x9E, 0x9A, padded)
+ logger.info("Message signed")
+ if attributes.algorithm_id == 0x13:
+ ln = len(response) // 2
+ return encode_dss_signature(
+ int.from_bytes(response[:ln], "big"),
+ int.from_bytes(response[ln:], "big"),
+ )
+ return response
+
+[docs] def decrypt(self, value: Union[bytes, EcPublicKey]) -> bytes:
+ """Decrypt a value using the DEC key.
+
+ For RSA the `value` should be an encrypted block.
+ For ECDH the `value` should be a peer public-key to perform the key exchange
+ with, and the result will be the derived shared secret.
+
+ Requires (extended) User PIN verification.
+
+ :param value: The value to decrypt.
+ """
+ attributes = self.get_algorithm_attributes(KEY_REF.DEC)
+ logger.debug(f"Decrypting a value with {attributes}")
+
+ if isinstance(value, ec.EllipticCurvePublicKey):
+ data = value.public_bytes(Encoding.X962, PublicFormat.UncompressedPoint)
+ elif isinstance(value, x25519.X25519PublicKey):
+ data = value.public_bytes(Encoding.Raw, PublicFormat.Raw)
+ elif isinstance(value, bytes):
+ data = value
+
+ if isinstance(attributes, RsaAttributes):
+ data = b"\0" + data
+ elif isinstance(attributes, EcAttributes):
+ data = Tlv(0xA6, Tlv(0x7F49, Tlv(0x86, data)))
+
+ response = self.protocol.send_apdu(0, INS.PSO, 0x80, 0x86, data)
+ logger.info("Value decrypted")
+ return response
+
+[docs] def authenticate(
+ self, message: bytes, hash_algorithm: hashes.HashAlgorithm
+ ) -> bytes:
+ """Authenticate a message using the AUT key.
+
+ Requires User PIN verification.
+
+ :param message: The message to authenticate.
+ :param hash_algorithm: The pre-authentication hash algorithm.
+ """
+ attributes = self.get_algorithm_attributes(KEY_REF.AUT)
+ padded = _pad_message(attributes, message, hash_algorithm)
+ logger.debug(f"Authenticating a message with {attributes}")
+ response = self.protocol.send_apdu(
+ 0, INS.INTERNAL_AUTHENTICATE, 0x0, 0x0, padded
+ )
+ logger.info("Message authenticated")
+ if attributes.algorithm_id == 0x13:
+ ln = len(response) // 2
+ return encode_dss_signature(
+ int.from_bytes(response[:ln], "big"),
+ int.from_bytes(response[ln:], "big"),
+ )
+ return response
+
+# 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 .core import (
+ require_version as _require_version,
+ int2bytes,
+ bytes2int,
+ Version,
+ Tlv,
+ NotSupportedError,
+ BadResponseError,
+ InvalidPinError,
+)
+from .core.smartcard import (
+ SW,
+ AID,
+ ApduError,
+ ApduFormat,
+ SmartCardConnection,
+ SmartCardProtocol,
+)
+
+from cryptography import x509
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+from cryptography.hazmat.primitives.constant_time import bytes_eq
+from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
+from cryptography.hazmat.primitives.asymmetric import rsa, ec
+from cryptography.hazmat.primitives.asymmetric.padding import AsymmetricPadding
+from cryptography.hazmat.primitives.asymmetric.utils import Prehashed
+from cryptography.hazmat.backends import default_backend
+
+from dataclasses import dataclass
+from enum import Enum, IntEnum, unique
+from typing import Optional, Union, Type, cast
+
+import logging
+import gzip
+import os
+import re
+
+
+logger = logging.getLogger(__name__)
+
+
+
+
+
+# Don't treat pre 1.0 versions as "developer builds".
+[docs]def require_version(my_version: Version, *args, **kwargs):
+ if my_version <= (0, 1, 4): # Last pre 1.0 release of ykneo-piv
+ my_version = Version(1, 0, 0)
+ _require_version(my_version, *args, **kwargs)
+
+
+[docs]@unique
+class KEY_TYPE(IntEnum):
+ RSA1024 = 0x06
+ RSA2048 = 0x07
+ ECCP256 = 0x11
+ ECCP384 = 0x14
+
+ @property
+ def algorithm(self):
+ return ALGORITHM.EC if self.name.startswith("ECC") else ALGORITHM.RSA
+
+ @property
+ def bit_len(self):
+ match = re.search(r"\d+$", self.name)
+ if match:
+ return int(match.group())
+ raise ValueError("No bit_len")
+
+[docs] @classmethod
+ def from_public_key(cls, key):
+ if isinstance(key, rsa.RSAPublicKey):
+ try:
+ return getattr(cls, "RSA%d" % key.key_size)
+ except AttributeError:
+ raise ValueError("Unsupported RSA key size: %d" % key.key_size)
+ pass # Fall through to ValueError
+ elif isinstance(key, ec.EllipticCurvePublicKey):
+ curve_name = key.curve.name
+ if curve_name == "secp256r1":
+ return cls.ECCP256
+ elif curve_name == "secp384r1":
+ return cls.ECCP384
+ raise ValueError(f"Unsupported EC curve: {curve_name}")
+ raise ValueError(f"Unsupported key type: {type(key).__name__}")
+
+
+[docs]@unique
+class MANAGEMENT_KEY_TYPE(IntEnum):
+ TDES = 0x03
+ AES128 = 0x08
+ AES192 = 0x0A
+ AES256 = 0x0C
+
+ @property
+ def key_len(self):
+ if self.name == "TDES":
+ return 24
+ # AES
+ return int(self.name[3:]) // 8
+
+ @property
+ def challenge_len(self):
+ if self.name == "TDES":
+ return 8
+ return 16
+
+
+def _parse_management_key(key_type, management_key):
+ if key_type == MANAGEMENT_KEY_TYPE.TDES:
+ return algorithms.TripleDES(management_key)
+ else:
+ return algorithms.AES(management_key)
+
+
+# The card management slot is special, we don't include it in SLOT below
+SLOT_CARD_MANAGEMENT = 0x9B
+
+
+[docs]@unique
+class SLOT(IntEnum):
+ AUTHENTICATION = 0x9A
+ SIGNATURE = 0x9C
+ KEY_MANAGEMENT = 0x9D
+ CARD_AUTH = 0x9E
+
+ RETIRED1 = 0x82
+ RETIRED2 = 0x83
+ RETIRED3 = 0x84
+ RETIRED4 = 0x85
+ RETIRED5 = 0x86
+ RETIRED6 = 0x87
+ RETIRED7 = 0x88
+ RETIRED8 = 0x89
+ RETIRED9 = 0x8A
+ RETIRED10 = 0x8B
+ RETIRED11 = 0x8C
+ RETIRED12 = 0x8D
+ RETIRED13 = 0x8E
+ RETIRED14 = 0x8F
+ RETIRED15 = 0x90
+ RETIRED16 = 0x91
+ RETIRED17 = 0x92
+ RETIRED18 = 0x93
+ RETIRED19 = 0x94
+ RETIRED20 = 0x95
+
+ ATTESTATION = 0xF9
+
+ def __str__(self) -> str:
+ return f"{int(self):02X} ({self.name})"
+
+
+[docs]@unique
+class OBJECT_ID(IntEnum):
+ CAPABILITY = 0x5FC107
+ CHUID = 0x5FC102
+ AUTHENTICATION = 0x5FC105 # cert for 9a key
+ FINGERPRINTS = 0x5FC103
+ SECURITY = 0x5FC106
+ FACIAL = 0x5FC108
+ PRINTED = 0x5FC109
+ SIGNATURE = 0x5FC10A # cert for 9c key
+ KEY_MANAGEMENT = 0x5FC10B # cert for 9d key
+ CARD_AUTH = 0x5FC101 # cert for 9e key
+ DISCOVERY = 0x7E
+ KEY_HISTORY = 0x5FC10C
+ IRIS = 0x5FC121
+
+ RETIRED1 = 0x5FC10D
+ RETIRED2 = 0x5FC10E
+ RETIRED3 = 0x5FC10F
+ RETIRED4 = 0x5FC110
+ RETIRED5 = 0x5FC111
+ RETIRED6 = 0x5FC112
+ RETIRED7 = 0x5FC113
+ RETIRED8 = 0x5FC114
+ RETIRED9 = 0x5FC115
+ RETIRED10 = 0x5FC116
+ RETIRED11 = 0x5FC117
+ RETIRED12 = 0x5FC118
+ RETIRED13 = 0x5FC119
+ RETIRED14 = 0x5FC11A
+ RETIRED15 = 0x5FC11B
+ RETIRED16 = 0x5FC11C
+ RETIRED17 = 0x5FC11D
+ RETIRED18 = 0x5FC11E
+ RETIRED19 = 0x5FC11F
+ RETIRED20 = 0x5FC120
+
+ ATTESTATION = 0x5FFF01
+
+
+
+
+
+
+
+[docs]@unique
+class TOUCH_POLICY(IntEnum):
+ DEFAULT = 0x0
+ NEVER = 0x1
+ ALWAYS = 0x2
+ CACHED = 0x3
+
+
+# 010203040506070801020304050607080102030405060708
+DEFAULT_MANAGEMENT_KEY = (
+ b"\x01\x02\x03\x04\x05\x06\x07\x08"
+ + b"\x01\x02\x03\x04\x05\x06\x07\x08"
+ + b"\x01\x02\x03\x04\x05\x06\x07\x08"
+)
+
+PIN_LEN = 8
+
+# Instruction set
+INS_VERIFY = 0x20
+INS_CHANGE_REFERENCE = 0x24
+INS_RESET_RETRY = 0x2C
+INS_GENERATE_ASYMMETRIC = 0x47
+INS_AUTHENTICATE = 0x87
+INS_GET_DATA = 0xCB
+INS_PUT_DATA = 0xDB
+INS_GET_METADATA = 0xF7
+INS_ATTEST = 0xF9
+INS_SET_PIN_RETRIES = 0xFA
+INS_RESET = 0xFB
+INS_GET_VERSION = 0xFD
+INS_IMPORT_KEY = 0xFE
+INS_SET_MGMKEY = 0xFF
+
+# Tags for parsing responses and preparing requests
+TAG_AUTH_WITNESS = 0x80
+TAG_AUTH_CHALLENGE = 0x81
+TAG_AUTH_RESPONSE = 0x82
+TAG_AUTH_EXPONENTIATION = 0x85
+TAG_GEN_ALGORITHM = 0x80
+TAG_OBJ_DATA = 0x53
+TAG_OBJ_ID = 0x5C
+TAG_CERTIFICATE = 0x70
+TAG_CERT_INFO = 0x71
+TAG_DYN_AUTH = 0x7C
+TAG_LRC = 0xFE
+TAG_PIN_POLICY = 0xAA
+TAG_TOUCH_POLICY = 0xAB
+
+# Metadata tags
+TAG_METADATA_ALGO = 0x01
+TAG_METADATA_POLICY = 0x02
+TAG_METADATA_ORIGIN = 0x03
+TAG_METADATA_PUBLIC_KEY = 0x04
+TAG_METADATA_IS_DEFAULT = 0x05
+TAG_METADATA_RETRIES = 0x06
+
+ORIGIN_GENERATED = 1
+ORIGIN_IMPORTED = 2
+
+INDEX_PIN_POLICY = 0
+INDEX_TOUCH_POLICY = 1
+INDEX_RETRIES_TOTAL = 0
+INDEX_RETRIES_REMAINING = 1
+
+PIN_P2 = 0x80
+PUK_P2 = 0x81
+
+
+def _pin_bytes(pin):
+ pin = pin.encode()
+ if len(pin) > PIN_LEN:
+ raise ValueError("PIN/PUK must be no longer than 8 bytes")
+ return pin.ljust(PIN_LEN, b"\xff")
+
+
+def _retries_from_sw(sw):
+ if sw == SW.AUTH_METHOD_BLOCKED:
+ return 0
+ if sw & 0xFFF0 == 0x63C0:
+ return sw & 0x0F
+ elif sw & 0xFF00 == 0x6300:
+ return sw & 0xFF
+ return None
+
+
+[docs]@dataclass
+class PinMetadata:
+ default_value: bool
+ total_attempts: int
+ attempts_remaining: int
+
+
+[docs]@dataclass
+class ManagementKeyMetadata:
+ key_type: MANAGEMENT_KEY_TYPE
+ default_value: bool
+ touch_policy: TOUCH_POLICY
+
+
+[docs]@dataclass
+class SlotMetadata:
+ key_type: KEY_TYPE
+ pin_policy: PIN_POLICY
+ touch_policy: TOUCH_POLICY
+ generated: bool
+ public_key_encoded: bytes
+
+ @property
+ def public_key(self):
+ return _parse_device_public_key(self.key_type, self.public_key_encoded)
+
+
+def _pad_message(key_type, message, hash_algorithm, padding):
+ if key_type.algorithm == ALGORITHM.EC:
+ if isinstance(hash_algorithm, Prehashed):
+ hashed = message
+ else:
+ h = hashes.Hash(hash_algorithm, default_backend())
+ h.update(message)
+ hashed = h.finalize()
+ byte_len = key_type.bit_len // 8
+ if len(hashed) < byte_len:
+ return hashed.rjust(byte_len // 8, b"\0")
+ return hashed[:byte_len]
+ elif key_type.algorithm == ALGORITHM.RSA:
+ # Sign with a dummy key, then encrypt the signature to get the padded message
+ e = 65537
+ dummy = rsa.generate_private_key(e, key_type.bit_len, default_backend())
+ signature = dummy.sign(message, padding, hash_algorithm)
+ # Raw (textbook) RSA encrypt
+ n = dummy.public_key().public_numbers().n
+ return int2bytes(pow(bytes2int(signature), e, n), key_type.bit_len // 8)
+
+
+def _unpad_message(padded, padding):
+ e = 65537
+ dummy = rsa.generate_private_key(e, len(padded) * 8, default_backend())
+ # Raw (textbook) RSA encrypt
+ n = dummy.public_key().public_numbers().n
+ encrypted = int2bytes(pow(bytes2int(padded), e, n), len(padded))
+ return dummy.decrypt(encrypted, padding)
+
+
+[docs]def check_key_support(
+ version: Version,
+ key_type: KEY_TYPE,
+ pin_policy: PIN_POLICY,
+ touch_policy: TOUCH_POLICY,
+ generate: bool = True,
+) -> None:
+ """Check if a key type is supported by a specific YubiKey firmware version.
+
+ This method will return None if the key (with PIN and touch policies) is supported,
+ or it will raise a NotSupportedError if it is not.
+ """
+ if version[0] == 0 and version > (0, 1, 3):
+ return # Development build, skip version checks
+
+ if version < (4, 0, 0):
+ if key_type == KEY_TYPE.ECCP384:
+ raise NotSupportedError("ECCP384 requires YubiKey 4 or later")
+ if touch_policy != TOUCH_POLICY.DEFAULT or pin_policy != PIN_POLICY.DEFAULT:
+ raise NotSupportedError("PIN/Touch policy requires YubiKey 4 or later")
+
+ if version < (4, 3, 0) and touch_policy == TOUCH_POLICY.CACHED:
+ raise NotSupportedError("Cached touch policy requires YubiKey 4.3 or later")
+
+ # ROCA
+ if (4, 2, 0) <= version < (4, 3, 5):
+ if generate and key_type.algorithm == ALGORITHM.RSA:
+ raise NotSupportedError("RSA key generation not supported on this YubiKey")
+
+ # FIPS
+ if (4, 4, 0) <= version < (4, 5, 0):
+ if key_type == KEY_TYPE.RSA1024:
+ raise NotSupportedError("RSA 1024 not supported on YubiKey FIPS")
+ if pin_policy == PIN_POLICY.NEVER:
+ raise NotSupportedError("PIN_POLICY.NEVER not allowed on YubiKey FIPS")
+
+
+def _parse_device_public_key(key_type, encoded):
+ data = Tlv.parse_dict(encoded)
+ if key_type.algorithm == ALGORITHM.RSA:
+ modulus = bytes2int(data[0x81])
+ exponent = bytes2int(data[0x82])
+ return rsa.RSAPublicNumbers(exponent, modulus).public_key(default_backend())
+ else:
+ if key_type == KEY_TYPE.ECCP256:
+ curve: Type[ec.EllipticCurve] = ec.SECP256R1
+ else:
+ curve = ec.SECP384R1
+
+ return ec.EllipticCurvePublicKey.from_encoded_point(curve(), data[0x86])
+
+
+[docs]class PivSession:
+ """A session with the PIV application."""
+
+ def __init__(self, connection: SmartCardConnection):
+ self.protocol = SmartCardProtocol(connection)
+ self.protocol.select(AID.PIV)
+ self._version = Version.from_bytes(
+ self.protocol.send_apdu(0, INS_GET_VERSION, 0, 0)
+ )
+ self.protocol.enable_touch_workaround(self.version)
+ if self.version >= (4, 0, 0):
+ self.protocol.apdu_format = ApduFormat.EXTENDED
+ self._current_pin_retries = 3
+ self._max_pin_retries = 3
+ logger.debug(f"PIV session initialized (version={self.version})")
+
+ @property
+ def version(self) -> Version:
+ return self._version
+
+[docs] def reset(self) -> None:
+ logger.debug("Preparing PIV reset")
+
+ # Block PIN
+ logger.debug("Verify PIN with invalid attempts until blocked")
+ counter = self.get_pin_attempts()
+ while counter > 0:
+ try:
+ self.verify_pin("")
+ except InvalidPinError as e:
+ counter = e.attempts_remaining
+ logger.debug("PIN is blocked")
+
+ # Block PUK
+ logger.debug("Verify PUK with invalid attempts until blocked")
+ counter = 1
+ while counter > 0:
+ try:
+ self._change_reference(INS_RESET_RETRY, PIN_P2, "", "")
+ except InvalidPinError as e:
+ counter = e.attempts_remaining
+ logger.debug("PUK is blocked")
+
+ # Reset
+ logger.debug("Sending reset")
+ self.protocol.send_apdu(0, INS_RESET, 0, 0)
+ self._current_pin_retries = 3
+ self._max_pin_retries = 3
+
+ logger.info("PIV application data reset performed")
+
+[docs] def authenticate(
+ self, key_type: MANAGEMENT_KEY_TYPE, management_key: bytes
+ ) -> None:
+ """Authenticate to PIV with management key.
+
+ :param key_type: The management key type.
+ :param management_key: The management key in raw bytes.
+ """
+ key_type = MANAGEMENT_KEY_TYPE(key_type)
+ logger.debug(f"Authenticating with key type: {key_type}")
+ response = self.protocol.send_apdu(
+ 0,
+ INS_AUTHENTICATE,
+ key_type,
+ SLOT_CARD_MANAGEMENT,
+ Tlv(TAG_DYN_AUTH, Tlv(TAG_AUTH_WITNESS)),
+ )
+ witness = Tlv.unpack(TAG_AUTH_WITNESS, Tlv.unpack(TAG_DYN_AUTH, response))
+ challenge = os.urandom(key_type.challenge_len)
+
+ backend = default_backend()
+ cipher_key = _parse_management_key(key_type, management_key)
+ cipher = Cipher(cipher_key, modes.ECB(), backend) # nosec
+ decryptor = cipher.decryptor()
+ decrypted = decryptor.update(witness) + decryptor.finalize()
+
+ response = self.protocol.send_apdu(
+ 0,
+ INS_AUTHENTICATE,
+ key_type,
+ SLOT_CARD_MANAGEMENT,
+ Tlv(
+ TAG_DYN_AUTH,
+ Tlv(TAG_AUTH_WITNESS, decrypted) + Tlv(TAG_AUTH_CHALLENGE, challenge),
+ ),
+ )
+ encrypted = Tlv.unpack(TAG_AUTH_RESPONSE, Tlv.unpack(TAG_DYN_AUTH, response))
+ encryptor = cipher.encryptor()
+ expected = encryptor.update(challenge) + encryptor.finalize()
+ if not bytes_eq(expected, encrypted):
+ raise BadResponseError("Device response is incorrect")
+
+[docs] def set_management_key(
+ self,
+ key_type: MANAGEMENT_KEY_TYPE,
+ management_key: bytes,
+ require_touch: bool = False,
+ ) -> None:
+ """Set a new management key.
+
+ :param key_type: The management key type.
+ :param management_key: The management key in raw bytes.
+ :param require_touch: The touch policy.
+ """
+ key_type = MANAGEMENT_KEY_TYPE(key_type)
+ logger.debug(f"Setting management key of type: {key_type}")
+
+ if key_type != MANAGEMENT_KEY_TYPE.TDES:
+ require_version(self.version, (5, 4, 0))
+ if len(management_key) != key_type.key_len:
+ raise ValueError("Management key must be %d bytes" % key_type.key_len)
+
+ self.protocol.send_apdu(
+ 0,
+ INS_SET_MGMKEY,
+ 0xFF,
+ 0xFE if require_touch else 0xFF,
+ int2bytes(key_type) + Tlv(SLOT_CARD_MANAGEMENT, management_key),
+ )
+ logger.info("Management key set")
+
+[docs] def verify_pin(self, pin: str) -> None:
+ """Verify the PIN.
+
+ :param pin: The PIN.
+ """
+ logger.debug("Verifying PIN")
+ try:
+ self.protocol.send_apdu(0, INS_VERIFY, 0, PIN_P2, _pin_bytes(pin))
+ self._current_pin_retries = self._max_pin_retries
+ except ApduError as e:
+ retries = _retries_from_sw(e.sw)
+ if retries is None:
+ raise
+ self._current_pin_retries = retries
+ raise InvalidPinError(retries)
+
+[docs] def get_pin_attempts(self) -> int:
+ """Get remaining PIN attempts."""
+ logger.debug("Getting PIN attempts")
+ try:
+ return self.get_pin_metadata().attempts_remaining
+ except NotSupportedError:
+ try:
+ self.protocol.send_apdu(0, INS_VERIFY, 0, PIN_P2)
+ # Already verified, no way to know true count
+ logger.debug("Using cached value, may be incorrect.")
+ return self._current_pin_retries
+ except ApduError as e:
+ retries = _retries_from_sw(e.sw)
+ if retries is None:
+ raise
+ self._current_pin_retries = retries
+ logger.debug("Using value from empty verify")
+ return retries
+
+[docs] def change_pin(self, old_pin: str, new_pin: str) -> None:
+ """Change the PIN.
+
+ :param old_pin: The current PIN.
+ :param new_pin: The new PIN.
+ """
+ logger.debug("Changing PIN")
+ self._change_reference(INS_CHANGE_REFERENCE, PIN_P2, old_pin, new_pin)
+ logger.info("New PIN set")
+
+[docs] def change_puk(self, old_puk: str, new_puk: str) -> None:
+ """Change the PUK.
+
+ :param old_puk: The current PUK.
+ :param new_puk: The new PUK.
+ """
+ logger.debug("Changing PUK")
+ self._change_reference(INS_CHANGE_REFERENCE, PUK_P2, old_puk, new_puk)
+ logger.info("New PUK set")
+
+[docs] def unblock_pin(self, puk: str, new_pin: str) -> None:
+ """Reset PIN with PUK.
+
+ :param puk: The PUK.
+ :param new_pin: The new PIN.
+ """
+ logger.debug("Using PUK to set new PIN")
+ self._change_reference(INS_RESET_RETRY, PIN_P2, puk, new_pin)
+ logger.info("New PIN set")
+
+[docs] def set_pin_attempts(self, pin_attempts: int, puk_attempts: int) -> None:
+ """Set PIN retries for PIN and PUK.
+
+ Both PIN and PUK will be reset to default values when this is executed.
+
+ Requires authentication with management key and PIN verification.
+
+ :param pin_attempts: The PIN attempts.
+ :param puk_attempts: The PUK attempts.
+ """
+ logger.debug(f"Setting PIN/PUK attempts ({pin_attempts}, {puk_attempts})")
+ try:
+ self.protocol.send_apdu(0, INS_SET_PIN_RETRIES, pin_attempts, puk_attempts)
+ self._max_pin_retries = pin_attempts
+ self._current_pin_retries = pin_attempts
+ logger.info("PIN/PUK attempts set")
+ except ApduError as e:
+ if e.sw == SW.INVALID_INSTRUCTION:
+ raise NotSupportedError(
+ "Setting PIN attempts not supported on this YubiKey"
+ )
+ raise
+
+[docs] def get_pin_metadata(self) -> PinMetadata:
+ """Get PIN metadata."""
+ logger.debug("Getting PIN metadata")
+ return self._get_pin_puk_metadata(PIN_P2)
+
+[docs] def get_puk_metadata(self) -> PinMetadata:
+ """Get PUK metadata."""
+ logger.debug("Getting PUK metadata")
+ return self._get_pin_puk_metadata(PUK_P2)
+
+[docs] def get_management_key_metadata(self) -> ManagementKeyMetadata:
+ """Get management key metadata."""
+ logger.debug("Getting management key metadata")
+ require_version(self.version, (5, 3, 0))
+ data = Tlv.parse_dict(
+ self.protocol.send_apdu(0, INS_GET_METADATA, 0, SLOT_CARD_MANAGEMENT)
+ )
+ policy = data[TAG_METADATA_POLICY]
+ return ManagementKeyMetadata(
+ MANAGEMENT_KEY_TYPE(data.get(TAG_METADATA_ALGO, b"\x03")[0]),
+ data[TAG_METADATA_IS_DEFAULT] != b"\0",
+ TOUCH_POLICY(policy[INDEX_TOUCH_POLICY]),
+ )
+
+[docs] def get_slot_metadata(self, slot: SLOT) -> SlotMetadata:
+ """Get slot metadata.
+
+ :param slot: The slot to get metadata from.
+ """
+ slot = SLOT(slot)
+ logger.debug(f"Getting metadata for slot {slot}")
+ require_version(self.version, (5, 3, 0))
+ data = Tlv.parse_dict(self.protocol.send_apdu(0, INS_GET_METADATA, 0, slot))
+ policy = data[TAG_METADATA_POLICY]
+ return SlotMetadata(
+ KEY_TYPE(data[TAG_METADATA_ALGO][0]),
+ PIN_POLICY(policy[INDEX_PIN_POLICY]),
+ TOUCH_POLICY(policy[INDEX_TOUCH_POLICY]),
+ data[TAG_METADATA_ORIGIN][0] == ORIGIN_GENERATED,
+ data[TAG_METADATA_PUBLIC_KEY],
+ )
+
+[docs] def sign(
+ self,
+ slot: SLOT,
+ key_type: KEY_TYPE,
+ message: bytes,
+ hash_algorithm: hashes.HashAlgorithm,
+ padding: Optional[AsymmetricPadding] = None,
+ ) -> bytes:
+ """Sign message with key.
+
+ Requires PIN verification.
+
+ :param slot: The slot of the key to use.
+ :param key_type: The type of the key to sign with.
+ :param message: The message to sign.
+ :param hash_algorithm: The pre-signature hash algorithm to use.
+ :param padding: The pre-signature padding.
+ """
+ slot = SLOT(slot)
+ key_type = KEY_TYPE(key_type)
+ logger.debug(
+ f"Signing data with key in slot {slot} of type {key_type} using "
+ f"hash={hash_algorithm}, padding={padding}"
+ )
+ padded = _pad_message(key_type, message, hash_algorithm, padding)
+ return self._use_private_key(slot, key_type, padded, False)
+
+[docs] def decrypt(
+ self, slot: SLOT, cipher_text: bytes, padding: AsymmetricPadding
+ ) -> bytes:
+ """Decrypt cipher text.
+
+ Requires PIN verification.
+
+ :param slot: The slot.
+ :param cipher_text: The cipher text to decrypt.
+ :param padding: The padding of the plain text.
+ """
+ slot = SLOT(slot)
+ if len(cipher_text) == 1024 // 8:
+ key_type = KEY_TYPE.RSA1024
+ elif len(cipher_text) == 2048 // 8:
+ key_type = KEY_TYPE.RSA2048
+ else:
+ raise ValueError("Invalid length of ciphertext")
+ logger.debug(
+ f"Decrypting data with key in slot {slot} of type {key_type} using ",
+ f"padding={padding}",
+ )
+ padded = self._use_private_key(slot, key_type, cipher_text, False)
+ return _unpad_message(padded, padding)
+
+[docs] def calculate_secret(
+ self, slot: SLOT, peer_public_key: ec.EllipticCurvePublicKey
+ ) -> bytes:
+ """Calculate shared secret using ECDH.
+
+ Requires PIN verification.
+
+ :param slot: The slot.
+ :param peer_public_key: The peer's public key.
+ """
+ slot = SLOT(slot)
+ key_type = KEY_TYPE.from_public_key(peer_public_key)
+ if key_type.algorithm != ALGORITHM.EC:
+ raise ValueError("Unsupported key type")
+ logger.debug(
+ f"Performing key agreement with key in slot {slot} of type {key_type}"
+ )
+ data = peer_public_key.public_bytes(
+ Encoding.X962, PublicFormat.UncompressedPoint
+ )
+ return self._use_private_key(slot, key_type, data, True)
+
+[docs] def get_object(self, object_id: int) -> bytes:
+ """Get object by ID.
+
+ Requires PIN verification.
+
+ :param object_id: The object identifier.
+ """
+ logger.debug(f"Reading data from object slot {hex(object_id)}")
+ if object_id == OBJECT_ID.DISCOVERY:
+ expected: int = OBJECT_ID.DISCOVERY
+ else:
+ expected = TAG_OBJ_DATA
+
+ try:
+ return Tlv.unpack(
+ expected,
+ self.protocol.send_apdu(
+ 0,
+ INS_GET_DATA,
+ 0x3F,
+ 0xFF,
+ Tlv(TAG_OBJ_ID, int2bytes(object_id)),
+ ),
+ )
+ except ValueError as e:
+ raise BadResponseError("Malformed object data", e)
+
+[docs] def put_object(self, object_id: int, data: Optional[bytes] = None) -> None:
+ """Write data to PIV object.
+
+ Requires authentication with management key.
+
+ :param object_id: The object identifier.
+ :param data: The object data.
+ """
+ self.protocol.send_apdu(
+ 0,
+ INS_PUT_DATA,
+ 0x3F,
+ 0xFF,
+ Tlv(TAG_OBJ_ID, int2bytes(object_id)) + Tlv(TAG_OBJ_DATA, data or b""),
+ )
+ logger.info(f"Data written to object slot {hex(object_id)}")
+
+[docs] def get_certificate(self, slot: SLOT) -> x509.Certificate:
+ """Get certificate from slot.
+
+ :param slot: The slot to get the certificate from.
+ """
+ slot = SLOT(slot)
+ logger.debug(f"Reading certificate in slot {slot}")
+ try:
+ data = Tlv.parse_dict(self.get_object(OBJECT_ID.from_slot(slot)))
+ except ValueError:
+ raise BadResponseError("Malformed certificate data object")
+
+ cert_data = data[TAG_CERTIFICATE]
+ cert_info = data[TAG_CERT_INFO][0] if TAG_CERT_INFO in data else 0
+ if cert_info == 1:
+ logger.debug("Certificate is compressed, decompressing...")
+ # Compressed certificate
+ cert_data = gzip.decompress(cert_data)
+ elif cert_info != 0:
+ raise NotSupportedError("Unsupported value in CertInfo")
+
+ try:
+ return x509.load_der_x509_certificate(cert_data, default_backend())
+ except Exception as e:
+ raise BadResponseError("Invalid certificate", e)
+
+[docs] def put_certificate(
+ self, slot: SLOT, certificate: x509.Certificate, compress: bool = False
+ ) -> None:
+ """Import certificate to slot.
+
+ Requires authentication with management key.
+
+ :param slot: The slot to import the certificate to.
+ :param certificate: The certificate to import.
+ :param compress: If the certificate should be compressed or not.
+ """
+ slot = SLOT(slot)
+ logger.debug(f"Storing certificate in slot {slot}")
+ cert_data = certificate.public_bytes(Encoding.DER)
+ logger.debug(f"Certificate is {len(cert_data)} bytes, compression={compress}")
+ if compress:
+ cert_info = b"\1"
+ cert_data = gzip.compress(cert_data)
+ logger.debug(f"Compressed size: {len(cert_data)} bytes")
+ else:
+ cert_info = b"\0"
+ data = (
+ Tlv(TAG_CERTIFICATE, cert_data)
+ + Tlv(TAG_CERT_INFO, cert_info)
+ + Tlv(TAG_LRC)
+ )
+ self.put_object(OBJECT_ID.from_slot(slot), data)
+ logger.info(f"Certificate written to slot {slot}, compression={compress}")
+
+[docs] def delete_certificate(self, slot: SLOT) -> None:
+ """Delete certificate.
+
+ Requires authentication with management key.
+
+ :param slot: The slot to delete the certificate from.
+ """
+ slot = SLOT(slot)
+ logger.debug(f"Deleting certificate in slot {slot}")
+ self.put_object(OBJECT_ID.from_slot(slot))
+
+[docs] def put_key(
+ self,
+ slot: SLOT,
+ private_key: Union[
+ rsa.RSAPrivateKeyWithSerialization,
+ ec.EllipticCurvePrivateKeyWithSerialization,
+ ],
+ pin_policy: PIN_POLICY = PIN_POLICY.DEFAULT,
+ touch_policy: TOUCH_POLICY = TOUCH_POLICY.DEFAULT,
+ ) -> None:
+ """Import a private key to slot.
+
+ Requires authentication with management key.
+
+ :param slot: The slot to import the key to.
+ :param private_key: The private key to import.
+ :param pin_policy: The PIN policy.
+ :param touch_policy: The touch policy.
+ """
+ slot = SLOT(slot)
+ key_type = KEY_TYPE.from_public_key(private_key.public_key())
+ check_key_support(self.version, key_type, pin_policy, touch_policy, False)
+ ln = key_type.bit_len // 8
+ numbers = private_key.private_numbers()
+ if key_type.algorithm == ALGORITHM.RSA:
+ numbers = cast(rsa.RSAPrivateNumbers, numbers)
+ if numbers.public_numbers.e != 65537:
+ raise NotSupportedError("RSA exponent must be 65537")
+ ln //= 2
+ data = (
+ Tlv(0x01, int2bytes(numbers.p, ln))
+ + Tlv(0x02, int2bytes(numbers.q, ln))
+ + Tlv(0x03, int2bytes(numbers.dmp1, ln))
+ + Tlv(0x04, int2bytes(numbers.dmq1, ln))
+ + Tlv(0x05, int2bytes(numbers.iqmp, ln))
+ )
+ else:
+ numbers = cast(ec.EllipticCurvePrivateNumbers, numbers)
+ data = Tlv(0x06, int2bytes(numbers.private_value, ln))
+ if pin_policy:
+ data += Tlv(TAG_PIN_POLICY, int2bytes(pin_policy))
+ if touch_policy:
+ data += Tlv(TAG_TOUCH_POLICY, int2bytes(touch_policy))
+
+ logger.debug(
+ f"Importing key with pin_policy={pin_policy}, touch_policy={touch_policy}"
+ )
+ self.protocol.send_apdu(0, INS_IMPORT_KEY, key_type, slot, data)
+ logger.info(f"Private key imported in slot {slot} of type {key_type}")
+ return key_type
+
+[docs] def generate_key(
+ self,
+ slot: SLOT,
+ key_type: KEY_TYPE,
+ pin_policy: PIN_POLICY = PIN_POLICY.DEFAULT,
+ touch_policy: TOUCH_POLICY = TOUCH_POLICY.DEFAULT,
+ ) -> Union[rsa.RSAPublicKey, ec.EllipticCurvePublicKey]:
+ """Generate private key in slot.
+
+ Requires authentication with management key.
+
+ :param slot: The slot to generate the private key in.
+ :param key_type: The key type.
+ :param pin_policy: The PIN policy.
+ :param touch_policy: The touch policy.
+ """
+ slot = SLOT(slot)
+ key_type = KEY_TYPE(key_type)
+ check_key_support(self.version, key_type, pin_policy, touch_policy, True)
+ data: bytes = Tlv(TAG_GEN_ALGORITHM, int2bytes(key_type))
+ if pin_policy:
+ data += Tlv(TAG_PIN_POLICY, int2bytes(pin_policy))
+ if touch_policy:
+ data += Tlv(TAG_TOUCH_POLICY, int2bytes(touch_policy))
+
+ logger.debug(
+ f"Generating key with pin_policy={pin_policy}, touch_policy={touch_policy}"
+ )
+ response = self.protocol.send_apdu(
+ 0, INS_GENERATE_ASYMMETRIC, 0, slot, Tlv(0xAC, data)
+ )
+ logger.info(f"Private key generated in slot {slot} of type {key_type}")
+ return _parse_device_public_key(key_type, Tlv.unpack(0x7F49, response))
+
+[docs] def attest_key(self, slot: SLOT) -> x509.Certificate:
+ """Attest key in slot.
+
+ :param slot: The slot where the key has been generated.
+ :return: A X.509 certificate.
+ """
+ require_version(self.version, (4, 3, 0))
+ slot = SLOT(slot)
+ response = self.protocol.send_apdu(0, INS_ATTEST, slot, 0)
+ logger.debug(f"Attested key in slot {slot}")
+ return x509.load_der_x509_certificate(response, default_backend())
+
+ def _change_reference(self, ins, p2, value1, value2):
+ try:
+ self.protocol.send_apdu(
+ 0, ins, 0, p2, _pin_bytes(value1) + _pin_bytes(value2)
+ )
+ except ApduError as e:
+ retries = _retries_from_sw(e.sw)
+ if retries is None:
+ raise
+ if p2 == PIN_P2:
+ self._current_pin_retries = retries
+ raise InvalidPinError(retries)
+
+ def _get_pin_puk_metadata(self, p2):
+ require_version(self.version, (5, 3, 0))
+ data = Tlv.parse_dict(self.protocol.send_apdu(0, INS_GET_METADATA, 0, p2))
+ attempts = data[TAG_METADATA_RETRIES]
+ return PinMetadata(
+ data[TAG_METADATA_IS_DEFAULT] != b"\0",
+ attempts[INDEX_RETRIES_TOTAL],
+ attempts[INDEX_RETRIES_REMAINING],
+ )
+
+ def _use_private_key(self, slot, key_type, message, exponentiation):
+ try:
+ response = self.protocol.send_apdu(
+ 0,
+ INS_AUTHENTICATE,
+ key_type,
+ slot,
+ Tlv(
+ TAG_DYN_AUTH,
+ Tlv(TAG_AUTH_RESPONSE)
+ + Tlv(
+ TAG_AUTH_EXPONENTIATION
+ if exponentiation
+ else TAG_AUTH_CHALLENGE,
+ message,
+ ),
+ ),
+ )
+ return Tlv.unpack(
+ TAG_AUTH_RESPONSE,
+ Tlv.unpack(
+ TAG_DYN_AUTH,
+ response,
+ ),
+ )
+ except ApduError as e:
+ if e.sw == SW.INCORRECT_PARAMETERS:
+ raise e # TODO: Different error, No key?
+ raise
+
+# Copyright (c) 2015-2022 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 .core import (
+ TRANSPORT,
+ YUBIKEY,
+ PID,
+ Version,
+ Connection,
+ NotSupportedError,
+ ApplicationNotAvailableError,
+)
+from .core.otp import OtpConnection, CommandRejectedError
+from .core.fido import FidoConnection
+from .core.smartcard import (
+ AID,
+ SmartCardConnection,
+ SmartCardProtocol,
+)
+from .management import (
+ ManagementSession,
+ DeviceInfo,
+ DeviceConfig,
+ Mode,
+ USB_INTERFACE,
+ CAPABILITY,
+ FORM_FACTOR,
+ DEVICE_FLAG,
+)
+from .yubiotp import YubiOtpSession
+
+from time import sleep
+from typing import Optional
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+# Old U2F AID, only used to detect the presence of the applet
+_AID_U2F_YUBICO = bytes.fromhex("a0000005271002")
+
+_SCAN_APPLETS = (
+ # OTP will be checked elsewhere and thus isn't needed here
+ (AID.FIDO, CAPABILITY.U2F),
+ (_AID_U2F_YUBICO, CAPABILITY.U2F),
+ (AID.PIV, CAPABILITY.PIV),
+ (AID.OPENPGP, CAPABILITY.OPENPGP),
+ (AID.OATH, CAPABILITY.OATH),
+)
+
+_BASE_NEO_APPS = CAPABILITY.OTP | CAPABILITY.OATH | CAPABILITY.PIV | CAPABILITY.OPENPGP
+
+
+def _read_info_ccid(conn, key_type, interfaces):
+ version: Optional[Version] = None
+ try:
+ mgmt = ManagementSession(conn)
+ version = mgmt.version
+ try:
+ return mgmt.read_device_info()
+ except NotSupportedError:
+ # Workaround to "de-select" the Management Applet needed for NEO
+ conn.send_and_receive(b"\xa4\x04\x00\x08")
+ except ApplicationNotAvailableError:
+ logger.debug("Couldn't select Management application, use fallback")
+
+ # Synthesize data
+ capabilities = CAPABILITY(0)
+
+ # Try to read serial (and version if needed) from OTP application
+ serial = None
+ try:
+ otp = YubiOtpSession(conn)
+ if version is None:
+ version = otp.version
+ try:
+ serial = otp.get_serial()
+ except Exception:
+ logger.debug("Unable to read serial over OTP, no serial", exc_info=True)
+
+ capabilities |= CAPABILITY.OTP
+ except ApplicationNotAvailableError:
+ logger.debug("Couldn't select OTP application, serial unknown")
+
+ if version is None:
+ logger.debug("Firmware version unknown, using 3.0.0 as a baseline")
+ version = Version(3, 0, 0) # Guess, no way to know
+
+ # Scan for remaining capabilities
+ logger.debug("Scan for available applications...")
+ protocol = SmartCardProtocol(conn)
+ for aid, code in _SCAN_APPLETS:
+ try:
+ protocol.select(aid)
+ capabilities |= code
+ logger.debug("Found applet: aid: %s, capability: %s", aid, code)
+ except ApplicationNotAvailableError:
+ logger.debug("Missing applet: aid: %s, capability: %s", aid, code)
+ except Exception:
+ logger.warning(
+ "Error selecting aid: %s, capability: %s", aid, code, exc_info=True
+ )
+
+ if not capabilities and not key_type:
+ # NFC, no capabilities, probably not a YubiKey.
+ raise ValueError("Device does not seem to be a YubiKey")
+
+ # Assume U2F on devices >= 3.3.0
+ if USB_INTERFACE.FIDO in interfaces or version >= (3, 3, 0):
+ capabilities |= CAPABILITY.U2F
+
+ return DeviceInfo(
+ config=DeviceConfig(
+ enabled_capabilities={}, # Populated later
+ auto_eject_timeout=0,
+ challenge_response_timeout=0,
+ device_flags=DEVICE_FLAG(0),
+ ),
+ serial=serial,
+ version=version,
+ form_factor=FORM_FACTOR.UNKNOWN,
+ supported_capabilities={
+ TRANSPORT.USB: capabilities,
+ TRANSPORT.NFC: capabilities,
+ },
+ is_locked=False,
+ )
+
+
+def _read_info_otp(conn, key_type, interfaces):
+ otp = None
+ serial = None
+
+ try:
+ mgmt = ManagementSession(conn)
+ except ApplicationNotAvailableError:
+ otp = YubiOtpSession(conn)
+
+ # Retry during potential reclaim timeout period (~3s).
+ for _ in range(8):
+ try:
+ if otp is None:
+ try:
+ return mgmt.read_device_info() # Rejected while reclaim
+ except NotSupportedError:
+ otp = YubiOtpSession(conn)
+ serial = otp.get_serial() # Rejected if reclaim (or not API_SERIAL_VISIBLE)
+ break
+ except CommandRejectedError:
+ if otp and interfaces == USB_INTERFACE.OTP:
+ break # Can't be reclaim with only one interface
+ logger.debug("Potential reclaim, sleep...", exc_info=True)
+ sleep(0.5) # Potential reclaim
+ else:
+ otp = YubiOtpSession(conn)
+
+ # Synthesize info
+ logger.debug("Unable to get info via Management application, use fallback")
+
+ version = otp.version
+ if key_type == YUBIKEY.NEO:
+ usb_supported = _BASE_NEO_APPS
+ if USB_INTERFACE.FIDO in interfaces or version >= (3, 3, 0):
+ usb_supported |= CAPABILITY.U2F
+ capabilities = {
+ TRANSPORT.USB: usb_supported,
+ TRANSPORT.NFC: usb_supported,
+ }
+ elif key_type == YUBIKEY.YKP:
+ capabilities = {
+ TRANSPORT.USB: CAPABILITY.OTP | CAPABILITY.U2F,
+ }
+ else:
+ capabilities = {
+ TRANSPORT.USB: CAPABILITY.OTP,
+ }
+
+ return DeviceInfo(
+ config=DeviceConfig(
+ enabled_capabilities={}, # Populated later
+ auto_eject_timeout=0,
+ challenge_response_timeout=0,
+ device_flags=DEVICE_FLAG(0),
+ ),
+ serial=serial,
+ version=version,
+ form_factor=FORM_FACTOR.UNKNOWN,
+ supported_capabilities=capabilities.copy(),
+ is_locked=False,
+ )
+
+
+def _read_info_ctap(conn, key_type, interfaces):
+ try:
+ mgmt = ManagementSession(conn)
+ return mgmt.read_device_info()
+ except Exception: # SKY 1, NEO, or YKP
+ logger.debug("Unable to get info via Management application, use fallback")
+
+ # Best guess version
+ if key_type == YUBIKEY.YKP:
+ version = Version(4, 0, 0)
+ else:
+ version = Version(3, 0, 0)
+
+ supported_apps = {TRANSPORT.USB: CAPABILITY.U2F}
+ if key_type == YUBIKEY.NEO:
+ supported_apps[TRANSPORT.USB] |= _BASE_NEO_APPS
+ supported_apps[TRANSPORT.NFC] = supported_apps[TRANSPORT.USB]
+
+ return DeviceInfo(
+ config=DeviceConfig(
+ enabled_capabilities={}, # Populated later
+ auto_eject_timeout=0,
+ challenge_response_timeout=0,
+ device_flags=DEVICE_FLAG(0),
+ ),
+ serial=None,
+ version=version,
+ form_factor=FORM_FACTOR.USB_A_KEYCHAIN,
+ supported_capabilities=supported_apps,
+ is_locked=False,
+ )
+
+
+[docs]def read_info(conn: Connection, pid: Optional[PID] = None) -> DeviceInfo:
+ """Reads out DeviceInfo from a YubiKey, or attempts to synthesize the data.
+
+ Reading DeviceInfo from a ManagementSession is only supported for newer YubiKeys.
+ This function attempts to read that information, but will fall back to gathering the
+ data using other mechanisms if needed. It will also make adjustments to the data if
+ required, for example to "fix" known bad values.
+
+ The *pid* parameter must be provided whenever the YubiKey is connected via USB.
+
+ :param conn: A connection to a YubiKey.
+ :param pid: The USB Product ID.
+ """
+
+ logger.debug(f"Attempting to read device info, using {type(conn).__name__}")
+ if pid:
+ key_type: Optional[YUBIKEY] = pid.yubikey_type
+ interfaces = pid.usb_interfaces
+ elif isinstance(conn, SmartCardConnection) and conn.transport == TRANSPORT.NFC:
+ # No PID for NFC connections
+ key_type = None
+ interfaces = USB_INTERFACE(0) # Add interfaces later
+ # For NEO we need to figure out the mode, newer keys get it from Management
+ protocol = SmartCardProtocol(conn)
+ try:
+ resp = protocol.select(AID.OTP)
+ if resp[0] == 3 and len(resp) > 6:
+ interfaces = Mode.from_code(resp[6]).interfaces
+ except ApplicationNotAvailableError:
+ pass # OTP turned off, this must be YK5, no problem
+ else:
+ raise ValueError("PID must be provided for non-NFC connections")
+
+ if isinstance(conn, SmartCardConnection):
+ info = _read_info_ccid(conn, key_type, interfaces)
+ elif isinstance(conn, OtpConnection):
+ info = _read_info_otp(conn, key_type, interfaces)
+ elif isinstance(conn, FidoConnection):
+ info = _read_info_ctap(conn, key_type, interfaces)
+ else:
+ raise TypeError("Invalid connection type")
+
+ logger.debug("Read info: %s", info)
+
+ # Set usb_enabled if missing (pre YubiKey 5)
+ if (
+ info.has_transport(TRANSPORT.USB)
+ and TRANSPORT.USB not in info.config.enabled_capabilities
+ ):
+ usb_enabled = info.supported_capabilities[TRANSPORT.USB]
+ if usb_enabled == (CAPABILITY.OTP | CAPABILITY.U2F | USB_INTERFACE.CCID):
+ # YubiKey Edge, hide unusable CCID interface from supported
+ # usb_enabled = CAPABILITY.OTP | CAPABILITY.U2F
+ info.supported_capabilities = {
+ TRANSPORT.USB: CAPABILITY.OTP | CAPABILITY.U2F
+ }
+
+ if USB_INTERFACE.OTP not in interfaces:
+ usb_enabled &= ~CAPABILITY.OTP
+ if USB_INTERFACE.FIDO not in interfaces:
+ usb_enabled &= ~(CAPABILITY.U2F | CAPABILITY.FIDO2)
+ if USB_INTERFACE.CCID not in interfaces:
+ usb_enabled &= ~(
+ USB_INTERFACE.CCID
+ | CAPABILITY.OATH
+ | CAPABILITY.OPENPGP
+ | CAPABILITY.PIV
+ )
+
+ info.config.enabled_capabilities[TRANSPORT.USB] = usb_enabled
+
+ # SKY identified by PID
+ if key_type == YUBIKEY.SKY:
+ info.is_sky = True
+
+ # YK4-based FIPS version
+ if (4, 4, 0) <= info.version < (4, 5, 0):
+ info.is_fips = True
+
+ # Set nfc_enabled if missing (pre YubiKey 5)
+ if (
+ info.has_transport(TRANSPORT.NFC)
+ and TRANSPORT.NFC not in info.config.enabled_capabilities
+ ):
+ info.config.enabled_capabilities[TRANSPORT.NFC] = info.supported_capabilities[
+ TRANSPORT.NFC
+ ]
+
+ # Workaround for invalid configurations.
+ if info.version >= (4, 0, 0):
+ if info.form_factor in (
+ FORM_FACTOR.USB_A_NANO,
+ FORM_FACTOR.USB_C_NANO,
+ FORM_FACTOR.USB_C_LIGHTNING,
+ ) or (
+ info.form_factor is FORM_FACTOR.USB_C_KEYCHAIN and info.version < (5, 2, 4)
+ ):
+ # Known not to have NFC
+ info.supported_capabilities.pop(TRANSPORT.NFC, None)
+ info.config.enabled_capabilities.pop(TRANSPORT.NFC, None)
+
+ logger.debug("Device info, after tweaks: %s", info)
+ return info
+
+
+def _fido_only(capabilities):
+ return capabilities & ~(CAPABILITY.U2F | CAPABILITY.FIDO2) == 0
+
+
+def _is_preview(version):
+ _PREVIEW_RANGES = (
+ ((5, 0, 0), (5, 1, 0)),
+ ((5, 2, 0), (5, 2, 3)),
+ ((5, 5, 0), (5, 5, 2)),
+ )
+ for start, end in _PREVIEW_RANGES:
+ if start <= version < end:
+ return True
+ return False
+
+
+[docs]def get_name(info: DeviceInfo, key_type: Optional[YUBIKEY]) -> str:
+ """Determine the product name of a YubiKey
+
+ :param info: The device info.
+ :param key_type: The YubiKey hardware platform.
+ """
+ usb_supported = info.supported_capabilities[TRANSPORT.USB]
+
+ # Guess the key type (over NFC)
+ if not key_type:
+ if info.version[0] == 3:
+ key_type = YUBIKEY.NEO
+ elif info.serial is None and _fido_only(usb_supported):
+ key_type = YUBIKEY.SKY if info.version < (5, 2, 8) else YUBIKEY.YK4
+ else:
+ key_type = YUBIKEY.YK4
+
+ # Generic name based on key type alone
+ device_name = key_type.value
+
+ # Improved name based on configuration
+ if key_type == YUBIKEY.SKY:
+ if CAPABILITY.FIDO2 not in usb_supported:
+ device_name = "FIDO U2F Security Key" # SKY 1
+ if info.has_transport(TRANSPORT.NFC):
+ device_name = "Security Key NFC"
+ elif key_type == YUBIKEY.YK4:
+ major_version = info.version[0]
+ if major_version < 4:
+ if info.version[0] == 0:
+ return f"YubiKey ({info.version})"
+ else:
+ return "YubiKey"
+ elif major_version == 4:
+ if info.is_fips:
+ device_name = "YubiKey FIPS"
+ elif usb_supported == CAPABILITY.OTP | CAPABILITY.U2F:
+ device_name = "YubiKey Edge"
+ else:
+ device_name = "YubiKey 4"
+
+ if _is_preview(info.version):
+ device_name = "YubiKey Preview"
+ elif info.version >= (5, 1, 0):
+ # Dynamic name building for YK5
+ is_nano = info.form_factor in (
+ FORM_FACTOR.USB_A_NANO,
+ FORM_FACTOR.USB_C_NANO,
+ )
+ is_bio = info.form_factor in (FORM_FACTOR.USB_A_BIO, FORM_FACTOR.USB_C_BIO)
+ is_c = info.form_factor in ( # Does NOT include Ci
+ FORM_FACTOR.USB_C_KEYCHAIN,
+ FORM_FACTOR.USB_C_NANO,
+ FORM_FACTOR.USB_C_BIO,
+ )
+
+ if info.is_sky:
+ name_parts = ["Security Key"]
+ else:
+ name_parts = ["YubiKey"]
+ if not is_bio:
+ name_parts.append("5")
+ if is_c:
+ name_parts.append("C")
+ elif info.form_factor == FORM_FACTOR.USB_C_LIGHTNING:
+ name_parts.append("Ci")
+ if is_nano:
+ name_parts.append("Nano")
+ if info.has_transport(TRANSPORT.NFC):
+ name_parts.append("NFC")
+ elif info.form_factor == FORM_FACTOR.USB_A_KEYCHAIN:
+ name_parts.append("A") # Only for non-NFC A Keychain.
+ if is_bio:
+ name_parts.append("Bio")
+ if _fido_only(usb_supported):
+ name_parts.append("- FIDO Edition")
+ if info.is_fips:
+ name_parts.append("FIPS")
+ if info.is_sky and info.serial:
+ name_parts.append("- Enterprise Edition")
+ device_name = " ".join(name_parts).replace("5 C", "5C").replace("5 A", "5A")
+
+ return device_name
+
+# 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 .core import (
+ TRANSPORT,
+ Version,
+ bytes2int,
+ require_version,
+ NotSupportedError,
+ BadResponseError,
+)
+from .core import ApplicationNotAvailableError
+from .core.otp import (
+ check_crc,
+ calculate_crc,
+ OtpConnection,
+ OtpProtocol,
+ CommandRejectedError,
+)
+from .core.smartcard import AID, SmartCardConnection, SmartCardProtocol
+
+import abc
+import struct
+from hashlib import sha1
+from threading import Event
+from enum import unique, IntEnum, IntFlag
+from typing import TypeVar, Optional, Union, Callable
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+T = TypeVar("T")
+
+
+[docs]@unique
+class SLOT(IntEnum):
+ ONE = 1
+ TWO = 2
+
+[docs] @staticmethod
+ def map(slot: "SLOT", one: T, two: T) -> T:
+ if slot == 1:
+ return one
+ elif slot == 2:
+ return two
+ raise ValueError("Invalid slot (must be 1 or 2)")
+
+
+[docs]@unique
+class CONFIG_SLOT(IntEnum):
+ CONFIG_1 = 1 # First (default / V1) configuration
+ NAV = 2 # V1 only
+ CONFIG_2 = 3 # Second (V2) configuration
+ UPDATE_1 = 4 # Update slot 1
+ UPDATE_2 = 5 # Update slot 2
+ SWAP = 6 # Swap slot 1 and 2
+ NDEF_1 = 8 # Write NDEF record
+ NDEF_2 = 9 # Write NDEF record for slot 2
+
+ DEVICE_SERIAL = 0x10 # Device serial number
+ DEVICE_CONFIG = 0x11 # Write device configuration record
+ SCAN_MAP = 0x12 # Write scancode map
+ YK4_CAPABILITIES = 0x13 # Read YK4 capabilities (device info) list
+ YK4_SET_DEVICE_INFO = 0x15 # Write device info
+
+ CHAL_OTP_1 = 0x20 # Write 6 byte challenge to slot 1, get Yubico OTP response
+ CHAL_OTP_2 = 0x28 # Write 6 byte challenge to slot 2, get Yubico OTP response
+
+ CHAL_HMAC_1 = 0x30 # Write 64 byte challenge to slot 1, get HMAC-SHA1 response
+ CHAL_HMAC_2 = 0x38 # Write 64 byte challenge to slot 2, get HMAC-SHA1 response
+
+
+[docs]class TKTFLAG(IntFlag):
+ # Yubikey 1 and above
+ TAB_FIRST = 0x01 # Send TAB before first part
+ APPEND_TAB1 = 0x02 # Send TAB after first part
+ APPEND_TAB2 = 0x04 # Send TAB after second part
+ APPEND_DELAY1 = 0x08 # Add 0.5s delay after first part
+ APPEND_DELAY2 = 0x10 # Add 0.5s delay after second part
+ APPEND_CR = 0x20 # Append CR as final character
+
+ # Yubikey 2 and above
+ PROTECT_CFG2 = 0x80
+ # Block update of config 2 unless config 2 is configured and has this bit set
+
+ # Yubikey 2.1 and above
+ OATH_HOTP = 0x40 # OATH HOTP mode
+
+ # Yubikey 2.2 and above
+ CHAL_RESP = 0x40 # Challenge-response enabled (both must be set)
+
+
+[docs]class CFGFLAG(IntFlag):
+ # Yubikey 1 and above
+ SEND_REF = 0x01 # Send reference string (0..F) before data
+ PACING_10MS = 0x04 # Add 10ms intra-key pacing
+ PACING_20MS = 0x08 # Add 20ms intra-key pacing
+ STATIC_TICKET = 0x20 # Static ticket generation
+
+ # Yubikey 1 only
+ TICKET_FIRST = 0x02 # Send ticket first (default is fixed part)
+ ALLOW_HIDTRIG = 0x10 # Allow trigger through HID/keyboard
+
+ # Yubikey 2 and above
+ SHORT_TICKET = 0x02 # Send truncated ticket (half length)
+ STRONG_PW1 = 0x10 # Strong password policy flag #1 (mixed case)
+ STRONG_PW2 = 0x40 # Strong password policy flag #2 (subtitute 0..7 to digits)
+ MAN_UPDATE = 0x80 # Allow manual (local) update of static OTP
+
+ # Yubikey 2.1 and above
+ OATH_HOTP8 = 0x02 # Generate 8 digits HOTP rather than 6 digits
+ OATH_FIXED_MODHEX1 = 0x10 # First byte in fixed part sent as modhex
+ OATH_FIXED_MODHEX2 = 0x40 # First two bytes in fixed part sent as modhex
+ OATH_FIXED_MODHEX = 0x50 # Fixed part sent as modhex
+ OATH_FIXED_MASK = 0x50 # Mask to get out fixed flags
+
+ # Yubikey 2.2 and above
+ CHAL_YUBICO = 0x20 # Challenge-response enabled - Yubico OTP mode
+ CHAL_HMAC = 0x22 # Challenge-response enabled - HMAC-SHA1
+ HMAC_LT64 = 0x04 # Set when HMAC message is less than 64 bytes
+ CHAL_BTN_TRIG = 0x08 # Challenge-response operation requires button press
+
+
+[docs]class EXTFLAG(IntFlag):
+ SERIAL_BTN_VISIBLE = 0x01 # Serial number visible at startup (button press)
+ SERIAL_USB_VISIBLE = 0x02 # Serial number visible in USB iSerial field
+ SERIAL_API_VISIBLE = 0x04 # Serial number visible via API call
+
+ # V2.3 flags only
+ USE_NUMERIC_KEYPAD = 0x08 # Use numeric keypad for digits
+ FAST_TRIG = 0x10 # Use fast trig if only cfg1 set
+ ALLOW_UPDATE = 0x20
+ # Allow update of existing configuration (selected flags + access code)
+ DORMANT = 0x40 # Dormant config (woken up, flag removed, requires update flag)
+
+ # V2.4/3.1 flags only
+ LED_INV = 0x80 # LED idle state is off rather than on
+
+
+# Flags valid for update
+TKTFLAG_UPDATE_MASK = (
+ TKTFLAG.TAB_FIRST
+ | TKTFLAG.APPEND_TAB1
+ | TKTFLAG.APPEND_TAB2
+ | TKTFLAG.APPEND_DELAY1
+ | TKTFLAG.APPEND_DELAY2
+ | TKTFLAG.APPEND_CR
+)
+CFGFLAG_UPDATE_MASK = CFGFLAG.PACING_10MS | CFGFLAG.PACING_20MS
+EXTFLAG_UPDATE_MASK = (
+ EXTFLAG.SERIAL_BTN_VISIBLE
+ | EXTFLAG.SERIAL_USB_VISIBLE
+ | EXTFLAG.SERIAL_API_VISIBLE
+ | EXTFLAG.USE_NUMERIC_KEYPAD
+ | EXTFLAG.FAST_TRIG
+ | EXTFLAG.ALLOW_UPDATE
+ | EXTFLAG.DORMANT
+ | EXTFLAG.LED_INV
+)
+
+# Data sizes
+FIXED_SIZE = 16
+UID_SIZE = 6
+KEY_SIZE = 16
+ACC_CODE_SIZE = 6
+CONFIG_SIZE = 52
+NDEF_DATA_SIZE = 54
+HMAC_KEY_SIZE = 20
+HMAC_CHALLENGE_SIZE = 64
+HMAC_RESPONSE_SIZE = 20
+SCAN_CODES_SIZE = FIXED_SIZE + UID_SIZE + KEY_SIZE
+
+SHA1_BLOCK_SIZE = 64
+
+
+
+
+
+DEFAULT_NDEF_URI = "https://my.yubico.com/yk/#"
+
+NDEF_URL_PREFIXES = (
+ "http://www.",
+ "https://www.",
+ "http://",
+ "https://",
+ "tel:",
+ "mailto:",
+ "ftp://anonymous:anonymous@",
+ "ftp://ftp.",
+ "ftps://",
+ "sftp://",
+ "smb://",
+ "nfs://",
+ "ftp://",
+ "dav://",
+ "news:",
+ "telnet://",
+ "imap:",
+ "rtsp://",
+ "urn:",
+ "pop:",
+ "sip:",
+ "sips:",
+ "tftp:",
+ "btspp://",
+ "btl2cap://",
+ "btgoep://",
+ "tcpobex://",
+ "irdaobex://",
+ "file://",
+ "urn:epc:id:",
+ "urn:epc:tag:",
+ "urn:epc:pat:",
+ "urn:epc:raw:",
+ "urn:epc:",
+ "urn:nfc:",
+)
+
+
+def _build_config(fixed, uid, key, ext, tkt, cfg, acc_code=None):
+ buf = (
+ fixed.ljust(FIXED_SIZE, b"\0")
+ + uid
+ + key
+ + (acc_code or b"\0" * ACC_CODE_SIZE)
+ + struct.pack(">BBBB", len(fixed), ext, tkt, cfg)
+ + b"\0\0" # RFU
+ )
+ return buf + struct.pack("<H", 0xFFFF & ~calculate_crc(buf))
+
+
+def _build_update(ext, tkt, cfg, acc_code=None):
+ if ext & ~EXTFLAG_UPDATE_MASK != 0:
+ raise ValueError("Unsupported ext flags for update")
+ if tkt & ~TKTFLAG_UPDATE_MASK != 0:
+ raise ValueError("Unsupported tkt flags for update")
+ if cfg & ~CFGFLAG_UPDATE_MASK != 0:
+ raise ValueError("Unsupported cfg flags for update")
+ return _build_config(
+ b"", b"\0" * UID_SIZE, b"\0" * KEY_SIZE, ext, tkt, cfg, acc_code
+ )
+
+
+def _build_ndef_config(value, ndef_type=NDEF_TYPE.URI):
+ if ndef_type == NDEF_TYPE.URI:
+ if value is None:
+ value = DEFAULT_NDEF_URI
+ for i, prefix in enumerate(NDEF_URL_PREFIXES):
+ if value.startswith(prefix):
+ id_code = i + 1
+ value = value[len(prefix) :]
+ break
+ else:
+ id_code = 0
+ data = bytes([id_code]) + value.encode()
+ else:
+ if value is None:
+ value = ""
+ data = b"\x02en" + value.encode()
+ if len(data) > NDEF_DATA_SIZE:
+ raise ValueError("URI payload too large")
+ return bytes([len(data), ndef_type]) + data.ljust(NDEF_DATA_SIZE, b"\0")
+
+
+[docs]@unique
+class CFGSTATE(IntFlag):
+ # Bits in touch_level
+ SLOT1_VALID = 0x01 # configuration 1 is valid (from firmware 2.1)
+ SLOT2_VALID = 0x02 # configuration 2 is valid (from firmware 2.1)
+ SLOT1_TOUCH = 0x04 # configuration 1 requires touch (from firmware 3.0)
+ SLOT2_TOUCH = 0x08 # configuration 2 requires touch (from firmware 3.0)
+ LED_INV = 0x10 # LED behavior is inverted (EXTFLAG_LED_INV mirror)
+
+
+def _shorten_hmac_key(key: bytes) -> bytes:
+ if len(key) > SHA1_BLOCK_SIZE:
+ key = sha1(key).digest() # nosec
+ elif len(key) > HMAC_KEY_SIZE:
+ raise NotSupportedError(f"Key lengths > {HMAC_KEY_SIZE} bytes not supported")
+ return key
+
+
+Cfg = TypeVar("Cfg", bound="SlotConfiguration")
+
+
+[docs]class SlotConfiguration:
+ def __init__(self):
+ self._fixed = b""
+ self._uid = b"\0" * UID_SIZE
+ self._key = b"\0" * KEY_SIZE
+ self._flags = {}
+
+ self._update_flags(EXTFLAG.SERIAL_API_VISIBLE, True)
+ self._update_flags(EXTFLAG.ALLOW_UPDATE, True)
+
+ def _update_flags(self, flag: IntFlag, value: bool) -> None:
+ flag_key = type(flag)
+ flags = self._flags.get(flag_key, 0)
+ self._flags[flag_key] = flags | flag if value else flags & ~flag
+
+
+
+[docs] def get_config(self, acc_code: Optional[bytes] = None) -> bytes:
+ return _build_config(
+ self._fixed,
+ self._uid,
+ self._key,
+ self._flags.get(EXTFLAG, 0),
+ self._flags.get(TKTFLAG, 0),
+ self._flags.get(CFGFLAG, 0),
+ acc_code,
+ )
+
+[docs] def serial_api_visible(self: Cfg, value: bool) -> Cfg:
+ self._update_flags(EXTFLAG.SERIAL_API_VISIBLE, value)
+ return self
+
+[docs] def serial_usb_visible(self: Cfg, value: bool) -> Cfg:
+ self._update_flags(EXTFLAG.SERIAL_USB_VISIBLE, value)
+ return self
+
+[docs] def allow_update(self: Cfg, value: bool) -> Cfg:
+ self._update_flags(EXTFLAG.ALLOW_UPDATE, value)
+ return self
+
+[docs] def dormant(self: Cfg, value: bool) -> Cfg:
+ self._update_flags(EXTFLAG.DORMANT, value)
+ return self
+
+[docs] def invert_led(self: Cfg, value: bool) -> Cfg:
+ self._update_flags(EXTFLAG.LED_INV, value)
+ return self
+
+[docs] def protect_slot2(self: Cfg, value: bool) -> Cfg:
+ self._update_flags(TKTFLAG.PROTECT_CFG2, value)
+ return self
+
+
+[docs]class HmacSha1SlotConfiguration(SlotConfiguration):
+ def __init__(self, key: bytes):
+ super(HmacSha1SlotConfiguration, self).__init__()
+
+ key = _shorten_hmac_key(key)
+
+ # Key is packed into key and uid
+ self._key = key[:KEY_SIZE].ljust(KEY_SIZE, b"\0")
+ self._uid = key[KEY_SIZE:].ljust(UID_SIZE, b"\0")
+
+ self._update_flags(TKTFLAG.CHAL_RESP, True)
+ self._update_flags(CFGFLAG.CHAL_HMAC, True)
+ self._update_flags(CFGFLAG.HMAC_LT64, True)
+
+
+
+[docs] def require_touch(self: Cfg, value: bool) -> Cfg:
+ self._update_flags(CFGFLAG.CHAL_BTN_TRIG, value)
+ return self
+
+[docs] def lt64(self: Cfg, value: bool) -> Cfg:
+ self._update_flags(CFGFLAG.HMAC_LT64, value)
+ return self
+
+
+[docs]class KeyboardSlotConfiguration(SlotConfiguration):
+ def __init__(self):
+ super(KeyboardSlotConfiguration, self).__init__()
+ self._update_flags(TKTFLAG.APPEND_CR, True)
+ self._update_flags(EXTFLAG.FAST_TRIG, True)
+
+[docs] def append_cr(self: Cfg, value: bool) -> Cfg:
+ self._update_flags(TKTFLAG.APPEND_CR, value)
+ return self
+
+[docs] def fast_trigger(self: Cfg, value: bool) -> Cfg:
+ self._update_flags(EXTFLAG.FAST_TRIG, value)
+ return self
+
+[docs] def pacing(self: Cfg, pacing_10ms: bool = False, pacing_20ms: bool = False) -> Cfg:
+ self._update_flags(CFGFLAG.PACING_10MS, pacing_10ms)
+ self._update_flags(CFGFLAG.PACING_20MS, pacing_20ms)
+ return self
+
+[docs] def use_numeric(self: Cfg, value: bool) -> Cfg:
+ self._update_flags(EXTFLAG.USE_NUMERIC_KEYPAD, value)
+ return self
+
+
+[docs]class HotpSlotConfiguration(KeyboardSlotConfiguration):
+ def __init__(self, key: bytes):
+ super(HotpSlotConfiguration, self).__init__()
+
+ key = _shorten_hmac_key(key)
+
+ # Key is packed into key and uid
+ self._key = key[:KEY_SIZE].ljust(KEY_SIZE, b"\0")
+ self._uid = key[KEY_SIZE:].ljust(UID_SIZE, b"\0")
+
+ self._update_flags(TKTFLAG.OATH_HOTP, True)
+ self._update_flags(CFGFLAG.OATH_FIXED_MODHEX2, True)
+
+
+
+[docs] def digits8(self: Cfg, value: bool) -> Cfg:
+ self._update_flags(CFGFLAG.OATH_HOTP8, value)
+ return self
+
+[docs] def token_id(
+ self: Cfg,
+ token_id: bytes,
+ fixed_modhex1: bool = False,
+ fixed_modhex2: bool = True,
+ ) -> Cfg:
+ if len(token_id) > FIXED_SIZE:
+ raise ValueError(f"token_id must be <= {FIXED_SIZE} bytes")
+
+ self._fixed = token_id
+ self._update_flags(CFGFLAG.OATH_FIXED_MODHEX1, fixed_modhex1)
+ self._update_flags(CFGFLAG.OATH_FIXED_MODHEX2, fixed_modhex2)
+ return self
+
+[docs] def imf(self: Cfg, imf: int) -> Cfg:
+ if not (imf % 16 == 0 and 0 <= imf <= 0xFFFF0):
+ raise ValueError(
+ f"imf should be between {0} and {1048560}, evenly dividable by 16"
+ )
+ self._uid = self._uid[:4] + struct.pack(">H", imf >> 4)
+ return self
+
+
+[docs]class StaticPasswordSlotConfiguration(KeyboardSlotConfiguration):
+ def __init__(self, scan_codes: bytes):
+ super(StaticPasswordSlotConfiguration, self).__init__()
+
+ if len(scan_codes) > SCAN_CODES_SIZE:
+ raise NotSupportedError("Password is too long")
+
+ # Scan codes are packed into fixed, uid, and key
+ scan_codes = scan_codes.ljust(SCAN_CODES_SIZE, b"\0")
+ self._fixed = scan_codes[:FIXED_SIZE]
+ self._uid = scan_codes[FIXED_SIZE : FIXED_SIZE + UID_SIZE]
+ self._key = scan_codes[FIXED_SIZE + UID_SIZE :]
+
+ self._update_flags(CFGFLAG.SHORT_TICKET, True)
+
+
+
+
+[docs]class YubiOtpSlotConfiguration(KeyboardSlotConfiguration):
+ def __init__(self, fixed: bytes, uid: bytes, key: bytes):
+ super(YubiOtpSlotConfiguration, self).__init__()
+
+ if len(fixed) > FIXED_SIZE:
+ raise ValueError(f"fixed must be <= {FIXED_SIZE} bytes")
+
+ if len(uid) != UID_SIZE:
+ raise ValueError(f"uid must be {UID_SIZE} bytes")
+
+ if len(key) != KEY_SIZE:
+ raise ValueError(f"key must be {KEY_SIZE} bytes")
+
+ self._fixed = fixed
+ self._uid = uid
+ self._key = key
+
+[docs] def tabs(
+ self: Cfg,
+ before: bool = False,
+ after_first: bool = False,
+ after_second: bool = False,
+ ) -> Cfg:
+ self._update_flags(TKTFLAG.TAB_FIRST, before)
+ self._update_flags(TKTFLAG.APPEND_TAB1, after_first)
+ self._update_flags(TKTFLAG.APPEND_TAB2, after_second)
+ return self
+
+[docs] def delay(self: Cfg, after_first: bool = False, after_second: bool = False) -> Cfg:
+ self._update_flags(TKTFLAG.APPEND_DELAY1, after_first)
+ self._update_flags(TKTFLAG.APPEND_DELAY2, after_second)
+ return self
+
+[docs] def send_reference(self: Cfg, value: bool) -> Cfg:
+ self._update_flags(CFGFLAG.SEND_REF, value)
+ return self
+
+
+[docs]class StaticTicketSlotConfiguration(KeyboardSlotConfiguration):
+ def __init__(self, fixed: bytes, uid: bytes, key: bytes):
+ super(StaticTicketSlotConfiguration, self).__init__()
+
+ if len(fixed) > FIXED_SIZE:
+ raise ValueError(f"fixed must be <= {FIXED_SIZE} bytes")
+
+ if len(uid) != UID_SIZE:
+ raise ValueError(f"uid must be {UID_SIZE} bytes")
+
+ if len(key) != KEY_SIZE:
+ raise ValueError(f"key must be {KEY_SIZE} bytes")
+
+ self._fixed = fixed
+ self._uid = uid
+ self._key = key
+
+ self._update_flags(CFGFLAG.STATIC_TICKET, True)
+
+[docs] def short_ticket(self: Cfg, value: bool) -> Cfg:
+ self._update_flags(CFGFLAG.SHORT_TICKET, value)
+ return self
+
+[docs] def strong_password(
+ self: Cfg, upper_case: bool = False, digit: bool = False, special: bool = False
+ ) -> Cfg:
+ self._update_flags(CFGFLAG.STRONG_PW1, upper_case)
+ self._update_flags(CFGFLAG.STRONG_PW2, digit or special)
+ self._update_flags(CFGFLAG.SEND_REF, special)
+ return self
+
+[docs] def manual_update(self: Cfg, value: bool) -> Cfg:
+ self._update_flags(CFGFLAG.MAN_UPDATE, value)
+ return self
+
+
+[docs]class UpdateConfiguration(KeyboardSlotConfiguration):
+ def __init__(self):
+ super(UpdateConfiguration, self).__init__()
+
+ self._fixed = b"\0" * FIXED_SIZE
+ self._uid = b"\0" * UID_SIZE
+ self._key = b"\0" * KEY_SIZE
+
+
+
+ def _update_flags(self, flag, value):
+ # NB: All EXT flags are allowed
+ if isinstance(flag, TKTFLAG):
+ if not TKTFLAG_UPDATE_MASK & flag:
+ raise ValueError("Unsupported TKT flag for update")
+ elif isinstance(flag, CFGFLAG):
+ if not CFGFLAG_UPDATE_MASK & flag:
+ raise ValueError("Unsupported CFG flag for update")
+ super(UpdateConfiguration, self)._update_flags(flag, value)
+
+[docs] def protect_slot2(self: Cfg, value):
+ raise ValueError("protect_slot2 cannot be applied to UpdateConfiguration")
+
+[docs] def tabs(
+ self: Cfg,
+ before: bool = False,
+ after_first: bool = False,
+ after_second: bool = False,
+ ) -> Cfg:
+ self._update_flags(TKTFLAG.TAB_FIRST, before)
+ self._update_flags(TKTFLAG.APPEND_TAB1, after_first)
+ self._update_flags(TKTFLAG.APPEND_TAB2, after_second)
+ return self
+
+[docs] def delay(self: Cfg, after_first: bool = False, after_second: bool = False) -> Cfg:
+ self._update_flags(TKTFLAG.APPEND_DELAY1, after_first)
+ self._update_flags(TKTFLAG.APPEND_DELAY2, after_second)
+ return self
+
+
+[docs]class ConfigState:
+ """The configuration state of the YubiOTP application."""
+
+ def __init__(self, version: Version, touch_level: int):
+ self.version = version
+ self.flags = sum(CFGSTATE) & touch_level
+
+[docs] def is_configured(self, slot: SLOT) -> bool:
+ """Checks of a slot is programmed, or empty"""
+ require_version(self.version, (2, 1, 0))
+ return self.flags & (CFGSTATE.SLOT1_VALID, CFGSTATE.SLOT2_VALID)[slot - 1] != 0
+
+[docs] def is_touch_triggered(self, slot: SLOT) -> bool:
+ """Checks if a (programmed) state is triggered by touch (not challenge-response)
+ Requires YubiKey 3 or later.
+ """
+ require_version(self.version, (3, 0, 0))
+ return self.flags & (CFGSTATE.SLOT1_TOUCH, CFGSTATE.SLOT2_TOUCH)[slot - 1] != 0
+
+[docs] def is_led_inverted(self) -> bool:
+ """Checks if the LED behavior is inverted."""
+ return self.flags & CFGSTATE.LED_INV != 0
+
+ def __repr__(self):
+ items = []
+ try:
+ items.append(
+ "configured: (%s, %s)"
+ % (self.is_configured(SLOT.ONE), self.is_configured(SLOT.TWO))
+ )
+ items.append(
+ "touch_triggered: (%s, %s)"
+ % (self.is_touch_triggered(SLOT.ONE), self.is_touch_triggered(SLOT.TWO))
+ )
+ items.append("led_inverted: %s" % self.is_led_inverted())
+ except NotSupportedError:
+ pass
+ return f"ConfigState({', '.join(items)})"
+
+
+class _Backend(abc.ABC):
+ version: Version
+
+ @abc.abstractmethod
+ def close(self) -> None:
+ ...
+
+ @abc.abstractmethod
+ def write_update(self, slot: CONFIG_SLOT, data: bytes) -> bytes:
+ ...
+
+ @abc.abstractmethod
+ def send_and_receive(
+ self,
+ slot: CONFIG_SLOT,
+ data: bytes,
+ expected_len: int,
+ event: Optional[Event] = None,
+ on_keepalive: Optional[Callable[[int], None]] = None,
+ ) -> bytes:
+ ...
+
+
+class _YubiOtpOtpBackend(_Backend):
+ def __init__(self, protocol):
+ self.protocol = protocol
+
+ def close(self):
+ self.protocol.close()
+
+ def write_update(self, slot, data):
+ return self.protocol.send_and_receive(slot, data)
+
+ def send_and_receive(self, slot, data, expected_len, event=None, on_keepalive=None):
+ response = self.protocol.send_and_receive(slot, data, event, on_keepalive)
+ if check_crc(response[: expected_len + 2]):
+ return response[:expected_len]
+ raise BadResponseError("Invalid CRC")
+
+
+INS_CONFIG = 0x01
+
+
+class _YubiOtpSmartCardBackend(_Backend):
+ def __init__(self, protocol, version, prog_seq):
+ self.protocol = protocol
+ self._version = version
+ self._prog_seq = prog_seq
+
+ def close(self):
+ self.protocol.close()
+
+ def write_update(self, slot, data):
+ status = self.protocol.send_apdu(0, INS_CONFIG, slot, 0, data)
+ prev_prog_seq, self._prog_seq = self._prog_seq, status[3]
+ if self._prog_seq == prev_prog_seq + 1:
+ return status
+ if self._prog_seq == 0 and prev_prog_seq > 0:
+ version = Version.from_bytes(status[:3])
+ if (4, 0) <= version < (5, 5): # Programming state does not update
+ return status
+ if status[4] & 0x1F == 0:
+ return status
+ raise CommandRejectedError("Not updated")
+
+ def send_and_receive(self, slot, data, expected_len, event=None, on_keepalive=None):
+ response = self.protocol.send_apdu(0, INS_CONFIG, slot, 0, data)
+ if expected_len == len(response):
+ return response
+ raise BadResponseError("Unexpected response length")
+
+
+[docs]class YubiOtpSession:
+ """A session with the YubiOTP application."""
+
+ def __init__(self, connection: Union[OtpConnection, SmartCardConnection]):
+ if isinstance(connection, OtpConnection):
+ otp_protocol = OtpProtocol(connection)
+ self._status = otp_protocol.read_status()
+ self._version = otp_protocol.version
+ self.backend: _Backend = _YubiOtpOtpBackend(otp_protocol)
+ elif isinstance(connection, SmartCardConnection):
+ card_protocol = SmartCardProtocol(connection)
+ mgmt_version = None
+ if connection.transport == TRANSPORT.NFC:
+ # This version is more reliable over NFC
+ try:
+ card_protocol.select(AID.MANAGEMENT)
+ select_str = card_protocol.select(AID.MANAGEMENT).decode()
+ mgmt_version = Version.from_string(select_str)
+ except ApplicationNotAvailableError:
+ pass # Not available (probably NEO), get version from status
+
+ self._status = card_protocol.select(AID.OTP)
+ otp_version = Version.from_bytes(self._status[:3])
+ if mgmt_version and mgmt_version[0] == 3:
+ # NEO reports the highest of these two
+ self._version = max(mgmt_version, otp_version)
+ else:
+ self._version = mgmt_version or otp_version
+ card_protocol.enable_touch_workaround(self._version)
+ self.backend = _YubiOtpSmartCardBackend(
+ card_protocol, self._version, self._status[3]
+ )
+ else:
+ raise TypeError("Unsupported connection type")
+ logger.debug(
+ "YubiOTP session initialized for "
+ f"connection={type(connection).__name__}, version={self.version}, "
+ f"state={self.get_config_state()}"
+ )
+
+
+
+ @property
+ def version(self) -> Version:
+ return self._version
+
+[docs] def get_serial(self) -> int:
+ """Get serial number."""
+ return bytes2int(
+ self.backend.send_and_receive(CONFIG_SLOT.DEVICE_SERIAL, b"", 4)
+ )
+
+[docs] def get_config_state(self) -> ConfigState:
+ """Get configuration state of the YubiOTP application."""
+ return ConfigState(self.version, struct.unpack("<H", self._status[4:6])[0])
+
+ def _write_config(self, slot, config, cur_acc_code):
+ has_acc = bool(cur_acc_code)
+ logger.debug(f"Writing configuration to slot {slot}, access code: {has_acc}")
+ self._status = self.backend.write_update(
+ slot, config + (cur_acc_code or b"\0" * ACC_CODE_SIZE)
+ )
+ logger.info("Configuration written")
+
+[docs] def put_configuration(
+ self,
+ slot: SLOT,
+ configuration: SlotConfiguration,
+ acc_code: Optional[bytes] = None,
+ cur_acc_code: Optional[bytes] = None,
+ ) -> None:
+ """Write configuration to slot.
+
+ :param slot: The slot to configure.
+ :param configuration: The slot configuration.
+ :param acc_code: The new access code.
+ :param cur_acc_code: The current access code.
+ """
+ if not configuration.is_supported_by(self.version):
+ raise NotSupportedError(
+ "This configuration is not supported on this YubiKey version"
+ )
+ slot = SLOT(slot)
+ logger.debug(
+ f"Writing configuration of type {type(configuration).__name__} to "
+ f"slot {slot}"
+ )
+ self._write_config(
+ SLOT.map(slot, CONFIG_SLOT.CONFIG_1, CONFIG_SLOT.CONFIG_2),
+ configuration.get_config(acc_code),
+ cur_acc_code,
+ )
+
+[docs] def update_configuration(
+ self,
+ slot: SLOT,
+ configuration: SlotConfiguration,
+ acc_code: Optional[bytes] = None,
+ cur_acc_code: Optional[bytes] = None,
+ ) -> None:
+ """Update configuration in slot.
+
+ :param slot: The slot to update the configuration in.
+ :param configuration: The slot configuration.
+ :param acc_code: The new access code.
+ :param cur_acc_code: The current access code.
+ """
+ if not configuration.is_supported_by(self.version):
+ raise NotSupportedError(
+ "This configuration is not supported on this YubiKey version"
+ )
+ if acc_code != cur_acc_code and (4, 3, 2) <= self.version < (4, 3, 6):
+ raise NotSupportedError(
+ "The access code cannot be updated on this YubiKey. "
+ "Instead, delete the slot and configure it anew."
+ )
+ slot = SLOT(slot)
+ logger.debug(f"Writing configuration update to slot {slot}")
+ self._write_config(
+ SLOT.map(slot, CONFIG_SLOT.UPDATE_1, CONFIG_SLOT.UPDATE_2),
+ configuration.get_config(acc_code),
+ cur_acc_code,
+ )
+
+[docs] def swap_slots(self) -> None:
+ """Swap the two slot configurations."""
+ logger.debug("Swapping touch slots")
+ self._write_config(CONFIG_SLOT.SWAP, b"", None)
+
+[docs] def delete_slot(self, slot: SLOT, cur_acc_code: Optional[bytes] = None) -> None:
+ """Delete configuration stored in slot.
+
+ :param slot: The slot to delete the configuration in.
+ :param cur_acc_code: The current access code.
+ """
+ slot = SLOT(slot)
+ logger.debug(f"Deleting slot {slot}")
+ self._write_config(
+ SLOT.map(slot, CONFIG_SLOT.CONFIG_1, CONFIG_SLOT.CONFIG_2),
+ b"\0" * CONFIG_SIZE,
+ cur_acc_code,
+ )
+
+[docs] def set_scan_map(
+ self, scan_map: bytes, cur_acc_code: Optional[bytes] = None
+ ) -> None:
+ """Update scan-codes on YubiKey.
+
+ This updates the scan-codes (or keyboard presses) that the YubiKey
+ will use when typing out OTPs.
+ """
+ logger.debug("Writing scan map")
+ self._write_config(CONFIG_SLOT.SCAN_MAP, scan_map, cur_acc_code)
+
+[docs] def set_ndef_configuration(
+ self,
+ slot: SLOT,
+ uri: Optional[str] = None,
+ cur_acc_code: Optional[bytes] = None,
+ ndef_type: NDEF_TYPE = NDEF_TYPE.URI,
+ ) -> None:
+ """Configure a slot to be used over NDEF (NFC).
+
+ :param slot: The slot to configure.
+ :param uri: URI or static text.
+ :param cur_acc_code: The current access code.
+ :param ndef_type: The NDEF type (text or URI).
+ """
+ slot = SLOT(slot)
+ logger.debug(f"Writing NDEF configuration for slot {slot} of type {ndef_type}")
+ self._write_config(
+ SLOT.map(slot, CONFIG_SLOT.NDEF_1, CONFIG_SLOT.NDEF_2),
+ _build_ndef_config(uri, ndef_type),
+ cur_acc_code,
+ )
+
+[docs] def calculate_hmac_sha1(
+ self,
+ slot: SLOT,
+ challenge: bytes,
+ event: Optional[Event] = None,
+ on_keepalive: Optional[Callable[[int], None]] = None,
+ ) -> bytes:
+ """Perform a challenge-response operation using HMAC-SHA1.
+
+ :param slot: The slot to perform the operation against.
+ :param challenge: The challenge.
+ :param event: An event.
+ """
+ require_version(self.version, (2, 2, 0))
+ slot = SLOT(slot)
+ logger.debug(f"Calculating response for slot {slot}")
+
+ # Pad challenge with byte different from last
+ challenge = challenge.ljust(
+ HMAC_CHALLENGE_SIZE, b"\1" if challenge.endswith(b"\0") else b"\0"
+ )
+ return self.backend.send_and_receive(
+ SLOT.map(slot, CONFIG_SLOT.CHAL_HMAC_1, CONFIG_SLOT.CHAL_HMAC_2),
+ challenge,
+ HMAC_RESPONSE_SIZE,
+ event,
+ on_keepalive,
+ )
+
' + + '' + + _("Hide Search Matches") + + "
" + ) + ); + }, + + /** + * helper function to hide the search marks again + */ + hideSearchWords: () => { + document + .querySelectorAll("#searchbox .highlight-link") + .forEach((el) => el.remove()); + document + .querySelectorAll("span.highlighted") + .forEach((el) => el.classList.remove("highlighted")); + localStorage.removeItem("sphinx_highlight_terms") + }, + + initEscapeListener: () => { + // only install a listener if it is really needed + if (!DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS) return; + + document.addEventListener("keydown", (event) => { + // bail for input elements + if (BLACKLISTED_KEY_CONTROL_ELEMENTS.has(document.activeElement.tagName)) return; + // bail with special keys + if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return; + if (DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS && (event.key === "Escape")) { + SphinxHighlight.hideSearchWords(); + event.preventDefault(); + } + }); + }, +}; + +_ready(SphinxHighlight.highlightSearchWords); +_ready(SphinxHighlight.initEscapeListener); diff --git a/static/yubikey-manager/API_Documentation/genindex.html b/static/yubikey-manager/API_Documentation/genindex.html new file mode 100644 index 000000000..ecb843a66 --- /dev/null +++ b/static/yubikey-manager/API_Documentation/genindex.html @@ -0,0 +1,2423 @@ + + + + + ++ | + |
+ |
+ | + |
+ y | ||
+ |
+ ykman | + |
+ |
+ ykman.base | + |
+ |
+ ykman.device | + |
+ |
+ ykman.fido | + |
+ |
+ ykman.hsmauth | + |
+ |
+ ykman.oath | + |
+ |
+ ykman.openpgp | + |
+ |
+ ykman.piv | + |
+ |
+ ykman.scripting | + |
+ |
+ yubikit | + |
+ |
+ yubikit.core | + |
+ |
+ yubikit.core.fido | + |
+ |
+ yubikit.core.otp | + |
+ |
+ yubikit.core.smartcard | + |
+ |
+ yubikit.hsmauth | + |
+ |
+ yubikit.logging | + |
+ |
+ yubikit.management | + |
+ |
+ yubikit.oath | + |
+ |
+ yubikit.openpgp | + |
+ |
+ yubikit.piv | + |
+ |
+ yubikit.support | + |
+ |
+ yubikit.yubiotp | + |
ALGORITHM
+Credential
+HsmAuthSession
HsmAuthSession.calculate_session_keys_asymmetric()
HsmAuthSession.calculate_session_keys_symmetric()
HsmAuthSession.delete_credential()
HsmAuthSession.generate_credential_asymmetric()
HsmAuthSession.get_challenge()
HsmAuthSession.get_management_key_retries()
HsmAuthSession.get_public_key()
HsmAuthSession.list_credentials()
HsmAuthSession.put_credential_asymmetric()
HsmAuthSession.put_credential_derived()
HsmAuthSession.put_credential_symmetric()
HsmAuthSession.put_management_key()
HsmAuthSession.reset()
HsmAuthSession.version
SessionKeys
+CAPABILITY
+DEVICE_FLAG
+DeviceConfig
+DeviceInfo
+FORM_FACTOR
+ManagementSession
+Mode
+Code
+Credential
+CredentialData
+HASH_ALGORITHM
+OATH_TYPE
+OathSession
OathSession.calculate()
OathSession.calculate_all()
OathSession.calculate_code()
OathSession.delete_credential()
OathSession.derive_key()
OathSession.device_id
OathSession.has_key
OathSession.list_credentials()
OathSession.locked
OathSession.put_credential()
OathSession.rename_credential()
OathSession.reset()
OathSession.set_key()
OathSession.unset_key()
OathSession.validate()
OathSession.version
parse_b32_key()
AlgorithmAttributes
+ApplicationRelatedData
+CRT
+CardholderRelatedData
+CurveOid
DO
DO.AID
DO.ALGORITHM_ATTRIBUTES_ATT
DO.ALGORITHM_ATTRIBUTES_AUT
DO.ALGORITHM_ATTRIBUTES_DEC
DO.ALGORITHM_ATTRIBUTES_SIG
DO.ALGORITHM_INFORMATION
DO.APPLICATION_RELATED_DATA
DO.ATT_CERTIFICATE
DO.CARDHOLDER_CERTIFICATE
DO.CARDHOLDER_RELATED_DATA
DO.CA_FINGERPRINT_1
DO.CA_FINGERPRINT_2
DO.CA_FINGERPRINT_3
DO.CA_FINGERPRINT_4
DO.EXTENDED_LENGTH_INFO
DO.FINGERPRINT_ATT
DO.FINGERPRINT_AUT
DO.FINGERPRINT_DEC
DO.FINGERPRINT_SIG
DO.GENERAL_FEATURE_MANAGEMENT
DO.GENERATION_TIME_ATT
DO.GENERATION_TIME_AUT
DO.GENERATION_TIME_DEC
DO.GENERATION_TIME_SIG
DO.HISTORICAL_BYTES
DO.KDF
DO.LANGUAGE
DO.LOGIN_DATA
DO.NAME
DO.PRIVATE_USE_1
DO.PRIVATE_USE_2
DO.PRIVATE_USE_3
DO.PRIVATE_USE_4
DO.PW_STATUS_BYTES
DO.RESETTING_CODE
DO.SECURITY_SUPPORT_TEMPLATE
DO.SEX
DO.UIF_ATT
DO.UIF_AUT
DO.UIF_DEC
DO.UIF_SIG
DO.URL
DiscretionaryDataObjects
DiscretionaryDataObjects.attributes_att
DiscretionaryDataObjects.attributes_aut
DiscretionaryDataObjects.attributes_dec
DiscretionaryDataObjects.attributes_sig
DiscretionaryDataObjects.ca_fingerprints
DiscretionaryDataObjects.extended_capabilities
DiscretionaryDataObjects.fingerprints
DiscretionaryDataObjects.generation_times
DiscretionaryDataObjects.get_algorithm_attributes()
DiscretionaryDataObjects.key_information
DiscretionaryDataObjects.parse()
DiscretionaryDataObjects.pw_status
DiscretionaryDataObjects.uif_att
DiscretionaryDataObjects.uif_aut
DiscretionaryDataObjects.uif_dec
DiscretionaryDataObjects.uif_sig
EC_IMPORT_FORMAT
+EXTENDED_CAPABILITY_FLAGS
EXTENDED_CAPABILITY_FLAGS.ALGORITHM_ATTRIBUTES_CHANGEABLE
EXTENDED_CAPABILITY_FLAGS.GET_CHALLENGE
EXTENDED_CAPABILITY_FLAGS.KDF
EXTENDED_CAPABILITY_FLAGS.KEY_IMPORT
EXTENDED_CAPABILITY_FLAGS.PRIVATE_USE
EXTENDED_CAPABILITY_FLAGS.PSO_DEC_ENC_AES
EXTENDED_CAPABILITY_FLAGS.PW_STATUS_CHANGEABLE
EXTENDED_CAPABILITY_FLAGS.SECURE_MESSAGING
EcAttributes
+EcKeyTemplate
+ExtendedCapabilities
ExtendedCapabilities.certificate_max_length
ExtendedCapabilities.challenge_max_length
ExtendedCapabilities.flags
ExtendedCapabilities.mse_command
ExtendedCapabilities.parse()
ExtendedCapabilities.pin_block_2_format
ExtendedCapabilities.sm_algorithm
ExtendedCapabilities.special_do_max_length
ExtendedLengthInfo
+GENERAL_FEATURE_MANAGEMENT
+HASH_ALGORITHM
+INS
+KEY_REF
+KEY_STATUS
+Kdf
+KdfIterSaltedS2k
KdfIterSaltedS2k.algorithm
KdfIterSaltedS2k.create()
KdfIterSaltedS2k.get_salt()
KdfIterSaltedS2k.hash_algorithm
KdfIterSaltedS2k.initial_hash_admin
KdfIterSaltedS2k.initial_hash_user
KdfIterSaltedS2k.iteration_count
KdfIterSaltedS2k.process()
KdfIterSaltedS2k.salt_admin
KdfIterSaltedS2k.salt_reset
KdfIterSaltedS2k.salt_user
KdfNone
+OpenPgpAid
+OpenPgpSession
OpenPgpSession.aid
OpenPgpSession.attest_key()
OpenPgpSession.authenticate()
OpenPgpSession.change_admin()
OpenPgpSession.change_pin()
OpenPgpSession.decrypt()
OpenPgpSession.delete_certificate()
OpenPgpSession.delete_key()
OpenPgpSession.extended_capabilities
OpenPgpSession.generate_ec_key()
OpenPgpSession.generate_rsa_key()
OpenPgpSession.get_algorithm_attributes()
OpenPgpSession.get_algorithm_information()
OpenPgpSession.get_application_related_data()
OpenPgpSession.get_certificate()
OpenPgpSession.get_challenge()
OpenPgpSession.get_data()
OpenPgpSession.get_fingerprints()
OpenPgpSession.get_generation_times()
OpenPgpSession.get_kdf()
OpenPgpSession.get_key_information()
OpenPgpSession.get_pin_status()
OpenPgpSession.get_public_key()
OpenPgpSession.get_signature_counter()
OpenPgpSession.get_uif()
OpenPgpSession.put_certificate()
OpenPgpSession.put_data()
OpenPgpSession.put_key()
OpenPgpSession.reset()
OpenPgpSession.reset_pin()
OpenPgpSession.set_algorithm_attributes()
OpenPgpSession.set_fingerprint()
OpenPgpSession.set_generation_time()
OpenPgpSession.set_kdf()
OpenPgpSession.set_pin_attempts()
OpenPgpSession.set_reset_code()
OpenPgpSession.set_signature_pin_policy()
OpenPgpSession.set_uif()
OpenPgpSession.sign()
OpenPgpSession.unverify_pin()
OpenPgpSession.verify_admin()
OpenPgpSession.verify_pin()
OpenPgpSession.version
PIN_POLICY
+PW
+PrivateKeyTemplate
+PwStatus
+RSA_IMPORT_FORMAT
+RSA_SIZE
+RsaAttributes
+RsaCrtKeyTemplate
+RsaKeyTemplate
+SecuritySupportTemplate
+UIF
+ALGORITHM
+KEY_TYPE
+MANAGEMENT_KEY_TYPE
+ManagementKeyMetadata
+OBJECT_ID
OBJECT_ID.ATTESTATION
OBJECT_ID.AUTHENTICATION
OBJECT_ID.CAPABILITY
OBJECT_ID.CARD_AUTH
OBJECT_ID.CHUID
OBJECT_ID.DISCOVERY
OBJECT_ID.FACIAL
OBJECT_ID.FINGERPRINTS
OBJECT_ID.IRIS
OBJECT_ID.KEY_HISTORY
OBJECT_ID.KEY_MANAGEMENT
OBJECT_ID.PRINTED
OBJECT_ID.RETIRED1
OBJECT_ID.RETIRED10
OBJECT_ID.RETIRED11
OBJECT_ID.RETIRED12
OBJECT_ID.RETIRED13
OBJECT_ID.RETIRED14
OBJECT_ID.RETIRED15
OBJECT_ID.RETIRED16
OBJECT_ID.RETIRED17
OBJECT_ID.RETIRED18
OBJECT_ID.RETIRED19
OBJECT_ID.RETIRED2
OBJECT_ID.RETIRED20
OBJECT_ID.RETIRED3
OBJECT_ID.RETIRED4
OBJECT_ID.RETIRED5
OBJECT_ID.RETIRED6
OBJECT_ID.RETIRED7
OBJECT_ID.RETIRED8
OBJECT_ID.RETIRED9
OBJECT_ID.SECURITY
OBJECT_ID.SIGNATURE
OBJECT_ID.from_slot()
PIN_POLICY
+PinMetadata
+PivSession
PivSession.attest_key()
PivSession.authenticate()
PivSession.calculate_secret()
PivSession.change_pin()
PivSession.change_puk()
PivSession.decrypt()
PivSession.delete_certificate()
PivSession.generate_key()
PivSession.get_certificate()
PivSession.get_management_key_metadata()
PivSession.get_object()
PivSession.get_pin_attempts()
PivSession.get_pin_metadata()
PivSession.get_puk_metadata()
PivSession.get_slot_metadata()
PivSession.put_certificate()
PivSession.put_key()
PivSession.put_object()
PivSession.reset()
PivSession.set_management_key()
PivSession.set_pin_attempts()
PivSession.sign()
PivSession.unblock_pin()
PivSession.verify_pin()
PivSession.version
SLOT
SLOT.ATTESTATION
SLOT.AUTHENTICATION
SLOT.CARD_AUTH
SLOT.KEY_MANAGEMENT
SLOT.RETIRED1
SLOT.RETIRED10
SLOT.RETIRED11
SLOT.RETIRED12
SLOT.RETIRED13
SLOT.RETIRED14
SLOT.RETIRED15
SLOT.RETIRED16
SLOT.RETIRED17
SLOT.RETIRED18
SLOT.RETIRED19
SLOT.RETIRED2
SLOT.RETIRED20
SLOT.RETIRED3
SLOT.RETIRED4
SLOT.RETIRED5
SLOT.RETIRED6
SLOT.RETIRED7
SLOT.RETIRED8
SLOT.RETIRED9
SLOT.SIGNATURE
SlotMetadata
+TOUCH_POLICY
+check_key_support()
require_version()
CFGFLAG
CFGFLAG.ALLOW_HIDTRIG
CFGFLAG.CHAL_BTN_TRIG
CFGFLAG.CHAL_HMAC
CFGFLAG.CHAL_YUBICO
CFGFLAG.HMAC_LT64
CFGFLAG.MAN_UPDATE
CFGFLAG.OATH_FIXED_MASK
CFGFLAG.OATH_FIXED_MODHEX
CFGFLAG.OATH_FIXED_MODHEX1
CFGFLAG.OATH_FIXED_MODHEX2
CFGFLAG.OATH_HOTP8
CFGFLAG.PACING_10MS
CFGFLAG.PACING_20MS
CFGFLAG.SEND_REF
CFGFLAG.SHORT_TICKET
CFGFLAG.STATIC_TICKET
CFGFLAG.STRONG_PW1
CFGFLAG.STRONG_PW2
CFGFLAG.TICKET_FIRST
CFGSTATE
+CONFIG_SLOT
CONFIG_SLOT.CHAL_HMAC_1
CONFIG_SLOT.CHAL_HMAC_2
CONFIG_SLOT.CHAL_OTP_1
CONFIG_SLOT.CHAL_OTP_2
CONFIG_SLOT.CONFIG_1
CONFIG_SLOT.CONFIG_2
CONFIG_SLOT.DEVICE_CONFIG
CONFIG_SLOT.DEVICE_SERIAL
CONFIG_SLOT.NAV
CONFIG_SLOT.NDEF_1
CONFIG_SLOT.NDEF_2
CONFIG_SLOT.SCAN_MAP
CONFIG_SLOT.SWAP
CONFIG_SLOT.UPDATE_1
CONFIG_SLOT.UPDATE_2
CONFIG_SLOT.YK4_CAPABILITIES
CONFIG_SLOT.YK4_SET_DEVICE_INFO
ConfigState
+EXTFLAG
+HmacSha1SlotConfiguration
+HotpSlotConfiguration
+KeyboardSlotConfiguration
+NDEF_TYPE
+SLOT
SLOT.ONE
SLOT.TWO
SLOT.map()
SlotConfiguration
+StaticPasswordSlotConfiguration
+StaticTicketSlotConfiguration
+TKTFLAG
+UpdateConfiguration
+YubiOtpSession
YubiOtpSession.calculate_hmac_sha1()
YubiOtpSession.close()
YubiOtpSession.delete_slot()
YubiOtpSession.get_config_state()
YubiOtpSession.get_serial()
YubiOtpSession.put_configuration()
YubiOtpSession.set_ndef_configuration()
YubiOtpSession.set_scan_map()
YubiOtpSession.swap_slots()
YubiOtpSession.update_configuration()
YubiOtpSession.version
YubiOtpSlotConfiguration
+YkmanDevice
+PivmanData
+PivmanProtectedData
+check_key()
derive_management_key()
generate_ccc()
generate_chuid()
generate_csr()
generate_random_management_key()
generate_self_signed_certificate()
get_piv_info()
get_pivman_data()
get_pivman_protected_data()
list_certificates()
parse_rfc4514_string()
pivman_change_pin()
pivman_set_mgm_key()
sign_certificate_builder()
sign_csr_builder()
Connect to all attached YubiKeys and read device info from them.
+connection_types (Iterable
[Type
[Connection
]]) – An iterable of YubiKey connection types.
A list of (device, info) tuples for each connected device.
+Change the PIN on a YubiKey FIPS.
+If no PIN is set, pass None or an empty string as old_pin.
+ +Reset the FIDO module of a YubiKey FIPS.
+Note: This action is only permitted immediately after YubiKey FIPS power-up. It +also requires the user to touch the flashing button on the YubiKey, and will halt +until that happens, or the command times out.
+fido_connection (CtapDevice
) – A FIDO connection.
Check if OATH credential is hidden.
+Get human readable information about the OpenPGP configuration.
+session (OpenPgpSession
) – The OpenPGP session.
Bases: object
Bases: object
Check that a given public key corresponds to the private key in a slot.
+This will create a signature using the private key, so the PIN must be verified +prior to calling this function if the PIN policy requires it.
+session (PivSession
) – The PIV session.
slot (SLOT
) – The slot.
public_key (Union
[RSAPublicKey
, EllipticCurvePublicKey
]) – The public key.
Derive a management key from the users PIN and a salt.
+NOTE: This method of derivation is deprecated! Protect the management key using +PivmanProtectedData instead.
+ +Generate a CCC (Card Capability Container).
+Generate a CHUID (Cardholder Unique Identifier).
+Generate a CSR using a private key in a slot.
+session (PivSession
) – The PIV session.
slot (SLOT
) – The slot.
public_key (Union
[RSAPublicKey
, EllipticCurvePublicKey
]) – The public key.
subject_str (str
) – The subject RFC 4514 string.
hash_algorithm (Type
[Union
[SHA224
, SHA256
, SHA384
, SHA512
, SHA3_224
, SHA3_256
, SHA3_384
, SHA3_512
]]) – The hash algorithm.
Generate a new random management key.
+algorithm (MANAGEMENT_KEY_TYPE
) – The algorithm for the management key.
Generate a self-signed certificate using a private key in a slot.
+session (PivSession
) – The PIV session.
slot (SLOT
) – The slot.
public_key (Union
[RSAPublicKey
, EllipticCurvePublicKey
]) – The public key.
subject_str (str
) – The subject RFC 4514 string.
valid_from (datetime
) – The date from when the certificate is valid.
valid_to (datetime
) – The date when the certificate expires.
hash_algorithm (Type
[Union
[SHA224
, SHA256
, SHA384
, SHA512
, SHA3_224
, SHA3_256
, SHA3_384
, SHA3_512
]]) – The hash algorithm.
Get human readable information about the PIV configuration.
+session (PivSession
) – The PIV session.
Read out the Pivman data from a YubiKey.
+session (PivSession
) – The PIV session.
Read out the Pivman protected data from a YubiKey.
+This function requires PIN verification prior to being called.
+session (PivSession
) – The PIV session.
Read out and parse stored certificates.
+Only certificates which are successfully parsed are returned.
+session (PivSession
) – The PIV session.
Change the PIN, while keeping PivmanData in sync.
+session (PivSession
) – The PIV session.
old_pin (str
) – The old PIN.
new_pin (str
) – The new PIN.
Set a new management key, while keeping PivmanData in sync.
+session (PivSession
) – The PIV session.
new_key (bytes
) – The new management key.
algorithm (MANAGEMENT_KEY_TYPE
) – The algorithm for the management key.
touch (bool
) – If set, touch is required.
store_on_device (bool
) – If set, the management key is stored on device.
Sign a Certificate.
+Sign a CSR.
+session (PivSession
) – The PIV session.
slot (SLOT
) – The slot.
public_key (Union
[RSAPublicKey
, EllipticCurvePublicKey
]) – The public key.
builder (CertificateSigningRequestBuilder
) – The x509 certificate signing request builder
+object.
hash_algorithm (Type
[Union
[SHA224
, SHA256
, SHA384
, SHA512
, SHA3_224
, SHA3_256
, SHA3_384
, SHA3_512
]]) – The hash algorithm.
Bases: object
Scripting-friendly proxy for YkmanDevice.
+This wrapper adds some helpful utility methods useful for scripting.
+ + +Connect to multiple YubiKeys.
+Connect to multiple YubiKeys over NFC.
+reader – The name of the NFC reader.
ignore_duplicates – When set, duplicates are ignored.
allow_initial – When set, YubiKeys can be connected +at the start of the function call.
prompt – When set, you will be prompted to place +YubiKeys on the NFC reader.
Connect to a YubiKey.
+prompt – When set, you will be prompted to +insert a YubiKey.
+Bases: CommandError
The issues command was rejected by the YubiKey
+Bases: Connection
ClassVar
[USB_INTERFACE
] = 1Bases: object
An implementation of the OTP protocol.
+ + +Receive status bytes from YubiKey.
+Status bytes (first 3 bytes are the firmware version).
+IOException – in case of communication error.
+Sends a command to the YubiKey, and reads the response.
+If the command results in a configuration update, the programming sequence +number is verified and the updated status bytes are returned.
+Response data (including CRC) in the case of data, or an updated status +struct.
+YubiKey Application smart card AID values.
+Bases: CommandError
Thrown when an APDU response has the wrong SW code
+APDU encoding format
+Bases: IntEnum
An enumeration.
+Bases: Connection
ClassVar
[USB_INTERFACE
] = 4Bases: object
An implementation of the Smart Card protocol.
+ + + + + + + + +Bases: CommandError
The application is either disabled or not supported on this YubiKey
+Bases: CommandError
Invalid response data from the YubiKey
+Bases: Exception
An error response from a YubiKey
+Bases: ABC
A connection to a YubiKey
+ + +ClassVar
[USB_INTERFACE
] = 0Bases: 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.
+Bases: ValueError
Attempting an action that is not supported on this YubiKey
+Bases: IntEnum
USB Product ID values for YubiKey devices.
+YubiKey physical connection transports.
+Bases: CommandError
An operation timed out waiting for something
+Bases: bytes
Bases: IntFlag
YubiKey USB interface identifiers.
+Bases: tuple
3-digit version tuple.
+ + + + + + + + + + +Bases: Enum
YubiKey hardware platforms.
+Bases: ABC
YubiKey device reference
+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.
+Opens a connection to the YubiKey
+TypeVar
(T_Connection
, bound= Connection
)
Bases: IntEnum
Algorithms for YubiHSM Auth credentials.
+Bases: object
A YubiHSM Auth credential object.
+ + + + + + + + +Bases: object
A session with the YubiHSM Auth application.
+Calculate session keys from an asymmetric YubiHSM Auth credential.
+label (str
) – The label of the credential.
context (bytes
) – The context (EPK.OCE + EPK.SD).
public_key (EllipticCurvePublicKey
) – The YubiHSM device’s public key.
credential_password (Union
[bytes
, str
]) – The password used to protect
+access to the credential.
card_crypto (bytes
) – The card cryptogram.
Calculate session keys from a symmetric YubiHSM Auth credential.
+Generate an asymmetric YubiHSM Auth credential.
+Generates a private key on the YubiKey, whose corresponding +public key can be retrieved using get_public_key.
+ +Get the Host Challenge.
+For symmetric credentials this is Host Challenge, a random +8 byte value. For asymmetric credentials this is EPK-OCE.
+ +Get retries remaining for Management key
+Get the public key for an asymmetric credential.
+This will return the long-term public key “PK-OCE” for an +asymmetric credential.
+label (str
) – The label of the credential.
Import an asymmetric YubiHSM Auth credential.
+management_key (bytes
) – The management key.
label (str
) – The label of the credential.
private_key (EllipticCurvePrivateKey
) – Private key corresponding to the public
+authentication key object on the YubiHSM.
credential_password (Union
[bytes
, str
]) – The password used to protect
+access to the credential.
touch_required (bool
) – The touch requirement policy.
Import a symmetric YubiHSM Auth credential derived from password.
+management_key (bytes
) – The management key.
label (str
) – The label of the credential.
derivation_password (str
) – The password used to derive the keys from.
credential_password (Union
[bytes
, str
]) – The password used to protect
+access to the credential.
touch_required (bool
) – The touch requirement policy.
Import a symmetric YubiHSM Auth credential.
+management_key (bytes
) – The management key.
label (str
) – The label of the credential.
key_enc (bytes
) – The static K-ENC.
key_mac (bytes
) – The static K-MAC.
credential_password (Union
[bytes
, str
]) – The password used to protect
+access to the credential.
touch_required (bool
) – The touch requirement policy.
Change YubiHSM Auth management key
+ +Bases: IntFlag
YubiKey Application identifiers.
+Bases: IntFlag
Configuration flags.
+Bases: object
Management settings for YubiKey which can be configured by the user.
+ + + + +Optional
[DEVICE_FLAG
]Mapping
[TRANSPORT
, CAPABILITY
]Bases: object
Information about a YubiKey readable using the ManagementSession.
+DeviceConfig
FORM_FACTOR
Mapping
[TRANSPORT
, CAPABILITY
]Bases: IntEnum
YubiKey device form factors.
+Bases: object
Write connection modes (USB interfaces) for YubiKey.
+ +Bases: object
An OATH code object.
+ + + + + + +Bases: object
An OATH credential object.
+ + + + + + + + + + + + + + +Bases: object
An object holding OATH credential data.
+ + + + + + +HASH_ALGORITHM
Bases: IntEnum
An enumeration.
+Bases: IntEnum
An enumeration.
+Bases: object
A session with the OATH application.
+ + +Calculate codes for all OATH credentials on the YubiKey.
+ +Calculate code for an OATH credential.
+credential (Credential
) – The credential object.
Add a OATH credential.
+credential_data (CredentialData
) – The credential data.
touch_required (bool
) – The touch policy.
Remove access code.
+WARNING: This removes authentication.
+Bases: ABC
OpenPGP key algorithm attributes.
+ + + + +Bases: object
OpenPGP related data.
+OpenPgpAid
DiscretionaryDataObjects
Optional
[ExtendedLengthInfo
]Optional
[GENERAL_FEATURE_MANAGEMENT
]Control Reference Template values.
+Bases: object
Bases: IntEnum
An enumeration.
+Bases: object
Optional
[AlgorithmAttributes
]AlgorithmAttributes
AlgorithmAttributes
AlgorithmAttributes
ExtendedCapabilities
Mapping
[KEY_REF
, KEY_STATUS
]Bases: IntEnum
An enumeration.
+Bases: IntFlag
An enumeration.
+Bases: AlgorithmAttributes
EC_IMPORT_FORMAT
Bases: PrivateKeyTemplate
Bases: object
EXTENDED_CAPABILITY_FLAGS
Bases: object
Bases: IntFlag
An enumeration.
+Bases: IntEnum
An enumeration.
+Bases: IntEnum
An enumeration.
+Bases: IntEnum
An enumeration.
+Bases: IntEnum
An enumeration.
+Bases: Kdf
HASH_ALGORITHM
Bases: bytes
OpenPGP Application Identifier (AID)
+The OpenPGP AID is a string of bytes identifying the OpenPGP application. +It also embeds some values which are accessible though properties.
+16-bit integer value identifying the manufacturer of the device.
+This should be 6 for Yubico devices.
+Bases: object
A session with the OpenPGP application.
+Get the AID used to select the applet.
+Create an attestation certificate for a key.
+The certificte is written to the certificate slot for the key, and its +content is returned.
+Requires User PIN verification.
+key_ref (KEY_REF
) – The key slot.
Authenticate a message using the AUT key.
+Requires User PIN verification.
+message (bytes
) – The message to authenticate.
hash_algorithm (HashAlgorithm
) – The pre-authentication hash algorithm.
Decrypt a value using the DEC key.
+For RSA the value should be an encrypted block. +For ECDH the value should be a peer public-key to perform the key exchange +with, and the result will be the derived shared secret.
+Requires (extended) User PIN verification.
+value (Union
[bytes
, EllipticCurvePublicKey
, Ed25519PublicKey
, X25519PublicKey
]) – The value to decrypt.
Delete a certificate in a slot.
+Requires Admin PIN verification.
+ +Delete the contents of a key slot.
+Requires Admin PIN verification.
+ +Get the Extended Capabilities from the YubiKey.
+Generate an EC key in the given slot.
+Requires Admin PIN verification.
+Union
[EllipticCurvePublicKey
, Ed25519PublicKey
, X25519PublicKey
]
Generate an RSA key in the given slot.
+Requires Admin PIN verification.
+Get the algorithm attributes for one of the key slots.
+key_ref (KEY_REF
) – The key slot.
Get the list of supported algorithm attributes for each key.
+The return value is a mapping of KEY_REF to a list of supported algorithm +attributes, which can be set using set_algorithm_attributes.
+Read the Application Related Data.
+Get a certificate from a slot.
+key_ref (KEY_REF
) – The slot.
Get the public key from a slot.
+key_ref (KEY_REF
) – The key slot.
Union
[EllipticCurvePublicKey
, Ed25519PublicKey
, X25519PublicKey
, RSAPublicKey
]
Get the number of times the signature key has been used.
+Import a certificate into a slot.
+Requires Admin PIN verification.
+key_ref (KEY_REF
) – The slot.
certificate (Certificate
) – The X.509 certificate to import.
Write a Data Object to the YubiKey.
+do (DO
) – The Data Object to write to.
data (Union
[bytes
, SupportsBytes
]) – The data to write.
Import a private key into the given slot.
+Requires Admin PIN verification.
+key_ref (KEY_REF
) – The key slot.
private_key (Union
[RSAPrivateKey
, EllipticCurvePrivateKey
, Ed25519PrivateKey
, X25519PrivateKey
]) – The private key to import.
Perform a factory reset on the OpenPGP application.
+WARNING: This will delete all stored keys, certificates and other data.
+Reset the User PIN to a new value.
+This command requires Admin PIN verification, or the Reset Code.
+ +Set the algorithm attributes for a key slot.
+WARNING: This will delete any key already stored in the slot if the attributes +are changed!
+This command requires Admin PIN verification.
+key_ref (KEY_REF
) – The key slot.
attributes (AlgorithmAttributes
) – The algorithm attributes to set.
Set the fingerprint for a key.
+Requires Admin PIN verification.
+ +Set the generation timestamp for a key.
+Requires Admin PIN verification.
+ +Set up a PIN Key Derivation Function.
+This enables (or disables) the use of a KDF for PIN verification, as well +as resetting the User and Admin PINs to their default (initial) values.
+If a Reset Code is present, it will be invalidated.
+This command requires Admin PIN verification.
+ +Set the number of PIN attempts to allow before blocking.
+WARNING: On YubiKey NEO this will reset the PINs to their default values.
+Requires Admin PIN verification.
+ +Set the Reset Code for User PIN.
+The Reset Code can be used to set a new User PIN if it is lost or becomes +blocked, using the reset_pin method.
+This command requires Admin PIN verification.
+ +Set signature PIN policy.
+Requires Admin PIN verification.
+pin_policy (PIN_POLICY
) – The PIN policy.
Set the User Interaction Flag (touch requirement) for a key.
+Requires Admin PIN verification.
+ +Sign a message using the SIG key.
+Requires User PIN verification.
+message (bytes
) – The message to sign.
hash_algorithm (HashAlgorithm
) – The pre-signature hash algorithm.
Verify the Admin PIN.
+This will unlock functionality that requires Admin PIN verification.
+admin_pin – The Admin PIN.
+Verify the User PIN.
+This will unlock functionality that requires User PIN verification. +Note that with extended=False (default) only sign operations are allowed. +Inversely, with extended=True sign operations are NOT allowed.
+pin – The User PIN.
extended (bool
) – If False only sign operations are allowed,
+otherwise sign operations are NOT allowed.
Bases: IntEnum
An enumeration.
+Bases: IntEnum
An enumeration.
+Bases: object
PIN_POLICY
Bases: IntEnum
An enumeration.
+Bases: IntEnum
An enumeration.
+Bases: AlgorithmAttributes
RSA_IMPORT_FORMAT
Bases: RsaKeyTemplate
Bases: PrivateKeyTemplate
An enumeration.
+Bases: IntEnum
An enumeration.
+Bases: IntEnum
An enumeration.
+Bases: object
MANAGEMENT_KEY_TYPE
TOUCH_POLICY
Bases: IntEnum
An enumeration.
+Bases: IntEnum
An enumeration.
+Bases: object
Bases: object
A session with the PIV application.
+Attest key in slot.
+slot (SLOT
) – The slot where the key has been generated.
A X.509 certificate.
+Authenticate to PIV with management key.
+key_type (MANAGEMENT_KEY_TYPE
) – The management key type.
management_key (bytes
) – The management key in raw bytes.
Calculate shared secret using ECDH.
+Requires PIN verification.
+slot (SLOT
) – The slot.
peer_public_key (EllipticCurvePublicKey
) – The peer’s public key.
Decrypt cipher text.
+Requires PIN verification.
+ +Delete certificate.
+Requires authentication with management key.
+ +Generate private key in slot.
+Requires authentication with management key.
+slot (SLOT
) – The slot to generate the private key in.
key_type (KEY_TYPE
) – The key type.
pin_policy (PIN_POLICY
) – The PIN policy.
touch_policy (TOUCH_POLICY
) – The touch policy.
Get certificate from slot.
+slot (SLOT
) – The slot to get the certificate from.
Get slot metadata.
+slot (SLOT
) – The slot to get metadata from.
Import certificate to slot.
+Requires authentication with management key.
+slot (SLOT
) – The slot to import the certificate to.
certificate (Certificate
) – The certificate to import.
compress (bool
) – If the certificate should be compressed or not.
Import a private key to slot.
+Requires authentication with management key.
+slot (SLOT
) – The slot to import the key to.
private_key (Union
[RSAPrivateKey
, EllipticCurvePrivateKey
]) – The private key to import.
pin_policy (PIN_POLICY
) – The PIN policy.
touch_policy (TOUCH_POLICY
) – The touch policy.
Write data to PIV object.
+Requires authentication with management key.
+ +Set a new management key.
+key_type (MANAGEMENT_KEY_TYPE
) – The management key type.
management_key (bytes
) – The management key in raw bytes.
require_touch (bool
) – The touch policy.
Set PIN retries for PIN and PUK.
+Both PIN and PUK will be reset to default values when this is executed.
+Requires authentication with management key and PIN verification.
+ +Sign message with key.
+Requires PIN verification.
+slot (SLOT
) – The slot of the key to use.
key_type (KEY_TYPE
) – The type of the key to sign with.
message (bytes
) – The message to sign.
hash_algorithm (HashAlgorithm
) – The pre-signature hash algorithm to use.
padding (Optional
[AsymmetricPadding
]) – The pre-signature padding.
Bases: IntEnum
An enumeration.
+Bases: object
PIN_POLICY
TOUCH_POLICY
Bases: IntEnum
An enumeration.
+Check if a key type is supported by a specific YubiKey firmware version.
+This method will return None if the key (with PIN and touch policies) is supported, +or it will raise a NotSupportedError if it is not.
+Determine the product name of a YubiKey
+info (DeviceInfo
) – The device info.
key_type (Optional
[YUBIKEY
]) – The YubiKey hardware platform.
Reads out DeviceInfo from a YubiKey, or attempts to synthesize the data.
+Reading DeviceInfo from a ManagementSession is only supported for newer YubiKeys. +This function attempts to read that information, but will fall back to gathering the +data using other mechanisms if needed. It will also make adjustments to the data if +required, for example to “fix” known bad values.
+The pid parameter must be provided whenever the YubiKey is connected via USB.
+conn (Connection
) – A connection to a YubiKey.
Bases: IntFlag
An enumeration.
+Bases: IntFlag
An enumeration.
+Bases: IntEnum
An enumeration.
+Bases: object
The configuration state of the YubiOTP application.
+ + + + + + +Bases: IntFlag
An enumeration.
+Bases: SlotConfiguration
Bases: KeyboardSlotConfiguration
Bases: SlotConfiguration
Bases: IntEnum
An enumeration.
+Bases: IntEnum
An enumeration.
+Bases: object
Bases: KeyboardSlotConfiguration
Bases: KeyboardSlotConfiguration
Bases: IntFlag
An enumeration.
+Bases: KeyboardSlotConfiguration
Bases: object
A session with the YubiOTP application.
+Perform a challenge-response operation using HMAC-SHA1.
+ +Get configuration state of the YubiOTP application.
+Write configuration to slot.
+ +Configure a slot to be used over NDEF (NFC).
+ +Update scan-codes on YubiKey.
+This updates the scan-codes (or keyboard presses) that the YubiKey +will use when typing out OTPs.
+Contains the modules corresponding to the different applications supported +by a YubiKey.
+