From ef3be8452e6afe569ec5d2f513d49f67af38c496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sosth=C3=A8ne=20Gu=C3=A9don?= Date: Wed, 29 Nov 2023 14:19:32 +0100 Subject: [PATCH 1/8] App PIV commands --- pynitrokey/cli/nk3/__init__.py | 1 + pynitrokey/cli/nk3/piv.py | 692 +++++++++++++++++++++++++++++++++ pynitrokey/nk3/piv_app.py | 409 +++++++++++++++++++ pynitrokey/tlv.py | 81 ++++ pyproject.toml | 4 + 5 files changed, 1187 insertions(+) create mode 100644 pynitrokey/cli/nk3/piv.py create mode 100644 pynitrokey/nk3/piv_app.py create mode 100644 pynitrokey/tlv.py diff --git a/pynitrokey/cli/nk3/__init__.py b/pynitrokey/cli/nk3/__init__.py index adfea71b..197517cc 100644 --- a/pynitrokey/cli/nk3/__init__.py +++ b/pynitrokey/cli/nk3/__init__.py @@ -298,4 +298,5 @@ def wink(ctx: Context) -> None: # This import has to be added here to avoid circular dependency # Import "secrets" subcommand from the secrets module +from . import piv # noqa: F401,E402 from . import secrets # noqa: F401,E402 diff --git a/pynitrokey/cli/nk3/piv.py b/pynitrokey/cli/nk3/piv.py new file mode 100644 index 00000000..8be5b3d2 --- /dev/null +++ b/pynitrokey/cli/nk3/piv.py @@ -0,0 +1,692 @@ +import datetime +import sys +from typing import Optional, Sequence + +import click +import cryptography +from asn1crypto import x509 +from asn1crypto.core import ParsableOctetString +from asn1crypto.csr import CertificationRequest, CertificationRequestInfo +from asn1crypto.keys import PublicKeyInfo +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec, rsa +from cryptography.hazmat.primitives.serialization import Encoding + +from pynitrokey.cli.nk3 import nk3 +from pynitrokey.helpers import local_critical, local_print +from pynitrokey.nk3.piv_app import PivApp, find_by_id +from pynitrokey.tlv import Tlv + + +@nk3.group() +def piv() -> None: + """Nitrokey PIV App""" + pass + + +@piv.command() +@click.argument( + "admin-key", + type=click.STRING, + default="010203040506070801020304050607080102030405060708", +) +def admin_auth(admin_key: str) -> None: + try: + admin_key_bytes = bytearray.fromhex(admin_key) + except ValueError: + local_critical( + "Key is expected to be an hexadecimal string", + support_hint=False, + ) + + device = PivApp() + device.authenticate_admin(admin_key_bytes) + local_print("Authenticated successfully") + + +@piv.command() +@click.argument( + "admin-key", + type=click.STRING, + default="010203040506070801020304050607080102030405060708", +) +def init(admin_key: str) -> None: + try: + admin_key_bytes = bytearray.fromhex(admin_key) + except ValueError: + local_critical( + "Key is expected to be an hexadecimal string", + support_hint=False, + ) + + device = PivApp() + device.authenticate_admin(admin_key_bytes) + guid = device.init() + local_print("Device intialized successfully") + local_print(f"GUID: {guid.hex().upper()}") + + +@piv.command() +def info() -> None: + device = PivApp() + serial_number = device.serial() + local_print(f"Device: {serial_number}") + reader = device.reader() + local_print(f"Reader: {reader}") + guid = device.guid() + local_print(f"GUID: {guid.hex().upper()}") + + printed_head = False + for key, slot in KEY_TO_CERT_OBJ_ID_MAP.items(): + cert = device.cert(bytes(bytearray.fromhex(slot))) + if cert is not None: + if not printed_head: + local_print("Keys:") + printed_head = True + parsed_cert = cryptography.x509.load_der_x509_certificate(cert) + local_print(f" {key}") + local_print( + f" algorithm: {parsed_cert.signature_algorithm_oid._name}" + ) + if not printed_head: + local_print("No certificate found") + pass + + +@piv.command() +@click.option( + "--current-admin-key", + type=click.STRING, + default="010203040506070801020304050607080102030405060708", +) +@click.argument( + "new-admin-key", + type=click.STRING, +) +def change_admin_key(current_admin_key: str, new_admin_key: str) -> None: + try: + current_admin_key_bytes = bytearray.fromhex(current_admin_key) + new_admin_key_bytes = bytearray.fromhex(new_admin_key) + except ValueError: + local_critical( + "Key is expected to be an hexadecimal string", + support_hint=False, + ) + + device = PivApp() + device.authenticate_admin(current_admin_key_bytes) + device.set_admin_key(new_admin_key_bytes) + local_print("Changed key successfully") + + +@piv.command() +@click.option( + "--current-pin", + type=click.STRING, + prompt="Enter the PIN", + hide_input=True, +) +@click.option( + "--new-pin", + type=click.STRING, + prompt="Enter the PIN", + hide_input=True, +) +def change_pin(current_pin: str, new_pin: str) -> None: + device = PivApp() + device.change_pin(current_pin, new_pin) + local_print("Changed pin successfully") + + +@piv.command() +@click.option( + "--current-puk", + type=click.STRING, + prompt="Enter the current PUK", + hide_input=True, +) +@click.option( + "--new-puk", + type=click.STRING, + prompt="Enter the new PUK", + hide_input=True, +) +def change_puk(current_puk: str, new_puk: str) -> None: + device = PivApp() + device.change_puk(current_puk, new_puk) + local_print("Changed puk successfully") + + +@piv.command() +@click.option( + "--puk", + type=click.STRING, + prompt="Enter the PUK", + hide_input=True, +) +@click.option( + "--new-pin", + type=click.STRING, + prompt="Enter the new PIN", + hide_input=True, +) +def reset_retry_counter(puk: str, new_pin: str) -> None: + device = PivApp() + device.reset_retry_counter(puk, new_pin) + local_print("Unlocked PIN successfully") + + +@piv.command() +def factory_reset() -> None: + 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, + ) + local_print("Factory reset successfully") + + +KEY_TO_CERT_OBJ_ID_MAP = { + "9A": "5FC105", + "9C": "5FC10A", + "9D": "5FC10B", + "9E": "5FC101", + "82": "5FC10D", + "83": "5FC10E", + "84": "5FC10F", + "85": "5FC110", + "86": "5FC111", + "87": "5FC112", + "88": "5FC113", + "89": "5FC114", + "8A": "5FC115", + "8B": "5FC116", + "8C": "5FC117", + "8D": "5FC118", + "8E": "5FC119", + "8F": "5FC11A", + "90": "5FC11B", + "91": "5FC11C", + "92": "5FC11D", + "93": "5FC11E", + "94": "5FC11F", + "95": "5FC120", +} + + +@piv.command() +@click.option( + "--admin-key", + type=click.STRING, + default="010203040506070801020304050607080102030405060708", +) +@click.option( + "--key", + type=click.Choice( + [ + "9A", + "9C", + "9D", + "9E", + "82", + "83", + "84", + "85", + "86", + "87", + "88", + "89", + "8A", + "8B", + "8C", + "8D", + "8E", + "8F", + "90", + "91", + "92", + "93", + "94", + "95", + ] + ), + default="9A", +) +@click.option( + "--algo", + type=click.Choice(["rsa2048", "nistp256"]), + default="nistp256", +) +@click.option( + "--domain-component", + type=click.STRING, + multiple=True, +) +@click.option( + "--subject-name", + type=click.STRING, + multiple=True, +) +@click.option( + "--subject-alt-name-upn", + type=click.STRING, +) +@click.option( + "--pin", + type=click.STRING, + prompt="Enter the PIN", + hide_input=True, +) +@click.option( + "--out-file", + type=click.Path(allow_dash=True), + default="-", +) +def generate_key( + admin_key: str, + key: str, + algo: str, + domain_component: Optional[Sequence[str]], + subject_name: Optional[Sequence[str]], + subject_alt_name_upn: Optional[str], + pin: str, + out_file: str, +) -> None: + try: + admin_key_bytes = bytearray.fromhex(admin_key) + except ValueError: + local_critical( + "Key is expected to be an hexadecimal string", + support_hint=False, + ) + key_hex = key + key_ref = int(key_hex, 16) + + device = PivApp() + device.authenticate_admin(admin_key_bytes) + device.login(pin) + + if algo == "rsa2048": + algo_id = b"\x07" + signature_algorithm = "sha256_rsa" + elif algo == "nistp256": + algo_id = b"\x11" + signature_algorithm = "sha256_ecdsa" + else: + local_critical("Unimplemented algorithm", support_hint=False) + + body = Tlv.build([(0xAC, Tlv.build([(0x80, algo_id)]))]) + ins = 0x47 + p1 = 0 + p2 = key_ref + response = device.send_receive(ins, p1, p2, body) + + data = Tlv.parse(response) + data_tmp = find_by_id(0x7F49, data) + if data_tmp is None: + local_critical("Device did not send public key data") + return + + data = Tlv.parse(data_tmp) + + if algo == "nistp256": + key_data = find_by_id(0x86, data) + if key_data is None: + local_critical("Device did not send public key data") + return + key_data = key_data[1:] + public_x = int.from_bytes(key_data[:32], byteorder="big", signed=False) + public_y = int.from_bytes(key_data[32:], byteorder="big", signed=False) + public_numbers_ecc = ec.EllipticCurvePublicNumbers( + public_x, + public_y, + cryptography.hazmat.primitives.asymmetric.ec.SECP256R1(), + ) + public_key_ecc = public_numbers_ecc.public_key() + public_key_der = public_key_ecc.public_bytes( + serialization.Encoding.DER, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + elif algo == "rsa2048": + modulus_data = find_by_id(0x81, data) + exponent_data = find_by_id(0x82, data) + if modulus_data is None or exponent_data is None: + local_critical("Device did not send public key data") + return + + modulus = int.from_bytes(modulus_data, byteorder="big", signed=False) + exponent = int.from_bytes(exponent_data, byteorder="big", signed=False) + public_numbers_rsa = rsa.RSAPublicNumbers(exponent, modulus) + public_key_rsa = public_numbers_rsa.public_key() + public_key_der = public_key_rsa.public_bytes( + serialization.Encoding.DER, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + else: + local_critical("Unimplemented algorithm") + + public_key_info = PublicKeyInfo.load(public_key_der, strict=True) + + if domain_component is None: + domain_component = [] + + if subject_name is None: + rdns = [] + else: + rdns = [ + x509.RelativeDistinguishedName( + [ + x509.NameTypeAndValue( + { + "type": x509.NameType.map("domain_component"), + "value": x509.DNSName(subject), + } + ) + ] + ) + for subject in domain_component + ] + [ + x509.RelativeDistinguishedName( + [ + x509.NameTypeAndValue( + { + "type": x509.NameType.map("common_name"), + "value": x509.DirectoryString( + name="utf8_string", value=subject + ), + } + ) + ] + ) + for subject in subject_name + ] + + # SEQUENCE + # SEQUENCE + # OBJECT :aes-256-cbc + # SEQUENCE + # OBJECT :id-aes256-wrap + # SEQUENCE + # OBJECT :aes-192-cbc + # SEQUENCE + # OBJECT :id-aes192-wrap + # SEQUENCE + # OBJECT :aes-128-cbc + # SEQUENCE + # OBJECT :id-aes128-wrap + # SEQUENCE + # OBJECT :des-ede3-cbc + # SEQUENCE + # OBJECT :des-cbc + # SEQUENCE + # OBJECT :rc2-cbc + # INTEGER :80 + # SEQUENCE + # OBJECT :rc4 + # INTEGER :0200 + smime_extension = ParsableOctetString( + value=bytes( + bytearray.fromhex( + "308183300B060960864801650304012A300B060960864801650304012D300B0609608648016503040116300B0609608648016503040119300B0609608648016503040102300B0609608648016503040105300A06082A864886F70D0307300706052B0E030207300E06082A864886F70D030202020080300E06082A864886F70D030402020200" + ) + ) + ) + + extensions = [ + { + "extn_id": "basic_constraints", + "critical": True, + "extn_value": x509.BasicConstraints({"ca": False}), + }, + { + "extn_id": "key_usage", + "critical": True, + "extn_value": x509.KeyUsage({"digital_signature", "non_repudiation"}), + }, + { + "extn_id": "extended_key_usage", + "critical": False, + "extn_value": x509.ExtKeyUsageSyntax( + ["client_auth", "microsoft_smart_card_logon"] + ), + }, + { + "extn_id": "1.2.840.113549.1.9.15", + "critical": False, + "extn_value": smime_extension, + }, + ] + + if subject_alt_name_upn is not None: + extensions.append( + { + "extn_id": "subject_alt_name", + "critical": False, + "extn_value": [ + x509.GeneralName( + "other_name", + { + "type_id": "1.3.6.1.4.1.311.20.2.3", + "value": x509.UTF8String(subject_alt_name_upn).retag( + {"explicit": 0} + ), + }, + ) + ], + } + ) + + csr_info = CertificationRequestInfo( + { + "version": "v1", + "subject": x509.Name(name="", value=x509.RDNSequence(rdns)), + "subject_pk_info": public_key_info, + "attributes": [{"type": "extension_request", "values": [extensions]}], + } + ) + + # To Be Signed + tbs = csr_info.dump() + + if algo == "nistp256": + signature = device.sign_p256(tbs, key_ref) + elif algo == "rsa2048": + signature = device.sign_rsa2048(tbs, key_ref) + else: + local_critical("Unimplemented algorithm") + + csr = CertificationRequest( + { + "certification_request_info": csr_info, + "signature_algorithm": { + "algorithm": signature_algorithm, + }, + "signature": signature, + } + ) + + with click.open_file(out_file, mode="wb") as file: + file.write(csr.dump()) + + cert_info = x509.TbsCertificate( + { + "version": "v3", + "subject": x509.Name(name="", value=x509.RDNSequence(rdns)), + "issuer": x509.Name(name="", value=x509.RDNSequence(rdns)), + "serial_number": 0, + "signature": { + "algorithm": signature_algorithm, + }, + "validity": { + "not_before": x509.GeneralizedTime( + datetime.datetime( + 2000, 1, 1, tzinfo=datetime.timezone(datetime.timedelta()) + ) + ), + "not_after": x509.GeneralizedTime( + datetime.datetime( + 2099, 1, 1, tzinfo=datetime.timezone(datetime.timedelta()) + ) + ), + }, + "subject_public_key_info": public_key_info, + "extensions": extensions, + } + ) + + tbs = cert_info.dump() + if algo == "nistp256": + signature = device.sign_p256(tbs, key_ref) + elif algo == "rsa2048": + signature = device.sign_rsa2048(tbs, key_ref) + else: + local_critical("Unimplemented algorithm") + + certificate = x509.Certificate( + { + "tbs_certificate": cert_info, + "signature_value": signature, + "signature_algorithm": {"algorithm": signature_algorithm}, + } + ).dump() + payload = Tlv.build( + [ + (0x5C, bytes(bytearray.fromhex(KEY_TO_CERT_OBJ_ID_MAP[key_hex]))), + (0x53, Tlv.build([(0x70, certificate), (0x71, bytes([0]))])), + ] + ) + + device.send_receive(0xDB, 0x3F, 0xFF, payload) + + +@piv.command() +@click.argument( + "admin-key", + type=click.STRING, + default="010203040506070801020304050607080102030405060708", +) +@click.option("--format", type=click.Choice(["DER", "PEM"]), default="PEM") +@click.option( + "--key", + type=click.Choice( + [ + "9A", + "9C", + "9D", + "9E", + "82", + "83", + "84", + "85", + "86", + "87", + "88", + "89", + "8A", + "8B", + "8C", + "8D", + "8E", + "8F", + "90", + "91", + "92", + "93", + "94", + "95", + ] + ), + default="9A", +) +@click.option( + "--path", + type=click.Path(allow_dash=True), + default="-", +) +def write_certificate(admin_key: str, format: str, key: str, path: str) -> None: + try: + admin_key_bytes: bytes = bytearray.fromhex(admin_key) + except ValueError: + local_critical( + "Key is expected to be an hexadecimal string", + support_hint=False, + ) + + device = PivApp() + device.authenticate_admin(admin_key_bytes) + + with click.open_file(path, mode="rb") as f: + cert_bytes = f.read() + if format == "DER": + cert_serialized = cert_bytes + cert = cryptography.x509.load_der_x509_certificate(cert_bytes) + elif format == "PEM": + cert = cryptography.x509.load_pem_x509_certificate(cert_bytes) + cert_serialized = cert.public_bytes(Encoding.DER) + + payload = Tlv.build( + [ + (0x5C, bytes(bytearray.fromhex(KEY_TO_CERT_OBJ_ID_MAP[key]))), + (0x53, Tlv.build([(0x70, cert_serialized), (0x71, bytes([0]))])), + ] + ) + + device.send_receive(0xDB, 0x3F, 0xFF, payload) + + +@piv.command() +@click.option("--out-format", type=click.Choice(["DER", "PEM"]), default="PEM") +@click.option( + "--key", + type=click.Choice( + [ + "9A", + "9C", + "9D", + "9E", + "82", + "83", + "84", + "85", + "86", + "87", + "88", + "89", + "8A", + "8B", + "8C", + "8D", + "8E", + "8F", + "90", + "91", + "92", + "93", + "94", + "95", + ] + ), + default="9A", +) +@click.option("--path", type=click.Path(allow_dash=True), default="-") +def read_certificate(out_format: str, key: str, path: str) -> None: + device = PivApp() + + value = device.cert(bytes(bytearray.fromhex(KEY_TO_CERT_OBJ_ID_MAP[key]))) + + if value is None: + print("Certificate not found", file=sys.stderr) + return + + if out_format == "DER": + cert_serialized = value + cryptography.x509.load_der_x509_certificate(value) + elif out_format == "PEM": + cert = cryptography.x509.load_der_x509_certificate(value) + cert_serialized = cert.public_bytes(Encoding.PEM) + + with click.open_file(path, mode="wb") as f: + f.write(cert_serialized) diff --git a/pynitrokey/nk3/piv_app.py b/pynitrokey/nk3/piv_app.py new file mode 100644 index 00000000..78f199e1 --- /dev/null +++ b/pynitrokey/nk3/piv_app.py @@ -0,0 +1,409 @@ +import logging +import os +from typing import Any, Callable, Optional, Sequence, Union + +import smartcard +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from smartcard.CardConnection import CardConnection +from smartcard.Exceptions import NoCardException + +from pynitrokey.helpers import local_critical +from pynitrokey.start.gnuk_token import iso7816_compose +from pynitrokey.tlv import Tlv + +LogFn = Callable[[str], Any] + + +def find_by_id(tag: int, data: Sequence[tuple[int, bytes]]) -> Optional[bytes]: + for t, b in data: + if t == tag: + return b + return None + + +# size is in bytes +def prepare_for_pkcs1v15_sign_2048(data: bytes) -> bytes: + digest = hashes.Hash(hashes.SHA256()) + digest.update(data) + hashed = digest.finalize() + + prefix = bytearray.fromhex("3031300d060960864801650304020105000420") + padding_len = 256 - 32 - 19 - 3 + padding = b"\x00\x01" + (b"\xFF" * padding_len) + b"\x00" + total = padding + prefix + hashed + assert len(total) == 256 + return total + + +class StatusError(Exception): + id: int + + def __init__(self, value: int): + self.value = value + + def __str__(self) -> str: + return f"{hex(self.value)}" + + +class PivApp: + log: logging.Logger + logfn: LogFn + connection: CardConnection + + def __init__(self, logfn: Optional[LogFn] = None): + self.log = logging.getLogger("pivapp") + readers = smartcard.System.readers() + chosen_connection: Optional[CardConnection] = None + for r in readers: + print(r) + connection = r.createConnection() + try: + connection.connect() + except NoCardException: + continue + select = [ + 0x00, + 0xA4, + 0x04, + 0x00, + 0x0C, + 0xA0, + 0x00, + 0x00, + 0x03, + 0x08, + 0x00, + 0x00, + 0x10, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + ] + data, sw1, sw2 = connection.transmit(select) + if sw1 != 0x90 or sw2 != 0x00: + continue + chosen_connection = connection + + if not chosen_connection: + raise NoCardException("No PIV card found", -1) + + self.connection = chosen_connection + + if logfn is not None: + self.logfn = logfn + else: + self.logfn = self.log.info + + def send_receive( + self, + ins: int, + p1: int, + p2: int, + data: bytes = b"", + ) -> bytes: + bytes_data = iso7816_compose(ins, p1, p2, data) + return self._send_receive_inner(bytes_data, log_info=f"{ins}") + + def _send_receive_inner(self, data: bytes, log_info: str = "") -> bytes: + self.logfn( + f"Sending {log_info if log_info else ''} {data.hex() if data else data!r}" + ) + + try: + result_list, sw1, sw2 = self.connection.transmit(list(data)) + except Exception as e: + self.logfn(f"Got exception: {e}") + raise + + result = bytes(result_list) + status_bytes = bytes([sw1, sw2]) + self.logfn(f"Received [{status_bytes.hex()}] {result.hex()}") + + log_multipacket = False + data_final = bytes(result) + MORE_DATA_STATUS_BYTE = 0x61 + while status_bytes[0] == MORE_DATA_STATUS_BYTE: + if log_multipacket: + self.logfn( + f"Got RemainingData status: [{status_bytes.hex()}] {result.hex() if result else result!r}" + ) + log_multipacket = True + ins = 0xC0 + p1 = 0 + p2 = 0 + le = sw2 if sw2 != 0 else 0xFF + bytes_data = iso7816_compose(ins, p1, p2, le=le) + try: + result_list, sw1, sw2 = self.connection.transmit(list(bytes_data)) + except Exception as e: + self.logfn(f"Got exception: {e}") + raise + # Data order is different here than in APDU - SW is first, then the data if any + result = bytes(result_list) + status_bytes = bytes([sw1, sw2]) + self.logfn(f"Received [{status_bytes.hex()}] {bytes(result).hex()}") + if status_bytes[0] in [0x90, MORE_DATA_STATUS_BYTE]: + data_final += bytes(result) + + if status_bytes != b"\x90\x00" and status_bytes[0] != MORE_DATA_STATUS_BYTE: + raise StatusError(int.from_bytes(status_bytes, byteorder="big")) + + if log_multipacket: + self.logfn( + f"Received final data: [{status_bytes.hex()}] {data_final.hex() if data_final else data_final!r}" + ) + + if data_final: + try: + self.logfn(f"Decoded received: {data_final.hex()}") + except Exception: + pass + + return data_final + + def authenticate_admin(self, admin_key: bytes) -> None: + + if len(admin_key) == 24: + algorithm: Union[ + algorithms.TripleDES, algorithms.AES128, algorithms.AES256 + ] = algorithms.TripleDES(admin_key) + # algo = "tdes" + algo_byte = 0x03 + expected_len = 8 + elif len(admin_key) == 16: + algorithm = algorithms.AES128(admin_key) + # algo = "aes128" + algo_byte = 0x08 + expected_len = 16 + elif len(admin_key) == 32: + algorithm = algorithms.AES256(admin_key) + # algo = "aes256" + algo_byte = 0x0C + expected_len = 16 + else: + local_critical( + "Unsupported key length", + support_hint=False, + ) + + challenge_body = Tlv.build([(0x7C, Tlv.build([(0x80, b"")]))]) + challenge_response = self.send_receive(0x87, algo_byte, 0x9B, challenge_body) + general_auth_data = find_by_id(0x7C, Tlv.parse(challenge_response)) + if general_auth_data is None: + local_critical("Failed to get response to GENERAL AUTHENTICATE") + return + + challenge = find_by_id( + 0x80, + Tlv.parse(general_auth_data), + ) + + if challenge is None: + local_critical("Failed to get authentication challenge from the device") + return + + # challenge = decoded.first_by_id(0x7C).data.first_by_id(0x80).data + if len(challenge) != expected_len: + local_critical("Got unexpected authentication challenge length") + + our_challenge = os.urandom(expected_len) + cipher = Cipher(algorithm, mode=modes.ECB()) + encryptor = cipher.encryptor() + decryptor = cipher.decryptor() + response = encryptor.update(challenge) + encryptor.finalize() + our_challenge_encrypted = decryptor.update(our_challenge) + decryptor.finalize() + response_body = Tlv.build( + [(0x7C, Tlv.build([(0x80, response), (0x81, our_challenge_encrypted)]))] + ) + + final_response = self.send_receive(0x87, algo_byte, 0x9B, response_body) + general_auth_data = find_by_id(0x7C, Tlv.parse(final_response)) + if general_auth_data is None: + local_critical("Failed to get response to GENERAL AUTHENTICATE") + return + + decoded_challenge = find_by_id( + 0x82, + Tlv.parse(general_auth_data), + ) + + if decoded_challenge != our_challenge: + local_critical( + "Failed to authenticate with administrator key", support_hint=False + ) + + def set_admin_key(self, new_key: bytes) -> None: + if len(new_key) == 24: + # algo = "tdes" + algo_byte = 0x03 + elif len(new_key) == 16: + # algo = "aes128" + algo_byte = 0x08 + elif len(new_key) == 32: + # algo = "aes256" + algo_byte = 0x0C + else: + local_critical( + "Unsupported key length", + support_hint=False, + ) + data = bytes([algo_byte, 0x9B, len(new_key)]) + new_key + self.send_receive(0xFF, 0xFF, 0xFE, data) + + def encode_pin(self, pin: str) -> bytes: + body = pin.encode("utf-8") + if len(body) > 8: + local_critical("PIN can only be up to 8 bytes long", support_hint=False) + + body += bytes([0xFF for i in range(8 - len(body))]) + return body + + def login(self, pin: str) -> None: + body = self.encode_pin(pin) + self.send_receive(0x20, 0x00, 0x80, body) + + def change_pin(self, old_pin: str, new_pin: str) -> None: + body = self.encode_pin(old_pin) + self.encode_pin(new_pin) + self.send_receive(0x24, 0, 0x80, body) + + 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: + 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) + + def reset_retry_counter(self, puk: str, new_pin: str) -> None: + puk_bytes = puk.encode("utf-8") + + if len(puk_bytes) != 8: + local_critical("PUK must be 8 bytes long", support_hint=False) + + body = puk_bytes + self.encode_pin(new_pin) + self.send_receive(0x2C, 0, 0x80, body) + + def factory_reset(self) -> None: + self.send_receive(0xFB, 0, 0) + + def sign_p256(self, data: bytes, key: int) -> bytes: + prepare_for_pkcs1v15_sign_2048(data) + digest = hashes.Hash(hashes.SHA256()) + digest.update(data) + payload = digest.finalize() + return self.raw_sign(payload, key, 0x11) + + def sign_rsa2048(self, data: bytes, key: int) -> bytes: + payload = prepare_for_pkcs1v15_sign_2048(data) + return self.raw_sign(payload, key, 0x07) + + def raw_sign(self, payload: bytes, key: int, algo: int) -> bytes: + body = Tlv.build([(0x7C, Tlv.build([(0x81, payload), (0x82, b"")]))]) + result = self.send_receive(0x87, algo, key, body) + general_auth_data = find_by_id(0x7C, Tlv.parse(result)) + if general_auth_data is None: + local_critical("Failed to get response to GENERAL AUTHENTICATE") + return bytes() + + signature = find_by_id( + 0x82, + Tlv.parse(general_auth_data), + ) + + if signature is None: + local_critical("Failed to get signature from device") + # Satisfy the type checker. + # local_critical raises always raises an error + return b"" + + return signature + + def init(self) -> bytes: + # Template for card capabilities with nothing but a random ID + template_begin = bytearray.fromhex("f015a000000116") + template_end = bytearray.fromhex( + "f10121f20121f300f40100f50110f600f700fa00fb00fc00fd00fe00" + ) + card_id = os.urandom(16) + cardcaps = template_begin + card_id + template_end + cardcaps_body = Tlv.build( + [(0x5C, bytes(bytearray.fromhex("5fc107"))), (0x53, bytes(cardcaps))] + ) + self.send_receive(0xDB, 0x3F, 0xFF, cardcaps_body) + + pinfo_body = Tlv.build( + [ + (0x5C, bytes(bytearray.fromhex("5FC109"))), + ( + 0x53, + Tlv.build( + [ + (0x01, "Nitrokey PIV user".encode("ascii")), + # TODO: use representation of real serial number of card (currently static value) + # Base 10 representation of + # https://github.com/Nitrokey/piv-authenticator/blob/2c948a966f3e410e9a4cee3c351ca20b956383e0/src/lib.rs#L197 + (0x05, "5437251".encode("ascii")), + ] + ), + ), + ] + ) + self.send_receive(0xDB, 0x3F, 0xFF, pinfo_body) + return card_id + + def serial(self) -> int: + response = self.send_receive(0x01, 0x00, 0x00) + return int.from_bytes(response, byteorder="big") + + def reader(self) -> str: + reader: str = self.connection.getReader() # type: ignore + return reader + + def guid(self) -> bytes: + payload = Tlv.build([(0x5C, bytes(bytearray.fromhex("5FC102")))]) + chuid = self.send_receive(0xCB, 0x3F, 0xFF, payload) + + chuid_tmp = find_by_id(0x53, Tlv.parse(chuid)) + if chuid_tmp is None: + local_critical("Failed to get chuid from device") + return b"" + + chuid_data = find_by_id(0x34, Tlv.parse(chuid_tmp)) + if chuid_data is None: + local_critical("Failed to get chuid from device") + # Satisfy the type checker. + # local_critical raises always raises an error + return b"" + + return chuid_data + + def cert(self, container_id: bytes) -> Optional[bytes]: + payload = Tlv.build([(0x5C, container_id)]) + try: + cert = self.send_receive(0xCB, 0x3F, 0xFF, payload) + parsed = Tlv.parse(cert) + if len(parsed) != 1: + local_critical("Bad number of elements", support_hint=False) + + tag, value = parsed[0] + if tag != 0x53: + local_critical("Bad tag", support_hint=False) + + parsed = Tlv.parse(value) + if len(parsed) < 1: + local_critical("Bad number of sub-elements", support_hint=False) + + tag, value = parsed[0] + if tag != 0x70: + local_critical("Bad tag", support_hint=False) + + return value + + except StatusError as e: + if e.value == 0x6A82: + return None + else: + raise ValueError(f"{hex(e.value)}, Received error") diff --git a/pynitrokey/tlv.py b/pynitrokey/tlv.py new file mode 100644 index 00000000..c33040a5 --- /dev/null +++ b/pynitrokey/tlv.py @@ -0,0 +1,81 @@ +from typing import Optional, Sequence, Tuple + + +def build_one(tag: int, data: bytes) -> bytes: + data_len = len(data) + out = bytearray() + out += tag.to_bytes((tag.bit_length() + 7) // 8, byteorder="big") + if data_len <= 0x7F: + out.append(data_len) + elif data_len <= 0xFF: + out.append(0x81) + out.append(data_len) + else: + assert data_len <= 0xFFFF + out.append(0x82) + out += data_len.to_bytes((data_len.bit_length() + 7) // 8, byteorder="big") + + return out + data + + +def take_tag(data: bytes) -> Tuple[int, bytes]: + data_len = len(data) + if data_len == 0: + raise ValueError("Failed to parse TLV data: empty data when parsing tag") + + b1 = data[0] + if (b1 & 0x1F) == 0x1F: + if data_len < 2: + raise ValueError("Failed to parse TLV data: partial tag") + b2 = data[1] + return (int.from_bytes([b1, b2], byteorder="big"), data[2:]) + else: + return (int.from_bytes([0, b1], byteorder="big"), data[1:]) + + +def take_len(data: bytes) -> Tuple[int, bytes]: + data_len = len(data) + if data_len == 0: + raise ValueError("Failed to parse TLV data: empty data when parsing len") + + l1 = data[0] + if l1 <= 0x7F: + return (l1, data[1:]) + elif l1 == 0x81: + if data_len < 2: + raise ValueError("Failed to parse TLV data: partial len") + return (data[1], data[2:]) + elif l1 == 0x82: + if data_len < 3: + raise ValueError("Failed to parse TLV data: partial len") + l2 = data[1] + l3 = data[2] + return (int.from_bytes([l2, l3], byteorder="big"), data[3:]) + else: + raise ValueError("Failed to parse TLV data: invalid len") + + +def take_do(data: bytes) -> Tuple[int, bytes, bytes]: + tag, rem = take_tag(data) + len, rem = take_len(rem) + + return tag, rem[:len], rem[len:] + + +class Tlv: + @staticmethod + def build(input: Sequence[Tuple[int, bytes]]) -> bytes: + out = bytearray() + for tag, data in input: + out += build_one(tag, data) + return out + + @staticmethod + def parse(data: bytes) -> Sequence[Tuple[int, bytes]]: + res = [] + current = data + while len(current) != 0: + tag, value, rem = take_do(current) + res.append((tag, value)) + current = rem + return res diff --git a/pyproject.toml b/pyproject.toml index 188ecf16..6120b7dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,8 @@ dependencies = [ "click-aliases", "semver", "nethsm >=1.2.1, <2", + "pyscard", + "asn1crypto", ] dynamic = ["version", "description"] @@ -125,6 +127,8 @@ module = [ "tlv8.*", "pytest.*", "click_aliases.*", + "smartcard.*", + "asn1crypto.*", ] ignore_missing_imports = true From a080fe8338d45198dbedbfaf2c0de8763485c126 Mon Sep 17 00:00:00 2001 From: Markus Merklinger Date: Fri, 13 Sep 2024 14:15:06 +0200 Subject: [PATCH 2/8] Change option to be consistend with other commands --- pynitrokey/cli/nk3/piv.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pynitrokey/cli/nk3/piv.py b/pynitrokey/cli/nk3/piv.py index 8be5b3d2..0378e000 100644 --- a/pynitrokey/cli/nk3/piv.py +++ b/pynitrokey/cli/nk3/piv.py @@ -281,7 +281,7 @@ def factory_reset() -> None: hide_input=True, ) @click.option( - "--out-file", + "--path", type=click.Path(allow_dash=True), default="-", ) @@ -293,7 +293,7 @@ def generate_key( subject_name: Optional[Sequence[str]], subject_alt_name_upn: Optional[str], pin: str, - out_file: str, + path: str, ) -> None: try: admin_key_bytes = bytearray.fromhex(admin_key) @@ -508,7 +508,7 @@ def generate_key( } ) - with click.open_file(out_file, mode="wb") as file: + with click.open_file(path, mode="wb") as file: file.write(csr.dump()) cert_info = x509.TbsCertificate( From 93d92478f186f92948fed60999a76fad8df0d62a Mon Sep 17 00:00:00 2001 From: Markus Merklinger Date: Fri, 13 Sep 2024 14:32:44 +0200 Subject: [PATCH 3/8] Make choice options case insensitiv --- pynitrokey/cli/nk3/piv.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/pynitrokey/cli/nk3/piv.py b/pynitrokey/cli/nk3/piv.py index 0378e000..56c3b2db 100644 --- a/pynitrokey/cli/nk3/piv.py +++ b/pynitrokey/cli/nk3/piv.py @@ -251,13 +251,14 @@ def factory_reset() -> None: "93", "94", "95", - ] + ], + case_sensitive=False, ), default="9A", ) @click.option( "--algo", - type=click.Choice(["rsa2048", "nistp256"]), + type=click.Choice(["rsa2048", "nistp256"], case_sensitive=False), default="nistp256", ) @click.option( @@ -302,13 +303,14 @@ def generate_key( "Key is expected to be an hexadecimal string", support_hint=False, ) - key_hex = key + key_hex = key.upper() key_ref = int(key_hex, 16) device = PivApp() device.authenticate_admin(admin_key_bytes) device.login(pin) + algo = algo.lower() if algo == "rsa2048": algo_id = b"\x07" signature_algorithm = "sha256_rsa" @@ -568,7 +570,9 @@ def generate_key( type=click.STRING, default="010203040506070801020304050607080102030405060708", ) -@click.option("--format", type=click.Choice(["DER", "PEM"]), default="PEM") +@click.option( + "--format", type=click.Choice(["DER", "PEM"], case_sensitive=False), default="PEM" +) @click.option( "--key", type=click.Choice( @@ -597,7 +601,8 @@ def generate_key( "93", "94", "95", - ] + ], + case_sensitive=False, ), default="9A", ) @@ -620,6 +625,7 @@ def write_certificate(admin_key: str, format: str, key: str, path: str) -> None: with click.open_file(path, mode="rb") as f: cert_bytes = f.read() + format = format.upper() if format == "DER": cert_serialized = cert_bytes cert = cryptography.x509.load_der_x509_certificate(cert_bytes) @@ -629,7 +635,7 @@ def write_certificate(admin_key: str, format: str, key: str, path: str) -> None: payload = Tlv.build( [ - (0x5C, bytes(bytearray.fromhex(KEY_TO_CERT_OBJ_ID_MAP[key]))), + (0x5C, bytes(bytearray.fromhex(KEY_TO_CERT_OBJ_ID_MAP[key.upper()]))), (0x53, Tlv.build([(0x70, cert_serialized), (0x71, bytes([0]))])), ] ) @@ -638,7 +644,11 @@ def write_certificate(admin_key: str, format: str, key: str, path: str) -> None: @piv.command() -@click.option("--out-format", type=click.Choice(["DER", "PEM"]), default="PEM") +@click.option( + "--out-format", + type=click.Choice(["DER", "PEM"], case_sensitive=False), + default="PEM", +) @click.option( "--key", type=click.Choice( @@ -667,7 +677,8 @@ def write_certificate(admin_key: str, format: str, key: str, path: str) -> None: "93", "94", "95", - ] + ], + case_sensitive=False, ), default="9A", ) @@ -675,12 +686,13 @@ def write_certificate(admin_key: str, format: str, key: str, path: str) -> None: def read_certificate(out_format: str, key: str, path: str) -> None: device = PivApp() - value = device.cert(bytes(bytearray.fromhex(KEY_TO_CERT_OBJ_ID_MAP[key]))) + value = device.cert(bytes(bytearray.fromhex(KEY_TO_CERT_OBJ_ID_MAP[key.upper()]))) if value is None: print("Certificate not found", file=sys.stderr) return + out_format = out_format.upper() if out_format == "DER": cert_serialized = value cryptography.x509.load_der_x509_certificate(value) From 8df126e16557072555d07025552437c8cf209584 Mon Sep 17 00:00:00 2001 From: Markus Merklinger Date: Fri, 13 Sep 2024 14:34:59 +0200 Subject: [PATCH 4/8] Rename format option on read_certificate to be consistend with write_certificate --- pynitrokey/cli/nk3/piv.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pynitrokey/cli/nk3/piv.py b/pynitrokey/cli/nk3/piv.py index 56c3b2db..1e59cf75 100644 --- a/pynitrokey/cli/nk3/piv.py +++ b/pynitrokey/cli/nk3/piv.py @@ -645,7 +645,7 @@ def write_certificate(admin_key: str, format: str, key: str, path: str) -> None: @piv.command() @click.option( - "--out-format", + "--format", type=click.Choice(["DER", "PEM"], case_sensitive=False), default="PEM", ) @@ -683,7 +683,7 @@ def write_certificate(admin_key: str, format: str, key: str, path: str) -> None: default="9A", ) @click.option("--path", type=click.Path(allow_dash=True), default="-") -def read_certificate(out_format: str, key: str, path: str) -> None: +def read_certificate(format: str, key: str, path: str) -> None: device = PivApp() value = device.cert(bytes(bytearray.fromhex(KEY_TO_CERT_OBJ_ID_MAP[key.upper()]))) @@ -692,11 +692,11 @@ def read_certificate(out_format: str, key: str, path: str) -> None: print("Certificate not found", file=sys.stderr) return - out_format = out_format.upper() - if out_format == "DER": + format = format.upper() + if format == "DER": cert_serialized = value cryptography.x509.load_der_x509_certificate(value) - elif out_format == "PEM": + elif format == "PEM": cert = cryptography.x509.load_der_x509_certificate(value) cert_serialized = cert.public_bytes(Encoding.PEM) From a11114822d1c4cd16b0b3545b3b8c8d454d66144 Mon Sep 17 00:00:00 2001 From: Markus Merklinger Date: Fri, 13 Sep 2024 16:31:03 +0200 Subject: [PATCH 5/8] Add help texts to the commands --- pynitrokey/cli/nk3/piv.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/pynitrokey/cli/nk3/piv.py b/pynitrokey/cli/nk3/piv.py index 1e59cf75..d6d90fa9 100644 --- a/pynitrokey/cli/nk3/piv.py +++ b/pynitrokey/cli/nk3/piv.py @@ -24,7 +24,7 @@ def piv() -> None: pass -@piv.command() +@piv.command(help="Authenticate with the admin key.") @click.argument( "admin-key", type=click.STRING, @@ -44,7 +44,7 @@ def admin_auth(admin_key: str) -> None: local_print("Authenticated successfully") -@piv.command() +@piv.command(help="Initialize the PIV application.") @click.argument( "admin-key", type=click.STRING, @@ -66,7 +66,7 @@ def init(admin_key: str) -> None: local_print(f"GUID: {guid.hex().upper()}") -@piv.command() +@piv.command(help="Print information about the PIV application.") def info() -> None: device = PivApp() serial_number = device.serial() @@ -93,7 +93,7 @@ def info() -> None: pass -@piv.command() +@piv.command(help="Change the admin key.") @click.option( "--current-admin-key", type=click.STRING, @@ -119,7 +119,7 @@ def change_admin_key(current_admin_key: str, new_admin_key: str) -> None: local_print("Changed key successfully") -@piv.command() +@piv.command(help="Change the PIN.") @click.option( "--current-pin", type=click.STRING, @@ -138,7 +138,7 @@ def change_pin(current_pin: str, new_pin: str) -> None: local_print("Changed pin successfully") -@piv.command() +@piv.command(help="Change the PUK.") @click.option( "--current-puk", type=click.STRING, @@ -157,7 +157,7 @@ def change_puk(current_puk: str, new_puk: str) -> None: local_print("Changed puk successfully") -@piv.command() +@piv.command(help="Reset the retry counter.") @click.option( "--puk", type=click.STRING, @@ -176,7 +176,7 @@ def reset_retry_counter(puk: str, new_pin: str) -> None: local_print("Unlocked PIN successfully") -@piv.command() +@piv.command(help="Reset the PIV application.") def factory_reset() -> None: device = PivApp() try: @@ -217,7 +217,7 @@ def factory_reset() -> None: } -@piv.command() +@piv.command(help="Generate a new key and certificate signing request.") @click.option( "--admin-key", type=click.STRING, @@ -564,7 +564,7 @@ def generate_key( device.send_receive(0xDB, 0x3F, 0xFF, payload) -@piv.command() +@piv.command(help="Write a certificate to a key slot.") @click.argument( "admin-key", type=click.STRING, @@ -643,7 +643,7 @@ def write_certificate(admin_key: str, format: str, key: str, path: str) -> None: device.send_receive(0xDB, 0x3F, 0xFF, payload) -@piv.command() +@piv.command(help="Read a certificate from a key slot.") @click.option( "--format", type=click.Choice(["DER", "PEM"], case_sensitive=False), From 16bfcb90bcc50e18c5185bd06c482fb1e519512e Mon Sep 17 00:00:00 2001 From: Markus Merklinger Date: Fri, 13 Sep 2024 17:02:45 +0200 Subject: [PATCH 6/8] Add help texts to the command options --- pynitrokey/cli/nk3/piv.py | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/pynitrokey/cli/nk3/piv.py b/pynitrokey/cli/nk3/piv.py index d6d90fa9..2b8d855d 100644 --- a/pynitrokey/cli/nk3/piv.py +++ b/pynitrokey/cli/nk3/piv.py @@ -98,6 +98,7 @@ def info() -> None: "--current-admin-key", type=click.STRING, default="010203040506070801020304050607080102030405060708", + help="Current admin key.", ) @click.argument( "new-admin-key", @@ -125,12 +126,14 @@ def change_admin_key(current_admin_key: str, new_admin_key: str) -> None: type=click.STRING, prompt="Enter the PIN", hide_input=True, + help="Current PIN.", ) @click.option( "--new-pin", type=click.STRING, prompt="Enter the PIN", hide_input=True, + help="New PIN.", ) def change_pin(current_pin: str, new_pin: str) -> None: device = PivApp() @@ -144,12 +147,14 @@ def change_pin(current_pin: str, new_pin: str) -> None: type=click.STRING, prompt="Enter the current PUK", hide_input=True, + help="Current PUK.", ) @click.option( "--new-puk", type=click.STRING, prompt="Enter the new PUK", hide_input=True, + help="New PUK.", ) def change_puk(current_puk: str, new_puk: str) -> None: device = PivApp() @@ -163,12 +168,14 @@ def change_puk(current_puk: str, new_puk: str) -> None: type=click.STRING, prompt="Enter the PUK", hide_input=True, + help="Current PUK.", ) @click.option( "--new-pin", type=click.STRING, prompt="Enter the new PIN", hide_input=True, + help="New PIN.", ) def reset_retry_counter(puk: str, new_pin: str) -> None: device = PivApp() @@ -222,6 +229,7 @@ def factory_reset() -> None: "--admin-key", type=click.STRING, default="010203040506070801020304050607080102030405060708", + help="Current admin key", ) @click.option( "--key", @@ -255,36 +263,43 @@ def factory_reset() -> None: case_sensitive=False, ), default="9A", + help="Key slot for operation.", ) @click.option( "--algo", type=click.Choice(["rsa2048", "nistp256"], case_sensitive=False), default="nistp256", + help="Algorithm for the key.", ) @click.option( "--domain-component", type=click.STRING, multiple=True, + help="Domain component for the certificate signing request.", ) @click.option( "--subject-name", type=click.STRING, multiple=True, + help="Subject name for the certificate signing request.", ) @click.option( "--subject-alt-name-upn", type=click.STRING, + help="Subject alternative name (UPN) for the certificate signing request.", ) @click.option( "--pin", type=click.STRING, prompt="Enter the PIN", hide_input=True, + help="Current PIN.", ) @click.option( "--path", type=click.Path(allow_dash=True), default="-", + help="Write certificate signing request to path.", ) def generate_key( admin_key: str, @@ -571,7 +586,10 @@ def generate_key( default="010203040506070801020304050607080102030405060708", ) @click.option( - "--format", type=click.Choice(["DER", "PEM"], case_sensitive=False), default="PEM" + "--format", + type=click.Choice(["DER", "PEM"], case_sensitive=False), + default="PEM", + help="Format of certificate.", ) @click.option( "--key", @@ -605,11 +623,13 @@ def generate_key( case_sensitive=False, ), default="9A", + help="Key slot for operation.", ) @click.option( "--path", type=click.Path(allow_dash=True), default="-", + help="Write certificate to path.", ) def write_certificate(admin_key: str, format: str, key: str, path: str) -> None: try: @@ -648,6 +668,7 @@ def write_certificate(admin_key: str, format: str, key: str, path: str) -> None: "--format", type=click.Choice(["DER", "PEM"], case_sensitive=False), default="PEM", + help="Format of certificate.", ) @click.option( "--key", @@ -681,8 +702,14 @@ def write_certificate(admin_key: str, format: str, key: str, path: str) -> None: case_sensitive=False, ), default="9A", + help="Key slot for operation.", +) +@click.option( + "--path", + type=click.Path(allow_dash=True), + default="-", + help="Read certificate from path.", ) -@click.option("--path", type=click.Path(allow_dash=True), default="-") def read_certificate(format: str, key: str, path: str) -> None: device = PivApp() From 0ff57ff1d33537f586576b2bab295a92ca13145f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sosth=C3=A8ne=20Gu=C3=A9don?= Date: Tue, 17 Sep 2024 10:51:51 +0200 Subject: [PATCH 7/8] Add experimental flag to piv command group --- pynitrokey/cli/nk3/piv.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/pynitrokey/cli/nk3/piv.py b/pynitrokey/cli/nk3/piv.py index 2b8d855d..e7d8845d 100644 --- a/pynitrokey/cli/nk3/piv.py +++ b/pynitrokey/cli/nk3/piv.py @@ -13,14 +13,21 @@ from cryptography.hazmat.primitives.serialization import Encoding from pynitrokey.cli.nk3 import nk3 -from pynitrokey.helpers import local_critical, local_print +from pynitrokey.helpers import check_experimental_flag, local_critical, local_print from pynitrokey.nk3.piv_app import PivApp, find_by_id from pynitrokey.tlv import Tlv @nk3.group() -def piv() -> None: +@click.option( + "--experimental", + default=False, + is_flag=True, + help="Allow to execute experimental features", +) +def piv(experimental: bool) -> None: """Nitrokey PIV App""" + check_experimental_flag(experimental) pass From a867028fa0ad38d8ce61a737130b0968db1778a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sosth=C3=A8ne=20Gu=C3=A9don?= Date: Tue, 17 Sep 2024 14:54:14 +0200 Subject: [PATCH 8/8] Explain why asn1crypto is used --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6120b7dc..4dea2d4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ dependencies = [ "semver", "nethsm >=1.2.1, <2", "pyscard", - "asn1crypto", + "asn1crypto", # FIXME: replace by cryptography. Blocked by https://github.com/pyca/cryptography/issues/11616 ] dynamic = ["version", "description"]