diff --git a/.gitignore b/.gitignore index e88b3411..8664311b 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ firmware-*.json .tags* .idea/ *.log +tmp/ diff --git a/interactive_test.sh b/interactive_test.sh new file mode 100755 index 00000000..0c29a99f --- /dev/null +++ b/interactive_test.sh @@ -0,0 +1,179 @@ +#!/bin/bash + +npy=venv/bin/nitropy + +function make_title +{ + if [[ "$2" = "" ]]; then + echo "------------------------------------------------------------------------" + echo "-> $1" + else + echo "########################################################################" + echo "########################################################################" + echo "## $1" + fi + + if [[ "$2" != "" ]]; then + echo "## $2" + [[ "$NO_WAIT" = "" ]] && echo -n ">> press enter to continue... " && read foo + fi +} + +function askout +{ + [[ "$NO_WAIT" = "" ]] && 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!)" + + make_title "Simple listing of device(s)" + run ls + run fido2 list + + + 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 + + + make_title "reboot, version, verify, update, verify, reset, version" + + run fido2 reboot + echo "sleeping for 5secs..." + sleep 5 + + run fido2 version + run fido2 verify + run fido2 update + run fido2 verify + run fido2 reset + run fido2 version + + + make_title "rng 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 + + 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 +{ + 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 0 +} + +if [[ "$1" = "" ]] || [[ "$1" = "fido2" ]]; then + testfido2 +fi + +if [[ "$1" = "" ]] || [[ "$1" = "start" ]]; then + teststart +fi + + + + + diff --git a/pynitrokey/__init__.py b/pynitrokey/__init__.py index 711e61a7..f78e564d 100644 --- a/pynitrokey/__init__.py +++ b/pynitrokey/__init__.py @@ -12,7 +12,6 @@ import pathlib -from . 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..fea4161f 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,14 @@ from pynitrokey.cli.monitor import monitor from pynitrokey.cli.program import program -import pynitrokey.operations +import pynitrokey.fido2.operations + +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() @@ -35,15 +46,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") @@ -57,35 +66,37 @@ 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) - - 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() - + vk = pynitrokey.fido2.operations.genkey(output_pem_file, input_seed_file=input_seed_file) + 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.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: 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") @@ -112,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, @@ -122,63 +133,62 @@ def mergehex( ) - - - - - @click.group() def rng(): """Access TRNG on key, see subcommands.""" pass + @click.command() def list(): """List all 'Nitrokey FIDO2' devices""" - solos = pynitrokey.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(pynitrokey.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()) +# @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): """Output raw entropy endlessly.""" - p = pynitrokey.client.find(serial) + p = nkfido2.find(serial) while True: r = p.get_rng(255) 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") def status(serial, blink: bool): """Print device's status""" - p = pynitrokey.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) @@ -189,14 +199,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 = pynitrokey.client.find(serial) + p = nkfido2.find(serial) import struct import fcntl @@ -204,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) @@ -226,12 +234,19 @@ 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()}") + 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() @@ -255,9 +270,7 @@ 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 +305,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, @@ -306,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( @@ -314,93 +330,113 @@ 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""" + + import cbor + from pynitrokey.fido2.commands import SoloBootloader - # hash_type = hash_type.upper() - assert hash_type in ("SHA256", "SHA512", "RSA2048", "Ed25519") + # @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 - assert len(data) <= 6 * 1024 - p = pynitrokey.client.find(serial, udp=udp) - import fido2 + # @todo: proper error/exception + cut in chunks? + assert len(data) <= 6 * 1024 - serialized_command = fido2.cbor.dumps({"subcommand": hash_type, "data": data}) - from pynitrokey.commands import SoloBootloader + p = nkfido2.find(serial, udp=udp) + serialized_command = cbor.dumps({"subcommand": hash_type, "data": data}) result = p.send_data_hid(SoloBootloader.HIDCommandProbe, serialized_command) result_hex = result.hex() - print(result_hex) + local_print(result_hex) + + # @todo: unreachable if hash_type == "Ed25519": - 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]}") + # @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 - print(f"verified? {verified}") + local_print("failed verification!") + # 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!!!") - pynitrokey.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") +# @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: - click.echo("New pin are mismatched. Please try again!") - return + local_critical("new pin does not match confirm-pin", + "please try again!", support_hint=False) try: - pynitrokey.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) + # @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: - click.echo("New pin are mismatched. Please try again!") - return + local_critical("new pin does not match confirm-pin", + "please try again!", support_hint=False) try: - pynitrokey.client.find(serial).set_pin(new_pin) - click.echo("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: - print(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() @@ -413,46 +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 - print("Please press the button on your Nitrokey key") + local_print("please press the button on your Nitrokey key") + + cert = None try: - cert = pynitrokey.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` - if "PIN required" in str(e): - print("Your key has a PIN set. Please pass it using `--pin `") - sys.exit(1) - raise + cert = nkfido2.find(serial, udp=udp).make_credential(pin=pin) except Fido2ClientError as e: 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) + + # 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", @@ -463,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: - print('Found device: {}'.format(hashdb[a_hex])) + local_print(f"found device: {hashdb[a_hex]}") else: - print("Unknown fingerprint! ", a_hex) + local_print(f"unknown fingerprint! {a_hex}") @click.command() @@ -477,7 +515,7 @@ def version(serial, udp): """Version of firmware on key.""" try: - res = pynitrokey.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: @@ -485,14 +523,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() @@ -503,7 +543,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.find(serial, udp=udp).wink() @click.command() @click.option("-s", "--serial", help="Serial number of Nitrokey to use") @@ -512,35 +552,50 @@ def wink(serial, udp): ) def reboot(serial, udp): """Send reboot command to key (development command)""" - print('Reboot') + local_print("Reboot") CTAP_REBOOT = 0x53 - dev = pynitrokey.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) + +# @fixme: this one exists twice, once here, once in "util program aux" 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) -# 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) + +# 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) + + +# 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..d223f074 100644 --- a/pynitrokey/cli/monitor.py +++ b/pynitrokey/cli/monitor.py @@ -7,6 +7,7 @@ # http://opensource.org/licenses/MIT>, at your option. This file may not be # copied, modified, or distributed except according to those terms. + import sys import time diff --git a/pynitrokey/cli/program.py b/pynitrokey/cli/program.py index e497e1eb..b4c49f5c 100644 --- a/pynitrokey/cli/program.py +++ b/pynitrokey/cli/program.py @@ -11,10 +11,11 @@ import time import click -import pynitrokey -import usb from fido2.ctap import CtapError -from pynitrokey.dfu import hot_patch_windows_libusb + +from pynitrokey.helpers import local_print, local_critical + +from pynitrokey.fido2 import hot_patch_windows_libusb @@ -22,134 +23,20 @@ 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 +60,46 @@ def bootloader(serial, firmware): Enter bootloader mode using `nitropy fido2 util program aux enter-bootloader` first. """ - p = pynitrokey.client.find(serial) + 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 +114,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 +128,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) + + diff --git a/pynitrokey/cli/start.py b/pynitrokey/cli/start.py index 7a3abb48..1eb79c7a 100644 --- a/pynitrokey/cli/start.py +++ b/pynitrokey/cli/start.py @@ -7,20 +7,26 @@ # 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 time import sleep 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 + +# @fixme: add 'version' for consistency with fido2 # https://pocoo-click.readthedocs.io/en/latest/commands/#nested-handling-and-contexts @@ -33,9 +39,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 +50,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 +64,42 @@ 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(f"reset done - now active identity: {identity}") + break 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) + except Exception as 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 +109,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 one 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) @@ -124,19 +131,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/cli/update.py b/pynitrokey/cli/update.py index 523d87de..b007058a 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,66 +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") + + 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 = pynitrokey.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: @@ -97,39 +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(): - c = pynitrokey.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) + 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 if client.is_solo_bootloader(): @@ -140,34 +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: - client = pynitrokey.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..8137e451 --- /dev/null +++ b/pynitrokey/confconsts.py @@ -0,0 +1,42 @@ + +from enum import IntEnum +import tempfile +import os +import logging + +class Verbosity(IntEnum): + """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 + +# 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 = '%(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/fido2/__init__.py b/pynitrokey/fido2/__init__.py index 07ae4561..5f7623b1 100644 --- a/pynitrokey/fido2/__init__.py +++ b/pynitrokey/fido2/__init__.py @@ -1,11 +1,27 @@ + +import time import socket +import usb + +import pynitrokey.fido2.hmac_secret as hmac_secret import fido2._pyu2f import fido2._pyu2f.base -def force_udp_backend(): - fido2._pyu2f.InternalPlatformSwitch = _UDP_InternalPlatformSwitch + +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): @@ -14,6 +30,10 @@ def _UDP_InternalPlatformSwitch(funcname, *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(): @@ -56,3 +76,43 @@ def Read(self): except TypeError: msg[i] = v return msg + + +def find(solo_serial=None, retries=5, raw_device=None, udp=False): + if udp: + 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: move magic numbers + (1155, 41674), + (0x20A0, 0x42B3), + (0x20A0, 0x42B1), + ] + ] + return [find(raw_device=device) for device in solo_devices] + + diff --git a/pynitrokey/client.py b/pynitrokey/fido2/client.py similarity index 90% rename from pynitrokey/client.py rename to pynitrokey/fido2/client.py index ae7141de..bc4ea52b 100644 --- a/pynitrokey/client.py +++ b/pynitrokey/fido2/client.py @@ -14,7 +14,6 @@ import tempfile import time -import pynitrokey.exceptions from cryptography import x509 from cryptography.hazmat.backends import default_backend from fido2.attestation import Attestation @@ -23,49 +22,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 -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() - # TODO: change `p` (for programmer) throughout - p = SoloClient() +import pynitrokey.exceptions +from pynitrokey import helpers +from pynitrokey.fido2.commands import SoloBootloader, SoloExtension - # 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" @@ -135,11 +99,11 @@ 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): - req = SoloClient.format_request(cmd, addr, data) + req = NKFido2Client.format_request(cmd, addr, data) data = self.send_data_hid(SoloBootloader.HIDCommandBoot, req) @@ -153,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) @@ -166,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"}] @@ -218,13 +182,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: @@ -280,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/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 95% rename from pynitrokey/dfu.py rename to pynitrokey/fido2/dfu.py index 6cabe442..0e2b1ae4 100644 --- a/pynitrokey/dfu.py +++ b/pynitrokey/fido2/dfu.py @@ -14,7 +14,10 @@ import usb._objfinalizer import usb.core import usb.util -from pynitrokey.commands import DFU, STM32L4 +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): @@ -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 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 73% rename from pynitrokey/hmac_secret.py rename to pynitrokey/fido2/hmac_secret.py index 1b0c3943..250ee4e1 100644 --- a/pynitrokey/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 -import pynitrokey.client from fido2.extensions import HmacSecretExtension - def make_credential( host="nitrokeys.dev", user_id="they", @@ -26,7 +23,9 @@ def make_credential( udp=False, ): user_id = user_id.encode() - client = pynitrokey.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"} client.host = host @@ -39,9 +38,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,13 +63,14 @@ 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, ): user_id = user_id.encode() - client = pynitrokey.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"} @@ -85,9 +91,12 @@ 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/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 d2a98fe0..b3b9cb0a 100644 --- a/pynitrokey/helpers.py +++ b/pynitrokey/helpers.py @@ -8,7 +8,15 @@ # 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 getpass import getpass + +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("+", "-") @@ -23,18 +31,178 @@ 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' -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)}' +# @todo: introduce granularization: dbg, info, err (warn?) +# + machine-readable +# + logfile-only +def local_print(*messages, **kwargs): + """ + application-wide 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: + # handle exception in order as, if it is a regular message + if isinstance(item, Exception): + logger.exception(item) + passed_exc = item + item = repr(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: {str(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, ret_code=1, **kwargs): + messages = ["Critical error:"] + list(messages) + local_print(*messages, **kwargs) + if support_hint: + 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"- 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) + + +# @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 + `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, + strict: bool=False, + 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.strict = strict + self.repeat = repeat or 1 + self.hide_input = hide_input + + @classmethod + def yes_no(cls, what: str, strict: bool=False): + opts = ["yes", "no"] + return cls(what, options=opts, strict=strict).ask() == opts[0] + + @classmethod + def strict_yes_no(cls, what: str): + return cls.yes_no(what, strict=True) + + @classmethod + def plain(cls, what): + return cls(what).ask() + + @classmethod + def hidden(cls, what): + return cls(what, hide_input=True).ask() + + 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: + self.data = answer + return self.data + + # now `options` based + retries = self.repeat + while retries: + 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 = self.get_input() + 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 4c978568..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,14 +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.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, 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' @@ -100,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 @@ -241,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 @@ -272,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 @@ -299,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), @@ -336,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 @@ -347,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 @@ -358,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: @@ -403,12 +439,14 @@ 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)) 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"] @@ -427,100 +465,96 @@ 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: - 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() @@ -528,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(UPGRADE_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}") 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)) diff --git a/pyproject.toml b/pyproject.toml index 09290b71..48f88bb6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,13 +14,14 @@ requires = [ "click >= 7.0", "cryptography", "ecdsa", - "fido2 == 0.7.3", + "fido2", "intelhex", "pyserial", "pyusb", "requests", "pygments", - "cffi" + "cffi", + "cbor", ] classifiers=[ "License :: OSI Approved :: MIT License",