def test(ctx: Context, pin: Optional[str]) -> None: """Run some tests on all connected Nitrokey 3 devices.""" from .test import TestContext, log_devices, log_system, run_tests log_system() devices = ctx.list() if len(devices) == 0: log_devices() raise CliException("No connected Nitrokey 3 devices found") local_print(f"Found {len(devices)} Nitrokey 3 device(s):") for device in devices: local_print(f"- {device.name} at {device.path}") results = [] test_ctx = TestContext(pin=pin) for device in devices: results.append(run_tests(test_ctx, device)) n = len(devices) success = sum(results) failure = n - success local_print("") local_print( f"Summary: {n} device(s) tested, {success} successful, {failure} failed" ) if failure > 0: local_print("") raise CliException(f"Test failed for {failure} device(s)")
def challenge_response(serial, host, user, prompt, credential_id, challenge, udp): """(EXPERIMENTAL) Uses `hmac-secret` to implement a challenge-response mechanism. We abuse hmac-secret, which gives us `HMAC(K, hash(challenge))`, where `K` is a secret tied to the `credential_id`. We hash the challenge first, since a 32 byte value is expected (in original usage, it's a salt). This means that we first need to setup a credential_id; this depends on the specific authenticator used. To do this, use `nitropy fido2 make-credential`. If so desired, user and relying party can be changed from the defaults. The prompt can be suppressed using `--prompt ""`. """ local_print("EXPERIMENTAL: Currently disabled: challenge-response") return nkfido2.hmac_secret.simple_secret(credential_id, challenge, host=host, user_id=user, serial=serial, prompt=prompt, output=True, udp=udp)
def rng(ctx: Context, length: int) -> None: """Generate random data on the key.""" with ctx.connect_device() as device: while length > 0: rng = device.rng() local_print(rng[:length].hex()) length -= len(rng)
def _print_download_warning( release_version: Version, current_version: Optional[Version] = None, ) -> None: current_version_str = str( current_version) if current_version else "[unknown]" local_print(f"Current firmware version: {current_version_str}") local_print(f"Latest firmware version: {release_version}") if current_version and current_version > release_version: raise CliException( "The latest firmare release is older than the firmware on the device.", support_hint=False, ) elif current_version and current_version == release_version: click.confirm( "You are already running the latest firmware release on the device. Do you want " f"to continue and download the firmware version {release_version} anyway?", abort=True, ) else: click.confirm( f"Do you want to download the firmware version {release_version}?", default=True, abort=True, )
def hexbytes(count, serial): """Output COUNT number of random bytes, hex-encoded.""" 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())
def _print_update_warning() -> None: local_print("") local_print( "Please do not remove the Nitrokey 3 or insert any other Nitrokey 3 devices " "during the update. Doing so may damage the Nitrokey 3.") if not click.confirm("Do you want to perform the firmware update now?"): logger.info("Update cancelled by user") raise click.Abort()
def progress_func(x): x = x * 100 if x == 0: progress_func.last = 0 if progress_func.last * 10 <= x < 100: progress_func.last += 1 local_print(f"Progress: {round(x, 2)}%\r", end="", flush=True)
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.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())
def show_kdf_details(passwd): gnuk = None try: gnuk = get_gnuk_device(logger=logger, verbose=True) except ValueError as e: local_print("Connection error", e) if "No ICC present" in str(e): print("Cannot connect to device. Closing other open connections.") kill_smartcard_services() return else: raise gnuk.cmd_select_openpgp() # Compute passwd data try: kdf_data = gnuk.cmd_get_data(0x00, 0xF9).tobytes() except: kdf_data = b"" if kdf_data == b"": print("KDF not set") # passwd_data = passwd.encode('UTF-8') else: ( algo, subalgo, iters, salt_user, salt_reset, salt_admin, hash_user, hash_admin, ) = parse_kdf_data(kdf_data) if salt_admin: salt = salt_admin else: salt = salt_user d = { "algo": algo, "subalgo": subalgo, "iters": iters, "salt_user": binascii.b2a_hex(salt_user), "salt_reset": binascii.b2a_hex(salt_reset), "salt_admin": binascii.b2a_hex(salt_admin), "hash_user": binascii.b2a_hex(hash_user), "hash_admin": binascii.b2a_hex(hash_admin), } pprint(d, width=100) if passwd: try: passwd_data = kdf_calc(passwd, salt, iters) print(f"passwd_data: {binascii.b2a_hex(passwd_data)}") except ValueError as e: local_print("Error getting KDF", e) else: print("Provide password to calculate final hash")
def _reboot_to_bootloader(device: Nitrokey3Device) -> None: local_print( "Please press the touch button to reboot the device into bootloader mode ..." ) try: device.reboot(BootMode.BOOTROM) except TimeoutException: raise CliException( "The reboot was not confirmed with the touch button.", support_hint=False, )
def _enter_bootloader(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() local_print("Nitrokey rebooted. Reconnecting...") time.sleep(0.5) if find(serial) is None: local_critical(RuntimeError("Failed to reconnect!"))
def feedkernel(count, serial): """Feed random bytes to /dev/random.""" if os.name != "posix": local_critical("This is a Linux-specific command!") if not 0 <= count <= 255: local_critical( f"Number of bytes must be between 0 and 255, you passed {count}") p = nkfido2.find(serial) RNDADDENTROPY = 0x40085203 entropy_info_file = "/proc/sys/kernel/random/entropy_avail" print(f"entropy before: 0x{open(entropy_info_file).read().strip()}") r = p.get_rng(count) # man 4 random # RNDADDENTROPY # Add some additional entropy to the input pool, incrementing the # entropy count. This differs from writing to /dev/random or # /dev/urandom, which only adds some data but does not increment the # entropy count. The following structure is used: # struct rand_pool_info { # int entropy_count; # int buf_size; # __u32 buf[0]; # }; # Here entropy_count is the value added to (or subtracted from) the # entropy count, and buf is the buffer of size buf_size which gets # added to the entropy pool. # 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) 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()}")
def status(serial, blink: bool): """Print device's status""" p = nkfido2.find(serial) t0 = time() while True: if time() - t0 > 5 and blink: p.wink() r = p.get_status() for b in r: local_print('{:#02d} '.format(b), end='') local_print("") sleep(0.3)
def reboot(serial, udp): """Send reboot command to key (development command)""" local_print("Reboot", "Press key to confirm!") CTAP_REBOOT = 0x53 dev = nkfido2.find(serial, udp=udp).dev try: dev.call(CTAP_REBOOT ^ 0x80, b'') except OSError: local_print("...done") except CtapError as e: local_critical(f"...failed ({str(e)})")
def make_credential(serial, host, user, udp, prompt): """(EXPERIMENTAL) Generate a credential. Pass `--prompt ""` to output only the `credential_id` as hex. """ local_print("EXPERIMENTAL: use with care, not a fully supported function") nkfido2.hmac_secret.make_credential(host=host, user_id=user, serial=serial, output=True, prompt=prompt, udp=udp)
def set_identity(identity): """set given identity (one of: 0, 1, 2)""" if not identity.isdigit(): local_critical("identity number must be a digit") identity = int(identity) if identity < 0 or identity > 2: 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() gnuk.cmd_select_openpgp() try: gnuk.cmd_set_identity(identity) except USBError: local_print(f"reset done - now active identity: {identity}") break except ValueError as e: 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: local_critical(e) except Exception as e: local_critical(e)
def probe(serial, udp, hash_type, filename): """Calculate HASH""" # @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 # 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) 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": # @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")) # @fixme: where does this 'magic-number' come from!? verify_key = nacl.signing.VerifyKey( bytes.fromhex( "c69995185efa20bf7a88139f5920335aa3d3e7f20464345a2c095c766dfa157a" )) try: verify_key.verify(result) local_print("verified!") except nacl.exceptions.BadSignatureError: local_print("failed verification!")
def genkey(input_seed_file, output_pem_file): """Generates key pair that can be used for Solo signed firmware updates. \b * Generates NIST P256 keypair. * Public key must be copied into correct source location in solo bootloader * The private key can be used for signing updates. * You may optionally supply a file to seed the RNG for key generating. """ 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)
def get_firmware_file(file_name: str, type: FirmwareType): if file_name: with open(file_name, "rb") as f: firmware_data = f.read() local_print("- {}: {}".format(file_name, len(firmware_data))) return firmware_data tag = get_latest_release_data()["tag_name"] url = FIRMWARE_URL.get(type, None).format(tag) # type: ignore 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" local_print( f"- {type}: {len(firmware_data)}, " f"hash: ...{hash_data[-8:]} {hash_valid} (from ...{url[-24:]})") return firmware_data
def fetch_update(path: str, force: bool, version: Optional[str]) -> None: """ Fetches a firmware update for the Nitrokey 3 and stores it at the given path. If no path is given, the firmware image stored in the current working directory. If the given path is a directory, the image is stored under that directory. Otherwise it is written to the path. Existing files are only overwritten if --force is set. Per default, the latest firmware release is fetched. If you want to download a specific version, use the --version option. """ try: update = get_repo().get_update_or_latest(version) except Exception as e: if version: raise CliException(f"Failed to find firmware update {version}", e) else: raise CliException("Failed to find latest firmware update", e) bar = DownloadProgressBar(desc=update.tag) try: if os.path.isdir(path): path = update.download_to_dir(path, overwrite=force, callback=bar.update) else: if not force and os.path.exists(path): raise OverwriteError(path) with open(path, "wb") as f: update.download(f, callback=bar.update) bar.close() local_print( f"Successfully downloaded firmware release {update.tag} to {path}") except OverwriteError as e: raise CliException( f"{e.path} already exists. Use --force to overwrite the file.", support_hint=False, ) except Exception as e: raise CliException(f"Failed to download firmware update {update.tag}", e)
def set_pin(serial): """Set pin of current key""" new_pin = AskUser.hidden("Please enter new pin: ") confirm_pin = AskUser.hidden("Please confirm new pin: ") if new_pin != confirm_pin: local_critical("new pin does not match confirm-pin", "please try again!", support_hint=False) try: # @fixme: move this (function) into own fido2-client-class client = nkfido2.find(serial).client PIN(client.ctap2).set_pin(new_pin) local_print("done - please use new pin to verify key") except Exception as 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)
def download_firmware(): # download asset url # @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: with open(fw_fn, "wb") as fd: firmware = requests.get(download_url) fd.write(firmware.content) except Exception as e: local_critical("Failed downloading firmware", e) local_print( f"\tFirmware saved to {fw_fn}", f"\tDownloaded firmware version: {gh_release_data['tag_name']}", ) return fw_fn
def _print_version_warning( metadata: FirmwareMetadata, current_version: Optional[Version] = None, ) -> None: current_version_str = str( current_version) if current_version else "[unknown]" local_print(f"Current firmware version: {current_version_str}") local_print(f"Updated firmware version: {metadata.version}") if current_version: if current_version > metadata.version: raise CliException( "The firmware image is older than the firmware on the device.", support_hint=False, ) elif current_version == metadata.version: if not click.confirm( "The version of the firmware image is the same as on the device. Do you want " "to continue anyway?"): raise click.Abort()
def list(): """List all 'Nitrokey FIDO2' devices""" devs = nkfido2.find_all() local_print(":: 'Nitrokey FIDO2' keys") for c in devs: descr = c.dev.descriptor if hasattr(descr, "product_name"): name = descr.product_name elif c.is_solo_bootloader(): name = "FIDO2 Bootloader device" else: name = "FIDO2 device" if hasattr(descr, "serial_number"): id_ = descr.serial_number else: id_ = descr.path local_print(f"{id_}: {name}")
def change_pin(serial): """Change pin of current key""" 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_critical("new pin does not match confirm-pin", "please try again!", support_hint=False) try: # @fixme: move this (function) into own fido2-client-class client = nkfido2.find(serial).client PIN(client.ctap2).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)
def version(serial, udp): """Version of firmware on key.""" try: res = nkfido2.find(serial, udp=udp).solo_version() major, minor, patch = res[:3] locked = "" if len(res) > 3: if res[3]: locked = "locked" else: locked = "unlocked" local_print(f"{major}.{minor}.{patch} {locked}") except pynitrokey.exceptions.NoSoloFoundError: local_critical("No Nitrokey found.", "If you are on Linux, are your udev rules up to date?") # unused ??? except (pynitrokey.exceptions.NoSoloFoundError, ApduError): local_critical( "Firmware is out of date (key does not know the NITROKEY_VERSION command)." )
def bootloader_version(serial, pubkey): """Version of bootloader.""" from pynitrokey.fido2 import find p = find(serial) if not p.is_solo_bootloader(): local_print("Not in Bootloader Mode!") return else: local_print("Detected Bootloader Mode") local_print("Version: " + ".".join(map(str, p.bootloader_version()))) from binascii import b2a_hex from hashlib import sha256 if pubkey: bpub = p.boot_pubkey() bpub = b2a_hex(bpub) local_print(f"Bootloader public key: \t\t{bpub}") s = sha256() s.update(bpub) bpubh = b2a_hex(s.digest()) local_print(f"Bootloader public key sha256: \t{bpubh}")
def list() -> None: """List all Nitrokey 3 devices.""" local_print(":: 'Nitrokey 3' keys") for device in list_nk3(): with device as device: uuid = device.uuid() if uuid: local_print(f"{device.path}: {device.name} {device.uuid():X}") else: local_print(f"{device.path}: {device.name}")
def get_dev_details(): # @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: 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 and version_raw[3] else "unlocked" local_print(f"Firmware version: {major}.{minor}.{patch} {locked}", None)
def list(): """List all 'Nitrokey FIDO2' devices""" solos = nkfido2.find_all() local_print(":: 'Nitrokey FIDO2' keys") for c in solos: devdata = c.dev.descriptor if "serial_number" in devdata: local_print( f"{devdata['serial_number']}: {devdata['product_string']}") else: local_print(f"{devdata['path']}: {devdata['product_string']}")