diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index e5eff93b4..000000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: "Code scanning - action" - -on: - push: - pull_request: - schedule: - - cron: '0 11 * * 3' - -jobs: - CodeQL-Build: - - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v2 - with: - # We must fetch at least the immediate parents so that if this is - # a pull request then we can checkout the head. - fetch-depth: 2 - - # If this run was triggered by a pull request event, then checkout - # the head of the pull request instead of the merge commit. - - run: git checkout HEAD^2 - if: ${{ github.event_name == 'pull_request' }} - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - - - name: Autobuild - uses: github/codeql-action/autobuild@v1 - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/macOS.yml b/.github/workflows/macOS.yml index fb2d3dba1..d52319655 100644 --- a/.github/workflows/macOS.yml +++ b/.github/workflows/macOS.yml @@ -2,56 +2,60 @@ name: macOS on: [push, pull_request] +# Adapted from https://github.com/sabnzbd/sabnzbd/blob/develop/.github/workflows/build_release.yml#L80 jobs: build: - runs-on: macos-latest + runs-on: macos-11 + + env: + MACOSX_DEPLOYMENT_TARGET: "11.0" + CFLAGS: -arch x86_64 -arch arm64 + ARCHFLAGS: -arch x86_64 -arch arm64 steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v3 + + - name: Install Poetry + run: pipx install poetry - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: - python-version: '3.x' + python-version: '3.12.0' + cache: poetry - name: Install dependencies - run: | - brew update - brew install swig - python -m pip install --upgrade pip - pip install poetry - poetry install - - - name: Run unit tests - run: poetry run pytest -v - - - name: Build - run: poetry build + run: poetry install - - name: Install from tar.gz + - name: Explicitly use universal versions run: | - pip install dist/*.tar.gz - ykman --version - [[ -z "$(ykman --version | grep -E "not found|missing")" ]] - pip uninstall -y yubikey-manager - - - name: Install from wheel - run: | - pip install dist/*.whl - ykman --version - [[ -z "$(ykman --version | grep -E "not found|missing")" ]] - pip uninstall -y yubikey-manager + # Export exact versions + poetry export --without-hashes > requirements.txt + grep cryptography requirements.txt > cryptography.txt + grep cffi requirements.txt > source-reqs.txt + grep pyscard requirements.txt >> source-reqs.txt + # Remove non-universal packages + poetry run pip uninstall -y cryptography cffi pyscard + # Build cffi from source to get universal build + poetry run pip install --upgrade -r source-reqs.txt --no-binary :all: + # Explicitly install pre-build universal build of cryptography + poetry run pip download -r cryptography.txt --platform macosx_10_12_universal2 --only-binary :all: --no-deps --dest . + poetry run pip install -r cryptography.txt --no-cache-dir --no-index --find-links . - name: PyInstaller run: | - pip install pyinstaller - pip install dist/*.whl - pyinstaller ykman.spec - dist/ykman --version - [[ -z "$(dist/ykman --version | grep -E "not found|missing")" ]] - export REF=$(echo ${GITHUB_REF} | cut -d '/' -f 3) - mv dist/ykman dist/ykman-$REF + poetry run pyinstaller ykman.spec + dist/ykman/ykman --version + [[ -z "$(dist/ykman/ykman --version | grep -E "not found|missing")" ]] + + - name: Copy scripts + shell: bash + run: cp -r resources/macos dist/scripts + + - name: Build installer + working-directory: ./dist + run: ./scripts/make_pkg.sh - name: Upload build uses: actions/upload-artifact@v1 diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml deleted file mode 100644 index c940cfcbe..000000000 --- a/.github/workflows/scan.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: static code analysis -# Documentation: https://github.com/Yubico/yes-static-code-analysis - -on: - push: - schedule: - - cron: '0 0 * * 1' - -env: - SCAN_IMG: - yubico-yes-docker-local.jfrog.io/static-code-analysis/python:v1 - SECRET: ${{ secrets.ARTIFACTORY_READER_TOKEN }} - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@master - - - name: Scan and fail if warnings - run: | - if [ "${SECRET}" != "" ]; then - docker login yubico-yes-docker-local.jfrog.io/ \ - -u svc-static-code-analysis-reader -p ${SECRET} - docker pull ${SCAN_IMG} - docker run -v${PWD}:/k -e PROJECT_NAME=${GITHUB_REPOSITORY#Yubico/} -t ${SCAN_IMG} - fi - - - uses: actions/upload-artifact@master - if: failure() - with: - name: suppression_files - path: suppression_files diff --git a/.github/workflows/source-package.yml b/.github/workflows/source-package.yml index 382633bad..9ea861605 100644 --- a/.github/workflows/source-package.yml +++ b/.github/workflows/source-package.yml @@ -1,19 +1,18 @@ -name: Build a source package +name: Source package on: [push, pull_request] jobs: build: - runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.8 - name: Install dependencies run: | @@ -31,7 +30,7 @@ jobs: poetry build mkdir artifacts export REF=$(echo ${GITHUB_REF} | cut -d '/' -f 3) - mv dist/yubikey-manager-*.tar.gz artifacts/yubikey-manager-$REF.tar.gz + mv dist/yubikey_manager-*.tar.gz artifacts/yubikey_manager-$REF.tar.gz mv dist/yubikey_manager-*.whl artifacts/yubikey_manager-$REF.whl - name: Upload artifact @@ -39,3 +38,32 @@ jobs: with: name: yubikey-manager-source-package path: artifacts + + docs: + runs-on: ubuntu-latest + name: Build sphinx documentation + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: 3.12 + + - name: Install python dependencies + run: | + sudo apt-get update + sudo apt-get install -qq swig libpcsclite-dev + python -m pip install --upgrade pip + pip install poetry + poetry install + + - name: Build sphinx documentation + run: poetry run make -C docs/ html + + - name: Upload documentation + uses: actions/upload-artifact@v1 + with: + name: yubikey-manager-docs + path: docs/_build/html diff --git a/.github/workflows/ubuntu.yml b/.github/workflows/ubuntu.yml index fd2cf8b50..5229e5f66 100644 --- a/.github/workflows/ubuntu.yml +++ b/.github/workflows/ubuntu.yml @@ -7,28 +7,26 @@ jobs: runs-on: ubuntu-latest - strategy: - matrix: - python: [ '3.x' ] - - name: Python ${{ matrix.python }} steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v3 + + - name: Install Poetry + run: pipx install poetry - name: Set up Python - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: - python-version: ${{ matrix.python }} + python-version: '3.12.0' + cache: poetry - name: Install dependencies run: | sudo apt-get install -qq swig libpcsclite-dev - python -m pip install --upgrade pip - pip install poetry poetry install - name: Run pre-commit hooks run: | + python -m pip install --upgrade pip pip install pre-commit pre-commit install pre-commit run --all-files --verbose @@ -44,10 +42,9 @@ jobs: - name: PyInstaller run: | - pip install pyinstaller - pyinstaller ykman.spec - dist/ykman --version - [[ -z "$(dist/ykman --version | grep -E "not found|missing")" ]] + poetry run pyinstaller ykman.spec + dist/ykman/ykman --version + [[ -z "$(dist/ykman/ykman --version | grep -E "not found|missing")" ]] export REF=$(echo ${GITHUB_REF} | cut -d '/' -f 3) mv dist/ykman dist/ykman-$REF diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index c4cfc2021..79434cd89 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -8,15 +8,19 @@ jobs: runs-on: windows-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 + + - name: Install Poetry + run: pipx install poetry + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.12.0' + cache: poetry - name: Install dependencies - run: | - choco install swig - python -m pip install --upgrade pip - pip install poetry - poetry install - shell: powershell + run: poetry install - name: Run unit tests run: poetry run pytest -v @@ -27,6 +31,7 @@ jobs: - name: Install from tar.gz shell: bash run: | + python -m pip install --upgrade pip pip install dist/*.tar.gz ykman --version [[ -z "$(ykman --version | grep -E "not found|missing")" ]] @@ -40,31 +45,20 @@ jobs: [[ -z "$(ykman --version | grep -E "not found|missing")" ]] pip uninstall -y yubikey-manager - - name: Clone PyInstaller - uses: actions/checkout@v2 - with: - repository: pyinstaller/pyinstaller - ref: 90431c010a23b1c9080fcb44f932d4615bb71825 - path: pyinstaller - - - name: Build PyInstaller + - name: PyInstaller shell: bash run: | - cd pyinstaller/bootloader - python waf distclean all - cd .. - pip install . - cd .. + poetry run pyinstaller ykman.spec + dist/ykman/ykman.exe --version + [[ -z "$(dist/ykman/ykman.exe --version | grep -E "not found|missing")" ]] - - name: Build exe + - name: Copy scripts shell: bash - run: | - pip install dist/*.whl - pyinstaller ykman.spec - dist/ykman.exe --version - [[ -z "$(dist/ykman.exe --version | grep -E "not found|missing")" ]] - export REF=$(echo ${GITHUB_REF} | cut -d '/' -f 3) - mv dist/ykman.exe dist/ykman-$REF.exe + run: cp -r resources/win dist/scripts + + - name: Build installer + working-directory: ./dist + run: .\scripts\make_msi.ps1 - name: Upload build uses: actions/upload-artifact@v1 diff --git a/.gitignore b/.gitignore index 990674972..ce20ddeac 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ ykman-gui.pkg ykman-script.py ykman.pkg lib/ +**/_build # Libraries unpacked by Windows VM /libjson-c*.dll diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3ff70cf07..bce669c09 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,19 +1,20 @@ repos: -- repo: https://gitlab.com/pycqa/flake8 - rev: 3.9.0 +- repo: https://github.com/PyCQA/flake8 + rev: 6.1.0 hooks: - id: flake8 - repo: https://github.com/psf/black - rev: 20.8b1 + rev: 23.9.1 hooks: - id: black - repo: https://github.com/PyCQA/bandit - rev: 1.7.0 + rev: 1.7.5 hooks: - id: bandit - exclude: ^test(s)?/ # keep in sync with .bandit file + exclude: ^(test(s)?/|docs/) # keep in sync with .bandit file - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.812 + rev: v1.5.1 hooks: - id: mypy - exclude: ^tests/ # keep in sync with mypy.ini + exclude: ^(tests/|docs/) # keep in sync with mypy.ini + additional_dependencies: [] diff --git a/NEWS b/NEWS index e31f676b5..ae4bfc1aa 100644 --- a/NEWS +++ b/NEWS @@ -1,3 +1,74 @@ +* 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. diff --git a/README.adoc b/README.adoc index 3393ceb30..cf78bbb46 100644 --- a/README.adoc +++ b/README.adoc @@ -1,11 +1,14 @@ == YubiKey Manager CLI -image:https://github.com/Yubico/yubikey-manager/workflows/build/badge.svg["Build Status", link="https://github.com/Yubico/yubikey-manager/actions"] +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.6 (or later) library and command line tool for configuring a YubiKey. -If you're looking for the full graphical application, which also includes the command line tool, it's https://developers.yubico.com/yubikey-manager-qt/[here]. +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://support.yubico.com/support/solutions/articles/15000012643-yubikey-manager-cli-ykman-user-guide[YubiKey Manager CLI User Manual]. +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]... @@ -21,26 +24,25 @@ Usage: ykman [OPTIONS] COMMAND [ARGS]... $ ykman --device 0123456 info Options: - -v, --version Show version information about the app - -d, --device SERIAL Specify which YubiKey to interact with by serial number. - -l, --log-level [DEBUG|INFO|WARNING|ERROR|CRITICAL] - Enable logging at given verbosity level. - --log-file FILE Write logs to the given FILE instead of standard error; ignored - unless --log-level is also set. - - -r, --reader NAME Use an external smart card reader. Conflicts with --device and list. - --diagnose Show diagnostics information useful for troubleshooting. - -h, --help Show this message and exit. + -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/Disable applications. - fido Manage the FIDO applications. - oath Manage the OATH Application. - openpgp Manage the OpenPGP Application. - otp Manage the OTP Application. - piv Manage the PIV Application. + 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 @@ -48,6 +50,17 @@ 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): @@ -61,7 +74,7 @@ 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.alioth.debian.org/pcsclite.html[PCSC lite]. +potentially https://pcsclite.apdu.fr/[PCSC lite]. === Pre-build packages Pre-built packages specific to your platform may be available from Yubico or @@ -69,24 +82,83 @@ third parties. Please refer to your platforms native package manager for detailed instructions on how to install, if available. ==== Windows -The command line tool ykman.exe is provided as part of the installer for the -https://developers.yubico.com/yubikey-manager-qt/[YubiKey Manager] on Windows. +A Windows installer is available to download from the +https://github.com/Yubico/yubikey-manager/releases/latest[Releases page]. ==== MacOS -Packages for MacOS are available from Homebrew and MacPorts. +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 (for amd64 -ONLY, other architectures such as arm should use the general `pip` instructions -above instead): +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 -==== Source +==== 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<= 3.6, and have `yubikey-manager` installed and +You will need to have Python >= 3.7, and have `yubikey-manager` installed and added to your PYTHON_PATH. You can verify that this is set up correctly by running the following command from a Terminal: @@ -12,39 +12,15 @@ running the following command from a Terminal: If the above runs without error (no output at all), then you should be all set. -=== Connecting to a YubiKey -The first step you'll likely want to do is to establish a Session with a -YubiKey Application. Depending on which Application you intend to access, -you'll need to establish a specific type of Connection. The `connect_to_device` -function lets you search for, and connect to, a YubiKey. Once you are done -using a Connection, you should close it. This can be done explicitly by calling -`connection.close()`, or by using a `with` block. - -==== Example -[source,py] ----- -from ykman.device import connect_to_device -from yubikit.core.smartcard import SmartCardConnection -from yubikit.piv import PivSession - -# Connect to a YubiKey over a SmartCardConnection, which is needed for PIV. -connection, device, info = connect_to_device( - serial=123456, # Serial number of the YubiKey to connect to, can be omitted - connection_types=[SmartCardConnection], # Possible Connection types to allow -) - -with connection: # This closes the connection after the block - piv = PivSession(connection) - attempts = piv.get_pin_attempts() - print(f"You have {attempts} PIN attempts left.") ----- - - -=== Listing all connected YubiKeys -Just using `connect_to_device` allows you to connect to a single YubiKey, -either by specifying its serial number, or by only having a single YubiKey -connected. When working with multiple connected YubiKeys you'll likely find a -need for enumerating these. You can use `list_all_devices` for this purpose. +=== Listing connected YubiKeys +The first step you'll likely want to do is to list currently connected +YubiKeys, and get some information about them. This is what the +`list_all_devices` function is for. It detects and connects to each attached +YubiKey, reading some information about it. It returns a list of tuples +consisting of a YubiKeyDevice and a corresponding DeviceInfo. The DeviceInfo +can tell you what kind of YubiKey it is, what capabilities it has, its serial +number, etc. The YubiKeyDevice will let you open a Connection to it, which will +let you interact with one of the available Applications. [NOTE] ==== @@ -57,14 +33,43 @@ connections. ==== Example [source,py] ---- -from ykman.device import connect_to_device, list_all_devices +from ykman.device import list_all_devices from yubikit.core.smartcard import SmartCardConnection for device, info in list_all_devices(): if info.version >= (5, 0, 0): # The info object provides details about the YubiKey - connection, _, _ = connect_to_device(serial=info.serial, connection_types=[SmartCardConnection]) - with connection: - ... # Do something with the connection. + print(f"Found YubiKey with serial number: {info.serial}") +---- + + +=== Connecting to a YubiKey +To actually do anything with a YubiKey you'll need to create a Connection and +establish a session with a YubiKey Application. Depending on which Application +you intend to access, you'll need to establish a specific type of Connection. +Once you have a reference to a YubiKeyDevice you can call the `open_connection` +method on it to open a Connection of a specific type. There are three different +types of Connections, used for different Applications. These are +`SmartCardConnection`, `OtpConnection` and `FidoConnection`. Once you are done +using a Connection, you should close it. This can be done explicitly by calling +`connection.close()`, or by using a `with` block. + +==== Example +[source,py] +---- +from ykman.device import list_all_devices +from yubikit.core.smartcard import SmartCardConnection +from yubikit.piv import PivSession + +# Select a connected YubiKeyDevice +dev, info = list_all_devices()[0] + +# Connect to a YubiKey over a SmartCardConnection, which is needed for PIV. +with dev.open_connection(SmartCardConnection) as connection: + # The connection will be automatically closed after this block + + piv = PivSession(connection) + attempts = piv.get_pin_attempts() + print(f"You have {attempts} PIN attempts left.") ---- @@ -79,7 +84,8 @@ connected YubiKeys changes. ==== Example [source,py] ---- -from ykman.device import connect_to_device, list_all_devices, scan_devices +from ykman.device import list_all_devices, scan_devices +from yubikit.core.smartcard import SmartCardConnection from time import sleep handled_serials = set() # Keep track of YubiKeys we've already handled. @@ -92,10 +98,8 @@ while True: # Run this until we stop the script with Ctrl+C for device, info in list_all_devices(): if info.serial not in handled_serials: # Unhandled YubiKey print(f"Programming YubiKey with serial: {info.serial}") - # Since we're not filtering on connection type here the Connection may have any type. - with connect_to_device(info.serial)[0] as connection: - ... # Do something with the connection. - handled.add(info.serial) + ... # Do something with the device here. + handled_serials.add(info.serial) else: sleep(1.0) # No change, sleep for 1 second. ---- diff --git a/doc/Scripting.adoc b/doc/Scripting.adoc new file mode 100644 index 000000000..5efcb8116 --- /dev/null +++ b/doc/Scripting.adoc @@ -0,0 +1,147 @@ +== Running custom scripts +The ykman executable lets you run custom python scripts in the context of +YubiKey Manager. + +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! + +See also: link:Library_Usage.adoc[Library Usage]. + + +=== Invoking the script +To run a script, use the `script` subcommand of ykman: + + ykman script myscript.py + +You can pass additional arguments used in the script by adding them at the end +of the command: + + ykman script myscript.py 123456 a_word "a string with spaces" + +These arguments are accessible in the standard Python way of using sys.argv: + + import sys + + print(sys.argv[1]) # prints "123456" + print(sys.argv[3]) # prints "a string with spaces" + + +=== Scripting utilities +We include some functions which may be helpful for scripting purposes in +`ykman/scripting.py`, such as connectiong to one or more YubiKeys to perform +actions upon them. See "Writing your first script" below for some example +usage. + + +=== Adding additional dependencies +By default, the script will run with the full ykman library available, as well +as the Python dependencies used by the application. If your script needs +additional dependencies, you can provide an additional location to load Python +packages from, by using the `--site-dir` argument: + + ykman script --site-dir /path/to/additional/site-packages myscript.py + + +=== Writing your first script +Create a new file, `myscript.py` and add the following content to it: + +[source,py] +---- +print("Hello, from ykman!") +---- + +Now, save the file and run: + + ykman script myscript.py + +If everything went as planned, you should see the print output in your +terminal. Something like: + +.... +> ykman script myscript.py +Hello, from ykman! +> +.... + +Now for something a bit more interesting. Let's connect to a YubiKey and read +its serial number. Modify the `myscript.py` file to contain the following: + +[source,py] +---- +from ykman import scripting as s + +device = s.single() +print("Found a YubiKey:", device) +---- + +Save the file, then run it again using `ykman script`, and you should see +output similar to: + +.... +> ykman script myscript.py +Found a YubiKey: YubiKey 5 NFC (9681624) +> +.... + +Now, let's pass an argument to our script. We'll modify the script to take a +serial number, and check for the presense of that particular YubiKey. We'll use +the `s.multi` function to keep waiting for more YubiKeys until either the +correct one is found, or the user presses CTRL+C to stop the script. By setting +`allow_initial=True` we allow there to be YubiKeys connected at the start of +the function call. By default, the call will fail if there are YubiKeys already +connected, to prevent accidental programming of the wrong YubiKey. + +[source,py] +---- +from ykman import scripting as s +import sys + +try: + target_serial = int(sys.argv[1]) +except: + print("Usage: ykman script myscript.py ") + sys.exit(1) + +for device in s.multi(allow_initial=True): + if device.info.serial == target_serial: + print("YubiKey found, with serial:", target_serial) + break + else: + print("This is not the YubiKey we are looking for, try again...") +---- + +Now if we run the script like before, it will fail: + +.... +> ykman script myscript.py +Usage: ykman script myscript.py +> +.... + +This is because the script now expects a serial number. If we try it again, +this time with a serial number, we instead get: + +.... +> ykman script myscript.py 7800302 +This is not the YubiKey we are looking for, try again... +YubiKey found, with serial: 7800302 +.... + +The serial number we passed to the script is stored in `sys.argv`. Since +multiple argument can be passed in, the variable will contain a list, and we +need to tell our script to use the "first" argument, which in our case is the +serial. The first value in `sys.argv` is always the name of the script, so our +argument will be the second value, with index `1`. Arguments passed to the +script are always of type `string`, so we need to interpret it as a number, and +`int`. Now, we use `s.multi` to watch for connected YubiKeys. Each one is +checked against the given serial number, and the script will stop when either +the correct YubiKey is found, or if the user presses `Ctrl+C` to stop the +`s.multi` call. + +Congratulations! You've written your first script that interacts with a +YubiKey. There's a lot more that's possible. See the section on +link:Library_Usage.adoc[Library Usage] for more advanced usage. diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 000000000..001c5b7cb --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SPHINXPROJ = yubikey-manager +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 000000000..5f88bfdad --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- +# +# Configuration file for the Sphinx documentation builder. +# +# This file does only contain a selection of the most common options. For a +# full list see the documentation: +# http://www.sphinx-doc.org/en/master/config + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import sys +import re + +sys.path.insert(0, os.path.abspath("../")) + + +def get_version(): + with open("../ykman/__init__.py", "r") as f: + match = re.search(r"(?m)^__version__\s*=\s*['\"](.+)['\"]$", f.read()) + return match.group(1) + + +# -- Project information ----------------------------------------------------- + +project = "yubikey-manager" +copyright = "2023, Yubico" +author = "Yubico" + +# The full version, including alpha/beta/rc tags +release = get_version() + +# The short X.Y version +version = ".".join(release.split(".")[:2]) + +# -- General configuration --------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + "sphinx.ext.autodoc", + "sphinx_autodoc_typehints", + "sphinx.ext.doctest", + "sphinx.ext.intersphinx", + "sphinx.ext.viewcode", +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = ".rst" + +# The master toctree document. +master_doc = "index" + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = "en" + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path . +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = "sphinx" + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = "sphinx_rtd_theme" + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +html_favicon = "favicon.ico" + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ["_static"] + +# Don't show a "View page source" link on each page. +html_show_sourcelink = False + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# The default sidebars (for documents that don't match any pattern) are +# defined by theme itself. Builtin themes are using these templates by +# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', +# 'searchbox.html']``. +# +# html_sidebars = {} + + +# -- Options for HTMLHelp output --------------------------------------------- + +# Output file base name for HTML help builder. +htmlhelp_basename = "yubikey-managerdoc" + + +# -- Options for LaTeX output ------------------------------------------------ + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + ( + master_doc, + "yubikey-manager.tex", + "yubikey-manager Documentation", + "Yubico", + "manual", + ) +] + + +# -- Options for manual page output ------------------------------------------ + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, "yubikey-manager", "yubikey-manager Documentation", [author], 1) +] + + +# -- Options for Texinfo output ---------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ( + master_doc, + "yubikey-manager", + "yubikey-manager Documentation", + author, + "yubikey-manager", + "One line description of project.", + "Miscellaneous", + ) +] + + +# -- Extension configuration ------------------------------------------------- + +# -- Options for intersphinx extension --------------------------------------- + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = { + "python": ("https://docs.python.org/", None), + "cryptography": ("https://cryptography.io/en/latest/", None), +} diff --git a/docs/favicon.ico b/docs/favicon.ico new file mode 100644 index 000000000..c7194ccf4 Binary files /dev/null and b/docs/favicon.ico differ diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 000000000..e4b8880ed --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,20 @@ +.. yubikey-manager documentation master file, created by + sphinx-quickstart on Tue Aug 8 11:32:10 2023. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to yubikey-manager's documentation! +=========================================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + rst/packages + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 000000000..3c35eccd2 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,36 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build +set SPHINXPROJ=yubikey-manager + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% + +:end +popd diff --git a/docs/rst/packages.rst b/docs/rst/packages.rst new file mode 100644 index 000000000..e922028c1 --- /dev/null +++ b/docs/rst/packages.rst @@ -0,0 +1,12 @@ +yubikey-manager +=============== + +.. toctree:: + :maxdepth: 4 + + yubikit + +.. toctree:: + :maxdepth: 4 + + ykman \ No newline at end of file diff --git a/docs/rst/ykman.rst b/docs/rst/ykman.rst new file mode 100644 index 000000000..f1701ce21 --- /dev/null +++ b/docs/rst/ykman.rst @@ -0,0 +1,77 @@ +ykman package +============= + +Submodules +---------- + +ykman.base module +----------------- + +.. automodule:: ykman.base + :members: + :undoc-members: + :show-inheritance: + +ykman.device module +------------------- + +.. automodule:: ykman.device + :members: + :undoc-members: + :show-inheritance: + +ykman.fido module +----------------- + +.. automodule:: ykman.fido + :members: + :undoc-members: + :show-inheritance: + +ykman.hsmauth module +-------------------- + +.. automodule:: ykman.hsmauth + :members: + :undoc-members: + :show-inheritance: + +ykman.oath module +----------------- + +.. automodule:: ykman.oath + :members: + :undoc-members: + :show-inheritance: + +ykman.openpgp module +-------------------- + +.. automodule:: ykman.openpgp + :members: + :undoc-members: + :show-inheritance: + +ykman.piv module +----------------- + +.. automodule:: ykman.piv + :members: + :undoc-members: + :show-inheritance: + +ykman.scripting module +---------------------- + +.. automodule:: ykman.scripting + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: ykman + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/rst/yubikit.core.rst b/docs/rst/yubikit.core.rst new file mode 100644 index 000000000..97a09400c --- /dev/null +++ b/docs/rst/yubikit.core.rst @@ -0,0 +1,37 @@ +yubikit.core package +==================== + +Submodules +---------- + +yubikit.core.fido module +------------------------ + +.. automodule:: yubikit.core.fido + :members: + :undoc-members: + :show-inheritance: + +yubikit.core.otp module +----------------------- + +.. automodule:: yubikit.core.otp + :members: + :undoc-members: + :show-inheritance: + +yubikit.core.smartcard module +----------------------------- + +.. automodule:: yubikit.core.smartcard + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: yubikit.core + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/rst/yubikit.rst b/docs/rst/yubikit.rst new file mode 100644 index 000000000..d107db9c6 --- /dev/null +++ b/docs/rst/yubikit.rst @@ -0,0 +1,85 @@ +yubikit package +=============== + +Subpackages +----------- + +.. toctree:: + :maxdepth: 1 + + yubikit.core + +Submodules +---------- + +yubikit.hsmauth module +---------------------- + +.. automodule:: yubikit.hsmauth + :members: + :undoc-members: + :show-inheritance: + +yubikit.logging module +---------------------- + +.. automodule:: yubikit.logging + :members: + :undoc-members: + :show-inheritance: + +yubikit.management module +------------------------- + +.. automodule:: yubikit.management + :members: + :undoc-members: + :show-inheritance: + +yubikit.oath module +------------------- + +.. automodule:: yubikit.oath + :members: + :undoc-members: + :show-inheritance: + +yubikit.openpgp module +---------------------- + +.. automodule:: yubikit.openpgp + :members: + :undoc-members: + :show-inheritance: + +yubikit.piv module +------------------ + +.. automodule:: yubikit.piv + :members: + :undoc-members: + :show-inheritance: + +yubikit.support module +---------------------- + +.. automodule:: yubikit.support + :members: + :undoc-members: + :show-inheritance: + +yubikit.yubiotp module +---------------------- + +.. automodule:: yubikit.yubiotp + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: yubikit + :members: + :undoc-members: + :show-inheritance: diff --git a/examples/README.adoc b/examples/README.adoc new file mode 100644 index 000000000..dce5e44ff --- /dev/null +++ b/examples/README.adoc @@ -0,0 +1,16 @@ +== Example scripts +The files in this repository are examples of scripting with ykman. Scripts can +be much more flexible and more powerful than invoking the built-in commands in +`ykman`, and can be used for programming YubiKeys in batches. For an +introduction on this type of scripting, see +link:../doc/Scripting.adoc[Scripting]. + +=== Running a script +The simplest way to run a script is by passing it to the `ykman` tool: + + ykman script script_file.py [args...] + +You can also run the scripts with the standard Python interpreter, though +`ykman` and its dependencies will need to be available: + + python script_file.py [args...] diff --git a/examples/piv_certificate.py b/examples/piv_certificate.py new file mode 100644 index 000000000..7fc6f943b --- /dev/null +++ b/examples/piv_certificate.py @@ -0,0 +1,115 @@ +""" +This script will program an x.509 certificate into a slot of a YubiKey. + +By using a script instead of the command line interface, we are able to fully customize +the certificate with arbitrary fields, extensions, etc. + +This script is mainly intended as a template, to be customized according to your +liking. For more details on this, see: + +https://cryptography.io/en/latest/x509/reference/#x-509-certificate-builder +https://cryptography.io/en/latest/x509/reference/#object-identifiers + +This script generates a self-signed certificate, but can be easily altered to instead +use a different public key. + +NOTE: This same approach can be used to generate a CSR, see: + +https://cryptography.io/en/latest/x509/reference/#x-509-csr-certificate-signing-request-builder-object + +And instead use sign_csr_builder instead of sign_certificate_builder. + +Usage: piv_certificate.py +""" + +from yubikit.piv import ( + PivSession, + SLOT, + KEY_TYPE, + MANAGEMENT_KEY_TYPE, + DEFAULT_MANAGEMENT_KEY, +) +from ykman.piv import sign_certificate_builder +from ykman import scripting as s + +from cryptography import x509 +from cryptography.hazmat.primitives import serialization +from cryptography.x509.oid import NameOID + +import datetime +import click + +# Use slot 9A (authentication), and key type RSA 2048 +slot = SLOT.AUTHENTICATION +key_type = KEY_TYPE.RSA2048 + +# Connect to a YubiKey +yubikey = s.single() + +# Establish a PIV session +piv = PivSession(yubikey.smart_card()) + +click.echo("WARNING") +click.echo(f"This will overwrite any key already in slot {slot:X} of the YubiKey!") +click.echo("") + +# Unlock with the management key +key = click.prompt( + "Enter management key", default=DEFAULT_MANAGEMENT_KEY.hex(), hide_input=True +) +piv.authenticate(MANAGEMENT_KEY_TYPE.TDES, bytes.fromhex(key)) + +# Generate a private key on the YubiKey +print(f"Generating {key_type.name} key in slot {slot:X}...") +pub_key = piv.generate_key(slot, key_type) + +now = datetime.datetime.now() + +# Prepare the subject: +subject = x509.Name( + [ + x509.NameAttribute(NameOID.COUNTRY_NAME, "SE"), + x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "Stockholm"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Example Co"), + x509.NameAttribute(NameOID.COMMON_NAME, "Example Certificate"), + ] +) + +# Prepare the certificate +builder = ( + x509.CertificateBuilder() + .issuer_name(subject) # Same as subject since this is self-signed + .subject_name(subject) + .not_valid_before(now) + .not_valid_after(now + datetime.timedelta(days=7)) # 7 day validity + .serial_number(x509.random_serial_number()) + .public_key(pub_key) + # Some examples of extensions to add, many more are possible: + .add_extension( + x509.BasicConstraints(ca=False, path_length=None), + critical=True, + ) + .add_extension( + x509.SubjectAlternativeName( + [ + x509.DNSName("example.com"), + ] + ), + critical=False, + ) +) + + +# Verify the PIN +pin = click.prompt("Enter PIN", hide_input=True) +piv.verify_pin(pin) + +# Sign the certificate +certificate = sign_certificate_builder(piv, slot, key_type, builder) +pem = certificate.public_bytes(serialization.Encoding.PEM) + +click.echo("Certificate generated!") +click.echo("") + +# Print the certificate +print(pem.decode()) diff --git a/examples/yubiotp_batch.py b/examples/yubiotp_batch.py new file mode 100644 index 000000000..69c29d3d1 --- /dev/null +++ b/examples/yubiotp_batch.py @@ -0,0 +1,68 @@ +""" +This script will program Yubico OTP credentials for a batch of YubiKeys, outputting +a .csv file with the secret values for upload to a validation server. + +YubiKeys are inserted as the script is running, and can be removed once programmed. +When done, press Ctrl+C to end the batch. If a YubiKey is inserted twice in the same +session it will be ignored. + +Usage: yubiotp_batch.py +""" + +from ykman import scripting as s +from ykman.otp import format_csv +from yubikit.yubiotp import YubiOtpSession, YubiOtpSlotConfiguration, SLOT + +import os +import sys +import struct + + +# csv file out output to, given as an argument +try: + output_fname = sys.argv[1] +except IndexError: + print("USAGE: yubiotp_batch.py ") + sys.exit(1) + +# Write configuration to file +with open(output_fname, "a") as output: + for device in s.multi(allow_initial=True): + print(f"Programming YubiKey: {device}...") + serial = device.info.serial + if serial is None: + print("No serial number, skipping") + continue + + with device.otp() as connection: + session = YubiOtpSession(connection) + + # Change these as appropriate + # slot = SLOT.ONE + slot = SLOT.TWO + + # "cccc" + serial, 6 bytes (12 characters in modhex) + public_id = b"\x00\x00" + struct.pack(b">I", serial) + + # Randomly generate private ID and AES key + private_id = os.urandom(6) + key = os.urandom(16) + + # Access code from serial (BCD encoded, 6 bytes) + # access_code = bytes.fromhex(f"{serial:012}") + access_code = None + + # Write the configuration to the YubiKey + session.put_configuration( + slot, + YubiOtpSlotConfiguration(public_id, private_id, key).append_cr(True), + access_code, + ) + + # Write the configuration as a line in the output file + csv_line = format_csv(serial, public_id, private_id, key, access_code) + output.write(csv_line + "\n") + + print("Done! Insert next YubiKey...") + +print("Done programming. Output written to:", output_fname) diff --git a/examples/yubiotp_batch_nfc.py b/examples/yubiotp_batch_nfc.py new file mode 100644 index 000000000..295520c8a --- /dev/null +++ b/examples/yubiotp_batch_nfc.py @@ -0,0 +1,72 @@ +""" +This script will program Yubico OTP credentials for a batch of YubiKeys, outputting +a .csv file with the secret values for upload to a validation server. + +YubiKeys are programmed over NFC using an NFC reader. One YubiKey can be placed +on the reader at a time. When done, press Ctrl+C to end the batch. If a YubiKey +is presented twice in the same session it will be ignored. + +Usage: yubiotp_batch_nfc.py +""" + +from ykman import scripting as s +from ykman.otp import format_csv +from yubikit.yubiotp import YubiOtpSession, YubiOtpSlotConfiguration, SLOT + +import os +import sys +import struct + + +try: + # name of the NFC reader to use. Case-insentitive substring matching. + nfc_reader = sys.argv[1] # e.g: "hid" + # csv file out output to, given as an argument + output_fname = sys.argv[2] # e.g: "output.csv" +except IndexError: + print("USAGE: yubiotp_batch_nfc.py ") + sys.exit(1) + +# Write configuration to file +with open(output_fname, "a") as output: + # Look for YubiKeys on the NFC reader matched by the argument + for device in s.multi_nfc(nfc_reader): + print(f"Programming YubiKey: {device}...") + serial = device.info.serial + if serial is None: + print("No serial number, skipping") + continue + + # NFC uses a SmartCardConnection for the OTP application + with device.smart_card() as connection: + session = YubiOtpSession(connection) + + # Change these as appropriate + # slot = SLOT.ONE + slot = SLOT.TWO + + # "cccc" + serial, 6 bytes (12 characters in modhex) + public_id = b"\x00\x00" + struct.pack(b">I", serial) + + # Randomly generate private ID and AES key + private_id = os.urandom(6) + key = os.urandom(16) + + # Access code from serial (BCD encoded, 6 bytes) + # access_code = bytes.fromhex(f"{serial:012}") + access_code = None + + # Write the configuration to the YubiKey + session.put_configuration( + slot, + YubiOtpSlotConfiguration(public_id, private_id, key).append_cr(True), + access_code, + ) + + # Write the configuration as a line in the output file + csv_line = format_csv(serial, public_id, private_id, key, access_code) + output.write(csv_line + "\n") + + print("Done! Replace the YubiKey with the next one...") + +print("Done programming. Output written to:", output_fname) diff --git a/generate-man.py b/generate-man.py new file mode 100644 index 000000000..10f4f4f49 --- /dev/null +++ b/generate-man.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 + +import re +import sys +from subprocess import check_output # nosec +from typing import List + +if len(sys.argv) != 4: + print("Usage: generate-man.py ") + sys.exit(1) + +version, year, month = sys.argv[1:] +int(year) +month = month[0].upper() + month[1:].lower() + +print( + rf""".TH YKMAN "1" "{month} {year}" "ykman {version}" "User Commands" +.SH NAME +ykman \- YubiKey Manager (ykman) +.SH SYNOPSIS +.B ykman +[\fI\,OPTIONS\/\fR] \fI\,COMMAND \/\fR[\fI\,ARGS\/\fR]...""" +) + +help_text = check_output(["poetry", "run", "ykman", "--help"]).decode() # nosec +parts = re.split(r"\b[A-Z][a-z]+:\s+", help_text) +description = re.split(r"\s{2,}", parts[1])[1].strip() + +print(f".SH DESCRIPTION\n.PP\n{description}\n.SH OPTIONS") + +options = re.split(r"\s{2,}", parts[3].strip()) +buf = "" +opt: List[str] = [] +while options: + o = options.pop(0) + if o.startswith("-"): + if opt: + print(".TP") + print((opt[0] + "\n" + " ".join(opt[1:])).replace("-", r"\-")) + opt = [re.sub(r"([-a-z]+)", r"\\fB\1\\fR", o)] + else: + opt.append(o) +print(".TP") +print((opt[0] + "\n" + " ".join(opt[1:])).replace("-", r"\-")) + +print('.SS "Commands:"') +commands = re.split(r"\s{2,}", parts[4].strip()) +while commands: + print(f".TP\n{commands.pop(0)}\n{commands.pop(0)}") + +print(".SH EXAMPLES") +examples = re.split(r"\s{2,}", parts[2].strip()) +while examples: + print(f".PP\n{examples.pop(0)}\n.PP\n{examples.pop(0)}") diff --git a/lgtm.yml b/lgtm.yml deleted file mode 100644 index a4b95fb09..000000000 --- a/lgtm.yml +++ /dev/null @@ -1,7 +0,0 @@ ---- -extraction: - python: - prepare: - packages: - - swig - - libpcsclite-dev diff --git a/man/ykman.1 b/man/ykman.1 index 3ed99fe78..0c5f31bd6 100644 --- a/man/ykman.1 +++ b/man/ykman.1 @@ -1,4 +1,4 @@ -.TH YKMAN "1" "July 2021" "ykman 4.0.5" "User Commands" +.TH YKMAN "1" "October 2023" "ykman 5.2.1" "User Commands" .SH NAME ykman \- YubiKey Manager (ykman) .SH SYNOPSIS @@ -10,60 +10,65 @@ 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. +specify which YubiKey to interact with by serial number .TP \fB\-r\fR, \fB\-\-reader\fR NAME -Use an external smart card reader. Conflicts with --device and list. +specify a YubiKey by smart card reader name (can't be used with \-\-device or list) .TP -\fB\-l\fR, \fB\-\-log\fR-level [DEBUG|INFO|WARNING|ERROR|CRITICAL] -Enable logging at given verbosity level. +\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 logs to the given FILE instead of standard error; ignored unless --log-level -is also set. +write log to FILE instead of printing to stderr (requires \-\-log\-level) .TP \fB\-\-diagnose\fR -Show diagnostics information useful for troubleshooting. +show diagnostics information useful for troubleshooting .TP \fB\-v\fR, \fB\-\-version\fR -Show version information about the app +show version information about the app .TP \fB\-\-full\-help\fR -Show --help, including hidden commands, and exit. +show \-\-help output, including hidden commands .TP \fB\-h\fR, \fB\-\-help\fR -Show this message and exit. +show this message and exit .SS "Commands:" .TP info -Show general information. +show general information .TP list -List connected YubiKeys. +list connected YubiKeys +.TP +script +run a python script .TP config -Enable/Disable applications. +enable or disable applications .TP fido -Manage the FIDO applications. +manage the FIDO applications +.TP +hsmauth +manage the YubiHSM Auth application .TP oath -Manage the OATH application. +manage the OATH application .TP openpgp -Manage the OpenPGP application. +manage the OpenPGP application .TP otp -Manage the YubiOTP application. +manage the YubiOTP application .TP piv -Manage the PIV application. +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 0123456: +Show information about YubiKey with serial number 123456: .PP -$ ykman --device 0123456 info +$ ykman --device 123456 info diff --git a/poetry.lock b/poetry.lock new file mode 100755 index 000000000..2221264a0 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1062 @@ +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. + +[[package]] +name = "alabaster" +version = "0.7.13" +description = "A configurable sidebar-enabled Sphinx theme" +optional = false +python-versions = ">=3.6" +files = [ + {file = "alabaster-0.7.13-py3-none-any.whl", hash = "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3"}, + {file = "alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2"}, +] + +[[package]] +name = "altgraph" +version = "0.17.4" +description = "Python graph (network) package" +optional = false +python-versions = "*" +files = [ + {file = "altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff"}, + {file = "altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406"}, +] + +[[package]] +name = "babel" +version = "2.13.1" +description = "Internationalization utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "Babel-2.13.1-py3-none-any.whl", hash = "sha256:7077a4984b02b6727ac10f1f7294484f737443d7e2e66c5e4380e41a3ae0b4ed"}, + {file = "Babel-2.13.1.tar.gz", hash = "sha256:33e0952d7dd6374af8dbf6768cc4ddf3ccfefc244f9986d4074704f2fbd18900"}, +] + +[package.dependencies] +pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} +setuptools = {version = "*", markers = "python_version >= \"3.12\""} + +[package.extras] +dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] + +[[package]] +name = "certifi" +version = "2023.7.22" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, + {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, +] + +[[package]] +name = "cffi" +version = "1.16.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cffi-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088"}, + {file = "cffi-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614"}, + {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743"}, + {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d"}, + {file = "cffi-1.16.0-cp310-cp310-win32.whl", hash = "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a"}, + {file = "cffi-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1"}, + {file = "cffi-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404"}, + {file = "cffi-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e"}, + {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc"}, + {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb"}, + {file = "cffi-1.16.0-cp311-cp311-win32.whl", hash = "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab"}, + {file = "cffi-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba"}, + {file = "cffi-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956"}, + {file = "cffi-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969"}, + {file = "cffi-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520"}, + {file = "cffi-1.16.0-cp312-cp312-win32.whl", hash = "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b"}, + {file = "cffi-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235"}, + {file = "cffi-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324"}, + {file = "cffi-1.16.0-cp38-cp38-win32.whl", hash = "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a"}, + {file = "cffi-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36"}, + {file = "cffi-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed"}, + {file = "cffi-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098"}, + {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000"}, + {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe"}, + {file = "cffi-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4"}, + {file = "cffi-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8"}, + {file = "cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0"}, +] + +[package.dependencies] +pycparser = "*" + +[[package]] +name = "charset-normalizer" +version = "3.3.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.3.1.tar.gz", hash = "sha256:d9137a876020661972ca6eec0766d81aef8a5627df628b664b234b73396e727e"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8aee051c89e13565c6bd366813c386939f8e928af93c29fda4af86d25b73d8f8"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:352a88c3df0d1fa886562384b86f9a9e27563d4704ee0e9d56ec6fcd270ea690"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:223b4d54561c01048f657fa6ce41461d5ad8ff128b9678cfe8b2ecd951e3f8a2"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f861d94c2a450b974b86093c6c027888627b8082f1299dfd5a4bae8e2292821"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1171ef1fc5ab4693c5d151ae0fdad7f7349920eabbaca6271f95969fa0756c2d"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28f512b9a33235545fbbdac6a330a510b63be278a50071a336afc1b78781b147"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0e842112fe3f1a4ffcf64b06dc4c61a88441c2f02f373367f7b4c1aa9be2ad5"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f9bc2ce123637a60ebe819f9fccc614da1bcc05798bbbaf2dd4ec91f3e08846"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f194cce575e59ffe442c10a360182a986535fd90b57f7debfaa5c845c409ecc3"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:9a74041ba0bfa9bc9b9bb2cd3238a6ab3b7618e759b41bd15b5f6ad958d17605"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b578cbe580e3b41ad17b1c428f382c814b32a6ce90f2d8e39e2e635d49e498d1"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:6db3cfb9b4fcecb4390db154e75b49578c87a3b9979b40cdf90d7e4b945656e1"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:debb633f3f7856f95ad957d9b9c781f8e2c6303ef21724ec94bea2ce2fcbd056"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-win32.whl", hash = "sha256:87071618d3d8ec8b186d53cb6e66955ef2a0e4fa63ccd3709c0c90ac5a43520f"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:e372d7dfd154009142631de2d316adad3cc1c36c32a38b16a4751ba78da2a397"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ae4070f741f8d809075ef697877fd350ecf0b7c5837ed68738607ee0a2c572cf"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:58e875eb7016fd014c0eea46c6fa92b87b62c0cb31b9feae25cbbe62c919f54d"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dbd95e300367aa0827496fe75a1766d198d34385a58f97683fe6e07f89ca3e3c"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de0b4caa1c8a21394e8ce971997614a17648f94e1cd0640fbd6b4d14cab13a72"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:985c7965f62f6f32bf432e2681173db41336a9c2611693247069288bcb0c7f8b"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a15c1fe6d26e83fd2e5972425a772cca158eae58b05d4a25a4e474c221053e2d"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae55d592b02c4349525b6ed8f74c692509e5adffa842e582c0f861751701a673"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be4d9c2770044a59715eb57c1144dedea7c5d5ae80c68fb9959515037cde2008"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:851cf693fb3aaef71031237cd68699dded198657ec1e76a76eb8be58c03a5d1f"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:31bbaba7218904d2eabecf4feec0d07469284e952a27400f23b6628439439fa7"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:871d045d6ccc181fd863a3cd66ee8e395523ebfbc57f85f91f035f50cee8e3d4"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:501adc5eb6cd5f40a6f77fbd90e5ab915c8fd6e8c614af2db5561e16c600d6f3"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f5fb672c396d826ca16a022ac04c9dce74e00a1c344f6ad1a0fdc1ba1f332213"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-win32.whl", hash = "sha256:bb06098d019766ca16fc915ecaa455c1f1cd594204e7f840cd6258237b5079a8"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:8af5a8917b8af42295e86b64903156b4f110a30dca5f3b5aedea123fbd638bff"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:7ae8e5142dcc7a49168f4055255dbcced01dc1714a90a21f87448dc8d90617d1"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5b70bab78accbc672f50e878a5b73ca692f45f5b5e25c8066d748c09405e6a55"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5ceca5876032362ae73b83347be8b5dbd2d1faf3358deb38c9c88776779b2e2f"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d95638ff3613849f473afc33f65c401a89f3b9528d0d213c7037c398a51296"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9edbe6a5bf8b56a4a84533ba2b2f489d0046e755c29616ef8830f9e7d9cf5728"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6a02a3c7950cafaadcd46a226ad9e12fc9744652cc69f9e5534f98b47f3bbcf"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10b8dd31e10f32410751b3430996f9807fc4d1587ca69772e2aa940a82ab571a"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edc0202099ea1d82844316604e17d2b175044f9bcb6b398aab781eba957224bd"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b891a2f68e09c5ef989007fac11476ed33c5c9994449a4e2c3386529d703dc8b"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:71ef3b9be10070360f289aea4838c784f8b851be3ba58cf796262b57775c2f14"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:55602981b2dbf8184c098bc10287e8c245e351cd4fdcad050bd7199d5a8bf514"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:46fb9970aa5eeca547d7aa0de5d4b124a288b42eaefac677bde805013c95725c"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:520b7a142d2524f999447b3a0cf95115df81c4f33003c51a6ab637cbda9d0bf4"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-win32.whl", hash = "sha256:8ec8ef42c6cd5856a7613dcd1eaf21e5573b2185263d87d27c8edcae33b62a61"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:baec8148d6b8bd5cee1ae138ba658c71f5b03e0d69d5907703e3e1df96db5e41"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:63a6f59e2d01310f754c270e4a257426fe5a591dc487f1983b3bbe793cf6bac6"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d6bfc32a68bc0933819cfdfe45f9abc3cae3877e1d90aac7259d57e6e0f85b1"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4f3100d86dcd03c03f7e9c3fdb23d92e32abbca07e7c13ebd7ddfbcb06f5991f"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39b70a6f88eebe239fa775190796d55a33cfb6d36b9ffdd37843f7c4c1b5dc67"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e12f8ee80aa35e746230a2af83e81bd6b52daa92a8afaef4fea4a2ce9b9f4fa"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b6cefa579e1237ce198619b76eaa148b71894fb0d6bcf9024460f9bf30fd228"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:61f1e3fb621f5420523abb71f5771a204b33c21d31e7d9d86881b2cffe92c47c"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4f6e2a839f83a6a76854d12dbebde50e4b1afa63e27761549d006fa53e9aa80e"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:1ec937546cad86d0dce5396748bf392bb7b62a9eeb8c66efac60e947697f0e58"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:82ca51ff0fc5b641a2d4e1cc8c5ff108699b7a56d7f3ad6f6da9dbb6f0145b48"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:633968254f8d421e70f91c6ebe71ed0ab140220469cf87a9857e21c16687c034"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-win32.whl", hash = "sha256:c0c72d34e7de5604df0fde3644cc079feee5e55464967d10b24b1de268deceb9"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:63accd11149c0f9a99e3bc095bbdb5a464862d77a7e309ad5938fbc8721235ae"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5a3580a4fdc4ac05f9e53c57f965e3594b2f99796231380adb2baaab96e22761"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2465aa50c9299d615d757c1c888bc6fef384b7c4aec81c05a0172b4400f98557"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cb7cd68814308aade9d0c93c5bd2ade9f9441666f8ba5aa9c2d4b389cb5e2a45"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91e43805ccafa0a91831f9cd5443aa34528c0c3f2cc48c4cb3d9a7721053874b"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:854cc74367180beb327ab9d00f964f6d91da06450b0855cbbb09187bcdb02de5"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c15070ebf11b8b7fd1bfff7217e9324963c82dbdf6182ff7050519e350e7ad9f"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c4c99f98fc3a1835af8179dcc9013f93594d0670e2fa80c83aa36346ee763d2"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fb765362688821404ad6cf86772fc54993ec11577cd5a92ac44b4c2ba52155b"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dced27917823df984fe0c80a5c4ad75cf58df0fbfae890bc08004cd3888922a2"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a66bcdf19c1a523e41b8e9d53d0cedbfbac2e93c649a2e9502cb26c014d0980c"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:ecd26be9f112c4f96718290c10f4caea6cc798459a3a76636b817a0ed7874e42"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:3f70fd716855cd3b855316b226a1ac8bdb3caf4f7ea96edcccc6f484217c9597"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:17a866d61259c7de1bdadef418a37755050ddb4b922df8b356503234fff7932c"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-win32.whl", hash = "sha256:548eefad783ed787b38cb6f9a574bd8664468cc76d1538215d510a3cd41406cb"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:45f053a0ece92c734d874861ffe6e3cc92150e32136dd59ab1fb070575189c97"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bc791ec3fd0c4309a753f95bb6c749ef0d8ea3aea91f07ee1cf06b7b02118f2f"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0c8c61fb505c7dad1d251c284e712d4e0372cef3b067f7ddf82a7fa82e1e9a93"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2c092be3885a1b7899cd85ce24acedc1034199d6fca1483fa2c3a35c86e43041"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2000c54c395d9e5e44c99dc7c20a64dc371f777faf8bae4919ad3e99ce5253e"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4cb50a0335382aac15c31b61d8531bc9bb657cfd848b1d7158009472189f3d62"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c30187840d36d0ba2893bc3271a36a517a717f9fd383a98e2697ee890a37c273"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe81b35c33772e56f4b6cf62cf4aedc1762ef7162a31e6ac7fe5e40d0149eb67"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d0bf89afcbcf4d1bb2652f6580e5e55a840fdf87384f6063c4a4f0c95e378656"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:06cf46bdff72f58645434d467bf5228080801298fbba19fe268a01b4534467f5"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:3c66df3f41abee950d6638adc7eac4730a306b022570f71dd0bd6ba53503ab57"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:cd805513198304026bd379d1d516afbf6c3c13f4382134a2c526b8b854da1c2e"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:9505dc359edb6a330efcd2be825fdb73ee3e628d9010597aa1aee5aa63442e97"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:31445f38053476a0c4e6d12b047b08ced81e2c7c712e5a1ad97bc913256f91b2"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-win32.whl", hash = "sha256:bd28b31730f0e982ace8663d108e01199098432a30a4c410d06fe08fdb9e93f4"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:555fe186da0068d3354cdf4bbcbc609b0ecae4d04c921cc13e209eece7720727"}, + {file = "charset_normalizer-3.3.1-py3-none-any.whl", hash = "sha256:800561453acdecedaac137bf09cd719c7a440b6800ec182f077bb8e7025fb708"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "cryptography" +version = "41.0.5" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = ">=3.7" +files = [ + {file = "cryptography-41.0.5-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:da6a0ff8f1016ccc7477e6339e1d50ce5f59b88905585f77193ebd5068f1e797"}, + {file = "cryptography-41.0.5-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b948e09fe5fb18517d99994184854ebd50b57248736fd4c720ad540560174ec5"}, + {file = "cryptography-41.0.5-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d38e6031e113b7421db1de0c1b1f7739564a88f1684c6b89234fbf6c11b75147"}, + {file = "cryptography-41.0.5-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e270c04f4d9b5671ebcc792b3ba5d4488bf7c42c3c241a3748e2599776f29696"}, + {file = "cryptography-41.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ec3b055ff8f1dce8e6ef28f626e0972981475173d7973d63f271b29c8a2897da"}, + {file = "cryptography-41.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7d208c21e47940369accfc9e85f0de7693d9a5d843c2509b3846b2db170dfd20"}, + {file = "cryptography-41.0.5-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:8254962e6ba1f4d2090c44daf50a547cd5f0bf446dc658a8e5f8156cae0d8548"}, + {file = "cryptography-41.0.5-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:a48e74dad1fb349f3dc1d449ed88e0017d792997a7ad2ec9587ed17405667e6d"}, + {file = "cryptography-41.0.5-cp37-abi3-win32.whl", hash = "sha256:d3977f0e276f6f5bf245c403156673db103283266601405376f075c849a0b936"}, + {file = "cryptography-41.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:73801ac9736741f220e20435f84ecec75ed70eda90f781a148f1bad546963d81"}, + {file = "cryptography-41.0.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3be3ca726e1572517d2bef99a818378bbcf7d7799d5372a46c79c29eb8d166c1"}, + {file = "cryptography-41.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:e886098619d3815e0ad5790c973afeee2c0e6e04b4da90b88e6bd06e2a0b1b72"}, + {file = "cryptography-41.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:573eb7128cbca75f9157dcde974781209463ce56b5804983e11a1c462f0f4e88"}, + {file = "cryptography-41.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0c327cac00f082013c7c9fb6c46b7cc9fa3c288ca702c74773968173bda421bf"}, + {file = "cryptography-41.0.5-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:227ec057cd32a41c6651701abc0328135e472ed450f47c2766f23267b792a88e"}, + {file = "cryptography-41.0.5-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:22892cc830d8b2c89ea60148227631bb96a7da0c1b722f2aac8824b1b7c0b6b8"}, + {file = "cryptography-41.0.5-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:5a70187954ba7292c7876734183e810b728b4f3965fbe571421cb2434d279179"}, + {file = "cryptography-41.0.5-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:88417bff20162f635f24f849ab182b092697922088b477a7abd6664ddd82291d"}, + {file = "cryptography-41.0.5-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c707f7afd813478e2019ae32a7c49cd932dd60ab2d2a93e796f68236b7e1fbf1"}, + {file = "cryptography-41.0.5-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:580afc7b7216deeb87a098ef0674d6ee34ab55993140838b14c9b83312b37b86"}, + {file = "cryptography-41.0.5-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1e91467c65fe64a82c689dc6cf58151158993b13eb7a7f3f4b7f395636723"}, + {file = "cryptography-41.0.5-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:0d2a6a598847c46e3e321a7aef8af1436f11c27f1254933746304ff014664d84"}, + {file = "cryptography-41.0.5.tar.gz", hash = "sha256:392cb88b597247177172e02da6b7a63deeff1937fa6fec3bbf902ebd75d97ec7"}, +] + +[package.dependencies] +cffi = ">=1.12" + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] +nox = ["nox"] +pep8test = ["black", "check-sdist", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + +[[package]] +name = "docutils" +version = "0.18.1" +description = "Docutils -- Python Documentation Utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "docutils-0.18.1-py2.py3-none-any.whl", hash = "sha256:23010f129180089fbcd3bc08cfefccb3b890b0050e1ca00c867036e9d161b98c"}, + {file = "docutils-0.18.1.tar.gz", hash = "sha256:679987caf361a7539d76e584cbeddc311e3aee937877c87346f31debc63e9d06"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.1.3" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, + {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "fido2" +version = "1.1.2" +description = "FIDO2/WebAuthn library for implementing clients and servers." +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "fido2-1.1.2-py3-none-any.whl", hash = "sha256:a3b7d7d233dec3a4fa0d6178fc34d1cce17b820005a824f6ab96917a1e3be8d8"}, + {file = "fido2-1.1.2.tar.gz", hash = "sha256:6110d913106f76199201b32d262b2857562cc46ba1d0b9c51fbce30dc936c573"}, +] + +[package.dependencies] +cryptography = ">=2.6,<35 || >35,<44" + +[package.extras] +pcsc = ["pyscard (>=1.9,<3)"] + +[[package]] +name = "idna" +version = "3.4" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, + {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, +] + +[[package]] +name = "imagesize" +version = "1.4.1" +description = "Getting image size from png/jpeg/jpeg2000/gif file" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, + {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, +] + +[[package]] +name = "importlib-metadata" +version = "6.8.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "importlib_metadata-6.8.0-py3-none-any.whl", hash = "sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb"}, + {file = "importlib_metadata-6.8.0.tar.gz", hash = "sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743"}, +] + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +perf = ["ipython"] +testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] + +[[package]] +name = "importlib-resources" +version = "6.1.0" +description = "Read resources from Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "importlib_resources-6.1.0-py3-none-any.whl", hash = "sha256:aa50258bbfa56d4e33fbd8aa3ef48ded10d1735f11532b8df95388cc6bdb7e83"}, + {file = "importlib_resources-6.1.0.tar.gz", hash = "sha256:9d48dcccc213325e810fd723e7fbb45ccb39f6cf5c31f00cf2b965f5f10f3cb9"}, +] + +[package.dependencies] +zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-ruff", "zipp (>=3.17)"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "jaraco-classes" +version = "3.3.0" +description = "Utility functions for Python class constructs" +optional = false +python-versions = ">=3.8" +files = [ + {file = "jaraco.classes-3.3.0-py3-none-any.whl", hash = "sha256:10afa92b6743f25c0cf5f37c6bb6e18e2c5bb84a16527ccfc0040ea377e7aaeb"}, + {file = "jaraco.classes-3.3.0.tar.gz", hash = "sha256:c063dd08e89217cee02c8d5e5ec560f2c8ce6cdc2fcdc2e68f7b2e5547ed3621"}, +] + +[package.dependencies] +more-itertools = "*" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-ruff"] + +[[package]] +name = "jeepney" +version = "0.8.0" +description = "Low-level, pure Python DBus protocol wrapper." +optional = false +python-versions = ">=3.7" +files = [ + {file = "jeepney-0.8.0-py3-none-any.whl", hash = "sha256:c0a454ad016ca575060802ee4d590dd912e35c122fa04e70306de3d076cce755"}, + {file = "jeepney-0.8.0.tar.gz", hash = "sha256:5efe48d255973902f6badc3ce55e2aa6c5c3b3bc642059ef3a91247bcfcc5806"}, +] + +[package.extras] +test = ["async-timeout", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"] +trio = ["async_generator", "trio"] + +[[package]] +name = "jinja2" +version = "3.1.2" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +files = [ + {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, + {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "keyring" +version = "24.2.0" +description = "Store and access your passwords safely." +optional = false +python-versions = ">=3.8" +files = [ + {file = "keyring-24.2.0-py3-none-any.whl", hash = "sha256:4901caaf597bfd3bbd78c9a0c7c4c29fcd8310dab2cffefe749e916b6527acd6"}, + {file = "keyring-24.2.0.tar.gz", hash = "sha256:ca0746a19ec421219f4d713f848fa297a661a8a8c1504867e55bfb5e09091509"}, +] + +[package.dependencies] +importlib-metadata = {version = ">=4.11.4", markers = "python_version < \"3.12\""} +importlib-resources = {version = "*", markers = "python_version < \"3.9\""} +"jaraco.classes" = "*" +jeepney = {version = ">=0.4.2", markers = "sys_platform == \"linux\""} +pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""} +SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""} + +[package.extras] +completion = ["shtab"] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-ruff"] + +[[package]] +name = "macholib" +version = "1.16.3" +description = "Mach-O header analysis and editing" +optional = false +python-versions = "*" +files = [ + {file = "macholib-1.16.3-py2.py3-none-any.whl", hash = "sha256:0e315d7583d38b8c77e815b1ecbdbf504a8258d8b3e17b61165c6feb60d18f2c"}, + {file = "macholib-1.16.3.tar.gz", hash = "sha256:07ae9e15e8e4cd9a788013d81f5908b3609aa76f9b1421bae9c4d7606ec86a30"}, +] + +[package.dependencies] +altgraph = ">=0.17" + +[[package]] +name = "makefun" +version = "1.15.1" +description = "Small library to dynamically create python functions." +optional = false +python-versions = "*" +files = [ + {file = "makefun-1.15.1-py2.py3-none-any.whl", hash = "sha256:a63cfc7b47a539c76d97bd4fdb833c7d0461e759fd1225f580cb4be6200294d4"}, + {file = "makefun-1.15.1.tar.gz", hash = "sha256:40b0f118b6ded0d8d78c78f1eb679b8b6b2462e3c1b3e05fb1b2da8cd46b48a5"}, +] + +[[package]] +name = "markupsafe" +version = "2.1.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"}, + {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, +] + +[[package]] +name = "more-itertools" +version = "10.1.0" +description = "More routines for operating on iterables, beyond itertools" +optional = false +python-versions = ">=3.8" +files = [ + {file = "more-itertools-10.1.0.tar.gz", hash = "sha256:626c369fa0eb37bac0291bce8259b332fd59ac792fa5497b59837309cd5b114a"}, + {file = "more_itertools-10.1.0-py3-none-any.whl", hash = "sha256:64e0735fcfdc6f3464ea133afe8ea4483b1c5fe3a3d69852e6503b43a0b222e6"}, +] + +[[package]] +name = "packaging" +version = "23.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.7" +files = [ + {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, + {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, +] + +[[package]] +name = "pefile" +version = "2023.2.7" +description = "Python PE parsing module" +optional = false +python-versions = ">=3.6.0" +files = [ + {file = "pefile-2023.2.7-py3-none-any.whl", hash = "sha256:da185cd2af68c08a6cd4481f7325ed600a88f6a813bad9dea07ab3ef73d8d8d6"}, + {file = "pefile-2023.2.7.tar.gz", hash = "sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc"}, +] + +[[package]] +name = "pluggy" +version = "1.3.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, + {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pycparser" +version = "2.21" +description = "C parser in Python" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, +] + +[[package]] +name = "pygments" +version = "2.16.1" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.7" +files = [ + {file = "Pygments-2.16.1-py3-none-any.whl", hash = "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692"}, + {file = "Pygments-2.16.1.tar.gz", hash = "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29"}, +] + +[package.extras] +plugins = ["importlib-metadata"] + +[[package]] +name = "pyinstaller" +version = "6.1.0" +description = "PyInstaller bundles a Python application and all its dependencies into a single package." +optional = false +python-versions = "<3.13,>=3.8" +files = [ + {file = "pyinstaller-6.1.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:da78942d31c1911ea4abcd3ca3bd0c062af7f163a5e227fd18a359b61deda4ca"}, + {file = "pyinstaller-6.1.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:f63d2353537bac7bfeeaedbe5ac99f3be35daa290dd1ad1be90768acbf77e3d5"}, + {file = "pyinstaller-6.1.0-py3-none-manylinux2014_i686.whl", hash = "sha256:6e71d9f6f5a1e0f7523e8ebee1b76bb29538f64d863e3711c2b21033f499e2b9"}, + {file = "pyinstaller-6.1.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:bebf6f442bbe6343acaec873803510ee1930d026846a018f727da4e0690081f8"}, + {file = "pyinstaller-6.1.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:3c04963637481a3edf1eec64ab4c3fce098908f02fc472c11e73be7eedc08b95"}, + {file = "pyinstaller-6.1.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:4368e4eb9999ce32e3280330b3c26f175e0fa7fa13efb4d2dc4ade488ff6d7c2"}, + {file = "pyinstaller-6.1.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:041ab9311d08162356829bf47293a613c44dc9ace28846fb63098889c7383c5d"}, + {file = "pyinstaller-6.1.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:331f050e8f9e923bb6b50454acfc0547fd52092585c61eb5f2fc93de60703f13"}, + {file = "pyinstaller-6.1.0-py3-none-win32.whl", hash = "sha256:9e8b5bbc1bdf554ade1360e62e4959091430c3cc15ebfff3c28c8894fd1f312a"}, + {file = "pyinstaller-6.1.0-py3-none-win_amd64.whl", hash = "sha256:f9f5bcaef6122d93c54ee7a9ecb07eab5b81a7ebfb5cb99af2b2a6ff49eff62f"}, + {file = "pyinstaller-6.1.0-py3-none-win_arm64.whl", hash = "sha256:dd438afd2abb643f5399c0cb254a11c217c06782cb274a2911dd785f9f67fa9e"}, + {file = "pyinstaller-6.1.0.tar.gz", hash = "sha256:8f3d49c60f3344bf3d4a6d4258bda665dad185ab2b097341d3af2a6387c838ef"}, +] + +[package.dependencies] +altgraph = "*" +importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} +macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""} +packaging = ">=20.0" +pefile = {version = ">=2022.5.30", markers = "sys_platform == \"win32\""} +pyinstaller-hooks-contrib = ">=2021.4" +pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""} +setuptools = ">=42.0.0" + +[package.extras] +hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2023.10" +description = "Community maintained hooks for PyInstaller" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyinstaller-hooks-contrib-2023.10.tar.gz", hash = "sha256:4b4a998036abb713774cb26534ca06b7e6e09e4c628196017a10deb11a48747f"}, + {file = "pyinstaller_hooks_contrib-2023.10-py2.py3-none-any.whl", hash = "sha256:6dc1786a8f452941245d5bb85893e2a33632ebdcbc4c23eea41f2ee08281b0c0"}, +] + +[[package]] +name = "pyscard" +version = "2.0.7" +description = "Smartcard module for Python." +optional = false +python-versions = "*" +files = [ + {file = "pyscard-2.0.7-cp310-cp310-win32.whl", hash = "sha256:06666a597e1293421fa90e0d4fc2418add447b10b7dc85f49b3cafc23480f046"}, + {file = "pyscard-2.0.7-cp310-cp310-win_amd64.whl", hash = "sha256:a2266345bd387854298153264bff8b74f494581880a76e3e8679460c1b090fab"}, + {file = "pyscard-2.0.7-cp311-cp311-win32.whl", hash = "sha256:beacdcdc3d1516e195f7a38ec3966c5d4df7390c8f036cb41f6fef72bc5cc646"}, + {file = "pyscard-2.0.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e37b697327e8dc4848c481428d1cbd10b7ae2ce037bc799e5b8bbd2fc3ab5ed"}, + {file = "pyscard-2.0.7-cp37-cp37m-win32.whl", hash = "sha256:a0c5edbedafba62c68160884f878d9f53996d7219a3fc11b1cea6bab59c7f34a"}, + {file = "pyscard-2.0.7-cp37-cp37m-win_amd64.whl", hash = "sha256:f704ad40dc40306e1c0981941789518ab16aa1f84443b1d52ec0264884092b3b"}, + {file = "pyscard-2.0.7-cp38-cp38-win32.whl", hash = "sha256:59a466ab7ae20188dd197664b9ca1ea9524d115a5aa5b16b575a6b772cdcb73c"}, + {file = "pyscard-2.0.7-cp38-cp38-win_amd64.whl", hash = "sha256:da70aa5b7be5868b88cdb6d4a419d2791b6165beeb90cd01d2748033302a0f43"}, + {file = "pyscard-2.0.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2d4bdc1f4e0e6c46e417ac1bc9d5990f7cfb24a080e890d453781405f7bd29dc"}, + {file = "pyscard-2.0.7-cp39-cp39-win32.whl", hash = "sha256:39e030c47878b37ae08038a917959357be6468da52e8b144e84ffc659f50e6e2"}, + {file = "pyscard-2.0.7-cp39-cp39-win_amd64.whl", hash = "sha256:5a5865675be294c8d91f22dc91e7d897c4138881e5295fb6b2cd821f7c0389d9"}, + {file = "pyscard-2.0.7.tar.gz", hash = "sha256:278054525fa75fbe8b10460d87edcd03a70ad94d688b11345e4739987f85c1bf"}, +] + +[package.extras] +gui = ["wxPython"] +pyro = ["Pyro"] + +[[package]] +name = "pytest" +version = "7.4.3" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"}, + {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytz" +version = "2023.3.post1" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +files = [ + {file = "pytz-2023.3.post1-py2.py3-none-any.whl", hash = "sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7"}, + {file = "pytz-2023.3.post1.tar.gz", hash = "sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b"}, +] + +[[package]] +name = "pywin32" +version = "306" +description = "Python for Window Extensions" +optional = false +python-versions = "*" +files = [ + {file = "pywin32-306-cp310-cp310-win32.whl", hash = "sha256:06d3420a5155ba65f0b72f2699b5bacf3109f36acbe8923765c22938a69dfc8d"}, + {file = "pywin32-306-cp310-cp310-win_amd64.whl", hash = "sha256:84f4471dbca1887ea3803d8848a1616429ac94a4a8d05f4bc9c5dcfd42ca99c8"}, + {file = "pywin32-306-cp311-cp311-win32.whl", hash = "sha256:e65028133d15b64d2ed8f06dd9fbc268352478d4f9289e69c190ecd6818b6407"}, + {file = "pywin32-306-cp311-cp311-win_amd64.whl", hash = "sha256:a7639f51c184c0272e93f244eb24dafca9b1855707d94c192d4a0b4c01e1100e"}, + {file = "pywin32-306-cp311-cp311-win_arm64.whl", hash = "sha256:70dba0c913d19f942a2db25217d9a1b726c278f483a919f1abfed79c9cf64d3a"}, + {file = "pywin32-306-cp312-cp312-win32.whl", hash = "sha256:383229d515657f4e3ed1343da8be101000562bf514591ff383ae940cad65458b"}, + {file = "pywin32-306-cp312-cp312-win_amd64.whl", hash = "sha256:37257794c1ad39ee9be652da0462dc2e394c8159dfd913a8a4e8eb6fd346da0e"}, + {file = "pywin32-306-cp312-cp312-win_arm64.whl", hash = "sha256:5821ec52f6d321aa59e2db7e0a35b997de60c201943557d108af9d4ae1ec7040"}, + {file = "pywin32-306-cp37-cp37m-win32.whl", hash = "sha256:1c73ea9a0d2283d889001998059f5eaaba3b6238f767c9cf2833b13e6a685f65"}, + {file = "pywin32-306-cp37-cp37m-win_amd64.whl", hash = "sha256:72c5f621542d7bdd4fdb716227be0dd3f8565c11b280be6315b06ace35487d36"}, + {file = "pywin32-306-cp38-cp38-win32.whl", hash = "sha256:e4c092e2589b5cf0d365849e73e02c391c1349958c5ac3e9d5ccb9a28e017b3a"}, + {file = "pywin32-306-cp38-cp38-win_amd64.whl", hash = "sha256:e8ac1ae3601bee6ca9f7cb4b5363bf1c0badb935ef243c4733ff9a393b1690c0"}, + {file = "pywin32-306-cp39-cp39-win32.whl", hash = "sha256:e25fd5b485b55ac9c057f67d94bc203f3f6595078d1fb3b458c9c28b7153a802"}, + {file = "pywin32-306-cp39-cp39-win_amd64.whl", hash = "sha256:39b61c15272833b5c329a2989999dcae836b1eed650252ab1b7bfbe1d59f30f4"}, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.2" +description = "A (partial) reimplementation of pywin32 using ctypes/cffi" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pywin32-ctypes-0.2.2.tar.gz", hash = "sha256:3426e063bdd5fd4df74a14fa3cf80a0b42845a87e1d1e81f6549f9daec593a60"}, + {file = "pywin32_ctypes-0.2.2-py3-none-any.whl", hash = "sha256:bf490a1a709baf35d688fe0ecf980ed4de11d2b3e37b51e5442587a75d9957e7"}, +] + +[[package]] +name = "requests" +version = "2.31.0" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.7" +files = [ + {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, + {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "secretstorage" +version = "3.3.3" +description = "Python bindings to FreeDesktop.org Secret Service API" +optional = false +python-versions = ">=3.6" +files = [ + {file = "SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99"}, + {file = "SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77"}, +] + +[package.dependencies] +cryptography = ">=2.0" +jeepney = ">=0.6" + +[[package]] +name = "setuptools" +version = "68.2.2" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "setuptools-68.2.2-py3-none-any.whl", hash = "sha256:b454a35605876da60632df1a60f736524eb73cc47bbc9f3f1ef1b644de74fd2a"}, + {file = "setuptools-68.2.2.tar.gz", hash = "sha256:4ac1475276d2f1c48684874089fefcd83bd7162ddaafb81fac866ba0db282a87"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + +[[package]] +name = "snowballstemmer" +version = "2.2.0" +description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." +optional = false +python-versions = "*" +files = [ + {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, + {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, +] + +[[package]] +name = "sphinx" +version = "7.1.2" +description = "Python documentation generator" +optional = false +python-versions = ">=3.8" +files = [ + {file = "sphinx-7.1.2-py3-none-any.whl", hash = "sha256:d170a81825b2fcacb6dfd5a0d7f578a053e45d3f2b153fecc948c37344eb4cbe"}, + {file = "sphinx-7.1.2.tar.gz", hash = "sha256:780f4d32f1d7d1126576e0e5ecc19dc32ab76cd24e950228dcf7b1f6d3d9e22f"}, +] + +[package.dependencies] +alabaster = ">=0.7,<0.8" +babel = ">=2.9" +colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} +docutils = ">=0.18.1,<0.21" +imagesize = ">=1.3" +importlib-metadata = {version = ">=4.8", markers = "python_version < \"3.10\""} +Jinja2 = ">=3.0" +packaging = ">=21.0" +Pygments = ">=2.13" +requests = ">=2.25.0" +snowballstemmer = ">=2.0" +sphinxcontrib-applehelp = "*" +sphinxcontrib-devhelp = "*" +sphinxcontrib-htmlhelp = ">=2.0.0" +sphinxcontrib-jsmath = "*" +sphinxcontrib-qthelp = "*" +sphinxcontrib-serializinghtml = ">=1.1.5" + +[package.extras] +docs = ["sphinxcontrib-websupport"] +lint = ["docutils-stubs", "flake8 (>=3.5.0)", "flake8-simplify", "isort", "mypy (>=0.990)", "ruff", "sphinx-lint", "types-requests"] +test = ["cython", "filelock", "html5lib", "pytest (>=4.6)"] + +[[package]] +name = "sphinx-autodoc-typehints" +version = "1.24.0" +description = "Type hints (PEP 484) support for the Sphinx autodoc extension" +optional = false +python-versions = ">=3.8" +files = [ + {file = "sphinx_autodoc_typehints-1.24.0-py3-none-any.whl", hash = "sha256:6a73c0c61a9144ce2ed5ef2bed99d615254e5005c1cc32002017d72d69fb70e6"}, + {file = "sphinx_autodoc_typehints-1.24.0.tar.gz", hash = "sha256:94e440066941bb237704bb880785e2d05e8ae5406c88674feefbb938ad0dc6af"}, +] + +[package.dependencies] +sphinx = ">=7.0.1" + +[package.extras] +docs = ["furo (>=2023.5.20)", "sphinx (>=7.0.1)"] +numpy = ["nptyping (>=2.5)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "sphobjinv (>=2.3.1)", "typing-extensions (>=4.6.3)"] + +[[package]] +name = "sphinx-rtd-theme" +version = "1.3.0" +description = "Read the Docs theme for Sphinx" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +files = [ + {file = "sphinx_rtd_theme-1.3.0-py2.py3-none-any.whl", hash = "sha256:46ddef89cc2416a81ecfbeaceab1881948c014b1b6e4450b815311a89fb977b0"}, + {file = "sphinx_rtd_theme-1.3.0.tar.gz", hash = "sha256:590b030c7abb9cf038ec053b95e5380b5c70d61591eb0b552063fbe7c41f0931"}, +] + +[package.dependencies] +docutils = "<0.19" +sphinx = ">=1.6,<8" +sphinxcontrib-jquery = ">=4,<5" + +[package.extras] +dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client", "wheel"] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "1.0.4" +description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" +optional = false +python-versions = ">=3.8" +files = [ + {file = "sphinxcontrib-applehelp-1.0.4.tar.gz", hash = "sha256:828f867945bbe39817c210a1abfd1bc4895c8b73fcaade56d45357a348a07d7e"}, + {file = "sphinxcontrib_applehelp-1.0.4-py3-none-any.whl", hash = "sha256:29d341f67fb0f6f586b23ad80e072c8e6ad0b48417db2bde114a4c9746feb228"}, +] + +[package.extras] +lint = ["docutils-stubs", "flake8", "mypy"] +test = ["pytest"] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "1.0.2" +description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." +optional = false +python-versions = ">=3.5" +files = [ + {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, + {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, +] + +[package.extras] +lint = ["docutils-stubs", "flake8", "mypy"] +test = ["pytest"] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.0.1" +description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" +optional = false +python-versions = ">=3.8" +files = [ + {file = "sphinxcontrib-htmlhelp-2.0.1.tar.gz", hash = "sha256:0cbdd302815330058422b98a113195c9249825d681e18f11e8b1f78a2f11efff"}, + {file = "sphinxcontrib_htmlhelp-2.0.1-py3-none-any.whl", hash = "sha256:c38cb46dccf316c79de6e5515e1770414b797162b23cd3d06e67020e1d2a6903"}, +] + +[package.extras] +lint = ["docutils-stubs", "flake8", "mypy"] +test = ["html5lib", "pytest"] + +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +description = "Extension to include jQuery on newer Sphinx releases" +optional = false +python-versions = ">=2.7" +files = [ + {file = "sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a"}, + {file = "sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae"}, +] + +[package.dependencies] +Sphinx = ">=1.8" + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +description = "A sphinx extension which renders display math in HTML via JavaScript" +optional = false +python-versions = ">=3.5" +files = [ + {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, + {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, +] + +[package.extras] +test = ["flake8", "mypy", "pytest"] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "1.0.3" +description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." +optional = false +python-versions = ">=3.5" +files = [ + {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, + {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, +] + +[package.extras] +lint = ["docutils-stubs", "flake8", "mypy"] +test = ["pytest"] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "1.1.5" +description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." +optional = false +python-versions = ">=3.5" +files = [ + {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"}, + {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, +] + +[package.extras] +lint = ["docutils-stubs", "flake8", "mypy"] +test = ["pytest"] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "urllib3" +version = "2.0.7" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.7" +files = [ + {file = "urllib3-2.0.7-py3-none-any.whl", hash = "sha256:fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e"}, + {file = "urllib3-2.0.7.tar.gz", hash = "sha256:c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9cae55add7d0dd0f84"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "zipp" +version = "3.17.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.8" +files = [ + {file = "zipp-3.17.0-py3-none-any.whl", hash = "sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31"}, + {file = "zipp-3.17.0.tar.gz", hash = "sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.8" +content-hash = "cf9f1f7143c228ad540beb6940d11c8509540fd5ac050cbff9d4d73d2691651e" diff --git a/pyproject.toml b/pyproject.toml index baaa1945b..20322665a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "yubikey-manager" -version = "4.0.6-dev0" +version = "5.2.2-dev.0" description = "Tool for managing your YubiKey configuration." authors = ["Dain Nilsson "] license = "BSD" @@ -14,9 +14,9 @@ classifiers = [ "Topic :: Utilities" ] include = [ - "COPYING", - "NEWS", - "README.adoc", + { path = "COPYING", format = "sdist"}, + { path = "NEWS", format = "sdist"}, + { path = "README.adoc", format = "sdist"}, "man/", "tests/", ] @@ -27,22 +27,24 @@ packages = [ [tool.poetry.dependencies] -python = "^3.6" -dataclasses = {version = "^0.8", python = "<3.7"} -cryptography = "^2.1 || ^3.0" -pyOpenSSL = {version = ">=0.15.1", optional = true} -pyscard = "^1.9 || ^2.0" -fido2 = ">=0.9, <1.0" +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 = "^6.0" -pyOpenSSL = "^17.0" +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" +ykman = "ykman._cli.__main__:main" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/resources/macos/distribution.xml b/resources/macos/distribution.xml new file mode 100644 index 000000000..f12777146 --- /dev/null +++ b/resources/macos/distribution.xml @@ -0,0 +1,16 @@ + + + yubikey-manager + + + + + + + + + + + + ykman.pkg + diff --git a/resources/macos/make_pkg.sh b/resources/macos/make_pkg.sh new file mode 100755 index 000000000..b9bc5efe1 --- /dev/null +++ b/resources/macos/make_pkg.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# Script to produce an OS X installer .pkg + +set -e + +CWD=`pwd` +SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" +SOURCE_DIR="$CWD/ykman" +RELEASE_VERSION=`$SOURCE_DIR/ykman --version | awk '{print $(NF)}'` + +if [ -z "$1" ] +then + PKG="ykman.pkg" +else + PKG="$1" +fi + +echo "Release version : $RELEASE_VERSION" +echo "Binaries: $SOURCE_DIR" + +set -x + +cd $SCRIPT_DIR + +mkdir -p pkg/root/usr/local/bin pkg/comp +cp -r $SOURCE_DIR pkg/root/usr/local/ +(cd pkg/root/usr/local/bin && ln -s ../ykman/ykman) + +pkgbuild --root="pkg/root" --identifier "com.yubico.yubikey-manager" --version "$RELEASE_VERSION" "pkg/comp/ykman.pkg" + +productbuild --package-path "pkg/comp" --distribution "distribution.xml" "$PKG" + +# Move to dist +mv $PKG $CWD/$PKG + +# Clean up +rm -rf pkg diff --git a/resources/macos/make_release.sh b/resources/macos/make_release.sh new file mode 100755 index 000000000..8b58604e9 --- /dev/null +++ b/resources/macos/make_release.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# Script to produce a signed OS X installer .pkg + +set -e + +if [ "$#" -lt 2 ]; then + echo "" + echo " Usage: ./make_release.sh " + echo "" + exit 0 +fi + +CWD=`pwd` +SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" +echo "Script dir: $SCRIPT_DIR" + +SOURCE_DIR="$CWD/ykman" + +# Ensure executable, since we may have unpacked from zip +chmod +x $SOURCE_DIR/ykman + +RELEASE_VERSION=`$SOURCE_DIR/ykman --version | awk '{print $(NF)}'` +PKG="yubikey-manager-$RELEASE_VERSION-mac.pkg" + +echo "This will sign and notarize the app. Please make sure you have the code signing YubiKey connected." +echo "" +echo "Release version: $RELEASE_VERSION" +echo "Binaries: $SOURCE_DIR" +echo "Apple user ID for notarization: $1" +echo "" +read -p "Press enter to continue..." + +# Sign binaries +codesign -f --timestamp --options runtime --entitlements $SCRIPT_DIR/ykman.entitlements --sign 'Application' $SOURCE_DIR/ykman +codesign -f --timestamp --options runtime --sign 'Application' $(find $SOURCE_DIR/_internal -name "*.dylib" -o -name "*.so") +codesign -f --timestamp --options runtime --sign 'Application' $SOURCE_DIR/_internal/Python + +# Build pkg +sh $SCRIPT_DIR/make_pkg.sh ykman-unsigned.pkg + +# Sign the installer +productsign --sign 'Installer' ykman-unsigned.pkg $PKG + +# Clean up +rm ykman-unsigned.pkg + +echo "Installer signed, submitting for Notarization..." + +# Notarize +STATUS=$(xcrun notarytool submit "$PKG" --apple-id $1 --team-id LQA3CS5MM7 --password $2 --wait) +echo "Notarization status: ${STATUS}" + +if [[ "$STATUS" == *"Accepted"* ]]; then + echo "Notarization successfull. Staple the .pkg" + xcrun stapler staple -v "$PKG" + + echo "# .pkg stapled. Everything should be ready for release!" +fi diff --git a/resources/macos/ykman.entitlements b/resources/macos/ykman.entitlements new file mode 100644 index 000000000..0d4cdfd97 --- /dev/null +++ b/resources/macos/ykman.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.smartcard + + com.apple.security.device.usb + + com.apple.security.cs.allow-unsigned-executable-memory + + + diff --git a/resources/win/make_msi.ps1 b/resources/win/make_msi.ps1 new file mode 100644 index 000000000..a3fc26b28 --- /dev/null +++ b/resources/win/make_msi.ps1 @@ -0,0 +1,42 @@ +# Set-PSDebug -Trace 1 + +$ErrorActionPreference = "Stop" + +$CWD = pwd +$SOURCE_DIR = "$CWD\ykman" + +$VERSION = $(& "$SOURCE_DIR\ykman.exe" --version).Split(' ')[-1] + +echo "Release version: $VERSION" +echo "Binaries: $SOURCE_DIR" + +$SIMPLE_VERSION = "$($VERSION.Split('-')[0]).0" + +cd $PSScriptRoot + +((Get-Content -path ykman.wxs.in -Raw) -replace '{RELEASE_VERSION}',$SIMPLE_VERSION) | Set-Content -Path ykman.wxs + +$env:SRCDIR = $SOURCE_DIR + +echo "Running heat..." +& "$env:WIX\bin\heat.exe" dir $SOURCE_DIR -out fragment.wxs -gg -scom -srd -sfrag -sreg -dr INSTALLDIR -cg ApplicationFiles -var env.SRCDIR +echo "Running candle..." +& "$env:WIX\bin\candle.exe" fragment.wxs "ykman.wxs" -ext WixUtilExtension -arch "x64" +echo "Running light..." +& "$env:WIX\bin\light.exe" -v fragment.wixobj "ykman.wixobj" -ext WixUIExtension -ext WixUtilExtension -o "ykman.msi" + +# Move to dist +$OUTPUT="$CWD\ykman.msi" +if (Test-Path $OUTPUT) { + Remove-Item $OUTPUT +} +mv ykman.msi $OUTPUT + +# Cleanup +rm fragment.wxs +rm fragment.wixobj +rm ykman.wxs +rm ykman.wixobj +rm ykman.wixpdb + +cd $CWD diff --git a/resources/win/make_release.ps1 b/resources/win/make_release.ps1 new file mode 100644 index 000000000..9cfb81a7a --- /dev/null +++ b/resources/win/make_release.ps1 @@ -0,0 +1,21 @@ +# Set-PSDebug -Trace 1 + +$ErrorActionPreference = "Stop" + +$CWD = pwd +$SOURCE_DIR = "$CWD\ykman" + +echo "Signing ykman.exe" +signtool.exe sign /sha1 DD86A2E1383B0E4E1C823B606DDBBCC26E1FF82D /fd SHA256 /t http://timestamp.digicert.com "$SOURCE_DIR\ykman.exe" + +$VERSION = $(& "$SOURCE_DIR\ykman.exe" --version).Split(' ')[-1] + +& $PSScriptRoot\make_msi.ps1 + +echo "Signing .msi" +$OUTPUT_FILE = "yubikey-manager-$VERSION-win64.msi" +mv ".\ykman.msi" $OUTPUT_FILE + +signtool.exe sign /sha1 DD86A2E1383B0E4E1C823B606DDBBCC26E1FF82D /fd SHA256 /t http://timestamp.digicert.com /d "YubiKey Manager CLI" ".\$OUTPUT_FILE" + +echo "Installer signed: $OUTPUT_FILE" diff --git a/resources/win/ykman.wxs.in b/resources/win/ykman.wxs.in new file mode 100644 index 000000000..72c32cbea --- /dev/null +++ b/resources/win/ykman.wxs.in @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/win/yubico-msi-background.png b/resources/win/yubico-msi-background.png new file mode 100644 index 000000000..7b12abda4 Binary files /dev/null and b/resources/win/yubico-msi-background.png differ diff --git a/resources/win/yubico-msi-y-banner.png b/resources/win/yubico-msi-y-banner.png new file mode 100644 index 000000000..4de00d5f1 Binary files /dev/null and b/resources/win/yubico-msi-y-banner.png differ diff --git a/tests/device/cli/conftest.py b/tests/device/cli/conftest.py index 6ecdc4bd2..21cb5e2df 100644 --- a/tests/device/cli/conftest.py +++ b/tests/device/cli/conftest.py @@ -1,8 +1,10 @@ from yubikit.core import TRANSPORT -from ykman.cli.__main__ import cli -from ykman.cli.aliases import apply_aliases +from ykman._cli.__main__ import cli, _DefaultFormatter +from ykman._cli.aliases import apply_aliases +from ykman._cli.util import CliFail from click.testing import CliRunner from functools import partial +import logging import pytest @@ -17,9 +19,16 @@ def ykman_cli(device, info): def _ykman_cli(*argv, **kwargs): + handler = logging.StreamHandler() + handler.setLevel(logging.WARNING) + handler.setFormatter(_DefaultFormatter()) + logging.getLogger().addHandler(handler) + argv = apply_aliases(["ykman"] + [str(a) for a in argv]) runner = CliRunner(mix_stderr=False) result = runner.invoke(cli, argv[1:], obj={}, **kwargs) if result.exit_code != 0: + if isinstance(result.exception, CliFail): + raise SystemExit() raise result.exception return result diff --git a/tests/device/cli/piv/test_fips.py b/tests/device/cli/piv/test_fips.py index 578ca4e44..8b57e15f8 100644 --- a/tests/device/cli/piv/test_fips.py +++ b/tests/device/cli/piv/test_fips.py @@ -6,7 +6,7 @@ @pytest.fixture(autouse=True) -@condition.fips(True) +@condition.yk4_fips(True) @condition.capability(CAPABILITY.PIV) def ensure_piv(ykman_cli): ykman_cli("piv", "reset", "-f") diff --git a/tests/device/cli/piv/test_generate_cert_and_csr.py b/tests/device/cli/piv/test_generate_cert_and_csr.py index c56de2a34..76a1ba8cd 100644 --- a/tests/device/cli/piv/test_generate_cert_and_csr.py +++ b/tests/device/cli/piv/test_generate_cert_and_csr.py @@ -78,9 +78,9 @@ def _test_generate_self_signed(self, ykman_cli, slot, algo): fingerprint = b2a_hex(cert.fingerprint(hashes.SHA256())).decode("ascii") output = ykman_cli("piv", "info").output - assert "Fingerprint:\t" + fingerprint in output + assert fingerprint in output - @condition.fips(False) + @condition.yk4_fips(False) @condition.check(not_roca) def test_generate_self_signed_slot_9a_rsa1024(self, ykman_cli): self._test_generate_self_signed(ykman_cli, "9a", "RSA1024") @@ -88,7 +88,7 @@ def test_generate_self_signed_slot_9a_rsa1024(self, ykman_cli): def test_generate_self_signed_slot_9a_eccp256(self, ykman_cli): self._test_generate_self_signed(ykman_cli, "9a", "ECCP256") - @condition.fips(False) + @condition.yk4_fips(False) @condition.check(not_roca) def test_generate_self_signed_slot_9c_rsa1024(self, ykman_cli): self._test_generate_self_signed(ykman_cli, "9c", "RSA1024") @@ -96,7 +96,7 @@ def test_generate_self_signed_slot_9c_rsa1024(self, ykman_cli): def test_generate_self_signed_slot_9c_eccp256(self, ykman_cli): self._test_generate_self_signed(ykman_cli, "9c", "ECCP256") - @condition.fips(False) + @condition.yk4_fips(False) @condition.check(not_roca) def test_generate_self_signed_slot_9d_rsa1024(self, ykman_cli): self._test_generate_self_signed(ykman_cli, "9d", "RSA1024") @@ -104,7 +104,7 @@ def test_generate_self_signed_slot_9d_rsa1024(self, ykman_cli): def test_generate_self_signed_slot_9d_eccp256(self, ykman_cli): self._test_generate_self_signed(ykman_cli, "9d", "ECCP256") - @condition.fips(False) + @condition.yk4_fips(False) @condition.check(not_roca) def test_generate_self_signed_slot_9e_rsa1024(self, ykman_cli): self._test_generate_self_signed(ykman_cli, "9e", "RSA1024") @@ -145,7 +145,7 @@ def _test_generate_csr(self, ykman_cli, slot, algo): assert subject_input == subject_output - @condition.fips(False) + @condition.yk4_fips(False) @condition.check(not_roca) def test_generate_csr_slot_9a_rsa1024(self, ykman_cli): self._test_generate_csr(ykman_cli, "9a", "RSA1024") @@ -153,7 +153,7 @@ def test_generate_csr_slot_9a_rsa1024(self, ykman_cli): def test_generate_csr_slot_9a_eccp256(self, ykman_cli): self._test_generate_csr(ykman_cli, "9a", "ECCP256") - @condition.fips(False) + @condition.yk4_fips(False) @condition.check(not_roca) def test_generate_csr_slot_9c_rsa1024(self, ykman_cli): self._test_generate_csr(ykman_cli, "9c", "RSA1024") @@ -161,7 +161,7 @@ def test_generate_csr_slot_9c_rsa1024(self, ykman_cli): def test_generate_csr_slot_9c_eccp256(self, ykman_cli): self._test_generate_csr(ykman_cli, "9c", "ECCP256") - @condition.fips(False) + @condition.yk4_fips(False) @condition.check(not_roca) def test_generate_csr_slot_9d_rsa1024(self, ykman_cli): self._test_generate_csr(ykman_cli, "9d", "RSA1024") @@ -169,7 +169,7 @@ def test_generate_csr_slot_9d_rsa1024(self, ykman_cli): def test_generate_csr_slot_9d_eccp256(self, ykman_cli): self._test_generate_csr(ykman_cli, "9d", "ECCP256") - @condition.fips(False) + @condition.yk4_fips(False) @condition.check(not_roca) def test_generate_csr_slot_9e_rsa1024(self, ykman_cli): self._test_generate_csr(ykman_cli, "9e", "RSA1024") @@ -214,9 +214,9 @@ def _test_generate_self_signed(self, ykman_cli, slot, algo): fingerprint = b2a_hex(cert.fingerprint(hashes.SHA256())).decode("ascii") output = ykman_cli("piv", "info").output - assert "Fingerprint:\t" + fingerprint in output + assert fingerprint in output - @condition.fips(False) + @condition.yk4_fips(False) @condition.check(not_roca) def test_generate_self_signed_slot_9a_rsa1024(self, ykman_cli): self._test_generate_self_signed(ykman_cli, "9a", "RSA1024") @@ -224,7 +224,7 @@ def test_generate_self_signed_slot_9a_rsa1024(self, ykman_cli): def test_generate_self_signed_slot_9a_eccp256(self, ykman_cli): self._test_generate_self_signed(ykman_cli, "9a", "ECCP256") - @condition.fips(False) + @condition.yk4_fips(False) @condition.check(not_roca) def test_generate_self_signed_slot_9c_rsa1024(self, ykman_cli): self._test_generate_self_signed(ykman_cli, "9c", "RSA1024") @@ -232,7 +232,7 @@ def test_generate_self_signed_slot_9c_rsa1024(self, ykman_cli): def test_generate_self_signed_slot_9c_eccp256(self, ykman_cli): self._test_generate_self_signed(ykman_cli, "9c", "ECCP256") - @condition.fips(False) + @condition.yk4_fips(False) @condition.check(not_roca) def test_generate_self_signed_slot_9d_rsa1024(self, ykman_cli): self._test_generate_self_signed(ykman_cli, "9d", "RSA1024") @@ -240,7 +240,7 @@ def test_generate_self_signed_slot_9d_rsa1024(self, ykman_cli): def test_generate_self_signed_slot_9d_eccp256(self, ykman_cli): self._test_generate_self_signed(ykman_cli, "9d", "ECCP256") - @condition.fips(False) + @condition.yk4_fips(False) @condition.check(not_roca) def test_generate_self_signed_slot_9e_rsa1024(self, ykman_cli): self._test_generate_self_signed(ykman_cli, "9e", "RSA1024") @@ -273,7 +273,7 @@ def _test_generate_csr(self, ykman_cli, slot, algo): assert subject_input == subject_output - @condition.fips(False) + @condition.yk4_fips(False) @condition.check(not_roca) def test_generate_csr_slot_9a_rsa1024(self, ykman_cli): self._test_generate_csr(ykman_cli, "9a", "RSA1024") @@ -281,7 +281,7 @@ def test_generate_csr_slot_9a_rsa1024(self, ykman_cli): def test_generate_csr_slot_9a_eccp256(self, ykman_cli): self._test_generate_csr(ykman_cli, "9a", "ECCP256") - @condition.fips(False) + @condition.yk4_fips(False) @condition.check(not_roca) def test_generate_csr_slot_9c_rsa1024(self, ykman_cli): self._test_generate_csr(ykman_cli, "9c", "RSA1024") @@ -289,7 +289,7 @@ def test_generate_csr_slot_9c_rsa1024(self, ykman_cli): def test_generate_csr_slot_9c_eccp256(self, ykman_cli): self._test_generate_csr(ykman_cli, "9c", "ECCP256") - @condition.fips(False) + @condition.yk4_fips(False) @condition.check(not_roca) def test_generate_csr_slot_9d_rsa1024(self, ykman_cli): self._test_generate_csr(ykman_cli, "9d", "RSA1024") @@ -297,7 +297,7 @@ def test_generate_csr_slot_9d_rsa1024(self, ykman_cli): def test_generate_csr_slot_9d_eccp256(self, ykman_cli): self._test_generate_csr(ykman_cli, "9d", "ECCP256") - @condition.fips(False) + @condition.yk4_fips(False) @condition.check(not_roca) def test_generate_csr_slot_9e_rsa1024(self, ykman_cli): self._test_generate_csr(ykman_cli, "9e", "RSA1024") diff --git a/tests/device/cli/piv/test_key_management.py b/tests/device/cli/piv/test_key_management.py index 71f1056f8..c2eac6b4a 100644 --- a/tests/device/cli/piv/test_key_management.py +++ b/tests/device/cli/piv/test_key_management.py @@ -189,7 +189,7 @@ def test_generate_key_default_cve201715361(self, ykman_cli): ) @condition.check(not_roca) - @condition.fips(False) + @condition.yk4_fips(False) def test_generate_key_rsa1024(self, ykman_cli): output = ykman_cli( "piv", @@ -219,7 +219,7 @@ def test_generate_key_rsa2048(self, ykman_cli): ).output assert "BEGIN PUBLIC KEY" in output - @condition.fips(False) + @condition.yk4_fips(False) @condition.check(roca) def test_generate_key_rsa1024_cve201715361(self, ykman_cli): with pytest.raises(NotSupportedError): @@ -400,7 +400,7 @@ def _test_generate_csr(self, ykman_cli, tmp_file, algo): csr = x509.load_pem_x509_csr(output.encode(), default_backend()) assert csr.is_signature_valid - @condition.fips(False) + @condition.yk4_fips(False) @condition.check(not_roca) def test_generate_csr_rsa1024(self, ykman_cli, tmp_file): self._test_generate_csr(ykman_cli, tmp_file, "RSA1024") diff --git a/tests/device/cli/piv/test_pin_puk.py b/tests/device/cli/piv/test_pin_puk.py index 61ba669fc..1390ff68c 100644 --- a/tests/device/cli/piv/test_pin_puk.py +++ b/tests/device/cli/piv/test_pin_puk.py @@ -6,8 +6,6 @@ NON_DEFAULT_PUK, ) -import contextlib -import io import pytest @@ -20,17 +18,6 @@ def test_change_pin(self, ykman_cli): "piv", "access", "change-pin", "-P", NON_DEFAULT_PIN, "-n", DEFAULT_PIN ) - def test_change_pin_alias(self, ykman_cli): - with io.StringIO() as buf: - with contextlib.redirect_stderr(buf): - ykman_cli("piv", "change-pin", "-P", DEFAULT_PIN, "-n", NON_DEFAULT_PIN) - err = buf.getvalue() - assert "piv access change-pin" in err - - ykman_cli( - "piv", "access", "change-pin", "-P", NON_DEFAULT_PIN, "-n", DEFAULT_PIN - ) - def test_change_pin_prompt(self, ykman_cli): ykman_cli( "piv", diff --git a/tests/device/cli/piv/test_read_write_object.py b/tests/device/cli/piv/test_read_write_object.py index b6a8738a0..340a32b50 100644 --- a/tests/device/cli/piv/test_read_write_object.py +++ b/tests/device/cli/piv/test_read_write_object.py @@ -4,8 +4,6 @@ from ....util import generate_self_signed_certificate from yubikit.core import Tlv from yubikit.piv import OBJECT_ID, SLOT -import contextlib -import io import pytest @@ -78,29 +76,6 @@ def test_read_write_read_is_noop(self, ykman_cli): ).stdout_bytes assert output2 == data - def test_read_write_aliases(self, ykman_cli): - data = os.urandom(32) - - with io.StringIO() as buf: - with contextlib.redirect_stderr(buf): - ykman_cli( - "piv", - "write-object", - hex(OBJECT_ID.AUTHENTICATION), - "-", - "-m", - DEFAULT_MANAGEMENT_KEY, - input=data, - ) - - output1 = ykman_cli( - "piv", "read-object", hex(OBJECT_ID.AUTHENTICATION), "-" - ).stdout_bytes - err = buf.getvalue() - assert output1 == data - assert "piv objects import" in err - assert "piv objects export" in err - def test_read_write_certificate_as_object(self, ykman_cli): with pytest.raises(SystemExit): ykman_cli("piv", "objects", "export", hex(OBJECT_ID.AUTHENTICATION), "-") diff --git a/tests/device/cli/test_aliases.py b/tests/device/cli/test_aliases.py new file mode 100644 index 000000000..e1b79736d --- /dev/null +++ b/tests/device/cli/test_aliases.py @@ -0,0 +1,13 @@ +import pytest +import contextlib +import io + + +def test_fail_on_alias(ykman_cli): + with io.StringIO() as buf: + with contextlib.redirect_stderr(buf): + with pytest.raises(SystemExit): + ykman_cli("oath", "code") + err = buf.getvalue() + + assert "oath accounts code" in err diff --git a/tests/device/cli/test_config.py b/tests/device/cli/test_config.py index ebc004ae1..a65912b36 100644 --- a/tests/device/cli/test_config.py +++ b/tests/device/cli/test_config.py @@ -1,10 +1,7 @@ -from yubikit.core import TRANSPORT +from yubikit.core import TRANSPORT, YUBIKEY from yubikit.management import CAPABILITY -from ykman.base import YUBIKEY from .. import condition -import contextlib -import io import pytest @@ -23,7 +20,7 @@ def not_sky(device, info): and _fido_only(info.supported_capabilities[TRANSPORT.USB]) ) else: - return device.pid.get_type() != YUBIKEY.SKY + return device.pid.yubikey_type != YUBIKEY.SKY class TestConfigUSB: @@ -137,18 +134,6 @@ def test_mode_command(self, ykman_cli, await_reboot): assert "PIV" not in output assert "OpenPGP" not in output - def test_mode_alias(self, ykman_cli, await_reboot): - with io.StringIO() as buf: - with contextlib.redirect_stderr(buf): - ykman_cli("mode", "ccid", "-f") - await_reboot() - output = ykman_cli("config", "usb", "--list").output - assert "FIDO U2F" not in output - assert "FIDO2" not in output - assert "OTP" not in output - err = buf.getvalue() - assert "config mode ccid" in err - class TestConfigNFC: @pytest.fixture(autouse=True) diff --git a/tests/device/cli/test_hsmauth.py b/tests/device/cli/test_hsmauth.py new file mode 100644 index 000000000..7887fd1ac --- /dev/null +++ b/tests/device/cli/test_hsmauth.py @@ -0,0 +1,318 @@ +# -*- coding: utf-8 -*- +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec + +from yubikit.management import CAPABILITY +from yubikit.hsmauth import ( + TAG_LABEL, + TAG_CONTEXT, + TAG_CREDENTIAL_PASSWORD, + INS_CALCULATE, + _parse_label, + _parse_credential_password, +) +from yubikit.core import Tlv +from .. import condition + +import pytest +import re +import os +import tempfile +import struct + +DEFAULT_MANAGEMENT_KEY = "00000000000000000000000000000000" +NON_DEFAULT_MANAGEMENT_KEY = "11111111111111111111111111111111" + + +def generate_pem_eccp256_keypair(): + pk = ec.generate_private_key(ec.SECP256R1(), default_backend()) + return ( + pk.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ), + pk.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ), + ) + + +@pytest.fixture() +def eccp256_keypair(): + tmp = tempfile.NamedTemporaryFile(delete=False) + private_key, public_key = generate_pem_eccp256_keypair() + tmp.write(private_key) + tmp.close() + yield tmp.name, public_key + os.remove(tmp.name) + + +@pytest.fixture() +def tmp_file(): + tmp = tempfile.NamedTemporaryFile(delete=False) + yield tmp + tmp.close() + os.remove(tmp.name) + + +@pytest.fixture(autouse=True) +@condition.capability(CAPABILITY.HSMAUTH) +@condition.min_version(5, 4, 3) +def preconditions(ykman_cli): + ykman_cli("hsmauth", "reset", "-f") + + +class TestHsmAuth: + def test_hsmauth_info(self, ykman_cli): + output = ykman_cli("hsmauth", "info").output + assert "version:" in output + + def test_hsmauth_reset(self, ykman_cli): + output = ykman_cli("hsmauth", "reset", "-f").output + assert ( + "Success! All YubiHSM Auth data have been cleared from the YubiKey." + in output + ) + + +def calculate_session_keys_apdu(label, context, credential_password): + data = ( + Tlv(TAG_LABEL, _parse_label(label)) + + Tlv(TAG_CONTEXT, context) + + Tlv(TAG_CREDENTIAL_PASSWORD, _parse_credential_password(credential_password)) + ) + + apdu = struct.pack(" {vers}") -def fips(status=True): +def yk4_fips(status=True): return check( - lambda version: status == is_fips_version(version), - f"Requires FIPS = {status}", + lambda info: status == (info.is_fips and info.version[0] == 4), + f"Requires YK4 FIPS = {status}", ) diff --git a/tests/device/conftest.py b/tests/device/conftest.py index e2279dd20..559cb8298 100644 --- a/tests/device/conftest.py +++ b/tests/device/conftest.py @@ -1,10 +1,9 @@ -from ykman.device import connect_to_device, list_all_devices, read_info +from 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 yubikit.management import USB_INTERFACE from functools import partial from . import condition @@ -29,7 +28,7 @@ def _device(pytestconfig): pytest.exit("No/Multiple readers matched") dev = readers[0] with dev.open_connection(SmartCardConnection) as conn: - info = read_info(None, conn) + info = read_info(conn) else: devices = list_all_devices() if len(devices) != 1: @@ -78,26 +77,23 @@ def await_reboot(transport): @pytest.fixture(scope=connection_scope) @condition.transport(TRANSPORT.USB) def otp_connection(device, info): - if USB_INTERFACE.OTP in device.pid.get_interfaces(): - with connect_to_device(info.serial, [OtpConnection])[0] as c: + 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 USB_INTERFACE.FIDO in device.pid.get_interfaces(): - with connect_to_device(info.serial, [FidoConnection])[0] as c: + 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.transport == TRANSPORT.NFC: + if device.supports_connection(SmartCardConnection): with device.open_connection(SmartCardConnection) as c: yield c - elif USB_INTERFACE.CCID in device.pid.get_interfaces(): - with connect_to_device(info.serial, [SmartCardConnection])[0] as c: - yield c else: pytest.skip("CCID connection not available") diff --git a/tests/device/test_fips_u2f_commands.py b/tests/device/test_fips_u2f_commands.py index 5d2ced2bf..ae67e3004 100644 --- a/tests/device/test_fips_u2f_commands.py +++ b/tests/device/test_fips_u2f_commands.py @@ -9,7 +9,7 @@ @pytest.fixture(autouse=True) -@condition.fips(True) +@condition.yk4_fips(True) @condition.capability(CAPABILITY.U2F) @condition.transport(TRANSPORT.USB) def preconditions(): diff --git a/tests/device/test_hsmauth.py b/tests/device/test_hsmauth.py new file mode 100644 index 000000000..ec05b10c7 --- /dev/null +++ b/tests/device/test_hsmauth.py @@ -0,0 +1,271 @@ +import 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) diff --git a/tests/device/test_interfaces.py b/tests/device/test_interfaces.py index ad1b021fe..9365bbd68 100644 --- a/tests/device/test_interfaces.py +++ b/tests/device/test_interfaces.py @@ -1,31 +1,25 @@ -from ykman.device import connect_to_device 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 USB_INTERFACE from . import condition -def try_connection(conn_type): - with connect_to_device(None, [conn_type])[0]: +def try_connection(device, conn_type): + with device.open_connection(conn_type): return True @condition.transport(TRANSPORT.USB) -def test_switch_interfaces(pid): - interfaces = pid.get_interfaces() - if USB_INTERFACE.FIDO in interfaces: - assert try_connection(FidoConnection) - if USB_INTERFACE.OTP in interfaces: - assert try_connection(OtpConnection) - if USB_INTERFACE.FIDO in interfaces: - assert try_connection(FidoConnection) - if USB_INTERFACE.CCID in interfaces: - assert try_connection(SmartCardConnection) - if USB_INTERFACE.OTP in interfaces: - assert try_connection(OtpConnection) - if USB_INTERFACE.CCID in interfaces: - assert try_connection(SmartCardConnection) - if USB_INTERFACE.FIDO in interfaces: - assert try_connection(FidoConnection) +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) diff --git a/tests/device/test_oath.py b/tests/device/test_oath.py index 37e46cf73..ade4ca241 100644 --- a/tests/device/test_oath.py +++ b/tests/device/test_oath.py @@ -1,7 +1,6 @@ import pytest -from yubikit.core import AID -from yubikit.core.smartcard import ApduError, SW +from yubikit.core.smartcard import ApduError, AID, SW from yubikit.management import CAPABILITY from yubikit.oath import ( OathSession, @@ -9,7 +8,6 @@ HASH_ALGORITHM, OATH_TYPE, ) -from ykman.device import is_fips_version from . import condition @@ -152,11 +150,12 @@ def _ids_hmac(params): class TestHmacVectors: @pytest.mark.parametrize("params", HMAC_PARAMS, ids=_ids_hmac) - def test_vector(self, session, params): + def test_vector(self, info, session, params): key, challenge, hash_algorithm, expected = params if hash_algorithm == HASH_ALGORITHM.SHA512: - if session.version < (4, 3, 1) or is_fips_version(session.version): - pytest.skip("SHA512 requires (non-FIPS) YubiKey 4.3.1 or later") + 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) ) @@ -196,11 +195,12 @@ class TestTotpVectors: @pytest.mark.parametrize( "params", TOTP_PARAMS, ids=lambda x: "{1.name}-{0}".format(*x) ) - def test_vector(self, session, params, digits): + def test_vector(self, info, session, params, digits): timestamp, hash_algorithm, value, key = params if hash_algorithm == HASH_ALGORITHM.SHA512: - if session.version < (4, 3, 1) or is_fips_version(session.version): - pytest.skip("SHA512 requires (non-FIPS) YubiKey 4.3.1 or later") + 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) diff --git a/tests/device/test_openpgp.py b/tests/device/test_openpgp.py index b8452e924..80c77e580 100644 --- a/tests/device/test_openpgp.py +++ b/tests/device/test_openpgp.py @@ -1,14 +1,20 @@ -from __future__ import unicode_literals - from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat -from cryptography.hazmat.primitives.asymmetric import ec, rsa -from ykman.openpgp import OpenPgpController, KEY_SLOT +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 @@ -20,8 +26,8 @@ @pytest.fixture @condition.capability(CAPABILITY.OPENPGP) -def controller(ccid_connection): - pgp = OpenPgpController(ccid_connection) +def session(ccid_connection): + pgp = OpenPgpSession(ccid_connection) pgp.reset() return pgp @@ -31,82 +37,191 @@ def not_roca(version): return not ((4, 2, 0) <= version < (4, 3, 5)) -def test_generate_requires_admin(controller): +def test_import_requires_admin(session): + priv = rsa.generate_private_key(E, RSA_SIZE.RSA2048, default_backend()) with pytest.raises(ApduError): - controller.generate_rsa_key(KEY_SLOT.SIG, 2048) + session.put_key(KEY_REF.SIG, priv) @condition.check(not_roca) -def test_generate_rsa2048(controller): - controller.verify_admin(DEFAULT_ADMIN_PIN) - pub = controller.generate_rsa_key(KEY_SLOT.SIG, 2048) - assert pub.key_size == 2048 - controller.delete_key(KEY_SLOT.SIG) +def test_generate_requires_admin(session): + with pytest.raises(ApduError): + session.generate_rsa_key(KEY_REF.SIG, RSA_SIZE.RSA2048) -@condition.check(not_roca) -@condition.min_version(4) -def test_generate_rsa4096(controller): - controller.verify_admin(DEFAULT_ADMIN_PIN) - pub = controller.generate_rsa_key(KEY_SLOT.SIG, 4096) - assert pub.key_size == 4096 +@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_generate_secp256r1(controller): - controller.verify_admin(DEFAULT_ADMIN_PIN) - pub = controller.generate_ec_key(KEY_SLOT.SIG, "secp256r1") - assert pub.key_size == 256 - assert pub.curve.name == "secp256r1" +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) -def test_generate_ed25519(controller): - controller.verify_admin(DEFAULT_ADMIN_PIN) - pub = controller.generate_ec_key(KEY_SLOT.SIG, "ed25519") - assert len(pub.public_bytes(Encoding.Raw, PublicFormat.Raw)) == 32 +@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_generate_x25519(controller): - controller.verify_admin(DEFAULT_ADMIN_PIN) - pub = controller.generate_ec_key(KEY_SLOT.ENC, "x25519") - assert len(pub.public_bytes(Encoding.Raw, PublicFormat.Raw)) == 32 +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()) -def test_import_rsa2048(controller): - priv = rsa.generate_private_key(E, 2048, default_backend()) - controller.verify_admin(DEFAULT_ADMIN_PIN) - controller.import_key(KEY_SLOT.SIG, priv) +@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(4) -def test_import_rsa4096(controller): - priv = rsa.generate_private_key(E, 4096, default_backend()) - controller.verify_admin(DEFAULT_ADMIN_PIN) - controller.import_key(KEY_SLOT.SIG, priv) +@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_import_secp256r1(controller): - priv = ec.generate_private_key(ec.SECP256R1(), default_backend()) - controller.verify_admin(DEFAULT_ADMIN_PIN) - controller.import_key(KEY_SLOT.SIG, priv) +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_import_ed25519(controller): - from cryptography.hazmat.primitives.asymmetric import ed25519 +def test_kdf(session): + with pytest.raises(ApduError): + session.set_kdf(KdfIterSaltedS2k.create()) - priv = ed25519.Ed25519PrivateKey.generate() - controller.verify_admin(DEFAULT_ADMIN_PIN) - controller.import_key(KEY_SLOT.SIG, priv) + 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_import_x25519(controller): - from cryptography.hazmat.primitives.asymmetric import x25519 +def test_attestation(session): + session.verify_admin(DEFAULT_ADMIN_PIN) + pub = session.generate_ec_key(KEY_REF.SIG, OID.SECP256R1) - priv = x25519.X25519PrivateKey.generate() - controller.verify_admin(DEFAULT_ADMIN_PIN) - controller.import_key(KEY_SLOT.ENC, priv) + session.verify_pin(DEFAULT_PIN) + cert = session.attest_key(KEY_REF.SIG) + + assert cert.public_key() == pub diff --git a/tests/device/test_otp.py b/tests/device/test_otp.py index 433c4b309..0b6fe5ab7 100644 --- a/tests/device/test_otp.py +++ b/tests/device/test_otp.py @@ -8,7 +8,7 @@ StaticPasswordSlotConfiguration, ) from yubikit.management import CAPABILITY, ManagementSession -from ykman.device import connect_to_device +from ykman.device import list_all_devices from . import condition import pytest @@ -28,12 +28,8 @@ def conn_type(request, version, transport): @pytest.fixture() @condition.capability(CAPABILITY.OTP) def session(conn_type, info, device): - if device.transport == TRANSPORT.NFC: - with device.open_connection(conn_type) as c: - yield YubiOtpSession(c) - else: - with connect_to_device(info.serial, [conn_type])[0] as c: - yield YubiOtpSession(c) + with device.open_connection(conn_type) as c: + yield YubiOtpSession(c) def test_status(info, session): @@ -61,7 +57,14 @@ def call(): else: ManagementSession(protocol.connection).write_device_config(reboot=True) await_reboot() - conn = connect_to_device(info.serial, [SmartCardConnection])[0] + 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() @@ -108,6 +111,10 @@ def test_slot_configured(self, session, read_config): assert not state.is_configured(SLOT.ONE) assert not state.is_configured(SLOT.TWO) + def test_configure_ndef(self, session): + session.put_configuration(SLOT.ONE, StaticPasswordSlotConfiguration(b"a")) + session.set_ndef_configuration(SLOT.ONE) + @condition.min_version(3) @pytest.mark.parametrize("slot", [SLOT.ONE, SLOT.TWO]) def test_slot_touch_triggered(self, session, read_config, slot): diff --git a/tests/device/test_piv.py b/tests/device/test_piv.py index 282bf5de0..f2f94a54f 100644 --- a/tests/device/test_piv.py +++ b/tests/device/test_piv.py @@ -7,8 +7,8 @@ from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric import rsa, ec, padding -from yubikit.core import AID, NotSupportedError -from yubikit.core.smartcard import ApduError +from yubikit.core import NotSupportedError +from yubikit.core.smartcard import AID, ApduError from yubikit.management import CAPABILITY from yubikit.piv import ( PivSession, @@ -17,6 +17,7 @@ PIN_POLICY, TOUCH_POLICY, SLOT, + OBJECT_ID, MANAGEMENT_KEY_TYPE, InvalidPinError, ) @@ -29,7 +30,6 @@ pivman_set_mgm_key, ) from ykman.util import parse_certificates, parse_private_key -from ykman.device import is_fips_version from ..util import open_file from . import condition @@ -104,7 +104,6 @@ def import_key( 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() @@ -133,12 +132,14 @@ def verify_cert_signature(cert, public_key=None): class TestCertificateSignatures: @pytest.mark.parametrize("key_type", list(KEY_TYPE)) @pytest.mark.parametrize( - "hash_algorithm", (hashes.SHA1, hashes.SHA256, hashes.SHA384, hashes.SHA512) + "hash_algorithm", (hashes.SHA256, hashes.SHA384, hashes.SHA512) ) - def test_generate_self_signed_certificate(self, session, key_type, hash_algorithm): + 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 is_fips_version(session.version): + 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 @@ -248,7 +249,7 @@ def _test_put_key_pairing(self, session, alg1, alg2): assert not check_key(session, SLOT.AUTHENTICATION, cert.public_key()) @condition.check(not_roca) - @condition.fips(False) + @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) @@ -285,6 +286,21 @@ def test_get_certificate_does_not_require_authentication(self, 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 @@ -402,14 +418,14 @@ def test_sign_with_pin_policy_always_requires_pin_every_time(self, session): sig = sign(session, SLOT.AUTHENTICATION, KEY_TYPE.ECCP256, b"foo") assert sig - @condition.fips(False) + @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.fips(True) + @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) diff --git a/tests/test_device.py b/tests/test_device.py index f3fd27bba..7f2158b28 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -1,6 +1,4 @@ -from ykman.device import get_name -from ykman.base import YUBIKEY -from yubikit.core import TRANSPORT +from yubikit.core import TRANSPORT, YUBIKEY from yubikit.management import ( CAPABILITY, FORM_FACTOR, @@ -8,6 +6,7 @@ DeviceConfig, Version, ) +from yubikit.support import get_name from typing import cast @@ -89,3 +88,69 @@ def test_yk5_fips_formfactors(): assert get_name(fips(info(FORM_FACTOR.USB_C_BIO)), kt) == "YubiKey C Bio FIPS" assert get_name(fips(info(FORM_FACTOR.UNKNOWN)), kt) == "YubiKey 5 FIPS" assert get_name(fips(info_nfc(FORM_FACTOR.UNKNOWN)), kt) == "YubiKey 5 NFC FIPS" + + +def sky(device_info): + device_info.is_sky = True + return device_info + + +def test_sky_formfactors(): + kt = YUBIKEY.YK4 + assert get_name(sky(info(FORM_FACTOR.USB_A_KEYCHAIN)), kt) == "Security Key A" + assert get_name(sky(info_nfc(FORM_FACTOR.USB_A_KEYCHAIN)), kt) == "Security Key NFC" + assert get_name(sky(info(FORM_FACTOR.USB_A_NANO)), kt) == "Security Key Nano" + assert get_name(sky(info(FORM_FACTOR.USB_C_KEYCHAIN)), kt) == "Security Key C" + assert ( + get_name(sky(info_nfc(FORM_FACTOR.USB_C_KEYCHAIN)), kt) == "Security Key C NFC" + ) + assert get_name(sky(info(FORM_FACTOR.USB_C_NANO)), kt) == "Security Key C Nano" + assert get_name(sky(info(FORM_FACTOR.USB_C_LIGHTNING)), kt) == "Security Key Ci" + assert get_name(sky(info(FORM_FACTOR.UNKNOWN)), kt) == "Security Key" + assert get_name(sky(info_nfc(FORM_FACTOR.UNKNOWN)), kt) == "Security Key NFC" + + +def skyep(device_info): + device_info.is_sky = True + device_info.serial = 123456 + return device_info + + +def test_sky_enterprise_formfactors(): + kt = YUBIKEY.YK4 + assert ( + get_name(skyep(info(FORM_FACTOR.USB_A_KEYCHAIN)), kt) + == "Security Key A - Enterprise Edition" + ) + assert ( + get_name(skyep(info_nfc(FORM_FACTOR.USB_A_KEYCHAIN)), kt) + == "Security Key NFC - Enterprise Edition" + ) + assert ( + get_name(skyep(info(FORM_FACTOR.USB_A_NANO)), kt) + == "Security Key Nano - Enterprise Edition" + ) + assert ( + get_name(skyep(info(FORM_FACTOR.USB_C_KEYCHAIN)), kt) + == "Security Key C - Enterprise Edition" + ) + assert ( + get_name(skyep(info_nfc(FORM_FACTOR.USB_C_KEYCHAIN)), kt) + == "Security Key C NFC - Enterprise Edition" + ) + assert ( + get_name(skyep(info(FORM_FACTOR.USB_C_NANO)), kt) + == "Security Key C Nano - Enterprise Edition" + ) + assert ( + get_name(skyep(info(FORM_FACTOR.USB_C_LIGHTNING)), kt) + == "Security Key Ci - Enterprise Edition" + ) + assert ( + get_name(skyep(info(FORM_FACTOR.UNKNOWN)), kt) + == "Security Key - Enterprise Edition" + ) + assert ( + get_name(skyep(info_nfc(FORM_FACTOR.UNKNOWN)), kt) + == "Security Key NFC - Enterprise Edition" + ) diff --git a/tests/test_hsmauth.py b/tests/test_hsmauth.py new file mode 100644 index 000000000..7a9e284f0 --- /dev/null +++ b/tests/test_hsmauth.py @@ -0,0 +1,65 @@ +from ykman.hsmauth import generate_random_management_key + +from yubikit.hsmauth import ( + _parse_credential_password, + _parse_label, + _password_to_key, + CREDENTIAL_PASSWORD_LEN, + MAX_LABEL_LEN, +) +from binascii import a2b_hex + +import pytest + + +class TestHsmAuthFunctions: + def test_generate_random_management_key(self): + output1 = generate_random_management_key() + output2 = generate_random_management_key() + + assert isinstance(output1, bytes) + assert isinstance(output2, bytes) + assert 16 == len(output1) == len(output2) + + def test_parse_credential_password(self): + parsed_credential_password = _parse_credential_password("123456") + + assert ( + b"123456\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + == parsed_credential_password + ) + + def test_parse_credential_password_wrong_length(self): + with pytest.raises(ValueError): + _parse_credential_password(b"1" * (CREDENTIAL_PASSWORD_LEN + 1)) + + def test_parse_label(self): + parsed_label = _parse_label("Default key") + + assert isinstance(parsed_label, bytes) + + def test_parse_label_wrong_length(self): + with pytest.raises(ValueError): + _parse_label("1" * (MAX_LABEL_LEN + 1)) + + with pytest.raises(ValueError): + _parse_label("") + + def test_password_to_key(self): + assert ( + a2b_hex("090b47dbed595654901dee1cc655e420"), + a2b_hex("592fd483f759e29909a04c4505d2ce0a"), + ) == _password_to_key("password") + + def test__password_to_key_utf8(self): + assert ( + a2b_hex("f320972c667ba5cd4d35119a6b0271a1"), + a2b_hex("f10050ca688e5a6ce62b1ffb0f6f6869"), + ) == _password_to_key("κόσμε") + + def test_password_to_key_bytes_fails(self): + with pytest.raises(AttributeError): + _password_to_key(b"password") + + with pytest.raises(AttributeError): + _password_to_key(a2b_hex("cebae1bdb9cf83cebcceb5")) diff --git a/tests/test_oath.py b/tests/test_oath.py index e9ebdf465..00f556bb6 100644 --- a/tests/test_oath.py +++ b/tests/test_oath.py @@ -61,15 +61,15 @@ def test_credential_data_make_key(self): def test_derive_key(self): self.assertEqual( b"\xb0}\xa1\xe7\xde\x87\xf8\x9a\x87\xa2\xb5\x98\xea\xa2\x18\x8c", - _derive_key(b"\0\0\0\0\0\0\0\0", u"foobar"), + _derive_key(b"\0\0\0\0\0\0\0\0", "foobar"), ) self.assertEqual( b"\xda\x81\x8ek,\xf0\xa2\xd0\xbf\x19\xb3\xdd\xd3K\x83\xf5", - _derive_key(b"12345678", u"Hallå världen!"), + _derive_key(b"12345678", "Hallå världen!"), ) self.assertEqual( b"\xf3\xdf\xa7\x81T\xc8\x102\x99E\xfb\xc4\xb55\xe57", - _derive_key(b"saltsalt", u"Ťᶒśƫ ᵽĥřӓşḛ"), + _derive_key(b"saltsalt", "Ťᶒśƫ ᵽĥřӓşḛ"), ) def test_parse_uri_issuer(self): diff --git a/tests/test_piv.py b/tests/test_piv.py index 7f5f35e3b..fe801497c 100644 --- a/tests/test_piv.py +++ b/tests/test_piv.py @@ -19,8 +19,9 @@ r"OU=Sales+CN=J. Smith,DC=example,DC=net", r"CN=James \"Jim\" Smith\, III,DC=example,DC=net", r"CN=Before\0dAfter,DC=example,DC=net", - # r"1.3.6.1.4.1.1466.0=#04024869", - Not supported. + r"1.3.6.1.4.1.1466.0=#04024869", r"CN=Lu\C4\8Di\C4\87", + r"1.2.840.113549.1.9.1=user@example.com", ], ) def test_parse_rfc4514_string(value): diff --git a/tests/test_util.py b/tests/test_util.py index 6ccda6c8e..9336a861b 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,18 +1,23 @@ # vim: set fileencoding=utf-8 : -from yubikit.core import Tlv, bytes2int +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_pyopenssl, _parse_pkcs12_cryptography +from ykman.util import _parse_pkcs12 from ykman.otp import format_oath_code, generate_static_pw, time_challenge from .util import open_file -from cryptography.hazmat.primitives.serialization import pkcs12 -from OpenSSL import crypto 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")) @@ -118,11 +123,8 @@ 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() - key1, certs1 = _parse_pkcs12_cryptography(pkcs12, data, None) - key2, certs2 = _parse_pkcs12_pyopenssl(crypto, data, None) - self.assertEqual(key1.private_numbers(), key2.private_numbers()) - self.assertEqual(1, len(certs1)) - self.assertEqual(certs1, certs2) + 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")) diff --git a/tests/util.py b/tests/util.py index aae72d258..3709bd6b6 100644 --- a/tests/util.py +++ b/tests/util.py @@ -21,7 +21,6 @@ def open_file(*relative_path): 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) diff --git a/version_info.txt.in b/version_info.txt.in new file mode 100644 index 000000000..8548d5af7 --- /dev/null +++ b/version_info.txt.in @@ -0,0 +1,42 @@ +# UTF-8 +# +# For more details about fixed file info 'ffi' see: +# http://msdn.microsoft.com/en-us/library/ms646997.aspx +VSVersionInfo( + ffi=FixedFileInfo( + # filevers and prodvers should be always a tuple with four items: (1, 2, 3, 4) + # Set not needed items to zero 0. + filevers={VERSION_TUPLE}, + prodvers={VERSION_TUPLE}, + # Contains a bitmask that specifies the valid bits 'flags'r + mask=0x3f, + # Contains a bitmask that specifies the Boolean attributes of the file. + flags=0x0, + # The operating system for which this file was designed. + # 0x4 - NT and there is no need to change it. + OS=0x4, + # The general type of file. + # 0x1 - the file is an application. + fileType=0x2, + # The function of the file. + # 0x0 - the function is not defined for this fileType + subtype=0x0, + # Creation date and time stamp. + date=(0, 0) + ), + kids=[ + StringFileInfo( + [ + StringTable( + '040904b0', + [StringStruct('CompanyName', 'Yubico'), + StringStruct('FileDescription', 'YubiKey Manager CLI'), + StringStruct('FileVersion', '{VERSION}'), + StringStruct('LegalCopyright', 'Copyright (c) 2023 Yubico AB'), + StringStruct('OriginalFilename', 'ykman.exe'), + StringStruct('ProductName', 'YubiKey Manager'), + StringStruct('ProductVersion', '{VERSION}')]) + ]), + VarFileInfo([VarStruct('Translation', [1033, 1200])]) + ] +) diff --git a/ykman.exe.manifest b/ykman.exe.manifest new file mode 100644 index 000000000..9a388e317 --- /dev/null +++ b/ykman.exe.manifest @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/ykman.spec b/ykman.spec index 7dd5ce1e0..85e1b891c 100755 --- a/ykman.spec +++ b/ykman.spec @@ -1,7 +1,21 @@ # -*- mode: python ; coding: utf-8 -*- -# This is a spec file used by PyInstaller to build a single executable for ykman. -# See: https://pyinstaller.readthedocs.io/en/stable/spec-files.html +import re +import os + +with open("ykman/__init__.py") as f: + version_file = f.read() +version = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M).group(1) +version_tuple = "(" + version.split("-")[0].replace(".", ", ") + ", 0)" + +with open("version_info.txt.in") as f: + version_info = f.read() +version_info = version_info.replace("{VERSION}", version).replace( + "{VERSION_TUPLE}", version_tuple +) +with open("version_info.txt", "w") as f: + f.write(version_info) + # This recipe allows PyInstaller to understand the entrypoint. # See: https://github.com/pyinstaller/pyinstaller/wiki/Recipe-Setuptools-Entry-Point @@ -47,17 +61,28 @@ pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) exe = EXE( pyz, a.scripts, - a.binaries, - a.zipfiles, - a.datas, [], + exclude_binaries=True, name="ykman", icon="NONE", + target_arch="universal2", debug=False, bootloader_ignore_signals=False, strip=False, upx=True, - upx_exclude=[], - runtime_tmpdir=None, console=True, + manifest="ykman.exe.manifest", + version="version_info.txt", ) +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name="ykman", +) + +os.unlink("version_info.txt") diff --git a/ykman/__init__.py b/ykman/__init__.py index 397172b01..3b99e1fa5 100644 --- a/ykman/__init__.py +++ b/ykman/__init__.py @@ -25,14 +25,4 @@ # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -from .base import YUBIKEY, PID, YkmanDevice # noqa -from .device import ( # noqa - scan_devices, - list_all_devices, - connect_to_device, - get_name, - read_info, -) - - -__version__ = "4.0.6-dev0" +__version__ = "5.2.2-dev.0" diff --git a/ykman/cli/__init__.py b/ykman/_cli/__init__.py similarity index 100% rename from ykman/cli/__init__.py rename to ykman/_cli/__init__.py diff --git a/ykman/cli/__main__.py b/ykman/_cli/__main__.py similarity index 52% rename from ykman/cli/__main__.py rename to ykman/_cli/__main__.py index 11911bc89..7be34a0b8 100644 --- a/ykman/cli/__main__.py +++ b/ykman/_cli/__main__.py @@ -29,23 +29,16 @@ from yubikit.core.otp import OtpConnection from yubikit.core.fido import FidoConnection from yubikit.core.smartcard import SmartCardConnection -from yubikit.management import USB_INTERFACE - -import ykman.logging_setup +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 ( - read_info, - get_name, - list_all_devices, - scan_devices, - connect_to_device, - ConnectionNotAvailableException, -) +from ..device import scan_devices, list_all_devices from ..util import get_windows_version -from ..diagnostics import get_diagnostics -from .util import YkmanContextObject, ykman_group, cli_fail +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 @@ -55,12 +48,16 @@ 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 click.shell_completion import ctypes import os import time import sys + import logging @@ -70,13 +67,6 @@ CLICK_CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"], max_content_width=999) -USB_INTERFACE_MAPPING = { - SmartCardConnection: USB_INTERFACE.CCID, - OtpConnection: USB_INTERFACE.OTP, - FidoConnection: USB_INTERFACE.FIDO, -} - - WIN_CTAP_RESTRICTED = ( sys.platform == "win32" and not bool(ctypes.windll.shell32.IsUserAnAdmin()) @@ -84,26 +74,13 @@ ) -def retrying_connect(serial, connections, attempts=10, state=None): - while True: - try: - return connect_to_device(serial, connections) - except ConnectionNotAvailableException as e: - logger.error("Failed opening connection", exc_info=e) - raise # No need to retry - except Exception as e: - logger.error("Failed opening connection", exc_info=e) - while attempts: - attempts -= 1 - _, new_state = scan_devices() - if new_state != state: - state = new_state - logger.debug("State changed, re-try connect...") - break - logger.debug("Sleep...") - time.sleep(0.5) - else: - raise +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): @@ -116,81 +93,77 @@ def print_version(ctx, param, value): def print_diagnostics(ctx, param, value): if not value or ctx.resilient_parsing: return - click.echo(get_diagnostics()) + click.echo("\n".join(pretty_print(get_diagnostics()))) ctx.exit() -def _disabled_interface(connections, cmd_name): - interfaces = [USB_INTERFACE_MAPPING[c] for c in connections] - req = ", ".join((t.name for t in interfaces)) - cli_fail( - f"Command '{cmd_name}' requires one of the following USB interfaces " - f"to be enabled: '{req}'.\n\n" - "Use 'ykman config usb' to set the enabled USB interfaces." - ) - - -def _run_cmd_for_serial(cmd, connections, serial): - try: - return retrying_connect(serial, connections) - except ValueError: - try: - # Serial not found, see if it's among other interfaces in USB enabled: - conn = connect_to_device(serial)[0] - conn.close() - _disabled_interface(connections, cmd) - except ValueError: - cli_fail( - f"Failed connecting to a YubiKey with serial: {serial}.\n" - "Make sure the application has the required permissions." - ) - - -def _run_cmd_for_single(ctx, cmd, connections, reader_name=None): - # Use a specific CCID reader - if reader_name: - if SmartCardConnection in connections or cmd in (fido.name, otp.name): - readers = list_ccid(reader_name) - if len(readers) == 1: - dev = readers[0] - try: - conn = dev.open_connection(SmartCardConnection) - info = read_info(dev.pid, conn) - if cmd == fido.name: - conn.close() - conn = dev.open_connection(FidoConnection) - return conn, dev, info - except Exception as e: - logger.error("Failure connecting to card", exc_info=e) - cli_fail(f"Failed to connect: {e}") - elif len(readers) > 1: - cli_fail("Multiple YubiKeys on external readers detected.") - else: - cli_fail("No YubiKey found on external reader.") +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: - ctx.fail("Not a CCID command.") + 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." + ) - if n_devs == 0: - cli_fail("No YubiKey detected!") - if n_devs > 1: - cli_fail( - "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." + ) - # Only one connected device, check if any needed interfaces are available - pid = next(iter(devices.keys())) - for c in connections: - if USB_INTERFACE_MAPPING[c] & pid.get_interfaces(): - if WIN_CTAP_RESTRICTED and c == FidoConnection: - # FIDO-only command on Windows without Admin won't work. - cli_fail("FIDO access on Windows requires running as Administrator.") - return retrying_connect(None, connections, state=state) - _disabled_interface(connections, cmd) + 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.", + ) def _experimental_completion(env_var_name, f): @@ -200,13 +173,13 @@ def _experimental_completion(env_var_name, f): return lambda ctx, param, incomplete: [] -@ykman_group(context_settings=CLICK_CONTEXT_SETTINGS) +@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.", + help="specify which YubiKey to interact with by serial number", shell_complete=_experimental_completion( # Leading underscore for uniformity with _YKMAN_COMPLETE from Click "_YKMAN_EXPERIMENTAL_COMPLETE_DEVICE", @@ -223,7 +196,8 @@ def _experimental_completion(env_var_name, f): @click.option( "-r", "--reader", - help="Use an external smart card reader. Conflicts with --device and list.", + help="specify a YubiKey by smart card reader name " + "(can't be used with --device or list)", metavar="NAME", default=None, shell_complete=lambda ctx, param, incomplete: [ @@ -234,16 +208,15 @@ def _experimental_completion(env_var_name, f): "-l", "--log-level", default=None, - type=click.Choice(ykman.logging_setup.LOG_LEVEL_NAMES, case_sensitive=False), - help="Enable logging at given verbosity level.", + 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 logs to the given FILE instead of standard error; " - "ignored unless --log-level is also set.", + help="write log to FILE instead of printing to stderr (requires --log-level)", ) @click.option( "--diagnose", @@ -251,7 +224,7 @@ def _experimental_completion(env_var_name, f): callback=print_diagnostics, expose_value=False, is_eager=True, - help="Show diagnostics information useful for troubleshooting.", + help="show diagnostics information useful for troubleshooting", ) @click.option( "-v", @@ -260,13 +233,13 @@ def _experimental_completion(env_var_name, f): callback=print_version, expose_value=False, is_eager=True, - help="Show version information about the app", + help="show version information about the app", ) @click.option( "--full-help", is_flag=True, expose_value=False, - help="Show --help, including hidden commands, and exit.", + help="show --help output, including hidden commands", ) @click.pass_context def cli(ctx, device, log_level, log_file, reader): @@ -280,13 +253,16 @@ def cli(ctx, device, log_level, log_file, reader): $ ykman list --serials \b - Show information about YubiKey with serial number 0123456: - $ ykman --device 0123456 info + Show information about YubiKey with serial number 123456: + $ ykman --device 123456 info """ ctx.obj = YkmanContextObject() if log_level: - ykman.logging_setup.setup(log_level, log_file=log_file) + 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.") @@ -305,24 +281,26 @@ def cli(ctx, device, log_level, log_file, reader): subcmd, "connections", [SmartCardConnection, FidoConnection, OtpConnection] ) if connections: - if connections == [FidoConnection] and WIN_CTAP_RESTRICTED: - # FIDO-only command on Windows without Admin won't work. - cli_fail("FIDO access on Windows requires running as Administrator.") 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 device is not None: - items = _run_cmd_for_serial(subcmd.name, connections, device) + if reader is not None: + items = require_reader(connections, reader) else: - items = _run_cmd_for_single(ctx, subcmd.name, connections, reader) - ctx.call_on_close(items[0].close) + items = require_device(connections, device) setattr(resolve, "items", items) return items - ctx.obj.add_resolver("conn", lambda: resolve()[0]) - ctx.obj.add_resolver("pid", lambda: resolve()[1].pid) - ctx.obj.add_resolver("info", lambda: resolve()[2]) + 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") @@ -330,12 +308,10 @@ def resolve(): "-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." + 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): """ @@ -366,7 +342,7 @@ def list_keys(ctx, serials, readers): for pid, count in devs.items(): if pid not in pids: for _ in range(count): - name = pid.get_type().value + name = pid.yubikey_type.value mode = pid.name.split("_", 1)[1].replace("_", "+") click.echo(f"{name} [{mode}] ") @@ -374,20 +350,50 @@ def list_keys(ctx, serials, readers): def _describe_device(dev, dev_info): 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.get_type()) - version = "%d.%d.%d" % dev_info.version if dev_info.version else "unknown" + name = get_name(dev_info, dev.pid.yubikey_type) + version = dev_info.version or "unknown" mode = dev.pid.name.split("_", 1)[1].replace("_", "+") return f"{name} ({version}) [{mode}]" -COMMANDS = (list_keys, info, otp, openpgp, oath, piv, fido, config, apdu) +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. @@ -397,15 +403,23 @@ def main(): try: cli(obj={}) - except ApplicationNotAvailableError as e: - logger.error("Error", exc_info=e) - cli_fail( - "The functionality required for this command is not enabled or not " - "available on this YubiKey." - ) - except ValueError as e: - logger.error("Error", exc_info=e) - cli_fail(str(e)) + 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__": diff --git a/ykman/cli/aliases.py b/ykman/_cli/aliases.py similarity index 94% rename from ykman/cli/aliases.py rename to ykman/_cli/aliases.py index f2674531c..1b1fb34d7 100644 --- a/ykman/cli/aliases.py +++ b/ykman/_cli/aliases.py @@ -25,13 +25,16 @@ # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -import click +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 @@ -116,16 +119,14 @@ def _find_match(data, selection): def apply_aliases(argv): - for (alias, f) in _aliases: + for alias, f in _aliases: i = _find_match(argv, alias) if i is not None: if f: argv = f(argv, alias, i) - click.echo( - "WARNING: " - "The use of this command is deprecated and will be removed!\n" - "Replace with: ykman " + " ".join(argv[1:]) + "\n", - err=True, + logger.exception( + "This command has moved! Use ykman " + " ".join(argv[1:]) ) + sys.exit(1) break # Only handle first match return argv diff --git a/ykman/cli/apdu.py b/ykman/_cli/apdu.py similarity index 68% rename from ykman/cli/apdu.py rename to ykman/_cli/apdu.py index 0dc082e77..a41783534 100644 --- a/ykman/cli/apdu.py +++ b/ykman/_cli/apdu.py @@ -26,9 +26,14 @@ # POSSIBILITY OF SUCH DAMAGE. from binascii import a2b_hex -from yubikit.core import AID -from yubikit.core.smartcard import SmartCardConnection, SmartCardProtocol, ApduError, SW -from .util import EnumChoice, ykman_command +from yubikit.core.smartcard import ( + SmartCardConnection, + SmartCardProtocol, + ApduError, + SW, + AID, +) +from .util import EnumChoice, CliFail, click_command from typing import Tuple, Optional import re @@ -85,20 +90,20 @@ def _print_response(resp: bytes, sw: int, no_pretty: bool) -> None: ) -@ykman_command(SmartCardConnection, hidden="--full-help" not in sys.argv) +@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" + "-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", + help="select application", ) @click.argument("apdu", nargs=-1) -@click.option("-s", "--send-apdu", multiple=True, help="Provide full APDUs") +@click.option("-s", "--send-apdu", multiple=True, help="provide full APDUs") def apdu(ctx, no_pretty, app, apdu, send_apdu): """ Execute arbitary APDUs. @@ -133,44 +138,45 @@ def apdu(ctx, no_pretty, app, apdu, send_apdu): if not apdus and not app: ctx.fail("No commands provided.") - protocol = SmartCardProtocol(ctx.obj["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: - click.echo(f"Aborted due to error (expected SW={check:04X}).") - ctx.exit(1) + 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}).") diff --git a/ykman/cli/config.py b/ykman/_cli/config.py similarity index 52% rename from ykman/cli/config.py rename to ykman/_cli/config.py index 02cd1c26e..99fbc4e77 100644 --- a/ykman/cli/config.py +++ b/ykman/_cli/config.py @@ -25,7 +25,10 @@ # 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 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, @@ -34,13 +37,13 @@ DEVICE_FLAG, Mode, ) -from .. import YUBIKEY from .util import ( + click_group, click_postpone_execution, click_force_option, click_prompt, EnumChoice, - cli_fail, + CliFail, ) import os import re @@ -51,14 +54,14 @@ logger = logging.getLogger(__name__) -CLEAR_LOCK_CODE = "0" * 32 +CLEAR_LOCK_CODE = b"\0" * 16 -def prompt_lock_code(prompt="Enter your lock code"): - return click_prompt(prompt, default="", hide_input=True, show_default=False) +def prompt_lock_code(): + return click_prompt("Enter your lock code", hide_input=True) -@click.group() +@click_group(connections=[SmartCardConnection, OtpConnection, FidoConnection]) @click.pass_context @click_postpone_execution def config(ctx): @@ -83,13 +86,25 @@ def config(ctx): Generate and set a random application lock code: $ ykman config set-lock-code --generate """ - ctx.obj["controller"] = ManagementSession(ctx.obj["conn"]) + 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 info.version < (5, 0, 0): - cli_fail( + 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." ) @@ -98,19 +113,19 @@ def _require_config(ctx): @config.command("set-lock-code") @click.pass_context @click_force_option -@click.option("-l", "--lock-code", metavar="HEX", help="Current lock code.") +@click.option("-l", "--lock-code", metavar="HEX", help="current lock code") @click.option( "-n", "--new-lock-code", metavar="HEX", - help="New lock code. Conflicts with --generate.", + help="new lock code (can't be used with --generate)", ) -@click.option("-c", "--clear", is_flag=True, help="Clear the lock code.") +@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. Conflicts with --new-lock-code.", + 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): """ @@ -124,78 +139,132 @@ def set_lock_code(ctx, lock_code, new_lock_code, clear, generate, force): info = ctx.obj["info"] app = ctx.obj["controller"] - def prompt_new_lock_code(): - return prompt_lock_code(prompt="Enter your new lock code") - - def prompt_current_lock_code(): - return prompt_lock_code(prompt="Enter your current lock code") - - def change_lock_code(lock_code, new_lock_code): - lock_code = _parse_lock_code(ctx, lock_code) - new_lock_code = _parse_lock_code(ctx, new_lock_code) - try: - app.write_device_config( - None, - False, - lock_code, - new_lock_code, - ) - except Exception as e: - logger.error("Changing the lock code failed", exc_info=e) - cli_fail("Failed to change the lock code. Wrong current code?") - - def set_lock_code(new_lock_code): - new_lock_code = _parse_lock_code(ctx, new_lock_code) - try: - app.write_device_config( - None, - False, - None, - new_lock_code, - ) - except Exception as e: - logger.error("Setting the lock code failed", exc_info=e) - cli_fail("Failed to set the lock code.") - - if generate and new_lock_code: - ctx.fail("Invalid options: --new-lock-code conflicts with --generate.") + 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: - new_lock_code = CLEAR_LOCK_CODE - - if generate: - new_lock_code = os.urandom(16).hex() - click.echo(f"Using a randomly generated lock code: {new_lock_code}") + 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 lock_code: - if new_lock_code: - change_lock_code(lock_code, new_lock_code) - else: - new_lock_code = prompt_new_lock_code() - change_lock_code(lock_code, new_lock_code) - else: - if new_lock_code: - lock_code = prompt_current_lock_code() - change_lock_code(lock_code, new_lock_code) - else: - lock_code = prompt_current_lock_code() - new_lock_code = prompt_new_lock_code() - change_lock_code(lock_code, new_lock_code) + 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: - cli_fail( - "There is no current lock code set. Use --new-lock-code to set one." + raise CliFail( + "No lock code is currently set. Use --new-lock-code to set one." ) - else: - if new_lock_code: - set_lock_code(new_lock_code) - else: - new_lock_code = prompt_new_lock_code() - set_lock_code(new_lock_code) + 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() @@ -206,40 +275,40 @@ def set_lock_code(new_lock_code): "--enable", multiple=True, type=EnumChoice(CAPABILITY), - help="Enable applications.", + help="enable applications", ) @click.option( "-d", "--disable", multiple=True, type=EnumChoice(CAPABILITY), - help="Disable applications.", + help="disable applications", ) @click.option( - "-l", "--list", "list_enabled", is_flag=True, help="List enabled applications." + "-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("-a", "--enable-all", is_flag=True, help="enable all applications") @click.option( "-L", "--lock-code", metavar="HEX", - help="Current application configuration lock code.", + 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).", + 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("--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.", + help="when set, the smartcard will automatically eject" + " after the given time (implies --touch-eject)", ) @click.option( "--chalresp-timeout", @@ -247,8 +316,8 @@ def set_lock_code(new_lock_code): type=int, default=None, metavar="SECONDS", - help="Sets the timeout when waiting for touch" - " for challenge-response in the OTP application.", + help="sets the timeout when waiting for touch for challenge-response in the OTP " + "application", ) def usb( ctx, @@ -268,12 +337,6 @@ def usb( """ _require_config(ctx) - def ensure_not_all_disabled(ctx, usb_enabled): - for app in CAPABILITY: - if app & usb_enabled: - return - ctx.fail("Can not disable all applications over USB.") - if not ( list_enabled or enable_all @@ -286,88 +349,43 @@ def ensure_not_all_disabled(ctx, usb_enabled): ): ctx.fail("No configuration options chosen.") - info = ctx.obj["info"] - usb_supported = info.supported_capabilities[TRANSPORT.USB] - usb_enabled = info.config.enabled_capabilities[TRANSPORT.USB] - flags = info.config.device_flags - - if enable_all: - enable = [c for c in CAPABILITY if c in usb_supported] - - _ensure_not_invalid_options(ctx, enable, disable) - if touch_eject and no_touch_eject: ctx.fail("Invalid options.") - if not usb_supported: - cli_fail("USB not supported on this YubiKey.") - if list_enabled: - _list_apps(ctx, usb_enabled) + _list_apps(ctx, TRANSPORT.USB) - if touch_eject: - flags |= DEVICE_FLAG.EJECT - if no_touch_eject: - flags &= ~DEVICE_FLAG.EJECT - - for app in enable: - if app & usb_supported: - usb_enabled |= app - else: - cli_fail(f"{app.name} not supported over USB on this YubiKey.") - for app in disable: - if app & usb_supported: - usb_enabled &= ~app - else: - cli_fail(f"{app.name} not supported over USB on this YubiKey.") + config = DeviceConfig({}, autoeject_timeout, chalresp_timeout, None) + changes = [] + info = ctx.obj["info"] - ensure_not_all_disabled(ctx, usb_enabled) + if enable_all: + enable = info.supported_capabilities.get(TRANSPORT.USB) + else: + enable = CAPABILITY(sum(enable)) + disable = CAPABILITY(sum(disable)) - f_confirm = "" - if enable: - f_confirm += f"Enable {', '.join(str(app) for app in enable)}.\n" - if disable: - f_confirm += f"Disable {', '.join(str(app) for app in disable)}.\n" if touch_eject: - f_confirm += "Set touch eject.\n" - elif no_touch_eject: - f_confirm += "Disable touch eject.\n" + 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: - f_confirm += f"Set autoeject timeout to {autoeject_timeout}.\n" + changes.append(f"Set auto-eject timeout to {autoeject_timeout}") if chalresp_timeout: - f_confirm += f"Set challenge-response timeout to {chalresp_timeout}.\n" - f_confirm += "Configure USB?" + changes.append(f"Set challenge-response timeout to {chalresp_timeout}") - is_locked = info.is_locked - - if force and is_locked and not lock_code: - cli_fail("Configuration is locked - please supply the --lock-code option.") - if lock_code and not is_locked: - cli_fail("Configuration is not locked - please remove the --lock-code option.") - - force or click.confirm(f_confirm, 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) - - app = ctx.obj["controller"] - try: - app.write_device_config( - DeviceConfig( - {TRANSPORT.USB: usb_enabled}, - autoeject_timeout, - chalresp_timeout, - flags, - ), - True, - lock_code, - ) - except Exception as e: - logger.error("Failed to write config", exc_info=e) - cli_fail("Failed to configure USB applications.") + _configure_applications( + ctx, + config, + changes, + TRANSPORT.USB, + enable, + disable, + lock_code, + force, + ) @config.command() @@ -378,25 +396,25 @@ def ensure_not_all_disabled(ctx, usb_enabled): "--enable", multiple=True, type=EnumChoice(CAPABILITY), - help="Enable applications.", + help="enable applications", ) @click.option( "-d", "--disable", multiple=True, type=EnumChoice(CAPABILITY), - help="Disable applications.", + 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("-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" + "-l", "--list", "list_enabled", is_flag=True, help="list enabled applications" ) @click.option( "-L", "--lock-code", metavar="HEX", - help="Current application configuration lock code.", + help="current application configuration lock code", ) def nfc(ctx, enable, disable, enable_all, disable_all, list_enabled, lock_code, force): """ @@ -407,78 +425,47 @@ def nfc(ctx, enable, disable, enable_all, disable_all, list_enabled, lock_code, 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) - nfc_enabled = info.config.enabled_capabilities.get(TRANSPORT.NFC) + nfc_supported = info.supported_capabilities.get(TRANSPORT.NFC) if enable_all: - enable = [c for c in CAPABILITY if c in nfc_supported] - + enable = nfc_supported + else: + enable = CAPABILITY(sum(enable)) if disable_all: - disable = [c for c in CAPABILITY if c in nfc_enabled] - - _ensure_not_invalid_options(ctx, enable, disable) - - if not nfc_supported: - cli_fail("NFC not available on this YubiKey.") - - if list_enabled: - _list_apps(ctx, nfc_enabled) - - for app in enable: - if app & nfc_supported: - nfc_enabled |= app - else: - cli_fail(f"{app.name} not supported over NFC on this YubiKey.") - for app in disable: - if app & nfc_supported: - nfc_enabled &= ~app - else: - cli_fail(f"{app.name} not supported over NFC on this YubiKey.") - - f_confirm = "" - if enable: - f_confirm += f"Enable {', '.join(str(app) for app in enable)}.\n" - if disable: - f_confirm += f"Disable {', '.join(str(app) for app in disable)}.\n" - f_confirm += "Configure NFC?" - - is_locked = info.is_locked - - if force and is_locked and not lock_code: - cli_fail("Configuration is locked - please supply the --lock-code option.") - if lock_code and not is_locked: - cli_fail("Configuration is not locked - please remove the --lock-code option.") + disable = nfc_supported + else: + disable = CAPABILITY(sum(disable)) - force or click.confirm(f_confirm, abort=True, err=True) + _configure_applications( + ctx, + config, + [], + TRANSPORT.NFC, + enable, + disable, + lock_code, + force, + ) - if is_locked and not lock_code: - lock_code = prompt_lock_code() - if lock_code: - lock_code = _parse_lock_code(ctx, lock_code) +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.") - app = ctx.obj["controller"] - try: - app.write_device_config( - DeviceConfig({TRANSPORT.NFC: nfc_enabled}, None, None, None), - False, # No need to reboot for NFC. - lock_code, - ) - except Exception as e: - logger.error("Failed to write config", exc_info=e) - cli_fail("Failed to configure NFC applications.") - - -def _list_apps(ctx, enabled): for app in CAPABILITY: if app & enabled: - click.echo(str(app)) + click.echo(app.display_name) ctx.exit() def _ensure_not_invalid_options(ctx, enable, disable): - if any(a in enable for a in disable): + if enable & disable: ctx.fail("Invalid options.") @@ -497,7 +484,7 @@ def _parse_lock_code(ctx, lock_code): def _parse_interface_string(interface): for iface in USB_INTERFACE: - if iface.name.startswith(interface): + if (iface.name or "").startswith(interface): return iface raise ValueError() @@ -512,12 +499,10 @@ def _parse_mode_string(ctx, param, mode): pass # Not a numeric mode, parse string try: - interfaces = USB_INTERFACE(0) if mode[0] in ["+", "-"]: info = ctx.obj["info"] usb_enabled = info.config.enabled_capabilities[TRANSPORT.USB] - my_mode = _mode_from_usb_enabled(usb_enabled) - interfaces |= my_mode.interfaces + interfaces = usb_enabled.usb_interfaces for mod in re.findall(r"[+-][A-Z]+", mode.upper()): interface = _parse_interface_string(mod[1:]) if mod.startswith("+"): @@ -525,6 +510,7 @@ def _parse_mode_string(ctx, param, mode): else: interfaces ^= interface else: + interfaces = USB_INTERFACE(0) for t in re.split(r"[+]+", mode.upper()): if t: interfaces |= _parse_interface_string(t) @@ -534,25 +520,14 @@ def _parse_mode_string(ctx, param, mode): return Mode(interfaces) -def _mode_from_usb_enabled(usb_enabled): - interfaces = USB_INTERFACE(0) - if CAPABILITY.OTP & usb_enabled: - interfaces |= USB_INTERFACE.OTP - if (CAPABILITY.U2F | CAPABILITY.FIDO2) & usb_enabled: - interfaces |= USB_INTERFACE.FIDO - if (CAPABILITY.OPENPGP | CAPABILITY.PIV | CAPABILITY.OATH) & usb_enabled: - interfaces |= USB_INTERFACE.CCID - return Mode(interfaces) - - @config.command() @click.argument("mode", callback=_parse_mode_string) @click.option( "--touch-eject", is_flag=True, - help="When set, the button " + help="when set, the button " "toggles the state of the smartcard between ejected and inserted " - "(CCID mode only).", + "(CCID only)", ) @click.option( "--autoeject-timeout", @@ -560,8 +535,8 @@ def _mode_from_usb_enabled(usb_enabled): type=int, default=0, metavar="SECONDS", - help="When set, the smartcard will automatically eject after the " - "given time. Implies --touch-eject (CCID mode only).", + help="when set, the smartcard will automatically eject after the given time " + "(implies --touch-eject, CCID only)", ) @click.option( "--chalresp-timeout", @@ -569,7 +544,7 @@ def _mode_from_usb_enabled(usb_enabled): type=int, default=0, metavar="SECONDS", - help="Sets the timeout when waiting for touch for challenge response.", + help="sets the timeout when waiting for touch for challenge response", ) @click_force_option @click.pass_context @@ -598,12 +573,12 @@ def mode(ctx, mode, touch_eject, autoeject_timeout, chalresp_timeout, force): info = ctx.obj["info"] mgmt = ctx.obj["controller"] usb_enabled = info.config.enabled_capabilities[TRANSPORT.USB] - my_mode = _mode_from_usb_enabled(usb_enabled) + my_mode = Mode(usb_enabled.usb_interfaces) usb_supported = info.supported_capabilities[TRANSPORT.USB] - interfaces_supported = _mode_from_usb_enabled(usb_supported).interfaces + interfaces_supported = usb_supported.usb_interfaces pid = ctx.obj["pid"] if pid: - key_type = pid.get_type() + key_type = pid.yubikey_type else: key_type = None @@ -617,14 +592,14 @@ def mode(ctx, mode, touch_eject, autoeject_timeout, chalresp_timeout, force): if not force: if mode == my_mode: - cli_fail(f"Mode is already {mode}, nothing to do...", 0) + raise CliFail(f"Mode is already {mode}, nothing to do...", 0) elif key_type in (YUBIKEY.YKS, YUBIKEY.YKP): - cli_fail( + 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: - cli_fail( + raise CliFail( f"Mode {mode} is not supported on this YubiKey!\n" + "Use --force to attempt to set it anyway." ) @@ -632,13 +607,13 @@ def mode(ctx, mode, touch_eject, autoeject_timeout, chalresp_timeout, force): 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 as e: - logger.debug("Failed to switch mode", exc_info=e) - click.echo( + except Exception: + raise CliFail( "Failed to switch mode on the YubiKey. Make sure your " "YubiKey does not have an access code set." ) diff --git a/ykman/cli/fido.py b/ykman/_cli/fido.py similarity index 70% rename from ykman/cli/fido.py rename to ykman/_cli/fido.py index 636f07185..e5434afef 100755 --- a/ykman/cli/fido.py +++ b/ykman/_cli/fido.py @@ -42,17 +42,19 @@ click_postpone_execution, click_prompt, click_force_option, - ykman_group, + click_group, prompt_timeout, + is_yk4_fips, ) -from .util import cli_fail +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 ..device import is_fips_version from ..pcsc import list_devices as list_ccid from smartcard.Exceptions import NoCardException, CardConnectionException -from typing import Optional +from typing import Optional, Sequence, List +import io +import csv as _csv import click import logging @@ -63,7 +65,7 @@ PIN_MIN_LENGTH = 4 -@ykman_group(FidoConnection) +@click_group(connections=[FidoConnection]) @click.pass_context @click_postpone_execution def fido(ctx): @@ -81,11 +83,14 @@ def fido(ctx): $ ykman fido access change-pin --pin 123456 --new-pin 654321 """ - conn = ctx.obj["conn"] + 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) as e: - logger.info("FIDO device does not support CTAP2: %s", e) + except (ValueError, CtapError): + logger.info("FIDO device does not support CTAP2", exc_info=True) @fido.command() @@ -97,14 +102,14 @@ def info(ctx): conn = ctx.obj["conn"] ctap2 = ctx.obj.get("ctap2") - if is_fips_version(ctx.obj["info"].version): + 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 PID is disabled and must be changed before it can " + "NOTE: The FIDO PIN is disabled and must be changed before it can " "be used!" ) pin_retries, power_cycle = client_pin.get_pin_retries() @@ -122,7 +127,7 @@ def info(ctx): bio_enroll = ctap2.info.options.get("bioEnroll") if bio_enroll: - uv_retries, _ = client_pin.get_uv_retries() + uv_retries = client_pin.get_uv_retries() if uv_retries: click.echo( f"Fingerprints registered, with {uv_retries} attempt(s) " @@ -165,8 +170,7 @@ def reset(ctx, force): if isinstance(conn, CtapPcscDevice): # NFC readers = list_ccid(conn._name) if not readers or readers[0].reader.name != conn._name: - logger.error(f"Multiple readers matched: {readers}") - cli_fail("Unable to isolate NFC reader.") + raise CliFail("Unable to isolate NFC reader.") dev = readers[0] logger.debug(f"use: {dev}") is_fips = False @@ -194,12 +198,12 @@ def prompt_re_insert(): else: # USB n_keys = len(list_ctap_devices()) if n_keys > 1: - cli_fail("Only one YubiKey can be connected to perform a reset.") - is_fips = is_fips_version(ctx.obj["info"].version) + 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: - cli_fail("This YubiKey does not support FIDO reset.") + 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...") @@ -214,12 +218,12 @@ def prompt_re_insert(): return keys[0].open_connection(FidoConnection) if not force: - if not click.confirm( + click.confirm( "WARNING! This will delete all FIDO credentials, including FIDO U2F " "credentials, and restore factory settings. Proceed?", err=True, - ): - ctx.abort() + abort=True, + ) if is_fips: destroy_input = click_prompt( "WARNING! This is a YubiKey FIPS device. This command will also " @@ -230,7 +234,7 @@ def prompt_re_insert(): show_default=False, ) if destroy_input != "OVERWRITE": - cli_fail("Reset aborted by user.") + raise CliFail("Reset aborted by user.") conn = prompt_re_insert() @@ -240,45 +244,43 @@ def prompt_re_insert(): fips_reset(conn) else: Ctap2(conn).reset() + logger.info("FIDO application data reset") except CtapError as e: - logger.error("Reset failed", exc_info=e) if e.code == CtapError.ERR.ACTION_TIMEOUT: - cli_fail( + 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): - cli_fail( + raise CliFail( "Reset failed. Reset must be triggered within 5 seconds after the " "YubiKey is inserted." ) else: - cli_fail(f"Reset failed: {e.code.name}") + raise CliFail(f"Reset failed: {e.code.name}") except ApduError as e: # From fips_reset - logger.error("Reset failed", exc_info=e) if e.code == SW.COMMAND_NOT_ALLOWED: - cli_fail( + raise CliFail( "Reset failed. Reset must be triggered within 5 seconds after the " "YubiKey is inserted." ) else: - cli_fail("Reset failed.") - except Exception as e: - logger.error(e) - cli_fail("Reset failed.") + 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: - cli_fail("Wrong PIN.") + raise CliFail("Wrong PIN.") elif e.code == CtapError.ERR.PIN_AUTH_BLOCKED: - cli_fail( + raise CliFail( "PIN authentication is currently blocked. " "Remove and re-insert the YubiKey." ) elif e.code == CtapError.ERR.PIN_BLOCKED: - cli_fail("PIN is blocked.") + raise CliFail("PIN is blocked.") else: - cli_fail(other % e.code) + raise CliFail(other % e.code) @fido.group("access") @@ -290,10 +292,13 @@ def access(): @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("-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." + "-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): """ @@ -306,14 +311,16 @@ def change_pin(ctx, pin, new_pin, u2f): 6 characters long. """ - is_fips = is_fips_version(ctx.obj["info"].version) + is_fips = is_yk4_fips(ctx.obj["info"]) if is_fips and not u2f: - cli_fail("This is a YubiKey FIPS. To set the U2F PIN, pass the --u2f option.") + raise CliFail( + "This is a YubiKey FIPS. To set the U2F PIN, pass the --u2f option." + ) if u2f and not is_fips: - cli_fail( - "This is not a YubiKey FIPS, and therefore does not support a U2F PIN. " + 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." ) @@ -322,15 +329,13 @@ def change_pin(ctx, pin, new_pin, u2f): else: ctap2 = ctx.obj.get("ctap2") if not ctap2: - cli_fail("PIN is not supported on this YubiKey.") + raise CliFail("PIN is not supported on this YubiKey.") client_pin = ClientPin(ctap2) def prompt_new_pin(): return click_prompt( "Enter your new PIN", - default="", hide_input=True, - show_default=False, confirmation_prompt=True, ) @@ -354,31 +359,28 @@ def change_pin(pin, new_pin): client_pin.change_pin(pin, new_pin) except CtapError as e: - logger.error("Failed to change PIN", exc_info=e) if e.code == CtapError.ERR.PIN_POLICY_VIOLATION: - cli_fail("New PIN doesn't meet policy requirements.") + raise CliFail("New PIN doesn't meet policy requirements.") else: _fail_pin_error(ctx, e, "Failed to change PIN: %s") except ApduError as e: - logger.error("Failed to change PIN", exc_info=e) if e.code == SW.VERIFY_FAIL_NO_RETRY: - cli_fail("Wrong PIN.") + raise CliFail("Wrong PIN.") elif e.code == SW.AUTH_METHOD_BLOCKED: - cli_fail("PIN is blocked.") + raise CliFail("PIN is blocked.") else: - cli_fail(f"Failed to change PIN: SW={e.code:04x}") + 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: - logger.error("Failed to set PIN", exc_info=e) if e.code == CtapError.ERR.PIN_POLICY_VIOLATION: - cli_fail("PIN is too long.") + raise CliFail("New PIN doesn't meet policy requirements.") else: - cli_fail(f"Failed to set PIN: {e.code}") + raise CliFail(f"Failed to set PIN: {e.code}") if not is_fips: if ctap2.info.options.get("clientPin"): @@ -386,7 +388,7 @@ def set_pin(new_pin): pin = _prompt_current_pin() else: if pin: - cli_fail("There is no current PIN set. Use --new-pin to set one.") + raise CliFail("There is no current PIN set. Use --new-pin to set one.") if not new_pin: new_pin = prompt_new_pin() @@ -395,22 +397,24 @@ def set_pin(new_pin): _fail_if_not_valid_pin(ctx, new_pin, is_fips) change_pin(pin, new_pin) else: - if len(new_pin) < ctap2.info.min_pin_length: - cli_fail("New PIN is too short.") + 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: - cli_fail(f"{feature} is not supported on this YubiKey.") + raise CliFail(f"{feature} is not supported on this YubiKey.") if not ctap2.info.options.get("clientPin"): - cli_fail(f"{feature} requires having a PIN. Set a PIN first.") + raise CliFail(f"{feature} requires having a PIN. Set a PIN first.") if ctap2.info.force_pin_change: - cli_fail("The FIDO PIN is blocked. Change the PIN first.") + 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 @@ -418,7 +422,7 @@ def _require_pin(ctx, pin, feature="This feature"): @access.command("verify-pin") @click.pass_context -@click.option("-P", "--pin", help="Current PIN code.") +@click.option("-P", "--pin", help="current PIN code") def verify(ctx, pin): """ Verify the FIDO PIN against a YubiKey. @@ -437,29 +441,27 @@ def verify(ctx, pin): pin, ClientPin.PERMISSION.GET_ASSERTION, "ykman.example.com" ) except CtapError as e: - logger.error("PIN verification failed", exc_info=e) - cli_fail(f"Error: {e}") - elif is_fips_version(ctx.obj["info"].version): + 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: - logger.error("PIN verification failed", exc_info=e) if e.code == SW.VERIFY_FAIL_NO_RETRY: - cli_fail("Wrong PIN.") + raise CliFail("Wrong PIN.") elif e.code == SW.AUTH_METHOD_BLOCKED: - cli_fail("PIN is blocked.") + raise CliFail("PIN is blocked.") elif e.code == SW.COMMAND_NOT_ALLOWED: - cli_fail("PIN is not set.") + raise CliFail("PIN is not set.") else: - cli_fail(f"PIN verification failed: {e.code.name}") + raise CliFail(f"PIN verification failed: {e.code.name}") else: - cli_fail("This YubiKey does not support a FIDO PIN.") + 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, default="", hide_input=True, show_default=False) + return click_prompt(prompt, hide_input=True) def _fail_if_not_valid_pin(ctx, pin=None, is_fips=False): @@ -478,10 +480,27 @@ def _gen_creds(credman): rp[CredentialManagement.RESULT.RP]["id"], cred[CredentialManagement.RESULT.CREDENTIAL_ID], cred[CredentialManagement.RESULT.USER]["id"], - cred[CredentialManagement.RESULT.USER]["name"], + 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}" @@ -502,8 +521,8 @@ def creds(): $ ykman fido credentials list --pin 123456 \b - Delete a credential by user name (PIN will be prompted for): - $ ykman fido credentials delete example_user + Delete a credential (ID shown in "list" output, PIN will be prompted for): + $ ykman fido credentials delete da7fdc """ @@ -515,7 +534,6 @@ def _init_credman(ctx, pin): try: token = client_pin.get_pin_token(pin, ClientPin.PERMISSION.CREDENTIAL_MGMT) except CtapError as e: - logger.error("Ctap error", exc_info=e) _fail_pin_error(ctx, e, "PIN error: %s") return CredentialManagement(ctap2, client_pin.protocol, token) @@ -523,53 +541,86 @@ def _init_credman(ctx, pin): @creds.command("list") @click.pass_context -@click.option("-P", "--pin", help="PIN code.") -def creds_list(ctx, pin): +@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). """ - creds = _init_credman(ctx, pin) - for (rp_id, _, user_id, user_name) in _gen_creds(creds): - click.echo(_format_cred(rp_id, user_id, user_name)) + 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("query") -@click.option("-P", "--pin", help="PIN code.") -@click.option("-f", "--force", is_flag=True, help="Confirm deletion without prompting") -def creds_delete(ctx, query, pin, force): +@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 - QUERY A unique substring match of a credentials RP ID, user ID (hex) or name, - or credential ID. + 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_id, user_name) - for (rp_id, cred_id, user_id, user_name) in _gen_creds(credman) - if query.lower() in user_name.lower() - or query.lower() in rp_id.lower() - or user_id.hex().startswith(query.lower()) - or query.lower() in _format_cred(rp_id, user_id, user_name) + (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: - cli_fail("No matches, nothing to be done.") + raise CliFail("No matches, nothing to be done.") elif len(hits) == 1: - (rp_id, cred_id, user_id, user_name) = hits[0] + (rp_id, cred_id, user_name, display_name) = hits[0] if force or click.confirm( - f"Delete credential {_format_cred(rp_id, user_id, user_name)}?" + f"Delete {rp_id} {user_name} {display_name} ({cred_id['id'].hex()})?" ): try: credman.delete_cred(cred_id) - except CtapError as e: - logger.error("Failed to delete resident credential", exc_info=e) - cli_fail("Failed to delete resident credential.") + logger.info("Credential deleted") + except CtapError: + raise CliFail("Failed to delete credential.") else: - cli_fail("Multiple matches, make the query more specific.") + raise CliFail("Multiple matches, make the credential ID more specific.") @fido.group("fingerprints") @@ -601,14 +652,13 @@ def bio(): def _init_bio(ctx, pin): ctap2 = ctx.obj.get("ctap2") if not ctap2 or "bioEnroll" not in ctap2.info.options: - cli_fail("Biometrics is not supported on this YubiKey.") + 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: - logger.error("Ctap error", exc_info=e) _fail_pin_error(ctx, e, "PIN error: %s") return FPBioEnrollment(ctap2, client_pin.protocol, token) @@ -620,10 +670,10 @@ def _format_fp(template_id, name): @bio.command("list") @click.pass_context -@click.option("-P", "--pin", help="PIN code.") +@click.option("-P", "--pin", help="PIN code") def bio_list(ctx, pin): """ - List registered fingerprint. + List registered fingerprints. Lists fingerprints by ID and (if available) label. """ @@ -636,13 +686,13 @@ def bio_list(ctx, pin): @bio.command("add") @click.pass_context @click.argument("name") -@click.option("-P", "--pin", help="PIN code.") +@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"). + 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") @@ -658,34 +708,35 @@ def bio_enroll(ctx, name, pin): if remaining: click.echo(f"{remaining} more scans needed.") except CaptureError as e: - logger.error(f"Capture error: {e.code}") + logger.debug(f"Capture error: {e.code}") click.echo("Capture failed. Re-center your finger, and try again.") except CtapError as e: - logger.error("Failed to add fingerprint template", exc_info=e) if e.code == CtapError.ERR.FP_DATABASE_FULL: - cli_fail( + raise CliFail( "Fingerprint storage full. " "Remove some fingerprints before adding new ones." ) elif e.code == CtapError.ERR.USER_ACTION_TIMEOUT: - cli_fail("Failed to add fingerprint due to user inactivity.") - cli_fail(f"Failed to add fingerprint: {e.code.name}") + 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.") +@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"). + 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") @@ -695,16 +746,17 @@ def bio_rename(ctx, template_id, name, pin): key = bytes.fromhex(template_id) if key not in enrollments: - cli_fail(f"No fingerprint matching ID={template_id}.") + 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") +@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. @@ -724,9 +776,9 @@ def bio_delete(ctx, template_id, pin, force): # Match using template_id as NAME matches = [k for k in enrollments if enrollments[k] == template_id] if len(matches) == 0: - cli_fail(f"No fingerprint matching ID={template_id}") + raise CliFail(f"No fingerprint matching ID={template_id}") elif len(matches) > 1: - cli_fail( + raise CliFail( f"Multiple matches for NAME={template_id}. " "Delete by template ID instead." ) @@ -736,6 +788,6 @@ def bio_delete(ctx, template_id, pin, force): 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: - logger.error("Failed to delete fingerprint template", exc_info=e) - cli_fail(f"Failed to delete fingerprint: {e.code.name}") + raise CliFail(f"Failed to delete fingerprint: {e.code.name}") diff --git a/ykman/_cli/hsmauth.py b/ykman/_cli/hsmauth.py new file mode 100644 index 000000000..44eb2455c --- /dev/null +++ b/ykman/_cli/hsmauth.py @@ -0,0 +1,649 @@ +# 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." + ) diff --git a/ykman/cli/info.py b/ykman/_cli/info.py similarity index 83% rename from ykman/cli/info.py rename to ykman/_cli/info.py index 3a8409126..c59800adc 100644 --- a/ykman/cli/info.py +++ b/ykman/_cli/info.py @@ -32,9 +32,9 @@ 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 cli_fail -from ..device import is_fips_version, get_name, connect_to_device +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 @@ -46,8 +46,6 @@ logger = logging.getLogger(__name__) -SHOWN_CAPABILITIES = set(CAPABILITY) - def print_app_status_table(supported_apps, enabled_apps): usb_supported = supported_apps.get(TRANSPORT.USB, 0) @@ -55,7 +53,7 @@ def print_app_status_table(supported_apps, enabled_apps): nfc_supported = supported_apps.get(TRANSPORT.NFC, 0) nfc_enabled = enabled_apps.get(TRANSPORT.NFC, 0) rows = [] - for app in SHOWN_CAPABILITIES: + for app in CAPABILITY: if app & usb_supported: if app & usb_enabled: usb_status = "Enabled" @@ -71,9 +69,9 @@ def print_app_status_table(supported_apps, enabled_apps): nfc_status = "Disabled" else: nfc_status = "Not available" - rows.append([str(app), usb_status, nfc_status]) + rows.append([app.display_name, usb_status, nfc_status]) else: - rows.append([str(app), usb_status]) + rows.append([app.display_name, usb_status]) column_l: List[int] = [] for row in rows: @@ -93,7 +91,7 @@ def print_app_status_table(supported_apps, enabled_apps): for row in rows: for idx, c in enumerate(row): f_table += f"{c.ljust(column_l[idx])}\t" - f_table += "\n" + f_table = f_table.strip() + "\n" if nfc_supported: click.echo(f"{f_apps}\t{f_USB}\t{f_NFC}") @@ -102,33 +100,33 @@ def print_app_status_table(supported_apps, enabled_apps): click.echo(f_table, nl=False) -def get_overall_fips_status(pid, info): +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 connect_to_device(info.serial, [OtpConnection])[0] as conn: + 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 connect_to_device(info.serial, [SmartCardConnection])[0] as conn: + 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 connect_to_device(info.serial, [FidoConnection])[0] as conn: + with device.open_connection(FidoConnection) as conn: statuses["FIDO U2F"] = ctap_in_fips_mode(conn) return statuses -def _check_fips_status(pid, info): - fips_status = get_overall_fips_status(pid, info) +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'}") @@ -142,11 +140,10 @@ def _check_fips_status(pid, info): @click.option( "-c", "--check-fips", - help="Check if YubiKey is in FIPS Approved mode (available on YubiKey 4 FIPS " - "only).", + help="check if YubiKey is in FIPS Approved mode (YubiKey 4 FIPS only)", is_flag=True, ) -@click.command() +@click_command(connections=[SmartCardConnection, OtpConnection, FidoConnection]) @click.pass_context def info(ctx, check_fips): """ @@ -161,8 +158,8 @@ def info(ctx, check_fips): interfaces = None key_type = None else: - interfaces = pid.get_interfaces() - key_type = pid.get_type() + interfaces = pid.usb_interfaces + key_type = pid.yubikey_type device_name = get_name(info, key_type) click.echo(f"Device type: {device_name}") @@ -180,7 +177,7 @@ def info(ctx, check_fips): click.echo(f"Form factor: {info.form_factor!s}") if interfaces: f_interfaces = ", ".join( - t.name for t in USB_INTERFACE if t in USB_INTERFACE(interfaces) + 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: @@ -199,8 +196,8 @@ def info(ctx, check_fips): ) if check_fips: - if is_fips_version(info.version): - ctx.obj["conn"].close() - _check_fips_status(pid, info) + if is_yk4_fips(info): + device = ctx.obj["device"] + _check_fips_status(device, info) else: - cli_fail("Unable to check FIPS Approved mode - Not a YubiKey 4 FIPS") + raise CliFail("Unable to check FIPS Approved mode - Not a YubiKey 4 FIPS") diff --git a/ykman/cli/oath.py b/ykman/_cli/oath.py similarity index 72% rename from ykman/cli/oath.py rename to ykman/_cli/oath.py index f0fdbd969..6307b244e 100644 --- a/ykman/cli/oath.py +++ b/ykman/_cli/oath.py @@ -28,16 +28,17 @@ import click import logging from .util import ( - cli_fail, + CliFail, click_force_option, click_postpone_execution, click_callback, click_parse_b32_key, click_prompt, - ykman_group, + click_group, prompt_for_touch, prompt_timeout, EnumChoice, + is_yk4_fips, ) from yubikit.core.smartcard import ApduError, SW, SmartCardConnection from yubikit.oath import ( @@ -48,15 +49,14 @@ parse_b32_key, _format_cred_id, ) -from ..oath import is_steam, calculate_steam, is_hidden -from ..device import is_fips_version +from ..oath import is_steam, calculate_steam, is_hidden, delete_broken_credential from ..settings import AppData logger = logging.getLogger(__name__) -@ykman_group(SmartCardConnection) +@click_group(connections=[SmartCardConnection]) @click.pass_context @click_postpone_execution def oath(ctx): @@ -78,9 +78,12 @@ def oath(ctx): Set a password for the OATH application: $ ykman oath access change-password """ - session = OathSession(ctx.obj["conn"]) - ctx.obj["session"] = session - ctx.obj["settings"] = AppData("oath") + + 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() @@ -94,23 +97,18 @@ def info(ctx): click.echo(f"OATH version: {version[0]}.{version[1]}.{version[2]}") click.echo("Password protection: " + ("enabled" if session.locked else "disabled")) - keys = ctx.obj["settings"].get("keys", {}) + 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_fips_version(version): + 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.confirmation_option( - "-f", - "--force", - prompt="WARNING! This will delete all stored OATH accounts and restore factory " - "settings. Proceed?", -) -def reset(ctx): +@click_force_option +def reset(ctx, force): """ Reset all OATH data. @@ -118,56 +116,91 @@ def reset(ctx): 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() - settings = ctx.obj["settings"] - keys = settings.setdefault("keys", {}) + keys = ctx.obj["oath_keys"] if old_id in keys: del keys[old_id] - settings.write() + 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="Provide a password to unlock the YubiKey." + "-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): - try: - session = ctx.obj["session"] - session.validate(key) - if remember: - settings = ctx.obj["settings"] - keys = settings.setdefault("keys", {}) - keys[session.device_id] = key.hex() - settings.write() - click.echo("Password remembered.") - except Exception: - cli_fail("Authentication to the YubiKey failed. Wrong password?") + 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"] - settings = ctx.obj["settings"] - keys = settings.setdefault("keys", {}) + keys = ctx.obj["oath_keys"] device_id = session.device_id if session.locked: - if password: # If password argument given, use it - key = session.derive_key(password) - elif device_id in keys: # If remembered, use key - key = bytes.fromhex(keys[device_id]) - else: # Prompt for password + 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) + _validate(ctx, key, remember) + except ApduError: + raise CliFail("Authentication to the YubiKey failed. Wrong password?") + elif password: - cli_fail("Password provided, but no password is set.") + raise CliFail("Password provided, but no password is set.") @oath.group() @@ -182,10 +215,11 @@ def access(): "-c", "--clear", is_flag=True, - help="Clear the current password.", + help="remove the current password", ) -@click.option("-n", "--new-password", help="Provide a new password as an argument.") -def change(ctx, password, clear, new_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. @@ -198,29 +232,39 @@ def change(ctx, password, clear, new_password): _init_session(ctx, password, False, prompt="Enter the current password") session = ctx.obj["session"] - settings = ctx.obj["settings"] - keys = settings.setdefault("keys", {}) + keys = ctx.obj["oath_keys"] device_id = session.device_id if clear: session.unset_key() if device_id in keys: del keys[device_id] - settings.write() + 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.") - if device_id in keys: - keys[device_id] = key.hex() - settings.write() - click.echo("Password remembered.") @access.command() @@ -233,29 +277,38 @@ def remember(ctx, password): """ session = ctx.obj["session"] device_id = session.device_id - settings = ctx.obj["settings"] - keys = settings.setdefault("keys", {}) + keys = ctx.obj["oath_keys"] if not session.locked: if device_id in keys: del keys[session.device_id] - settings.write() + 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) - _validate(ctx, key, True) + 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 - settings = AppData("oath") - if "keys" in settings: - del settings["keys"] - settings.write() + keys = AppData("oath_keys") + if keys: + keys.clear() + keys.write() click.echo("All passwords have been forgotten.") ctx.exit() @@ -269,7 +322,7 @@ def _clear_all_passwords(ctx, param, value): is_eager=True, expose_value=False, callback=_clear_all_passwords, - help="Remove all stored passwords.", + help="remove all stored passwords", ) def forget(ctx): """ @@ -277,31 +330,24 @@ def forget(ctx): """ session = ctx.obj["session"] device_id = session.device_id - settings = ctx.obj["settings"] - keys = settings.setdefault("keys", {}) + keys = ctx.obj["oath_keys"] if device_id in keys: del keys[session.device_id] - settings.write() + keys.write() + logger.info("Deleted remembered access key") click.echo("Password forgotten.") else: click.echo("No password stored for this YubiKey.") -click_remember_option = click.option( - "-r", - "--remember", - is_flag=True, - help="Remember the password on this machine.", -) - click_touch_option = click.option( - "-t", "--touch", is_flag=True, help="Require touch on YubiKey to generate code." + "-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." + "-H", "--show-hidden", is_flag=True, help="include hidden accounts" ) @@ -345,7 +391,7 @@ def accounts(): "--oath-type", type=EnumChoice(OATH_TYPE), default=OATH_TYPE.TOTP.name, - help="Time-based (TOTP) or counter-based (HOTP) account.", + help="time-based (TOTP) or counter-based (HOTP) account", show_default=True, ) @click.option( @@ -353,7 +399,7 @@ def accounts(): "--digits", type=click.Choice(["6", "7", "8"]), default="6", - help="Number of digits in generated code.", + help="number of digits in generated code", show_default=True, ) @click.option( @@ -362,20 +408,20 @@ def accounts(): type=EnumChoice(HASH_ALGORITHM), default=HASH_ALGORITHM.SHA1.name, show_default=True, - help="Algorithm to use for code generation.", + help="algorithm to use for code generation", ) @click.option( "-c", "--counter", type=click.INT, default=0, - help="Initial counter value for HOTP accounts.", + help="initial counter value for HOTP accounts", ) -@click.option("-i", "--issuer", help="Issuer of the account (optional).") +@click.option("-i", "--issuer", help="issuer of the account (optional)") @click.option( "-P", "--period", - help="Number of seconds a TOTP code is valid.", + help="number of seconds a TOTP code is valid", default=30, show_default=True, ) @@ -405,8 +451,8 @@ def add( 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. + 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) @@ -483,15 +529,15 @@ def _add_cred(ctx, data, touch, force): ctx.fail("Secret must be at least 2 bytes.") if touch and version < (4, 2, 6): - cli_fail("Require touch is not supported on this YubiKey.") + 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_fips_version(version) + version < (4, 3, 1) or is_yk4_fips(ctx.obj["info"]) ): - cli_fail("Algorithm SHA512 not supported on this YubiKey.") + raise CliFail("Algorithm SHA512 not supported on this YubiKey.") creds = session.list_credentials() cred_id = data.get_id() @@ -510,16 +556,16 @@ def _add_cred(ctx, data, touch, force): # YK4 has an issue with credential overwrite in firmware versions < 4.3.5 if firmware_overwrite_issue and cred_is_subset: - cli_fail("Choose a name that is not a subset of an existing account.") + 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: - cli_fail("No space left on the YubiKey for OATH accounts.") + 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. - cli_fail("The command failed. Is there enough space on the YubiKey?") + raise CliFail("The command failed. Is there enough space on the YubiKey?") else: raise @@ -527,8 +573,8 @@ def _add_cred(ctx, data, touch, force): @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.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): @@ -562,7 +608,7 @@ def list(ctx, show_hidden, oath_type, period, password, remember): "-s", "--single", is_flag=True, - help="Ensure only a single match, and output only the code.", + help="ensure only a single match, and output only the code", ) @click_password_option @click_remember_option @@ -579,7 +625,19 @@ def code(ctx, show_hidden, query, single, password, remember): _init_session(ctx, password, remember) session = ctx.obj["session"] - entries = session.calculate_all() + 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: @@ -597,14 +655,14 @@ def code(ctx, show_hidden, query, single, password, remember): code = session.calculate_code(cred) except ApduError as e: if e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED: - cli_fail("Touch account timed out!") + 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: - cli_fail("No matching account found.") + raise CliFail("No matching account found.") if single and creds: if is_steam(cred): @@ -616,15 +674,16 @@ def code(ctx, show_hidden, query, single, password, remember): for cred in sorted(creds): code = entries[cred] if code: - code = code.value + 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 = "" - if is_steam(cred): - code = calculate_steam(session, cred) outputs.append((_string_id(cred), code)) longest_name = max(len(n) for (n, c) in outputs) if outputs else 0 @@ -639,16 +698,16 @@ def code(ctx, show_hidden, query, single, password, remember): @click.pass_context @click.argument("query") @click.argument("name") -@click.option("-f", "--force", is_flag=True, help="Confirm rename without prompting") +@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). + 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). + 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) @@ -666,7 +725,7 @@ def rename(ctx, query, name, force, password, remember): new_id = _format_cred_id(issuer, name, cred.oath_type, cred.period) if any(cred.id == new_id for cred in creds): - cli_fail( + raise CliFail( f"Another account with ID {new_id.decode()} " "already exists on this YubiKey." ) @@ -689,7 +748,7 @@ def rename(ctx, query, name, force, password, remember): @accounts.command() @click.pass_context @click.argument("query") -@click.option("-f", "--force", is_flag=True, help="Confirm deletion without prompting") +@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): @@ -699,7 +758,7 @@ def delete(ctx, query, force, password, remember): Delete an account from the YubiKey. \b - QUERY A query to match a single account (as shown in "list"). + QUERY a query to match a single account (as shown in "list") """ _init_session(ctx, password, remember) diff --git a/ykman/_cli/openpgp.py b/ykman/_cli/openpgp.py new file mode 100644 index 000000000..b3c7f6bfe --- /dev/null +++ b/ykman/_cli/openpgp.py @@ -0,0 +1,545 @@ +# 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") diff --git a/ykman/cli/otp.py b/ykman/_cli/otp.py similarity index 64% rename from ykman/cli/otp.py rename to ykman/_cli/otp.py index c31faa416..f91d6c4e1 100644 --- a/ykman/cli/otp.py +++ b/ykman/_cli/otp.py @@ -28,6 +28,7 @@ from base64 import b32encode from yubikit.yubiotp import ( SLOT, + NDEF_TYPE, YubiOtpSession, YubiOtpSlotConfiguration, HmacSha1SlotConfiguration, @@ -36,11 +37,17 @@ UpdateConfiguration, ) from yubikit.core import TRANSPORT, CommandError -from yubikit.core.otp import modhex_encode, modhex_decode, OtpConnection +from yubikit.core.otp import ( + MODHEX_ALPHABET, + modhex_encode, + modhex_decode, + OtpConnection, +) +from yubikit.core.smartcard import SmartCardConnection from .util import ( - ykman_group, - cli_fail, + CliFail, + click_group, click_force_option, click_callback, click_parse_b32_key, @@ -48,19 +55,20 @@ click_prompt, prompt_for_touch, EnumChoice, + is_yk4_fips, ) from .. import __version__ -from ..device import is_fips_version from ..scancodes import encode, KEYBOARD_LAYOUT from ..otp import ( - PrepareUploadFailed, - prepare_upload_key, + _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 @@ -101,12 +109,10 @@ def parse_access_code_hex(access_code_hex): ) -def _failed_to_write_msg(ctx, exc_info): - logger.error("Failed to write to device", exc_info=exc_info) - cli_fail( - "Failed to write to the YubiKey. Make sure the device does not " - "have restricted access." - ) +_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): @@ -118,14 +124,18 @@ def _confirm_slot_overwrite(slot_state, slot): ) -@ykman_group(OtpConnection) +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="A 6 byte access code. Set to empty to use a prompt for input.", + help='6 byte access code (use "-" as a value to prompt for input)', ) def otp(ctx, access_code): """ @@ -137,7 +147,9 @@ def otp(ctx, access_code): 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. + 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: @@ -156,28 +168,56 @@ def otp(ctx, access_code): \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) """ - ctx.obj["session"] = YubiOtpSession(ctx.obj["conn"]) if access_code is not None: - if access_code == "": - access_code = click_prompt("Enter the access code", show_default=False) + 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("Failed to parse access code: " + str(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 = ctx.obj["session"] + session = _get_session(ctx) state = session.get_config_state() slot1 = state.is_configured(1) slot2 = state.is_configured(2) @@ -185,50 +225,65 @@ def info(ctx): click.echo(f"Slot 1: {slot1 and 'programmed' or 'empty'}") click.echo(f"Slot 2: {slot2 and 'programmed' or 'empty'}") - if is_fips_version(session.version): + 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.confirmation_option("-f", "--force", prompt="Swap the two slots of the YubiKey?") +@click_force_option @click.pass_context -def swap(ctx): +def swap(ctx, force): """ Swaps the two slot configurations. """ - session = ctx.obj["session"] + 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 as e: - _failed_to_write_msg(ctx, e) + 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.") -def ndef(ctx, slot, prefix): +@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). - The default prefix will be used if no prefix is specified: - - "https://my.yubico.com/yk/#" + \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 = ctx.obj["session"] + session = _get_session(ctx) state = session.get_config_state() if not info.has_transport(TRANSPORT.NFC): - cli_fail("This YubiKey does not support NFC.") + raise CliFail("This YubiKey does not support NFC.") if not state.is_configured(slot): - cli_fail(f"Slot {slot} is empty.") + raise CliFail(f"Slot {slot} is empty.") try: - session.set_ndef_configuration(slot, prefix, ctx.obj["access_code"]) - except CommandError as e: - _failed_to_write_msg(ctx, e) + session.set_ndef_configuration(slot, prefix, ctx.obj["access_code"], ndef_type) + except CommandError: + raise CliFail(_WRITE_FAIL_MSG) @otp.command() @@ -239,10 +294,10 @@ def delete(ctx, slot, force): """ Deletes the configuration stored in a slot. """ - session = ctx.obj["session"] + session = _get_session(ctx) state = session.get_config_state() if not force and not state.is_configured(slot): - cli_fail("Not possible to delete an empty 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, @@ -251,8 +306,8 @@ def delete(ctx, slot, force): click.echo(f"Deleting the configuration in slot {slot}...") try: session.delete_slot(slot, ctx.obj["access_code"]) - except CommandError as e: - _failed_to_write_msg(ctx, e) + except CommandError: + raise CliFail(_WRITE_FAIL_MSG) @otp.command() @@ -261,7 +316,7 @@ def delete(ctx, slot, force): "-P", "--public-id", required=False, - help="Public identifier prefix.", + help="public identifier prefix", metavar="MODHEX", ) @click.option( @@ -270,7 +325,7 @@ def delete(ctx, slot, force): required=False, metavar="HEX", callback=parse_hex(6), - help="6 byte private identifier.", + help="6 byte private identifier", ) @click.option( "-k", @@ -278,40 +333,47 @@ def delete(ctx, slot, force): required=False, metavar="HEX", callback=parse_hex(16), - help="16 byte secret key.", + help="16 byte secret key", ) @click.option( "--no-enter", is_flag=True, - help="Don't send an Enter keystroke after emitting the OTP.", + 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. Conflicts with --public-id.", + 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. Conflicts with --private-id.", + 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. Conflicts with --key.", + 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 in browser). Conflicts with --force.", + 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 @@ -327,13 +389,15 @@ def yubiotp( generate_private_id, generate_key, upload, + config_output, ): """ Program a Yubico OTP credential. """ info = ctx.obj["info"] - session = ctx.obj["session"] + session = _get_session(ctx) + serial = None if public_id and serial_public_id: ctx.fail("Invalid options: --public-id conflicts with --serial-public-id.") @@ -352,7 +416,8 @@ def yubiotp( try: serial = session.get_serial() except CommandError: - cli_fail("Serial number not set, public ID must be provided") + 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: @@ -363,10 +428,12 @@ def yubiotp( 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 KeyError: - ctx.fail("Invalid public ID, must be modhex.") + except ValueError: + ctx.fail(f"Invalid public ID, must be modhex ({MODHEX_ALPHABET}).") if not private_id: if generate_private_id: @@ -394,11 +461,11 @@ def yubiotp( key = click_prompt("Enter secret key") key = bytes.fromhex(key) - if not upload and not force: - upload = click.confirm("Upload credential to YubiCloud?", abort=False, err=True) if upload: + click.confirm("Upload credential to YubiCloud?", abort=True, err=True) + try: - upload_url = prepare_upload_key( + upload_url = _prepare_upload_key( key, public_id, private_id, @@ -406,27 +473,36 @@ def yubiotp( user_agent="ykman/" + __version__, ) click.echo("Upload to YubiCloud initiated successfully.") - except PrepareUploadFailed as e: + logger.info("Initiated YubiCloud upload") + except _PrepareUploadFailed as e: error_msg = "\n".join(e.messages()) - cli_fail("Upload to YubiCloud failed.\n" + error_msg) + 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 ), - ctx.obj["access_code"], - ctx.obj["access_code"], + access_code, + access_code, ) - except CommandError as e: - _failed_to_write_msg(ctx, e) + 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) @@ -434,7 +510,7 @@ def yubiotp( @otp.command() @click_slot_argument @click.argument("password", required=False) -@click.option("-g", "--generate", is_flag=True, help="Generate a random password.") +@click.option("-g", "--generate", is_flag=True, help="generate a random password") @click.option( "-l", "--length", @@ -442,7 +518,7 @@ def yubiotp( type=click.IntRange(1, 38), default=38, show_default=True, - help="Length of generated password.", + help="length of generated password", ) @click.option( "-k", @@ -450,12 +526,12 @@ def yubiotp( type=EnumChoice(KEYBOARD_LAYOUT), default="MODHEX", show_default=True, - help="Keyboard layout to use for the static password.", + 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.", + help="don't send an Enter keystroke after outputting the password", ) @click_force_option @click.pass_context @@ -470,7 +546,7 @@ def static(ctx, slot, password, generate, length, keyboard_layout, no_enter, for preferred keyboard layout. """ - session = ctx.obj["session"] + session = _get_session(ctx) if password and len(password) > 38: ctx.fail("Password too long (maximum length is 38 characters).") @@ -493,8 +569,8 @@ def static(ctx, slot, password, generate, length, keyboard_layout, no_enter, for ctx.obj["access_code"], ctx.obj["access_code"], ) - except CommandError as e: - _failed_to_write_msg(ctx, e) + except CommandError: + raise CliFail(_WRITE_FAIL_MSG) @otp.command() @@ -504,21 +580,21 @@ def static(ctx, slot, password, generate, length, keyboard_layout, no_enter, for "-t", "--touch", is_flag=True, - help="Require touch on the YubiKey to generate a response.", + 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 for TOTP credentials.", + 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. Conflicts with KEY argument.", + help="generate a random secret key (can't be used with KEY argument)", ) @click_force_option @click.pass_context @@ -527,8 +603,11 @@ 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 = ctx.obj["session"] + session = _get_session(ctx) if key: if generate: @@ -547,9 +626,9 @@ def chalresp(ctx, slot, key, totp, touch, force, generate): key = os.urandom(20) if totp: b32key = b32encode(key).decode() - click.echo(f"Using a randomly generated key (Base32): {b32key}") + click.echo(f"Using a randomly generated key (base32): {b32key}") else: - click.echo(f"Using a randomly generated key: {key.hex()}") + click.echo(f"Using a randomly generated key (hex): {key.hex()}") elif totp: while True: key = click_prompt("Enter a secret key (base32)") @@ -575,8 +654,8 @@ def chalresp(ctx, slot, key, totp, touch, force, generate): ctx.obj["access_code"], ctx.obj["access_code"], ) - except CommandError as e: - _failed_to_write_msg(ctx, e) + except CommandError: + raise CliFail(_WRITE_FAIL_MSG) @otp.command() @@ -586,14 +665,15 @@ def chalresp(ctx, slot, key, totp, touch, force, generate): "-T", "--totp", is_flag=True, - help="Generate a TOTP code, use the current time if challenge is omitted.", + 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).", + 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): @@ -603,14 +683,19 @@ def calculate(ctx, slot, challenge, totp, digits): 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. """ - session = ctx.obj["session"] + 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): - cli_fail("Cannot perform challenge-response on an empty slot.") + raise CliFail("Cannot perform challenge-response on an empty slot.") if totp: # Challenge omitted or timestamp if challenge is None: @@ -618,8 +703,8 @@ def calculate(ctx, slot, challenge, totp, digits): else: try: challenge = time_challenge(int(challenge)) - except Exception as e: - logger.error("Error", exc_info=e) + 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) @@ -639,8 +724,19 @@ def on_keepalive(status): value = response.hex() click.echo(value) - except CommandError as e: - _failed_to_write_msg(ctx, e) + 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() @@ -651,21 +747,60 @@ def on_keepalive(status): "--digits", type=click.Choice(["6", "8"]), default="6", - help="Number of digits in generated code (default is 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("-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.", + help="don't send an Enter keystroke after outputting the code", ) @click_force_option @click.pass_context -def hotp(ctx, slot, key, digits, counter, no_enter, force): +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 = ctx.obj["session"] + 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)") @@ -683,13 +818,14 @@ def hotp(ctx, slot, key, digits, counter, no_enter, force): 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 as e: - _failed_to_write_msg(ctx, e) + except CommandError: + raise CliFail(_WRITE_FAIL_MSG) @otp.command() @@ -701,17 +837,16 @@ def hotp(ctx, slot, key, digits, counter, no_enter, force): "--new-access-code", metavar="HEX", required=False, - help='Set a new 6 byte access code for the slot. Use "-" as a value to prompt for ' - "input.", + 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." + "--delete-access-code", is_flag=True, help="remove access code from the slot" ) @click.option( "--enter/--no-enter", default=True, show_default=True, - help="Should send 'Enter' keystroke after slot output.", + help="send an Enter keystroke after slot output", ) @click.option( "-p", @@ -719,14 +854,14 @@ def hotp(ctx, slot, key, digits, counter, no_enter, force): 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.", + 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 with some keyboard layouts. ", + help="use scancodes for numeric keypad when sending digits " + "(helps for some keyboard layouts)", ) def settings( ctx, @@ -744,20 +879,28 @@ def settings( Change the settings for a slot without changing the stored secret. All settings not specified will be written with default values. """ - session = ctx.obj["session"] + 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): - cli_fail("Not possible to update settings on an empty 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", show_default=False) + 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) @@ -786,5 +929,5 @@ def settings( new_access_code, ctx.obj["access_code"], ) - except CommandError as e: - _failed_to_write_msg(ctx, e) + except CommandError: + raise CliFail(_WRITE_FAIL_MSG) diff --git a/ykman/cli/piv.py b/ykman/_cli/piv.py similarity index 74% rename from ykman/cli/piv.py rename to ykman/_cli/piv.py index 2884b772f..839e38f03 100644 --- a/ykman/cli/piv.py +++ b/ykman/_cli/piv.py @@ -61,8 +61,8 @@ generate_csr, ) from .util import ( - ykman_group, - cli_fail, + CliFail, + click_group, click_force_option, click_format_option, click_postpone_execution, @@ -70,9 +70,11 @@ 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 @@ -134,34 +136,38 @@ def click_parse_hash(ctx, param, val): click_management_key_option = click.option( "-m", "--management-key", - help="The management key.", + help="the management key", callback=click_parse_management_key, ) -click_pin_option = click.option("-P", "--pin", help="PIN code.") +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.", + 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.", + help="touch policy for slot", ) click_hash_option = click.option( "-a", "--hash-algorithm", - type=click.Choice(["SHA1", "SHA256", "SHA384", "SHA512"], case_sensitive=False), + type=click.Choice(["SHA256", "SHA384", "SHA512"], case_sensitive=False), default="SHA256", show_default=True, - help="Hash algorithm.", + help="hash algorithm", callback=click_parse_hash, ) -@ykman_group(SmartCardConnection) +def _fname(fobj): + return getattr(fobj, "name", fobj) + + +@click_group(connections=[SmartCardConnection]) @click.pass_context @click_postpone_execution def piv(ctx): @@ -184,7 +190,11 @@ def piv(ctx): Reset all PIV data and restore default settings: $ ykman piv reset """ - session = PivSession(ctx.obj["conn"]) + + 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) @@ -195,27 +205,30 @@ def info(ctx): """ Display general status of the PIV application. """ - click.echo(get_piv_info(ctx.obj["session"])) + info = get_piv_info(ctx.obj["session"]) + click.echo("\n".join(pretty_print(info))) @piv.command() @click.pass_context -@click.confirmation_option( - "-f", - "--force", - prompt="WARNING! This will delete all stored PIV data and restore factory settings." - " Proceed?", -) -def reset(ctx): +@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") @@ -238,6 +251,7 @@ def access(): 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"] @@ -256,15 +270,14 @@ def set_pin_retries(ctx, management_key, pin, pin_retries, puk_retries, force): click.echo("Default PINs are set:") click.echo("\tPIN:\t123456") click.echo("\tPUK:\t12345678") - except Exception as e: - logger.error("Failed to set PIN retries", exc_info=e) - cli_fail("Setting pin retries failed.") + 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.") +@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. @@ -299,19 +312,15 @@ def change_pin(ctx, pin, new_pin): except InvalidPinError as e: attempts = e.attempts_remaining if attempts: - logger.debug( - "Failed to change the PIN, %d tries left", attempts, exc_info=e - ) - cli_fail("PIN change failed - %d tries left." % attempts) + raise CliFail("PIN change failed - %d tries left." % attempts) else: - logger.debug("PIN is blocked.", exc_info=e) - cli_fail("PIN is blocked.") + 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.") +@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. @@ -344,11 +353,9 @@ def change_puk(ctx, puk, new_puk): except InvalidPinError as e: attempts = e.attempts_remaining if attempts: - logger.debug("Failed to change PUK, %d tries left", attempts, exc_info=e) - cli_fail("PUK change failed - %d tries left." % attempts) + raise CliFail("PUK change failed - %d tries left." % attempts) else: - logger.debug("PUK is blocked.", exc_info=e) - cli_fail("PUK is blocked.") + raise CliFail("PUK is blocked.") @access.command("change-management-key") @@ -358,24 +365,24 @@ def change_puk(ctx, puk, new_puk): "-t", "--touch", is_flag=True, - help="Require touch on YubiKey when prompted for management key.", + help="require touch on YubiKey when prompted for management key", ) @click.option( "-n", "--new-management-key", - help="A new management key.", + help="a new management key to set", callback=click_parse_management_key, ) @click.option( "-m", "--management-key", - help="Current management key.", + help="current management key", callback=click_parse_management_key, ) @click.option( "-a", "--algorithm", - help="Management key algorithm.", + help="management key algorithm", type=EnumChoice(MANAGEMENT_KEY_TYPE), default=MANAGEMENT_KEY_TYPE.TDES.name, show_default=True, @@ -384,16 +391,16 @@ def change_puk(ctx, puk, new_puk): "-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.", + 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. " - "Conflicts with --new-management-key.", + 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( @@ -432,7 +439,7 @@ def change_management_key( # Touch not supported on NEO. if touch and session.version < (4, 0, 0): - cli_fail("Require touch not supported on this YubiKey.") + 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: @@ -470,7 +477,7 @@ def change_management_key( ctx.fail("New management key has the wrong format.") if len(new_management_key) != algorithm.key_len: - cli_fail( + raise CliFail( "Management key has the wrong length (expected %d bytes)" % algorithm.key_len ) @@ -479,9 +486,8 @@ def change_management_key( pivman_set_mgm_key( session, new_management_key, algorithm, touch=touch, store_on_device=protect ) - except ApduError as e: - logger.error("Failed to change management key", exc_info=e) - cli_fail("Changing the management key failed.") + except ApduError: + raise CliFail("Changing the management key failed.") @access.command("unblock-pin") @@ -505,11 +511,9 @@ def unblock_pin(ctx, puk, new_pin): except InvalidPinError as e: attempts = e.attempts_remaining if attempts: - logger.debug("Failed to unblock PIN, %d tries left", attempts, exc_info=e) - cli_fail("PIN unblock failed - %d tries left." % attempts) + raise CliFail("PIN unblock failed - %d tries left." % attempts) else: - logger.debug("PUK is blocked.", exc_info=e) - cli_fail("PUK is blocked.") + raise CliFail("PUK is blocked.") @piv.group() @@ -526,7 +530,7 @@ def keys(): @click.option( "-a", "--algorithm", - help="Algorithm to use in key generation.", + help="algorithm to use in key generation", type=EnumChoice(KEY_TYPE), default=KEY_TYPE.RSA2048.name, show_default=True, @@ -553,8 +557,8 @@ def generate_key( 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. + SLOT PIV slot of the private key + PUBLIC-KEY file containing the generated public key (use '-' to use stdout) """ session = ctx.obj["session"] @@ -569,6 +573,10 @@ def generate_key( 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") @@ -579,7 +587,7 @@ def generate_key( @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.") +@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 ): @@ -589,8 +597,8 @@ def import_key( 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. + SLOT PIV slot of the private key + PRIVATE-KEY file containing the private key (use '-' to use stdin) """ session = ctx.obj["session"] @@ -601,8 +609,8 @@ def import_key( password = password.encode() try: private_key = parse_private_key(data, password) - except InvalidPasswordError as e: - logger.error("Error parsing key", exc_info=e) + except InvalidPasswordError: + logger.debug("Error parsing key", exc_info=True) if password is None: password = click_prompt( "Enter password to decrypt key", @@ -634,16 +642,50 @@ def attest(ctx, slot, certificate, format): 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. + 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 as e: - logger.error("Attestation failed", exc_info=e) - cli_fail("Attestation failed.") + 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() @@ -654,9 +696,9 @@ def attest(ctx, slot, certificate, format): "-v", "--verify", is_flag=True, - help="Verify that the public key matches the private key in the slot.", + help="verify that the public key matches the private key in the slot", ) -@click.option("-P", "--pin", help="PIN code (used for --verify).") +@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): """ @@ -672,17 +714,17 @@ def export(ctx, slot, public_key_output, format, verify, pin): require the PIN to be provided. \b - SLOT PIV slot of the private key. - PUBLIC-KEY File containing the generated public key. Use '-' to use stdout. + 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: - cli_fail(f"No key stored in slot {slot.name}.") + 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() @@ -694,16 +736,16 @@ def export(ctx, slot, public_key_output, format, verify, pin): if verify: # Only needed when read from certificate def do_verify(): - with prompt_timeout(): + with prompt_timeout(timeout=1.0): if not check_key(session, slot, public_key): - cli_fail( + raise CliFail( "This public key is not tied to the private key in " - f"the {slot.name} slot." + f"slot {slot}." ) _verify_pin_if_needed(ctx, session, do_verify, pin) except ApduError: - cli_fail(f"Unable to export public key from slot {slot.name}") + raise CliFail(f"Unable to export public key from slot {slot}.") key_encoding = format public_key_output.write( @@ -712,6 +754,7 @@ def do_verify(): format=serialization.PublicFormat.SubjectPublicKeyInfo, ) ) + logger.info(f"Public key for slot {slot} written to {_fname(public_key_output)}") @piv.group("certificates") @@ -725,24 +768,29 @@ def cert(): @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("-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.", + 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): +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. + SLOT PIV slot of the certificate + CERTIFICATE file containing the certificate (use '-' to use stdin) """ session = ctx.obj["session"] @@ -753,8 +801,8 @@ def import_certificate(ctx, management_key, pin, slot, cert, password, verify): password = password.encode() try: certs = parse_certificates(data, password) - except InvalidPasswordError as e: - logger.error("Error parsing certificate", exc_info=e) + except InvalidPasswordError: + logger.debug("Error parsing certificate", exc_info=True) if password is None: password = click_prompt( "Enter password to decrypt certificate", @@ -780,18 +828,36 @@ def import_certificate(ctx, management_key, pin, slot, cert, password, verify): _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(): - if not check_key(session, slot, cert_to_import.public_key()): - cli_fail( - "This certificate is not tied to the private key in the " - f"{slot.name} slot." + 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) + session.put_certificate(slot, cert_to_import, compress) session.put_object(OBJECT_ID.CHUID, generate_chuid()) @@ -807,18 +873,19 @@ def export_certificate(ctx, format, slot, 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. + 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: - cli_fail("No certificate found.") + raise CliFail("No certificate found.") else: - logger.error("Failed to read certificate from slot %s", slot, exc_info=e) - certificate.write(cert.public_bytes(encoding=format)) + raise CliFail("Failed reading certificate.") @cert.command("generate") @@ -830,13 +897,13 @@ def export_certificate(ctx, format, slot, certificate): @click.option( "-s", "--subject", - help="Subject for the certificate, as an RFC 4514 string.", + 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.", + help="number of days until the certificate expires", type=click.INT, default=365, show_default=True, @@ -852,11 +919,22 @@ def generate_certificate( 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. + SLOT PIV slot of the certificate + PUBLIC-KEY file containing a public key (use '-' to use stdin) """ session = ctx.obj["session"] - _ensure_authenticated(ctx, pin, management_key, require_pin_and_key=True) + + 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()) @@ -868,16 +946,18 @@ def generate_certificate( # 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(): + 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 as e: - logger.error("Failed to generate certificate for slot %s", slot, exc_info=e) - cli_fail("Certificate generation failed.") + session.put_certificate(slot, cert) + session.put_object(OBJECT_ID.CHUID, generate_chuid()) + except ApduError: + raise CliFail("Certificate generation failed.") @cert.command("request") @@ -889,7 +969,7 @@ def generate_certificate( @click.option( "-s", "--subject", - help="Subject for the requested certificate, as an RFC 4514 string.", + help="subject for the requested certificate, as an RFC 4514 string", required=True, ) @click_hash_option @@ -902,13 +982,12 @@ def generate_certificate_signing_request( 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. + 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"] - _verify_pin(ctx, session, pivman, pin) data = public_key.read() public_key = serialization.load_pem_public_key(data, default_backend()) @@ -918,12 +997,28 @@ def generate_certificate_signing_request( subject = "CN=" + subject try: - with prompt_timeout(): + 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: - cli_fail("Certificate Signing Request generation failed.") + 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") @@ -938,7 +1033,7 @@ def delete_certificate(ctx, management_key, pin, slot): Delete a certificate from a PIV slot on the YubiKey. \b - SLOT PIV slot of the certificate. + SLOT PIV slot of the certificate """ session = ctx.obj["session"] _ensure_authenticated(ctx, pin, management_key) @@ -977,8 +1072,8 @@ 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. + OBJECT name of PIV data object, or ID in HEX + OUTPUT file to write object to (use '-' to use stdout) """ session = ctx.obj["session"] @@ -987,10 +1082,11 @@ def read_object(ctx, pin, object_id, output): 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: - cli_fail("No data found.") - elif e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED: + 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: @@ -1014,23 +1110,19 @@ def write_object(ctx, pin, management_key, object_id, data): 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. + 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) - def do_write_object(): - try: - session.put_object(object_id, data.read()) - except ApduError as e: - logger.debug("Failed writing object", exc_info=e) - if e.sw == SW.INCORRECT_PARAMETERS: - cli_fail("Something went wrong, is the object id valid?") - raise - - do_write_object() + 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") @@ -1043,12 +1135,12 @@ def generate_object(ctx, pin, management_key, object_id): Generate and write data for a supported data object. \b - OBJECT Name of PIV data object, or ID in HEX. - - \b - Supported data objects are: + 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"] @@ -1070,7 +1162,7 @@ def _prompt_management_key(prompt="Enter a management key [blank to use default try: return bytes.fromhex(management_key) except Exception: - cli_fail("Management key has the wrong format.") + raise CliFail("Management key has the wrong format.") def _prompt_pin(prompt="Enter PIN"): @@ -1107,7 +1199,7 @@ def _ensure_authenticated( def _verify_pin(ctx, session, pivman, pin, no_prompt=False): if not pin: if no_prompt: - cli_fail("PIN required.") + raise CliFail("PIN required.") else: pin = _prompt_pin() @@ -1131,11 +1223,11 @@ def _verify_pin(ctx, session, pivman, pin, no_prompt=False): except InvalidPinError as e: attempts = e.attempts_remaining if attempts > 0: - cli_fail(f"PIN verification failed, {attempts} tries left.") + raise CliFail(f"PIN verification failed, {attempts} tries left.") else: - cli_fail("PIN is blocked.") + raise CliFail("PIN is blocked.") except Exception: - cli_fail("PIN verification failed.") + raise CliFail("PIN verification failed.") def _verify_pin_if_needed(ctx, session, func, pin=None, no_prompt=False): @@ -1143,6 +1235,7 @@ def _verify_pin_if_needed(ctx, session, func, pin=None, no_prompt=False): 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: @@ -1167,6 +1260,5 @@ def _authenticate(ctx, session, management_key, mgm_key_prompt, no_prompt=False) with prompt_timeout(): session.authenticate(key_type, management_key) - except Exception as e: - logger.error("Authentication with management key failed.", exc_info=e) - cli_fail("Authentication with management key failed.") + except Exception: + raise CliFail("Authentication with management key failed.") diff --git a/ykman/_cli/script.py b/ykman/_cli/script.py new file mode 100644 index 000000000..79c53c8e0 --- /dev/null +++ b/ykman/_cli/script.py @@ -0,0 +1,105 @@ +# 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 diff --git a/ykman/cli/util.py b/ykman/_cli/util.py similarity index 60% rename from ykman/cli/util.py rename to ykman/_cli/util.py index e61b63f6e..e7aef8504 100644 --- a/ykman/cli/util.py +++ b/ykman/_cli/util.py @@ -28,51 +28,48 @@ import functools import click import sys -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.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 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. - """ +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 - def __init__(self, choices_enum, hidden=[]): - super().__init__( - [v.name.replace("_", "-") for v in choices_enum if v not in hidden], - case_sensitive=False, - ) - self.choices_enum = choices_enum + super().__init__(*args, **kwargs) - def convert(self, value, param, ctx): - if isinstance(value, self.choices_enum): - return value - name = super().convert(value, param, ctx).replace("-", "_") - return self.choices_enum[name] + 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(".") - -class _YkmanCommand(click.Command): - def __init__(self, name=None, **attrs): - self.interfaces = attrs.pop("interfaces", None) - click.Command.__init__(self, name, **attrs) + def get_help_option(self, ctx): + option = super().get_help_option(ctx) + option.help = "show this message and exit" + return option -class _YkmanGroup(click.Group): - """click.Group which returns commands before subgroups in list_commands.""" +class _YkmanGroup(_YkmanCommand, click.Group): + command_class = _YkmanCommand - def __init__(self, name=None, commands=None, **attrs): - self.connections = attrs.pop("connections", None) - click.Group.__init__(self, name, commands, **attrs) + 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( @@ -80,26 +77,62 @@ def list_commands(self, ctx): ) -def ykman_group( - connections=[SmartCardConnection, OtpConnection, FidoConnection], *args, **kwargs -): - if not isinstance(connections, list): - connections = [connections] # Single type +_YkmanGroup.group_class = _YkmanGroup + + +def click_group(*args, connections=None, **kwargs): return click.group( - cls=_YkmanGroup, *args, + cls=_YkmanGroup, connections=connections, **kwargs, - ) # type: ignore + ) -def ykman_command(interfaces, *args, **kwargs): +def click_command(*args, connections=None, **kwargs): return click.command( - cls=_YkmanCommand, *args, - interfaces=interfaces, + cls=_YkmanCommand, + connections=connections, **kwargs, - ) # type: ignore + ) + + +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): @@ -129,7 +162,7 @@ def click_parse_format(ctx, param, val): click_force_option = click.option( - "-f", "--force", is_flag=True, help="Confirm the action without prompting." + "-f", "--force", is_flag=True, help="confirm the action without prompting" ) @@ -139,7 +172,7 @@ def click_parse_format(ctx, param, val): type=click.Choice(["PEM", "DER"], case_sensitive=False), default="PEM", show_default=True, - help="Encoding format.", + help="encoding format", callback=click_parse_format, ) @@ -198,16 +231,21 @@ def click_prompt(prompt, err=True, **kwargs): 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: @@ -224,6 +262,49 @@ def prompt_timeout(timeout=0.5): timer.cancel() -def cli_fail(message: str, code: int = 1): - click.echo(f"Error: {message}", err=True) - sys.exit(code) +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 diff --git a/ykman/base.py b/ykman/base.py index b5b4b70ad..64753c8b2 100644 --- a/ykman/base.py +++ b/ykman/base.py @@ -25,58 +25,10 @@ # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -from yubikit.core import TRANSPORT, YubiKeyDevice -from yubikit.management import USB_INTERFACE -from enum import Enum, IntEnum, unique +from yubikit.core import TRANSPORT, PID, YubiKeyDevice from typing import Optional, Hashable -@unique -class YUBIKEY(Enum): - """YubiKey hardware platforms.""" - - YKS = "YubiKey Standard" - NEO = "YubiKey NEO" - SKY = "Security Key by Yubico" - YKP = "YubiKey Plus" - YK4 = "YubiKey 4" # This includes YubiKey 5 - - def get_pid(self, interfaces: USB_INTERFACE) -> "PID": - suffix = "_".join( - t.name for t in USB_INTERFACE if t in USB_INTERFACE(interfaces) - ) - return PID[self.name + "_" + suffix] - - -@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 - - def get_type(self) -> YUBIKEY: - return YUBIKEY[self.name.split("_", 1)[0]] - - def get_interfaces(self) -> USB_INTERFACE: - return USB_INTERFACE(sum(USB_INTERFACE[x] for x in self.name.split("_")[1:])) - - class YkmanDevice(YubiKeyDevice): """YubiKey device reference, with optional PID""" diff --git a/ykman/cli/openpgp.py b/ykman/cli/openpgp.py deleted file mode 100644 index 578cfdeee..000000000 --- a/ykman/cli/openpgp.py +++ /dev/null @@ -1,416 +0,0 @@ -# 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 logging -import click -from ..util import parse_certificates, parse_private_key -from ..openpgp import OpenPgpController, KEY_SLOT, TOUCH_MODE, get_openpgp_info -from .util import ( - cli_fail, - click_force_option, - click_format_option, - click_postpone_execution, - click_prompt, - ykman_group, - EnumChoice, -) - -from yubikit.core.smartcard import ApduError, SW, SmartCardConnection - -logger = logging.getLogger(__name__) - - -def one_of(data): - def inner(ctx, param, key): - if key is not None: - return data[key] - - return inner - - -def get_or_fail(data): - def inner(key): - if key in data: - return data[key] - raise ValueError( - f"Invalid value: {key}. Must be one of: {', '.join(data.keys())}" - ) - - return inner - - -def int_in_range(minval, maxval): - def inner(val): - intval = int(val) - if minval <= intval <= maxval: - return intval - raise ValueError(f"Invalid value: {intval}. Must be in range {minval}-{maxval}") - - return inner - - -@ykman_group(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 - """ - ctx.obj["controller"] = OpenPgpController(ctx.obj["conn"]) - - -@openpgp.command() -@click.pass_context -def info(ctx): - """ - Display general status of the OpenPGP application. - """ - controller = ctx.obj["controller"] - click.echo(get_openpgp_info(controller)) - - -@openpgp.command() -@click.confirmation_option( - "-f", - "--force", - prompt="WARNING! This will delete " - "all stored OpenPGP keys and data and restore " - "factory settings?", -) -@click.pass_context -def reset(ctx): - """ - Reset all OpenPGP data. - - This action will wipe all OpenPGP data, and set all PINs to their default - values. - """ - click.echo("Resetting OpenPGP data, don't remove the YubiKey...") - ctx.obj["controller"].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("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, pin_retries, reset_code_retries, admin_pin_retries, force -): - """ - Set PIN, Reset Code and Admin PIN retries. - """ - controller = ctx.obj["controller"] - - if admin_pin is None: - admin_pin = click_prompt("Enter Admin PIN", hide_input=True) - - resets_pins = controller.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: {pin_retries} {reset_code_retries} " - f"{admin_pin_retries}?", - abort=True, - err=True, - ): - - controller.verify_admin(admin_pin) - controller.set_pin_retries(pin_retries, reset_code_retries, admin_pin_retries) - - if resets_pins: - click.echo("Default PINs are set.") - echo_default_pins() - - -@openpgp.group("keys") -def keys(): - """Manage private keys.""" - - -@keys.command("set-touch") -@click.argument("key", metavar="KEY", type=EnumChoice(KEY_SLOT)) -@click.argument("policy", metavar="POLICY", type=EnumChoice(TOUCH_MODE)) -@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 touch policy for OpenPGP keys. - - \b - KEY Key slot to set (sig, enc, aut or att). - POLICY Touch policy to set (on, off, fixed, cached or cached-fixed). - - 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 - - \b - $ ykman openpgp info - - 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 - """ - controller = ctx.obj["controller"] - - policy_name = policy.name.lower().replace("_", "-") - - if policy not in controller.supported_touch_policies: - cli_fail(f"Touch policy {policy_name} not supported by this YubiKey.") - - if key == KEY_SLOT.ATT and not controller.supports_attestation: - cli_fail("Attestation is not supported by this YubiKey.") - - if admin_pin is None: - admin_pin = click_prompt("Enter Admin PIN", hide_input=True) - - prompt = f"Set touch policy of {key.value.lower()} 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: - controller.verify_admin(admin_pin) - controller.set_touch(key, policy) - except ApduError as e: - if e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED: - cli_fail("Touch policy not allowed.") - logger.debug("Failed to set touch policy", exc_info=e) - cli_fail("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_SLOT)) -@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. - """ - controller = ctx.obj["controller"] - - if key != KEY_SLOT.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 as e: - logger.debug("Failed to parse", exc_info=e) - cli_fail("Failed to parse private key.") - try: - controller.verify_admin(admin_pin) - controller.import_key(key, private_key) - except Exception as e: - logger.debug("Failed to import", exc_info=e) - cli_fail("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_SLOT, hidden=[KEY_SLOT.ATT])) -@click.argument("certificate", type=click.File("wb"), metavar="CERTIFICATE") -def attest(ctx, key, certificate, pin, format): - """ - Generate a 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, enc, aut). - CERTIFICATE File to write attestation certificate to. Use '-' to use stdout. - """ - - controller = ctx.obj["controller"] - - if not pin: - pin = click_prompt("Enter PIN", default="", hide_input=True, show_default=False) - - try: - cert = controller.read_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 = controller.get_touch(KEY_SLOT.ATT) - if touch_policy in [TOUCH_MODE.ON, TOUCH_MODE.FIXED]: - click.echo("Touch the YubiKey sensor...") - try: - controller.verify_pin(pin) - cert = controller.attest(key) - certificate.write(cert.public_bytes(encoding=format)) - except Exception as e: - logger.debug("Failed to attest", exc_info=e) - cli_fail("Attestation failed") - - -@openpgp.group("certificates") -def certificates(): - """ - Manage certificates. - """ - - -@certificates.command("export") -@click.pass_context -@click.argument("key", metavar="KEY", type=EnumChoice(KEY_SLOT)) -@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, enc, aut, or att). - CERTIFICATE File to write certificate to. Use '-' to use stdout. - """ - controller = ctx.obj["controller"] - - if controller.version < (5, 2, 0) and key != KEY_SLOT.AUT: - cli_fail(f"Certificate slot {key.name} requires YubiKey 5.2.0 or later.") - - try: - cert = controller.read_certificate(key) - except ValueError: - cli_fail(f"Failed to read certificate from {key.name}") - certificate.write(cert.public_bytes(encoding=format)) - - -@certificates.command("delete") -@click.option("-a", "--admin-pin", help="Admin PIN for OpenPGP.") -@click.pass_context -@click.argument("key", metavar="KEY", type=EnumChoice(KEY_SLOT)) -def delete_certificate(ctx, key, admin_pin): - """ - Delete an OpenPGP certificate. - - \b - KEY Key slot to delete certificate from (sig, enc, aut, or att). - """ - controller = ctx.obj["controller"] - - if controller.version < (5, 2, 0) and key != KEY_SLOT.AUT: - cli_fail(f"Certificate slot {key.name} requires YubiKey 5.2.0 or later.") - - if admin_pin is None: - admin_pin = click_prompt("Enter Admin PIN", hide_input=True) - try: - controller.verify_admin(admin_pin) - controller.delete_certificate(key) - except Exception as e: - logger.debug("Failed to delete ", exc_info=e) - cli_fail("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_SLOT)) -@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, enc, aut, or att). - CERTIFICATE File containing the certificate. Use '-' to use stdin. - """ - controller = ctx.obj["controller"] - - if controller.version < (5, 2, 0) and key != KEY_SLOT.AUT: - cli_fail(f"Certificate slot {key.name} requires YubiKey 5.2.0 or later.") - - 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 as e: - logger.debug("Failed to parse", exc_info=e) - cli_fail("Failed to parse certificate.") - if len(certs) != 1: - cli_fail("Can only import one certificate.") - try: - controller.verify_admin(admin_pin) - controller.import_certificate(key, certs[0]) - except Exception as e: - logger.debug("Failed to import", exc_info=e) - cli_fail("Failed to import certificate") diff --git a/ykman/device.py b/ykman/device.py index 149a8591f..9935d0cde 100644 --- a/ykman/device.py +++ b/ykman/device.py @@ -25,31 +25,16 @@ # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -from yubikit.core import ( - AID, - TRANSPORT, - Version, - Connection, - NotSupportedError, - ApplicationNotAvailableError, -) -from yubikit.core.otp import OtpConnection, CommandRejectedError +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, - SmartCardProtocol, -) +from yubikit.core.smartcard import SmartCardConnection from yubikit.management import ( - ManagementSession, DeviceInfo, - DeviceConfig, USB_INTERFACE, - CAPABILITY, - FORM_FACTOR, - DEVICE_FLAG, ) -from yubikit.yubiotp import YubiOtpSession -from .base import PID, YUBIKEY, YkmanDevice +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, @@ -58,9 +43,18 @@ from smartcard.pcsc.PCSCExceptions import EstablishContextException from smartcard.Exceptions import NoCardException -from time import sleep +from time import sleep, time from collections import Counter -from typing import Dict, Mapping, List, Tuple, Optional, Iterable, Type +from typing import ( + Dict, + Mapping, + List, + Tuple, + Iterable, + Type, + Hashable, + Set, +) import sys import ctypes import logging @@ -68,14 +62,6 @@ logger = logging.getLogger(__name__) -class ConnectionNotAvailableException(ValueError): - def __init__(self, connection_types): - super().__init__( - f"No eligiable connections are available ({connection_types})." - ) - self.connection_types = connection_types - - def _warn_once(message, e_type=Exception): warned: List[bool] = [] @@ -85,7 +71,7 @@ def inner(): return f() except e_type: if not warned: - print("WARNING:", message, file=sys.stderr) + logger.warning(message) warned.append(True) raise @@ -95,31 +81,27 @@ def inner(): @_warn_once( - "PC/SC not available. Smart card protocols will not function.", + "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() -def is_fips_version(version: Version) -> bool: - """True if a given firmware version indicates a YubiKey (4) FIPS""" - return (4, 4, 0) <= version < (4, 5, 0) - - -BASE_NEO_APPS = CAPABILITY.OTP | CAPABILITY.OATH | CAPABILITY.PIV | CAPABILITY.OPENPGP - -CONNECTION_LIST_MAPPING = { +_CONNECTION_LIST_MAPPING = { SmartCardConnection: list_ccid_devices, OtpConnection: list_otp_devices, FidoConnection: list_ctap_devices, @@ -129,23 +111,23 @@ def is_fips_version(version: Version) -> bool: def scan_devices() -> Tuple[Mapping[PID, int], int]: """Scan USB for attached YubiKeys, without opening any connections. - Returns a dict mapping PID to device count, and a state object which can be used to - detect changes in attached devices. + :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(): + for list_devs in _CONNECTION_LIST_MAPPING.values(): try: devs = list_devs() - except Exception as e: - logger.error("Unable to list devices for connection", exc_info=e) + 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() + counter: Counter[PID] = Counter() for pid, path in list_paths(): if pid not in merged: try: @@ -157,428 +139,161 @@ def scan_devices() -> Tuple[Mapping[PID, int], int]: return merged, hash(tuple(fingerprints)) -def list_all_devices() -> List[Tuple[YkmanDevice, DeviceInfo]]: - """Connects to all attached YubiKeys and reads device info from them. - - Returns a list of (device, info) tuples for each connected device. - """ - handled_pids = set() - pids: Dict[PID, bool] = {} - devices = [] +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, + ) - for connection_type, list_devs in CONNECTION_LIST_MAPPING.items(): + 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: - devs = list_devs() - except Exception as e: - logger.error("Unable to list devices for connection", exc_info=e) - devs = [] - - for dev in devs: - if dev.pid not in handled_pids and pids.get(dev.pid, True): + while devs: + dev = devs.pop() try: - with dev.open_connection(connection_type) as conn: - info = read_info(dev.pid, conn) - pids[dev.pid] = True - devices.append((dev, info)) - except Exception as e: - pids[dev.pid] = False - logger.error("Failed opening device", exc_info=e) - handled_pids.update({pid for pid, handled in pids.items() if handled}) - - return devices - - -def connect_to_device( - serial: Optional[int] = None, - connection_types: Iterable[Type[Connection]] = CONNECTION_LIST_MAPPING.keys(), -) -> Tuple[Connection, YkmanDevice, DeviceInfo]: - """Looks for a YubiKey to connect to. - - :param serial: Used to filter devices by serial number, if present. - :param connection_types: Filter connection types. - :return: An open connection to the device, the device reference, and the device - information read from the device. - """ - failed_connections = set() - retry_ccid = [] - for connection_type in connection_types: - try: - devs = CONNECTION_LIST_MAPPING[connection_type]() - except Exception as e: - logger.error( - f"Error listing connection of type {connection_type}", exc_info=e + 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) ) - failed_connections.add(connection_type) - continue - - for dev in devs: - try: - conn = dev.open_connection(connection_type) - except NoCardException: - retry_ccid.append(dev) - logger.debug("CCID No card present, will retry") - continue - info = read_info(dev.pid, conn) - if serial and info.serial != serial: - conn.close() - else: - return conn, dev, info - - if set(connection_types) == failed_connections: - raise ConnectionNotAvailableException(connection_types) - - # NEO ejects the card when other interfaces are used, and returns it after ~3s. - for _ in range(6): - if not retry_ccid: - break - sleep(0.5) - for dev in retry_ccid[:]: - try: - conn = dev.open_connection(SmartCardConnection) - except NoCardException: - continue - retry_ccid.remove(dev) - info = read_info(dev.pid, conn) - if serial and info.serial != serial: - conn.close() - else: - return conn, dev, info - - if serial: - raise ValueError("YubiKey with given serial not found") - raise ValueError("No YubiKey found with the given interface(s)") - - -def _otp_read_data(conn) -> Tuple[Version, Optional[int]]: - otp = YubiOtpSession(conn) - version = otp.version - serial: Optional[int] = None - try: - serial = otp.get_serial() - except Exception as e: - logger.debug("Unable to read serial over OTP, no serial", exc_info=e) - return version, serial - - -AID_U2F_YUBICO = b"\xa0\x00\x00\x05\x27\x10\x02" # Old U2F AID - -SCAN_APPLETS = { - # AID.OTP: CAPABILITY.OTP, # NB: OTP will be checked elsewhere - AID.FIDO: CAPABILITY.U2F, - AID_U2F_YUBICO: CAPABILITY.U2F, - AID.PIV: CAPABILITY.PIV, - AID.OPENPGP: CAPABILITY.OPENPGP, - AID.OATH: CAPABILITY.OATH, -} + return results -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("Unable to select Management application, use fallback.") - - # Synthesize data - capabilities = CAPABILITY(0) - - # Try to read serial (and version if needed) from OTP application - try: - otp_version, serial = _otp_read_data(conn) - capabilities |= CAPABILITY.OTP - if version is None: - version = otp_version - except ApplicationNotAvailableError: - logger.debug("Unable to select OTP application") - serial = None - - if version is None: - version = Version(3, 0, 0) # Guess, no way to know - - # Scan for remaining capabilities - protocol = SmartCardProtocol(conn) - for aid, code in SCAN_APPLETS.items(): - try: - logger.debug("Check for %s", code) - 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 as e: - logger.error( - "Error selecting aid: %s, capability: %s", - aid, - code, - exc_info=e, - ) +class _UsbCompositeDevice(YkmanDevice): + def __init__(self, group, key, fingerprint, pid): + super().__init__(TRANSPORT.USB, fingerprint, pid) + self._group = group + self._key = key - # 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: - 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 - # 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 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") -def read_info(pid: Optional[PID], conn: Connection) -> DeviceInfo: - """Read out a DeviceInfo object from a YubiKey, or attempt to synthesize one.""" - if pid: - key_type: Optional[YUBIKEY] = pid.get_type() - interfaces = pid.get_interfaces() - else: # No PID for NFC connections - key_type = None - interfaces = USB_INTERFACE(0) - - 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 - usb_enabled = CAPABILITY.OTP | CAPABILITY.U2F - info.supported_capabilities = {TRANSPORT.USB: usb_enabled} - - 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 - - # YK4-based FIPS version - if is_fips_version(info.version): - 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) + # 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 ): - # Known not to have NFC - info.supported_capabilities.pop(TRANSPORT.NFC, None) - info.config.enabled_capabilities.pop(TRANSPORT.NFC, None) - - return info - + for _ in range(6): + try: + return self._group.connect(self._key, connection_type) + except (NoCardException, ValueError): + sleep(0.5) -def _fido_only(capabilities): - return capabilities & ~(CAPABILITY.U2F | CAPABILITY.FIDO2) == 0 + return self._group.connect(self._key, connection_type) -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 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] = {} -def get_name(info: DeviceInfo, key_type: Optional[YUBIKEY]) -> str: - """Determine the product name of a YubiKey""" - usb_supported = info.supported_capabilities[TRANSPORT.USB] - if not key_type: - if info.serial is None and _fido_only(usb_supported): - key_type = YUBIKEY.SKY - elif info.version[0] == 3: - key_type = YUBIKEY.NEO + 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: - key_type = YUBIKEY.YK4 - - device_name = key_type.value - - 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: - if info.version[0] == 0: - return "Yubikey (%d.%d.%d)" % info.version - if _is_preview(info.version): - device_name = "YubiKey Preview" - elif is_fips_version(info.version): # YK4 FIPS - device_name = "YubiKey FIPS" - elif usb_supported == CAPABILITY.OTP | CAPABILITY.U2F: - device_name = "YubiKey Edge" - elif info.version >= (5, 1, 0): - 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, - ) - - 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") - device_name = " ".join(name_parts).replace("5 C", "5C").replace("5 A", "5A") - - return device_name + 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 diff --git a/ykman/diagnostics.py b/ykman/diagnostics.py index 0f269dfd0..07b7aec0a 100644 --- a/ykman/diagnostics.py +++ b/ykman/diagnostics.py @@ -1,10 +1,10 @@ from . import __version__ as ykman_version -from .logging_setup import log_sys_info +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 .device import read_info, get_name from .piv import get_piv_info -from .openpgp import OpenPgpController, get_openpgp_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 @@ -13,173 +13,219 @@ 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): - lines = [] + data: List[Any] = [] try: - raw_info = ManagementSession(conn).backend.read_config() - lines.append(f"\tRawInfo: {raw_info.hex()}") + data.append( + { + "Raw Info": ManagementSession(conn).backend.read_config(), + } + ) except Exception as e: - lines.append(f"\tFailed to read device info via Management: {e!r}") + data.append(f"Failed to read device info via Management: {e!r}") + try: - info = read_info(pid, conn) - lines.append(f"\t{info}") - name = get_name(info, pid.get_type()) - lines.append(f"\tDevice name: {name}") + info = read_info(conn, pid) + data.append( + { + "DeviceInfo": asdict(info), + "Name": get_name(info, pid.yubikey_type), + } + ) except Exception as e: - lines.append(f"\tFailed to read device info: {e!r}") - return lines + data.append(f"Failed to read device info: {e!r}") + + return data def piv_info(conn): try: piv = PivSession(conn) - return ["\tPIV"] + [f"\t\t{ln}" for ln in get_piv_info(piv).splitlines() if ln] + return get_piv_info(piv) except Exception as e: - return [f"\tPIV not accessible {e!r}"] + return f"PIV not accessible {e!r}" def openpgp_info(conn): try: - openpgp = OpenPgpController(conn) - return ["\tOpenPGP"] + [ - f"\t\t{ln}" for ln in get_openpgp_info(openpgp).splitlines() if ln - ] + openpgp = OpenPgpSession(conn) + return get_openpgp_info(openpgp) except Exception as e: - return [f"\tOpenPGP not accessible {e!r}"] + return f"OpenPGP not accessible {e!r}" def oath_info(conn): try: oath = OathSession(conn) - return [ - "\tOATH", - f"\t\tOath version: {'.'.join('%d' % d for d in oath.version)}", - f"\t\tPassword protected: {oath.locked}", - ] + return { + "Oath version": ".".join("%d" % d for d in oath.version), + "Password protected": oath.locked, + } except Exception as e: - return [f"\tOATH not accessible {e!r}"] + 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(): - lines = [] try: - readers = list_readers() - lines.append("Detected PC/SC readers:") - for reader in readers: + readers = {} + for reader in list_readers(): try: c = reader.createConnection() c.connect() c.disconnect() result = "Success" except Exception as e: - result = e.__class__.__name__ - lines.append(f"\t{reader.name} (connect: {result})") - lines.append("") - except Exception as e: - return [ - f"PC/SC failure: {e!r}", - "", - ] + result = f"<{e.__class__.__name__}>" + readers[reader.name] = result - lines.append("Detected YubiKeys over PC/SC:") - try: + yubikeys: Dict[str, Any] = {} for dev in list_ccid_devices(): - lines.append(f"\t{dev!r}") try: with dev.open_connection(SmartCardConnection) as conn: - lines.extend(mgmt_info(dev.pid, conn)) - lines.extend(piv_info(conn)) - lines.extend(oath_info(conn)) - lines.extend(openpgp_info(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: - lines.append(f"\tPC/SC connection failure: {e!r}") - lines.append("") - except Exception as e: - return [ - f"PC/SC failure: {e!r}", - "", - ] + yubikeys[f"{dev!r}"] = f"PC/SC connection failure: {e!r}" - lines.append("") - return lines + 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(): - lines = [] - lines.append("Detected YubiKeys over HID OTP:") try: + yubikeys: Dict[str, Any] = {} for dev in list_otp_devices(): - lines.append(f"\t{dev!r}") try: + dev_info = [] with dev.open_connection(OtpConnection) as conn: - lines.extend(mgmt_info(dev.pid, conn)) + dev_info.append( + { + "Management": mgmt_info(dev.pid, conn), + } + ) otp = YubiOtpSession(conn) try: config = otp.get_config_state() - lines.append(f"\tOTP: {config!r}") + dev_info.append({"OTP": [f"{config}"]}) except ValueError as e: - lines.append(f"\tCouldn't read OTP state: {e!r}") + dev_info.append({"OTP": f"Couldn't read OTP state: {e!r}"}) + yubikeys[f"{dev!r}"] = dev_info except Exception as e: - lines.append(f"\tOTP connection failure: {e!r}") - lines.append("") + yubikeys[f"{dev!r}"] = f"OTP connection failure: {e!r}" + + return { + "Detected YubiKeys over HID OTP": yubikeys, + } except Exception as e: - lines.append(f"\tHID OTP backend failure: {e!r}") - lines.append("") - return lines + return f"HID OTP backend failure: {e!r}" def fido_info(): - lines = [] - lines.append("Detected YubiKeys over HID FIDO:") try: + yubikeys: Dict[str, Any] = {} for dev in list_ctap_devices(): - lines.append(f"\t{dev!r}") try: + dev_info: List[Any] = [] with dev.open_connection(FidoConnection) as conn: - lines.append("CTAP device version: %d.%d.%d" % conn.device_version) - lines.append(f"CTAPHID protocol version: {conn.version}") - lines.append("Capabilities: %d" % conn.capabilities) - lines.extend(mgmt_info(dev.pid, 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) - lines.append(f"\tCtap2Info: {ctap2.info.data!r}") + ctap_data: Dict[str, Any] = {"Ctap2Info": asdict(ctap2.info)} if ctap2.info.options.get("clientPin"): client_pin = ClientPin(ctap2) - lines.append(f"PIN retries: {client_pin.get_pin_retries()}") + ctap_data["PIN retries"] = client_pin.get_pin_retries() + bio_enroll = ctap2.info.options.get("bioEnroll") if bio_enroll: - lines.append( - "Fingerprint retries: " - f"{client_pin.get_uv_retries()}" - ) + ctap_data[ + "Fingerprint retries" + ] = client_pin.get_uv_retries() elif bio_enroll is False: - lines.append("Fingerprints: Not configured") + ctap_data["Fingerprints"] = "Not configured" else: - lines.append("PIN: Not configured") - + ctap_data["PIN"] = "Not configured" + dev_info.append(ctap_data) except (ValueError, CtapError) as e: - lines.append(f"\tCouldn't get info: {e!r}") + dev_info.append(f"Couldn't get CTAP2 info: {e!r}") + yubikeys[f"{dev!r}"] = dev_info except Exception as e: - lines.append(f"\tFIDO connection failure: {e!r}") - lines.append("") + yubikeys[f"{dev!r}"] = f"FIDO connection failure: {e!r}" + return { + "Detected YubiKeys over HID FIDO": yubikeys, + } + except Exception as e: - lines.append(f"\tHID FIDO backend failure: {e!r}") - return lines + return f"HID FIDO backend failure: {e!r}" def get_diagnostics(): - lines = [] - lines.append(f"ykman: {ykman_version}") - log_sys_info(lines.append) - lines.append("") - - lines.extend(ccid_info()) - lines.extend(otp_info()) - lines.extend(fido_info()) - lines.append("End of diagnostics") - - return "\n".join(lines) + """Runs diagnostics. + + The result of this can be printed using pretty_print. + """ + return [ + sys_info(), + ccid_info(), + otp_info(), + fido_info(), + "End of diagnostics", + ] diff --git a/ykman/fido.py b/ykman/fido.py index 31df4959a..17ae6f865 100644 --- a/ykman/fido.py +++ b/ykman/fido.py @@ -44,7 +44,10 @@ def is_in_fips_mode(fido_connection: FidoConnection) -> bool: - """Check if a YubiKey FIPS is in FIPS approved mode.""" + """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) @@ -62,6 +65,10 @@ def fips_change_pin( """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) @@ -75,7 +82,11 @@ def fips_change_pin( def fips_verify_pin(fido_connection: FidoConnection, pin: str): - """Unlock the YubiKey FIPS U2F module for credential creation.""" + """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()) @@ -86,6 +97,8 @@ def fips_reset(fido_connection: FidoConnection): 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: diff --git a/ykman/hid/__init__.py b/ykman/hid/__init__.py index cd9ae999c..a39ec4a1d 100644 --- a/ykman/hid/__init__.py +++ b/ykman/hid/__init__.py @@ -41,6 +41,8 @@ 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: @@ -84,7 +86,6 @@ def list_ctap_devices() -> List[CtapYubiKeyDevice]: logger.debug(f"Unsupported Yubico device with PID: {desc.pid:02x}") return devs - except Exception: # CTAP not supported on this platform diff --git a/ykman/hid/base.py b/ykman/hid/base.py index 7bdf4f15a..47b177816 100644 --- a/ykman/hid/base.py +++ b/ykman/hid/base.py @@ -25,8 +25,8 @@ # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -from ..base import YkmanDevice, PID -from yubikit.core import TRANSPORT +from yubikit.core import TRANSPORT, PID +from ..base import YkmanDevice YUBICO_VID = 0x1050 diff --git a/ykman/hid/freebsd.py b/ykman/hid/freebsd.py new file mode 100644 index 000000000..ca09e0b59 --- /dev/null +++ b/ykman/hid/freebsd.py @@ -0,0 +1,301 @@ +# 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) diff --git a/ykman/logging.py b/ykman/logging.py new file mode 100644 index 000000000..481da3e41 --- /dev/null +++ b/ykman/logging.py @@ -0,0 +1,76 @@ +# 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) diff --git a/ykman/logging_setup.py b/ykman/logging_setup.py index 2654c2e14..ad01147ed 100644 --- a/ykman/logging_setup.py +++ b/ykman/logging_setup.py @@ -27,6 +27,9 @@ 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 @@ -34,17 +37,11 @@ import os -LOG_LEVELS = [ - logging.DEBUG, - logging.INFO, - logging.WARNING, - logging.ERROR, - logging.CRITICAL, -] -LOG_LEVEL_NAMES = [logging.getLevelName(lvl) for lvl in LOG_LEVELS] +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()}") @@ -54,28 +51,11 @@ def log_sys_info(log): 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_value = next( - (lvl for lvl in LOG_LEVELS if logging.getLevelName(lvl) == log_level_name), None - ) + log_level = LOG_LEVEL[log_level_name.upper()] + init_logging(log_level, log_file=log_file) - if log_level_value is None: - raise ValueError("Unknown log level: " + log_level_name) - - logging.disable(logging.NOTSET) - logging.basicConfig( - datefmt="%Y-%m-%dT%H:%M:%S%z", - filename=log_file, - format="%(asctime)s %(levelname)s [%(name)s.%(funcName)s:%(lineno)d] %(message)s", # noqa: E501 - level=log_level_value, - ) - - logger = logging.getLogger(__name__) - logger.info("Initialized logging for level: %s", log_level_name) - logger.info("Running ykman version: %s", ykman_version) log_sys_info(logger.debug) - - -logging.disable(logging.CRITICAL * 2) diff --git a/ykman/oath.py b/ykman/oath.py index e357fe2ac..b989b4b95 100644 --- a/ykman/oath.py +++ b/ykman/oath.py @@ -25,23 +25,35 @@ # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -from yubikit.oath import OATH_TYPE +from 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): +def is_hidden(credential: Credential) -> bool: + """Check if OATH credential is hidden.""" return credential.issuer == "_hidden" -def is_steam(credential): +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, credential, timestamp=None): +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 @@ -53,5 +65,37 @@ def calculate_steam(app, credential, timestamp=None): return "".join(chars) -def is_in_fips_mode(app): +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 diff --git a/ykman/openpgp.py b/ykman/openpgp.py old mode 100755 new mode 100644 index 58546bc61..9f414ed06 --- a/ykman/openpgp.py +++ b/ykman/openpgp.py @@ -25,592 +25,35 @@ # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -from yubikit.core import ( - AID, - Tlv, - NotSupportedError, - require_version, - int2bytes, - bytes2int, -) -from yubikit.core.smartcard import SmartCardConnection, SmartCardProtocol, ApduError, SW +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, + } -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, - NoEncryption, -) -from cryptography.hazmat.primitives.asymmetric import rsa, ec - -from enum import Enum, IntEnum, unique -from dataclasses import dataclass -from typing import Optional, Tuple -import time -import struct -import logging - -from typing import NamedTuple - -logger = logging.getLogger(__name__) - - -class _KeySlot(NamedTuple): - value: str - indx: int - key_id: int - fingerprint: int - gen_time: int - uif: int # touch policy - crt: bytes # Control Reference Template - - -@unique -class KEY_SLOT(_KeySlot, Enum): # noqa: N801 - SIG = _KeySlot("SIGNATURE", 1, 0xC1, 0xC7, 0xCE, 0xD6, Tlv(0xB6)) - ENC = _KeySlot("ENCRYPTION", 2, 0xC2, 0xC8, 0xCF, 0xD7, Tlv(0xB8)) - AUT = _KeySlot("AUTHENTICATION", 3, 0xC3, 0xC9, 0xD0, 0xD8, Tlv(0xA4)) - ATT = _KeySlot( - "ATTESTATION", 4, 0xDA, 0xDB, 0xDD, 0xD9, Tlv(0xB6, Tlv(0x84, b"\x81")) - ) - - -@unique -class TOUCH_MODE(IntEnum): # noqa: N801 - OFF = 0x00 - ON = 0x01 - FIXED = 0x02 - CACHED = 0x03 - CACHED_FIXED = 0x04 - - @property - def is_fixed(self): - return "FIXED" in self.name - - def __str__(self): - if self == TOUCH_MODE.OFF: - return "Off" - elif self == TOUCH_MODE.ON: - return "On" - elif self == TOUCH_MODE.FIXED: - return "On (fixed)" - elif self == TOUCH_MODE.CACHED: - return "Cached" - elif self == TOUCH_MODE.CACHED_FIXED: - return "Cached (fixed)" - - -@unique -class INS(IntEnum): # noqa: N801 - GET_DATA = 0xCA - GET_VERSION = 0xF1 - SET_PIN_RETRIES = 0xF2 - VERIFY = 0x20 - TERMINATE = 0xE6 - ACTIVATE = 0x44 - GENERATE_ASYM = 0x47 - PUT_DATA = 0xDA - PUT_DATA_ODD = 0xDB - GET_ATTESTATION = 0xFB - SEND_REMAINING = 0xC0 - SELECT_DATA = 0xA5 - - -class PinRetries(NamedTuple): - pin: int - reset: int - admin: int - - -PW1 = 0x81 -PW3 = 0x83 -INVALID_PIN = b"\0" * 8 -TOUCH_METHOD_BUTTON = 0x20 - - -@unique -class DO(IntEnum): - AID = 0x4F - PW_STATUS = 0xC4 - CARDHOLDER_CERTIFICATE = 0x7F21 - ATT_CERTIFICATE = 0xFC - KDF = 0xF9 - - -@unique -class OID(bytes, Enum): - SECP256R1 = b"\x2a\x86\x48\xce\x3d\x03\x01\x07" - SECP256K1 = b"\x2b\x81\x04\x00\x0a" - SECP384R1 = b"\x2b\x81\x04\x00\x22" - SECP521R1 = b"\x2b\x81\x04\x00\x23" - BRAINPOOLP256R1 = b"\x2b\x24\x03\x03\x02\x08\x01\x01\x07" - BRAINPOOLP384R1 = b"\x2b\x24\x03\x03\x02\x08\x01\x01\x0b" - BRAINPOOLP512R1 = b"\x2b\x24\x03\x03\x02\x08\x01\x01\x0d" - X25519 = b"\x2b\x06\x01\x04\x01\x97\x55\x01\x05\x01" - ED25519 = b"\x2b\x06\x01\x04\x01\xda\x47\x0f\x01" - - @classmethod - def for_name(cls, name): - try: - return getattr(cls, name.upper()) - except AttributeError: - raise ValueError("Unsupported curve: " + name) - - -def _get_curve_name(key): - if isinstance(key, ec.EllipticCurvePrivateKey): - return key.curve.name - cls_name = key.__class__.__name__ - if "Ed25519" in cls_name: - return "ed25519" - if "X25519" in cls_name: - return "x25519" - raise ValueError("Unsupported private key") - - -def _format_rsa_attributes(key_size): - return struct.pack(">BHHB", 0x01, key_size, 32, 0) - - -def _format_ec_attributes(key_slot, curve_name): - if curve_name in ("ed25519", "x25519"): - algorithm = b"\x16" - elif key_slot == KEY_SLOT.ENC: - algorithm = b"\x12" - else: - algorithm = b"\x13" - return algorithm + OID.for_name(curve_name) - - -def _get_key_attributes(key, key_slot): - if isinstance(key, rsa.RSAPrivateKeyWithSerialization): - if key.private_numbers().public_numbers.e != 65537: - raise ValueError("RSA keys with e != 65537 are not supported!") - return _format_rsa_attributes(key.key_size) - curve_name = _get_curve_name(key) - return _format_ec_attributes(key_slot, curve_name) - - -def _get_key_template(key, key_slot, crt=False): - def _pack_tlvs(tlvs): - header = b"" - body = b"" - for tlv in tlvs: - header += tlv[: -tlv.length] - body += tlv.value - return Tlv(0x7F48, header) + Tlv(0x5F48, body) - - values: Tuple[Tlv, ...] - - if isinstance(key, rsa.RSAPrivateKeyWithSerialization): - rsa_numbers = key.private_numbers() - ln = (key.key_size // 8) // 2 - - e = Tlv(0x91, b"\x01\x00\x01") # e=65537 - p = Tlv(0x92, int2bytes(rsa_numbers.p, ln)) - q = Tlv(0x93, int2bytes(rsa_numbers.q, ln)) - values = (e, p, q) - if crt: - dp = Tlv(0x94, int2bytes(rsa_numbers.dmp1, ln)) - dq = Tlv(0x95, int2bytes(rsa_numbers.dmq1, ln)) - qinv = Tlv(0x96, int2bytes(rsa_numbers.iqmp, ln)) - n = Tlv(0x97, int2bytes(rsa_numbers.public_numbers.n, 2 * ln)) - values += (dp, dq, qinv, n) - - elif isinstance(key, ec.EllipticCurvePrivateKeyWithSerialization): - ec_numbers = key.private_numbers() - ln = key.key_size // 8 - - privkey = Tlv(0x92, int2bytes(ec_numbers.private_value, ln)) - values = (privkey,) - - elif _get_curve_name(key) in ("ed25519", "x25519"): - privkey = Tlv( - 0x92, key.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption()) - ) - values = (privkey,) - - return Tlv(0x4D, key_slot.crt + _pack_tlvs(values)) - - -@unique -class HashAlgorithm(IntEnum): - SHA256 = 0x08 - SHA512 = 0x0A - - def create_digest(self): - algorithm = getattr(hashes, self.name) - return hashes.Hash(algorithm(), default_backend()) - - -@unique -class KdfAlgorithm(IntEnum): - NONE = 0x00 - KDF_ITERSALTED_S2K = 0x03 - - -def _kdf_none(pin, salt, hash_algorithm, iteration_count): - return pin - - -def _kdf_itersalted_s2k(pin, salt, hash_algorithm, iteration_count): - data = salt + pin - digest = hash_algorithm.create_digest() - # 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)) - for _ in range(data_count): - digest.update(data) - digest.update(data[:trailing_bytes]) - return digest.finalize() - - -_KDFS = { - KdfAlgorithm.NONE: _kdf_none, - KdfAlgorithm.KDF_ITERSALTED_S2K: _kdf_itersalted_s2k, -} - - -def _parse_int(data, tag, func=lambda x: x, default=None): - return func(int.from_bytes(data[tag], "big")) if tag in data else default - - -@dataclass -class KdfData: - kdf_algorithm: KdfAlgorithm - hash_algorithm: Optional[HashAlgorithm] - iteration_count: Optional[int] - pw1_salt_bytes: Optional[bytes] - pw2_salt_bytes: Optional[bytes] - pw3_salt_bytes: Optional[bytes] - pw1_initial_hash: Optional[bytes] - pw3_initial_hash: Optional[bytes] - - def process(self, pw, pin): - kdf = _KDFS[self.kdf_algorithm] - if pw == PW1: - salt = self.pw1_salt_bytes - elif pw == PW3: - salt = self.pw3_salt_bytes or self.pw1_salt_bytes - else: - raise ValueError("Invalid value for pw") - return kdf(pin, salt, self.hash_algorithm, self.iteration_count) - - @classmethod - def parse(cls, data: bytes) -> "KdfData": - fields = Tlv.parse_dict(data) - return cls( - _parse_int(fields, 0x81, KdfAlgorithm, KdfAlgorithm.NONE), - _parse_int(fields, 0x82, HashAlgorithm), - _parse_int(fields, 0x83), - fields.get(0x84), - fields.get(0x85), - fields.get(0x86), - fields.get(0x87), - fields.get(0x88), - ) - - -class OpenPgpController(object): - def __init__(self, connection: SmartCardConnection): - protocol = SmartCardProtocol(connection) - self._app = protocol - try: - protocol.select(AID.OPENPGP) - except ApduError as e: - if e.sw in (SW.NO_INPUT_DATA, SW.CONDITIONS_NOT_SATISFIED): - protocol.send_apdu(0, INS.ACTIVATE, 0, 0) - protocol.select(AID.OPENPGP) - else: - raise - self._version = self._read_version() - - @property - def version(self): - return self._version - - def _get_data(self, do): - return self._app.send_apdu(0, INS.GET_DATA, do >> 8, do & 0xFF) - - def _put_data(self, do, data): - self._app.send_apdu(0, INS.PUT_DATA, do >> 8, do & 0xFF, data) - - def _select_certificate(self, key_slot): - try: - require_version(self.version, (5, 2, 0)) - data: bytes = Tlv(0x60, Tlv(0x5C, b"\x7f\x21")) - 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._app.send_apdu( - 0, - INS.SELECT_DATA, - 3 - key_slot.indx, - 0x04, - data, - ) - except NotSupportedError: - if key_slot == KEY_SLOT.AUT: - return # Older version still support AUT, which is the default slot. - raise - - def _read_version(self): - bcd_hex = self._app.send_apdu(0, INS.GET_VERSION, 0, 0).hex() - return tuple(int(bcd_hex[i : i + 2]) for i in range(0, 6, 2)) - - def get_openpgp_version(self): - data = self._get_data(DO.AID) - return data[6], data[7] - - def get_remaining_pin_tries(self): - data = self._get_data(DO.PW_STATUS) - return PinRetries(*data[4:7]) - - def _block_pins(self): - retries = self.get_remaining_pin_tries() - - for _ in range(retries.pin): - try: - self._app.send_apdu(0, INS.VERIFY, 0, PW1, INVALID_PIN) - except ApduError: - pass - for _ in range(retries.admin): - try: - self._app.send_apdu(0, INS.VERIFY, 0, PW3, INVALID_PIN) - except ApduError: - pass - - def reset(self): - if self.version < (1, 0, 6): - raise ValueError("Resetting OpenPGP data requires version 1.0.6 or later.") - self._block_pins() - self._app.send_apdu(0, INS.TERMINATE, 0, 0) - self._app.send_apdu(0, INS.ACTIVATE, 0, 0) - - def _get_kdf(self): - try: - data = self._get_data(DO.KDF) - except ApduError: - data = b"" - return KdfData.parse(data) - - def _verify(self, pw, pin): - try: - pin = self._get_kdf().process(pw, pin.encode()) - self._app.send_apdu(0, INS.VERIFY, 0, pw, pin) - except ApduError: - pw_remaining = self.get_remaining_pin_tries()[pw - PW1] - raise ValueError(f"Invalid PIN, {pw_remaining} tries remaining.") - - def verify_pin(self, pin): - self._verify(PW1, pin) - - def verify_admin(self, admin_pin): - self._verify(PW3, admin_pin) - - @property - def supported_touch_policies(self): - if self.version < (4, 2, 0): - return [] - if self.version < (5, 2, 1): - return [TOUCH_MODE.ON, TOUCH_MODE.OFF, TOUCH_MODE.FIXED] - if self.version >= (5, 2, 1): - return [ - TOUCH_MODE.ON, - TOUCH_MODE.OFF, - TOUCH_MODE.FIXED, - TOUCH_MODE.CACHED, - TOUCH_MODE.CACHED_FIXED, - ] - - @property - def supports_attestation(self): - return self.version >= (5, 2, 1) - - def get_touch(self, key_slot): - if not self.supported_touch_policies: - raise ValueError("Touch policy is available on YubiKey 4 or later.") - if key_slot == KEY_SLOT.ATT and not self.supports_attestation: - raise ValueError("Attestation key not available on this device.") - data = self._get_data(key_slot.uif) - return TOUCH_MODE(data[0]) - - def set_touch(self, key_slot, mode): - """Requires Admin PIN verification.""" - if not self.supported_touch_policies: - raise ValueError("Touch policy is available on YubiKey 4 or later.") - if mode not in self.supported_touch_policies: - raise ValueError("Touch policy not available on this device.") - self._put_data(key_slot.uif, struct.pack(">BB", mode, TOUCH_METHOD_BUTTON)) - - def set_pin_retries(self, pw1_tries, pw2_tries, pw3_tries): - """Requires Admin PIN verification.""" - if (1, 0, 0) <= self.version < (1, 0, 7): # For YubiKey NEO - raise ValueError( - "Setting PIN retry counters requires version 1.0.7 or later." - ) - if (4, 0, 0) <= self.version < (4, 3, 1): # For YubiKey 4 - raise ValueError( - "Setting PIN retry counters requires version 4.3.1 or later." - ) - self._app.send_apdu( - 0, - INS.SET_PIN_RETRIES, - 0, - 0, - struct.pack(">BBB", pw1_tries, pw2_tries, pw3_tries), - ) - - def read_certificate(self, key_slot): - if key_slot == KEY_SLOT.ATT: - require_version(self.version, (5, 2, 0)) - data = self._get_data(DO.ATT_CERTIFICATE) - else: - self._select_certificate(key_slot) - 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 import_certificate(self, key_slot, certificate): - """Requires Admin PIN verification.""" - cert_data = certificate.public_bytes(Encoding.DER) - if key_slot == KEY_SLOT.ATT: - require_version(self.version, (5, 2, 0)) - self._put_data(DO.ATT_CERTIFICATE, cert_data) - else: - self._select_certificate(key_slot) - self._put_data(DO.CARDHOLDER_CERTIFICATE, cert_data) - - def import_key(self, key_slot, key, fingerprint=None, timestamp=None): - """Requires Admin PIN verification.""" - if self.version >= (4, 0, 0): - attributes = _get_key_attributes(key, key_slot) - self._put_data(key_slot.key_id, attributes) - - template = _get_key_template(key, key_slot, self.version < (4, 0, 0)) - self._app.send_apdu(0, INS.PUT_DATA_ODD, 0x3F, 0xFF, template) - - if fingerprint is not None: - self._put_data(key_slot.fingerprint, fingerprint) - - if timestamp is not None: - self._put_data(key_slot.gen_time, struct.pack(">I", timestamp)) - - def generate_rsa_key(self, key_slot, key_size, timestamp=None): - """Requires Admin PIN verification.""" - if (4, 2, 0) <= self.version < (4, 3, 5): - raise NotSupportedError("RSA key generation not supported on this YubiKey") - - if timestamp is None: - timestamp = int(time.time()) - - neo = self.version < (4, 0, 0) - if not neo: - attributes = _format_rsa_attributes(key_size) - self._put_data(key_slot.key_id, attributes) - elif key_size != 2048: - raise ValueError("Unsupported key size!") - resp = self._app.send_apdu(0, INS.GENERATE_ASYM, 0x80, 0x00, key_slot.crt) - - data = Tlv.parse_dict(Tlv.unpack(0x7F49, resp)) - numbers = rsa.RSAPublicNumbers(bytes2int(data[0x82]), bytes2int(data[0x81])) - - self._put_data(key_slot.gen_time, struct.pack(">I", timestamp)) - # TODO: Calculate and write fingerprint - - return numbers.public_key(default_backend()) - - def generate_ec_key(self, key_slot, curve_name, timestamp=None): - require_version(self.version, (5, 2, 0)) - """Requires Admin PIN verification.""" - if timestamp is None: - timestamp = int(time.time()) - - attributes = _format_ec_attributes(key_slot, curve_name) - self._put_data(key_slot.key_id, attributes) - resp = self._app.send_apdu(0, INS.GENERATE_ASYM, 0x80, 0x00, key_slot.crt) - - data = Tlv.parse_dict(Tlv.unpack(0x7F49, resp)) - pubkey_enc = data[0x86] - - self._put_data(key_slot.gen_time, struct.pack(">I", timestamp)) - # TODO: Calculate and write fingerprint - - if curve_name == "x25519": - # Added in 2.0 - from cryptography.hazmat.primitives.asymmetric import x25519 - - return x25519.X25519PublicKey.from_public_bytes(pubkey_enc) - if curve_name == "ed25519": - # Added in 2.6 - from cryptography.hazmat.primitives.asymmetric import ed25519 - - return ed25519.Ed25519PublicKey.from_public_bytes(pubkey_enc) - - curve = getattr(ec, curve_name.upper()) - try: - # Added in cryptography 2.5 - return ec.EllipticCurvePublicKey.from_encoded_point(curve(), pubkey_enc) - except AttributeError: - return ec.EllipticCurvePublicNumbers.from_encoded_point( - curve(), pubkey_enc - ).public_key(default_backend()) - - def delete_key(self, key_slot): - """Requires Admin PIN verification.""" - if self.version < (4, 0, 0): - # Import over the key - self.import_key( - key_slot, - rsa.generate_private_key(65537, 2048, default_backend()), - b"\0" * 20, - 0, - ) - else: - # Delete key by changing the key attributes twice. - self._put_data(key_slot.key_id, _format_rsa_attributes(4096)) - self._put_data(key_slot.key_id, _format_rsa_attributes(2048)) - - def delete_certificate(self, key_slot): - """Requires Admin PIN verification.""" - if key_slot == KEY_SLOT.ATT: - require_version(self.version, (5, 2, 0)) - self._put_data(DO.ATT_CERTIFICATE, b"") - else: - self._select_certificate(key_slot) - self._put_data(DO.CARDHOLDER_CERTIFICATE, b"") - - def attest(self, key_slot): - """Requires User PIN verification.""" - require_version(self.version, (5, 2, 0)) - self._app.send_apdu(0x80, INS.GET_ATTESTATION, key_slot.indx, 0) - return self.read_certificate(key_slot) - - -def get_openpgp_info(controller: OpenPgpController) -> str: - """Get human readable information about the OpenPGP configuration.""" - lines = [] - lines.append("OpenPGP version: %d.%d" % controller.get_openpgp_version()) - lines.append("Application version: %d.%d.%d" % controller.version) - lines.append("") - retries = controller.get_remaining_pin_tries() - lines.append(f"PIN tries remaining: {retries.pin}") - lines.append(f"Reset code tries remaining: {retries.reset}") - lines.append(f"Admin PIN tries remaining: {retries.admin}") # Touch only available on YK4 and later - if controller.version >= (4, 2, 6): - lines.append("") - lines.append("Touch policies") - lines.append(f"Signature key {controller.get_touch(KEY_SLOT.SIG)!s}") - lines.append(f"Encryption key {controller.get_touch(KEY_SLOT.ENC)!s}") - lines.append(f"Authentication key {controller.get_touch(KEY_SLOT.AUT)!s}") - if controller.supports_attestation: - lines.append( - f"Attestation key {controller.get_touch(KEY_SLOT.ATT)!s}" - ) - - return "\n".join(lines) + 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 diff --git a/ykman/otp.py b/ykman/otp.py index 949534c92..caf6a4de5 100644 --- a/ykman/otp.py +++ b/ykman/otp.py @@ -32,9 +32,9 @@ from yubikit.oath import parse_b32_key from enum import Enum from http.client import HTTPSConnection -from typing import Iterable +from datetime import datetime +from typing import Iterable, Optional -import re import json import struct import random @@ -43,11 +43,11 @@ logger = logging.getLogger(__name__) -UPLOAD_HOST = "upload.yubico.com" -UPLOAD_PATH = "/prepare" +_UPLOAD_HOST = "upload.yubico.com" +_UPLOAD_PATH = "/prepare" -class PrepareUploadError(Enum): +class _PrepareUploadError(Enum): # Defined here CONNECTION_FAILED = "Failed to open HTTPS connection." NOT_FOUND = "Upload request not recognized by server." @@ -78,15 +78,13 @@ def message(self): return self.value -class PrepareUploadFailed(Exception): +class _PrepareUploadFailed(Exception): def __init__(self, status, content, error_ids): - super(PrepareUploadFailed, self).__init__( - f"Upload to YubiCloud failed with status {status}: {content}" - ) + 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] + e if isinstance(e, _PrepareUploadError) else _PrepareUploadError[e] for e in error_ids ] @@ -94,7 +92,7 @@ def messages(self): return [e.message() for e in self.errors] -def prepare_upload_key( +def _prepare_upload_key( key, public_id, private_id, @@ -109,18 +107,18 @@ def prepare_upload_key( "private_id": private_id.hex(), } - httpconn = HTTPSConnection(UPLOAD_HOST, timeout=1) # nosec + httpconn = HTTPSConnection(_UPLOAD_HOST, timeout=1) # nosec try: httpconn.request( "POST", - UPLOAD_PATH, + _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 as e: - logger.error("Failed to connect to %s", UPLOAD_HOST, exc_info=e) - raise PrepareUploadFailed(None, None, [PrepareUploadError.CONNECTION_FAILED]) + 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: @@ -130,23 +128,26 @@ def prepare_upload_key( 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] + raise _PrepareUploadFailed( + resp.status, resp_body, [_PrepareUploadError.NOT_FOUND] ) elif resp.status == 503: - raise PrepareUploadFailed( - resp.status, resp_body, [PrepareUploadError.SERVICE_UNAVAILABLE] + 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) + 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.""" + """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 @@ -158,29 +159,73 @@ def generate_static_pw( keyboard_layout: KEYBOARD_LAYOUT = KEYBOARD_LAYOUT.MODHEX, blocklist: Iterable[str] = DEFAULT_PW_CHAR_BLOCKLIST, ) -> str: - """Generate a random password.""" + """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.""" - val = val.upper() - if re.match(r"^([0-9A-F]{2})+$", val): # hex + """Parse a secret key encoded as either Hex or Base32. + + :param val: The secret key. + """ + try: return bytes.fromhex(val) - else: - # Key should be b32 encoded + except ValueError: return parse_b32_key(val) def format_oath_code(response: bytes, digits: int = 6) -> str: - """Formats an OATH code from a hash response.""" + """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) + return ("%%0%dd" % digits) % (code % 10**digits) def time_challenge(timestamp: int, period: int = 30) -> bytes: - """Formats a HMAC-SHA1 challenge based on an OATH timestamp and period.""" + """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 + ] + ) diff --git a/ykman/pcsc/__init__.py b/ykman/pcsc/__init__.py index e1bc33d6e..32b8738f3 100644 --- a/ykman/pcsc/__init__.py +++ b/ykman/pcsc/__init__.py @@ -25,10 +25,11 @@ # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -from yubikit.core import TRANSPORT +from ..base import YkmanDevice +from yubikit.core import TRANSPORT, YUBIKEY, PID from yubikit.core.smartcard import SmartCardConnection from yubikit.management import USB_INTERFACE -from ..base import YUBIKEY, YkmanDevice +from yubikit.logging import LOG_LEVEL from smartcard import System from smartcard.Exceptions import CardConnectionException @@ -60,7 +61,7 @@ def _pid_from_name(name): interfaces |= USB_INTERFACE.FIDO key_type = YUBIKEY.NEO if "NEO" in name else YUBIKEY.YK4 - return key_type.get_pid(interfaces) + return PID.of(key_type, interfaces) class ScardYubiKeyDevice(YkmanDevice): @@ -104,7 +105,9 @@ def __init__(self, connection): self.connection = connection connection.connect() atr = connection.getATR() - self._transport = TRANSPORT.USB if atr[1] & 0xF0 == 0xF0 else TRANSPORT.NFC + self._transport = ( + TRANSPORT.USB if atr and atr[1] & 0xF0 == 0xF0 else TRANSPORT.NFC + ) @property def transport(self): @@ -115,9 +118,11 @@ def close(self): def send_and_receive(self, apdu): """Sends a command APDU and returns the response data and sw""" - logger.debug("SEND: %s", apdu.hex()) + logger.log(LOG_LEVEL.TRAFFIC, "SEND: %s", apdu.hex()) data, sw1, sw2 = self.connection.transmit(list(apdu)) - logger.debug("RECV: %s SW=%02x%02x", bytes(data).hex(), sw1, sw2) + logger.log( + LOG_LEVEL.TRAFFIC, "RECV: %s SW=%02x%02x", bytes(data).hex(), sw1, sw2 + ) return bytes(data), sw1 << 8 | sw2 @@ -139,7 +144,7 @@ def kill_scdaemon(): killed = True except ImportError: # Works for Linux and OS X. - return_code = subprocess.call(["/usr/bin/pkill", "-9", "scdaemon"]) # nosec + return_code = subprocess.call(["pkill", "-9", "scdaemon"]) # nosec if return_code == 0: killed = True if killed: diff --git a/ykman/piv.py b/ykman/piv.py index 5d70e995d..7ca6c0e68 100644 --- a/ykman/piv.py +++ b/ykman/piv.py @@ -51,8 +51,9 @@ import logging import struct import os +import re -from typing import Union, Mapping, Optional, List, Type, cast +from typing import Union, Mapping, Optional, List, Dict, Type, Any, cast logger = logging.getLogger(__name__) @@ -113,10 +114,15 @@ def _parse(value: str) -> List[List[str]]: return name +_DOTTED_STRING_RE = re.compile(r"\d(\.\d+)+") + + def parse_rfc4514_string(value: str) -> x509.Name: - """Parses an RFC 4514 string into a 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] = [] @@ -126,9 +132,13 @@ def parse_rfc4514_string(value: str) -> x509.Name: if "=" not in part: raise ValueError("Invalid RFC 4514 string") k, v = part.split("=", 1) - if k not in _NAME_ATTRIBUTES: + 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(_NAME_ATTRIBUTES[k], v)) + parts.append(x509.NameAttribute(attr, v)) attributes.insert(0, x509.RelativeDistinguishedName(parts)) return x509.Name(attributes) @@ -151,13 +161,19 @@ def derive_management_key(pin: str, salt: bytes) -> bytes: 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: - """Generates a new random management key.""" + """Generate a new random management key. + + :param algorithm: The algorithm for the management key. + """ return os.urandom(algorithm.key_len) @@ -229,26 +245,35 @@ def get_bytes(self) -> bytes: def get_pivman_data(session: PivSession) -> PivmanData: - """Reads out the Pivman data from a YubiKey.""" + """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: - """Reads out the Pivman protected data from a YubiKey. + """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 @@ -260,15 +285,23 @@ def pivman_set_mgm_key( touch: bool = False, store_on_device: bool = False, ) -> None: - """Set a new management key, while keeping PivmanData in sync.""" + """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 as e: - logger.debug("Failed to initialize protected pivman data", exc_info=e) + except Exception: + logger.debug("Failed to initialize protected pivman data", exc_info=True) if store_on_device: raise @@ -277,35 +310,47 @@ def pivman_set_mgm_key( 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 store_on_device: - # Store key in protected pivman data - pivman_prot.key = new_key - session.put_object(OBJECT_ID_PIVMAN_PROTECTED_DATA, pivman_prot.get_bytes()) - elif not store_on_device and pivman.has_stored_key: - # If new key should not be stored and there is an old stored key, - # try to clear it. - try: - pivman_prot.key = None - session.put_object( - OBJECT_ID_PIVMAN_PROTECTED_DATA, - pivman_prot.get_bytes(), - ) - except ApduError as e: - logger.debug("No PIN provided, can't clear key...", exc_info=e) + + 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.""" + """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)), @@ -319,9 +364,11 @@ def pivman_change_pin(session: PivSession, old_pin: str, new_pin: str) -> None: def list_certificates(session: PivSession) -> Mapping[SLOT, Optional[x509.Certificate]]: - """Reads out and parses stored certificates. + """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}: @@ -344,9 +391,16 @@ def check_key( 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, @@ -371,15 +425,17 @@ def check_key( 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: - """Generates a CHUID (Cardholder Unique Identifier).""" + """Generate a CHUID (Cardholder Unique Identifier).""" # Non-Federal Issuer FASC-N # [9999-9999-999999-0-1-0000000000300001] FASC_N = ( @@ -399,7 +455,7 @@ def generate_chuid() -> bytes: def generate_ccc() -> bytes: - """Generates a CCC (Card Capability Container).""" + """Generate a CCC (Card Capability Container).""" return ( Tlv(0xF0, b"\xa0\x00\x00\x01\x16\xff\x02" + os.urandom(14)) + Tlv(0xF1, b"\x21") @@ -417,12 +473,16 @@ def generate_ccc() -> bytes: ) -def get_piv_info(session: PivSession) -> str: - """Get human readable information about the PIV configuration.""" - pivman = get_pivman_data(session) - lines = [] +def get_piv_info(session: PivSession): + """Get human readable information about the PIV configuration. - lines.append("PIV version: %d.%d.%d" % session.version) + :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() @@ -432,10 +492,22 @@ def get_piv_info(session: PivSession) -> str: 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) - lines.append(f"PIN tries remaining: {tries_str}") + tries_str = "15 or more" if tries == 15 else str(tries) + info["PIN tries remaining"] = tries_str if pivman.puk_blocked: - lines.append("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() @@ -444,31 +516,31 @@ def get_piv_info(session: PivSession) -> str: key_type = metadata.key_type except NotSupportedError: key_type = MANAGEMENT_KEY_TYPE.TDES - lines.append(f"Management key algorithm: {key_type.name}") + 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: - chuid = session.get_object(OBJECT_ID.CHUID).hex() + objects["CHUID"] = session.get_object(OBJECT_ID.CHUID) except ApduError as e: if e.sw == SW.FILE_NOT_FOUND: - chuid = "No data available." - lines.append("CHUID:\t" + chuid) + objects["CHUID"] = "No data available" try: - ccc = session.get_object(OBJECT_ID.CAPABILITY).hex() + objects["CCC"] = session.get_object(OBJECT_ID.CAPABILITY) except ApduError as e: if e.sw == SW.FILE_NOT_FOUND: - ccc = "No data available." - lines.append("CCC: \t" + ccc) + objects["CCC"] = "No data available" - for (slot, cert) in list_certificates(session).items(): - lines.append(f"Slot {slot:02x}:") - - if isinstance(cert, x509.Certificate): + 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 @@ -484,8 +556,8 @@ def get_piv_info(session: PivSession) -> str: 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=e) - lines.append(f"\tMalformed certificate: {e}") + logger.debug("Failed parsing certificate", exc_info=True) + cert_data["Error"] = f"Malformed certificate: {e}" continue fingerprint = cert.fingerprint(hashes.SHA256()).hex() @@ -496,32 +568,45 @@ def get_piv_info(session: PivSession) -> str: serial = cert.serial_number try: not_before: Optional[datetime] = cert.not_valid_before - except ValueError as e: - logger.debug("Failed reading not_valid_before", exc_info=e) + 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 as e: - logger.debug("Failed reading not_valid_after", exc_info=e) + except ValueError: + logger.debug("Failed reading not_valid_after", exc_info=True) not_after = None + # Print out everything - lines.append(f"\tAlgorithm:\t{key_algo}") + cert_data["Algorithm"] = key_algo if print_dn: - lines.append(f"\tSubject DN:\t{subject_dn}") - lines.append(f"\tIssuer DN:\t{issuer_dn}") + cert_data["Subject DN"] = subject_dn + cert_data["Issuer DN"] = issuer_dn else: - lines.append(f"\tSubject CN:\t{subject_cn}") - lines.append(f"\tIssuer CN:\t{issuer_cn}") - lines.append(f"\tSerial:\t\t{serial}") - lines.append(f"\tFingerprint:\t{fingerprint}") + cert_data["Subject CN"] = subject_cn + cert_data["Issuer CN"] = issuer_cn + cert_data["Serial"] = serial + cert_data["Fingerprint"] = fingerprint if not_before: - lines.append(f"\tNot before:\t{not_before}") + cert_data["Not before"] = not_before.isoformat() if not_after: - lines.append(f"\tNot after:\t{not_after}") + cert_data["Not after"] = not_after.isoformat() else: - lines.append("\tError: Failed to parse certificate.") + cert_data["Error"] = "Failed to parse certificate" + + return lines + - return "\n".join(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( @@ -529,9 +614,17 @@ def sign_certificate_builder( slot: SLOT, key_type: KEY_TYPE, builder: x509.CertificateBuilder, - hash_algorithm: Type[hashes.HashAlgorithm] = hashes.SHA256, + hash_algorithm: Type[_AllowedHashTypes] = hashes.SHA256, ) -> x509.Certificate: - """Sign a 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()) @@ -557,9 +650,18 @@ def sign_csr_builder( slot: SLOT, public_key: Union[rsa.RSAPublicKey, ec.EllipticCurvePublicKey], builder: x509.CertificateSigningRequestBuilder, - hash_algorithm: Type[hashes.HashAlgorithm] = hashes.SHA256, + hash_algorithm: Type[_AllowedHashTypes] = hashes.SHA256, ) -> x509.CertificateSigningRequest: - """Sign a CSR.""" + """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()) @@ -598,9 +700,19 @@ def generate_self_signed_certificate( subject_str: str, valid_from: datetime, valid_to: datetime, - hash_algorithm: Type[hashes.HashAlgorithm] = hashes.SHA256, + hash_algorithm: Type[_AllowedHashTypes] = hashes.SHA256, ) -> x509.Certificate: - """Generate a self-signed certificate using a private key in a slot.""" + """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) @@ -614,13 +726,7 @@ def generate_self_signed_certificate( .not_valid_after(valid_to) ) - try: - return sign_certificate_builder( - session, slot, key_type, builder, hash_algorithm - ) - except ApduError as e: - logger.error("Failed to generate certificate for slot %s", slot, exc_info=e) - raise + return sign_certificate_builder(session, slot, key_type, builder, hash_algorithm) def generate_csr( @@ -628,19 +734,19 @@ def generate_csr( slot: SLOT, public_key: Union[rsa.RSAPublicKey, ec.EllipticCurvePublicKey], subject_str: str, - hash_algorithm: Type[hashes.HashAlgorithm] = hashes.SHA256, + hash_algorithm: Type[_AllowedHashTypes] = hashes.SHA256, ) -> x509.CertificateSigningRequest: - """Generate a CSR using a private key in a slot.""" + """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) ) - try: - return sign_csr_builder(session, slot, public_key, builder, hash_algorithm) - except ApduError as e: - logger.error( - "Failed to generate Certificate Signing Request for slot %s", - slot, - exc_info=e, - ) - raise + return sign_csr_builder(session, slot, public_key, builder, hash_algorithm) diff --git a/ykman/py.typed b/ykman/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/ykman/scripting.py b/ykman/scripting.py new file mode 100644 index 000000000..0a6fa703e --- /dev/null +++ b/ykman/scripting.py @@ -0,0 +1,254 @@ +# 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 diff --git a/ykman/settings.py b/ykman/settings.py index 77ca6d464..ed0b88699 100644 --- a/ykman/settings.py +++ b/ykman/settings.py @@ -27,18 +27,20 @@ import os import json +import keyring from pathlib import Path +from cryptography.fernet import Fernet, InvalidToken -HOME_CONFIG = "~/.ykman" XDG_DATA_HOME = os.environ.get("XDG_DATA_HOME", "~/.local/share") + "/ykman" XDG_CONFIG_HOME = os.environ.get("XDG_CONFIG_HOME", "~/.config") + "/ykman" -USE_XDG = "YKMAN_XDG_EXPERIMENTAL" in os.environ +KEYRING_SERVICE = os.environ.get("YKMAN_KEYRING_SERVICE", "ykman") +KEYRING_KEY = os.environ.get("YKMAN_KEYRING_KEY", "wrap_key") class Settings(dict): - _config_dir = HOME_CONFIG + _config_dir = XDG_CONFIG_HOME def __init__(self, name): self.fname = Path(self._config_dir).expanduser().resolve() / (name + ".json") @@ -63,8 +65,50 @@ def write(self): class Configuration(Settings): - _config_dir = XDG_CONFIG_HOME if USE_XDG else HOME_CONFIG + _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 if USE_XDG else HOME_CONFIG + _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() diff --git a/ykman/util.py b/ykman/util.py index 96f536fb1..ac73bdcdc 100644 --- a/ykman/util.py +++ b/ykman/util.py @@ -26,10 +26,10 @@ # 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 functools import partial from typing import Tuple import ctypes @@ -46,75 +46,41 @@ class InvalidPasswordError(Exception): """Raised when parsing key/certificate and the password might be wrong/missing.""" -def _parse_pkcs12_cryptography(pkcs12, data, password): +def _parse_pkcs12(data, password): try: key, cert, cas = pkcs12.load_key_and_certificates( data, password, default_backend() ) - return key, [cert] + cas + if cert: + cas.insert(0, cert) + return key, cas except ValueError as e: # cryptography raises ValueError on wrong password raise InvalidPasswordError(e) -def _parse_pkcs12_pyopenssl(crypto, data, password): - try: - p12 = crypto.load_pkcs12(data, password) - key_pem = crypto.dump_privatekey(crypto.FILETYPE_PEM, p12.get_privatekey()) - key = serialization.load_pem_private_key( - key_pem, password=None, backend=default_backend() - ) - - certs = [p12.get_certificate()] - cas = p12.get_ca_certificates() - if cas: - certs.extend(cas) - certs_pem = [ - crypto.dump_certificate(crypto.FILETYPE_PEM, cert) for cert in certs - ] - certs = [ - x509.load_pem_x509_certificate(cert_pem, default_backend()) - for cert_pem in certs_pem - ] - return key, certs - except crypto.Error as e: - raise InvalidPasswordError(e) - - -def _parse_pkcs12_unsupported(data, password): - raise ValueError("PKCS#12 support requires cryptography >= 2.5 or pyOpenSSL") - - -try: # This requires cryptography 2.5. - from cryptography.hazmat.primitives.serialization import pkcs12 - - _parse_pkcs12 = partial(_parse_pkcs12_cryptography, pkcs12) -except ImportError: # Use pyOpenSSL as a backup - try: - from OpenSSL import crypto - - _parse_pkcs12 = partial(_parse_pkcs12_pyopenssl, crypto) - except ImportError: # Can't support PKCS#12 - _parse_pkcs12 = _parse_pkcs12_unsupported # type: ignore - - def parse_private_key(data, password): - """ - Identifies, decrypts and returns a cryptography private key object. + """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): - if b"ENCRYPTED" in data: - if password is None: - raise InvalidPasswordError("No password provided for encrypted key.") + 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. - raise InvalidPasswordError(e) - except Exception as e: - logger.debug("Failed to parse PEM private key ", exc_info=e) + 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): @@ -125,17 +91,20 @@ def parse_private_key(data, password): return serialization.load_der_private_key( data, password, backend=default_backend() ) - except Exception as e: - logger.debug("Failed to parse private key as DER", exc_info=e) + 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). """ - Identifies, decrypts and returns list of cryptography x509 certificates. - """ + logger.debug("Attempting to parse certificate using PEM, PKCS12 and DER") # PEM if is_pem(data): @@ -148,8 +117,8 @@ def parse_certificates(data, password): PEM_IDENTIFIER + cert, default_backend() ) ) - except Exception as e: - logger.debug("Failed to parse PEM certificate", exc_info=e) + 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)") @@ -162,17 +131,19 @@ def parse_certificates(data, password): # DER try: return [x509.load_der_x509_certificate(data, default_backend())] - except Exception as e: - logger.debug("Failed to parse certificate as DER", exc_info=e) + except Exception: + logger.debug("Failed to parse certificate as DER", exc_info=True) raise ValueError("Could not parse certificate.") def get_leaf_certificates(certs): - """ - Extracts the leaf certificates from a list of certificates. Leaf - certificates are ones whose subject does not appear as issuer among the - others. + """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 @@ -200,8 +171,8 @@ def is_pkcs12(data): try: header = Tlv.parse_from(Tlv.unpack(0x30, data))[0] return header.tag == 0x02 and header.value == b"\x03" - except ValueError as e: - logger.debug("Unable to parse TLV", exc_info=e) + except ValueError: + logger.debug("Unable to parse TLV", exc_info=True) return False diff --git a/yubikit/__init__.py b/yubikit/__init__.py index 5ca333ac1..7447b001c 100644 --- a/yubikit/__init__.py +++ b/yubikit/__init__.py @@ -24,3 +24,8 @@ # 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. +""" diff --git a/yubikit/core/__init__.py b/yubikit/core/__init__.py index 815bace5e..473af30d6 100644 --- a/yubikit/core/__init__.py +++ b/yubikit/core/__init__.py @@ -25,7 +25,7 @@ # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -from enum import Enum, unique +from enum import Enum, IntEnum, IntFlag, unique from typing import ( Type, List, @@ -36,6 +36,8 @@ Optional, Hashable, NamedTuple, + Callable, + ClassVar, ) import re import abc @@ -51,6 +53,9 @@ class Version(NamedTuple): minor: int patch: int + def __str__(self): + return "%d.%d.%d" % self + @classmethod def from_bytes(cls, data: bytes) -> "Version": return cls(*data) @@ -72,23 +77,35 @@ class TRANSPORT(str, Enum): 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 AID(bytes, Enum): - """YubiKey Application smart card AID values.""" +class YUBIKEY(Enum): + """YubiKey hardware platforms.""" - 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") + 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.""" @@ -99,6 +116,45 @@ 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) @@ -114,11 +170,14 @@ def transport(self) -> TRANSPORT: """Get the transport used to communicate with this YubiKey""" return self._transport - def supports_connection(self, connection_type: Type[T_Connection]) -> bool: + def supports_connection(self, connection_type: Type[Connection]) -> bool: """Check if a YubiKeyDevice supports a specific Connection type""" return False - def open_connection(self, connection_type: Type[T_Connection]) -> T_Connection: + # 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") @@ -159,6 +218,19 @@ 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 ): diff --git a/yubikit/core/fido.py b/yubikit/core/fido.py index 5d3dcd63a..3f6c3a7e4 100644 --- a/yubikit/core/fido.py +++ b/yubikit/core/fido.py @@ -25,10 +25,11 @@ # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -from . import Connection +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) diff --git a/yubikit/core/otp.py b/yubikit/core/otp.py index f0b4406f6..c00580151 100644 --- a/yubikit/core/otp.py +++ b/yubikit/core/otp.py @@ -25,7 +25,8 @@ # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -from . import Connection, CommandError, TimeoutError, Version +from . import Connection, CommandError, TimeoutError, Version, USB_INTERFACE +from yubikit.logging import LOG_LEVEL from time import sleep from threading import Event @@ -37,11 +38,16 @@ 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""" @@ -70,18 +76,18 @@ def check_crc(data: bytes) -> bool: return calculate_crc(data) == CRC_OK_RESIDUAL -_MODHEX = "cbdefghijklnrtuv" - - def modhex_encode(data: bytes) -> str: """Encode a bytes-like object using Modhex (modified hexadecimal) encoding.""" - return "".join(_MODHEX[b >> 4] + _MODHEX[b & 0xF] for b in data) + 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.index(string[i]) << 4 | _MODHEX.index(string[i + 1]) + MODHEX_ALPHABET.index(string[i]) << 4 | MODHEX_ALPHABET.index(string[i + 1]) for i in range(0, len(string), 2) ) @@ -117,6 +123,8 @@ def _format_frame(slot, payload): class OtpProtocol: + """An implementation of the OTP protocol.""" + def __init__(self, otp_connection: OtpConnection): self.connection = otp_connection report = self._receive() @@ -143,12 +151,12 @@ def send_and_receive( 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 + :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 + :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: @@ -157,11 +165,11 @@ def send_and_receive( on_keepalive = lambda x: None # noqa frame = _format_frame(slot, payload) - logger.debug("SEND: %s", frame.hex()) + logger.log(LOG_LEVEL.TRAFFIC, "SEND: %s", frame.hex()) response = self._read_frame( self._send_frame(frame), event or Event(), on_keepalive ) - logger.debug("RECV: %s", response.hex()) + logger.log(LOG_LEVEL.TRAFFIC, "RECV: %s", response.hex()) return response def _receive(self): @@ -174,10 +182,10 @@ def _receive(self): return report def read_status(self) -> bytes: - """Receive status bytes from YubiKey + """Receive status bytes from YubiKey. - @return status bytes (first 3 bytes are the firmware version) - @throws IOException in case of communication error + :return: Status bytes (first 3 bytes are the firmware version). + :raises IOException: in case of communication error. """ return self._receive()[1:-1] diff --git a/yubikit/core/smartcard.py b/yubikit/core/smartcard.py index b55418ec5..7df4ee77d 100644 --- a/yubikit/core/smartcard.py +++ b/yubikit/core/smartcard.py @@ -25,23 +25,22 @@ # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -from . import Version, TRANSPORT, Connection, CommandError, ApplicationNotAvailableError +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 - -class SmartCardConnection(Connection, metaclass=abc.ABCMeta): - @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""" +logger = logging.getLogger(__name__) class ApduError(CommandError): @@ -63,10 +62,24 @@ class ApduFormat(str, Enum): 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 @@ -78,12 +91,26 @@ class SW(IntEnum): 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 @@ -94,15 +121,23 @@ class SW(IntEnum): SHORT_APDU_MAX_CHUNK = 0xFF -def _encode_short_apdu(cla, ins, p1, p2, data): - return struct.pack(">BBBBB", cla, ins, p1, p2, len(data)) + data +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): - return struct.pack(">BBBBBH", cla, ins, p1, p2, 0, len(data)) + data +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, @@ -121,13 +156,19 @@ 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, ): @@ -135,13 +176,24 @@ def select(self, aid: bytes) -> bytes: raise def send_apdu( - self, cla: int, ins: int, p1: int, p2: int, data: bytes = b"" + 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 @@ -151,17 +203,17 @@ def send_apdu( 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) + _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) + _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) + _encode_extended_apdu(cla, ins, p1, p2, data, le) ) get_data = _encode_extended_apdu(0, self._ins_send_remaining, 0, 0, b"") else: diff --git a/yubikit/hsmauth.py b/yubikit/hsmauth.py new file mode 100644 index 000000000..eba56d5ce --- /dev/null +++ b/yubikit/hsmauth.py @@ -0,0 +1,612 @@ +# 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) diff --git a/yubikit/logging.py b/yubikit/logging.py new file mode 100644 index 000000000..d16c66403 --- /dev/null +++ b/yubikit/logging.py @@ -0,0 +1,39 @@ +# 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 diff --git a/yubikit/management.py b/yubikit/management.py index cee04ddd5..83669c67e 100644 --- a/yubikit/management.py +++ b/yubikit/management.py @@ -31,8 +31,8 @@ require_version, Version, Tlv, - AID, TRANSPORT, + USB_INTERFACE, NotSupportedError, BadResponseError, ApplicationNotAvailableError, @@ -45,7 +45,7 @@ CommandRejectedError, ) from .core.fido import FidoConnection -from .core.smartcard import SmartCardConnection, SmartCardProtocol +from .core.smartcard import AID, SmartCardConnection, SmartCardProtocol from fido2.hid import CAPABILITY as CTAP_CAPABILITY from enum import IntEnum, IntFlag, unique @@ -53,24 +53,9 @@ from typing import Optional, Union, Mapping import abc import struct +import logging - -@unique -class USB_INTERFACE(IntFlag): - """YubiKey USB interface identifiers.""" - - OTP = 0x01 - FIDO = 0x02 - CCID = 0x04 - - def supports_connection(self, connection_type) -> bool: - if issubclass(connection_type, SmartCardConnection): - return USB_INTERFACE.CCID in self - if issubclass(connection_type, FidoConnection): - return USB_INTERFACE.FIDO in self - if issubclass(connection_type, OtpConnection): - return USB_INTERFACE.OTP in self - return False +logger = logging.getLogger(__name__) @unique @@ -86,14 +71,34 @@ class CAPABILITY(IntFlag): 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" - else: - return getattr(self, "name", super().__str__()) + # 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 @@ -209,6 +214,7 @@ class DeviceInfo: 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 @@ -223,6 +229,7 @@ def parse(cls, encoded: bytes, default_version: Version) -> "DeviceInfo": 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: @@ -253,6 +260,7 @@ def parse(cls, encoded: bytes, default_version: Version) -> "DeviceInfo": supported, locked, fips, + sky, ) @@ -282,12 +290,15 @@ def __init__(self, interfaces: USB_INTERFACE): raise ValueError("Invalid mode!") def __repr__(self): - return "+".join(t.name for t in USB_INTERFACE if t in self.interfaces) + 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": - code = code & 0b00000111 - return cls(_MODES[code]) + # 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 @@ -354,13 +365,25 @@ def write_config(self, config): class _ManagementSmartCardBackend(_Backend): def __init__(self, smartcard_connection): self.protocol = SmartCardProtocol(smartcard_connection) - select_str = self.protocol.select(AID.MANAGEMENT).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) + 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() @@ -420,6 +443,10 @@ def __init__( 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() @@ -429,6 +456,7 @@ 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) @@ -439,15 +467,28 @@ def write_device_config( 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, @@ -455,6 +496,18 @@ def set_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) @@ -464,6 +517,8 @@ def set_mode( 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}, @@ -483,3 +538,4 @@ def 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") @@ -136,6 +148,8 @@ def get_id(self) -> bytes: @dataclass class Code: + """An OATH code object.""" + value: str valid_from: int valid_to: int @@ -144,6 +158,8 @@ class Code: @total_ordering @dataclass(order=False, frozen=True) class Credential: + """An OATH credential object.""" + device_id: str id: bytes issuer: Optional[str] @@ -196,7 +212,7 @@ def _parse_cred_id(cred_id, oath_type): issuer, data = data.split(":", 1) else: issuer = None - return issuer, data, None + return issuer, data, 0 def _get_device_id(salt): @@ -233,17 +249,19 @@ def _format_code(credential, timestamp, truncated): valid_to = (time_step + 1) * credential.period else: # HOTP valid_from = timestamp - valid_to = float("Inf") + valid_to = 0x7FFFFFFFFFFFFFFF digits = truncated[0] return Code( - str((bytes2int(truncated[1:]) & 0x7FFFFFFF) % 10 ** digits).rjust(digits, "0"), + 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( @@ -252,33 +270,53 @@ def __init__(self, connection: SmartCardConnection): 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) @@ -289,8 +327,13 @@ def validate(self, key: bytes) -> None: "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( @@ -304,31 +347,53 @@ def set_key(self, key: bytes) -> None: + 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(b">BB", TAG_PROPERTY, PROP_REQUIRE_TOUCH) + 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, @@ -342,15 +407,23 @@ def put_credential( 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) @@ -365,6 +438,11 @@ def list_credentials(self) -> List[Credential]: 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( @@ -378,13 +456,23 @@ def calculate(self, credential_id: bytes, challenge: bytes) -> bytes: 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( @@ -410,6 +498,7 @@ def calculate_all( 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 @@ -418,13 +507,23 @@ def calculate_all( 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( diff --git a/yubikit/openpgp.py b/yubikit/openpgp.py new file mode 100644 index 000000000..f500338e4 --- /dev/null +++ b/yubikit/openpgp.py @@ -0,0 +1,1742 @@ +# 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 diff --git a/yubikit/piv.py b/yubikit/piv.py index 695163d86..d5a415154 100755 --- a/yubikit/piv.py +++ b/yubikit/piv.py @@ -31,17 +31,17 @@ bytes2int, Version, Tlv, - AID, - CommandError, NotSupportedError, BadResponseError, + InvalidPinError, ) from .core.smartcard import ( - SmartCardConnection, - SmartCardProtocol, - ApduError, SW, + AID, + ApduError, ApduFormat, + SmartCardConnection, + SmartCardProtocol, ) from cryptography import x509 @@ -51,6 +51,7 @@ 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 @@ -58,6 +59,7 @@ from typing import Optional, Union, Type, cast import logging +import gzip import os import re @@ -176,6 +178,9 @@ class SLOT(IntEnum): ATTESTATION = 0xF9 + def __str__(self) -> str: + return f"{int(self):02X} ({self.name})" + @unique class OBJECT_ID(IntEnum): @@ -297,14 +302,6 @@ class TOUCH_POLICY(IntEnum): PUK_P2 = 0x81 -class InvalidPinError(CommandError): - def __init__(self, attempts_remaining): - super(InvalidPinError, self).__init__( - "Invalid PIN/PUK. Remaining attempts: %d" % attempts_remaining - ) - self.attempts_remaining = attempts_remaining - - def _pin_bytes(pin): pin = pin.encode() if len(pin) > PIN_LEN: @@ -312,15 +309,13 @@ def _pin_bytes(pin): return pin.ljust(PIN_LEN, b"\xff") -def _retries_from_sw(version, sw): +def _retries_from_sw(sw): if sw == SW.AUTH_METHOD_BLOCKED: return 0 - if version < (1, 0, 4): - if 0x6300 <= sw <= 0x63FF: - return sw & 0xFF - else: - if 0x63C0 <= sw <= 0x63CF: - return sw & 0x0F + if sw & 0xFFF0 == 0x63C0: + return sw & 0x0F + elif sw & 0xFF00 == 0x6300: + return sw & 0xFF return None @@ -353,9 +348,12 @@ def public_key(self): def _pad_message(key_type, message, hash_algorithm, padding): if key_type.algorithm == ALGORITHM.EC: - h = hashes.Hash(hash_algorithm, default_backend()) - h.update(message) - hashed = h.finalize() + 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") @@ -428,16 +426,12 @@ def _parse_device_public_key(key_type, encoded): else: curve = ec.SECP384R1 - try: - # Added in cryptography 2.5 - return ec.EllipticCurvePublicKey.from_encoded_point(curve(), data[0x86]) - except AttributeError: - return ec.EllipticCurvePublicNumbers.from_encoded_point( - curve(), data[0x86] - ).public_key(default_backend()) + 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) @@ -449,37 +443,53 @@ def __init__(self, connection: SmartCardConnection): 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, @@ -518,7 +528,15 @@ def set_management_key( 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: @@ -531,54 +549,109 @@ def set_management_key( 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(self.version, e.sw) + 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(self.version, e.sw) + 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: - 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 + """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) @@ -591,6 +664,12 @@ def get_management_key_metadata(self) -> ManagementKeyMetadata: ) 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] @@ -610,34 +689,80 @@ def sign( 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: @@ -658,6 +783,13 @@ def get_object(self, object_id: int) -> bytes: 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, @@ -665,32 +797,72 @@ def put_object(self, object_id: int, data: Optional[bytes] = None) -> None: 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_info = data.get(TAG_CERT_INFO) - if cert_info and cert_info[0] != 0: - raise NotSupportedError("Compressed certificates are not supported") + 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( - data[TAG_CERTIFICATE], default_backend() - ) + 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) -> None: + 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, b"\0") + Tlv(TAG_LRC) + 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( @@ -703,6 +875,16 @@ def put_key( 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 @@ -726,7 +908,12 @@ def put_key( 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( @@ -736,6 +923,16 @@ def generate_key( 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)) @@ -743,14 +940,26 @@ def generate_key( 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): @@ -759,7 +968,7 @@ def _change_reference(self, ins, p2, value1, value2): 0, ins, 0, p2, _pin_bytes(value1) + _pin_bytes(value2) ) except ApduError as e: - retries = _retries_from_sw(self.version, e.sw) + retries = _retries_from_sw(e.sw) if retries is None: raise if p2 == PIN_P2: diff --git a/yubikit/py.typed b/yubikit/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/yubikit/support.py b/yubikit/support.py new file mode 100644 index 000000000..b854b1fcf --- /dev/null +++ b/yubikit/support.py @@ -0,0 +1,453 @@ +# 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 diff --git a/yubikit/yubiotp.py b/yubikit/yubiotp.py index 5c9529453..ddd55e11f 100644 --- a/yubikit/yubiotp.py +++ b/yubikit/yubiotp.py @@ -26,7 +26,6 @@ # POSSIBILITY OF SUCH DAMAGE. from .core import ( - AID, TRANSPORT, Version, bytes2int, @@ -42,7 +41,7 @@ OtpProtocol, CommandRejectedError, ) -from .core.smartcard import SmartCardConnection, SmartCardProtocol +from .core.smartcard import AID, SmartCardConnection, SmartCardProtocol import abc import struct @@ -50,6 +49,9 @@ 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") @@ -195,6 +197,13 @@ class EXTFLAG(IntFlag): 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 = ( @@ -260,22 +269,25 @@ def _build_update(ext, tkt, cfg, acc_code=None): ) -def _build_ndef_config(uri): - uri = uri or DEFAULT_NDEF_URI - for i, prefix in enumerate(NDEF_URL_PREFIXES): - if uri.startswith(prefix): - id_code = i + 1 - uri = uri[len(prefix) :] - break +def _build_ndef_config(value, ndef_type=NDEF_TYPE.URI): + if ndef_type == NDEF_TYPE.URI: + if value is None: + value = DEFAULT_NDEF_URI + for i, prefix in enumerate(NDEF_URL_PREFIXES): + if value.startswith(prefix): + id_code = i + 1 + value = value[len(prefix) :] + break + else: + id_code = 0 + data = bytes([id_code]) + value.encode() else: - id_code = 0 - uri_bytes = uri.encode() - data_len = 1 + len(uri_bytes) - if data_len > NDEF_DATA_SIZE: + if value is None: + value = "" + data = b"\x02en" + value.encode() + if len(data) > NDEF_DATA_SIZE: raise ValueError("URI payload too large") - return struct.pack(" C class ConfigState: - """The confgiuration state of the YubiOTP application.""" + """The configuration state of the YubiOTP application.""" def __init__(self, version: Version, touch_level: int): self.version = version @@ -604,13 +616,20 @@ def is_led_inverted(self) -> bool: return self.flags & CFGSTATE.LED_INV != 0 def __repr__(self): - return "ConfigState(configured: %s, touch_triggered: %s, led_inverted: %s)" % ( - (self.is_configured(SLOT.ONE), self.is_configured(SLOT.TWO)), - (self.is_touch_triggered(SLOT.ONE), self.is_touch_triggered(SLOT.TWO)) - if self.version[0] >= 3 - else None, - self.is_led_inverted(), - ) + 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): @@ -654,6 +673,7 @@ def send_and_receive(self, slot, data, expected_len, event=None, on_keepalive=No INS_CONFIG = 0x01 +INS_YK2_STATUS = 0x03 class _YubiOtpSmartCardBackend(_Backend): @@ -667,6 +687,9 @@ def close(self): def write_update(self, slot, data): status = self.protocol.send_apdu(0, INS_CONFIG, slot, 0, data) + if not status: # Some commands don't return status on some YubiKeys + status = self.protocol.send_apdu(0, INS_YK2_STATUS, 0, 0) + prev_prog_seq, self._prog_seq = self._prog_seq, status[3] if self._prog_seq == prev_prog_seq + 1: return status @@ -686,6 +709,8 @@ def send_and_receive(self, slot, data, expected_len, event=None, on_keepalive=No class YubiOtpSession: + """A session with the YubiOTP application.""" + def __init__(self, connection: Union[OtpConnection, SmartCardConnection]): if isinstance(connection, OtpConnection): otp_protocol = OtpProtocol(connection) @@ -717,6 +742,11 @@ def __init__(self, connection: Union[OtpConnection, SmartCardConnection]): ) 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() @@ -726,17 +756,22 @@ 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), @@ -762,6 +809,13 @@ def update_configuration( 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" @@ -771,6 +825,8 @@ def update_configuration( "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), @@ -778,9 +834,18 @@ def update_configuration( ) 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, @@ -790,6 +855,12 @@ def delete_slot(self, slot: SLOT, cur_acc_code: Optional[bytes] = None) -> None: 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( @@ -797,10 +868,20 @@ def set_ndef_configuration( 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), + _build_ndef_config(uri, ndef_type), cur_acc_code, ) @@ -811,7 +892,15 @@ def calculate_hmac_sha1( 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(