Firmware for the Pico / Pico 2 that acts as a USB FIDO2 authenticator: CTAP2 and U2F, passkeys, BOOTSEL as the touch button, and clientPIN with PIN/UV auth protocols 1 and 2. A host simulator runs the same core so the python-fido2 test suite exercises it without hardware. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
107 lines
3.1 KiB
Python
107 lines
3.1 KiB
Python
"""Runs the authenticator simulator and exposes it as a python-fido2 HID device."""
|
|
|
|
import os
|
|
import pathlib
|
|
import subprocess
|
|
import threading
|
|
|
|
import pytest
|
|
from fido2.hid import CtapHidDevice
|
|
from fido2.hid.base import CtapHidConnection, HidDescriptor
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
SIMULATOR = pathlib.Path(os.environ.get("SKEY_SIM", ROOT / "build-host" / "skey_sim"))
|
|
PACKET_SIZE = 64
|
|
|
|
|
|
class PipeConnection(CtapHidConnection):
|
|
"""Carries HID reports over the simulator's stdin/stdout."""
|
|
|
|
def __init__(self, proc: subprocess.Popen):
|
|
self.proc = proc
|
|
|
|
def write_packet(self, data: bytes) -> None:
|
|
assert len(data) == PACKET_SIZE
|
|
self.proc.stdin.write(data)
|
|
self.proc.stdin.flush()
|
|
|
|
def read_packet(self) -> bytes:
|
|
data = self.proc.stdout.read(PACKET_SIZE)
|
|
if len(data) != PACKET_SIZE:
|
|
raise OSError(f"simulator exited with {self.proc.wait()}")
|
|
return data
|
|
|
|
def close(self) -> None:
|
|
self.proc.stdin.close()
|
|
assert self.proc.wait(timeout=10) == 0, "simulator crashed"
|
|
|
|
|
|
class Simulator:
|
|
"""One simulated key. Restarting keeps its flash, like unplugging a real one.
|
|
|
|
Without a button file every request is approved; with one, press() is a
|
|
touch of the button.
|
|
"""
|
|
|
|
def __init__(self, flash_file: pathlib.Path, button_file: pathlib.Path | None = None):
|
|
self.flash_file = flash_file
|
|
self.button_file = button_file
|
|
self.device = None
|
|
|
|
def start(self) -> CtapHidDevice:
|
|
if not SIMULATOR.exists():
|
|
pytest.fail(f"{SIMULATOR} not found; build it with: cmake -B build-host -DSKEY_HOST=ON && cmake --build build-host")
|
|
env = {**os.environ, "SKEY_FLASH_FILE": str(self.flash_file)}
|
|
if self.button_file:
|
|
env["SKEY_BUTTON_FILE"] = str(self.button_file)
|
|
proc = subprocess.Popen([SIMULATOR], stdin=subprocess.PIPE, stdout=subprocess.PIPE, env=env)
|
|
descriptor = HidDescriptor(
|
|
path="skey-sim",
|
|
vid=0x1209,
|
|
pid=0x0001,
|
|
report_size_in=PACKET_SIZE,
|
|
report_size_out=PACKET_SIZE,
|
|
product_name="Skey simulator",
|
|
serial_number="sim",
|
|
)
|
|
self.device = CtapHidDevice(descriptor, PipeConnection(proc))
|
|
return self.device
|
|
|
|
def stop(self) -> None:
|
|
if self.device:
|
|
self.device.close()
|
|
self.device = None
|
|
|
|
def restart(self) -> CtapHidDevice:
|
|
self.stop()
|
|
return self.start()
|
|
|
|
def press(self) -> None:
|
|
self.button_file.touch()
|
|
|
|
def press_after(self, seconds: float) -> threading.Timer:
|
|
timer = threading.Timer(seconds, self.press)
|
|
timer.start()
|
|
return timer
|
|
|
|
|
|
@pytest.fixture
|
|
def sim(tmp_path):
|
|
simulator = Simulator(tmp_path / "flash.bin")
|
|
simulator.start()
|
|
yield simulator
|
|
simulator.stop()
|
|
|
|
|
|
@pytest.fixture
|
|
def device(sim):
|
|
return sim.device
|
|
|
|
|
|
@pytest.fixture
|
|
def button_sim(tmp_path):
|
|
simulator = Simulator(tmp_path / "flash.bin", tmp_path / "button")
|
|
simulator.start()
|
|
yield simulator
|
|
simulator.stop()
|