"""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()