././@PaxHeader0000000000000000000000000000003200000000000010210 xustar0026 mtime=1679758451.87876 yubikey_manager-5.2.1/COPYING0000644000000000000000000000250414407612164012661 0ustar00Copyright (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. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1696967732.7442932 yubikey_manager-5.2.1/man/ykman.10000644000000000000000000000302414511326065013576 0ustar00.TH YKMAN "1" "October 2023" "ykman 5.2.1" "User Commands" .SH NAME ykman \- YubiKey Manager (ykman) .SH SYNOPSIS .B ykman [\fI\,OPTIONS\/\fR] \fI\,COMMAND \/\fR[\fI\,ARGS\/\fR]... .SH DESCRIPTION .PP Configure your YubiKey via the command line. .SH OPTIONS .TP \fB\-d\fR, \fB\-\-device\fR SERIAL specify which YubiKey to interact with by serial number .TP \fB\-r\fR, \fB\-\-reader\fR NAME specify a YubiKey by smart card reader name (can't be used with \-\-device or list) .TP \fB\-l\fR, \fB\-\-log\-level\fR [ERROR|WARNING|INFO|DEBUG|TRAFFIC] enable logging at given verbosity level .TP \fB\-\-log\-file\fR FILE write log to FILE instead of printing to stderr (requires \-\-log\-level) .TP \fB\-\-diagnose\fR show diagnostics information useful for troubleshooting .TP \fB\-v\fR, \fB\-\-version\fR show version information about the app .TP \fB\-\-full\-help\fR show \-\-help output, including hidden commands .TP \fB\-h\fR, \fB\-\-help\fR show this message and exit .SS "Commands:" .TP info show general information .TP list list connected YubiKeys .TP script run a python script .TP config enable or disable applications .TP fido manage the FIDO applications .TP hsmauth manage the YubiHSM Auth application .TP oath manage the OATH application .TP openpgp manage the OpenPGP application .TP otp manage the YubiOTP application .TP piv manage the PIV application .SH EXAMPLES .PP List connected YubiKeys, only output serial number: .PP $ ykman list --serials .PP Show information about YubiKey with serial number 123456: .PP $ ykman --device 123456 info ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1696967732.7442932 yubikey_manager-5.2.1/NEWS0000644000000000000000000003773014511326065012334 0ustar00* Version 5.2.1 (released 2023-10-10) ** Add support for Python 3.12. ** OATH: detect and remove corrupted credentials. ** Bugfix: HSMAUTH: Fix order of CLI arguments. * Version 5.2.0 (released 2023-08-21) ** PIV: Support for compressed certificates. ** OpenPGP: Use InvalidPinError for wrong PIN. ** Add YubiHSM Auth application support. ** Improved API documentation. ** Scripting: Add name attribute to device. ** Bugfix: PIV: don't throw InvalidPasswordError on malformed PEM private key. * Version 5.1.1 (released 2023-04-27) ** Bugfix: PIV: string representation of SLOT caused infinite loop on Python <3.11. ** Bugfix: Fix errors in 'ykman config nfc' on YubiKeys without NFC capability. ** Bugfix: Fix error message shown when invalid modhex input length given for YubiOTP. * Version 5.1.0 (released 2023-04-17) ** Add OpenPGP functionality to supported API. ** Add PIV key info command to CLI. ** PIV: Support signing prehashed data via API. ** Bugfix: Fix signing PIV certificates/CSRs with key that always requires PIN. ** Bugfix: Fix incorrect display name detection for certain keys over NFC. * Version 5.0.1 (released 2023-01-17) ** Bugfix: Fix the interactive confirmation prompt for some CLI commands. ** Bugfix: OpenPGP Signature PIN policy values were swapped. ** Bugfix: FIDO: Handle discoverable credentials that are missing name or displayName. ** Add support for Python 3.11. ** Remove extra whitespace characters from CLI into command output. * Version 5.0.0 (released 2022-10-19) ** Various cleanups and improvements to the API. ** Improvements to the handling of YubiKeys and connections. ** Command aliases for ykman 3.x (introduced in ykman 4.0) have now been dropped. ** Installers for ykman are now provided for Windows (amd64) and MacOS (universal2). ** Logging has been improved, and a new TRAFFIC level has been introduced. ** The codebase has been improved for scripting usage, either directly as a Python module, or via the new "ykman script" command. See doc/Scripting.adoc, doc/Library_Usage.adoc, and examples/ for more details. ** PIV: Add support for dotted-string OIDs when parsing RFC4514 strings. ** PIV: Drop support for signing certificates and CSRs with SHA-1. ** FIDO: Credential management commands have been improved to deal with ambiguity in certain cases. ** OATH: Access Keys ("remembered" passwords) are now stored in the system keyring. ** OpenPGP: Commands have been added to manage PINs. * Version 4.0.9 (released 2022-06-17) ** Dependency: Add support for python-fido2 1.x ** Fix: Drop stated support for Click 6 as features from 7 are being used. * Version 4.0.8 (released 2022-01-31) ** Bugfix: Fix error message for invalid modhex when programing a YubiOTP credential. ** Bugfix: Fix issue with displaying a Steam credential when it is the only account. ** Bugfix: Prevent installation of files in site-packages root. ** Bugfix: Fix cleanup logic in PIV for protected management key. ** Add support for token identifier when programming slot-based HOTP. ** Add support for programming NDEF in text mode. ** Dependency: Add support for Cryptography <= 38. * Version 4.0.7 (released 2021-09-08) ** Bugfix release: Fix broken naming for "YubiKey 4", and a small OATH issue with touch Steam credentials. * Version 4.0.6 (released 2021-09-08) ** Improve handling of YubiKey device reboots. ** More consistently mask PIN/password input in prompts. ** Support switching mode over CCID for YubiKey Edge. ** Run pkill from PATH instead of fixed location. * Version 4.0.5 (released 2021-07-16) ** Bugfix: Fix PIV feature detection for some YubiKey NEO versions. ** Bugfix: Fix argument short form for --period when adding TOTP credentials. ** Bugfix: More strict validation for some arguments, resulting in better error messages. ** Bugfix: Correctly handle TOTP credentials using period != 30 AND touch_required. ** Bugfix: Fix prompting for access code in the otp settings command (now uses "-A -"). * Version 4.0.3 (released 2021-05-17) ** Add support for fido reset over NFC. ** Bugfix: The --touch argument to piv change-management-key was ignored. ** Bugfix: Don't prompt for password when importing PIV key/cert if file is invalid. ** Bugfix: Fix setting touch-eject/auto-eject for YubiKey 4 and NEO. ** Bugfix: Detect PKCS#12 format when outer sequence uses indefinite length. ** Dependency: Add support for Click 8. * Version 4.0.2 (released 2021-04-12) ** Update device names. ** Add read_info output to the --diagnose command, and show exception types. ** Bugfix: Fix read_info for YubiKey Plus. * Version 4.0.1 (released 2021-03-29) ** Add support for YK5-based FIPS YubiKeys. ** Bugfix: Fix OTP device enumeration on Win32. * Version 4.0.0 (released 2021-03-02) ** Drop support for Python < 3.6. ** Drop reliance on libusb and libykpersonalize. ** Support the "fido" and "otp" subcommands over NFC (using the --reader flag) ** New "ykman --diagnose" command to aid in troubleshooting. ** New "ykman apdu" command for sending raw APDUs over the smart card interface. ** Restructuring of subcommands, with aliases for old versions (to be removed in a future release). ** Major changes to the underlying "library" code: *** New "yubikit" package added for custom development and advanced scripting. *** Type hints added for a large part of the "public" API. ** OpenPGP: Add support for KDF enabled YubiKeys. ** Static password: Add support for FR, IT, UK and BEPO keyboard layouts. * Version 3.1.2 (released 2021-01-21) ** Bugfix release: Fix dependency on python-fido2 version. * Version 3.1.1 (released 2020-01-29) ** Add support for YubiKey 5C NFC ** OpenPGP: set-touch now performs compatibility checks before prompting for PIN ** OpenPGP: Improve error messages and documentation for set-touch ** PIV: read-object command no longer adds a trailing newline ** CLI: Hint at missing permissions when opening a device fails ** Linux: Improve error handling when pcscd is not running ** Windows: Improve how .DLL files are loaded, thanks to Marius Gabriel Mihai for reporting this! ** Bugfix: set-touch now accepts the cached-fixed option ** Bugfix: Fix crash in OtpController.prepare_upload_key() error parsing ** Bugfix: Fix crash in piv info command when a certificate slot contains an invalid certificate ** Library: PivController.read_certificate(slot) now wraps certificate parsing exceptions in new exception type `InvalidCertificate` ** Library: PivController.list_certificates() now returns `None` for slots containing invalid certificate, instead of raising an exception * Version 3.1.0 (released 2019-08-20) ** Add support for YubiKey 5Ci ** OpenPGP: the info command now prints OpenPGP specification version as well ** OpenPGP: Update support for attestation to match OpenPGP v3.4 ** PIV: Use UTC time for self-signed certificates ** OTP: Static password now supports the Norman keyboard layout * Version 3.0.0 (released 2019-06-24) ** Add support for new YubiKey Preview and lightning form factor ** FIDO: Support for credential management ** OpenPGP: Support for OpenPGP attestation, cardholder certificates and cached touch policies ** OTP: Add flag for using numeric keypad when sending digits * Version 2.1.1 (released 2019-05-28) ** OTP: Add initial support for uploading Yubico OTP credentials to YubiCloud ** Don't automatically select the U2F applet on YubiKey NEO, it might be blocked by the OS ** ChalResp: Always pad challenge correctly ** Bugfix: Don't crash with older versions of cryptography ** Bugfix: Password was always prompted in OATH command, even if sent as argument * Version 2.1.0 (released 2019-03-11) ** Add --reader flag to ykman list, to list available smart card readers ** FIPS: Checking if a YubiKey FIPS is in FIPS mode is now opt-in, with the --check-fips flag ** PIV: Add commands for writing and reading arbitrary PIV objects ** PIV: Verify that the PIN must be between 6 - 8 characters long ** PIV: In import-certificate, make the verification that the certificate and private key matches opt-in, with the --verify flag ** PIV: The piv info command now shows the serial number of the certificates ** PIV: The piv info command now shows the full Distinguished Name (DN) of the certificate subject and issuer, if possible ** PIV: Malformed certificates are now handled better ** OpenPGP: The openpgp touch command now shows current touch policies ** The ykman usb/nfc config command now accepts openpgp as well as opgp as an argument ** Bugfix: Fix support for german (DE) keyboard layout for static passwords * Version 2.0.0 (released 2019-01-09) ** Add support for Security Key NFC ** Add experimental support for external smart card reader. See --reader flag ** Add a minimal manpage ** Add examples in help texts ** PIV: update CHUID when importing a certificate ** PIV: Optionally validate that private key and certificate match when importing a certificate (on by default in CLI) ** PIV: Improve support for importing certificate chains and .PEM files with comments ** Breaking API changes: *** Merge CCID status word constants into a single SW enum in ykman.driver_ccid *** Throw custom exception types instead of raw APDUErrors from many methods of PivController *** Write CLI prompts to standard error instead of standard output *** Replace function `ykman.util.parse_certificate` with `parse_certificates` which returns a list * Version 1.0.1 (released 2018-10-10) ** Support for YubiKey 5A ** OATH: Ignore extra parameters in URI parsing ** Bugfix: Never say that NFC is supported for YubiKeys without NFC * Version 1.0.0 (released 2018-09-24) ** Add support for YubiKey 5 Series ** Config: Add flag to generate a random configuration lock ** OATH: Give a proper error message when a touch credential times out ** NDEF: Allow setting the NDEF prefix from the CLI ** FIDO: Block reset when multiple YubiKeys are connected * Version 0.7.1 (released 2018-07-09) ** Support for YubiKey FIPS. ** OTP: Allow setting and removing access codes on the slots. ** Interfaces: set-lock-code now only accepts hexadecimal inputs. ** Bugfix: Don't fail to open the YubiKey when the serial is not visible. * Version 0.7.0 (released 2018-05-07) ** Support for YubiKey Preview. ** Add command to configure enabled applications over USB and NFC. See ykman config -h. ** Add command for selecting which slot to use for NDEF. See ykman otp ndef -h. * Version 0.6.1 (released 2018-04-16) ** Support for YubiKeys with FIDO2. See ykman fido -h ** Report the form factor for YubiKeys that support it. ** OTP: slot command is now called otp. See ykman otp -h for all changes. ** Static password: Add support for different keyboard layouts. See ykman otp static -h ** PIV: Signatures for CSRs are now correct. ** PIV: Commands on slots with PIN policy ALWAYS no longer fail if the YubiKey has a management key protected by PIN. ** Mode: The U2F mode is now called FIDO. ** Dependencies: libu2f-host is no longer used for FIDO communication over USB, instead the python library fido2 is used. * Version 0.6.0 (released 2018-02-09) ** OpenPGP: Expose remaining PIN retries in info command and API. ** CCID: Only try YubiKey smart card readers by default. ** Handle NEO issues with challenge-response credentials better. ** Improve logging. ** Improve error handling when opening device over OTP. ** Bugfix: Fix adding OTP data through the interactive prompt. * Version 0.5.0 (released 2017-12-15) ** API breaking changes: *** OATH: New API more similar to yubioath-android ** CLI breaking changes: *** OATH: Touch prompt now written to stderr instead of stdout *** OATH: `-a|--algorithm` option to `list` command removed *** OATH: Columns in `code` command are now dynamically spaced depending on contents *** OATH: `delete` command now requires confirmation or `-f|--force` argument *** OATH: IDs printed by `list` command now include TOTP period if not 30 *** Changed outputs: **** INFO: "Device name" output changed to "Device type" **** PIV: "Management key is stored on device" output changed to "Management key is stored on the YubiKey" **** PIV: "All PIV data have been cleared from the device" output changed to "All PIV data have been cleared from your YubiKey" **** PIV: "The current management key is stored on the device" prompt changed to "The current management key is stored on the YubiKey" **** SLOT: "blank to use device serial" prompt changed to "blank to use YubiKey serial number" **** SLOT: "Using device serial" output changed to "Using YubiKey device serial" **** Lots of failure case outputs changed ** New features: *** Support for multiple devices via new top-level option `-d|--device` *** New top-level option `-l|--log-level` to enable logging *** OATH: Support for remembering passwords locally. *** OATH: New option `-s|--single` for `code` command *** PIV: `set-pin-retries` command now warns that PIN and PUK will be reset to factory defaults, and prints those defaults after resetting ** API bug fixes: *** OATH: `valid_from` and `valid_to` for `Code` are now absolute instead of relative to the credential period *** OATH: `period` for non-TOTP `Code` is now `None` * Version 0.4.6 (released 2017-10-17) ** Will now attempt to open device 3 times before failing ** OpenPGP: Don't say data is removed when not ** OpenPGP: Don't swallow APDU errors ** PIV: Block on-chip RSA key generation for firmware versions 4.2.0 to 4.3.4 (inclusive) since these chips are vulnerable to http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2017-15361[CVE-2017-15631]. * Version 0.4.5 (released 2017-09-14) ** OATH: Don't print issuer if there is no issuer. * Version 0.4.4 (released 2017-09-06) ** OATH: Fix yet another issue with backwards compatibility, for adding new credentials. * Version 0.4.3 (released 2017-09-06) ** OATH: Fix issue with backwards compatibility, when used as a library. * Version 0.4.2 (released 2017-09-05) ** OATH: Support 7 digit credentials. ** OATH: Support credentials with a period other than 30 seconds. ** OATH: The remove command is now called delete. * Version 0.4.1 (released 2017-08-10) ** PIV: Dropped support for deriving a management key from PIN. ** PIV: Added support for generating a random management key and storing it on the device protected by the PIN. ** OpenPGP: The reset command now handles a device in terminated state. ** OATH: Credential filtering is now working properly on Python 2. * Version 0.4.0 (released 2017-06-19) ** Added PIV support. The tool and library now supports most of the PIV functionality found on the YubiKey 4 and NEO. To list the available commands, run ykman piv -h. ** Mode command now supports adding and removing modes incrementally. * Version 0.3.3 (released 2017-05-08) ** Bugfix: Fix issue with OATH credentials from Steam on YubiKey 4. * Version 0.3.2 (released 2017-04-24) ** Allow access code input through an interactive prompt. ** Bugfix: Some versions of YubiKey NEO occasionally failed calculating challenge-response credentials with touch. * Version 0.3.1 (released 2017-03-13) ** Allow programming of TOTP credentials in YubiKey Slots using the chalresp command. ** Add a calculate command (and library support) to perform a challenge-response operation. Can also be used to generate TOTP codes for credentials stored in a slot. ** OATH: Remove whitespace in secret keys provided by the user. ** OATH: Prompt the user to touch the YubiKey for HOTP touch credentials. ** Bugfix: The flag for showing hidden credentials was not working correctly for the oath code command. * Version 0.3.0 (released 2017-01-23) ** OATH functionality added. The tool now exposes the OATH functionality found on the YubiKey 4 and NEO. To list the available commands, run ykman oath -h. ** Added support for randomly generated static passwords. * Version 0.2.0 (released 2016-11-23) ** Removed all GUI code. This project is now only for the python library and CLI tool. The GUI will be re-released separately in a different project. ** Added command to update settings for YubiKey Slots. * Version 0.1.0 (released 2016-07-07) ** Initial release for beta testing. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1696967732.7454045 yubikey_manager-5.2.1/pyproject.toml0000644000000000000000000000246414511326065014545 0ustar00[tool.poetry] name = "yubikey-manager" version = "5.2.1" description = "Tool for managing your YubiKey configuration." authors = ["Dain Nilsson "] license = "BSD" homepage = "https://github.com/Yubico/yubikey-manager" repository = "https://github.com/Yubico/yubikey-manager" keywords = ["yubikey", "yubiotp", "piv", "fido"] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: End Users/Desktop", "Topic :: Security :: Cryptography", "Topic :: Utilities" ] include = [ { path = "COPYING", format = "sdist"}, { path = "NEWS", format = "sdist"}, { path = "README.adoc", format = "sdist"}, "man/", "tests/", ] packages = [ { include = "yubikit" }, { include = "ykman" }, ] [tool.poetry.dependencies] python = "^3.8" cryptography = ">=3.0, <44" pyscard = "^2.0" fido2 = "^1.0" click = "^8.0" keyring = ">=23.4, <25" pywin32 = {version = ">=223", platform = "win32"} [tool.poetry.dev-dependencies] pytest = "^7.2" makefun = "^1.9.5" pyinstaller = {version = "^6.0", python = "<3.13"} Sphinx = "^7.1" sphinx-rtd-theme = "^1.2.2" sphinx-autodoc-typehints = "^1.2.4" [tool.poetry.scripts] ykman = "ykman._cli.__main__:main" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" [tool.pytest.ini_options] testpaths = ["tests"] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1692961154.6802716 yubikey_manager-5.2.1/README.adoc0000644000000000000000000001757014472104603013420 0ustar00== YubiKey Manager CLI image:https://github.com/Yubico/yubikey-manager/actions/workflows/source-package.yml/badge.svg["Source package build", link="https://github.com/Yubico/yubikey-manager/actions/workflows/source-package.yml"] image:https://github.com/Yubico/yubikey-manager/actions/workflows/windows.yml/badge.svg["Windows build", link="https://github.com/Yubico/yubikey-manager/actions/workflows/windows.yml"] image:https://github.com/Yubico/yubikey-manager/actions/workflows/macOS.yml/badge.svg["MacOS build", link="https://github.com/Yubico/yubikey-manager/actions/workflows/macOS.yml"] image:https://github.com/Yubico/yubikey-manager/actions/workflows/ubuntu.yml/badge.svg["Ubuntu build", link="https://github.com/Yubico/yubikey-manager/actions/workflows/ubuntu.yml"] Python 3.7 (or later) library and command line tool for configuring a YubiKey. If you're looking for the graphical application, it's https://developers.yubico.com/yubikey-manager-qt/[here]. === Usage For more usage information and examples, see the https://docs.yubico.com/software/yubikey/tools/ykman/Using_the_ykman_CLI.html[YubiKey Manager CLI User Manual]. .... Usage: ykman [OPTIONS] COMMAND [ARGS]... Configure your YubiKey via the command line. Examples: List connected YubiKeys, only output serial number: $ ykman list --serials Show information about YubiKey with serial number 0123456: $ ykman --device 0123456 info Options: -d, --device SERIAL specify which YubiKey to interact with by serial number -r, --reader NAME specify a YubiKey by smart card reader name (can't be used with --device or list) -l, --log-level [ERROR|WARNING|INFO|DEBUG|TRAFFIC] enable logging at given verbosity level --log-file FILE write log to FILE instead of printing to stderr (requires --log-level) --diagnose show diagnostics information useful for troubleshooting -v, --version show version information about the app --full-help show --help output, including hidden commands -h, --help show this message and exit Commands: info show general information list list connected YubiKeys config enable or disable applications fido manage the FIDO applications oath manage the OATH application openpgp manage the OpenPGP application otp manage the YubiOTP application piv manage the PIV application .... The `--help` argument can also be used to get detailed information about specific subcommands: ykman oath --help === Versioning/Compatibility This project follows https://semver.org/[Semantic Versioning]. Any project depending on yubikey-manager should take care when specifying version ranges to not include any untested major version, as it is likely to have backwards incompatible changes. For example, you should NOT depend on ">=5", as it has no upper bound. Instead, depend on ">=5, <6", as any release before 6 will be compatible. Note that any private variables (names starting with '_') are not part of the public API, and may be changed between versions at any time. === Installation YubiKey Manager can be installed independently of platform by using pip (or equivalent): pip install --user yubikey-manager On Linux platforms you will need `pcscd` installed and running to be able to communicate with a YubiKey over the SmartCard interface. Additionally, you may need to set permissions for your user to access YubiKeys via the HID interfaces. More information available link:doc/Device_Permissions.adoc[here]. Some of the libraries used by yubikey-manager have C-extensions, and may require additional dependencies to build, such as http://www.swig.org/[swig] and potentially https://pcsclite.apdu.fr/[PCSC lite]. === Pre-build packages Pre-built packages specific to your platform may be available from Yubico or third parties. Please refer to your platforms native package manager for detailed instructions on how to install, if available. ==== Windows A Windows installer is available to download from the https://github.com/Yubico/yubikey-manager/releases/latest[Releases page]. ==== MacOS A MacOS installer is available to download from the https://github.com/Yubico/yubikey-manager/releases/latest[Releases page]. Additionally, packages are available from Homebrew and MacPorts. ===== Input Monitoring access on MacOS When running one of the `ykman otp` commands you may run into an error such as: `Failed to open device for communication: -536870174`. This indicates a problem with the permission to access the OTP (keyboard) USB interface. To access a YubiKey over this interface the application needs the `Input Monitoring` permission. If you are not automatically prompted to grant this permission, you may have to do so manually. Note that it is the _terminal_ you are using that needs the permission, not the ykman executable. To add your terminal application to the `Input Monitoring` permission list, go to `System Preferences -> Security & Privacy -> Privacy -> Input Monitoring` to resolve this. ==== Linux Packages are available for several Linux distributions by third party package maintainers. Yubico also provides packages for Ubuntu in the yubico/stable PPA: $ sudo apt-add-repository ppa:yubico/stable $ sudo apt update $ sudo apt install yubikey-manager ==== FreeBSD Althought not being officially supported on this platform, YubiKey Manager can be installed on FreeBSD. It's available via its ports tree or as pre-built package. Should you opt to install and use YubiKey Manager on this platform, please be aware that it's **NOT** maintained by Yubico. To install the binary package, use `pkg install pyXY-yubikey-manager`, with `pyXY` specifying the version of Python the package was built for, so in order to install YubiKey Manager for Python 3.8, use: # pkg install py38-yubikey-manager For more information about how to install packages or ports on FreeBSD, please refer to its official documentation: https://docs.freebsd.org/en/books/handbook/ports[FreeBSD Handbook]. In order to use `ykman otp` commands, you need to make sure the _uhid(4)_ driver attaches to the USB device: # usbconfig ugenX.Y add_quirk UQ_KBD_IGNORE # usbconfig ugenX.Y reset The correct device to operate on _(ugenX.Y)_ can be determined using `usbconfig list`. When using FreeBSD 13 or higher, you can switch to the more modern _hidraw(4)_ driver. This allows YubiKey Manager to access OTP HID in a non-exclusive way, so that the key will still function as a USB keyboard: # sysrc kld_list+="hidraw hkbd" # cat >>/boot/loader.conf<= vers, f"Version < {vers}") def max_version(major, minor=0, micro=0): if isinstance(major, tuple): vers = major else: vers = (major, minor, micro) return check(lambda version: version <= vers, f"Version > {vers}") def yk4_fips(status=True): return check( lambda info: status == (info.is_fips and info.version[0] == 4), f"Requires YK4 FIPS = {status}", ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.8900158 yubikey_manager-5.2.1/tests/device/conftest.py0000644000000000000000000000536714407612164016440 0ustar00from ykman.device import list_all_devices, read_info from ykman.pcsc import list_devices from yubikit.core import TRANSPORT from yubikit.core.otp import OtpConnection from yubikit.core.fido import FidoConnection from yubikit.core.smartcard import SmartCardConnection from functools import partial from . import condition import pytest import time import os @pytest.fixture(scope="session") def _device(pytestconfig): serial = pytestconfig.getoption("device") no_serial = pytestconfig.getoption("no_serial") if not serial: if no_serial: serial = None else: pytest.skip("No serial specified for device tests") reader = pytestconfig.getoption("reader") if reader: readers = list_devices(reader) if len(readers) != 1: pytest.exit("No/Multiple readers matched") dev = readers[0] with dev.open_connection(SmartCardConnection) as conn: info = read_info(conn) else: devices = list_all_devices() if len(devices) != 1: pytest.exit("Device tests require a single YubiKey") dev, info = devices[0] if info.serial != serial: pytest.exit("Device serial does not match: %d != %r" % (serial, info.serial)) return dev, info @pytest.fixture(scope="session") def device(_device): return _device[0] @pytest.fixture(scope="session") def info(_device): return _device[1] @pytest.fixture(scope="session") def version(info): return info.version @pytest.fixture(scope="session") def transport(device): return device.transport @pytest.fixture(scope="session") def pid(device): return device.pid @pytest.fixture(scope="session") def await_reboot(transport): delay = float(os.environ.get("REBOOT_TIME", "2.0")) return partial(time.sleep, delay) if transport == TRANSPORT.USB else lambda: None connection_scope = os.environ.get("CONNECTION_SCOPE", "function") @pytest.fixture(scope=connection_scope) @condition.transport(TRANSPORT.USB) def otp_connection(device, info): if device.supports_connection(OtpConnection): with device.open_connection(OtpConnection) as c: yield c @pytest.fixture(scope=connection_scope) @condition.transport(TRANSPORT.USB) def fido_connection(device, info): if device.supports_connection(FidoConnection): with device.open_connection(FidoConnection) as c: yield c @pytest.fixture(scope=connection_scope) def ccid_connection(device, info): if device.supports_connection(SmartCardConnection): with device.open_connection(SmartCardConnection) as c: yield c else: pytest.skip("CCID connection not available") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.8900158 yubikey_manager-5.2.1/tests/device/test_ccid.py0000644000000000000000000000043414407612164016542 0ustar00from yubikit.core.smartcard import SmartCardProtocol, ApplicationNotAvailableError import pytest def test_select_wrong_app(ccid_connection): p = SmartCardProtocol(ccid_connection) with pytest.raises(ApplicationNotAvailableError): p.select(b"not_a_real_aid") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.8900158 yubikey_manager-5.2.1/tests/device/test_fips_u2f_commands.py0000644000000000000000000000404014407612164021233 0ustar00from fido2.ctap1 import ApduError from yubikit.core import TRANSPORT from yubikit.management import CAPABILITY from yubikit.core.smartcard import SW from ykman.fido import fips_change_pin, fips_verify_pin, fips_reset, is_in_fips_mode from . import condition import pytest @pytest.fixture(autouse=True) @condition.yk4_fips(True) @condition.capability(CAPABILITY.U2F) @condition.transport(TRANSPORT.USB) def preconditions(): pass class TestFipsU2fCommands: def test_pin_commands(self, fido_connection): # Assumes PIN is 012345 or not set at beginning of test # Make sure PIN is 012345 try: fips_verify_pin(fido_connection, "012345") fips_change_pin(fido_connection, "012345", "012345") except ApduError as e: if e.code == SW.VERIFY_FAIL_NO_RETRY: pytest.skip("PIN set to something other than 012345") elif e.code == SW.AUTH_METHOD_BLOCKED: pytest.skip("PIN blocked") elif e.code == SW.COMMAND_NOT_ALLOWED: fips_change_pin(fido_connection, None, "012345") # Verify with correct PIN fips_verify_pin(fido_connection, "012345") # Change the PIN, verify, then change back fips_change_pin(fido_connection, "012345", "012012") fips_verify_pin(fido_connection, "012012") fips_change_pin(fido_connection, "012012", "012345") # Verify with incorrect PIN with pytest.raises(ApduError) as ctx: fips_verify_pin(fido_connection, "543210") assert SW.VERIFY_FAIL_NO_RETRY == ctx.value.code # Verify with correct PIN fips_verify_pin(fido_connection, "012345") def test_reset_command(self, fido_connection): try: fips_reset(fido_connection) except ApduError as e: assert e.code in [SW.COMMAND_NOT_ALLOWED, SW.CONDITIONS_NOT_SATISFIED] def test_verify_fips_mode_command(self, fido_connection): is_in_fips_mode(fido_connection) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1696438353.084531 yubikey_manager-5.2.1/tests/device/test_hsmauth.py0000644000000000000000000002264214507314121017307 0ustar00import pytest from yubikit.core.smartcard import ApduError from yubikit.management import CAPABILITY from yubikit.hsmauth import ( HsmAuthSession, Credential, INITIAL_RETRY_COUNTER, InvalidPinError, ) from . import condition from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat import os DEFAULT_MANAGEMENT_KEY = bytes.fromhex("00000000000000000000000000000000") NON_DEFAULT_MANAGEMENT_KEY = bytes.fromhex("11111111111111111111111111111111") @pytest.fixture @condition.capability(CAPABILITY.HSMAUTH) @condition.min_version(5, 4, 3) def session(ccid_connection): hsmauth = HsmAuthSession(ccid_connection) hsmauth.reset() yield hsmauth def import_key_derived( session, management_key, credential_password="123456", derivation_password="password", ) -> Credential: credential = session.put_credential_derived( management_key, "Test PUT credential symmetric (derived)", derivation_password, credential_password, ) return credential def import_key_symmetric( session, management_key, key_enc, key_mac, credential_password="123456" ) -> Credential: credential = session.put_credential_symmetric( management_key, "Test PUT credential symmetric", key_enc, key_mac, credential_password, ) return credential def import_key_asymmetric( session, management_key, private_key, credential_password="123456" ) -> Credential: credential = session.put_credential_asymmetric( management_key, "Test PUT credential asymmetric", private_key, credential_password, ) return credential def generate_key_asymmetric( session, management_key, credential_password="123456" ) -> Credential: credential = session.generate_credential_asymmetric( management_key, "Test GENERATE credential asymmetric", credential_password, ) return credential class TestCredentialManagement: def check_credential_in_list(self, session, credential: Credential): credentials = session.list_credentials() assert credential in credentials credential_retrieved = next(cred for cred in credentials if cred == credential) assert credential_retrieved.label == credential.label assert credential_retrieved.touch_required == credential.touch_required assert credential_retrieved.algorithm == credential.algorithm assert credential_retrieved.counter == INITIAL_RETRY_COUNTER def verify_credential_password( self, session, credential_password: str, credential: Credential ): context = b"g\xfc\xf1\xfe\xb5\xf1\xd8\x83\xedv=\xbfI0\x90\xbb" # Try to calculate session keys using credential password session.calculate_session_keys_symmetric( label=credential.label, context=context, credential_password=credential_password, ) def test_import_credential_symmetric_wrong_management_key(self, session): with pytest.raises(InvalidPinError): import_key_derived(session, NON_DEFAULT_MANAGEMENT_KEY) def test_import_credential_symmetric_wrong_key_length(self, session): with pytest.raises(ValueError): import_key_symmetric( session, DEFAULT_MANAGEMENT_KEY, os.urandom(24), os.urandom(24) ) def test_import_credential_symmetric_exists(self, session): import_key_derived(session, DEFAULT_MANAGEMENT_KEY) with pytest.raises(ApduError): import_key_derived(session, DEFAULT_MANAGEMENT_KEY) def test_import_credential_symmetric_works(self, session): credential = import_key_derived(session, DEFAULT_MANAGEMENT_KEY, "1234") self.verify_credential_password(session, "1234", credential) self.check_credential_in_list(session, credential) session.delete_credential(DEFAULT_MANAGEMENT_KEY, credential.label) @condition.min_version(5, 6) def test_import_credential_asymmetric_unsupported_key(self, session): private_key = ec.generate_private_key( ec.SECP224R1, backend=default_backend() ) # curve secp224r1 is not supported with pytest.raises(ValueError): import_key_asymmetric(session, DEFAULT_MANAGEMENT_KEY, private_key) @condition.min_version(5, 6) def test_import_credential_asymmetric_works(self, session): private_key = ec.generate_private_key(ec.SECP256R1, backend=default_backend()) credential = import_key_asymmetric(session, DEFAULT_MANAGEMENT_KEY, private_key) public_key = private_key.public_key() assert public_key.public_bytes( encoding=Encoding.PEM, format=PublicFormat.SubjectPublicKeyInfo ) == session.get_public_key(credential.label).public_bytes( encoding=Encoding.PEM, format=PublicFormat.SubjectPublicKeyInfo ) self.check_credential_in_list(session, credential) session.delete_credential(DEFAULT_MANAGEMENT_KEY, credential.label) @condition.min_version(5, 6) def test_generate_credential_asymmetric_works(self, session): credential = generate_key_asymmetric(session, DEFAULT_MANAGEMENT_KEY) self.check_credential_in_list(session, credential) public_key = session.get_public_key(credential.label) assert isinstance(public_key, ec.EllipticCurvePublicKey) assert isinstance(public_key.curve, ec.SECP256R1) session.delete_credential(DEFAULT_MANAGEMENT_KEY, credential.label) @condition.min_version(5, 6) def test_export_public_key_symmetric_credential(self, session): credential = import_key_derived(session, DEFAULT_MANAGEMENT_KEY) with pytest.raises(ApduError): session.get_public_key(credential.label) session.delete_credential(DEFAULT_MANAGEMENT_KEY, credential.label) def test_delete_credential_wrong_management_key(self, session): credential = import_key_derived(session, DEFAULT_MANAGEMENT_KEY) with pytest.raises(InvalidPinError): session.delete_credential(NON_DEFAULT_MANAGEMENT_KEY, credential.label) def test_delete_credential_non_existing(self, session): with pytest.raises(ApduError): session.delete_credential(DEFAULT_MANAGEMENT_KEY, "Default key") def test_delete_credential_works(self, session): credential = import_key_derived(session, DEFAULT_MANAGEMENT_KEY) session.delete_credential(DEFAULT_MANAGEMENT_KEY, credential.label) credentials = session.list_credentials() assert len(credentials) == 0 class TestAccess: def test_change_management_key(self, session): session.put_management_key(DEFAULT_MANAGEMENT_KEY, NON_DEFAULT_MANAGEMENT_KEY) # Can't import key with old management key with pytest.raises(InvalidPinError): import_key_derived(session, DEFAULT_MANAGEMENT_KEY) session.put_management_key(NON_DEFAULT_MANAGEMENT_KEY, DEFAULT_MANAGEMENT_KEY) def test_management_key_retries(self, session): session.put_management_key(DEFAULT_MANAGEMENT_KEY, DEFAULT_MANAGEMENT_KEY) initial_retries = session.get_management_key_retries() assert initial_retries == 8 with pytest.raises(InvalidPinError): import_key_derived(session, NON_DEFAULT_MANAGEMENT_KEY) post_retries = session.get_management_key_retries() assert post_retries == 7 class TestSessionKeys: def test_calculate_session_keys_symmetric(self, session): credential_password = "1234" credential = import_key_derived( session, DEFAULT_MANAGEMENT_KEY, credential_password=credential_password, derivation_password="pwd", ) # Example context and session keys context = b"g\xfc\xf1\xfe\xb5\xf1\xd8\x83\xedv=\xbfI0\x90\xbb" key_senc = b"\xb0o\x1a\xc9\x87\x91.\xbe\xdc\x1b\xf0\xe0*k]\x85" key_smac = b"\xea\xd6\xc3\xa5\x96\xea\x86u\xbf1\xd3I\xab\xb5,t" key_srmac = b"\xc2\xc6\x1e\x96\xab,X\xe9\x83z\xd0\xe7\xd0n\xe9\x0c" session_keys = session.calculate_session_keys_symmetric( label=credential.label, context=context, credential_password=credential_password, ) assert key_senc == session_keys.key_senc assert key_smac == session_keys.key_smac assert key_srmac == session_keys.key_srmac class TestHostChallenge: @condition.min_version(5, 6) def test_get_challenge_symmetric(self, session): credential = import_key_derived(session, DEFAULT_MANAGEMENT_KEY) challenge1 = session.get_challenge(credential.label) challenge2 = session.get_challenge(credential.label) assert len(challenge1) == 8 assert len(challenge2) == 8 assert challenge1 != challenge2 session.delete_credential(DEFAULT_MANAGEMENT_KEY, credential.label) @condition.min_version(5, 6) def test_get_challenge_asymmetric(self, session): credential = generate_key_asymmetric(session, DEFAULT_MANAGEMENT_KEY) challenge1 = session.get_challenge(credential.label) challenge2 = session.get_challenge(credential.label) assert len(challenge1) == 65 assert len(challenge2) == 65 assert challenge1 != challenge2 session.delete_credential(DEFAULT_MANAGEMENT_KEY, credential.label) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1679758451.891014 yubikey_manager-5.2.1/tests/device/test_interfaces.py0000644000000000000000000000131714407612164017764 0ustar00from yubikit.core import TRANSPORT from yubikit.core.otp import OtpConnection from yubikit.core.fido import FidoConnection from yubikit.core.smartcard import SmartCardConnection from . import condition def try_connection(device, conn_type): with device.open_connection(conn_type): return True @condition.transport(TRANSPORT.USB) def test_switch_interfaces(device): for conn_type in ( FidoConnection, OtpConnection, FidoConnection, SmartCardConnection, OtpConnection, SmartCardConnection, FidoConnection, ): if device.pid.supports_connection(conn_type): assert try_connection(device, conn_type) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1679758451.891014 yubikey_manager-5.2.1/tests/device/test_oath.py0000644000000000000000000002010614407612164016571 0ustar00import pytest from yubikit.core.smartcard import ApduError, AID, SW from yubikit.management import CAPABILITY from yubikit.oath import ( OathSession, CredentialData, HASH_ALGORITHM, OATH_TYPE, ) from . import condition KEY = bytes.fromhex("01020304050607080102030405060708") @pytest.fixture @condition.capability(CAPABILITY.OATH) def session(ccid_connection): oath = OathSession(ccid_connection) oath.reset() yield oath CRED_DATA = CredentialData("name", OATH_TYPE.TOTP, HASH_ALGORITHM.SHA1, b"secret") class TestFunctions: @condition.min_version(5, 3) def test_rename(self, session): cred = session.put_credential(CRED_DATA) new_id = session.rename_credential(cred.id, "newname", "newissuer") with pytest.raises(ApduError): session.calculate(cred.id, b"challenge") session.calculate(new_id, b"challenge") @condition.min_version(5, 3) def test_rename_to_existing(self, session): cred = session.put_credential(CRED_DATA) new_id = session.rename_credential(cred.id, "newname", "newissuer") with pytest.raises(ApduError): session.rename_credential(new_id, "newname", "newissuer") class TestLockPreventsAccess: @pytest.fixture(autouse=True) def set_lock(self, session): assert not session.locked session.put_credential(CRED_DATA) session.set_key(KEY) # Force re-select to lock session.protocol.connection.connection.disconnect() session.protocol.connection.connection.connect() session.protocol.select(AID.OATH) def test_list(self, session): with pytest.raises(ApduError) as ctx: session.list_credentials() assert ctx.value.sw == SW.SECURITY_CONDITION_NOT_SATISFIED def test_calculate(self, session): with pytest.raises(ApduError) as ctx: session.calculate(CRED_DATA.get_id(), b"challenge") assert ctx.value.sw == SW.SECURITY_CONDITION_NOT_SATISFIED def test_calculate_all(self, session): with pytest.raises(ApduError) as ctx: session.calculate_all() assert ctx.value.sw == SW.SECURITY_CONDITION_NOT_SATISFIED def test_delete(self, session): with pytest.raises(ApduError) as ctx: session.delete_credential(CRED_DATA.get_id()) assert ctx.value.sw == SW.SECURITY_CONDITION_NOT_SATISFIED @condition.min_version(5, 3) def test_rename(self, session): with pytest.raises(ApduError) as ctx: session.rename_credential(CRED_DATA.get_id(), "renamed") assert ctx.value.sw == SW.SECURITY_CONDITION_NOT_SATISFIED HMAC_VECTORS = { b"\x0B" * 20: { b"Hi There": { HASH_ALGORITHM.SHA256: bytes.fromhex( "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7" ), HASH_ALGORITHM.SHA512: bytes.fromhex( "87aa7cdea5ef619d4ff0b4241a1d6cb02379f4e2ce4ec2787ad0b30545e17cde" "daa833b7d6b8a702038b274eaea3f4e4be9d914eeb61f1702e696c203a126854" ), } }, b"Jefe": { b"what do ya want for nothing?": { HASH_ALGORITHM.SHA256: bytes.fromhex( "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843" ), HASH_ALGORITHM.SHA512: bytes.fromhex( "164b7a7bfcf819e2e395fbe73b56e0a387bd64222e831fd610270cd7ea250554" "9758bf75c05a994a6d034f65f8f0e6fdcaeab1a34d4a6b4b636e070a38bce737" ), } }, b"\xAA" * 20: { b"\xDD" * 50: { HASH_ALGORITHM.SHA256: bytes.fromhex( "773ea91e36800e46854db8ebd09181a72959098b3ef8c122d9635514ced565fe" ), HASH_ALGORITHM.SHA512: bytes.fromhex( "fa73b0089d56a284efb0f0756c890be9b1b5dbdd8ee81a3655f83e33b2279d39" "bf3e848279a722c806b485a47e67c807b946a337bee8942674278859e13292fb" ), } }, bytes.fromhex("0102030405060708090a0b0c0d0e0f10111213141516171819"): { b"\xCD" * 50: { HASH_ALGORITHM.SHA256: bytes.fromhex( "82558a389a443c0ea4cc819899f2083a85f0faa3e578f8077a2e3ff46729665b" ), HASH_ALGORITHM.SHA512: bytes.fromhex( "b0ba465637458c6990e5a8c5f61d4af7e576d97ff94b872de76f8050361ee3db" "a91ca5c11aa25eb4d679275cc5788063a5f19741120c4f2de2adebeb10a298dd" ), } }, } HMAC_PARAMS = [ (key, timestamp, algo, HMAC_VECTORS[key][timestamp][algo]) for key in HMAC_VECTORS for timestamp in HMAC_VECTORS[key] for algo in HMAC_VECTORS[key][timestamp] ] def _ids_hmac(params): key, challenge, hash_algorithm, expected = params key_s = key.hex() if len(key) < 6 else key[:6].hex() + "..." challenge_s = challenge.hex() if len(challenge) < 6 else challenge[:6].hex() + "..." return f"{hash_algorithm.name}-{key_s}-{challenge_s}" class TestHmacVectors: @pytest.mark.parametrize("params", HMAC_PARAMS, ids=_ids_hmac) def test_vector(self, info, session, params): key, challenge, hash_algorithm, expected = params if hash_algorithm == HASH_ALGORITHM.SHA512: if info.version[0] <= 4: if info.is_fips or info.version < (4, 3, 1): pytest.skip("SHA512 requires (non-FIPS) YubiKey 4.3.1 or later") cred = session.put_credential( CredentialData("test", OATH_TYPE.TOTP, hash_algorithm, key) ) value = session.calculate(cred.id, challenge) assert value == expected TOTP_VECTOR_KEYS = { HASH_ALGORITHM.SHA1: b"12345678901234567890", HASH_ALGORITHM.SHA256: b"12345678901234567890123456789012", HASH_ALGORITHM.SHA512: b"12345678901234567890123456789012" b"34567890123456789012345678901234", } TOTP_VECTORS = { 59: { HASH_ALGORITHM.SHA1: "94287082", HASH_ALGORITHM.SHA256: "46119246", HASH_ALGORITHM.SHA512: "90693936", }, 1111111109: { HASH_ALGORITHM.SHA1: "07081804", HASH_ALGORITHM.SHA256: "68084774", HASH_ALGORITHM.SHA512: "25091201", }, } TOTP_PARAMS = [ (timestamp, algo, TOTP_VECTORS[timestamp][algo], TOTP_VECTOR_KEYS[algo]) for timestamp in TOTP_VECTORS for algo in TOTP_VECTORS[timestamp] ] class TestTotpVectors: @pytest.mark.parametrize("digits", [6, 8]) @pytest.mark.parametrize( "params", TOTP_PARAMS, ids=lambda x: "{1.name}-{0}".format(*x) ) def test_vector(self, info, session, params, digits): timestamp, hash_algorithm, value, key = params if hash_algorithm == HASH_ALGORITHM.SHA512: if info.version[0] <= 4: if info.is_fips or info.version < (4, 3, 1): pytest.skip("SHA512 requires (non-FIPS) YubiKey 4.3.1 or later") cred = session.put_credential( CredentialData("test", OATH_TYPE.TOTP, hash_algorithm, key, digits) ) code = session.calculate_code(cred, timestamp) assert len(code.value) == digits assert value.endswith(code.value) HOTP_VECTORS = { b"12345678901234567890": [ "84755224", "94287082", "37359152", "26969429", "40338314", "68254676", "18287922", "82162583", "73399871", "45520489", ] } class TestHotpVectors: @pytest.mark.parametrize("digits", [6, 8]) @pytest.mark.parametrize( "params", HOTP_VECTORS.items(), ids=lambda x: "{0}".format(*x) ) def test_vector(self, session, params, digits): key, values = params cred = session.put_credential( CredentialData("test", OATH_TYPE.HOTP, HASH_ALGORITHM.SHA1, key, digits) ) for expected in values: code = session.calculate_code(cred) assert len(code.value) == digits assert expected.endswith(code.value) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1691654743.720124 yubikey_manager-5.2.1/tests/device/test_openpgp.py0000644000000000000000000001705014465115130017305 0ustar00from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric import ec, rsa, ed25519, x25519, padding from cryptography.hazmat.primitives import hashes from yubikit.openpgp import ( OpenPgpSession, KEY_REF, RSA_SIZE, OID, KdfIterSaltedS2k, KdfNone, ) from yubikit.management import CAPABILITY from yubikit.core.smartcard import ApduError from . import condition import pytest import time E = 65537 DEFAULT_PIN = "123456" NON_DEFAULT_PIN = "654321" DEFAULT_ADMIN_PIN = "12345678" NON_DEFAULT_ADMIN_PIN = "87654321" @pytest.fixture @condition.capability(CAPABILITY.OPENPGP) def session(ccid_connection): pgp = OpenPgpSession(ccid_connection) pgp.reset() return pgp def not_roca(version): """ROCA affected""" return not ((4, 2, 0) <= version < (4, 3, 5)) def test_import_requires_admin(session): priv = rsa.generate_private_key(E, RSA_SIZE.RSA2048, default_backend()) with pytest.raises(ApduError): session.put_key(KEY_REF.SIG, priv) @condition.check(not_roca) def test_generate_requires_admin(session): with pytest.raises(ApduError): session.generate_rsa_key(KEY_REF.SIG, RSA_SIZE.RSA2048) @condition.min_version(5, 2) @pytest.mark.parametrize("oid", [x for x in OID if "25519" not in x.name]) def test_import_sign_ecdsa(session, oid): priv = ec.generate_private_key(getattr(ec, oid.name)()) session.verify_admin(DEFAULT_ADMIN_PIN) session.put_key(KEY_REF.SIG, priv) message = b"Hello world" session.verify_pin(DEFAULT_PIN) sig = session.sign(message, hashes.SHA256()) priv.public_key().verify(sig, message, ec.ECDSA(hashes.SHA256())) @condition.min_version(5, 2) def test_import_sign_eddsa(session): priv = ed25519.Ed25519PrivateKey.generate() session.verify_admin(DEFAULT_ADMIN_PIN) session.put_key(KEY_REF.SIG, priv) message = b"Hello world" session.verify_pin(DEFAULT_PIN) sig = session.sign(message, hashes.SHA256()) priv.public_key().verify(sig, message) @condition.min_version(5, 2) @pytest.mark.parametrize("oid", [x for x in OID if "25519" not in x.name]) def test_import_ecdh(session, oid): priv = ec.generate_private_key(getattr(ec, oid.name)()) session.verify_admin(DEFAULT_ADMIN_PIN) session.put_key(KEY_REF.DEC, priv) e_priv = ec.generate_private_key(getattr(ec, oid.name)()) shared1 = e_priv.exchange(ec.ECDH(), priv.public_key()) session.verify_pin(DEFAULT_PIN, extended=True) shared2 = session.decrypt(e_priv.public_key()) assert shared1 == shared2 @condition.min_version(5, 2) def test_import_ecdh_x25519(session): priv = x25519.X25519PrivateKey.generate() session.verify_admin(DEFAULT_ADMIN_PIN) session.put_key(KEY_REF.DEC, priv) e_priv = x25519.X25519PrivateKey.generate() shared1 = e_priv.exchange(priv.public_key()) session.verify_pin(DEFAULT_PIN, extended=True) shared2 = session.decrypt(e_priv.public_key()) assert shared1 == shared2 @pytest.mark.parametrize("key_size", [2048, 3072, 4096]) def test_import_sign_rsa(session, key_size, info): if key_size != 2048: if session.version[0] < 4: pytest.skip(f"RSA {key_size} requires YuibKey 4 or later") elif session.version[0] == 4 and info.is_fips: pytest.skip(f"RSA {key_size} not supported on YubiKey 4 FIPS") priv = rsa.generate_private_key(E, key_size, default_backend()) session.verify_admin(DEFAULT_ADMIN_PIN) session.put_key(KEY_REF.SIG, priv) if session.version[0] < 5: # Keys don't work without a generation time (or fingerprint) session.set_generation_time(KEY_REF.SIG, int(time.time())) message = b"Hello world" session.verify_pin(DEFAULT_PIN) sig = session.sign(message, hashes.SHA256()) priv.public_key().verify(sig, message, padding.PKCS1v15(), hashes.SHA256()) @pytest.mark.parametrize("key_size", [2048, 3072, 4096]) def test_import_decrypt_rsa(session, key_size, info): if key_size != 2048: if session.version[0] < 4: pytest.skip(f"RSA {key_size} requires YuibKey 4 or later") elif session.version[0] == 4 and info.is_fips: pytest.skip(f"RSA {key_size} not supported on YubiKey 4 FIPS") priv = rsa.generate_private_key(E, key_size, default_backend()) session.verify_admin(DEFAULT_ADMIN_PIN) session.put_key(KEY_REF.DEC, priv) if session.version[0] < 5: # Keys don't work without a generation time (or fingerprint) session.set_generation_time(KEY_REF.DEC, int(time.time())) message = b"Hello world" cipher = priv.public_key().encrypt(message, padding.PKCS1v15()) session.verify_pin(DEFAULT_PIN, extended=True) plain = session.decrypt(cipher) assert message == plain @pytest.mark.parametrize("key_size", [2048, 3072, 4096]) def test_generate_rsa(session, key_size, info): if key_size != 2048: if session.version[0] < 4: pytest.skip(f"RSA {key_size} requires YuibKey 4 or later") elif session.version[0] == 4 and info.is_fips: pytest.skip(f"RSA {key_size} not supported on YubiKey 4 FIPS") session.verify_admin(DEFAULT_ADMIN_PIN) pub = session.generate_rsa_key(KEY_REF.SIG, RSA_SIZE(key_size)) if session.version[0] < 5: # Keys don't work without a generation time (or fingerprint) session.set_generation_time(KEY_REF.SIG, int(time.time())) assert pub.key_size == key_size message = b"Hello world" session.verify_pin(DEFAULT_PIN) sig = session.sign(message, hashes.SHA256()) pub.verify(sig, message, padding.PKCS1v15(), hashes.SHA256()) @condition.min_version(5, 2) @pytest.mark.parametrize("oid", [x for x in OID if "25519" not in x.name]) def test_generate_ecdsa(session, oid): session.verify_admin(DEFAULT_ADMIN_PIN) pub = session.generate_ec_key(KEY_REF.SIG, oid) message = b"Hello world" session.verify_pin(DEFAULT_PIN) sig = session.sign(message, hashes.SHA256()) pub.verify(sig, message, ec.ECDSA(hashes.SHA256())) @condition.min_version(5, 2) def test_generate_ed25519(session): session.verify_admin(DEFAULT_ADMIN_PIN) pub = session.generate_ec_key(KEY_REF.SIG, OID.Ed25519) message = b"Hello world" session.verify_pin(DEFAULT_PIN) sig = session.sign(message, hashes.SHA256()) pub.verify(sig, message) @condition.min_version(5, 2) def test_generate_x25519(session): session.verify_admin(DEFAULT_ADMIN_PIN) pub = session.generate_ec_key(KEY_REF.DEC, OID.X25519) e_priv = x25519.X25519PrivateKey.generate() shared1 = e_priv.exchange(pub) session.verify_pin(DEFAULT_PIN, extended=True) shared2 = session.decrypt(e_priv.public_key()) assert shared1 == shared2 @condition.min_version(5, 2) def test_kdf(session): with pytest.raises(ApduError): session.set_kdf(KdfIterSaltedS2k.create()) session.change_admin(DEFAULT_ADMIN_PIN, NON_DEFAULT_ADMIN_PIN) session.verify_admin(NON_DEFAULT_ADMIN_PIN) session.set_kdf(KdfIterSaltedS2k.create()) session.verify_admin(DEFAULT_ADMIN_PIN) session.verify_pin(DEFAULT_PIN) session.change_admin(DEFAULT_ADMIN_PIN, NON_DEFAULT_ADMIN_PIN) session.change_pin(DEFAULT_PIN, NON_DEFAULT_PIN) session.verify_pin(NON_DEFAULT_PIN) session.set_kdf(KdfNone()) session.verify_admin(DEFAULT_ADMIN_PIN) session.verify_pin(DEFAULT_PIN) @condition.min_version(5, 2) def test_attestation(session): session.verify_admin(DEFAULT_ADMIN_PIN) pub = session.generate_ec_key(KEY_REF.SIG, OID.SECP256R1) session.verify_pin(DEFAULT_PIN) cert = session.attest_key(KEY_REF.SIG) assert cert.public_key() == pub ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1679758451.891014 yubikey_manager-5.2.1/tests/device/test_otp.py0000644000000000000000000001230014407612164016435 0ustar00from yubikit.core import TRANSPORT from yubikit.core.otp import OtpConnection from yubikit.core.smartcard import SmartCardConnection from yubikit.yubiotp import ( YubiOtpSession, SLOT, HmacSha1SlotConfiguration, StaticPasswordSlotConfiguration, ) from yubikit.management import CAPABILITY, ManagementSession from ykman.device import list_all_devices from . import condition import pytest @pytest.fixture(params=[OtpConnection, SmartCardConnection]) def conn_type(request, version, transport): conn_type = request.param if transport == TRANSPORT.NFC: if conn_type != SmartCardConnection: pytest.skip("Using NFC") else: if conn_type == SmartCardConnection and (4, 0) <= version < (5, 3): pytest.skip("3.x/5.3+ only") return conn_type @pytest.fixture() @condition.capability(CAPABILITY.OTP) def session(conn_type, info, device): with device.open_connection(conn_type) as c: yield YubiOtpSession(c) def test_status(info, session): assert session.get_serial() == info.serial def not_usb_ccid(conn_type, transport): return transport != TRANSPORT.USB or conn_type != SmartCardConnection @pytest.fixture() def read_config(session, conn_type, info, transport, await_reboot): need_reboot = conn_type == SmartCardConnection and (4, 0) <= info.version < (5, 5) if need_reboot and info.version[0] == 4: pytest.skip("Can't read config") def call(): otp = session if need_reboot: protocol = session.backend.protocol if transport == TRANSPORT.NFC: protocol.connection.connection.disconnect() conn = protocol.connection conn.connection.connect() else: ManagementSession(protocol.connection).write_device_config(reboot=True) await_reboot() devs = list_all_devices([SmartCardConnection]) if len(devs) != 1: raise Exception("More than one YubiKey connected") dev, info2 = devs[0] if info.serial != info2.serial: raise Exception("Connected YubiKey has wrong serial") conn = dev.open_connection(SmartCardConnection) otp = YubiOtpSession(conn) session.backend = otp.backend return otp.get_config_state() return call class TestProgrammingState: @pytest.fixture(autouse=True) @condition.min_version(2, 1) def clear_slots(self, session, read_config): state = read_config() for slot in (SLOT.ONE, SLOT.TWO): if state.is_configured(slot): session.delete_slot(slot) def test_slot_configured(self, session, read_config): state = read_config() assert not state.is_configured(SLOT.ONE) assert not state.is_configured(SLOT.TWO) session.put_configuration(SLOT.ONE, HmacSha1SlotConfiguration(b"a" * 16)) state = read_config() assert state.is_configured(SLOT.ONE) assert not state.is_configured(SLOT.TWO) session.put_configuration(SLOT.TWO, HmacSha1SlotConfiguration(b"a" * 16)) state = read_config() assert state.is_configured(SLOT.ONE) assert state.is_configured(SLOT.TWO) session.delete_slot(SLOT.ONE) state = read_config() assert not state.is_configured(SLOT.ONE) assert state.is_configured(SLOT.TWO) session.swap_slots() state = read_config() assert state.is_configured(SLOT.ONE) assert not state.is_configured(SLOT.TWO) session.delete_slot(SLOT.ONE) state = read_config() assert not state.is_configured(SLOT.ONE) assert not state.is_configured(SLOT.TWO) @condition.min_version(3) @pytest.mark.parametrize("slot", [SLOT.ONE, SLOT.TWO]) def test_slot_touch_triggered(self, session, read_config, slot): session.put_configuration(slot, HmacSha1SlotConfiguration(b"a" * 16)) state = read_config() assert state.is_configured(slot) assert not state.is_touch_triggered(slot) session.put_configuration(slot, StaticPasswordSlotConfiguration(b"a")) state = read_config() assert state.is_configured(slot) assert state.is_touch_triggered(slot) session.delete_slot(slot) state = read_config() assert not state.is_configured(slot) assert not state.is_touch_triggered(slot) class TestChallengeResponse: @pytest.fixture(autouse=True) @condition.check(not_usb_ccid) def clear_slot2(self, session, read_config): state = read_config() if state.is_configured(SLOT.TWO): session.delete_slot(SLOT.TWO) def test_calculate_hmac_sha1(self, session): session.put_configuration( SLOT.TWO, HmacSha1SlotConfiguration( bytes.fromhex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b") ), ) output = session.calculate_hmac_sha1(SLOT.TWO, b"Hi There") assert output == bytes.fromhex("b617318655057264e28bc0b6fb378c8ef146be00") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1690184528.1561828 yubikey_manager-5.2.1/tests/device/test_piv.py0000644000000000000000000005417314457425520016452 0ustar00import datetime import random import pytest from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric import rsa, ec, padding from yubikit.core import NotSupportedError from yubikit.core.smartcard import AID, ApduError from yubikit.management import CAPABILITY from yubikit.piv import ( PivSession, ALGORITHM, KEY_TYPE, PIN_POLICY, TOUCH_POLICY, SLOT, OBJECT_ID, MANAGEMENT_KEY_TYPE, InvalidPinError, ) from ykman.piv import ( check_key, get_pivman_data, get_pivman_protected_data, generate_self_signed_certificate, generate_csr, pivman_set_mgm_key, ) from ykman.util import parse_certificates, parse_private_key from ..util import open_file from . import condition DEFAULT_PIN = "123456" NON_DEFAULT_PIN = "654321" DEFAULT_PUK = "12345678" NON_DEFAULT_PUK = "87654321" DEFAULT_MANAGEMENT_KEY = bytes.fromhex( "010203040506070801020304050607080102030405060708" ) NON_DEFAULT_MANAGEMENT_KEY = bytes.fromhex( "010103040506070801020304050607080102030405060708" ) NOW = datetime.datetime.now() def get_test_cert(): with open_file("rsa_2048_cert.pem") as f: return parse_certificates(f.read(), None)[0] def get_test_key(): with open_file("rsa_2048_key.pem") as f: return parse_private_key(f.read(), None) @pytest.fixture @condition.capability(CAPABILITY.PIV) def session(ccid_connection): piv = PivSession(ccid_connection) piv.reset() yield piv reset_state(piv) def not_roca(version): return not ((4, 2, 0) <= version < (4, 3, 5)) def reset_state(session): session.protocol.connection.connection.disconnect() session.protocol.connection.connection.connect() session.protocol.select(AID.PIV) def assert_mgm_key_is(session, key): session.authenticate(MANAGEMENT_KEY_TYPE.TDES, key) def assert_mgm_key_is_not(session, key): with pytest.raises(ApduError): session.authenticate(MANAGEMENT_KEY_TYPE.TDES, key) def generate_key( session, slot=SLOT.AUTHENTICATION, alg=KEY_TYPE.ECCP256, pin_policy=PIN_POLICY.DEFAULT, ): session.authenticate(MANAGEMENT_KEY_TYPE.TDES, DEFAULT_MANAGEMENT_KEY) key = session.generate_key(slot, alg, pin_policy=pin_policy) reset_state(session) return key def import_key( session, slot=SLOT.AUTHENTICATION, key_type=KEY_TYPE.ECCP256, pin_policy=PIN_POLICY.DEFAULT, ): if key_type.algorithm == ALGORITHM.RSA: private_key = rsa.generate_private_key( 65537, key_type.bit_len, default_backend() ) elif key_type == KEY_TYPE.ECCP256: private_key = ec.generate_private_key(ec.SECP256R1(), default_backend()) elif key_type == KEY_TYPE.ECCP384: private_key = ec.generate_private_key(ec.SECP384R1(), default_backend()) session.authenticate(MANAGEMENT_KEY_TYPE.TDES, DEFAULT_MANAGEMENT_KEY) session.put_key(slot, private_key, pin_policy) reset_state(session) return private_key.public_key() def verify_cert_signature(cert, public_key=None): if not public_key: public_key = cert.public_key args = [cert.signature, cert.tbs_certificate_bytes, cert.signature_hash_algorithm] if KEY_TYPE.from_public_key(public_key).algorithm == ALGORITHM.RSA: args.insert(2, padding.PKCS1v15()) else: args[2] = ec.ECDSA(args[2]) public_key.verify(*args) class TestCertificateSignatures: @pytest.mark.parametrize("key_type", list(KEY_TYPE)) @pytest.mark.parametrize( "hash_algorithm", (hashes.SHA256, hashes.SHA384, hashes.SHA512) ) def test_generate_self_signed_certificate( self, info, session, key_type, hash_algorithm ): if key_type == KEY_TYPE.ECCP384 and session.version < (4, 0, 0): pytest.skip("ECCP384 requires YubiKey 4 or later") if key_type == KEY_TYPE.RSA1024 and info.is_fips and info.version[0] == 4: pytest.skip("RSA1024 not available on YubiKey FIPS") slot = SLOT.SIGNATURE public_key = import_key(session, slot, key_type) session.authenticate(MANAGEMENT_KEY_TYPE.TDES, DEFAULT_MANAGEMENT_KEY) session.verify_pin(DEFAULT_PIN) cert = generate_self_signed_certificate( session, slot, public_key, "CN=alice", NOW, NOW, hash_algorithm ) assert cert.public_key().public_numbers() == public_key.public_numbers() verify_cert_signature(cert, public_key) class TestKeyManagement: def test_delete_certificate_requires_authentication(self, session): generate_key(session, SLOT.AUTHENTICATION) with pytest.raises(ApduError): session.delete_certificate(SLOT.AUTHENTICATION) session.authenticate(MANAGEMENT_KEY_TYPE.TDES, DEFAULT_MANAGEMENT_KEY) session.delete_certificate(SLOT.AUTHENTICATION) def test_generate_csr_works(self, session): public_key = generate_key(session, SLOT.AUTHENTICATION) session.verify_pin(DEFAULT_PIN) csr = generate_csr(session, SLOT.AUTHENTICATION, public_key, "CN=alice") assert csr.public_key().public_numbers() == public_key.public_numbers() assert ( csr.subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME)[0].value == "alice" ) def test_generate_self_signed_certificate_requires_pin(self, session): session.verify_pin(DEFAULT_PIN) public_key = generate_key(session, SLOT.AUTHENTICATION) with pytest.raises(ApduError): generate_self_signed_certificate( session, SLOT.AUTHENTICATION, public_key, "CN=alice", NOW, NOW ) session.verify_pin(DEFAULT_PIN) generate_self_signed_certificate( session, SLOT.AUTHENTICATION, public_key, "CN=alice", NOW, NOW ) @pytest.mark.parametrize("slot", (SLOT.SIGNATURE, SLOT.AUTHENTICATION)) def test_generate_self_signed_certificate(self, session, slot): public_key = generate_key(session, slot) session.authenticate(MANAGEMENT_KEY_TYPE.TDES, DEFAULT_MANAGEMENT_KEY) session.verify_pin(DEFAULT_PIN) cert = generate_self_signed_certificate( session, slot, public_key, "CN=alice", NOW, NOW ) assert cert.public_key().public_numbers() == public_key.public_numbers() assert ( cert.subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME)[0].value == "alice" ) def test_generate_key_requires_authentication(self, session): with pytest.raises(ApduError): session.generate_key( SLOT.AUTHENTICATION, KEY_TYPE.ECCP256, touch_policy=TOUCH_POLICY.DEFAULT ) session.authenticate(MANAGEMENT_KEY_TYPE.TDES, DEFAULT_MANAGEMENT_KEY) session.generate_key(SLOT.AUTHENTICATION, KEY_TYPE.ECCP256) def test_put_certificate_requires_authentication(self, session): cert = get_test_cert() with pytest.raises(ApduError): session.put_certificate(SLOT.AUTHENTICATION, cert) session.authenticate(MANAGEMENT_KEY_TYPE.TDES, DEFAULT_MANAGEMENT_KEY) session.put_certificate(SLOT.AUTHENTICATION, cert) def _test_put_key_pairing(self, session, alg1, alg2): # Set up a key in the slot and create a certificate for it public_key = generate_key(session, SLOT.AUTHENTICATION, alg=alg1) session.authenticate(MANAGEMENT_KEY_TYPE.TDES, DEFAULT_MANAGEMENT_KEY) session.verify_pin(DEFAULT_PIN) cert = generate_self_signed_certificate( session, SLOT.AUTHENTICATION, public_key, "CN=test", NOW, NOW ) session.put_certificate(SLOT.AUTHENTICATION, cert) assert check_key(session, SLOT.AUTHENTICATION, cert.public_key()) cert2 = session.get_certificate(SLOT.AUTHENTICATION) assert cert == cert2 session.delete_certificate(SLOT.AUTHENTICATION) # Overwrite the key with one of the same type generate_key(session, SLOT.AUTHENTICATION, alg=alg1) session.verify_pin(DEFAULT_PIN) assert not check_key(session, SLOT.AUTHENTICATION, cert.public_key()) # Overwrite the key with one of a different type generate_key(session, SLOT.AUTHENTICATION, alg=alg2) session.verify_pin(DEFAULT_PIN) assert not check_key(session, SLOT.AUTHENTICATION, cert.public_key()) @condition.check(not_roca) @condition.yk4_fips(False) def test_put_certificate_verifies_key_pairing_rsa1024(self, session): self._test_put_key_pairing(session, KEY_TYPE.RSA1024, KEY_TYPE.ECCP256) @condition.check(not_roca) def test_put_certificate_verifies_key_pairing_rsa2048(self, session): self._test_put_key_pairing(session, KEY_TYPE.RSA2048, KEY_TYPE.ECCP256) @condition.check(not_roca) def test_put_certificate_verifies_key_pairing_eccp256_a(self, session): self._test_put_key_pairing(session, KEY_TYPE.ECCP256, KEY_TYPE.RSA2048) @condition.min_version(4) def test_put_certificate_verifies_key_pairing_eccp256_b(self, session): self._test_put_key_pairing(session, KEY_TYPE.ECCP256, KEY_TYPE.ECCP384) @condition.min_version(4) def test_put_certificate_verifies_key_pairing_eccp384(self, session): self._test_put_key_pairing(session, KEY_TYPE.ECCP384, KEY_TYPE.ECCP256) def test_put_key_requires_authentication(self, session): private_key = get_test_key() with pytest.raises(ApduError): session.put_key(SLOT.AUTHENTICATION, private_key) session.authenticate(MANAGEMENT_KEY_TYPE.TDES, DEFAULT_MANAGEMENT_KEY) session.put_key(SLOT.AUTHENTICATION, private_key) def test_get_certificate_does_not_require_authentication(self, session): cert = get_test_cert() session.authenticate(MANAGEMENT_KEY_TYPE.TDES, DEFAULT_MANAGEMENT_KEY) session.put_certificate(SLOT.AUTHENTICATION, cert) reset_state(session) assert session.get_certificate(SLOT.AUTHENTICATION) class TestCompressedCertificate: def test_put_and_read_compressed_certificate(self, session): session.authenticate(MANAGEMENT_KEY_TYPE.TDES, DEFAULT_MANAGEMENT_KEY) cert = get_test_cert() session.put_certificate(SLOT.AUTHENTICATION, cert) session.put_certificate(SLOT.SIGNATURE, cert, compress=True) assert session.get_certificate(SLOT.AUTHENTICATION) == session.get_certificate( SLOT.SIGNATURE ) obj1 = session.get_object(OBJECT_ID.from_slot(SLOT.AUTHENTICATION)) obj2 = session.get_object(OBJECT_ID.from_slot(SLOT.SIGNATURE)) assert obj1 != obj2 assert len(obj1) > len(obj2) class TestManagementKeyReadOnly: """ Tests after which the management key is always the default management key. Placing compatible tests here reduces the amount of slow reset calls needed. """ def test_authenticate_twice_does_not_throw(self, session): session.authenticate(MANAGEMENT_KEY_TYPE.TDES, DEFAULT_MANAGEMENT_KEY) session.authenticate(MANAGEMENT_KEY_TYPE.TDES, DEFAULT_MANAGEMENT_KEY) def test_reset_resets_has_stored_key_flag(self, session): pivman = get_pivman_data(session) assert not pivman.has_stored_key session.verify_pin(DEFAULT_PIN) session.authenticate(MANAGEMENT_KEY_TYPE.TDES, DEFAULT_MANAGEMENT_KEY) pivman_set_mgm_key( session, NON_DEFAULT_MANAGEMENT_KEY, MANAGEMENT_KEY_TYPE.TDES, store_on_device=True, ) pivman = get_pivman_data(session) assert pivman.has_stored_key reset_state(session) session.reset() pivman = get_pivman_data(session) assert not pivman.has_stored_key # Should this really fail? def disabled_test_reset_while_verified_throws_nice_ValueError(self, session): session.verify_pin(DEFAULT_PIN) with pytest.raises(ValueError) as cm: session.reset() assert "Cannot read remaining tries from status word: 9000" in str(cm.exception) def test_set_mgm_key_does_not_change_key_if_not_authenticated(self, session): with pytest.raises(ApduError): session.set_management_key( MANAGEMENT_KEY_TYPE.TDES, NON_DEFAULT_MANAGEMENT_KEY ) assert_mgm_key_is(session, DEFAULT_MANAGEMENT_KEY) @condition.min_version(3, 5) def test_set_stored_mgm_key_does_not_destroy_key_if_pin_not_verified(self, session): session.authenticate(MANAGEMENT_KEY_TYPE.TDES, DEFAULT_MANAGEMENT_KEY) with pytest.raises(ApduError): pivman_set_mgm_key( session, NON_DEFAULT_MANAGEMENT_KEY, MANAGEMENT_KEY_TYPE.TDES, store_on_device=True, ) assert_mgm_key_is(session, DEFAULT_MANAGEMENT_KEY) class TestManagementKeyReadWrite: """ Tests after which the management key may not be the default management key. """ def test_set_mgm_key_changes_mgm_key(self, session): session.authenticate(MANAGEMENT_KEY_TYPE.TDES, DEFAULT_MANAGEMENT_KEY) session.set_management_key(MANAGEMENT_KEY_TYPE.TDES, NON_DEFAULT_MANAGEMENT_KEY) assert_mgm_key_is_not(session, DEFAULT_MANAGEMENT_KEY) assert_mgm_key_is(session, NON_DEFAULT_MANAGEMENT_KEY) def test_set_stored_mgm_key_succeeds_if_pin_is_verified(self, session): session.verify_pin(DEFAULT_PIN) session.authenticate(MANAGEMENT_KEY_TYPE.TDES, DEFAULT_MANAGEMENT_KEY) pivman_set_mgm_key( session, NON_DEFAULT_MANAGEMENT_KEY, MANAGEMENT_KEY_TYPE.TDES, store_on_device=True, ) assert_mgm_key_is_not(session, DEFAULT_MANAGEMENT_KEY) assert_mgm_key_is(session, NON_DEFAULT_MANAGEMENT_KEY) pivman_prot = get_pivman_protected_data(session) assert pivman_prot.key == NON_DEFAULT_MANAGEMENT_KEY pivman_prot = get_pivman_protected_data(session) assert_mgm_key_is(session, pivman_prot.key) def sign(session, slot, key_type, message): return session.sign(slot, key_type, message, hashes.SHA256(), padding.PKCS1v15()) class TestOperations: @condition.min_version(4) def test_sign_with_pin_policy_always_requires_pin_every_time(self, session): generate_key(session, pin_policy=PIN_POLICY.ALWAYS) with pytest.raises(ApduError): sign(session, SLOT.AUTHENTICATION, KEY_TYPE.ECCP256, b"foo") session.verify_pin(DEFAULT_PIN) sig = sign(session, SLOT.AUTHENTICATION, KEY_TYPE.ECCP256, b"foo") assert sig with pytest.raises(ApduError): sign(session, SLOT.AUTHENTICATION, KEY_TYPE.ECCP256, b"foo") session.verify_pin(DEFAULT_PIN) sig = sign(session, SLOT.AUTHENTICATION, KEY_TYPE.ECCP256, b"foo") assert sig @condition.yk4_fips(False) @condition.min_version(4) def test_sign_with_pin_policy_never_does_not_require_pin(self, session): generate_key(session, pin_policy=PIN_POLICY.NEVER) sig = sign(session, SLOT.AUTHENTICATION, KEY_TYPE.ECCP256, b"foo") assert sig @condition.yk4_fips(True) def test_pin_policy_never_blocked_on_fips(self, session): with pytest.raises(NotSupportedError): generate_key(session, pin_policy=PIN_POLICY.NEVER) @condition.min_version(4) def test_sign_with_pin_policy_once_requires_pin_once_per_session(self, session): generate_key(session, pin_policy=PIN_POLICY.ONCE) with pytest.raises(ApduError): sign(session, SLOT.AUTHENTICATION, KEY_TYPE.ECCP256, b"foo") session.verify_pin(DEFAULT_PIN) sig = sign(session, SLOT.AUTHENTICATION, KEY_TYPE.ECCP256, b"foo") assert sig sig = sign(session, SLOT.AUTHENTICATION, KEY_TYPE.ECCP256, b"foo") assert sig reset_state(session) with pytest.raises(ApduError): sign(session, SLOT.AUTHENTICATION, KEY_TYPE.ECCP256, b"foo") session.verify_pin(DEFAULT_PIN) sig = sign(session, SLOT.AUTHENTICATION, KEY_TYPE.ECCP256, b"foo") assert sig sig = sign(session, SLOT.AUTHENTICATION, KEY_TYPE.ECCP256, b"foo") assert sig def test_signature_can_be_verified_by_public_key(self, session): public_key = generate_key(session) signed_data = bytes(random.randint(0, 255) for i in range(32)) session.verify_pin(DEFAULT_PIN) sig = sign(session, SLOT.AUTHENTICATION, KEY_TYPE.ECCP256, signed_data) assert sig public_key.verify(sig, signed_data, ec.ECDSA(hashes.SHA256())) def block_pin(session): while session.get_pin_attempts() > 0: try: session.verify_pin(NON_DEFAULT_PIN) except Exception: pass class TestUnblockPin: def test_unblock_pin_requires_no_previous_authentication(self, session): session.unblock_pin(DEFAULT_PUK, NON_DEFAULT_PIN) def test_unblock_pin_with_wrong_puk_throws_InvalidPinError(self, session): with pytest.raises(InvalidPinError): session.unblock_pin(NON_DEFAULT_PUK, NON_DEFAULT_PIN) def test_unblock_pin_resets_pin_and_retries(self, session): session.reset() reset_state(session) block_pin(session) with pytest.raises(InvalidPinError): session.verify_pin(DEFAULT_PIN) session.unblock_pin(DEFAULT_PUK, NON_DEFAULT_PIN) assert session.get_pin_attempts() == 3 session.verify_pin(NON_DEFAULT_PIN) def test_set_pin_retries_requires_pin_and_mgm_key(self, session): # Fails with no authentication with pytest.raises(ApduError): session.set_pin_attempts(4, 4) # Fails with only PIN session.verify_pin(DEFAULT_PIN) with pytest.raises(ApduError): session.set_pin_attempts(4, 4) reset_state(session) session.authenticate(MANAGEMENT_KEY_TYPE.TDES, DEFAULT_MANAGEMENT_KEY) # Fails with only management key (requirement added in 0.1.3) if session.version >= (0, 1, 3): with pytest.raises(ApduError): session.set_pin_attempts(4, 4) # Succeeds with both PIN and management key session.verify_pin(DEFAULT_PIN) session.set_pin_attempts(4, 4) def test_set_pin_retries_sets_pin_and_puk_tries(self, session): pin_tries = 9 puk_tries = 7 session.verify_pin(DEFAULT_PIN) session.authenticate(MANAGEMENT_KEY_TYPE.TDES, DEFAULT_MANAGEMENT_KEY) session.set_pin_attempts(pin_tries, puk_tries) reset_state(session) assert session.get_pin_attempts() == pin_tries with pytest.raises(InvalidPinError) as ctx: session.change_puk(NON_DEFAULT_PUK, DEFAULT_PUK) assert ctx.value.attempts_remaining == puk_tries - 1 class TestMetadata: @pytest.fixture(autouse=True) @condition.min_version(5, 3) def preconditions(self): pass def test_pin_metadata(self, session): data = session.get_pin_metadata() assert data.default_value is True assert data.total_attempts == 3 assert data.attempts_remaining == 3 def test_management_key_metadata(self, session): data = session.get_management_key_metadata() assert data.key_type == MANAGEMENT_KEY_TYPE.TDES assert data.default_value is True assert data.touch_policy is TOUCH_POLICY.NEVER session.authenticate(MANAGEMENT_KEY_TYPE.TDES, DEFAULT_MANAGEMENT_KEY) session.set_management_key( MANAGEMENT_KEY_TYPE.AES192, NON_DEFAULT_MANAGEMENT_KEY ) data = session.get_management_key_metadata() assert data.key_type == MANAGEMENT_KEY_TYPE.AES192 assert data.default_value is False assert data.touch_policy is TOUCH_POLICY.NEVER session.set_management_key(MANAGEMENT_KEY_TYPE.TDES, DEFAULT_MANAGEMENT_KEY) data = session.get_management_key_metadata() assert data.default_value is True session.set_management_key(MANAGEMENT_KEY_TYPE.AES192, DEFAULT_MANAGEMENT_KEY) data = session.get_management_key_metadata() assert data.default_value is False @pytest.mark.parametrize("key_type", list(KEY_TYPE)) def test_slot_metadata_generate(self, session, key_type): slot = SLOT.SIGNATURE key = generate_key(session, slot, key_type) data = session.get_slot_metadata(slot) assert data.key_type == key_type assert data.pin_policy == PIN_POLICY.ALWAYS assert data.touch_policy == TOUCH_POLICY.NEVER assert data.generated is True assert data.public_key.public_bytes( encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) == key.public_bytes( encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) @pytest.mark.parametrize( "key", [ rsa.generate_private_key(65537, 1024, default_backend()), rsa.generate_private_key(65537, 2048, default_backend()), ec.generate_private_key(ec.SECP256R1(), default_backend()), ec.generate_private_key(ec.SECP384R1(), default_backend()), ], ) @pytest.mark.parametrize( "slot, pin_policy", [ (SLOT.AUTHENTICATION, PIN_POLICY.ONCE), (SLOT.SIGNATURE, PIN_POLICY.ALWAYS), (SLOT.KEY_MANAGEMENT, PIN_POLICY.ONCE), (SLOT.CARD_AUTH, PIN_POLICY.NEVER), ], ) def test_slot_metadata_put(self, session, key, slot, pin_policy): session.authenticate(MANAGEMENT_KEY_TYPE.TDES, DEFAULT_MANAGEMENT_KEY) session.put_key(slot, key) data = session.get_slot_metadata(slot) assert data.key_type == KEY_TYPE.from_public_key(key.public_key()) assert data.pin_policy == pin_policy assert data.touch_policy == TOUCH_POLICY.NEVER assert data.generated is False assert data.public_key.public_bytes( encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) == key.public_key().public_bytes( encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.8920102 yubikey_manager-5.2.1/tests/files/rsa_1024_key.pem0000644000000000000000000000160614407612164016702 0ustar00-----BEGIN RSA PRIVATE KEY----- MIICWwIBAAKBgQDGAQa8eT5T9FjSP2Xcw/uj5LZrq5/hdpEGG7XO10IfPy0wbwqP j+omaCxJlAPXuxFy0cYFNQlangIu0HAJ/TMAZXPJLBSRwdK7X/aZn/Ds2vRNAcp5 av+Pym9cfnfgMoS+6CvbUMAduLhzrnh4tQv4lb/AkliomHuoczdbcWHvKwIDAQAB AoGAXzxrIwgmBHeIqUe5FOBnDsOZQlyAQA+pXYjCf8Rll2XptFwUdkzAUMzWUGWT G5ZspA9l8Wc7IozRe/bhjMxuVK5yZhPDKbjqRdWICA95Jd7fxlIirHOVMQRdzI7x NKqMNQN05MLJfsEHUYtOLhZE+tfhJTJnnmB7TMwnJgc4O5ECQQD8oOJ45tyr46zc OAt6ao7PefVLiW5Qu+PxfoHmZmDV2UQqeM5XtZg4O97VBSugOs3+quIdAC6LotYl /6N+E4y3AkEAyKWD2JNCrAgtjk2bfF1HYt24tq8+q7x2ek3/cUhqwInkrZqOFoke x3+yBB879TuUOadvBXndgMHHcJQKSAJlLQJAXRuGnHyptAhTe06EnHeNbtZKG67p I4Q8PJMdmSb+ZZKP1v9zPUxGb+NQ+z3OmF1T8ppUf8/DV9+KAbM4NI1L/QJAdGBs BKYFObrUkYE5+fwwd4uao3sponqBTZcH3jDemiZg2MCYQUHu9E+AdRuYrziLVJVk s4xniVLb1tRG0lVxUQJASfjdGT81HDJSzTseigrM+JnBKPPrzpeEp0RbTP52Lm23 YARjLCwmPMMdAwYZsvqeTuHEDQcOHxLHWuyN/zgP2A== -----END RSA PRIVATE KEY----- ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.8920102 yubikey_manager-5.2.1/tests/files/rsa_2048_cert.der0000644000000000000000000000133714407612164017050 0ustar000‚Ú0‚C ü0  *†H†÷ 0›1 0 UJP10 UTokyo10UChuo-ku10U Frank4DD10U WebCert Support10UFrank4DD Web CA1#0! *†H†÷  support@frank4dd.com0 120822052741Z 170821052741Z0J1 0 UJP10 U Tokyo10U Frank4DD10U www.example.com0‚"0  *†H†÷ ‚0‚ ‚´ÏÑ^3)ì Ï®võþ-È™Æxy¹ø Ôº´×žR ô“LÔpÑB )’sPwö‰¬,Öñ«­lÀÙÕ¦«ÊÍZÒV&QåKНÌ%4°*)AUñk“Û³ÌÜìëÇU×B%ÞI52’œÆiã<ûôšøû‹Åà~ýO%º?å–Wš$yI'׉Kj. ‡QÙ#=…VøX1î™xhÍnD~ÉÚŒZ{¿$@)HÑœïÜ®*]ø÷jÇé¼Å°Yö•üËØœíÃü“xZu´VƒúüA„ödy45¬z…sxrç$‰%žÚe¼¯‡“ŒÛu¶à0ÇøY0  *†H†÷ @Ëþ[ÆtÅs‘ßÿ¶ž…sþà o:D/ÌSs2?yd9èxŒbIj²æ‘…·O8Ú¹i.ÉI–„Âëã#ôë¬hKWZQ×ëÀ1l† öU¨øÐB”¥àh§Ÿ¶óœÐá";«…=¡'›P2b¸ìzúÖ}+)æ­²iM(´ø ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.8920102 yubikey_manager-5.2.1/tests/files/rsa_2048_cert.pem0000644000000000000000000000205414407612164017054 0ustar00-----BEGIN CERTIFICATE----- MIIC2jCCAkMCAg38MA0GCSqGSIb3DQEBBQUAMIGbMQswCQYDVQQGEwJKUDEOMAwG A1UECBMFVG9reW8xEDAOBgNVBAcTB0NodW8ta3UxETAPBgNVBAoTCEZyYW5rNERE MRgwFgYDVQQLEw9XZWJDZXJ0IFN1cHBvcnQxGDAWBgNVBAMTD0ZyYW5rNEREIFdl YiBDQTEjMCEGCSqGSIb3DQEJARYUc3VwcG9ydEBmcmFuazRkZC5jb20wHhcNMTIw ODIyMDUyNzQxWhcNMTcwODIxMDUyNzQxWjBKMQswCQYDVQQGEwJKUDEOMAwGA1UE CAwFVG9reW8xETAPBgNVBAoMCEZyYW5rNEREMRgwFgYDVQQDDA93d3cuZXhhbXBs ZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0z9FeMynsC8+u dvX+LciZxnh5uRj4C9S6tNeeAlIGCfQYk0zUcNFCoCkTknNQd/YEiawDLNbxBqut bMDZ1aarys1a0lYmUeVLCIqvzBkPJTSQsCopQQ9V8WuT252zzNzs68dVGNdCJd5J NRQykpwexmnjPPv0mvj7i8XgG379TyW6P+WWV5okeUkXJ9eJS2ouDYdR2SM9BoVW +FgxDu6BmXhozW5EfsnajFp7HL8kQClI0QOc79yuKl3492rH6bzFsFn2lfwWy9ic 7cP8EpCTeFp1tFaD+vxBhPZkeTQ1HKx6hQ5zeHIB5ySJJZ7af2W8r4eTGYzbdRW2 4DDHCPhZAgMBAAEwDQYJKoZIhvcNAQEFBQADgYEAQMv+BFvGdMVzkQaQ3/+2noVz /uAKbzpEL8xTcxYyP3lkOeh4FoxiSWqy5pGFALdPONoDuYFpLhjJSZaEwuvjI/Tr rGhLV1pRG9frwDFshqD2Vaj4ENBCBh6UpeBop5+285zQ4SI7q4U9oSebUDJiuOx6 +tZ9KynmrbJpTSi0+BM= -----END CERTIFICATE----- ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.8920102 yubikey_manager-5.2.1/tests/files/rsa_2048_cert_metadata.pem0000644000000000000000000000212414407612164020712 0ustar00Subject: Subject Name Another comment -----BEGIN CERTIFICATE----- MIIC2jCCAkMCAg38MA0GCSqGSIb3DQEBBQUAMIGbMQswCQYDVQQGEwJKUDEOMAwG A1UECBMFVG9reW8xEDAOBgNVBAcTB0NodW8ta3UxETAPBgNVBAoTCEZyYW5rNERE MRgwFgYDVQQLEw9XZWJDZXJ0IFN1cHBvcnQxGDAWBgNVBAMTD0ZyYW5rNEREIFdl YiBDQTEjMCEGCSqGSIb3DQEJARYUc3VwcG9ydEBmcmFuazRkZC5jb20wHhcNMTIw ODIyMDUyNzQxWhcNMTcwODIxMDUyNzQxWjBKMQswCQYDVQQGEwJKUDEOMAwGA1UE CAwFVG9reW8xETAPBgNVBAoMCEZyYW5rNEREMRgwFgYDVQQDDA93d3cuZXhhbXBs ZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0z9FeMynsC8+u dvX+LciZxnh5uRj4C9S6tNeeAlIGCfQYk0zUcNFCoCkTknNQd/YEiawDLNbxBqut bMDZ1aarys1a0lYmUeVLCIqvzBkPJTSQsCopQQ9V8WuT252zzNzs68dVGNdCJd5J NRQykpwexmnjPPv0mvj7i8XgG379TyW6P+WWV5okeUkXJ9eJS2ouDYdR2SM9BoVW +FgxDu6BmXhozW5EfsnajFp7HL8kQClI0QOc79yuKl3492rH6bzFsFn2lfwWy9ic 7cP8EpCTeFp1tFaD+vxBhPZkeTQ1HKx6hQ5zeHIB5ySJJZ7af2W8r4eTGYzbdRW2 4DDHCPhZAgMBAAEwDQYJKoZIhvcNAQEFBQADgYEAQMv+BFvGdMVzkQaQ3/+2noVz /uAKbzpEL8xTcxYyP3lkOeh4FoxiSWqy5pGFALdPONoDuYFpLhjJSZaEwuvjI/Tr rGhLV1pRG9frwDFshqD2Vaj4ENBCBh6UpeBop5+285zQ4SI7q4U9oSebUDJiuOx6 +tZ9KynmrbJpTSi0+BM= -----END CERTIFICATE----- ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.8930123 yubikey_manager-5.2.1/tests/files/rsa_2048_key.pem0000644000000000000000000000325214407612164016710 0ustar00-----BEGIN RSA PRIVATE KEY----- MIIEpAIBAAKCAQEA5ltzQUrGUklMtSXFHG7bb4BcQ4UPCpV09X9oUCvOt/IqU6GB bGHb4K6IZcFi3Jd/3LKknNmV3wq16Jjvc38/vtPlsI+hw733I4d73feyR/+NHt/e dL9pmSAjSyui6HY/Cdu+Gacd8lDekAe/Hndn9Z4E9eitTkCjQwfJulZg1AqFNUxi yeIxJPTF8xTHxgLiXEuK+x8gmu1kpT1aZo++MqFsV+PHuh6wSrO6K1LGJAMdOYWc IrAfI6z8geKrE7XdXdyUwofJNN8qdtxwl4z33Af2f/0uLX9wNNasmh8gA/LSXYXJ VEp8vF5PNkF1WF/4PqouEyvGvPJBcaxW7sf6hQIDAQABAoIBADud1VFDidoH8Fs9 YCsAobfUr4wl5oOltHRIufVtsP04Ji4osTcciGw4n0I+b1iJuOSkMygIw9nKitOc qPPqLdQ0QNCWC5Z+FnTSfoMutKwffiVMaOUsGKcxgxDURUAGQkBJ54P6FSz+Mutx pcu7uWL+t2fxBNEot1gErveTnVGiva0lX+xTQM8c+Ghce5IXHrjFD9Em7Mu+1mms OYxlinwfUQvgvaJArHmKKOGmG9/nwNIrRuvQCWHfVSj0feEubFaZIxHRRU3k21OB 51knwBPh6vDwQ2ksqyWvRyshq0EsWNuLT5l2+Zb1C+NXwqh8/SlxzHUqi3LbK4Gg MRPEhO0CgYEA9SPEwe2MCOv3onpFWJOtsBulyrLed+MzVbDqRC29Y3PjHFftb6mi 2Dh0LpkjoFeGA+L29JZJnqfkhVLbu+5z8LB6Z/zem4EzxXMxQ2b8h9MnbJQYqDHg Xq1XeVmxPJMKN67O8u5ku19av5vZHGq0Q65nln77UiKwEND54DoOBucCgYEA8JAG +INMMYPAAJIjhWAV8UbiY0IRcpE+eIpb4gjWmPciskzxLKAjedrQxJywebu12r8r IH2redvfTSyeDWH1dAj2n71W14flZThbPmkSlb5gO2R0wEhDw2pUdTBTtegP88+Z rL5WIA+Hle/AU7uFQKOn4r6php474Bt06sZzwbMCgYEAktjxdeZyO6n3NyqdvfkB U/zL7UgHQrQkvVF0lJD94cS7KPB3OKva9EGlP4DXOacUjeF5ZH1e7p7OoxtGrCak 52sgeIifZXIZbE+cFC9uWYMhG8b/mkn+iVi3jOcw6AOBXGfoathqGWB+wUd/4Kj/ AYhJX3sD3GkRJZG6DhtY6cMCgYEAp7RAp88gtwQaPkui58Bsi5/XA0tzzmLjIjWS iKmQsWLYlWR+XZXmJXUeRXLWtIbf6HeNIUF64aEeszZ/mOTJsPLuu73LZMYgbcg0 E/Y8NphZjg4iNkoqs3jVGD1wnkgBlv8LKxomAIPTCfvyIG2CH+X3jGNO28JEC6AY ifN/j3ECgYBkWWh8gQEHIEFkce4GvjDI0TQiT1HAO01MNVl1Si5BjBL9rPLlbAPO sMkiGjKCbAn/w7xhln9IcsTm8EhsKrpsNkAon2sNNGg5uoCsMwO9CqGIvVTBsX84 lSqIz1Vex5yCZAySKgPUFw89Llvu+WLcy6ZXf9ZDiH6oqR+rdmd6EQ== -----END RSA PRIVATE KEY----- ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.8930123 yubikey_manager-5.2.1/tests/files/rsa_2048_key_cert.pfx0000644000000000000000000000464514407612164017750 0ustar000‚ ¡0‚ g *†H†÷  ‚ X‚ T0‚ P0‚ *†H†÷  ‚ø0‚ô0‚í *†H†÷ 0 *†H†÷  0݆|¬o–€‚À©@‹Sšz­ÛY<ÐXtÕZKˆø7õgÀE€ ëî¦:ƒõš¥òÌA@SÈÿ‚‘;¢¿|w’ºCŽ£–4T\Ål*]Z«ÅVƒðÕ@ûÓ:f/Cf—t£•Úˆ ¦ðÕ¢®ÞáÒ²ôV/@A] ffŠ¡¹ƒWgËñqðæ oìm–!e¦§gåç8Dš•ÝØXqO¼s£<ÇpxsLqéì…GÊ|Ŷÿ…®½g„vÏ•¢¬ò)uê7¬“°f5Id±É*\‚ófkš˜ŠY#è·VùI jÎþÚy¬¶ç,KhkФû™Üƒ†à»íÌ:?H „œÆrlLŒryh–Z^žèv¯õ GúS¶ÂîGZ²´ËãxyV¤ýlßþfÝEŠ`=ßPRÛ®÷^nS“@±”ø²Fþ»ÊjyÈNÞT7‘ïu£Ç†˜ÖjÕXä¹l…e`ºº¿p’}àxYw’[ùk´†’1aÝ ßÀŠÆXÅÂIûÏ$Ÿ}yšî¿¨b1Õr+õ”ê¿§É|D¯ù ~ý˜ 8v©4»e‡ÅÎo2#SGÑ1•´T µå:Ÿ*¨Ÿ¡OÈ™ËC;ß6Œ·i#Ôö~rEÌÙ[ü)mãJ•f8#:Vk RZCËI¡m'F4˜" õøwç÷P,ÔS‰‘~hU0§} Yâö.×4||m<ÊÃ-ÓIþñ™kï÷)ÈóêÈO±~ò•°–h:¹ýE¢¶;uPdS^¬ø]Y ¿bÞPsZ(ùÌ^˜G(U Ê­ÿcC·N²òo¾Ô¦¯ñ.¦hŒÓ#Uªïw>ÔIÉâ­GlÿÞ²…ŒQÇìòY°ÀÇ|5ËÎ¥€|³ýØ¥¿†¦ªP»]Ý,âÛ[P‘OWI,I­XŠæÇ¤Ñ éœ×§øt"÷To7T^ G%〘2ˆB¸Õý´Åd6D{ý°-ÞYók€îyM Š+î€;0Ñ:2bA'¬¥L8‡ßãÙxƒë»q"CÂå\Ùߘ™÷¨Sø}£„P;ª©ÿô4.5û¾_´ë»Ï2`–;²µ«±‚ÚìW%”™1ÒªíH ‚­Âäqc輌õ\„@“˜àL!ÉdAºô*íäiDz˜·ÕgІg¼vjz¹$§­eNBŒ|N÷çœ÷sQ­[8#'W…ýÃê;Š—>¦ð#*7Ê0‚A *†H†÷  ‚2‚.0‚*0‚& *†H†÷   ‚î0‚ê0 *†H†÷  0 …è ‚Ȫ+2Á´Å4•ȯè‹:sî4Bjr+…ÏÃýÒaËéM…}äB¤2·U£’üÇDªR\AÈ\ý¯¼öl¾ß, ®FAá‹F^¥KÄK—ý?ƒÞ–²išD-O­*S‹‰ÔÇÉ¿I ?ŸÞl¨8Ï]’ŒEÛB0gSC@EÓ)eO(½ÉÕA˜Áe=ÍR»ü[¯ª]‰3Ãw“µ‹å—‘^9Γ׵ ษ‡É½ë ñP mƒ4HšÿË£ÜÐÂOjÁ‡MSË«ö€÷ébô0…œ¸BÜùIú×u”Û¿Ÿ|¶‚Y2¬ šS‹î06Æ}35]ÿ= •¡;´åD_/F’ãŽá*ˆ¦@po¯âËq;âÁÊ 5^6év(µô¬gë’šy¯üž^šfW©i ¨ q»„kÈÈŠ$B7…Ι4»oÐÉlqäÝW¨©ãÍ<Šâ/åX®[¾Þºü½i\dÛ1Åwç°ŒL¾—Þ¯iÞë Új]ÚL@p™ê‘ )‘Lî…mƒÆô¼X‹Ô`v殨NJ±¬<#¤˜½6‡×s9–c R]ê; è Ѽ㨈ÂB«ôæöBji] Sš\ùaI\‘(~ó¾ûþ¾~%—_†¥ãå¨Û%½cJ£éÜ’¨#’ãLÎZk½¯0†±.sV܇y;߈6L¼¤¸<çÃÁ.Å<Ì$.’í3äa´4}¨œ«Â£¬±¿ ÓÒ£ÜaüÒ¨H\I³#ÉÎÙåb‡Á¬ÿܶ1ZŠéÑ_˜¯Fs r*à¦`ÃM÷’a\´Ðl«xîpéš—bvùC|šx Õ6Ìo0Õ}gœ‡ÕJ!Ü=‡ÄATx£á¯^Šq^„µÖdÕ]3/Ý]ÅJÐV/Û2O³A‘_8ÙV/NåÊ?”„Â}ø:ÁÜDðëc”ñEžõCßnlw˜ŽÞ÷ž ¾_™c»†È ]åDs;Z„~-ræJÍœ2G˜u¦Ì–wÊføc-õÛ=¥?6?~Æ3Jº%¿XSI5'´åÄ=;SkjÆíeR΀¼Hï÷²‰»— õ¬mÛÜ=2Že«fö͹MòtÄ’ÄSÞ—ýô¦ÏÑqkeåÿT0ø+Dàª}ù¾¬I†eqvج‰cD^#°¾^°æ>2ô"Lsô‡tÕ?|£‡®Ž•–¢ê7 1%0# *†H†÷  1g (»eÁȦÊáã3„­þÆÎd010!0 + g¡Ì!o"êgµ½ï§ÔLmC­÷ooCµÅ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.8930123 yubikey_manager-5.2.1/tests/files/rsa_2048_key_cert_encrypted.pfx0000644000000000000000000000464514407612164022025 0ustar000‚ ¡0‚ g *†H†÷  ‚ X‚ T0‚ P0‚ *†H†÷  ‚ø0‚ô0‚í *†H†÷ 0 *†H†÷  0æ.儫ꀂÀ[ NÿT‰¤‚6æw1uó Ê0J®sÜyS7*—É«kVS/ÐdPu1}•/çK—à .• œ’?â>wPÛ•ÖbÝßÐÙÿ/ÒØ\Ò¦]¡‹:¶ÝÑîp2˜~ŠÜQ¬vF©zL´Ë‘ ÉÀtæ%®\¤þûÜ;%A†;Ú)¦Už;Hó ,Û{¼¢Â+…µ4•ÑwMN ÞŸÜn,C¸Öb÷dôzgûØ·kñùä¿×“m¯1ñt6B]9ÛqÂÉ;”­Ì«¶.è8%$B’B«‘íÔN&¢0Æ íŠ½«ž,EÝ0Ñ…F/"¤pˆeFÝ;(ʦ‚|u–Ë‘À-á{‘LeÇì"Ð)=*{ É"T¹RþÏ×…`? ãl‘ª´33÷$1:äñݼ 2è }³nкõY\å.àJï!ã"êFßW¹JS ×*à)ßá½’ùK?^L¬`œ›ÀÜ¢G–’;ì<½ ô(a&–Ó:ä]ÇpŒ¼óyßxV º&÷Š ï,1Keâêeùøp]·!‡´³ø_s1N`²ë³ÉPû¯Î¸u'ãòš çgãŒÓkÞ8²ëûrWº:¸wòˆÉ[[ˆ@H é?hçÞñTè¥@f5%'Õ´ðÏ[Å@2Ú2 ã¦gC;sò>ª¯ÏäÉSk”RÆ6¸anúÝ<˜EýÉ #j5ÒpŸÀòˆ2ÕH %SÁÚº% OBɹõ–y©“„ÍÛ`k›²»Hðlö»w m…‹z[/ ü)†ùýiZýÞk~ÛºÒgZÑB]§Üédúsh¶€´å†G(²ý±\«*ˆ5XL¢ªs‡1ç0Yiôþ¿àïWº”žz*@‹çÙÓ”1‡r«CÒ§7|-ÐE¶¤JÞ$Q/ï~!ôBËûôšñXÃãôƒ@"üùS²rÒ}¼°”Óï’ÕÛ¸<Ÿ¡b­irO 0@M]á q£›Á\»/ܬ3ìèÒ¯°Æ Rð]wçt˜Û–ÅAT¦ZîQ ¦H[_ u†`Ž9-‘¯äž o*CΚ¢ÆJ*¥5J‘>ÞEA¿ÎyjÙ’+{óî¼){ð:í§Ëc¦ÚtÜBËSŸx¢Ôsx£\_¦©Z&Çêî× [ÇÒ‹ ý¨è?_Òùf oXÞ@ÀǹÆë", KEYBOARD_LAYOUT.US)) self.assertEqual(b"\xb8", encode("?", KEYBOARD_LAYOUT.US)) self.assertEqual(b"\x9f", encode("@", KEYBOARD_LAYOUT.US)) self.assertEqual(b"\x2f", encode("[", KEYBOARD_LAYOUT.US)) self.assertEqual(b"\x32", encode("\\", KEYBOARD_LAYOUT.US)) self.assertEqual(b"\x30", encode("]", KEYBOARD_LAYOUT.US)) self.assertEqual(b"\xa3", encode("^", KEYBOARD_LAYOUT.US)) self.assertEqual(b"\xad", encode("_", KEYBOARD_LAYOUT.US)) self.assertEqual(b"\xaf", encode("{", KEYBOARD_LAYOUT.US)) self.assertEqual(b"\xb0", encode("}", KEYBOARD_LAYOUT.US)) self.assertEqual(b"\xb2", encode("|", KEYBOARD_LAYOUT.US)) self.assertEqual(b"\xb5", encode("~", KEYBOARD_LAYOUT.US)) self.assertEqual(b"\x04\x05\x06", encode("abc", KEYBOARD_LAYOUT.US)) with self.assertRaises(ValueError): encode("ö") def test_de_layout(self): self.assertEqual(b"\x04", encode("a", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x05", encode("b", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x06", encode("c", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x07", encode("d", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x08", encode("e", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x09", encode("f", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x0a", encode("g", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x0b", encode("h", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x0c", encode("i", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x0d", encode("j", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x0e", encode("k", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x0f", encode("l", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x10", encode("m", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x11", encode("n", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x12", encode("o", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x13", encode("p", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x14", encode("q", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x15", encode("r", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x16", encode("s", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x17", encode("t", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x18", encode("u", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x19", encode("v", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x1a", encode("w", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x1b", encode("x", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x1d", encode("y", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x1c", encode("z", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x84", encode("A", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x85", encode("B", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x86", encode("C", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x87", encode("D", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x88", encode("E", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x89", encode("F", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x8a", encode("G", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x8b", encode("H", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x8c", encode("I", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x8d", encode("J", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x8e", encode("K", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x8f", encode("L", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x90", encode("M", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x91", encode("N", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x92", encode("O", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x93", encode("P", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x94", encode("Q", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x95", encode("R", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x96", encode("S", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x97", encode("T", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x98", encode("U", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x99", encode("V", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x9a", encode("W", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x9b", encode("X", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x9d", encode("Y", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x9c", encode("Z", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x27", encode("0", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x1e", encode("1", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x1f", encode("2", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x20", encode("3", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x21", encode("4", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x22", encode("5", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x23", encode("6", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x24", encode("7", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x25", encode("8", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x26", encode("9", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x2b", encode("\t", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x28", encode("\n", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x32", encode("#", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x30", encode("+", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x36", encode(",", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x38", encode("-", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x64", encode("<", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x35", encode("^", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x2c", encode(" ", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x2e", encode("´", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x2d", encode("ß", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x34", encode("ä", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x33", encode("ö", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x2f", encode("ü", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x9e", encode("!", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\x9f", encode('"', KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\xa1", encode("$", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\xa2", encode("%", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\xa3", encode("&", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\xb2", encode("'", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\xa5", encode("(", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\xa6", encode(")", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\xb0", encode("*", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\xa4", encode("/", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\xb7", encode(":", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\xb6", encode(";", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\xa7", encode("=", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\xe4", encode(">", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\xad", encode("?", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\xb8", encode("_", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\xad", encode("`", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\xa0", encode("§", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\xb4", encode("Ä", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\xb3", encode("Ö", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\xaf", encode("Ü", KEYBOARD_LAYOUT.DE)) self.assertEqual(b"\xb4\xb3\xaf", encode("ÄÖÜ", KEYBOARD_LAYOUT.DE)) with self.assertRaises(ValueError): encode("@", KEYBOARD_LAYOUT.DE) def test_norman_layout(self): self.assertEqual(b"\x04", encode("a", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x05", encode("b", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x06", encode("c", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x08", encode("d", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x07", encode("e", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x15", encode("f", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x0a", encode("g", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x33", encode("h", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x0e", encode("i", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x1c", encode("j", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x17", encode("k", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x12", encode("l", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x10", encode("m", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x0d", encode("n", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x0f", encode("o", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x11", encode("p", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x14", encode("q", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x0c", encode("r", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x16", encode("s", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x09", encode("t", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x18", encode("u", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x19", encode("v", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x1a", encode("w", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x1b", encode("x", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x0b", encode("y", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x1d", encode("z", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x84", encode("A", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x85", encode("B", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x86", encode("C", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x88", encode("D", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x87", encode("E", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x95", encode("F", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x8a", encode("G", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\xb3", encode("H", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x8e", encode("I", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x9c", encode("J", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x97", encode("K", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x92", encode("L", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x90", encode("M", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x8d", encode("N", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x8f", encode("O", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x91", encode("P", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x94", encode("Q", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x8c", encode("R", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x96", encode("S", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x89", encode("T", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x98", encode("U", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x99", encode("V", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x9a", encode("W", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x9b", encode("X", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x8b", encode("Y", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x9d", encode("Z", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x27", encode("0", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x1e", encode("1", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x1f", encode("2", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x20", encode("3", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x21", encode("4", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x22", encode("5", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x23", encode("6", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x24", encode("7", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x25", encode("8", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x26", encode("9", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x2b", encode("\t", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x28", encode("\n", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\xa0", encode("#", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\xae", encode("+", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x36", encode(",", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"-", encode("-", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\xb6", encode("<", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\xa3", encode("^", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x2c", encode(" ", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x9e", encode("!", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\xb4", encode('"', KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\xa1", encode("$", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\xa2", encode("%", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\xa4", encode("&", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"4", encode("'", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\xa6", encode("(", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\xa7", encode(")", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\xa5", encode("*", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"8", encode("/", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\xb3", encode(":", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x13", encode(";", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b".", encode("=", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\xb7", encode(">", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\xb8", encode("?", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\xad", encode("_", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"5", encode("`", KEYBOARD_LAYOUT.NORMAN)) self.assertEqual(b"\x04\x05\x06", encode("abc", KEYBOARD_LAYOUT.NORMAN)) with self.assertRaises(ValueError): encode("ö") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1691654743.7211213 yubikey_manager-5.2.1/tests/test_util.py0000644000000000000000000001435614465115130015361 0ustar00# vim: set fileencoding=utf-8 : from ykman import __version__ as version from yubikit.core import Tlv, bytes2int, InvalidPinError from yubikit.core.otp import modhex_encode, modhex_decode from yubikit.management import FORM_FACTOR from ykman.util import is_pkcs12, is_pem, parse_private_key, parse_certificates from ykman.util import _parse_pkcs12 from ykman.otp import format_oath_code, generate_static_pw, time_challenge from .util import open_file import unittest def test_invalid_pin_exception_value_error(): # Fail if InvalidPinError still inherits ValueError in ykman 6.0 if int(version.split(".")[0]) != 5: assert not isinstance(InvalidPinError(3), ValueError) class TestUtilityFunctions(unittest.TestCase): def test_bytes2int(self): self.assertEqual(0x57, bytes2int(b"\x57")) self.assertEqual(0x1234, bytes2int(b"\x12\x34")) self.assertEqual(0xCAFED00D, bytes2int(b"\xca\xfe\xd0\x0d")) def test_format_oath_code(self): self.assertEqual("000000", format_oath_code(b"\0" * 20)) self.assertEqual("00000000", format_oath_code(b"\0" * 20, 8)) self.assertEqual("345678", format_oath_code(b"\x00\xbc\x61\x4e" + b"\0" * 16)) self.assertEqual( "34567890", format_oath_code(b"\x49\x96\x02\xd2" + b"\0" * 16, 8) ) def test_generate_static_pw(self): for i in range(0, 38): self.assertRegex( generate_static_pw(i), "^[cbdefghijklnrtuvCBDEFGHIJKLNRTUV]{%d}$" % i ) def test_modhex_decode(self): self.assertEqual(b"", modhex_decode("")) self.assertEqual(b"\x2d\x34\x4e\x83", modhex_decode("dteffuje")) self.assertEqual( b"\x69\xb6\x48\x1c\x8b\xab\xa2\xb6\x0e\x8f\x22\x17\x9b\x58\xcd\x56", modhex_decode("hknhfjbrjnlnldnhcujvddbikngjrtgh"), ) def test_modhex_encode(self): self.assertEqual("", modhex_encode(b"")) self.assertEqual("dteffuje", modhex_encode(b"\x2d\x34\x4e\x83")) self.assertEqual( "hknhfjbrjnlnldnhcujvddbikngjrtgh", modhex_encode( b"\x69\xb6\x48\x1c\x8b\xab\xa2\xb6" b"\x0e\x8f\x22\x17\x9b\x58\xcd\x56" ), ) def test_parse_tlvs(self): tlvs = Tlv.parse_list(b"\x00\x02\xd0\x0d\xa1\x00\xfe\x04\xfe\xed\xfa\xce") self.assertEqual(3, len(tlvs)) self.assertEqual(0, tlvs[0].tag) self.assertEqual(2, tlvs[0].length) self.assertEqual(b"\xd0\x0d", tlvs[0].value) self.assertEqual(0xA1, tlvs[1].tag) self.assertEqual(0, tlvs[1].length) self.assertEqual(b"", tlvs[1].value) self.assertEqual(0xFE, tlvs[2].tag) self.assertEqual(4, tlvs[2].length) self.assertEqual(b"\xfe\xed\xfa\xce", tlvs[2].value) def test_time_challenge(self): self.assertEqual(b"\0" * 8, time_challenge(0)) self.assertEqual(b"\x00\x00\x00\x00\x00\x06G\x82", time_challenge(12345678)) self.assertEqual( b"\x00\x00\x00\x00\x02\xf2\xeaC", time_challenge(1484223461.2644958), # type: ignore ) def test_tlv(self): self.assertEqual(Tlv(b"\xfe\6foobar"), Tlv(0xFE, b"foobar")) tlv1 = Tlv(b"\0\5hello") tlv2 = Tlv(0xFE, b"") tlv3 = Tlv(0x12, b"hi" * 200) self.assertEqual(b"\0\5hello", tlv1) self.assertEqual(b"\xfe\0", tlv2) self.assertEqual(b"\x12\x82\x01\x90" + b"hi" * 200, tlv3) self.assertEqual( b"\0\5hello\xfe\0\x12\x82\x01\x90" + b"hi" * 200, tlv1 + tlv2 + tlv3 ) def test_is_pkcs12(self): with self.assertRaises(TypeError): is_pkcs12(None) with open_file("rsa_2048_key.pem") as rsa_2048_key_pem: self.assertFalse(is_pkcs12(rsa_2048_key_pem.read())) with open_file("rsa_2048_key_encrypted.pem") as f: self.assertFalse(is_pkcs12(f.read())) with open_file("rsa_2048_cert.pem") as rsa_2048_cert_pem: self.assertFalse(is_pkcs12(rsa_2048_cert_pem.read())) with open_file("rsa_2048_key_cert.pfx") as rsa_2048_key_cert_pfx: data = rsa_2048_key_cert_pfx.read() self.assertTrue(is_pkcs12(data)) parse_private_key(data, None) parse_certificates(data, None) with open_file( "rsa_2048_key_cert_encrypted.pfx" ) as rsa_2048_key_cert_encrypted_pfx: self.assertTrue(is_pkcs12(rsa_2048_key_cert_encrypted_pfx.read())) def test_parse_pkcs12(self): with open_file("rsa_2048_key_cert.pfx") as rsa_2048_key_cert_pfx: data = rsa_2048_key_cert_pfx.read() key, certs = _parse_pkcs12(data, None) self.assertEqual(1, len(certs)) def test_is_pem(self): self.assertFalse(is_pem(b"just a byte string")) self.assertFalse(is_pem(None)) with open_file("rsa_2048_key.pem") as rsa_2048_key_pem: self.assertTrue(is_pem(rsa_2048_key_pem.read())) with open_file("rsa_2048_key_encrypted.pem") as f: self.assertTrue(is_pem(f.read())) with open_file("rsa_2048_cert.pem") as rsa_2048_cert_pem: self.assertTrue(is_pem(rsa_2048_cert_pem.read())) with open_file("rsa_2048_key_cert.pfx") as rsa_2048_key_cert_pfx: self.assertFalse(is_pem(rsa_2048_key_cert_pfx.read())) with open_file("rsa_2048_cert_metadata.pem") as f: self.assertTrue(is_pem(f.read())) with open_file( "rsa_2048_key_cert_encrypted.pfx" ) as rsa_2048_key_cert_encrypted_pfx: self.assertFalse(is_pem(rsa_2048_key_cert_encrypted_pfx.read())) def test_form_factor_from_code(self): with self.assertRaises(ValueError): FORM_FACTOR.from_code("im a string") # type: ignore self.assertEqual(FORM_FACTOR.UNKNOWN, FORM_FACTOR.from_code(0x00)) self.assertEqual(FORM_FACTOR.USB_A_KEYCHAIN, FORM_FACTOR.from_code(0x01)) self.assertEqual(FORM_FACTOR.USB_A_NANO, FORM_FACTOR.from_code(0x02)) self.assertEqual(FORM_FACTOR.USB_C_KEYCHAIN, FORM_FACTOR.from_code(0x03)) self.assertEqual(FORM_FACTOR.USB_C_NANO, FORM_FACTOR.from_code(0x04)) self.assertEqual(FORM_FACTOR.USB_C_LIGHTNING, FORM_FACTOR.from_code(0x05)) self.assertEqual(FORM_FACTOR.UNKNOWN, FORM_FACTOR.from_code(0x99)) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1696967723.9426942 yubikey_manager-5.2.1/tests/util.py0000644000000000000000000000242514511326054014315 0ustar00import datetime import logging import os from cryptography import x509 from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import ec from cryptography.x509.oid import NameOID logger = logging.getLogger(__name__) PKG_DIR = os.path.dirname(os.path.abspath(__file__)) def open_file(*relative_path): return open(os.path.join(PKG_DIR, "files", *relative_path), "rb") def generate_self_signed_certificate( common_name="Test", valid_from=None, valid_to=None ): valid_from = valid_from if valid_from else datetime.datetime.utcnow() valid_to = valid_to if valid_to else valid_from + datetime.timedelta(days=1) private_key = ec.generate_private_key(ec.SECP256R1(), default_backend()) public_key = private_key.public_key() subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)]) return ( 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) ).sign(private_key, hashes.SHA256(), default_backend()) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1696967732.7454045 yubikey_manager-5.2.1/ykman/__init__.py0000644000000000000000000000256214511326065015060 0ustar00# 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. __version__ = "5.2.1" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.8970091 yubikey_manager-5.2.1/ykman/_cli/__init__.py0000644000000000000000000000256514407612164015773 0ustar00# 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. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1696967732.7464807 yubikey_manager-5.2.1/ykman/_cli/__main__.py0000644000000000000000000003061614511326065015750 0ustar00# 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.core import ApplicationNotAvailableError from yubikit.core.otp import OtpConnection from yubikit.core.fido import FidoConnection from yubikit.core.smartcard import SmartCardConnection from yubikit.support import get_name, read_info from yubikit.logging import LOG_LEVEL from .. import __version__ from ..pcsc import list_devices as list_ccid, list_readers from ..device import scan_devices, list_all_devices from ..util import get_windows_version from ..logging import init_logging from ..diagnostics import get_diagnostics, sys_info from .util import YkmanContextObject, click_group, EnumChoice, CliFail, pretty_print from .info import info from .otp import otp from .openpgp import openpgp from .oath import oath from .piv import piv from .fido import fido from .config import config from .aliases import apply_aliases from .apdu import apdu from .script import run_script from .hsmauth import hsmauth import click import ctypes import time import sys import logging logger = logging.getLogger(__name__) CLICK_CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"], max_content_width=999) WIN_CTAP_RESTRICTED = ( sys.platform == "win32" and not bool(ctypes.windll.shell32.IsUserAnAdmin()) and get_windows_version() >= (10, 0, 18362) ) def _scan_changes(state, attempts=10): for _ in range(attempts): time.sleep(0.25) devices, new_state = scan_devices() if new_state != state: return devices, new_state raise TimeoutError("Timed out waiting for state change") def print_version(ctx, param, value): if not value or ctx.resilient_parsing: return click.echo(f"YubiKey Manager (ykman) version: {__version__}") ctx.exit() def print_diagnostics(ctx, param, value): if not value or ctx.resilient_parsing: return click.echo("\n".join(pretty_print(get_diagnostics()))) ctx.exit() def require_reader(connection_types, reader): if SmartCardConnection in connection_types or FidoConnection in connection_types: readers = list_ccid(reader) if len(readers) == 1: dev = readers[0] try: with dev.open_connection(SmartCardConnection) as conn: info = read_info(conn, dev.pid) return dev, info except Exception: raise CliFail("Failed to connect to YubiKey") elif len(readers) > 1: raise CliFail("Multiple external readers match name.") else: raise CliFail("No YubiKey found on external reader.") else: raise CliFail("Not a CCID command.") def require_device(connection_types, serial=None): # Find all connected devices devices, state = scan_devices() n_devs = sum(devices.values()) if serial is None: if n_devs == 0: # The device might not yet be ready, wait a bit try: devices, state = _scan_changes(state) n_devs = sum(devices.values()) except TimeoutError: raise CliFail("No YubiKey detected!") if n_devs > 1: raise CliFail( "Multiple YubiKeys detected. Use --device SERIAL to specify " "which one to use." ) # Only one connected device, check if any needed interfaces are available pid = next(iter(devices.keys())) supported = [c for c in connection_types if pid.supports_connection(c)] if WIN_CTAP_RESTRICTED and supported == [FidoConnection]: # FIDO-only command on Windows without Admin won't work. raise CliFail("FIDO access on Windows requires running as Administrator.") if not supported: interfaces = [c.usb_interface for c in connection_types] req = ", ".join(t.name or str(t) for t in interfaces) raise CliFail( f"Command requires one of the following USB interfaces " f"to be enabled: '{req}'.\n\n" "Use 'ykman config usb' to set the enabled USB interfaces." ) devs = list_all_devices(supported) if len(devs) != 1: raise CliFail("Failed to connect to YubiKey.") return devs[0] else: for _ in (0, 1): # If no match initially, wait a bit for state change. devs = list_all_devices(connection_types) for dev, nfo in devs: if nfo.serial == serial: return dev, nfo devices, state = _scan_changes(state) raise CliFail( f"Failed connecting to a YubiKey with serial: {serial}.\n" "Make sure the application has the required permissions.", ) @click_group(context_settings=CLICK_CONTEXT_SETTINGS) @click.option( "-d", "--device", type=int, metavar="SERIAL", help="specify which YubiKey to interact with by serial number", ) @click.option( "-r", "--reader", help="specify a YubiKey by smart card reader name " "(can't be used with --device or list)", metavar="NAME", default=None, ) @click.option( "-l", "--log-level", default=None, type=EnumChoice(LOG_LEVEL, hidden=[LOG_LEVEL.NOTSET]), help="enable logging at given verbosity level", ) @click.option( "--log-file", default=None, type=str, metavar="FILE", help="write log to FILE instead of printing to stderr (requires --log-level)", ) @click.option( "--diagnose", is_flag=True, callback=print_diagnostics, expose_value=False, is_eager=True, help="show diagnostics information useful for troubleshooting", ) @click.option( "-v", "--version", is_flag=True, callback=print_version, expose_value=False, is_eager=True, help="show version information about the app", ) @click.option( "--full-help", is_flag=True, expose_value=False, help="show --help output, including hidden commands", ) @click.pass_context def cli(ctx, device, log_level, log_file, reader): """ Configure your YubiKey via the command line. Examples: \b List connected YubiKeys, only output serial number: $ ykman list --serials \b Show information about YubiKey with serial number 123456: $ ykman --device 123456 info """ ctx.obj = YkmanContextObject() if log_level: init_logging(log_level, log_file=log_file) logger.info("\n".join(pretty_print({"System info": sys_info()}))) elif log_file: ctx.fail("--log-file requires specifying --log-level.") if reader and device: ctx.fail("--reader and --device options can't be combined.") subcmd = next(c for c in COMMANDS if c.name == ctx.invoked_subcommand) # Commands that don't directly act on a key if subcmd in (list_keys,): if device: ctx.fail("--device can't be used with this command.") if reader: ctx.fail("--reader can't be used with this command.") return # Commands which need a YubiKey to act on connections = getattr( subcmd, "connections", [SmartCardConnection, FidoConnection, OtpConnection] ) if connections: def resolve(): if connections == [FidoConnection] and WIN_CTAP_RESTRICTED: # FIDO-only command on Windows without Admin won't work. raise CliFail( "FIDO access on Windows requires running as Administrator." ) items = getattr(resolve, "items", None) if not items: if reader is not None: items = require_reader(connections, reader) else: items = require_device(connections, device) setattr(resolve, "items", items) return items ctx.obj.add_resolver("device", lambda: resolve()[0]) ctx.obj.add_resolver("pid", lambda: resolve()[0].pid) ctx.obj.add_resolver("info", lambda: resolve()[1]) @cli.command("list") @click.option( "-s", "--serials", is_flag=True, help="output only serial numbers, one per line " "(devices without serial will be omitted)", ) @click.option("-r", "--readers", is_flag=True, help="list available smart card readers") @click.pass_context def list_keys(ctx, serials, readers): """ List connected YubiKeys. """ if readers: for reader in list_readers(): click.echo(reader.name) ctx.exit() # List all attached devices pids = set() for dev, dev_info in list_all_devices(): if serials: if dev_info.serial: click.echo(dev_info.serial) else: if dev.pid is None: # Devices from list_all_devices should always have PID. raise AssertionError("PID is None") name = get_name(dev_info, dev.pid.yubikey_type) version = dev_info.version or "unknown" mode = dev.pid.name.split("_", 1)[1].replace("_", "+") click.echo( f"{name} ({version}) [{mode}]" + (f" Serial: {dev_info.serial}" if dev_info.serial else "") ) pids.add(dev.pid) # Look for FIDO devices that we can't access if not serials: devs, _ = scan_devices() for pid, count in devs.items(): if pid not in pids: for _ in range(count): name = pid.yubikey_type.value mode = pid.name.split("_", 1)[1].replace("_", "+") click.echo(f"{name} [{mode}] ") COMMANDS = ( list_keys, info, otp, openpgp, oath, piv, fido, config, apdu, run_script, hsmauth, ) for cmd in COMMANDS: cli.add_command(cmd) class _DefaultFormatter(logging.Formatter): def __init__(self, show_trace=False): self.show_trace = show_trace def format(self, record): message = f"{record.levelname}: {record.getMessage()}" if self.show_trace and record.exc_info: message += self.formatException(record.exc_info) return message def main(): # Set up default logging handler = logging.StreamHandler() handler.setLevel(logging.WARNING) formatter = _DefaultFormatter() handler.setFormatter(formatter) logging.getLogger().addHandler(handler) sys.argv = apply_aliases(sys.argv) try: # --full-help triggers --help, hidden commands will already have read it by now. sys.argv[sys.argv.index("--full-help")] = "--help" except ValueError: pass # No --full-help try: cli(obj={}) except Exception as e: status = 1 if isinstance(e, CliFail): status = e.status msg = e.args[0] elif isinstance(e, ApplicationNotAvailableError): msg = ( "The functionality required for this command is not enabled or not " "available on this YubiKey." ) elif isinstance(e, ValueError): msg = f"{e}" else: msg = "An unexpected error has occured" formatter.show_trace = True logger.exception(msg) sys.exit(status) if __name__ == "__main__": main() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1696967723.9426942 yubikey_manager-5.2.1/ykman/_cli/aliases.py0000644000000000000000000001266014511326054015646 0ustar00# 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. import sys import logging """ Command line aliases to support commands which have moved. The old commands are no longer supported and will fail, but will show their replacement. """ logger = logging.getLogger(__name__) ignore = None def replace(*args): def inner(argv, alias, match_at): return argv[:match_at] + list(args) + argv[match_at + len(alias) :] return inner def oath_access_remember(argv, alias, match_at): args = ["oath", "access"] for flag in ("-c", "--clear-all"): if flag in argv: argv.remove(flag) args.extend(["forget", "--all"]) break else: for flag in ("-F", "--forget"): if flag in argv: argv.remove(flag) args.append("forget") break else: args.append("remember") argv = argv[:match_at] + args + argv[match_at + len(alias) :] return argv _aliases = ( (["config", "mode"], ignore), # Avoid match on next line (["mode"], replace("config", "mode")), (["fido", "delete"], replace("fido", "credentials", "delete")), (["fido", "list"], replace("fido", "credentials", "list")), (["fido", "set-pin"], replace("fido", "access", "change-pin")), (["fido", "unlock"], replace("fido", "access", "verify-pin")), (["piv", "change-pin"], replace("piv", "access", "change-pin")), (["piv", "change-puk"], replace("piv", "access", "change-puk")), ( ["piv", "change-management-key"], replace("piv", "access", "change-management-key"), ), (["piv", "set-pin-retries"], replace("piv", "access", "set-retries")), (["piv", "unblock-pin"], replace("piv", "access", "unblock-pin")), (["piv", "attest"], replace("piv", "keys", "attest")), (["piv", "import-key"], replace("piv", "keys", "import")), (["piv", "generate-key"], replace("piv", "keys", "generate")), (["piv", "import-certificate"], replace("piv", "certificates", "import")), (["piv", "export-certificate"], replace("piv", "certificates", "export")), (["piv", "generate-certificate"], replace("piv", "certificates", "generate")), (["piv", "delete-certificate"], replace("piv", "certificates", "delete")), (["piv", "generate-csr"], replace("piv", "certificates", "request")), (["piv", "read-object"], replace("piv", "objects", "export")), (["piv", "write-object"], replace("piv", "objects", "import")), (["piv", "set-chuid"], replace("piv", "objects", "generate", "chuid")), (["piv", "set-ccc"], replace("piv", "objects", "generate", "ccc")), (["openpgp", "set-pin-retries"], replace("openpgp", "access", "set-retries")), (["openpgp", "import-certificate"], replace("openpgp", "certificates", "import")), (["openpgp", "export-certificate"], replace("openpgp", "certificates", "export")), (["openpgp", "delete-certificate"], replace("openpgp", "certificates", "delete")), (["openpgp", "attest"], replace("openpgp", "keys", "attest")), ( ["openpgp", "import-attestation-key"], replace("openpgp", "keys", "import", "att"), ), (["openpgp", "set-touch"], replace("openpgp", "keys", "set-touch")), (["oath", "add"], replace("oath", "accounts", "add")), (["oath", "code"], replace("oath", "accounts", "code")), (["oath", "delete"], replace("oath", "accounts", "delete")), (["oath", "list"], replace("oath", "accounts", "list")), (["oath", "uri"], replace("oath", "accounts", "uri")), (["oath", "set-password"], replace("oath", "access", "change")), (["oath", "remember-password"], oath_access_remember), ) def _find_match(data, selection): ln = len(selection) for i in range(0, len(data) - ln + 1): if data[i : i + ln] == selection: return i def apply_aliases(argv): for alias, f in _aliases: i = _find_match(argv, alias) if i is not None: if f: argv = f(argv, alias, i) logger.exception( "This command has moved! Use ykman " + " ".join(argv[1:]) ) sys.exit(1) break # Only handle first match return argv ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.8970091 yubikey_manager-5.2.1/ykman/_cli/apdu.py0000644000000000000000000001447514407612164015170 0ustar00# 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 binascii import a2b_hex from yubikit.core.smartcard import ( SmartCardConnection, SmartCardProtocol, ApduError, SW, AID, ) from .util import EnumChoice, CliFail, click_command from typing import Tuple, Optional import re import sys import click import struct import logging logger = logging.getLogger(__name__) APDU_PATTERN = re.compile( r"^" r"(?P[0-9a-f]{2})?(?P[0-9a-f]{2})(?P[0-9a-f]{4})?" r"(?::(?P(?:[0-9a-f]{2})+))?" r"(?P=(?P[0-9a-f]{4})?)?" r"$", re.IGNORECASE, ) def _hex(data: bytes) -> str: return " ".join(f"{d:02X}" for d in data) def _parse_apdu(data: str) -> Tuple[Tuple[int, int, int, int, bytes], Optional[int]]: m = APDU_PATTERN.match(data) if not m: raise ValueError("Invalid APDU format: " + data) cla = int(m.group("cla") or "00", 16) ins = int(m.group("ins"), 16) params = int(m.group("params") or "0000", 16) body = a2b_hex(m.group("body") or "") if m.group("check"): sw: Optional[int] = int(m.group("sw") or "9000", 16) else: sw = None p1, p2 = params >> 8, params & 0xFF return (cla, ins, p1, p2, body), sw def _print_response(resp: bytes, sw: int, no_pretty: bool) -> None: click.echo(f"RECV (SW={sw:04X})" + (":" if resp else "")) if no_pretty: click.echo(resp.hex().upper()) else: for i in range(0, len(resp), 16): chunk = resp[i : i + 16] click.echo( " ".join(f"{c:02X}" for c in chunk).ljust(50) # Replace non-printable characters with a dot. + "".join(chr(c) if 31 < c < 127 else chr(183) for c in chunk) ) @click_command(connections=[SmartCardConnection], hidden="--full-help" not in sys.argv) @click.pass_context @click.option( "-x", "--no-pretty", is_flag=True, help="print only the hex output of a response" ) @click.option( "-a", "--app", type=EnumChoice(AID), required=False, help="select application", ) @click.argument("apdu", nargs=-1) @click.option("-s", "--send-apdu", multiple=True, help="provide full APDUs") def apdu(ctx, no_pretty, app, apdu, send_apdu): """ Execute arbitary APDUs. Provide APDUs as a hex encoded, space-separated list using the following syntax: [CLA]INS[P1P2][:DATA][=EXPECTED_SW] If not provided CLA, P1 and P2 are all set to zero. Setting EXPECTED_SW will cause the command to check the response SW an fail if it differs. "=" can be used as shorthand for "=9000" (SW=OK). Examples: \b Select the OATH application, send a LIST instruction (0xA1), and make sure we get sw=9000 (these are equivalent): $ ykman apdu a40400:a000000527210101=9000 a1=9000 or $ ykman apdu -a oath a1= \b Factory reset the OATH application: $ ykman apdu -a oath 04dead or $ ykman apdu a40400:a000000527210101 04dead or (using full-apdu mode) $ ykman apdu -s 00a4040008a000000527210101 -s 0004dead """ if apdu and send_apdu: ctx.fail("Cannot mix positional APDUs and -s/--send-apdu.") elif not send_apdu: apdus = [_parse_apdu(data) for data in apdu] if not apdus and not app: ctx.fail("No commands provided.") dev = ctx.obj["device"] with dev.open_connection(SmartCardConnection) as conn: protocol = SmartCardProtocol(conn) is_first = True if app: is_first = False click.echo("SELECT AID: " + _hex(app)) resp = protocol.select(app) _print_response(resp, SW.OK, no_pretty) if send_apdu: # Compatibility mode (full APDUs) for apdu in send_apdu: if not is_first: click.echo() else: is_first = False apdu = a2b_hex(apdu) click.echo("SEND: " + _hex(apdu)) resp, sw = protocol.connection.send_and_receive(apdu) _print_response(resp, sw, no_pretty) else: # Standard mode for apdu, check in apdus: if not is_first: click.echo() else: is_first = False header, body = apdu[:4], apdu[4] req = _hex(struct.pack(">BBBB", *header)) if body: req += " -- " + _hex(body) click.echo("SEND: " + req) try: resp = protocol.send_apdu(*apdu) sw = SW.OK except ApduError as e: resp = e.data sw = e.sw _print_response(resp, sw, no_pretty) if check is not None and sw != check: raise CliFail(f"Aborted due to error (expected SW={check:04X}).") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1692616769.9874134 yubikey_manager-5.2.1/ykman/_cli/config.py0000644000000000000000000004275614470644102015504 0ustar00# 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. from yubikit.core import TRANSPORT, YUBIKEY from yubikit.core.otp import OtpConnection from yubikit.core.smartcard import SmartCardConnection from yubikit.core.fido import FidoConnection from yubikit.management import ( ManagementSession, DeviceConfig, CAPABILITY, USB_INTERFACE, DEVICE_FLAG, Mode, ) from .util import ( click_group, click_postpone_execution, click_force_option, click_prompt, EnumChoice, CliFail, ) import os import re import click import logging logger = logging.getLogger(__name__) CLEAR_LOCK_CODE = b"\0" * 16 def prompt_lock_code(): return click_prompt("Enter your lock code", hide_input=True) @click_group(connections=[SmartCardConnection, OtpConnection, FidoConnection]) @click.pass_context @click_postpone_execution def config(ctx): """ Enable or disable applications. The applications may be enabled and disabled independently over different transports (USB and NFC). The configuration may also be protected by a lock code. Examples: \b Disable PIV over NFC: $ ykman config nfc --disable PIV \b Enable all applications over USB: $ ykman config usb --enable-all \b Generate and set a random application lock code: $ ykman config set-lock-code --generate """ dev = ctx.obj["device"] for conn_type in (SmartCardConnection, OtpConnection, FidoConnection): if dev.supports_connection(conn_type): try: conn = dev.open_connection(conn_type) ctx.call_on_close(conn.close) ctx.obj["controller"] = ManagementSession(conn) return except Exception: logger.warning( f"Failed connecting to the YubiKey over {conn_type}", exc_info=True ) raise CliFail("Couldn't connect to the YubiKey.") def _require_config(ctx): info = ctx.obj["info"] if (1, 0, 0) < info.version < (5, 0, 0): raise CliFail( "Configuring applications is not supported on this YubiKey. " "Use the `mode` command to configure USB interfaces." ) @config.command("set-lock-code") @click.pass_context @click_force_option @click.option("-l", "--lock-code", metavar="HEX", help="current lock code") @click.option( "-n", "--new-lock-code", metavar="HEX", help="new lock code (can't be used with --generate)", ) @click.option("-c", "--clear", is_flag=True, help="clear the lock code") @click.option( "-g", "--generate", is_flag=True, help="generate a random lock code (can't be used with --new-lock-code)", ) def set_lock_code(ctx, lock_code, new_lock_code, clear, generate, force): """ Set or change the configuration lock code. A lock code may be used to protect the application configuration. The lock code must be a 32 characters (16 bytes) hex value. """ _require_config(ctx) info = ctx.obj["info"] app = ctx.obj["controller"] if sum(1 for arg in [new_lock_code, generate, clear] if arg) > 1: raise CliFail( "Invalid options: Only one of --new-lock-code, --generate, " "and --clear may be used." ) # Get the new lock code to set if clear: set_code = CLEAR_LOCK_CODE elif generate: set_code = os.urandom(16) click.echo(f"Using a randomly generated lock code: {set_code.hex()}") force or click.confirm( "Lock configuration with this lock code?", abort=True, err=True ) else: if not new_lock_code: new_lock_code = click_prompt( "Enter your new lock code", hide_input=True, confirmation_prompt=True ) set_code = _parse_lock_code(ctx, new_lock_code) # Get the current lock code to use if info.is_locked: if not lock_code: lock_code = click_prompt("Enter your current lock code", hide_input=True) use_code = _parse_lock_code(ctx, lock_code) else: if lock_code: raise CliFail( "No lock code is currently set. Use --new-lock-code to set one." ) use_code = None # Set new lock code try: app.write_device_config( None, False, use_code, set_code, ) logger.info("Lock code updated") except Exception: if info.is_locked: raise CliFail("Failed to change the lock code. Wrong current code?") raise CliFail("Failed to set the lock code.") def _configure_applications( ctx, config, changes, transport, enable, disable, lock_code, force, ): _require_config(ctx) info = ctx.obj["info"] supported = info.supported_capabilities.get(transport) enabled = info.config.enabled_capabilities.get(transport) if not supported: raise CliFail(f"{transport} not supported on this YubiKey.") if enable & disable: ctx.fail("Invalid options.") unsupported = ~supported & (enable | disable) if unsupported: raise CliFail( f"{unsupported.display_name} not supported over {transport} on this " "YubiKey." ) new_enabled = (enabled | enable) & ~disable if transport == TRANSPORT.USB: if sum(CAPABILITY) & new_enabled == 0: ctx.fail(f"Can not disable all applications over {transport}.") reboot = enabled.usb_interfaces != new_enabled.usb_interfaces else: reboot = False if enable: changes.append(f"Enable {enable.display_name}") if disable: changes.append(f"Disable {disable.display_name}") if reboot: changes.append("The YubiKey will reboot") is_locked = info.is_locked if force and is_locked and not lock_code: raise CliFail("Configuration is locked - please supply the --lock-code option.") if lock_code and not is_locked: raise CliFail( "Configuration is not locked - please remove the --lock-code option." ) click.echo(f"{transport} configuration changes:") for change in changes: click.echo(f" {change}") force or click.confirm("Proceed?", abort=True, err=True) if is_locked and not lock_code: lock_code = prompt_lock_code() if lock_code: lock_code = _parse_lock_code(ctx, lock_code) config.enabled_capabilities = {transport: new_enabled} app = ctx.obj["controller"] try: app.write_device_config( config, reboot, lock_code, ) logger.info(f"{transport} application configuration updated") except Exception: raise CliFail(f"Failed to configure {transport} applications.") @config.command() @click.pass_context @click_force_option @click.option( "-e", "--enable", multiple=True, type=EnumChoice(CAPABILITY), help="enable applications", ) @click.option( "-d", "--disable", multiple=True, type=EnumChoice(CAPABILITY), help="disable applications", ) @click.option( "-l", "--list", "list_enabled", is_flag=True, help="list enabled applications" ) @click.option("-a", "--enable-all", is_flag=True, help="enable all applications") @click.option( "-L", "--lock-code", metavar="HEX", help="current application configuration lock code", ) @click.option( "--touch-eject", is_flag=True, help="when set, the button toggles the state" " of the smartcard between ejected and inserted (CCID only)", ) @click.option("--no-touch-eject", is_flag=True, help="disable touch eject (CCID only)") @click.option( "--autoeject-timeout", required=False, type=int, default=None, metavar="SECONDS", help="when set, the smartcard will automatically eject" " after the given time (implies --touch-eject)", ) @click.option( "--chalresp-timeout", required=False, type=int, default=None, metavar="SECONDS", help="sets the timeout when waiting for touch for challenge-response in the OTP " "application", ) def usb( ctx, enable, disable, list_enabled, enable_all, touch_eject, no_touch_eject, autoeject_timeout, chalresp_timeout, lock_code, force, ): """ Enable or disable applications over USB. """ _require_config(ctx) if not ( list_enabled or enable_all or enable or disable or touch_eject or no_touch_eject or autoeject_timeout or chalresp_timeout ): ctx.fail("No configuration options chosen.") if touch_eject and no_touch_eject: ctx.fail("Invalid options.") if list_enabled: _list_apps(ctx, TRANSPORT.USB) config = DeviceConfig({}, autoeject_timeout, chalresp_timeout, None) changes = [] info = ctx.obj["info"] if enable_all: enable = info.supported_capabilities.get(TRANSPORT.USB) else: enable = CAPABILITY(sum(enable)) disable = CAPABILITY(sum(disable)) if touch_eject: config.device_flags = info.config.device_flags | DEVICE_FLAG.EJECT changes.append("Enable touch-eject") if no_touch_eject: config.device_flags = info.config.device_flags & ~DEVICE_FLAG.EJECT changes.append("Disable touch-eject") if autoeject_timeout: changes.append(f"Set auto-eject timeout to {autoeject_timeout}") if chalresp_timeout: changes.append(f"Set challenge-response timeout to {chalresp_timeout}") _configure_applications( ctx, config, changes, TRANSPORT.USB, enable, disable, lock_code, force, ) @config.command() @click.pass_context @click_force_option @click.option( "-e", "--enable", multiple=True, type=EnumChoice(CAPABILITY), help="enable applications", ) @click.option( "-d", "--disable", multiple=True, type=EnumChoice(CAPABILITY), help="disable applications", ) @click.option("-a", "--enable-all", is_flag=True, help="enable all applications") @click.option("-D", "--disable-all", is_flag=True, help="disable all applications") @click.option( "-l", "--list", "list_enabled", is_flag=True, help="list enabled applications" ) @click.option( "-L", "--lock-code", metavar="HEX", help="current application configuration lock code", ) def nfc(ctx, enable, disable, enable_all, disable_all, list_enabled, lock_code, force): """ Enable or disable applications over NFC. """ _require_config(ctx) if not (list_enabled or enable_all or enable or disable_all or disable): ctx.fail("No configuration options chosen.") if list_enabled: _list_apps(ctx, TRANSPORT.NFC) config = DeviceConfig({}, None, None, None) info = ctx.obj["info"] nfc_supported = info.supported_capabilities.get(TRANSPORT.NFC) if enable_all: enable = nfc_supported else: enable = CAPABILITY(sum(enable)) if disable_all: disable = nfc_supported else: disable = CAPABILITY(sum(disable)) _configure_applications( ctx, config, [], TRANSPORT.NFC, enable, disable, lock_code, force, ) def _list_apps(ctx, transport): enabled = ctx.obj["info"].config.enabled_capabilities.get(transport) if enabled is None: raise CliFail(f"{transport} not supported on this YubiKey.") for app in CAPABILITY: if app & enabled: click.echo(app.display_name) ctx.exit() def _ensure_not_invalid_options(ctx, enable, disable): if enable & disable: ctx.fail("Invalid options.") def _parse_lock_code(ctx, lock_code): try: lock_code = bytes.fromhex(lock_code) if lock_code and len(lock_code) != 16: ctx.fail("Lock code must be exactly 16 bytes (32 hexadecimal digits) long.") return lock_code except Exception: ctx.fail("Lock code has the wrong format.") # MODE def _parse_interface_string(interface): for iface in USB_INTERFACE: if (iface.name or "").startswith(interface): return iface raise ValueError() def _parse_mode_string(ctx, param, mode): try: mode_int = int(mode) return Mode.from_code(mode_int) except IndexError: ctx.fail(f"Invalid mode: {mode_int}") except ValueError: pass # Not a numeric mode, parse string try: if mode[0] in ["+", "-"]: info = ctx.obj["info"] usb_enabled = info.config.enabled_capabilities[TRANSPORT.USB] interfaces = usb_enabled.usb_interfaces for mod in re.findall(r"[+-][A-Z]+", mode.upper()): interface = _parse_interface_string(mod[1:]) if mod.startswith("+"): interfaces |= interface else: interfaces ^= interface else: interfaces = USB_INTERFACE(0) for t in re.split(r"[+]+", mode.upper()): if t: interfaces |= _parse_interface_string(t) except ValueError: ctx.fail(f"Invalid mode string: {mode}") return Mode(interfaces) @config.command() @click.argument("mode", callback=_parse_mode_string) @click.option( "--touch-eject", is_flag=True, help="when set, the button " "toggles the state of the smartcard between ejected and inserted " "(CCID only)", ) @click.option( "--autoeject-timeout", required=False, type=int, default=0, metavar="SECONDS", help="when set, the smartcard will automatically eject after the given time " "(implies --touch-eject, CCID only)", ) @click.option( "--chalresp-timeout", required=False, type=int, default=0, metavar="SECONDS", help="sets the timeout when waiting for touch for challenge response", ) @click_force_option @click.pass_context def mode(ctx, mode, touch_eject, autoeject_timeout, chalresp_timeout, force): """ Manage connection modes (USB Interfaces). This command is generaly used with YubiKeys prior to the 5 series. Use "ykman config usb" for more granular control on YubiKey 5 and later. Get the current connection mode of the YubiKey, or set it to MODE. MODE can be a string, such as "OTP+FIDO+CCID", or a shortened form: "o+f+c". It can also be a mode number. Examples: \b Set the OTP and FIDO mode: $ ykman config mode OTP+FIDO \b Set the CCID only mode and use touch to eject the smart card: $ ykman config mode CCID --touch-eject """ info = ctx.obj["info"] mgmt = ctx.obj["controller"] usb_enabled = info.config.enabled_capabilities[TRANSPORT.USB] my_mode = Mode(usb_enabled.usb_interfaces) usb_supported = info.supported_capabilities[TRANSPORT.USB] interfaces_supported = usb_supported.usb_interfaces pid = ctx.obj["pid"] if pid: key_type = pid.yubikey_type else: key_type = None if autoeject_timeout: # autoeject implies touch eject touch_eject = True autoeject = autoeject_timeout if touch_eject else None if mode.interfaces != USB_INTERFACE.CCID: if touch_eject: ctx.fail("--touch-eject can only be used when setting CCID-only mode") if not force: if mode == my_mode: raise CliFail(f"Mode is already {mode}, nothing to do...", 0) elif key_type in (YUBIKEY.YKS, YUBIKEY.YKP): raise CliFail( "Mode switching is not supported on this YubiKey!\n" "Use --force to attempt to set it anyway." ) elif mode.interfaces not in interfaces_supported: raise CliFail( f"Mode {mode} is not supported on this YubiKey!\n" + "Use --force to attempt to set it anyway." ) force or click.confirm(f"Set mode of YubiKey to {mode}?", abort=True, err=True) try: mgmt.set_mode(mode, chalresp_timeout, autoeject) logger.info("USB mode updated") click.echo( "Mode set! You must remove and re-insert your YubiKey " "for this change to take effect." ) except Exception: raise CliFail( "Failed to switch mode on the YubiKey. Make sure your " "YubiKey does not have an access code set." ) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1696438353.084531 yubikey_manager-5.2.1/ykman/_cli/fido.py0000644000000000000000000006274314507314121015152 0ustar00# 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. from fido2.ctap import CtapError from fido2.ctap1 import ApduError from fido2.ctap2 import ( Ctap2, ClientPin, CredentialManagement, FPBioEnrollment, CaptureError, ) from fido2.pcsc import CtapPcscDevice from yubikit.core.fido import FidoConnection from yubikit.core.smartcard import SW from time import sleep from .util import ( click_postpone_execution, click_prompt, click_force_option, click_group, prompt_timeout, is_yk4_fips, ) from .util import CliFail from ..fido import is_in_fips_mode, fips_reset, fips_change_pin, fips_verify_pin from ..hid import list_ctap_devices from ..pcsc import list_devices as list_ccid from smartcard.Exceptions import NoCardException, CardConnectionException from typing import Optional, Sequence, List import io import csv as _csv import click import logging logger = logging.getLogger(__name__) FIPS_PIN_MIN_LENGTH = 6 PIN_MIN_LENGTH = 4 @click_group(connections=[FidoConnection]) @click.pass_context @click_postpone_execution def fido(ctx): """ Manage the FIDO applications. Examples: \b Reset the FIDO (FIDO2 and U2F) applications: $ ykman fido reset \b Change the FIDO2 PIN from 123456 to 654321: $ ykman fido access change-pin --pin 123456 --new-pin 654321 """ dev = ctx.obj["device"] conn = dev.open_connection(FidoConnection) ctx.call_on_close(conn.close) ctx.obj["conn"] = conn try: ctx.obj["ctap2"] = Ctap2(conn) except (ValueError, CtapError): logger.info("FIDO device does not support CTAP2", exc_info=True) @fido.command() @click.pass_context def info(ctx): """ Display general status of the FIDO2 application. """ conn = ctx.obj["conn"] ctap2 = ctx.obj.get("ctap2") if is_yk4_fips(ctx.obj["info"]): click.echo("FIPS Approved Mode: " + ("Yes" if is_in_fips_mode(conn) else "No")) elif ctap2: client_pin = ClientPin(ctap2) # N.B. All YubiKeys with CTAP2 support PIN. if ctap2.info.options["clientPin"]: if ctap2.info.force_pin_change: click.echo( "NOTE: The FIDO PIN is disabled and must be changed before it can " "be used!" ) pin_retries, power_cycle = client_pin.get_pin_retries() if pin_retries: click.echo(f"PIN is set, with {pin_retries} attempt(s) remaining.") if power_cycle: click.echo( "PIN is temporarily blocked. " "Remove and re-insert the YubiKey to unblock." ) else: click.echo("PIN is set, but has been blocked.") else: click.echo("PIN is not set.") bio_enroll = ctap2.info.options.get("bioEnroll") if bio_enroll: uv_retries = client_pin.get_uv_retries() if uv_retries: click.echo( f"Fingerprints registered, with {uv_retries} attempt(s) " "remaining." ) else: click.echo( "Fingerprints registered, but blocked until PIN is verified." ) elif bio_enroll is False: click.echo("No fingerprints have been registered.") always_uv = ctap2.info.options.get("alwaysUv") if always_uv is not None: click.echo( "Always Require User Verification is turned " + ("on." if always_uv else "off.") ) else: click.echo("PIN is not supported.") @fido.command("reset") @click_force_option @click.pass_context def reset(ctx, force): """ Reset all FIDO applications. This action will wipe all FIDO credentials, including FIDO U2F credentials, on the YubiKey and remove the PIN code. The reset must be triggered immediately after the YubiKey is inserted, and requires a touch on the YubiKey. """ conn = ctx.obj["conn"] if isinstance(conn, CtapPcscDevice): # NFC readers = list_ccid(conn._name) if not readers or readers[0].reader.name != conn._name: raise CliFail("Unable to isolate NFC reader.") dev = readers[0] logger.debug(f"use: {dev}") is_fips = False def prompt_re_insert(): click.echo( "Remove and re-place your YubiKey on the NFC reader to perform the " "reset..." ) removed = False while True: sleep(0.5) try: with dev.open_connection(FidoConnection): if removed: sleep(1.0) # Wait for the device to settle break except CardConnectionException: pass # Expected, ignore except NoCardException: removed = True return dev.open_connection(FidoConnection) else: # USB n_keys = len(list_ctap_devices()) if n_keys > 1: raise CliFail("Only one YubiKey can be connected to perform a reset.") is_fips = is_yk4_fips(ctx.obj["info"]) ctap2 = ctx.obj.get("ctap2") if not is_fips and not ctap2: raise CliFail("This YubiKey does not support FIDO reset.") def prompt_re_insert(): click.echo("Remove and re-insert your YubiKey to perform the reset...") removed = False while True: sleep(0.5) keys = list_ctap_devices() if not keys: removed = True if removed and len(keys) == 1: return keys[0].open_connection(FidoConnection) if not force: click.confirm( "WARNING! This will delete all FIDO credentials, including FIDO U2F " "credentials, and restore factory settings. Proceed?", err=True, abort=True, ) if is_fips: destroy_input = click_prompt( "WARNING! This is a YubiKey FIPS device. This command will also " "overwrite the U2F attestation key; this action cannot be undone and " "this YubiKey will no longer be a FIPS compliant device.\n" 'To proceed, please enter the text "OVERWRITE"', default="", show_default=False, ) if destroy_input != "OVERWRITE": raise CliFail("Reset aborted by user.") conn = prompt_re_insert() try: with prompt_timeout(): if is_fips: fips_reset(conn) else: Ctap2(conn).reset() logger.info("FIDO application data reset") except CtapError as e: if e.code == CtapError.ERR.ACTION_TIMEOUT: raise CliFail( "Reset failed. You need to touch your YubiKey to confirm the reset." ) elif e.code in (CtapError.ERR.NOT_ALLOWED, CtapError.ERR.PIN_AUTH_BLOCKED): raise CliFail( "Reset failed. Reset must be triggered within 5 seconds after the " "YubiKey is inserted." ) else: raise CliFail(f"Reset failed: {e.code.name}") except ApduError as e: # From fips_reset if e.code == SW.COMMAND_NOT_ALLOWED: raise CliFail( "Reset failed. Reset must be triggered within 5 seconds after the " "YubiKey is inserted." ) else: raise CliFail("Reset failed.") except Exception: raise CliFail("Reset failed.") def _fail_pin_error(ctx, e, other="%s"): if e.code == CtapError.ERR.PIN_INVALID: raise CliFail("Wrong PIN.") elif e.code == CtapError.ERR.PIN_AUTH_BLOCKED: raise CliFail( "PIN authentication is currently blocked. " "Remove and re-insert the YubiKey." ) elif e.code == CtapError.ERR.PIN_BLOCKED: raise CliFail("PIN is blocked.") else: raise CliFail(other % e.code) @fido.group("access") def access(): """ Manage the PIN for FIDO. """ @access.command("change-pin") @click.pass_context @click.option("-P", "--pin", help="current PIN code") @click.option("-n", "--new-pin", help="a new PIN") @click.option( "-u", "--u2f", is_flag=True, help="set FIDO U2F PIN instead of FIDO2 PIN (YubiKey 4 FIPS only)", ) def change_pin(ctx, pin, new_pin, u2f): """ Set or change the PIN code. The FIDO2 PIN must be at least 4 characters long, and supports any type of alphanumeric characters. On YubiKey FIPS, a PIN can be set for FIDO U2F. That PIN must be at least 6 characters long. """ is_fips = is_yk4_fips(ctx.obj["info"]) if is_fips and not u2f: raise CliFail( "This is a YubiKey FIPS. To set the U2F PIN, pass the --u2f option." ) if u2f and not is_fips: raise CliFail( "This is not a YubiKey 4 FIPS, and therefore does not support a U2F PIN. " "To set the FIDO2 PIN, remove the --u2f option." ) if is_fips: conn = ctx.obj["conn"] else: ctap2 = ctx.obj.get("ctap2") if not ctap2: raise CliFail("PIN is not supported on this YubiKey.") client_pin = ClientPin(ctap2) def prompt_new_pin(): return click_prompt( "Enter your new PIN", hide_input=True, confirmation_prompt=True, ) def change_pin(pin, new_pin): if pin is not None: _fail_if_not_valid_pin(ctx, pin, is_fips) try: if is_fips: try: # Failing this with empty current PIN does not cost a retry fips_change_pin(conn, pin or "", new_pin) except ApduError as e: if e.code == SW.WRONG_LENGTH: pin = _prompt_current_pin() _fail_if_not_valid_pin(ctx, pin, is_fips) fips_change_pin(conn, pin, new_pin) else: raise else: client_pin.change_pin(pin, new_pin) except CtapError as e: if e.code == CtapError.ERR.PIN_POLICY_VIOLATION: raise CliFail("New PIN doesn't meet policy requirements.") else: _fail_pin_error(ctx, e, "Failed to change PIN: %s") except ApduError as e: if e.code == SW.VERIFY_FAIL_NO_RETRY: raise CliFail("Wrong PIN.") elif e.code == SW.AUTH_METHOD_BLOCKED: raise CliFail("PIN is blocked.") else: raise CliFail(f"Failed to change PIN: SW={e.code:04x}") def set_pin(new_pin): _fail_if_not_valid_pin(ctx, new_pin, is_fips) try: client_pin.set_pin(new_pin) except CtapError as e: if e.code == CtapError.ERR.PIN_POLICY_VIOLATION: raise CliFail("New PIN doesn't meet policy requirements.") else: raise CliFail(f"Failed to set PIN: {e.code}") if not is_fips: if ctap2.info.options.get("clientPin"): if not pin: pin = _prompt_current_pin() else: if pin: raise CliFail("There is no current PIN set. Use --new-pin to set one.") if not new_pin: new_pin = prompt_new_pin() if is_fips: _fail_if_not_valid_pin(ctx, new_pin, is_fips) change_pin(pin, new_pin) else: min_len = ctap2.info.min_pin_length if len(new_pin) < min_len: raise CliFail("New PIN is too short. Minimum length: {min_len}") if ctap2.info.options.get("clientPin"): change_pin(pin, new_pin) else: set_pin(new_pin) logger.info("FIDO PIN updated") def _require_pin(ctx, pin, feature="This feature"): ctap2 = ctx.obj.get("ctap2") if not ctap2: raise CliFail(f"{feature} is not supported on this YubiKey.") if not ctap2.info.options.get("clientPin"): raise CliFail(f"{feature} requires having a PIN. Set a PIN first.") if ctap2.info.force_pin_change: raise CliFail("The FIDO PIN is blocked. Change the PIN first.") if pin is None: pin = _prompt_current_pin(prompt="Enter your PIN") return pin @access.command("verify-pin") @click.pass_context @click.option("-P", "--pin", help="current PIN code") def verify(ctx, pin): """ Verify the FIDO PIN against a YubiKey. For YubiKeys supporting FIDO2 this will reset the "retries" counter of the PIN. For YubiKey FIPS this will unlock the session, allowing U2F registration. """ ctap2 = ctx.obj.get("ctap2") if ctap2: pin = _require_pin(ctx, pin) client_pin = ClientPin(ctap2) try: # Get a PIN token to verify the PIN. client_pin.get_pin_token( pin, ClientPin.PERMISSION.GET_ASSERTION, "ykman.example.com" ) except CtapError as e: raise CliFail(f"PIN verification failed: {e}") elif is_yk4_fips(ctx.obj["info"]): _fail_if_not_valid_pin(ctx, pin, True) try: fips_verify_pin(ctx.obj["conn"], pin) except ApduError as e: if e.code == SW.VERIFY_FAIL_NO_RETRY: raise CliFail("Wrong PIN.") elif e.code == SW.AUTH_METHOD_BLOCKED: raise CliFail("PIN is blocked.") elif e.code == SW.COMMAND_NOT_ALLOWED: raise CliFail("PIN is not set.") else: raise CliFail(f"PIN verification failed: {e.code.name}") else: raise CliFail("This YubiKey does not support a FIDO PIN.") click.echo("PIN verified.") def _prompt_current_pin(prompt="Enter your current PIN"): return click_prompt(prompt, hide_input=True) def _fail_if_not_valid_pin(ctx, pin=None, is_fips=False): min_length = FIPS_PIN_MIN_LENGTH if is_fips else PIN_MIN_LENGTH if not pin or len(pin) < min_length: ctx.fail(f"PIN must be over {min_length} characters long") def _gen_creds(credman): data = credman.get_metadata() if data.get(CredentialManagement.RESULT.EXISTING_CRED_COUNT) == 0: return # No credentials for rp in credman.enumerate_rps(): for cred in credman.enumerate_creds(rp[CredentialManagement.RESULT.RP_ID_HASH]): yield ( rp[CredentialManagement.RESULT.RP]["id"], cred[CredentialManagement.RESULT.CREDENTIAL_ID], cred[CredentialManagement.RESULT.USER]["id"], cred[CredentialManagement.RESULT.USER].get("name", ""), cred[CredentialManagement.RESULT.USER].get("displayName", ""), ) def _format_table(headings: Sequence[str], rows: List[Sequence[str]]) -> str: all_rows = [headings] + rows padded_rows = [["" for cell in row] for row in all_rows] max_cols = max(len(row) for row in all_rows) for c in range(max_cols): max_width = max(len(row[c]) for row in all_rows if len(row) > c) for r in range(len(all_rows)): if c < len(all_rows[r]): padded_rows[r][c] = all_rows[r][c] + ( " " * (max_width - len(all_rows[r][c])) ) return "\n".join(" ".join(row) for row in padded_rows) def _format_cred(rp_id, user_id, user_name): return f"{rp_id} {user_id.hex()} {user_name}" @fido.group("credentials") def creds(): """ Manage discoverable (resident) credentials. This command lets you manage credentials stored on your YubiKey. Credential management is only available when a FIDO PIN is set on the YubiKey. \b Examples: \b List credentials (providing PIN via argument): $ ykman fido credentials list --pin 123456 \b Delete a credential (ID shown in "list" output, PIN will be prompted for): $ ykman fido credentials delete da7fdc """ def _init_credman(ctx, pin): pin = _require_pin(ctx, pin, "Credential Management") ctap2 = ctx.obj.get("ctap2") client_pin = ClientPin(ctap2) try: token = client_pin.get_pin_token(pin, ClientPin.PERMISSION.CREDENTIAL_MGMT) except CtapError as e: _fail_pin_error(ctx, e, "PIN error: %s") return CredentialManagement(ctap2, client_pin.protocol, token) @creds.command("list") @click.pass_context @click.option("-P", "--pin", help="PIN code") @click.option( "-c", "--csv", is_flag=True, help="output full credential information as CSV", ) def creds_list(ctx, pin, csv): """ List credentials. Shows a list of credentials stored on the YubiKey. The --csv flag will output more complete information about each credential, formatted as a CSV (comma separated values). """ credman = _init_credman(ctx, pin) creds = list(_gen_creds(credman)) if csv: buf = io.StringIO() writer = _csv.writer(buf) writer.writerow( ["credential_id", "rp_id", "user_name", "user_display_name", "user_id"] ) writer.writerows( [cred_id["id"].hex(), rp_id, user_name, display_name, user_id.hex()] for rp_id, cred_id, user_id, user_name, display_name in creds ) click.echo(buf.getvalue()) else: ln = 4 while len(set(c[1]["id"][:ln] for c in creds)) < len(creds): ln += 1 click.echo( _format_table( ["Credential ID", "RP ID", "Username", "Display name"], [ (cred_id["id"][:ln].hex() + "...", rp_id, user_name, display_name) for rp_id, cred_id, _, user_name, display_name in creds ], ) ) @creds.command("delete") @click.pass_context @click.argument("credential_id") @click.option("-P", "--pin", help="PIN code") @click.option("-f", "--force", is_flag=True, help="confirm deletion without prompting") def creds_delete(ctx, credential_id, pin, force): """ Delete a credential. List stored credential IDs using the "list" subcommand. \b CREDENTIAL_ID a unique substring match of a Credential ID """ credman = _init_credman(ctx, pin) credential_id = credential_id.rstrip(".").lower() hits = [ (rp_id, cred_id, user_name, display_name) for (rp_id, cred_id, _, user_name, display_name) in _gen_creds(credman) if cred_id["id"].hex().startswith(credential_id) ] if len(hits) == 0: raise CliFail("No matches, nothing to be done.") elif len(hits) == 1: (rp_id, cred_id, user_name, display_name) = hits[0] if force or click.confirm( f"Delete {rp_id} {user_name} {display_name} ({cred_id['id'].hex()})?" ): try: credman.delete_cred(cred_id) logger.info("Credential deleted") except CtapError: raise CliFail("Failed to delete credential.") else: raise CliFail("Multiple matches, make the credential ID more specific.") @fido.group("fingerprints") def bio(): """ Manage fingerprints. Requires a YubiKey with fingerprint sensor. Fingerprint management is only available when a FIDO PIN is set on the YubiKey. \b Examples: \b Register a new fingerprint (providing PIN via argument): $ ykman fido fingerprints add "Left thumb" --pin 123456 \b List already stored fingerprints (providing PIN via argument): $ ykman fido fingerprints list --pin 123456 \b Delete a stored fingerprint with ID "f691" (PIN will be prompted for): $ ykman fido fingerprints delete f691 """ def _init_bio(ctx, pin): ctap2 = ctx.obj.get("ctap2") if not ctap2 or "bioEnroll" not in ctap2.info.options: raise CliFail("Biometrics is not supported on this YubiKey.") pin = _require_pin(ctx, pin, "Biometrics") client_pin = ClientPin(ctap2) try: token = client_pin.get_pin_token(pin, ClientPin.PERMISSION.BIO_ENROLL) except CtapError as e: _fail_pin_error(ctx, e, "PIN error: %s") return FPBioEnrollment(ctap2, client_pin.protocol, token) def _format_fp(template_id, name): return f"{template_id.hex()}{f' ({name})' if name else ''}" @bio.command("list") @click.pass_context @click.option("-P", "--pin", help="PIN code") def bio_list(ctx, pin): """ List registered fingerprints. Lists fingerprints by ID and (if available) label. """ bio = _init_bio(ctx, pin) for t_id, name in bio.enumerate_enrollments().items(): click.echo(f"ID: {_format_fp(t_id, name)}") @bio.command("add") @click.pass_context @click.argument("name") @click.option("-P", "--pin", help="PIN code") def bio_enroll(ctx, name, pin): """ Add a new fingerprint. \b NAME a short readable name for the fingerprint (eg. "Left thumb") """ if len(name.encode()) > 15: ctx.fail("Fingerprint name must be a maximum of 15 characters") bio = _init_bio(ctx, pin) enroller = bio.enroll() template_id = None while template_id is None: click.echo("Place your finger against the sensor now...") try: template_id = enroller.capture() remaining = enroller.remaining if remaining: click.echo(f"{remaining} more scans needed.") except CaptureError as e: logger.debug(f"Capture error: {e.code}") click.echo("Capture failed. Re-center your finger, and try again.") except CtapError as e: if e.code == CtapError.ERR.FP_DATABASE_FULL: raise CliFail( "Fingerprint storage full. " "Remove some fingerprints before adding new ones." ) elif e.code == CtapError.ERR.USER_ACTION_TIMEOUT: raise CliFail("Failed to add fingerprint due to user inactivity.") raise CliFail(f"Failed to add fingerprint: {e.code.name}") logger.info("Fingerprint template registered") click.echo("Capture complete.") bio.set_name(template_id, name) logger.info("Fingerprint template name set") @bio.command("rename") @click.pass_context @click.argument("template_id", metavar="ID") @click.argument("name") @click.option("-P", "--pin", help="PIN code") def bio_rename(ctx, template_id, name, pin): """ Set the label for a fingerprint. \b ID the ID of the fingerprint to rename (as shown in "list") NAME a short readable name for the fingerprint (eg. "Left thumb") """ if len(name.encode()) >= 16: ctx.fail("Fingerprint name must be a maximum of 15 bytes") bio = _init_bio(ctx, pin) enrollments = bio.enumerate_enrollments() key = bytes.fromhex(template_id) if key not in enrollments: raise CliFail(f"No fingerprint matching ID={template_id}.") bio.set_name(key, name) logger.info("Fingerprint template renamed") @bio.command("delete") @click.pass_context @click.argument("template_id", metavar="ID") @click.option("-P", "--pin", help="PIN code") @click.option("-f", "--force", is_flag=True, help="confirm deletion without prompting") def bio_delete(ctx, template_id, pin, force): """ Delete a fingerprint. Delete a fingerprint from the YubiKey by its ID, which can be seen by running the "list" subcommand. """ bio = _init_bio(ctx, pin) enrollments = bio.enumerate_enrollments() try: key: Optional[bytes] = bytes.fromhex(template_id) except ValueError: key = None if key not in enrollments: # Match using template_id as NAME matches = [k for k in enrollments if enrollments[k] == template_id] if len(matches) == 0: raise CliFail(f"No fingerprint matching ID={template_id}") elif len(matches) > 1: raise CliFail( f"Multiple matches for NAME={template_id}. " "Delete by template ID instead." ) key = matches[0] name = enrollments[key] if force or click.confirm(f"Delete fingerprint {_format_fp(key, name)}?"): try: bio.remove_enrollment(key) logger.info("Fingerprint template deleted") except CtapError as e: raise CliFail(f"Failed to delete fingerprint: {e.code.name}") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1696967723.9437795 yubikey_manager-5.2.1/ykman/_cli/hsmauth.py0000644000000000000000000004423514511326054015701 0ustar00# 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.core.smartcard import SmartCardConnection from yubikit.hsmauth import ( HsmAuthSession, InvalidPinError, ALGORITHM, MANAGEMENT_KEY_LEN, DEFAULT_MANAGEMENT_KEY, ) from yubikit.core.smartcard import ApduError, SW from ..util import parse_private_key, InvalidPasswordError from ..hsmauth import ( get_hsmauth_info, generate_random_management_key, ) from .util import ( CliFail, click_force_option, click_postpone_execution, click_callback, click_format_option, click_prompt, click_group, pretty_print, ) from cryptography.hazmat.primitives import serialization import click import os import logging logger = logging.getLogger(__name__) def handle_credential_error(e: Exception, default_exception_msg): if isinstance(e, InvalidPinError): attempts = e.attempts_remaining if attempts: raise CliFail(f"Wrong management key, {attempts} attempts remaining.") else: raise CliFail("Management key is blocked.") elif isinstance(e, ApduError): if e.sw == SW.AUTH_METHOD_BLOCKED: raise CliFail("A credential with the provided label already exists.") elif e.sw == SW.NO_SPACE: raise CliFail("No space left on the YubiKey for YubiHSM Auth credentials.") elif e.sw == SW.FILE_NOT_FOUND: raise CliFail("Credential with the provided label was not found.") elif e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED: raise CliFail("The device was not touched.") raise CliFail(default_exception_msg) def _parse_touch_required(touch_required: bool) -> str: if touch_required: return "On" else: return "Off" def _parse_algorithm(algorithm: ALGORITHM) -> str: if algorithm == ALGORITHM.AES128_YUBICO_AUTHENTICATION: return "Symmetric" else: return "Asymmetric" def _parse_key(key, key_len, key_type): try: key = bytes.fromhex(key) except Exception: ValueError(key) if len(key) != key_len: raise ValueError( f"{key_type} must be exactly {key_len} bytes long " f"({key_len * 2} hexadecimal digits) long" ) return key def _parse_hex(hex): try: val = bytes.fromhex(hex) return val except Exception: raise ValueError(hex) @click_callback() def click_parse_management_key(ctx, param, val): return _parse_key(val, MANAGEMENT_KEY_LEN, "Management key") @click_callback() def click_parse_enc_key(ctx, param, val): return _parse_key(val, ALGORITHM.AES128_YUBICO_AUTHENTICATION.key_len, "ENC key") @click_callback() def click_parse_mac_key(ctx, param, val): return _parse_key(val, ALGORITHM.AES128_YUBICO_AUTHENTICATION.key_len, "MAC key") @click_callback() def click_parse_card_crypto(ctx, param, val): return _parse_hex(val) @click_callback() def click_parse_context(ctx, param, val): return _parse_hex(val) def _prompt_management_key(prompt="Enter a management key [blank to use default key]"): management_key = click_prompt( prompt, default="", hide_input=True, show_default=False ) if management_key == "": return DEFAULT_MANAGEMENT_KEY return _parse_key(management_key, MANAGEMENT_KEY_LEN, "Management key") def _prompt_credential_password(prompt="Enter credential password"): credential_password = click_prompt( prompt, default="", hide_input=True, show_default=False ) return credential_password def _prompt_symmetric_key(type): symmetric_key = click_prompt(f"Enter {type}", default="", show_default=False) return _parse_key( symmetric_key, ALGORITHM.AES128_YUBICO_AUTHENTICATION.key_len, "ENC key" ) def _fname(fobj): return getattr(fobj, "name", fobj) click_credential_password_option = click.option( "-c", "--credential-password", help="password to protect credential" ) click_management_key_option = click.option( "-m", "--management-key", help="the management key", callback=click_parse_management_key, ) click_touch_option = click.option( "-t", "--touch", is_flag=True, help="require touch on YubiKey to access credential" ) @click_group(connections=[SmartCardConnection]) @click.pass_context @click_postpone_execution def hsmauth(ctx): """ Manage the YubiHSM Auth application """ dev = ctx.obj["device"] conn = dev.open_connection(SmartCardConnection) ctx.call_on_close(conn.close) ctx.obj["session"] = HsmAuthSession(conn) @hsmauth.command() @click.pass_context def info(ctx): """ Display general status of the YubiHSM Auth application. """ info = get_hsmauth_info(ctx.obj["session"]) click.echo("\n".join(pretty_print(info))) @hsmauth.command() @click.pass_context @click_force_option def reset(ctx, force): """ Reset all YubiHSM Auth data. This action will wipe all data and restore factory setting for the YubiHSM Auth application on the YubiKey. """ force or click.confirm( "WARNING! This will delete all stored YubiHSM Auth data and restore factory " "setting. Proceed?", abort=True, err=True, ) click.echo("Resetting YubiHSM Auth data...") ctx.obj["session"].reset() click.echo("Success! All YubiHSM Auth data have been cleared from the YubiKey.") click.echo( "Your YubiKey now has the default Management Key" f"({DEFAULT_MANAGEMENT_KEY.hex()})." ) @hsmauth.group() def credentials(): """Manage YubiHSM Auth credentials.""" @credentials.command() @click.pass_context def list(ctx): """ List all credentials. List all credentials stored on the YubiKey. """ session = ctx.obj["session"] creds = session.list_credentials() if len(creds) == 0: click.echo("No items found") else: click.echo(f"Found {len(creds)} item(s)") max_size_label = max(len(cred.label) for cred in creds) max_size_type = ( 10 if any( c.algorithm == ALGORITHM.EC_P256_YUBICO_AUTHENTICATION for c in creds ) else 9 ) format_str = "{0: <{label_width}}\t{1: <{type_width}}\t{2}\t{3}" click.echo( format_str.format( "Label", "Type", "Touch", "Retries", label_width=max_size_label, type_width=max_size_type, ) ) for cred in creds: click.echo( format_str.format( cred.label, _parse_algorithm(cred.algorithm), _parse_touch_required(cred.touch_required), cred.counter, label_width=max_size_label, type_width=max_size_type, ) ) @credentials.command() @click.pass_context @click.argument("label") @click_credential_password_option @click_management_key_option @click_touch_option def generate(ctx, label, credential_password, management_key, touch): """Generate an asymmetric credential. This will generate an asymmetric YubiHSM Auth credential (private key) on the YubiKey. \b LABEL label for the YubiHSM Auth credential """ if not credential_password: credential_password = _prompt_credential_password() if not management_key: management_key = _prompt_management_key() session = ctx.obj["session"] try: session.generate_credential_asymmetric( management_key, label, credential_password, touch ) except Exception as e: handle_credential_error( e, default_exception_msg="Failed to generate asymmetric credential." ) @credentials.command("import") @click.pass_context @click.argument("label") @click.argument("private-key", type=click.File("rb"), metavar="PRIVATE-KEY") @click.option("-p", "--password", help="password used to decrypt the private key") @click_credential_password_option @click_management_key_option @click_touch_option def import_credential( ctx, label, private_key, password, credential_password, management_key, touch ): """Import an asymmetric credential. This will import a private key as an asymmetric YubiHSM Auth credential to the YubiKey. \b LABEL label for the YubiHSM Auth credential PRIVATE-KEY file containing the private key (use '-' to use stdin) """ if not credential_password: credential_password = _prompt_credential_password() if not management_key: management_key = _prompt_management_key() session = ctx.obj["session"] data = private_key.read() while True: if password is not None: password = password.encode() try: private_key = parse_private_key(data, password) except InvalidPasswordError: logger.debug("Error parsing key", exc_info=True) if password is None: password = click_prompt( "Enter password to decrypt key", default="", hide_input=True, show_default=False, ) continue else: password = None click.echo("Wrong password.") continue break try: session.put_credential_asymmetric( management_key, label, private_key, credential_password, touch, ) except Exception as e: handle_credential_error( e, default_exception_msg="Failed to import asymmetric credential." ) @credentials.command() @click.pass_context @click.argument("label") @click.argument("public-key-output", type=click.File("wb"), metavar="PUBLIC-KEY") @click_format_option def export(ctx, label, public_key_output, format): """Export the public key corresponding to an asymmetric credential. This will export the long-term public key corresponding to the asymmetric YubiHSM Auth credential stored on the YubiKey. \b LABEL label for the YubiHSM Auth credential PUBLIC-KEY file to write the public key to (use '-' to use stdout) """ session = ctx.obj["session"] try: public_key = session.get_public_key(label) key_encoding = format public_key_encoded = public_key.public_bytes( encoding=key_encoding, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) public_key_output.write(public_key_encoded) logger.info(f"Public key for {label} written to {_fname(public_key_output)}") except ApduError as e: if e.sw == SW.AUTH_METHOD_BLOCKED: raise CliFail("The entry is not an asymmetric credential.") elif e.sw == SW.FILE_NOT_FOUND: raise CliFail("Credential not found.") else: raise CliFail("Unable to export public key.") @credentials.command() @click.pass_context @click.argument("label") @click.option("-E", "--enc-key", help="the ENC key", callback=click_parse_enc_key) @click.option("-M", "--mac-key", help="the MAC key", callback=click_parse_mac_key) @click.option( "-g", "--generate", is_flag=True, help="generate a random encryption and mac key" ) @click_credential_password_option @click_management_key_option @click_touch_option def symmetric( ctx, label, credential_password, management_key, enc_key, mac_key, generate, touch ): """Import a symmetric credential. This will import an encryption and mac key as a symmetric YubiHSM Auth credential on the YubiKey. \b LABEL label for the YubiHSM Auth credential """ if not credential_password: credential_password = _prompt_credential_password() if not management_key: management_key = _prompt_management_key() if generate and (enc_key or mac_key): ctx.fail("--enc-key and --mac-key cannot be combined with --generate") if generate: enc_key = os.urandom(ALGORITHM.AES128_YUBICO_AUTHENTICATION.key_len) mac_key = os.urandom(ALGORITHM.AES128_YUBICO_AUTHENTICATION.key_len) click.echo("Generated ENC and MAC keys:") click.echo("\n".join(pretty_print({"ENC-KEY": enc_key, "MAC-KEY": mac_key}))) if not enc_key: enc_key = _prompt_symmetric_key("ENC key") if not mac_key: mac_key = _prompt_symmetric_key("MAC key") session = ctx.obj["session"] try: session.put_credential_symmetric( management_key, label, enc_key, mac_key, credential_password, touch, ) except Exception as e: handle_credential_error( e, default_exception_msg="Failed to import symmetric credential." ) @credentials.command() @click.pass_context @click.argument("label") @click.option( "-d", "--derivation-password", help="deriviation password for ENC and MAC keys" ) @click_credential_password_option @click_management_key_option @click_touch_option def derive(ctx, label, derivation_password, credential_password, management_key, touch): """Import a symmetric credential derived from a password. This will import a symmetric YubiHSM Auth credential by deriving ENC and MAC keys from a password. \b LABEL label for the YubiHSM Auth credential """ if not credential_password: credential_password = _prompt_credential_password() if not management_key: management_key = _prompt_management_key() if not derivation_password: derivation_password = click_prompt( "Enter derivation password", default="", show_default=False ) session = ctx.obj["session"] try: session.put_credential_derived( management_key, label, derivation_password, credential_password, touch ) except Exception as e: handle_credential_error( e, default_exception_msg="Failed to import symmetric credential." ) @credentials.command() @click.pass_context @click.argument("label") @click_management_key_option @click_force_option def delete(ctx, label, management_key, force): """ Delete a credential. This will delete a YubiHSM Auth credential from the YubiKey. \b LABEL a label to match a single credential (as shown in "list") """ if not management_key: management_key = _prompt_management_key() force or click.confirm( f"Delete credential: {label} ?", abort=True, err=True, ) session = ctx.obj["session"] try: session.delete_credential(management_key, label) except Exception as e: handle_credential_error( e, default_exception_msg="Failed to delete credential.", ) @hsmauth.group() def access(): """Manage Management Key for YubiHSM Auth""" @access.command() @click.pass_context @click.option( "-m", "--management-key", help="current management key", default=DEFAULT_MANAGEMENT_KEY, show_default=True, callback=click_parse_management_key, ) @click.option( "-n", "--new-management-key", help="a new management key to set", callback=click_parse_management_key, ) @click.option( "-g", "--generate", is_flag=True, help="generate a random management key " "(can't be used with --new-management-key)", ) def change_management_key(ctx, management_key, new_management_key, generate): """ Change the management key. Allows you to change the management key which is required to add and delete YubiHSM Auth credentials stored on the YubiKey. """ if not management_key: management_key = _prompt_management_key( "Enter current management key [blank to use default key]" ) session = ctx.obj["session"] # Can't combine new key with generate. if new_management_key and generate: ctx.fail("Invalid options: --new-management-key conflicts with --generate") if not new_management_key: if generate: new_management_key = generate_random_management_key() click.echo(f"Generated management key: {new_management_key.hex()}") else: try: new_management_key = bytes.fromhex( click_prompt( "Enter the new management key", hide_input=True, confirmation_prompt=True, ) ) except Exception: ctx.fail("New management key has the wrong format.") if len(new_management_key) != MANAGEMENT_KEY_LEN: raise CliFail( "Management key has the wrong length (expected %d bytes)" % MANAGEMENT_KEY_LEN ) try: session.put_management_key(management_key, new_management_key) except Exception as e: handle_credential_error( e, default_exception_msg="Failed to change management key." ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.8990352 yubikey_manager-5.2.1/ykman/_cli/info.py0000644000000000000000000001600514407612164015161 0ustar00# Copyright (c) 2016 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 from yubikit.core.otp import OtpConnection from yubikit.core.fido import FidoConnection from yubikit.core.smartcard import SmartCardConnection from yubikit.management import CAPABILITY, USB_INTERFACE from yubikit.yubiotp import YubiOtpSession from yubikit.oath import OathSession from yubikit.support import get_name from .util import CliFail, is_yk4_fips, click_command from ..otp import is_in_fips_mode as otp_in_fips_mode from ..oath import is_in_fips_mode as oath_in_fips_mode from ..fido import is_in_fips_mode as ctap_in_fips_mode from typing import List import click import logging logger = logging.getLogger(__name__) def print_app_status_table(supported_apps, enabled_apps): usb_supported = supported_apps.get(TRANSPORT.USB, 0) usb_enabled = enabled_apps.get(TRANSPORT.USB, 0) nfc_supported = supported_apps.get(TRANSPORT.NFC, 0) nfc_enabled = enabled_apps.get(TRANSPORT.NFC, 0) rows = [] for app in CAPABILITY: if app & usb_supported: if app & usb_enabled: usb_status = "Enabled" else: usb_status = "Disabled" else: usb_status = "Not available" if nfc_supported: if app & nfc_supported: if app & nfc_enabled: nfc_status = "Enabled" else: nfc_status = "Disabled" else: nfc_status = "Not available" rows.append([app.display_name, usb_status, nfc_status]) else: rows.append([app.display_name, usb_status]) column_l: List[int] = [] for row in rows: for idx, c in enumerate(row): if len(column_l) > idx: if len(c) > column_l[idx]: column_l[idx] = len(c) else: column_l.append(len(c)) f_apps = "Applications".ljust(column_l[0]) if nfc_supported: f_USB = "USB".ljust(column_l[1]) f_NFC = "NFC".ljust(column_l[2]) f_table = "" for row in rows: for idx, c in enumerate(row): f_table += f"{c.ljust(column_l[idx])}\t" f_table = f_table.strip() + "\n" if nfc_supported: click.echo(f"{f_apps}\t{f_USB}\t{f_NFC}") else: click.echo(f"{f_apps}") click.echo(f_table, nl=False) def get_overall_fips_status(device, info): statuses = {} usb_enabled = info.config.enabled_capabilities[TRANSPORT.USB] statuses["OTP"] = False if usb_enabled & CAPABILITY.OTP: with device.open_connection(OtpConnection) as conn: otp_app = YubiOtpSession(conn) statuses["OTP"] = otp_in_fips_mode(otp_app) statuses["OATH"] = False if usb_enabled & CAPABILITY.OATH: with device.open_connection(SmartCardConnection) as conn: oath_app = OathSession(conn) statuses["OATH"] = oath_in_fips_mode(oath_app) statuses["FIDO U2F"] = False if usb_enabled & CAPABILITY.U2F: with device.open_connection(FidoConnection) as conn: statuses["FIDO U2F"] = ctap_in_fips_mode(conn) return statuses def _check_fips_status(device, info): fips_status = get_overall_fips_status(device, info) click.echo() click.echo(f"FIPS Approved Mode: {'Yes' if all(fips_status.values()) else 'No'}") status_keys = list(fips_status.keys()) status_keys.sort() for status_key in status_keys: click.echo(f" {status_key}: {'Yes' if fips_status[status_key] else 'No'}") @click.option( "-c", "--check-fips", help="check if YubiKey is in FIPS Approved mode (YubiKey 4 FIPS only)", is_flag=True, ) @click_command(connections=[SmartCardConnection, OtpConnection, FidoConnection]) @click.pass_context def info(ctx, check_fips): """ Show general information. Displays information about the attached YubiKey such as serial number, firmware version, capabilities, etc. """ info = ctx.obj["info"] pid = ctx.obj["pid"] if pid is None: interfaces = None key_type = None else: interfaces = pid.usb_interfaces key_type = pid.yubikey_type device_name = get_name(info, key_type) click.echo(f"Device type: {device_name}") if info.serial: click.echo(f"Serial number: {info.serial}") if info.version: f_version = ".".join(str(x) for x in info.version) click.echo(f"Firmware version: {f_version}") else: click.echo( "Firmware version: Uncertain, re-run with only one YubiKey connected" ) if info.form_factor: click.echo(f"Form factor: {info.form_factor!s}") if interfaces: f_interfaces = ", ".join( t.name or str(t) for t in USB_INTERFACE if t in USB_INTERFACE(interfaces) ) click.echo(f"Enabled USB interfaces: {f_interfaces}") if TRANSPORT.NFC in info.supported_capabilities: f_nfc = ( "enabled" if info.config.enabled_capabilities.get(TRANSPORT.NFC) else "disabled" ) click.echo(f"NFC transport is {f_nfc}.") if info.is_locked: click.echo("Configured capabilities are protected by a lock code.") click.echo() print_app_status_table( info.supported_capabilities, info.config.enabled_capabilities ) if check_fips: if is_yk4_fips(info): device = ctx.obj["device"] _check_fips_status(device, info) else: raise CliFail("Unable to check FIPS Approved mode - Not a YubiKey 4 FIPS") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1696967723.9448662 yubikey_manager-5.2.1/ykman/_cli/oath.py0000644000000000000000000005443014511326054015161 0ustar00# 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. import click import logging from .util import ( CliFail, click_force_option, click_postpone_execution, click_callback, click_parse_b32_key, click_prompt, click_group, prompt_for_touch, prompt_timeout, EnumChoice, is_yk4_fips, ) from yubikit.core.smartcard import ApduError, SW, SmartCardConnection from yubikit.oath import ( OathSession, CredentialData, OATH_TYPE, HASH_ALGORITHM, parse_b32_key, _format_cred_id, ) from ..oath import is_steam, calculate_steam, is_hidden, delete_broken_credential from ..settings import AppData logger = logging.getLogger(__name__) @click_group(connections=[SmartCardConnection]) @click.pass_context @click_postpone_execution def oath(ctx): """ Manage the OATH application. Examples: \b Generate codes for accounts starting with 'yubi': $ ykman oath accounts code yubi \b Add an account with the secret key f5up4ub3dw and the name yubico, which requires touch: $ ykman oath accounts add yubico f5up4ub3dw --touch \b Set a password for the OATH application: $ ykman oath access change-password """ dev = ctx.obj["device"] conn = dev.open_connection(SmartCardConnection) ctx.call_on_close(conn.close) ctx.obj["session"] = OathSession(conn) ctx.obj["oath_keys"] = AppData("oath_keys") @oath.command() @click.pass_context def info(ctx): """ Display general status of the OATH application. """ session = ctx.obj["session"] version = session.version click.echo(f"OATH version: {version[0]}.{version[1]}.{version[2]}") click.echo("Password protection: " + ("enabled" if session.locked else "disabled")) keys = ctx.obj["oath_keys"] if session.locked and session.device_id in keys: click.echo("The password for this YubiKey is remembered by ykman.") if is_yk4_fips(ctx.obj["info"]): click.echo(f"FIPS Approved Mode: {'Yes' if session.locked else 'No'}") @oath.command() @click.pass_context @click_force_option def reset(ctx, force): """ Reset all OATH data. This action will delete all accounts and restore factory settings for the OATH application on the YubiKey. """ force or click.confirm( "WARNING! This will delete all stored OATH accounts and restore factory " "settings. Proceed?", abort=True, err=True, ) session = ctx.obj["session"] click.echo("Resetting OATH data...") old_id = session.device_id session.reset() keys = ctx.obj["oath_keys"] if old_id in keys: del keys[old_id] keys.write() logger.info("Deleted remembered access key") click.echo("Success! All OATH accounts have been deleted from the YubiKey.") click_password_option = click.option( "-p", "--password", help="the password to unlock the YubiKey" ) click_remember_option = click.option( "-r", "--remember", is_flag=True, help="remember the password on this machine", ) def _validate(ctx, key, remember): session = ctx.obj["session"] keys = ctx.obj["oath_keys"] session.validate(key) if remember: keys.put_secret(session.device_id, key.hex()) keys.write() logger.info("Access key remembered") click.echo("Password remembered.") def _init_session(ctx, password, remember, prompt="Enter the password"): session = ctx.obj["session"] keys = ctx.obj["oath_keys"] device_id = session.device_id if session.locked: try: # Use password, if given as argument if password: logger.debug("Access key required, using provided password") key = session.derive_key(password) _validate(ctx, key, remember) return # Use stored key, if available if device_id in keys: logger.debug("Access key required, using remembered key") try: key = bytes.fromhex(keys.get_secret(device_id)) _validate(ctx, key, False) return except ApduError as e: # Delete wrong key and fall through to prompt if e.sw == SW.INCORRECT_PARAMETERS: logger.debug("Remembered key incorrect, deleting key") del keys[device_id] keys.write() except Exception as e: # Other error, fall though to prompt logger.warning("Error authenticating", exc_info=e) # Prompt for password password = click_prompt(prompt, hide_input=True) key = session.derive_key(password) _validate(ctx, key, remember) except ApduError: raise CliFail("Authentication to the YubiKey failed. Wrong password?") elif password: raise CliFail("Password provided, but no password is set.") @oath.group() def access(): """Manage password protection for OATH.""" @access.command() @click.pass_context @click_password_option @click.option( "-c", "--clear", is_flag=True, help="remove the current password", ) @click.option("-n", "--new-password", help="provide a new password as an argument") @click_remember_option def change(ctx, password, clear, new_password, remember): """ Change the password used to protect OATH accounts. Allows you to set or change a password that will be required to access the OATH accounts stored on the YubiKey. """ if clear and new_password: ctx.fail("--clear cannot be combined with --new-password.") _init_session(ctx, password, False, prompt="Enter the current password") session = ctx.obj["session"] keys = ctx.obj["oath_keys"] device_id = session.device_id if clear: session.unset_key() if device_id in keys: del keys[device_id] keys.write() logger.info("Deleted remembered access key") click.echo("Password cleared from YubiKey.") else: if remember: try: keys.ensure_unlocked() except ValueError: raise CliFail( "Failed to remember password, the keyring is locked or unavailable." ) if not new_password: new_password = click_prompt( "Enter the new password", hide_input=True, confirmation_prompt=True ) key = session.derive_key(new_password) if remember: keys.put_secret(device_id, key.hex()) keys.write() click.echo("Password remembered.") elif device_id in keys: del keys[device_id] keys.write() session.set_key(key) click.echo("Password updated.") @access.command() @click.pass_context @click_password_option def remember(ctx, password): """ Store the YubiKeys password on this computer to avoid having to enter it on each use. """ session = ctx.obj["session"] device_id = session.device_id keys = ctx.obj["oath_keys"] if not session.locked: if device_id in keys: del keys[session.device_id] keys.write() logger.info("Deleted remembered access key") click.echo("This YubiKey is not password protected.") else: try: keys.ensure_unlocked() except ValueError: raise CliFail( "Failed to remember password, the keyring is locked or unavailable." ) if not password: password = click_prompt("Enter the password", hide_input=True) key = session.derive_key(password) try: _validate(ctx, key, True) except Exception: raise CliFail("Authentication to the YubiKey failed. Wrong password?") def _clear_all_passwords(ctx, param, value): if not value or ctx.resilient_parsing: return keys = AppData("oath_keys") if keys: keys.clear() keys.write() click.echo("All passwords have been forgotten.") ctx.exit() @access.command() @click.pass_context @click.option( "-a", "--all", is_flag=True, is_eager=True, expose_value=False, callback=_clear_all_passwords, help="remove all stored passwords", ) def forget(ctx): """ Remove a stored password from this computer. """ session = ctx.obj["session"] device_id = session.device_id keys = ctx.obj["oath_keys"] if device_id in keys: del keys[session.device_id] keys.write() logger.info("Deleted remembered access key") click.echo("Password forgotten.") else: click.echo("No password stored for this YubiKey.") click_touch_option = click.option( "-t", "--touch", is_flag=True, help="require touch on YubiKey to generate code" ) click_show_hidden_option = click.option( "-H", "--show-hidden", is_flag=True, help="include hidden accounts" ) def _string_id(credential): return credential.id.decode("utf-8") def _error_multiple_hits(ctx, hits): click.echo( "Error: Multiple matches, please make the query more specific.", err=True ) click.echo("", err=True) for cred in hits: click.echo(_string_id(cred), err=True) ctx.exit(1) def _search(creds, query, show_hidden): hits = [] for c in creds: cred_id = _string_id(c) if not show_hidden and is_hidden(c): continue if cred_id == query: return [c] if query.lower() in cred_id.lower(): hits.append(c) return hits @oath.group() def accounts(): """Manage and use OATH accounts.""" @accounts.command() @click.argument("name") @click.argument("secret", callback=click_parse_b32_key, required=False) @click.option( "-o", "--oath-type", type=EnumChoice(OATH_TYPE), default=OATH_TYPE.TOTP.name, help="time-based (TOTP) or counter-based (HOTP) account", show_default=True, ) @click.option( "-d", "--digits", type=click.Choice(["6", "7", "8"]), default="6", help="number of digits in generated code", show_default=True, ) @click.option( "-a", "--algorithm", type=EnumChoice(HASH_ALGORITHM), default=HASH_ALGORITHM.SHA1.name, show_default=True, help="algorithm to use for code generation", ) @click.option( "-c", "--counter", type=click.INT, default=0, help="initial counter value for HOTP accounts", ) @click.option("-i", "--issuer", help="issuer of the account (optional)") @click.option( "-P", "--period", help="number of seconds a TOTP code is valid", default=30, show_default=True, ) @click_touch_option @click_force_option @click_password_option @click_remember_option @click.pass_context def add( ctx, secret, name, issuer, period, oath_type, digits, touch, algorithm, counter, force, password, remember, ): """ Add a new account. This will add a new OATH account to the YubiKey. \b NAME human readable name of the account, such as a username or e-mail address SECRET base32-encoded secret/key value provided by the server """ digits = int(digits) if not secret: while True: secret = click_prompt("Enter a secret key (base32)") try: secret = parse_b32_key(secret) break except Exception as e: click.echo(e) _init_session(ctx, password, remember) _add_cred( ctx, CredentialData( name, oath_type, algorithm, secret, digits, period, counter, issuer ), touch, force, ) @click_callback() def click_parse_uri(ctx, param, val): try: return CredentialData.parse_uri(val) except ValueError: raise click.BadParameter("URI seems to have the wrong format.") @accounts.command() @click.argument("data", callback=click_parse_uri, required=False, metavar="URI") @click_touch_option @click_force_option @click_password_option @click_remember_option @click.pass_context def uri(ctx, data, touch, force, password, remember): """ Add a new account from an otpauth:// URI. Use a URI to add a new account to the YubiKey. """ if not data: while True: uri = click_prompt("Enter an OATH URI (otpauth://)") try: data = CredentialData.parse_uri(uri) break except Exception as e: click.echo(e) # Steam is a special case where we allow the otpauth # URI to contain a 'digits' value of '5'. if data.digits == 5 and is_steam(data): data.digits = 6 _init_session(ctx, password, remember) _add_cred(ctx, data, touch, force) def _add_cred(ctx, data, touch, force): session = ctx.obj["session"] version = session.version if not (0 < len(data.name) <= 64): ctx.fail("Name must be between 1 and 64 bytes.") if len(data.secret) < 2: ctx.fail("Secret must be at least 2 bytes.") if touch and version < (4, 2, 6): raise CliFail("Require touch is not supported on this YubiKey.") if data.counter and data.oath_type != OATH_TYPE.HOTP: ctx.fail("Counter only supported for HOTP accounts.") if data.hash_algorithm == HASH_ALGORITHM.SHA512 and ( version < (4, 3, 1) or is_yk4_fips(ctx.obj["info"]) ): raise CliFail("Algorithm SHA512 not supported on this YubiKey.") creds = session.list_credentials() cred_id = data.get_id() if not force and any(cred.id == cred_id for cred in creds): click.confirm( f"An account called {data.name} already exists on this YubiKey." " Do you want to overwrite it?", abort=True, err=True, ) firmware_overwrite_issue = (4, 0, 0) < version < (4, 3, 5) cred_is_subset = any( (cred.id.startswith(cred_id) and cred.id != cred_id) for cred in creds ) # YK4 has an issue with credential overwrite in firmware versions < 4.3.5 if firmware_overwrite_issue and cred_is_subset: raise CliFail("Choose a name that is not a subset of an existing account.") try: session.put_credential(data, touch) except ApduError as e: if e.sw == SW.NO_SPACE: raise CliFail("No space left on the YubiKey for OATH accounts.") elif e.sw == SW.COMMAND_ABORTED: # Some NEOs do not use the NO_SPACE error. raise CliFail("The command failed. Is there enough space on the YubiKey?") else: raise @accounts.command() @click_show_hidden_option @click.pass_context @click.option("-o", "--oath-type", is_flag=True, help="display the OATH type") @click.option("-P", "--period", is_flag=True, help="display the period") @click_password_option @click_remember_option def list(ctx, show_hidden, oath_type, period, password, remember): """ List all accounts. List all accounts stored on the YubiKey. """ _init_session(ctx, password, remember) session = ctx.obj["session"] creds = [ cred for cred in session.list_credentials() if show_hidden or not is_hidden(cred) ] creds.sort() for cred in creds: click.echo(_string_id(cred), nl=False) if oath_type: click.echo(f", {cred.oath_type.name}", nl=False) if period: click.echo(f", {cred.period}", nl=False) click.echo() @accounts.command() @click_show_hidden_option @click.pass_context @click.argument("query", required=False, default="") @click.option( "-s", "--single", is_flag=True, help="ensure only a single match, and output only the code", ) @click_password_option @click_remember_option def code(ctx, show_hidden, query, single, password, remember): """ Generate codes. Generate codes from OATH accounts stored on the YubiKey. Provide a query string to match one or more specific accounts. Accounts of type HOTP, or those that require touch, requre a single match to be triggered. """ _init_session(ctx, password, remember) session = ctx.obj["session"] try: entries = session.calculate_all() except ApduError as e: if e.sw == SW.MEMORY_FAILURE: logger.warning("Corrupted data in OATH accounts, attempting to fix") if delete_broken_credential(session): entries = session.calculate_all() else: logger.error("Unable to fix memory failure") raise else: raise creds = _search(entries.keys(), query, show_hidden) if len(creds) == 1: cred = creds[0] code = entries[cred] if cred.touch_required: prompt_for_touch() try: if cred.oath_type == OATH_TYPE.HOTP: with prompt_timeout(): # HOTP might require touch, we don't know. # Assume yes after 500ms. code = session.calculate_code(cred) elif code is None: code = session.calculate_code(cred) except ApduError as e: if e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED: raise CliFail("Touch account timed out!") entries[cred] = code elif single and len(creds) > 1: _error_multiple_hits(ctx, creds) elif single and len(creds) == 0: raise CliFail("No matching account found.") if single and creds: if is_steam(cred): click.echo(calculate_steam(session, cred)) else: click.echo(code.value) else: outputs = [] for cred in sorted(creds): code = entries[cred] if code: if is_steam(cred): code = calculate_steam(session, cred) else: code = code.value elif cred.touch_required: code = "[Requires Touch]" elif cred.oath_type == OATH_TYPE.HOTP: code = "[HOTP Account]" else: code = "" outputs.append((_string_id(cred), code)) longest_name = max(len(n) for (n, c) in outputs) if outputs else 0 longest_code = max(len(c) for (n, c) in outputs) if outputs else 0 format_str = "{:<%d} {:>%d}" % (longest_name, longest_code) for name, result in outputs: click.echo(format_str.format(name, result)) @accounts.command() @click.pass_context @click.argument("query") @click.argument("name") @click.option("-f", "--force", is_flag=True, help="confirm rename without prompting") @click_password_option @click_remember_option def rename(ctx, query, name, force, password, remember): """ Rename an account (requires YubiKey 5.3 or later). \b QUERY a query to match a single account (as shown in "list") NAME the name of the account (use ":" to specify issuer) """ _init_session(ctx, password, remember) session = ctx.obj["session"] creds = session.list_credentials() hits = _search(creds, query, True) if len(hits) == 0: click.echo("No matches, nothing to be done.") elif len(hits) == 1: cred = hits[0] if ":" in name: issuer, name = name.split(":", 1) else: issuer = None new_id = _format_cred_id(issuer, name, cred.oath_type, cred.period) if any(cred.id == new_id for cred in creds): raise CliFail( f"Another account with ID {new_id.decode()} " "already exists on this YubiKey." ) if force or ( click.confirm( f"Rename account: {_string_id(cred)} ?", default=False, err=True, ) ): session.rename_credential(cred.id, name, issuer) click.echo(f"Renamed {_string_id(cred)} to {new_id.decode()}.") else: click.echo("Rename aborted by user.") else: _error_multiple_hits(ctx, hits) @accounts.command() @click.pass_context @click.argument("query") @click.option("-f", "--force", is_flag=True, help="confirm deletion without prompting") @click_password_option @click_remember_option def delete(ctx, query, force, password, remember): """ Delete an account. Delete an account from the YubiKey. \b QUERY a query to match a single account (as shown in "list") """ _init_session(ctx, password, remember) session = ctx.obj["session"] creds = session.list_credentials() hits = _search(creds, query, True) if len(hits) == 0: click.echo("No matches, nothing to be done.") elif len(hits) == 1: cred = hits[0] if force or ( click.confirm( f"Delete account: {_string_id(cred)} ?", default=False, err=True, ) ): session.delete_credential(cred.id) click.echo(f"Deleted {_string_id(cred)}.") else: click.echo("Deletion aborted by user.") else: _error_multiple_hits(ctx, hits) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1691654743.7221272 yubikey_manager-5.2.1/ykman/_cli/openpgp.py0000644000000000000000000004117314465115130015675 0ustar00# 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.core.smartcard import ApduError, SW, SmartCardConnection from yubikit.openpgp import OpenPgpSession, UIF, PIN_POLICY, KEY_REF as _KEY_REF from ..util import parse_certificates, parse_private_key from ..openpgp import get_openpgp_info from .util import ( CliFail, click_force_option, click_format_option, click_postpone_execution, click_prompt, click_group, EnumChoice, pretty_print, ) from enum import IntEnum import logging import click logger = logging.getLogger(__name__) class KEY_REF(IntEnum): SIG = 0x01 DEC = 0x02 AUT = 0x03 ATT = 0x81 ENC = 0x02 # Alias for backwards compatibility, will be removed in ykman 6 def __getattribute__(self, name: str): return _KEY_REF(self).__getattribute__(name) def _fname(fobj): return getattr(fobj, "name", fobj) @click_group(connections=[SmartCardConnection]) @click.pass_context @click_postpone_execution def openpgp(ctx): """ Manage the OpenPGP application. Examples: \b Set the retries for PIN, Reset Code and Admin PIN to 10: $ ykman openpgp access set-retries 10 10 10 \b Require touch to use the authentication key: $ ykman openpgp keys set-touch aut on """ dev = ctx.obj["device"] conn = dev.open_connection(SmartCardConnection) ctx.call_on_close(conn.close) ctx.obj["session"] = OpenPgpSession(conn) @openpgp.command() @click.pass_context def info(ctx): """ Display general status of the OpenPGP application. """ session = ctx.obj["session"] click.echo("\n".join(pretty_print(get_openpgp_info(session)))) @openpgp.command() @click_force_option @click.pass_context def reset(ctx, force): """ Reset all OpenPGP data. This action will wipe all OpenPGP data, and set all PINs to their default values. """ force or click.confirm( "WARNING! This will delete all stored OpenPGP keys and data and restore " "factory settings. Proceed?", abort=True, err=True, ) click.echo("Resetting OpenPGP data, don't remove the YubiKey...") ctx.obj["session"].reset() logger.info("OpenPGP application data reset") click.echo("Success! All data has been cleared and default PINs are set.") echo_default_pins() def echo_default_pins(): click.echo("PIN: 123456") click.echo("Reset code: NOT SET") click.echo("Admin PIN: 12345678") @openpgp.group("access") def access(): """Manage PIN, Reset Code, and Admin PIN.""" @access.command("set-retries") @click.argument("user-pin-retries", type=click.IntRange(1, 99), metavar="PIN-RETRIES") @click.argument( "reset-code-retries", type=click.IntRange(1, 99), metavar="RESET-CODE-RETRIES" ) @click.argument( "admin-pin-retries", type=click.IntRange(1, 99), metavar="ADMIN-PIN-RETRIES" ) @click.option("-a", "--admin-pin", help="admin PIN for OpenPGP") @click_force_option @click.pass_context def set_pin_retries( ctx, admin_pin, user_pin_retries, reset_code_retries, admin_pin_retries, force ): """ Set the number of retry attempts for the User PIN, Reset Code, and Admin PIN. """ session = ctx.obj["session"] if admin_pin is None: admin_pin = click_prompt("Enter Admin PIN", hide_input=True) resets_pins = session.version < (4, 0, 0) if resets_pins: click.echo("WARNING: Setting PIN retries will reset the values for all 3 PINs!") if force or click.confirm( f"Set PIN retry counters to: {user_pin_retries} {reset_code_retries} " f"{admin_pin_retries}?", abort=True, err=True, ): session.verify_admin(admin_pin) session.set_pin_attempts( user_pin_retries, reset_code_retries, admin_pin_retries ) logger.info("Number of PIN/Reset Code/Admin PIN retries set") if resets_pins: click.echo("Default PINs are set.") echo_default_pins() @access.command("change-pin") @click.option("-P", "--pin", help="current PIN code") @click.option("-n", "--new-pin", help="a new PIN") @click.pass_context def change_pin(ctx, pin, new_pin): """ Change the User PIN. The PIN has a minimum length of 6, and supports any type of alphanumeric characters. """ session = ctx.obj["session"] if pin is None: pin = click_prompt("Enter PIN", hide_input=True) if new_pin is None: new_pin = click_prompt( "New PIN", hide_input=True, confirmation_prompt=True, ) session.change_pin(pin, new_pin) @access.command("change-reset-code") @click.option("-a", "--admin-pin", help="Admin PIN") @click.option("-r", "--reset-code", help="a new Reset Code") @click.pass_context def change_reset_code(ctx, admin_pin, reset_code): """ Change the Reset Code. The Reset Code has a minimum length of 6, and supports any type of alphanumeric characters. """ session = ctx.obj["session"] if admin_pin is None: admin_pin = click_prompt("Enter Admin PIN", hide_input=True) if reset_code is None: reset_code = click_prompt( "New Reset Code", hide_input=True, confirmation_prompt=True, ) session.verify_admin(admin_pin) session.set_reset_code(reset_code) @access.command("change-admin-pin") @click.option("-a", "--admin-pin", help="current Admin PIN") @click.option("-n", "--new-admin-pin", help="new Admin PIN") @click.pass_context def change_admin(ctx, admin_pin, new_admin_pin): """ Change the Admin PIN. The Admin PIN has a minimum length of 8, and supports any type of alphanumeric characters. """ session = ctx.obj["session"] if admin_pin is None: admin_pin = click_prompt("Enter Admin PIN", hide_input=True) if new_admin_pin is None: new_admin_pin = click_prompt( "New Admin PIN", hide_input=True, confirmation_prompt=True, ) session.change_admin(admin_pin, new_admin_pin) @access.command("unblock-pin") @click.option( "-a", "--admin-pin", help='admin PIN (use "-" as a value to prompt for input)' ) @click.option("-r", "--reset-code", help="Reset Code") @click.option("-n", "--new-pin", help="a new PIN") @click.pass_context def unblock_pin(ctx, admin_pin, reset_code, new_pin): """ Unblock the PIN (using Reset Code or Admin PIN). If the PIN is lost or blocked you can reset it to a new value using either the Reset Code OR the Admin PIN. The new PIN has a minimum length of 6, and supports any type of alphanumeric characters. """ session = ctx.obj["session"] if reset_code is not None and admin_pin is not None: raise CliFail( "Invalid options: Only one of --reset-code and --admin-pin may be used." ) if admin_pin == "-": admin_pin = click_prompt("Enter Admin PIN", hide_input=True) if reset_code is None and admin_pin is None: reset_code = click_prompt("Enter Reset Code", hide_input=True) if new_pin is None: new_pin = click_prompt( "New PIN", hide_input=True, confirmation_prompt=True, ) if admin_pin: session.verify_admin(admin_pin) session.reset_pin(new_pin, reset_code) @access.command("set-signature-policy") @click.argument("policy", metavar="POLICY", type=EnumChoice(PIN_POLICY)) @click.option("-a", "--admin-pin", help="Admin PIN for OpenPGP") @click.pass_context def set_signature_policy(ctx, policy, admin_pin): """ Set the Signature PIN policy. The Signature PIN policy is used to control whether the PIN is always required when using the Signature key, or if it is required only once per session. \b POLICY signature PIN policy to set (always, once) """ session = ctx.obj["session"] if admin_pin is None: admin_pin = click_prompt("Enter Admin PIN", hide_input=True) try: session.verify_admin(admin_pin) session.set_signature_pin_policy(policy) except Exception: raise CliFail("Failed to set new Signature PIN policy") @openpgp.group("keys") def keys(): """Manage private keys.""" @keys.command("set-touch") @click.argument("key", metavar="KEY", type=EnumChoice(KEY_REF)) @click.argument("policy", metavar="POLICY", type=EnumChoice(UIF)) @click.option("-a", "--admin-pin", help="Admin PIN for OpenPGP") @click_force_option @click.pass_context def set_touch(ctx, key, policy, admin_pin, force): """ Set the touch policy for OpenPGP keys. The touch policy is used to require user interaction for all operations using the private key on the YubiKey. The touch policy is set individually for each key slot. To see the current touch policy, run the "openpgp info" subcommand. Touch policies: \b Off (default) no touch required On touch required Fixed touch required, can't be disabled without deleting the private key Cached touch required, cached for 15s after use Cached-Fixed touch required, cached for 15s after use, can't be disabled without deleting the private key \b KEY key slot to set (sig, dec, aut or att) POLICY touch policy to set (on, off, fixed, cached or cached-fixed) """ session = ctx.obj["session"] policy_name = policy.name.lower().replace("_", "-") if admin_pin is None: admin_pin = click_prompt("Enter Admin PIN", hide_input=True) prompt = f"Set touch policy of {key.name} key to {policy_name}?" if policy.is_fixed: prompt = ( "WARNING: This touch policy cannot be changed without deleting the " + "corresponding key slot!\n" + prompt ) if force or click.confirm(prompt, abort=True, err=True): try: session.verify_admin(admin_pin) session.set_uif(key, policy) logger.info(f"Touch policy for slot {key.name} set") except ApduError as e: if e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED: raise CliFail("Touch policy not allowed.") raise CliFail("Failed to set touch policy.") @keys.command("import") @click.option("-a", "--admin-pin", help="Admin PIN for OpenPGP") @click.pass_context @click.argument("key", metavar="KEY", type=EnumChoice(KEY_REF)) @click.argument("private-key", type=click.File("rb"), metavar="PRIVATE-KEY") def import_key(ctx, key, private_key, admin_pin): """ Import a private key (ONLY SUPPORTS ATTESTATION KEY). Import a private key for OpenPGP attestation. \b PRIVATE-KEY file containing the private key (use '-' to use stdin) """ session = ctx.obj["session"] if key != KEY_REF.ATT: ctx.fail("Importing keys is only supported for the Attestation slot.") if admin_pin is None: admin_pin = click_prompt("Enter Admin PIN", hide_input=True) try: private_key = parse_private_key(private_key.read(), password=None) except Exception: raise CliFail("Failed to parse private key.") try: session.verify_admin(admin_pin) session.put_key(key, private_key) logger.info(f"Private key imported for slot {key.name}") except Exception: raise CliFail("Failed to import attestation key.") @keys.command() @click.pass_context @click.option("-P", "--pin", help="PIN code") @click_format_option @click.argument("key", metavar="KEY", type=EnumChoice(KEY_REF, hidden=[KEY_REF.ATT])) @click.argument("certificate", type=click.File("wb"), metavar="CERTIFICATE") def attest(ctx, key, certificate, pin, format): """ Generate an attestation certificate for a key. Attestation is used to show that an asymmetric key was generated on the YubiKey and therefore doesn't exist outside the device. \b KEY key slot to attest (sig, dec, aut) CERTIFICATE file to write attestation certificate to (use '-' to use stdout) """ session = ctx.obj["session"] if not pin: pin = click_prompt("Enter PIN", hide_input=True) try: cert = session.get_certificate(key) except ValueError: cert = None if not cert or click.confirm( f"There is already data stored in the certificate slot for {key.value}, " "do you want to overwrite it?" ): touch_policy = session.get_uif(KEY_REF.ATT) if touch_policy in [UIF.ON, UIF.FIXED]: click.echo("Touch the YubiKey sensor...") try: session.verify_pin(pin) cert = session.attest_key(key) certificate.write(cert.public_bytes(encoding=format)) logger.info( f"Attestation certificate for slot {key.name} written to " f"{_fname(certificate)}" ) except Exception: raise CliFail("Attestation failed") @openpgp.group("certificates") def certificates(): """ Manage certificates. """ @certificates.command("export") @click.pass_context @click.argument("key", metavar="KEY", type=EnumChoice(KEY_REF)) @click_format_option @click.argument("certificate", type=click.File("wb"), metavar="CERTIFICATE") def export_certificate(ctx, key, format, certificate): """ Export an OpenPGP certificate. \b KEY key slot to read from (sig, dec, aut, or att) CERTIFICATE file to write certificate to (use '-' to use stdout) """ session = ctx.obj["session"] try: cert = session.get_certificate(key) except ValueError: raise CliFail(f"Failed to read certificate from slot {key.name}") certificate.write(cert.public_bytes(encoding=format)) logger.info(f"Certificate for slot {key.name} exported to {_fname(certificate)}") @certificates.command("delete") @click.option("-a", "--admin-pin", help="Admin PIN for OpenPGP") @click.pass_context @click.argument("key", metavar="KEY", type=EnumChoice(KEY_REF)) def delete_certificate(ctx, key, admin_pin): """ Delete an OpenPGP certificate. \b KEY Key slot to delete certificate from (sig, dec, aut, or att). """ session = ctx.obj["session"] if admin_pin is None: admin_pin = click_prompt("Enter Admin PIN", hide_input=True) try: session.verify_admin(admin_pin) session.delete_certificate(key) logger.info(f"Certificate for slot {key.name} deleted") except Exception: raise CliFail("Failed to delete certificate.") @certificates.command("import") @click.option("-a", "--admin-pin", help="Admin PIN for OpenPGP") @click.pass_context @click.argument("key", metavar="KEY", type=EnumChoice(KEY_REF)) @click.argument("cert", type=click.File("rb"), metavar="CERTIFICATE") def import_certificate(ctx, key, cert, admin_pin): """ Import an OpenPGP certificate. \b KEY key slot to import certificate to (sig, dec, aut, or att) CERTIFICATE file containing the certificate (use '-' to use stdin) """ session = ctx.obj["session"] if admin_pin is None: admin_pin = click_prompt("Enter Admin PIN", hide_input=True) try: certs = parse_certificates(cert.read(), password=None) except Exception: raise CliFail("Failed to parse certificate.") if len(certs) != 1: raise CliFail("Can only import one certificate.") try: session.verify_admin(admin_pin) session.put_certificate(key, certs[0]) except Exception: raise CliFail("Failed to import certificate") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1690184528.1561828 yubikey_manager-5.2.1/ykman/_cli/otp.py0000644000000000000000000006542714457425520015047 0ustar00# 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 base64 import b32encode from yubikit.yubiotp import ( SLOT, NDEF_TYPE, YubiOtpSession, YubiOtpSlotConfiguration, HmacSha1SlotConfiguration, StaticPasswordSlotConfiguration, HotpSlotConfiguration, UpdateConfiguration, ) from yubikit.core import TRANSPORT, CommandError from yubikit.core.otp import ( MODHEX_ALPHABET, modhex_encode, modhex_decode, OtpConnection, ) from yubikit.core.smartcard import SmartCardConnection from .util import ( CliFail, click_group, click_force_option, click_callback, click_parse_b32_key, click_postpone_execution, click_prompt, prompt_for_touch, EnumChoice, is_yk4_fips, ) from .. import __version__ from ..scancodes import encode, KEYBOARD_LAYOUT from ..otp import ( _PrepareUploadFailed, _prepare_upload_key, is_in_fips_mode, generate_static_pw, parse_oath_key, parse_b32_key, time_challenge, format_oath_code, format_csv, ) from threading import Event from time import time import logging import os import struct import click import webbrowser logger = logging.getLogger(__name__) def parse_hex(length): @click_callback() def inner(ctx, param, val): val = bytes.fromhex(val) if len(val) != length: raise ValueError(f"Must be exactly {length} bytes.") return val return inner def parse_access_code_hex(access_code_hex): try: access_code = bytes.fromhex(access_code_hex) except TypeError as e: raise ValueError(e) if len(access_code) != 6: raise ValueError("Must be exactly 6 bytes.") return access_code click_slot_argument = click.argument( "slot", type=click.Choice(["1", "2"]), callback=lambda c, p, v: SLOT(int(v)) ) _WRITE_FAIL_MSG = ( "Failed to write to the YubiKey. Make sure the device does not " 'have restricted access (see "ykman otp --help" for more info).' ) def _confirm_slot_overwrite(slot_state, slot): if slot_state.is_configured(slot): click.confirm( f"Slot {slot} is already configured. Overwrite configuration?", abort=True, err=True, ) def _fname(fobj): return getattr(fobj, "name", fobj) @click_group(connections=[OtpConnection, SmartCardConnection]) @click.pass_context @click_postpone_execution @click.option( "--access-code", required=False, metavar="HEX", help='6 byte access code (use "-" as a value to prompt for input)', ) def otp(ctx, access_code): """ Manage the YubiOTP application. The YubiKey provides two keyboard-based slots which can each be configured with a credential. Several credential types are supported. A slot configuration may be write-protected with an access code. This prevents the configuration to be overwritten without the access code provided. Mode switching the YubiKey is not possible when a slot is configured with an access code. To provide an access code to commands which require it, use the --access-code option. Note that this option must be given directly after the "otp" command, before any sub-command. Examples: \b Swap the configurations between the two slots: $ ykman otp swap \b Program a random challenge-response credential to slot 2: $ ykman otp chalresp --generate 2 \b Program a Yubico OTP credential to slot 1, using the serial as public id: $ ykman otp yubiotp 1 --serial-public-id \b Program a random 38 characters long static password to slot 2: $ ykman otp static --generate 2 --length 38 \b Remove a currently set access code from slot 2): $ ykman otp --access-code 0123456789ab settings 2 --delete-access-code """ """ # TODO: Require OTP for chalresp, or FW < 5.?. Require CCID for HashOTP dev = ctx.obj["device"] if dev.supports_connection(OtpConnection): conn = dev.open_connection(OtpConnection) else: conn = dev.open_connection(SmartCardConnection) ctx.call_on_close(conn.close) ctx.obj["session"] = YubiOtpSession(conn) """ if access_code is not None: if access_code == "-": access_code = click_prompt("Enter the access code", hide_input=True) try: access_code = parse_access_code_hex(access_code) except Exception as e: ctx.fail(f"Failed to parse access code: {e}") ctx.obj["access_code"] = access_code def _get_session(ctx, types=[OtpConnection, SmartCardConnection]): dev = ctx.obj["device"] for conn_type in types: if dev.supports_connection(conn_type): conn = dev.open_connection(conn_type) ctx.call_on_close(conn.close) return YubiOtpSession(conn) raise CliFail( "The connection type required for this command is not supported/enabled on the " "YubiKey" ) @otp.command() @click.pass_context def info(ctx): """ Display general status of the YubiKey OTP slots. """ session = _get_session(ctx) state = session.get_config_state() slot1 = state.is_configured(1) slot2 = state.is_configured(2) click.echo(f"Slot 1: {slot1 and 'programmed' or 'empty'}") click.echo(f"Slot 2: {slot2 and 'programmed' or 'empty'}") if is_yk4_fips(ctx.obj["info"]): click.echo(f"FIPS Approved Mode: {'Yes' if is_in_fips_mode(session) else 'No'}") @otp.command() @click_force_option @click.pass_context def swap(ctx, force): """ Swaps the two slot configurations. """ session = _get_session(ctx) force or click.confirm( "Swap the two slots of the YubiKey?", abort=True, err=True, ) click.echo("Swapping slots...") try: session.swap_slots() except CommandError: raise CliFail(_WRITE_FAIL_MSG) @otp.command() @click_slot_argument @click.pass_context @click.option("-p", "--prefix", help="added before the NDEF payload, typically a URI") @click.option( "-t", "--ndef-type", type=EnumChoice(NDEF_TYPE), default="URI", show_default=True, help="NDEF payload type", ) def ndef(ctx, slot, prefix, ndef_type): """ Configure a slot to be used over NDEF (NFC). \b If "--prefix" is not specified, a default value will be used, based on the type: - For URI the default value is: "https://my.yubico.com/yk/#" - For TEXT the default is an empty string """ info = ctx.obj["info"] session = _get_session(ctx) state = session.get_config_state() if not info.has_transport(TRANSPORT.NFC): raise CliFail("This YubiKey does not support NFC.") if not state.is_configured(slot): raise CliFail(f"Slot {slot} is empty.") try: session.set_ndef_configuration(slot, prefix, ctx.obj["access_code"], ndef_type) except CommandError: raise CliFail(_WRITE_FAIL_MSG) @otp.command() @click_slot_argument @click_force_option @click.pass_context def delete(ctx, slot, force): """ Deletes the configuration stored in a slot. """ session = _get_session(ctx) state = session.get_config_state() if not force and not state.is_configured(slot): raise CliFail("Not possible to delete an empty slot.") force or click.confirm( f"Do you really want to delete the configuration of slot {slot}?", abort=True, err=True, ) click.echo(f"Deleting the configuration in slot {slot}...") try: session.delete_slot(slot, ctx.obj["access_code"]) except CommandError: raise CliFail(_WRITE_FAIL_MSG) @otp.command() @click_slot_argument @click.option( "-P", "--public-id", required=False, help="public identifier prefix", metavar="MODHEX", ) @click.option( "-p", "--private-id", required=False, metavar="HEX", callback=parse_hex(6), help="6 byte private identifier", ) @click.option( "-k", "--key", required=False, metavar="HEX", callback=parse_hex(16), help="16 byte secret key", ) @click.option( "--no-enter", is_flag=True, help="don't send an Enter keystroke after emitting the OTP", ) @click.option( "-S", "--serial-public-id", is_flag=True, required=False, help="use YubiKey serial number as public ID (can't be used with --public-id)", ) @click.option( "-g", "--generate-private-id", is_flag=True, required=False, help="generate a random private ID (can't be used with --private-id)", ) @click.option( "-G", "--generate-key", is_flag=True, required=False, help="generate a random secret key (can't be used with --key)", ) @click.option( "-u", "--upload", is_flag=True, required=False, help="upload credential to YubiCloud (opens a browser, can't be used with --force)", ) @click.option( "-O", "--config-output", type=click.File("a"), required=False, help="file to output the configuration to (existing file will be appended to)", ) @click_force_option @click.pass_context def yubiotp( ctx, slot, public_id, private_id, key, no_enter, force, serial_public_id, generate_private_id, generate_key, upload, config_output, ): """ Program a Yubico OTP credential. """ info = ctx.obj["info"] session = _get_session(ctx) serial = None if public_id and serial_public_id: ctx.fail("Invalid options: --public-id conflicts with --serial-public-id.") if private_id and generate_private_id: ctx.fail("Invalid options: --private-id conflicts with --generate-public-id.") if upload and force: ctx.fail("Invalid options: --upload conflicts with --force.") if key and generate_key: ctx.fail("Invalid options: --key conflicts with --generate-key.") if not public_id: if serial_public_id: try: serial = session.get_serial() except CommandError: raise CliFail("Serial number not set, public ID must be provided") public_id = modhex_encode(b"\xff\x00" + struct.pack(b">I", serial)) click.echo(f"Using YubiKey serial as public ID: {public_id}") elif force: ctx.fail( "Public ID not given. Please remove the --force flag, or " "add the --serial-public-id flag or --public-id option." ) else: public_id = click_prompt("Enter public ID") if len(public_id) % 2: ctx.fail("Invalid public ID, length must be a multiple of 2.") try: public_id = modhex_decode(public_id) except ValueError: ctx.fail(f"Invalid public ID, must be modhex ({MODHEX_ALPHABET}).") if not private_id: if generate_private_id: private_id = os.urandom(6) click.echo(f"Using a randomly generated private ID: {private_id.hex()}") elif force: ctx.fail( "Private ID not given. Please remove the --force flag, or " "add the --generate-private-id flag or --private-id option." ) else: private_id = click_prompt("Enter private ID") private_id = bytes.fromhex(private_id) if not key: if generate_key: key = os.urandom(16) click.echo(f"Using a randomly generated secret key: {key.hex()}") elif force: ctx.fail( "Secret key not given. Please remove the --force flag, or " "add the --generate-key flag or --key option." ) else: key = click_prompt("Enter secret key") key = bytes.fromhex(key) if upload: click.confirm("Upload credential to YubiCloud?", abort=True, err=True) try: upload_url = _prepare_upload_key( key, public_id, private_id, serial=info.serial, user_agent="ykman/" + __version__, ) click.echo("Upload to YubiCloud initiated successfully.") logger.info("Initiated YubiCloud upload") except _PrepareUploadFailed as e: error_msg = "\n".join(e.messages()) raise CliFail("Upload to YubiCloud failed.\n" + error_msg) force or click.confirm( f"Program a YubiOTP credential in slot {slot}?", abort=True, err=True ) access_code = ctx.obj["access_code"] try: session.put_configuration( slot, YubiOtpSlotConfiguration(public_id, private_id, key).append_cr( not no_enter ), access_code, access_code, ) except CommandError: raise CliFail(_WRITE_FAIL_MSG) if config_output: serial = serial or session.get_serial() csv = format_csv(serial, public_id, private_id, key, access_code) config_output.write(csv + "\n") logger.info(f"Configuration parameters written to {_fname(config_output)}") if upload: logger.info("Launching browser for YubiCloud upload") click.echo("Opening upload form in browser: " + upload_url) webbrowser.open_new_tab(upload_url) @otp.command() @click_slot_argument @click.argument("password", required=False) @click.option("-g", "--generate", is_flag=True, help="generate a random password") @click.option( "-l", "--length", metavar="LENGTH", type=click.IntRange(1, 38), default=38, show_default=True, help="length of generated password", ) @click.option( "-k", "--keyboard-layout", type=EnumChoice(KEYBOARD_LAYOUT), default="MODHEX", show_default=True, help="keyboard layout to use for the static password", ) @click.option( "--no-enter", is_flag=True, help="don't send an Enter keystroke after outputting the password", ) @click_force_option @click.pass_context def static(ctx, slot, password, generate, length, keyboard_layout, no_enter, force): """ Configure a static password. To avoid problems with different keyboard layouts, the following characters (upper and lower case) are allowed by default: cbdefghijklnrtuv Use the --keyboard-layout option to allow more characters based on preferred keyboard layout. """ session = _get_session(ctx) if password and len(password) > 38: ctx.fail("Password too long (maximum length is 38 characters).") if generate and not length: ctx.fail("Provide a length for the generated password.") if not password and not generate: password = click_prompt("Enter a static password") elif not password and generate: password = generate_static_pw(length, keyboard_layout) scan_codes = encode(password, keyboard_layout) if not force: _confirm_slot_overwrite(session.get_config_state(), slot) try: session.put_configuration( slot, StaticPasswordSlotConfiguration(scan_codes).append_cr(not no_enter), ctx.obj["access_code"], ctx.obj["access_code"], ) except CommandError: raise CliFail(_WRITE_FAIL_MSG) @otp.command() @click_slot_argument @click.argument("key", required=False) @click.option( "-t", "--touch", is_flag=True, help="require touch on the YubiKey to generate a response", ) @click.option( "-T", "--totp", is_flag=True, required=False, help="use a base32 encoded key (optionally padded) for TOTP credentials", ) @click.option( "-g", "--generate", is_flag=True, required=False, help="generate a random secret key (can't be used with KEY argument)", ) @click_force_option @click.pass_context def chalresp(ctx, slot, key, totp, touch, force, generate): """ Program a challenge-response credential. If KEY is not given, an interactive prompt will ask for it. \b KEY a key given in hex (or base32, if --totp is specified) """ session = _get_session(ctx) if key: if generate: ctx.fail("Invalid options: --generate conflicts with KEY argument.") elif totp: key = parse_b32_key(key) else: key = parse_oath_key(key) else: if force and not generate: ctx.fail( "No secret key given. Please remove the --force flag, " "set the KEY argument or set the --generate flag." ) elif generate: key = os.urandom(20) if totp: b32key = b32encode(key).decode() click.echo(f"Using a randomly generated key (base32): {b32key}") else: click.echo(f"Using a randomly generated key (hex): {key.hex()}") elif totp: while True: key = click_prompt("Enter a secret key (base32)") try: key = parse_b32_key(key) break except Exception as e: click.echo(e) else: key = click_prompt("Enter a secret key") key = parse_oath_key(key) cred_type = "TOTP" if totp else "challenge-response" force or click.confirm( f"Program a {cred_type} credential in slot {slot}?", abort=True, err=True, ) try: session.put_configuration( slot, HmacSha1SlotConfiguration(key).require_touch(touch), ctx.obj["access_code"], ctx.obj["access_code"], ) except CommandError: raise CliFail(_WRITE_FAIL_MSG) @otp.command() @click_slot_argument @click.argument("challenge", required=False) @click.option( "-T", "--totp", is_flag=True, help="generate a TOTP code, use the current time if challenge is omitted", ) @click.option( "-d", "--digits", type=click.Choice(["6", "8"]), default="6", help="number of digits in generated TOTP code (default: 6), " "ignored unless --totp is set", ) @click.pass_context def calculate(ctx, slot, challenge, totp, digits): """ Perform a challenge-response operation. Send a challenge (in hex) to a YubiKey slot with a challenge-response credential, and read the response. Supports output as a OATH-TOTP code. """ dev = ctx.obj["device"] if dev.transport == TRANSPORT.NFC: session = _get_session(ctx, [SmartCardConnection]) else: # Calculate over USB is only available over OtpConnection session = _get_session(ctx, [OtpConnection]) if not challenge and not totp: challenge = click_prompt("Enter a challenge (hex)") # Check that slot is not empty if not session.get_config_state().is_configured(slot): raise CliFail("Cannot perform challenge-response on an empty slot.") if totp: # Challenge omitted or timestamp if challenge is None: challenge = time_challenge(int(time())) else: try: challenge = time_challenge(int(challenge)) except Exception: logger.exception("Error parsing challenge") ctx.fail("Timestamp challenge for TOTP must be an integer.") else: # Challenge is hex challenge = bytes.fromhex(challenge) try: event = Event() def on_keepalive(status): if not hasattr(on_keepalive, "prompted") and status == 2: prompt_for_touch() setattr(on_keepalive, "prompted", True) response = session.calculate_hmac_sha1(slot, challenge, event, on_keepalive) if totp: value = format_oath_code(response, int(digits)) else: value = response.hex() click.echo(value) except CommandError: raise CliFail(_WRITE_FAIL_MSG) def parse_modhex_or_bcd(value): try: return True, modhex_decode(value) except ValueError: try: int(value) return False, bytes.fromhex(value) except ValueError: raise ValueError("value must be modhex or decimal") @otp.command() @click_slot_argument @click.argument("key", callback=click_parse_b32_key, required=False) @click.option( "-d", "--digits", type=click.Choice(["6", "8"]), default="6", help="number of digits in generated code (default is 6)", ) @click.option("-c", "--counter", type=int, default=0, help="initial counter value") @click.option("-i", "--identifier", help="token identifier") @click.option( "--no-enter", is_flag=True, help="don't send an Enter keystroke after outputting the code", ) @click_force_option @click.pass_context def hotp(ctx, slot, key, digits, counter, identifier, no_enter, force): """ Program an HMAC-SHA1 OATH-HOTP credential. The YubiKey can be configured to output an OATH Token Identifier as a prefix to the OTP itself, which consists of OMP+TT+MUI. Using the "--identifier" option, you may specify the OMP+TT as 4 characters, the MUI as 8 characters, or the full OMP+TT+MUI as 12 characters. If omitted, a default value of "ubhe" will be used for OMP+TT, and the YubiKey serial number will be used as MUI. """ session = _get_session(ctx) mh1 = False mh2 = False if identifier: if identifier == "-": identifier = "ubhe" if len(identifier) == 4: identifier += f"{session.get_serial():08}" elif len(identifier) == 8: identifier = "ubhe" + identifier if len(identifier) != 12: raise ValueError("Incorrect length for token identifier.") omp_m, omp = parse_modhex_or_bcd(identifier[:2]) tt_m, tt = parse_modhex_or_bcd(identifier[2:4]) mui_m, mui = parse_modhex_or_bcd(identifier[4:]) if tt_m and not omp_m: raise ValueError("TT can only be modhex encoded if OMP is as well.") if mui_m and not (omp_m and tt_m): raise ValueError( "MUI can only be modhex encoded if OMP and TT are as well." ) token_id = omp + tt + mui if mui_m: mh1 = mh2 = True elif tt_m: mh2 = True elif omp_m: mh1 = True else: token_id = b"" if not key: while True: key = click_prompt("Enter a secret key (base32)") try: key = parse_b32_key(key) break except Exception as e: click.echo(e) force or click.confirm( f"Program a HOTP credential in slot {slot}?", abort=True, err=True ) try: session.put_configuration( slot, HotpSlotConfiguration(key) .imf(counter) .token_id(token_id, mh1, mh2) .digits8(int(digits) == 8) .append_cr(not no_enter), ctx.obj["access_code"], ctx.obj["access_code"], ) except CommandError: raise CliFail(_WRITE_FAIL_MSG) @otp.command() @click_slot_argument @click_force_option @click.pass_context @click.option( "-A", "--new-access-code", metavar="HEX", required=False, help='a new 6 byte access code to set (use "-" as a value to prompt for input)', ) @click.option( "--delete-access-code", is_flag=True, help="remove access code from the slot" ) @click.option( "--enter/--no-enter", default=True, show_default=True, help="send an Enter keystroke after slot output", ) @click.option( "-p", "--pacing", type=click.Choice(["0", "20", "40", "60"]), default="0", show_default=True, help="throttle output speed by adding a delay (in ms) between characters emitted", ) @click.option( "--use-numeric-keypad", is_flag=True, show_default=True, help="use scancodes for numeric keypad when sending digits " "(helps for some keyboard layouts)", ) def settings( ctx, slot, new_access_code, delete_access_code, enter, pacing, use_numeric_keypad, force, ): """ Update the settings for a slot. Change the settings for a slot without changing the stored secret. All settings not specified will be written with default values. """ session = _get_session(ctx) if new_access_code and delete_access_code: ctx.fail("--new-access-code conflicts with --delete-access-code.") if delete_access_code and not ctx.obj["access_code"]: raise CliFail( "--delete-access-code used without providing an access code " '(see "ykman otp --help" for more info).' ) if not session.get_config_state().is_configured(slot): raise CliFail("Not possible to update settings on an empty slot.") if new_access_code is None: if not delete_access_code: new_access_code = ctx.obj["access_code"] else: if new_access_code == "-": new_access_code = click_prompt( "Enter new access code", hide_input=True, confirmation_prompt=True ) try: new_access_code = parse_access_code_hex(new_access_code) except Exception as e: ctx.fail("Failed to parse access code: " + str(e)) force or click.confirm( f"Update the settings for slot {slot}? " "All existing settings will be overwritten.", abort=True, err=True, ) click.echo(f"Updating settings for slot {slot}...") pacing_bits = int(pacing or "0") // 20 pacing_10ms = bool(pacing_bits & 1) pacing_20ms = bool(pacing_bits & 2) try: session.update_configuration( slot, UpdateConfiguration() .append_cr(enter) .use_numeric(use_numeric_keypad) .pacing(pacing_10ms, pacing_20ms), new_access_code, ctx.obj["access_code"], ) except CommandError: raise CliFail(_WRITE_FAIL_MSG) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1690184528.1561828 yubikey_manager-5.2.1/ykman/_cli/piv.py0000644000000000000000000011316214457425520015031 0ustar00# 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 NotSupportedError from yubikit.core.smartcard import SmartCardConnection from yubikit.piv import ( PivSession, InvalidPinError, KEY_TYPE, MANAGEMENT_KEY_TYPE, OBJECT_ID, SLOT, PIN_POLICY, TOUCH_POLICY, DEFAULT_MANAGEMENT_KEY, ) from yubikit.core.smartcard import ApduError, SW from ..util import ( get_leaf_certificates, parse_private_key, parse_certificates, InvalidPasswordError, ) from ..piv import ( get_piv_info, get_pivman_data, get_pivman_protected_data, pivman_set_mgm_key, pivman_change_pin, derive_management_key, generate_random_management_key, generate_chuid, generate_ccc, check_key, generate_self_signed_certificate, generate_csr, ) from .util import ( CliFail, click_group, click_force_option, click_format_option, click_postpone_execution, click_callback, click_prompt, prompt_timeout, EnumChoice, pretty_print, ) from cryptography.hazmat.primitives import serialization, hashes from cryptography.hazmat.backends import default_backend import click import datetime import logging logger = logging.getLogger(__name__) @click_callback() def click_parse_piv_slot(ctx, param, val): try: return SLOT[val.upper().replace("-", "_")] except KeyError: try: return SLOT(int(val, 16)) except Exception: raise ValueError(val) @click_callback() def click_parse_piv_object(ctx, param, val): if val.upper() == "CCC": return OBJECT_ID.CAPABILITY try: return OBJECT_ID[val.upper().replace("-", "_")] except KeyError: try: return int(val, 16) except Exception: raise ValueError(val) @click_callback() def click_parse_management_key(ctx, param, val): try: key = bytes.fromhex(val) if key and len(key) not in (16, 24, 32): raise ValueError( "Management key must be exactly 16, 24, or 32 bytes " "(32, 48, or 64 hexadecimal digits) long." ) return key except Exception: raise ValueError(val) @click_callback() def click_parse_hash(ctx, param, val): try: return getattr(hashes, val) except AttributeError: raise ValueError(val) click_slot_argument = click.argument("slot", callback=click_parse_piv_slot) click_object_argument = click.argument( "object_id", callback=click_parse_piv_object, metavar="OBJECT" ) click_management_key_option = click.option( "-m", "--management-key", help="the management key", callback=click_parse_management_key, ) click_pin_option = click.option("-P", "--pin", help="PIN code") click_pin_policy_option = click.option( "--pin-policy", type=EnumChoice(PIN_POLICY), default=PIN_POLICY.DEFAULT.name, help="PIN policy for slot", ) click_touch_policy_option = click.option( "--touch-policy", type=EnumChoice(TOUCH_POLICY), default=TOUCH_POLICY.DEFAULT.name, help="touch policy for slot", ) click_hash_option = click.option( "-a", "--hash-algorithm", type=click.Choice(["SHA256", "SHA384", "SHA512"], case_sensitive=False), default="SHA256", show_default=True, help="hash algorithm", callback=click_parse_hash, ) def _fname(fobj): return getattr(fobj, "name", fobj) @click_group(connections=[SmartCardConnection]) @click.pass_context @click_postpone_execution def piv(ctx): """ Manage the PIV application. Examples: \b Generate an ECC P-256 private key and a self-signed certificate in slot 9a: $ ykman piv keys generate --algorithm ECCP256 9a pubkey.pem $ ykman piv certificates generate --subject "CN=yubico" 9a pubkey.pem \b Change the PIN from 123456 to 654321: $ ykman piv access change-pin --pin 123456 --new-pin 654321 \b Reset all PIV data and restore default settings: $ ykman piv reset """ dev = ctx.obj["device"] conn = dev.open_connection(SmartCardConnection) ctx.call_on_close(conn.close) session = PivSession(conn) ctx.obj["session"] = session ctx.obj["pivman_data"] = get_pivman_data(session) @piv.command() @click.pass_context def info(ctx): """ Display general status of the PIV application. """ info = get_piv_info(ctx.obj["session"]) click.echo("\n".join(pretty_print(info))) @piv.command() @click.pass_context @click_force_option def reset(ctx, force): """ Reset all PIV data. This action will wipe all data and restore factory settings for the PIV application on the YubiKey. """ force or click.confirm( "WARNING! This will delete all stored PIV data and restore factory " "settings. Proceed?", abort=True, err=True, ) click.echo("Resetting PIV data...") ctx.obj["session"].reset() click.echo("Success! All PIV data have been cleared from the YubiKey.") click.echo("Your YubiKey now has the default PIN, PUK and Management Key:") click.echo("\tPIN:\t123456") click.echo("\tPUK:\t12345678") click.echo("\tManagement Key:\t010203040506070801020304050607080102030405060708") @piv.group() def access(): """Manage PIN, PUK, and Management Key.""" @access.command("set-retries") @click.pass_context @click.argument("pin-retries", type=click.IntRange(1, 255), metavar="PIN-RETRIES") @click.argument("puk-retries", type=click.IntRange(0, 255), metavar="PUK-RETRIES") @click_management_key_option @click_pin_option @click_force_option def set_pin_retries(ctx, management_key, pin, pin_retries, puk_retries, force): """ Set the number of PIN and PUK retry attempts. NOTE: This will reset the PIN and PUK to their factory defaults. """ session = ctx.obj["session"] _ensure_authenticated( ctx, pin, management_key, require_pin_and_key=True, no_prompt=force ) click.echo("WARNING: This will reset the PIN and PUK to the factory defaults!") force or click.confirm( f"Set the number of PIN and PUK retry attempts to: {pin_retries} " f"{puk_retries}?", abort=True, err=True, ) try: session.set_pin_attempts(pin_retries, puk_retries) click.echo("Default PINs are set:") click.echo("\tPIN:\t123456") click.echo("\tPUK:\t12345678") except Exception: raise CliFail("Setting pin retries failed.") @access.command("change-pin") @click.pass_context @click.option("-P", "--pin", help="current PIN code") @click.option("-n", "--new-pin", help="a new PIN to set") def change_pin(ctx, pin, new_pin): """ Change the PIN code. The PIN must be between 6 and 8 characters long, and supports any type of alphanumeric characters. For cross-platform compatibility, numeric PINs are recommended. """ session = ctx.obj["session"] if not pin: pin = _prompt_pin("Enter the current PIN") if not new_pin: new_pin = click_prompt( "Enter the new PIN", default="", hide_input=True, show_default=False, confirmation_prompt=True, ) if not _valid_pin_length(pin): ctx.fail("Current PIN must be between 6 and 8 characters long.") if not _valid_pin_length(new_pin): ctx.fail("New PIN must be between 6 and 8 characters long.") try: pivman_change_pin(session, pin, new_pin) click.echo("New PIN set.") except InvalidPinError as e: attempts = e.attempts_remaining if attempts: raise CliFail("PIN change failed - %d tries left." % attempts) else: raise CliFail("PIN is blocked.") @access.command("change-puk") @click.pass_context @click.option("-p", "--puk", help="current PUK code") @click.option("-n", "--new-puk", help="a new PUK code to set") def change_puk(ctx, puk, new_puk): """ Change the PUK code. If the PIN is lost or blocked it can be reset using a PUK. The PUK must be between 6 and 8 characters long, and supports any type of alphanumeric characters. """ session = ctx.obj["session"] if not puk: puk = _prompt_pin("Enter the current PUK") if not new_puk: new_puk = click_prompt( "Enter the new PUK", default="", hide_input=True, show_default=False, confirmation_prompt=True, ) if not _valid_pin_length(puk): ctx.fail("Current PUK must be between 6 and 8 characters long.") if not _valid_pin_length(new_puk): ctx.fail("New PUK must be between 6 and 8 characters long.") try: session.change_puk(puk, new_puk) click.echo("New PUK set.") except InvalidPinError as e: attempts = e.attempts_remaining if attempts: raise CliFail("PUK change failed - %d tries left." % attempts) else: raise CliFail("PUK is blocked.") @access.command("change-management-key") @click.pass_context @click_pin_option @click.option( "-t", "--touch", is_flag=True, help="require touch on YubiKey when prompted for management key", ) @click.option( "-n", "--new-management-key", help="a new management key to set", callback=click_parse_management_key, ) @click.option( "-m", "--management-key", help="current management key", callback=click_parse_management_key, ) @click.option( "-a", "--algorithm", help="management key algorithm", type=EnumChoice(MANAGEMENT_KEY_TYPE), default=MANAGEMENT_KEY_TYPE.TDES.name, show_default=True, ) @click.option( "-p", "--protect", is_flag=True, help="store new management key on the YubiKey, protected by PIN " "(a random key will be used if no key is provided)", ) @click.option( "-g", "--generate", is_flag=True, help="generate a random management key " "(implied by --protect unless --new-management-key is also given, " "can't be used with --new-management-key)", ) @click_force_option def change_management_key( ctx, management_key, algorithm, pin, new_management_key, touch, protect, generate, force, ): """ Change the management key. Management functionality is guarded by a management key. This key is required for administrative tasks, such as generating key pairs. A random key may be generated and stored on the YubiKey, protected by PIN. """ session = ctx.obj["session"] pivman = ctx.obj["pivman_data"] pin_verified = _ensure_authenticated( ctx, pin, management_key, require_pin_and_key=protect, mgm_key_prompt="Enter the current management key [blank to use default key]", no_prompt=force, ) # Can't combine new key with generate. if new_management_key and generate: ctx.fail("Invalid options: --new-management-key conflicts with --generate") # Touch not supported on NEO. if touch and session.version < (4, 0, 0): raise CliFail("Require touch not supported on this YubiKey.") # If an old stored key needs to be cleared, the PIN is needed. if not pin_verified and pivman.has_stored_key: if pin: _verify_pin(ctx, session, pivman, pin, no_prompt=force) elif not force: click.confirm( "The current management key is stored on the YubiKey" " and will not be cleared if no PIN is provided. Continue?", abort=True, err=True, ) if not new_management_key: if protect or generate: new_management_key = generate_random_management_key(algorithm) if not protect: click.echo(f"Generated management key: {new_management_key.hex()}") elif force: ctx.fail( "New management key not given. Please remove the --force " "flag, or set the --generate flag or the " "--new-management-key option." ) else: try: new_management_key = bytes.fromhex( click_prompt( "Enter the new management key", hide_input=True, confirmation_prompt=True, ) ) except Exception: ctx.fail("New management key has the wrong format.") if len(new_management_key) != algorithm.key_len: raise CliFail( "Management key has the wrong length (expected %d bytes)" % algorithm.key_len ) try: pivman_set_mgm_key( session, new_management_key, algorithm, touch=touch, store_on_device=protect ) except ApduError: raise CliFail("Changing the management key failed.") @access.command("unblock-pin") @click.pass_context @click.option("-p", "--puk", required=False) @click.option("-n", "--new-pin", required=False, metavar="NEW-PIN") def unblock_pin(ctx, puk, new_pin): """ Unblock the PIN (using PUK). """ session = ctx.obj["session"] if not puk: puk = click_prompt("Enter PUK", default="", show_default=False, hide_input=True) if not new_pin: new_pin = click_prompt( "Enter a new PIN", default="", show_default=False, hide_input=True ) try: session.unblock_pin(puk, new_pin) click.echo("PIN unblocked") except InvalidPinError as e: attempts = e.attempts_remaining if attempts: raise CliFail("PIN unblock failed - %d tries left." % attempts) else: raise CliFail("PUK is blocked.") @piv.group() def keys(): """ Manage private keys. """ @keys.command("generate") @click.pass_context @click_management_key_option @click_pin_option @click.option( "-a", "--algorithm", help="algorithm to use in key generation", type=EnumChoice(KEY_TYPE), default=KEY_TYPE.RSA2048.name, show_default=True, ) @click_format_option @click_pin_policy_option @click_touch_policy_option @click_slot_argument @click.argument("public-key-output", type=click.File("wb"), metavar="PUBLIC-KEY") def generate_key( ctx, slot, public_key_output, management_key, pin, algorithm, format, pin_policy, touch_policy, ): """ Generate an asymmetric key pair. The private key is generated on the YubiKey, and written to one of the slots. \b SLOT PIV slot of the private key PUBLIC-KEY file containing the generated public key (use '-' to use stdout) """ session = ctx.obj["session"] _ensure_authenticated(ctx, pin, management_key) public_key = session.generate_key(slot, algorithm, pin_policy, touch_policy) key_encoding = format public_key_output.write( public_key.public_bytes( encoding=key_encoding, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) ) logger.info( f"Private key generated in slot {slot}, public key written to " f"{_fname(public_key_output)}" ) @keys.command("import") @click.pass_context @click_pin_option @click_management_key_option @click_pin_policy_option @click_touch_policy_option @click_slot_argument @click.argument("private-key", type=click.File("rb"), metavar="PRIVATE-KEY") @click.option("-p", "--password", help="password used to decrypt the private key") def import_key( ctx, management_key, pin, slot, private_key, pin_policy, touch_policy, password ): """ Import a private key from file. Write a private key to one of the PIV slots on the YubiKey. \b SLOT PIV slot of the private key PRIVATE-KEY file containing the private key (use '-' to use stdin) """ session = ctx.obj["session"] data = private_key.read() while True: if password is not None: password = password.encode() try: private_key = parse_private_key(data, password) except InvalidPasswordError: logger.debug("Error parsing key", exc_info=True) if password is None: password = click_prompt( "Enter password to decrypt key", default="", hide_input=True, show_default=False, ) continue else: password = None click.echo("Wrong password.") continue break _ensure_authenticated(ctx, pin, management_key) session.put_key(slot, private_key, pin_policy, touch_policy) @keys.command() @click.pass_context @click_format_option @click_slot_argument @click.argument("certificate", type=click.File("wb"), metavar="CERTIFICATE") def attest(ctx, slot, certificate, format): """ Generate an attestation certificate for a key pair. Attestation is used to show that an asymmetric key was generated on the YubiKey and therefore doesn't exist outside the device. \b SLOT PIV slot of the private key CERTIFICATE file to write attestation certificate to (use '-' to use stdout) """ session = ctx.obj["session"] try: cert = session.attest_key(slot) except ApduError: raise CliFail("Attestation failed.") certificate.write(cert.public_bytes(encoding=format)) logger.info( f"Attestation certificate for slot {slot} written to {_fname(certificate)}" ) @keys.command("info") @click.pass_context @click_slot_argument def metadata(ctx, slot): """ Show metadata about a private key. This will show what type of key is stored in a specific slot, whether it was imported into the YubiKey, or generated on-chip, and what the PIN and Touch policies are for using the key. \b SLOT PIV slot of the private key """ session = ctx.obj["session"] try: metadata = session.get_slot_metadata(slot) info = { "Key slot": slot, "Algorithm": metadata.key_type.name, "Origin": "GENERATED" if metadata.generated else "IMPORTED", "PIN required for use": metadata.pin_policy.name, "Touch required for use": metadata.touch_policy.name, } click.echo("\n".join(pretty_print(info))) except ApduError as e: if e.sw == SW.REFERENCE_DATA_NOT_FOUND: raise CliFail(f"No key stored in slot {slot}.") raise e @keys.command() @click.pass_context @click_format_option @click_slot_argument @click.option( "-v", "--verify", is_flag=True, help="verify that the public key matches the private key in the slot", ) @click.option("-P", "--pin", help="PIN code (used for --verify)") @click.argument("public-key-output", type=click.File("wb"), metavar="PUBLIC-KEY") def export(ctx, slot, public_key_output, format, verify, pin): """ Export a public key corresponding to a stored private key. This command uses several different mechanisms for exporting the public key corresponding to a stored private key, which may fail. If a certificate is stored in the slot it is assumed to contain the correct public key. If this is not the case, the wrong public key will be returned. The --verify flag can be used to verify that the public key being returned matches the private key, by using the slot to create and verify a signature. This may require the PIN to be provided. \b SLOT PIV slot of the private key PUBLIC-KEY file to write the public key to (use '-' to use stdout) """ session = ctx.obj["session"] try: # Prefer metadata if available public_key = session.get_slot_metadata(slot).public_key logger.debug("Public key read from YubiKey") except ApduError as e: if e.sw == SW.REFERENCE_DATA_NOT_FOUND: raise CliFail(f"No key stored in slot {slot}.") raise CliFail(f"Unable to export public key from slot {slot}.") except NotSupportedError: try: # Try attestation public_key = session.attest_key(slot).public_key() logger.debug("Public key read using attestation") except (NotSupportedError, ApduError): try: # Read from stored certificate public_key = session.get_certificate(slot).public_key() logger.debug("Public key read from stored certificate") if verify: # Only needed when read from certificate def do_verify(): with prompt_timeout(timeout=1.0): if not check_key(session, slot, public_key): raise CliFail( "This public key is not tied to the private key in " f"slot {slot}." ) _verify_pin_if_needed(ctx, session, do_verify, pin) except ApduError: raise CliFail(f"Unable to export public key from slot {slot}.") key_encoding = format public_key_output.write( public_key.public_bytes( encoding=key_encoding, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) ) logger.info(f"Public key for slot {slot} written to {_fname(public_key_output)}") @piv.group("certificates") def cert(): """ Manage certificates. """ @cert.command("import") @click.pass_context @click_management_key_option @click_pin_option @click.option("-p", "--password", help="a password may be needed to decrypt the data") @click.option( "-v", "--verify", is_flag=True, help="verify that the certificate matches the private key in the slot", ) @click.option( "-c", "--compress", is_flag=True, help="compresses the certificate before storing" ) @click_slot_argument @click.argument("cert", type=click.File("rb"), metavar="CERTIFICATE") def import_certificate( ctx, management_key, pin, slot, cert, password, verify, compress ): """ Import an X.509 certificate. Write a certificate to one of the PIV slots on the YubiKey. \b SLOT PIV slot of the certificate CERTIFICATE file containing the certificate (use '-' to use stdin) """ session = ctx.obj["session"] data = cert.read() while True: if password is not None: password = password.encode() try: certs = parse_certificates(data, password) except InvalidPasswordError: logger.debug("Error parsing certificate", exc_info=True) if password is None: password = click_prompt( "Enter password to decrypt certificate", default="", hide_input=True, show_default=False, ) continue else: password = None click.echo("Wrong password.") continue break if len(certs) > 1: # If multiple certs, only import leaf. # Leaf is the cert with a subject that is not an issuer in the chain. leafs = get_leaf_certificates(certs) cert_to_import = leafs[0] else: cert_to_import = certs[0] _ensure_authenticated(ctx, pin, management_key) if verify: public_key = cert_to_import.public_key() try: metadata = session.get_slot_metadata(slot) if metadata.pin_policy in (PIN_POLICY.ALWAYS, PIN_POLICY.ONCE): pivman = ctx.obj["pivman_data"] _verify_pin(ctx, session, pivman, pin) if metadata.touch_policy in (TOUCH_POLICY.ALWAYS, TOUCH_POLICY.CACHED): timeout = 0.0 else: timeout = None except ApduError as e: if e.sw == SW.REFERENCE_DATA_NOT_FOUND: raise CliFail("No private key in slot {slot}") raise e except NotSupportedError: timeout = 1.0 def do_verify(): with prompt_timeout(timeout=timeout): if not check_key(session, slot, public_key): raise CliFail( "The public key of the certificate does not match the " f"private key in slot {slot}" ) _verify_pin_if_needed(ctx, session, do_verify, pin) session.put_certificate(slot, cert_to_import, compress) session.put_object(OBJECT_ID.CHUID, generate_chuid()) @cert.command("export") @click.pass_context @click_format_option @click_slot_argument @click.argument("certificate", type=click.File("wb"), metavar="CERTIFICATE") def export_certificate(ctx, format, slot, certificate): """ Export an X.509 certificate. Reads a certificate from one of the PIV slots on the YubiKey. \b SLOT PIV slot of the certificate CERTIFICATE file to write certificate to (use '-' to use stdout) """ session = ctx.obj["session"] try: cert = session.get_certificate(slot) certificate.write(cert.public_bytes(encoding=format)) logger.info(f"Certificate from slot {slot} exported to {_fname(certificate)}") except ApduError as e: if e.sw == SW.FILE_NOT_FOUND: raise CliFail("No certificate found.") else: raise CliFail("Failed reading certificate.") @cert.command("generate") @click.pass_context @click_management_key_option @click_pin_option @click_slot_argument @click.argument("public-key", type=click.File("rb"), metavar="PUBLIC-KEY") @click.option( "-s", "--subject", help="subject for the certificate, as an RFC 4514 string", required=True, ) @click.option( "-d", "--valid-days", help="number of days until the certificate expires", type=click.INT, default=365, show_default=True, ) @click_hash_option def generate_certificate( ctx, management_key, pin, slot, public_key, subject, valid_days, hash_algorithm ): """ Generate a self-signed X.509 certificate. A self-signed certificate is generated and written to one of the slots on the YubiKey. A private key must already be present in the corresponding key slot. \b SLOT PIV slot of the certificate PUBLIC-KEY file containing a public key (use '-' to use stdin) """ session = ctx.obj["session"] try: metadata = session.get_slot_metadata(slot) if metadata.touch_policy in (TOUCH_POLICY.ALWAYS, TOUCH_POLICY.CACHED): timeout = 0.0 else: timeout = None except ApduError as e: if e.sw == SW.REFERENCE_DATA_NOT_FOUND: raise CliFail("No private key in slot {slot}") except NotSupportedError: timeout = 1.0 data = public_key.read() public_key = serialization.load_pem_public_key(data, default_backend()) now = datetime.datetime.utcnow() valid_to = now + datetime.timedelta(days=valid_days) if "=" not in subject: # Old style, common name only. subject = "CN=" + subject # This verifies PIN, make sure next action is sign _ensure_authenticated(ctx, pin, management_key, require_pin_and_key=True) try: with prompt_timeout(timeout=timeout): cert = generate_self_signed_certificate( session, slot, public_key, subject, now, valid_to, hash_algorithm ) session.put_certificate(slot, cert) session.put_object(OBJECT_ID.CHUID, generate_chuid()) except ApduError: raise CliFail("Certificate generation failed.") @cert.command("request") @click.pass_context @click_pin_option @click_slot_argument @click.argument("public-key", type=click.File("rb"), metavar="PUBLIC-KEY") @click.argument("csr-output", type=click.File("wb"), metavar="CSR") @click.option( "-s", "--subject", help="subject for the requested certificate, as an RFC 4514 string", required=True, ) @click_hash_option def generate_certificate_signing_request( ctx, pin, slot, public_key, csr_output, subject, hash_algorithm ): """ Generate a Certificate Signing Request (CSR). A private key must already be present in the corresponding key slot. \b SLOT PIV slot of the certificate PUBLIC-KEY file containing a public key (use '-' to use stdin) CSR file to write CSR to (use '-' to use stdout) """ session = ctx.obj["session"] pivman = ctx.obj["pivman_data"] data = public_key.read() public_key = serialization.load_pem_public_key(data, default_backend()) if "=" not in subject: # Old style, common name only. subject = "CN=" + subject try: metadata = session.get_slot_metadata(slot) if metadata.touch_policy in (TOUCH_POLICY.ALWAYS, TOUCH_POLICY.CACHED): timeout = 0.0 else: timeout = None except ApduError as e: if e.sw == SW.REFERENCE_DATA_NOT_FOUND: raise CliFail("No private key in slot {slot}") except NotSupportedError: timeout = 1.0 # This verifies PIN, make sure next action is sign _verify_pin(ctx, session, pivman, pin) try: with prompt_timeout(timeout=timeout): csr = generate_csr(session, slot, public_key, subject, hash_algorithm) except ApduError: raise CliFail("Certificate Signing Request generation failed.") csr_output.write(csr.public_bytes(encoding=serialization.Encoding.PEM)) logger.info(f"CSR for slot {slot} written to {_fname(csr_output)}") @cert.command("delete") @click.pass_context @click_management_key_option @click_pin_option @click_slot_argument def delete_certificate(ctx, management_key, pin, slot): """ Delete a certificate. Delete a certificate from a PIV slot on the YubiKey. \b SLOT PIV slot of the certificate """ session = ctx.obj["session"] _ensure_authenticated(ctx, pin, management_key) session.delete_certificate(slot) session.put_object(OBJECT_ID.CHUID, generate_chuid()) @piv.group("objects") def objects(): """ Manage PIV data objects. Examples: \b Write the contents of a file to data object with ID: abc123: $ ykman piv objects import abc123 myfile.txt \b Read the contents of the data object with ID: abc123 into a file: $ ykman piv objects export abc123 myfile.txt \b Generate a random value for CHUID: $ ykman piv objects generate chuid """ @objects.command("export") @click_pin_option @click.pass_context @click_object_argument @click.argument("output", type=click.File("wb"), metavar="OUTPUT") def read_object(ctx, pin, object_id, output): """ Export an arbitrary PIV data object. \b OBJECT name of PIV data object, or ID in HEX OUTPUT file to write object to (use '-' to use stdout) """ session = ctx.obj["session"] pivman = ctx.obj["pivman_data"] def do_read_object(retry=True): try: output.write(session.get_object(object_id)) logger.info(f"Exported object {object_id} to {_fname(output)}") except ApduError as e: if e.sw == SW.FILE_NOT_FOUND: raise CliFail("No data found.") elif e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED and retry: _verify_pin(ctx, session, pivman, pin) do_read_object(retry=False) else: raise do_read_object() @objects.command("import") @click_pin_option @click_management_key_option @click.pass_context @click_object_argument @click.argument("data", type=click.File("rb"), metavar="DATA") def write_object(ctx, pin, management_key, object_id, data): """ Write an arbitrary PIV object. Write a PIV object by providing the object id. Yubico writable PIV objects are available in the range 5f0000 - 5fffff. \b OBJECT name of PIV data object, or ID in HEX DATA file containing the data to be written (use '-' to use stdin) """ session = ctx.obj["session"] _ensure_authenticated(ctx, pin, management_key) try: session.put_object(object_id, data.read()) except ApduError as e: if e.sw == SW.INCORRECT_PARAMETERS: raise CliFail("Something went wrong, is the object id valid?") raise CliFail("Error writing object") @objects.command("generate") @click_pin_option @click_management_key_option @click.pass_context @click_object_argument def generate_object(ctx, pin, management_key, object_id): """ Generate and write data for a supported data object. \b Supported data objects: "CHUID" (Card Holder Unique ID) "CCC" (Card Capability Container) \b OBJECT name of PIV data object, or ID in HEX """ session = ctx.obj["session"] _ensure_authenticated(ctx, pin, management_key) if OBJECT_ID.CHUID == object_id: session.put_object(OBJECT_ID.CHUID, generate_chuid()) elif OBJECT_ID.CAPABILITY == object_id: session.put_object(OBJECT_ID.CAPABILITY, generate_ccc()) else: ctx.fail("Unsupported object ID for generate.") def _prompt_management_key(prompt="Enter a management key [blank to use default key]"): management_key = click_prompt( prompt, default="", hide_input=True, show_default=False ) if management_key == "": return DEFAULT_MANAGEMENT_KEY try: return bytes.fromhex(management_key) except Exception: raise CliFail("Management key has the wrong format.") def _prompt_pin(prompt="Enter PIN"): return click_prompt(prompt, default="", hide_input=True, show_default=False) def _valid_pin_length(pin): return 6 <= len(pin) <= 8 def _ensure_authenticated( ctx, pin=None, management_key=None, require_pin_and_key=False, mgm_key_prompt=None, no_prompt=False, ): session = ctx.obj["session"] pivman = ctx.obj["pivman_data"] if pivman.has_protected_key and not management_key: _verify_pin(ctx, session, pivman, pin, no_prompt=no_prompt) return True _authenticate(ctx, session, management_key, mgm_key_prompt, no_prompt=no_prompt) if require_pin_and_key: # Ensure verify was the last thing we did _verify_pin(ctx, session, pivman, pin, no_prompt=no_prompt) return True def _verify_pin(ctx, session, pivman, pin, no_prompt=False): if not pin: if no_prompt: raise CliFail("PIN required.") else: pin = _prompt_pin() try: session.verify_pin(pin) if pivman.has_derived_key: with prompt_timeout(): session.authenticate( MANAGEMENT_KEY_TYPE.TDES, derive_management_key(pin, pivman.salt) ) session.verify_pin(pin) # Ensure verify was the last thing we did elif pivman.has_stored_key: pivman_prot = get_pivman_protected_data(session) try: key_type = session.get_management_key_metadata().key_type except NotSupportedError: key_type = MANAGEMENT_KEY_TYPE.TDES with prompt_timeout(): session.authenticate(key_type, pivman_prot.key) session.verify_pin(pin) # Ensure verify was the last thing we did except InvalidPinError as e: attempts = e.attempts_remaining if attempts > 0: raise CliFail(f"PIN verification failed, {attempts} tries left.") else: raise CliFail("PIN is blocked.") except Exception: raise CliFail("PIN verification failed.") def _verify_pin_if_needed(ctx, session, func, pin=None, no_prompt=False): try: return func() except ApduError as e: if e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED: logger.debug("Command failed due to PIN required, verifying and retrying") pivman = ctx.obj["pivman_data"] _verify_pin(ctx, session, pivman, pin, no_prompt) else: raise return func() def _authenticate(ctx, session, management_key, mgm_key_prompt, no_prompt=False): if not management_key: if no_prompt: ctx.fail("Management key required.") else: if mgm_key_prompt is None: management_key = _prompt_management_key() else: management_key = _prompt_management_key(mgm_key_prompt) try: try: key_type = session.get_management_key_metadata().key_type except NotSupportedError: key_type = MANAGEMENT_KEY_TYPE.TDES with prompt_timeout(): session.authenticate(key_type, management_key) except Exception: raise CliFail("Authentication with management key failed.") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.9002984 yubikey_manager-5.2.1/ykman/_cli/script.py0000644000000000000000000000656614407612164015545 0ustar00# 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 .util import click_force_option, click_command from .. import scripting # noqa - make sure this file gets included by PyInstaller. import sys import click import logging logger = logging.getLogger(__name__) _WARNING = """ WARNING: Never run a script without fully understanding what it does! Scripts are very powerful, and have the power to harm to both your YubiKey and your computer. ONLY run scripts that you fully trust! """ def _add_warning(obj): obj.__doc__ = obj.__doc__.format("\n ".join(_WARNING.splitlines())) return obj @click_command( "script", context_settings=dict(ignore_unknown_options=True), ) @click.pass_context @click.option( "-s", "--site-dir", type=click.Path(exists=True), multiple=True, metavar="DIR", help="specify additional path(s) to load python modules from", ) @click.argument("script", type=click.File("rb"), metavar="FILE") @click.argument("arguments", nargs=-1, type=click.UNPROCESSED) @click_force_option @_add_warning def run_script(ctx, site_dir, script, arguments, force): """ Run a python script. {0} Argument can be passed to the script by adding them after the end of the command. These will be accessible inside the script as sys.argv, with the script name as the initial value. For more information on scripting, see the "Scripting" page in the documentation. Examples: \b Run the file "myscript.py", passing arguments "123456" and "indata.csv": $ ykman script myscript.py 123456 indata.csv """ force or click.confirm( f"{_WARNING}\n" "You can bypass this message by running the command with the --force flag.\n\n" "Run script?", abort=True, err=True, ) for sd in site_dir: logger.debug("Add %s to path.", sd) sys.path.append(sd) script_body = script.read() sys.argv = [script.name, *arguments] exec(script_body, {}) # nosec ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1690184528.1561828 yubikey_manager-5.2.1/ykman/_cli/util.py0000644000000000000000000002202414457425520015204 0ustar00# 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. import functools import click import sys from yubikit.management import DeviceInfo from yubikit.oath import parse_b32_key from collections import OrderedDict from collections.abc import MutableMapping from cryptography.hazmat.primitives import serialization from contextlib import contextmanager from threading import Timer from enum import Enum from typing import List import logging logger = logging.getLogger(__name__) class _YkmanCommand(click.Command): def __init__(self, *args, **kwargs): connections = kwargs.pop("connections", None) if connections and not isinstance(connections, list): connections = [connections] # Single type self.connections = connections super().__init__(*args, **kwargs) def get_short_help_str(self, limit=45): help_str = super().get_short_help_str(limit) return help_str[0].lower() + help_str[1:].rstrip(".") def get_help_option(self, ctx): option = super().get_help_option(ctx) option.help = "show this message and exit" return option class _YkmanGroup(_YkmanCommand, click.Group): command_class = _YkmanCommand def add_command(self, cmd, name=None): if not isinstance(cmd, (_YkmanGroup, _YkmanCommand)): raise ValueError( f"Command {cmd} does not inherit from _YkmanGroup or _YkmanCommand" ) super().add_command(cmd, name) def list_commands(self, ctx): return sorted( self.commands, key=lambda c: (isinstance(self.commands[c], click.Group), c) ) _YkmanGroup.group_class = _YkmanGroup def click_group(*args, connections=None, **kwargs): return click.group( *args, cls=_YkmanGroup, connections=connections, **kwargs, ) def click_command(*args, connections=None, **kwargs): return click.command( *args, cls=_YkmanCommand, connections=connections, **kwargs, ) class EnumChoice(click.Choice): """ Use an enum's member names as the definition for a choice option. Enum member names MUST be all uppercase. Options are not case sensitive. Underscores in enum names are translated to dashes in the option choice. """ def __init__(self, choices_enum, hidden=[]): self.choices_names = [ v.name.replace("_", "-") for v in choices_enum if v not in hidden ] super().__init__( self.choices_names, case_sensitive=False, ) self.hidden = hidden self.choices_enum = choices_enum def convert(self, value, param, ctx): if isinstance(value, self.choices_enum): return value try: # Allow aliases self.choices = [ k.replace("_", "-") for k, v in self.choices_enum.__members__.items() if v not in self.hidden ] name = super().convert(value, param, ctx).replace("-", "_") finally: self.choices = self.choices_names return self.choices_enum[name] def click_callback(invoke_on_missing=False): def wrap(f): @functools.wraps(f) def inner(ctx, param, val): if not invoke_on_missing and not param.required and val is None: return None try: return f(ctx, param, val) except ValueError as e: ctx.fail(f'Invalid value for "{param.name}": {str(e)}') return inner return wrap @click_callback() def click_parse_format(ctx, param, val): if val == "PEM": return serialization.Encoding.PEM elif val == "DER": return serialization.Encoding.DER else: raise ValueError(val) click_force_option = click.option( "-f", "--force", is_flag=True, help="confirm the action without prompting" ) click_format_option = click.option( "-F", "--format", type=click.Choice(["PEM", "DER"], case_sensitive=False), default="PEM", show_default=True, help="encoding format", callback=click_parse_format, ) class YkmanContextObject(MutableMapping): def __init__(self): self._objects = OrderedDict() self._resolved = False def add_resolver(self, key, f): if self._resolved: f = f() self._objects[key] = f def resolve(self): if not self._resolved: self._resolved = True for k, f in self._objects.copy().items(): self._objects[k] = f() def __getitem__(self, key): self.resolve() return self._objects[key] def __setitem__(self, key, value): if not self._resolved: raise ValueError("BUG: Attempted to set item when unresolved.") self._objects[key] = value def __delitem__(self, key): del self._objects[key] def __len__(self): return len(self._objects) def __iter__(self): return iter(self._objects) def click_postpone_execution(f): @functools.wraps(f) def inner(*args, **kwargs): click.get_current_context().obj.add_resolver(str(f), lambda: f(*args, **kwargs)) return inner @click_callback() def click_parse_b32_key(ctx, param, val): return parse_b32_key(val) def click_prompt(prompt, err=True, **kwargs): """Replacement for click.prompt to better work when piping input to the command. Note that we change the default of err to be True, since that's how we typically use it. """ logger.debug(f"Input requested ({prompt})") if not sys.stdin.isatty(): # Piped from stdin, see if there is data logger.debug("TTY detected, reading line from stdin...") line = sys.stdin.readline() if line: return line.rstrip("\n") logger.debug("No data available on stdin") # No piped data, use standard prompt logger.debug("Using interactive prompt...") return click.prompt(prompt, err=err, **kwargs) def prompt_for_touch(): logger.debug("Prompting user to touch YubiKey...") try: click.echo("Touch your YubiKey...", err=True) except Exception: sys.stderr.write("Touch your YubiKey...\n") @contextmanager def prompt_timeout(timeout=0.5): timer = Timer(timeout, prompt_for_touch) try: timer.start() yield None finally: timer.cancel() class CliFail(Exception): def __init__(self, message, status=1): super().__init__(message) self.status = status def pretty_print(value, level: int = 0) -> List[str]: """Pretty-prints structured data, as that returned by get_diagnostics. Returns a list of strings which can be printed as lines. """ indent = " " * level lines = [] if isinstance(value, list): for v in value: lines.extend(pretty_print(v, level)) elif isinstance(value, dict): res = [] mlen = 0 for k, v in value.items(): if isinstance(k, Enum): k = k.name or str(k) p = pretty_print(v, level + 1) ml = len(p) > 1 or isinstance(v, (list, dict)) if not ml: mlen = max(mlen, len(k)) res.append((k, p, ml)) mlen += len(indent) + 1 for k, p, ml in res: k_line = f"{indent}{k}:".ljust(mlen) if ml: lines.append(k_line) lines.extend(p) if lines[-1] != "": lines.append("") else: lines.append(f"{k_line} {p[0].lstrip()}") elif isinstance(value, bytes): lines.append(f"{indent}{value.hex()}") else: lines.append(f"{indent}{value}") return lines def is_yk4_fips(info: DeviceInfo) -> bool: return info.version[0] == 4 and info.is_fips ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.9013216 yubikey_manager-5.2.1/ykman/base.py0000644000000000000000000000406314407612164014233 0ustar00# 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 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, ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1692611472.1437936 yubikey_manager-5.2.1/ykman/device.py0000644000000000000000000002472414470631620014564 0ustar00# 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 @_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() @_warn_once("No CTAP HID backend available. FIDO protocols will not function.") def list_ctap_devices(): """List CTAP devices.""" return _list_ctap_devices() @_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, } 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) 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 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1692611472.1437936 yubikey_manager-5.2.1/ykman/diagnostics.py0000644000000000000000000001640014470631620015624 0ustar00from . import __version__ as ykman_version from .util import get_windows_version from .pcsc import list_readers, list_devices as list_ccid_devices from .hid import list_otp_devices, list_ctap_devices from .piv import get_piv_info from .openpgp import get_openpgp_info from .hsmauth import get_hsmauth_info from yubikit.core.smartcard import SmartCardConnection from yubikit.core.fido import FidoConnection from yubikit.core.otp import OtpConnection from yubikit.management import ManagementSession from yubikit.yubiotp import YubiOtpSession from yubikit.piv import PivSession from yubikit.oath import OathSession from yubikit.openpgp import OpenPgpSession from yubikit.hsmauth import HsmAuthSession from yubikit.support import read_info, get_name from fido2.ctap import CtapError from fido2.ctap2 import Ctap2, ClientPin from dataclasses import asdict from datetime import datetime from typing import List, Dict, Any import platform import ctypes import sys import os def sys_info(): info: Dict[str, Any] = { "ykman": ykman_version, "Python": sys.version, "Platform": sys.platform, "Arch": platform.machine(), "System date": datetime.today().strftime("%Y-%m-%d"), } if sys.platform == "win32": info.update( { "Running as admin": bool(ctypes.windll.shell32.IsUserAnAdmin()), "Windows version": get_windows_version(), } ) else: info["Running as admin"] = os.getuid() == 0 return info def mgmt_info(pid, conn): data: List[Any] = [] try: data.append( { "Raw Info": ManagementSession(conn).backend.read_config(), } ) except Exception as e: data.append(f"Failed to read device info via Management: {e!r}") try: info = read_info(conn, pid) data.append( { "DeviceInfo": asdict(info), "Name": get_name(info, pid.yubikey_type), } ) except Exception as e: data.append(f"Failed to read device info: {e!r}") return data def piv_info(conn): try: piv = PivSession(conn) return get_piv_info(piv) except Exception as e: return f"PIV not accessible {e!r}" def openpgp_info(conn): try: openpgp = OpenPgpSession(conn) return get_openpgp_info(openpgp) except Exception as e: return f"OpenPGP not accessible {e!r}" def oath_info(conn): try: oath = OathSession(conn) return { "Oath version": ".".join("%d" % d for d in oath.version), "Password protected": oath.locked, } except Exception as e: return f"OATH not accessible {e!r}" def hsmauth_info(conn): try: hsmauth = HsmAuthSession(conn) return get_hsmauth_info(hsmauth) except Exception as e: return f"YubiHSM Auth not accessible {e!r}" def ccid_info(): try: readers = {} for reader in list_readers(): try: c = reader.createConnection() c.connect() c.disconnect() result = "Success" except Exception as e: result = f"<{e.__class__.__name__}>" readers[reader.name] = result yubikeys: Dict[str, Any] = {} for dev in list_ccid_devices(): try: with dev.open_connection(SmartCardConnection) as conn: yubikeys[f"{dev!r}"] = { "Management": mgmt_info(dev.pid, conn), "PIV": piv_info(conn), "OATH": oath_info(conn), "OpenPGP": openpgp_info(conn), "YubiHSM Auth": hsmauth_info(conn), } except Exception as e: yubikeys[f"{dev!r}"] = f"PC/SC connection failure: {e!r}" return { "Detected PC/SC readers": readers, "Detected YubiKeys over PC/SC": yubikeys, } except Exception as e: return f"PC/SC failure: {e!r}" def otp_info(): try: yubikeys: Dict[str, Any] = {} for dev in list_otp_devices(): try: dev_info = [] with dev.open_connection(OtpConnection) as conn: dev_info.append( { "Management": mgmt_info(dev.pid, conn), } ) otp = YubiOtpSession(conn) try: config = otp.get_config_state() dev_info.append({"OTP": [f"{config}"]}) except ValueError as e: dev_info.append({"OTP": f"Couldn't read OTP state: {e!r}"}) yubikeys[f"{dev!r}"] = dev_info except Exception as e: yubikeys[f"{dev!r}"] = f"OTP connection failure: {e!r}" return { "Detected YubiKeys over HID OTP": yubikeys, } except Exception as e: return f"HID OTP backend failure: {e!r}" def fido_info(): try: yubikeys: Dict[str, Any] = {} for dev in list_ctap_devices(): try: dev_info: List[Any] = [] with dev.open_connection(FidoConnection) as conn: dev_info.append( { "CTAP device version": "%d.%d.%d" % conn.device_version, "CTAPHID protocol version": conn.version, "Capabilities": conn.capabilities, "Management": mgmt_info(dev.pid, conn), } ) try: ctap2 = Ctap2(conn) ctap_data: Dict[str, Any] = {"Ctap2Info": asdict(ctap2.info)} if ctap2.info.options.get("clientPin"): client_pin = ClientPin(ctap2) ctap_data["PIN retries"] = client_pin.get_pin_retries() bio_enroll = ctap2.info.options.get("bioEnroll") if bio_enroll: ctap_data[ "Fingerprint retries" ] = client_pin.get_uv_retries() elif bio_enroll is False: ctap_data["Fingerprints"] = "Not configured" else: ctap_data["PIN"] = "Not configured" dev_info.append(ctap_data) except (ValueError, CtapError) as e: dev_info.append(f"Couldn't get CTAP2 info: {e!r}") yubikeys[f"{dev!r}"] = dev_info except Exception as e: yubikeys[f"{dev!r}"] = f"FIDO connection failure: {e!r}" return { "Detected YubiKeys over HID FIDO": yubikeys, } except Exception as e: return f"HID FIDO backend failure: {e!r}" def get_diagnostics(): """Runs diagnostics. The result of this can be printed using pretty_print. """ return [ sys_info(), ccid_info(), otp_info(), fido_info(), "End of diagnostics", ] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1692611472.1437936 yubikey_manager-5.2.1/ykman/fido.py0000644000000000000000000000742014470631620014240 0ustar00# 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 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 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) 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()) 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 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.9023232 yubikey_manager-5.2.1/ykman/hid/__init__.py0000644000000000000000000000744114407612164015627 0ustar00# 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 ..base import YkmanDevice, PID from .base import OtpYubiKeyDevice from yubikit.core import TRANSPORT from typing import List, Callable import sys import logging logger = logging.getLogger(__name__) if sys.platform.startswith("linux"): from . import linux as backend elif sys.platform.startswith("win32"): from . import windows as backend elif sys.platform.startswith("darwin"): from . import macos as backend elif sys.platform.startswith("freebsd"): from . import freebsd as backend else: class backend: @staticmethod def list_devices(): raise NotImplementedError( "OTP HID support is not implemented on this platform" ) list_otp_devices: Callable[[], List[OtpYubiKeyDevice]] = backend.list_devices try: from fido2.hid import list_descriptors, open_connection, CtapHidDevice class CtapYubiKeyDevice(YkmanDevice): """YubiKey FIDO USB HID device""" def __init__(self, descriptor): super(CtapYubiKeyDevice, self).__init__( TRANSPORT.USB, descriptor.path, PID(descriptor.pid) ) self.descriptor = descriptor def supports_connection(self, connection_type): return issubclass(CtapHidDevice, connection_type) def open_connection(self, connection_type): if self.supports_connection(connection_type): return CtapHidDevice(self.descriptor, open_connection(self.descriptor)) return super(CtapYubiKeyDevice, self).open_connection(connection_type) def list_ctap_devices() -> List[CtapYubiKeyDevice]: devs = [] for desc in list_descriptors(): if desc.vid == 0x1050: try: devs.append(CtapYubiKeyDevice(desc)) except ValueError: logger.debug(f"Unsupported Yubico device with PID: {desc.pid:02x}") return devs except Exception: # CTAP not supported on this platform class CtapYubiKeyDevice(YkmanDevice): # type: ignore def __init__(self, *args, **kwargs): raise NotImplementedError( "CTAP HID support is not implemented on this platform" ) def list_ctap_devices() -> List[CtapYubiKeyDevice]: raise NotImplementedError( "CTAP HID support is not implemented on this platform" ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.9023232 yubikey_manager-5.2.1/ykman/hid/base.py0000644000000000000000000000421314407612164014774 0ustar00# 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 yubikit.core import TRANSPORT, PID from ..base import YkmanDevice YUBICO_VID = 0x1050 USAGE_FIDO = (0xF1D0, 1) USAGE_OTP = (1, 6) class OtpYubiKeyDevice(YkmanDevice): """YubiKey USB HID OTP device""" def __init__(self, path, pid, connection_cls): super(OtpYubiKeyDevice, self).__init__(TRANSPORT.USB, path, PID(pid)) self.path = path self._connection_cls = connection_cls def supports_connection(self, connection_type): return issubclass(self._connection_cls, connection_type) def open_connection(self, connection_type): if self.supports_connection(connection_type): return self._connection_cls(self.path) return super(OtpYubiKeyDevice, self).open_connection(connection_type) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.9033933 yubikey_manager-5.2.1/ykman/hid/freebsd.py0000644000000000000000000002432414407612164015501 0ustar00# Original work Copyright 2016 Google Inc. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # Modified work Copyright 2022 Michael Gmelin. All Rights Reserved. # This file, with modifications, is licensed under the above Apache License. # # Modified work Copyright 2022 Yubico AB. All Rights Reserved. # This file, with modifications, is licensed under the above Apache License. # FreeBSD HID driver. # # There are two options to access UHID on FreeBSD: # # hidraw(4) - New method, not enabled by default # on FreeBSD 13.x and earlier # uhid(4) - Classic method, default option on # FreeBSD 13.x and earlier # # To avoid attaching the Yubikey as a keyboard, do: # # usbconfig ugenX.Y add_quirk UQ_KBD_IGNORE # usbconfig ugenX.Y reset # # The list of available devices is shown using `usbconfig list` # You can make these changes permanent by altering loader.conf. # # Starting from FreeBSD 13 hidraw(4) can be enabled using: # # sysrc kld_list+="hidraw hkbd" # cat >>/boot/loader.conf< HIDIOCGRAWINFO = 0x40085520 HIDIOCGRDESC = 0x2000551F HIDIOCGRDESCSIZE = 0x4004551E HIDIOCGFEATURE_9 = 0xC0095524 HIDIOCSFEATURE_9 = 0x80095523 class HidrawConnection(OtpConnection): """ hidraw(4) is FreeBSD's modern raw access driver, based on usbhid(4). It is available since FreeBSD 13 and can be activated by adding `hw.usb.usbhid.enable="1"` to `/boot/loader.conf`. The actual kernel module is loaded with `kldload hidraw`. """ def __init__(self, path): self.fd = os.open(path, os.O_RDWR) def close(self): os.close(self.fd) def receive(self): buf = bytearray(1 + 8) fcntl.ioctl(self.fd, HIDIOCGFEATURE_9, buf, True) return buf[1:] def send(self, data): buf = bytes([0]) + data fcntl.ioctl(self.fd, HIDIOCSFEATURE_9, buf) @staticmethod def get_info(dev): buf = bytearray(4 + 2 + 2) fcntl.ioctl(dev, HIDIOCGRAWINFO, buf, True) return struct.unpack("B", data)[0], data[1:] key, size = REPORT_DESCRIPTOR_KEY_MASK & head, SIZE_MASK & head value = struct.unpack_from(" bytes: """Generate a new random management key.""" return os.urandom(16) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.9043975 yubikey_manager-5.2.1/ykman/logging.py0000644000000000000000000000542714407612164014754 0ustar00# 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 yubikit.logging import LOG_LEVEL import logging logging.addLevelName(LOG_LEVEL.TRAFFIC, LOG_LEVEL.TRAFFIC.name) logger = logging.getLogger(__name__) def _print_box(*lines): w = max([len(ln) for ln in lines]) bar = "#" * (w + 4) box = ["", bar] for ln in [""] + list(lines) + [""]: box.append(f"# {ln.ljust(w)} #") box.append(bar) return "\n".join(box) TRAFFIC_WARNING = ( "WARNING: All data sent to/from the YubiKey will be logged!", "This data may contain sensitive values, such as secret keys, PINs or passwords!", ) DEBUG_WARNING = ( "WARNING: Sensitive data may be logged!", "Some personally identifying information may be logged, such as usernames!", ) def set_log_level(level: LOG_LEVEL): logging.getLogger().setLevel(level) logger.info(f"Logging at level: {level.name}") if level <= LOG_LEVEL.TRAFFIC: logger.warning(_print_box(*TRAFFIC_WARNING)) elif level <= LOG_LEVEL.DEBUG: logger.warning(_print_box(*DEBUG_WARNING)) def init_logging(log_level: LOG_LEVEL, log_file=None): logging.basicConfig( force=log_file is None, # Replace the default logger if logging to stderr datefmt="%H:%M:%S", filename=log_file, format="%(levelname)s %(asctime)s.%(msecs)d [%(name)s.%(funcName)s:%(lineno)d] " "%(message)s", ) set_log_level(log_level) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.9043975 yubikey_manager-5.2.1/ykman/logging_setup.py0000644000000000000000000000451714407612164016173 0ustar00# 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 ykman import __version__ as ykman_version from ykman.util import get_windows_version from ykman.logging import init_logging from yubikit.logging import LOG_LEVEL from datetime import datetime import platform import logging import ctypes import sys import os logger = logging.getLogger(__name__) def log_sys_info(log): log(f"ykman: {ykman_version}") log(f"Python: {sys.version}") log(f"Platform: {sys.platform}") log(f"Arch: {platform.machine()}") if sys.platform == "win32": log(f"Windows version: {get_windows_version()}") is_admin = bool(ctypes.windll.shell32.IsUserAnAdmin()) else: is_admin = os.getuid() == 0 log(f"Running as admin: {is_admin}") log("System date: %s", datetime.today().strftime("%Y-%m-%d")) def setup(log_level_name, log_file=None): log_level = LOG_LEVEL[log_level_name.upper()] init_logging(log_level, log_file=log_file) log_sys_info(logger.debug) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1696967723.945911 yubikey_manager-5.2.1/ykman/oath.py0000644000000000000000000000724314511326054014253 0ustar00# 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.core.smartcard import ApduError, SW from yubikit.oath import OathSession, Credential, OATH_TYPE from time import time from typing import Optional import struct import logging logger = logging.getLogger(__name__) STEAM_CHAR_TABLE = "23456789BCDFGHJKMNPQRTVWXY" def is_hidden(credential: Credential) -> bool: """Check if OATH credential is hidden.""" return credential.issuer == "_hidden" def is_steam(credential: Credential) -> bool: """Check if OATH credential is steam.""" return credential.oath_type == OATH_TYPE.TOTP and credential.issuer == "Steam" def calculate_steam( app: OathSession, credential: Credential, timestamp: Optional[int] = None ) -> str: """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) def is_in_fips_mode(app: OathSession) -> bool: """Check if OATH application is in FIPS mode.""" return app.locked def delete_broken_credential(app: OathSession) -> bool: """Checks for credential in a broken state and deletes it.""" logger.debug("Probing for broken credentials") creds = app.list_credentials() broken = [] for c in creds: if c.oath_type == OATH_TYPE.TOTP and not c.touch_required: for i in range(5): try: app.calculate_code(c) logger.debug(f"Credential appears OK: {c.id!r}") break except ApduError as e: if e.sw == SW.MEMORY_FAILURE: if i == 0: logger.debug(f"Memory failure in: {c.id!r}") continue raise else: broken.append(c.id) logger.warning(f"Credential appears to be broken: {c.id!r}") if len(broken) == 1: logger.info("Deleting broken credential") app.delete_credential(broken[0]) return True logger.warning(f"Requires a single broken credential, found {len(broken)}") return False ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1692611472.148818 yubikey_manager-5.2.1/ykman/openpgp.py0000644000000000000000000000500514470631620014764 0ustar00# 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 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 ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1692611472.148818 yubikey_manager-5.2.1/ykman/otp.py0000644000000000000000000001724414470631620014126 0ustar00# 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. from . import __version__ from .scancodes import KEYBOARD_LAYOUT from yubikit.core.otp import modhex_encode from yubikit.yubiotp import YubiOtpSession from yubikit.oath import parse_b32_key from enum import Enum from http.client import HTTPSConnection from datetime import datetime from typing import Iterable, Optional import json import struct import random import logging logger = logging.getLogger(__name__) _UPLOAD_HOST = "upload.yubico.com" _UPLOAD_PATH = "/prepare" class _PrepareUploadError(Enum): # Defined here CONNECTION_FAILED = "Failed to open HTTPS connection." NOT_FOUND = "Upload request not recognized by server." SERVICE_UNAVAILABLE = ( "Service temporarily unavailable, please try again later." # noqa: E501 ) # Defined in upload project PRIVATE_ID_INVALID_LENGTH = "Private ID must be 12 characters long." PRIVATE_ID_NOT_HEX = ( "Private ID must consist only of hex characters (0-9A-F)." # noqa: E501 ) PRIVATE_ID_UNDEFINED = "Private ID is required." PUBLIC_ID_INVALID_LENGTH = "Public ID must be 12 characters long." PUBLIC_ID_NOT_MODHEX = "Public ID must consist only of modhex characters (cbdefghijklnrtuv)." # noqa: E501 PUBLIC_ID_NOT_VV = 'Public ID must begin with "vv".' PUBLIC_ID_OCCUPIED = "Public ID is already in use." PUBLIC_ID_UNDEFINED = "Public ID is required." SECRET_KEY_INVALID_LENGTH = "Secret key must be 32 character long." # nosec SECRET_KEY_NOT_HEX = ( "Secret key must consist only of hex characters (0-9A-F)." # noqa: E501 # nosec ) SECRET_KEY_UNDEFINED = "Secret key is required." # nosec SERIAL_NOT_INT = "Serial number must be an integer." SERIAL_TOO_LONG = "Serial number is too long." def message(self): return self.value class _PrepareUploadFailed(Exception): def __init__(self, status, content, error_ids): super().__init__(f"Upload to YubiCloud failed with status {status}: {content}") self.status = status self.content = content self.errors = [ e if isinstance(e, _PrepareUploadError) else _PrepareUploadError[e] for e in error_ids ] def messages(self): return [e.message() for e in self.errors] def _prepare_upload_key( key, public_id, private_id, serial=None, user_agent="python-yubikey-manager/" + __version__, ): modhex_public_id = modhex_encode(public_id) data = { "aes_key": key.hex(), "serial": serial or 0, "public_id": modhex_public_id, "private_id": private_id.hex(), } httpconn = HTTPSConnection(_UPLOAD_HOST, timeout=1) # nosec try: httpconn.request( "POST", _UPLOAD_PATH, body=json.dumps(data, indent=False, sort_keys=True).encode("utf-8"), headers={"Content-Type": "application/json", "User-Agent": user_agent}, ) except Exception: logger.error("Failed to connect to %s", _UPLOAD_HOST, exc_info=True) raise _PrepareUploadFailed(None, None, [_PrepareUploadError.CONNECTION_FAILED]) resp = httpconn.getresponse() if resp.status == 200: url = json.loads(resp.read().decode("utf-8"))["finish_url"] return url else: resp_body = resp.read() logger.debug("Upload failed with status %d: %s", resp.status, resp_body) if resp.status == 404: raise _PrepareUploadFailed( resp.status, resp_body, [_PrepareUploadError.NOT_FOUND] ) elif resp.status == 503: raise _PrepareUploadFailed( resp.status, resp_body, [_PrepareUploadError.SERVICE_UNAVAILABLE] ) else: try: errors = json.loads(resp_body.decode("utf-8")).get("errors") except Exception: errors = [] raise _PrepareUploadFailed(resp.status, resp_body, errors) def is_in_fips_mode(session: YubiOtpSession) -> bool: """Check if the OTP application of a FIPS YubiKey is in FIPS approved mode. :param session: The YubiOTP session. """ return session.backend.send_and_receive(0x14, b"", 1) == b"\1" # type: ignore DEFAULT_PW_CHAR_BLOCKLIST = ["\t", "\n", " "] def generate_static_pw( length: int, keyboard_layout: KEYBOARD_LAYOUT = KEYBOARD_LAYOUT.MODHEX, blocklist: Iterable[str] = DEFAULT_PW_CHAR_BLOCKLIST, ) -> str: """Generate a random password. :param length: The length of the password. :param keyboard_layout: The keyboard layout. :param blocklist: The list of characters to block. """ chars = [k for k in keyboard_layout.value.keys() if k not in blocklist] sr = random.SystemRandom() return "".join([sr.choice(chars) for _ in range(length)]) def parse_oath_key(val: str) -> bytes: """Parse a secret key encoded as either Hex or Base32. :param val: The secret key. """ try: return bytes.fromhex(val) except ValueError: return parse_b32_key(val) def format_oath_code(response: bytes, digits: int = 6) -> str: """Format an OATH code from a hash response. :param response: The response. :param digits: The number of digits in the OATH code. """ offs = response[-1] & 0xF code = struct.unpack_from(">I", response[offs:])[0] & 0x7FFFFFFF return ("%%0%dd" % digits) % (code % 10**digits) def time_challenge(timestamp: int, period: int = 30) -> bytes: """Format a HMAC-SHA1 challenge based on an OATH timestamp and period. :param timestamp: The timestamp. :param period: The period. """ return struct.pack(">q", int(timestamp // period)) def format_csv( serial: int, public_id: bytes, private_id: bytes, key: bytes, access_code: Optional[bytes] = None, timestamp: Optional[datetime] = None, ) -> str: """Produce a CSV line in the "Yubico" format. :param serial: The serial number. :param public_id: The public ID. :param private_id: The private ID. :param key: The secret key. :param access_code: The access code. """ ts = timestamp or datetime.now() return ",".join( [ str(serial), modhex_encode(public_id), private_id.hex(), key.hex(), access_code.hex() if access_code else "", ts.isoformat(timespec="seconds"), "", # Add trailing comma ] ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.9053967 yubikey_manager-5.2.1/ykman/pcsc/__init__.py0000644000000000000000000001432614407612164016013 0ustar00# 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 ..base import YkmanDevice from yubikit.core import TRANSPORT, YUBIKEY, PID from yubikit.core.smartcard import SmartCardConnection from yubikit.management import USB_INTERFACE from yubikit.logging import LOG_LEVEL from smartcard import System from smartcard.Exceptions import CardConnectionException from smartcard.pcsc.PCSCExceptions import ListReadersException from smartcard.pcsc.PCSCContext import PCSCContext from fido2.pcsc import CtapPcscDevice from time import sleep import subprocess # nosec import logging logger = logging.getLogger(__name__) YK_READER_NAME = "yubico yubikey" # Figure out what the PID should be based on the reader name def _pid_from_name(name): if YK_READER_NAME not in name.lower(): return None interfaces = USB_INTERFACE(0) for iface in USB_INTERFACE: if iface.name in name: interfaces |= iface if "U2F" in name: interfaces |= USB_INTERFACE.FIDO key_type = YUBIKEY.NEO if "NEO" in name else YUBIKEY.YK4 return PID.of(key_type, interfaces) class ScardYubiKeyDevice(YkmanDevice): """YubiKey Smart card device""" def __init__(self, reader): # Base transport on reader name: NFC readers will have a different name if YK_READER_NAME in reader.name.lower(): transport = TRANSPORT.USB else: transport = TRANSPORT.NFC super(ScardYubiKeyDevice, self).__init__( transport, reader.name, _pid_from_name(reader.name) ) self.reader = reader def supports_connection(self, connection_type): if issubclass(CtapPcscDevice, connection_type): return self.transport == TRANSPORT.NFC return issubclass(ScardSmartCardConnection, connection_type) def open_connection(self, connection_type): if issubclass(ScardSmartCardConnection, connection_type): return self._open_smartcard_connection() elif issubclass(CtapPcscDevice, connection_type): if self.transport == TRANSPORT.NFC: return CtapPcscDevice(self.reader.createConnection(), self.reader.name) return super(ScardYubiKeyDevice, self).open_connection(connection_type) def _open_smartcard_connection(self) -> SmartCardConnection: try: return ScardSmartCardConnection(self.reader.createConnection()) except CardConnectionException as e: if kill_scdaemon(): return ScardSmartCardConnection(self.reader.createConnection()) raise e class ScardSmartCardConnection(SmartCardConnection): def __init__(self, connection): self.connection = connection connection.connect() atr = connection.getATR() self._transport = ( TRANSPORT.USB if atr and atr[1] & 0xF0 == 0xF0 else TRANSPORT.NFC ) @property def transport(self): return self._transport def close(self): self.connection.disconnect() def send_and_receive(self, apdu): """Sends a command APDU and returns the response data and sw""" logger.log(LOG_LEVEL.TRAFFIC, "SEND: %s", apdu.hex()) data, sw1, sw2 = self.connection.transmit(list(apdu)) logger.log( LOG_LEVEL.TRAFFIC, "RECV: %s SW=%02x%02x", bytes(data).hex(), sw1, sw2 ) return bytes(data), sw1 << 8 | sw2 def kill_scdaemon(): killed = False try: # Works for Windows. from win32com.client import GetObject from win32api import OpenProcess, CloseHandle, TerminateProcess wmi = GetObject("winmgmts:") ps = wmi.InstancesOf("Win32_Process") for p in ps: if p.Properties_("Name").Value == "scdaemon.exe": pid = p.Properties_("ProcessID").Value handle = OpenProcess(1, False, pid) TerminateProcess(handle, -1) CloseHandle(handle) killed = True except ImportError: # Works for Linux and OS X. return_code = subprocess.call(["pkill", "-9", "scdaemon"]) # nosec if return_code == 0: killed = True if killed: sleep(0.1) return killed def list_readers(): try: return System.readers() except ListReadersException: # If the PCSC system has restarted the context might be stale, try # forcing a new context (This happens on Windows if the last reader is # removed): PCSCContext.instance = None return System.readers() def list_devices(name_filter=None): name_filter = YK_READER_NAME if name_filter is None else name_filter devices = [] for reader in list_readers(): if name_filter.lower() in reader.name.lower(): devices.append(ScardYubiKeyDevice(reader)) return devices ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1692616769.9884138 yubikey_manager-5.2.1/ykman/piv.py0000644000000000000000000006054214470644102014120 0ustar00# 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+)+") 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") 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")) 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) 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 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) class PivmanProtectedData: def __init__(self, raw_data: bytes = Tlv(0x88)): data = Tlv.parse_dict(Tlv(raw_data).value) self.key = data.get(0x89) def get_bytes(self) -> bytes: data = b"" if self.key is not None: data += Tlv(0x89, self.key) return Tlv(0x88, data) 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 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 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) 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()) 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 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 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) ) 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) ) 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, ] 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()) 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()) 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) 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) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.9053967 yubikey_manager-5.2.1/ykman/py.typed0000644000000000000000000000000014407612164014431 0ustar00././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.9053967 yubikey_manager-5.2.1/ykman/scancodes/__init__.py0000644000000000000000000000364214407612164017024 0ustar00# 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. from enum import Enum from . import us, uk, de, fr, it, modhex, norman, bepo class KEYBOARD_LAYOUT(Enum): MODHEX = modhex.scancodes US = us.scancodes UK = uk.scancodes DE = de.scancodes FR = fr.scancodes IT = it.scancodes BEPO = bepo.scancodes NORMAN = norman.scancodes def encode(data, keyboard_layout=KEYBOARD_LAYOUT.MODHEX): try: return bytes(bytearray(keyboard_layout.value[c] for c in data)) except KeyError as e: raise ValueError(f"Unsupported character: {e.args[0]}") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.9063976 yubikey_manager-5.2.1/ykman/scancodes/bepo.py0000644000000000000000000000672314407612164016215 0ustar00# vim: set fileencoding:utf-8 : # 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. """Scancode map for BÉPO (fr dvorak) keyboard layout""" SHIFT = 0x80 scancodes = { "\t": 0x2B | SHIFT, "\n": 0x28 | SHIFT, " ": 0x2C, "!": 0x1C | SHIFT, '"': 0x1E, "#": 0x35 | SHIFT, "$": 0x35, "%": 0x2E, "'": 0x11, "(": 0x21, ")": 0x22, "*": 0x27, "+": 0x24, ",": 0x0A, "-": 0x25, ".": 0x19, "/": 0x26, "0": 0x27 | SHIFT, "1": 0x1E | SHIFT, "2": 0x1F | SHIFT, "3": 0x20 | SHIFT, "4": 0x21 | SHIFT, "5": 0x22 | SHIFT, "6": 0x23 | SHIFT, "7": 0x24 | SHIFT, "8": 0x25 | SHIFT, "9": 0x26 | SHIFT, ":": 0x19 | SHIFT, ";": 0x0A | SHIFT, "=": 0x2D, "?": 0x11 | SHIFT, "@": 0x23, "A": 0x04 | SHIFT, "B": 0x14 | SHIFT, "C": 0x0B | SHIFT, "D": 0x0C | SHIFT, "E": 0x09 | SHIFT, "F": 0x38 | SHIFT, "G": 0x36 | SHIFT, "H": 0x37 | SHIFT, "I": 0x07 | SHIFT, "J": 0x13 | SHIFT, "K": 0x05 | SHIFT, "L": 0x12 | SHIFT, "M": 0x34 | SHIFT, "N": 0x33 | SHIFT, "O": 0x15 | SHIFT, "P": 0x08 | SHIFT, "Q": 0x10 | SHIFT, "R": 0x0F | SHIFT, "S": 0x0E | SHIFT, "T": 0x0D | SHIFT, "U": 0x16 | SHIFT, "V": 0x18 | SHIFT, "W": 0x30 | SHIFT, "X": 0x06 | SHIFT, "Y": 0x1B | SHIFT, "Z": 0x2F | SHIFT, "`": 0x2E | SHIFT, "a": 0x04, "b": 0x14, "c": 0x0B, "d": 0x0C, "e": 0x09, "f": 0x38, "g": 0x36, "h": 0x37, "i": 0x07, "j": 0x13, "k": 0x05, "l": 0x12, "m": 0x34, "n": 0x33, "o": 0x15, "p": 0x08, "q": 0x10, "r": 0x0F, "s": 0x0E, "t": 0x0D, "u": 0x16, "v": 0x18, "w": 0x30, "x": 0x06, "y": 0x1B, "z": 0x2F, "\xa0": 0x2C | SHIFT, "«": 0x1F, "°": 0x2D | SHIFT, "»": 0x20, "À": 0x1D | SHIFT, "Ç": 0x31 | SHIFT, "È": 0x17 | SHIFT, "É": 0x1A | SHIFT, "Ê": 0x64 | SHIFT, "à": 0x1D, "ç": 0x31, "è": 0x17, "é": 0x1A, "ê": 0x64, } ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.9063976 yubikey_manager-5.2.1/ykman/scancodes/de.py0000644000000000000000000000662414407612164015660 0ustar00# vim: set fileencoding=utf-8 : # 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. """Scancode map for DE German keyboard layout""" SHIFT = 0x80 scancodes = { "a": 0x04, "b": 0x05, "c": 0x06, "d": 0x07, "e": 0x08, "f": 0x09, "g": 0x0A, "h": 0x0B, "i": 0x0C, "j": 0x0D, "k": 0x0E, "l": 0x0F, "m": 0x10, "n": 0x11, "o": 0x12, "p": 0x13, "q": 0x14, "r": 0x15, "s": 0x16, "t": 0x17, "u": 0x18, "v": 0x19, "w": 0x1A, "x": 0x1B, "y": 0x1D, "z": 0x1C, "A": 0x04 | SHIFT, "B": 0x05 | SHIFT, "C": 0x06 | SHIFT, "D": 0x07 | SHIFT, "E": 0x08 | SHIFT, "F": 0x09 | SHIFT, "G": 0x0A | SHIFT, "H": 0x0B | SHIFT, "I": 0x0C | SHIFT, "J": 0x0D | SHIFT, "K": 0x0E | SHIFT, "L": 0x0F | SHIFT, "M": 0x10 | SHIFT, "N": 0x11 | SHIFT, "O": 0x12 | SHIFT, "P": 0x13 | SHIFT, "Q": 0x14 | SHIFT, "R": 0x15 | SHIFT, "S": 0x16 | SHIFT, "T": 0x17 | SHIFT, "U": 0x18 | SHIFT, "V": 0x19 | SHIFT, "W": 0x1A | SHIFT, "X": 0x1B | SHIFT, "Y": 0x1D | SHIFT, "Z": 0x1C | SHIFT, "0": 0x27, "1": 0x1E, "2": 0x1F, "3": 0x20, "4": 0x21, "5": 0x22, "6": 0x23, "7": 0x24, "8": 0x25, "9": 0x26, "\t": 0x2B, "\n": 0x28, "!": 0x1E | SHIFT, '"': 0x1F | SHIFT, "#": 0x32, "$": 0x21 | SHIFT, "%": 0x22 | SHIFT, "&": 0x23 | SHIFT, "'": 0x32 | SHIFT, "(": 0x25 | SHIFT, ")": 0x26 | SHIFT, "*": 0x30 | SHIFT, "+": 0x30, ",": 0x36, "-": 0x38, ".": 0x37, "/": 0x24 | SHIFT, ":": 0x37 | SHIFT, ";": 0x36 | SHIFT, "<": 0x64, "=": 0x27 | SHIFT, ">": 0x64 | SHIFT, "?": 0x2D | SHIFT, "^": 0x35, "_": 0x38 | SHIFT, " ": 0x2C, "`": 0x2D | SHIFT, "§": 0x20 | SHIFT, "´": 0x2E, "Ä": 0x34 | SHIFT, "Ö": 0x33 | SHIFT, "Ü": 0x2F | SHIFT, "ß": 0x2D, "ä": 0x34, "ö": 0x33, "ü": 0x2F, } ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.9063976 yubikey_manager-5.2.1/ykman/scancodes/fr.py0000644000000000000000000000653114407612164015674 0ustar00# vim: set fileencoding:utf-8 : # 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. """Scancode map for FR French (AZERTY) keyboard layout""" SHIFT = 0x80 scancodes = { "a": 0x14, "b": 0x05, "c": 0x06, "d": 0x07, "e": 0x08, "f": 0x09, "g": 0x0A, "h": 0x0B, "i": 0x0C, "j": 0x0D, "k": 0x0E, "l": 0x0F, "m": 0x33, "n": 0x11, "o": 0x12, "p": 0x13, "q": 0x04, "r": 0x15, "s": 0x16, "t": 0x17, "u": 0x18, "v": 0x19, "w": 0x1D, "x": 0x1B, "y": 0x1C, "z": 0x1A, "A": 0x14 | SHIFT, "B": 0x05 | SHIFT, "C": 0x06 | SHIFT, "D": 0x07 | SHIFT, "E": 0x08 | SHIFT, "F": 0x09 | SHIFT, "G": 0x0A | SHIFT, "H": 0x0B | SHIFT, "I": 0x0C | SHIFT, "J": 0x0D | SHIFT, "K": 0x0E | SHIFT, "L": 0x0F | SHIFT, "M": 0x33 | SHIFT, "N": 0x11 | SHIFT, "O": 0x12 | SHIFT, "P": 0x13 | SHIFT, "Q": 0x04 | SHIFT, "R": 0x15 | SHIFT, "S": 0x16 | SHIFT, "T": 0x17 | SHIFT, "U": 0x18 | SHIFT, "V": 0x19 | SHIFT, "W": 0x1D | SHIFT, "X": 0x1B | SHIFT, "Y": 0x1C | SHIFT, "Z": 0x1A | SHIFT, "0": 0x27 | SHIFT, "1": 0x1E | SHIFT, "2": 0x1F | SHIFT, "3": 0x20 | SHIFT, "4": 0x21 | SHIFT, "5": 0x22 | SHIFT, "6": 0x23 | SHIFT, "7": 0x24 | SHIFT, "8": 0x25 | SHIFT, "9": 0x26 | SHIFT, "\t": 0x2B, "\n": 0x28, " ": 0x2C, "!": 0x38, '"': 0x20, "$": 0x30, "%": 0x34 | SHIFT, "&": 0x1E, "'": 0x21, "(": 0x22, ")": 0x2D, "*": 0x31, "+": 0x2E | SHIFT, ",": 0x10, "-": 0x23, ".": 0x36 | SHIFT, "/": 0x37 | SHIFT, ":": 0x37, ";": 0x36, "<": 0x64, "=": 0x2E, "_": 0x25, "\x7f": 0x2A, "£": 0x30 | SHIFT, "§": 0x38 | SHIFT, "°": 0x2D | SHIFT, "²": 0x35, "µ": 0x31 | SHIFT, "à": 0x27, "ç": 0x26, "è": 0x24, "é": 0x1F, "ù": 0x34, } ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.9063976 yubikey_manager-5.2.1/ykman/scancodes/it.py0000644000000000000000000000664014407612164015702 0ustar00# vim: set fileencoding:utf-8 : # 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. """Scancode map for IT Italian (AZERTY) keyboard layout""" SHIFT = 0x80 scancodes = { "\t": 0x2B, "\n": 0x28, " ": 0x2C, "!": 0x1E | SHIFT, '"': 0x1F | SHIFT, "#": 0x32, "$": 0x21 | SHIFT, "%": 0x22 | SHIFT, "&": 0x23 | SHIFT, "'": 0x2D, "(": 0x25 | SHIFT, ")": 0x26 | SHIFT, "*": 0x55, "+": 0x30, ",": 0x36, "-": 0x38, ".": 0x63, "/": 0x24 | SHIFT, "0": 0x27, "1": 0x1E, "2": 0x1F, "3": 0x20, "4": 0x21, "5": 0x22, "6": 0x23, "7": 0x24, "8": 0x25, "9": 0x26, ":": 0xB7, ";": 0xB6, "<": 0x64, "=": 0x27 | SHIFT, ">": 0x64 | SHIFT, "?": 0x2D | SHIFT, "@": 0x24, "A": 0x04 | SHIFT, "B": 0x05 | SHIFT, "C": 0x06 | SHIFT, "D": 0x07 | SHIFT, "E": 0x08 | SHIFT, "F": 0x09 | SHIFT, "G": 0x0A | SHIFT, "H": 0x0B | SHIFT, "I": 0x0C | SHIFT, "J": 0x0D | SHIFT, "K": 0x0E | SHIFT, "L": 0x0F | SHIFT, "M": 0x10 | SHIFT, "N": 0x11 | SHIFT, "O": 0x12 | SHIFT, "P": 0x13 | SHIFT, "Q": 0x14 | SHIFT, "R": 0x15 | SHIFT, "S": 0x16 | SHIFT, "T": 0x17 | SHIFT, "U": 0x18 | SHIFT, "V": 0x19 | SHIFT, "W": 0x1A | SHIFT, "X": 0x1B | SHIFT, "Y": 0x1C | SHIFT, "Z": 0x1D | SHIFT, "\\": 0x35, "^": 0xAE, "_": 0xB8, "`": 0x2D | SHIFT, "a": 0x04, "b": 0x05, "c": 0x06, "d": 0x07, "e": 0x08, "f": 0x09, "g": 0x0A, "h": 0x0B, "i": 0x0C, "j": 0x0D, "k": 0x0E, "l": 0x0F, "m": 0x10, "n": 0x11, "o": 0x12, "p": 0x13, "q": 0x14, "r": 0x15, "s": 0x16, "t": 0x17, "u": 0x18, "v": 0x19, "w": 0x1A, "x": 0x1B, "y": 0x1C, "z": 0x1D, "|": 0xB5, "£": 0xA0, "§": 0xB2, "°": 0xB4, "ç": 0xB3, "è": 0x2F, "é": 0x2F | SHIFT, "à": 0x34, "ì": 0x2E, "ò": 0x33, "ù": 0x31, } ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.9074368 yubikey_manager-5.2.1/ykman/scancodes/modhex.py0000644000000000000000000000430014407612164016541 0ustar00# vim: set fileencoding=utf-8 : # 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. """Scancode map for keyboard layout based on Modhex. Note that this layouts allows both upper and lowercase characters.""" SHIFT = 0x80 scancodes = { "b": 0x05, "c": 0x06, "d": 0x07, "e": 0x08, "f": 0x09, "g": 0x0A, "h": 0x0B, "i": 0x0C, "j": 0x0D, "k": 0x0E, "l": 0x0F, "n": 0x11, "r": 0x15, "t": 0x17, "u": 0x18, "v": 0x19, "B": 0x05 | SHIFT, "C": 0x06 | SHIFT, "D": 0x07 | SHIFT, "E": 0x08 | SHIFT, "F": 0x09 | SHIFT, "G": 0x0A | SHIFT, "H": 0x0B | SHIFT, "I": 0x0C | SHIFT, "J": 0x0D | SHIFT, "K": 0x0E | SHIFT, "L": 0x0F | SHIFT, "N": 0x11 | SHIFT, "R": 0x15 | SHIFT, "T": 0x17 | SHIFT, "U": 0x18 | SHIFT, "V": 0x19 | SHIFT, } ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.9074368 yubikey_manager-5.2.1/ykman/scancodes/norman.py0000644000000000000000000000656414407612164016565 0ustar00# vim: set fileencoding=utf-8 : # 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. """Scancode map for US English Norman keyboard layout""" SHIFT = 0x80 scancodes = { "a": 0x04, "b": 0x05, "c": 0x06, "d": 0x08, "e": 0x07, "f": 0x15, "g": 0x0A, "h": 0x33, "i": 0x0E, "j": 0x1C, "k": 0x17, "l": 0x12, "m": 0x10, "n": 0x0D, "o": 0x0F, "p": 0x11, "q": 0x14, "r": 0x0C, "s": 0x16, "t": 0x09, "u": 0x18, "v": 0x19, "w": 0x1A, "x": 0x1B, "y": 0x0B, "z": 0x1D, "A": 0x04 | SHIFT, "B": 0x05 | SHIFT, "C": 0x06 | SHIFT, "D": 0x08 | SHIFT, "E": 0x07 | SHIFT, "F": 0x15 | SHIFT, "G": 0x0A | SHIFT, "H": 0x33 | SHIFT, "I": 0x0E | SHIFT, "J": 0x1C | SHIFT, "K": 0x17 | SHIFT, "L": 0x12 | SHIFT, "M": 0x10 | SHIFT, "N": 0x0D | SHIFT, "O": 0x0F | SHIFT, "P": 0x11 | SHIFT, "Q": 0x14 | SHIFT, "R": 0x0C | SHIFT, "S": 0x16 | SHIFT, "T": 0x09 | SHIFT, "U": 0x18 | SHIFT, "V": 0x19 | SHIFT, "W": 0x1A | SHIFT, "X": 0x1B | SHIFT, "Y": 0x0B | SHIFT, "Z": 0x1D | SHIFT, "0": 0x27, "1": 0x1E, "2": 0x1F, "3": 0x20, "4": 0x21, "5": 0x22, "6": 0x23, "7": 0x24, "8": 0x25, "9": 0x26, "\t": 0x2B, "\n": 0x28, "!": 0x1E | SHIFT, '"': 0x34 | SHIFT, "#": 0x20 | SHIFT, "$": 0x21 | SHIFT, "%": 0x22 | SHIFT, "&": 0x24 | SHIFT, "'": 0x34, "`": 0x35, "(": 0x26 | SHIFT, ")": 0x27 | SHIFT, "*": 0x25 | SHIFT, "+": 0x2E | SHIFT, ",": 0x36, "-": 0x2D, ".": 0x37, "/": 0x38, ":": 0x33 | SHIFT, ";": 0x13, "<": 0x36 | SHIFT, "=": 0x2E, ">": 0x37 | SHIFT, "?": 0x38 | SHIFT, "@": 0x1F | SHIFT, "[": 0x2F, "\\": 0x32, "]": 0x30, "^": 0xA3, "_": 0xAD, "{": 0x2F | SHIFT, "}": 0x30 | SHIFT, "|": 0x32 | SHIFT, "~": 0x35 | SHIFT, " ": 0x2C, } ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.9074368 yubikey_manager-5.2.1/ykman/scancodes/uk.py0000644000000000000000000000655614407612164015713 0ustar00# vim: set fileencoding=utf-8 : # 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. """Scancode map for UK English keyboard layout""" SHIFT = 0x80 scancodes = { "a": 0x04, "b": 0x05, "c": 0x06, "d": 0x07, "e": 0x08, "f": 0x09, "g": 0x0A, "h": 0x0B, "i": 0x0C, "j": 0x0D, "k": 0x0E, "l": 0x0F, "m": 0x10, "n": 0x11, "o": 0x12, "p": 0x13, "q": 0x14, "r": 0x15, "s": 0x16, "t": 0x17, "u": 0x18, "v": 0x19, "w": 0x1A, "x": 0x1B, "y": 0x1C, "z": 0x1D, "A": 0x04 | SHIFT, "B": 0x05 | SHIFT, "C": 0x06 | SHIFT, "D": 0x07 | SHIFT, "E": 0x08 | SHIFT, "F": 0x09 | SHIFT, "G": 0x0A | SHIFT, "H": 0x0B | SHIFT, "I": 0x0C | SHIFT, "J": 0x0D | SHIFT, "K": 0x0E | SHIFT, "L": 0x0F | SHIFT, "M": 0x10 | SHIFT, "N": 0x11 | SHIFT, "O": 0x12 | SHIFT, "P": 0x13 | SHIFT, "Q": 0x14 | SHIFT, "R": 0x15 | SHIFT, "S": 0x16 | SHIFT, "T": 0x17 | SHIFT, "U": 0x18 | SHIFT, "V": 0x19 | SHIFT, "W": 0x1A | SHIFT, "X": 0x1B | SHIFT, "Y": 0x1C | SHIFT, "Z": 0x1D | SHIFT, "0": 0x27, "1": 0x1E, "2": 0x1F, "3": 0x20, "4": 0x21, "5": 0x22, "6": 0x23, "7": 0x24, "8": 0x25, "9": 0x26, "\t": 0x2B, "\n": 0x28, "!": 0x1E | SHIFT, "@": 0x34 | SHIFT, "£": 0x20 | SHIFT, "$": 0x21 | SHIFT, "%": 0x22 | SHIFT, "&": 0x24 | SHIFT, "'": 0x34, "`": 0x35, "(": 0x26 | SHIFT, ")": 0x27 | SHIFT, "*": 0x25 | SHIFT, "+": 0x2E | SHIFT, ",": 0x36, "-": 0x2D, ".": 0x37, "/": 0x38, ":": 0x33 | SHIFT, ";": 0x33, "<": 0x36 | SHIFT, "=": 0x2E, ">": 0x37 | SHIFT, "?": 0x38 | SHIFT, '"': 0x1F | SHIFT, "[": 0x2F, "#": 0x32, "]": 0x30, "^": 0xA3, "_": 0xAD, "{": 0x2F | SHIFT, "}": 0x30 | SHIFT, "~": 0x32 | SHIFT, "¬": 0x35 | SHIFT, " ": 0x2C, } ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.9074368 yubikey_manager-5.2.1/ykman/scancodes/us.py0000644000000000000000000000655514407612164015722 0ustar00# vim: set fileencoding=utf-8 : # 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. """Scancode map for US English keyboard layout""" SHIFT = 0x80 scancodes = { "a": 0x04, "b": 0x05, "c": 0x06, "d": 0x07, "e": 0x08, "f": 0x09, "g": 0x0A, "h": 0x0B, "i": 0x0C, "j": 0x0D, "k": 0x0E, "l": 0x0F, "m": 0x10, "n": 0x11, "o": 0x12, "p": 0x13, "q": 0x14, "r": 0x15, "s": 0x16, "t": 0x17, "u": 0x18, "v": 0x19, "w": 0x1A, "x": 0x1B, "y": 0x1C, "z": 0x1D, "A": 0x04 | SHIFT, "B": 0x05 | SHIFT, "C": 0x06 | SHIFT, "D": 0x07 | SHIFT, "E": 0x08 | SHIFT, "F": 0x09 | SHIFT, "G": 0x0A | SHIFT, "H": 0x0B | SHIFT, "I": 0x0C | SHIFT, "J": 0x0D | SHIFT, "K": 0x0E | SHIFT, "L": 0x0F | SHIFT, "M": 0x10 | SHIFT, "N": 0x11 | SHIFT, "O": 0x12 | SHIFT, "P": 0x13 | SHIFT, "Q": 0x14 | SHIFT, "R": 0x15 | SHIFT, "S": 0x16 | SHIFT, "T": 0x17 | SHIFT, "U": 0x18 | SHIFT, "V": 0x19 | SHIFT, "W": 0x1A | SHIFT, "X": 0x1B | SHIFT, "Y": 0x1C | SHIFT, "Z": 0x1D | SHIFT, "0": 0x27, "1": 0x1E, "2": 0x1F, "3": 0x20, "4": 0x21, "5": 0x22, "6": 0x23, "7": 0x24, "8": 0x25, "9": 0x26, "\t": 0x2B, "\n": 0x28, "!": 0x1E | SHIFT, '"': 0x34 | SHIFT, "#": 0x20 | SHIFT, "$": 0x21 | SHIFT, "%": 0x22 | SHIFT, "&": 0x24 | SHIFT, "'": 0x34, "`": 0x35, "(": 0x26 | SHIFT, ")": 0x27 | SHIFT, "*": 0x25 | SHIFT, "+": 0x2E | SHIFT, ",": 0x36, "-": 0x2D, ".": 0x37, "/": 0x38, ":": 0x33 | SHIFT, ";": 0x33, "<": 0x36 | SHIFT, "=": 0x2E, ">": 0x37 | SHIFT, "?": 0x38 | SHIFT, "@": 0x1F | SHIFT, "[": 0x2F, "\\": 0x32, "]": 0x30, "^": 0xA3, "_": 0xAD, "{": 0x2F | SHIFT, "}": 0x30 | SHIFT, "|": 0x32 | SHIFT, "~": 0x35 | SHIFT, " ": 0x2C, } ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1692611472.148818 yubikey_manager-5.2.1/ykman/scripting.py0000644000000000000000000002115314470631620015320 0ustar00# 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") """ 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 def otp(self) -> OtpConnection: """Establish a OTP connection.""" return self.open_connection(OtpConnection) def smart_card(self) -> SmartCardConnection: """Establish a Smart Card connection.""" return self.open_connection(SmartCardConnection) def fido(self) -> FidoConnection: """Establish a FIDO connection.""" return self.open_connection(FidoConnection) YkmanDevice.register(ScriptingDevice) 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") 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] 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) 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 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.9084005 yubikey_manager-5.2.1/ykman/settings.py0000644000000000000000000001006114407612164015154 0ustar00# 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. import os import json import keyring from pathlib import Path from cryptography.fernet import Fernet, InvalidToken XDG_DATA_HOME = os.environ.get("XDG_DATA_HOME", "~/.local/share") + "/ykman" XDG_CONFIG_HOME = os.environ.get("XDG_CONFIG_HOME", "~/.config") + "/ykman" KEYRING_SERVICE = os.environ.get("YKMAN_KEYRING_SERVICE", "ykman") KEYRING_KEY = os.environ.get("YKMAN_KEYRING_KEY", "wrap_key") class Settings(dict): _config_dir = XDG_CONFIG_HOME def __init__(self, name): self.fname = Path(self._config_dir).expanduser().resolve() / (name + ".json") if self.fname.is_file(): with self.fname.open("r") as fd: self.update(json.load(fd)) def __eq__(self, other): return other is not None and self.fname == other.fname def __ne__(self, other): return other is None or self.fname != other.fname def write(self): conf_dir = self.fname.parent if not conf_dir.is_dir(): conf_dir.mkdir(0o700, parents=True) with self.fname.open("w") as fd: json.dump(self, fd, indent=2) __hash__ = None class Configuration(Settings): _config_dir = XDG_CONFIG_HOME class KeystoreError(Exception): """Error accessing the OS keystore""" class UnwrapValueError(Exception): """Error unwrapping a particular secret value""" class AppData(Settings): _config_dir = XDG_DATA_HOME def __init__(self, name, keyring_service=KEYRING_SERVICE, keyring_key=KEYRING_KEY): super().__init__(name) self._service = keyring_service self._username = keyring_key @property def keyring_unlocked(self) -> bool: return hasattr(self, "_fernet") def ensure_unlocked(self): if not self.keyring_unlocked: try: wrap_key = keyring.get_password(self._service, self._username) except keyring.errors.KeyringError: raise KeystoreError("Keyring locked or unavailable") if wrap_key is None: key = Fernet.generate_key() keyring.set_password(self._service, self._username, key.decode()) self._fernet = Fernet(key) else: self._fernet = Fernet(wrap_key) def get_secret(self, key: str): self.ensure_unlocked() try: return json.loads(self._fernet.decrypt(self[key].encode())) except InvalidToken: raise UnwrapValueError("Undecryptable value") def put_secret(self, key: str, value) -> None: self.ensure_unlocked() self[key] = self._fernet.encrypt(json.dumps(value).encode()).decode() ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1692611472.148818 yubikey_manager-5.2.1/ykman/util.py0000644000000000000000000001463414470631620014301 0ustar00# 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.core import Tlv from cryptography.hazmat.primitives.serialization import pkcs12 from cryptography.hazmat.primitives import serialization from cryptography.hazmat.backends import default_backend from cryptography import x509 from typing import Tuple import ctypes import logging logger = logging.getLogger(__name__) PEM_IDENTIFIER = b"-----BEGIN" class InvalidPasswordError(Exception): """Raised when parsing key/certificate and the password might be wrong/missing.""" def _parse_pkcs12(data, password): try: key, cert, cas = pkcs12.load_key_and_certificates( data, password, default_backend() ) if cert: cas.insert(0, cert) return key, cas except ValueError as e: # cryptography raises ValueError on wrong password raise InvalidPasswordError(e) def parse_private_key(data, password): """Identify, decrypt and return a cryptography private key object. :param data: The private key in bytes. :param password: The password to decrypt the private key (if it is encrypted). """ # PEM if is_pem(data): encrypted = b"ENCRYPTED" in data if encrypted and password is None: raise InvalidPasswordError("No password provided for encrypted key.") try: return serialization.load_pem_private_key( data, password, backend=default_backend() ) except ValueError as e: # Cryptography raises ValueError if decryption fails. if encrypted: raise InvalidPasswordError(e) logger.debug("Failed to parse PEM private key ", exc_info=True) except Exception: logger.debug("Failed to parse PEM private key ", exc_info=True) # PKCS12 if is_pkcs12(data): return _parse_pkcs12(data, password)[0] # DER try: return serialization.load_der_private_key( data, password, backend=default_backend() ) except Exception: logger.debug("Failed to parse private key as DER", exc_info=True) # All parsing failed raise ValueError("Could not parse private key.") def parse_certificates(data, password): """Identify, decrypt and return a list of cryptography x509 certificates. :param data: The certificate(s) in bytes. :param password: The password to decrypt the certificate(s). """ logger.debug("Attempting to parse certificate using PEM, PKCS12 and DER") # PEM if is_pem(data): certs = [] for cert in data.split(PEM_IDENTIFIER): if cert: try: certs.append( x509.load_pem_x509_certificate( PEM_IDENTIFIER + cert, default_backend() ) ) except Exception: logger.debug("Failed to parse PEM certificate", exc_info=True) # Could be valid PEM but not certificates. if not certs: raise ValueError("PEM file does not contain any certificate(s)") return certs # PKCS12 if is_pkcs12(data): return _parse_pkcs12(data, password)[1] # DER try: return [x509.load_der_x509_certificate(data, default_backend())] except Exception: logger.debug("Failed to parse certificate as DER", exc_info=True) raise ValueError("Could not parse certificate.") def get_leaf_certificates(certs): """Extract the leaf certificates from a list of certificates. Leaf certificates are ones whose subject does not appear as issuer among theothers. :param certs: The list of cryptography x509 certificate objects. """ issuers = [ cert.issuer.get_attributes_for_oid(x509.NameOID.COMMON_NAME) for cert in certs ] leafs = [ cert for cert in certs if ( cert.subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME) not in issuers ) ] return leafs def is_pem(data): return data and PEM_IDENTIFIER in data def is_pkcs12(data): """ Tries to identify a PKCS12 container. The PFX PDU version is assumed to be v3. See: https://tools.ietf.org/html/rfc7292. """ try: header = Tlv.parse_from(Tlv.unpack(0x30, data))[0] return header.tag == 0x02 and header.value == b"\x03" except ValueError: logger.debug("Unable to parse TLV", exc_info=True) return False class OSVERSIONINFOW(ctypes.Structure): _fields_ = [ ("dwOSVersionInfoSize", ctypes.c_ulong), ("dwMajorVersion", ctypes.c_ulong), ("dwMinorVersion", ctypes.c_ulong), ("dwBuildNumber", ctypes.c_ulong), ("dwPlatformId", ctypes.c_ulong), ("szCSDVersion", ctypes.c_wchar * 128), ] def get_windows_version() -> Tuple[int, int, int]: """Get the true Windows version, since sys.getwindowsversion lies.""" osvi = OSVERSIONINFOW() osvi.dwOSVersionInfoSize = ctypes.sizeof(osvi) ctypes.windll.Ntdll.RtlGetVersion(ctypes.byref(osvi)) # type: ignore return osvi.dwMajorVersion, osvi.dwMinorVersion, osvi.dwBuildNumber ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1692611472.151332 yubikey_manager-5.2.1/yubikit/__init__.py0000644000000000000000000000267514470631620015426 0ustar00# 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. """ Contains the modules corresponding to the different applications supported by a YubiKey. """ ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1691654743.7221272 yubikey_manager-5.2.1/yubikit/core/__init__.py0000644000000000000000000002537414465115130016354 0ustar00# 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\d+).(?P\d).(?P\d)\b") class Version(NamedTuple): """3-digit version tuple.""" major: int minor: int patch: int def __str__(self): return "%d.%d.%d" % self @classmethod def from_bytes(cls, data: bytes) -> "Version": return cls(*data) @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") @unique class TRANSPORT(str, Enum): """YubiKey physical connection transports.""" USB = "usb" NFC = "nfc" def __str__(self): return super().__str__().upper() @unique class USB_INTERFACE(IntFlag): """YubiKey USB interface identifiers.""" OTP = 0x01 FIDO = 0x02 CCID = 0x04 @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 class Connection(abc.ABC): """A connection to a YubiKey""" usb_interface: ClassVar[USB_INTERFACE] = USB_INTERFACE(0) def close(self) -> None: """Close the device, releasing any held resources.""" def __enter__(self): return self def __exit__(self, typ, value, traceback): self.close() @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:])) @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] 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) 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 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] 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})" class CommandError(Exception): """An error response from a YubiKey""" class BadResponseError(CommandError): """Invalid response data from the YubiKey""" class TimeoutError(CommandError): """An operation timed out waiting for something""" class ApplicationNotAvailableError(CommandError): """The application is either disabled or not supported on this YubiKey""" class NotSupportedError(ValueError): """Attempting an action that is not supported on this YubiKey""" 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 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) 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 bytes2int(data: bytes) -> int: return int.from_bytes(data, "big") def _tlv_parse(data, offset=0): try: tag = data[offset] offset += 1 if tag & 0x1F == 0x1F: # Long form tag = tag << 8 | data[offset] offset += 1 while tag & 0x80 == 0x80: # Additional bytes tag = tag << 8 | data[offset] offset += 1 ln = data[offset] offset += 1 if ln == 0x80: # Indefinite length end = offset while data[end] or data[end + 1]: # Run until 0x0000 end = _tlv_parse(data, end)[3] # Skip over TLV ln = end - offset end += 2 # End after 0x0000 else: if ln > 0x80: # Length spans multiple bytes n_bytes = ln - 0x80 ln = bytes2int(data[offset : offset + n_bytes]) offset += n_bytes end = offset + ln return tag, offset, ln, end except IndexError: raise ValueError("Invalid encoding of tag/length") T_Tlv = TypeVar("T_Tlv", bound="Tlv") 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()})" @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:] @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 @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)) @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 ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1679758451.909404 yubikey_manager-5.2.1/yubikit/core/fido.py0000644000000000000000000000313414407612164015531 0ustar00# 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, USB_INTERFACE from fido2.ctap import CtapDevice # Make CtapDevice a Connection FidoConnection = CtapDevice FidoConnection.usb_interface = USB_INTERFACE.FIDO Connection.register(FidoConnection) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1692611472.151332 yubikey_manager-5.2.1/yubikit/core/otp.py0000644000000000000000000002274214470631620015416 0ustar00# 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" class CommandRejectedError(CommandError): """The issues command was rejected by the YubiKey""" class OtpConnection(Connection, metaclass=abc.ABCMeta): usb_interface = USB_INTERFACE.OTP @abc.abstractmethod def receive(self) -> bytes: """Reads an 8 byte feature report""" @abc.abstractmethod def send(self, data: bytes) -> None: """Writes an 8 byte feature report""" CRC_OK_RESIDUAL = 0xF0B8 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 def check_crc(data: bytes) -> bool: return calculate_crc(data) == CRC_OK_RESIDUAL 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) 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(" None: self.connection.close() 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 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")) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1696967723.9459815 yubikey_manager-5.2.1/yubikit/core/smartcard.py0000644000000000000000000001672714511326054016600 0ustar00# 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__) 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}" @unique class ApduFormat(str, Enum): """APDU encoding format""" SHORT = "short" EXTENDED = "extended" @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") @unique class SW(IntEnum): NO_INPUT_DATA = 0x6285 VERIFY_FAIL_NO_RETRY = 0x63C0 MEMORY_FAILURE = 0x6581 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 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)""" @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 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 def close(self) -> None: self.connection.close() 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}") 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 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 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1696967723.9470844 yubikey_manager-5.2.1/yubikit/hsmauth.py0000644000000000000000000004623314511326054015334 0ustar00# 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 @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 @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) class SessionKeys(NamedTuple): """YubiHSM Session Keys.""" key_senc: bytes key_smac: bytes key_srmac: bytes @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, ) 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 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") 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) 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, ) 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 ) 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, ) 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, ) 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) 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", ) 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", ) 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 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, ) ) 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, ) ) 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) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1679758451.910393 yubikey_manager-5.2.1/yubikit/logging.py0000644000000000000000000000322114407612164015303 0ustar00# 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 @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 ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1696967723.948201 yubikey_manager-5.2.1/yubikit/management.py0000644000000000000000000004401714511326054015775 0ustar00# 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__) @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) -> str: if self == CAPABILITY.U2F: return "FIDO U2F" elif self == CAPABILITY.OPENPGP: return "OpenPGP" elif self == CAPABILITY.HSMAUTH: return "YubiHSM Auth" # mypy bug? return self.name or ", ".join( c.display_name for c in CAPABILITY if c in self # type: ignore ) @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 @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" @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 @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 @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] 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 @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 def has_transport(self, transport: TRANSPORT) -> bool: return transport in self.supported_capabilities @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 ] @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) @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) 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}" ) def close(self) -> None: self.backend.close() @property def version(self) -> Version: return self.backend.version 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) 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") 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(" "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), ) def get_id(self) -> bytes: return _format_cred_id(self.issuer, self.name, self.oath_type, self.period) @dataclass class Code: """An OATH code object.""" value: str valid_from: int valid_to: int @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, ) 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 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) def derive_key(self, password: str) -> bytes: """Derive a key from password. :param password: The derivation password. """ return _derive_key(self._salt, password) 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 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) 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 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, ) 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 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 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:] 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") 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 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) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1692611472.151332 yubikey_manager-5.2.1/yubikit/openpgp.py0000644000000000000000000016062414470631620015336 0ustar00# 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" @unique class UIF(IntEnum): # noqa: N801 OFF = 0x00 ON = 0x01 FIXED = 0x02 CACHED = 0x03 CACHED_FIXED = 0x04 @classmethod def parse(cls, encoded: bytes): return cls(encoded[0]) 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() @unique class PIN_POLICY(IntEnum): # noqa: N801 ALWAYS = 0x00 ONCE = 0x01 def __str__(self): return self.name[0] + self.name[1:].lower() @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 @unique class PW(IntEnum): USER = 0x81 RESET = 0x82 ADMIN = 0x83 @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) 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] @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 @dataclass class CardholderRelatedData: name: bytes language: bytes sex: int @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], ) @dataclass class ExtendedLengthInfo: request_max_bytes: int response_max_bytes: int @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])), ) @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 @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 @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, ) @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 def get_max_len(self, pw: PW) -> int: return getattr(self, f"max_len_{pw.name.lower()}") def get_attempts(self, pw: PW) -> int: return getattr(self, f"attempts_{pw.name.lower()}") @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], ) @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")) @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) @unique class KEY_STATUS(IntEnum): NONE = 0 GENERATED = 1 IMPORTED = 2 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 @dataclass # type: ignore[misc] class AlgorithmAttributes(abc.ABC): """OpenPGP key algorithm attributes.""" _supported_ids: ClassVar[Sequence[int]] algorithm_id: int @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() @unique class RSA_SIZE(IntEnum): RSA2048 = 2048 RSA3072 = 3072 RSA4096 = 4096 @unique class RSA_IMPORT_FORMAT(IntEnum): STANDARD = 0 STANDARD_W_MOD = 1 CRT = 2 CRT_W_MOD = 3 @dataclass class RsaAttributes(AlgorithmAttributes): _supported_ids = [0x01] n_len: int e_len: int import_format: RSA_IMPORT_FORMAT @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 ) 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) @unique class EC_IMPORT_FORMAT(IntEnum): STANDARD = 0 STANDARD_W_PUBKEY = 0xFF @dataclass class EcAttributes(AlgorithmAttributes): _supported_ids = [0x12, 0x13, 0x16] oid: CurveOid import_format: EC_IMPORT_FORMAT @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)) } @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] @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), ) def get_algorithm_attributes(self, key_ref: KEY_REF) -> AlgorithmAttributes: return getattr(self, f"attributes_{key_ref.name.lower()}") @dataclass class ApplicationRelatedData: """OpenPGP related data.""" aid: OpenPgpAid historical: bytes extended_length_info: Optional[ExtendedLengthInfo] general_feature_management: Optional[GENERAL_FEATURE_MANAGEMENT] discretionary: DiscretionaryDataObjects @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), ) @dataclass class SecuritySupportTemplate: signature_counter: int @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 @dataclass # type: ignore[misc] class Kdf(abc.ABC): algorithm: ClassVar[int] @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() @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() @dataclass class KdfNone(Kdf): algorithm = 0 @classmethod def _parse_data(cls, data) -> "KdfNone": return cls() def process(self, pw, pin): return pin.encode() def __bytes__(self): return Tlv(0x81, struct.pack(">B", self.algorithm)) @unique class HASH_ALGORITHM(IntEnum): SHA256 = 0x08 SHA512 = 0x0A def create_digest(self): algorithm = getattr(hashes, self.name) return hashes.Hash(algorithm(), default_backend()) @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() @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), ) def get_salt(self, pw: PW) -> bytes: return getattr(self, f"salt_{pw.name.lower()}") 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 @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)), ) @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), ) @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), ) @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}") 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 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) 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) 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})") def get_pin_status(self) -> PwStatus: """Get the current status of PINS.""" return PwStatus.parse(self.get_data(DO.PW_STATUS_BYTES)) 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 def get_application_related_data(self) -> ApplicationRelatedData: """Read the Application Related Data.""" return ApplicationRelatedData.parse(self.get_data(DO.APPLICATION_RELATED_DATA)) 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") 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") 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") 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)) 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 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) 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) 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") 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) 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) 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") 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") 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) 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 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") 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 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}") 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 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 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}") def get_fingerprints(self) -> Fingerprints: """Get key fingerprints.""" logger.debug("Getting key fingerprints") return self.get_application_related_data().discretionary.fingerprints 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}") 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) 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) 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) 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}") 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 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()) 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}") 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}") 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) 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 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 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 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1692616769.9900398 yubikey_manager-5.2.1/yubikit/piv.py0000644000000000000000000010122114470644102014447 0ustar00# 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__) @unique class ALGORITHM(str, Enum): EC = "ec" RSA = "rsa" # Don't treat pre 1.0 versions as "developer builds". 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) @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") @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__}") @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 @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})" @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 @classmethod def from_slot(cls, slot): return getattr(cls, SLOT(slot).name) @unique class PIN_POLICY(IntEnum): DEFAULT = 0x0 NEVER = 0x1 ONCE = 0x2 ALWAYS = 0x3 @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 @dataclass class PinMetadata: default_value: bool total_attempts: int attempts_remaining: int @dataclass class ManagementKeyMetadata: key_type: MANAGEMENT_KEY_TYPE default_value: bool touch_policy: TOUCH_POLICY @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) 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]) 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 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") 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") 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") 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) 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 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") 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") 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") 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 def get_pin_metadata(self) -> PinMetadata: """Get PIN metadata.""" logger.debug("Getting PIN metadata") return self._get_pin_puk_metadata(PIN_P2) def get_puk_metadata(self) -> PinMetadata: """Get PUK metadata.""" logger.debug("Getting PUK metadata") return self._get_pin_puk_metadata(PUK_P2) 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]), ) 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], ) 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) 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) 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) 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) 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)}") 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) 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}") 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)) 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 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)) 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 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1679758451.9113967 yubikey_manager-5.2.1/yubikit/py.typed0000644000000000000000000000000014407612164014772 0ustar00././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1692611472.151332 yubikey_manager-5.2.1/yubikit/support.py0000644000000000000000000003676614470631620015413 0ustar00# 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, ) 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 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 ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1692611472.151332 yubikey_manager-5.2.1/yubikit/yubiotp.py0000644000000000000000000007404614470631620015363 0ustar00# 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") @unique class SLOT(IntEnum): ONE = 1 TWO = 2 @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)") @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 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) 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 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 @unique class NDEF_TYPE(IntEnum): TEXT = ord("T") URI = ord("U") 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(" NDEF_DATA_SIZE: raise ValueError("URI payload too large") return bytes([len(data), ndef_type]) + data.ljust(NDEF_DATA_SIZE, b"\0") @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") 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 def is_supported_by(self, version: Version) -> bool: return True 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, ) def serial_api_visible(self: Cfg, value: bool) -> Cfg: self._update_flags(EXTFLAG.SERIAL_API_VISIBLE, value) return self def serial_usb_visible(self: Cfg, value: bool) -> Cfg: self._update_flags(EXTFLAG.SERIAL_USB_VISIBLE, value) return self def allow_update(self: Cfg, value: bool) -> Cfg: self._update_flags(EXTFLAG.ALLOW_UPDATE, value) return self def dormant(self: Cfg, value: bool) -> Cfg: self._update_flags(EXTFLAG.DORMANT, value) return self def invert_led(self: Cfg, value: bool) -> Cfg: self._update_flags(EXTFLAG.LED_INV, value) return self def protect_slot2(self: Cfg, value: bool) -> Cfg: self._update_flags(TKTFLAG.PROTECT_CFG2, value) return self 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) def is_supported_by(self, version): return version >= (2, 2, 0) or version[0] == 0 def require_touch(self: Cfg, value: bool) -> Cfg: self._update_flags(CFGFLAG.CHAL_BTN_TRIG, value) return self def lt64(self: Cfg, value: bool) -> Cfg: self._update_flags(CFGFLAG.HMAC_LT64, value) return self class KeyboardSlotConfiguration(SlotConfiguration): def __init__(self): super(KeyboardSlotConfiguration, self).__init__() self._update_flags(TKTFLAG.APPEND_CR, True) self._update_flags(EXTFLAG.FAST_TRIG, True) def append_cr(self: Cfg, value: bool) -> Cfg: self._update_flags(TKTFLAG.APPEND_CR, value) return self def fast_trigger(self: Cfg, value: bool) -> Cfg: self._update_flags(EXTFLAG.FAST_TRIG, value) return self 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 def use_numeric(self: Cfg, value: bool) -> Cfg: self._update_flags(EXTFLAG.USE_NUMERIC_KEYPAD, value) return self 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) def is_supported_by(self, version): return version >= (2, 2, 0) or version[0] == 0 def digits8(self: Cfg, value: bool) -> Cfg: self._update_flags(CFGFLAG.OATH_HOTP8, value) return self 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 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 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) def is_supported_by(self, version): return version >= (2, 2, 0) or version[0] == 0 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 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 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 def send_reference(self: Cfg, value: bool) -> Cfg: self._update_flags(CFGFLAG.SEND_REF, value) return self 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) def short_ticket(self: Cfg, value: bool) -> Cfg: self._update_flags(CFGFLAG.SHORT_TICKET, value) return self 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 def manual_update(self: Cfg, value: bool) -> Cfg: self._update_flags(CFGFLAG.MAN_UPDATE, value) return self 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 is_supported_by(self, version): return version >= (2, 2, 0) or version[0] == 0 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) def protect_slot2(self: Cfg, value): raise ValueError("protect_slot2 cannot be applied to UpdateConfiguration") 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 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 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 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 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 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") 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()}" ) def close(self) -> None: self.backend.close() @property def version(self) -> Version: return self._version def get_serial(self) -> int: """Get serial number.""" return bytes2int( self.backend.send_and_receive(CONFIG_SLOT.DEVICE_SERIAL, b"", 4) ) def get_config_state(self) -> ConfigState: """Get configuration state of the YubiOTP application.""" return ConfigState(self.version, struct.unpack(" 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, ) 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, ) def swap_slots(self) -> None: """Swap the two slot configurations.""" logger.debug("Swapping touch slots") self._write_config(CONFIG_SLOT.SWAP, b"", None) 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, ) 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) 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, ) 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, ) yubikey_manager-5.2.1/PKG-INFO0000644000000000000000000000207100000000000012656 0ustar00Metadata-Version: 2.1 Name: yubikey-manager Version: 5.2.1 Summary: Tool for managing your YubiKey configuration. Home-page: https://github.com/Yubico/yubikey-manager License: BSD Keywords: yubikey,yubiotp,piv,fido Author: Dain Nilsson Author-email: dain@yubico.com Requires-Python: >=3.8,<4.0 Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: End Users/Desktop Classifier: License :: Other/Proprietary License Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Topic :: Security :: Cryptography Classifier: Topic :: Utilities Requires-Dist: click (>=8.0,<9.0) Requires-Dist: cryptography (>=3.0,<44) Requires-Dist: fido2 (>=1.0,<2.0) Requires-Dist: keyring (>=23.4,<25) Requires-Dist: pyscard (>=2.0,<3.0) Requires-Dist: pywin32 (>=223) ; sys_platform == "win32" Project-URL: Repository, https://github.com/Yubico/yubikey-manager