[0.4.5] - 2026-08-03
Security
Audit run-31 fixes (bcdDevice 0x085F, rsk 0.3.23, rsk-tui 0.3.3):
- A cancel can no longer end another transport's touch ceremony.
CANCEL_REQUESTEDwas one global flag, so an unprivileged process holding only
the FIDO HID nub couldCTAPHID_CANCELan OpenPGP, PIV, OATH or Yubico-OTP
ceremony —WORKER_LOCKdoes not serialize the two, because a parked FIDO
request acquires it inside the future the keepalive loop is already driving.
On a screenless build the next ceremony then started withspent == false, so
the user's descending finger could confirm the attacker'smakeCredential/
getAssertioninstead. The single flag becomes a typedWAIT_SCOPEset around
every dispatch;request_cancelhonours it exactly ascancel_otp_waitalready
did, and the keepalive advertisesUPNEEDEDonly for the channel that owns the
wait (which also closes a cross-transport "a touch is imminent" oracle). - The trusted display's device PIN is a first-class credential on the host path.
The FIDO vendor gate keyed solely on the clientPIN, so a display build whose owner
completed the panel's own onboarding — device PIN set, no clientPIN — exported its
master seed on one touch, and a panel lock did not stand in the way (a host
ceremony paints over it).pin_gatenow falls back to a device-PIN entry on the
device's own pad, coveringBACKUP_EXPORT,BACKUP_LOAD,ATT_IMPORT,
ATT_CLEARand the audit subcommands. - The on-device auto-lock has its own deadline. It rode on the display-sleep
timer, which every host ceremony refreshed — including the ungated
authenticatorSelection— so a loop of them held the panel unlocked for the whole
plugged-in session. The lock now counts from the last local interaction, and it
re-arms without blanking, so "Display sleep: Off" no longer switches a security
control off with it. - Seal backup and Firmware → reboot-to-BOOTSEL take the device PIN, like every
other irreversible panel action. Sealing cannot be undone except by a factory
reset that destroys the seed it protects, and BOOTSEL is the entry point for the
issue-#37 flash-rollback. - The passkey rename is device-PIN gated, and the delete card names the real
relying party. A nickname replaces the rpId on the browse screens, so an
unauthenticated relabel could aim the owner's own PIN-gated delete at the wrong
credential. - A host can no longer hold the on-device UI shut. Modals abandoned themselves
the instant a host command queued, with no floor — and becauseREQlatches until
the worker drains it, one repeated ungated command kept the unlock pad closing on
its first poll. Entry and hold modals now keep a short guaranteed slice and never
yield mid-entry or mid-hold. - Touch, the wake button and the auto-lock no longer depend on USB being
configured. They sat behind the LED status, which stays atBootuntil a host
completesSET_CONFIGURATION, so on charger or battery power the panel animated
but ignored every touch. - The at-rest scrub is re-armed by the lazy OpenPGP migration. The one-shot
compaction lap latched at boot, but the OpenPGP DEK chain and its verifiers migrate
off the pre-OTP key base on the first PIN verify — appends, leaving the superseded
chip-serial-rooted copies readable in a flash dump forever. That contradicted the
threat model's "a flash dump cannot brute-force the PIN offline": the pre-OTP root
derives from the public chip serial.EF_HARDENEDmoves torsk-fsand the
migration clears it, so the next boot scrubs. - A host-requested warm reboot no longer advances the Yubico-OTP use counter.
power_up_bumpran on everymain, so ~32768 ungated warm reboots saturated the
15-bit counter while the RAM session counter restarted at 0 — leaving the key
re-emitting(useCtr, sessionCtr)pairs a validation server rejects as replays. - The OTP keyboard transport zeroizes its buffers (frame reassembly, the taken
request, the type queue) and joins the pre-BOOTSEL scrub. They could hold a slot's
AES key, private UID, access code or static password. forcesigholds on OpenPGP PSO:CDS. PW3 is still accepted for parity, but not
when the card is configured "PW1 valid for one signature" — only PW1 can be cleared
per signature, so an admin-PIN entry would otherwise have authorised unlimited
signatures silently.TERMINATE DFfails instead of reporting a wipe it could not prove.
wipe_openpgpuseddelete(which skips a false-absent filefor_each_keykeeps
yielding, so the sweep could spin) and discarded the enumeration's completeness
flag. It now matches the FIDO and PIV sweeps:force_delete, a delete budget, and
MEMORY_FAILUREon a truncated walk.rsk offboard,rsk inventory verifyandrsk backup restore/finalizerefuse
to guess between two attached keys. The PC/SC half picks its device by reader
name and the HID half took the first match, so offboard could confirm one device's
serial and factory-reset another's FIDO identity, andinventory verifycould bind
a serial to a different device's attestation key — the enrollment anchor
docs/guides/fleet.mdtells operators to record.ctaphid.find_allis new;
connect_fido(exclusive=True)gates the destructive callers.rsk backup exportno longer prints the PIN beside the mnemonic. The export is
gated on touch plus the PIN; echoing it into the block the user was just told to
record collapsed both factors into one artifact.rsk-tui: no first-reader fallback, a real reboot status word, and the audit
window cross-check. With CCID absent the cockpit connected to whatever card was
in a reader — SELECTing five applets on it every 5 s and rendering its PIN counters
as the RS-Key's.rebootreported a declined on-screen gate as success.audit_read
kept only the modulo check under a comment claiming parity withaudit.py, so a
device could present 1 of N events as a complete window. The header also refuses to
vouch for an identity when more than one FIDO device is attached.
Fixed
- Board files: flash size and LED pins.
waveshare-touch-lcdandtenstar-usb
declaredsize_mb = 4where every other reference (nix, CI, the docs, the flash-map
diagram) builds them at 16M — a 12 MiB shift of the key store, which reads as an
empty device and boots a display build unlocked.tenstar-usbandseeed-xiao
pointed the WS2812 at GP16 (the Waveshare One's pin) instead of the hardware-verified
GP22, leaving the consent indicator dark. Each shipped board file is now smoke-built
in CI, andBOARD=is documented indocs/build.md. build.rsrejects instead of truncating.u8()wrapped an out-of-range pin into
a plausible one before the resolvers' range asserts could see it, and the four
display control pins had no range check at all — a value ≥ 128 aliases onto a real
GPIO through embassy's bit-7-bankedAnyPin. Board-file booleans now accept the same
spellings the env resolvers do (yes/on) and panic on anything else, rather than
silently reading asfalse;presence.sourceis matched case-insensitively.rsk backup finalizeworks on a device with a PIN set (rsk0.3.22). It
sent the vendor command with no pinUvAuthToken, sopin_gateanswered
CTAP2_ERR_PUAT_REQUIRED(0x36) and the one-time export window could not be
sealed onceclientPinwas enabled; it now takes the same_gated()path
rsk backup exportalready used
(#59, thanks
@lockedmutex).- A touch-gated challenge no longer looks like a timeout the moment the button
is pressed. While a command ran, the keyboard transport picked its status
byte from the live presence flag —0x20while a touch was awaited,0x10
otherwise. But that flag drops as soon as the press is collected, and the
response only appears once the HMAC has been computed, so every touch-gated
challenge served a short0x10window in between: measured at 9 ms against
Windows and 11 ms against macOS. ykpers' blocking read
(yk_wait_for_key_status, which KeePassXC vendors) arms itself on0x20and
then reads any byte carrying neither the pending nor the waiting bit as "the
key timed out waiting for the user" — so a host polling inside that window
abandoned a challenge the key had already answered. A YubiKey never shows it:
it reports the wait, plus a seconds countdown, right up to the response. The
wait now latches for the rest of the command, so only the response — or the
idle status frame after a real timeout — replaces it, which leaves an expired
wait ending exactly as promptly as before. bcdDevice →0x085C. - The PIN-entry band no longer leaves a stale "+" overflow marker and the right
half of the 10th dot behind when the user deletes from a long PIN back to ≤10
digits (render_pin_dots). The repaint cleared one small rectangle per dot,
centred on each circle, butmasked_entrydraws dots top-left-aligned — so the
clear was off byENTRY_DIA/2and missed the "+" at x 184 plus dot 10's right
tail (x 176..180). Deleting 11 → 10 left the "+" on screen for the rest of the
session, lying that the PIN was still long, and 10 → 9 left a one-pixel stub.
The repaint now clears one strip over the whole entry band (every dot position
plus the overflow slot to its right) before redrawing. Covered by a host test
inrsk-ui.
Audit run-30 fixes (bcdDevice 0x085D, rsk 0.3.21, rsk-tui 0.3.2):
- The OTP frame protocol is served on the keyboard interface only again. It
had also been answered on the FIDO HID interface to match a YubiKey; on macOS
that removed a privilege boundary — IOKit gates a keyboard-usage HID nub behind
Input Monitoring while the0xF1D0FIDO nub opens to any console-user process,
so slot programming and challenge-response became reachable unprompted. The
keyboard interface is already index 0 (what index-addressing hosts need), so the
FIDO door gained nothing and is removed. rsk openpgp resetno longer risks destroying an unrelated OpenPGP card. It
now checks the SELECT status (refusing a card with no OpenPGP application) and
takes the typed confirmation the docs already promised, andfind_readerfails
with a clear error instead of silently grabbing the first PC/SC reader when no
RS-Key is present.- A slot UPDATE no longer resets the Yubico-OTP use counter / OATH-HOTP moving
factor. It built a 52-byte record, dropping the 8-byte counter tail, so a
routineykman otp settingssilently rolled the anti-replay counter back. The
tail is now carried forward; only a full re-CONFIGURE resets it. - A YubiKey config-lock code is no longer stored or disclosed. RS-Key does not
implement the lock, but WRITE CONFIG kept the 16-byte code and READ CONFIG
echoed it in cleartext to any unauthenticated host. The lock tags are now
stripped on write, and READ CONFIG always reports the lock unset (as hardware
does). rsk offboardalways writes a receipt now. A missed touch (or a malformed
device) on the post-wipe journal read used to abort before the receipt was
written, leaving an irreversible wipe with no artifact; the failure now degrades
to a note in a written receipt.rsk backup statussanitizes device output. Thesealed/has_seedvalues
from the device are coerced to booleans, so a hostile device can no longer inject
terminal escapes during the seed-backup ceremony.rsk-tuino longer prints "identity verified ✓" for an unpinned device. The
verifying key comes from the same response being checked, so a self-signed
counterfeit passed; the verdict now states only that the signature is
self-consistent and points atrsk inventory verify --expect-key, matching the
CLI.- The host tools bound CTAPHID response reassembly by wall clock. A device
trickling one small continuation frame per timeout could hangrskor freeze
the TUI for hours; both now enforce the same 120 s deadline the keepalive loop
uses. - The release workflow refuses a tag that is not an ancestor of
mainas
defence in depth. The primary control against a leaked write token laundering
unreviewed code into a signed release is a repository tag ruleset restricting
who may createrefs/tags/v*— configure that in the repo settings. docs/unsafe.mdrecords the four newAnyPin::stealsites (display
CS/DC/RST/TP_RST) and thefirmware/build.rsenv::set_varsite,
restoring the runtime-site count from 15 to 19 and matching the new
collision-assert containment in the prose.
Added
- Per-board build configuration:
BOARD=<name>picksfirmware/boards/<name>.toml
instead of setting the LED / presence / flash / display env vars one by one.
Ships withwaveshare-one,waveshare-touch-lcd,tenstar-usb, and
seeed-xiao. The individual env vars still work and still win over the board
file, so existing build recipes are unaffected. The display's SPI1
(PIN_10/11/12) and I2C1 (PIN_6/7) lines stay hard-wired; only the control
GPIOs (cs/dc/rst/tp_rst/backlight), bus frequencies, colour inversion,
and colour order are per-board. - Trusted-display UI redesign (
--features display): anti-aliased circles for
the boot, reset, and PIN-entry dots; a shared component system (card,
rect_card,list::group_card,list::row) behind the Passkeys, Audit,
Applets, and Settings screens; and a unified 10 px gap between the title bar and
the content on every screen. The passkey rename screen replaces the up/down
character wheel with a T9 phone-style keypad — a repeated press cycles the
key's letter group, a different key or an 800 ms pause commits, and the field
and keypad repaint in place instead of clearing the frame.
Changed
bcdDevicebumped to0x085Efor the UI redesign and the per-board
configuration system.- OpenPGP RSA heap restored to 128 KiB. It was halved to 64 KiB inside the
per-board-config commit without a callout; onembedded_alloca failed
allocation aborts (handle_alloc_error→ panic → watchdog reset), so a long
RSA-4096 keygen/CRT mid-operation could reset the device. Back to the v0.4.4
value until a separate justification for the smaller size is on record. - Display control GPIOs are now checked for collisions at compile time. The
four newAnyPin::stealsites forCS/DC/RST/TP_RSTwere added by number
with no compile-time guard beyondWAKE_PINvs the10..=18range, so a board
config could aimcsat the same pad asWAKE_PIN,LED_PIN, the hard-wired
SPI1 (PIN_10/11/12) / I2C1 (PIN_6/7) lines, or another control line, and
silently drive one pad from two owners. Aconst _: () = assert!(...)now
rejects all of those at build time. The backlight PWM(pin, slice, channel)
combo likewise had no compile-time guard and panicked at boot on an unsupported
board (a runtime panic on fully constant operands); it too is now aconst
assert, and the runtime match arm isunreachable!. firmware/build.rsre-rustc's when any of the 11PK_DISPLAY_*env vars
change. They were the only env knobs missing acargo:rerun-if-env-changed,
so overridingPK_DISPLAY_CSetc. without touchingBOARDreused the cached
build and shipped a firmware with stale pins.
Removed
- Dead code from the UI redesign:
aa::filled_rounded_rect(~64 lines, never
called), and theCARET_BLINK_MSconst left behind with#[allow(dead_code)]
after the rename pad switched from a caret blink to a T9 pending-char.
Internal
- The on-device tests no longer guess which key they are talking to. Every
tests/*.pyscript picked the first FIDO HID device the OS listed, so with a real
YubiKey attached next to a board builtVIDPID=Yubikey5(both1050:0407), tests
10and15ran against the YubiKey and reported its aaguid, itsalwaysUvand
its6700as RS-Key failures. Selection now lives in one place,tests/_device.py:
theRSKmarker breaks a tie,RSK_TEST_SERIAL/RSK_TEST_PATHname a target
explicitly, and an unresolved choice stops the run instead of picking one. The
seven copiedfind()helpers andctaphid.findroute through it, as do the
python-fido2 suites (61,65) andreplug.reset_fido2— that last one sent
authenticatorResetto whatever it found first. Same bug class as the audit run-31
fix intools/rsk(ctaphid.find_all,connect_fido(exclusive=True)). - The CCID half of that, over PC/SC. The reader pick was copy-pasted into 24
scripts. Eighteen matched theRSKmarker in the reader name but fell back to
rs[0], so a build withUSB_PRODUCToverridden drove whatever reader the OS listed
first — next to a real YubiKey, the YubiKey, which enumerates ahead of the board on
the maintainer's machine.53tookrs[0]with no match at all,90took the first
of the marker-matched, and80_piv.pymatched"Yubico"and"PIV"too, aiming a
suite that blocks both PIN references and factory-RESETs the applet at a real
YubiKey's PIV. All 24 now call_device.find_reader(), withRSK_TEST_READERas the
pin. Five passrequire_marker=True, so an unmarked reader reads as "not attached"
rather than as the board: the destructive80and90, and the reboot pollers14,
51and76, where "is the board back yet?" was answerable by a stranger —51
probesA0 00 00 05 27 47 11 17, Yubico's own management AID, and a real YubiKey
answers it9000. - Test fixed.
31_openpgp_select.pyasserted the OpenPGPVERSION(INS0xF1)
reply was04 06 00and so failed against any default build, which answers with
the device firmware version (05 07 04). The expectation now derives from
FW_VERSIONlike the firmware does, as does10_fido_getinfo.py's
firmwareVersioncheck, which had the default packed in by hand. - Cross-executor
Orderingconsistency: theCANCEL_REQUESTED.store(false, …)
false-clears indisplay/pin.rsanddisplay/presence.rsareRelaxed(no
publication occurs from afalsestore — the publication is the subsequent
true/Releasestore), matching the same false-clears already in
firmware/src/presence.rs. - Test changed.
t9_groups_are_printable_and_have_distinct_charschecked
for duplicate characters within each group (where the old
rename_charset_is_printable_and_cycleschecked the whole charset). The test
now also rejects a character appearing in two groups — a T9 char must belong
to exactly one, elseactive_group/cycle_aton the rename screen is
ambiguous. (const _: () = assert!(T9_GROUPS.len() == 10)pins the
relationshiphit_rename(Char(0..=9)) expects, so dropping a group fails
the build instead of panicking on device atgroups[gi].) - The AA fringe in
aa::filled_circlenow blends to a caller-passedbg
colour instead of the hardcodedtheme::BG— truthful blending against
the surface the circle is drawn over, so a future AA circle on a card or
other non-BGregion won't get a global-background halo. Existing call
sites (boot splash, reset warning, PIN-pad dots, success circle) pass
theme::BG, so the rendered output is byte-equivalent. firmware/build.rsstrips a trailing# ...comment from a TOML value
before parsing it (handling the"-quoted case, since a quoted value may
legitimately contain#). Previouslypin = 13 # GPIO for chip-select
read as13 # …and panickedparse_toml'su32helper; today's four
board configs are clean of inline comments, so this is a trap removed
for future edits, not a current-data fix.firmware/build.rsrenames the board-config display slice fromb2to
disp_cfgand documents thatdisplay_csis the semantic gate pin
(a[display]section withoutcsis dropped, and the knobs fall back
to the Waveshare defaults). It was previously a one-line cleverand_then
with no note explaining why.masked_entry'stotalreverts to the v0.4.4 one-liner
(expected as usize).max(entered).min(ENTRY_MAX_SHOWN)— the UI redesign
rewrote it as anif/elsewith the same result and a cosier comment
("no leftover outlines on delete") describing a change that didn't happen;
the original is shorter and says exactly what it does.
Verify a download: https://github.com/TheMaxMur/RS-Key/blob/v0.4.5/docs/supply-chain.md