From 91e163b3561b709fa3300c382ffc4cca441085e7 Mon Sep 17 00:00:00 2001 From: Nicolas Stalder Date: Mon, 17 Aug 2020 14:40:39 +0200 Subject: [PATCH 01/19] Update to fido2 0.8 package Collected from upstream commits: - c9d26b99516305e21c119ac7ad80af48a1e76243 - 096a9401c31b3882fdf45d78f5de0c2d872f0725 - 4d0a4fd4d50c9148d11fdcc3c6c7fe6e501f784e --- pynitrokey/client.py | 8 ++++---- pynitrokey/helpers.py | 29 +++++++++++++++++++++++++++++ pyproject.toml | 2 +- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/pynitrokey/client.py b/pynitrokey/client.py index ae7141de..f9e3a77c 100644 --- a/pynitrokey/client.py +++ b/pynitrokey/client.py @@ -13,8 +13,8 @@ import sys import tempfile import time +from threading import Event, Timer -import pynitrokey.exceptions from cryptography import x509 from cryptography.hazmat.backends import default_backend from fido2.attestation import Attestation @@ -23,14 +23,14 @@ from fido2.ctap1 import CTAP1 from fido2.ctap2 import CTAP2 from fido2.hid import CTAPHID, CtapHidDevice -from fido2.utils import Timeout from intelhex import IntelHex + +import pynitrokey.exceptions from pynitrokey import helpers from pynitrokey.commands import SoloBootloader, SoloExtension def find(solo_serial=None, retries=5, raw_device=None, udp=False): - if udp: pynitrokey.fido2.force_udp_backend() @@ -135,7 +135,7 @@ def send_only_hid(self, cmd, data): def send_data_hid(self, cmd, data): if not isinstance(data, bytes): data = struct.pack("%dB" % len(data), *[ord(x) for x in data]) - with Timeout(1.0) as event: + with helpers.Timeout(1.0) as event: return self.dev.call(cmd, data, event) def exchange_hid(self, cmd, addr=0, data=b"A" * 16): diff --git a/pynitrokey/helpers.py b/pynitrokey/helpers.py index d2a98fe0..1a15bf05 100644 --- a/pynitrokey/helpers.py +++ b/pynitrokey/helpers.py @@ -9,6 +9,9 @@ import logging import tempfile +from numbers import Number +from threading import Event, Timer + def to_websafe(data): data = data.replace("+", "-") @@ -23,6 +26,32 @@ def from_websafe(data): return data + "=="[: (3 * len(data)) % 4] +class Timeout(object): + """Utility class for adding a timeout to an event. + :param time_or_event: A number, in seconds, or a threading.Event object. + :ivar event: The Event associated with the Timeout. + :ivar timer: The Timer associated with the Timeout, if any. + """ + + +def __init__(self, time_or_event): + if isinstance(time_or_event, Number): + self.event = Event() + self.timer = Timer(time_or_event, self.event.set) + else: + self.event = time_or_event + self.timer = None + + def __enter__(self): + if self.timer: + self.timer.start() + return self.event + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.timer: + self.timer.join() + self.timer.cancel() + UPGRADE_LOG_FN = tempfile.NamedTemporaryFile(prefix="nitropy.log.").name LOG_FORMAT_STDOUT = '*** %(asctime)-15s %(levelname)6s %(name)10s %(message)s' diff --git a/pyproject.toml b/pyproject.toml index 09290b71..0d7fe40e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ requires = [ "click >= 7.0", "cryptography", "ecdsa", - "fido2 == 0.7.3", + "fido2 == 0.8", "intelhex", "pyserial", "pyusb", From 0c9b58e72e7ae6cfbec40193566a57423736bef4 Mon Sep 17 00:00:00 2001 From: Szczepan Zalega Date: Mon, 17 Aug 2020 14:47:52 +0200 Subject: [PATCH 02/19] .gitignore update --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e88b3411..8664311b 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ firmware-*.json .tags* .idea/ *.log +tmp/ From 781aef041304ffe43f4269a88ce8548280129e7b Mon Sep 17 00:00:00 2001 From: Markus Meissner Date: Tue, 18 Aug 2020 01:09:15 +0200 Subject: [PATCH 03/19] fixes for #38 --- pynitrokey/helpers.py | 14 +++++++------- pynitrokey/start/usb_strings.py | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pynitrokey/helpers.py b/pynitrokey/helpers.py index 1a15bf05..463295a6 100644 --- a/pynitrokey/helpers.py +++ b/pynitrokey/helpers.py @@ -34,13 +34,13 @@ class Timeout(object): """ -def __init__(self, time_or_event): - if isinstance(time_or_event, Number): - self.event = Event() - self.timer = Timer(time_or_event, self.event.set) - else: - self.event = time_or_event - self.timer = None + def __init__(self, time_or_event): + if isinstance(time_or_event, Number): + self.event = Event() + self.timer = Timer(time_or_event, self.event.set) + else: + self.event = time_or_event + self.timer = None def __enter__(self): if self.timer: diff --git a/pynitrokey/start/usb_strings.py b/pynitrokey/start/usb_strings.py index c5527215..8569a42f 100755 --- a/pynitrokey/start/usb_strings.py +++ b/pynitrokey/start/usb_strings.py @@ -22,7 +22,6 @@ along with this program. If not, see . """ -from pynitrokey.start.gnuk_token import * import usb, sys field = ['Vendor', 'Product', 'Serial', 'Revision', 'Config', 'Sys', 'Board'] @@ -42,6 +41,7 @@ def get_dict_for_device(dev: usb.Device) -> dict: def get_devices() -> list: + from pynitrokey.start.gnuk_token import gnuk_devices_by_vidpid res = [] for dev in gnuk_devices_by_vidpid(): res.append(get_dict_for_device(dev=dev)) From 25528f71f327639c8e65e6ad6f2028e4c03ac842 Mon Sep 17 00:00:00 2001 From: Markus Meissner Date: Wed, 19 Aug 2020 15:03:44 +0200 Subject: [PATCH 04/19] unpinned fido2; fido2 api call fixes --- pynitrokey/client.py | 11 ++++++++--- pynitrokey/hmac_secret.py | 26 +++++++++++++++++++------- pyproject.toml | 2 +- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/pynitrokey/client.py b/pynitrokey/client.py index f9e3a77c..c584b4a7 100644 --- a/pynitrokey/client.py +++ b/pynitrokey/client.py @@ -218,13 +218,18 @@ def wink(self,): def reset(self,): self.ctap2.reset() + # @todo: unneeded, remove this... def make_credential(self, pin=None): rp = {"id": self.host, "name": "example site"} user = {"id": self.user_id, "name": "example user"} challenge = "Y2hhbGxlbmdl" - attest, data = self.client.make_credential( - rp, user, challenge, exclude_list=[], pin=pin - ) + attest, data = self.client.make_credential({ + "rp": rp, + "user": user, + "challenge": challenge.encode("utf8"), + "pubKeyCredParams": [{"type": "public-key", "alg": -7}], + }, pin=pin) + try: attest.verify(data.hash) except AttributeError: diff --git a/pynitrokey/hmac_secret.py b/pynitrokey/hmac_secret.py index 1b0c3943..dd533737 100644 --- a/pynitrokey/hmac_secret.py +++ b/pynitrokey/hmac_secret.py @@ -26,8 +26,10 @@ def make_credential( udp=False, ): user_id = user_id.encode() + client = pynitrokey.client.find(solo_serial=serial, udp=udp).client + rp = {"id": host, "name": "Example RP"} client.host = host client.origin = f"https://{client.host}" @@ -39,9 +41,15 @@ def make_credential( print(prompt) hmac_ext = HmacSecretExtension(client.ctap2) - attestation_object, client_data = client.make_credential( - rp, user, challenge, extensions=hmac_ext.create_dict(), pin=pin - ) + + attestation_object, client_data = client.make_credential({ + "rp": rp, + "user": user, + "challenge": challenge.encode("utf8"), + "pubKeyCredParams": [{"type": "public-key", "alg": -7}], + "extensions": hmac_ext.create_dict(), + }, pin=pin) + credential = attestation_object.auth_data.credential_data credential_id = credential.credential_id @@ -58,7 +66,7 @@ def simple_secret( user_id="they", serial=None, pin=None, - prompt="Touch your authenticator to generate a reponse...", + prompt="Touch your authenticator to generate a response...", output=True, udp=False, ): @@ -85,9 +93,13 @@ def simple_secret( if prompt: print(prompt) - assertions, client_data = client.get_assertion( - host, challenge, allow_list, extensions=hmac_ext.get_dict(salt), pin=pin - ) + assertions, client_data = client.get_assertion({ + "rpId": host, + "challenge": challenge.encode("utf8"), + "allowCredentials": allow_list, + "extensions": hmac_ext.get_dict(salt), + }, + pin=pin) assertion = assertions[0] # Only one cred in allowList, only one response. response = hmac_ext.results_for(assertion.auth_data)[0] diff --git a/pyproject.toml b/pyproject.toml index 0d7fe40e..55f580a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ requires = [ "click >= 7.0", "cryptography", "ecdsa", - "fido2 == 0.8", + "fido2", "intelhex", "pyserial", "pyusb", From 260b38aad2cfa18bbf954dfe214184ed17456e0c Mon Sep 17 00:00:00 2001 From: Markus Meissner Date: Tue, 25 Aug 2020 10:20:42 +0200 Subject: [PATCH 05/19] move fido2 related files into fido2-dir --- pynitrokey/__init__.py | 2 +- pynitrokey/cli/__init__.py | 6 +-- pynitrokey/cli/fido2.py | 43 ++++++++++--------- pynitrokey/cli/program.py | 2 +- pynitrokey/cli/update.py | 9 ++-- pynitrokey/fido2/__init__.py | 60 +-------------------------- pynitrokey/{ => fido2}/client.py | 3 +- pynitrokey/{ => fido2}/commands.py | 0 pynitrokey/{ => fido2}/dfu.py | 2 +- pynitrokey/{ => fido2}/enums.py | 0 pynitrokey/{ => fido2}/hmac_secret.py | 9 ++-- pynitrokey/{ => fido2}/operations.py | 0 pynitrokey/helpers.py | 3 +- 13 files changed, 44 insertions(+), 95 deletions(-) rename pynitrokey/{ => fido2}/client.py (99%) rename pynitrokey/{ => fido2}/commands.py (100%) rename pynitrokey/{ => fido2}/dfu.py (99%) rename pynitrokey/{ => fido2}/enums.py (100%) rename pynitrokey/{ => fido2}/hmac_secret.py (92%) rename pynitrokey/{ => fido2}/operations.py (100%) diff --git a/pynitrokey/__init__.py b/pynitrokey/__init__.py index 711e61a7..b223ff94 100644 --- a/pynitrokey/__init__.py +++ b/pynitrokey/__init__.py @@ -12,7 +12,7 @@ import pathlib -from . import client, commands, dfu, helpers, operations +#from fido2 import client, commands, dfu, helpers, operations __version__ = open(pathlib.Path(__file__).parent / "VERSION").read().strip() diff --git a/pynitrokey/cli/__init__.py b/pynitrokey/cli/__init__.py index 396f0f6c..2e9b573d 100644 --- a/pynitrokey/cli/__init__.py +++ b/pynitrokey/cli/__init__.py @@ -11,10 +11,8 @@ import click -import json - import pynitrokey -import pynitrokey.operations +import pynitrokey.fido2.operations from pynitrokey.cli.fido2 import fido2 from pynitrokey.cli.start import start @@ -63,5 +61,7 @@ def ls(): nitropy.add_command(ls) + + from pygments.console import colorize print(f'*** {colorize("red", "Nitrokey tool for Nitrokey FIDO2 & Nitrokey Start")}') diff --git a/pynitrokey/cli/fido2.py b/pynitrokey/cli/fido2.py index 6c7348cb..763f77d8 100644 --- a/pynitrokey/cli/fido2.py +++ b/pynitrokey/cli/fido2.py @@ -18,7 +18,11 @@ import json import click import pynitrokey -import pynitrokey.fido2 + +# @fixme: 1st layer `nkfido2` lower layer `fido2` not to be used here ? +import pynitrokey.fido2 as nkfido2 + + from cryptography.hazmat.primitives import hashes from fido2.client import ClientError as Fido2ClientError from fido2.ctap1 import ApduError @@ -26,7 +30,7 @@ from pynitrokey.cli.monitor import monitor from pynitrokey.cli.program import program -import pynitrokey.operations +import pynitrokey.fido2.operations # https://pocoo-click.readthedocs.io/en/latest/commands/#nested-handling-and-contexts @click.group() @@ -135,7 +139,7 @@ def rng(): @click.command() def list(): """List all 'Nitrokey FIDO2' devices""" - solos = pynitrokey.client.find_all() + solos = nkfido2.client.find_all() print(":: 'Nitrokey FIDO2' keys") for c in solos: descriptor = c.dev.descriptor @@ -153,14 +157,14 @@ def hexbytes(count, serial): print(f"Number of bytes must be between 0 and 255, you passed {count}") sys.exit(1) - print(pynitrokey.client.find(serial).get_rng(count).hex()) + print(nkfido2.client.find(serial).get_rng(count).hex()) @click.command() @click.option("-s", "--serial", help="Serial number of Nitrokey to use") def raw(serial): """Output raw entropy endlessly.""" - p = pynitrokey.client.find(serial) + p = nkfido2.client.find(serial) while True: r = p.get_rng(255) sys.stdout.buffer.write(r) @@ -170,7 +174,7 @@ def raw(serial): @click.option("-b", "--blink", is_flag=True, help="Blink in the meantime") def status(serial, blink: bool): """Print device's status""" - p = pynitrokey.client.find(serial) + p = nkfido2.client.find(serial) t0 = time() while True: if time() - t0 > 5 and blink: @@ -196,7 +200,7 @@ def feedkernel(count, serial): print(f"Number of bytes must be between 0 and 255, you passed {count}") sys.exit(1) - p = pynitrokey.client.find(serial) + p = nkfido2.client.find(serial) import struct import fcntl @@ -255,9 +259,10 @@ def make_credential(serial, host, user, udp, prompt): Pass `--prompt ""` to output only the `credential_id` as hex. """ - import pynitrokey.hmac_secret - pynitrokey.hmac_secret.make_credential( + + + nkfido2.hmac_secret.make_credential( host=host, user_id=user, serial=serial, output=True, prompt=prompt, udp=udp ) @@ -292,9 +297,7 @@ def challenge_response(serial, host, user, prompt, credential_id, challenge, udp The prompt can be suppressed using `--prompt ""`. """ - import pynitrokey.hmac_secret - - pynitrokey.hmac_secret.simple_secret( + nkfido2.hmac_secret.simple_secret( credential_id, challenge, host=host, @@ -326,7 +329,7 @@ def probe(serial, udp, hash_type, filename): # so 6kb is conservative assert len(data) <= 6 * 1024 - p = pynitrokey.client.find(serial, udp=udp) + p = nkfido2.client.find(serial, udp=udp) import fido2 serialized_command = fido2.cbor.dumps({"subcommand": hash_type, "data": data}) @@ -364,7 +367,7 @@ def reset(serial): "Warning: Your credentials will be lost!!! Do you wish to continue?" ): print("Press the button to confirm -- again, your credentials will be lost!!!") - pynitrokey.client.find(serial).reset() + nkfido2.client.find(serial).reset() click.echo("....aaaand they're gone") @@ -380,7 +383,7 @@ def change_pin(serial): click.echo("New pin are mismatched. Please try again!") return try: - pynitrokey.client.find(serial).change_pin(old_pin, new_pin) + nkfido2.client.find(serial).change_pin(old_pin, new_pin) click.echo("Done. Please use new pin to verify key") except Exception as e: print(e) @@ -397,7 +400,7 @@ def set_pin(serial): click.echo("New pin are mismatched. Please try again!") return try: - pynitrokey.client.find(serial).set_pin(new_pin) + nkfido2.client.find(serial).set_pin(new_pin) click.echo("Done. Please use new pin to verify key") except Exception as e: print(e) @@ -415,7 +418,7 @@ def verify(pin, serial, udp): # Any longer and this needs to go in a submodule print("Please press the button on your Nitrokey key") try: - cert = pynitrokey.client.find(serial, udp=udp).make_credential(pin=pin) + cert = nkfido2.client.find(serial, udp=udp).make_credential(pin=pin) except ValueError as e: # python-fido2 library pre-emptively returns `ValueError('PIN required!')` # instead of trying, and returning `CTAP error: 0x36 - PIN_REQUIRED` @@ -477,7 +480,7 @@ def version(serial, udp): """Version of firmware on key.""" try: - res = pynitrokey.client.find(serial, udp=udp).solo_version() + res = nkfido2.client.find(serial, udp=udp).solo_version() major, minor, patch = res[:3] locked = "" if len(res) > 3: @@ -503,7 +506,7 @@ def version(serial, udp): def wink(serial, udp): """Send wink command to key (blinks LED a few times).""" - pynitrokey.client.find(serial, udp=udp).wink() + nkfido2.client.find(serial, udp=udp).wink() @click.command() @click.option("-s", "--serial", help="Serial number of Nitrokey to use") @@ -514,7 +517,7 @@ def reboot(serial, udp): """Send reboot command to key (development command)""" print('Reboot') CTAP_REBOOT = 0x53 - dev = pynitrokey.client.find(serial, udp=udp).dev + dev = nkfido2.client.find(serial, udp=udp).dev try: dev.call(CTAP_REBOOT ^ 0x80, b'') except OSError: diff --git a/pynitrokey/cli/program.py b/pynitrokey/cli/program.py index e497e1eb..175cff73 100644 --- a/pynitrokey/cli/program.py +++ b/pynitrokey/cli/program.py @@ -14,7 +14,7 @@ import pynitrokey import usb from fido2.ctap import CtapError -from pynitrokey.dfu import hot_patch_windows_libusb +from pynitrokey.fido2.dfu import hot_patch_windows_libusb diff --git a/pynitrokey/cli/update.py b/pynitrokey/cli/update.py index 523d87de..a292ef52 100644 --- a/pynitrokey/cli/update.py +++ b/pynitrokey/cli/update.py @@ -44,9 +44,10 @@ def update(serial, yes): local_print("") local_print("Starting update procedure for Nitrokey FIDO2") + from pynitrokey.fido2 import client as _client # Determine target key try: - client = pynitrokey.client.find(serial) + client = _client.find(serial) except pynitrokey.exceptions.NoSoloFoundError as e: print() @@ -103,7 +104,8 @@ def update(serial, yes): local_print(f"Downloaded firmware version: {github_release_data['tag_name']}") def get_dev_details(): - c = pynitrokey.client.find_all()[0] + from pynitrokey.fido2 import client as _client + c = _client.find_all()[0] descriptor = c.dev.descriptor local_print(f'Device connected:') if "serial_number" in descriptor: @@ -145,7 +147,8 @@ def get_dev_details(): # reconnect and actually flash it... try: - client = pynitrokey.client.find(serial) + from pynitrokey.fido2 import client as _client + client = _client.find(serial) client.use_hid() client.program_file(fw_fn) except Exception as e: diff --git a/pynitrokey/fido2/__init__.py b/pynitrokey/fido2/__init__.py index 07ae4561..775c563c 100644 --- a/pynitrokey/fido2/__init__.py +++ b/pynitrokey/fido2/__init__.py @@ -1,58 +1,2 @@ -import socket - -import fido2._pyu2f -import fido2._pyu2f.base - - -def force_udp_backend(): - fido2._pyu2f.InternalPlatformSwitch = _UDP_InternalPlatformSwitch - - -def _UDP_InternalPlatformSwitch(funcname, *args, **kwargs): - if funcname == "__init__": - return HidOverUDP(*args, **kwargs) - return getattr(HidOverUDP, funcname)(*args, **kwargs) - - -class HidOverUDP(fido2._pyu2f.base.HidDevice): - @staticmethod - def Enumerate(): - a = [ - { - "vendor_id": 0x1234, - "product_id": 0x5678, - "product_string": "software test interface", - "serial_number": "12345678", - "usage": 0x01, - "usage_page": 0xF1D0, - "path": "localhost:8111", - } - ] - return a - - def __init__(self, path): - self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - self.sock.bind(("127.0.0.1", 7112)) - addr, port = path.split(":") - port = int(port) - self.token = (addr, port) - self.sock.settimeout(1.0) - - def GetInReportDataLength(self): - return 64 - - def GetOutReportDataLength(self): - return 64 - - def Write(self, packet): - self.sock.sendto(bytearray(packet), self.token) - - def Read(self): - msg = [0] * 64 - pkt, _ = self.sock.recvfrom(64) - for i, v in enumerate(pkt): - try: - msg[i] = ord(v) - except TypeError: - msg[i] = v - return msg +import pynitrokey.fido2.client as client +import pynitrokey.fido2.hmac_secret as hmac_secret diff --git a/pynitrokey/client.py b/pynitrokey/fido2/client.py similarity index 99% rename from pynitrokey/client.py rename to pynitrokey/fido2/client.py index c584b4a7..9e515f15 100644 --- a/pynitrokey/client.py +++ b/pynitrokey/fido2/client.py @@ -13,7 +13,6 @@ import sys import tempfile import time -from threading import Event, Timer from cryptography import x509 from cryptography.hazmat.backends import default_backend @@ -27,7 +26,7 @@ import pynitrokey.exceptions from pynitrokey import helpers -from pynitrokey.commands import SoloBootloader, SoloExtension +from pynitrokey.fido2.commands import SoloBootloader, SoloExtension def find(solo_serial=None, retries=5, raw_device=None, udp=False): diff --git a/pynitrokey/commands.py b/pynitrokey/fido2/commands.py similarity index 100% rename from pynitrokey/commands.py rename to pynitrokey/fido2/commands.py diff --git a/pynitrokey/dfu.py b/pynitrokey/fido2/dfu.py similarity index 99% rename from pynitrokey/dfu.py rename to pynitrokey/fido2/dfu.py index 6cabe442..85eb188d 100644 --- a/pynitrokey/dfu.py +++ b/pynitrokey/fido2/dfu.py @@ -14,7 +14,7 @@ import usb._objfinalizer import usb.core import usb.util -from pynitrokey.commands import DFU, STM32L4 +from pynitrokey.fido2.commands import DFU, STM32L4 def find(dfu_serial=None, attempts=8, raw_device=None, altsetting=1): diff --git a/pynitrokey/enums.py b/pynitrokey/fido2/enums.py similarity index 100% rename from pynitrokey/enums.py rename to pynitrokey/fido2/enums.py diff --git a/pynitrokey/hmac_secret.py b/pynitrokey/fido2/hmac_secret.py similarity index 92% rename from pynitrokey/hmac_secret.py rename to pynitrokey/fido2/hmac_secret.py index dd533737..0f20d193 100644 --- a/pynitrokey/hmac_secret.py +++ b/pynitrokey/fido2/hmac_secret.py @@ -12,10 +12,10 @@ import hashlib import secrets -import pynitrokey.client from fido2.extensions import HmacSecretExtension + def make_credential( host="nitrokeys.dev", user_id="they", @@ -26,8 +26,8 @@ def make_credential( udp=False, ): user_id = user_id.encode() - - client = pynitrokey.client.find(solo_serial=serial, udp=udp).client + from pynitrokey.fido2 import client as _client + client = _client.find(solo_serial=serial, udp=udp).client rp = {"id": host, "name": "Example RP"} @@ -72,7 +72,8 @@ def simple_secret( ): user_id = user_id.encode() - client = pynitrokey.client.find(solo_serial=serial, udp=udp).client + from pynitrokey.fido2 import client + client = client.find(solo_serial=serial, udp=udp).client hmac_ext = HmacSecretExtension(client.ctap2) # rp = {"id": host, "name": "Example RP"} diff --git a/pynitrokey/operations.py b/pynitrokey/fido2/operations.py similarity index 100% rename from pynitrokey/operations.py rename to pynitrokey/fido2/operations.py diff --git a/pynitrokey/helpers.py b/pynitrokey/helpers.py index 463295a6..1826a07b 100644 --- a/pynitrokey/helpers.py +++ b/pynitrokey/helpers.py @@ -32,8 +32,7 @@ class Timeout(object): :ivar event: The Event associated with the Timeout. :ivar timer: The Timer associated with the Timeout, if any. """ - - + def __init__(self, time_or_event): if isinstance(time_or_event, Number): self.event = Event() From 64d5df22aab231cad1dffe968b8e259ac796dfda Mon Sep 17 00:00:00 2001 From: Markus Meissner Date: Tue, 25 Aug 2020 10:43:11 +0200 Subject: [PATCH 06/19] start cleanups --- pynitrokey/cli/start.py | 17 +---------------- pynitrokey/helpers.py | 2 +- pynitrokey/start/upgrade_by_passwd.py | 2 +- 3 files changed, 3 insertions(+), 18 deletions(-) diff --git a/pynitrokey/cli/start.py b/pynitrokey/cli/start.py index 7a3abb48..9412fc90 100644 --- a/pynitrokey/cli/start.py +++ b/pynitrokey/cli/start.py @@ -124,19 +124,4 @@ def kdf_details(passwd): start.add_command(set_identity) start.add_command(update) start.add_command(kdf_details) -# start.add_command(rng) -# start.add_command(reboot) -# rng.add_command(hexbytes) -# rng.add_command(raw) -# rng.add_command(feedkernel) -# start.add_command(make_credential) -# start.add_command(challenge_response) -# start.add_command(reset) -# start.add_command(status) -# start.add_command(update) -# start.add_command(probe) -# # key.add_command(sha256sum) -# # key.add_command(sha512sum) -# start.add_command(version) -# start.add_command(verify) -# start.add_command(wink) + diff --git a/pynitrokey/helpers.py b/pynitrokey/helpers.py index 1826a07b..21af120a 100644 --- a/pynitrokey/helpers.py +++ b/pynitrokey/helpers.py @@ -32,7 +32,7 @@ class Timeout(object): :ivar event: The Event associated with the Timeout. :ivar timer: The Timer associated with the Timeout, if any. """ - + def __init__(self, time_or_event): if isinstance(time_or_event, Number): self.event = Event() diff --git a/pynitrokey/start/upgrade_by_passwd.py b/pynitrokey/start/upgrade_by_passwd.py index 4c978568..cf293ec0 100755 --- a/pynitrokey/start/upgrade_by_passwd.py +++ b/pynitrokey/start/upgrade_by_passwd.py @@ -427,7 +427,7 @@ def start_update(regnual, gnuk, default_password, password, wait_e, keyno, verbo if password: passwd = password - elif default_password: # F for Factory setting + elif default_password: passwd = DEFAULT_PW3 if not passwd: try: From 8f962bf5571b1ce45252537df49aa91f01aa27bc Mon Sep 17 00:00:00 2001 From: Markus Meissner Date: Tue, 25 Aug 2020 13:58:25 +0200 Subject: [PATCH 07/19] more fido2 cleanups; local_print consistency; add confconsts.py; introduce local_critical --- pynitrokey/__init__.py | 1 - pynitrokey/cli/fido2.py | 198 +++++++++++++------------- pynitrokey/cli/update.py | 158 ++++++++++---------- pynitrokey/confconsts.py | 22 +++ pynitrokey/fido2/__init__.py | 45 +++++- pynitrokey/fido2/client.py | 45 +----- pynitrokey/fido2/hmac_secret.py | 14 +- pynitrokey/helpers.py | 137 ++++++++++++++++-- pynitrokey/start/upgrade_by_passwd.py | 7 +- 9 files changed, 381 insertions(+), 246 deletions(-) create mode 100644 pynitrokey/confconsts.py diff --git a/pynitrokey/__init__.py b/pynitrokey/__init__.py index b223ff94..f78e564d 100644 --- a/pynitrokey/__init__.py +++ b/pynitrokey/__init__.py @@ -12,7 +12,6 @@ import pathlib -#from fido2 import client, commands, dfu, helpers, operations __version__ = open(pathlib.Path(__file__).parent / "VERSION").read().strip() diff --git a/pynitrokey/cli/fido2.py b/pynitrokey/cli/fido2.py index 763f77d8..3f9722a0 100644 --- a/pynitrokey/cli/fido2.py +++ b/pynitrokey/cli/fido2.py @@ -19,7 +19,7 @@ import click import pynitrokey -# @fixme: 1st layer `nkfido2` lower layer `fido2` not to be used here ? +# @fixme: 1st layer `nkfido2` lower layer `fido2` not to be used here ! import pynitrokey.fido2 as nkfido2 @@ -32,6 +32,9 @@ from pynitrokey.cli.program import program import pynitrokey.fido2.operations +from pynitrokey.helpers import AskUser, local_print, local_critical + + # https://pocoo-click.readthedocs.io/en/latest/commands/#nested-handling-and-contexts @click.group() def fido2(): @@ -39,15 +42,13 @@ def fido2(): pass - @click.group() def util(): """Additional utilities, see subcommands.""" pass - - +# @todo: is this working as intended @click.command() @click.option("--input-seed-file") @click.argument("output_pem_file") @@ -63,33 +64,35 @@ def genkey(input_seed_file, output_pem_file): vk = pynitrokey.operations.genkey(output_pem_file, input_seed_file=input_seed_file) - print("Public key in various formats:") - print() - print([c for c in vk.to_string()]) - print() - print("".join(["%02x" % c for c in vk.to_string()])) - print() - print('"\\x' + "\\x".join(["%02x" % c for c in vk.to_string()]) + '"') - print() - - + local_print( + "Public key in various formats:", + None, + [c for c in vk.to_string()], + None, + "".join(["%02x" % c for c in vk.to_string()]), + None, + '"\\x' + "\\x".join(["%02x" % c for c in vk.to_string()]) + '"', + None) +# @todo: is this working as intended @click.command() @click.argument("verifying-key") @click.argument("app-hex") @click.argument("output-json") -@click.option("--end_page", help="Set APPLICATION_END_PAGE. Should be in sync with firmware settings.", default=20, type=int) +@click.option("--end_page", + help="Set APPLICATION_END_PAGE. Shall be in sync with firmware settings", + default=20, type=int) def sign(verifying_key, app_hex, output_json, end_page): - """Signs a firmware hex file, outputs a .json file that can be used for signed update.""" + """Signs a fw-hex file, outputs a .json file that can be used for signed update.""" - msg = pynitrokey.operations.sign_firmware(verifying_key, app_hex, APPLICATION_END_PAGE=end_page) - print("Saving signed firmware to", output_json) + msg = pynitrokey.operations.sign_firmware( + verifying_key, app_hex, APPLICATION_END_PAGE=end_page) + local_print(f"Saving signed firmware to: {output_json}") with open(output_json, "wb+") as fh: fh.write(json.dumps(msg).encode()) - @click.command() @click.option("--attestation-key", help="attestation key in hex") @click.option("--attestation-cert", help="attestation certificate file") @@ -126,63 +129,60 @@ def mergehex( ) - - - - - @click.group() def rng(): """Access TRNG on key, see subcommands.""" pass + @click.command() def list(): """List all 'Nitrokey FIDO2' devices""" - solos = nkfido2.client.find_all() - print(":: 'Nitrokey FIDO2' keys") + solos = nkfido2.find_all() + local_print(":: 'Nitrokey FIDO2' keys") for c in solos: - descriptor = c.dev.descriptor - if "serial_number" in descriptor: - print(f"{descriptor['serial_number']}: {descriptor['product_string']}") + devdata = c.dev.descriptor + if "serial_number" in devdata: + local_print(f"{devdata['serial_number']}: {devdata['product_string']}") else: - print(f"{descriptor['path']}: {descriptor['product_string']}") + local_print(f"{devdata['path']}: {devdata['product_string']}") + @click.command() @click.option("--count", default=8, help="How many bytes to generate (defaults to 8)") @click.option("-s", "--serial", help="Serial number of Nitrokey to use") def hexbytes(count, serial): """Output COUNT number of random bytes, hex-encoded.""" - if not 0 <= count <= 255: - print(f"Number of bytes must be between 0 and 255, you passed {count}") - sys.exit(1) - print(nkfido2.client.find(serial).get_rng(count).hex()) + if not 0 <= count <= 255: + local_critical(f"Number of bytes must be between 0 and 255, you passed {count}") + local_print(nkfido2.find(serial).get_rng(count).hex()) @click.command() @click.option("-s", "--serial", help="Serial number of Nitrokey to use") def raw(serial): """Output raw entropy endlessly.""" - p = nkfido2.client.find(serial) + p = nkfido2.find(serial) while True: r = p.get_rng(255) sys.stdout.buffer.write(r) + @click.command() @click.option("-s", "--serial", help="Serial number of Nitrokey to use") @click.option("-b", "--blink", is_flag=True, help="Blink in the meantime") def status(serial, blink: bool): """Print device's status""" - p = nkfido2.client.find(serial) + p = nkfido2.find(serial) t0 = time() while True: if time() - t0 > 5 and blink: p.wink() r = p.get_status() for b in r: - print('{:#02d} '.format(b), end='') - print('') + local_print('{:#02d} '.format(b), end='') + local_print("") sleep(0.3) @@ -193,14 +193,12 @@ def feedkernel(count, serial): """Feed random bytes to /dev/random.""" if os.name != "posix": - print("This is a Linux-specific command!") - sys.exit(1) + local_critical("This is a Linux-specific command!") if not 0 <= count <= 255: - print(f"Number of bytes must be between 0 and 255, you passed {count}") - sys.exit(1) + local_critical(f"Number of bytes must be between 0 and 255, you passed {count}") - p = nkfido2.client.find(serial) + p = nkfido2.find(serial) import struct import fcntl @@ -230,12 +228,13 @@ def feedkernel(count, serial): # entropy count, and buf is the buffer of size buf_size which gets # added to the entropy pool. - entropy_bits_per_byte = 2 # maximum 8, tend to be pessimistic + # maximum 8, tend to be pessimistic + entropy_bits_per_byte = 2 t = struct.pack(f"ii{count}s", count * entropy_bits_per_byte, count, r) with open("/dev/random", mode="wb") as fh: fcntl.ioctl(fh, RNDADDENTROPY, t) - print(f"Entropy after: 0x{open(entropy_info_file).read().strip()}") + local_print(f"Entropy after: 0x{open(entropy_info_file).read().strip()}") @click.command() @@ -259,9 +258,6 @@ def make_credential(serial, host, user, udp, prompt): Pass `--prompt ""` to output only the `credential_id` as hex. """ - - - nkfido2.hmac_secret.make_credential( host=host, user_id=user, serial=serial, output=True, prompt=prompt, udp=udp ) @@ -329,20 +325,21 @@ def probe(serial, udp, hash_type, filename): # so 6kb is conservative assert len(data) <= 6 * 1024 - p = nkfido2.client.find(serial, udp=udp) + p = nkfido2.find(serial, udp=udp) + import fido2 serialized_command = fido2.cbor.dumps({"subcommand": hash_type, "data": data}) - from pynitrokey.commands import SoloBootloader + from pynitrokey.fido2.commands import SoloBootloader result = p.send_data_hid(SoloBootloader.HIDCommandProbe, serialized_command) result_hex = result.hex() - print(result_hex) + local_print(result_hex) if hash_type == "Ed25519": - print(f"content: {result[64:]}") + local_print(f"content: {result[64:]}") # print(f"content from hex: {bytes.fromhex(result_hex[128:]).decode()}") - print(f"content from hex: {bytes.fromhex(result_hex[128:])}") - print(f"signature: {result[:128]}") + local_print(f"content from hex: {bytes.fromhex(result_hex[128:])}") + local_print(f"signature: {result[:128]}") import nacl.signing # verify_key = nacl.signing.VerifyKey(bytes.fromhex("c69995185efa20bf7a88139f5920335aa3d3e7f20464345a2c095c766dfa157a")) @@ -356,19 +353,17 @@ def probe(serial, udp, hash_type, filename): verified = True except nacl.exceptions.BadSignatureError: verified = False - print(f"verified? {verified}") + local_print(f"verified? {verified}") # print(fido2.cbor.loads(result)) @click.command() @click.option("-s", "--serial", help="Serial number of Nitrokey to use") def reset(serial): """Reset key - wipes all credentials!!!""" - if click.confirm( - "Warning: Your credentials will be lost!!! Do you wish to continue?" - ): - print("Press the button to confirm -- again, your credentials will be lost!!!") - nkfido2.client.find(serial).reset() - click.echo("....aaaand they're gone") + if AskUser.yes_no("Warning: Your credentials will be lost!!! continue?"): + local_print("Press key to confirm -- again, your credentials will be lost!!!") + nkfido2.find(serial).reset() + local_print("....aaaand they're gone") @click.command() @@ -380,13 +375,13 @@ def change_pin(serial): new_pin = getpass.getpass("Please enter new pin: ") confirm_pin = getpass.getpass("Please confirm new pin: ") if new_pin != confirm_pin: - click.echo("New pin are mismatched. Please try again!") + local_print("New pin are mismatched. Please try again!") return try: - nkfido2.client.find(serial).change_pin(old_pin, new_pin) - click.echo("Done. Please use new pin to verify key") + nkfido2.find(serial).change_pin(old_pin, new_pin) + local_print("Done. Please use new pin to verify key") except Exception as e: - print(e) + local_critical(e) @click.command() @@ -397,13 +392,13 @@ def set_pin(serial): new_pin = getpass.getpass("Please enter new pin: ") confirm_pin = getpass.getpass("Please confirm new pin: ") if new_pin != confirm_pin: - click.echo("New pin are mismatched. Please try again!") + local_print("New pin are mismatched. Please try again!") return try: - nkfido2.client.find(serial).set_pin(new_pin) - click.echo("Done. Please use new pin to verify key") + nkfido2.find(serial).set_pin(new_pin) + local_print("Done. Please use new pin to verify key") except Exception as e: - print(e) + local_critical(e) @click.command() @@ -416,14 +411,14 @@ def verify(pin, serial, udp): """Verify key is valid Nitrokey 'Start' or 'FIDO2' key.""" # Any longer and this needs to go in a submodule - print("Please press the button on your Nitrokey key") + local_print("Please press the button on your Nitrokey key") try: - cert = nkfido2.client.find(serial, udp=udp).make_credential(pin=pin) + cert = nkfido2.find(serial, udp=udp).make_credential(pin=pin) except ValueError as e: # python-fido2 library pre-emptively returns `ValueError('PIN required!')` # instead of trying, and returning `CTAP error: 0x36 - PIN_REQUIRED` if "PIN required" in str(e): - print("Your key has a PIN set. Please pass it using `--pin `") + local_print("Your key has a PIN set. Please pass it using `--pin `") sys.exit(1) raise @@ -431,31 +426,29 @@ def verify(pin, serial, udp): cause = str(e.cause) # error 0x31 if "PIN_INVALID" in cause: - print("Your key has a different PIN. Please try to remember it :)") - sys.exit(1) + local_critical("Your key has a different PIN. Please try to remember it :)", + e) + # error 0x34 (power cycle helps) if "PIN_AUTH_BLOCKED" in cause: - print( - "Your key's PIN authentication is blocked due to too many incorrect attempts." - ) - print("Please plug it out and in again, then again!") - print( - "Please be careful, after too many incorrect attempts, the key will fully block." - ) - sys.exit(1) + local_critical( + "Your key's PIN auth is blocked due to too many incorrect attempts.", + "Please plug it out and in again, then again!", + "Please be careful, after too many incorrect attempts, ", + " the key will fully block.", e) + # error 0x32 (only reset helps) if "PIN_BLOCKED" in cause: - print( - "Your key's PIN is blocked. To use it again, you need to fully reset it." - ) - print("You can do this using: `nitropy fido2 reset`") - sys.exit(1) + local_critical( + "Your key's PIN is blocked. ", + "To use it again, you need to fully reset it.", + "You can do this using: `nitropy fido2 reset`", e) + # error 0x01 if "INVALID_COMMAND" in cause: - print("Error getting credential, is your key in bootloader mode?") - print("Try: `nitropy fido2 util program aux leave-bootloader`") - sys.exit(1) - raise + local_critical( + "Error getting credential, is your key in bootloader mode?", + "Try: `nitropy fido2 util program aux leave-bootloader`", e) hashdb = { b'd7a23679007fe799aeda4388890f33334aba4097bb33fee609c8998a1ba91bd3': "Nitrokey FIDO2 1.x", @@ -466,9 +459,9 @@ def verify(pin, serial, udp): dev_fingerprint = cert.fingerprint(hashes.SHA256()) a_hex = binascii.b2a_hex(dev_fingerprint) if a_hex in hashdb: - print('Found device: {}'.format(hashdb[a_hex])) + local_print('Found device: {}'.format(hashdb[a_hex])) else: - print("Unknown fingerprint! ", a_hex) + local_print("Unknown fingerprint! ", a_hex) @click.command() @@ -480,7 +473,7 @@ def version(serial, udp): """Version of firmware on key.""" try: - res = nkfido2.client.find(serial, udp=udp).solo_version() + res = nkfido2.find(serial, udp=udp).solo_version() major, minor, patch = res[:3] locked = "" if len(res) > 3: @@ -488,14 +481,16 @@ def version(serial, udp): locked = "locked" else: locked = "unlocked" - print(f"{major}.{minor}.{patch} {locked}") + local_print(f"{major}.{minor}.{patch} {locked}") except pynitrokey.exceptions.NoSoloFoundError: - print("No Nitrokey found.") - print("If you are on Linux, are your udev rules up to date?") + local_critical("No Nitrokey found.", + "If you are on Linux, are your udev rules up to date?") + + # unused ??? except (pynitrokey.exceptions.NoSoloFoundError, ApduError): - # Older - print("Firmware is out of date (key does not know the NITROKEY_VERSION command).") + local_critical( + "Firmware is out of date (key does not know the NITROKEY_VERSION command).") @click.command() @@ -506,7 +501,7 @@ def version(serial, udp): def wink(serial, udp): """Send wink command to key (blinks LED a few times).""" - nkfido2.client.find(serial, udp=udp).wink() + nkfido2.find(serial, udp=udp).wink() @click.command() @click.option("-s", "--serial", help="Serial number of Nitrokey to use") @@ -517,12 +512,13 @@ def reboot(serial, udp): """Send reboot command to key (development command)""" print('Reboot') CTAP_REBOOT = 0x53 - dev = nkfido2.client.find(serial, udp=udp).dev + dev = nkfido2.find(serial, udp=udp).dev try: dev.call(CTAP_REBOOT ^ 0x80, b'') except OSError: pass + fido2.add_command(rng) fido2.add_command(reboot) fido2.add_command(list) diff --git a/pynitrokey/cli/update.py b/pynitrokey/cli/update.py index a292ef52..e9a3d4d4 100644 --- a/pynitrokey/cli/update.py +++ b/pynitrokey/cli/update.py @@ -20,7 +20,9 @@ import pynitrokey -from pynitrokey.helpers import local_print, UPGRADE_LOG_FN, logger +from pynitrokey.helpers import local_print, local_critical, LOG_FN, logger +from pynitrokey.helpers import AskUser + @click.command() @@ -30,67 +32,71 @@ def update(serial, yes): """Update Nitrokey key to latest firmware version.""" + # @fixme: print this and allow user to cancel (if not -y is active) #update_url = 'https://update.nitrokey.com/' #print('Please use {} to run the firmware update'.format(update_url)) #return IS_LINUX = platform.system() == "Linux" - local_print('Nitrokey FIDO2 firmware update tool') - logger.debug('Start session {}'.format(datetime.now())) - local_print('Platform: {}'.format(platform.platform())) - local_print('System: {}, is_linux: {}'.format(platform.system(), IS_LINUX)) - local_print('Python: {}'.format(platform.python_version())) - local_print('Saving run log to: {}'.format(UPGRADE_LOG_FN)) - local_print("") - local_print("Starting update procedure for Nitrokey FIDO2") - - from pynitrokey.fido2 import client as _client + + logger.debug(f"Start session {datetime.now()}") + + # @fixme: move to generic startup stuff logged into file exclusively! + local_print("Nitrokey FIDO2 firmware update tool", + f"Platform: {platform.platform()}", + f"System: {platform.system()}, is_linux: {IS_LINUX}", + f"Python: {platform.python_version()}", + f"Saving run log to: {LOG_FN}", "", + f"Starting update procedure for Nitrokey FIDO2...") + + from pynitrokey.fido2 import find + # Determine target key + client = None try: - client = _client.find(serial) + client = find(serial) except pynitrokey.exceptions.NoSoloFoundError as e: - print() - local_print("No Nitrokey key found!", exc=e) - print() - local_print("If you are on Linux, are your udev rules up to date?") - local_print("For more, see https://www.nitrokey.com/documentation/installation#os:linux") - print() - sys.exit(1) + local_critical(None, + "No Nitrokey key found!", e, None, + "If you are on Linux, are your udev rules up to date?", + "For more, see: ", + " https://www.nitrokey.com/documentation/installation#os:linux", + None) + except pynitrokey.exceptions.NonUniqueDeviceError as e: - print() - local_print("Multiple Nitrokey keys are plugged in!", exc=e) - local_print("Please unplug all but one key") - print() - sys.exit(1) + local_critical(None, + "Multiple Nitrokey keys are plugged in!", e, None, + "Please unplug all but one key", None) + except Exception as e: - print() - local_print("Unhandled error connecting to key.", exc=e) - local_print("Please report via https://github.com/Nitrokey/pynitrokey/issues/") - print() - sys.exit(1) + local_critical(None, "Unhandled error connecting to key", e, None) # determine asset url: we want the (signed) json file - api_url = "https://api.github.com/repos/Nitrokey/nitrokey-fido2-firmware/releases/latest" + # @fixme: move to confconsts.py ... + api_base_url = "https://api.github.com/repos" + api_url = f"{api_base_url}/Nitrokey/nitrokey-fido2-firmware/releases/latest" try: - github_release_data = json.loads(requests.get(api_url).text) + gh_release_data = json.loads(requests.get(api_url).text) except Exception as e: - local_print('Failed downloading firmware', exc=e) - sys.exit(1) + local_critical("Failed downloading firmware", e) - assets = [(x["name"], x["browser_download_url"]) - for x in github_release_data["assets"]] + # search asset with `fn` suffix being .json and take its url + assets = [(x["name"], x["browser_download_url"]) \ + for x in gh_release_data["assets"]] download_url = None for fn, url in assets: if fn.endswith(".json"): download_url = url break - if download_url is None: - local_print("Failed to determine latest release") - sys.exit(1) + if not download_url: + local_critical("Failed to determine latest release (url)", + "assets:", *map(str, assets)) # download asset url - local_print(f"Downloading latest firmware: {github_release_data['tag_name']} (published at {github_release_data['published_at']})") + # @fixme: move to confconsts.py ... + local_print(f"Downloading latest firmware: {gh_release_data['tag_name']} " + f"(published at {gh_release_data['published_at']})") tmp_dir = tempfile.gettempdir() fw_fn = os.path.join(tmp_dir, "fido2_firmware.json") try: @@ -98,40 +104,38 @@ def update(serial, yes): firmware = requests.get(download_url) fd.write(firmware.content) except Exception as e: - local_print('Failed downloading firmware', exc=e) - sys.exit(1) - local_print(f"Firmware saved to {fw_fn}") - local_print(f"Downloaded firmware version: {github_release_data['tag_name']}") + local_critical("Failed downloading firmware", e) + local_print(f"Firmware saved to {fw_fn}", + f"Downloaded firmware version: {gh_release_data['tag_name']}") + + # @fixme: whyyyyy is this here, move away... (maybe directly next to `fido2.find()`) def get_dev_details(): - from pynitrokey.fido2 import client as _client - c = _client.find_all()[0] - descriptor = c.dev.descriptor - local_print(f'Device connected:') - if "serial_number" in descriptor: - print(f"{descriptor['serial_number']}: {descriptor['product_string']}") + + # @fixme: why not use `find` here... + from pynitrokey.fido2 import find_all + c = find_all()[0] + + _props = c.dev.descriptor + local_print(f"Device connected:") + if "serial_number" in _props: + local_print(f"{_props['serial_number']}: {_props['product_string']}") else: - print(f"{descriptor['path']}: {descriptor['product_string']}") + local_print(f"{_props['path']}: {_props['product_string']}") + version_raw = c.solo_version() major, minor, patch = version_raw[:3] - locked = "" - if len(version_raw) > 3: - if version_raw[3]: - locked = "" - else: - locked = "unlocked" - local_print(f'Firmware version: {major}.{minor}.{patch} {locked}') - local_print("") + locked = "" if len(version_raw) > 3 and version_raw[3] else "unlocked" + + local_print(f"Firmware version: {major}.{minor}.{patch} {locked}", None) + get_dev_details() # ask for permission if not yes: - local_print('This will update your Nitrokey FIDO2...') - answer = input('Do you want to continue? [yes/no]: ') - local_print('Entered: "{}"'.format(answer)) - if answer != 'yes': - local_print('Device is not modified. Exiting.') - sys.exit(1) + if not AskUser.strict_yes_no("Do you want to continue? [yes/no]: ", + title="This will update your Nitrokey FIDO2"): + local_critical("exiting due to user input...", support_hint=False) # Ensure we are in bootloader mode if client.is_solo_bootloader(): @@ -142,35 +146,33 @@ def get_dev_details(): client.enter_bootloader_or_die() time.sleep(0.5) except Exception as e: - local_print("ERROR - problem switching to bootloader mode:", exc=e) - sys.exit(1) + local_critical("problem switching to bootloader mode:", e) # reconnect and actually flash it... try: - from pynitrokey.fido2 import client as _client - client = _client.find(serial) + from pynitrokey.fido2 import find + client = find(serial) client.use_hid() client.program_file(fw_fn) + except Exception as e: - local_print("ERROR - problem flashing firmware:", exc=e) - sys.exit(1) + local_critical("problem flashing firmware:", e) - local_print('') - local_print('After update check') - ATTEMPTS = 100 - for i in range(ATTEMPTS): + local_print(None, "After update check") + tries = 100 + for i in range(tries): try: get_dev_details() break except Exception as e: - if i > ATTEMPTS-1: - local_print("Could not connect to device after update", exc=e) + if i > tries-1: + local_critical("Could not connect to device after update", e) raise time.sleep(0.5) local_print("Congratulations, your key was updated to the latest firmware.") - logger.debug('Finishing session {}'.format(datetime.now())) - local_print('Log saved to: {}'.format(UPGRADE_LOG_FN)) + logger.debug("Finishing session {}".format(datetime.now())) + local_print("Log saved to: {}".format(LOG_FN)) diff --git a/pynitrokey/confconsts.py b/pynitrokey/confconsts.py new file mode 100644 index 00000000..fd37010b --- /dev/null +++ b/pynitrokey/confconsts.py @@ -0,0 +1,22 @@ + +from enum import IntEnum +import tempfile + + +class Verbosity(IntEnum): + unset = 0 + silent = 1 + machine = 2 + user = 3 + full = 4 + debug = 5 + + +#VERBOSE = Verbosity.user +VERBOSE = Verbosity.debug + +LOG_FN = tempfile.NamedTemporaryFile(prefix="nitropy.log.").name +LOG_FORMAT_STDOUT = '*** %(asctime)-15s %(levelname)6s %(name)10s %(message)s' +LOG_FORMAT = '%(relativeCreated)-8d %(levelname)6s %(name)10s %(message)s' + +ISSUES_URL = "https://github.com/Nitrokey/pynitrokey/issues/" diff --git a/pynitrokey/fido2/__init__.py b/pynitrokey/fido2/__init__.py index 775c563c..4c8aff0f 100644 --- a/pynitrokey/fido2/__init__.py +++ b/pynitrokey/fido2/__init__.py @@ -1,2 +1,45 @@ -import pynitrokey.fido2.client as client + +import time + import pynitrokey.fido2.hmac_secret as hmac_secret + + +def find(solo_serial=None, retries=5, raw_device=None, udp=False): + # @fixme: revive + #if udp: + # pynitrokey.fido2.force_udp_backend() + + from pynitrokey.fido2.client import NKFido2Client + from pynitrokey.exceptions import NoSoloFoundError + + p = NKFido2Client() + + # This... is not the right way to do it yet + p.use_u2f() + + for i in range(retries): + try: + p.find_device(dev=raw_device, solo_serial=solo_serial) + return p + except RuntimeError: + time.sleep(0.2) + + # return None + raise NoSoloFoundError("no Nitrokey FIDO2 found") + + +def find_all(): + from fido2.hid import CtapHidDevice + + hid_devices = list(CtapHidDevice.list_devices()) + solo_devices = [d for d in hid_devices + if (d.descriptor["vendor_id"], d.descriptor["product_id"]) in [ + ## @FIXME: woop, woop MAGIC NUMBERS ahoi... + (1155, 41674), + (0x20A0, 0x42B3), + (0x20A0, 0x42B1), + ] + ] + return [find(raw_device=device) for device in solo_devices] + + diff --git a/pynitrokey/fido2/client.py b/pynitrokey/fido2/client.py index 9e515f15..bc4ea52b 100644 --- a/pynitrokey/fido2/client.py +++ b/pynitrokey/fido2/client.py @@ -29,42 +29,7 @@ from pynitrokey.fido2.commands import SoloBootloader, SoloExtension -def find(solo_serial=None, retries=5, raw_device=None, udp=False): - if udp: - pynitrokey.fido2.force_udp_backend() - - # TODO: change `p` (for programmer) throughout - p = SoloClient() - - # This... is not the right way to do it yet - p.use_u2f() - - for i in range(retries): - try: - p.find_device(dev=raw_device, solo_serial=solo_serial) - return p - except RuntimeError: - time.sleep(0.2) - - # return None - raise pynitrokey.exceptions.NoSoloFoundError("no Nitrokey found") - - -def find_all(): - hid_devices = list(CtapHidDevice.list_devices()) - solo_devices = [ - d - for d in hid_devices - if (d.descriptor["vendor_id"], d.descriptor["product_id"]) in [ - (1155, 41674), - (0x20A0, 0x42B3), - (0x20A0, 0x42B1), - ] - ] - return [find(raw_device=device) for device in solo_devices] - - -class SoloClient: +class NKFido2Client: def __init__(self,): self.origin = "https://example.org" self.host = "example.org" @@ -138,7 +103,7 @@ def send_data_hid(self, cmd, data): return self.dev.call(cmd, data, event) def exchange_hid(self, cmd, addr=0, data=b"A" * 16): - req = SoloClient.format_request(cmd, addr, data) + req = NKFido2Client.format_request(cmd, addr, data) data = self.send_data_hid(SoloBootloader.HIDCommandBoot, req) @@ -152,7 +117,7 @@ def exchange_u2f(self, cmd, addr=0, data=b"A" * 16): appid = b"A" * 32 chal = b"B" * 32 - req = SoloClient.format_request(cmd, addr, data) + req = NKFido2Client.format_request(cmd, addr, data) res = self.ctap1.authenticate(chal, appid, req) @@ -165,7 +130,7 @@ def exchange_u2f(self, cmd, addr=0, data=b"A" * 16): def exchange_fido2(self, cmd, addr=0, data=b"A" * 16): chal = b"B" * 32 - req = SoloClient.format_request(cmd, addr, data) + req = NKFido2Client.format_request(cmd, addr, data) assertion = self.ctap2.get_assertion( self.host, chal, [{"id": req, "type": "public-key"}] @@ -284,7 +249,7 @@ def enter_st_dfu(self,): soloboot = self.is_solo_bootloader() if soloboot or self.exchange == self.exchange_u2f: - req = SoloClient.format_request(SoloBootloader.st_dfu) + req = NKFido2Client.format_request(SoloBootloader.st_dfu) self.send_only_hid(SoloBootloader.HIDCommandBoot, req) else: self.send_only_hid(SoloBootloader.HIDCommandEnterSTBoot, "") diff --git a/pynitrokey/fido2/hmac_secret.py b/pynitrokey/fido2/hmac_secret.py index 0f20d193..250ee4e1 100644 --- a/pynitrokey/fido2/hmac_secret.py +++ b/pynitrokey/fido2/hmac_secret.py @@ -7,15 +7,12 @@ # http://opensource.org/licenses/MIT>, at your option. This file may not be # copied, modified, or distributed except according to those terms. - import binascii import hashlib import secrets from fido2.extensions import HmacSecretExtension - - def make_credential( host="nitrokeys.dev", user_id="they", @@ -26,8 +23,8 @@ def make_credential( udp=False, ): user_id = user_id.encode() - from pynitrokey.fido2 import client as _client - client = _client.find(solo_serial=serial, udp=udp).client + from pynitrokey.fido2 import find + client = find(solo_serial=serial, udp=udp).client rp = {"id": host, "name": "Example RP"} @@ -72,8 +69,8 @@ def simple_secret( ): user_id = user_id.encode() - from pynitrokey.fido2 import client - client = client.find(solo_serial=serial, udp=udp).client + from pynitrokey.fido2 import find + client = find(solo_serial=serial, udp=udp).client hmac_ext = HmacSecretExtension(client.ctap2) # rp = {"id": host, "name": "Example RP"} @@ -99,8 +96,7 @@ def simple_secret( "challenge": challenge.encode("utf8"), "allowCredentials": allow_list, "extensions": hmac_ext.get_dict(salt), - }, - pin=pin) + }, pin=pin) assertion = assertions[0] # Only one cred in allowList, only one response. response = hmac_ext.results_for(assertion.auth_data)[0] diff --git a/pynitrokey/helpers.py b/pynitrokey/helpers.py index 21af120a..0e691185 100644 --- a/pynitrokey/helpers.py +++ b/pynitrokey/helpers.py @@ -8,10 +8,13 @@ # copied, modified, or distributed except according to those terms. import logging -import tempfile +import sys + from numbers import Number from threading import Event, Timer +from typing import List +from pynitrokey.confconsts import LOG_FN, LOG_FORMAT, ISSUES_URL, VERBOSE, Verbosity def to_websafe(data): data = data.replace("+", "-") @@ -27,7 +30,8 @@ def from_websafe(data): class Timeout(object): - """Utility class for adding a timeout to an event. + """ + Utility class for adding a timeout to an event. :param time_or_event: A number, in seconds, or a threading.Event object. :ivar event: The Event associated with the Timeout. :ivar timer: The Timer associated with the Timeout, if any. @@ -52,17 +56,124 @@ def __exit__(self, exc_type, exc_val, exc_tb): self.timer.cancel() -UPGRADE_LOG_FN = tempfile.NamedTemporaryFile(prefix="nitropy.log.").name -LOG_FORMAT_STDOUT = '*** %(asctime)-15s %(levelname)6s %(name)10s %(message)s' -LOG_FORMAT = '%(relativeCreated)-8d %(levelname)6s %(name)10s %(message)s' -logging.basicConfig(format=LOG_FORMAT, level=logging.DEBUG, filename=UPGRADE_LOG_FN) +logging.basicConfig(format=LOG_FORMAT, level=logging.DEBUG, filename=LOG_FN) logger = logging.getLogger() -def local_print(message: str = '', exc: Exception=None, **kwargs): - if message and message != '.': - if exc: - logger.exception(message, exc_info=exc) - message += f'\nError: {str(exc)}' + +def local_print(*messages, **kwargs): + """ + Convenience logging function + `messages`: `str` -> log single string + `Exception` -> log exception + `list of ...` -> list of either `str` or `Exception` handle serialized + """ + passed_exc = None + + for item in messages: + # append exception print to last message + if isinstance(item, Exception): + logger.exception("EXCEPTION", exc_info=item) + passed_exc = item + + # just a newline, don't log to file... + elif item is None or item == "": + item = "" + + # logfile debug output else: - logger.debug('print: {}'.format(message.strip())) - print(message, **kwargs) + logger.debug(f"print: {item.strip()}") + + # to stdout + print(item, **kwargs) + + # handle `passed_exc`: re-raise on debug verbosity! + if VERBOSE == Verbosity.debug and passed_exc: + raise passed_exc + + +def local_critical(*messages, support_hint=True, **kwargs): + messages = ["ERROR:"] + list(messages) + local_print(*messages, **kwargs) + if support_hint: + local_print("", + "#" * 40, + "Critical error occurred, exiting now", + "Unexpected? Is this a bug? Do you would like to get support/help?", + f"- You can report issues at: {ISSUES_URL}", + f"- Please attach the log: '{LOG_FN}' with any support/help request!", + "#" * 40, "" + ) + sys.exit(1) + + +# @fixme: consider exchanging/wrapping click.confirm() instead of this... +class AskUser: + """ + Asking user for input: + `question`: printed user question + `options`: `None` -> we want some data input + `iter of str` -> only allow items inside iterable + `title`: additionally print this string before the question + `strict`: if `options` are used, force full match + `repeat`: ask questions up to `repeat` times if `options` and not matched + """ + def __init__(self, question: str, + options: List[str]=None, + title: str=None, + strict: bool=False, + repeat: int=3): + + self.data = None + + self.question = question + self.options = options + self.title = title + self.strict = strict + self.repeat = repeat or 1 + + @classmethod + def yes_no(cls, what: str, title: str=None, strict: bool=False): + opts = ["yes", "no"] + return cls(what, options=opts, title=title, strict=strict).ask() == opts[0] + + @classmethod + def strict_yes_no(cls, what: str, title: str=None): + return cls.yes_no(what, title=title, strict=True) + + @classmethod + def plain(cls, what, title=None): + return cls(what, title=title).ask() + + def ask(self): + if self.title: + local_print(self.title) + + answer = input(self.question).strip() + + # handle plain input request first + if not self.options: + self.data = answer + return self.data + + # now `options` based + retries = self.repeat + while retries: + if self.strict: + if answer in self.options: + self.data = answer + return self.data + else: + short_opts = {c[0].lower(): c for c in self.options} + self.data = short_opts.get(answer[0].lower()) + if self.data: + local_print("choosing: {short_opts[short_answer]}") + return self.data + + answer = input(self.question).strip() + retries -= 1 + + if retries == 0: + local_critical("max tries exceeded - exiting...") + + assert self.data is None, "expecting `self.data` to be None at this point!" + return self.data diff --git a/pynitrokey/start/upgrade_by_passwd.py b/pynitrokey/start/upgrade_by_passwd.py index cf293ec0..5d5cb93b 100755 --- a/pynitrokey/start/upgrade_by_passwd.py +++ b/pynitrokey/start/upgrade_by_passwd.py @@ -69,7 +69,8 @@ from pynitrokey.start.usb_strings import get_devices, print_device from pynitrokey.start.rsa_pub_key import rsa_key_data -from pynitrokey.helpers import local_print, UPGRADE_LOG_FN, LOG_FORMAT_STDOUT +from pynitrokey.confconsts import LOG_FN, LOG_FORMAT_STDOUT +from pynitrokey.helpers import local_print # This should be event driven, not guessing some period, or polling. @@ -408,7 +409,7 @@ def start_update(regnual, gnuk, default_password, password, wait_e, keyno, verbo local_print('Platform: {}'.format(platform.platform())) local_print('System: {}, is_linux: {}'.format(platform.system(), IS_LINUX)) local_print('Python: {}'.format(platform.python_version())) - local_print('Saving run log to: {}'.format(UPGRADE_LOG_FN)) + local_print('Saving run log to: {}'.format(LOG_FN)) arg_descs = ["regnual", "gnuk", "default_password", "password", "wait_e", "keyno", "verbose", "yes", "skip_bootloader", "green_led"] @@ -538,5 +539,5 @@ def start_update(regnual, gnuk, default_password, password, wait_e, keyno, verbo local_print('Device could be removed from the USB slot.') logger.debug('Final device strings: {}'.format(dev_strings_upgraded)) logger.debug('Finishing session {}'.format(datetime.now())) - local_print('Log saved to: {}'.format(UPGRADE_LOG_FN)) + local_print('Log saved to: {}'.format(LOG_FN)) From 0c6aa23ed2925c323a1e07fa7aa82d1d9208e564 Mon Sep 17 00:00:00 2001 From: Markus Meissner Date: Tue, 25 Aug 2020 13:58:51 +0200 Subject: [PATCH 08/19] add 'interactive_test.sh' for semi-manual testing --- interactive_test.sh | 79 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 interactive_test.sh diff --git a/interactive_test.sh b/interactive_test.sh new file mode 100644 index 00000000..82044976 --- /dev/null +++ b/interactive_test.sh @@ -0,0 +1,79 @@ +#!/bin/bash + +npy=venv/bin/nitropy + +function make_title +{ + echo "########################################################################" + echo "## $1" + echo "## $2" + echo -n ">> press enter to continue... "; read foo +} + +function askout +{ + echo -n "<<<<<<<<<<< stop? " + read inp + + if [[ "$inp" = "y" ]]; then + exit 1; + fi +} + +function run +{ + echo + echo ">>>>>>>>>>> " $npy "$@" + $npy "$@" + askout +} + +function testfido2 +{ + make_title 'Testing Nitrokey - FIDO2' \ + 'Please insert a Nitrokey FIDO2 (will be wiped!)' + + run ls + run fido2 list + run fido2 make-credential + + echo "press again..." + out=`${npy} fido2 make-credential | tail -n 1` + echo key $out + + run fido2 challenge-response $out my_challenge + + run fido2 reboot + + echo "sleeping for 10secs..." + sleep 10 + + run fido2 verify + run fido2 update + run fido2 verify + run fido2 reset + +} + +function teststart +{ + make_title 'Testing Nitrokey - Start' \ + 'Please insert a Nitrokey Start (will be wiped!)' + + run ls + run start list + run start set-identity 1 + run start set-identity 2 + run start set-identity 3 + run start set-identity 1 + run start update + +} + +testfido2 + +teststart + + + + From dd84c443d22697552d6671b7fd26691e962491d9 Mon Sep 17 00:00:00 2001 From: Markus Meissner Date: Tue, 25 Aug 2020 14:06:29 +0200 Subject: [PATCH 09/19] first start cleanups --- interactive_test.sh | 4 +-- pynitrokey/cli/start.py | 78 +++++++++++++++++++++-------------------- pynitrokey/helpers.py | 5 +-- 3 files changed, 45 insertions(+), 42 deletions(-) diff --git a/interactive_test.sh b/interactive_test.sh index 82044976..6cf1f2b7 100644 --- a/interactive_test.sh +++ b/interactive_test.sh @@ -62,10 +62,10 @@ function teststart run ls run start list + run start set-identity 0 run start set-identity 1 run start set-identity 2 - run start set-identity 3 - run start set-identity 1 + run start set-identity 0 run start update } diff --git a/pynitrokey/cli/start.py b/pynitrokey/cli/start.py index 9412fc90..a0559bff 100644 --- a/pynitrokey/cli/start.py +++ b/pynitrokey/cli/start.py @@ -7,20 +7,24 @@ # http://opensource.org/licenses/MIT>, at your option. This file may not be # copied, modified, or distributed except according to those terms. -import sys + from time import sleep, time from subprocess import check_output import click +from usb.core import USBError + from pynitrokey.start.gnuk_token import get_gnuk_device from pynitrokey.start.usb_strings import get_devices as get_devices_strings -from pynitrokey.start.upgrade_by_passwd import validate_gnuk, validate_regnual, logger, \ - start_update, DEFAULT_WAIT_FOR_REENUMERATION, DEFAULT_PW3, IS_LINUX, show_kdf_details +from pynitrokey.start.upgrade_by_passwd import validate_gnuk, validate_regnual, logger,\ + start_update, DEFAULT_WAIT_FOR_REENUMERATION, DEFAULT_PW3, IS_LINUX, \ + show_kdf_details + from pynitrokey.start.threaded_log import ThreadLog -from usb.core import USBError +from pynitrokey.helpers import local_print, local_critical # https://pocoo-click.readthedocs.io/en/latest/commands/#nested-handling-and-contexts @@ -33,9 +37,10 @@ def start(): @click.command() def list(): """list connected devices""" - print(":: 'Nitrokey Start' keys:") + local_print(":: 'Nitrokey Start' keys:") for dct in get_devices_strings(): - print(f"{dct['Serial']}: {dct['Vendor']} {dct['Product']} ({dct['Revision']})") + local_print(f"{dct['Serial']}: {dct['Vendor']} " + f"{dct['Product']} ({dct['Revision']})") @click.command() @@ -43,13 +48,13 @@ def list(): def set_identity(identity): """set given identity (one of: 0, 1, 2)""" if not identity.isdigit(): - print("identity number must be a digit") - sys.exit(1) + local_critical("identity number must be a digit") + identity = int(identity) if identity < 0 or identity > 2: - print("identity must be 0, 1 or 2") - sys.exit(1) - print(f"Trying to set identity to {identity}") + local_print("identity must be 0, 1 or 2") + + local_print(f"Setting identity to {identity}") for x in range(3): try: gnuk = get_gnuk_device() @@ -57,40 +62,39 @@ def set_identity(identity): try: gnuk.cmd_set_identity(identity) except USBError: - print("device has reset, and should now have the new identity") - sys.exit(0) + local_print("device has reset, and should now have the new identity") except ValueError as e: - if 'No ICC present' in str(e): - print("Could not connect to device, trying to close scdaemon") + if "No ICC present" in str(e): + local_print("Could not connect to device, trying to close scdaemon") result = check_output(["gpg-connect-agent", "SCD KILLSCD", "SCD BYE", "/bye"]) # gpgconf --kill all might be better? sleep(3) else: - print('*** Found error: {}'.format(str(e))) + local_critical(e) @click.command() @click.option( - '--regnual', default=None, callback=validate_regnual, help='path to regnual binary' + "--regnual", default=None, callback=validate_regnual, help="path to regnual binary" ) @click.option( - '--gnuk', default=None, callback=validate_gnuk, help='path to gnuk binary' + "--gnuk", default=None, callback=validate_gnuk, help="path to gnuk binary" ) -@click.option('-f', 'default_password', is_flag=True, default=False, - help=f'use default Admin PIN: {DEFAULT_PW3}') -@click.option('-p', 'password', help='use provided Admin PIN') -@click.option('-e', 'wait_e', default=DEFAULT_WAIT_FOR_REENUMERATION, type=int, - help='time to wait for device to enumerate, after regnual was executed on device') -@click.option('-k', 'keyno', default=0, type=int, help='selected key index') -@click.option('-v', 'verbose', default=0, type=int, help='verbosity level') -@click.option('-y', 'yes', default=False, is_flag=True, help='agree to everything') -@click.option('-b', 'skip_bootloader', default=False, is_flag=True, - help='Skip bootloader upload (e.g. when done so already)') +@click.option("-f", "default_password", is_flag=True, default=False, + help=f"use default Admin PIN: {DEFAULT_PW3}") +@click.option("-p", "password", help="use provided Admin PIN") +@click.option("-e", "wait_e", default=DEFAULT_WAIT_FOR_REENUMERATION, type=int, + help="time to wait for device to enumerate, after regnual was executed on device") +@click.option("-k", "keyno", default=0, type=int, help="selected key index") +@click.option("-v", "verbose", default=0, type=int, help="verbosity level") +@click.option("-y", "yes", default=False, is_flag=True, help="agree to everything") +@click.option("-b", "skip_bootloader", default=False, is_flag=True, + help="Skip bootloader upload (e.g. when done so already)") @click.option( - '--green-led', is_flag=True, default=False, - help='Use firmware for early "Nitrokey Start" key hardware revisions' + "--green-led", is_flag=True, default=False, + help="Use firmware for early 'Nitrokey Start' key hardware revisions" ) def update(regnual, gnuk, default_password, password, wait_e, keyno, verbose, yes, skip_bootloader, green_led): @@ -100,22 +104,20 @@ def update(regnual, gnuk, default_password, password, wait_e, keyno, verbose, ye skip_bootloader, green_led) if green_led and (regnual is None or gnuk is None): - print("You selected the --green-led option, please provide '--regnual' and " - "'--gnuk' in addition to proceed. ") - print("use on from: https://github.com/Nitrokey/nitrokey-start-firmware)") - sys.exit(1) + local_critical( + "You selected the --green-led option, please provide '--regnual' and " + "'--gnuk' in addition to proceed. ", + "use on from: https://github.com/Nitrokey/nitrokey-start-firmware)") if IS_LINUX: - with ThreadLog(logger.getChild('dmesg'), 'dmesg -w'): + with ThreadLog(logger.getChild("dmesg"), "dmesg -w"): start_update(*args) else: start_update(*args) @click.command() -@click.option( - '--passwd', default='', help='password' -) +@click.option("--passwd", default="", help="password") def kdf_details(passwd): return show_kdf_details(passwd) diff --git a/pynitrokey/helpers.py b/pynitrokey/helpers.py index 0e691185..a8e60cd6 100644 --- a/pynitrokey/helpers.py +++ b/pynitrokey/helpers.py @@ -164,9 +164,10 @@ def ask(self): return self.data else: short_opts = {c[0].lower(): c for c in self.options} - self.data = short_opts.get(answer[0].lower()) + if len(answer) > 0: + self.data = short_opts.get(answer[0].lower()) if self.data: - local_print("choosing: {short_opts[short_answer]}") + local_print(f"choosing: {self.data}") return self.data answer = input(self.question).strip() From 9593979ded40de2068194e63d20ed054d3996b36 Mon Sep 17 00:00:00 2001 From: Markus Meissner Date: Tue, 25 Aug 2020 18:17:48 +0200 Subject: [PATCH 10/19] more start cleanups --- interactive_test.sh | 4 ++++ pynitrokey/cli/start.py | 10 +++++----- pynitrokey/helpers.py | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/interactive_test.sh b/interactive_test.sh index 6cf1f2b7..7b77029b 100644 --- a/interactive_test.sh +++ b/interactive_test.sh @@ -67,6 +67,10 @@ function teststart run start set-identity 2 run start set-identity 0 run start update + run start set-identity 0 + run start set-identity 1 + run start set-identity 2 + run start set-identity 0 } diff --git a/pynitrokey/cli/start.py b/pynitrokey/cli/start.py index a0559bff..4cd9ec78 100644 --- a/pynitrokey/cli/start.py +++ b/pynitrokey/cli/start.py @@ -8,7 +8,7 @@ # copied, modified, or distributed except according to those terms. -from time import sleep, time +from time import sleep from subprocess import check_output import click @@ -62,7 +62,8 @@ def set_identity(identity): try: gnuk.cmd_set_identity(identity) except USBError: - local_print("device has reset, and should now have the new identity") + local_print(f"reset done - now having the new identity: {identity}") + break except ValueError as e: if "No ICC present" in str(e): @@ -74,7 +75,6 @@ def set_identity(identity): else: local_critical(e) - @click.command() @click.option( "--regnual", default=None, callback=validate_regnual, help="path to regnual binary" @@ -83,7 +83,7 @@ def set_identity(identity): "--gnuk", default=None, callback=validate_gnuk, help="path to gnuk binary" ) @click.option("-f", "default_password", is_flag=True, default=False, - help=f"use default Admin PIN: {DEFAULT_PW3}") + help=f"use default Admin PIN: {DEFAULT_PW3}") @click.option("-p", "password", help="use provided Admin PIN") @click.option("-e", "wait_e", default=DEFAULT_WAIT_FOR_REENUMERATION, type=int, help="time to wait for device to enumerate, after regnual was executed on device") @@ -107,7 +107,7 @@ def update(regnual, gnuk, default_password, password, wait_e, keyno, verbose, ye local_critical( "You selected the --green-led option, please provide '--regnual' and " "'--gnuk' in addition to proceed. ", - "use on from: https://github.com/Nitrokey/nitrokey-start-firmware)") + "use one from: https://github.com/Nitrokey/nitrokey-start-firmware)") if IS_LINUX: with ThreadLog(logger.getChild("dmesg"), "dmesg -w"): diff --git a/pynitrokey/helpers.py b/pynitrokey/helpers.py index a8e60cd6..91f549db 100644 --- a/pynitrokey/helpers.py +++ b/pynitrokey/helpers.py @@ -106,7 +106,7 @@ def local_critical(*messages, support_hint=True, **kwargs): sys.exit(1) -# @fixme: consider exchanging/wrapping click.confirm() instead of this... +# @fixme: consider using/wrapping click.confirm() instead of this... class AskUser: """ Asking user for input: From 1c5ca5619bdccbef526b3ce8af246e07989faaa7 Mon Sep 17 00:00:00 2001 From: Markus Meissner Date: Tue, 25 Aug 2020 21:34:42 +0200 Subject: [PATCH 11/19] draft #39 + nk-start cleanups/refactor start --- pynitrokey/cli/start.py | 6 +- pynitrokey/cli/update.py | 4 +- pynitrokey/confconsts.py | 41 +++- pynitrokey/helpers.py | 106 ++++++---- pynitrokey/start/upgrade_by_passwd.py | 281 +++++++++++++++----------- 5 files changed, 261 insertions(+), 177 deletions(-) diff --git a/pynitrokey/cli/start.py b/pynitrokey/cli/start.py index 4cd9ec78..e98739cd 100644 --- a/pynitrokey/cli/start.py +++ b/pynitrokey/cli/start.py @@ -63,7 +63,6 @@ def set_identity(identity): gnuk.cmd_set_identity(identity) except USBError: local_print(f"reset done - now having the new identity: {identity}") - break except ValueError as e: if "No ICC present" in str(e): @@ -75,6 +74,9 @@ def set_identity(identity): else: local_critical(e) + except Exception as e: + local_critical(e) + @click.command() @click.option( "--regnual", default=None, callback=validate_regnual, help="path to regnual binary" @@ -106,7 +108,7 @@ def update(regnual, gnuk, default_password, password, wait_e, keyno, verbose, ye if green_led and (regnual is None or gnuk is None): local_critical( "You selected the --green-led option, please provide '--regnual' and " - "'--gnuk' in addition to proceed. ", + "'--gnuk' in addition to proceed. ", "use one from: https://github.com/Nitrokey/nitrokey-start-firmware)") if IS_LINUX: diff --git a/pynitrokey/cli/update.py b/pynitrokey/cli/update.py index e9a3d4d4..b007058a 100644 --- a/pynitrokey/cli/update.py +++ b/pynitrokey/cli/update.py @@ -133,8 +133,8 @@ def get_dev_details(): # ask for permission if not yes: - if not AskUser.strict_yes_no("Do you want to continue? [yes/no]: ", - title="This will update your Nitrokey FIDO2"): + local_print("This will update your Nitrokey FIDO2") + if not AskUser.strict_yes_no("Do you want to continue?"): local_critical("exiting due to user input...", support_hint=False) # Ensure we are in bootloader mode diff --git a/pynitrokey/confconsts.py b/pynitrokey/confconsts.py index fd37010b..8a86267e 100644 --- a/pynitrokey/confconsts.py +++ b/pynitrokey/confconsts.py @@ -1,22 +1,41 @@ from enum import IntEnum import tempfile - +import os +import logging class Verbosity(IntEnum): - unset = 0 - silent = 1 - machine = 2 - user = 3 - full = 4 - debug = 5 + """regular lvls from `logging` & `machine` for machine-readable output only""" + machine = 100 + silent = logging.CRITICAL + minimal = logging.WARNING + user = logging.INFO + debug = logging.DEBUG + unset = logging.NOTSET + +ENV_DEBUG_VAR = "PYNK_DEBUG" +DEFAULT_VERBOSE = Verbosity.user +VERBOSE = DEFAULT_VERBOSE -#VERBOSE = Verbosity.user -VERBOSE = Verbosity.debug +# set global debug/verbosity -> search for environment variable: ENV_DEBUG_VAR +_env_dbg_lvl = os.environ.get(ENV_DEBUG_VAR) +if _env_dbg_lvl: + try: + # env-var only set w/o contents equals 'Verbosity.debug' + if _env_dbg_lvl.strip() == "": + VERBOSE = Verbosity.debug + # non-empty env-var shall be a number, representing a level + else: + VERBOSE = Verbosity(int(_env_dbg_lvl)) + except ValueError as e: + VERBOSE = DEFAULT_VERBOSE + print(f"exception: {e}") + print(f"environment variable: '{ENV_DEBUG_VAR}' invalid, " + f"setting default: {VERBOSE.name} = {VERBOSE.value}") LOG_FN = tempfile.NamedTemporaryFile(prefix="nitropy.log.").name -LOG_FORMAT_STDOUT = '*** %(asctime)-15s %(levelname)6s %(name)10s %(message)s' +LOG_FORMAT_STDOUT = '%(asctime)-15s %(levelname)6s %(name)10s %(message)s' LOG_FORMAT = '%(relativeCreated)-8d %(levelname)6s %(name)10s %(message)s' -ISSUES_URL = "https://github.com/Nitrokey/pynitrokey/issues/" +GH_ISSUES_URL = "https://github.com/Nitrokey/pynitrokey/issues/" diff --git a/pynitrokey/helpers.py b/pynitrokey/helpers.py index 91f549db..2cc978a9 100644 --- a/pynitrokey/helpers.py +++ b/pynitrokey/helpers.py @@ -13,8 +13,9 @@ from numbers import Number from threading import Event, Timer from typing import List +from getpass import getpass -from pynitrokey.confconsts import LOG_FN, LOG_FORMAT, ISSUES_URL, VERBOSE, Verbosity +from pynitrokey.confconsts import LOG_FN, LOG_FORMAT, GH_ISSUES_URL, VERBOSE, Verbosity def to_websafe(data): data = data.replace("+", "-") @@ -59,7 +60,9 @@ def __exit__(self, exc_type, exc_val, exc_tb): logging.basicConfig(format=LOG_FORMAT, level=logging.DEBUG, filename=LOG_FN) logger = logging.getLogger() - +# @todo: introduce granularization: dbg, info, err (warn?) +# + machine-readable +# + logfile-only def local_print(*messages, **kwargs): """ Convenience logging function @@ -70,10 +73,11 @@ def local_print(*messages, **kwargs): passed_exc = None for item in messages: - # append exception print to last message + # handle exception in order as, if it is a regular message if isinstance(item, Exception): - logger.exception("EXCEPTION", exc_info=item) + logger.exception(item) passed_exc = item + item = repr(item) # just a newline, don't log to file... elif item is None or item == "": @@ -91,64 +95,85 @@ def local_print(*messages, **kwargs): raise passed_exc -def local_critical(*messages, support_hint=True, **kwargs): - messages = ["ERROR:"] + list(messages) +def local_critical(*messages, support_hint=True, ret_code=1, **kwargs): + messages = ["Critical error:"] + list(messages) local_print(*messages, **kwargs) if support_hint: - local_print("", - "#" * 40, - "Critical error occurred, exiting now", - "Unexpected? Is this a bug? Do you would like to get support/help?", - f"- You can report issues at: {ISSUES_URL}", - f"- Please attach the log: '{LOG_FN}' with any support/help request!", - "#" * 40, "" - ) - sys.exit(1) + local_print( + "", "-" * 80, + "Critical error occurred, exiting now", + "Unexpected? Is this a bug? Do you would like to get support/help?", + f"- You can report issues at: {GH_ISSUES_URL}", + f"- Please attach the log: '{LOG_FN}' with any support/help request!", + "-" * 80, "") + sys.exit(ret_code) # @fixme: consider using/wrapping click.confirm() instead of this... class AskUser: """ Asking user for input: - `question`: printed user question - `options`: `None` -> we want some data input - `iter of str` -> only allow items inside iterable - `title`: additionally print this string before the question - `strict`: if `options` are used, force full match - `repeat`: ask questions up to `repeat` times if `options` and not matched + `question`: printed user question + `options`: `None` -> we want some data input + `iter of str` -> only allow items inside iterable + `strict`: if `options` are used, force full match + `repeat`: ask `question` up to `repeat` times, if `options` are provided + `adapt_question`: adapt user-provided `question` (add options, whitespace...), + set to `False`, if strictly `question` shall be used + `hide_input`: use 'getpass' instead of regular `input` """ def __init__(self, question: str, options: List[str]=None, - title: str=None, strict: bool=False, - repeat: int=3): + repeat: int=3, + adapt_question=True, + hide_input=False): self.data = None self.question = question + self.adapt_question = adapt_question + self.final_question = question + if self.adapt_question: + _q = self.final_question + # strip ending colon(s) ':' or whitespace(s) ' ' + _q = _q.strip(" ").strip(":").strip(" ").strip(":") + if options: + _q += f" [{'/'.join(options)}]" if strict else \ + f" [{'/'.join(f'({o[0]}){o[1:]}' for o in options)}]" + _q += ": " + self.final_question = _q + self.options = options - self.title = title self.strict = strict self.repeat = repeat or 1 + self.hide_input = hide_input @classmethod - def yes_no(cls, what: str, title: str=None, strict: bool=False): + def yes_no(cls, what: str, strict: bool=False): opts = ["yes", "no"] - return cls(what, options=opts, title=title, strict=strict).ask() == opts[0] + return cls(what, options=opts, strict=strict).ask() == opts[0] @classmethod - def strict_yes_no(cls, what: str, title: str=None): - return cls.yes_no(what, title=title, strict=True) + def strict_yes_no(cls, what: str): + return cls.yes_no(what, strict=True) @classmethod - def plain(cls, what, title=None): - return cls(what, title=title).ask() + def plain(cls, what): + return cls(what).ask() - def ask(self): - if self.title: - local_print(self.title) + @classmethod + def hidden(cls, what): + return cls(what, hide_input=True).ask() - answer = input(self.question).strip() + def get_input(self, pre_str=None, hide_input=None): + pre_input_string = pre_str or self.final_question + hide_input = hide_input if hide_input is not None else self.hide_input + return input(pre_input_string).strip() if not hide_input \ + else getpass(pre_input_string) + + def ask(self): + answer = self.get_input() # handle plain input request first if not self.options: @@ -158,19 +183,20 @@ def ask(self): # now `options` based retries = self.repeat while retries: - if self.strict: - if answer in self.options: - self.data = answer - return self.data - else: + if answer in self.options: + self.data = answer + return self.data + + if not self.strict: short_opts = {c[0].lower(): c for c in self.options} if len(answer) > 0: self.data = short_opts.get(answer[0].lower()) + if self.data: local_print(f"choosing: {self.data}") return self.data - answer = input(self.question).strip() + answer = self.get_input() retries -= 1 if retries == 0: diff --git a/pynitrokey/start/upgrade_by_passwd.py b/pynitrokey/start/upgrade_by_passwd.py index 5d5cb93b..7ae7c3e7 100755 --- a/pynitrokey/start/upgrade_by_passwd.py +++ b/pynitrokey/start/upgrade_by_passwd.py @@ -48,12 +48,11 @@ import logging import os import time -from sys import platform, exit +from sys import platform from collections import defaultdict from datetime import datetime from enum import Enum from functools import lru_cache -from getpass import getpass from struct import pack from subprocess import check_output import platform @@ -65,15 +64,16 @@ from pynitrokey.start.gnuk_token import get_gnuk_device, gnuk_devices_by_vidpid, \ regnual, SHA256_OID_PREFIX, crc32, parse_kdf_data from pynitrokey.start.kdf_calc import kdf_calc -from pynitrokey.start.threaded_log import ThreadLog +#from pynitrokey.start.threaded_log import ThreadLog from pynitrokey.start.usb_strings import get_devices, print_device from pynitrokey.start.rsa_pub_key import rsa_key_data from pynitrokey.confconsts import LOG_FN, LOG_FORMAT_STDOUT -from pynitrokey.helpers import local_print +from pynitrokey.helpers import local_print, local_critical, AskUser # This should be event driven, not guessing some period, or polling. +# @todo: move to confconsts.py TIME_DETECT_DEVICE_AFTER_UPDATE_LONG_S = 5 TIME_DETECT_DEVICE_AFTER_UPDATE_S = 30 ERR_EMPTY_COUNTER = '6983' @@ -101,28 +101,37 @@ def progress_func(x): def main(wait_e, keyno, passwd, data_regnual, data_upgrade, skip_bootloader, verbosity=0): reg = None - for i in range(3): + + # @todo: this is constantly used: how about a consistent/generic solution? + conn_retries = 3 + + for i in range(conn_retries): if reg is not None: break - local_print('.', end='', flush=True) + + local_print(".", end="", flush=True) time.sleep(1) + for dev in gnuk_devices_by_vidpid(): try: reg = regnual(dev) if dev.filename: - local_print("Device: %s" % dev.filename) + local_print(f"Device: {dev.filename}") reg.set_logger(logger) break except Exception as e: - if str(e) != 'Wrong interface class': + if str(e) != "Wrong interface class": local_print(e) if reg is None and not skip_bootloader: - local_print('\n*** Starting bootloader upload procedure') - l = len(data_regnual) - if (l & 0x03) != 0: - data_regnual = data_regnual.ljust(l + 4 - (l & 0x03), chr(0)) + local_print("", "Starting bootloader upload procedure") + + _l = len(data_regnual) + if (_l & 0x03) != 0: + data_regnual = data_regnual.ljust(_l + 4 - (_l & 0x03), chr(0)) crc32code = crc32(data_regnual) + + # @todo: use global verbosity if verbosity: local_print("CRC32: %04x\n" % crc32code) data_regnual += pack(' 1 else ''), - end='') + local_print(f" Wait {wait_e} second{'s' if wait_e > 1 else ''}...", end="") + for i in range(wait_e): if reg is not None: break - local_print('.', end='', flush=True) + + local_print(".", end="", flush=True) time.sleep(1) + for dev in gnuk_devices_by_vidpid(): try: reg = regnual(dev) if dev.filename: - local_print("Device: %s" % dev.filename) + local_print("Device: {dev.filename}") break except Exception as e: - local_print(e) - pass - local_print('') - local_print('') + local_print(f"failed - trying again - retry: {i+1}") + # @todo: log exception to file: e + + local_print("", "") if reg is None: - local_print('Device not found. Exiting.') - raise RuntimeWarning('Device not found. Exiting.') + # @todo: replace with proper Exception + raise RuntimeWarning("device not found - exiting") # Then, send upgrade program... mem_info = reg.mem_info() + + # @todo: use global verbosity if verbosity: local_print("%08x:%08x" % mem_info) + local_print("Downloading the program") reg.download(mem_info[0], data_upgrade, progress_func=progress_func, verbose=verbosity == 2) + local_print("Protecting device") reg.protect() + local_print("Finish flashing") reg.finish() + local_print("Resetting device") reg.reset_device() - local_print("Update procedure finished. Device could be removed from USB slot.") - local_print('') - return 0 + local_print("Update procedure finished. Device could be removed from USB slot.", "") + + return 0 @lru_cache() def get_latest_release_data(): try: + # @todo: move to confconsts.py r = requests.get('https://api.github.com/repos/Nitrokey/nitrokey-start-firmware/releases') json = r.json() if r.status_code == 403: - logger.debug('JSON release data {}'.format(json)) - local_print('No Github API access') - exit(3) + local_critical(f"JSON raw data: {json}", + f"No Github API access, status code: {r.status_code}") latest_tag = json[0] + except Exception as e: - logger.exception('Failed getting release data') - latest_tag = defaultdict(lambda: 'unknown') + local_critical("Failed getting release data", e) + latest_tag = defaultdict(lambda: "unknown") + return latest_tag @@ -242,16 +275,18 @@ def validate_binary_file(path: str): raise BadParameter('Path does not exist: "{}"'.format(path)) if not path.endswith('.bin'): raise BadParameter( - 'Supplied file "{}" does not have ".bin" extension. Make sure you are sending correct file to the device.'.format( - os.path.basename(path))) + 'Supplied file "{}" does not have ".bin" extension. ' + 'Make sure you are sending correct file to the device.' + .format(os.path.basename(path))) return path def validate_name(path: str, name: str): if name not in path: raise BadParameter( - 'Supplied file "{}" does not have "{}" in name. Make sure you have not swapped the arguments.'.format( - os.path.basename(path), name)) + 'Supplied file "{}" does not have "{}" in name. ' + 'Make sure you have not swapped the arguments.' + .format(os.path.basename(path), name)) return path @@ -273,26 +308,26 @@ def validate_regnual(ctx, param, path: str): return path def kill_smartcard_services(): - local_print('*** Could not connect to the device. Attempting to close scdaemon.') + local_print('Could not connect to the device. Attempting to close scdaemon.') + # check_output(["gpg-connect-agent", # "SCD KILLSCD", "SCD BYE", "/bye"]) + commands = [('gpgconf --kill all'.split(), True), + ('sudo systemctl stop pcscd pcscd.socket'.split(), IS_LINUX)] - commands = [ - (['gpgconf', '--kill', 'all'], True), - ('sudo systemctl stop pcscd pcscd.socket'.split(), IS_LINUX) - ] for command, flag in commands: if not flag: continue - local_print('*** Running: "{}"'.format(' '.join(command))) - logger.debug('Running {}'.format(command)) + local_print(f"Running: {' '.join(command)}") try: check_output(command) except Exception as e: - logger.exception('Error while running command') + local_print("Error while running command", e) + time.sleep(3) +# @fixme: maybe also move to confconsts.py? class FirmwareType(Enum): UNKNOWN = 0 REGNUAL = 1 @@ -300,6 +335,7 @@ class FirmwareType(Enum): CHECKSUM = 3 +# @fixme: move constants to confconsts.py REMOTE_PATH = 'https://raw.githubusercontent.com/Nitrokey/nitrokey-start-firmware/gnuk1.2-regnual-fix/prebuilt' FIRMWARE_URL = { FirmwareType.REGNUAL: ('%s/{}/regnual.bin' % REMOTE_PATH), @@ -337,10 +373,10 @@ def get_firmware_file(file_name: str, type: FirmwareType): url = FIRMWARE_URL.get(type, None).format(tag) firmware_data = download_file_or_exit(url) hash_data = hash_data_512(firmware_data) - hash_valid = 'valid' if validate_hash(url, hash_data) else 'invalid' + hash_valid = "valid" if validate_hash(url, hash_data) else "invalid" - local_print("- {}: {}, hash: ...{} {} (from ...{})".format( - type, len(firmware_data), hash_data[-8:], hash_valid, url[-24:])) + local_print(f"- {type}: {len(firmware_data)}, " + f"hash: ...{hash_data[-8:]} {hash_valid} (from ...{url[-24:]})") return firmware_data @@ -348,8 +384,7 @@ def get_firmware_file(file_name: str, type: FirmwareType): def download_file_or_exit(url): resp = requests.get(url) if not resp.ok: - local_print(f"Cannot download firmware: {url}: {resp.status_code}") - exit(1) + local_critical(f"Cannot download firmware: {url}: {resp.status_code}") firmware_data = resp.content return firmware_data @@ -359,8 +394,8 @@ def show_kdf_details(passwd): try: gnuk = get_gnuk_device(logger=logger, verbose=True) except ValueError as e: - if 'No ICC present' in str(e): - print('Cannot connect to device. Closing other open connections.') + if "No ICC present" in str(e): + print("Cannot connect to device. Closing other open connections.") kill_smartcard_services() return else: @@ -404,7 +439,9 @@ def show_kdf_details(passwd): def start_update(regnual, gnuk, default_password, password, wait_e, keyno, verbose, yes, skip_bootloader, green_led): + # @todo: move to some more generic position... local_print('Nitrokey Start firmware update tool') + # @fixme: especially this, which is to be handle application wide logger.debug('Start session {}'.format(datetime.now())) local_print('Platform: {}'.format(platform.platform())) local_print('System: {}, is_linux: {}'.format(platform.system(), IS_LINUX)) @@ -432,96 +469,92 @@ def start_update(regnual, gnuk, default_password, password, wait_e, keyno, verbo passwd = DEFAULT_PW3 if not passwd: try: - passwd = getpass("Admin password: ") - except: - local_print('Quitting') - exit(2) + passwd = AskUser.hidden("Admin password:") + except Exception as e: + local_critical("aborting", e) - local_print('Firmware data to be used:') + local_print("Firmware data to be used:") data = get_firmware_file(regnual, FirmwareType.REGNUAL) data_upgrade = get_firmware_file(gnuk, FirmwareType.GNUK) # Detect devices dev_strings = get_devices() if len(dev_strings) > 1: - local_print('Only one device should be connected. Please remove other devices and retry.') - exit(1) + local_critical("Only one device should be connected", + "Please remove other devices and retry") if dev_strings: - local_print('Currently connected device strings:') + local_print("Currently connected device strings:") print_device(dev_strings[0]) else: - local_print('Cannot identify device') + local_print("Cannot identify device") - logger.debug('Initial device strings: {}'.format(dev_strings)) + # @todo: debugging information, log-file only + local_print(f"initial device strings: {dev_strings}") latest_tag = get_latest_release_data() - local_print('Please note:') - local_print('- Latest firmware available is: ' - '{} (published: {}),\n provided firmware: {}' - .format(latest_tag['tag_name'], latest_tag['published_at'], gnuk)) - local_print('- All data will be removed from the device') - local_print('- Do not interrupt the update process, or the device will not run properly') - local_print('- Whole process should not take more than 1 minute') + local_print(f"Please note:", + f"- Latest firmware available is: ", + f" {latest_tag['tag_name']} (published: {latest_tag['published_at']})", + f"- provided firmware: {gnuk}", + f"- all data will be removed from the device!", + f"- do not interrupt update process - the device may not run properly!", + f"- the process should not take more than 1 minute") if yes: - local_print('Accepted automatically') + local_print("Accepted automatically") else: - answer = input('Do you want to continue? [yes/no]: ') - local_print('Entered: "{}"'.format(answer)) - logger.debug('Continue? "{}"'.format(answer)) - if answer != 'yes': - local_print('Device is not modified. Exiting.') - exit(1) + if not AskUser.strict_yes_no("Do you want to continue?"): + local_critical("Exiting due to user request", support_hint=False) update_done = False - for attempt_counter in range(2): + retries = 3 + for attempt_counter in range(retries): try: # First 4096-byte in data_upgrade is SYS, so, skip it. main(wait_e, keyno, passwd, data, data_upgrade[4096:], skip_bootloader, verbosity=verbose) update_done = True break + + # @todo: add proper exceptions (for each case) here except ValueError as e: - logger.exception('Error while running update') - str_factory_reset = 'Please "factory-reset" your device to ' \ - 'continue (this will delete all user data from the device) ' \ - 'and try again with PIN="12345678".' - if 'No ICC present' in str(e): + local_print("error while running update") + str_factory_reset = "Please 'factory-reset' your device to " \ + "continue (this will delete all user data from the device) " \ + "and try again with PIN='12345678'" + + if "No ICC present" in str(e): kill_smartcard_services() - # local_print('*** Please run update tool again.') + local_print("retrying...") + else: - local_print('*** Could not proceed with the update.') - local_print('*** Found error: {}'.format(str(e))) - # FIXME run factory reset here since data are lost anyway + # @fixme run factory reset here since data are lost anyway (rly?) if str(e) == ERR_EMPTY_COUNTER: - local_print('*** Device returns "Attempt counter empty" error for Admin PIN.' - + ' ' + str_factory_reset - ) + local_critical("- device returns: 'Attempt counter empty' " + "- error for Admin PIN", str_factory_reset, e) + if str(e) == ERR_INVALID_PIN: - local_print('*** Device returns "Invalid PIN" error.' - + ' ' + str_factory_reset) - break + local_critical("- device returns: 'Invalid PIN' error", + "- please retry with correct PIN", e) except Exception as e: - # unknown error, bail - local_print('*** Found unexpected error: {}'.format(str(e))) - break + local_critical("unexpected error", e) if not update_done: - local_print() - local_print('*** Could not proceed with the update. Please execute one or all of the following and try again:\n' - '- reinsert device to the USB slot;\n' - '- run factory-reset on the device;\n' - '- close other applications, that possibly could use it (e.g. scdaemon, pcscd).\n') - exit(1) + local_critical("", + "Could not proceed with the update", + "Please execute one or all of the following and try again:", + "- re-insert device to the USB slot", + "- run factory-reset on the device", + "- close other applications, which could use it (e.g., scdaemon, pcscd)") dev_strings_upgraded = None takes_long_time = False - local_print('Currently connected device strings (after upgrade):') + local_print("Currently connected device strings (after upgrade):") for i in range(TIME_DETECT_DEVICE_AFTER_UPDATE_S): if i > TIME_DETECT_DEVICE_AFTER_UPDATE_LONG_S: if not takes_long_time: - local_print('\n*** Please reinsert device to the USB slot') + local_print("", "Please reinsert device to the USB slot") takes_long_time = True time.sleep(1) dev_strings_upgraded = get_devices() @@ -529,15 +562,19 @@ def start_update(regnual, gnuk, default_password, password, wait_e, keyno, verbo local_print() print_device(dev_strings_upgraded[0]) break - local_print('.', end='', flush=True) + local_print(".", end="", flush=True) if not dev_strings_upgraded: - local_print() - local_print('Could not connect to the device. ' - 'It should be working fine though after power cycle - please reinsert device to ' - 'USB slot and test it.') - local_print('Device could be removed from the USB slot.') - logger.debug('Final device strings: {}'.format(dev_strings_upgraded)) - logger.debug('Finishing session {}'.format(datetime.now())) - local_print('Log saved to: {}'.format(LOG_FN)) + local_print("", + "could not connect to the device - might be due to a failed update", + "please re-insert the device, check version using:", + "$ nitropy start list") + + local_print(f"device can now be safely removed from the USB slot", + f"final device strings: {dev_strings_upgraded}") + + # @todo: add this to all logs and skip it here + local_print(f"finishing session {datetime.now()}") + # @todo: always output this in certain situations... (which ones? errors? warnings?) + local_print(f"Log saved to: {LOG_FN}") From bc45e74d377f28853ff05cfe926e5ac9b7b36267 Mon Sep 17 00:00:00 2001 From: Markus Meissner Date: Tue, 25 Aug 2020 23:43:43 +0200 Subject: [PATCH 12/19] adding cbor to pyproject.toml; fido2 does not contain it anymore --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 55f580a3..406574bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,9 @@ requires = [ "pyusb", "requests", "pygments", - "cffi" + "cffi", + "cbor", + "nacl" ] classifiers=[ "License :: OSI Approved :: MIT License", From 90f348e93332303ea02f2a4eaf27f3036873b88c Mon Sep 17 00:00:00 2001 From: Markus Meissner Date: Tue, 25 Aug 2020 23:44:44 +0200 Subject: [PATCH 13/19] more cleanups; add fido2 set-pin + change-pin --- pynitrokey/cli/fido2.py | 176 ++++++++++++++++++++++++++-------------- pynitrokey/cli/start.py | 7 +- pynitrokey/helpers.py | 2 +- 3 files changed, 119 insertions(+), 66 deletions(-) diff --git a/pynitrokey/cli/fido2.py b/pynitrokey/cli/fido2.py index 3f9722a0..13323b0f 100644 --- a/pynitrokey/cli/fido2.py +++ b/pynitrokey/cli/fido2.py @@ -35,6 +35,10 @@ from pynitrokey.helpers import AskUser, local_print, local_critical +# @todo: in version 0.4 UDP & anything earlier inside fido2.__init__ is broken/removed +# - check if/what is needed here +# - revive UDP support + # https://pocoo-click.readthedocs.io/en/latest/commands/#nested-handling-and-contexts @click.group() def fido2(): @@ -48,7 +52,7 @@ def util(): pass -# @todo: is this working as intended +# @todo: is this working as intended? @click.command() @click.option("--input-seed-file") @click.argument("output_pem_file") @@ -75,7 +79,7 @@ def genkey(input_seed_file, output_pem_file): None) -# @todo: is this working as intended +# @todo: is this working as intended ? @click.command() @click.argument("verifying-key") @click.argument("app-hex") @@ -159,6 +163,7 @@ def hexbytes(count, serial): local_print(nkfido2.find(serial).get_rng(count).hex()) +# @todo: not really useful like this? endless output only on request (--count ?) @click.command() @click.option("-s", "--serial", help="Serial number of Nitrokey to use") def raw(serial): @@ -169,6 +174,7 @@ def raw(serial): sys.stdout.buffer.write(r) +# @todo: also review, endless output only on request (--count ?) @click.command() @click.option("-s", "--serial", help="Serial number of Nitrokey to use") @click.option("-b", "--blink", is_flag=True, help="Blink in the meantime") @@ -206,7 +212,7 @@ def feedkernel(count, serial): RNDADDENTROPY = 0x40085203 entropy_info_file = "/proc/sys/kernel/random/entropy_avail" - print(f"Entropy before: 0x{open(entropy_info_file).read().strip()}") + print(f"entropy before: 0x{open(entropy_info_file).read().strip()}") r = p.get_rng(count) @@ -232,9 +238,15 @@ def feedkernel(count, serial): entropy_bits_per_byte = 2 t = struct.pack(f"ii{count}s", count * entropy_bits_per_byte, count, r) - with open("/dev/random", mode="wb") as fh: - fcntl.ioctl(fh, RNDADDENTROPY, t) - local_print(f"Entropy after: 0x{open(entropy_info_file).read().strip()}") + try: + with open("/dev/random", mode="wb") as fh: + fcntl.ioctl(fh, RNDADDENTROPY, t) + + except PermissionError as e: + local_critical("insufficient permissions to use `fnctl.ioctl` on '/dev/random'", + "please run 'nitropy' with proper permissions", e) + + local_print(f"entropy after: 0x{open(entropy_info_file).read().strip()}") @click.command() @@ -305,6 +317,11 @@ def challenge_response(serial, host, user, prompt, credential_id, challenge, udp ) + +###### +###### @fixme: - excluded 'probe' for now, as command: +###### SoloBootloader.HIDCommandProbe => 0x70 returns "INVALID_COMMAND" +###### - decide its future asap... @click.command() @click.option("-s", "--serial", help="Serial number of Nitrokey use") @click.option( @@ -313,47 +330,59 @@ def challenge_response(serial, host, user, prompt, credential_id, challenge, udp @click.argument("hash-type") @click.argument("filename") def probe(serial, udp, hash_type, filename): - """Calculate HASH.""" + """Calculate HASH""" - # hash_type = hash_type.upper() - assert hash_type in ("SHA256", "SHA512", "RSA2048", "Ed25519") + import cbor + from pynitrokey.fido2.commands import SoloBootloader + + # @todo: move to constsconf.py + #all_hash_types = ("SHA256", "SHA512", "RSA2048", "Ed25519") + all_hash_types = ("SHA256", "SHA512", "RSA2048") + # @fixme: Ed25519 needs `nacl` dependency, which is not available currently?! + + if hash_type.upper() not in all_hash_types: + local_critical(f"invalid [HASH_TYPE] provided: {hash_type}", + f"use one of: {', '.join(all_hash_types)}") data = open(filename, "rb").read() + # < CTAPHID_BUFFER_SIZE - # https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-client-to-authenticator-protocol-v2.0-id-20180227.html#usb-message-and-packet-structure + # https://fidoalliance.org/specs/fido-v2.0-id-20180227/ + # fido-client-to-authenticator-protocol-v2.0-id-20180227.html + # #usb-message-and-packet-structure # also account for padding (see data below....) # so 6kb is conservative + + # @todo: proper error/exception + cut in chunks? assert len(data) <= 6 * 1024 p = nkfido2.find(serial, udp=udp) - import fido2 - - serialized_command = fido2.cbor.dumps({"subcommand": hash_type, "data": data}) - from pynitrokey.fido2.commands import SoloBootloader - + serialized_command = cbor.dumps({"subcommand": hash_type, "data": data}) result = p.send_data_hid(SoloBootloader.HIDCommandProbe, serialized_command) result_hex = result.hex() local_print(result_hex) + + # @todo: unreachable if hash_type == "Ed25519": - local_print(f"content: {result[64:]}") - # print(f"content from hex: {bytes.fromhex(result_hex[128:]).decode()}") - local_print(f"content from hex: {bytes.fromhex(result_hex[128:])}") - local_print(f"signature: {result[:128]}") + # @fixme: mmmh, where to get `nacl` (python-libnacl? python-pynacl?) import nacl.signing + # print(f"content from hex: {bytes.fromhex(result_hex[128:]).decode()}") + local_print(f"content: {result[64:]}", + f"content from hex: {bytes.fromhex(result_hex[128:])}", + f"signature: {result[:128]}") + # verify_key = nacl.signing.VerifyKey(bytes.fromhex("c69995185efa20bf7a88139f5920335aa3d3e7f20464345a2c095c766dfa157a")) - verify_key = nacl.signing.VerifyKey( - bytes.fromhex( - "c69995185efa20bf7a88139f5920335aa3d3e7f20464345a2c095c766dfa157a" - ) - ) + # @fixme: where does this 'magic-number' come from!? + verify_key = nacl.signing.VerifyKey(bytes.fromhex( + "c69995185efa20bf7a88139f5920335aa3d3e7f20464345a2c095c766dfa157a")) try: verify_key.verify(result) - verified = True + local_print("verified!") except nacl.exceptions.BadSignatureError: - verified = False - local_print(f"verified? {verified}") + local_print("failed verification!") + # print(fido2.cbor.loads(result)) @click.command() @@ -366,39 +395,48 @@ def reset(serial): local_print("....aaaand they're gone") +# @fixme: lacking functionality? remove? implement? @click.command() @click.option("-s", "--serial", help="Serial number of Nitrokey to use") # @click.option("--new-pin", help="change current pin") def change_pin(serial): """Change pin of current key""" - old_pin = getpass.getpass("Please enter old pin: ") - new_pin = getpass.getpass("Please enter new pin: ") - confirm_pin = getpass.getpass("Please confirm new pin: ") + + old_pin = AskUser.hidden("Please enter old pin: ") + new_pin = AskUser.hidden("Please enter new pin: ") + confirm_pin = AskUser.hidden("Please confirm new pin: ") + if new_pin != confirm_pin: - local_print("New pin are mismatched. Please try again!") - return + local_critical("new pin does not match confirm-pin", + "please try again!", support_hint=False) try: - nkfido2.find(serial).change_pin(old_pin, new_pin) - local_print("Done. Please use new pin to verify key") - except Exception as e: - local_critical(e) + # @fixme: move this (function) into own fido2-client-class + nkfido2.find(serial).client.pin_protocol.change_pin(old_pin, new_pin) + local_print("done - please use new pin to verify key") + except Exception as e: + local_critical("failed changing to new pin!", + "did you set one already? or is it wrong?", e) @click.command() @click.option("-s", "--serial", help="Serial number of Nitrokey to use") # @click.option("--new-pin", help="change current pin") def set_pin(serial): """Set pin of current key""" - new_pin = getpass.getpass("Please enter new pin: ") - confirm_pin = getpass.getpass("Please confirm new pin: ") + new_pin = AskUser.hidden("Please enter new pin: ") + confirm_pin = AskUser.hidden("Please confirm new pin: ") if new_pin != confirm_pin: - local_print("New pin are mismatched. Please try again!") - return + local_critical("new pin does not match confirm-pin", + "please try again!", support_hint=False) try: - nkfido2.find(serial).set_pin(new_pin) - local_print("Done. Please use new pin to verify key") + # @fixme: move this (function) into own fido2-client-class + nkfido2.find(serial).client.pin_protocol.set_pin(new_pin) + local_print("done - please use new pin to verify key") + except Exception as e: - local_critical(e) + local_critical("failed setting new pin, maybe it's already set?", + "to change an already set pin, please use:", + "$ nitropy fido2 change-pin", e) @click.command() @@ -411,44 +449,48 @@ def verify(pin, serial, udp): """Verify key is valid Nitrokey 'Start' or 'FIDO2' key.""" # Any longer and this needs to go in a submodule - local_print("Please press the button on your Nitrokey key") + local_print("please press the button on your Nitrokey key") + + cert = None try: cert = nkfido2.find(serial, udp=udp).make_credential(pin=pin) - except ValueError as e: - # python-fido2 library pre-emptively returns `ValueError('PIN required!')` - # instead of trying, and returning `CTAP error: 0x36 - PIN_REQUIRED` - if "PIN required" in str(e): - local_print("Your key has a PIN set. Please pass it using `--pin `") - sys.exit(1) - raise except Fido2ClientError as e: cause = str(e.cause) # error 0x31 if "PIN_INVALID" in cause: - local_critical("Your key has a different PIN. Please try to remember it :)", + local_critical("your key has a different PIN. Please try to remember it :)", e) # error 0x34 (power cycle helps) if "PIN_AUTH_BLOCKED" in cause: local_critical( - "Your key's PIN auth is blocked due to too many incorrect attempts.", - "Please plug it out and in again, then again!", - "Please be careful, after too many incorrect attempts, ", + "your key's PIN auth is blocked due to too many incorrect attempts.", + "please plug it out and in again, then again!", + "please be careful, after too many incorrect attempts, ", " the key will fully block.", e) # error 0x32 (only reset helps) if "PIN_BLOCKED" in cause: local_critical( - "Your key's PIN is blocked. ", - "To use it again, you need to fully reset it.", - "You can do this using: `nitropy fido2 reset`", e) + "your key's PIN is blocked. ", + "to use it again, you need to fully reset it.", + "you can do this using: `nitropy fido2 reset`", e) # error 0x01 if "INVALID_COMMAND" in cause: local_critical( - "Error getting credential, is your key in bootloader mode?", - "Try: `nitropy fido2 util program aux leave-bootloader`", e) + "error getting credential, is your key in bootloader mode?", + "try: `nitropy fido2 util program aux leave-bootloader`", e) + + # pin required error + if "PIN required" in str(e): + local_critical("your key has a PIN set - pass it using `--pin `", e) + + local_critical("unexpected Fido2Client (CTAP) error", e) + + except Exception as e: + local_critical("unexpected error", e) hashdb = { b'd7a23679007fe799aeda4388890f33334aba4097bb33fee609c8998a1ba91bd3': "Nitrokey FIDO2 1.x", @@ -459,9 +501,9 @@ def verify(pin, serial, udp): dev_fingerprint = cert.fingerprint(hashes.SHA256()) a_hex = binascii.b2a_hex(dev_fingerprint) if a_hex in hashdb: - local_print('Found device: {}'.format(hashdb[a_hex])) + local_print(f"found device: {hashdb[a_hex]}") else: - local_print("Unknown fingerprint! ", a_hex) + local_print(f"unknown fingerprint! {a_hex}") @click.command() @@ -522,21 +564,29 @@ def reboot(serial, udp): fido2.add_command(rng) fido2.add_command(reboot) fido2.add_command(list) + rng.add_command(hexbytes) rng.add_command(raw) rng.add_command(feedkernel) + fido2.add_command(make_credential) fido2.add_command(challenge_response) fido2.add_command(reset) fido2.add_command(status) fido2.add_command(update) -fido2.add_command(probe) + +# see above.... +#fido2.add_command(probe) # key.add_command(sha256sum) # key.add_command(sha512sum) + fido2.add_command(version) fido2.add_command(verify) fido2.add_command(wink) +fido2.add_command(set_pin) +fido2.add_command(change_pin) + fido2.add_command(util) util.add_command(monitor) util.add_command(program) diff --git a/pynitrokey/cli/start.py b/pynitrokey/cli/start.py index e98739cd..1eb79c7a 100644 --- a/pynitrokey/cli/start.py +++ b/pynitrokey/cli/start.py @@ -26,6 +26,8 @@ from pynitrokey.helpers import local_print, local_critical +# @fixme: add 'version' for consistency with fido2 + # https://pocoo-click.readthedocs.io/en/latest/commands/#nested-handling-and-contexts @click.group() @@ -62,7 +64,8 @@ def set_identity(identity): try: gnuk.cmd_set_identity(identity) except USBError: - local_print(f"reset done - now having the new identity: {identity}") + local_print(f"reset done - now active identity: {identity}") + break except ValueError as e: if "No ICC present" in str(e): @@ -73,10 +76,10 @@ def set_identity(identity): sleep(3) else: local_critical(e) - except Exception as e: local_critical(e) + @click.command() @click.option( "--regnual", default=None, callback=validate_regnual, help="path to regnual binary" diff --git a/pynitrokey/helpers.py b/pynitrokey/helpers.py index 2cc978a9..6c99cad9 100644 --- a/pynitrokey/helpers.py +++ b/pynitrokey/helpers.py @@ -65,7 +65,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): # + logfile-only def local_print(*messages, **kwargs): """ - Convenience logging function + application-wide logging function `messages`: `str` -> log single string `Exception` -> log exception `list of ...` -> list of either `str` or `Exception` handle serialized From 3259b20a3c59102b2c4c1097e6e2141585a83085 Mon Sep 17 00:00:00 2001 From: Markus Meissner Date: Tue, 25 Aug 2020 23:44:58 +0200 Subject: [PATCH 14/19] update interactive_test.sh script --- interactive_test.sh | 96 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 81 insertions(+), 15 deletions(-) diff --git a/interactive_test.sh b/interactive_test.sh index 7b77029b..9cf583c4 100644 --- a/interactive_test.sh +++ b/interactive_test.sh @@ -4,10 +4,19 @@ npy=venv/bin/nitropy function make_title { - echo "########################################################################" - echo "## $1" - echo "## $2" - echo -n ">> press enter to continue... "; read foo + if [[ "$2" = "" ]]; then + echo "------------------------------------------------------------------------" + echo "-> $1" + else + echo "########################################################################" + echo "########################################################################" + echo "## $1" + fi + + if [[ "$2" != "" ]]; then + echo "## $2" + echo -n ">> press enter to continue... "; read foo + fi } function askout @@ -30,53 +39,110 @@ function run function testfido2 { - make_title 'Testing Nitrokey - FIDO2' \ - 'Please insert a Nitrokey FIDO2 (will be wiped!)' + make_title "Testing Nitrokey - FIDO2" \ + "Please insert a Nitrokey FIDO2 (will be wiped!)" + make_title "Simple listing of device(s)" run ls run fido2 list - run fido2 make-credential + + make_title "create a credential + challenge-response using it" + + run fido2 make-credential echo "press again..." out=`${npy} fido2 make-credential | tail -n 1` echo key $out - run fido2 challenge-response $out my_challenge - run fido2 reboot + make_title "reboot, version, verify, update, verify, reset, version" + + run fido2 reboot echo "sleeping for 10secs..." sleep 10 + run fido2 version run fido2 verify run fido2 update run fido2 verify run fido2 reset + run fido2 version + + + make_title "rnd subcommand(s)" + + run fido2 rng hexbytes + run fido2 rng hexbytes --count 12 + + echo "SKIP: sudo run fido2 rng feedkernel" + echo "SKIP: run fido2 rng raw" + + make_title "wink, reboot, wink, reboot, reset, set-pin, change-pin, verify" + + run fido2 wink + run fido2 reboot + sleep 5 + run fido2 wink + run fido2 reboot + sleep 5 + + # hrm ... + #echo -ne "1234\n1234\n" > set_pin.txt + #echo -ne "1234\n123456\n123456\n" > change_pin.txt + + run fido2 reset + run fido2 set-pin + echo "make sure pin is finally: 123456" + run fido2 change-pin + run fido2 verify --pin 123456 + + make_title "finally one more reset and then verify" + run fido2 reset + run fido2 verify } function teststart { - make_title 'Testing Nitrokey - Start' \ - 'Please insert a Nitrokey Start (will be wiped!)' + make_title "Testing Nitrokey - Start" \ + "Please insert a Nitrokey Start (will be wiped!)" + + make_title "Simple listing of devices" + run ls run start list + + make_title "setting identity 0, 1, 2, 0" + run start set-identity 0 run start set-identity 1 run start set-identity 2 run start set-identity 0 + + + make_title "updating with latest firmware" + run start update + + + make_title "setting identity 2, 0, 1, 0" + + run start set-identity 2 run start set-identity 0 run start set-identity 1 - run start set-identity 2 run start set-identity 0 - } -testfido2 +if [[ "$1" = "" ]] || [[ "$1" = "fido2" ]]; then + testfido2 +fi + +if [[ "$1" = "" ]] || [[ "$1" = "start" ]]; then + teststart +fi -teststart From 1ed5b3f4a17641c695814e64efec4922afc9f40d Mon Sep 17 00:00:00 2001 From: Markus Meissner Date: Tue, 25 Aug 2020 23:47:17 +0200 Subject: [PATCH 15/19] remove nacl dependency for now --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 406574bb..48f88bb6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,6 @@ requires = [ "pygments", "cffi", "cbor", - "nacl" ] classifiers=[ "License :: OSI Approved :: MIT License", From 73184c00b815857194ceab3fb37cbea6755877d9 Mon Sep 17 00:00:00 2001 From: Markus Meissner Date: Wed, 26 Aug 2020 00:10:41 +0200 Subject: [PATCH 16/19] fido2-api fix for fido2 util --- pynitrokey/cli/fido2.py | 6 +++--- pynitrokey/helpers.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pynitrokey/cli/fido2.py b/pynitrokey/cli/fido2.py index 13323b0f..74f5af23 100644 --- a/pynitrokey/cli/fido2.py +++ b/pynitrokey/cli/fido2.py @@ -66,7 +66,7 @@ def genkey(input_seed_file, output_pem_file): * You may optionally supply a file to seed the RNG for key generating. """ - vk = pynitrokey.operations.genkey(output_pem_file, input_seed_file=input_seed_file) + vk = pynitrokey.fido2.operations.genkey(output_pem_file, input_seed_file=input_seed_file) local_print( "Public key in various formats:", @@ -90,7 +90,7 @@ def genkey(input_seed_file, output_pem_file): def sign(verifying_key, app_hex, output_json, end_page): """Signs a fw-hex file, outputs a .json file that can be used for signed update.""" - msg = pynitrokey.operations.sign_firmware( + msg = pynitrokey.fido2.operations.sign_firmware( verifying_key, app_hex, APPLICATION_END_PAGE=end_page) local_print(f"Saving signed firmware to: {output_json}") with open(output_json, "wb+") as fh: @@ -123,7 +123,7 @@ def mergehex( If no attestation key is passed, uses default Solo Hacker one. Note that later hex files replace data of earlier ones, if they overlap. """ - pynitrokey.operations.mergehex( + pynitrokey.fido2.operations.mergehex( input_hex_files, output_hex_file, attestation_key=attestation_key, diff --git a/pynitrokey/helpers.py b/pynitrokey/helpers.py index 6c99cad9..d18bbbdd 100644 --- a/pynitrokey/helpers.py +++ b/pynitrokey/helpers.py @@ -85,7 +85,7 @@ def local_print(*messages, **kwargs): # logfile debug output else: - logger.debug(f"print: {item.strip()}") + logger.debug(f"print: {str(item).strip()}") # to stdout print(item, **kwargs) From 9189f66d9d3fa9d20205f2533df0c0a55e2a83f1 Mon Sep 17 00:00:00 2001 From: Markus Meissner Date: Wed, 26 Aug 2020 00:21:16 +0200 Subject: [PATCH 17/19] adding support mail into critical-fail banner (#34) --- pynitrokey/confconsts.py | 1 + pynitrokey/helpers.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/pynitrokey/confconsts.py b/pynitrokey/confconsts.py index 8a86267e..8137e451 100644 --- a/pynitrokey/confconsts.py +++ b/pynitrokey/confconsts.py @@ -39,3 +39,4 @@ class Verbosity(IntEnum): LOG_FORMAT = '%(relativeCreated)-8d %(levelname)6s %(name)10s %(message)s' GH_ISSUES_URL = "https://github.com/Nitrokey/pynitrokey/issues/" +SUPPORT_EMAIL = "support@nitrokey.com" diff --git a/pynitrokey/helpers.py b/pynitrokey/helpers.py index d18bbbdd..b3b9cb0a 100644 --- a/pynitrokey/helpers.py +++ b/pynitrokey/helpers.py @@ -15,7 +15,8 @@ from typing import List from getpass import getpass -from pynitrokey.confconsts import LOG_FN, LOG_FORMAT, GH_ISSUES_URL, VERBOSE, Verbosity +from pynitrokey.confconsts import LOG_FN, LOG_FORMAT, GH_ISSUES_URL, SUPPORT_EMAIL +from pynitrokey.confconsts import VERBOSE, Verbosity def to_websafe(data): data = data.replace("+", "-") @@ -104,6 +105,7 @@ def local_critical(*messages, support_hint=True, ret_code=1, **kwargs): "Critical error occurred, exiting now", "Unexpected? Is this a bug? Do you would like to get support/help?", f"- You can report issues at: {GH_ISSUES_URL}", + f"- Writing an e-mail to: {SUPPORT_EMAIL} is also possible", f"- Please attach the log: '{LOG_FN}' with any support/help request!", "-" * 80, "") sys.exit(ret_code) From f373e8ec7d6c4fcd1014d5cf90768dc90174d9d3 Mon Sep 17 00:00:00 2001 From: Markus Meissner Date: Wed, 26 Aug 2020 12:50:29 +0200 Subject: [PATCH 18/19] cleanups inside fido2.util; sh-test updated --- interactive_test.sh | 42 +++++- pynitrokey/cli/fido2.py | 24 ++-- pynitrokey/cli/monitor.py | 3 + pynitrokey/cli/program.py | 267 ++++++++------------------------------ 4 files changed, 107 insertions(+), 229 deletions(-) mode change 100644 => 100755 interactive_test.sh diff --git a/interactive_test.sh b/interactive_test.sh old mode 100644 new mode 100755 index 9cf583c4..0c29a99f --- a/interactive_test.sh +++ b/interactive_test.sh @@ -15,14 +15,13 @@ function make_title if [[ "$2" != "" ]]; then echo "## $2" - echo -n ">> press enter to continue... "; read foo + [[ "$NO_WAIT" = "" ]] && echo -n ">> press enter to continue... " && read foo fi } function askout { - echo -n "<<<<<<<<<<< stop? " - read inp + [[ "$NO_WAIT" = "" ]] && echo -n "<<<<<<<<<<< stop? " && read inp if [[ "$inp" = "y" ]]; then exit 1; @@ -59,8 +58,8 @@ function testfido2 make_title "reboot, version, verify, update, verify, reset, version" run fido2 reboot - echo "sleeping for 10secs..." - sleep 10 + echo "sleeping for 5secs..." + sleep 5 run fido2 version run fido2 verify @@ -70,7 +69,7 @@ function testfido2 run fido2 version - make_title "rnd subcommand(s)" + make_title "rng subcommand(s)" run fido2 rng hexbytes run fido2 rng hexbytes --count 12 @@ -101,6 +100,37 @@ function testfido2 run fido2 reset run fido2 verify + make_title "get .hex firmware, gen sign-key, sign, (skipped: flash bad fw), flash good fw" + wget "https://github.com/Nitrokey/nitrokey-fido2-firmware/releases/download/2.0.0.nitrokey/nitrokey-fido2-firmware-2.0.0-app-to_sign.hex" + run fido2 util genkey test_key.pem + run fido2 util sign test_key.pem nitrokey-fido2-firmware-2.0.0-app-to_sign.hex output.json + + #echo "###>>>> THIS ONE WILL FAIL, EXPECTED FAIL:" + #run fido2 util program bootloader output.json + #sleep 1 + + wget "https://github.com/Nitrokey/nitrokey-fido2-firmware/releases/download/2.0.0.nitrokey/nitrokey-fido2-firmware-2.0.0.json" + echo "###>>>> THIS ONE MUST WORK - if not: brick!? :D" + run fido2 util program bootloader nitrokey-fido2-firmware-2.0.0.json + sleep 1 + + make_title "util program aux enter-bootloader, show version, leave + lists & reboots after each" + run fido2 util program aux enter-bootloader + sleep 1 + run fido2 list + run fido2 util program aux bootloader-version + run fido2 util program aux reboot + echo "longer sleep" + sleep 5 + run fido2 list + run fido2 util program aux leave-bootloader + sleep 5 + run fido2 list + run fido2 reboot + sleep 1 + run fido2 list + + } function teststart diff --git a/pynitrokey/cli/fido2.py b/pynitrokey/cli/fido2.py index 74f5af23..e8f1062c 100644 --- a/pynitrokey/cli/fido2.py +++ b/pynitrokey/cli/fido2.py @@ -552,7 +552,7 @@ def wink(serial, udp): ) def reboot(serial, udp): """Send reboot command to key (development command)""" - print('Reboot') + local_print("Reboot") CTAP_REBOOT = 0x53 dev = nkfido2.find(serial, udp=udp).dev try: @@ -562,6 +562,8 @@ def reboot(serial, udp): fido2.add_command(rng) + +# @fixme: this one exists twice, once here, once in "util program aux" fido2.add_command(reboot) fido2.add_command(list) @@ -575,11 +577,6 @@ def reboot(serial, udp): fido2.add_command(status) fido2.add_command(update) -# see above.... -#fido2.add_command(probe) -# key.add_command(sha256sum) -# key.add_command(sha512sum) - fido2.add_command(version) fido2.add_command(verify) fido2.add_command(wink) @@ -588,8 +585,19 @@ def reboot(serial, udp): fido2.add_command(change_pin) fido2.add_command(util) -util.add_command(monitor) + util.add_command(program) + +# used for fw-signing... (does not seem to work @fixme) util.add_command(sign) util.add_command(genkey) -util.add_command(mergehex) + + +# @fixme: removed for now, are these applicable for nk-fido2? +#util.add_command(mergehex) +#util.add_command(monitor) + +# see above -> @fixme: likely to be removed?! +#fido2.add_command(probe) +# key.add_command(sha256sum) +# key.add_command(sha512sum) diff --git a/pynitrokey/cli/monitor.py b/pynitrokey/cli/monitor.py index 3c37bf51..c375ce4a 100644 --- a/pynitrokey/cli/monitor.py +++ b/pynitrokey/cli/monitor.py @@ -7,6 +7,9 @@ # http://opensource.org/licenses/MIT>, at your option. This file may not be # copied, modified, or distributed except according to those terms. + +# @fixme: remove this file towards 0.5 + import sys import time diff --git a/pynitrokey/cli/program.py b/pynitrokey/cli/program.py index 175cff73..fce1eb74 100644 --- a/pynitrokey/cli/program.py +++ b/pynitrokey/cli/program.py @@ -11,145 +11,28 @@ import time import click -import pynitrokey -import usb from fido2.ctap import CtapError -from pynitrokey.fido2.dfu import hot_patch_windows_libusb - +from pynitrokey.helpers import local_print, local_critical @click.group() def program(): """Program a key.""" pass - - -@click.command() -@click.option("-s", "--serial", help="serial number of DFU to use") -@click.option( - "-a", "--connect-attempts", default=8, help="number of times to attempt connecting" -) -# @click.option("--attach", default=False, help="Attempt switching to DFU before starting") -@click.option( - "-d", - "--detach", - default=False, - is_flag=True, - help="Reboot after successful programming", -) -@click.option("-n", "--dry-run", is_flag=True, help="Just attach and detach") -@click.argument("firmware") # , help="firmware (bundle) to program") -def dfu(serial, connect_attempts, detach, dry_run, firmware): - """Program via STMicroelectronics DFU interface. - - - Enter dfu mode using `nitropy fido2 util program aux enter-dfu` first. - """ - - import time - - from intelhex import IntelHex - import usb.core - - dfu = pynitrokey.dfu.find(serial, attempts=connect_attempts) - - if dfu is None: - print("No STU DFU device found.") - if serial is not None: - print("Serial number used: ", serial) - sys.exit(1) - - dfu.init() - - if not dry_run: - # The actual programming - # TODO: move to `operations.py` or elsewhere - ih = IntelHex() - ih.fromfile(firmware, format="hex") - - chunk = 2048 - # Why is this unused - # seg = ih.segments()[0] - size = sum([max(x[1] - x[0], chunk) for x in ih.segments()]) - total = 0 - t1 = time.time() * 1000 - - print("erasing...") - try: - dfu.mass_erase() - except usb.core.USBError: - # garbage write, sometimes needed before mass_erase - dfu.write_page(0x08000000 + 2048 * 10, "ZZFF" * (2048 // 4)) - dfu.mass_erase() - - page = 0 - for start, end in ih.segments(): - for i in range(start, end, chunk): - page += 1 - data = ih.tobinarray(start=i, size=chunk) - dfu.write_page(i, data) - total += chunk - # here and below, progress would overshoot 100% otherwise - progress = min(100, total / float(size) * 100) - - sys.stdout.write( - "downloading %.2f%% %08x - %08x ... \r" - % (progress, i, i + page) - ) - # time.sleep(0.100) - - # print('done') - # print(dfu.read_mem(i,16)) - - t2 = time.time() * 1000 - print() - print("time: %d ms" % (t2 - t1)) - print("verifying...") - progress = 0 - for start, end in ih.segments(): - for i in range(start, end, chunk): - data1 = dfu.read_mem(i, 2048) - data2 = ih.tobinarray(start=i, size=chunk) - total += chunk - progress = min(100, total / float(size) * 100) - sys.stdout.write( - "reading %.2f%% %08x - %08x ... \r" - % (progress, i, i + page) - ) - if (end - start) == chunk: - assert data1 == data2 - print() - print("firmware readback verified.") - - if detach: - dfu.prepare_options_bytes_detach() - dfu.detach() - print("Please powercycle the device (pull out, plug in again)") - - hot_patch_windows_libusb() - - -program.add_command(dfu) - - -@click.command() -@click.option("-s", "--serial", help="Serial number of Nitrokey to use") -@click.argument("firmware") # , help="firmware (bundle) to program") -def check_only(serial, firmware): - """Validate currently flashed firmware, and run on success. Bootloader only.""" - p = pynitrokey.client.find(serial) - try: - p.use_hid() - p.program_file(firmware) - except CtapError as e: - if e.code == CtapError.ERR.INVALID_COMMAND: - print("Not in bootloader mode.") - # else: - # raise e - raise e - - -program.add_command(check_only) +# +# @click.command() +# @click.option("-s", "--serial", help="Serial number of Nitrokey to use") +# @click.argument("firmware") # , help="firmware (bundle) to program") +# def check_only(serial, firmware): +# """Validate currently flashed firmware, and run on success. Bootloader only.""" +# from pynitrokey.fido2 import find +# p = find(serial) +# try: +# p.use_hid() +# p.program_file(firmware) +# except CtapError as e: +# if e.code == CtapError.ERR.INVALID_COMMAND: +# local_critical("Not in bootloader mode.", e) @click.command() @@ -173,49 +56,47 @@ def bootloader(serial, firmware): Enter bootloader mode using `nitropy fido2 util program aux enter-bootloader` first. """ - p = pynitrokey.client.find(serial) + from pynitrokey.fido2 import find + p = find(serial) try: p.use_hid() p.program_file(firmware) except CtapError as e: if e.code == CtapError.ERR.INVALID_COMMAND: - print("Not in bootloader mode. Attempting to switch...") + local_print("Not in bootloader mode. Attempting to switch...") else: - raise e + local_critical(e) p.enter_bootloader_or_die() - print("Nitrokey rebooted. Reconnecting...") - time.sleep(0.5) - p = pynitrokey.client.find(serial) + local_print("Nitrokey rebooted. Reconnecting...") + time.sleep(2.0) + + find(serial) if p is None: - print("Cannot find Nitrokey device.") - sys.exit(1) + local_critical("Cannot find Nitrokey device.") + p.use_hid() p.program_file(firmware) -program.add_command(bootloader) - - @click.group() def aux(): """Auxiliary commands related to firmware/bootloader/dfu mode.""" pass -program.add_command(aux) - - def _enter_bootloader(serial): - p = pynitrokey.client.find(serial) + from pynitrokey.fido2 import find + p = find(serial) + local_print("please use the button on the device to confirm") p.enter_bootloader_or_die() - print("Nitrokey rebooted. Reconnecting...") + local_print("Nitrokey rebooted. Reconnecting...") time.sleep(0.5) - if pynitrokey.client.find(serial) is None: - raise RuntimeError("Failed to reconnect!") + if find(serial) is None: + local_critical(RuntimeError("Failed to reconnect!")) @click.command() @@ -230,67 +111,12 @@ def enter_bootloader(serial): return _enter_bootloader(serial) -aux.add_command(enter_bootloader) - - @click.command() @click.option("-s", "--serial", help="Serial number of Nitrokey to use") def leave_bootloader(serial): """Switch from Nitrokey bootloader to Nitrokey firmware.""" - p = pynitrokey.client.find(serial) - # this is a bit too low-level... - # p.exchange(pynitrokey.commands.SoloBootloader.done, 0, b"A" * 64) - p.reboot() - - -aux.add_command(leave_bootloader) - - -@click.command() -@click.option("-s", "--serial", help="Serial number of Nitrokey to use") -def enter_dfu(serial): - """Switch from Nitrokey bootloader to ST DFU bootloader. - - This changes the boot options of the key, which only reliably - take effect after a powercycle. - """ - - p = pynitrokey.client.find(serial) - p.enter_st_dfu() - # this doesn't really work yet ;) - # p.reboot() - - print("Please powercycle the device (pull out, plug in again)") - - -aux.add_command(enter_dfu) - - -@click.command() -@click.option("-s", "--serial", help="Serial number of Nitrokey to use") -def leave_dfu(serial): - """Leave ST DFU bootloader. - - Switches to Nitrokey bootloader or firmware, latter if firmware is valid. - - This changes the boot options of the key, which only reliably - take effect after a powercycle. - - """ - - dfu = pynitrokey.dfu.find(serial) # select option bytes - dfu.init() - dfu.prepare_options_bytes_detach() - try: - dfu.detach() - except usb.core.USBError: - pass - - hot_patch_windows_libusb() - print("Please powercycle the device (pull out, plug in again)") - - -aux.add_command(leave_dfu) + from pynitrokey.fido2 import find + find(serial).reboot() @click.command() @@ -299,24 +125,35 @@ def reboot(serial): """Reboot. \b - This should reboot from anything (firmware, bootloader, DFU). - Separately, need to be able to set boot options. + This implementation actually only works for bootloader reboot """ # this implementation actually only works for bootloader # firmware doesn't have a reboot command - pynitrokey.client.find(serial).reboot() - - -aux.add_command(reboot) + from pynitrokey.fido2 import find + find(serial).reboot() @click.command() @click.option("-s", "--serial", help="Serial number of Nitrokey to use") def bootloader_version(serial): """Version of bootloader.""" - p = pynitrokey.client.find(serial) - print(".".join(map(str, p.bootloader_version()))) + from pynitrokey.fido2 import find + p = find(serial) + local_print(".".join(map(str, p.bootloader_version()))) +program.add_command(aux) + aux.add_command(bootloader_version) +aux.add_command(leave_bootloader) +aux.add_command(enter_bootloader) +aux.add_command(reboot) + +program.add_command(bootloader) + + +# @fixme: looks useless, so remove it? +#program.add_command(check_only) + + From 6384dbc2dd433d8788933fb880549e6692445486 Mon Sep 17 00:00:00 2001 From: Markus Meissner Date: Tue, 1 Sep 2020 13:35:44 +0200 Subject: [PATCH 19/19] review changes #40 --- pynitrokey/cli/fido2.py | 6 +-- pynitrokey/cli/monitor.py | 2 - pynitrokey/cli/program.py | 5 ++- pynitrokey/fido2/__init__.py | 81 ++++++++++++++++++++++++++++++++++-- pynitrokey/fido2/dfu.py | 16 ++----- 5 files changed, 86 insertions(+), 24 deletions(-) diff --git a/pynitrokey/cli/fido2.py b/pynitrokey/cli/fido2.py index e8f1062c..fea4161f 100644 --- a/pynitrokey/cli/fido2.py +++ b/pynitrokey/cli/fido2.py @@ -591,12 +591,10 @@ def reboot(serial, udp): # used for fw-signing... (does not seem to work @fixme) util.add_command(sign) util.add_command(genkey) +util.add_command(mergehex) +util.add_command(monitor) -# @fixme: removed for now, are these applicable for nk-fido2? -#util.add_command(mergehex) -#util.add_command(monitor) - # see above -> @fixme: likely to be removed?! #fido2.add_command(probe) # key.add_command(sha256sum) diff --git a/pynitrokey/cli/monitor.py b/pynitrokey/cli/monitor.py index c375ce4a..d223f074 100644 --- a/pynitrokey/cli/monitor.py +++ b/pynitrokey/cli/monitor.py @@ -8,8 +8,6 @@ # copied, modified, or distributed except according to those terms. -# @fixme: remove this file towards 0.5 - import sys import time diff --git a/pynitrokey/cli/program.py b/pynitrokey/cli/program.py index fce1eb74..b4c49f5c 100644 --- a/pynitrokey/cli/program.py +++ b/pynitrokey/cli/program.py @@ -15,6 +15,10 @@ from pynitrokey.helpers import local_print, local_critical +from pynitrokey.fido2 import hot_patch_windows_libusb + + + @click.group() def program(): """Program a key.""" @@ -56,7 +60,6 @@ def bootloader(serial, firmware): Enter bootloader mode using `nitropy fido2 util program aux enter-bootloader` first. """ - from pynitrokey.fido2 import find p = find(serial) try: p.use_hid() diff --git a/pynitrokey/fido2/__init__.py b/pynitrokey/fido2/__init__.py index 4c8aff0f..5f7623b1 100644 --- a/pynitrokey/fido2/__init__.py +++ b/pynitrokey/fido2/__init__.py @@ -1,13 +1,86 @@ import time +import socket +import usb import pynitrokey.fido2.hmac_secret as hmac_secret +import fido2._pyu2f +import fido2._pyu2f.base + + + +def hot_patch_windows_libusb(): + # hot patch for windows libusb backend + olddel = usb._objfinalizer._AutoFinalizedObjectBase.__del__ + + def newdel(self): + try: + olddel(self) + except OSError: + pass + + usb._objfinalizer._AutoFinalizedObjectBase.__del__ = newdel + + + +def _UDP_InternalPlatformSwitch(funcname, *args, **kwargs): + if funcname == "__init__": + return HidOverUDP(*args, **kwargs) + return getattr(HidOverUDP, funcname)(*args, **kwargs) + + +def force_udp_backend(): + fido2._pyu2f.InternalPlatformSwitch = _UDP_InternalPlatformSwitch + + +class HidOverUDP(fido2._pyu2f.base.HidDevice): + @staticmethod + def Enumerate(): + a = [ + { + "vendor_id": 0x1234, + "product_id": 0x5678, + "product_string": "software test interface", + "serial_number": "12345678", + "usage": 0x01, + "usage_page": 0xF1D0, + "path": "localhost:8111", + } + ] + return a + + def __init__(self, path): + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.sock.bind(("127.0.0.1", 7112)) + addr, port = path.split(":") + port = int(port) + self.token = (addr, port) + self.sock.settimeout(1.0) + + def GetInReportDataLength(self): + return 64 + + def GetOutReportDataLength(self): + return 64 + + def Write(self, packet): + self.sock.sendto(bytearray(packet), self.token) + + def Read(self): + msg = [0] * 64 + pkt, _ = self.sock.recvfrom(64) + for i, v in enumerate(pkt): + try: + msg[i] = ord(v) + except TypeError: + msg[i] = v + return msg + def find(solo_serial=None, retries=5, raw_device=None, udp=False): - # @fixme: revive - #if udp: - # pynitrokey.fido2.force_udp_backend() + if udp: + force_udp_backend() from pynitrokey.fido2.client import NKFido2Client from pynitrokey.exceptions import NoSoloFoundError @@ -34,7 +107,7 @@ def find_all(): hid_devices = list(CtapHidDevice.list_devices()) solo_devices = [d for d in hid_devices if (d.descriptor["vendor_id"], d.descriptor["product_id"]) in [ - ## @FIXME: woop, woop MAGIC NUMBERS ahoi... + ## @FIXME: move magic numbers (1155, 41674), (0x20A0, 0x42B3), (0x20A0, 0x42B1), diff --git a/pynitrokey/fido2/dfu.py b/pynitrokey/fido2/dfu.py index 85eb188d..0e2b1ae4 100644 --- a/pynitrokey/fido2/dfu.py +++ b/pynitrokey/fido2/dfu.py @@ -16,6 +16,9 @@ import usb.util from pynitrokey.fido2.commands import DFU, STM32L4 +# @fixme: remove for 0.5 +# hotpatch windows stuff extracted to __init__ + def find(dfu_serial=None, attempts=8, raw_device=None, altsetting=1): """dfu_serial is the ST bootloader serial number. @@ -40,19 +43,6 @@ def find_all(): return [find(raw_device=st_dfu) for st_dfu in st_dfus] -def hot_patch_windows_libusb(): - # hot patch for windows libusb backend - olddel = usb._objfinalizer._AutoFinalizedObjectBase.__del__ - - def newdel(self): - try: - olddel(self) - except OSError: - pass - - usb._objfinalizer._AutoFinalizedObjectBase.__del__ = newdel - - class DFUDevice: def __init__(self,): pass