From 1dd6177abc7900386303de8e62dc37c042795fa3 Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Tue, 14 Nov 2023 18:52:53 +0100 Subject: [PATCH 1/3] fido2: Add list-large-blobs command This patch adds a new subcommand to the fido2 command that lists all entries of the large-blob array if the largeBlobKey extension is supported by the device. --- pynitrokey/cli/fido2.py | 118 +++++++++++++++++++++++++++++++++++++ pynitrokey/fido2/client.py | 8 +++ 2 files changed, 126 insertions(+) diff --git a/pynitrokey/cli/fido2.py b/pynitrokey/cli/fido2.py index 52accae4..909610f3 100644 --- a/pynitrokey/cli/fido2.py +++ b/pynitrokey/cli/fido2.py @@ -12,6 +12,7 @@ import platform import struct import sys +from dataclasses import dataclass from time import sleep, time from typing import List, Literal, Optional @@ -26,6 +27,7 @@ from fido2.ctap import CtapError from fido2.ctap1 import ApduError from fido2.ctap2.base import Ctap2 +from fido2.ctap2.blob import LargeBlobs from fido2.ctap2.credman import CredentialManagement from fido2.ctap2.pin import ClientPin, PinProtocol from fido2.hid import CtapHidDevice @@ -33,6 +35,7 @@ import pynitrokey import pynitrokey.fido2 as nkfido2 import pynitrokey.fido2.operations +from pynitrokey.cli.exceptions import CliException from pynitrokey.cli.monitor import monitor from pynitrokey.cli.program import program from pynitrokey.cli.update import update @@ -844,6 +847,119 @@ def reboot(serial: Optional[str], udp: bool) -> None: local_critical(f"...failed ({str(e)})") +def _large_blobs(client: NKFido2Client) -> LargeBlobs: + large_blobs = client.large_blobs() + if large_blobs is None: + raise CliException("Device does not support large blobs", support_hint=False) + return large_blobs + + +def _cred_mgmt(client: NKFido2Client, pin: str) -> Optional[CredentialManagement]: + if not client.ctap2: + return None + if not CredentialManagement.is_supported(client.ctap2.info): + return None + client_pin = ClientPin(client.ctap2) + try: + client_token = client_pin.get_pin_token(pin) + except CtapError as error: + if error.code == CtapError.ERR.PIN_NOT_SET: + return None + elif error.code == CtapError.ERR.PIN_AUTH_BLOCKED: + raise CliException( + "Pin authentication is blocked, try reinserting the key or setting a pin if none is set.", + support_hint=False, + ) + elif error.code == CtapError.ERR.PIN_BLOCKED: + raise CliException( + "Your device has been blocked after too many failed unlock attempts. You need to reset it to fix this. " + "If no pin is set, reinserting the key might fix this warning.", + support_hint=False, + ) + else: + raise + return CredentialManagement(client.ctap2, client_pin.protocol, client_token) + + +@dataclass +class LargeBlobKey: + rp: str + cred: str + large_blob_key: str + + +def _large_blob_keys(cred_mgmt: CredentialManagement) -> List[LargeBlobKey]: + credentials = [] + + for rp in cred_mgmt.enumerate_rps(): + rp_id_hash = rp[CredentialManagement.RESULT.RP_ID_HASH] + rp_entity = rp[CredentialManagement.RESULT.RP] + rp_label = rp_entity.get("name", rp_entity.get("id", rp_id_hash)) + for cred in cred_mgmt.enumerate_creds(rp_id_hash): + if CredentialManagement.RESULT.LARGE_BLOB_KEY not in cred: + continue + user_entity = cred[CredentialManagement.RESULT.USER] + cred_id = cred[CredentialManagement.RESULT.CREDENTIAL_ID] + cred_label = user_entity.get( + "displayName", user_entity.get("name", cred_id["id"].hex()) + ) + large_blob_key = cred[CredentialManagement.RESULT.LARGE_BLOB_KEY] + credentials.append( + LargeBlobKey( + rp=rp_label, cred=cred_label, large_blob_key=large_blob_key + ) + ) + + return credentials + + +@click.command() +def list_large_blobs() -> None: + """ + List the large blobs on the FIDO2 device. + + This command only works for models that implement the Large Blobs extension + for FIDO2. + """ + # TODO: use public API + import zlib + + from fido2.ctap2.blob import _decompress, _lb_unpack + + pin = AskUser.hidden("Please provide pin: ") + client = nkfido2.find() + large_blobs = _large_blobs(client) + large_blob_array = large_blobs.read_blob_array() + print(f"Found large blob array with {len(large_blob_array)} elements") + + cred_mgmt = _cred_mgmt(client, pin) + large_blob_keys = _large_blob_keys(cred_mgmt) if cred_mgmt else [] + print(f"Found {len(large_blob_keys)} credentials with large blob keys") + + print() + print("Large blob array:") + + for entry in large_blob_array: + key = None + blob = None + for large_blob_key in large_blob_keys: + try: + compressed, orig_size = _lb_unpack(large_blob_key.large_blob_key, entry) # type: ignore[no-untyped-call] + decompressed = _decompress(compressed) # type: ignore[no-untyped-call] + if len(decompressed) == orig_size: + key = large_blob_key + blob = decompressed + break + except (ValueError, zlib.error): + pass + + if blob and key: + print(f"- entry for {key.rp}/{key.cred}:") + print(f" {blob.hex()}") + else: + print("- entry without matching key") + + fido2.add_command(rng) # @fixme: this one exists twice, once here, once in "util program aux" @@ -870,6 +986,8 @@ def reboot(serial: Optional[str], udp: bool) -> None: fido2.add_command(set_pin) fido2.add_command(change_pin) +fido2.add_command(list_large_blobs) + fido2.add_command(util) util.add_command(program) diff --git a/pynitrokey/fido2/client.py b/pynitrokey/fido2/client.py index f9c6c0ce..95887ca2 100644 --- a/pynitrokey/fido2/client.py +++ b/pynitrokey/fido2/client.py @@ -24,6 +24,7 @@ from fido2.ctap import CtapError from fido2.ctap1 import Ctap1 from fido2.ctap2.base import Ctap2 +from fido2.ctap2.blob import LargeBlobs from fido2.ctap2.credman import CredentialManagement from fido2.ctap2.pin import ClientPin from fido2.hid import CTAPHID, CtapHidDevice, open_device @@ -382,6 +383,13 @@ def cred_mgmt(self, serial: str, pin: str) -> CredentialManagement: return CredentialManagement(device.ctap2, client_pin.protocol, client_token) + def large_blobs(self) -> Optional[LargeBlobs]: + if not self.ctap2: + return None + if not LargeBlobs.is_supported(self.ctap2.info): + return None + return LargeBlobs(self.ctap2) + def enter_bootloader(self) -> None: """ If Nitrokey is configured as Nitrokey hacker or something similar, From 0e9e619c1297999c26c116d271fb4144c3244cb9 Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Wed, 22 Nov 2023 18:02:36 +0100 Subject: [PATCH 2/3] Add {set,delete}-large-blob --- pynitrokey/cli/fido2.py | 70 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/pynitrokey/cli/fido2.py b/pynitrokey/cli/fido2.py index 909610f3..d43fa2ac 100644 --- a/pynitrokey/cli/fido2.py +++ b/pynitrokey/cli/fido2.py @@ -885,7 +885,8 @@ def _cred_mgmt(client: NKFido2Client, pin: str) -> Optional[CredentialManagement class LargeBlobKey: rp: str cred: str - large_blob_key: str + cred_id: str + large_blob_key: bytes def _large_blob_keys(cred_mgmt: CredentialManagement) -> List[LargeBlobKey]: @@ -906,7 +907,7 @@ def _large_blob_keys(cred_mgmt: CredentialManagement) -> List[LargeBlobKey]: large_blob_key = cred[CredentialManagement.RESULT.LARGE_BLOB_KEY] credentials.append( LargeBlobKey( - rp=rp_label, cred=cred_label, large_blob_key=large_blob_key + rp=rp_label, cred=cred_label, cred_id=cred_id["id"].hex(), large_blob_key=large_blob_key ) ) @@ -960,6 +961,69 @@ def list_large_blobs() -> None: print("- entry without matching key") +@click.command() +@click.argument("cred-id") +@click.argument("blob") +def set_large_blob(cred_id: str, blob: str) -> None: + """ + List the large blobs on the FIDO2 device. + + This command only works for models that implement the Large Blobs extension + for FIDO2. + """ + pin = AskUser.hidden("Please provide pin: ") + client = nkfido2.find() + assert client.ctap2 + + client_pin = ClientPin(client.ctap2) + client_token = client_pin.get_pin_token(pin) + cred_mgmt = CredentialManagement(client.ctap2, client_pin.protocol, client_token) + + large_blob_keys = _large_blob_keys(cred_mgmt) if cred_mgmt else [] + + large_blob_key = None + for key in large_blob_keys: + if key.cred_id == cred_id: + large_blob_key = key + break + if not large_blob_key: + raise CliException(f"No credential with large blob key and ID {cred_id} found", support_hint=False) + + large_blobs = LargeBlobs(client.ctap2, client_pin.protocol, client_token) + large_blobs.put_blob(large_blob_key.large_blob_key, blob.encode()) + + +@click.command() +@click.argument("cred-id") +def delete_large_blob(cred_id: str) -> None: + """ + List the large blobs on the FIDO2 device. + + This command only works for models that implement the Large Blobs extension + for FIDO2. + """ + pin = AskUser.hidden("Please provide pin: ") + client = nkfido2.find() + assert client.ctap2 + + client_pin = ClientPin(client.ctap2) + client_token = client_pin.get_pin_token(pin) + cred_mgmt = CredentialManagement(client.ctap2, client_pin.protocol, client_token) + + large_blob_keys = _large_blob_keys(cred_mgmt) if cred_mgmt else [] + + large_blob_key = None + for key in large_blob_keys: + if key.cred_id == cred_id: + large_blob_key = key + break + if not large_blob_key: + raise CliException(f"No credential with large blob key and ID {cred_id} found", support_hint=False) + + large_blobs = LargeBlobs(client.ctap2, client_pin.protocol, client_token) + large_blobs.delete_blob(large_blob_key.large_blob_key) + + fido2.add_command(rng) # @fixme: this one exists twice, once here, once in "util program aux" @@ -987,6 +1051,8 @@ def list_large_blobs() -> None: fido2.add_command(change_pin) fido2.add_command(list_large_blobs) +fido2.add_command(set_large_blob) +fido2.add_command(delete_large_blob) fido2.add_command(util) From 790cc3906876e91a5829a07dafc75bc984738ed8 Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Thu, 23 Nov 2023 18:40:02 +0100 Subject: [PATCH 3/3] Improve usability - show blob as UTF-8 if possible - read blob from stdin if argument is - --- pynitrokey/cli/fido2.py | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/pynitrokey/cli/fido2.py b/pynitrokey/cli/fido2.py index d43fa2ac..6c2b3b31 100644 --- a/pynitrokey/cli/fido2.py +++ b/pynitrokey/cli/fido2.py @@ -907,7 +907,10 @@ def _large_blob_keys(cred_mgmt: CredentialManagement) -> List[LargeBlobKey]: large_blob_key = cred[CredentialManagement.RESULT.LARGE_BLOB_KEY] credentials.append( LargeBlobKey( - rp=rp_label, cred=cred_label, cred_id=cred_id["id"].hex(), large_blob_key=large_blob_key + rp=rp_label, + cred=cred_label, + cred_id=cred_id["id"].hex(), + large_blob_key=large_blob_key, ) ) @@ -956,7 +959,11 @@ def list_large_blobs() -> None: if blob and key: print(f"- entry for {key.rp}/{key.cred}:") - print(f" {blob.hex()}") + try: + print(f" {blob.decode()}") + except UnicodeError: + print(" [raw]") + print(f" {blob.hex()}") else: print("- entry without matching key") @@ -966,11 +973,16 @@ def list_large_blobs() -> None: @click.argument("blob") def set_large_blob(cred_id: str, blob: str) -> None: """ - List the large blobs on the FIDO2 device. + Set the large blob stored for a credential. This command only works for models that implement the Large Blobs extension - for FIDO2. + for FIDO2. If blob is set to -, the blob content is read from the standard + input instead. """ + + if blob == "-": + blob = sys.stdin.read() + pin = AskUser.hidden("Please provide pin: ") client = nkfido2.find() assert client.ctap2 @@ -987,7 +999,10 @@ def set_large_blob(cred_id: str, blob: str) -> None: large_blob_key = key break if not large_blob_key: - raise CliException(f"No credential with large blob key and ID {cred_id} found", support_hint=False) + raise CliException( + f"No credential with large blob key and ID {cred_id} found", + support_hint=False, + ) large_blobs = LargeBlobs(client.ctap2, client_pin.protocol, client_token) large_blobs.put_blob(large_blob_key.large_blob_key, blob.encode()) @@ -997,7 +1012,7 @@ def set_large_blob(cred_id: str, blob: str) -> None: @click.argument("cred-id") def delete_large_blob(cred_id: str) -> None: """ - List the large blobs on the FIDO2 device. + Delete the large blob stored for a credential. This command only works for models that implement the Large Blobs extension for FIDO2. @@ -1018,7 +1033,10 @@ def delete_large_blob(cred_id: str) -> None: large_blob_key = key break if not large_blob_key: - raise CliException(f"No credential with large blob key and ID {cred_id} found", support_hint=False) + raise CliException( + f"No credential with large blob key and ID {cred_id} found", + support_hint=False, + ) large_blobs = LargeBlobs(client.ctap2, client_pin.protocol, client_token) large_blobs.delete_blob(large_blob_key.large_blob_key)