[0.4.9] - 2026-08-09
The emulator release: tools/emu runs the applet stack on the host, and with it
the suites that had been hand-run against a flashed board — including, over
USB/IP, the ones that need a kernel to have enumerated the device. Both vendored
upstream conformance suites are refreshed, classified against the specs and now
gate; one of their new tests found a real getInfo bug. CI runs what a change can
affect instead of everything.
⚠️ Upgrading a 16 MB key provisioned before 0.4.8 wipes it
Export your seed first (seed backup).
0.4.8 moved the KV store on a 16 MB part 4 KB down — 0.4.7's partition table
claimed the chip's last sector, which holds the0x10FFFF00block the bootrom's
RP2350-E10 workaround owns, so the 16 MB images could not be built at all. A key
provisioned with an older 16 MB build comes up factory-empty: no passkeys, no
OpenPGP or PIV keys, no OATH credentials.The 16 MB flavors only —
displayand16mb, and theabrobot-16mand
waveshare-touch-lcdboard presets. 4 MB and 2 MB keys upgrade in place.
Added
-
A software emulator,
tools/emu. The applet crates run on the host and
serve CTAPHID and APDUs over TCP, so thetests/*.pysuites — until now
hand-run against a flashed board, and therefore never run in CI — work with no
hardware attached (python tests/emu.py tests/11_fido_makecredential.py). It
is a development tool, not a key: no secure boot, no OTP, no fuses, no USB
stack. Its device identity is deliberately its own, so emulator-made material
is recognisable as such. See docs/testing.md and
tools/emu/README.md. -
tools/emu --displayruns the trusted display in a window. The Approve/Deny
ceremony — the screen whose whole promise is that a signature cannot be had
without a tap on a panel naming the true relying party — could until now only be
seen on a board with a screen soldered to it. It renders on the host, from the
samersk_ui::renderand the samecrates/rsk-displayflow, and a mouse held on
the button enters that flow through the sameTouchPada finger does. The
ambient loop runs alongside the host's, on one executor, as on the board — so
the window is a device you can pick up and use, not just a ceremony viewer. -
tools/emu --usbipmakes the emulator a real USB device. The Linux kernel's
vhci_hcdattaches a TCP peer as a virtual host controller, so a host sees
/dev/hidraw*and a PC/SC reader with no USB hardware anywhere — and what it
enumerates is the device's own stack, not a description of it: the same
embassy_usb::Builder, the same three interfaces in the same order, the same
rsk-usbtransports, over anembassy_usb::driver::Driverwritten against the
USB/IP protocol.fido2-token, a browser,ykmanandgpgreach the emulator
through it, and the interface order issue #55 was about is now checked against
the descriptors a host actually reads. USB/IP is network-transparent, so the
emulator can stay on a Mac while a Linux VM imports it. See
tools/emu/README.md. -
The trusted-display guide shows the display, not photographs of it. The six
screens in docs/guides/display.md were camera shots of
a 2.8" panel, complete with the room's white balance — the background reads blue
in them and is near-black on the device. They are now whatrsk_ui::render
draws, at the panel's own 240×320, written byrsk-emu --screenshotsand
regenerable the day a screen changes. The guide also says the display can be
tried in a window (--display) before buying the board. Ten screens it only
described in prose are now shown too — including the Approve / Deny ceremony the
page is about, and the same prompt against a padded look-alike relying party,
where the clip keeps…m.attacker.comin view instead of the head an attacker
padded it with. -
The emulator is described where people look for it. README, the docs' front
page, the quick start and CONTRIBUTING all now say it exists and how to use it
for testing — it had been reachable only fromdocs/testing.mdand
docs/architecture.md, which is not where someone with no board goes looking.
CONTRIBUTING's on-device bullet also said those suites are run by hand against a
board; that stopped being true when CI started running them. -
CI runs the on-device suites.
scripts/emu-suites.shdrives every suite
that needs no board —tests/*.pyover the emulator's socket transports, plus
the vendored OpenPGP card conformance suite — each on a fresh flash image, and
.github/workflows/emulator.ymlruns it on pull requests and nightly. These
suites were hand-run against a flashed key, which is why seven of them had
rotted unnoticed; this is what stops the eighth. The half that wants real USB
(02,61,65,73,77and the pico-fido suite) still needs a runner with
vhci_hcdand is not in CI yet. -
tests/third_party.pyruns the vendored upstream suites against RS-Key.
third_party/has carried pico-fido's and pico-openpgp/Gnuk's own conformance
suites for a while with no way to run them that did not need a board and a
person. The runner supplies what they cannot ask for — the power cycle RS-Key's
CTAP 2.1 §6.6 reset window needs, which againsttools/emuis one message on the
card socket — and names every deliberate divergence as a strictxfail, so one
that gets fixed fails the run rather than staying listed for ever. Nothing in
third_party/is edited: the run is steered from outside by a pytest plugin.
Both suites were refreshed from upstream at the same time (commits recorded in
third_party/README.md, which nothing did before). pico-fido: 214 passed / 19
expected divergences / 0 failed. The OpenPGP card suite: 269 passed / 19
divergences / 181 deselected / 0 failed — it reaches the card through pyscard
alone, so it runs over the emulator's card socket with no PC/SC, no USB and no
root, on any machine rather than one with a reader and a key in it. Both gate.
Every listed divergence is a spec citation, not a shrug: where the suite and the
spec disagree the spec decides, and one of the refreshed suite's new tests found
a real bug (authenticatorConfigCommands, below). The OpenPGP entries include a
place where the spec contradicts itself (§4.4.1 says a constructed DO is returned
including its tag and length; §7.2.6's worked example omits it). The
deselections are a separate list, and a narrower claim: whole modules exercising
a vendor extension RS-Key does not implement — Gnuk's admin-less mode, where PW1
gains admin rights, which OpenPGP Card 3.4.1 never mentions. They are removed at
collection rather than xfailed because an xfailed test still runs, and these ones
block the card's admin PIN on the way past: deselecting the feature took the card
suite from 192 failures to 13, and the difference was all cascade. -
The emulator speaks the OTP frame protocol. The keyboard interface's feature
reports — the transportykman otpuses, and the oneykpers/KeePassXC drive —
now answer on--usbip, running the device's own state machine
(rsk_otp::hid::OtpHid, moved out offirmware/so both builds share it). With
it,tests/02_usb_interfaces.py,73_otp_keyboard.pyand77_otp_touch_wait.py
run with no hardware: interface order, an HMAC-SHA1 challenge-response through
ykman's ownOtpConnection, and a touch wait the host can abandon — as do
61/65, driven by python-fido2's own HID transport with OpenSSL verifying the
ML-DSA signatures. Five of the nine suites the socket shim refuses now run. Typed
tickets are not emulated — a ticket comes from a button gesture, and this build
has no button. -
A USB/IP attach is a power-up. RAM state goes, the card resets and the CTAP
2.1 §6.6 reset window reopens, sotests/replug.py's physical unplug becomes a
usbip detach+attach. Measuring that window from process start instead left
it already shut the first time any host looked, andauthenticatorResetanswered
NOT_ALLOWEDfor ever. -
--yubiconow presents the whole Yubico identity, USB VID/PID and descriptor
strings included, not only the ATR and the OpenPGP AID vendor.ykmanand Yubico
Authenticator find a device by the Yubico VID; a half-applied masquerade is a
card they cannot see at all, which is why the firmware ties all of it to one
effective VID. -
The CTAPHID and CCID message vocabularies in
rsk-usbare public, so the
emulator's transports name the same values instead of redeclaring them. -
scripts/docs_constants.pynow checks the constants copied intotests/*.py
andmetadata/*.json, not only those quoted indocs/, and resolves one
const A = B;indirection — which is where the large-blob value below hid.
66 copies checked, up from 5; the gate prints the live count on every run. -
The applet wiring moved into
crates/rsk-device.firmware/src/handler.rs
andccid_handler.rswere the last of the device that no test could reach and
the emulator had to reimplement — and what they hold is exactly the load-bearing
part: whether a U2F command can land on the vendor applet, whether a disabled
application is really invisible, and which records a device-wide wipe may take
first. Both builds now run the same code, and the board's own parts (the LED
atomics, the watchdog register carrying the clientPIN soft lock across a warm
reset, the dual-core prime search, the display's PIN latch) sit behind aHooks
trait whose defaults are exact no-ops. Behaviour and wire surface unchanged; no
bcdDevicebump. -
The vendor applet moved into
crates/rsk-vendor. It was the last applet
living infirmware/, so it was the only one with no host tests and the only
one the emulator could not serve. Its hardware — the LED atomics, the second
core's counters, the measurement benches, the reset — now sits behind a
Platformtrait the firmware fills in, and the applet itself is host-tested.
Behaviour and wire surface unchanged: the same AID, the same instructions, the
same status words (a build without an LED answersINS_NOT_SUPPORTEDexactly
as the unmatched instruction did before). NobcdDevicebump — nothing the
device does over the wire changed. -
The flash backend moved into
crates/rsk-store. The two
sequential-storagepartitions, the counter-FID routing and the scrub lap
lived only infirmware/src/flash_storage.rs, so nothing that is not a board
could run them: thepower_cutfuzz target tortured a hand-written mirror, and
the emulator had no log-structured store at all. Both now drive the shipped
backend — the emulator oversequential-storage's mock NOR flash with the
device's geometry, with--power-cut <n>arming the injector. The firmware
keeps what is the board's: the shared flash peripheral and its cache sizes.
Behaviour unchanged; nobcdDevicebump. -
The trusted display's flow moved into
crates/rsk-display.rsk-uialready
held what to draw, host-tested and Kani-proved; the layer that decides which
screen when — the PIN pad's state machine, the browse modals, the Approve/Deny
wait that is the anti-phishing guarantee — lived infirmware/, so the only
thing that could run it was a flashed board with a panel soldered on. The panel
and the touch controller are now type parameters (aDrawTarget<Color = Rgb565>
and aTouchPad), and the verbs that are genuinely the board's — backlight, wake
button, the LED a ceremony borrows, the firmware globals it coordinates through —
sit behind aHookstrait whose defaults are exact no-ops. Behaviour and wire
surface unchanged; nobcdDevicebump. -
The suites that need a real USB stack now run in CI too, inside a VM.
02_usb_interfaces,61/65,73/77and the pico-fido conformance suite
read USB descriptors or go through python-fido2's and pyscard's own transports,
so they want a device the kernel enumerated —vhci_hcd, which a GitHub-hosted
runner cannot supply: it cannot load a module
(runner-images#7541)
and has no reliable/dev/kvm
(community#8305).
scripts/usbip-suites.shboots a QEMU guest that does (nix build .#usbip-vm)
and attaches the emulator to it over the network. The emulator stays outside
the guest — it is a TCP peer, not a device — which keeps the guest a fixed
appliance (kernel,usbip,pcscd, Python) that no firmware change can
invalidate, and keeps its build the samecargoone as everywhere else. Two
emulators run at once on separate ports, because73drives ykman's own
OtpConnection(which binds Yubico USB ids and nothing else) while the rest
must stay on the default identity — the one whose CCID interface a stock driver
skips, and the reasonnix/ccid.nixexists. Everything inside runs on software
emulation, so it costs minutes rather than seconds; it runs on every PR and
nightly alongside the socket half.
Changed
-
CI stopped building 24 firmware images for a documentation edit. Every pull
request ran the whole package — the gate, every feature flavour, every build
knob — whilescripts/docs.sh checkran in no workflow at all, so a docs-only
change got 26 heavy jobs and zero link checking.scripts/ci-scope.shnow
classifies a change and the jobs gate on it: the flavour matrix and the knob
smokes want firmware,crates/, a toolchain pin ornix/firmware.nix; the
emulator suites want the code they run; a documentation-only change runs the
newdocsjob and nothing else. The rules are a script with a--self-test
thatcheck.shruns, not apaths:filter, because their failure direction is
a job that silently does not run. Two things stay unconditional: the mdBook
build with its link check, and gitleaks — a secret scan that can be skipped is
not a secret scan. -
The firmware matrix stopped rebuilding from cold every time. All 24 flavour
rows shared one cargo cache key with each other and with the gate, so they
raced to write the same entry while each restored atarget/some other feature
set had built — a matrix whose entire point is that the rows differ, thrashing a
cache on that difference. Each row now has its own key. The build-knob smokes,
which ran thirteen VIDPID presets and five env builds in sequence as the
workflow's slowest job, moved intoscripts/ci-knobs.shand run as five
parallel rows; grouped rather than one row per preset, because a public repo
gets 20 concurrent runners and the flavour matrix already wants 24. The script
and the matrix name the same groups in two places, socheck.shchecks they
still agree — a group only the script knows about is a smoke nobody runs.
Fixed
-
On-panel settings were lost if the key was unplugged with the menu still
open. Brightness, display-sleep and the touch timeout were written to flash
only when Settings closed — one write per editing session instead of one per
−/+ tap, which is what keeps that churn out of the credential partition. But a
USB key is unplugged, not shut down, and the settings screen is exactly where
someone decides they are done: the change they had already watched take effect
silently did not survive. An edit now also flushes once the menu has gone quiet
for 1.5 s, so a run of taps is still a single write and the loss window shrinks
from "the whole time the menu is open" to a moment.bcdDevice0x0873→
0x0874. -
The emulator pinned two CPU cores while doing nothing.
embassy_futures::block_on
re-polls in a tight loop and its waker does nothing — correct on a microcontroller
with nothing else to do, and 200% of a laptop for a tool meant to be left running
while you use the browser talking to it. Everything the emulator awaits registers a
real waker, so it now sleeps between them: 201% → 1.3% idle, and the same while a
USB/IP host is attached. -
tests/02_usb_interfaces.pydemanded behaviour the device deliberately does
not have. It required the OTP frame protocol on both HID interfaces, which
was true until audit run-30 removed it from the FIDO one — on macOS, serving it
there put the whole FIDO interface behind the Input Monitoring prompt. The suite
has failed on real hardware ever since and nobody ran it. It now checks the
decision instead: the keyboard interface serves the frame protocol, the FIDO
interface must refuse it. -
tests/14_up_only_after_reboot.pypassed on one laptop and crashed
everywhere else. It asserts with the credential from an enrolled
ed25519-skkey and defaults to~/.ssh/id_ed25519_sk; on a machine that has
never runssh-keygen -t ed25519-skthat is aFileNotFoundErrortraceback,
which reads as a device fault. It now skips (77) with the reason. Found by
running the emulator sweep on a second machine — which is what the CI job is
for. -
The emulator was building against a different embassy than the firmware.
tools/emuis a detached workspace, so itsbranch = "main"resolved on its own
clock — two months ahead of the lock the device ships. Harmless while the
emulator only spoke sockets; not harmless now that it runs the real USB stack,
where the drifted crate is the one that emits the descriptors a host enumerates.
It follows the firmware's rev, andscripts/check.shfails if any workspace
parts from it. Same shape as the vendoredsequential-storagefork the same
workspace had silently replaced with upstream. -
The
power_cutfuzz target was tearing a mirror of the store, not the
store. The re-implementation it drove had drifted three ways, each load-bearing
for what the target claims to prove: nolast_error, so it could not see
"absent" being confused with "the read failed" (the audit run-36 class); no
compact, so the scrub lap that destroys superseded secrets was never fuzzed;
andEF_CRED_CTR(0xC001) routed to the main partition where the device routes
it to the counter one, tearing the store's busiest key in the wrong place. It
now drivesrsk_store::SeqStoragedirectly. -
The published metadata statements said
maxSerializedLargeBlobArraywas
2048. The value moved to 2046 on 2026-08-04, whenMAX_LARGE_BLOB_SIZE
becamersk_fs::MAX_VALUE_BYTES— the store's real per-record ceiling — and
bothmetadata/rs-key.metadata.jsonand its conformance variant kept
advertising the old number to whoever reads them.tests/62_metadata_statement.py
is the check for exactly this drift, and it had not been run since. -
getInfo hid the
vendorPrototypeconfig subcommand it implements.
authenticatorConfigCommands(0x1F) listed0x01,0x02and0x03but not
0xFF, and CTAP 2.3.1 §6.11.7 is explicit: "authenticatorConfigCommands MUST
contain an array member with the value 0xFF if this subcommand is supported".
RS-Key does support it — it is the phy/soft-lock configuration arm
docs/protocol.md §11 publishes for PicoForge — so a client
reading the member to decide whether to use it was told the arm was absent while
the wire spec documented it. Not obscurity in either direction: the same section
says vendors must not count on it, and the arm still needs anacfg
pinUvAuthToken.0x15(vendorPrototypeConfigCommands, which vendor ids
exist) stays unadvertised. Found by the refreshed pico-fido suite.
bcdDevice0x0874→0x0875.
Verify a download: https://github.com/TheMaxMur/RS-Key/blob/v0.4.9/docs/supply-chain.md