The RS-Key applet stack with no hardware under it. It runs the same
crates/rsk-* code a real key runs — FIDO2/U2F, PIV, OpenPGP, OATH, management,
rescue — and speaks CTAPHID and APDUs over TCP instead of USB.
It is not a security key. No secure boot, no OTP root, no fuses, no tamper resistance; the seed lives in a file you can read. It exists to run the protocol suites without a board and to develop host tools against.
cargo run --manifest-path tools/emu/Cargo.toml --target "$HOST" -- --store ./my.store --host <addr> bind address (default 127.0.0.1)
--fido-port <n> CTAPHID port, 0 disables (default 7799)
--ccid-port <n> APDU/card port, 0 disables (default 7800)
--store <path> the flash image to mount (default: a blank chip, memory only)
--touch ask for every user presence on the terminal
--auto-touch-ms <n> report presence pending, then approve after n milliseconds
--display open the trusted display in a window (SDL2); presence
becomes an on-screen hold, as on a screen board
--usbip [addr] serve USB/IP (default 127.0.0.1:3240) so a Linux host can
attach the emulator as a real USB device
--trace log every command and its status
--seed <hex> seed the DRBG deterministically (predictable keys)
--serial <16 hex> device serial
--yubico present the Yubico identity — USB VID/PID and descriptor
strings, the ATR, the OpenPGP AID vendor — as the
VIDPID=Yubikey5 build does. `ykman` needs it.
--power-cut <n> cut the flash's power after n bytes of writes
--auto-touch-ms is mutually exclusive with --touch and --display. During
the delay the CTAPHID endpoint reports UPNEEDED; a CANCEL on the active
channel ends the operation before it is approved. This mode is intended for
deterministic conformance runs that need to observe keepalive and cancellation.
A timer answers the prompt, so this remains an auto-confirming authenticator,
not a confirm-showing one: the CTAP 2.1 §6.6 reset window still applies, exactly
as under the default instant presence. --touch, where a person answers, is the
mode that is exempt from it.
Build the emulator with the conformance-specific FIDO feature and run it with delayed presence like this:
HOST="$(rustc -vV | sed -n 's/^host: //p')"
cargo build --manifest-path tools/emu/Cargo.toml --target "$HOST" \
--features fido-conformance
tools/emu/target/"$HOST"/debug/rsk-emu \
--store ./conformance.store --fido-port 7799 --ccid-port 7800 \
--auto-touch-ms 250 --traceThe fido-conformance feature forwards to rsk-fido/fido-conformance, which
uses the authenticator profile intended for the upstream FIDO corpus.
python tests/emu.py tests/11_fido_makecredential.py
python tests/emu.py tests/34_openpgp_rsa.pytests/emu.py installs a fake hid module pointed at the CTAPHID socket and a
fake smartcard package pointed at the card socket, and redirects the
power-cycle helper at the emulator — so the suites run unmodified, and neither
hidapi nor pyscard need be installed. RSK_EMU / RSK_EMU_CCID override the
addresses.
42 of the 53 suites pass; the other 10 are refused by name, with the reason, before they start (exit 77 — so a sweep counts skips apart from failures). None of them is an unexplained failure:
| Skipped here | Why |
|---|---|
02, 73, 77 |
raw USB — this shim serves reports. They run against --usbip below, as ordinary hardware suites |
51 |
reboots to BOOTSEL; there is no bootloader to fall into |
53 |
the PC/SC FEATURE_VERIFY_PIN_DIRECT reader layer |
61, 65 |
driven through python-fido2's own HID transport — faking it would leave the suite testing this shim instead of a third-party client. Under --usbip there is nothing to fake, and they run |
29, 54, 90 |
real power-cut, SRAM residue and OTP-fuse migration — hardware by definition |
The list lives in tests/emu.py (UNSUPPORTED); removing an entry is a claim
that the emulator grew the capability.
14 is the 43rd: it asserts with a credential a person enrolled
(ssh-keygen -t ed25519-sk), so without that key it skips 77 too.
30 needs the Yubico card identity: start the emulator --yubico and it runs,
otherwise the shim asks the card for its ATR and skips. 28 and 76 take --pin and want a PIN already set (21_pin_webauthn sets
1234); given both, they pass. Without the flag the shim refuses them by name
(NEEDS_ARGS) rather than letting them die in argparse, so a hand-run sweep reads
them as not invoked instead of as a device failure. 50 and 52 measure that a
touch took time, so they only mean
something with --touch and a human at the keyboard.
The sockets above are not something a browser, ykman or gpg can reach — they
look for USB. --usbip fixes that without any USB hardware: the Linux kernel's
vhci_hcd attaches a TCP peer as a virtual host controller, and USB/IP is
network-transparent, so the emulator can stay on a Mac while a Linux VM imports
it.
cargo run --manifest-path tools/emu/Cargo.toml --target "$HOST" -- --usbip 0.0.0.0:3240
# then, on a Linux box that can reach it:
sudo modprobe vhci-hcd
sudo usbip list -r <host> # lists rsk-emu and its three interfaces
sudo usbip attach -r <host> -b rsk-emuWhat attaches is the device's own USB stack, not a description of it: the same
embassy_usb::Builder, the same three interfaces in the same order, the same
rsk_usb::ctaphid and rsk_usb::ccid transports the firmware runs, over
usbip_driver instead of the RP2350's USB peripheral. So the interface order —
02_usb_interfaces, issue #55 — is checked against the descriptors a host really
reads, and fido2-token, a browser, ykman and gpg all work.
fido2-token -L # /dev/hidraw1: vendor=0x1209, product=0x0001
fido2-token -I /dev/hidraw1 # CTAP2.3 getInfo
opensc-tool -a # 3b:fc:…:52:53:2d:4b:65:79 — the RS-Key ATRFive of the nine suites this shim refuses run here instead, with nothing faked:
02_usb_interfaces, 61/65 (python-fido2's own HID transport, ML-DSA verified
by OpenSSL), 73_otp_keyboard and 77_otp_touch_wait. A USB/IP attach is this
build's power-up — RAM state goes, the card resets, the CTAP 2.1 §6.6 window
reopens — so tests/replug.py's physical unplug becomes usbip detach +
usbip attach.
The keyboard interface carries the OTP frame protocol — the transport
ykman otp speaks — so 02_usb_interfaces, 73_otp_keyboard and
77_otp_touch_wait all run here. What it does not do is type: a ticket is
emitted by a button gesture and this build has no button, so the keyboard's IN
endpoint stays silent.
ykman finds a device by the Yubico VID, so those suites need --yubico, which
now presents the whole Yubico identity — VID/PID and descriptor strings as well as
the ATR and the OpenPGP AID vendor. 77 also needs --touch and a slot
programmed with one, or there is no wait for it to watch the device let go of:
rsk-emu --store ./emu.store --yubico --touch --usbip 0.0.0.0:3240
ykman otp chalresp --touch --force 1 <20-byte-hex>
python tests/77_otp_touch_wait.py --slot 1PC/SC finds no reader on the default identity until the ccid driver's whitelist
carries it (overlays.ccid-rs-key, docs/linux.md) — the same thing that happens
with a real key, for the same reason.
Needs Linux and root for vhci_hcd; the emulator itself can stay on a Mac,
because USB/IP is network-transparent.
All of that in one command — including the two identities, since 73 wants the
Yubico one and the rest must not have it — is scripts/usbip-suites.sh, which is
also what CI runs. It boots a guest that owns a vhci_hcd
(nix build .#usbip-vm) because a GitHub-hosted runner cannot be one, and keeps
the emulators outside it on the VM host. All six run: 02, 61, 65, 73,
77 and the pico-fido conformance suite.
77 needs the emulator's stdin held open — the runner uses a fifo it never
writes to. On EOF the emulator correctly stops pretending anyone could answer and
times the touch out at once, which makes a --touch device behave like a
no-touch one and leaves the suite nothing to watch; a fifo gives it the real
thing, a wait nobody ever ends.
CTAPHID — the stream carries 64-byte HID reports, both directions, exactly
as the USB interface would. A client is a send(64) / recv(64) shim away.
Card — one CCID message at a time:
request op:u8 | len:u32 BE | payload
response len:u32 BE | payload
op is 00 for a CCID message and 03 for a replug (a power cycle: RAM state
is dropped, the CTAP 2.1 §6.6 reset window reopens, and every plain Yubico-OTP
slot's use counter advances — CCID has no message for that, because a power cycle
is not a card reset, and a warm reboot is neither). The payload of a 00 is a
whole PC_to_RDR message, header and all, and the answer is a whole RDR_to_PC:
the same bytes a PC/SC driver puts on the bulk endpoints, so rsk_usb::ccid runs
here rather than being bypassed. One request may draw several responses, as a
bulk-IN stream does — a slow XfrBlock gets bStatus = 0x80 time extensions
before its DataBlock, and a client is expected to step over them.
The device identity is deliberately its own (serial RSKEMU\x00\x01), so
anything derived from it — the OpenPGP AID, the seal context, the management
serial — is recognisable as emulator-made.
- Hardware: secure boot, OTP fuses, the anti-rollback epoch, the partition table, glitch detectors, side channels, the TRNG.
- Flash semantics: these are real now. The store is the device's
(
crates/rsk-store) oversequential-storage's mock NOR flash with the device's geometry — 4 KiB sectors, 1408 KiB main + 128 KiB counter — so writes clear bits and never set them, a page is erased before it is rewritten, and the ring migrates and reclaims where the board's does.--power-cut <n>arms the mock's own injector. What is still standing in for hardware is the medium itself: no wear, no partial-erase physics, and the write-once tracking resets across a restart (the bits do not — they are in the image). - The trusted display:
--displayruns it for real. The window is the panel: the pixels come from the samersk_ui::renderthe ST7789 gets, the flow is the samecrates/rsk-display, and the mouse enters it through the sameTouchPada finger does — held, not clicked, because a panel reports contact continuously and the 800 ms hold-to-approve is built on that. The ambient loop runs too, so the window behaves like a device sitting on the desk: it comes up on its own screen, the tabs and menus answer taps, and a host ceremony paints over them — the panel's loop and the host's share one executor exactly as they do on the board. The backlight is applied by scaling the pixels on their way to the window, and the space bar is the wake button. An open menu hands the parked worker its executor back when a host command lands, as a board's does — afterUI_YIELD_FLOOR_MS, which is what stops a browser loopinggetInfofrom shutting the operator's screen. - The vendor AID's hardware arms: the applet itself runs (
crates/rsk-vendor— the counter, the U2F/SELECT routing, the warm reboot), but SET/GET LED, the second core's statistics, the measurement benches and the drop to BOOTSEL all answerINS_NOT_SUPPORTED, because there is nothing behind them here. - USB: enumeration, interface order, the OTP keyboard interface, and the LED. The CCID block layer does run — the socket carries whole CCID messages — but its packetisation does not: a socket delivers a message whole, where the device accumulates it off 64-byte bulk-OUT transfers with a receive timeout.
- The firmware's outer loop: the applet wiring is shared now
(
crates/rsk-device), so a routing or gating bug shows up here. What is still written twice is the worker's sequencing — refresh the capability set when the dirty latch is up, run a queued reboot only after the response is out — andfirmware/src/{main,worker,presence,led}.rs, which are the board's.
A green run against the emulator is a protocol result, not a device result.