Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-pynitrokey for openSUSE:Factory checked in at 2026-09-21 12:23:45 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-pynitrokey (Old) and /work/SRC/openSUSE:Factory/.python-pynitrokey.new.383539 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-pynitrokey" Mon Sep 21 12:23:45 2026 rev:29 rq:1379360 version:0.13.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-pynitrokey/python-pynitrokey.changes 2026-06-19 17:21:50.787045097 +0200 +++ /work/SRC/openSUSE:Factory/.python-pynitrokey.new.383539/python-pynitrokey.changes 2026-09-21 12:23:48.066750733 +0200 @@ -1,0 +2,23 @@ +Sun Sep 20 11:21:04 UTC 2026 - Johannes Kastl <[email protected]> + +- update to 13.0.0: + This release adds support for NethHSM v5 and the fido2 + provision-credential command, updates the minimum Python version + to 3.11 and fixes some bugs. + * PIV factory-reset: show proper error message when the PIN isn't + locked in #771 + * Windows versioninfo and version format in #774 + * pre-registration for entra in #783 + * nethsm generate-key: Fix interactive mechanism selection in + #784 + * PIV import and PUK fixes in #766 + * fido2: plug some resource leaks in the client in #781 + * Add NetHSM features from Nethsm v3.0.0rc in #787 + * Entra provision in #789 + * Update clustering features for NetHSM v5.0 and release + v0.13.0rc1 in #788 +- allow cryptography < 53, see + https://github.com/Nitrokey/pynitrokey/pull/798 + https://github.com/Nitrokey/pynitrokey/issues/797 + +------------------------------------------------------------------- Old: ---- pynitrokey-0.12.3.tar.gz New: ---- pynitrokey-0.13.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-pynitrokey.spec ++++++ --- /var/tmp/diff_new_pack.SRlR5K/_old 2026-09-21 12:23:48.916786260 +0200 +++ /var/tmp/diff_new_pack.SRlR5K/_new 2026-09-21 12:23:48.917786302 +0200 @@ -18,7 +18,7 @@ %{?sle15_python_module_pythons} Name: python-pynitrokey -Version: 0.12.3 +Version: 0.13.0 Release: 0 Summary: Python Library for Nitrokey devices License: Apache-2.0 OR MIT @@ -33,12 +33,12 @@ # BuildRequires: %{python_module cffi >= 1.15 with %python-cffi < 3} BuildRequires: %{python_module click >= 8.2 with %python-click < 9} -BuildRequires: %{python_module cryptography >= 43 with %python-cryptography < 50} +BuildRequires: %{python_module cryptography >= 43 with %python-cryptography < 53} BuildRequires: %{python_module fido2 >= 2 with %python-fido2 < 3} # https://github.com/Nitrokey/pynitrokey/issues/601 BuildRequires: %{python_module hidapi >= 0.14.0.post2 with %python-hidapi < 0.14.0.post3} BuildRequires: %{python_module libusb1 >= 3 with %python-libusb1 < 4} -BuildRequires: %{python_module nethsm >= 2.1.1 with %python-nethsm < 3} +BuildRequires: %{python_module nethsm >= 3 with %python-nethsm < 4} BuildRequires: %{python_module nitrokey >= 0.4.2 with %python-nitrokey < 0.5} BuildRequires: %{python_module nkdfu >= 0.2 with %python-nkdfu < 0.3} BuildRequires: %{python_module pyusb >= 1.2 with %python-pyusb < 2} @@ -53,12 +53,12 @@ Requires: (python-cffi >= 1.15 with python-cffi < 3) Requires: (intelhex >= 2.3 with intelhex < 3) Requires: (python-click >= 8.2 with python-click < 9) -Requires: (python-cryptography >= 43 with python-cryptography < 50) +Requires: (python-cryptography >= 43 with python-cryptography < 53) Requires: (python-fido2 >= 2 with python-fido2 < 3) # https://github.com/Nitrokey/pynitrokey/issues/601 Requires: (python-hidapi >= 0.14.0.post2 with python-hidapi < 0.14.0.post3) Requires: (python-libusb1 >= 3 with python-libusb1 < 4) -Requires: (python-nethsm >= 2.1.1 with python-nethsm < 3) +Requires: (python-nethsm >= 3 with python-nethsm < 4) Requires: (python-nitrokey >= 0.4.2 with python-nitrokey < 0.5) Requires: (python-nkdfu >= 0.2 with python-nkdfu < 0.3) Requires: (python-pyusb >= 1.2 with python-pyusb < 2) ++++++ pynitrokey-0.12.3.tar.gz -> pynitrokey-0.13.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pynitrokey-0.12.3/PKG-INFO new/pynitrokey-0.13.0/PKG-INFO --- old/pynitrokey-0.12.3/PKG-INFO 1970-01-01 01:00:00.000000000 +0100 +++ new/pynitrokey-0.13.0/PKG-INFO 1970-01-01 01:00:00.000000000 +0100 @@ -1,20 +1,18 @@ Metadata-Version: 2.4 Name: pynitrokey -Version: 0.12.3 +Version: 0.13.0 Summary: Python client for Nitrokey devices -License: Apache-2.0 OR MIT +License-Expression: Apache-2.0 OR MIT License-File: LICENSES/Apache-2.0.txt License-File: LICENSES/GPL-3.0-or-later.txt License-File: LICENSES/LGPL-3.0-only.txt License-File: LICENSES/MIT.txt Author: Nitrokey Author-email: [email protected] -Requires-Python: >=3.10 +Requires-Python: >=3.11 Classifier: Intended Audience :: Developers Classifier: Intended Audience :: End Users/Desktop -Classifier: License :: Other/Proprietary License Classifier: Programming Language :: Python :: 3 -Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 @@ -28,7 +26,7 @@ Requires-Dist: hidapi (>=0.14,<0.15) Requires-Dist: intelhex (>=2.3,<3) Requires-Dist: libusb1 (>=3,<4) -Requires-Dist: nethsm (>=2.1.1,<3) +Requires-Dist: nethsm (>=3,<4) Requires-Dist: nitrokey (>=0.4.2,<0.5) Requires-Dist: nkdfu (>=0.2,<0.3) Requires-Dist: pyscard (>=2,<3) ; extra == "pcsc" @@ -96,7 +94,7 @@ ## Compatibility -`nitropy` requires Python 3.10 or later. +`nitropy` requires Python 3.11 or later. ## Development diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pynitrokey-0.12.3/README.md new/pynitrokey-0.13.0/README.md --- old/pynitrokey-0.12.3/README.md 1970-01-01 01:00:00.000000000 +0100 +++ new/pynitrokey-0.13.0/README.md 1970-01-01 01:00:00.000000000 +0100 @@ -53,7 +53,7 @@ ## Compatibility -`nitropy` requires Python 3.10 or later. +`nitropy` requires Python 3.11 or later. ## Development diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pynitrokey-0.12.3/pynitrokey/cli/fido2.py new/pynitrokey-0.13.0/pynitrokey/cli/fido2.py --- old/pynitrokey-0.12.3/pynitrokey/cli/fido2.py 1970-01-01 01:00:00.000000000 +0100 +++ new/pynitrokey-0.13.0/pynitrokey/cli/fido2.py 1970-01-01 01:00:00.000000000 +0100 @@ -3,11 +3,12 @@ # SPDX-License-Identifier: Apache-2.0 OR MIT import hashlib +import json import secrets import time from dataclasses import fields from getpass import getpass -from typing import Any, Optional +from typing import Any, Callable, Optional, TextIO import click from fido2.attestation.base import InvalidSignature @@ -38,6 +39,8 @@ from pynitrokey.cli.exceptions import CliException from pynitrokey.exceptions import NonUniqueDeviceError, NoSoloFoundError +from pynitrokey.fido2.entra import Entra +from pynitrokey.fido2.provision_credential import ProvisionCredential from pynitrokey.helpers import AskUser, local_critical, local_print, require_windows_admin # https://pocoo-click.readthedocs.io/en/latest/commands/#nested-handling-and-contexts @@ -602,6 +605,36 @@ _device(serial).wink() +provcred_services: dict[str, Callable[[], ProvisionCredential]] = { + Entra.get_service_name().lower(): Entra +} + + [email protected]() [email protected]( + "-s", + "--serial", + help="Serial number of Nitrokey to use. Prefix with 'device=' to provide device file, e.g. 'device=/dev/hidraw5'.", +) [email protected]("service", type=click.Choice(list(provcred_services.keys()), case_sensitive=False)) [email protected]("user") [email protected]("--config", "-c", type=click.File("r"), required=True, help="JSON config file") [email protected]("--create-user", is_flag=True, default=False, help="Create user if it does not exist") +def provision_credential( + serial: Optional[str], service: str, user: str, config: TextIO, create_user: bool +) -> None: + """Pre-register Nitrokey for services.""" + config_dict = json.load(config) + provcred_service = provcred_services[service.lower()] + service_ob = provcred_service() + service_ob.set_config(config_dict) + host = service_ob.get_rp_id() + device = _device(serial) + client = _fido2(device, host) + result = service_ob.provision(create_user, user, client) + local_print(result) + + fido2.add_command(challenge_response) fido2.add_command(change_pin) fido2.add_command(delete_credential) @@ -612,3 +645,4 @@ fido2.add_command(set_pin) fido2.add_command(verify) fido2.add_command(wink) +fido2.add_command(provision_credential) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pynitrokey-0.12.3/pynitrokey/cli/nethsm.py new/pynitrokey-0.13.0/pynitrokey/cli/nethsm.py --- old/pynitrokey-0.12.3/pynitrokey/cli/nethsm.py 1970-01-01 01:00:00.000000000 +0100 +++ new/pynitrokey-0.13.0/pynitrokey/cli/nethsm.py 1970-01-01 01:00:00.000000000 +0100 @@ -20,7 +20,7 @@ from cryptography.hazmat.primitives.asymmetric import ec, ed25519, rsa from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat from cryptography.hazmat.primitives.serialization.pkcs12 import load_key_and_certificates -from nethsm import Authentication, Base64, ClusterJoinData, NetHSM, State +from nethsm import Authentication, Base64, ClusterJoinData, ClusterMember, NetHSM, State from nethsm.backup import EncryptedBackup from pynitrokey.cli.exceptions import CliException @@ -552,21 +552,24 @@ @click.option("--details/--no-details", default=True, help="Also query the key data") @click.option("-f", "--filter", type=str, help="Filter keys by tags for respective user") @click.option("-p", "--prefix", type=str, help="Only list keys with the given prefix") [email protected]("-s", "--label", type=str, help="Filter keys by label") @click.pass_context -def list_keys(ctx: Context, details: bool, filter: Optional[str], prefix: Optional[str]) -> None: +def list_keys( + ctx: Context, details: bool, filter: Optional[str], prefix: Optional[str], label: Optional[str] +) -> None: """List all keys on the NetHSM. This command requires authentication as a user with the Administrator or Operator role.""" with connect(ctx) as nethsm: - key_ids = nethsm.list_keys(filter, prefix=prefix) + key_ids = nethsm.list_keys(filter, prefix=prefix, label=label) print(f"Keys on NetHSM {nethsm.host}:") print() headers = ["Key ID"] if details: - headers += ["Type", "Mechanisms", "Operations", "Tags"] + headers += ["Type", "Mechanisms", "Operations", "Tags", "Label"] data = [] for key_id in key_ids: key = nethsm.get_key(key_id=key_id) @@ -577,6 +580,7 @@ ", ".join([m.value for m in key.mechanisms]), key.operations, ", ".join(key.tags) if key.tags is not None else "", + key.label, ] ) else: @@ -608,6 +612,9 @@ tags = ", ".join(key.tags) print(f"Tags: {tags}") + if key.label: + print(f"Label: {key.label}") + if isinstance(key.public_key, nethsm_sdk.RsaPublicKey): print(f"Modulus: {key.public_key.modulus}") print(f"Public exponent: {key.public_key.public_exponent}") @@ -670,7 +677,6 @@ "finish the list of mechanisms." ) - mechanism_type = click.Choice(available_mechanisms, case_sensitive=False) mechanisms: list[str] = [] cont = True while cont: @@ -679,9 +685,9 @@ if mechanisms: prompt_text += " (or empty string to continue)" default = "" - mechanism = prompt( + mechanism_str = prompt( prompt_text, - type=mechanism_type, + type=click.Choice(available_mechanisms, case_sensitive=False), default=default, show_choices=False, show_default=False, @@ -690,10 +696,9 @@ if "" not in available_mechanisms: available_mechanisms.append("") - assert isinstance(mechanism, str) - if mechanism: - mechanisms.append(mechanism) - available_mechanisms.remove(mechanism) + if mechanism_str: + mechanisms.append(mechanism_str) + available_mechanisms.remove(mechanism_str) else: cont = False @@ -788,10 +793,16 @@ ) @click.option("--tags", type=str, multiple=True, help="The tags for the new key") @click.option("-k", "--key-id", help="The ID of the new key") [email protected]("-s", "--label", help="The label of the new key") @click.argument("filename") @click.pass_context def import_key( - ctx: Context, mechanisms: list[str], tags: list[str], key_id: Optional[str], filename: str + ctx: Context, + mechanisms: list[str], + tags: list[str], + key_id: Optional[str], + label: Optional[str], + filename: str, ) -> None: """Import a key pair from a PEM file into the NetHSM. @@ -810,6 +821,7 @@ mechanisms=[nethsm_sdk.KeyMechanism.from_string(m) for m in mechanisms], tags=tags, private_key=private_key, + label=label, ) print(f"Key {key_id} added to NetHSM {nethsm.host}") @@ -826,6 +838,7 @@ @click.option("--tags", type=str, multiple=True, help="The tags for the new key") @click.option("-k", "--key-id", help="The ID of the new key") @click.option("-p", "--password", help="Password for the PKCS#12 archive") [email protected]("-s", "--label", help="The label of the new key") @click.argument("filename", type=click.File("rb")) @click.pass_context def import_pkcs12( @@ -834,6 +847,7 @@ tags: list[str], key_id: Optional[str], password: Optional[str], + label: Optional[str], filename: BinaryIO, ) -> None: """Import a PKCS#12 archive from a file into the NetHSM. @@ -879,6 +893,7 @@ mechanisms=[nethsm_sdk.KeyMechanism.from_string(m) for m in mechanisms], tags=tags, private_key=private_key_pem, + label=label, ) nethsm.set_key_certificate(key_id, certificate_pem) print(f"Key and certificate {key_id} added to NetHSM {nethsm.host}") @@ -898,9 +913,15 @@ ) @click.option("-l", "--length", type=int, prompt=True, help="The length of the generated key") @click.option("-k", "--key-id", help="The ID of the generated key") [email protected]("-s", "--label", help="Label of the key") @click.pass_context def generate_key( - ctx: Context, type: str, mechanisms: list[str], length: int, key_id: Optional[str] + ctx: Context, + type: str, + mechanisms: list[str], + length: int, + key_id: Optional[str], + label: Optional[str], ) -> None: """Generate a key pair on the NetHSM. @@ -913,6 +934,7 @@ [nethsm_sdk.KeyMechanism.from_string(m) for m in mechanisms], length, key_id, + label, ) print(f"Key {key_id} generated on NetHSM {nethsm.host}") @@ -927,6 +949,7 @@ @click.option("--logging", is_flag=True, help="Query the logging configuration") @click.option("--network", is_flag=True, help="Query the network configuration") @click.option("--time", is_flag=True, help="Query the system time") [email protected]("--ntp", is_flag=True, help="Query the NTP configuration") @click.option("--unattended-boot", is_flag=True, help="Query the unattended boot configuration") @click.option("--public-key", is_flag=True, help="Query the public key") @click.option("--certificate", is_flag=True, help="Query the certificate") @@ -936,6 +959,7 @@ logging: bool, network: bool, time: bool, + ntp: bool, unattended_boot: bool, public_key: bool, certificate: bool, @@ -949,7 +973,7 @@ role.""" with connect(ctx) as nethsm: print(f"Configuration for NetHSM {nethsm.host}:") - show_all = not any([logging, network, time, unattended_boot, public_key, certificate]) + show_all = not any([logging, network, time, ntp, unattended_boot, public_key, certificate]) if show_all or logging: logging_config = nethsm.get_config_logging() @@ -975,6 +999,14 @@ time_config = nethsm.get_config_time() print(" Time: ", time_config) + if show_all or ntp: + ntp_config = nethsm.get_config_ntp() + print(" NTP:") + ntp_ip = _optional_or(ntp_config.ntp_ip, "not configured") + print(" NTP Server IP:", ntp_ip) + nts_name = _optional_or(ntp_config.nts_name, "not configured") + print(" NTS Name: ", nts_name) + if show_all or unattended_boot: unattended_boot_config = nethsm.get_config_unattended_boot() print(" Unattended boot:", unattended_boot_config) @@ -1157,6 +1189,48 @@ @nethsm.command() [email protected]("-a", "--ntp-ip", help="IP Address of NTP Server") [email protected]("-s", "--nts-name", help="Name of NTP Server") [email protected]_context +def set_ntp_config(ctx: Context, ntp_ip: Optional[str], nts_name: Optional[str]) -> None: + """Set the NTP configuration of a NetHSM. + + This command requires authentication as a user with the Administrator + role.""" + + with connect(ctx) as nethsm: + nethsm.set_ntp_config(ntp_ip=ntp_ip, nts_name=nts_name) + print(f"Updated the NTP configuration for NetHSM {nethsm.host}") + + [email protected]() [email protected]("key-id", type=str, required=True) [email protected]("label", type=str, required=True) [email protected]_context +def set_key_label(ctx: Context, key_id: str, label: str) -> None: + """Set the label of a key. + + This command requires authentication as a user with the Administrator + role.""" + with connect(ctx) as nethsm: + nethsm.set_key_label(key_id, label) + print(f"Updated label for key {key_id} on NetHSM {nethsm.host}") + + [email protected]() [email protected]("key-id", type=str, required=True) [email protected]_context +def delete_key_label(ctx: Context, key_id: str) -> None: + """Delete the label of a key. + + This command requires authentication as a user with the Administrator + role.""" + with connect(ctx) as nethsm: + nethsm.set_key_label(key_id, None) + print(f"Updated label for key {key_id} on NetHSM {nethsm.host}") + + [email protected]() @click.argument("status", type=UNATTENDED_BOOT_STATUS_TYPE) @click.pass_context def set_unattended_boot(ctx: Context, status: str) -> None: @@ -1801,6 +1875,16 @@ nethsm.factory_reset() +def print_cluster_members(cluster_members: list[ClusterMember]) -> None: + n = len(cluster_members) + print(f"{n} cluster members:") + for m in cluster_members: + print(f"- id: {m.id}") + print(f" name: {m.name}") + print(f" peer URLs: {m.urls}") + print(f" learner: {m.learner}") + + @nethsm.command() @click.option("--url", multiple=True) @click.argument("join_data_path", type=Path) @@ -1809,9 +1893,11 @@ with connect(ctx) as nethsm: join_data = nethsm.add_cluster_member(list(url)) - s = json.dumps(join_data.to_dict()) + print_cluster_members(join_data.members) + + s = json.dumps(join_data.to_cluster_join_data().to_dict()) join_data_path.write_text(s) - print("Wrote join data to {join_data_path}") + print(f"Wrote join data to {join_data_path}") @nethsm.command() @@ -1820,12 +1906,7 @@ with connect(ctx) as nethsm: cluster_members = nethsm.list_cluster_members() - n = len(cluster_members) - print(f"{n} cluster members:") - for m in cluster_members: - print(f"- id: {m.id}") - print(f" name: {m.name}") - print(f" peer URLs: {m.urls}") + print_cluster_members(cluster_members) @nethsm.command() @@ -1859,6 +1940,53 @@ @nethsm.command() [email protected]_context +def get_cluster_diagnostics(ctx: Context) -> None: + with connect(ctx) as nethsm: + diagnostics = nethsm.get_cluster_diagnostics() + + state = diagnostics.state + snapshot = diagnostics.snapshot + + print("Cluster diagnostics:") + print(" State:") + print(f" running: {state.running}") + if state.exited is not None: + print(f" exited: {state.exited}") + if state.signaled is not None: + print(f" signaled: {state.signaled}") + if state.stopped is not None: + print(f" stopped: {state.stopped}") + if snapshot is not None: + print(" Snapshot:") + print(f" hash: {snapshot.hash}") + print(f" revision: {snapshot.revision}") + print(f" total key: {snapshot.total_key}") + print(f" total size: {snapshot.total_size}") + if snapshot.version is not None: + print(f" version: {snapshot.version}") + if diagnostics.logs: + print(" Log items:") + for item in diagnostics.logs: + print(f" - [{item.level}] {item.msg}") + + [email protected]() [email protected]_context +def force_new_cluster(ctx: Context) -> None: + with connect(ctx) as nethsm: + nethsm.force_new_cluster() + + [email protected]() [email protected]("member-id") [email protected]_context +def promote_cluster_member(ctx: Context, member_id: str) -> None: + with connect(ctx) as nethsm: + nethsm.promote_cluster_member(member_id) + + [email protected]() @click.pass_context def get_cluster_ca_certificate(ctx: Context) -> None: with connect(ctx) as nethsm: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pynitrokey-0.12.3/pynitrokey/cli/nk3/piv.py new/pynitrokey-0.13.0/pynitrokey/cli/nk3/piv.py --- old/pynitrokey-0.12.3/pynitrokey/cli/nk3/piv.py 1970-01-01 01:00:00.000000000 +0100 +++ new/pynitrokey-0.13.0/pynitrokey/cli/nk3/piv.py 1970-01-01 01:00:00.000000000 +0100 @@ -22,7 +22,7 @@ # Pyscard does not have wheels for all targets, leading to installation errors # It is therefore made optional try: - from pynitrokey.nk3.piv_app import PivApp, find_by_id + from pynitrokey.nk3.piv_app import PivApp, StatusError, find_by_id default_admin_key = "010203040506070801020304050607080102030405060708" all_key_ids = [ @@ -268,8 +268,8 @@ key_ref, Tlv.build( [ - (0x01, key.p.to_bytes(256, "big")), - (0x02, key.q.to_bytes(256, "big")), + (0x01, key.p.to_bytes(128, "big")), + (0x02, key.q.to_bytes(128, "big")), (0x03, public_key.e.to_bytes((public_key.e.bit_length() + 7) // 8, "big")), ] ), @@ -403,11 +403,14 @@ device = PivApp() try: device.factory_reset() - except ValueError: - local_critical( - "Factory reset could not be performed. You first need to lock the PIN with 3 failed attempts", - support_hint=False, - ) + except StatusError as e: + if e.value == 0x6985: + local_critical( + "Factory reset could not be performed. You first need to lock the PIN with 3 failed attempts", + support_hint=False, + ) + else: + raise e from e local_print("Factory reset successfully") KEY_TO_CERT_OBJ_ID_MAP = { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pynitrokey-0.12.3/pynitrokey/fido2/client.py new/pynitrokey-0.13.0/pynitrokey/fido2/client.py --- old/pynitrokey-0.12.3/pynitrokey/fido2/client.py 1970-01-01 01:00:00.000000000 +0100 +++ new/pynitrokey-0.13.0/pynitrokey/fido2/client.py 1970-01-01 01:00:00.000000000 +0100 @@ -13,7 +13,8 @@ from fido2.ctap import CtapError from fido2.ctap1 import Ctap1 from fido2.ctap2.base import Ctap2 -from fido2.hid import CTAPHID, CtapHidDevice, open_device +from fido2.hid import CTAPHID, CtapHidDevice, list_descriptors, open_connection, open_device +from fido2.hid.base import HidDescriptor from intelhex import IntelHex import pynitrokey.exceptions @@ -21,18 +22,25 @@ from pynitrokey.fido2.commands import SoloBootloader, SoloExtension from pynitrokey.helpers import local_critical +VIDPID = [ + (0x0483, 0xA2CA), # + (0x20A0, 0x42B3), # ... + (0x20A0, 0x42B1), # NK FIDO2 +] + + +def list_ctaphid_descriptors() -> list[HidDescriptor]: + return [ + desc + for desc in list_descriptors() # type: ignore[no-untyped-call] + if (desc.vid, desc.pid) in VIDPID + ] + def list_ctaphid_devices() -> list[CtapHidDevice]: - hid_devices = list(CtapHidDevice.list_devices()) return [ - d - for d in hid_devices - if (d.descriptor.vid, d.descriptor.pid) - in [ - (0x0483, 0xA2CA), # - (0x20A0, 0x42B3), # ... - (0x20A0, 0x42B1), # NK FIDO2 - ] + CtapHidDevice(desc, open_connection(desc)) # type: ignore[no-untyped-call] + for desc in list_ctaphid_descriptors() ] @@ -69,8 +77,11 @@ solo_serial = solo_serial.split("=")[1] found_dev = open_device(solo_serial) else: - devices = list_ctaphid_devices() - devices = [d for d in devices if d.descriptor.serial_number == solo_serial] + devices = [ + CtapHidDevice(desc, open_connection(desc)) # type: ignore[no-untyped-call] + for desc in list_ctaphid_descriptors() + if desc.serial_number == solo_serial + ] else: devices = list_ctaphid_devices() if len(devices) > 1: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pynitrokey-0.12.3/pynitrokey/fido2/entra.py new/pynitrokey-0.13.0/pynitrokey/fido2/entra.py --- old/pynitrokey-0.12.3/pynitrokey/fido2/entra.py 1970-01-01 01:00:00.000000000 +0100 +++ new/pynitrokey-0.13.0/pynitrokey/fido2/entra.py 1970-01-01 01:00:00.000000000 +0100 @@ -0,0 +1,166 @@ +import json +import random +import re +import string +import time +from datetime import datetime, timedelta +from typing import Any + +import requests +from fido2.client import Fido2Client +from fido2.utils import websafe_decode, websafe_encode +from fido2.webauthn import PublicKeyCredentialCreationOptions + +from .provision_credential import ProvisionCredential + + +class Entra(ProvisionCredential): + service_name = "Entra" + rp_id = "login.microsoft.com" + + def __init__(self) -> None: + self._reset_token() + + def _reset_token(self) -> None: + self.token = "" + self.token_validity = datetime.fromtimestamp(0) + + def _generate_password(self, length: int = 16) -> str: + characters = string.ascii_letters + string.digits + string.punctuation + password = "".join(random.choice(characters) for _ in range(length)) + return password + + def _get_endpoint(self, graph_version: str = "v1.0") -> str: + graph_endpoint = f"https://graph.microsoft.com/{graph_version}" + return graph_endpoint + + def _set_http_headers(self) -> dict[str, str]: + return { + "Accept": "application/json", + "Authorization": self.get_token(), + "Content-Type": "application/json", + "Accept-Encoding": "gzip, deflate, br", + } + + def _get_username(self, name: str) -> str: + domain = self.config["domain"] + assert name.count("@") < 1 or (name.count("@") == 1 and name.endswith(f"@{domain}")), ( + "Invalid name" + ) + temp = name.split("@")[0] + temp = re.sub(r"[^a-zA-Z0-9]", "", temp) + return f"{temp}@{domain}" + + def _get_access_token_for_microsoft_graph(self) -> str: + headers = {"Content-Type": "application/x-www-form-urlencoded"} + token_endpoint = ( + "https://login.microsoftonline.com/" + self.config["tenant"] + "/oauth2/v2.0/token" + ) + + body = { + "grant_type": "client_credentials", + "client_id": self.config["client"], + "client_secret": self.config["secret"], + "scope": "https://graph.microsoft.com/.default", + } + + token_response = requests.post(token_endpoint, data=body, headers=headers) + decoded_response = json.loads(token_response.content) + assert "access_token" in decoded_response, "Authentication failed" + self.token = decoded_response.get("access_token", "") + expiry = decoded_response.get("expires_in", 0) + self.token_validity = datetime.now() + timedelta(seconds=expiry) + return str(decoded_response.get("access_token", "")) + + def get_token(self) -> str: + if self.token and datetime.now() < self.token_validity: + return self.token + return self._get_access_token_for_microsoft_graph() + + def validate_config(self, config: dict[str, Any]) -> None: + assert "tenant" in config, "Tenant not found" + assert "client" in config, "Client ID not found" + assert "secret" in config, "Client Secret not found" + assert "domain" in config, "Domain not found" + + def create_user(self, user: str) -> bool: + endpoint = f"{self._get_endpoint()}/users" + email = self._get_username(user) + name = email.split("@")[0] + body = { + "accountEnabled": True, + "displayName": user, + "mailNickName": name, + "passwordProfile": { + "forceChangePasswordNextSignIn": False, + "password": self._generate_password(), + }, + "userPrincipalName": email, + } + resp = requests.post(endpoint, json=body, headers=self._set_http_headers()) + success = resp.status_code == 201 + if success: + print("Waiting 5 secs for Graph API to update") + self._reset_token() # Unable to read the user just after creation + time.sleep(5) + return success + + def get_user_id(self, user: str) -> str: + email = self._get_username(user) + endpoint = f"{self._get_endpoint()}/users/{email}?$select=id" + resp = requests.get(endpoint, headers=self._set_http_headers()) + decoded_response = json.loads(resp.content) + assert "id" in decoded_response, "User not found" + return str(decoded_response.get("id")) + + def get_creation_options(self, user: str) -> dict[str, Any]: + endpoint_base = self._get_endpoint("beta") + endpoint = f"{endpoint_base}/users/{user}/authentication/fido2Methods/creationOptions" + resp = requests.get(endpoint, headers=self._set_http_headers()) + decoded_response = json.loads(resp.content) + assert "publicKey" in decoded_response + pubkey: dict[str, Any] = decoded_response.get("publicKey") + pubkey["challenge"] = websafe_decode(pubkey["challenge"]) + pubkey["user"]["id"] = websafe_decode(pubkey["user"]["id"]) + if "excludeCredentials" in pubkey: + for i in range(len(pubkey["excludeCredentials"])): + pubkey["excludeCredentials"][i]["id"] = websafe_decode( + pubkey["excludeCredentials"][i]["id"][:-1] + ) # That -1 is because https://learn.microsoft.com/en-us/graph/api/fido2authenticationmethod-creationoptions?view=graph-rest-beta&tabs=http#response + + return pubkey + + def make_creds(self, pubkey: dict[str, Any], client: Fido2Client) -> dict[str, Any]: + result = client.make_credential(PublicKeyCredentialCreationOptions.from_dict(pubkey)) + attestation_obj = result.response.attestation_object + client_data = result.response.client_data + cred_id = ( + attestation_obj.auth_data.credential_data.credential_id + if attestation_obj.auth_data.credential_data is not None + else b"" + ) + + return { + "id": websafe_encode(cred_id), + "response": { + "clientDataJson": websafe_encode(client_data), + "attestationObject": websafe_encode(attestation_obj), + }, + } + + def save_creds(self, att_resp: dict[str, Any], user: str, name: str) -> str: + endpoint_base = self._get_endpoint("beta") + endpoint = f"{endpoint_base}/users/{user}/authentication/fido2Methods" + body = {"displayName": name, "publicKeyCredential": att_resp} + resp = requests.post(endpoint, json=body, headers=self._set_http_headers()) + assert resp.status_code == 201, "Credential creation failed" + decoded_response = json.loads(resp.content) + return str(decoded_response.get("id")) + + def enroll_device(self, user: str, client: Fido2Client) -> str: + user_id = self.get_user_id(user) + device_name = self.get_device_name(client) + pubkey = self.get_creation_options(user_id) + resp = self.make_creds(pubkey, client) + cred_id = self.save_creds(resp, user_id, device_name) + return f"Entra credential for {user} pre-registered on {device_name} with Credential ID {cred_id}." diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pynitrokey-0.12.3/pynitrokey/fido2/provision_credential.py new/pynitrokey-0.13.0/pynitrokey/fido2/provision_credential.py --- old/pynitrokey-0.12.3/pynitrokey/fido2/provision_credential.py 1970-01-01 01:00:00.000000000 +0100 +++ new/pynitrokey-0.13.0/pynitrokey/fido2/provision_credential.py 1970-01-01 01:00:00.000000000 +0100 @@ -0,0 +1,64 @@ +from abc import ABC, abstractmethod +from typing import Any + +from fido2.client import Fido2Client +from nitrokey.nk3 import NK3 +from nitrokey.nkpk import NKPK + + +class ProvisionCredential(ABC): + """ + Inherit from this class for other providers + """ + + service_name: str + rp_id: str + + def __init__(self) -> None: + self.config: dict[str, Any] = {} + + def set_config(self, config: dict[str, Any]) -> None: + self.validate_config(config) + self.config = config + + @abstractmethod + def create_user(self, user: str) -> bool: + """Return if user creation was successful""" + pass + + @abstractmethod + def enroll_device(self, user: str, client: Fido2Client) -> str: # Return a status string + """Enroll the device for the user""" + pass + + @abstractmethod + def validate_config(self, config: dict[str, Any]) -> None: # Raise error if validation fails + """Validate config""" + pass + + def get_device_name(self, client: Fido2Client) -> str: + device = client._backend.ctap2.device # type: ignore + try: + name = f"NK3 {str(NK3(device).uuid())[:5]}" + except Exception: + try: + name = f"NKPK {str(NKPK(device).uuid())[:5]}" + except Exception: + name = "Nitrokey" + return name + + @classmethod + def get_rp_id(cls) -> str: + return cls.rp_id + + @classmethod + def get_service_name(cls) -> str: + return cls.service_name + + def provision(self, create: bool, user: str, client: Fido2Client) -> str: + if create: + if self.create_user(user): + print(f"User {user} created on {self.get_service_name()}") + else: + print(f"User {user} not created on {self.get_service_name()}") + return self.enroll_device(user, client) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pynitrokey-0.12.3/pynitrokey/nk3/piv_app.py new/pynitrokey-0.13.0/pynitrokey/nk3/piv_app.py --- old/pynitrokey-0.12.3/pynitrokey/nk3/piv_app.py 1970-01-01 01:00:00.000000000 +0100 +++ new/pynitrokey-0.13.0/pynitrokey/nk3/piv_app.py 1970-01-01 01:00:00.000000000 +0100 @@ -259,7 +259,7 @@ def change_puk(self, old_puk: str, new_puk: str) -> None: old_puk_bytes = old_puk.encode("utf-8") new_puk_bytes = new_puk.encode("utf-8") - if len(old_puk_bytes) != 8 or len(new_puk) != 8: + if len(old_puk_bytes) != 8 or len(new_puk_bytes) != 8: local_critical("PUK must be 8 bytes long", support_hint=False) body = old_puk_bytes + new_puk_bytes self.send_receive(0x24, 0, 0x81, body) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pynitrokey-0.12.3/pyproject.toml new/pynitrokey-0.13.0/pyproject.toml --- old/pynitrokey-0.12.3/pyproject.toml 1970-01-01 01:00:00.000000000 +0100 +++ new/pynitrokey-0.13.0/pyproject.toml 1970-01-01 01:00:00.000000000 +0100 @@ -8,14 +8,14 @@ [project] name = "pynitrokey" -version = "0.12.3" +version = "0.13.0" description = "Python client for Nitrokey devices" -license = { text = "Apache-2.0 OR MIT" } +license = "Apache-2.0 OR MIT" authors = [ { name = "Nitrokey", email = "[email protected]" }, ] readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" dynamic = ["classifiers"] dependencies = [ "cffi >=1.15, <3", @@ -29,7 +29,7 @@ "hidapi ==0.14.0.post2 ; sys_platform == 'linux'", "intelhex >=2.3, <3", "libusb1 >=3, <4", - "nethsm >=2.1.1, <3", + "nethsm >=3, <4", "nitrokey >=0.4.2, <0.5", "nkdfu >=0.2, <0.3", "pyusb >=1.2, <2", @@ -52,7 +52,7 @@ [tool.mypy] mypy_path = "stubs" show_error_codes = true -python_version = "3.10" +python_version = "3.11" strict = true # disable strict checks for old code, see @@ -79,7 +79,7 @@ ] [tool.poetry.dependencies] -python = ">= 3.10, <3.15" +python = ">= 3.11, <3.15" [tool.poetry.group.dev] optional = true @@ -100,7 +100,7 @@ [tool.poetry.group.pyinstaller.dependencies] pyinstaller = "^6.11.1" -pyinstaller-versionfile = { version = "==3.0.0", markers = "sys_platform=='win32'" } +packaging = { version = "^26.2", markers = "sys_platform=='win32'" } [tool.pytest.ini_options] log_cli = false
