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

Reply via email to