Skip to content

Commit

Permalink
Merge pull request #114 from robin-nitrokey/nk3-test-pin
Browse files Browse the repository at this point in the history
nk3: Add PIN handling to the test subcommand
  • Loading branch information
robin-nitrokey authored Dec 8, 2021
2 parents 71019c4 + 65691e8 commit 9c13f45
Show file tree
Hide file tree
Showing 3 changed files with 46 additions and 16 deletions.
13 changes: 10 additions & 3 deletions pynitrokey/cli/nk3/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,16 @@ def rng(ctx: Context, length: int) -> None:


@nk3.command()
@click.option(
"-p",
"--pin",
"pin",
help="The FIDO2 PIN of the device (if enabled)",
)
@click.pass_obj
def test(ctx: Context) -> None:
def test(ctx: Context, pin: Optional[str]) -> None:
"""Run some tests on all connected Nitrokey 3 devices."""
from .test import log_devices, log_system, run_tests
from .test import TestContext, log_devices, log_system, run_tests

log_system()
devices = ctx.list()
Expand All @@ -119,8 +125,9 @@ def test(ctx: Context) -> None:
local_print(f"- {device.name} at {device.path}")

results = []
test_ctx = TestContext(pin=pin)
for device in devices:
results.append(run_tests(device))
results.append(run_tests(test_ctx, device))

n = len(devices)
success = sum(results)
Expand Down
38 changes: 27 additions & 11 deletions pynitrokey/cli/nk3/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@
ExcInfo = Tuple[Type[BaseException], BaseException, TracebackType]


class TestContext:
def __init__(self, pin: Optional[str]) -> None:
self.pin = pin


@unique
class TestStatus(Enum):
SKIPPED = auto()
Expand All @@ -50,7 +55,7 @@ def __init__(
self.exc_info = exc_info


TestCaseFn = Callable[[Nitrokey3Base], TestResult]
TestCaseFn = Callable[[TestContext, Nitrokey3Base], TestResult]


class TestCase:
Expand All @@ -60,7 +65,7 @@ def __init__(self, name: str, fn: TestCaseFn) -> None:


def test_case(name: str) -> Callable[[TestCaseFn], TestCaseFn]:
def decorator(func: Callable[[Nitrokey3Base], TestResult]) -> TestCaseFn:
def decorator(func: TestCaseFn) -> TestCaseFn:
TEST_CASES.append(TestCase(name, func))
return func

Expand All @@ -83,28 +88,28 @@ def log_system() -> None:


@test_case("UUID query")
def test_uuid_query(device: Nitrokey3Base) -> TestResult:
def test_uuid_query(ctx: TestContext, device: Nitrokey3Base) -> TestResult:
uuid = device.uuid()
uuid_str = f"{uuid:X}" if uuid else "[not supported]"
return TestResult(TestStatus.SUCCESS, uuid_str)


@test_case("Firmware version query")
def test_firmware_version_query(device: Nitrokey3Base) -> TestResult:
def test_firmware_version_query(ctx: TestContext, device: Nitrokey3Base) -> TestResult:
if not isinstance(device, Nitrokey3Device):
return TestResult(TestStatus.SKIPPED)
version = device.version()
return TestResult(TestStatus.SUCCESS, str(version))


@test_case("FIDO2")
def test_fido2(device: Nitrokey3Base) -> TestResult:
def test_fido2(ctx: TestContext, device: Nitrokey3Base) -> TestResult:
if not isinstance(device, Nitrokey3Device):
return TestResult(TestStatus.SKIPPED)

# Based on https://github.com/Yubico/python-fido2/blob/142587b3e698ca0e253c78d75758fda635cac51a/examples/credential.py

from fido2.client import Fido2Client
from fido2.client import Fido2Client, PinRequiredError
from fido2.server import Fido2Server

client = Fido2Client(device.device, "https://example.com")
Expand All @@ -119,7 +124,15 @@ def test_fido2(device: Nitrokey3Base) -> TestResult:
)

local_print("Please press the touch button on the device ...")
make_credential_result = client.make_credential(create_options["publicKey"])
try:
make_credential_result = client.make_credential(
create_options["publicKey"], pin=ctx.pin
)
except PinRequiredError:
return TestResult(
TestStatus.FAILURE,
"PIN activated -- please set the --pin option",
)
cert = make_credential_result.attestation_object.att_statement["x5c"]
cert_hash = sha256(cert[0]).digest().hex()
data = ""
Expand All @@ -138,7 +151,9 @@ def test_fido2(device: Nitrokey3Base) -> TestResult:
)

local_print("Please press the touch button on the device ...")
get_assertion_result = client.get_assertion(request_options["publicKey"])
get_assertion_result = client.get_assertion(
request_options["publicKey"], pin=ctx.pin
)
get_assertion_response = get_assertion_result.get_response(0)

server.authenticate_complete(
Expand All @@ -153,7 +168,7 @@ def test_fido2(device: Nitrokey3Base) -> TestResult:
return TestResult(TestStatus.SUCCESS, data)


def run_tests(device: Nitrokey3Base) -> bool:
def run_tests(ctx: TestContext, device: Nitrokey3Base) -> bool:
results = []

local_print("")
Expand All @@ -167,7 +182,7 @@ def run_tests(device: Nitrokey3Base) -> bool:

for (i, test_case) in enumerate(TEST_CASES):
try:
result = test_case.fn(device)
result = test_case.fn(ctx, device)
except Exception:
result = TestResult(TestStatus.FAILURE, exc_info=sys.exc_info())
results.append(result)
Expand All @@ -177,8 +192,9 @@ def run_tests(device: Nitrokey3Base) -> bool:
status = result.status.name.ljust(status_len)
msg = ""
if result.data:
print(repr(result.data))
msg = str(result.data)
elif result.exc_info:
elif result.exc_info[1]:
logger.error(
f"An exception occured during the execution of the test {test_case.name}:",
exc_info=result.exc_info,
Expand Down
11 changes: 9 additions & 2 deletions pynitrokey/stubs/fido2/client.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,23 @@
# http://opensource.org/licenses/MIT>, at your option. This file may not be
# copied, modified, or distributed except according to those terms.

from typing import Optional

from .ctap import CtapDevice
from .webauthn import AuthenticatorAssertionResponse, AuthenticatorAttestationResponse

class Fido2Client:
def __init__(self, device: CtapDevice, origin: str) -> None: ...
def make_credential(self, options: dict) -> AuthenticatorAttestationResponse: ...
def get_assertion(self, options: dict) -> Fido2ClientAssertionSelection: ...
def make_credential(
self, options: dict, pin: Optional[str] = None
) -> AuthenticatorAttestationResponse: ...
def get_assertion(
self, options: dict, pin: Optional[str] = None
) -> Fido2ClientAssertionSelection: ...

class ClientData(bytes): ...
class ClientError(Exception): ...
class PinRequiredError(ClientError): ...

class Fido2ClientAssertionSelection:
def get_response(self, idx: int) -> AuthenticatorAssertionResponse: ...

0 comments on commit 9c13f45

Please sign in to comment.