Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

RS-Key

An open-source hardware passkey. Flash one file onto a Raspberry Pi RP2350 board and it becomes a USB security key: passkey logins in the browser, ssh and git signing, GPG, PIV, and TOTP codes.

What this isFirmware. A .uf2 file you drop onto a board. Nothing here is for sale.
What you needAny RP2350 board (from about $5) and a USB cable. No soldering, no programmer, no toolchain.
What you getA USB authenticator your browser, ssh, gpg and ykman already know how to talk to.

What RS-Key is: any RP2350 board, plus this firmware, gives you passkey and WebAuthn logins, ssh and git signing, an OpenPGP card for gpg, a PIV smart card, and TOTP codes

This project is experimental. It has had no external security audit. The RP2350 is not a secure element. A stolen board is only as strong as the optional OTP / secure-boot hardening you have applied to it. Do not use it to guard credentials you cannot afford to lose or have stolen. Read the threat model and limitations before trusting it with anything real.

Written in Rust (no_std, embassy). Under the hood it is FIDO2/WebAuthn/U2F, an OpenPGP card, PIV, OATH and Yubico-style OTP, plus the host-side tooling to drive and provision all of it.

Start here

What it is, plainly

  • It aims to behave like a USB security key and to work with the host software people already use: ssh, gpg, browsers, libfido2, and ykman (which needs the opt-in VIDPID=Yubikey5 build, see below). What has actually been checked on hardware is recorded in the interop matrix, with dates.
  • It is not a certified hardware security key, and not a drop-in replacement for an audited commercial key in production. There is no secure element.
  • The default USB identity is RS-Key’s own (VID 0x1209 / PID 0x0001, from pid.codes, the open-source USB VID), presenting as “RS-Key Security Key”. An opt-in VIDPID=Yubikey5 build instead borrows a YubiKey’s identity (VID 0x1050 / PID 0x0407) so that ykman and Yubico Authenticator (which key off the “Yubico YubiKey” reader name) work without custom rules. That flavor is for interop only and is never distributed. See limitations. RS-Key is not affiliated with or endorsed by Yubico, Nitrokey, or Raspberry Pi.

License

AGPL-3.0-only. RS-Key is a from-scratch Rust reimplementation of the AGPL-3.0-only pico-keys firmware family, so it inherits that license and cannot be relicensed. See NOTICE and COMPLIANCE.md.

Quick start

From zero to a working security key in about ten minutes.

This is experimental firmware with no security audit and no secure element. It’s fine for trying things out and for credentials you can afford to lose. See the threat model before using it for anything real.

flowchart TD
    a["download rs-key-&lt;version&gt;-default.uf2<br/>(or build it yourself)"] --> c["hold BOOT, plug in"]
    c --> d["flash: drag-and-drop or picotool load"]
    d --> e["board reboots, enumerates over USB"]
    e --> f["set PIN, enroll a passkey / ssh key"]

What you need

  • An RP2350 board (tested: Waveshare RP2350-One; any RP2350 with USB works)
  • A USB cable

That is the whole list. You only need a toolchain if you want to build the firmware yourself instead of downloading it.

No board yet? The emulator runs the same applet code on your machine — enough to drive the protocol suites, and with --display, to try the trusted screen’s Approve/Deny ceremony with a mouse.

0. Easy way: use the web flasher

Use the RS-Key Web Flasher for a guided browser flow. It can download, verify, sign, and flash the firmware.

Browser requirement: Flashing requires a desktop Chromium-based browser, such as Google Chrome or Microsoft Edge.

1. Get the firmware

Download the newest rs-key-<version>-default.uf2 from the releases page. Take 2mb, 16mb or display instead if that is your board; releases.md has the table of all fourteen images, the cosign signature check, and the reproducibility check.

Every published image is a touch build: FIDO operations (registering, logging in) require a press of the presence button, BOOTSEL by default.

Or build it yourself

You need Nix with flakes enabled; the dev shell provides the toolchain, picotool and the host tools. Without Nix: rustup + rustup target add thumbv8m.main-none-eabihf + picotool ≥ 2.0, and the Python deps from flake.nix.

nix develop                                  # first run downloads the toolchain
cargo build --release -p firmware
scripts/pt.sh target/thumbv8m.main-none-eabihf/release/firmware firmware-pt.elf
picotool uf2 convert firmware-pt.elf -t elf firmware.uf2

pt.sh embeds the partition table that keeps the USB bootloader out of the key’s storage — cargo build cannot, since it is added after linking (build.md). nix build .#firmware does it for you.

Set PRESENCE_PIN=<gpio> for a dedicated presence button instead of BOOTSEL. For a no-touch build (needed by the automated test suites, or if your board is hard to reach) add --features no-touch. All build knobs: build.md.

2. Flash

To flash the firmware manually:

  1. Hold the BOOT button while plugging the board in (or hold BOOT, tap RESET). A mass-storage drive named RP2350 appears.

  2. Flash it, either way (firmware.uf2 here is whichever .uf2 you got in step 1):

    • Drag-and-drop: cp firmware.uf2 /Volumes/RP2350/ (macOS) or copy it to the mounted drive on Linux.
    • picotool (more reliable: it verifies and skips the mass-storage layer): picotool load -v firmware.uf2 && picotool reboot.

    The RP2350 drive is a fake FAT volume the bootrom emulates. It only understands the UF2 blocks written to it, not a real filesystem. On some machines the OS’s mass-storage layer breaks that (macOS resource-fork sidecar files and Spotlight, buffered or reordered writes, the board rebooting the instant the last block lands), so the copy errors or silently does nothing. picotool load speaks the bootrom’s PICOBOOT protocol directly, so reach for it whenever the drive never appears or the copy fails.

  3. The board reboots itself and enumerates as RS-Key Security Key. (The default build uses the project’s own USB identity, VID:PID 0x1209:0x0001 from pid.codes; the PC/SC reader name contains “RS-Key”. For a build that presents the YubiKey USB identity so ykman/Yubico Authenticator auto-recognize it, build the opt-in VIDPID=Yubikey5 flavor; see build.md.)

Check it (optional, needs the host tools from the dev shell or tools/):

rsk status        # FIDO getInfo + secure-boot + backup state, over USB
ykman info        # needs the opt-in VIDPID=Yubikey5 build: YubiKey 5A, firmware 5.7.4, 6 apps

On Linux, the CCID half (OpenPGP/PIV/OATH) needs pcscd + a polkit rule first. See linux.md. FIDO works as soon as the udev rules are in place.

Or from a GUI, with no terminal at all: PicoForge (third-party, from the librekeys project) shows the same state on one screen — identity and firmware build, FIDO2 info, storage use, LED settings, boot mode — with sections for passkeys, PIV, OpenPGP, OTP slots, the audit journal, backup, soft-lock and attestation. It writes the same phy record rsk hw writes, over the interface documented in protocol.md §11. It is not part of this repo and ships on its own schedule.

PicoForge on its Device Overview page, reading a freshly flashed RS-Key: a sidebar of sections (Home, Passkeys, Accounts, Slots, PIV, OpenPGP, Audit, Backup, Lock, Attestation, Configuration, Security, Offboard) and four cards — Device Information (serial number, firmware “RS-Key build 0x0872”, VID:PID 1209:0001, manufacturer and product name “RS-Key Security Key”, storage 2 of 1536 KB, 4 MB flash chip), FIDO2 Information (AAGUID, U2F_V2 and FIDO 2.0 / 2.1 / 2.3, PIN “Not Set”, resident keys supported, minimum PIN length 4, 256 remaining credentials), LED Configuration (GPIO 16, 30 s presence touch timeout) and Security Status (boot mode Development, debug enabled, secure lock pending)

That is a board straight out of step 2, so the screen reads the way yours will: the identity is the default 1209:0001, no PIN is set yet (that is step 3), and the boot mode is still Development. production.md is what turns the last one into a locked-down key — irreversibly, so read it first.

rsk fido set-pin

Without the host tools, your browser does the same job: it offers to set a PIN the first time you register a passkey.

Browsers and ssh-keygen will prompt for it when enrolling. 8 wrong attempts lock the PIN until a reset. Standard security-key behaviour.

4. Enroll something

A passkey: go to any WebAuthn site (or https://webauthn.io to try), register a security key, touch the button when the LED asks.

An SSH key:

ssh-keygen -t ed25519-sk -f ~/.ssh/id_ed25519_sk    # touch twice, enter PIN
ssh-copy-id -i ~/.ssh/id_ed25519_sk you@server
ssh -i ~/.ssh/id_ed25519_sk you@server              # one touch to log in

The id_ed25519_sk file is a handle, not a key. It is useless without the board. Copy it to other machines you ssh from.

macOS note: Apple’s /usr/bin/ssh has no FIDO support. Use Homebrew OpenSSH (brew install openssh, then the absolute path /opt/homebrew/opt/openssh/bin/ssh or put it first in PATH). Details: guides/ssh.md.

5. Back up your identity (optional but wise)

rsk backup export --scheme bip39          # 24 words, write them down
rsk backup finalize                       # seals the export window

The words recover your deterministic FIDO identity (ssh-sk logins, 2FA registrations) onto a fresh board with rsk backup restore. Anyone who has the words can recreate that identity on their own board, so store them like cash. They do not cover resident passkeys, OpenPGP or PIV keys. See guides/seed-backup.md.

Where next

  • Feature guides: OpenPGP with gpg, PIV, OATH codes, OTP slots, soft-lock, LED colors
  • production.md: fuse the master key into OTP + enable secure boot (irreversible, read first)
  • threat-model.md: what this device protects against

Hardware

What RS-Key runs on, and the build knobs you need for a board other than the reference one. The full knob reference is in build.md. This page is the short version.

Supported boards

Waveshare RP2350-One — a blue USB-A stick board Waveshare RP2350-Zero — a small blue USB-C stick board TenStar RP2350-USB — a black USB-A stick board Waveshare RP2350-Touch-LCD-2.8 — a board with a 2.8-inch touch screen
RP2350-One
reference · USB-A
WS2812 on GPIO16
RP2350-Zero
mini stick · USB-C
TenStar RP2350-USB
USB-A stick
WS2812 on GP22
RP2350-Touch-LCD-2.8
trusted display · 2.8″ LCD

Board photos: Waveshare (RP2350-One / Zero / Touch-LCD-2.8).

Any RP2350 board with a USB connector should work. Development and on-device testing happen on the Waveshare RP2350-One, where the WS2812 status LED on GPIO16 works out of the box. Boards without an addressable LED run fine. The indicator is optional and the firmware just runs dark. A board whose LED sits behind a power gate — the Seeed XIAO RP2350 powers its WS2812 through GP23 — lights up once that enable pin is set with LED_POWER_PIN (below).

The RP2350’s dual Cortex-M33, 520 KB SRAM, hardware TRNG, OTP fuses, and glitch detectors do the work. There is no secure element and no debugger requirement: the firmware flashes over USB BOOTSEL, so a bare board and a USB cable are enough.

Defaults and the knobs to change them

The default build targets a 4 MB flash chip with the LED on GPIO16, uses BOOTSEL for user presence, and assumes a standard 12 MHz crystal. For a different board, three compile-time knobs usually cover it:

KnobDefaultWhen to change it
FLASH_SIZE4MA board with a different QSPI flash chip (e.g. 8M). build.rs regenerates memory.x from it. Must be ≤ 16 MB and leave ≥ 1 MB for code after the KV store; a 2 MB board also needs a smaller KVMAIN (below).
KVMAIN1408KA 2 MB board (Seeed XIAO RP2350, Waveshare RP2350-Zero-CM): the default 1408K KV main partition leaves too little for the ~900K image. Shrink it — FLASH_SIZE=2M KVMAIN=896K — to fit. A fully provisioned key uses only a few hundred KB. See build.md.
LED_PIN16A board that uses GPIO16 for something else, or wires its addressable LED elsewhere (RP2350A: GPIO 0..=29).
LED_POWER_PINnoneA board whose LED sits behind a power gate that must be driven high to light it (e.g. the Seeed XIAO RP2350’s WS2812 on GP23). Set the enable GPIO; it must differ from LED_PIN and any GPIO PRESENCE_PIN.
USR_LED_PINnoneA board with a nuisance onboard user LED that lights by default (the Seeed XIAO RP2350’s active-low USR LED on GP25). Set the GPIO to park it off at boot; flip USR_LED_ACTIVE_HIGH=1 for an active-high LED. Must differ from LED_PIN, LED_POWER_PIN, and any GPIO PRESENCE_PIN.
PRESENCE_PINbootselA board with a dedicated user-presence button on a GPIO. Set a pin number (0..=29); active-low with a pull-up by default (e.g. 0 for GPIO0-to-GND).
PRESENCE_ACTIVE_HIGH0A presence button/sensor that reads high when pressed (a capacitive touch sensor, or a button to VCC). 1 flips the GPIO to pull-down + active-high. Only with a GPIO PRESENCE_PIN.
LED_KINDws2812ws2812 (addressable RGB, default), gpio (plain on/off), pimoroni (3-pin PWM RGB), or none (no indicator). See build.md.
LED_ORDERrgbA ws2812 board whose red and green come out swapped (blue fine): set grb (the WS2812B standard). The Waveshare RP2350-One is rgb; most other parts are grb.
MAX_LEDS8A board with more than 8 daisy-chained addressable LEDs. The buffer ceiling; the actual connected count is set at runtime (guides/led.md).
# example: an 8 MB board with a plain LED on GPIO25
env FLASH_SIZE=8M LED_KIND=gpio LED_PIN=25 cargo build --release -p firmware

# example: a 16 MB TenStar RP2350-USB — WS2812 on GP22, standard GRB order
env FLASH_SIZE=16M LED_PIN=22 LED_ORDER=grb cargo build --release -p firmware

# example: a Seeed XIAO RP2350 — WS2812 on GP22, GRB, power-gated by GP23 (driven high),
# its active-low USR LED on GP25 parked off, and its 2 MB flash (smaller KVMAIN)
env FLASH_SIZE=2M KVMAIN=896K LED_PIN=22 LED_ORDER=grb LED_POWER_PIN=23 USR_LED_PIN=25 cargo build --release -p firmware

# example: WS2812 on GP22 and a button-to-GND on GP0 (active-low)
env LED_PIN=22 PRESENCE_PIN=0 cargo build --release -p firmware

# example: an active-high capacitive touch sensor on GP0
env PRESENCE_PIN=0 PRESENCE_ACTIVE_HIGH=1 cargo build --release -p firmware

The four LED knobs (LED_PIN / LED_KIND / LED_ORDER / MAX_LEDS) set only the boot defaults. A non-none build compiles all three backends, so the pin, driver, wire order, and buffer ceiling are also changeable at runtime (no reflash) with rsk hw or PicoForge, which write them to the device’s phy record (guides/led.md). The build knobs still matter for picking a lean none build and for the out-of-the-box default.

Most RP2350A boards work with at most a one-line change. Everything else (USB descriptors, applets, flash layout) is board-independent.

Enclosures

A bare board works fine, but a printed case makes it pocketable. Four community designs fit the boards above:

The two Printables designs as well as the Thingiverse design are licensed CC BY-SA 4.0: print, sell, and remix them freely, as long as you credit the authors and keep any derivative under the same license. Check each MakerWorld page for the design license before you reuse the design. All are third-party designs, linked for convenience, not part of this project.

What the hardware does not give you

The OTP fuses and secure boot (production.md) are real hardening, but the RP2350 is a general-purpose microcontroller, not a certified secure element. Physical attacks are out of scope: decapping, microprobing, fault injection beyond the on-chip glitch detectors, power/EM side channels. See the threat model and limitations.

Build options

Every knob is compile-time. Set environment variables and cargo features at cargo build and they bake into the image. Nothing here changes at runtime (except where noted for the phy record).

# the general shape
nix develop -c env KNOB=value cargo build --release -p firmware [--features ...]
nix develop -c scripts/pt.sh target/thumbv8m.main-none-eabihf/release/firmware firmware-pt.elf
picotool uf2 convert firmware-pt.elf -t elf firmware.uf2

The middle step embeds the partition table; skip it and the key ships with its storage exposed to the bootloader. nix build .#firmware folds all three together.

flowchart TD
    knobs["env knobs + cargo features"] --> build["cargo build (or nix build)"]
    build --> elf["firmware.elf"]
    elf --> pt["scripts/pt.sh<br/>embed partition table"]
    pt --> conv["picotool uf2 convert"]
    conv --> uf2["firmware.uf2"]
    uf2 --> flash["BOOTSEL flash"]
    pt -. "secure boot only — seal the ELF" .-> seal["picotool seal --sign<br/>signing key (host-only)"]
    seal -.-> sconv["picotool uf2 convert"]
    sconv -.-> signed["firmware-signed.uf2"]
    signed -.-> flash

Cargo features

FeatureDefaultEffect
no-touchoffAuto-confirm user presence. FIDO operations (makeCredential, getAssertion, U2F, reset, selection) and OpenPGP UIF data objects no longer require a press of the presence button (BOOTSEL by default, or PRESENCE_PIN when set). Requiring a touch is the default and is not a feature; --features no-touch is the explicit escape hatch for the automated suites (tests/, python-fido2, OpenPGP card tests), which cannot press a button and would hang on the (default) touch build. Never ship a no-touch build.
advertise-pqcoffPrepends ML-DSA-87 (COSE −50), ML-DSA-65 (−49) and ML-DSA-44 (−48) to the getInfo algorithms list, in descending security order. Off by default because released Firefox versions abort the entire getInfo parse on an unknown COSE id and report the authenticator broken. PQC capability is on regardless of this flag. makeCredential negotiates −49 / −48 from the request’s pubKeyCredParams; the flag only controls advertising.
fips-profileoffBakes a locked FIPS-style policy into the image: ES256K (secp256k1) leaves the FIDO menu, the minimum PIN rises to 6 and trivially guessable PINs are refused (the strong-pin policy below), the vendor seed export is refused, and PIV refuses new 3DES management keys and RSA-1024. The default build is unchanged; with secure boot the policy is sealed by your signature. A profile, not a FIPS validation. Details and rationale: guides/fips.md.
strong-pinoffRaises the FIDO clientPIN minimum to 6 code points (from CTAP’s default 4) and refuses the most guessable PINs: a repeated period (000000, 121212, 123123), a ±1 run (123456, 654321), two or fewer distinct code points (112211 — two smudges on the glass is a two-symbol search space at any length), and ten denylisted keypad shapes (159753, 123321, …). Every rule counts code points, not bytes, matching the floor beside it. A narrow PIN-hardening knob: unlike fips-profile (which bundles this same PIN policy with dropping secp256k1, refusing the seed export, and constraining PIV), it changes only the PIN policy. Off by default; the default build keeps the CTAP-standard 4-code-point floor. Rationale: on the RP2350 a BOOTSEL flash snapshot/restore rolls back the wrong-PIN counter (#37), so a longer, non-trivial PIN is the practical bound on offline brute force. Enforced on the host setPIN/changePIN path and the trusted-display PIN pad.
strict-upoffRequire a touch on every getAssertion. By default RS-Key honors the platform’s silent pre-flight probe (up:false): it returns the discovery assertion with no touch and the UP flag clear, so a WebAuthn login with an allowCredentials (non-resident) credential is a single touch, matching the CTAP2 spec and YubiKey. With strict-up the button is polled even for that probe, so such a login asks for two touches (one for the probe, one for the real assertion). A deliberately stricter “every assertion needs an explicit gesture” stance for those who want it; it is not spec-conformant for up:false. Resident-credential / passkey logins are a single touch either way. fido-conformance enables this implicitly (the conformance pass was validated with it).
always-uvoffBake the CTAP 2.1 alwaysUv option on by default, so the key requires user verification (a PIN — or the display build’s built-in UV pad) for every makeCredential / getAssertion straight out of the box, with no post-flash ykman fido config toggle-always-uv. The shipped image leaves alwaysUv off until a platform turns it on; this flag flips the default. It stays runtime-toggleable — toggleAlwaysUv writes an override that survives reboots but not an authenticatorReset, which restores this compiled default. Set a PIN after flashing: with alwaysUv on and no PIN, FIDO operations return CTAP2_ERR_PUAT_REQUIRED until one is set — the standard cue for the platform (Windows, Chrome) to prompt for a PIN. While alwaysUv is on the CTAP1/U2F interface is disabled (CTAP 2.1 §7.2.4, as on a YubiKey): U2F only proves presence, never verification, so legacy U2F-only logins stop working, and getInfo drops U2F_V2 from its advertised versions; WebAuthn / CTAP2 (passkeys) are unaffected. The one exception §7.2.4 allows is a build “protected by a built-in user verification method”: on a display build with a PIN set, U2F stays available and every register / authenticate collects the PIN on the panel instead of a touch.
largeblob-extoffServe the CTAP 2.3 §12.4 largeBlob extension instead of the CTAP 2.1 large-blob design. Not additive, and not a choice the firmware could dodge: §12.4 says “Authenticators MUST NOT support both extensions”, so enabling it withdraws the largeBlobKey extension, the authenticatorLargeBlobs command (0x0C, which then answers CTAP1_ERR_INVALID_COMMAND), the largeBlobs option and maxSerializedLargeBlobArray. Blobs then live per credential (discoverable credentials only) instead of in one platform-managed array, and a whole blob rides in the getAssertion that reads or writes it — up to 4046 bytes, sealed at rest under the device seed with the credential id as AAD, which the 2.1 design does not need because the platform encrypts that array itself. Off by default, and think before turning it on: the 2.1 pair is what every shipping browser drives today, so a largeblob-ext key loses WebAuthn largeBlob support with current clients.
displayoffExperimental trusted-display build for a screen + touch board (Waveshare RP2350-Touch-LCD-2.8). Adds an on-screen Approve / Deny that paints the real relying party for every touch (Deny refuses with OPERATION_DENIED), an on-device PIN pad (built-in user verification (options.uv), plus a CCID pinpad so GnuPG / OpenSC collect the OpenPGP / PIV PIN on the panel, never over USB), a read-only Apps browser (OpenPGP / PIV / OATH state, no PIN), a Passkeys manager (delete / rename on-device), and Settings (device & FIDO PINs, an optional scrambled PIN pad, on-screen BIP-39 / SLIP-39 recovery export, audit log, factory reset). Entirely dep:-gated. A standard key compiles none of it, so its image is unaffected. Build it LED_KIND=none FLASH_SIZE=16M … --features display (the panel takes GPIO16 for its backlight; a compile-time guard enforces LED_KIND=none). Full walkthrough with screenshots: guides/display.md.
strict-configoffRestores the historical strict admin-write authorization. The DEFAULT build is now the permissive, full-ykman/YubiKey-compatible admin surface: device-config writes (CCID Management WRITE CONFIG, FIDO vendor CONFIG_WRITE, the CTAPHID 0x43 and OTP-HID 0x15 transport writes) are ungated, and Management RESET (device-wide factory reset) plus the OTP-HID DEVICE_CONFIG / SCAN_MAP / NDEF slots are served. --features strict-config re-imposes the presence/PIN gates and refuses the ungated transport writes — the historical shipped behavior. This deliberately weakens the DEFAULT threat model: any USB host can rewrite the reported identity with no operator confirmation (threat-model.md). Not the runtime flash flag EF_HARDENED. Ship it as firmware-strict-config.

Environment variables

VariableDefaultValuesEffect
BOARDnonea file name under firmware/boards/ (abrobot-4m, abrobot-16m, waveshare-one, waveshare-touch-lcd, tenstar-usb, seeed-xiao)Load that board’s knob set instead of setting the LED / presence / flash / display variables one by one. Every variable below still wins when you set it explicitly, so a board file is a default set, not a lock. It selects no cargo features — a display board still needs --features display. Each shipped board file is smoke-built in CI.
VIDPIDRSKeyRSKey, Yubikey5, YubikeyNeo, YubiHSM, NitroHSM, NitroFIDO2, NitroStart, NitroPro, Nitro3, Gnuk, GnuPG, Pico, DevUSB VID/PID preset. The default RSKey (0x1209:0x0001) is this project’s own pid.codes identity, not a masquerade. The opt-in Yubikey5 (0x1050:0x0407) instead presents Yubico’s VID/PID and swaps the descriptor strings to Yubico / YubiKey RSK …. That is what makes ykman, Yubico Authenticator and the stock Yubico udev rules recognize the device; build it only for local interop / the interop suite. Pico = the Raspberry Pi generic id (0x2E8A:0x10FD); Dev = a non-colliding placeholder (0xFEFF:0xFCFD). An unknown preset fails the build. The vendor-mimicking presets are for local interop only. Never distribute hardware carrying them.
USB_VID / USB_PIDfrom preset0xHHHHRaw override, applied on top of the preset (you can override either half alone).
USB_MANUFACTURER / USB_PRODUCTfrom presetstringRaw override of the USB descriptor strings. The default is RS-Key / RS-Key Security Key; the Yubico VID instead bakes Yubico / YubiKey RSK OTP+FIDO+CCID. The project’s own tools (rsk, rsk-tui) match the reader by the RS-Key (or RSK) token in the product string.
FW_VERSION5.7.4X.Y.Z or X.YThe firmware version reported everywhere a tool looks: management DeviceInfo (ykman info), FIDO getInfo, CTAPHID INIT, OATH/OTP/PIV version fields. Yubico tools gate features on it; 5.7.4 mimics a current YubiKey 5. Does not change the OpenPGP card version (3.4) or the USB bcdDevice (an internal build counter).
XOSC_DELAY_MULT1281..=1024Crystal-oscillator startup-delay multiplier (“delayed boot”). A longer settle wait is intended to harden the early-boot clock-switch window against glitch/fault injection. 128 is the embassy default.
FLASH_SIZE4Mbytes, 0xHEX, or <n>K/<n>MExternal QSPI flash size. build.rs regenerates memory.x from it. The KV store (KVMAIN + KVCNT) stays pinned at the top and the code region is the rest; 4M with the default KVMAIN reproduces the checked-in layout byte-for-byte. Use this for boards with a different flash chip (e.g. 8M); must be ≤ 16 MB and leave ≥ 1 MB for code after the KV store (a 2 MB board needs a smaller KVMAIN, below).
KVMAIN1408Kbytes, 0xHEX, or <n>K/<n>MSize of the KV main partition (credentials, keys, OpenPGP DOs). The default 1408K is the checked-in layout; shrink it to free code space on a small flash. A 2 MB board (Seeed XIAO RP2350, Waveshare RP2350-Zero-CM) can’t fit the firmware (~900K) under a 1408K KVMAIN, so build it FLASH_SIZE=2M KVMAIN=896K (896K creds + 128K counters + 1024K code). Sector-aligned, min 128K; the counter partition (KVCNT, 128K) is fixed. Baked into both memory.x and flash_storage.rs from one value, so the two never drift. Set at build time only — changing it on a provisioned device shifts the partition offsets and orphans the store.
LED_PIN160..=29The status-LED GPIO for the ws2812 and gpio backends (RP2350A). Default GPIO16 is the Waveshare RP2350-One. Point it at a free GPIO on boards that use 16 for something else; the indicator simply drives whatever pin you pick. (Unused by pimoroni, which has fixed PWM pins, and by none.)
LED_POWER_PINnonenone or 0..=29An optional GPIO driven high at boot to power a gated LED rail — some boards put the addressable LED behind a load switch. The Seeed XIAO RP2350 is the case in point: its onboard WS2812 data is on GP22 but its power sits behind GP23, so the LED stays dark until GP23 is high (LED_PIN=22 LED_ORDER=grb LED_POWER_PIN=23). Held for the device’s lifetime; must differ from LED_PIN and a GPIO PRESENCE_PIN (rejected at compile time). Boot-only, so it is not in the runtime phy record. Ignored by LED_KIND=none.
USR_LED_PINnonenone or 0..=29An optional GPIO wired to a nuisance onboard user/status LED that the firmware drives to its OFF level at boot and holds. The Seeed XIAO RP2350’s USR LED sits on GP25, is active-low, and comes up lit (weak pull-down); USR_LED_PIN=25 parks it high so it stays dark. Independent of the addressable status LED, so it works on a LED_KIND=none build too. Held for the device’s lifetime; must differ from LED_PIN, LED_POWER_PIN, and a GPIO PRESENCE_PIN (rejected at compile time), and is unsupported on a display build (the panel replaces the onboard LED). Boot-only, not in the runtime phy record.
USR_LED_ACTIVE_HIGH00 / 1Polarity of the USR_LED_PIN LED. 0 (default) = active-low (lit when the pin is low, so OFF = drive high — the XIAO USR LED). 1 = active-high (lit when high, so OFF = drive low). Ignored without a USR_LED_PIN.
PRESENCE_PINbootselbootsel or 0..=29User-presence input source. Default bootsel keeps the BOOTSEL hardware-button path. Set a GPIO number for a dedicated button (active-low with an internal pull-up by default; flip with PRESENCE_ACTIVE_HIGH). Example: PRESENCE_PIN=0 for a button to ground on GPIO0.
PRESENCE_ACTIVE_HIGH00 / 1GPIO presence-button polarity, only meaningful with a GPIO PRESENCE_PIN. 0 (default) = active-low: button to ground, internal pull-up, a press reads low. 1 = active-high: internal pull-down, a press reads high, for a capacitive touch sensor or a button to VCC. Ignored for the BOOTSEL default.
WAKE_PIN25none or 0..=29display builds only. The button that wakes the panel from display sleep. Default 25 is the Waveshare RP2350-Touch-LCD-2.8’s BAT_PWR button. none makes wake touch-only (no button); any other GPIO selects a different button. A value in the LCD/touch range (10..=18) is rejected at compile time. The display-sleep timeout itself is set on-device (Settings → Display sleep).
WAKE_ACTIVE_HIGH00 / 1Wake-button polarity (display builds, with a GPIO WAKE_PIN). 0 (default) = active-low (internal pull-up, press reads low, e.g. BAT_PWR to ground); 1 = active-high (internal pull-down).
LED_KINDws2812ws2812 / gpio / pimoroni / noneThe LED driver backend, and the boot default. A non-none build compiles all three so the driver/pin/order are runtime-switchable via rsk hw / PicoForge (see below). ws2812 = a single addressable RGB on LED_PIN (the Waveshare default). gpio = a plain on/off LED on LED_PIN. Hue/brightness collapse to lit/unlit, but the blink pattern still distinguishes statuses. pimoroni = a 3-pin PWM RGB (Pimoroni Tiny 2350: R=GPIO18, G=GPIO19, B=GPIO20, common-anode). none = no indicator (the status engine still runs; nothing renders it, and the phy LED fields are ignored).
LED_ORDERrgbrgb / grbWS2812 wire byte order (ws2812 backend only). The Waveshare RP2350-One is unusually rgb (the default); standard WS2812B parts (e.g. the TenStar RP2350-USB) are grb. If a ws2812 board comes up with red and green swapped (blue fine), flip this to grb.
MAX_LEDS1164PIO state-machine and frame-buffer ceiling for addressable LEDs. The actual connected count is set at runtime via rsk hw --led-num and must be ≤ MAX_LEDS. Default 1 is a single onboard LED; a board with a chain of N builds with MAX_LEDS=N (up to 64).
FAKE_MKEK / FAKE_DEVKunset64 hex charsTest builds only. Bakes a fake OTP master key / device key into the image instead of reading the OTP fuses, so the whole OTP migration path can be exercised with zero fuse writes. The build prints a loud warning and the key is greppable in the binary. Flashing a FAKE build onto a provisioned device migrates its data under the fake key. Going back orphans that data (recovery = per-applet resets). Never flash one on a device you care about.

The LED_PIN / LED_KIND / LED_ORDER / MAX_LEDS values are boot defaults only. A non-none build compiles all three backends, so the LED pin, driver, and wire order are runtime-configurable (no reflash) with rsk hw (or PicoForge), which write them to the device’s phy record. The build knobs decide the out-of-the-box behaviour and let you drop to a lean none build. Everything else is a rsk hw call away (guides/led.md).

Verify what got baked without flashing:

rg PK_USB_VID  target/thumbv8m.main-none-eabihf/release/build/firmware-*/output   # decimal: 4617 = 0x1209
rg PK_FW_VERSION target/thumbv8m.main-none-eabihf/release/build/rsk-sdk-*/output
rg PK_XOSC_DELAY_MULT target/thumbv8m.main-none-eabihf/release/build/firmware-*/output

The firmware-* glob matches one build dir per feature combination you have built, so a stale entry can show an old value. Read the freshest one (or cargo clean -p firmware first) if the output looks doubled.

Flash size and the memory map

FLASH_SIZE regenerates memory.x: the KV store stays pinned to the top of flash and only the code region grows. A 16 MB board just gets more (unused) code headroom. The credential capacity is unchanged (why the flash is mostly empty by design: architecture.md).

One exception, and only at 16 MB: the store stops a sector short of the top, because the bootrom’s RP2350-E10 workaround owns the last 256 bytes of the XIP window (0x10FFFF00) and picotool partition create refuses a table that claims them — so a 16 MB image could not carry the fence at all. The reserved 4 KB is outside every partition, which is where the bootrom needs it.

The KV store is 1.5 MB by default (KVMAIN 1408K + KVCNT 128K). On a 2 MB board that leaves too little for the ~900K image, so the firmware can’t link. KVMAIN shrinks the main partition to make room: build a 2 MB Seeed XIAO RP2350 or Waveshare RP2350-Zero-CM with FLASH_SIZE=2M KVMAIN=896K (896K creds + 128K counters + 1024K code). build.rs bakes the size into both memory.x and flash_storage.rs, so the two partitions never disagree, and it rejects a split that leaves under 1 MB for code with a message that names the fix. A fully provisioned key needs only a few hundred KB, so 896K is ample.

4 MB vs 16 MB flash layout: the KV store is identical on both; only the code region and the KV origin move with FLASH_SIZE

At the 4M default the code region is 2560K; the shipping image uses roughly a third of it. check.sh enforces a ratchet well under that: a ceiling that hugs the current image, so a runaway dependency (a whole extra EC curve is ~150 KiB) or any surprise growth trips it long before the linker’s hard limit. Lower FIRMWARE_FLASH_BUDGET_KIB when the image shrinks; raise it in the same commit when a real feature legitimately grows it.

RAM has the mirror-image ratchet, FIRMWARE_STACK_FLOOR_KIB. Whatever .data and .bss do not take is stack, so a new static costs stack depth — and the deepest paths on this chip (ML-DSA-65 keygen, the RSA modexp chain) run close enough to the ceiling that it has already wedged a device. The gate reads the figure straight out of the ELF and fails under the floor. The images link through flip-link, which places the stack below the statics, so an overflow faults on unmapped memory instead of quietly overwriting them; that needs rust-lld on PATH, which the dev shell arranges, so build inside nix develop.

The partition table

The shipped image carries an RP2350 partition table that fences the KV store off from the USB bootloader: picotool save and picotool load over the store answer permission failure, while the running firmware keeps full access to it. It is a defence-in-depth layer against the BOOTSEL snapshot/restore rollback, and it is a real barrier only where secure boot is on — read threat-model.md before quoting it as protection.

scripts/pt.sh reads the fence out of the built ELF’s own __kvmain_start / __kvcnt_end symbols — the same ones flash_storage.rs uses to find the store — so it follows FLASH_SIZE / KVMAIN / BOARD with no second copy of the layout to keep in step. check.sh asserts the emitted table back against those symbols, because a table that misses the store still links, still boots, and then loses the firmware’s writes at runtime.

⚠️ A bare cargo build does not produce it. The table is added after linking, so target/…/release/firmware carries none and a key flashed straight from that ELF is unfenced. nix build .#firmware applies it; for a local cargo build, apply it yourself:

cargo build --release -p firmware
scripts/pt.sh target/thumbv8m.main-none-eabihf/release/firmware firmware-pt.elf
picotool load firmware-pt.elf -t elf

Examples

# default: touch build, RS-Key identity (0x1209:0x0001), fw 5.7.4
cargo build --release -p firmware

# opt-in Yubico interop flavor (so ykman / Yubico Authenticator see the device)
env VIDPID=Yubikey5 cargo build --release -p firmware

# no-touch test build (for the automated suites)
cargo build --release -p firmware --features no-touch

# Nitrokey FIDO2 identity with its own version number
env VIDPID=NitroFIDO2 FW_VERSION=1.4.0 cargo build --release -p firmware

# advertise PQC in getInfo (breaks released Firefox — see above)
cargo build --release -p firmware --features advertise-pqc

# ship with CTAP 2.1 alwaysUv enabled by default (set a PIN after flashing)
cargo build --release -p firmware --features always-uv

# strict admin-write posture (the historical default; config writes stay gated)
cargo build --release -p firmware --features strict-config

nix build (hermetic, no dev shell)

The flake exposes the firmware as a package, so you can build a UF2 without entering the dev shell or having a Rust toolchain installed. Nix pins the toolchain, the cross target, and every dependency:

nix build .#firmware                 # default touch image
ls result/                           # firmware.elf  firmware.uf2

result/firmware.uf2 is functionally the image the dev-shell cargo build produces. Unlike the dev-shell build, it is bit-for-bit reproducible: the derivation remaps the two absolute build inputs out of the binary (the per-build sandbox dir and the toolchain store path; both land in panic-location strings in .rodata, plus DWARF in the .elf) with stable --remap-path-prefix, so one flake.lock yields one firmware.uf2 on every machine of a platform. The daily repro job in deep-checks proves it (nix build twice, the second with --rebuild so nix compares every output byte) and publishes the canonical sha256 in its run summary.

To verify a published image: nix build .#firmware at the release commit and compare hashes. A sealed image can’t be reproduced by a third party (the signature is the signer’s); verify the unsigned payload instead, then check the seal with picotool. The flavors mirror the CI matrix:

AttributeImage
.#firmware (default)touch build, RS-Key identity (0x1209:0x0001), fw 5.7.4
.#firmware-no-touch--features no-touch (the test build)
.#firmware-fips--features fips-profile
.#firmware-pqc--features advertise-pqc
.#firmware-strong-pin--features strong-pin (6-code-point PIN floor + trivial-PIN block; also .#firmware-strong-pin-pqc)
.#firmware-always-uv--features always-uv (CTAP 2.1 alwaysUv on by default; also .#firmware-always-uv-pqc)
.#firmware-strict-up--features strict-up (touch on every assertion — not spec-conformant for up:false; also .#firmware-strict-up-pqc)
.#firmware-display--features display, FLASH_SIZE=16M, LED_KIND=none (experimental, Waveshare RP2350-Touch-LCD-2.8)
.#firmware-2mbdefault features + RS-Key identity, FLASH_SIZE=2M, KVMAIN=896K (2 MB boards: Seeed XIAO RP2350, Waveshare RP2350-Zero-CM)
.#firmware-16mbdefault features + RS-Key identity, FLASH_SIZE=16M (16 MB boards, e.g. TenStar RP2350-USB)
.#firmware-strict-config--features strict-config (historical strict admin-write posture; the default firmware is now the permissive full-ykman admin surface)

Two caveats:

  • The output is UNSIGNED. On a secure-boot device you still seal it with your key. The signing key deliberately never enters the build sandbox:

    # the ELF, not the UF2: sealing a UF2 leaves the image's own unsigned
    # IMAGE_DEF first in the chain, and a secure-boot device refuses that
    picotool seal --sign --hash result/firmware.elf -t elf firmware-signed.elf -t elf \
        ~/.rs-key-secrets/secure_boot_key.pem ~/.rs-key-secrets/otp_secureboot.json \
        --major 1 --minor 0 --rollback 1
    picotool uf2 convert firmware-signed.elf -t elf firmware-signed.uf2
    

    The .pem is your signing key, the .json is where seal writes the boot-key fingerprint, and --major/--minor stamp an image version into the boot metadata: a plain major.minor label, separate from both the firmware version RS-Key reports (5.7.x) and the rollback version. The full meaning of each flag is in production.md.

    If you have enabled anti-rollback, the seal additionally needs --rollback <your board's floor>, a separate, deliberate step with its own rules and a finite OTP budget. Don’t add it blindly; the full flashing-with-rollback workflow is in anti-rollback.md.

  • The env knobs above are declarative Nix args, not ambient env. A plain nix build bakes the defaults; to customize, pass them to the builder. For a config you reuse, add a one-line preset package (the flake ships firmware-pico = mkFirmware { name = "firmware-pico"; vidpid = "Pico"; } as a copy-me example) and build it:

    nix build .#firmware-pico
    

    For a one-off without committing a package, call the exposed builder. (The --impure here only lets getFlake read the working tree; the knobs themselves are pure. A committed/pushed flakeref needs no flag.)

    nix build --impure --expr \
      '(builtins.getFlake (toString ./.)).lib.${builtins.currentSystem}.mkFirmware
         { name = "fw"; vidpid = "Nitro3"; fwVersion = "2.0.0"; }'
    

    Knobs: vidpid, usbVid, usbPid, fwVersion, xoscDelayMult, flashSize, ledPin, presencePin, fakeMkek, fakeDevk (mirroring the env vars above). As a convenience each also falls back to the like-named env var, so VIDPID=Pico nix build --impure .#firmware works for a quick throwaway, but the declarative arg is the reproducible path and needs no --impure.

nix run — host tools without the dev shell

The host tooling is also exposed as flake apps, so it runs straight from the flake without nix develop (Nix pins every dependency):

nix run .#rsk -- status          # the Python device CLI (rsk --help for groups)
nix run .#rsk-tui                # the live ratatui dashboard (prebuilt binary)
nix run .#flash -- --help        # build + sign + flash, one command (secure boot)

#rsk wraps the bundled tools/rsk package on the pinned interpreter; #rsk-tui is a prebuilt host binary (no compile-on-run). Both are also buildable as packages (nix build .#rsk-tui).

nix run .#flash wraps the secure-boot flash ritual end to end: it seals (signs) an unsigned image, reboots the device into BOOTSEL, loads it, then reboots. With no argument it seals the reproducible default firmware (.#firmware); pass a path to seal a flavor you built yourself (nix run .#flash -- firmware-no-touch.uf2). It reads the signing key from the host (~/.rs-key-secrets/{secure_boot_key.pem,otp_secureboot.json} by default, override the directory with RS_KEY_SECRETS) and stamps --rollback 1 into the seal (set RSK_ROLLBACK to change). It prompts before flashing (-y skips). The device must already run secure boot with the matching boot key provisioned; the full ritual, the anti-rollback rules, and recovery are in production.md and anti-rollback.md.

Runtime overrides (phy record)

The rescue applet can store a small config record in flash (rsk / rsk-tui expose the safe fields). At boot, a stored VID/PID and product string override the compile-time defaults, useful to re-identify a device without rebuilding. A bad value can make the device enumerate strangely; recovery is a BOOTSEL reflash (which never reads the record) or rewriting the record over CCID.

The effective identity is resolved in this order:

flowchart TD
    a["VIDPID preset"] --> b["USB_VID / USB_PID raw override (compile time)"]
    b --> c["phy record (runtime, at boot)"]
    c --> d["effective VID/PID + product string"]

Notes

  • The PC/SC reader name comes from the USB strings. The default build reads RS-Key RS-Key Security Key …, and the project’s own tools (rsk, rsk-tui) match the RS-Key token. ykman and Yubico Authenticator derive the device’s PID purely from that name. They need the Yubico YubiKey words and the OTP/FIDO/CCID tokens, which only the opt-in VIDPID=Yubikey5 flavor supplies (Yubico YubiKey RSK OTP+FIDO+CCID); on the default build those tools do not see the device. gpg, ssh -sk, browsers, libfido2 and OpenSC are identity-independent and work on either build.
  • bcdDevice (USB device release) is an internal build counter, not the firmware version.
  • The two UF2 flavors on a release build of this repo: firmware.uf2 (touch) and firmware-test.uf2 (no-touch). scripts/check.sh builds both.

Releases & verification

Releases live on the GitHub Releases page. Each is cut from a v* git tag by the release workflow. It builds every artifact reproducibly, hashes it, and signs the manifest.

What a release contains

  • Fourteen firmware images: rs-key-<tag>-<flavor>.uf2. Every published image requires a physical touch; the no-touch test builds are never released (a signed presence-bypass asset would remove the consent gate):

    flavorflagsuse
    defaulttouchthe normal build; start here
    pqc+ advertise-pqcadvertises all three ML-DSA sets in getInfo (breaks old Firefox)
    fips+ fips-profilethe locked FIPS-style policy (guides/fips.md)
    fips-pqc+ both
    strong-pin+ strong-pin6-code-point PIN floor + trivial-PIN block (build.md, threat-model.md)
    strong-pin-pqc+ both
    always-uv+ always-uvbakes CTAP 2.1 alwaysUv on: user verification (a PIN) for every operation, U2F disabled. Set a PIN after flashing (build.md)
    always-uv-pqc+ both
    strict-up+ strict-upnot spec-conformant: a touch on every assertion, so a WebAuthn allowCredentials login asks for two touches (build.md). Pick it only if you want that stricter stance
    strict-up-pqc+ both
    display+ displayexperimental trusted-display build (Waveshare RP2350-Touch-LCD-2.8, guides/display.md)
    2mbFLASH_SIZE=2M KVMAIN=896K2 MB boards (Seeed XIAO RP2350, Waveshare RP2350-Zero-CM)
    16mbFLASH_SIZE=16M16 MB boards (e.g. TenStar RP2350-USB)
    strict-config+ strict-configthe historical strict admin-write posture: config writes stay presence/PIN-gated and the ungated transport writes are refused (build.md, threat-model.md). The default build is now the permissive full-ykman admin surface

    All fourteen present the default RS-Key USB identity (0x1209:0x0001). For the YubiKey-interop identity, build VIDPID=Yubikey5 yourself (build.md).

  • SHA256SUMS: a checksum for every image and the SBOM.

  • SHA256SUMS.sigstore.json: a keyless cosign signature of SHA256SUMS (sigstore/Fulcio; the signer is the reusable build workflow’s GitHub OIDC identity, release-build.yml, see the verify step below; logged in Rekor). Releases up to and including v0.4.10 carry the same file under the older name SHA256SUMS.cosign.bundle — identical bytes, and .sigstore.json is simply the canonical extension for what it always was (application/vnd.dev.sigstore.bundle.v0.3+json). Those releases are immutable and cannot be renamed in place, so both names exist in the wild.

  • rs-key-<tag>.intoto.jsonl: the build-provenance attestation, as a file. The authoritative copy stays in GitHub’s attestation API and in Rekor; this one is for checking a download offline.

  • rs-key-<tag>-sbom.cdx.json: a CycloneDX software bill of materials for the firmware’s dependency tree.

The images are UNSIGNED for secure boot. The cosign signature attests who built them, not the boot seal. On a secure-boot device you seal an image with your own key before flashing. nix run .#flash does it, or see production.md. The reproducibility claim is about the unsigned payload (a seal is signer-specific and not reproducible by a third party).

Verify a download

Grab the images you want plus SHA256SUMS and the signature — SHA256SUMS.sigstore.json, or SHA256SUMS.cosign.bundle on releases up to v0.4.10. Same bytes either way; substitute the name you actually downloaded.

# 1. the checksums file is authentic (keyless cosign — needs cosign >= 2.0)
#    The signer is the *reusable* build workflow (release-build.yml), not the
#    thin release.yml caller: a workflow_call job's OIDC identity is its own
#    job_workflow_ref, so that is what the Fulcio cert's SAN carries.
cosign verify-blob \
  --bundle SHA256SUMS.sigstore.json \
  --certificate-identity-regexp '^https://github\.com/TheMaxMur/RS-Key/\.github/workflows/release-build\.yml@refs/tags/v.*' \
  --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
  SHA256SUMS

# 2. the images match the (now-trusted) checksums
sha256sum -c SHA256SUMS

Both must pass. Step 1 proves SHA256SUMS was produced by this repo’s release workflow; step 2 ties each .uf2 (and the SBOM) to it.

Verify the build is reproducible

The images are bit-for-bit reproducible per platform, per flake.lock, so you can rebuild them yourself and compare (no need to trust the published binary):

git checkout <tag>
nix build .#firmware              # the default flavor (others: .#firmware-fips, …)
sha256sum result/firmware.uf2     # compare against SHA256SUMS for rs-key-<tag>-default.uf2

A match on Linux reproduces the CI-built artifact exactly. (Cross-platform identity, macOS vs Linux, is not guaranteed; the canonical bytes are the Linux ones the workflow publishes.)

Supply chain

How a downloaded RS-Key release proves it came from this source tree, was built by this project’s CI, and pulls in only reviewed dependencies. And how you verify each claim yourself.

No private keys are involved here. The build provenance is keyless (sigstore/Fulcio, signed against this workflow’s GitHub OIDC identity, recorded in the public Rekor transparency log). The only signing key in the project is the secure-boot key. That is a different thing entirely. It seals an image so the RP2350 bootrom will run it. It has nothing to do with the supply chain. See production.md for that.

What every release carries

LayerArtifactWhat it proves
Reproducible buildthe 11 .uf2 flavorsthe binary is a pure function of the source at the tag. Anyone can rebuild it
Repro gate(CI, blocking)the release job fails if any flavor doesn’t rebuild bit-identical, so a non-reproducible image is never published
Checksums + signatureSHA256SUMS + SHA256SUMS.sigstore.jsonthe hashes were signed by this repo’s release workflow (keyless cosign). Up to v0.4.10 the same file is named SHA256SUMS.cosign.bundle
Build provenancea GitHub attestation, plus rs-key-<tag>.intoto.jsonl on the releasewhich reusable workflow, at which commit, on which runner built each .uf2. SLSA v1 Build L3, keyless via attest-build-provenance. The API copy is authoritative; the file is for offline checking
SBOMrs-key-<tag>-sbom.cdx.jsonthe CycloneDX bill of materials for the firmware crate
Dependency auditsupply-chain/ (in-repo)every dependency is covered by an imported audit or a recorded exemption (cargo-vet)

Verifying a download

1. Reproducible build

Rebuild from the tagged source and compare. This is the strongest check, because it needs no trust in the publisher at all:

git checkout <tag>
nix build .#firmware            # or .#firmware-pqc, .#firmware-fips, …
sha256sum result/firmware.uf2   # compare against SHA256SUMS

CI already enforces this: the release job rebuilds all eleven flavors with nix build --rebuild and fails on any bit-level difference before publishing.

2. Checksum signature (keyless cosign)

cosign verify-blob \
  --bundle SHA256SUMS.sigstore.json \
  --certificate-identity-regexp '^https://github.com/TheMaxMur/RS-Key/\.github/workflows/release-build\.yml@.*$' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  SHA256SUMS
sha256sum -c SHA256SUMS          # then check the artifacts against it

The certificate identity is release-build.yml, not release.yml: cosign runs inside the reusable builder. Sigstore stamps the cert with the reusable workflow’s identity (job_workflow_ref).

3. Build provenance (GitHub attestation)

gh attestation verify rs-key-<tag>-default.uf2 \
  --repo TheMaxMur/RS-Key \
  --signer-workflow TheMaxMur/RS-Key/.github/workflows/release-build.yml

This confirms the .uf2 was built by the release-build.yml reusable workflow in this repo. The attestation records the workflow, commit and runner, so a hand-built upload won’t verify. Pinning --signer-workflow to the reusable builder is the SLSA Build L3 check: it proves a specific, trusted workflow produced the artifact, not merely that something in the repo did. (Dropping --signer-workflow still verifies an attestation exists for this repo, a weaker Build-L2-style check.) The provenance is a GitHub attestation (Sigstore-signed, logged in Rekor) kept in the attestation API rather than as a release asset, so it stays available even though the published release is immutable.

Dependency review — cargo-vet

cargo-deny already blocks bad licenses and known advisories. cargo-vet answers a different question (has anyone actually reviewed this crate’s code?) by requiring every dependency to be covered by a recorded audit.

The audit set lives in supply-chain/: imported audits from Mozilla, Google, ISRG and Zcash, plus our own exemptions for everything they don’t cover. The gate runs in check.sh:

nix develop -c cargo vet --locked

Honest scope. RS-Key is an embedded tree: embassy, the RP2350 HAL, defmt and many RustCrypto crates are not in the big organizations’ audit sets, so they are recorded as exemptions (grandfathered in, not yet line-reviewed). The point isn’t that every line is audited. It’s that a new, unreviewed crate cannot enter the tree silently. It fails cargo vet until it’s audited, imported, or explicitly exempted. To see the current state and shrink the exemption list:

nix develop -c cargo vet              # what's audited vs exempted
nix develop -c cargo vet suggest      # diffs to review next

The host tools/tui workspace is separate and not yet under cargo-vet. It is covered by Dependabot and cargo-deny.

Transparency-log monitoring

The Rekor log that backs every signature above is tamper-evident, not tamper-proof: it records misuse, but only if someone looks. A scheduled workflow, rekor-monitor.yml, does the looking. Every hour it runs sigstore/rekor-monitor and files a GitHub issue listing any Rekor entry signed as an RS-Key GitHub-Actions identity (any …/RS-Key/.github/workflows/* subject under the GitHub OIDC issuer).

This is the detection half that complements the verification above: the attestations prove a legitimate release is genuine, while the monitor surfaces an illegitimate signature, one made with our identity by something we didn’t run (a compromised OIDC token, repo, or runner). Each real release adds a couple of expected entries from release-build.yml, so a known issue or two per release is normal. The alarm is an entry you don’t recognise.

What’s deliberately not here

  • No cargo-vet of a fully line-reviewed tree. See the honest scope above.
  • No image encryption / signed-for-boot release artifacts. The published .uf2s are unsigned for secure boot by design. You seal them with your own key (production.md). The signatures on this page attest the build, not the boot.

See also: releases.md for the release index, and COMPLIANCE.md for the licensing posture.

Linux host setup

The board enumerates as a composite FIDO HID + CCID device. By default it uses the project’s own RS-Key USB identity 0x1209:0x0001 (pid.codes), with the PC/SC reader name containing RS-Key. The opt-in VIDPID=Yubikey5 interop build instead presents the YubiKey identity 0x1050:0x0407 (other presets: build.md). The two transports have different host requirements on Linux:

TransportUsed byOut of the box?
FIDO HID (0xF1D0)WebAuthn, ssh ed25519-sk, fido2-token, python-fido2yes, once the yubico udev rules grant your user access to the hidraw node
CCID (PC/SC)OpenPGP, PIV, OATH, Yubico-OTP, gpg --card-status (ykman only on the opt-in VIDPID=Yubikey5 build)needs pcscd running and a polkit rule to use it as a non-root / SSH-session user
flowchart TD
    a["FIDO HID<br/>WebAuthn · ssh-sk · fido2-token"] --> b["hidraw + yubico udev rules<br/>(usually works out of the box)"]
    c["CCID (PC/SC)<br/>ykman · gpg · OpenPGP / PIV / OATH"] --> d["pcscd + polkit rule<br/>(+ disable-ccid for gpg)<br/>— needs both pieces"]

FIDO generally works after installing the standard yubico udev rules. CCID needs three more pieces: the CCID driver’s reader list must contain this device’s USB id at all (next section — on the default identity it does not, and that alone hides every applet), a polkit rule (so a non-root user, including one over SSH, may talk to pcscd), and, if you also use GnuPG, disable-ccid in scdaemon.conf so gpg’s scdaemon goes through pcscd instead of grabbing the raw CCID interface and locking out ykman/pcsc-tools.

Verified on a NixOS 25.11 host (kernel 6.18.x): FIDO getInfo works as a plain user over SSH once the udev rule below is in place, and gpg --card-status works with disable-ccid. ykman info works the same way on the opt-in VIDPID=Yubikey5 build (it gates on the Yubico YubiKey reader name, which the default RS-Key build does not present).

Replace youruser with your login name throughout.

The CCID driver’s reader list (default identity only)

Read this first if the applets are missing. pcscd does not drive readers itself — the ccid driver does, and it binds only USB ids that appear in its own reader list (supported_readers.txt, compiled into ifd-ccid.bundle/Contents/Info.plist). The default RS-Key identity 0x1209:0x0001 is not in that list, so the CCID interface is skipped silently: FIDO keeps working, pcsc_scan shows nothing, and OpenPGP, PIV, OATH and Yubico-OTP all look absent rather than broken. No polkit or udev change fixes this — those govern access to a reader the driver never claimed.

Why it is not simply fixed upstream: 0x1209:0x0001 is pid.codes’ shared prototype id, not an allocation to this project. Listing it in the ccid driver would bind every unrelated prototype using the same id. A dedicated VID/PID is pending, and the upstream submission waits on it.

Until then, pick one:

  • Build the interop identityVIDPID=Yubikey5 (build.md) presents 0x1050:0x0407, which is already in the driver’s list, and the stock yubico udev rules cover it too. Nothing to patch; this is why the applets work out of the box on that build.
  • Apply this flake’s ccid overlay (NixOS) — the same driver with that one line already in its reader list, and nothing else changed; the build refuses to produce a bundle the id did not reach.
  • Add the id to your local ccid driver. Building from source, a one-line addition to supported_readers.txt before the build; on an FHS distro, edit /usr/lib/pcsc/drivers/ifd-ccid.bundle/Contents/Info.plist, which holds ifdVendorID, ifdProductID and ifdFriendlyName as three parallel arrays — add one entry to each, at the same position, or the mapping shifts. Restart pcscd and re-plug.

Both routes are user reports rather than something CI covers: the source-patch route in #67, the Info.plist route in discussion #58.

NixOS (declarative)

Add to your configuration.nix:

{ pkgs, ... }:
{
  # PC/SC daemon for the CCID applets (OpenPGP / PIV / OATH / OTP).
  services.pcscd.enable = true;

  # udev rules that grant access to the FIDO hidraw node. The stock yubico
  # rules match VID 0x1050 only, so the default RS-Key identity (0x1209) needs
  # its own rule; build VIDPID=Yubikey5 instead if you want to reuse the stock
  # yubico rules unchanged.
  services.udev.packages = [
    pkgs.yubikey-personalization
    pkgs.libfido2
  ];
  services.udev.extraRules = ''
    # RS-Key own identity (pid.codes 0x1209:0x0001) — FIDO HID + CCID access.
    SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="0001", TAG+="uaccess"
    SUBSYSTEM=="usb", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="0001", TAG+="uaccess"
  '';

  # Let a non-root user (e.g. over SSH) talk to pcscd. Without this, CCID works
  # only as root and `ykman`/`gpg --card-status` fail from an SSH session.
  security.polkit.extraConfig = ''
    polkit.addRule(function(action, subject) {
      if ((action.id == "org.debian.pcsc-lite.access_pcsc" ||
           action.id == "org.debian.pcsc-lite.access_card") &&
          subject.user == "youruser") {
        return polkit.Result.YES;
      }
    });
  '';

  # Optional: the host tools (ykman, gpg, openssh with FIDO support).
  environment.systemPackages = with pkgs; [
    yubikey-manager   # ykman
    libfido2          # fido2-token, fido2-assert
    opensc            # opensc-tool -l, pkcs11
    pcsctools         # pcsc_scan
  ];
}

nixos-rebuild switch, then re-plug the board (or restart pcscd).

On the default identity that is not yet enough — the ccid driver still has to know the id (see above). This flake carries an overlay for it; add the repo as an input of your system flake and apply it:

# flake.nix: inputs.rs-key.url = "github:TheMaxMur/RS-Key";
{
  nixpkgs.overlays = [ inputs.rs-key.overlays.ccid-rs-key ];
}

The overlay replaces pkgs.ccid, which is exactly what the pcscd module puts in its plugin list, so there is nothing else to set. If you would rather not override the attribute for the whole system, name the package instead — with lib.mkForce, because the module contributes its own [ pkgs.ccid ] and two ccid bundles collide in the plugin buildEnv:

services.pcscd.plugins = lib.mkForce [
  inputs.rs-key.packages.${pkgs.system}.ccid-rs-key
];

Generic Linux (Debian / Ubuntu / Fedora / Arch)

  1. Install the stack. Package names vary by distro:

    • Debian/Ubuntu: pcscd pcsc-tools libfido2-1 yubikey-manager opensc
    • Fedora: pcsc-lite pcsc-tools libfido2 yubikey-manager opensc
    • Arch: pcsclite ccid yubikey-manager libfido2 opensc
  2. Enable pcscd: sudo systemctl enable --now pcscd.socket

  3. udev rules. The stock yubico rules that ship with libfido2 / yubikey-personalization / libu2f-host match VID 0x1050 only, so they do not cover the default RS-Key identity (0x1209). Add your own rule. Create /etc/udev/rules.d/70-rsk.rules:

    # RS-Key own identity (pid.codes 0x1209:0x0001) — FIDO HID + CCID access.
    SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="0001", TAG+="uaccess", GROUP="plugdev", MODE="0660"
    SUBSYSTEM=="usb", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="0001", TAG+="uaccess", GROUP="plugdev", MODE="0660"
    

    Then sudo udevadm control --reload && sudo udevadm trigger and re-plug. (Alternatively, build VIDPID=Yubikey5 to reuse the stock yubico rules unchanged.) If your user still can’t open the device, confirm you’re in the right group (plugdev on Debian/Ubuntu).

  4. polkit rule for non-root pcscd access. Create /etc/polkit-1/rules.d/41-pcsc-rsk.rules:

    polkit.addRule(function(action, subject) {
      if ((action.id == "org.debian.pcsc-lite.access_pcsc" ||
           action.id == "org.debian.pcsc-lite.access_card") &&
          subject.user == "youruser") {
        return polkit.Result.YES;
      }
    });
    

    (Use subject.isInGroup("plugdev") instead of subject.user == … to grant a whole group.) Restart polkit/pcscd or re-plug afterwards.

GnuPG (gpg --card-status, OpenPGP)

scdaemon defaults to grabbing the CCID interface directly, which fights pcscd and locks out ykman/pcsc_scan. Route it through pcscd instead by adding to ~/.gnupg/scdaemon.conf:

disable-ccid
pcsc-shared

Then reload it: gpgconf --kill scdaemon. After this, gpg --card-status and pcsc_scan (and ykman, on the opt-in VIDPID=Yubikey5 build) coexist (they share the one reader through pcscd).

FIDO / SSH (ed25519-sk)

Once the udev rules are in place, OpenSSH with libfido2 support works directly. No pcscd involved (FIDO is HID, not CCID):

ssh-keygen -t ed25519-sk -f ~/.ssh/id_ed25519_sk   # enroll (touch + PIN)
ssh -i ~/.ssh/id_ed25519_sk youruser@host          # login (one touch)

The key file is a handle, copyable between machines. Use lowercase -i (not -I, which is PKCS#11). Most distro OpenSSH builds already link libfido2; if ssh-keygen reports “no FIDO SecurityKeyProvider”, install libfido2 and point SSH_SK_PROVIDER / SecurityKeyProvider at libsk-libfido2.so.

Going further (NixOS quality-of-life)

The FIDO-based YubiKey-on-NixOS recipes (PAM U2F for sudo/login, LUKS FIDO2 unlock, gpg-agent SSH) bind the FIDO HID usage page (or the OpenPGP card via PC/SC), not the VID/PID, so they apply to the default RS-Key build unchanged. (Recipes that gate on ykman or the Yubico YubiKey reader name need the opt-in VIDPID=Yubikey5 build.) A good walkthrough: Improving QoL on NixOS with a YubiKey. Substitute this device wherever it says YubiKey.

Troubleshooting

  • pcsc_scan (or ykman, on the VIDPID=Yubikey5 build) says no reader, or “Failed to connect”: scdaemon (from a prior gpg) is holding the reader exclusively. gpgconf --kill scdaemon, then retry. The disable-ccid + pcsc-shared config above prevents the recurrence.
  • ykman does not see the device at all: ykman derives the device purely from the PC/SC reader name, which must contain Yubico YubiKey. The default RS-Key build names the reader RS-Key Security Key, so ykman will not recognize it. Build the opt-in VIDPID=Yubikey5 flavor (reader name Yubico YubiKey RSK OTP+FIDO+CCID) to use ykman (see build.md).
  • Everything hangs after heavy USB debugging: the pcscd + scdaemon + kernel USB stack can wedge in a way that surviving pcscd/scdaemon restarts or a re-plug do not clear. A full host reboot does. This is a host-stack quirk, not a firmware issue.
  • Verify the reader: pcsc_scan (or opensc-tool -l) should list RS-Key Security Key on the default build (or Yubico YubiKey RSK OTP+FIDO+CCID on the opt-in VIDPID=Yubikey5 build). On that opt-in build, ykman info should report 5.7.4 with all six applications enabled.

Windows host setup

The board enumerates as a composite FIDO HID + CCID device. By default it uses the project’s own RS-Key USB identity 0x1209:0x0001 (pid.codes), with the PC/SC reader name containing RS-Key. The opt-in VIDPID=Yubikey5 interop build instead presents the YubiKey identity 0x1050:0x0407 (other presets: build.md). The two transports have different host requirements on Windows:

TransportUsed byOut of the box?
FIDO HID (0xF1D0)WebAuthn, ssh ed25519-sk (see ssh.md), browser passkeysyes — Windows drives it through the built-in webauthn.dll, no driver install
CCID (PC/SC)OpenPGP (gpg / Kleopatra), PIV, OATH, Yubico-OTPyes — the Windows Smart Card service binds the standard CCID class; the friction is arbitration, not drivers

FIDO needs nothing: WebAuthn talks to the HID interface directly. CCID needs no driver either — the device is a standard CCID smart card — but the whole card is a single reader carrying several applets (OpenPGP, PIV, OATH, OTP), and only one host program can hold that one reader at a time. That is the crux of the Windows friction below.

Not yet hardware-verified on Windows. The Linux remedy in linux.md is confirmed on real hardware; the steps here mirror it and the cross-platform GnuPG behaviour, but have not been checked on a Windows host. Treat them as a starting point and please report corrections via an issue. (The Linux page carries the tested reference config.)

The single-reader contention (issue #44)

RS-Key presents OpenPGP and PIV on one CCID card, exactly as a YubiKey does. On Windows two different stacks then compete for that one card:

  • GnuPG’s scdaemon claims it to drive the OpenPGP applet (Kleopatra, gpg --card-status).
  • The Windows PIV minidriver (and any PKCS#11 layer such as OpenSC/ykcs11) claims the same card to drive the PIV applet.

Because a smart-card SELECT switches the whole card between applets, two stacks talking at once stomp on each other’s selection. The visible symptoms in issue #44 — Kleopatra showing the device as two cards, a PIV error that then makes the OpenPGP keys look gone, and PicoForge unable to reconnect until you unplug and re-plug — are this host-side arbitration, not a fault on the card. The keys are never lost: OpenPGP clears only its PIN-verified state on an applet switch (spec-required, same as a YubiKey), and re-selecting the card shows them again.

Reducing the contention

There is no driver to install. The fixes are arbitration hygiene:

  1. One PC/SC app at a time. Close PicoForge (and any other tool that opened the card) before using Kleopatra/gpg, and vice-versa. If a tool wedges and will not reconnect, unplug and re-plug the board — this clears the held reader handle.

  2. Let scdaemon share the reader. By default scdaemon can grab the reader exclusively and lock out other tools. Route it through the Windows Smart Card service and let it share. Edit (create if absent) %APPDATA%\gnupg\scdaemon.conf:

    pcsc-shared
    disable-ccid
    

    Then reload it from a terminal:

    gpgconf --kill scdaemon
    

    pcsc-shared is the key line (share the reader instead of taking it exclusively); disable-ccid forces scdaemon onto the Windows PC/SC stack rather than its own internal CCID driver. Re-plug the board afterwards.

  3. Prefer one PIV path. For PIV, either the native Windows minidriver or OpenSC/ykcs11 (PKCS#11) — not both against the card at the same moment. If OpenSC and GnuPG clash, drive PIV through ykcs11 and keep OpenPGP in Kleopatra, one operation at a time.

FIDO / SSH (ed25519-sk)

FIDO uses the HID transport and the built-in webauthn.dll; no PC/SC, no scdaemon, so none of the contention above applies. OpenSSH for Windows enrols and authenticates directly:

ssh-keygen -t ed25519-sk -f %USERPROFILE%\.ssh\id_ed25519_sk
ssh -i %USERPROFILE%\.ssh\id_ed25519_sk user@host

See ssh.md for the WebAuthn/OpenSSH details.

Troubleshooting

  • Kleopatra shows two cards / a serial unrelated to the PIV one: expected — one physical card surfaced through two host stacks (OpenPGP via scdaemon, PIV via the minidriver). Recent firmware aligns the OpenPGP card serial with the device serial the other applets report; older firmware showed a divergent OpenPGP serial.
  • PIV certificates unusable / signing “pending” under CAPI (CryptoAPI): the Windows PIV minidriver enumerates the card’s containers from its CHUID. Recent firmware serves a default CHUID automatically, so a freshly flashed card is usable; on older firmware, provision one with ykman piv objects generate chuid. (The RSA/EC signing itself is unaffected — it matches a real YubiKey byte-for-byte.)
  • PicoForge / a PC/SC tool cannot connect until re-plug: another program is holding the single reader. Close it (PicoForge, Kleopatra, a stray gpg), or unplug and re-plug. gpgconf --kill scdaemon releases a gpg-held handle.
  • gpg --card-status and another PC/SC tool cannot coexist: apply the pcsc-shared + disable-ccid config above, then gpgconf --kill scdaemon.
  • ykman does not see the device: ykman matches on the PC/SC reader name containing Yubico YubiKey, which the default RS-Key build does not present. Build the opt-in VIDPID=Yubikey5 flavor (reader name Yubico YubiKey RSK OTP+FIDO+CCID) to use ykman (see build.md).

rsk — the device CLI

rsk is the host-side command-line tool for an RS-Key. It consolidates every day-to-day and production task into one command: device status, seed backup, secure-boot provisioning, OTP fuses, FIDO2 management, OpenPGP reset, audit verification, fleet inventory, offboarding. It talks to the device directly: CTAPHID over hidapi for the FIDO interface, and the CCID applets over PC/SC.

It is the canonical interface. The terminal cockpit (rsk-tui) is a read-mostly companion that points you back here for anything irreversible, and PicoForge is a third-party desktop GUI over the same device-config surface (protocol.md §11) for anyone who would rather click than type — it is not part of this repo. Most other guides in this section assume rsk is on your PATH and show the exact rsk <group> … command for the task.

flowchart LR
    cli["rsk (host CLI)"] -->|hidapi / CTAPHID| fido["Device — FIDO<br/>backup · audit · lock · attestation"]
    cli -->|PC/SC / pcscd| ccid["Device — CCID applets<br/>OpenPGP · PIV · OATH · rescue"]
    cli -->|USB BOOTSEL / picotool| boot["Device — bootloader<br/>secure-boot · OTP fuses"]

Running it

In the Nix dev shell, rsk is already on PATH with every dependency pinned:

nix develop
rsk status            # FIDO getInfo + secure-boot + backup state
rsk --help            # all command groups
rsk <group> --help    # a group's subcommands and flags

Without Nix

rsk also runs on any host with Python ≥ 3.9, packaged at tools/. With uv (no install step, an ephemeral environment), from the repo root:

uvx --from ./tools rsk status
uvx --from ./tools rsk --help

Or install it as a persistent tool, so plain rsk is on your PATH:

uv tool install ./tools        # then: rsk status
pipx install ./tools           # or pipx
pip install ./tools            # or a venv + pip

Two dependencies wrap system libraries: hidapi (the CTAPHID transport) and pyscard (PC/SC, for the CCID applets). macOS ships both frameworks and the wheels work out of the box. On Linux install pcsclite and run pcscd for the CCID half, plus udev rules for non-root HID. The full setup and the Apple-Silicon pyscard rebuild note are in tools/README.md and Linux host setup.

The Nix shell stays the primary, reproducible path (it also carries picotool, ykman, gpg, and the test suites). The uv/pip path is for hosts without Nix.

Command groups

GroupWhat it doesGuide
statusone-shot device overview (FIDO getInfo + secure-boot + backup)quickstart
inventoryfleet enumeration (list) + identity proof (verify)Fleet tooling
identifyblink a key’s indicator so you can tell it from the others plugged inLED
backupwallet-style seed export / restore / finalize (BIP-39, SLIP-39)Seed backup
pairguided primary + backup (two independent keys) enrollmentBackup key
lockat-rest soft-lock of the FIDO seed (enable/unlock/disable)Soft-lock
secure-bootsecure-boot provisioning + key rotation (irreversible)Production, OTP fuses
otpburn + lock the at-rest master key (MKEK) into OTP (irreversible)OTP fuses
fidoFIDO2 management: set-pin, list-passkeys, attestationFIDO2, Attestation
ledLED color / brightness per device stateLED
openpgpOpenPGP applet utilities (e.g. reset to factory PINs)OpenPGP card
rebootreboot to the app or to BOOTSEL, over CCID
auditread + cryptographically verify the tamper-evident journalAudit journal
offboardguided full wipe + signed receipt; prompts for a replug (destructive)Fleet tooling

Note the two distinct “OTP“s: rsk otp burns the device’s at-rest master key into RP2350 one-time-programmable fuses (a production ritual). The Yubico-style OTP slots feature (touch-to-type codes) is a runtime applet managed with ykman. They share a name, not a mechanism.

Conventions

These hold across the whole CLI, so each group’s guide does not repeat them.

Entering a PIN

Every command that needs the FIDO2 clientPIN accepts it the same way: pass --pin <value> for scripting, or omit it and rsk prompts interactively (hidden input). You never have to remember which form a given command supports.

rsk backup export --pin 1234     # explicit, for scripts / CI
rsk backup export                # prompts:  FIDO2 PIN:

The prompt is only shown when the device actually has a clientPIN set. A touch-only key is never asked, so the plug-and-touch flow is unchanged. If stdin is not a terminal (a pipe) and no --pin was given, rsk skips the prompt and lets the device report device requires a PIN rather than hanging. The PIN-gated commands are:

  • backup export / backup restore
  • audit log / audit verify
  • lock enable / lock disable
  • inventory verify
  • fido list-passkeys, fido attestation import / clear
  • fido set-pin: --pin is the current PIN when changing. --new-pin sets the new one (prompted, with confirmation, if omitted)

This is always the FIDO2 clientPIN (the one rsk fido set-pin manages), never an OpenPGP PW1/PW3 or a PIV PIN. Those are entered only in their own tools (gpg, ykman).

Touch

Operations that release or sign over a secret require a physical touch: a press of the BOOTSEL button while the LED blinks, within a 30-second window. rsk prints touch the device … to stderr before blocking. Touch-gated commands include backup export/restore/finalize, audit verify, inventory verify, lock enable/disable, and fido attestation import/ clear. Read-only commands (status, inventory list, */status) need no touch. audit log needs one only on a device with no PIN (the PIN token gates it otherwise).

One press approves one operation. Consent is per-operation, so a press is spent the moment the ceremony it approved returns. Holding the button through a second prompt does nothing: lift your finger and press again. This matters because requests from every transport are serialised, so a browser, gpg and ykman can queue behind each other — without the rule, one long hold would approve whatever ran next. The touch window is 30 seconds by default and never shorter than 10, even if the device config asks for less.

Machine-readable output

status and inventory list take --json for scripting: a stable object you can pipe to jq. Everything else is human-formatted text.

rsk status --json | jq '.serial, .secure_boot, .fido.clientPin'
rsk inventory list --json        # one JSON object per connected key, per line

Irreversible actions

Destructive or fuse-burning commands (offboard, secure-boot, otp, lock enable, openpgp reset) require a typed confirmation: you type an exact token (e.g. LOCK-SEED) rather than pressing a single key. Anything else aborts. The OTP and secure-boot rituals burn one-time-programmable fuses and cannot be undone. Read Production setup before running them.

Errors and exit codes

On failure rsk prints error: <message> to stderr and exits non-zero (1), so it composes in scripts. A device-reported status is surfaced with its CTAP code where it helps (e.g. a wrong PIN, a sealed export window, a missing OTP DEVK) instead of a bare stack trace.

Where next

For contributors

The CLI lives in tools/rsk/: one module per group, each exposing a register(sub) that adds its argparse subparser, wired together in __main__.py. Shared helpers (error exit, the --pin flag + PIN resolution, the picotool runner, the FIDO HID connect) live in common.py. The raw CTAPHID transport and CBOR codec are in ctaphid.py.

PIN entry goes through one chokepoint, common.resolve_pin (flag-or-prompt) and common.add_pin_arg (the shared flag), so consistency is structural, not per-command discipline. The pure-logic unit tests need no device:

# from tools/ (or: uv run --extra test python -m pytest …)
nix develop -c bash -c 'cd tools && python -m pytest rsk/'

Host-tool changes like these do not bump the firmware bcdDevice. See Versions.

FIDO2 / WebAuthn / U2F

The FIDO half of the device: passkeys, two-factor security-key logins, and legacy U2F. It speaks CTAP2 (FIDO_2_0) and CTAP1 (U2F_V2) over the HID interface, so standard WebAuthn browsers and OS dialogs drive it without extra software. It passes the FIDO Alliance Conformance Tools clean (CTAP2.3 235/0, U2F 55/0, a self-run pass, not a paid certification; see testing). What has been checked against real client software is in the interop matrix.

The default build enumerates as “RS-Key”, its own USB identity (0x1209:0x0001, the pid.codes FOSS VID), not a YubiKey one (build.md). FIDO clients don’t care: browsers, python-fido2, and libfido2 bind the FIDO HID usage page, not the VID/PID, so everything on this page works regardless of USB identity. The one exception is ykman, which gates on a “Yubico YubiKey” reader name and so needs the opt-in VIDPID=Yubikey5 interop build (build.md). The reported firmware version is 5.7.4, which is what FIDO tooling reads back. It is a build constant, not the RS-Key release.

Touch is always required

On the default (touch) build every FIDO operation needs a press of the BOOTSEL button: both registration (makeCredential) and login (getAssertion). The firmware’s user-presence bit is implicitly true and it does not honour a request to skip it. WebAuthn userVerification/up cannot turn the touch off, and OpenSSH’s -O no-touch-required is silently ignored on this device. A “no-touch” SSH key still asks for the touch at login. The LED tells you when the device is waiting. See led.md for the colours. A request times out after no touch (the browser shows its own timeout UI). No button wired on a custom board means presence confirms instantly.

Set a PIN first

rsk fido set-pin            # set, or change once one exists
ykman fido access change-pin   # the same operation via ykman (needs the VIDPID=Yubikey5 build)

The clientPIN gates credential creation once it exists. It unlocks anything a site requests user verification (UV) for. Rules from the firmware:

Value
Length4–63 characters (6–63 on the fips-profile build), counted in Unicode code points — 密码 is two, not six
Per-power-cycle3 wrong attempts → PIN_AUTH_BLOCKED (0x34), re-plug to retry
Retry budget8 wrong attempts (across power cycles)
On exhaustionPIN locks until a factory reset, no separate unblock

After 3 wrong attempts in a single power cycle the device returns PIN_AUTH_BLOCKED (0x34) and refuses more PIN entry until you unplug and re-insert it. The 8-attempt budget is the across-power-cycle hard limit.

“Power cycle” means a real one. Both the block and the count of wrong attempts that arms it are held in a register a warm reset preserves and only a power-on reset clears, so a host that reboots the device — which it can do without any credential — cannot restart the batch and walk through the 8-attempt budget unattended.

The retry counter resets on a correct PIN. There is no PUK or admin override: once it is locked, the only way back is ykman fido reset, which wipes everything and has to run within ten seconds of a replug (see Factory reset). rsk fido set-pin asks for the current PIN when changing, the new one twice, and prints the resulting clientPin state.

Once a PIN is set, registering a passkey refuses to run without it (CTAP PUAT_REQUIRED, 0x36). The browser collects the PIN and retries. That is expected, not a fault.

Registering a plain second-factor credential (non-discoverable, what a site asks for with userVerification: "discouraged") needs only a touch, PIN or no PIN — the CTAP 2.1 makeCredUvNotRqd option, as on a YubiKey. Sites that want the PIN on every registration ask for UV, and ykman fido config toggle-always-uv (or the always-uv build) forces it device-wide.

Passkeys (resident / discoverable credentials)

Register on any site offering a passkey or “security key” method. The browser drives the device. You touch the button when the LED pulses. These are stored on the device and surface at login without the site sending an allow-list.

Capacity: 256 resident passkeys (and 256 relying parties), flash-bound. When the store is full, makeCredential returns KEY_STORE_FULL (0x28) and the browser reports the key is out of space. Delete some first.

Inspect and clean up (PIN required, credentialManagement is PIN-gated):

rsk fido list-passkeys              # relying parties + user handles + free slots
ykman fido credentials list        # same, via ykman (needs the VIDPID=Yubikey5 build)
ykman fido credentials delete <id>  # remove one (same build; browsers expose this too)

rsk fido list-passkeys prints the existing count and remaining slots, then each relying party with its user names and a credential-id prefix. There is no rsk-native delete yet. Use ykman or the browser/OS passkey manager for that.

credProtect. A site can mark a passkey UV-required (credProtect level 3). The firmware then hides it from discovery and from exclude-list checks until you verify with the PIN, so it never leaks its existence to an unauthenticated caller. RS-Key applies a credProtect level only when the relying party asks for one. It does not silently force a default.

Second-factor registrations (non-resident)

The classic security-key flow (GitHub, Google, GitLab, …) stores nothing on the device. The credential is derived deterministically from the master seed and handed back as an opaque id the site presents at login. This path is effectively unlimited (it costs no flash). The registrations survive a seed backup → restore onto a new board: the same derivation on the same seed reproduces the same keys.

Legacy U2F (CTAP1, U2F_V2) works the same way for older 2FA setups: the register/authenticate pair is non-resident, with a monotonic signature counter, attested by the device’s end-entity certificate.

Signature counters

Every assertion carries a signature counter, the tripwire a relying party watches for a cloned key. RS-Key keeps one per resident credential: a passkey starts at 0 and only its own logins advance it, so colluding sites can’t read a shared global counter to gauge how much you use the key elsewhere (WebAuthn §6.1.1). A non-resident second-factor credential stores nothing on the device, so it reports 0. Legacy U2F keeps the single monotonic counter that protocol expects.

Upgrading an existing key is forward-safe for passkeys: each seeds its counter from the old global value on first use, so the reported number never counts backwards.

Advertised algorithms

getInfo advertises these COSE algorithms. A relying party picks one in its pubKeyCredParams:

COSE algCurve / schemeNotes
-7 ES256NIST P-256the universal default
-8 EdDSAEd25519
-35 ES384NIST P-384slow keygen/sign (pure-Rust arithmetic)
-36 ES512NIST P-521slow keygen/sign
-47 ES256Ksecp256k1dropped from new credentials on fips-profile

RS-Key selects the first supported algorithm a site offers, so put your preferred curve first in the list.

The curve-explicit COSE ids (-9 ESP256, -19 Ed25519, -51 ESP384, -52 ESP512) are accepted too, and a credential comes back stamped with the id the site asked for — offer only -9 and the attested key says -9, not -7. They name the same curves and the same keys as the classic spellings; only the label differs. They are not advertised in getInfo, like -8, -47 and the ML-DSA ids: a host that knows to ask gets them, and one that reads the advertisement offers a classic spelling instead. A YubiKey refuses all four — we are deliberately wider here.

Post-quantum credentials

The device implements all three FIPS 204 parameter sets — ML-DSA-44 (COSE -48), ML-DSA-65 (-49) and ML-DSA-87 (-50) — for makeCredential and getAssertion. They obey the same first-supported rule as everything else, so a site that wants one lists it before its classic fallback. Nothing mainstream requests them yet. A client that does (e.g. a python-fido2 script offering -50) gets a PQC credential today. All three are backed by the in-tree, stack-optimized rsk-mldsa implementation, which streams the FIPS 204 matrix A on the fly so even ML-DSA-87’s 2592-byte key and 4627-byte signature fit the RP2350 stack.

The getInfo advertisement is build-gated behind advertise-pqc (build.md) because shipped Firefoxes (authenticator-rs before 2026-06-02) hard-fail the whole getInfo parse on an unknown COSE id. The capability is always on. Only the advertisement is opt-in, and it lists the three sets in descending security order: ML-DSA-87 (-50), ML-DSA-65 (-49), ML-DSA-44 (-48).

Extensions supported

getInfo advertises seven extensions:

ExtensionWhat it doesLimit
hmac-secretper-credential secret keyed by a salt (the WebAuthn PRF maps onto it)32-byte output
hmac-secret-mcthe same evaluation at registration time
credProtectUV-gated credential visibility (levels 1–3)
credBlobsmall opaque blob stored with the credential128 bytes
largeBlobKey + large blobsper-credential key into a device blob store4 KB store
largeBlob (CTAP 2.3)the blob itself, per credential, inside the assertion4046 B/credential; replaces the row above, largeblob-ext build only
minPinLengththe device hands its PIN-length policy to the RP
thirdPartyPaymentthe secure-payment-confirmation marker

Enterprise attestation is supported but off until enabled. The ep option flips to true once an org key is installed. See attestation.md.

Factory reset

ykman fido reset            # needs the VIDPID=Yubikey5 build; or any WebAuthn "reset security key" UI

Wipes all FIDO state: resident passkeys, the PIN, and the master seed (so all derived non-resident credentials and U2F registrations die too). It regenerates a fresh identity and signature counter. The OpenPGP / PIV / OATH applets are untouched: each applet’s reset wipes only its own files, and a FIDO reset deliberately steps around them even where the file ids interleave.

Replug the key first. The wipe is gated by a physical touch and by the CTAP 2.1 §6.6 power-up window: the firmware accepts authenticatorReset only within 10 seconds of the key attaching to USB. Later than that it answers CTAP2_ERR_NOT_ALLOWED (0x30) straight away, before asking for the touch, and the host tool reports the reset as failed. So the flow is: unplug, plug back in, run the reset, touch within ten seconds.

Two details that catch people out:

  • A software reboot does not reopen the window — it closes it. A host can ask the device to reboot without any credential (rsk reboot, a phy config write), so a window a host could restart at will would be no protection. Pull the key out.
  • Trusted-display builds are exempt. There the prompt names the operation on screen, so the window is not what stands between you and an accidental wipe; the reset works whenever you confirm it on the panel.

The flash wipe is bounded: if storage keeps reporting a record after a successful delete, reset returns CTAP2_ERR_OTHER instead of staying in the processing state indefinitely.

rsk offboard handles this for you: it sends the reset, and only if the device refuses does it prompt for the replug and retry.

Troubleshooting

  • “device not eligible / already registered”. Expected: the site sent an exclude-list matching a credential already on the device.
  • “PIN required” / repeated PIN prompts at registration. A PIN is set and the site is registering a passkey, so makeCredential needs it (PUAT_REQUIRED 0x36). Enter it. If you have forgotten it, only a reset clears it — replug first.
  • The reset fails immediately with CTAP2_ERR_NOT_ALLOWED (0x30), without ever asking for a touch. The key was plugged in more than ten seconds ago. Unplug it, plug it back in, re-run. A rsk reboot does not count: only a real power cycle opens the window.
  • A site demands UV but you have no PIN. Set one (rsk fido set-pin); UV on this device means the FIDO PIN.
  • “no space” / store-full at registration. 256 resident passkeys is the cap (KEY_STORE_FULL 0x28). Delete some with ykman fido credentials delete (needs the VIDPID=Yubikey5 build) or your browser/OS passkey manager.
  • rsk fido … says “missing dependency: python-fido2”. Run rsk from inside nix develop. The management commands need the python-fido2 library.
  • A “no-touch” SSH key still asks for a touch. By design: the firmware always polls the button (see the top of this page). For the ssh-keygen PIN / touch / resident flags, see ssh.md.
  • Linux permissions / the device is invisible to the browser. Udev rules in linux.md.

SSH with FIDO keys (ed25519-sk / ecdsa-sk)

Hardware-backed SSH keys. The private key is generated on the device and never leaves it. The file on disk is only a handle that points at it. Logging in takes one touch (and a PIN, if you ask for one). This is the OpenSSH “security key” (-sk) feature. The device is a FIDO2 authenticator, and RS-Key supports both key types it can use:

Key typeAlgorithmUse it when
ed25519-skEd25519 (EdDSA)the default: smallest, fastest
ecdsa-skNIST P-256 (ES256)a server or old client rejects ed25519-sk

Requirements

  • OpenSSH 8.2+ for -sk keys (8.3+ to download resident keys with -K).
  • A FIDO middleware: distro OpenSSH links libfido2; check with ssh -Q key | grep sk.
  • macOS: Apple’s /usr/bin/ssh ships without FIDO support and fails with Permission denied before touching the device. Use Homebrew OpenSSH:
    brew install openssh
    export PATH="/opt/homebrew/opt/openssh/bin:$PATH"   # ahead of /usr/bin
    
  • Linux: OpenSSH links libfido2 almost everywhere; you only need the FIDO udev rules (see linux.md). FIDO is local to wherever ssh runs, so logging in to a remote box needs nothing special there.
  • Windows: OpenSSH for Windows routes -sk keys through the Windows WebAuthn API (webauthn.dll), which only offers algorithms the device advertises in its FIDO getInfo. ed25519-sk needs firmware that advertises EdDSA: RS-Key bcdDevice 0x077D or newer (see rsk status). On older firmware ed25519-sk fails at create with a generic error while ecdsa-sk still works, so either reflash or use ecdsa-sk. macOS and Linux talk to libfido2 directly and send the algorithm regardless, so they are unaffected.

Enroll

ssh-keygen -t ed25519-sk -f ~/.ssh/id_ed25519_sk -C "you@laptop"
# → enter the FIDO PIN if one is set, then touch the button

The -C comment is free text that ends up in the .pub and on the server. It is handy for telling keys apart. Two files appear:

  • id_ed25519_sk: the handle. Not a private key; useless without the physical device. Copy it (and the .pub) to every machine you ssh from. The device is the second factor, the file is just a pointer.
  • id_ed25519_sk.pub: the public key, for authorized_keys.

Then install it on a server:

ssh-copy-id -i ~/.ssh/id_ed25519_sk.pub you@server
ssh -i ~/.ssh/id_ed25519_sk you@server      # one touch

Enrollment options (-O)

Pass -O flags at ssh-keygen time to shape the credential:

-O optionEffect
residentstore the key on the device so it can be downloaded later (see below)
verify-requireddemand the FIDO PIN on every login, not just a touch
application=ssh:NAMEtag the credential (default ssh:); a distinct string is a distinct key
user=NAMEuser handle stored with a resident key (for listing/telling them apart)
no-touch-requiredmark the key as not needing a touch; see the note below
write-attestation=FILEsave the enrollment attestation for later verification
challenge=FILEuse a fixed challenge (for reproducible attestation)
# PIN on every login, and store the key on the device:
ssh-keygen -t ed25519-sk -O resident -O verify-required \
    -O application=ssh:work -f ~/.ssh/id_work_sk

no-touch-required does nothing useful on RS-Key. The default (touch) build always polls the button on every assertion. The firmware does not honor up:false. The flag still marks the credential, but you will be asked to touch regardless. The touch is the point; enroll without it.

PIN and touch: what to expect

RS-Key follows the standard FIDO2 flow, the same as a YubiKey: the PIN unlocks a session token silently (no touch for the PIN itself), and then each operation takes one touch.

ActionPINTouch
Enroll (ssh-keygen -t …-sk)onceonce*
Log in, normal keyonce
Log in, verify-required keyonceonce

* You only touch twice at enrollment when several FIDO devices are plugged in at once: the first touch is a CTAP “selection” gesture (which key did you mean?), the second authorizes the key creation. With one device connected it is a single touch.

So a single login asks for the PIN at most once. If you ever see the PIN prompt twice in one action, two separate operations are running. Usually the key is offered by both ssh-agent and an IdentityFile (add IdentitiesOnly yes), or git push opened two SSH channels (use ControlMaster / ControlPersist, see the git guide). A real YubiKey behaves identically in those setups. It is the client, not the device.

Resident (discoverable) keys

-O resident stores the key handle on the device itself, so you can recover it onto any machine later instead of carrying the file:

ssh-keygen -K                      # download handles into the current dir (PIN)
# → writes id_ed25519_sk_rk[...]  and the matching .pub
ssh-add -K                         # load resident keys straight into the agent
rsk fido list-passkeys             # see what's stored on the device

Resident keys cost one of the device’s 256 discoverable-credential slots. For most people the non-resident default plus a seed backup is better: non-resident keys are re-derivable from the seed, so a restored board logs in with the same handle files. No slot used, nothing to download. Reach for resident keys when you want to walk up to a fresh machine with only the device in your pocket.

ssh-agent and ~/.ssh/config

Add the key to the agent so you are not retyping -i:

ssh-add ~/.ssh/id_ed25519_sk       # non-resident: add the handle file
ssh-add -K                         # resident: pull from the device
ssh-add -l                         # list loaded keys

A config block makes plain ssh host use the right key (and the Homebrew binary on macOS):

# ~/.ssh/config
Host server
    HostName server.example.com
    User you
    IdentityFile ~/.ssh/id_ed25519_sk
    IdentitiesOnly yes

IdentitiesOnly yes stops the agent from offering every other key first. Worth it so each connection prompts for exactly one touch.

Server side

The .pub goes in ~/.ssh/authorized_keys like any key. You can also pin requirements there, independent of how the key was enrolled:

# authorized_keys — require the FIDO PIN for this key (touch is omitted = required)
verify-required sk-ssh-ed25519@openssh.com AAAA... you@laptop

sshd enforces verify-required from OpenSSH 8.4+; older servers accept the key but skip the check. A key enrolled verify-required always asks for the PIN on the client regardless of the server. (Adding no-touch-required here would relax the touch requirement, but RS-Key touches anyway, as above.)

Signing git commits

The same key signs git commits and tags (no GPG needed). See the git guide. The short version:

git config gpg.format ssh
git config user.signingkey ~/.ssh/id_ed25519_sk.pub
git config commit.gpgsign true     # one touch per commit

Using the OpenPGP AUT slot instead

If you already run an OpenPGP key on the card, its authentication subkey doubles as an SSH key via gpg-agent, a different path that needs no -sk support in the client. See openpgp.md (gpg --export-ssh-key, enable-ssh-support).

Troubleshooting

  • Permission denied instantly on macOS → you are on /usr/bin/ssh; use the Homebrew binary (above).
  • Key enrollment failed: requested feature not supported → the client lacks ed25519-sk middleware; install libfido2 / Homebrew OpenSSH, or fall back to -t ecdsa-sk.
  • device not found / no prompt → FIDO udev rules missing (linux.md), or a browser / gpg-agent is holding the device; close it and retry.
  • Asks for a PIN you never set → some client builds require a PIN to enroll; set one with rsk fido set-pin and retry.
  • sign_and_send_pubkey: signing failed on login → the wrong device is plugged in, or the key is resident on a device you reset. Re-plug the right key, or ssh-add -K again.
  • After a factory FIDO reset, old id_*_sk files stop working. The seed they derive from is gone. Re-enroll, or restore the seed first so the same handles work again.

Git with the device

Two jobs you do with git and a hardware key: sign your commits and tags, and authenticate to push, pull, and clear the forge’s 2FA / “confirm access” challenges. The same RS-Key handles both. They are just different credentials on it. Signing comes first. Authentication and 2FA are at the end.

Signing keeps the key off the disk and takes a touch per signature. It has two flavours. RS-Key supports both:

SSH signingOpenPGP signing
Keyyour -sk SSH key (ssh.md)a key on the OpenPGP card (openpgp.md)
Needsgit 2.34+, nothing elsegpg + scdaemon
Trust modelan allowed_signers file you curatethe OpenPGP web of trust
Touch / PINtouch per signaturePIN once, then touch per signature (UIF)
Best whenyou only want commit signing, no GPGyou already use GPG, or want WoT

If you have no GPG setup and just want verified commits, use SSH signing. It is the smaller path. If you already keep a GPG identity, use OpenPGP.

SSH signing

Point git at your -sk public key and turn signing on:

git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519_sk.pub
git config --global commit.gpgsign true     # sign every commit
git config --global tag.gpgsign true        # sign every tag

Now git commit asks for one touch. Drop --global to scope it to a single repo (handy if only some repos should be signed by the device).

git commit -m "…"          # touch the device when the LED blinks
git log --show-signature   # see the signature on each commit

Verify locally

Verification needs an allowed_signers file mapping identities to public keys:

mkdir -p ~/.config/git
echo "you@example.com namespaces=\"git\" $(cat ~/.ssh/id_ed25519_sk.pub)" \
    >> ~/.config/git/allowed_signers
git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers

git verify-commit HEAD              # "Good \"git\" signature for you@example.com"
git log --show-signature -1

Without that file git can make signatures but reports every commit as “No signature” on verify. That means the file is missing, not a bad signature.

On GitHub / GitLab

Add the same .pub as a Signing key. On GitHub this is a separate entry from an authentication key (Settings → SSH and GPG keys → New SSH key → Key type: Signing Key). Commits then show as Verified. Turn on vigilant mode (GitHub) to flag any unsigned commit on your account as Unverified.

OpenPGP signing

First put a signing-capable key on the card and learn its key id. See openpgp.md. Then:

git config --global gpg.format openpgp        # the git default; set it explicitly
git config --global user.signingkey 0xLONGKEYID
git config --global commit.gpgsign true
git config --global tag.gpgsign true
# if gpg isn't found by name on your platform:
git config --global gpg.program $(command -v gpg)

git commit now goes through gpgscdaemon → the card: the User PIN once per session, then a touch per signature if the SIG slot’s UIF touch policy is on (openpgp.md).

git commit -m "…"
git log --show-signature        # "Good signature from …" via your gpg keyring
git verify-tag v1.0

On GitHub / GitLab

Export the public key and add it as a GPG key (Settings → SSH and GPG keys → New GPG key):

gpg --armor --export 0xLONGKEYID    # paste the block into the forge

The email on a signing UID must match your commit email for the forge to mark it Verified.

Authenticating: push, pull, and 2FA

Signing proves who wrote a commit. Authenticating is how you push, pull, and get past the forge’s security-key prompts. The device does this too, with separate credentials from the signing key.

Push / pull over SSH

The cleanest path: use the device’s -sk SSH key (or the OpenPGP AUT subkey via gpg-agent) as your transport. Add the public key to the forge as an Authentication key. On GitHub this is a separate entry from the signing key (Settings → SSH and GPG keys → New SSH key → Key type: Authentication). Point the remote at SSH, and each connection is a challenge the key answers with a touch:

git remote set-url origin git@github.com:you/repo.git
git push                     # touch when the LED blinks

That is one touch per connection, not per command. To keep a burst of pushes under a single touch, reuse the SSH channel:

# ~/.ssh/config
Host github.com
    User git
    IdentityFile ~/.ssh/id_ed25519_sk
    IdentitiesOnly yes
    ControlMaster auto
    ControlPath ~/.ssh/cm-%r@%h:%p
    ControlPersist 10m         # one touch covers everything in the window

The key setup itself is the SSH guide. Here it just doubles as the git transport.

Push / pull over HTTPS

Over HTTPS git authenticates with a token, not the key. The device isn’t in that path. But it protects the account the token comes from: gh auth login, or signing in to mint a token, triggers the 2FA challenge below, which a tap clears.

Account 2FA and “confirm access” challenges

The forges require 2FA and re-challenge for sensitive actions: signing in on a new machine, changing keys, deleting a repo (GitHub calls this sudo mode). Register the device once and a tap answers every such prompt:

  • GitHub: Settings → Password and authentication → add a Passkey (one tap, no password) or a Security key (second factor).
  • GitLab: Settings → Account → enable a WebAuthn Device.

A passkey is a resident credential. It costs one of the device’s 256 discoverable slots (ssh.md). A security key 2FA credential is non-resident. Either way the browser shows “use your security key”, you touch, and the challenge clears.

One device, three jobs: a signing key for commits, an SSH auth key for push/pull, and a passkey / 2FA credential for the account. Three independent credentials on the same RS-Key, each its own touch.

Living with the touch

Every signature is one touch. That is the security benefit (malware can’t sign in the background), but it adds up on a rebase that re-signs many commits.

  • SSH signing always touches. There is no caching. For a big rebase, sign the final result rather than every intermediate commit, or temporarily set commit.gpgsign false for the rebase and re-sign at the end.
  • OpenPGP caches the PIN (via gpg-agent, default-cache-ttl), but the touch still happens per signature when UIF is on. Turn UIF off on the SIG slot if you want PIN-only signing (weaker: any process with the cached PIN can then sign).
  • git commit --no-gpg-sign skips signing for a one-off commit.

Troubleshooting

  • error: gpg failed to sign the data (SSH mode) → gpg.format isn’t ssh, or user.signingkey points at a missing file. Re-check both.
  • git verify-commit says “No signature” but the commit is signed → the allowed_signers file isn’t configured (above).
  • OpenPGP: No secret key / selecting card failedscdaemon lost the reader (often after ykman/another tool grabbed it): gpgconf --kill scdaemon and retry. On Linux apply the linux.md scdaemon settings.
  • Commits show Unverified on the forge → the signing key/GPG key isn’t added to your account, or the commit email doesn’t match the key’s identity.
  • git push says Permission denied (publickey) → the authentication key isn’t on the forge (it is a separate entry from the signing key), or the remote is HTTPS not SSH. Check with git remote -v and ssh -T git@github.com.
  • The device never prompts for a touch → another process is holding it (a browser, gpg-agent, the TUI). Close it and retry.

OpenPGP card

A full OpenPGP card 3.4 over CCID: three key slots (signature, decryption, authentication), works with stock GnuPG. The same slots cover commit signing, SSH login (via gpg-agent), and end-to-end mail/file encryption.

Prereqs: on Linux, pcscd + the scdaemon.conf lines from linux.md. Check the card is visible:

gpg --card-status            # reader: RS-Key Security Key …, OpenPGP v3.4

gpg works regardless of the reader name. scdaemon identifies the card by its ATR and applet SELECT, not the USB identity. The default build reports the reader as “RS-Key”; the opt-in VIDPID=Yubikey5 flavor reports it as “Yubico YubiKey” (build.md).

PINs

DefaultLengthUnlocks
User PIN (PW1)1234566–127signing, decryption, authentication
Admin PIN (PW3)123456788–127key import/generation, card settings
Reset Code (RC)unset8–127unblocking PW1 without PW3

Those columns are exclusive: the admin PIN authorises no key operation. It cannot sign, decrypt or authenticate, however recently it was entered — that is the card spec’s rule and what a YubiKey does. (Earlier RS-Key builds let PW3 stand in for PW1 here. If you have a script that unlocks signing with the admin PIN, it needs the user PIN now.)

The same split governs the four private-use data objects: 0101 and 0103 are the cardholder’s and open to the user PIN, 0102 and 0104 are the admin’s. There is no admin override on the cardholder’s pair — 0101 and 0103 answer 6982 to PW3, as a YubiKey does. (Earlier RS-Key builds took the admin PIN on those two as well; a script that stashes data there needs the user PIN now.)

The card enforces those lengths itself: a CHANGE REFERENCE DATA or RESET RETRY COUNTER carrying a new value outside the range is refused with 6985, whatever the host’s own policy is. gpg applies the same ≥ 6 / ≥ 8 minima before it ever reaches the card. A shorter reference stored by an older firmware keeps verifying; only new ones are checked.

A verify of a value outside the range is refused the same way, with 6A80 — and it costs no retry and leaves an already-entered PIN standing, because a password the reference could not be is a malformed request rather than a wrong guess. On a card still holding a legacy out-of-range reference the check is off, so that reference stays verifiable.

A fresh card has no Reset Code. It stays deactivated until an admin sets one (passwd option 4, below), so RESET RETRY COUNTER in its RC form cannot run against a known default.

Each PIN has its own retry counter, default 3. Every attempt is charged against it before the card compares anything, and a correct entry gives the charge back; a wrong one keeps it. The one visible consequence: pulling the key out mid-VERIFY can cost a try the holder never used. That is the safe direction — charging afterwards would mean an interrupted write leaves a wrong guess free, and this counter is the only limit the card has. gpg --card-status prints them as PIN retry counter : 3 3 3 (PW1, RC, PW3: all three default to 3).

Change them first:

gpg --card-edit
gpg/card> admin
gpg/card> passwd            # menu: 1 change PW1 · 3 change PW3 · 4 set Reset Code

The same menu sets the Reset Code (option 4, under admin), which lets a holder who has forgotten PW1 reset it without the admin PIN. Useful when the admin PIN lives somewhere offline.

Two ways admin operations lock:

  • Three wrong PW3 blocks the admin PIN. Unlike PW1, the admin PIN has no higher authority to unblock it. Recovery is a factory reset of the applet (below). Plan to keep PW3 written down somewhere offline.
  • Three wrong PW1 blocks the user PIN. This one is recoverable: unblock it with the admin PIN or the Reset Code (see Unblocking PW1).

Generate keys on-card

gpg --card-edit
gpg/card> admin
gpg/card> key-attr           # per slot, pick the algorithm (table below)
gpg/card> generate           # makes all three keys + a gpg keyring entry

key-attr is asked once per slot (signature, then encryption, then authentication), so you can mix: e.g. Ed25519 for signing and authentication, Cv25519 for encryption (gpg’s default modern pair), or RSA across the board.

Supported per-slot attributes (advertised via DO 0xFA, the list ykman and gpg read back):

FamilyChoicesNotes
ECC (sign/auth)Ed25519, NIST P-256 / P-384 / P-521, secp256k1, brainpoolP256r1 / P384r1EdDSA on Ed25519; ECDSA on the Weierstrass curves
ECC (encrypt)Cv25519 (X25519), NIST P-256 / P-384 / P-521, secp256k1, brainpoolP256r1 / P384r1ECDH; the DEC slot only
RSA2048 / 3072 / 4096, plus 1024exponent fixed at 65537 (what gpg imports)

Changing a slot’s algorithm after a key exists invalidates that slot’s old private/public key pair. Re-select the intended attribute before generating or importing the replacement; writing the same attribute leaves the key intact.

RSA-1024 is advertised and it works — a YubiKey does not offer it at all. It is below every current guidance (NIST SP 800-131A retired it in 2013) and it is here only so a key generated under an older build keeps working. Do not pick it for a new key.

Not supported: X448, Ed448 and brainpoolP512r1. gpg will offer them; the card advertises none of the three in DO 0xFA and refuses the key-attr write with 0x6A80, so a slot cannot end up announcing a curve it will not use. No mature no_std Rust arithmetic exists for those yet, so shipping them would mean unaudited curve math.

On-card generation means the private keys never existed anywhere else, and cannot be backed up. gpg’s “make an off-card backup” prompt covers the encryption key only, and only if you say yes. (A lost signing or authentication key is regenerated, not recovered.) RSA generation is slow on this hardware. The firmware races both RP2350 cores for the two primes and streams CCID keepalives while gpg waits:

SizeTypical on-card keygen
RSA-2048≈ 4–6 s
RSA-3072≈ 22 s
RSA-4096≈ 50 s
any EC curveinstant

The spread is wide because the prime search is random. RSA-4096 has been seen anywhere from ~17 s to ~120 s on the same board. See ../limitations.md for the measured dual-core numbers. EC is the pragmatic default unless a peer needs RSA.

Or import existing keys

If you already have a GnuPG key (and want a recoverable off-card copy), import the subkeys instead of generating:

gpg --expert --edit-key YOURKEY
gpg> toggle                  # show secret subkeys (ssb)
gpg> key 1                   # select the subkey to move (repeat per subkey)
gpg> keytocard               # pick the matching slot: 1 sig · 2 enc · 3 auth
gpg> save

keytocard moves the selected subkey onto the card, replacing the on-disk copy with a stub that points at the device. Set key-attr to match the incoming key’s algorithm before keytocard, or the card refuses the import. The size counts as much as the family: a 2048-bit key offered to a slot announcing RSA-4096 is refused, because the attribute is what gpg --card-status and every other host reads back as the truth about that slot. A mismatched algorithm/curve/size returns “Wrong data” / “Function not supported” and a missing admin (PW3) session returns “Security status not satisfied”. gpg surfaces one of these as a card refusal.

Importing keeps an off-card copy in your keyring until you delete it. Your call which way the trade-off goes. The usual recoverable setup: generate the master key offline, move only the three subkeys to the card, and store the master key material on encrypted offline media.

The card records which way each slot was filled and reports it in DO 0xDE, because that is the difference between a key that can only exist here and one that has a copy somewhere. A slot filled by a build older than this one, or one whose GENERATE lost power partway, reads as imported: the card will not claim on-card generation it cannot prove. Generate into the slot again if you want the stronger claim back.

Daily use

Signing and decryption

echo hi | gpg --clearsign                 # PW1, then a touch if UIF is on
gpg --encrypt -r alice@example.com file    # public-key op, no card needed
gpg --decrypt file.gpg                     # PW1 (PW2), card does the ECDH/RSA

gpg drives the slots automatically: the SIG slot signs, the DEC slot decrypts. Encryption to a recipient is a public-key operation and never touches the card. Only decryption does.

By default PW1 stays valid for the session after the first signature. That session ends when the key is unplugged or another application is selected on the card — a PIV or OATH tool reaching for the same key mid-session does exactly that. Selecting OpenPGP again does not end it (§4.2 spends its rule on “a SELECT to a different DF”), so a tool that re-selects before each command keeps the PIN it already gave. To force a PIN on every signature, flip the PW1 status byte:

gpg/card> admin
gpg/card> forcesig          # toggles "PW1 valid for one signature only"

SSH authentication via gpg-agent

The AUT slot doubles as an SSH key through gpg-agent:

# one-time agent setup
echo enable-ssh-support >> ~/.gnupg/gpg-agent.conf
gpgconf --kill gpg-agent

# add the authentication subkey's keygrip to sshcontrol
gpg --list-keys --with-keygrip YOURKEY     # find the [A] subkey's keygrip
echo <KEYGRIP> >> ~/.gnupg/sshcontrol

# export the public key in OpenSSH format and install it
gpg --export-ssh-key YOURKEY > ~/.ssh/id_rsk.pub
ssh-copy-id -f -i ~/.ssh/id_rsk.pub you@server

Then export SSH_AUTH_SOCK=$(gpgconf --list-dirs agent-ssh-socket) (in your shell rc) and ssh you@server prompts for PW1 and logs in. This is the standard gpg-agent recipe, nothing device-specific.

For FIDO-backed SSH (ed25519-sk, no gpg) see ssh.md; for signing git commits and tags with the SIG slot see git.md.

Touch policies (UIF)

Each slot has an independent user-interaction flag. When on, every use of that key additionally requires a button press. The firmware polls the BOOTSEL button and fails the operation (0x6600) if it is not pressed in time. PIN alone is no longer enough. A remote attacker holding your unlocked session still cannot sign or decrypt without physical access.

gpg/card> admin
gpg/card> uif 1 on          # 1 sig · 2 enc · 3 auth   (off to disable)
gpg/card> uif 1 permanent   # irreversible: only a factory reset clears it

UIF is per-slot, so you can require a touch for signing but not decryption, or any mix. On a board with no button configured the check is a no-op.

on is revocable with the admin PIN, so it protects against a stolen user PIN, not a stolen admin PIN. permanent (the card’s UIF value 02) cannot be lowered by any command — PUT DATA answers 6985 — so a host that learns PW3 still cannot turn your signatures touchless. Clearing it takes TERMINATE DF + ACTIVATE FILE, which wipes the applet. Set it only when you mean it.

AES encryption (PSO)

The card carries one AES key, in DO D5. It belongs to the card, not to a key slot — the spec names it by the commands it serves, and ENCIPHER uses no key slot at all. The card seeds it as AES-256 the first time you generate an encryption keypair, so the feature works out of the box. Tools that expose the card’s symmetric PSO (e.g. gpg-card) can ENCIPHER / DECIPHER arbitrary block-aligned data with it (raw AES-CBC, zero IV; output is 0x02 || cryptogram). It needs PW1 (PW2). Most users never touch this. Public-key encryption is the normal path.

A host can also supply the key itself, with PUT DATA on DO D5 under the admin PIN: 16 bytes for AES-128 or 32 for AES-256, and no other length.

Once a key is there nothing replaces it but another PUT DATA D5 — generating or importing a keypair leaves it alone, on every slot. There is no way to remove it short of TERMINATE DF + ACTIVATE FILE, which wipes the applet, and the card cannot tell you a key is there: DO D5 is write-only.

Recovery and reset

Unblocking PW1

Three wrong user-PIN tries block PW1 but not the keys. Two ways back:

# with the admin PIN
gpg --card-edit
gpg/card> admin
gpg/card> unblock           # verify PW3, set a new PW1

# or with the Reset Code, if one was set (no admin PIN needed)
gpg/card> passwd            # menu option 2: "unblock PIN" via Reset Code

Both reset PW1’s retry counter and re-seal its key material under the new PIN.

A PIN change interrupted by unplugging the key

Changing a PIN rewrites two things: the PIN’s verifier, and the copy of the key that PIN unwraps. Pull the key out of the port between the two — during gpg --change-pin — and the card reports a memory error.

Since firmware 0x0889 the card finishes the interrupted change itself on the next VERIFY, so there is nothing to do. On an older build the card comes back in a state that looks fatal and is not: the affected PIN verifies, and every operation that needs a key answers “Card error”. Do not factory-reset it.

The card keeps one copy of the key per PIN and only the copy belonging to the PIN you changed was damaged, so the repair is to reach the key through a different PIN. Which one depends on which PIN you were changing, and the two recipes are not interchangeable:

The admin PIN (PW3) was being changed — verify the user PIN first, in the same session, then change PW3 again:

gpg --card-edit
gpg/card> verify            # PW1 — its copy of the key is untouched
gpg/card> admin
gpg/card> passwd            # change PW3 again; the card is repaired

The user PIN (PW1) was being changed — use the admin unblock, and do not verify PW1 anywhere in that session:

gpg --card-edit
gpg/card> admin
gpg/card> passwd            # menu option 1, "unblock PIN" — sets PW1 via PW3

The order matters for a reason worth knowing: the card reaches for PW1’s copy of the key whenever PW1 is verified, ahead of the admin’s. So for a torn PW1 change, verifying PW1 — even with the PIN that now works — puts the damaged copy back in front and the repair fails. unblock never verifies PW1, which is why it is the one that works there.

Factory reset (OpenPGP only)

rsk openpgp reset      # or: gpg --card-edit → admin → factory-reset

rsk openpgp reset blocks both PINs, then drives the spec-compliant TERMINATE (0xE6) + ACTIVATE (0x44) and reseeds factory defaults (PW1 123456, PW3 12345678). It wipes the OpenPGP applet (keys, PINs, DOs, reset code) and nothing else. FIDO / PIV / OATH / OTP survive (the TERMINATE is scoped to the OpenPGP FIDs). This is also the only way out of a PW3 that you have blocked: a blocked admin PIN cannot be unblocked, only reset away, along with the keys it protected. It also works when the admin verifier itself is unusable — a card provisioned by an older firmware with an empty PW3 could otherwise neither verify nor terminate, leaving a device-wide factory reset as the only escape.

It is destructive but idempotent, so it is the clean way to clear non-default PINs a prior gpg session left behind (which otherwise block the test suite at VERIFY).

Troubleshooting

  • gpg: selecting card failed: No such device → scdaemon vs pcscd fight; apply linux.md’s disable-ccid, then gpgconf --kill scdaemon.
  • ykman stops seeing the device after gpg used it → same fix; gpg’s scdaemon holds the reader. gpgconf --kill scdaemon releases it.
  • A card refusal on keytocard / generate (gpg may report “Function not supported”, “Wrong data”, or “Security status not satisfied”) → the slot’s key-attr doesn’t match the key, or you skipped admin (no PW3 session).
  • gpg --card-status shows PIN retry counter : 0 … → that PIN is blocked; see Recovery and reset.
  • RSA generate seems to hang → it isn’t; on-card RSA keygen takes the times above and gpg shows no progress bar. Wait it out, or use an EC curve.
  • ykman openpgp info (needs the opt-in VIDPID=Yubikey5 build: ykman only sees the device when the reader name contains “Yubico YubiKey”) → ERROR: Incorrect TLV length on firmware before 0x0759: the GET DATA 6E reply was missing its constructed-DO wrapper, which ykman’s strict parser requires (gpg tolerated it). Fixed in 0x0759; flash it and re-run. See interop.md.

PIV

A PIV smart-card (NIST SP 800-73-4) over CCID: X.509 client certificates, S/MIME, PIV-aware OS login, SSH and age through PKCS#11. Driven with ykman piv or yubico-piv-tool; the applet also speaks the Yubico extensions (metadata, serial, attestation, move/delete, set-retries) those tools use. ykman piv and yubico-piv-tool gate on the “Yubico YubiKey” reader name, which the default RS-Key build (VID:PID 0x1209:0x0001) does not present. They need the opt-in VIDPID=Yubikey5 interop build (build.md). The PKCS#11 / OpenSC and OS-native (macOS CryptoTokenKit, Windows) routes below identify the card by its applet, not the reader name, so they work on the default build.

Windows note: the card serves a default CHUID automatically — the Windows PIV minidriver needs it to enumerate the certificate containers, so no manual ykman piv objects generate chuid step is required.

Prereqs: on Linux, pcscd plus the polkit rule from linux.md; if you also use GnuPG, the disable-ccid line so scdaemon and pcscd stop fighting over the reader. Check the card is visible (the ykman commands here assume the opt-in VIDPID=Yubikey5 build):

ykman piv info            # PIV version 5.7.4, slot + PIN/PUK/mgmt-key state

Defaults

DefaultNotes
PIN1234566–8 chars; padded to 8 with 0xFF on the wire
PUK123456786–8 chars; unblocks a blocked PIN
Management key010203040506070801020304050607080102030405060708AES-192, the well-known YubiKey 5.7-era default
PIN / PUK retries3 / 3resets to full on each correct entry

Change all three before real use:

ykman piv access change-pin
ykman piv access change-puk
ykman piv access change-management-key --generate --protect

--protect stores the new management key on the card, encrypted under the PIN, so ykman can recover it from the PIN alone (no separate hex string to carry). The applet accepts AES-128/192/256 management keys; under the FIPS-style build it refuses to set a new 3DES key, though an existing 3DES key still authenticates so a reflashed device can migrate itself to AES.

PIN protection is per key and does not survive a rotation. Setting a new management key clears the protected flag, so the new key is not PIN-readable unless you opt in again — ykman piv access change-management-key --protect re-writes the flag as part of the same operation, so nothing changes for the command above. It matters if you rotate with anything else (a raw SET MANAGEMENT KEY APDU, yubico-piv-tool): the card then holds a key only the hex string opens, and ykman piv info reports it as not protected. Rotating away from a protected key is also how you revoke that PIN-only access. The flag is cleared only once the new key is stored, so a rotation that fails part-way leaves the escrow describing the key that is still on the card — you are never left holding a PRINTED-only key the card no longer admits to escrowing.

On the panel (trusted-display builds). The PIV PIN and PUK can be changed (and a blocked PIN unblocked with the PUK) on the device, no host needed: Settings → Security → PIV PINChange PIN / Change PUK / Unblock PIN. Each verifies the current PIN/PUK against the applet’s own retry counter (shown on the pad) and stores the new value in the 8-byte 0xFF-padded wire form, so a later ykman / yubico-piv-tool VERIFY accepts it.

A 24-byte management key can’t be typed on a numeric pad, so the panel sets a random, PIN-protected one instead: Settings → Security → PIV PINProtect mgmt key. The device generates a random AES-256 management key, seals it, and marks it PIN-protected (the ykman --protect scheme), so a host then uses it with just the PIV PIN. ykman piv info shows it as protected and ykman piv operations no longer need the hex key. Security: once protected, the PIV PIN alone grants management access (it unlocks the random key), so treat the PIN accordingly; the panel states this and gates the action behind the device PIN and a hold. (ykman piv access change-management-key --generate --protect installs a random PIN-protected key from the host too.) If you had raised the management key’s touch policy (--touch, below), the panel action keeps it — it replaces the key, not the gate, so admin actions still ask for a press. The host command does not: ykman sends the policy in the command itself and defaults it to off, so re-run it with --touch if you want the gate back.

The panel manages PINs/PUKs that follow the standard PIV convention (6–8 digits, padded to 8 bytes with 0xFF), which is what ykman, yubico-piv-tool and OpenSC all use. Since firmware 0x088A the card refuses to store a value shorter than six bytes at all, so a sub-6-digit reference can no longer be provisioned by any route. A reference stored unpadded by an older build is still possible, and it can’t be verified on the panel; re-set it with ykman first. The factory defaults follow the convention, so the panel works out of the box.

If an older build shortened both the PIN and the PUK, the only way back destroys the keys. Since 0x08D1 a CHANGE REFERENCE DATA / RESET RETRY COUNTER body is sixteen bytes or nothing, so no build can enter this state any more — but a card already in it has no non-destructive exit. A shortened PIN alone is repaired by ykman piv access unblock-pin (the PUK still works), and a shortened PUK alone by ykman piv access set-retries (the PIN still works), both with every key intact. With both shortened, neither repair is presentable: block both counters with wrong guesses and run ykman piv reset, which wipes every PIV key and certificate. The state needed an older build and a host that sent a fourteen-byte reference pair, which no shipped tool emits, so it is unlikely you are here — but if you are, restore the slots from backup after the reset rather than looking for a gentler command.

The defaults are public. Until you change the PIN, PUK and management key, anyone with physical access can generate, import or delete keys. Treat a default-credential card as unprovisioned.

Slots

SlotRoleTypical useDefault PIN policy
9aPIV Authenticationsystem / domain login, SSH, client TLSonce per session
9cDigital Signaturedocument & email signingevery operation
9dKey Managementdecryption, key agreement (ECDH)once per session
9eCard Authenticationphysical-access / contactlessno PIN
8295Retired Key Management20 slots for old decryption keysonce per session
9bManagement Keyadmin auth (not an asymmetric key)
f9Attestationsigns slot attestation certs (on-card)

The signature slot (9c) demands the PIN before every private-key operation; the other slots cache the PIN for the rest of the session after one VERIFY. A session ends when the card loses power or another application is selected on it — an OpenPGP or OATH tool reaching for the same key mid-session does exactly that. Selecting PIV again does not end it (SP 800-73-4 Part 2 §3.1.1), so a tool that re-selects before each command keeps the PIN it already gave. 9e is the exception at the other end: SP 800-73-4 makes the Card Authentication Key the one usable without a PIN, so a default-policy 9e key signs with no VERIFY at all — which is what makes it usable for physical-access and contactless readers. Give it --pin-policy ONCE (or ALWAYS) at generate time if you want it gated like the rest.

Algorithms. On-card generation and import accept RSA-2048 / 3072 / 4096, RSA-1024 (disabled under the FIPS-style build, SP 800-131A), ECC P-256 / P-384, and the Curve25519 pair Ed25519 (signing) and X25519 (key agreement), the Yubico 5.7 PIV algorithm ids 0xE0 / 0xE1, so ykman drives them as --algorithm ED25519 / X25519. An Ed25519 key generates with a self-signed certificate like the other curves; an X25519 key is key-agreement-only and can’t self-sign, so generation writes no auto-certificate (provision one from a CA via ykman piv certificates import). RSA-3072/4096 keygen is slow on this hardware (tens of seconds to a minute-plus).

Generate a key on-card

ykman piv keys generate --algorithm ECCP256 9a pub.pem   # on-card key, public part out
ykman piv certificates generate --subject "CN=me" 9a pub.pem   # self-signed cert into 9a
ykman piv info

Generating in a slot already writes a self-signed certificate into that slot’s certificate object, so a GET DATA serves one immediately even before you run certificates generate. Management-key auth is required to generate.

For a real CA, emit a CSR instead of a self-signed cert:

ykman piv certificates request --subject "CN=me" 9a pub.pem me.csr
# … sign me.csr at your CA, then import the issued cert:
ykman piv certificates import 9a issued.pem

On-card generation means the private key never existed off-device and cannot be exported or backed up. Losing the card loses the key (that is the point). RSA generation is slow on this hardware (RSA-2048 takes 4–6 s on the reference board and about twice that on others, and the prime search is random so run-to-run times vary; the device streams CCID keepalives so the connection stays alive; it is not a hang). See limitations.md for the measured dual-core figures and which board they are from. EC generation is instant.

Or import an existing key

ykman piv keys import 9d existing.pem        # PEM with the private key
ykman piv certificates import 9d existing-cert.pem

Import is management-key gated and also accepts RSA-2048/1024, P-256/P-384 and Ed25519/X25519. An imported key keeps whatever copy you imported it from. Your call which way the trade-off goes. Imported keys cannot be attested (see below): attestation proves on-card generation, which import didn’t do.

PIN and touch policy per key

Both policies are fixed at generate/import time and stored in the slot metadata:

ykman piv keys generate --pin-policy ALWAYS --touch-policy ALWAYS 9a pub.pem
--pin-policyEffect
NEVERno PIN to use the key (default for 9e)
ONCEPIN once per session (default for 9a/9d/retired)
ALWAYSPIN before every operation (default for 9c)

ALWAYS means the VERIFY has to be the last thing before the operation: a private-key operation at any PIN-gated slot uses it up — including one that fails after reaching the key — so VERIFY → sign at 9a → sign at 9c refuses the second signature with 6982. Verify again between them. Nothing else is affected — the PIN itself stays verified, so a 9c signature does not close the ONCE slots, the PIN-protected management key or a plain VERIFY status query, and a NEVER-policy key never uses anything up.

--touch-policyEffect
NEVERno button press — the default, for slot keys and the 9b management key alike
ALWAYSa physical touch before every private-key operation
CACHEDtreated as ALWAYS on this device (see below)

Ask for the press and you get it on every sign / decrypt / ECDH, and a declined touch fails the operation with 6982. Don’t ask, and the key never prompts — which is what makes pkcs11, age-plugin and SSH usable unattended. Raise the management key’s own policy with ykman piv access change-management-key --touch if you want admin actions gated too.

CACHED is treated as ALWAYS. The device has no wall clock, so it cannot honour the 15-second touch cache a real YubiKey offers; it errs strict and asks every time. If you set CACHED, expect ALWAYS behaviour.

Data objects that need the PIN to read

Most PUT DATA objects are readable by anything that can open the reader — that is what SP 800-73-4 pt1 Table 3 says, and a certificate is public anyway. Four are not:

ObjectName
5FC103Cardholder Fingerprints
5FC108Cardholder Facial Image
5FC109Printed Information
5FC121Cardholder Iris Images

ykman piv objects export 5fc103 - on one of those needs a VERIFY first, or the card answers 6982. The refusal comes before the lookup, so an empty object and a populated one are indistinguishable without the PIN. The management key does not substitute for it: writing these is management-gated, reading them is PIN-gated, and the two are separate conditions.

5FC109 has a second job: it is where a PIN-protected management key is read back from. While protection is on, PRINTED is that escrow and nothing else — the read answers with the key, synthesized from slot 9b, and a write of any other content is refused with 6985 rather than accepted and hidden underneath it. Revoke the protection first (ykman piv access change-management-key without --protect, or set a new key any other way) and it is ordinary storage again. Note that ykman’s own revoke clears PRINTED as its first step, so anything stored there before you turned protection on does not survive the round trip.

The one write the card never keeps is a PivmanProtectedData body — exactly 88 L { 89 L <key> }, what --protect sends — which is acknowledged and dropped, because the key it carries is already sealed in 9b and a second copy would sit in flash in plaintext. The match is on that exact shape, so printed information that merely happens to contain those tags is stored like anything else. One consequence worth knowing: --protect fails on a card that already has other content in PRINTED, because ykman tries to parse it as an escrow record. Clear the object first.

Attestation

ykman piv keys attest 9a attestation.pem

Proves a slot key was generated on-device, not imported. The attestation certificate is signed on-card by the f9 key (a P-384 CA key, self-signed at first boot) and carries the standard Yubico OIDs: firmware version, device serial, and the slot’s pin/touch policy. Subject/issuer names are C=ES, O=RS-Key, CN=RS-Key PIV …. Read the f9 CA cert with:

ykman piv certificates export f9 attestation-ca.pem

Attestation only works for generated keys; an imported key returns 6A80 / WRONG DATA (there is nothing to attest). For the FIDO side of attestation (org-provisioned enterprise attestation) see attestation.md.

An interrupted keys import or keys move fails closed: the target slot’s metadata is dropped before the new key is written, so a power cut between the two leaves a slot that reads as empty (GET METADATA6A88) rather than one whose key and its recorded provenance disagree. Re-run the import; the slot works again once it completes. That ordering is what keeps attestation honest — it can never certify an imported key as generated on-device.

Move and delete keys

ykman piv 5.7 can move a key (with its certificate and metadata) between slots, or delete it:

ykman piv keys move 9a 82          # 9a → retired slot 82, cert + metadata follow
ykman piv keys delete 9c           # wipe the signature slot's key

Moves go both ways — a key parked in a retired slot can come back to an active one. Moving a key onto its own slot is refused, because the source-delete would erase what the move just wrote. Both operations require management-key auth.

Use it

The card shows up as a standard PIV token; nothing here is RS-Key-specific.

  • PKCS#11 (browsers, VPNs, SSH, age): point the app at OpenSC’s opensc-pkcs11.so, found at /usr/lib/x86_64-linux-gnu/opensc-pkcs11.so on Debian, /usr/lib/opensc-pkcs11.so on many distros, or the Nix store path under NixOS (linux.md).

  • SSH via PKCS#11:

    ssh-keygen -D /usr/lib/opensc-pkcs11.so          # print the slot 9a public key
    ssh -I /usr/lib/opensc-pkcs11.so you@host        # log in with it (touch + PIN per policy)
    

    For an ed25519-sk hardware SSH key the FIDO path is simpler (see ssh.md). PIV-over-PKCS#11 is the route when you need an RSA or NIST-curve key, a smart-card-login certificate, or a server that wants a real X.509 chain.

  • age encryption: age-plugin-yubikey drives PIV slots directly for identity files but, like ykman, keys off the “Yubico YubiKey” reader name, so it wants the opt-in VIDPID=Yubikey5 build; on the default RS-Key build use any PKCS#11-aware age build against opensc-pkcs11.so.

  • ECDH / key agreement (9d and retired slots, P-256/P-384 and X25519): ykman piv ... exposes it (ykman piv keys calculate-secret for X25519); at the wire level it is GENERAL AUTHENTICATE with tag 0x85, the operation yubico-piv-tool and OpenSC use for decryption.

  • Windows / macOS native smart-card stacks pick the PIV applet up as-is; macOS CryptoTokenKit binds its pivtoken.appex to the reader (interop.md).

At rest

PIV private keys are stored AES-256-GCM-sealed under the device root (the sealed blob is nonce ‖ ciphertext ‖ tag, authenticated against the device serial). Once the OTP master key is fused, a flash dump does not yield key material; before that burn, the seal’s root derives from on-chip state an attacker with the flash and chip could reconstruct, so at-rest protection is only meaningful after provisioning (see threat-model.md). The seal is bound to the device, not the slot, so a keys move re-homes the blob verbatim (no re-encryption).

Factory reset (PIV only)

ykman piv reset

Wipes PIV keys, certificates and PINs only; the other applets are untouched. The reset is only accepted once both the PIN and the PUK are blocked; ykman blocks them for you first. To wipe every applet at once (PIV included), use rsk offboard, which blocks PIN+PUK then resets PIV as part of a full-device wipe with a signed receipt (see fleet.md).

9000 from the reset means every PIV file is gone — private keys, public-key caches, certificates, the data objects a host wrote through PUT DATA, and the PIN/PUK files, which are then re-seeded to the factory defaults. The sweep runs until the enumeration comes back empty rather than for a fixed number of passes, and its safety budget counts files actually deleted, so neither a card stuffed with PUT DATA objects nor one whose flash holds many superseded copies of them can outrun it.

If the flash refuses a delete — or reports an enumeration it could not finish, so “nothing left” cannot be proven — the command answers 6581 (MEMORY_FAILURE) instead of claiming a wipe it did not complete. Treat that as “the card is still provisioned” and retry: a failed reset still re-creates the PIN, PUK and retry-counter files it deleted first, so the next attempt behaves like the first one instead of answering 6A88 for the rest of the power cycle.

There is no rsk piv command group: PIV is provisioned entirely through ykman piv / yubico-piv-tool / PKCS#11, with rsk only involved for a whole-device offboard.

Troubleshooting

  • ykman can’t connect → linux.md (pcscd + polkit + the disable-ccid scdaemon note).
  • ykman stops seeing the card after gpg used it → scdaemon grabbed the raw CCID interface; apply disable-ccid and gpgconf --kill scdaemon (openpgp.md).
  • PIN blockedykman piv access unblock-pin (needs the PUK). PUK blocked too → only ykman piv reset recovers, and it wipes the slots.
  • ykman piv keys attest fails with INCORRECT PARAMS → the key in that slot was imported, not generated; attestation is generated-keys-only.
  • change-management-key rejects 3DES on the FIPS-style build → expected; set an AES-128/192/256 key instead.
  • RSA-2048 generate takes a few seconds (≈ 4–6 s on the reference board, ≈ 10 s on other boards, occasionally longer since the prime search is random) → that’s the prime search on this hardware, not a hang; the device keeps the CCID connection alive with keepalives.

OATH — TOTP / HOTP codes

The device stores authenticator-app secrets and computes the 6/8-digit codes on-card, over Yubico’s YKOATH protocol (CCID). The HMAC secret is written once and never leaves the chip again: every code is derived inside the firmware and only the digits come back. Up to 255 accounts.

Clients are the stock Yubico tooling: ykman oath on the command line and the Yubico Authenticator desktop/mobile app over USB. There is no rsk oath subcommand; OATH is driven entirely through those. Both ykman oath and the Yubico Authenticator gate on a Yubico YubiKey reader name, so they only see the device on the opt-in VIDPID=Yubikey5 build; the default RS-Key build enumerates as RS-Key Security Key (VID:PID 0x1209:0x0001) and stays invisible to them. Build that flavor when you want to drive OATH from the Yubico tools (build.md).

On Linux this needs pcscd running plus the polkit rule from linux.md; if you also use gpg, the disable-ccid line there keeps scdaemon from grabbing the reader and locking ykman out.

ykman oath info        # applet version + whether an access password is set

Add accounts

# Interactive: paste the base32 secret when prompted.
ykman oath accounts add github --issuer GitHub

# Straight from an otpauth:// URI (everything — secret, issuer, digits,
# algorithm, period — is parsed out of the URI):
ykman oath accounts uri 'otpauth://totp/GitHub:me@example.com?secret=BASE32SECRET&issuer=GitHub'

The account name shown in lists is issuer:account (here GitHub:me@example.com). Most sites hand you the secret two ways at enrollment: a QR code and a “can’t scan it?” base32 string. Either works:

  • base32 stringaccounts add (or accounts uri if they give the full otpauth:// link).
  • QR code on screenykman oath accounts uri -- reads it from the primary display, or the Yubico Authenticator GUI has a Scan QR code button that grabs whatever QR is visible.

Options that matter:

OptionEffectDefault
--touchcomputing this account’s code needs a button pressoff
--oath-type {TOTP,HOTP}counter-based vs time-basedTOTP
--algorithm {SHA1,SHA256,SHA512}HMAC hashSHA1
--digits {6,7,8}code length6
--period NTOTP step in seconds30
--counter NHOTP starting counter0
--forceoverwrite an existing account of the same name without asking

All three hashes are implemented on-card (SHA-1/256/512); RFC 6238/4226 test vectors for each pass in crates/rsk-oath. Adding a name that already exists overwrites the old secret in place (--force skips the prompt). There is one credential per name.

Get codes

ykman oath accounts code            # every TOTP account at once
ykman oath accounts code github     # one account by name substring

code with no name runs the bulk path (CALCULATE ALL). Two kinds of account are not computed there and show a placeholder instead:

  • Touch-required accounts are listed but not calculated. Name them explicitly (ykman oath accounts code github) and the firmware waits for the press, then prints the code. This is deliberate: a bulk read can never make a touch account leak a code without the button.
  • HOTP accounts are never computed in bulk either (it would silently burn the counter). Name them to step the counter once.

The Yubico Authenticator GUI shows all TOTP codes live and re-derives them each period. Touch and HOTP entries get a tap-to-reveal button instead.

Touch-required accounts

ykman oath accounts add aws --issuer AWS --touch
# existing account → re-add with --touch --force, or toggle it in the GUI

With --touch, the firmware refuses to compute that account’s code until the BOOTSEL button is pressed; a timeout or a declined press returns “security status not satisfied” and nothing is computed. For HOTP this gate sits before the counter advances, so a denied touch burns no counter. A refused press leaves the account exactly where it was. The button is the same physical press used by FIDO and OpenPGP UIF; only one prompt is outstanding at a time.

Accounts that only go forward

The YKOATH protocol has a second per-account property beside touch, only increasing, and the firmware enforces it the way a YubiKey does. A TOTP account carrying it remembers the highest challenge it has served and refuses anything at or below that, so its codes can only move forward in time. That is worth having against brief physical access: nobody can walk your clock backwards and harvest the windows you already used, and if someone does read a future window, your next legitimate code fails — which is how you find out.

The cost is that the account is only as usable as your clock. If the host’s time jumps backwards (a bad NTP correction, a timezone-confused VM, a dual-boot machine with a local-time RTC), the account stops answering until real time catches up, and there is no way to lower the mark short of re-adding the account. ykman does not expose the property today; a client that does will say so. Ordinary accounts are unaffected — the mark exists only where the property was set at enrollment.

The OATH access password

By default the credential list and codes are readable by anything that can reach the CCID interface. An optional access password gates the applet:

ykman oath access change            # set or change the password
ykman oath access remember          # cache it for this host (keyring)
ykman oath access forget            # drop the cached password

How it works on-card: the password becomes an HMAC key (PBKDF2 over the password, salted with the device serial, done host-side by ykman). On every fresh connection the card issues a random challenge; the host must answer with HMAC(key, challenge) before any account command is allowed. The card answers the host’s challenge with the same key (mutual proof). Selecting the applet again re-locks it. The compare is constant-time and full-length, so a truncated or guessed response can’t brute-force its way in one byte at a time.

Footguns:

  • The password gates listing and computing codes over CCID. At rest the secrets are protected separately: every credential record — name, secret, HOTP counter — and the access password’s own validation key are individually AES-256-GCM-sealed before they reach flash, under the device key kbase (accounts enrolled before that landed are re-sealed on the next boot). The password is not part of the sealing key, deliberately: codes have to compute without a separate at-rest unlock, exactly like PIV slot keys. So the seal is device-bound, not password-bound. Once the OTP master key is burned a flash dump on its own no longer yields your secrets (threat-model.md); what it never buys is protection from something that can drive this chip’s firmware — that is what the access password and --touch are for.
  • There is no recovery for a forgotten access password short of ykman oath reset, which wipes every account with it.
  • --touch per account and the access password are independent hardenings. Use either, both, or neither.

The OTP PIN and the password safe

Alongside the OATH accounts the applet can store a login, a password and a note per credential — the password-safe extension nitropy speaks. Those fields are secrets in a way a TOTP code is not: a code expires, a stored password does not.

They are read by GET CREDENTIAL, and the rule is that an OTP PIN, once set, is always required for it — on a fresh connection, whether or not an access password is also configured, and again after every re-select. VERIFY CODE is gated the same way. Set the PIN with nitropy nk3 secrets set-pin; the card demands a touch when it mints one, so a PIN cannot be planted on a key someone briefly picked up.

With neither a PIN nor an access password the store is open to anything on the CCID interface, exactly as the credential list is — that is the code-less default this applet shares with a YubiKey, not an oversight. If you keep passwords on the key, set one of the two.

A failed PIN attempt shuts the safe again, whether it arrives as a verify or as a PIN change with the wrong old PIN, and either way it spends a retry. So walking away mid-session no longer leaves the safe readable to whoever tries the PIN next: the three failed guesses that lock the card also close what the last correct PIN opened. A malformed request is not an attempt — it costs no retry and changes nothing.

Manage

ykman oath accounts list                       # account names (extended list also flags which need touch)
ykman oath accounts list -P                    # include the period column
ykman oath accounts rename github GitHub:work  # current name → new name
ykman oath accounts delete github              # remove one account
ykman oath reset                               # wipe the OATH applet only

rename rewrites the name in place and keeps the same secret and counter; renaming to a name that already exists is rejected. delete of an unknown name is a no-op error. reset clears all accounts, the access password, and the OTP password-PIN. Nothing outside OATH is affected (FIDO/PIV/OpenPGP survive). To wipe the whole key instead, see rsk offboard.

Notes

  • Secrets live in device flash, each record sealed to this chip (see the footgun above). Codes are computed on-card, so the secret never returns to the host after add.
  • OATH accounts are not covered by the seed backup and do not come back on a backup key: they are sealed to this chip, not derived from the FIDO seed. Keep your otpauth:// URIs/QRs somewhere safe, or re-enroll on loss. The device cannot export a secret once it is stored.
  • HOTP counters are persisted across reboots and continue from where they were. Touch-required HOTP accounts only advance the counter after the touch, so there are no drive-by increments.
  • Enrollment is checked before anything is written, the way a YubiKey checks it: the secret is 14–64 bytes, the name 1–64, the code 6/7/8 digits, the hash one of SHA1/SHA256/SHA512. A client that sends anything else gets 6A80 and nothing is stored — in particular, a rejected add over an existing account leaves that account working instead of replacing it with one that can never produce a code. ykman and Yubico Authenticator stay inside these bounds; a client that does not was writing an account no authenticator app could ever use.
  • OATH interop (add → list → calculate → delete, plus TOTP crypto-verified against RFC vectors, via both ykman oath and Yubico Authenticator) is tracked in interop.md.

Troubleshooting

  • ykman finds no reader / “Failed to connect”: on Linux this is almost always scdaemon holding the CCID interface after a gpg call. Apply the disable-ccid line from linux.md and run gpgconf --kill scdaemon, then retry.
  • Codes are rejected by the site: TOTP depends on the host clock. The card has no battery-backed time and trusts the timestamp ykman/the GUI sends. Fix the host’s clock (NTP) and re-read. For HOTP, a code is rejected once the server counter has moved past yours; resync on the server side.
  • “Touch” account prints nothing under accounts code: that’s expected. Bulk read skips touch accounts. Name the account so the firmware prompts for the press.
  • Forgot the access password: there is no unlock; ykman oath reset is the only way out, and it deletes every account.

OTP slots — Yubico OTP, challenge-response, static passwords

The YubiKey “OTP” feature: four slots, each holding one credential, output by a button press or read over USB. Program slots 1–2 with stock ykman otp. Slots 3–4 are two extra slots reached over the same protocol with a slot offset.

ykman otp needs the opt-in Yubico flavor. ykman gates on the “Yubico YubiKey” reader name. Only the opt-in VIDPID=Yubikey5 build presents it. The default RSKey build (VID:PID 0x1209:0x0001, reader “RS-Key”) is invisible to it. The HID-keyboard typing of a slot’s output is identity-independent and works on either build. Only programming and reading slots over ykman needs the Yubico flavor.

Not the RP2350 fuses. This page is the Yubico one-time-password feature. The chip’s One-Time-Programmable fuses (secure-boot key, master sealing key) are a different thing with the same three letters. See otp-fuses.md.

The device is also a USB keyboard: pressing the button types the selected slot’s output wherever the cursor is. That keyboard is a third USB interface (standard boot keyboard). It sits after the FIDO HID and CCID interfaces. If your OS asks about a new keyboard at first plug, that’s this one.

flowchart LR
    press["button press"] --> kbd["USB HID keyboard"] --> types["types the slot output"]
    ykman["ykman otp"] --> c1["CCID / HID frame"] --> s12["slots 1-2"]
    raw["slot-offset APDU"] --> c2["CCID"] --> s34["slots 3-4"]

Slot selection by clicks

The firmware runs a click counter: clicks landing within 1 second of each other count toward one gesture. The gesture types that slot when the window closes.

A press the key asked for — a touch confirming a signature, a PIN change, a reset — is not a click, even if you keep holding it after the operation finishes: its release belongs to that ceremony. Only a press made while the key is idle counts toward a gesture.

ClicksSlotykman otp
1 (short)1yes
22yes
33slot-offset APDU only
44slot-offset APDU only

ykman otp only knows slots 1 and 2, the classic short/long-press pair. Slots 3 and 4 use the same wire protocol with a 1- or 2-step slot offset (the firmware addresses all four as one contiguous block). A tool has to send that offset itself. Most setups never need them. Everything below targets 1/2 unless noted.

What each slot type does

Four credential types share the slots. Only some type on a press:

TypeOn button pressOver USB (ykman otp calculate)
Yubico OTPtypes a 44-char modhex OTP
Static passwordtypes the stored password
OATH-HOTPtypes the 6/8-digit code
Challenge-response (HMAC-SHA1)nothinganswers the challenge
Challenge-response (Yubico mode)nothinganswers the challenge

A challenge-response slot types nothing on a press. The button only gates the USB calculate when the slot was programmed touch-required. So a slot can hold a Yubico OTP or a static password or a HOTP secret or a challenge-response secret, not several at once.

Program a slot

ykman otp info                                                   # what's in each slot
ykman otp yubiotp 1 --serial-public-id -g -G                     # classic Yubico OTP (random ids + key)
ykman otp chalresp 2 --generate --touch                          # HMAC-SHA1 challenge-response
ykman otp static 1 --generate --length 38                        # typed static password
ykman otp hotp 2 --digits 6 'base32secret'                       # OATH-HOTP
ykman otp swap                                                   # swap slots 1 and 2
ykman otp delete 2                                               # clear a slot

Notes on the options:

  • --touch (chalresp) sets the touch-trigger bit: the USB calculate then waits for a button press and refuses without one. Touch-typing slots (Yubico OTP, static, HOTP) always need the press anyway. That’s the only way to fire them.
  • yubiotp: -S/--serial-public-id uses the device serial as the public id, -g/--generate-private-id randomises the private id, -G/--generate-key randomises the AES key. All three together give a self-contained random credential.
  • --length on a static slot is the typed-password length, max 38 characters (the slot stores 38 password bytes). --length 38 fills it. By default static only uses the modhex alphabet (cbdefghijklnrtuv) so the password types the same on any keyboard layout. --keyboard-layout us (etc.) widens the character set at the cost of layout-dependence.
  • Reprogramming a slot is destructive. The new secret overwrites the old. There is no “read it back.” Yubico OTP and chal-resp secrets are random and exist only on the device once programmed (see Validation below for the exception).
  • Access code: set one with ykman otp settings <slot> --new-access-code. The firmware then refuses to overwrite, update, delete, or swap that slot unless the code is presented (ykman otp --access-code <hex> …, given before the sub-command). Lose the code and the only way out is a factory reset of the OTP applet.

ykman otp reaches the device over the HID frame protocol on the keyboard interface (and over CCID). Both work without any PIN. OTP slots are not PIN-protected, only optionally access-code-protected.

Challenge-response from software

ykman otp calculate 2 <hex-challenge>     # HMAC-SHA1, returns the 20-byte HMAC

This is the KeePassXC / LUKS pattern: the database (or disk) key is mixed with the slot’s HMAC of a fixed challenge. --touch slots wait for a press first. The keyboard interface answers the same protocol, so tools written for YubiKeys (“YubiKey challenge-response” in KeePassXC, ykchalresp, the pam_yubico HMAC mode) work unchanged.

Those tools are the ykpers/ykcore family, and on Linux they talk to the key over raw libusb rather than through the kernel HID stack. That path has two requirements a stock YubiKey satisfies implicitly:

  • A Yubico VID. KeePassXC filters on Yubico’s (and OnlyKey’s) vendor id and a fixed PID list, so a default 0x1209 build is invisible to it — build the firmware with VIDPID=Yubikey5 (see build.md).
  • libusb access to the device. Install Yubico’s udev rules (69-yubikey.rules / 70-yubikey.rules, packaged as yubikey-personalization on most distributions); without them libusb cannot open the device at all.

ykman otp calculate is not affected by either — it goes through hidraw.

Since bcdDevice 0x085A the frame protocol also answers on the FIDO interface, the way a YubiKey does, so a tool that addresses OTP by interface index finds it whatever the enabled interface set looks like. Turning the keyboard interface off in the phy record still takes it away from both.

Two challenge-response modes exist:

  • HMAC-SHA1 is the common one. Variable-length challenges (HMAC_LT64) are supported with the classic YubiKey trim quirk: a short challenge is padded by repeating its last byte, and a challenge that ends in its own last byte loses that tail. KeePassXC and ykchalresp -H already account for this.
  • Yubico mode is a 6-byte challenge, AES-encrypted with the slot key after mixing in the device serial. Rarely used. HMAC-SHA1 is what tools expect.

Yubico OTP validation

A Yubico-OTP slot types 44-modhex one-time passwords (a 6-byte public id in clear, then an AES-128-ECB block over the private id, a 16-bit usage counter, a session counter, an uptime stamp, two random bytes and a CRC). A validation server decrypts and replay-checks them.

  • The usage counter advances every power-up and every session wrap, so a token never repeats across reboots: the standard replay defence.
  • Public validation (YubiCloud) requires uploading the slot’s AES key to Yubico. --generate-key prints the key, public id and private id at programming time. Capture them then, because they cannot be read back later.
  • Self-hosted validation (yubikey-val, or a custom verifier) keeps the AES key on your own server. Nothing leaves the building.

Static passwords

A static slot types a fixed string on each press. Handy for a long machine password or a prefix you append a moving code to. It is not a secret store: anyone who can press the button gets the password typed out, and a static password is replayable by definition. The string is typed as raw HID scancodes, so it is keyboard-layout independent up to the characters ykman will accept.

OATH-HOTP slots

An OTP-slot HOTP credential types an RFC 4226 code (6 or 8 digits, --digits) and advances its counter on each press. This is the typed HOTP, distinct from the full OATH applet, which stores many TOTP/HOTP accounts and is read by an authenticator app rather than typed. Use a slot when you want one HOTP typed by a button. Use the OATH applet for a list of accounts.

Reading status / serial

ykman otp info               # slot 1/2 programmed? touch? type?
ykman info                   # device serial, firmware version, enabled apps

The serial that OTP reports is the YubiKey-style 8-digit serial derived from the chip id, the same value ykman info shows. A serial derived from the same chip id is mixed into Yubico-mode challenge-response, though it draws on more of the chip-id bytes than the reported 8-digit value, so the two aren’t byte-identical.

Disabling the keyboard interface

If the extra keyboard is unwanted (some KVMs or login screens dislike a button that types), ykman config usb --disable otp now genuinely turns OTP off:

ykman config usb --disable otp        # disables the OTP application (enforced)
ykman config usb --list               # confirm what is on
ykman config usb --enable otp         # reversible — turns it back on

The write persists to the Yubico management capability blob (EF_DEV_CONF) and the firmware enforces it live (no replug): a button press then types nothing, and Yubico / HMAC challenge-response is refused on both the keyboard frame protocol and the CCID OTP applet.

One caveat: the keyboard USB interface still enumerates. Whether the HID interface is present at all is decided at boot by a separate mask in the rescue phy record (EF_PHY / USB_ITF_KB), which a capability-bit change does not touch — so the OS still sees an (now inert) keyboard. To drop the interface itself, clear the USB_ITF_KB bit in the rescue phy mask via the rescue applet (stock ykman can’t do it). If accidental typing was the only worry, --disable otp — or leaving the slots empty / behind an access code — already stops the presses.

Troubleshooting

  • ykman otp info hangs or errors right after gpg/ssh used the device → scdaemon is holding the CCID interface. gpgconf --kill scdaemon, then retry. See linux.md and openpgp.md.
  • A press types nothing → the slot is empty, or it’s a challenge-response slot (those never type). ykman otp info shows which.
  • ykman otp calculate returns CONDITIONS_NOT_SATISFIED / waits forever → the slot is --touch. Press the button.
  • Overwrite/delete refused → the slot has an access code. Pass it with ykman otp --access-code <hex> … (before the sub-command), or factory-reset the OTP applet.
  • Slots 3/4 don’t show in ykman otp info → expected. ykman otp only enumerates 1 and 2.
  • KeePassXC on Linux says Hardware key USB error: Pipe error, or ykchalresp fails while ykman otp calculate works → firmware older than bcdDevice 0x0859 enumerated the FIDO interface first, and these tools address the OTP interface as interface 0 unconditionally. Update the firmware. If the key isn’t listed at all, check the VID and udev requirements above.
  • A --touch slot works from ykman but not from KeePassXC, and afterwards even ykinfo fails for half a minute → firmware older than bcdDevice 0x085B stayed in the touch wait once a host had probed the slot and moved on, and answered “would block” to every command until it timed out. Update the firmware. A non-touch slot never hit this.

Notes and limits

  • OTP slot secrets are not covered by the seed backup and not by a primary/backup-key pair. They’re sealed to this chip. Re-program the slots on a replacement board, or keep the AES keys / HOTP seeds you generated somewhere safe.
  • Touch-triggered typing always requires the physical press. Only the challenge-response USB path honours each slot’s touch-trigger flag.
  • No PINs here. Anything that can reach the USB interfaces can press-by-proxy a typing slot and can run calculate on a non-touch chal-resp slot. Make the sensitive ones --touch, and put an access code on slots you don’t want silently reprogrammed.

Seed backup — BIP-39 / SLIP-39

Hardware-wallet-style backup of the FIDO master seed. The 32-byte seed is exported once and rendered as words. Later it can resurrect your deterministic FIDO identity on a fresh board. Every non-resident credential (ssh ed25519-sk keys, classic 2FA registrations) derives from the seed. After a restore the same key files and registrations keep working.

Not covered (sealed to the chip, not derivable from the seed): resident passkeys, OpenPGP keys, PIV keys, OATH accounts, OTP slots.

Two ways to stay recoverable

Seed backup is one strategy. A primary + backup device pair is the other. They are complementary:

  • Back up the seed (this page): one identity, kept recoverable by its mnemonic. Restore it onto a replacement board to resurrect the same credentials. Simple, but it is a single secret, and resident passkeys / PIV / OpenPGP don’t come back.
  • Primary + backup pair (backup-key.md): two independent keys with different seeds, both registered on every account. Lose one and the other already works. No restore, no shared secret to leak. The cost is registering both keys everywhere (and enrolling resident passkeys on each).

Many people do both: two devices and a written mnemonic for each.

Export (once, at setup)

rsk backup export --scheme bip39                       # 24 words
# or Shamir shares — any 2 of 3 reconstruct:
rsk backup export --scheme slip39 --threshold 2 --shares 3

Gates, all enforced by the firmware: an encrypted transport channel, a touch, the FIDO PIN (when set), and the setup window (below). Write the words on paper. The host that runs the export sees the seed, so do this on a machine you trust.

rsk backup finalize        # seals the export window — typed confirmation
rsk backup status

After finalize, export is refused forever (until a factory reset makes a new seed). That’s the anti-exfiltration gate: malware on a later host cannot quietly re-export your seed. Corollary: lost words cannot be re-exported either, so pick a SLIP-39 share count with margin (2-of-3 minimum, 3-of-5 for the paranoid).

Seed-backup export window — a device starts with No seed; first boot or a factory reset provisions a seed and Opens the export window, during which rsk backup export works given touch and the FIDO PIN/UV; rsk backup finalize moves it to Finalized, where export is refused; a factory reset regenerates a new seed and reopens a fresh window

Restore (onto any RS-Key board)

rsk backup restore --scheme bip39          # prompts for the 24 words
# or: --scheme slip39                      # prompts for ≥ threshold shares

Touch + PIN gated. The incoming seed is re-sealed under the destination device’s own root. A restored board is cryptographically indistinguishable from the original for every derived credential. Restore overwrites the destination’s auto-generated seed (it warns; a fresh board loses nothing).

After restoring: your ~/.ssh/id_*_sk files log in again, 2FA registrations answer again. Resident passkeys do not come back, so re-enroll those.

The TUI

rsk-tui has the same export/restore/finalize flows interactively (BIP-39 only; SLIP-39 stays in the CLI), with the seed phrase revealed on-screen and zeroized after.

Mechanics (for the curious)

The seed crosses USB only inside an ephemeral encrypted channel: P-256 ECDH → HKDF-SHA256 → ChaCha20-Poly1305, fresh nonce per message. The mnemonic encodings are entirely host-side. BIP-39 (24 words) and SLIP-39 (Shamir T-of-N) both encode the same raw 32 bytes, so either reconstructs the seed. The setup-window flag lives with the seed’s lifecycle: cleared when a seed is generated (first boot, factory reset), set by finalize.

Backup key — a primary + backup pair

A single security key is a single point of failure. Lose it, break it, leave it at the office, or have it stolen, and you are locked out of everything it was your only key for. The fix is the one hardware-key vendors recommend: keep two keys, a primary you carry and a backup you store somewhere safe, and register both with every account. Lose one and the other already works, with no recovery dance.

This page is the why and the how. The seed-backup mnemonic (seed-backup.md) is a different, complementary safety net. The two are compared at the end.

Why two independent keys

The pair are two separate devices with different seeds, not one identity copied onto two sticks. That independence is the point:

  • No single point of failure. A primary that is lost, bricked, or left behind doesn’t lock you out. The backup is already enrolled everywhere.
  • No shared secret. Because the seeds differ, compromising one key tells an attacker nothing about the other. (Cloning the same seed onto both would make a single leak break both, the opposite of what a backup is for.)
  • It is how WebAuthn is meant to be used. Every serious account lets you add more than one security key precisely so you can enroll a backup. You are using the platform’s built-in redundancy, not working around it.
  • Resident credentials aren’t seed-portable anyway. Passkeys, PIV, and OpenPGP keys are sealed to the chip, so even with a seed backup you would re-enroll them on a replacement. Two live keys sidestep that entirely.

The model

Primary and backup key redundancy — the primary key (seed A, everyday carry) and the backup key (seed B, stored offsite) are each enrolled as separate credentials at every account (GitHub, Google, …); if the primary is lost you sign in with the backup, so no single lost device locks you out

Both keys are enrolled on every account. Day to day you use the primary. The backup sits in a drawer or a safe. If the primary is gone, the backup logs you in to remove the lost key and enroll a fresh replacement. No downtime, no restore.

Set it up — rsk pair

rsk pair walks you through it. It reads each device in turn (touch-free), confirms they are two different physical keys, and prints the checklist:

rsk pair          # plug in the primary, then the backup, when prompted

Then, working down the checklist:

  1. Give each device its own PIN (in your browser / OS security-key settings).
  2. Back up each seed separately. Two independent seeds means two different mnemonics (seed-backup.md). Do rsk backup export with each device, then rsk backup finalize.
  3. Register both keys on every important account. In each service’s security-key settings, add the primary and the backup. Don’t stop at the accounts you remember. Email and your password manager first, since they gate everything else.
  4. Store the backup key somewhere separate from the primary (a different building is ideal, since fire and theft take whatever is in one place).
  5. Test the backup by signing in with only it, once, before you rely on it.

Different seeds — on purpose

Each RS-Key generates its own seed on first boot, so two fresh keys are already independent. You don’t have to do anything to make their seeds differ. The one way to accidentally defeat this is to rsk backup restore the same mnemonic onto both, which turns them into clones. Don’t. If you want the same identity on a replacement board, that’s the seed-backup flow, not the pair flow.

rsk pair can’t cryptographically prove the seeds differ. RS-Key credentials are randomized (a fresh key handle per registration), so there is no stable seed-derived value to compare between two devices, and the device attestation key is per-chip, not per-seed. The wizard confirms two distinct physical devices and relies on the self-generated-seed property above. If you want to check by hand, rsk backup export both and compare the phrases (they must differ).

If you lose a key

Losing one of a registered pair is a routine event, not an emergency:

  1. Sign in with the surviving key. It is already enrolled everywhere.
  2. Remove the lost key from each account. In each service’s security-key settings, delete the missing authenticator so it can no longer be used.
  3. Get a new key and make it the new backup. rsk pair again with the survivor as the primary, then enroll the new one across your accounts.

A stolen key is gated by its PIN (a few wrong tries lock it), but removing it from your accounts is what actually retires it. Do that promptly.

This vs. the seed-backup mnemonic

Primary + backup pairSeed-backup mnemonic
What it protectslive access, a second key already enrolledone identity, recoverable later
Recover bygrabbing the backup key (no steps)restoring the phrase onto a new board
Secret exposurenone shared, two independent seedsthe seed passes through the host at export
Covers passkeys / PIV / OpenPGPenroll them on each keyno (sealed to the chip)
Costregister both keys everywherewrite the words down, keep them safe

They are complementary. The pair gives you redundancy with zero recovery effort. The mnemonic resurrects an identity onto a replacement when you only had one key. Many people do both: two keys, and a written mnemonic for each.

Soft-lock — at-rest seed lock

Optional hardening: with the lock engaged, the FIDO master seed exists in flash only encrypted to a 32-byte key that you hold (as BIP-39/SLIP-39 words or hex). A stolen board refuses every FIDO operation until that key is presented, even powered up, even running genuine firmware. Your identity becomes device + words, two factors.

This is the same idea as a wallet passphrase. It composes with (does not replace) the silicon protections: once provisioned, the OTP root and secure boot (production.md, otp-fuses.md) stop flash-dump and foreign-firmware attacks. The soft-lock additionally stops your own device in the wrong hands.

Needs firmware with soft-lock support (bcdDevice >= 0x0742). Older builds answer rsk lock status with “firmware too old”. Check with rsk status.

Soft-lock state machine: from power-on the device is Sealed (device-root-sealed seed); rsk lock enable (PIN + touch) moves it to Locked, where FIDO operations are refused and the seed carries an extra ChaCha20-Poly1305 wrap; rsk lock unlock with the 256-bit key moves it to Unlocked with the key held in RAM; a power cycle returns it to Locked and zeroizes the RAM key, and rsk lock disable (PIN + touch) returns it to Sealed

Prerequisite: a FIDO2 PIN

enable and disable ride authenticatorConfig, which the firmware always gates on a pinUvAuthToken with the acfg permission. So a FIDO2 PIN must already be set, and you pass it with --pin. With no PIN configured the command stops with “authenticatorConfig needs the acfg pinUvAuthToken”. Set one first:

rsk fido set-pin           # see fido2.md

unlock is the exception: it needs neither PIN nor touch (below).

Enable

rsk lock enable --pin 1234             # PIN + touch gated; typed confirmation

Generates a random 32-byte lock key, prints it once (default 24-word BIP-39), wraps the seed value with ChaCha20-Poly1305 under it into flash, and deletes the plaintext-sealed copy. Touch the device (BOOTSEL button) when prompted. Treat the key like the backup words: paper, not a file.

Choose how the key is rendered:

--schemeWhat it printsReconstruct with
bip39 (default)24 wordsthe same 24 words
slip39Shamir shares (--threshold/--shares, default 2-of-3)any threshold shares
hex64 hex charactersthe same hex
rsk lock enable --pin 1234 --scheme slip39 --threshold 2 --shares 3

--key-out FILE also writes the raw key hex to a 0600 file. A test/CI convenience, not for production: it defeats the point of holding the key only on paper.

The wrap is over the seed value, independent of the at-rest format tag and of the kbase the plain file was sealed under. So locking and the OTP re-sealing (otp-fuses.md) stay orthogonal.

Daily use — unlock at power-up

rsk lock status            # locked? unlocked this session?
rsk lock unlock            # prompts for the 24 words; seed goes to RAM only

status prints four flags read straight from the device:

FlagMeaning
sealedthe one-time backup-export window is closed (seed-backup.md)
has_seeda plaintext-sealed seed is on flash (false while locked)
lockedthe wrapped blob is what’s stored — an unlock is required
unlockeda RAM copy from this power cycle’s unlock is live

The lock re-engages at every power cycle: the unlocked seed lives only in RAM and is zeroized on unplug. While locked with no unlock this session, the seed loader fails and the firmware errors out of every credential operation: registration (makeCredential) and assertion (getAssertion/U2F) alike. Browsers show a generic failure and ssh says the key refused, until you unlock.

Unlock takes the key the same three ways and can run headless in a script:

rsk lock unlock --scheme bip39 --mnemonic "word1 word2 … word24"
rsk lock unlock --key-hex 0011…  # 64 hex chars
rsk lock unlock --scheme slip39  # prompts for shares, one per line, blank to finish

Unlock needs no PIN and no touch. Knowledge of the 256-bit key is the authorization (it is verified by the AEAD decrypt succeeding). A wrong key fails closed with unlock failed: 0x… (wrong key?) and leaves the device locked. Unlocking a device that isn’t locked reports device is not locked.

Disable

rsk lock disable --pin 1234            # needs an unlocked session + PIN + touch

Disable proves you hold the key by requiring the seed already unlocked this power cycle, then writes it back plaintext-sealed (device-root-sealed) and deletes the wrapped blob. If you haven’t unlocked yet, pass the key and disable unlocks first:

rsk lock disable --pin 1234 --mnemonic "word1 … word24"
rsk lock disable --pin 1234 --key-hex 0011…

Calling disable without an unlock prompts for the lock key (or pass --mnemonic/--key-hex). Supply nothing valid and it fails with the lock still engaged.

How it composes with seed backup

The soft-lock and the seed backup are independent (backup exports/imports the seed value, the lock wraps that same value), but their ordering matters:

  • Back up before you lock. A mnemonic taken before ENABLE still restores the original identity onto a fresh board later.
  • Restore is refused while locked. rsk backup restore (firmware BACKUP_LOAD) returns “not allowed” on a locked device. A restore next to a live wrapped blob would leave two competing seeds. disable (or a reset) first.
  • Export works once unlocked. With the seed unlocked this session, rsk backup export serves the in-RAM copy normally (subject to the one-time export window).

Lost the lock key?

Unrecoverable by design. The way forward is a FIDO factory reset, which deletes the locked blob and generates a fresh identity (ykman fido reset, which needs the opt-in VIDPID=Yubikey5 build, or any WebAuthn “reset security key” UI on the default build). Replug the key first: a screenless build accepts the reset only within ten seconds of a power-up (see fido2.md). Your seed backup, if you made one before locking, still restores the old identity afterwards. Without it the old credentials (ssh ed25519-sk keys, U2F registrations) are gone.

Honest caveats

  • The flash log keeps the superseded plaintext-sealed record until natural compaction overwrites it. The at-rest guarantee hardens over time after ENABLE rather than instantly. (That lingering record is still sealed to the device root, so this only matters against an attacker who has also defeated the OTP tier; see threat-model.md.)
  • A compromised host at unlock time cannot read the key from the wire (the channel is an ephemeral ChaCha20-Poly1305 tunnel) or the seed (never leaves the device). But while the device sits unlocked and plugged in, it can drive normal FIDO operations, like any session. Unplug when done.
  • The lock protects the FIDO seed only. OpenPGP, PIV, and OATH keys are sealed to the chip independently and are not gated by it. To gate those, rely on their own PINs and on the OTP/secure-boot tier.

LED

The status LED is the device’s only display. On the reference board (the Waveshare RP2350-One), it’s a WS2812 addressable RGB on GPIO16.

Build-time knobs

Three hardware properties of the indicator are compile-time knobs set by build flags. A fourth, MAX_LEDS, sets the upper bound for the PIO buffer. The actual number of connected LEDs is configured at runtime via rsk hw --led-num (or PicoForge) and must be ≤ MAX_LEDS.

KnobDefaultWhen to change it
LED_KINDws2812ws2812 (addressable RGB, default), gpio (plain on/off), pimoroni (3-pin PWM RGB), or none (no indicator). See build.md.
LED_PIN16A board whose addressable LED is on a different GPIO (0..=29).
LED_ORDERrgbA WS2812 board with swapped red/green: set grb (the WS2812B standard). The Waveshare RP2350-One is rgb; most other parts are grb.
MAX_LEDS1A board with multiple daisy-chained addressable LEDs: set it to the chain length (max 64). Default 1 is a single onboard LED. The actual connected count is set at runtime with rsk hw --led-num.
# example: build for a 4-LED board with standard GRB order
env MAX_LEDS=4 LED_ORDER=grb cargo build --release -p firmware
# then set the runtime count (persists across reboots):
rsk hw --led-num 4

Once built, a non-none build compiles all backends, so the pin, driver, wire order, and LED count are runtime-changeable (no reflash) with rsk hw (build.md). The build knobs set the boot defaults.

What the LED shows (colour, brightness, and the visual effect) is runtime-configurable separately, covered next.

Effects

Each of the four states can run one of several animated effects. The effect determines how the LED(s) display the state’s colour and brightness. All effects work with any number of LEDs. vapor and sparkle shine on a single LED too. bounce and flow naturally reduce to a static colour or a single pixel when there is only one LED.

Effects only render on the ws2812 backend (addressable RGB). The gpio and pimoroni backends always use the classic on/off blink, regardless of the effect setting. They lack per-LED control and pixel-level colour.

EffectIDWhat you seeSuits
legacy0Classic on/off blink (TIMING table)Original blink behaviour
vapor1All LEDs breathe together, smooth triangle-wave brightnessIdle (default)
bounce2A wide hump of light glides back and forth with half-step interpolationTouch (default)
flow3Yellow→red gradient flowing left to right with a trailing wakeProcessing (default)
sparkle4Each LED flashes an independent random colourBoot (default)

Default mapping (multiple LEDs)

StateDefault effectDefault colourMeans
idlevapor: gentle breathinggreenready, nothing in flight
processingflow: warm-colour flowyellow→red gradienthandling an APDU / crypto op
waiting for touchbounce: smooth bounceyellowpress the button to confirm
bootsparkle: random sparkleredthe brief power-up state

Status-LED cheat sheet — idle breathes green (vapor), processing flows a yellow-to-red gradient (flow), waiting-for-touch bounces yellow (bounce), and boot sparkles red (sparkle); the swatches and animations show each state’s default colour and effect

A few honest details:

  • No dedicated error colour. The firmware does not light a distinct “error” state. A failed operation just drops back to idle. Read the host tool’s exit code, not the LED, for success or failure.
  • The touch state needs the touch build. It is only ever shown on the default touch build. A no-touch build (--features no-touch) never enters it. The processing state still flashes during the operation either way (build.md).
  • Default brightness is gentle. 16 of 255 per channel, so the indicator is visible without being a flashlight. Turn it up if you want.
  • Boot is brief. You normally see it only for the moment between power-up and the first idle, so don’t tune your eye to it.

This is not the BOOTSEL / picotool state. Holding the button while plugging in puts the RP2350 in its ROM bootloader, where this firmware (and therefore this LED engine) isn’t running, so the LED is dark or shows whatever the ROM does. That mode is for flashing firmware and OTP, covered in build.md and otp-fuses.md.

Customize

Colour & brightness

Per-state colour and per-channel brightness are configurable. The values persist in flash (EF_LED_CONF) and apply live, no reboot:

rsk led --get                                  # print the current config
rsk led --status idle --color blue             # recolor a state
rsk led --status idle --brightness 64          # 0–255; 0 = that state goes dark
rsk led --status idle --color blue --brightness 64

touch cannot be switched off, and its colour is reserved. On a build without the trusted display the touch state is the only signal that the key is waiting for your consent. You can restyle it — colour, effect, speed, brighter — but the firmware normalizes four things on every write, whatever transport it came in on (rsk led, rsk led --transport fido, or a boot reload of the stored record):

  • --color off on --status touch becomes the default yellow.
  • --brightness 0 on --status touch is raised to 8.
  • --speed 1 is raised to 2. At 1 the breathing effect renders an all-black frame every tick while the brightness byte still reads fine.
  • no other state may wear the touch state’s colour. One that does is reset to its own factory look, whatever its effect, brightness or speed. The rule keys on colour alone because a brightness or speed one unit off is identical to the eye, and the effect is no signal at all in --steady mode or on a one-LED board.

Every other state still goes fully dark on --brightness 0. rsk led --get shows what the device is actually rendering, so read it back after a write that hits one of these rules.

There is one case where touch gives way instead. If the state wearing the touch colour has that colour as its own factory colour, resetting it would not resolve the clash, so touch reverts to its factory look (yellow, bounce). Only boot (red) and idle/processing (green) can trigger that:

rsk led --status touch --color red      # sticks — unless boot is red
rsk led --status touch --color green    # sticks — unless idle or processing is green
rsk led --status boot  --color blue     # …then a red touch is legitimate and kept

What this does not promise. Two states in different colours can still be hard to tell apart. On a single-colour (gpio) build hue collapses to lit or unlit; red, green and yellow are mutually confusable under red-green colour blindness; and on a one-LED board bounce, flow and steady mode all render the same solid frame. What separates the states there is the per-state blink timing (touch 1000/100 ms vs idle 500/500 ms), which no host write can change — but --steady suppresses blinking altogether, so on a single-colour build it leaves nothing to distinguish them. If the consent signal has to be unambiguous, use a trusted-display build, which names the operation on screen.

Effect & speed

Each state’s effect and animation speed are configurable the same way:

rsk led --status idle --effect vapor          # change the effect
rsk led --status touch --effect bounce --speed 15  # custom speed (ticks per step)
rsk led --status processing --effect legacy   # revert to classic on/off blink

--speed 0 (or omitting --speed) uses the effect’s built-in default.

--steady and --blink are global, not per-state: the firmware keeps each state’s timing internally, but a single flag decides whether any of them blink. --steady makes the whole indicator a solid lamp whose colour tracks the current state. --blink brings the blink patterns back.

rsk led --status idle --color cyan --steady    # solid cyan at idle, no pulse
rsk led --blink                                # back to the blink patterns

rsk-tui has a “cycle idle color” action that steps the idle state through the palette, plus “Read LED state”. For per-state colour, brightness, or the steady toggle, use rsk led.

Identify (CTAPHID wink)

A host can ask the key to point at itself — CTAPHID_WINK — which is how you tell two identical keys apart when both are plugged in. The indicator answers with four fast blinks over about half a second in the touch colour, then goes back to whatever it was showing.

rsk identify              # wink every attached key in turn, naming each
rsk identify --repeat 3   # three bursts each, if you looked away

rsk-tui has the same thing as “Identify this key” in Overview — that one winks the device the dashboard is showing, rather than walking them all.

The burst deliberately overrides the configured effect and --steady: the command is only useful if the key visibly flashes. It also uses the touch colour because that is the one state rsk led keeps above a visibility floor, so a key dimmed everywhere else still answers something you can see.

Two bounds follow from borrowing that colour. Repeated WINKs do not extend a burst already running — it always ends 600 ms after the first one — and a WINK arriving while the key is waiting for a touch shows the real prompt, not the burst. Without both, a host could hold the awaiting-touch indicator lit and forge the one consent signal a build without the display has.

A build with no indicator (LED_KIND=none, which includes the display build) does not advertise the wink capability at all, rather than accepting the command and doing nothing.

Selectors and values

FlagValues
--statusidle, processing, touch, boot (default idle)
--coloroff, red, green, blue, yellow, magenta, cyan, white
--brightness0255 per channel (0 = off, except --status touch, see above)
--effectlegacy, vapor, bounce, flow, sparkle
--speed0255 (0 = effect’s built-in default; 1 is raised to 2 on --status touch)
--steadysolid colour, no blinking, global, affects every state
--blinkthe opposite: restore blinking

Hardware wiring (rsk hw)

See the phy record spec for the full reference. The LED wiring (pin, driver, wire order) lives in the phy record, shared with PicoForge:

rsk hw --led-pin 22                     # move the WS2812/gpio data pin to GPIO22
rsk hw --led-driver gpio                # switch to a plain on/off LED
rsk hw --led-order grb                  # fix a red/green swap on a GRB part

By default rsk hw speaks CCID (PC/SC). On a host where pcscd can’t read or write the card, add --transport fido to do the same read-modify-write over the FIDO HID transport instead. It is gated by a device touch and, if a PIN is set, --pin (a pinUvAuthToken). rsk led takes the same flag. Wiring (rsk hw) applies on the next boot, so re-plug the device. Colours (rsk led) apply live:

rsk hw  --transport fido --touch-timeout 45   # wiring; approve with a touch
rsk led --transport fido --status idle --color blue   # colours, applied live

--touch-timeout has a floor of 10 seconds. A shorter window can expire while your finger is still on the button, and the next queued request would then inherit that same press as its consent. rsk hw refuses anything below 10, and the firmware raises it anyway if some other host writes the record directly.

It bounds the whole ceremony, not just the wait for a press: a key that confirms and then waits for the finger to lift stops waiting at the same deadline, so no one request occupies a button key for longer than the window. (A trusted-display key may add up to 3 s absorbing a finger already on the panel.)

Reset to defaults

rsk led --status idle       --color green  --brightness 16 --effect vapor
rsk led --status processing --color green  --brightness 16 --effect flow
rsk led --status touch      --color yellow --brightness 16 --effect bounce
rsk led --status boot       --color red    --brightness 16 --effect sparkle
rsk led --blink

Under the hood

rsk led talks to the firmware’s vendor applet over CCID (tools/rsk/led.py, firmware/src/vendor.rs):

  • SET LED (INS 0x10) packs brightness into P1 and colour + the steady bit + the target state into P2. When the caller sends 1–2 data bytes, they set the effect and speed for that state.
  • GET LED (INS 0x11) returns the whole config block: [steady:1, (effect:1, color:1, brightness:1, speed:1) × 4] (17 bytes).

The firmware writes the block to EF_LED_CONF and reloads it on every boot, so your settings survive a power cycle but not an OpenPGP/FIDO factory reset (those don’t touch this file). The led.rs module keeps per-status atomics that the render task reads live. SET LED updates them immediately, then persists the full block to flash — unless the record already holds exactly that block, in which case it answers 9000 and leaves the flash alone.

The touch rules above live in the block codec (crates/rsk-led), not in any one command handler, so they apply wherever a block is decoded: the CCID setter, the FIDO CONFIG_WRITE LED target, and the boot reload. The CCID path persists the normalized block; a FIDO CONFIG_WRITE stores your bytes as sent and normalizes them on the way to the pixels, so CONFIG_READ can echo a record the device is not rendering. rsk led --get (GET LED) always reports the rendered values.

For the wiring half (rsk hw), see the phy record spec. It writes to EF_PHY via the rescue applet and applies at next boot.

Troubleshooting

  • LED is dark and stays dark. Either the board has no addressable LED, or the data pin / driver is wrong for your wiring. Fix it live with rsk hw --led-pin N / --led-driver … (or rebuild with the right LED_PIN / LED_KIND, build.md). If a known-good board goes dark mid-session, the firmware task is likely wedged, not the LED.
  • Red and green look swapped. Wrong wire order for your LED part. Flip it with rsk hw --led-order grb (or build with LED_ORDER=grb). See the RGB-vs-GRB note above.
  • Only the first LED lights up; the rest stay dark. The board has multiple daisy-chained addressable LEDs, but the runtime LED count was never set. Run rsk hw --led-num <your count> to configure it (persists across reboots; the change applies after a warm reboot). If you need a higher buffer ceiling, rebuild with MAX_LEDS=<n>.
  • rsk led can’t reach the device. It needs the CCID interface up (pcscd on Linux). If gpg --card-status / rsk status also fail, fix that first (linux.md).
  • An app looks frozen. Check for the long-on yellow touch state and tap the button. If the LED is idle-green and the app is still stuck, it isn’t waiting on the device.

Trusted display

Experimental. An RS-Key variant for a screen-and-touch RP2350 board (the reference target is the Waveshare RP2350-Touch-LCD-2.8). The screen turns the key into a trusted display: the operations that matter (approving a sign-in, typing a PIN) happen on the device’s own glass, not on the host. A compromised or phishing host cannot fake what you see or capture what you type. Concretely:

  • An Approve / Deny prompt paints the real relying party for every signature. A signature cannot be obtained without a physical tap on a screen showing the true rpId.
  • PINs are entered on-screen (a FIDO clientPIN/UV, a device PIN, and the OpenPGP / PIV PINs over CCID) and never cross USB.
  • An on-device browser lets you inspect and prune credentials (delete a passkey, read the applet state) without a host.

The whole feature is dep:-gated. A standard key without a screen compiles none of the UI or driver code (the gate asserts the rsk-ui crate is absent from the default firmware image), so an ordinary build is byte-for-byte unaffected.

The trusted display’s Home screen: a bright “Ready” status beside a check, a status card reading USB connected / Device PIN set / Passkeys 0, and a bottom navigation bar with Home, Passkeys, Apps and Settings tabs

The full GUI uses always-on antialiasing. Text uses four-bit IBM Plex Sans and Mono coverage data. Icons, circles, status rings, rounded cards, and controls blend their edges into the surface below them. The device does this with integer math and retained panel writes. It does not use a framebuffer or a heap. A full page is recorded once as compact drawing commands. The firmware compares keyed 128-bit tags for 32×32 visual-state tiles and merges the changed tiles. Typed UI components can provide smaller exact damage before composition. Each changed rectangle uses two 8-row RGB565 buffers while PIO DMA sends the other buffer. Common background rows are copied from raster templates in flash instead of being generated for every band. The buffers use the active stack and do not reduce permanent RAM. RLE checkpoints and a vertical command index let each band start at its own data. One panel address window stays open for each rectangle. Small animations still use direct partial redraws, and there is no antialiasing setting to manage. A scene or SPI failure stops UI input before an incomplete prompt can stay active.

The display build runs the RP2350 at 160 MHz. A TX-only PIO program emits one serial bit every two cycles, for an 80 MHz panel clock. A complete 240×320 RGB565 transfer has a 15.36 ms wire-time floor.

Try it without a board

The screens on this page are not photographs — they are what rsk_ui::render draws, at the panel’s own 240×320. The same renderer runs in a window:

cargo run --manifest-path tools/emu/Cargo.toml --target "$HOST" -- --display

That is the whole flow, not a viewer. The ambient loop, the Approve/Deny hold, the on-screen PIN pad and the Settings menu are the code the board runs (crates/rsk-display); a mouse held on a button enters it through the same TouchPad a finger does, and the power button is the space bar. So the ceremony this page is about — a signature cannot be had without a tap on a screen naming the true relying party — can be tried before deciding whether to buy the hardware.

--taps <file> replaces the mouse with a script — one contact per line, x,y[,hold_ms[,gap_ms]] in panel pixels, # for a comment — so a sequence that takes six taps to reach can be replayed instead of performed:

# Settings → Security → FIDO PIN
209,301
119,123
119,123

The images themselves are regenerated with rsk-emu --screenshots docs/images when a screen changes.

Building and flashing

The panel takes over the addressable-LED pin, so the display flavor is built LED_KIND=none (a compile-time guard enforces this) with the larger flash the UI assets want:

env LED_KIND=none FLASH_SIZE=16M cargo build --release -p firmware --features display
# or the hermetic package:
nix build .#firmware-display        # → result/firmware.uf2

GPIO16 (the WS2812 pin on a standard board) drives the backlight here. WAKE_PIN (default 25, the board’s BAT_PWR button) both wakes the panel from display sleep and, while awake, sleeps it on demand from any screen. A press blanks the panel and (when a device PIN is set) locks the on-device UI, aborting any host prompt it interrupts. See the full knob table in build.md; the display-only knobs are WAKE_PIN / WAKE_ACTIVE_HIGH.

Flash it like any other image (BOOTSEL → picotool load, hardware.md). Two notes:

  • The build output is unsigned. On a secure-boot device you still picotool seal --sign it before loading (signing-keys.md, anti-rollback.md). The RP2350 boot ROM verifies the signature on boot.
  • You can reach BOOTSEL from the panel itself: Settings → Firmware → reboot to BOOTSEL (a deliberate hold). The reboot routes through the worker so live RAM secrets are scrubbed first.

What’s on screen

A bottom navigation bar carries four peer tabs, each captioned:

TabWhat it shows
HomeA calm “Ready” and a status card: USB, whether a device PIN is set, and the resident-passkey count (cached, refreshed only at modal boundaries).
PasskeysThe resident credentials, one row per relying party.
AppsA read-only browser for the OpenPGP / PIV / OATH applets.
SettingsDisplay, Security, Firmware, Audit log, Backup, Factory reset.

Approve / Deny — the anti-phishing core

Any operation that needs user presence paints a trusted prompt naming the operation and the real relying party, and waits for a deliberate action:

  • A WebAuthn registration shows a Save new passkey? card (relying party + account, Cancel / Save).
  • A sign-in and the generic OpenPGP / PIV touch prompts share an approve screen (shield + relying party + a hold-to-approve button). Deny refuses with OPERATION_DENIED.

The trusted display’s sign-in prompt: the trusted title “Sign in?”, a globe glyph beside the relying party “github.com” with the account “maxmur” under it, an amber “Approve only if you started this” caution, and a red Deny button beside a blue Hold to approve button The trusted display’s registration prompt: the same card under the trusted title “Save new passkey?”, showing relying party github.com and account maxmur above Deny and Hold to approve

Every prompt waits for the previous finger to lift before it will accept a tap, and a prompt that runs out of time is denied rather than approved — a finger resting on Save when the window expires cancels the registration, it does not confirm it. The release wait has a floor of its own, so shortening the presence timeout in the device config cannot shrink it away.

Because the device only knows the relying-party string (and its hash), it shows that string verbatim, never a host-supplied brand logo. A relying-party id too long for the box is clipped with a truncation marker, and the clip keeps the registrable-domain suffix (a leading ellipsis) rather than the head. So a padded look-alike such as accounts.google.com.attacker.com can never hide its real domain (…attacker.com) behind the cut.

The same sign-in prompt against a padded look-alike relying party: the name is clipped from the front with a leading ellipsis so it reads “…m.attacker.com”, leaving the real registrable domain in view rather than the “accounts.google.com” head the attacker padded it with This holds on every screen that shows an attacker-chosen rpId: the approve and enrollment prompts and the Passkeys manager’s list, service-detail title and Confirm-Delete card. A device-local nickname (which you set, not the host) keeps its head instead.

Entering a PIN on the trusted screen

The trusted display’s Device PIN screen: a row of masked entry dots and an eye reveal toggle above a 3×4 numeric keypad (1–9, a backspace key, 0, and a blue confirm key), with “8 tries remaining” beneath

The panel has an on-screen numeric PIN pad: digits are masked, an eye toggle reveals them briefly so you can check before committing, and the minimum length shows as placeholder dots. Every PIN screen names which credential it is collecting in the header (Device PIN, FIDO PIN, PIV PIN / PIV PUK, or the OpenPGP PINs), so the independent PINs are never confused. The New / Confirm / current step rides in the caption beneath. The PIN never leaves the device.

Whichever way the PIN arrives — typed on the pad or sent by the host — the panel asks before a pinUvAuthToken is issued (CTAP 2.1 §6.5.5.7 requires the consent on any authenticator with a display). Declining ends the operation with OPERATION_DENIED and costs no PIN retry, since the question comes before the PIN is checked. So ykman fido and anything else needing a token waits on a tap here.

This backs four things:

  • Built-in user verification. getInfo advertises options.uv. A PIN typed on the pad mints a pinUvAuthToken via clientPIN (getPinUvAuthTokenUsingUvWithPermissions), checked against the same EF_PIN the host clientPIN path uses. A platform can also skip the token: makeCredential / getAssertion carrying options: {uv: true} collect the PIN on the pad directly (CTAP 2.1 §6.1.2 step 11.2), and that entry counts as the ceremony’s user presence, so the response sets up without the spec requiring a second gesture. The panel still asks: pad first, then the Approve / Deny card, because that card is the only screen naming the relying party — the pad carries a trusted, firmware-supplied title and never relying-party text. With alwaysUv on, a request that brings no pinUvAuthParam takes the same route instead of being refused with PUAT_REQUIRED. Declining on the pad ends the operation with OPERATION_DENIED — deliberately, since the code CTAP would have the device send instead (PUAT_REQUIRED) asks the host to prompt for the same PIN, which would make the refusal meaningless. A wrong PIN still falls back to the host path.
  • CCID secure PIN entry (pinpad). A display build advertises bPINSupport and handles PC_to_RDR_Secure, so GnuPG (OpenPGP PW1/PW3) and OpenSC (PIV PIN) collect the PIN on the trusted screen. The PIN never crosses USB in pinpad mode. Details and host-driver caveats: protocol.md §1.3.
  • U2F under alwaysUv. A screenless key has to switch CTAP1/U2F off once alwaysUv is on, since a touch proves no verification. A pad with a PIN set is the exception CTAP 2.1 §7.2.4 allows, so U2F keeps working here — each register and authenticate names itself on screen (Register key? / Sign in?) and then collects the PIN on the panel. Turning alwaysUv on before setting a PIN still disables it; there would be nothing to verify against.
  • First-run onboarding. A fresh, PIN-less device offers a Set a PIN? screen at first run. Declining is remembered (a flag in EF_DISPLAY) so the offer isn’t repeated until a factory reset.

Passkeys

The trusted display’s Passkeys tab on a device with no resident credentials, showing a key glyph and the empty-state message “No passkeys yet”

The Passkeys tab lists resident credentials by relying party (real rpId + account count) and drills into a per-account detail where a passkey can be deleted on-device (gated by the device PIN, then a hold), decrypted on the device, never on the host. The detail’s pencil opens a character-wheel rename that sets a short device-local nickname for the relying party, shown in place of its rpId. The nickname is sealed at rest (a dedicated EF_RPNICK region), wiped by a reset, and (unlike a host updateUserInformation) never re-seals the credential, so the passkey keeps working. The trade-off: the nickname is device-local and not seen by host credential managers.

The trusted display’s per-service passkey detail for github.com: a back chevron and a pencil rename affordance beside the title, two account rows (maxmur, and maxmur-work marked UV), and a “2 accounts” footer

Apps — a read-only credential browser

The trusted display’s Apps tab listing three applets as read-only rows: OpenPGP (0 keys), PIV (0 slots) and OATH (0 codes), each with a chevron to drill in

The Apps tab reads applet state without a PIN. No key material, PIN or public point is ever shown, and no OATH code is computed (the device has no clock).

  • OpenPGP: the Signature / Encryption / Authentication slots with each one’s algorithm, the signature counter and PW1/PW3 attempts; a per-slot detail with the SHA-1 fingerprint and touch policy; and a Card holder row (name / login / URL / language).
  • PIV: the 9A/9C/9D/9E slots with algorithm and PIN/PUK attempts, a per-slot detail (PIN/touch policy, key origin, cert presence), and a Retired & F9 row listing the populated retired key-management slots (82–95) and F9. From it, Generate key creates a key (EC P-256/P-384, Ed25519, X25519, or RSA 2048/3072/4096) into the next free retired slot, gated by the device PIN and a hold, restricted to empty slots (add-only, never overwrite). There is no management-key auth: physical presence at the panel is the authorisation.
  • OATH: the stored credentials (label, TOTP/HOTP, a padlock when touch-gated), each with a detail (type, HMAC algorithm, digits, TOTP step).

The trusted display’s OpenPGP overview: Signature (Ed25519), Encryption (X25519), an empty Authentication slot and a Card holder row, with the signature counter and PIN attempts along the bottom The trusted display’s PIV overview: Authentication (P-256), Signature (RSA-2048), empty Key Management and Card Auth slots, a “Retired & F9” row showing 2 populated slots, and the PIN / PUK attempt counts The trusted display’s OATH list: three credentials — GitHub:maxmur (TOTP, padlocked for touch), AWS:root (TOTP) and Bank:counter (HOTP) — above the note that codes are shown in the RS-Key app

Settings

The trusted display’s Settings menu with three entries, Display, Security, and Firmware (showing the running bcdDevice build), above the navigation bar

Grouped into three domains, plus the journal / backup / reset actions:

  • Display: backlight brightness (PWM), the display-sleep timeout, and the touch timeout, each adjusted live. All three persist across reboots: brightness and sleep in an EF_DISPLAY flash record; the touch timeout in EF_PHY’s PresenceTimeout, the same field rsk hw --touch-timeout writes, so the panel and the host tool stay in sync.
  • Security: set / change the device PIN and the FIDO clientPIN (each chosen entirely on the panel). Changing the clientPIN asks for the current one first, and that prompt is the card’s changePIN check: a wrong entry spends a retry and ends any pinUvAuthToken a plugged-in platform holds, exactly as a wrong old PIN sent over USB does — the platform just asks for the PIN again. Then a PIV PIN sub-menu: change the PIV PIN, change the PUK, unblock a blocked PIN with the PUK, or protect the management key. Protect mgmt key generates a random AES-256 management key, seals it and marks it PIN-protected, the ykman --protect scheme, so a host then uses the management key with just the PIV PIN (which alone grants management access, a trade-off the panel states and gates behind the device PIN and a hold). Any existing host PivmanData (its PIN-change timestamp and other flags) is preserved (the obsolete derived-key salt is dropped, exactly as ykman does). Last on the page, Scramble PIN pad — a toggle, off by default. On, the ten digit keys are laid out afresh at random for every PIN entry (and again between the “New PIN” and “Confirm PIN” steps), so a fingerprint trail, a worn patch of glass, or an onlooker who sees your hand but not the panel learns nothing from where you tapped. It buys nothing against anyone who can see the screen — they read the digits off it — and it costs muscle memory: entry is slower and mistyping is likelier, against a limit of three wrong PINs per power cycle. That trade is the owner’s to make, which is why it ships off.
  • Firmware: the installed bcdDevice build and chip serial, the real OTP secure-boot fuse state (it warns when secure boot is off rather than claiming a check it isn’t doing), and the hold-to-reboot into BOOTSEL for an over-USB update.

The trusted display’s Firmware screen: the installed build 0x0875 under a chip glyph, “Updates arrive over USB.” and an amber warning that updates are NOT verified, the chip serial, and a blue Verify & install button

  • Audit log: the most recent device-journal events (sign-ins, passkeys added, PIN changes, lockouts, resets, power cycles), colour-coded, newest first.

The trusted display’s Audit log: five colour-coded rows newest first — Signed in (now), Passkey added (5m), PIN blocked (1h), PIN changed (2h), Powered on (1d) — with a “5 events” footer

  • Backup. An honest view of the recovery-seed export window: whether a seed is present and whether its one-time export has been sealed. While the window is open, Show recovery (gated by the device PIN) paints a 24-word BIP-39 phrase or a T-of-N SLIP-39 share set on the trusted screen, derived on the device, never crossing USB, behind a hold + warning, wiped the instant they’re shown. Seal backup closes the window for good (until a factory reset). See seed-backup.md.

The trusted display’s Backup screen: an amber “Review needed — Seed export still open.” card above rows reading Seed: Present and Backup window: Open, with Show recovery phrase and Seal backup buttons

  • Factory reset: erases every applet’s data (FIDO, PIV, OpenPGP, OATH), scrubs the flash, and reboots to a blank device (gated by the device PIN, then a hold). Only the org attestation and the fused OTP / secure-boot state survive.

A display build is also exempt from the power-up window that makes a screenless key refuse a host authenticatorReset more than ten seconds after it was plugged in. That window exists to stop a wipe being approved by a press collected under some other pretext; here the panel names the operation, so a host reset is accepted whenever you confirm it on screen — no replug.

Security model

The trusted display’s Locked screen: a padlock glyph centred above “Locked” and the hint “Touch to unlock”

The device PIN (EF_DEVICE_PIN, its own sealed record + retry counter) gates the on-device UI (unlock, on-device delete, factory reset) independently of FIDO. The device boot-locks when a device PIN is set. A forgotten device PIN is cleared only by a host authenticatorReset (the sole recovery, since the lock gates on-device Settings). Every device-driven ceremony (a granted Approve, an on-device delete, a factory wipe) ends on a brief success confirmation.

This is an experimental variant. Read threat-model.md and limitations.md for what the trusted display does and does not defend against.

See also

rsk-tui — the terminal cockpit

rsk-tui is a host-side dashboard for an RS-Key. It talks to the device directly (CTAPHID over hidapi and the CCID applets over PC/SC), so it does not shell out to rsk or any other process. It lives in its own workspace (tools/tui), separate from the firmware, and links the host PC/SC and HID stacks.

It is a companion to the rsk CLI, not a replacement: the cockpit covers the safe, day-to-day reads and a few in-band actions (LED, seed backup, reboot, audit, identity verify). Irreversible production rituals (secure-boot staging, OTP fuses, factory resets, soft-lock, attestation import) stay in the CLI on purpose. The cockpit points you at the exact command instead of doing them.

rsk-tui cockpit — a terminal dashboard: a header with the device summary, a sidebar of sections (Overview selected, then FIDO, OpenPGP, PIV, OATH/OTP, Backup, LED, Audit, Reboot/Maintenance, Help), an Overview panel grouping Identity, Transports and Security rows, an actions panel, an events log and a key-binding footer

flowchart LR
    tui["rsk-tui (host process)"] -->|hidapi / CTAPHID| fido["Device — FIDO"]
    tui -->|PC/SC / pcscd| ccid["Device — CCID applets<br/>OpenPGP · PIV · OATH · OTP"]

Running it

In the dev shell rsk-tui is on PATH:

nix develop
rsk-tui              # interactive cockpit

Without Nix, run it from its workspace (the repo defaults to the firmware target, so name the host target explicitly; this is what the launcher does):

cargo run --release --manifest-path tools/tui/Cargo.toml \
  --target "$(rustc -vV | sed -n 's/host: //p')"

On Linux the CCID half needs pcscd + a polkit rule; see linux.md. FIDO works as soon as the udev rules are in place. If PC/SC is down, the cockpit still starts: the FIDO sections work and every CCID field shows CCID unavailable rather than a fabricated value.

Flags

FlagEffect
(none)interactive cockpit
--demo, --mockinteractive cockpit against a simulated device, no hardware needed
--onceprint the gathered status once (human-readable) and exit
--jsonone-shot machine-readable status (JSON) and exit
--identifyblink this key’s indicator and exit (the Overview action, scripted)
--selftest [PIN]native backup export/restore round-trip (needs a no-touch build)
-h, --helpusage

--demo is handy for screenshots, docs, and trying the navigation without a key plugged in. Demo data is clearly labelled [DEMO] and every simulated action is prefixed [demo]; it never pretends to touch hardware.

--json and --once are the scriptable paths. Both gather one snapshot and exit, so they fit a health check or a CI probe. --json emits a stable, explicit object (identity, fido, backup, secure_boot, rollback, applets, led, pgp, piv_meta, errors, …); --once is the same data formatted for a human. Either honours --demo, so you can shape a pipeline against the mock first:

rsk-tui --json | jq '.secure_boot, .rollback'
rsk-tui --once --demo            # see the field layout without a key

--selftest drives the native MSE channel + clientPIN token + BIP-39 path end-to-end: export a seed, re-derive its fingerprint, restore it, confirm the fingerprint is stable, all without revealing the seed. It needs a no-touch firmware build (the touch build would block waiting for a button press), and takes the FIDO2 PIN as an optional positional argument if one is set.

It talks to real hardware, so it refuses two invocations that would otherwise mislead: a flag where the PIN should be (--selftest --demo would have sent the literal string --demo as the PIN, spending a real retry), and --demo in any position (it does not apply — there is no simulated round-trip).

A non-interactive snapshot of the simulated device (rsk-tui --once --demo) gives a sense of what the cockpit reads:

[DEMO — simulated device]
device     : serial 37bebfdca282 · fw 5.7.4 · bcd 0x0759
transports : HID present  PC/SC present  CCID present
serial     : 37bebfdca282523b
firmware   : 5.7.4  bcdDevice 0x0759  sdk 3.4
fido       : U2F_V2, FIDO_2_0, FIDO_2_1  clientPin=true
backup     : sealed=false  has_seed=true
seed lock  : off
secure boot: ENABLED (not locked)  (enabled=true locked=false bootkey=0x1)
rollback   : not required  boot version 0/48
org attest : not installed
applets    : OpenPGP present  PIV present  OATH present  OTP present
openpgp    : 2 keys  serial 2a1b3c4d  retries PW1 3 RC 0 PW3 3
piv        : PIN 3/3 tries (default)
led        : steady  idle cyan  processing blue  touch green  boot white

Layout

┌ header: app · health · device identity · refreshed ─────────────┐
│ sections │ selected section: status fields + action menu        │
│  …       │                                                      │
├──────────┴──────────────────────────────────────────────────────┤
│ events: recent operations and errors                            │
├─────────────────────────────────────────────────────────────────┤
│ last result · key bindings                                      │
└─────────────────────────────────────────────────────────────────┘

The sidebar narrows and the event panel drops away on small terminals; the UI keeps working down to a few rows. Status uses an OK / WARN / ERR / UNK / N/A word plus a colored glyph, so it reads on a monochrome or color-blind terminal. N/A is reserved for things the device supports but the TUI deliberately leaves to the CLI. It is never a faked or unknown value. Set RSK_TUI_ASCII=1 (or run in a non-UTF-8 locale) to force ASCII glyphs ([+] [!] [x] [?] [-] instead of ● ▲ ✖ ○ –); a UTF-8 LANG/LC_* is auto-detected otherwise.

Key bindings

KeyAction
Tab / Shift-Tab, / switch section
or j kmove selection in the action list
Enterrun the selected action
rrefresh device status
/search actions across all sections
?jump to Help
Esccancel a modal / input
q or Ctrl-Cquit (terminal restored on exit)

The status also auto-refreshes every few seconds while you are in the normal view, never while a modal is open, so a read can’t redraw over a PIN prompt or hammer the CCID bus mid-task.

Inside a modal the keys narrow to that modal: a text/PIN input takes characters + Backspace, Enter submits, Esc cancels (and wipes the buffer); a yes/no prompt takes y/n or Enter/Esc; a reveal panel dismisses on any key (a shoulder-surfed secret clears fast); a message panel (audit output, LED state, a verify report) scrolls with the arrows / j k / PgUp / PgDn / Home / End and closes on Enter, Esc, q, or space. The / palette filters by a case-insensitive substring of the action label as you type, / pick, Enter jumps to that action’s section and starts it.

Sections and what they do

SectionReads (safe)In-band actions
Overviewidentity (serial, fw, bcdDevice, sdk, aaguid), transports, backup/lock/secure-boot/rollback/attestation/flashRefresh, Verify identity
FIDOCTAPHID presence, versions, clientPIN, optionsCount resident passkeys (PIN · credMgmt)
OpenPGPapplet presence, card serial, PIN retries, populated key slots (from 6E)
PIVapplet presence, PIN retries + default-PIN flag (from GET METADATA)
OATH / OTPapplet presence
Backupseed / sealed / lock stateExport (BIP-39 · SLIP-39 2-of-3), Restore, Finalize
LEDLED mode + per-state color/brightness, with a live colour swatchRead state, Cycle idle color
Auditjournal head + checkpoint key hintRead journal, Verify identity
Reboot / Maintenancedevice summaryReboot → app, Reboot → BOOTSEL
Helpkey bindings, section guide, safety model

OpenPGP and PIV show a little more than presence, from unauthenticated reads in the same gather: OpenPGP parses its 6E application-related data (card serial, the PW1/RC/PW3 retry counters, and how many of the three key slots hold a key); PIV reads the PIN’s GET METADATA (retries left, and whether the PIN is still the factory default). OATH and OTP stay presence only. The full card contents (individual keys, accounts) still need the applet’s own tooling, so those rows point you at it: gpg --card-status (openpgp.md), ykman piv info, ykman oath accounts, and so on.

Verify identity issues a fresh 16-byte challenge, has the device sign it with its DEVK-derived P-256 attestation key (vendor AUDIT_CHECKPOINT), and verifies the ECDSA signature locally over tag‖head‖seq‖challenge. This is a real cryptographic check, not a display of device-asserted bytes. On success it prints an 8-byte fingerprint of the attestation public key. Record that fingerprint: a later rsk inventory verify --expect-key <hex> (or rsk audit verify --expect-key …) pins it, so a swapped or cloned board fails the check instead of quietly verifying. Verify needs a touch, the FIDO2 PIN if one is set, and a provisioned OTP DEVK. Without the DEVK it says no OTP DEVK provisioned — attestation unavailable rather than guessing.

Read journal dumps the tamper-evident audit log (vendor AUDIT_READ): a hash-chained sequence of events (BOOT, MAKE_CREDENTIAL, GET_ASSERTION, PIN_SET, BACKUP_EXPORT, CHECKPOINT, …) folded into an epoch, with the running chain head. It is read-only (the device never lets the host rewrite it) and asks for the FIDO2 PIN if one is set, or a physical touch if not. The full cross-check against the signed head lives in rsk audit verify.

LED reads the four LED states the firmware drives (idle, processing, touch, boot), each with a color and brightness, plus whether the idle LED is steady or blinking. The section paints a colour swatch beside each state — a background-filled block, so it shows with no block-glyph support, and the colour name is printed too — refreshed live with the status. Cycle idle color steps the idle color through the palette (red → green → blue → yellow → magenta → cyan → white, then wraps) and writes it back; it is a cosmetic, unauthenticated setting, not a security control.

PINs in the cockpit

Every PIN the TUI asks for is the FIDO2 clientPIN (the same one rsk fido set-pin manages), not an OpenPGP PW1/PW3 or a PIV PIN. It gates the in-band actions that need it: Verify, Read journal, and seed Export/Restore (when a PIN is set). If no clientPIN is set, those actions skip the prompt. OpenPGP and PIV PINs are only ever entered in their own tools (gpg, ykman), never here.

CLI-only / unsupported in the TUI

These are surfaced as menu entries that, when selected, print the exact command to run. They are never performed from the cockpit:

  • FIDO: set/change PIN (rsk fido set-pin), list resident passkeys (rsk fido list-passkeys --pin …), factory reset (ykman fido reset)
  • OpenPGP / PIV / OATH / OTP: full card data and factory resets (gpg --card-status, ykman piv info, ykman oath accounts, rsk openpgp reset, ykman piv reset, …). The ykman commands gate on the “Yubico YubiKey” reader name, so they only see the device on the opt-in VIDPID=Yubikey5 build; gpg and rsk work on the default RS-Key build.
  • Backup: SLIP-39 restore (recombining shares): rsk backup restore --scheme slip39. SLIP-39 export is now in the cockpit (2-of-3 shares, via the in-tree rsk-slip39 crate); other T-of-N splits stay in the CLI (seed-backup.md)
  • Maintenance (Reboot section): seed soft-lock (rsk lock enable | unlock | disable), org-attestation import/clear (rsk fido attestation import | clear), secure-boot staging, OTP fuses. See production.md and otp-fuses.md

The FIDO section’s Count resident passkeys action reads the count over credMgmt getCredsMetadata. It needs the FIDO2 PIN — there is no unauthenticated way to read it — and reports how many passkeys are stored plus roughly how many slots remain. Enumerating the passkeys themselves stays in the CLI: rsk fido list-passkeys --pin ….

Safety model — what is and is not logged

  • Destructive or irreversible operations require a typed confirmation, not a single keypress: export (EXPORT), restore (RESTORE), finalize/seal (SEAL), reboot to BOOTSEL (BOOTSEL). The typed word must match exactly (case-sensitive); anything else cancels the action. Reboot to app uses a yes/no prompt. Finalize is refused outright once the window is already sealed. It explains rather than re-confirming.
  • PINs are masked on entry and never written to the event log. The log additionally redacts any live PIN/phrase substring as a backstop, and it is a bounded ring (the last 200 lines), so nothing accumulates on disk. It is only ever in memory.
  • The seed is shown only after you confirm export. It appears once, in a modal, is zeroized from memory when you press a key, and never reaches the event log or any file. The same goes for a restore phrase you type in.
  • Sensitive buffers (PIN, phrase, revealed seed) are wiped (zeroize) on cancel, on submit, and on Ctrl-C. Ctrl-C wipes the in-flight buffer before it quits, from any modal.
  • The terminal is restored on every exit path: q, Ctrl-C, an I/O error, or a panic (device I/O can panic, so a panic hook leaves the alternate screen and raw mode first).

The seed backup flows here are exactly the BIP-39 export/restore/finalize of rsk backup, just interactive: same touch + PIN + setup-window gates, same forever-sealed semantics after finalize. Read seed-backup.md for what a backup does and does not recover before you rely on it.

Architecture (for contributors)

tools/tui/src is split so rendering, state, and I/O stay separate and the UI is testable without hardware:

  • model.rs: typed state (DeviceSnapshot, TransportStatus, Section, Action, ActionResult, EventLog, …) and --json serialization. No I/O.
  • device.rs: native CTAPHID + PC/SC I/O behind a DeviceProvider trait, with HardwareProvider (real) and MockProvider (--demo). Holds the native seed backup crypto: the MSE channel (P-256 ECDH → HKDF → ChaCha20-Poly1305) and the clientPIN protocol-two token (ECDH + HKDF + AES-CBC + HMAC).
  • app.rs: app state, navigation, and the modal/confirmation flow.
  • actions.rs: action dispatch + result handling (the one place that blocks on device I/O; it paints a “working — touch the device…” line and redraws before the blocking call so the prompt is visible).
  • input.rs: key handling → state change + Flow.
  • ui.rs: rendering only.
  • theme.rs: styles, colors, ASCII fallback.

Because the UI is driven by a DeviceProvider, the whole cockpit (navigation, confirmation flows, secret redaction, rendering) is unit-tested against the mock with no device attached.

# fmt / clippy / test / build, host target (the merge gate runs these too):
H="$(rustc -vV | sed -n 's/host: //p')"
cargo fmt   --manifest-path tools/tui/Cargo.toml --check
cargo clippy --manifest-path tools/tui/Cargo.toml --target "$H" --all-targets -- -D warnings
cargo test   --manifest-path tools/tui/Cargo.toml --target "$H"
cargo build --release --manifest-path tools/tui/Cargo.toml --target "$H"

The --target "$H" is load-bearing: the repo’s .cargo/config.toml defaults to the thumbv8m firmware target, and tools/tui is a detached workspace (note the empty [workspace] table in its Cargo.toml) precisely so it builds for the host instead. See build.md for the workspace layout.

Audit journal

A tamper-evident, on-device log of security events: boots, FIDO registrations and logins, factory resets, PIN set/change/lockouts, policy changes, seed backup and soft-lock activity.

rsk audit log              # export + print (add --pin if a PIN is set)
rsk audit verify           # log + DEVK-signed checkpoint (touch)
rsk audit verify --expect-key <hex>   # also pin the enrolled attestation key

log is a plain read. It pretty-prints the live window and the recomputed chain head, no signature. It takes --pin on a device with a PIN, and a touch on one with none (the read is never fully open). verify does the same read and asks the device to sign the head. It is the command that actually proves the log is real. Use log for a quick glance, verify when the answer matters.

What it records

eventdetail
BOOTfirst journal touch of each power cycle
MAKE_CREDENTIAL / GET_ASSERTION / U2F_REGISTER / U2F_AUTHfirst 8 bytes of the rpIdHash (only weakly pseudonymous, see Gating). A run of the two silent variants folds — see below
RESETfactory reset (survives it — see below)
PIN_SET / PIN_CHANGE / PIN_LOCKOUTlockout aux: 0 = retries exhausted, 1 = per-boot block
CFG_MIN_PINaux = new minimum; detail[0] = forceChangePin
CFG_ENTERPRISE_ATTno aux/detail (flag-only)
CFG_EA_RPIDSthe vendor-facilitated enterprise RP list was rewritten; aux = how many RPs it now holds, 0 = cleared
LOCK_ENGAGE / LOCK_RELEASEsoft-lock engage/release
BACKUP_EXPORT / BACKUP_LOAD / BACKUP_FINALIZEseed-backup lifecycle
ATT_IMPORT / ATT_CLEARorg attestation provisioning
CFG_ALWAYS_UValwaysUv toggled; no aux/detail (flag-only)
CONFIG_WRITEa device-config write over the FIDO vendor channel. aux = the target that opened the entry (0 dev-conf, 1 phy, 2 led); detail = repeats(2 LE) ‖ targets(1) (see below)
AUDIT_CFGjournalling itself: aux 1 = turned on, 0 = turned off (that entry is the last one written, so the trail shows when it stopped)
CHECKPOINTevery signed checkpoint is itself logged

Each entry is a fixed 20 bytes: seq(4) ‖ uptime_ms(4) ‖ event(1) ‖ aux(1) ‖ detail(8) ‖ repeats(2 LE). There is no wall clock on the device. uptime_ms counts from the moment the key attached to USB, not from power-on — boot spends seconds before that on TRNG seeding and flash migrations, and none of it is time a host could have used. Every power cycle opens with a BOOT entry, and the sequence number gives total order. Wall-clock attribution is the host’s job (e.g. record when you ran rsk audit verify).

For the FIDO operations — MAKE_CREDENTIAL, GET_ASSERTION, U2F_REGISTER, U2F_AUTH — the detail field carries the first 8 bytes of the rpIdHash and nothing else: no RP names, user handles, or credential IDs. That is deliberate: the log answers “how was this key used and how often” without revealing which sites. See gating below. Other events use detail for their own small payload (CFG_MIN_PIN’s forceChangePin flag, CONFIG_WRITE’s run counters); none of them records a site.

Events a silent host can drive cost one slot per run

The journal is append-only with one bounded exception. Three events can be driven on demand with no touch and no PIN, so 128 of any one of them would otherwise evict every other entry from the 128-slot window:

  • CONFIG_WRITE, ungated on the default build;
  • GET_ASSERTION from a getAssertion carrying up:false — the spec-mandated silent pre-flight, which needs a credential but no gesture;
  • U2F_AUTH from an AUTHENTICATE with P1=0x08 (don’t-enforce-user-presence), for the same reason.

Each folds a run into a single entry that keeps the seq and the timestamp of the first occurrence. The two shapes differ in where the count lives. CONFIG_WRITE carries its own inside detailrepeats(2 LE) ‖ targets(1), where targets is a 1 << target mask of every record the run touched — and folds only into the newest entry. The two silent FIDO events instead keep the detail of the first occurrence (so the rpIdHash shown is the first site of the run) and count the rest in the entry’s trailing repeats(2 LE), scanning the whole window rather than only the newest entry, so interleaving two of them does not defeat the fold. A gestured assertion always earns its own slot. rsk audit log renders both:

   seq      uptime  event              aux  detail
   201       8.2s  CONFIG_WRITE         1  300× write (phy+led)
   202       9.1s  GET_ASSERTION        0  a3f1c2...  ×128

Two consequences worth knowing:

  • A run never folds across a power cycle, so the BOOT entry between two runs is never swallowed.
  • A fold moves the chain head without advancing seq_next. Seeing the same seq_next with a different head is legitimate, not a tamper signal; verify re-folds the exported window and still matches.

This bounds the flood, it does not remove it: a phy write latches a reboot, so a host willing to make the key re-enumerate can still spend two slots per cycle (a fresh BOOT plus a fresh run). Each cycle is a visible re-enumeration, and building --features strict-config — which puts the write behind a touch and a PIN token — is the complete answer.

A log run prints a header, the chain state, then the window:

window [72, 200)  —  128 entries, 72 folded into the epoch
epoch : 4f1c…           (the accumulator for evicted history)
head  : a93b…  (chain over the window — OK)

   seq      uptime  event              aux  detail
    72       3.4s  GET_ASSERTION        0  1a2b3c4d5e6f7081
    73     120.9s  MAKE_CREDENTIAL      0  9f8e7d6c5b4a3928
   …

How the tamper evidence works

The journal is a 128-entry flash ring. Each entry extends a SHA-256 hash chain; when the ring is full, the oldest entry is folded into an epoch accumulator (epoch' = SHA-256(epoch ‖ entry)) before its slot is reused, so evicted history stays attested in aggregate even though its per-event details are gone. The chain head is fold(epoch, window): the epoch run forward through every entry still in the ring.

The chain is anchored, on an empty journal, at SHA-256("RSK-AUDIT-GENESIS-v1" ‖ serial_hash), bound to the device so two boards’ empty journals never share a head.

flowchart TD
    e["new event"] --> chain["SHA-256 hash chain (window)"]
    chain -->|ring full| fold["fold oldest into epoch accumulator"]
    fold --> reuse["slot reused"]
    chain --> head["chain head = fold(epoch, window)"]

rsk audit verify sends a fresh 16-byte random challenge. The device signs "RSK-AUDIT-CKPT-v1" ‖ head ‖ seq_next ‖ challenge with an ECDSA P-256 key derived (HKDF) from the OTP DEVK (production.md stage 1) and returns the signature plus its 65-byte SEC1 public key. The host refolds the exported window, verifies the signature over the message it reconstructs, and checks that the signed head matches the refold. The challenge is what makes the verdict fresh. A replayed old checkpoint signs a stale challenge and fails.

A successful verify prints the window, the head, and the attestation key with a short fingerprint. The verdict depends on whether you pinned the key:

chain   : OK — head a93b…
sig     : OK — checkpoint over seq_next=201, fresh challenge
att key : 04a1b2…   (65-byte SEC1)
          fingerprint 9c4e7f12ab… — record this; pin later runs with --expect-key
verdict : chain + signature OK — the key is NOT pinned, so this does not prove
          which device signed it

With --expect-key the last line becomes journal authentic ✓ (signed by the pinned key).

Meta updates are ordered so that a power cut at any point loses at most the newest event and never produces a false tamper verdict: when the ring is full the fold-and-advance meta is committed before the slot is reused.

Pinning the attestation key — --expect-key

The checkpoint key is deterministic and reset-stable (HKDF of the DEVK), so a given device always signs with the same public key. Record it once at provisioning, then pass it back on every later run:

rsk audit verify                         # first run: copy the printed "att key" hex
rsk audit verify --expect-key 04a1b2…    # afterwards: fail loudly on any mismatch
rsk audit verify --expect-key 9c4e7f12ab…   # the short fingerprint works too

A mismatch means the public key changed, which can only happen if the DEVK changed. That means you are talking to a different device, or a clone that was flashed without burning the same OTP. --expect-key takes either the full 65-byte SEC1 point (04 ‖ x ‖ y) or the 16-hex fingerprint, lower-case, and the comparison is exact. The full key is the stronger pin. Stash it in your provisioning record alongside the device serial.

Without a pin, verify proves nothing about identity. The public key it checks against arrives in the same response it is checking, so an unpinned run establishes only that the journal is internally consistent and self-signed — a counterfeit signing with a key of its own passes it. The device refuses to sign without an OTP DEVK, but a host cannot tell a DEVK-derived key from any other P-256 point. Pinning is what turns “some key” into “your device”.

Reset semantics (privacy by design)

authenticatorReset does not erase the journal. It folds the whole window into the epoch and deletes the per-event details, then logs the RESET. A handed-over device therefore proves “N events happened, then a reset” without revealing where it had been used. The chain (and the checkpoint key, which is DEVK-derived) continue uninterrupted across resets, so verify keeps working and the head still validates against the same --expect-key.

Gating

commandopen devicePIN set
audit log / AUDIT_READtouchpinUvAuthToken with the acfg permission
audit verify / AUDIT_CHECKPOINTtouchtouch + acfg pinUvAuthToken
  • AUDIT_READ (export). With a PIN set it needs a pinUvAuthToken carrying the acfg (authenticator-config) permission. With no PIN it needs a physical touch. The entries are only weakly pseudonymous. A detail is a 64-bit rpIdHash prefix, never an RP name or user handle, but short enough to be dictionary-matched back to a domain. So the touch is what stops a silent host from harvesting a no-PIN device’s RP-usage history.
  • AUDIT_CHECKPOINT. The same PIN gate plus a physical touch, and it refuses entirely without a provisioned OTP DEVK. An attestation that anyone could re-derive would be theatre. The signing step is what the touch protects. The read that precedes it is AUDIT_READ-gated as above.

If a PIN is set, both subcommands take --pin. The PIN is exchanged over the standard CTAP pinUvAuth protocol (it is not sent in clear), and a wrong PIN counts against the FIDO retry counter. Do not guess.

Troubleshooting

symptommeaning / fix
device requires a PIN — pass --pin (status 0x36)a FIDO PIN is set; add --pin <pin>
checkpoint refused — no OTP DEVK provisioned (status 0x30)dev board with no DEVK burned; verify cannot sign. log still works. See production.md
denied — no touch within 30 s (status 0x27)press the button when the LED blinks; rerun
attestation key MISMATCH — this is not the enrolled device--expect-key did not match: wrong device, or a clone flashed without your OTP
signed head differs from the exported windowthe journal changed between the read and the checkpoint. Rerun; if it persists, treat it as TAMPER
checkpoint SIGNATURE INVALID — do not trust this journalthe signature did not verify under the returned key. Do not trust the log
export length does not match the windowthe exported entry bytes don’t match seq_next − start; a corrupt or truncated read

The two “rerun first” verdicts are different. A head mismatch can happen benignly if an event landed between the read and the checkpoint (a login on another host, say). One rerun usually clears it. A signature failure or a key mismatch never has a benign cause. Do not retry your way past those.

What it does and does not prove

The log is written by the firmware, so its honesty is rooted in the boot chain: with secure boot + the OTP master key (production.md, otp-fuses.md) only your signed firmware can append to the journal or wield the checkpoint key, and a flash dump cannot forge it. On an unprovisioned dev board the journal still works as a debugging aid, but verify is refused. There is no device-bound key to sign with, and a checkpoint without one would prove nothing.

Two honest limits worth stating:

  • The window is 128 entries. Older events are folded into the epoch and their details are gone. You can prove they happened (the head still covers them) but not read them back. verify regularly if you want a per-event record. The host transcript is your archive, the device is not.
  • There is no wall clock. The device cannot tell you when in calendar time something happened, only the order and the milliseconds since that power cycle’s USB attach. Pair the seq and BOOT markers with your own host-side timestamps.

Enterprise attestation (org provisioning)

Out of the box, ordinary makeCredential returns packed basic attestation: an ES256 signature by the device key over authData ‖ clientDataHash, with the device’s own certificate as the x5c leaf. That certificate is a per-device P-256 X.509 leaf (subject C=XX, O=RS-Key, OU=Authenticator Attestation, CN=RS-Key FIDO2) built over the seed at first boot, and U2F registration presents the same one.

The two alternatives were both tried and both break clients. fmt:"none" with an empty statement is rejected by OpenSSH below 10.0, which hands every credential without a certificate to libfido2’s fido_cred_verify_self() regardless of format. Packed self-attestation signs with the credential key and so inherits its algorithm, and an Ed25519 statement fails on Windows WinHello, which broke ed25519-sk enrollment (issue #26). Basic attestation signs with ES256 whatever the credential algorithm is, so neither path is reached.

Because the leaf is per-device it is a stable identifier: every relying party that asks for attestation sees the same certificate, and two of them can tell they met the same key. Browsers strip it unless the site explicitly requests attestation: "direct" (WebAuthn §5.1.3 replaces the statement otherwise), but native CTAP clients do not. There is also no root to publish — a per-device self-signed leaf chains to nothing, so attestationRootCertificates in the metadata statement stays empty and a relying party that requires a trusted anchor will treat the attestation as untrusted.

An organization can replace that device cert with its own attestation key and certificate chain, so its relying parties can verify “this credential was created on one of our keys”. This is the CTAP 2.1 enterprise-attestation (EA) feature.

This page is for the team that provisions fleet keys. If you do not work for an org that has provisioned yours, it is a no-op: nothing here changes how an unprovisioned key behaves, and EA is never served unless a managed platform explicitly asks for it.

The key + chain model

Two pieces of state make up an org attestation, stored separately on the device:

Stored asHoldsSealing
EF_ATT_KEY (0xCE10)the org attestation P-256 private scalarkbase-sealed, exactly like the master seed
EF_ATT_CHAIN (0xCE11)the DER certificate chain, leaf first (count ‖ (len ‖ der)*)public material, stored plain

The key signs each attestation; the chain is what relying parties walk back to your CA. The leaf’s public key must match the imported scalar. The device does not check this (framing only), so a key/chain mismatch surfaces as your own relying party’s first signature-verification failure, not an import error.

Provisioning

Generate an attestation CA and a leaf however your PKI does it. The leaf’s subject public key must be the P-256 point of the private key you import; only P-256 (secp256r1) keys are accepted. rsk rejects any other curve before it touches the device.

# host-side, with your PKI:
#   org-att.pem    P-256 private key (PEM)
#   org-chain.pem  leaf cert first, then intermediates, then (optionally) the CA
rsk fido attestation import --key org-att.pem --chain org-chain.pem [--pin …]
rsk fido attestation status

--chain takes a PEM bundle (concatenated -----BEGIN CERTIFICATE----- blocks) or already-concatenated DER. Limits, enforced host-side and again in firmware:

LimitValue
CurveP-256 only
Chain size≤ 2048 bytes total
Certs in chain≤ 4

status is ungated and prints whether a chain is installed plus the SHA-256 of the packed chain (so you can confirm a fleet is on the right CA without moving any secret):

$ rsk fido attestation status
org attestation : installed
chain hash      : 9f2c…

To roll back to the factory self-signed cert:

rsk fido attestation clear [--pin …]

What changes once a chain is installed

  • makeCredential with enterpriseAttestation 2 (platform-managed, sent by managed platforms) returns a full attestation: signature by the org key, x5c = your chain (leaf first), and the ep response flag (true). Level 1 (vendor-facilitated) does the same, but only for a relying party on the device’s enterprise RP list — which is empty until you write it, so a level-1 request from an unlisted RP gets the ordinary per-device attestation and no ep.
  • U2F / CTAP1 registration attests with the chain’s leaf instead of the self-signed device cert (classic batch attestation: a U2F response carries exactly one certificate, so only the leaf travels).
  • Ordinary makeCredential is untouched: packed basic attestation under the device’s own certificate, no org chain. EA fires only when the platform sets the enterpriseAttestation request field and enableEnterpriseAttestation is on (below).

Without an org chain (the default)

If no org key is provisioned, the request field still has an effect, per the spec:

EA levelWithout org keyWith org key
(absent / 0)basic, device certbasic, device cert
1 — vendor-facilitated, RP listedbasic, device cert, ep flagfull org attestation
1 — vendor-facilitated, RP not listedbasic, device cert, no ep flagbasic, device cert, no ep flag
2 — platform-managedbasic, device cert, ep flagfull org attestation

Without an org key every level answers with the same per-device basic attestation; what an EA request adds is the org chain and the ep response flag. The device key and that self-signed cert are the same pair U2F register uses.

Enabling EA on the device (enableEnterpriseAttestation)

Importing the key is not enough. A makeCredential with the EA field is honored only after enableEnterpriseAttestation (CTAP 2.1 authenticatorConfig, subcommand 0x01) has been issued. RS-Key has no rsk command for this. It is the managed platform’s job (the OS/MDM/browser stack that drives EA), and it requires an acfg pinUvAuthToken, i.e. a FIDO PIN must be set. getInfo reports the current state in the ep option, which the firmware mirrors straight from EF_EA_ENABLED:

# python-fido2, the same library `rsk` uses:
python3 - <<'PY'
from fido2.hid import CtapHidDevice
from fido2.ctap2 import Ctap2
info = Ctap2(next(CtapHidDevice.list_devices())).info
print("ep =", info.options.get("ep"))   # True once enableEnterpriseAttestation ran
PY

enableEnterpriseAttestation persists across power cycles. It is written to flash (EF_EA_ENABLED), as CTAP 2.1 specifies. It is cleared only by authenticatorReset (see below).

Who gets vendor-facilitated (type 1) EA

Level 2 applies to every relying party the moment EA is enabled. Level 1 is narrower by design: the spec leaves it to the vendor to say which RPs qualify, so RS-Key keeps a list of relying parties on the device and honors a level-1 request only for one of them. The list holds up to 8 entries, is stored as sha256(rpId) (EF_EA_RPIDS), and is empty on a device that has never been told otherwise — including one upgraded from an older firmware. Until you write it, level 1 qualifies nobody, which is exactly how RS-Key behaved before the list existed.

rsk fido attestation set-rpids sso.corp.example vpn.corp.example
rsk fido attestation set-rpids --clear

Each write replaces the whole list — there is no add or remove, and no read path: the device never hands the list back, so keep your own copy of what you sent. More than 8 ids is refused outright (CTAP2_ERR_KEY_STORE_FULL) and the previous list stays in place; nothing is ever silently truncated. The ids travel as text and are hashed on the device, so the stored form cannot disagree with what makeCredential compares against.

Authorization is the same as enableEnterpriseAttestation: an acfg pinUvAuthToken, i.e. a FIDO PIN, and no touch. The list alone grants nothing — with EA disabled, every level-1 request is still rejected outright — so the touch that guards the seed would buy nothing here. Each write lands in the audit journal as CFG_EA_RPIDS, with the new entry count as its aux.

Under the hood it is authenticatorConfig (0x0D) subCommand vendorPrototype (0xFF), vendorCommandId 0x0e6841934e719be7, rpIds at subCommandParams key 4 — specified in protocol.md for third-party tools.

Transport and gating

The P-256 private scalar crosses USB ChaCha20-Poly1305-wrapped on the same ephemeral-ECDH channel (MSE handshake: P-256 ECDH → HKDF-SHA256 → ChaCha20-Poly1305) the seed backup uses. The chain is public certificate material and travels in the clear, MAC-covered by the PIN token like every subcommand parameter.

Import (0x09) and clear (0x0A) are gated exactly like a seed move: channel + PIN (when one is set) + physical touch. On this board the touch is the BOOTSEL button (build.md). On a device with no PIN the import asks for a second, named confirmation first (“Replace attestation identity?”): the PIN half of the gate is waived in that state, and one generic touch should not hand over the identity every later U2F registration signs with. status (0x0B) is ungated; the chain it returns is public. Both mutations land in the audit journal (ATT_IMPORT / ATT_CLEAR), and so does an enableEnterpriseAttestation (CFG_EA).

rsk fido attestation import …    # → "touch the device (BOOTSEL) to authorise…"
rsk fido attestation clear …     # → "touch the device (BOOTSEL) to remove…"

On the device the key is sealed under the same kbase arms as the master seed. The seal tag records which arm wrapped it, so importing before or after the OTP burn both stay loadable. Burn the OTP master key before importing and the sealed attestation key is rooted in fuses, not just flash (otp-fuses.md).

Reset semantics

authenticatorReset wipes FIDO user state, but the org provisioning splits across that line:

StateSurvives authenticatorReset?
EF_ATT_KEY (org key)yes: org-provisioned device identity, not user data
EF_ATT_CHAIN (chain)yes
EF_EA_ENABLED (the enable flag)no: wiped with PIN, credentials, counter
EF_EA_RPIDS (the type-1 RP list)no: wiped with the flag it serves

So a factory reset leaves the org attestation installed but switches EA off and forgets who qualified for level 1: the managed platform must re-issue enableEnterpriseAttestation, and an administrator must re-send the RP list, before level-1 EA fires again. The reset itself is recorded in the audit journal. Removing the key and chain is the explicit, gated attestation clear. Nothing else clears them.

Privacy note

A shared org chain makes credentials linkable to the organization across its relying parties. That is the entire point of EA, and why the spec gates it behind both an explicit per-request field and a device-wide enable. Ordinary (non-EA) makeCredential still carries the per-device certificate, which is linkable to the device but not to the organization; the org chain is served only on explicit EA requests.

Troubleshooting

  • attestation key must be P-256 (got …): the --key PEM is the wrong curve. RS-Key attests with ECDSA P-256 only. Re-issue the org key on secp256r1.
  • chain too large (… B, max 2048): trim the bundle. You rarely need the root CA in x5c; leaf + one intermediate is usually enough, and the leaf alone is all U2F can carry.
  • device requires a PIN — pass --pin (status 0x36): import/clear are gated; set a FIDO PIN first (rsk fido set-pin) and pass it.
  • An EA makeCredential comes back self-attested (no x5c, no ep). Either enableEnterpriseAttestation was never issued (check options.ep), or it was cleared by a factory reset. Have the managed platform re-enable it.
  • import failed: 0x33 (PIN_AUTH_INVALID): the PIN was wrong, or its token lacked the acfg permission. Re-run with the correct --pin (do not guess, wrong attempts burn PIN retries).
  • The import hangs at “touch the device…”: the physical touch never arrived. Press the BOOTSEL button while the prompt is up, then it completes.

AAGUID & metadata

Every FIDO2 authenticator model carries an AAGUID, a 128-bit identifier that says “this is a Model X authenticator.” It rides inside the attested credential data of every makeCredential. A relying party (RP) uses it to look up the model’s Metadata Statement: a machine-readable description of what the model can do and how it attests.

This page explains RS-Key’s AAGUID, the self-published Metadata Statement that ships in the repo, and (importantly) the line between what that buys you for free and what is gated behind FIDO certification.

RS-Key’s AAGUID

2479c7bf-6b30-5683-9ec8-0e8171a918b7

It is a UUIDv5, derived reproducibly so its provenance is self-evident:

python -c 'import uuid; print(uuid.uuid5(uuid.NAMESPACE_URL, "https://github.com/TheMaxMur/RS-Key"))'
# -> 2479c7bf-6b30-5683-9ec8-0e8171a918b7

An AAGUID is self-assigned. No central registration is required to pick one. Earlier RS-Key builds inherited an upstream AAGUID (the bytes of SHA-256("Pico FIDO2")), which meant the device claimed another project’s model identity. As of firmware bcdDevice 0x075F it carries its own.

A few consequences worth knowing:

  • One AAGUID for every flavor. It identifies the firmware model, not the USB branding, so the default pid.codes identity and the opt-in YubiKey-identity interop build report the same AAGUID. The YubiKey-identity build deliberately does not claim a real YubiKey AAGUID. That would be a forgery and would fail attestation anyway (it cannot chain to Yubico’s roots).
  • Existing credentials keep working. The AAGUID only appears in attestation at registration time. Resident keys made on an older build still assert fine. Only new registrations report the new AAGUID. An RP that pinned the old AAGUID for attestation matching would need a re-enroll.
  • Build-time override. AAGUID=<uuid-or-32-hex> cargo build bakes a custom AAGUID (build.rs validates it, consts.rs const-parses it); omit the flag to keep the default above. A non-default build makes the checked-in Metadata Statement (and the drift guard below) no longer match — reserve it for a fork that ships its own metadata, not a cosmetic tweak.

The Metadata Statement

metadata/rs-key.metadata.json is a FIDO Metadata Statement v3.1.1 describing the default build profile. It declares the AAGUID, the supported authentication algorithms, the attestation type, key/matcher protection, and an embedded authenticatorGetInfo that mirrors exactly what the device returns to authenticatorGetInfo (CTAP 0x04).

FieldRS-Key value
attestationTypes["basic_full"]: packed attestation with an x5c leaf
attestationRootCertificates[]: the leaf is per-device and self-signed, so there is no shareable root — the statement’s own legalHeader says so, since MDS defines this field as the anchors “for the batch chain” and an empty one otherwise reads as a broken chain rather than an absent one
authenticationAlgorithmssecp256r1 / ed25519 / secp384r1 / secp521r1 / secp256k1 (ECDSA + EdDSA)
keyProtection["hardware"]: RP2350 flash/OTP, not a separate certified secure element
matcherProtection["on_chip"]: the PIN is verified on the device
attachmentHint["external", "wired"]: a USB roaming token
upv1.0: matches the FIDO_2_0 entry the device advertises in versions

A drift guard, tests/62_metadata_statement.py, checks the statement against both the firmware source (the default AAGUID in build.rs) and a live device (the embedded authenticatorGetInfo vs the real one). Run it by hand. It is not in the hardware gate.

Two caveats baked into the statement

  • ML-DSA is not expressible. RS-Key implements post-quantum credential types (COSE -48 / -49, ML-DSA-44 / ML-DSA-65), but the FIDO Metadata Statement registry has no enum value for ML-DSA/Dilithium, so they cannot appear in authenticationAlgorithms. They are visible only inside the embedded authenticatorGetInfo (and only on the advertise-pqc build). A strict MDS consumer will not see the PQC capability.
  • One profile per statement. The build features advertise-pqc (adds COSE -48 to the algorithm list), fips-profile (drops secp256k1, raises the PIN floor to 6) and largeblob-ext (swaps the largeBlobKey extension, the largeBlobs option and maxSerializedLargeBlobArray for the CTAP 2.3 largeBlob extension) change getInfo. The shipped statement describes the default build. A different profile needs its own statement.

What this does — and does not — get you

RS-Key works as a passkey/security key with the overwhelming majority of relying parties without any of this. Self-attestation is accepted by GitHub, Google, Microsoft consumer accounts, browsers, ssh, and any RP that does not enforce attestation. The AAGUID and statement add a stable, honest identity and a machine-readable capability description for tooling that wants one.

The hard boundary is attestation enforcement. Taking Microsoft Entra ID as the strict reference:

Entra policyWhat RS-Key needs
Attestation not enforced (the common case)Nothing extra: none / packed-surrogate / a custom format ≤ 32 chars is accepted. RS-Key works today.
Attestation enforcedA packed attestation chaining to a root extracted from the FIDO MDS, the model’s metadata uploaded to the FIDO MDS, and a FIDO2 certification (any level).

That second row is a deliberate non-goal here: official MDS listing and FIDO certification require FIDO Alliance membership, a certification lab, and money, none of which is a code change. The self-published statement is the part that is actionable: an RP or library that can import a local metadata file (rather than only trusting the MDS BLOB) can consume it directly. If RS-Key is ever certified, the statement is already authored and ready to submit.

See also: Enterprise attestation for the org-provisioned cert-chain path, and the interop matrix for what has actually been observed working on hardware.

Fleet tooling

Inventory, identity verification, and offboarding for a fleet of RS-Keys. Three commands cover the lifecycle:

CommandQuestion it answersGates
rsk inventory listwhat is this key?none — touch-free, PIN-free
rsk inventory verifyis this the key we enrolled?touch; --pin if set
rsk offboardwipe a returned key, keep prooftyped confirmation, up to 3 touches

list reads only ungated state, so it is safe against a whole hub of keys. verify is the enrollment anchor. offboard is destructive and is the only one of the three that changes the device.

Inventory

rsk inventory list          # human-readable, one block per key
rsk inventory list --json   # one JSON object per line, for scripting

Walks every connected key over both transports. Prints one record per device with serial, firmware version, bcdDevice (the build counter), secure-boot state, flash usage, FIDO options, backup/soft-lock state, org-attestation state:

device 37bebfdca282523b  (ccid+hid)
  firmware   : 5.7.4  bcdDevice 0x0748  sdk 8.6
  secure boot: LOCKED  (bootkey 0x0)
  flash      : 16711/1572864 B used, 469 files
  fido       : U2F_V2, FIDO_2_0  clientPin=True
  backup     : sealed=False has_seed=True  seed lock: off
  org attest : installed  chain sha256 74d9f98c3fb0bb5c…

The serial is the RP2350’s OTP chip id, read from the rescue applet’s SELECT response. It is unique per chip, unlike the USB descriptor serial (identical across devices). Everything list reads is gate-free: no PIN, no touch, safe to run against a hub full of keys.

secure boot decodes to one of three states: not enabled (a blank dev board), ENABLED (the SECURE_BOOT_ENABLE fuse is set but the key pages are not yet locked), LOCKED (fully sealed). The bootkey index is the active key slot. See otp-fuses.md for what those fuses mean and production.md for how they get burned.

With several keys connected the CCID and HID transports cannot be matched to one another, so the records stay separate (tagged ccid / hid). The output ends with a note: saying so. Plug keys in one at a time when you want a single merged ccid+hid record per device.

The --json records

--json prints one object per line (JSON Lines, pipe it to jq, not json.load). The fields present depend on which applets answered. A merged record carries all of them:

FieldMeaning
transportccid, hid, or ccid+hid (merged)
serialRP2350 OTP chip id (CCID only)
sdkRS-Key rsk-sdk framework version (major.minor) from the rescue SELECT
secure_boot{enabled, locked, bootkey}
flash{free, used, kv_total, files, chip} (bytes)
fw, bcd_devicefirmware version string, build counter (HID)
versions, client_pinFIDO CTAP versions, PIN-set flag
aaguidthe device AAGUID (hex)
backup{sealed, has_seed} — seed-export lifecycle
lock{locked, unlocked}soft-lock state
org_attestation{installed, chain_sha256}
errorpopulated if an applet threw mid-read

A foreign PC/SC reader that does not answer the rescue SELECT is skipped, not reported. list only emits records for things that identify as RS-Keys.

Dump a fleet sweep to a ledger keyed by serial:

rsk inventory list --json | jq -s '
  map(select(.serial)) | INDEX(.serial)' > fleet-$(date +%F).json

Flag any key not on the current firmware (replace with your floor):

rsk inventory list --json \
  | jq -r 'select(.bcd_device and (.bcd_device < "0x0759"))
           | "\(.serial // .product)\tstale \(.bcd_device)"'

Identity verification

rsk inventory verify                            # print the fingerprint (touch)
rsk inventory verify --expect-key 66573f74ca06359a   # pin it (--pin if set)
rsk inventory verify --expect-key 04ab…   # or the full 65-byte SEC1 pubkey

Challenge-response against the device’s attestation key: the host sends a fresh 16-byte challenge, the device signs it (vendor AUDIT_CHECKPOINT) with the ECDSA P-256 key derived from its OTP DEVK, and the host verifies the signature. The printed fingerprint (SHA-256 of the public key, first 16 hex digits) is the same one rsk audit verify prints. One identity anchor for both workflows.

sequenceDiagram
    participant H as Host
    participant D as Device
    H->>D: fresh 16-byte challenge
    D-->>H: ECDSA-P256 signature (OTP-DEVK key) + public key
    H->>H: verify signature, match fingerprint vs record

--expect-key accepts either form printed by a prior run: the 16-hex fingerprint or the full hex att key (65-byte uncompressed SEC1 point). Either matches. The full key is the stronger pin since the fingerprint is a truncated hash.

This only proves identity once the device has a provisioned OTP DEVK. On an unprovisioned dev board there is no device-bound key and verify is refused. The error messages map to the vendor status:

SymptomStatusMeaning
device requires a PIN — pass --pin0x36a FIDO PIN is set; add --pin
refused — no OTP DEVK provisioned0x30blank board, no device key (production.md)
denied — no touch within 30 s0x27press the button when the LED blinks
attestation key MISMATCHnot the enrolled device (or a clone without the OTP DEVK)
SIGNATURE INVALIDthe device could not prove identity at all

Enrollment: when you hand a key out, run rsk inventory verify once and record serial + fingerprint (or the full att key). Any later verify with --expect-key <fingerprint> (or the full SEC1 public key) proves you are talking to that physical chip. A clone without the OTP DEVK cannot answer. Every verify is itself journaled as a CHECKPOINT event, so the device’s audit log shows each time it was checked.

verify has no --json (only inventory list does), so at provisioning time capture the two lines from its plain output, or just paste them into your record by hand:

rsk inventory verify | sed -n 's/^\(serial\|fingerprint\) *: //p'

Offboarding

rsk offboard                       # guided, typed confirmation, a replug, ~3 touches
rsk offboard --report ret-42.json  # choose the receipt path
rsk offboard --no-receipt          # wipe only — no preflight, no receipt file
rsk offboard --verify ret-42.json  # re-check a saved receipt, no device

Decommissions a returned key: wipes the OTP slots, OATH credentials, PIV (block PIN+PUK, then factory reset), OpenPGP (block PWs, then factory reset), the FIDO seed/passkeys/PIN, and the org attestation, then signs a final audit checkpoint over the post-wipe journal window and saves it as a JSON receipt. It needs both interfaces (CCID for the applet wipes, FIDO HID for the reset and the signature). If either is missing it refuses before touching anything.

The receipt needs the audit journal, which is off by default. The RESET event that makes the receipt mean anything is a journal entry, so on a device where journalling was never enabled there is nothing to sign — and after the wipe there is no way back. rsk offboard therefore probes the journal state before the confirmation prompt and refuses, pointing at rsk audit enable. Enable it at provisioning time, or pass --no-receipt to wipe with no attestation at all — that flag skips the preflight, the checkpoint touch and the receipt file, so the run leaves no record of any kind.

The wipe is interrupted once, on purpose. The FIDO reset is only accepted within ten seconds of a power-up (fido2.md), and by the time the four CCID applets are wiped the key has been plugged in for far longer. So rsk offboard sends the reset, and when the device refuses it prints an unplug/replug prompt and retries in the fresh window:

the key refuses a reset this long after power-up (CTAP 2.1 §6.6):
UNPLUG it now and plug it straight back in — it must be the only
FIDO key attached, and the reset lands within 10s of
power-up. Waiting up to 60s…

Plug the same key back in and nothing else. rsk offboard refuses to start with more than one FIDO device attached, because the USB serial is a constant and it cannot tell two RS-Keys apart: the applet wipes go to the card it picked by reader name, and the FIDO reset would go to whichever HID device enumerated first. A trusted-display key never sees the replug prompt — it is exempt from the window — and neither does firmware older than 0x0854.

The order is fixed and each step reports its own result:

StepWhat it doesRecords on success
OTPwrites an all-zero config to slots 1–4 (the protocol’s “delete”)ok
OATHsends the OATH RESET commandok
PIVexhausts PIN + PUK retry counters with two distinct wrong values, then factory RESETok
OpenPGPrsk openpgp reset (TERMINATE + ACTIVATE)ok
FIDOCTAP authenticatorResetreplug, then touchok
org attestationclears the chain if one was installed — touchcleared / none
receiptsigns a checkpoint over the post-wipe journal — touchsigned

The PIV step blocks the PIN and PUK with two different wrong values (00000000 then 11111111, eight tries each) before the reset, so even a device whose real PIN happened to be one of those still ends up with a dead retry counter. The reset then always succeeds regardless of the original credentials.

The receipt is a cryptographic statement that this device (attestation fingerprint) had its FIDO applet factory-reset (the signed window contains the RESET event). It is split along that trust boundary: attested is what the device signed plus the inputs needed to re-derive it, host_observations is what the tool saw and the device never vouched for.

{
  "receipt_version": 2,
  "signed": true,
  "reset_attested": true,
  "notes": [],
  "attested": {
    "signed_head": "…", "seq": 414, "signature": "…",
    "attestation_pubkey": "04…", "fingerprint": "66573f74ca06359a",
    "challenge": "…", "epoch": "…", "entries": "…"
  },
  "host_observations": {
    "device": "37bebfdca282523b",
    "timestamp": "2026-06-13T14:22:09-04:00",
    "steps": {"otp": "ok", "oath": "ok", "piv": "ok", "openpgp": "ok",
              "fido_reset": "ok", "org_attestation": "cleared"},
    "journal_window": [{"seq": 412, "event": "RESET", "...": "..."}]
  }
}

attested.epoch and attested.entries are the raw journal window the signature covers. Without them a reader can check the signature but cannot tie it to any particular window, so the RESET claim would rest on a decoded list anyone could hand-edit. Receipts written before this format (receipt_version absent) dropped them, which is why --verify refuses those outright rather than pretending to check them.

The default receipt path is offboard-<serial>-<YYYYMMDD-HHMMSS>.json in the working directory. --report overrides it. The file is always written, even on a partial failure or when a post-wipe check fails: a failed step leaves its error string in steps, a failed check appends a {"code", "detail"} entry to notes and clears reset_attested, the report still saves, and rsk offboard then exits non-zero so a script notices.

Re-checking a receipt

rsk offboard --verify ret-42.json --expect-key <fingerprint-from-enrollment>

Offline, no device: it re-folds entries from epoch, requires the result to equal the signed head, requires that bound window to contain the RESET event, then checks the ECDSA P-256 signature over "RSK-AUDIT-CKPT-v1" ‖ signed_head ‖ seq (LE32) ‖ challenge. --expect-key takes the 16-hex fingerprint or the full SEC1 point; without it the run still verifies the chain and the signature but says so — the key comes from the receipt itself, so an unpinned check cannot tell you which device signed.

Pin the fingerprint you recorded at enrollment, from your inventory record — not the one printed in the receipt you are checking. Pinning a receipt’s own attested.fingerprint compares it against itself and proves nothing. rsk offboard says as much when it prints the re-check line at the end of a wipe.

The steps are host observations. The journal has no event type for PIV, OATH, OpenPGP or OTP, so four of the five wipe steps are not attestable in principle; only the FIDO reset is. --verify prints that caveat on every run.

Why it needs no PIN

No PIN is needed anywhere in the flow. Every wipe path is deliberately reachable without credentials (the PIV/OpenPGP paths block the PINs first, which is the spec’s own anyone-can-reset design; OATH and FIDO have resetting paths of their own), so a key that comes back with unknown PINs can still be offboarded. What it cannot do is impersonate: nothing in the wipe path can read or export secrets, and the receipt’s signature still requires the device’s own OTP DEVK. A wipe tool cannot forge it.

Footguns and partial wipes

  • OTP slots protected by an access code are the one exception. They refuse the PIN-free delete (SW 6982), and the receipt’s host_observations.steps.otp records exactly which slots stayed (e.g. slots [2] protected by access codes — NOT wiped). A follow-up rsk offboard after recovering the code, or a full rsk-wipe flash nuke, covers that case.
  • The CCID wipes are idempotent. A failed FIDO reset is recorded in steps and the run still writes the receipt for what was destroyed, then exits non-zero. Re-run rsk offboard; the applet wipes already done are harmless to repeat.
  • no-session in notes means the key was never replugged (or never came back) within the timeout, so there was no FIDO session left to sign with. The receipt is written anyway, unsigned, with the CCID steps that did complete and fido_reset recording the abort — the applets are gone either way, and an irreversible step must not end with no artifact. Re-run to finish the FIDO half.
  • Unsigned receipt. On a board with no OTP DEVK the wipes still happen but the checkpoint is refused (0x30). The report saves with "signed": false and a no-devk note. That is expected on dev boards, not a fault.
  • no-reset-event in notes means the signed window did not contain the RESET, so the receipt does not certify a wipe (reset_attested is false and the command exits non-zero). The wipe still happened and re-running cannot produce the missing event. The usual cause is journalling being off, which the preflight now catches before anything is destroyed; the other is the journal ring having evicted the RESET past the export window.
  • head-mismatch in notes means the signed head did not fold from the window the device exported. Treat that as tampering or a broken device, not as a retryable error.

See also

  • audit.md: the journal and rsk audit verify; same attestation key and fingerprint as inventory verify.
  • attestation.md: the org-attestation chain that offboard clears and list reports.
  • seed-backup.md / backup-key.md: back up before you offboard if the key holds a recoverable seed.
  • production.md, otp-fuses.md: burning the OTP DEVK that makes verify and the signed receipt possible.
  • linux.md: pcscd / scdaemon setup so the CCID interface is visible to the inventory and offboard flows.

FIPS-style profile (fips-profile)

An opt-in build flavor that bakes a locked, FIPS-style algorithm policy into the image. Nothing is removed from the codebase or from the default build. Without the flag the firmware is byte-for-byte the usual one. With it, the policy is part of the signed image. Once secure boot is enabled the device runs nothing but that signed image: a policy you cannot toggle off at runtime, because there is no runtime knob to toggle.

cargo build --release -p firmware --features fips-profile
# or the reproducible Nix target (same flag):
nix build .#firmware-fips
# then sign + flash as usual (production.md)

A profile, not a validation. Nothing here is FIPS 140-3 validated: no CMVP certificate, no validated module boundary, no tested entropy source. This profile restricts the device to FIPS-approved algorithms and documents exactly where the line is drawn. If your compliance regime needs a certificate number, this is not it. If it needs “the device will not negotiate a non-approved algorithm,” this is exactly it.

What the profile locks

The flag wires through to two crates only: the FIDO applet (rsk-fido/fips-profile) and the PIV applet (rsk-piv/fips-profile). Every gate below is a compile-time cfg, so the restricted path is the only path compiled into the image.

AreaDefault buildfips-profile buildGate
FIDO algorithmsES256, EdDSA, ES384, ES512, ES256K, (ML-DSA-44/-65/-87)drops ES256K (secp256k1 — never NIST-approved) from both the advertised list and credential negotiationgetinfo.rs, makecredential.rs
FIDO minimum PIN46 (and setMinPINLength can only raise it, never lower it), plus trivially guessable PINs refused — a repeated period, a ±1 run, two or fewer distinct code points, or a denylisted keypad shape (the strong-pin policy)consts.rs, clientpin.rs, config.rs
Seed backupone-time export windowexport refused — non-exportable key material; restore (BACKUP_LOAD) still works, so keys may migrate into a profile device, never outvendor.rs
PIV management key3DES or AESno new 3DES keys (SP 800-131A); an existing 3DES key still authenticates so a reflashed device can migrate itself to AESpiv/lib.rs
PIV RSA1024 / 2048no RSA-1024 generation or importpiv/keygen.rs

Two things worth reading carefully:

  • ES256K leaves on both sides. getInfo’s algorithms list no longer carries -47, so a relying party never offers it. Even if a client asks for -47 anyway, makeCredential maps it to “unsupported” and declines. There is no path to a new secp256k1 FIDO credential.
  • RSA-1024 is blocked on two independent gates: the generation template parser (crates/rsk-piv/src/keygen.rs:47-50) and the separate import path (crates/rsk-piv/src/keygen.rs:596-598), which does not go through that parser. So neither ykman piv keys generate ... RSA1024 nor importing an external 1024-bit key onto a slot succeeds. Both return 6A 80 (incorrect data).

What deliberately stays

  • Ed25519 / X25519: approved by FIPS 186-5 (EdDSA) and SP 800-186. ssh ed25519-sk keeps working, and so do Ed25519/cv25519 OpenPGP keys.
  • NIST P-256 / P-384 / P-521 FIDO and PIV keys. The whole point of the profile is to keep these and drop the curve that was never on the list.
  • ML-DSA-44 / ML-DSA-65: FIPS 204. The post-quantum path is the point, and the profile does not touch it. (Whether they are advertised in getInfo is the separate advertise-pqc flag. Capability is on either way; see build.md.)
  • HMAC-SHA-1 in OATH HOTP/TOTP: RFC 4226 mandates it, and HMAC-SHA-1 (unlike bare SHA-1 signatures) remains approved.
  • The whole OpenPGP applet, unchanged. The profile is FIDO + PIV. It does not narrow OpenPGP key attributes. If you want only approved curves there, that is a card-edit policy choice (openpgp.md), not something this flag enforces. State it honestly to anyone relying on the profile.
  • Existing credentials still work. A secp256k1 credential created by a default build still asserts after you reflash with the profile. The gate is on makeCredential (creation), not on getAssertion (login). Same shape everywhere: an existing 3DES management key still authenticates (you can use it to set an AES one), existing RSA-1024 PIV keys still sign and decrypt. The profile gates creation and import, never your ability to keep using what is already on the device.

Migrating a device into the profile

Because the profile blocks creation but not use, an in-place migration is mechanical:

# 1. flash the profile image (signed, per production.md)
# 2. replace any non-approved long-lived keys:
ykman piv access change-management-key --algorithm AES256 --generate --protect
ykman piv keys generate 9a pub.pem            # re-issue any RSA-1024 slots as
                                              # ECC P-256 or RSA-2048
# 3. FIDO: re-register any sites that hold a secp256k1 credential with an
#    ES256 / EdDSA passkey; the old credential keeps working until you do.

ykman needs the opt-in VIDPID=Yubikey5 build. It gates on the “Yubico YubiKey” reader name, which the default RS-Key build (0x1209:0x0001) does not present. Build that flavor for the ykman commands here and under Verifying below, or drive the device with rsk / rsk-tui on the default build.

There is no “convert” command. You generate a new approved key and retire the old one through normal applet flows. The old 3DES/RSA-1024/secp256k1 material lingers only as long as you let it.

Verifying a device runs the profile

ykman fido info        # Minimum PIN length: 6
ykman info             # firmware version, applet capabilities

A profile build shows Minimum PIN length: 6. The absence of -47 (ES256K) is not something ykman fido info prints. It lists the AAGUID, options, PIN retries and minimum PIN length, but not the COSE algorithms array. To confirm secp256k1 is gone you need a raw getInfo dump (e.g. fido2-token -I or python-fido2): read the 0x0A array and confirm -47 is absent.

That advertisement is evidence, not proof on its own. A default build can have its minimum PIN raised to 6 by hand, which makes the PIN field alone ambiguous. The proof is the image signature. Combined with secure boot, the firmware’s own signature is the policy attestation: the fuses boot only your signed profile image, and that image is the only code present, so “the device enforces the profile” reduces to “the device booted, and your signature is the only one it accepts.”

flowchart TD
    src["Source (one tree)"] --> def["Default image"]
    src --> fips["fips-profile image"]
    fips --> sign["Sign + flash"]
    sign --> sb["Secure boot fuses<br/>boot only your key"]
    sb --> dev["Device runs only<br/>the signed profile"]
    dev --> att["getInfo + signature<br/>= the attestation"]

Why compile-time, not a runtime switch

A runtime “FIPS mode” toggle is one admin command away from not being FIPS mode, and one malware-driven APDU away from being turned off without you noticing. A compile-time profile under secure boot is a different object: the restricted menu is the only code in the image, the image is signed, and the fuses only boot your signatures. Changing the policy means signing and flashing a different image. That is exactly the auditable, physical event you want a policy change to be, rather than a silent state flip.

This mirrors how the rest of RS-Key’s hardening works: every knob is compile-time, and the irreversible posture lives in OTP fuses, not in mutable runtime state.

Honest limits

  • No CMVP certificate, no validated boundary. Read the first callout again: this is an algorithm policy, not a validation. The limitations and threat model pages apply unchanged.
  • The entropy source is the RP2350’s, untested against SP 800-90B. A validated module needs a validated RNG. This profile does not provide one.
  • OpenPGP and OATH are not narrowed by the flag. If your policy needs approved-only algorithms there too, you enforce that through the applets’ own configuration, not through fips-profile.
  • “Approved algorithm” ≠ “approved usage.” The profile keeps you from negotiating a disapproved primitive. It does not audit key sizes, rotation, or how you use the device. That part is on you.
  • build.md: all compile-time flags, including the .#firmware-fips Nix target.
  • production.md: signing and the secure-boot fuse sequence that seals the profile in place.
  • seed-backup.md: the seed export/finalize flow on a default build (and why the profile refuses it).
  • piv.md, fido2.md, openpgp.md: the applets the profile narrows (and the one it leaves alone).

Production setup — signed boot + OTP master key

⚠️ EXPERIMENTAL. IRREVERSIBLE. BRICK RISK. Everything on this page burns one-time-programmable fuses or changes what the chip will ever boot again. A mistake can permanently brick the board or permanently lose your enrolled credentials. Read the whole page before running anything. The tools refuse to act without typed confirmations and support --dry-run. Use it.

Out of the box, RS-Key’s at-rest encryption roots in a key derived on the device and stored sealed in flash. That stops casual key extraction. But a motivated attacker who steals the board can dump flash over BOOTSEL and grind offline. The production path closes that in two independent stages:

  1. OTP master key (MKEK): fuse a random 32-byte key into RP2350 OTP page 58 and re-root all at-rest sealing in it, then hard-lock the page so neither BOOTSEL nor non-secure code can ever read it. A flash dump alone is now worthless.
  2. Secure boot: fuse your public-key fingerprint and the SECURE_BOOT_ENABLE bit so the bootrom runs only images you signed. Attacker-flashed firmware (the remaining way to read the OTP key) no longer runs.

A third, optional stage builds on those: anti-rollback, so that old images you signed (with bugs you have since fixed) stop booting too. It has its own page: anti-rollback.md.

Each stage is usable alone. Together they are the full story. All are driven from the host. The firmware never burns a fuse behind your back. The two exceptions are rows that physically cannot be written from BOOTSEL (bootloader-read-only OTP pages): the page-58 lock and the ROLLBACK_REQUIRED flag, each applied by the firmware on explicit command. The fuses these stages write are explained in otp-fuses.md.

flowchart TD
    d["Default<br/>flash-derived root · any image boots"]
    d --> s1["Stage 1 — OTP master key<br/>burn page 58 · migrate · lock"]
    s1 --> s2a["Stage 2 — secure boot<br/>load-key · harden (non-enforcing)"]
    s2a --> s2b{{"ENABLE<br/>the one irreversible bit"}}
    s2b --> s2c["lock key slots + fuse pages"]
    s2c --> s3["Stage 3 — anti-rollback<br/>(optional)"]
    s2b -. "a correctly-signed UF2 can always be re-flashed over BOOTSEL" .-> rec["BOOTSEL recovery"]

Every command on this page, in order

The CLI groups by fuse familyrsk otp owns the OTP master key and the rollback flag, rsk secure-boot owns the boot-key and enforcement fuses — while this page groups by goal. The two cuts cross, which is why rsk otp shows up in stage 1 and again in stage 3. Nothing is out of order; they are different axes. The stages themselves are independent and usable alone, and this is the order to run them in if you are doing all three.

#CommandStageWhat it writesUndo
1rsk otp burn1random MKEK + DEVK into OTP page 58never
2rsk otp lock-page581the page-58 read locknever
3picotool seal --sign …2bnothing — host-only, and it produces the otp.json step 4 needsre-run it
4rsk secure-boot load-key <otp.json>2cboot-key fingerprint + KEY_VALIDnever
5rsk secure-boot harden2cDEBUG_DISABLE + glitch detectorsnever
6rsk secure-boot enable2cSECURE_BOOT_ENABLEthe brick bitnever
7rsk secure-boot lock2crevokes unused key slots, locks the fuse pagesnever
8rsk otp rollback-require3ROLLBACK_REQUIREDnever

rsk secure-boot status reads the whole fuse state and is safe at any point. Every burning command takes --dry-run and a typed confirmation.

Before you start

  • Make a seed backup first if you haven’t: rsk backup export (guides/seed-backup.md). It is the only thing that survives a board swap. The production path is exactly when you start caring about that.
  • Plan for the signing key. Stage 2 generates an ECDSA key. Losing it bricks the board for new firmware. Decide now where you’ll keep it durably.
  • Understand the substrate. These stages write OTP fuses. otp-fuses.md explains what is irreversible and why.
  • Rehearse. Every step supports --dry-run, which prints the exact picotool commands without touching anything.

Stage 1 — OTP master key

What it does: writes a random DEVK (device attestation key) and MKEK (master sealing key) plus anti-imaging chaff into OTP page 58, ECC-verified, then locks the page. On the next boot the firmware notices the provisioned key and migrates everything already on the device (FIDO seed, PIV keys, OpenPGP key wraps, PIN verifiers) under the new root. Your enrolled credentials survive. That is the point of the migration layer.

At-rest hardening pass. The migration re-seals each secret under the new root. But the flash store is append-only, so the old chip-serial-sealed copies linger until their page is reclaimed. To stop a flash dump from recovering them, that same first post-burn boot runs a one-shot compaction that physically scrubs the credential partition. It is a multi-second stall that runs before the device re-attaches to USB, so on that one boot the device stays dark a little longer than usual. Don’t cut power during it. It runs once (a flash marker gates it) and re-runs if interrupted.

New deployments: burn OTP before you enroll. A seed generated after the burn is sealed under the fused root from birth, so no chip-serial-sealed copy ever exists and the hardening pass has nothing to scrub. The migrate-in-place path above is for hardening a device you have already been using.

rsk reboot bootsel               # picotool needs the chip in BOOTSEL
rsk otp burn --dry-run           # preview every step
rsk otp burn                     # typed confirmation; keys are generated and FORGOTTEN
picotool reboot -a               # back to the app; migration runs at boot
rsk otp lock-page58              # firmware applies the page-58 hard lock (typed confirm)

Facts to internalize first:

  • The burn tool generates MKEK/DEVK randomly and forgets them. There is no copy to lose, and none to back up. The fuses are the key.
  • After the burn, the device attestation public key changes (it now derives from the fused DEVK). FIDO/PIV identities survive. The rescue attestation key does not. That is expected, not data loss. If you use audit or fleet verification, re-record the device’s fingerprint afterward (guides/fleet.md).
  • The lock is the half that matters for at-rest. Burning the MKEK without lock-page58 leaves the key fused but still readable over BOOTSEL. The flash dump is not yet worthless. Run lock-page58 to actually close it.
  • After lock-page58, picotool otp get on page 58 fails with a permission error forever. Only the secure-mode firmware can read the keys. That failure is the lock working.
  • A seed backup (rsk backup) made before or after is unaffected. Backups carry the seed value, which gets re-sealed under whatever root the device has.
  • Never flash a FAKE_MKEK test build onto a provisioned board. It migrates the data under a fake, greppable key and orphans it (build.md).

Stage 2 — secure boot

What it does: the RP2350 bootrom verifies an ECDSA (secp256k1 + SHA-256) signature on every image against a fingerprint fused into OTP. Unsigned or foreign-signed images do not boot. The chip falls back to BOOTSEL, where you can always drag a correctly-signed UF2 (recovery path).

Secure-boot signing chain. On the host, build then picotool seal –sign produce a signed UF2; on the device the RP2350 bootrom verifies the ECDSA signature against the fingerprint fused in OTP, running a verified image and falling back to BOOTSEL for a rejected one

The permanent consequences:

  • Every future flash must be signed with your key. The dev loop becomes build → picotool seal --sign → flash.
  • Losing the signing key bricks the board for new firmware (the current signed image keeps booting). Back the key up before enabling enforcement.
  • DEBUG_DISABLE is burned along the way. SWD is gone (flashing is BOOTSEL anyway).

2a. Generate a signing key (once, off-repo)

mkdir -p ~/.rs-key-secrets && cd ~/.rs-key-secrets
openssl ecparam -genkey -name secp256k1 -noout -out secure_boot_key.pem
openssl ec -in secure_boot_key.pem -pubout -out secure_boot_pub.pem
chmod 600 secure_boot_key.pem
# BACK IT UP somewhere that survives this machine.

That is the whole of what you create by hand. The otp_secureboot.json the next step passes around is produced by seal, not written here — see 2b.

This key is the root of trust for the board’s whole life. Treat it like the most important secret you have here: if you lose it after enable, you can never flash new firmware to that board (the running image keeps booting). It is also what makes key rotation possible later, so a single, well-kept key with a backup is the goal. The full key lifecycle is in signing-keys.md: passphrase protection (and the decrypt-for-seal step it implies), backup, one fresh key per board, rotation, and recovery.

2b. Sign and prove a signed image boots (before any fuse)

# 1. an image to sign. The partition table has to be in it BEFORE sealing, or
#    the signature does not cover the fence (build.md#the-partition-table).
cargo build --release -p firmware
scripts/pt.sh target/thumbv8m.main-none-eabihf/release/firmware firmware-pt.elf
#    (or a single `nix build .#firmware`, whose result/firmware.elf already has it)

# 2. sign the ELF — NOT the UF2, and convert afterwards. `seal` retires the
#    image's own IMAGE_DEF (the linker's, carrying no signature and no rollback
#    version) only when handed an ELF; given a UF2 it appends its signed block and
#    leaves that one live and FIRST, which a SECURE_BOOT_ENABLE + ROLLBACK_REQUIRED
#    device meets first and refuses — while the host still prints
#    "signature: verified". otp_secureboot.json does NOT exist yet: you pick the
#    path, and `seal` creates the file there. It is the artifact stage 2c fuses.
picotool seal --sign --hash firmware-pt.elf -t elf firmware-signed.elf -t elf \
    ~/.rs-key-secrets/secure_boot_key.pem ~/.rs-key-secrets/otp_secureboot.json \
    --major 1 --minor 0 --rollback 1
picotool uf2 convert firmware-signed.elf -t elf firmware-signed.uf2
picotool info -a firmware-signed.elf   # "signature: verified", and block 1 "ignored"
ls -l ~/.rs-key-secrets/otp_secureboot.json    # seal wrote this; 2c needs it

# 3. BOOTSEL, then flash + confirm the device works:
picotool load -v firmware-signed.uf2 && picotool reboot   # or drag it onto the RP2350 drive

What otp_secureboot.json is for

The one part of this page with no obvious purpose until you see both ends of it.

The bootrom’s check is: does SHA-256(the public key inside this image) equal a fingerprint fused in OTP? So that fingerprint has to get into the fuses. But signing happens on your host, with a private key that must never go near the device, while fusing happens against the board — two separate operations, possibly on different machines and months apart. Nothing in the signed image can carry the value across: the device is exactly what you don’t trust yet.

That file is the courier, and its whole job:

Written bypicotool seal (2b) — it derives the fingerprint from your .pem
Read byrsk secure-boot load-key <that file> (2c) — burns it into a boot-key slot
Containsbootkey0 (the 32-byte fingerprint, length-checked before the burn) + key_valid + secure_boot_enable
Verified after the burnload-key reads the slot back and compares it to the fingerprint it wrote — a mismatch fails with verify failed: slot N reads back … rather than leaving an unusable slot marked valid
Touched by anything elseno

You do not back it up, and losing it costs nothing. It is a pure function of your signing key — sealing any image with the same .pem regenerates it byte-for-byte, and --major / --minor / --rollback do not change a byte of it. It holds no private material, so it is not a secret either. The .pem is the thing to protect; this is a derived artifact you can recreate in one command.

It is per-key, so with one key per board (recommended — signing-keys.md) keep each next to the .pem that produced it, under names you can tell apart.

The other arguments:

  • secure_boot_key.pem: your signing key. It signs the image.
  • --major / --minor: an image version (major.minor) stamped into the RP2350 boot metadata. It is a plain version label, distinct from the firmware version RS-Key reports (5.7.x, build.md) and from the rollback version. The bootrom can use it to prefer the newer of two images in an A/B setup. RS-Key ships a single image, so here it is effectively a label. Keep 1 0.
  • --rollback: the anti-rollback version, a separate counter (not the image version). It is harmless before anti-rollback is enabled, and having a version in every sealed image from day one makes that stage cheap. What it means and how to choose it: anti-rollback.md.

picotool info firmware-signed.uf2 must report signature: verified. The firmware’s image definition is already secure-boot compatible. The sealed UF2 carries the signature block.

Sealing also covers the image’s partition table, the fence that keeps the USB bootloader out of the KV store (build.md). That is the whole reason the fence is worth anything: on an unsigned board an attacker simply flashes an image carrying a permissive table, while a sealed table cannot be replaced without this key. It closes the one snapshot/restore gap secure boot does not close by itself — writing the data region is not code execution (threat-model.md). Prove it, don’t assume it: picotool info -a firmware-signed.uf2 must show the store partition as NSBOOT(-), and a byte flipped anywhere in the table must turn hash: verified into hash: incorrect.

2c. Burn, staged

rsk secure-boot splits provisioning so every irreversible write is proven by a real boot before the next. The only true point of no return is one bit:

rsk secure-boot status      # read the current fuse state any time
rsk secure-boot load-key ~/.rs-key-secrets/otp_secureboot.json   # 1. boot-key fingerprint + KEY_VALID (non-enforcing)
rsk secure-boot harden      # 2. DEBUG_DISABLE + glitch detectors   (non-enforcing)
rsk secure-boot enable      # 3. SECURE_BOOT_ENABLE = 1  ← the brick bit
rsk secure-boot lock        # 4. revoke unused key slots + lock the fuse pages

Each step has --dry-run and a typed confirmation. Between steps, reboot and confirm the device still works. After enable, verify the negative case: drag an unsigned UF2. The bootrom must reject it and fall back to BOOTSEL. Re-drag the signed one to recover.

lock and key rotation: a decision to make now. The lock stage revokes the three unused boot-key slots (KEY_INVALID), maximizing hardening against an attacker who tries to inject their own key. It also forecloses key rotation, the escape valve if you ever exhaust the 48-step anti-rollback budget (you rotate to a new signing key and revoke the old one). If you want to keep that valve, don’t run the full lock. Leave a slot. The trade-off is in anti-rollback.md. Most users should run the full lock. You will almost certainly never reach the ceiling.

The new flash workflow (forever)

cargo build --release -p firmware
scripts/pt.sh target/thumbv8m.main-none-eabihf/release/firmware firmware-pt.elf
# seal the ELF, convert after — a UF2 in leaves the unsigned IMAGE_DEF first (2b)
picotool seal --sign --hash firmware-pt.elf -t elf firmware-signed.elf -t elf \
    ~/.rs-key-secrets/secure_boot_key.pem ~/.rs-key-secrets/otp_secureboot.json \
    --major 1 --minor 0 --rollback 1
picotool uf2 convert firmware-signed.elf -t elf firmware-signed.uf2
# BOOTSEL (hands-free: rsk reboot bootsel), then:
picotool load -v firmware-signed.uf2 && picotool reboot   # or drag it onto the RP2350 drive

The pt.sh line is not optional here, and its position is the point. It embeds the partition table that fences the KV store off from the bootloader (build.md); cargo build alone never produces one. Running it before seal is what puts the table under the signature — which is the only thing that stops an attacker flashing a permissive table of their own. Seal a table-less image and you have signed away the fence without any error telling you so.

The --rollback value is your board’s current floor (see anti-rollback.md). 1 is the usual starting value.

To seal an image others can independently verify, build it with nix build .#firmware instead of the dev-shell cargo build. That path is bit-for-bit reproducible from the source tree (build.md), so anyone can rebuild at your release commit and confirm the payload you signed.

Stage 3 — anti-rollback (optional)

This stage stops your own older signed images from booting, so a bug you have since fixed can’t be re-introduced by downgrading. Read anti-rollback.md first. It is the full model: how the floor works, the 48-burn budget, when (and whether) to raise it, what to do at the ceiling, and the new-board case. This section is only the steps to turn it on.

The mechanism is two RP2350-native pieces: a per-image rollback version (--rollback N at seal time) checked against a 48-bit OTP thermometer, and the ROLLBACK_REQUIRED fuse that makes that check mandatory. Until the fuse is burned, versionless images boot and the feature is off.

Turning it on

  1. Seal and flash firmware with a rollback version (start at --rollback 1), reboot, and confirm rsk secure-boot status reports boot version 1/48. If it still reads 0/48, stop and investigate. Never burn the fuse on an unproven setup.
  2. Re-seal rsk-wipe at the same version. The recovery escape hatch must stay bootable.
  3. From the running firmware: rsk otp rollback-require (typed confirmation; --dry-run reports state without burning). The firmware refuses unless secure boot is enabled, so it can only run from an image that itself passed the rollback check, and the “fuse before a versioned image” footgun can’t happen.
  4. Negative-test: drag any versionless signed UF2. The bootrom must refuse it and fall back to BOOTSEL. Re-drag the current image to recover.

After it’s on

  • Every picotool seal must include --rollback <your floor>. A versionless sealed image no longer boots (fail-closed). You find out at flash time and recover by re-sealing.
  • To raise the floor and close a downgrade-fix, seal one step higher (--rollback <floor + 1>). The default is not to raise it. When, why, and the budget math are all in anti-rollback.md.
  • A board that has burned to floor N never boots an image below N again. No undo.

Everything about whether to bump, the 48-budget, the ceiling, key rotation, and moving to a new board lives in anti-rollback.md.

Recovery and failure cases

SituationWhat happens / what to do
Bad or wrong flashBOOTSEL stays enabled — drag a correctly-signed UF2 to recover.
Unsigned / foreign-signed image (secure boot on)Bootrom refuses it, falls back to BOOTSEL. Drag your signed image.
Lost the signing key (secure boot on)The current signed image keeps booting, but you can never flash new firmware. There is no recovery for the key — back it up before enable.
Image sealed below your rollback floorRefused at boot → BOOTSEL. Re-seal at ≥ your floor.
Image sealed above your floor by accidentIt boots and burns the thermometer up to it, spending budget irreversibly. Seal at exactly your floor unless you mean to raise it.
rsk-wipe won’t boot after enabling anti-rollbackRe-seal it at your current floor — the recovery image must carry a version too.
picotool save/load/erase over the store answers permission failureExpected: the partition table denies the bootloader there (build.md). Erase from the running side instead — rsk-wipe and the rescue applet execute as secure code, which the table leaves rw.
48-step rollback budget exhaustedKey rotation, or a new board — see anti-rollback.md.
Page 58 read fails after lock-page58That’s the lock working — only secure firmware can read the keys now.
Replacing the board entirelyProvision the new chip with a new signing key; restore your FIDO identity with rsk backup restore. Resident passkeys / OpenPGP / PIV don’t migrate — see anti-rollback.md.

Deliberate choices

  • USB BOOTSEL stays enabled. It is the only reflash and the only recovery path (no debugger). It cannot bypass signature enforcement, and after the page-58 lock it cannot read the OTP keys. Disabling it (the datasheet’s full checklist) would turn every bad flash into a permanent brick.
  • No image encryption. The code is open source. There is nothing secret in the image (secrets live sealed in flash, rooted in OTP). The RP2350 also has no transparent XIP decryption. Encrypted boot requires fitting the image in SRAM, which a ~1.7 MB image does not.

Residual risks (still open after all stages)

  • XIP TOCTOU: the image executes from external QSPI flash. Hardware that swaps or emulates the flash chip between the bootrom’s signature check and execution can subvert it. Decap/side-channel-class attack, out of scope.
  • A host compromised while the device is plugged in can drive normal operations (as with any security key). See threat-model.md.
  • An older signed image of yours re-opens the store fence. The partition table travels inside the image, so anything you signed before 0x0871 carries none, and flashing it makes the KV store bootloader-writable again — the snapshot/restore rollback is back. This is an ordinary downgrade attack, so the version axis is what answers it: raise your rollback floor past your pre-table builds (anti-rollback.md). Until you do, the fence is worth exactly as much as your oldest signed artifact.

Signing keys (secure boot)

Once secure boot is enabled, a single key you hold is the root of trust for the board’s whole life: it signs every image the board will run. This page is the full key lifecycle: what the key is, how it relates to the other on-chip keys, and the correct flow to generate, back up, provision, use, rotate, and (if it comes to it) recover it.

⚠️ This key is the most important secret in the production path. Lose it after enable and you can never flash new firmware to that board again. Read the backup section before you generate anything.

The keys on an RS-Key, and which one this is

Three different key concepts live on a provisioned board. Don’t conflate them:

KeyWhere it livesWho holds itThis page?
Secure-boot signing keyprivate key on your host; only its fingerprint is fused in OTPyouyes
MKEK / DEVK (master sealing / device attestation)OTP page 58, generated on-device and forgottennobody; the fuses are the keysno (production.md stage 1)
Per-applet secrets (FIDO seed, PIV/OpenPGP keys)sealed in flash under the MKEKthe deviceno (threat-model.md)

The signing key is the only one you generate, hold, and must keep. The MKEK/DEVK are random and unrecoverable by design. The signing key is the opposite: you keep it, and keeping it is the whole job here.

Architecture — the trust chain

You sign images with the private key. The board never sees it; OTP holds only a fingerprint (SHA-256 of the matching public key). The signed image carries its public key plus an ECDSA signature. The bootrom checks both at every boot:

flowchart TD
    priv["Private key<br/>(offline, backed up)"] -->|signs| seal["picotool seal --sign"]
    seal --> img["Signed image<br/>public key + ECDSA signature"]
    img -->|BOOTSEL flash| board["Board"]
    board --> rom{"bootrom: SHA-256(image's public key)<br/>matches a fused, valid slot?"}
    rom -->|no| reject["refuse → BOOTSEL"]
    rom -->|yes| verify["verify the ECDSA signature"]
    verify --> boot["boot the image"]
    slots["OTP boot-key slots (4)<br/>fingerprints + KEY_VALID / KEY_INVALID"] -. anchors .-> rom

Two consequences fall out of this shape and explain everything below:

  • The board only ever holds the public fingerprint, so a stolen board can’t yield your signing key, but the board also can’t help you recover it.
  • The bootrom matches against any valid slot and ignores which key signed, beyond the fingerprint. That is what makes key revocation a downgrade defense (see anti-rollback.md).

1. Generate (one fresh key per board, off-repo, ideally offline)

The RP2350 bootrom verifies secp256k1 + SHA-256, so the key must be secp256k1. Protect it with a passphrase. That is the difference between a stolen backup being your signing key and being ciphertext:

mkdir -p ~/.rs-key-secrets && cd ~/.rs-key-secrets
# generate, then encrypt at rest with a passphrase (openssl prompts you to set one):
openssl ecparam -genkey -name secp256k1 -noout \
  | openssl ec -aes256 -out secure_boot_key.pem
# derive the public key (prompts for the passphrase to read the private key):
openssl ec -in secure_boot_key.pem -pubout -out secure_boot_pub.pem
chmod 600 secure_boot_key.pem

Use a long, unique passphrase and back it up separately. Losing the passphrase loses the key as surely as losing the file. If you would rather not use one, drop the | openssl ec -aes256 … pipe and write the key straight out (-out secure_boot_key.pem): simpler, but then any copy of the file is the key. Either way, generate it on a machine you trust (ideally offline/air-gapped) and never commit it. The hermetic nix build path deliberately keeps the key out of the build sandbox (build.md); signing is always a separate, local step.

One key per board, one board per key. Generate a brand-new key for every chip you provision and trust only it on that board. A new device is signed with a new key. Never carry an old board’s key onto a new one. That rule is also what keeps a replacement board’s rollback floor safe: the old images are signed by the old key, which the new board does not trust (anti-rollback.md).

2. Back it up — before you fuse anything

This is the step people skip and regret. After enable, the fused fingerprint is permanent and only this key can sign images the board will boot. So:

  • Make at least two durable copies, on media that survive this machine (and each other), e.g. an encrypted USB stick kept offline, plus a printout of the PEM in a safe.
  • Encrypt it at rest. A passphrase-protected copy, or a copy held on a separate hardware token, is worth the friction.
  • Keep it off networked machines. The host that signs only needs the key for the few seconds of picotool seal.
  • There is no recovery from the device. It holds only the public fingerprint. If every copy of the private key is gone, the board’s current signed image keeps booting but you can never flash it again.

If you reserve a slot for rotation you get one escape hatch later. Even so, treat the key as un-loseable.

3. Provision — fuse the fingerprint (slot 0)

picotool seal writes the fingerprint into the otp_secureboot.json it produces. rsk secure-boot load-key fuses it into boot-key slot 0 without enabling enforcement yet:

# seal once to produce the otp.json (also see production.md, stage 2b):
picotool seal --sign --hash firmware-pt.elf -t elf firmware-signed.elf -t elf \
    ~/.rs-key-secrets/secure_boot_key.pem ~/.rs-key-secrets/otp_secureboot.json \
    --major 1 --minor 0 --rollback 1
rsk secure-boot status                          # bootkey present: False
rsk secure-boot load-key ~/.rs-key-secrets/otp_secureboot.json   # fuses slot 0

load-key is non-enforcing: unsigned images still boot until enable. It refuses if a key is already present, so it is a one-shot for slot 0. (If your key is passphrase-protected, decrypt it to a temp file for this seal too, exactly as in step 4.) The rest of the staged ritual (hardenenablelock) is in production.md.

4. Daily use — sign every flash

After enable, every image must be sealed with this key. picotool reads only an unencrypted PEM (it has no passphrase prompt), so a passphrase-protected key (recommended) is decrypted to a temporary file just for the seal and removed straight after:

# decrypt for the seal only — best on a RAM-backed dir so the plaintext never
# touches disk; openssl prompts for the passphrase:
( umask 077; openssl ec -in ~/.rs-key-secrets/secure_boot_key.pem -out /tmp/sk.pem )
picotool seal --sign --hash firmware-pt.elf -t elf firmware-signed.elf -t elf \
    /tmp/sk.pem ~/.rs-key-secrets/otp_secureboot.json --major 1 --minor 0 --rollback 1
picotool uf2 convert firmware-signed.elf -t elf firmware-signed.uf2
rm -P /tmp/sk.pem        # overwrite + delete (Linux: shred -u /tmp/sk.pem)
# BOOTSEL, then:
picotool load -v firmware-signed.uf2 && picotool reboot   # or drag it onto the RP2350 drive

If your key has no passphrase, pass ~/.rs-key-secrets/secure_boot_key.pem straight in as the <key> argument and skip the decrypt / rm lines.

The rsk-wipe recovery image must be sealed with a currently valid key too, or it won’t boot on a secure-boot board. After a rotation it must be re-signed with the new key. The full flag meaning is in production.md; if anti-rollback is on, add --rollback (anti-rollback.md).

Key slots and validity

The RP2350 has four boot-key slots, each holding one fingerprint, plus KEY_VALID / KEY_INVALID masks that say which slots the bootrom trusts:

  • The bootrom accepts an image whose public key matches any valid, non-revoked slot.
  • The lock stage (production.md) sets KEY_INVALID on every slot the bootrom does not already trust, keeping the one the board actually boots from. Maximum hardening, but it also removes any room to add a key later. On a board mid-rotation — a new key provisioned but the old slot not yet revoked — two slots are trusted and lock refuses rather than guess: run revoke <old slot> first.
  • Reserving a slot vs. full lock-down is a decision you make at provisioning time. It is the same trade-off as the anti-rollback escape valve, laid out in anti-rollback.md. Most users should take the full lock. Reserve a slot only if you specifically want a future rotation path.

5. Rotate a key

You rotate when you want a new signing key to take over and the old one to stop being trusted. The two real reasons: a suspected key compromise or the anti-rollback budget ceiling (a new key revokes the old, killing old signed images by signature; see anti-rollback.md).

The flow, in order (never revoke the old key until the new one is proven):

  1. Generate a new key K2 (section 1) and back it up (section 2).
  2. Provision K2’s fingerprint into a free, un-revoked slot: rsk secure-boot load-key --slot <free> K2-otp.json.
  3. Re-sign the current firmware with K2, flash it, and confirm it boots (the board now validates it via K2).
  4. Revoke the old key K1: rsk secure-boot revoke <K1-slot>. Old images signed only by K1 now fail secure boot.

rsk secure-boot rotate K2-otp.json runs steps 2–4 as a guided flow: it provisions the next free slot, then stops and tells you to flash and prove K2 before you revoke K1. It never revokes for you while only one key is proven.

Tooling. Rotation is driven by rsk secure-boot, not hand-rolled picotool otp writes: load-key --slot N provisions any of the four slots, revoke <slot> retires one (refusing to revoke your last valid key), and rotate <new.json> walks the whole flow: provision a free slot, then it tells you to flash and prove the new key before revoking the old one. It only works if you reserved a slot (didn’t run the full lock, which leaves the key pages bootloader-writable); on a fully-locked board the commands refuse, and a fresh board is the only path. With four slots you can rotate roughly three times before they’re spent.

Loss and recovery — the cases

SituationOutcome
Lost the key before load-keyNo harm. Regenerate. Nothing is fused yet.
Lost it after load-key, before enableEnforcement is still off, so the board boots unsigned images and keeps working, but slot 0 is now fused to a key you don’t have. Provision a different slot for a new key, or treat the board as not worth securing.
Lost it after enableThe key itself is unrecoverable, and the current signed image keeps booting forever. After a full lock, the unused slots are revoked and the key pages are locked, so you can never flash new firmware: a new board. If instead you reserved a free slot, you can still provision a new key into it and flash again (the bootrom never asks for the old key): rsk secure-boot load-key --slot <free> <new-otp.json> does it, no hand-rolled picotool needed.
Key compromised (someone else has it)Rotate to a new key and revoke the old (needs a reserved slot), or move to a new board. The old key can sign images your board still trusts until it is revoked.
Replacing the boardProvision the new chip with a new key; restore your FIDO identity with rsk backup restore. See anti-rollback.md.

Best practices, in one place

  • secp256k1, generated offline, passphrase-protected at rest, never in the repo or a build sandbox.
  • ≥2 durable, encrypted, offline backups (before you fuse anything).
  • One fresh key per board for its life: a new board gets a new key, never the old one. Rotate only on compromise or the rollback ceiling, and only if you reserved a slot.
  • Sign rsk-wipe with the same key as your firmware (keep the recovery hatch bootable).
  • Decide lock-down vs. a reserved rotation slot up front. It can’t be changed after lock.

OTP fuses (RP2350)

The “production” hardening in RS-Key comes down to writing the RP2350’s OTP: the OTP master key, secure boot, and anti-rollback. This page explains what OTP is, why it is irreversible, how RS-Key writes it, and exactly which rows it touches. Read it before production.md. It is the substrate everything else stands on.

“OTP” here means One-Time-Programmable fuses on the chip, not the Yubico one-time-password feature (guides/otp.md). Different thing, same three letters.

What OTP is

OTP is a block of on-chip memory made of antifuses: writing a bit physically and permanently changes the silicon. A bit goes 0 → 1 and never back. There is no erase, no reset, no “factory default”. For OTP, factory is wherever you left it. Every chip has its own OTP. Nothing about it is shared between boards.

This is the whole point. The value of OTP for a security key is exactly that it cannot be undone: a key fused here can’t be un-fused, a secure-boot bit can’t be cleared, a rollback floor can’t be lowered. Hardening that you could reverse would be hardening an attacker could reverse too.

⚠️ Every write on this page is permanent. A mistake can lock you out of reading a value forever, or brick the board for new firmware. The tools refuse to act without typed confirmations and support --dry-run. Use it.

Layout: pages, rows, and reliability copies

OTP is addressed in rows (24 bits each), grouped into pages of 64 rows (so page N starts at row N × 0x40; page 58 begins at row 0xE80). Page granularity matters because read/write locks are applied per page, not per row.

A single antifuse can be marginal, so the values that the bootrom and firmware depend on are stored redundantly:

  • RBIT-3: the value is written into three consecutive rows, and the reader takes the bitwise 2-of-3 majority. An interrupted burn that set only one copy doesn’t count. Two copies do. The secure-boot flags, the boot-key fingerprints, and the rollback rows are all RBIT-3.
  • ECC: other pages store an error-correcting code alongside the data.

Who can write OTP, and when

Two paths write OTP, and the difference is central to how RS-Key stays safe:

  • BOOTSEL / picotool: with the board in BOOTSEL you can read and write OTP directly from the host. This is how the bulk of provisioning happens (the master key, the secure-boot key, the enable bits).
  • Secure firmware (the rescue applet): a handful of rows must be written by the running, secure-boot-validated firmware, not from BOOTSEL. Two cases:
    • rows that are made bootloader-read-only by a page lock (so BOOTSEL can no longer write them) but stay secure-writable. The page-58 lock and the ROLLBACK_REQUIRED flag are applied this way, by rsk otp lock-page58 / rsk otp rollback-require, each guarded by an exact magic payload so a stray APDU can never trigger them.

Each OTP page lock is a byte encoding three independent levels: LOCK_BL (bootloader), LOCK_NS (non-secure), LOCK_S (secure), each of read-write / read-only / inaccessible. That three-way split is what lets a page be unreadable to BOOTSEL but still readable/writable by secure firmware (see page 58 below).

What RS-Key burns

These are the rows RS-Key provisions, grouped by the stage that writes them. The authoritative source is the code (tools/rsk/otp.py, tools/rsk/secureboot.py, crates/rsk-rescue/src/rollback.rs). The table is the map.

RegionRowsWhat it holdsWritten by
Page 580xE80…DEVK (device attestation key), MKEK (master sealing key), anti-imaging chaffrsk otp burn (BOOTSEL)
Page-58 lock0xFF5makes page 58 BOOTSEL-unreadable, secure read/writersk otp lock-page58 (firmware)
Boot key0x80…SHA-256 fingerprint of your secure-boot public key (slot 0 of 4)rsk secure-boot load-key <otp.json>
BOOT_FLAGS10x4BKEY_VALID / KEY_INVALID (which key slots are live / revoked)load-key, lock
CRIT10x40SECURE_BOOT_ENABLE, DEBUG_DISABLE, GLITCH_DETECTOR_ENABLE/SENSharden, enable
BOOT_FLAGS00x48ROLLBACK_REQUIRED (bit 11)rsk otp rollback-require (firmware)
DEFAULT_BOOT_VERSION0x4E, 0x51the 48-bit rollback thermometer (two 24-bit rows)the bootrom, on boot
Page 1/2 locks0xF83, 0xF85make the flag + key pages bootloader-read-onlyrsk secure-boot lock

A few notes that matter:

  • Page 58 is read-write to secure firmware even after the lock. The lock value (0x3C3C3C) sets BL and NS to inaccessible but leaves S read-write. So only secure-mode firmware can ever read the MKEK/DEVK again, and a BOOTSEL flash dump cannot.
  • The MKEK/DEVK are generated randomly and forgotten. rsk otp burn does not keep a copy. The fuses are the key. There is nothing to back up and nothing to lose.
  • The rollback thermometer is advanced by the bootrom, not by a host write, when a higher-version image boots. See anti-rollback.md.
  • Pages 1 and 2 stay bootloader-read-only after lock (0x141414), not inaccessible. The bootrom must read the keys and flags on every boot. They remain secure-writable, which is how the firmware applies ROLLBACK_REQUIRED after the pages are otherwise locked down.

OTP fuse map. The rows RS-Key provisions grouped by page: page 1 boot-policy flags (CRIT1, BOOT_FLAGS0/1, the 48-bit rollback thermometer), page 2 boot-key fingerprint, page 58 DEVK and MKEK with high-half complement chaff, and the lock rows. Each row coloured by whether picotool over BOOTSEL, the secure firmware, or the bootrom writes it

Anti-imaging chaff

Page 58 stores the keys in its low half and the bitwise complement of every key row in its high half: DEVK at 0xE80…0xE8F with its complement at 0xEA0, MKEK at 0xE90…0xE9F with its complement at 0xEB0 (offset 0x20 = half a page). The firmware never reads the high half. It plays no part in key reconstruction (firmware/src/otp_keys.rs reads only the key rows). It exists for one reason.

The cheapest invasive OTP read demonstrated against this antifuse family (IOActive, RP2350 Challenge 1, see threat-model.md) recovers the bitwise OR of two physically paired bitcell rows, not the rows individually. Raspberry Pi’s mitigation is to put the key in one half of a page and its complement in the opposite half, so each physical pair reads OR(b, ¬b) = 1, a uniform all-ones pattern that carries no key bits. The low-half / high-half placement here is exactly that scheme, and it neutralises the demonstrated readout.

This defeats the demonstrated OR-read. IOActive note that a more advanced attack might separate the paired (even/odd) rows with additional effort, which would read the key half directly, and a plain complement does not stop that. Raspberry Pi have said a forthcoming application note will describe a storage scheme mitigating both the current attack and that hypothetical one. If it prescribes more than complement-in-opposite-half, revisit this.

Reading OTP

OTP is readable until a lock says otherwise:

rsk secure-boot status        # decodes the secure-boot + rollback rows for you
picotool otp get -r -n 0x48   # raw row read (BOOTSEL), if you want the bytes

rsk inventory list / rsk status surface the human-readable state. Once page 58 is locked, picotool otp get on it fails with a permission error forever. That failure is the lock working, not a fault.

Honest limits

  • OTP is not a secure element. It hardens against software- and BOOTSEL-level attacks, and the anti-imaging chaff above neutralises the demonstrated passive-voltage-contrast OTP readout. But the antifuses are still on a general-purpose die: a funded lab with a focused ion beam can image them, and that (like laser fault injection and power/EM analysis) is out of scope (threat-model.md). The antifuse readout is not fixed by any silicon stepping. Use A4 for the fault and boot-ROM fixes (our own boards are A2, the stepping broken in the public challenge).
  • It is finite. The rollback thermometer is 48 bits for the board’s life. There are 4 key slots. Neither resets. See anti-rollback.md.
  • It is per-chip. None of this carries to another board. A new board is a fresh, blank OTP.

Anti-rollback

Secure boot (production.md) refuses foreign images. Every image you signed stays valid forever. The first time a release fixes an exploitable bug, your previous signed image becomes a hole: an attacker with the device drags your old, signed UF2 over BOOTSEL and attacks the bug you already fixed. Anti-rollback closes that downgrade path.

Scope: this page is about rolling back the image. Rolling back the data — snapshotting flash over BOOTSEL and restoring it later to reset a wrong-PIN counter — is a separate attack with separate defenses, in threat-model.md. Neither mechanism sees the other’s rollback.

This page is the model and every case. The operational steps to turn it on are in production.md, stage 3; the fuses it touches are in otp-fuses.md. It is optional. Until you enable it, nothing here applies.

Read this whole page before enabling it. Anti-rollback burns one-time fuses. Once enforced, it changes which images your board will boot. None of it is reversible.

Two independent axes

The single most important idea: an old, vulnerable image can be refused by two different mechanisms. They do not depend on each other.

AxisWhat it refuses onBudget per chip
Versiona rollback counter in OTP (“don’t boot below my floor”)48 steps
Keythe signature (“don’t boot if signed by a key I don’t trust”)4 key slots

While you have version budget, the first axis does the work. When it runs out, the second takes over. A fresh chip with a fresh key resets both. The rest of this page is those two axes and what happens at their limits.


Axis 1 — version (the rollback floor)

Two different numbers

  • Firmware version (v0.3.1, semver): “which build is this”, changes every release.
  • Rollback floor: a number on your board. It refuses to boot anything below it. You move it, rarely, by hand.

RS-Key does not assign project-wide “epoch” numbers. There is only your own floor, on each board, and you manage it.

How it works in hardware

The floor lives in RP2350 OTP as a 48-bit thermometer (its value is the number of burned bits). At boot the bootrom (not our firmware) compares the image’s rollback version against your board’s floor:

  • image version below the floor → refused, fall back to BOOTSEL;
  • equal → boots;
  • above → boots and immediately burns the thermometer up to the image’s version.

Anti-rollback thermometer — a 48-bit OTP floor with three bits burned; an image below the floor is refused, one at the floor boots, and one above boots and burns the thermometer up to its version

The whole mechanism has an on/off fuse, ROLLBACK_REQUIRED. Until it is burned, images carrying no rollback version boot regardless (anti-rollback has no teeth). You burn it from firmware:

rsk secure-boot status      # shows ROLLBACK_REQUIRED and the boot version N/48
rsk otp rollback-require     # fuse ROLLBACK_REQUIRED (requires secure boot enabled)

⚠️ Burning the thermometer is irreversible. OTP is one-time-programmable: a bit goes 0→1 and never back. See otp-fuses.md.

Budget: 48 steps per chip, for the board’s whole life

The thermometer is 48 bits, so the floor can advance 48 times over the entire life of that board, and never again. That is not “48 releases”. It is 48 burns. Treat each advance as spending an irreversible, finite resource.

The project flags downgrade-fixes; you decide

Because secure boot is rooted in your own signing key, there are no project-signed images. Every owner signs and chooses their own floor. So the project’s only job is to mark, in the changelog, which releases fix a downgrade-exploitable bug:

Releasedowngrade-fix?
v0.2.0 (OATH features)no
v0.3.0 (PIN-bypass fix)yes
v0.4.0 (PIV slots)no
v0.5.0 (signature-check fix)yes

When you build firmware, you check: were there downgrade-fix releases since my last build? If so, it is your call:

  • Raise the floor (close those bugs): seal with --rollback <your counter + 1>. The board burns up to it. Your older images below it stop booting.
  • Leave it (you accept the risk): seal with --rollback <your current counter>. Everything still boots, nothing burns.

The default is not to burn. Raising the floor is a deliberate act tied to a specific security release, not routine.

If you don’t burn, the downgrade attack stays possible. Your old signed image will still boot. That is a fine, conscious trade-off (you keep your 48-budget and don’t orphan working images), but it is your choice of risk, not a default-protected state.

Why +1 is enough

The floor only needs to sit above your own vulnerable builds. Every build you made was sealed at a version ≤ your current counter. So raising the floor to counter + 1 cuts off your entire build history below it in a single bit. There is no absolute number to “catch up” to.

A bonus falls out of this: “decline, decline, then raise once when you decide to care” spends fewer bits than raising the floor on every release, for the same protection, because all your older builds sit below the one bump.

Concretely:

rsk secure-boot status         # read your floor, say "boot version 2/48"
# build the fixed firmware, then seal it one step higher:
picotool seal --sign --hash firmware-pt.elf -t elf firmware-signed.elf -t elf \
    ~/.rs-key-secrets/secure_boot_key.pem ~/.rs-key-secrets/otp_secureboot.json \
    --major 1 --minor 0 --rollback 3        # 3 = counter (2) + 1
picotool uf2 convert firmware-signed.elf -t elf firmware-signed.uf2
# flash it; on boot the floor burns 2 → 3, and every image sealed ≤ 2 is now refused

One caveat for correctness: you must seal the fixed firmware strictly above the version you used for the vulnerable build. In practice that is counter + 1. If you accidentally seal the fixed image at the same version as a vulnerable one, the floor didn’t rise and you are not protected.

Your floor is per-board

The floor is private to each board. It equals the highest version that that board has booted. It depends only on your burns, not on how many releases exist. Different boards carry different floors. This is normal and needs no coordination. The one rule: a board will not boot an image below its own floor. You cannot go back down past a floor you’ve burned.

At the ceiling (floor = 48)

The thermometer is full, but you can still update forever. You seal every future image at version 48 and it boots. You lose only the growth of version-based downgrade protection: a new release can no longer out-version a previous one (there is no 49th step). The device works. Secure boot still holds. From here, downgrade protection moves to axis 2.


Axis 2 — key (the signature)

How the bootrom checks a signature

  • OTP holds not the key but a fingerprint (SHA-256(public key)) in a boot-key slot.
  • A signed image carries, inside it, the full public key plus an ECDSA signature (secp256k1 + SHA-256) over the image hash.
  • At boot the bootrom hashes the public key from the image and compares it to the fused fingerprints. No match → refused, before it even checks the signature. Match → it verifies the ECDSA signature with that key → boots.

This can’t be forged: an attacker can’t sign for a key they don’t hold, and they can’t make a different public key hash to a fused fingerprint (SHA-256).

Key revocation = downgrade defense without the thermometer

When the version budget is spent, you switch axes. For each new downgrade-fix:

  1. sign the fixed firmware with a new key K2;
  2. confirm it boots;
  3. revoke the old key K1 (KEY_INVALID).

The old vulnerable image, signed by K1, now fails secure boot by signature. The version counter is not involved. RP2350 has 4 key slots, so this buys roughly 3 more rounds on top of the 48.

A decision you must make before the ceiling

Key revocation needs a free key slot reserved at provisioning time. Today rsk secure-boot lock (stage D) revokes all three unused slots. After that there is nowhere to rotate to. So this is a genuine trade-off, made up front:

Full lock (current default)Reserve a slot for rotation
Hardening against attacker key injectionmaximumthe free slot is reachable by an attacker with physical access who can burn fuses
Escape valve at the 48 ceilingnone (new chip only)~3 key rotations

Detail: after lock, the key pages stay secure-writable (the firmware must still write ROLLBACK_REQUIRED and the page-58 lock), so a future firmware command could in principle provision a key into a free, un-revoked slot even after lock. That command does not exist yet.


When the 48 budget is exhausted — the ladder

  1. Rotate the key on the same board (~3 rounds, if you reserved a slot). Old images die by signature.
  2. New board + new key (next section): a fresh 48, and the whole old history is dead by signature.
  3. Accept the residual: secure boot still holds. A downgrade is then only possible to an attacker with physical access and one of your old signed artifacts. Document it; for most threat models this is acceptable.

This is the RP2350’s hard limit, not an RS-Key flaw. Any firmware on this chip has the same ceiling (48 versions + 4 keys). There is no software trick around it: a software rollback counter in the firmware would be weaker. It runs after the bootrom already booted the (possibly vulnerable) image, and its store would live in flash — which the same attacker can rewrite, by first flashing a partition table that permits it (threat-model.md: the table we ship fences BOOTSEL out, and only secure boot stops it being replaced).

Prevention beats cure. 48 genuine downgrade-exploitable fixes on a single chip is beyond even commercial keys over a decade. If you are approaching the limit, you are almost certainly flagging as “downgrade-fix” releases where booting the old image gains the attacker nothing. The real discipline is: floor +1 only when the old image is a working exploit.


Moving to a new board

A fresh chip gives a fresh 48 and fresh 4 slots. You provision a new signing key (yours, backed up) and trust only it on the new board.

Why the new board’s floor is 1, not a repeat of every old risk

This is the subtle part. Starting a new board at floor 1 can look like you are re-accepting every downgrade risk you defended against on the old board. You are not. The protection didn’t disappear. It moved from the version axis to the key axis:

  • the old vulnerable images are signed with the old key;
  • the new board trusts only the new key, so those images are refused by signature;
  • and there are no vulnerable images signed with the new key. You signed only the current, fixed firmware with it.

So there is nothing below floor 1 (on the new key) to block. Floor 1 is safe precisely because no image exists that is both (a) vulnerable and (b) signed by a trusted key.

The rule that makes this work: an image is dangerous only if it is both signed by a trusted key and exists as a file. On the new board, old vulnerable images are signed by an untrusted key, and the new key has signed only the fixed firmware.

⚠️ Required condition: the new board trusts only the new key. If you also provision the old key on the new board, the old vulnerable images pass the signature check again and floor 1 will not stop them. New board ⇒ fresh key, old key not carried over.

What migrates, what doesn’t

  • Migrates: your FIDO identity, via seed backup (ssh ed25519-sk, 2FA registrations), with rsk backup restore.
  • Does not migrate: resident passkeys, OpenPGP and PIV keys, sealed to the old chip, not derivable from the seed. Re-enroll those.

How to verify all of this

rsk secure-boot status              # fused boot-key fingerprint, ROLLBACK_REQUIRED, boot version N/48
picotool info firmware-signed.uf2   # the image's key fingerprint — compare to the fused one

Negative tests are the real proof:

  • an unsigned UF2 → bootrom refuses it, falls back to BOOTSEL;
  • an image below your floor → refused (the version axis works);
  • an image signed by the old / a foreign key → refused by signature (the key axis works).

Cheat sheet

  • Firmware version ≠ rollback floor. The project assigns no epochs. The floor is on your board and you control it.
  • The project only flags a release as a downgrade-fix; you decide and you burn.
  • Raise = your board’s current counter + 1. Default: don’t burn.
  • Don’t burn ⇒ downgrade is possible (your conscious risk).
  • Budget is 48 per chip; burning is irreversible.
  • At the ceiling, updates still work; protection moves to key revocation.
  • A new chip + new key is a clean restart at floor 1; the old history is dead by signature, not by version.

Threat model

What RS-Key defends against, what it deliberately does not, and the honest residuals in between. The defenses compose in tiers: each one assumes the ones before it.

What RS-Key defends and what is out of scope — attacker-controlled USB bytes enter the RP2350 and pass through three composing tiers: memory-safe parsers (safe Rust plus fuzzing), then protocol gates (PIN/UV, touch, management key), then the key material, which is additionally protected by an at-rest seal meaningful after the OTP master-key burn; physical and lab attacks and a compromised, unlocked host are explicitly out of scope and reach the device undefended, since the RP2350 is not a secure element

Assets

The FIDO master seed (every non-resident credential derives from it), resident passkeys, OpenPGP private keys and their DEK chain, PIV private keys, OATH secrets, OTP slot secrets, PINs.

Attackers, strongest defense first

1. A hostile host (malware on the computer)

Everything arriving over USB is attacker-controlled: CTAPHID frames, the CCID bulk stream, ISO-7816 APDUs, CTAP2 CBOR. Defenses:

  • Memory safety. no_std Rust end to end. The parsers and applet dispatch are safe code. The handful of unsafe sites are enumerated and justified in unsafe.md.
  • Fuzzing. Every parser and every applet’s full dispatch path has a cargo-fuzz target (30+). See testing.md.
  • Protocol gates. PINs/UV with retry counters and lockout, physical-touch requirements on FIDO operations and OpenPGP UIF, OATH access codes, PIV management-key auth.
  • What a hostile host can do: drive any operation you have authorized while the device is plugged in and unlocked (sign, decrypt, assert). A security key authenticates presence and possession, not the intent of every byte the host sends. Touch requirements bound the rate.
  • Device config is UNGATED on the default build. The shipped default is the full-ykman/YubiKey-compatible admin surface: a hostile USB host can silently rewrite the DeviceInfo / enabled-applications / USB identity — over CCID Management WRITE CONFIG, the FIDO vendor CONFIG_WRITE, and the CTAPHID (0x43) and OTP-HID (0x15) transport writes — with no touch or PIN, and can trigger a device-wide factory reset (that one keeps a presence gate). The USB identity (serial, strings) is cosmetic — never proof a device is genuine, attestation is (§3). The enabled-applications mask is enforced, though: a disabled application’s applet stops answering (PIV/OpenPGP/OATH/OTP over CCID, FIDO2/U2F over both CTAPHID and CCID, the OTP keyboard), so a hostile host can turn one off. That is a reversible denial-of-service, not a confidentiality or integrity break — the Management applet, the FIDO vendor command, and the OTP-HID identify/config slots are never gated, so any single transport can re-enable it, and no secret is exposed. Reversible describes the mask, not the flash it is written to. The same ungated commands persist their records, and a host that replays one indefinitely spends erase cycles that nothing gives back: measured on the device’s own store geometry, a SET LED replay at a nearly full ring costs ~204 bytes and one main-partition page erase per twenty writes, which at the rate a host can drive it puts the array’s endurance budget in weeks of continuous hammering. Idempotent writes are now dropped before they reach flash on both the vendor and FIDO paths, which removes the cheap version of that; what remains is bounded by how fast a host can produce distinct records. If you need config writes gated on the operator, build/flash firmware-strict-config, which restores the presence/PIN gates and refuses the ungated transport writes (build.md). It is not the runtime flash flag EF_HARDENED.
  • The FIDO applet answers on two transports, and the second one is easier to reach. CTAP2 and U2F are served over CCID as well as CTAPHID (protocol.md §5.2), so any process that can talk to pcscd can drive the whole CTAP surface — no exclusive HID handle, and none of the platform gatekeeping a browser or the Windows WebAuthn stack applies to its own FIDO path. What that does and does not buy an attacker: nothing a credential gate would have stopped. Every operation still spends the same PIN, the same pinUvAuthToken and the same physical touch, because the two transports run the same applet over the same FidoState — one PIN/UV token, one credential-management walk, and crucially one per-boot PIN-mismatch budget. A second session state would have handed a host PIN_MISMATCH_LIMIT guesses per transport instead of per power cycle, which is the restart-by-reboot attack §6.5.5.6 exists to stop; that it is shared is enforced by construction (one &RefCell<FidoState> reaches both) and pinned by both_transports_answer_from_one_session_state. What it does buy is reachability: a process that could not open the HID device can now open the card. Treat FIDO as available to anything with card access, and use ykman config usb --disable fido2 (or --disable u2f) if that is not wanted — the mask gates the two applications apart, over both transports. On the default 0x1209:0x0001 identity most hosts never bind the CCID interface at all, so the surface is absent there for an unrelated reason, not by design.
  • The residual gap is intent. The trusted-display flavor closes it. Because a standard key attests presence and possession, a malicious page can silently drive an authorized key over WebUSB to phish a real sign-in (demonstrated against YubiKeys in Chrome). The trusted-display flavor paints the true rpId on the device’s own screen and gates each signature on a tap there, so a compromised host cannot fake what you approve. Qubes OS’s CTAP proxy tackles the same gap in software, mediating CTAP through a trusted VM.

2. A thief with the powered-off device (at-rest)

  • All key material is sealed in flash: FIDO seed, PIV keys and OATH records (secrets plus the access-code key) under AES-256-CBC/GCM keyed by a device key (kbase = HKDF of the chip serial and the OTP master key once provisioned), OpenPGP keys under the PIN-wrapped DEK chain.
  • OTP master key (production.md stage 1): with the MKEK fused and page-58 hard-locked, a flash dump (even with BOOTSEL access and the chip id) does not reproduce the sealing key. Without the burn, the sealing key derives from on-chip state an attacker with full flash + chip access could reconstruct. The burn is what makes at-rest real.
  • The seals give confidentiality, not authenticity. Records written before the burn are keyed from the public chip serial alone, and those pre-OTP arms stay readable afterwards so a provisioned device keeps working across the upgrade. So an attacker who can write flash (BOOTSEL) can forge a record that opens under one, and the boot migration then re-seals it under the fused root. Reading the flash still tells them nothing. Closing this needs a fuse-rooted latch on the migration window; audit run-27 #8 has the analysis.
  • Soft-lock (guides/soft-lock.md): optionally, the seed at rest is additionally wrapped with ChaCha20-Poly1305 under a 32-byte key only you hold (BIP-39/SLIP-39 words). A stolen device (even running genuine firmware) refuses every FIDO operation until that key is presented over an encrypted channel at power-up. Device + words, two factors.
  • Caveat: superseded records linger. The flash log is append-only, so re-sealing or deleting a secret leaves the old copy on flash until its page is reclaimed. Two cases differ in how much that matters:
    • The OTP-burn migration supersedes the pre-OTP seed, which was sealed under the chip-serial-only root (no fuse secret). Left alone, a flash dump plus the chip id would recover it, bypassing the burn. So it is not left to lazy healing: the first boot after provisioning runs a one-shot compaction (Fs::compact, gated by the EF_HARDENED marker, crash-safe) that drives a full GC lap over the credential partition and physically erases every superseded pre-OTP record before the device re-attaches to USB.
    • The soft-lock transition leaves the same kind of lingering record, but on a provisioned device it is already sealed under the fused root (moot against anything short of a fused-key compromise), so soft-lock’s at-rest guarantee simply hardens over time as natural compaction overwrites it.
  • The FIDO seed is never PIN-wrapped at rest (a deliberate design decision). UP-only operations (ssh ed25519-sk, U2F, no-PIN assertions) must work from a cold boot with no PIN presented. So a PIN-keyed at-rest copy adds no protection an attacker couldn’t bypass via the always-loadable copy, while breaking those flows. At-rest strength is the kbase (tier above), not the PIN.

3. An attacker who can flash their own firmware

  • Secure boot (production.md stage 2): the bootrom refuses unsigned images, so no foreign code ever runs to read the OTP key in secure mode. Glitch detectors are fused on along the way.
  • Anti-rollback (anti-rollback.md, optional): with ROLLBACK_REQUIRED fused, images below your board’s rollback floor (or carrying no version at all, i.e. anything sealed before the feature) no longer boot. A kept copy of an old signed release with a since-fixed bug stops being a downgrade path.
  • Before secure boot is enabled, this attacker wins against the OTP tier: their firmware reads the MKEK exactly like ours does. That is why the production page calls the two stages one story.
  • The USB/smartcard identity (VID/PID, manufacturer, product, OpenPGP AID vendor) is fully host-configurable at runtime via the phy record and is not an authenticity signal: a phy write that sets the Yubico VID makes a stock key present a full Yubico identity, and the manufacturer/product strings are also settable outright (phy tags 0x0F / 0x09), so any VID can carry any vendor name. On the default build these config writes are ungated (no touch, no PIN — see §1); firmware-strict-config re-gates them. Treat the identity as cosmetic, never as proof a device is genuine — attestation (device-key / org cert) is the authenticity mechanism, and it is unaffected by any config write.

4. Physical / lab attacks — OUT OF SCOPE

Decapping, microprobing, advanced fault injection beyond the RP2350’s glitch detectors, power/EM side channels, and the XIP TOCTOU: interposing on the QSPI bus to serve the genuine image to secure boot’s verifier and a tampered one to the CPU, since nothing binds checked bytes to executed bytes and the image is too large to verify-in-place from SRAM. An in-package-flash part (RP2354) leaves no discrete flash chip to tap, raising a reliable swap to decap-class effort. The RP2350 is not a secure element and RS-Key does not pretend otherwise. If your threat model includes a funded lab, buy a certified key.

5. Network

None. The device speaks USB only. There is no radio and no IP stack.

Flash snapshot rollback (the PIN-counter reset)

Reported by Token2 (issue #37, advisory PDF). An attacker with brief physical access and BOOTSEL runs picotool save to snapshot the whole flash, guesses PINs until the wrong-PIN counter locks, then picotool loads the old snapshot to reset the counter and repeats. That is unlimited offline PIN guessing. It defeats every applet’s retry counter — FIDO clientPIN, PIV PIN/PUK, OpenPGP PW1/PW3, OATH — since all are ordinary flash records with no external freshness binding.

The gap is freshness, not confidentiality or authenticity. Secure boot accepts the restored image (it is genuine and signed); the at-rest seal decrypts it (same chip). Neither notices a rollback. RS-Key’s firmware anti-rollback (anti-rollback.md) versions the firmware image, not the flash data, so it is blind to the swap too. The clean fix is a monotonic counter in tamper-resistant NVM that the firmware checks on boot — exactly what a secure element has and the RP2350 lacks. Its OTP is write-once antifuse (a few dozen one-way bits for the board’s whole life), so it cannot back a retry counter that must reset to eight on every correct PIN. We cannot close the rollback itself on this silicon; we fence the store and raise the cost of getting past the fence:

  • Partition table (always on since 0x0871). The shipped image embeds an RP2350 partition table that fences the KV store off from the USB bootloader. picotool save and picotool load over that range answer permission failure — enforced by the bootrom, not by us — while the running firmware keeps secure: rw and full access to its own data. The snapshot/restore above no longer works as written.

    What that is worth depends on secure boot. The table sits inside the signed image, so where secure boot is on it cannot be swapped without your signing key: a byte flipped anywhere in the table fails the image hash and the signature. Where it is off, the fence is friction rather than a barrier — the firmware partition has to stay bootloader-writable or updates would not work at all, so an attacker flashes an image carrying a permissive table and is back where they started. Read this as “the attack now needs a reflash first, and secure boot is what denies the reflash”, not as “rollback fixed”.

    It is worth doing precisely because this is the one gap secure boot does not close by itself: secure boot verifies executable images, and writing the data region is not execution. Until this change a fully provisioned board was still snapshot/restore-able.

  • OTP-seeded PIN verifier (always on after the OTP burn). The stored verifier is HKDF(serial_hash, HMAC(kbase, pin)), with kbase rooted in the fused OTP master key (production.md stage 1). A flash dump does not contain that key, so it cannot brute-force the PIN offline — the guessing must run on the device, one try at a time.

  • strong-pin / fips-profile builds (opt-in). Raise the clientPIN floor to six code points and refuse the most guessable PINs (a repeated digit, a 123456-style run), so the on-device search space is at least a million. By Token2’s estimate an automated attack runs ~34 days at six digits and ~10 years at eight, against ~8 hours at four (build.md).

  • Soft-lock (opt-in). The FIDO seed is wrapped under a key only you hold (guides/soft-lock.md), so a brute-forced PIN yields nothing until you present it. It covers the FIDO seed; the PIV, OpenPGP and OATH counters stay rollback-resettable.

The durable defense on a general-purpose MCU is PIN entropy, plus the soft-lock second factor for the FIDO seed. A device left in an attacker’s hands, protected only by a short PIN, is not safe here. This is the same boundary as “anyone with the device and your PIN is you”.

Platform silicon: the RP2350 security challenges

Raspberry Pi has publicly stress-tested the RP2350 die. The results bound RS-Key’s physical-attack posture.

Challenge 1 broke the A2 stepping (results). The task was to extract an OTP secret from a board running secure boot. The winning attacks:

  • Aedan Cullen: voltage glitch on the USB_OTP_VDD rail, reading OTP secrets out of the guarded path (erratum E16) (writeup, talk).
  • Marius Muench: a glitch plus a boot-ROM flaw, bypassing secure boot to run unsigned code.
  • Kévin Courdesses: laser fault injection corrupting the boot-time signature check (erratum E24) (writeup).
  • IOActive: focused-ion-beam (FIB) plus passive voltage contrast (PVC), reading the antifuse array directly: the bitwise OR of two physically paired bitcell rows (writeup).

The first three are comparatively cheap fault / boot-ROM attacks. The IOActive readout needs FIB-class lab equipment (a tool worth hundreds of thousands of dollars, one to two days per target) and applies to every device built on the Synopsys dwc_nvm_ts40* antifuse IP on TSMC’s 40 nm node, an antifuse property, not an RP2350-specific defect.

The A4 stepping fixes the fault and boot-ROM attacks in silicon, but not the antifuse readout (announcement). A4 closes the boot-ROM errata (E20/E21/E24, including the laser signature bypass) in a new boot ROM, the OTP power-glitch (E16) through changes to the wrapper circuitry around the OTP macro, and the GPIO errata (E9, E3). The antifuse-array PVC readout is explicitly not fixed in A4. Raspberry Pi’s guidance is to mitigate it by how secrets are stored in OTP, the chaffing RS-Key applies (see otp-fuses.md). A third challenge (power side-channel analysis of the secure-boot AES) is open with no break reported (challenge 2).

What this means for RS-Key. Our development boards are A2: the broken stepping, kept as the conservative worst case. The firmware is A4-compatible, and A4 is recommended for the fault / boot-ROM attacks above. Against the antifuse readout (which no stepping fixes), RS-Key applies the chaffing mitigation directly (otp-fuses.md). What remains out of scope is unchanged: a funded lab with FIB/PVC, laser fault injection, or power/EM analysis against a device in hand. No software or provisioning choice on a general-purpose die closes those. That is what a dedicated secure element is for (limitations.md).

Seed backup (the deliberate exception)

A FIDO authenticator’s pitch is non-exportable keys. The wallet-style backup is a conscious trade for recoverability, gated accordingly. Export moves the seed over an ephemeral encrypted channel (P-256 ECDH → HKDF → ChaCha20-Poly1305), and requires (all at once) physical touch, the FIDO PIN/UV token when a PIN is set, and the one-time setup window: after an explicit finalize, export is refused until a full reset regenerates a new seed. Malware cannot exfiltrate the seed silently or later. Restore re-seals the seed under the destination chip’s root. The host driving a backup necessarily sees the seed plaintext. Do it on a machine you trust. Scope: the deterministic identity only (resident passkeys, OpenPGP, PIV are not covered).

On the trusted-display flavor the host need not be in that trust path: the device can render its BIP-39 recovery phrase on its own screen (the seed is turned into words on-device and never crosses USB), so a backup can be taken without trusting any host. That trades the host-observation surface for a physical/visual one. The words are briefly on the panel (shoulder-surf, camera). It is gated to keep that surface small: it requires a device PIN set and re-entered, a deliberate hold past an explicit “no one watching” warning, runs only inside the same one-time window (and seal closes it), is disabled on the fips-profile (non-exportable) build, zeroizes the seed/words from RAM on exit, and auto-clears the panel after a short idle.

sequenceDiagram
    participant U as You
    participant H as Host
    participant D as Device
    U->>D: touch + PIN/UV (when set)
    H->>D: ephemeral P-256 ECDH
    D-->>H: seed over HKDF → ChaCha20-Poly1305 channel
    Note over H: the host necessarily sees the seed in the clear
    H-->>U: BIP-39 / SLIP-39 words
    U->>D: finalize → export refused until a full reset

Zeroization

Key-grade material in RAM is wiped (zeroize, volatile writes) when its use ends: session state and PIN/UV tokens on drop, transient key copies at end of scope including error paths, and the transport/exchange buffers as soon as a message completes (requests carry PINs and imported keys). Neither fused key is held at all: the applets carry a way to read OTP, not the key, so the DEVK and the MKEK exist in RAM only inside the operation that asked for one and are wiped when it returns. That is what puts them out of reach of a parser bug — parsing runs before any store access, so at that moment neither key is anywhere in memory. It buys nothing against code execution, which can drive the same reads. Accepted residuals: Copy temporaries inside RustCrypto curve arithmetic, digest internals, and heap limbs inside num-bigint-dig (which has no zeroizing Drop of its own — every value rsk-rsa owns rides in a Zeroizing, but a temporary the library allocates internally does not). Short-lived, library-internal, not wipeable without forking the crates.

A WebAuthn large-blob key is obtainable without user interaction. CTAP 2.1 §12.3 puts no UP/UV precondition on the largeBlobKey extension output — unlike §12.5, which mandates refusing hmac-secret on a silent up:false probe — and §6.2.2 step 5 scopes alwaysUv to requests where up is present and true. RS-Key implements both as written, so a host process can obtain a credential’s large-blob key, and the ungated authenticatorLargeBlobs get alongside it, with no touch and no PIN, on a device with alwaysUv enabled. Per §6.10 the confidentiality of that data is a function of the credential’s protection policy, so a relying party that needs it gated should set credProtect accordingly. Deviating unilaterally would fail conformance; the asymmetry belongs upstream. The CTAP 2.3 §12.4 largeBlob extension (--features largeblob-ext) inherits the same property and is implemented as written for the same reason — it puts no UP/UV precondition on a read either, so a silent up:false probe returns the blob. It is not a wider exposure than the pair it replaces: there, an ungated assertion yields the largeBlobKey and an ungated authenticatorLargeBlobs get yields the ciphertext, which is the same plaintext by another route. What that build does add is a blob the device holds in the clear, since the platform no longer encrypts it — so largeblob-ext seals each blob at rest under the device seed, with the credential id as AAD.

The reboot to BOOTSEL is presence-gated but takes no PIN, so “one touch, then dump RAM” is a real attacker move. worker::reboot wipes the live key material before dropping, and the stack — which carries the deepest secrets, RSA primes and EC scalars — is not wiped. That is deliberate, and measured rather than assumed: on RP2350 A4 the platform clears main SRAM across the drop. All 520 KiB read back as zeros while a pattern written through picoboot read straight back, so the zeros are the memory and not a refused read (tests/54_sram_residue.py, 2026-08-05, secure boot off). This is a property of the silicon revision and boot configuration, so it is re-measured when either moves; the explicit wipes stay as depth in case a future one keeps SRAM.

Supply chain & process

  • cargo audit + cargo deny (advisories, license allow-list, source policy) and gitleaks run in scripts/check.sh and the pre-commit hook.
  • Dependencies are pinned (Cargo.lock). The git dependencies are restricted to the embassy organization.
  • No vulnerability advisory is ignored. The one that used to be — RUSTSEC-2023-0071, the Marvin timing side channel in the rsa crate, which has no fixed release — went away with the crate itself in 0.4.12; rsk-rsa owns the key type and both private paths now. The mitigation that carve-out rested on is still in force and is now the whole defence: per-operation base blinding on every private-key path (PKCS#1 v1.5 sign, decipher, and the raw fallback rsa_raw). The constant-time audit verified that this blinding leaves no unblinded private-exponent path.

Post-quantum notes

ML-DSA-44 (COSE −48), ML-DSA-65 (−49) and ML-DSA-87 (−50) FIDO2 credentials (all three the in-tree rsk-mldsa crate) with hedged signing (32 fresh DRBG bytes per signature; the hedge and expanded keys are zeroized). rsk-mldsa streams the FIPS 204 matrix A on the fly so even ML-DSA-87’s keygen+sign fit the RP2350 stack — measured, its keygen frame is the largest in the image at 123 KiB against a 205 KiB ceiling. It is hand-written, so its constant-time posture is a source-level claim (branch-free reductions, masked norm checks, no secret division), not proven at machine code. It is checked byte-for-byte against NIST ACVP KATs, with Kani proofs over the reductions and rounding. ML-KEM-768 is compiled in as scaffolding but nothing calls it until a CTAP PQC PIN/UV protocol exists. None of these has a third-party audit yet, the same standing as the rest of the RustCrypto stack, tracked via cargo-audit/deny.

Reporting

This is an experimental hobby project. If you find a security issue, please report it privately to the maintainer rather than opening a public issue.

Limitations — what RS-Key does not do, and why

Each gap below comes with its reasoning. “Not yet” and “never” are marked. The project as a whole is experimental and unaudited. The threat model covers the security boundary. This page covers feature and hardware gaps.

Cryptography

  • brainpoolP512r1 (OpenPGP): not offered. brainpoolP256r1 and P384r1 are supported (advertised in DO 0xFA, generate / keytocard / sign / decrypt), but no Rust arithmetic for the 512-bit brainpool curve exists yet, so the applet neither advertises nor generates it. Status: until a crate exists.

  • X448 / Ed448 (OpenPGP): not offered, same reason. RustCrypto coverage of Curve448 is thin and unaudited. Cv25519/Ed25519 plus the NIST curves and secp256k1 cover practical use. Status: until a serious crate exists.

  • PSO:DECIPHER is a padding oracle by response code. The card answers malformed padding with a distinct status word and well-formed padding with plaintext. That is inherent to the command — it must either hand back a session key or report failure — and no implementation choice removes it. Assume a host that can drive DECIPHER at will with PW1 verified can mount Bleichenbacher-class attacks on ciphertexts of its choosing. The on-card unpadding is constant-time so the reason for a refusal does not leak on top of the refusal itself, but the message copy that follows a success is length-proportional, as in every conforming implementation. Status: inherent to the OpenPGP card command.

  • The RSA private paths are our own code, and it is unaudited. The rsa crate is gone from the tree (0.4.12), and with it RUSTSEC-2023-0071 — the Marvin timing side channel, which never had a fixed release. What replaced it is rsk-rsa: its own key type, RsaKey, and a software private operation for the two paths the asm CRT core cannot serve (PIV certificate signing, and a legacy P‖Q key whose prime width is not a multiple of 32). Both are base-blinded and Bellcore-fault-checked like the asm path, and every signature and decryption is checked byte-for-byte against OpenSSL vectors in the host tests. It is still ours, still single-maintainer, and still unaudited — the advisory is closed, the class of bug it names is not. Status: accepted; see the constant-time audit for what has been looked at.

  • RSA-3072/4096 on-card generation is slow. The prime search dominates the cost: rejecting hundreds of composite candidates, each one asm-modexp-bound. Both cores run the search with the modexp hot path in SRAM (architecture). Typical timings, measured on the reference board (single-core → dual-core):

    keybeforeafter
    RSA-2048~8.9 s~4–6 s
    RSA-3072~35 s~22 s
    RSA-4096~65 s~50 s

    The total is set by how many candidates a given draw happens to need, which is random. The per-keygen spread is wide (17 s to 124 s seen at 4096) because that count varies, not because the silicon does. Per candidate the throughput is ~6.9 ms across both cores.

    These are reference-board numbers and do not carry to every board. A Waveshare RP2350-Zero measures RSA-2048 at a median of ~9.7 s where the reference board does 4–6 s. Budget for the board you ship, not for this table.

    Both halves of the hot path — the asm modexp, and the small-prime sieve loop with its prime table — are held in SRAM rather than run from XIP flash. That is not a micro-optimization: while the sieve was still in flash it and the surrounding code evicted each other from the small XIP cache, and which of them won depended on where the linker happened to put things, so an unrelated 1.7 KB of image growth moved RSA-2048 keygen by 1.36× (9.7 s → 12.7 s, three and four batches of 12, no overlap between them). Keep new hot loops out of XIP if you want a timing that survives the next commit.

    The lever is fewer candidates reaching the modexp: a deeper small-prime sieve. (The Baillie–PSW that confirms a survivor, asm strong Miller–Rabin plus a software Lucas test, runs only a handful of times per keygen, so it doesn’t move the total.) Depth is set by the measured cost ratio: one strong-MR modexp is ~35 ms (1024-bit) / ~239 ms (2048-bit) against ~11 µs / ~23 µs for one trial division, so it pays to sieve by every prime up to ~3.1k / ~10.5k, far past the old flat 256-prime (≤1619) sieve. Depth now scales with key size (448 primes at RSA-2048 … 1280 at RSA-4096), and the sieve runs incrementally: a candidate stream n, n+2, n+4, … from a random odd start, each residue n mod pᵢ stepped by one add instead of re-derived by a Horner pass (OpenSSL/GMP do the same). The primality decision is untouched, so key strength is unchanged. Same-device A/B (per-candidate cost, which divides out the prime-search-luck variance): depth-scaling took RSA-2048 7.84 → 6.48 ms/candidate and RSA-4096 36.0 → 26.2 ms versus the old flat 256-prime sieve, and the incremental step took those a further 6.48 → 5.28 ms (−18.5%) and 26.2 → 20.9 ms (−20.4%). The device streams keepalives throughout, so tools wait it out. Import is fast. Status: inherent to the hardware class; the parallel-scan share is at the two-core limit, the sieve at the measured modexp:division ratio and now incremental.

  • ML-KEM is scaffolding: compiled, tested, unused. No CTAP PIN/UV protocol number for PQC key agreement exists yet to implement. Status: waiting on standards.

  • PQC interop is limited by client support: ML-DSA-44 (COSE −48), ML-DSA-65 (−49) and ML-DSA-87 (−50) credentials work and verify on-device. Their signatures verify under OpenSSL and Yubico’s python-fido2, but no browser or mainstream WebAuthn library negotiates these COSE ids against security keys yet. Released Firefox versions abort getInfo if the algorithm is advertised (hence the advertise-pqc build flag, default off; capability stays on regardless). These are the ML-DSA schemes, not a FIPS-validated module. Status: waiting on clients.

Backup & migration

  • The seed backup covers the deterministic identity only. Non-resident credentials (ssh ed25519-sk, most 2FA registrations) derive from the master seed and survive a restore onto a new board. Not covered: resident passkeys (stored records, not derivable), OpenPGP private keys, PIV private keys, OATH secrets, OTP slots, all sealed to the source chip. A board swap means re-enrolling those. Status: by design; a full at-rest export would gut the at-rest story.
  • A finalized backup window stays closed until a factory reset regenerates the seed. Lost words cannot be re-exported. Pick a generous SLIP-39 share count. Status: by design (anti-exfiltration gate).

Hardware / physical

  • No secure element. The RP2350’s OTP fuses, glitch detectors and secure boot are real, and RS-Key adds anti-imaging OTP chaffing on top. But decap, microprobing, FIB imaging, advanced fault injection and power/EM side channels remain out of scope. The public RP2350 hacking challenge broke the A2 stepping. The A4 stepping fixes the boot-ROM and OTP power-glitch attacks in silicon, but not the antifuse-array readout (mitigated only by how secrets are stored: the chaffing RS-Key applies). Our development boards are A2. The firmware is A4-compatible and A4 is recommended. Status: never. These are silicon properties, not firmware ones; closing them fully is what a dedicated secure element is for.
  • The at-rest seals are not authenticated against a flash writer. They keep a flash dump from yielding key material, which is what the OTP burn buys. They do not stop someone who can write flash over BOOTSEL from planting a record: the pre-OTP key base derives from the public chip serial, and those arms stay readable after the burn so an already-provisioned device survives the upgrade. The boot migration then re-seals the planted record under the fused root. Status: needs a fuse-rooted latch that closes the migration window once the device is provisioned; the analysis is audit run-27 #8, the decision is the maintainer’s because it makes lock-page58 load-bearing for boot correctness.
  • PIV data objects are access-gated, not sealed. SP 800-73-4 pt1 Table 3 gives four of them a read condition of PIN — Cardholder Fingerprints (5FC103), Facial Image (5FC108), Printed Information (5FC109) and Iris Images (5FC121) — and RS-Key enforces exactly those four at the APDU layer. It does not back them at rest: the seal rule covers key material, so a flash dump of a provisioned device yields whatever a host put in them. Their names are the standard’s, not a capability of this firmware — there is no sensor, no enrolment and no matching here, and the 1900-byte object ceiling is far under a real FIPS 201 biometric template, so what they hold is whatever the owner chose to store. Status: won’t fix. The read condition is an access rule, not a confidentiality promise, and the device cannot produce the content a seal here would be protecting.
  • XIP TOCTOU residual: secure boot verifies the image in external QSPI flash, then executes from it. Nothing binds the bytes that were hashed to the bytes later fetched, so hardware interposing on the QSPI bus can serve the genuine image to the verifier and a tampered one to the CPU. The clean fix (copy the image into SRAM, verify, run verified-in-place) does not fit: the ~1.7 MB image plus working RAM far exceeds the 520 KB SRAM. There is also no runtime flash authentication in hardware. Part selection is the real lever: an in-package-flash device (RP2354) stacks the flash die on the QSPI bus inside the package, so there is no discrete flash chip to clip an emulator onto and a reliable interposer needs decap-class access. RS-Key is developed and tested on external-flash RP2350 boards, so RP2354 is a recommendation here, not a configuration the project has validated. Status: never on an external-flash board; decap-class effort on RP2354.
  • No TrustZone-M secure/non-secure split. Considered and rejected: the embassy ecosystem has no TrustZone support, so it would mean hand-rolling SAU/IDAU configuration, NSC veneers and dual images (the project’s single biggest item) to defend mainly against parser memory corruption, which safe Rust plus fuzzing already address. Physical attacks are orthogonal to TrustZone. Status: revisit only with ecosystem support.
  • Anti-rollback is opt-in and coarse: picotool seal --rollback plus the ROLLBACK_REQUIRED fuse (anti-rollback.md). The OTP thermometer has 48 steps for the board’s life, so the rollback floor is raised for security-relevant releases only. Until the fuse is set, any previously-signed image still boots. Status: shipped (optional).
  • No image encryption: pointless for open-source code (no secrets in the image; secrets live sealed in flash), and the RP2350 has no transparent XIP decryption anyway. Status: never.

Protocol / compatibility

  • The default USB identity is RS-Key’s own (0x1209:0x0001 on the pid.codes FOSS VID, manufacturer RS-Key, product RS-Key Security Key, reported firmware 5.7.4), not a YubiKey masquerade. We no longer ship Yubico’s identifiers by default. A YubiKey identity (0x1050:0x0407, reader name Yubico YubiKey …) exists only as the opt-in VIDPID=Yubikey5 build flavor (build.md), built for local interop testing and never distributed. Distributing hardware with Yubico’s identifiers is not OK. The trade-off: ykman, Yubico Authenticator and the stock Yubico udev rules gate on the Yubico YubiKey reader name / VID 0x1050, so on the default RS-Key build they do not see the device. Use them against the VIDPID=Yubikey5 flavor, or add a udev rule matching VID 0x1209. FIDO2/WebAuthn, ssh -sk, gpg/OpenPGP, OpenSC/PKCS#11 and the project’s own rsk/rsk-tui tools are identity-independent and work on the default build.
  • The attestation certificate is per-device, so it identifies the key. Every makeCredential carries packed basic attestation whose x5c leaf is this board’s own certificate (guides/attestation.md), and it is the same certificate for every relying party. A batch certificate shared across devices would avoid that, but it would have to ship inside open-source firmware, where anyone could extract it. Browsers hide the leaf unless a site asks for attestation: "direct"; native CTAP clients such as ssh-keygen see it. A factory reset regenerates the seed and with it the certificate.
  • The PIV attestation identity cannot be replaced by a host. A YubiKey lets one load its own chain — IMPORT into slot f9 for the key, PUT DATA 5FFF01 for the certificate — and that is a documented enterprise feature there. RS-Key refuses both (6A86 and 6A80). The f9 key is generated by the device at first boot and never leaves it, so accepting those two commands would let anyone holding the management key swap the device’s attestation identity irreversibly, over a single APDU, for a key they control. The cost of the other choice is on the record: one probe pass in this project destroyed a real YubiKey’s factory attestation chain with a single PUT DATA 5FFF01, and no reset brings it back. ATTEST and GET METADATA at f9 work normally, and a factory reset regenerates the identity. Status: never — deliberate.
  • SET RETRIES will not set a zero budget. 00 FA 00 00 on a YubiKey answers 9000 and leaves the card at 0/0 tries, permanently blocked, with only a factory reset — which destroys every key — to recover. RS-Key answers 6A80 and changes nothing. The project matches a YubiKey everywhere except where matching would lose user data, and this is that exception. Status: never — deliberate.
  • OpenPGP secure messaging is not implemented (rarely used by clients; PINs gate everything in practice).
  • One physical button on the base build. Touch = the BOOTSEL button, and there is no fingerprint reader, so UV is the PIN and “number matching” style UV is impossible. The trusted-display flavor (guides/display.md) adds a screen for on-device PIN entry and per-signature relying-party approval, but still no biometric UV.

Operational

  • The flash log heals lazily: deleting/superseding a record (e.g. enabling the soft-lock) leaves the old record in the log until compaction naturally overwrites it, so most at-rest guarantees harden over time rather than instantly. (On a provisioned device the superseded copy is sealed to the fused root.) The one record that is not left to lazy healing is the pre-OTP seed superseded by the OTP-burn migration. It is sealed under the chip-serial-only root, so the first boot after provisioning scrubs it eagerly with a one-shot full-GC-lap compaction.
  • The board is the security boundary: anyone with the device and your PIN is you. Same as every security key.

unsafe audit

The firmware is no_std Rust. Safety of the parsers and applet logic is the core defensive property, so every unsafe is enumerated here: its justification, why a safe alternative does not work, and how the risk is contained. Adding a new unsafe requires updating this page. (Safe Rust rules out memory-corruption bugs in this code. It is not a security audit; see the threat model.)

Runtime sites: 21. Twelve in the firmware proper (main.rs + presence.rs): the interrupt-handler pair (2), the Send impl, the heap init, and the eight GPIO-pin steals (the presence button, the LED power-enable rail, the nuisance USR LED, the display build’s wake button, and — display builds only — the panel’s CS/DC/RST/TP_RST control lines). Two for the per-core prime sieves and one stack limit per core, three in the RSA assembly FFI, two in the standalone flash-wipe tool.

flowchart TB
    subgraph fw["firmware/src/main.rs + firmware/src/presence.rs"]
      a["interrupt executor (×2)"]
      b["Send for SendUsb"]
      c["heap init"]
      d["GPIO pin steal ×8 (presence, LED power, USR LED, display wake + CS/DC/RST/TP_RST)"]
      d2["core0 stack limit (MSPLIM)"]
    end
    subgraph kg["firmware/src/core1.rs"]
      e["per-core prime sieves (×2)"]
      e2["core1 stack limit (MSPLIM)"]
    end
    subgraph asm["rsk-rsa"]
      f["modexp / sign_crt / modexp_pub FFI (×3)"]
    end
    subgraph wipe["rsk-wipe"]
      g["raw flash erase/program (×2)"]
    end

The unsafe lives only in plumbing. None of it is in a parser, applet, crypto wrapper, or the filesystem.

Firmware (firmware/src/main.rs, firmware/src/presence.rs)

1–2. The high-priority interrupt executor

#![allow(unused)]
fn main() {
#[interrupt]
unsafe fn SWI_IRQ_1() {
    unsafe { EXECUTOR_HIGH.on_interrupt() }
}
}

USB and the transports run on an embassy InterruptExecutor so they preempt long synchronous work (RSA keygen, flash GC) and keep the bus alive. The handler itself is unsafe fn (hardware interrupt ABI). The on_interrupt() contract (call only from the interrupt the executor was started on) is upheld by construction: EXECUTOR_HIGH.start(SWI_IRQ_1) is the only starter and this is the only caller. Safe alternative: none; this is embassy’s documented pattern for a second executor. Containment: two lines, no data touched.

3. unsafe impl Send for SendUsb

embassy_usb::UsbDevice is !Send only because it holds a list of &mut dyn Handler control-request handlers. Our only stateful handler is a zero-sized type whose state is Sync (critical-section-guarded statics). The device is moved into exactly one task on the interrupt executor and never touched from anywhere else: exclusive ownership after the move. Safe alternative: none while the USB device must live on the interrupt executor and embassy keeps the trait object !Send. Containment: the wrapper is private, constructed once, and the invariant (single task, single executor) is structural.

4. Heap initialization

#![allow(unused)]
fn main() {
unsafe { HEAP.init(core::ptr::addr_of_mut!(HEAP_MEM) as usize, HEAP_SIZE) }
}

A 128 KiB heap exists solely for rsk-rsa’s big integers (num-bigint-dig, the only allocating dependency). init’s contract (call once, with exclusive access to the region) is met: it runs once at the top of main, on a dedicated static buffer used by nothing else. Safe alternative: none; every embedded allocator initializes this way. Containment: one call, before any allocation can happen.

5–12. GPIO pin type-erasure (presence button, LED power rail, USR-LED-off, display wake + control pins, ×8)

#![allow(unused)]
fn main() {
let any = unsafe { AnyPin::steal(pin) };
}

Eight build-configurable GPIOs are chosen by number at build time rather than as a concrete PIN_n type, so each must be converted to embassy’s type-erased AnyPin: the optional PRESENCE_PIN=<gpio> presence button (ButtonPresence::new_gpio, presence.rs), the optional LED_POWER_PIN enable pin driven high to power a gated LED rail (the LED block in main.rs), the optional USR_LED_PIN driven to a nuisance onboard LED’s OFF level and held (the boot block in main.rs), and — display builds only — the optional WAKE_PIN button that wakes the panel from display sleep plus the panel’s CS/DC/RST/TP_RST control lines, all by board-config number, in the panel block of main.rs. AnyPin::steal is unsafe because the caller must guarantee unique ownership of that hardware pin — a match over p.PIN_0..=PIN_29 (as the LED data pin uses) is impossible here, since it would double-move the peripheral set the LED block already claims. Safe alternative: none for a runtime/number-selected GPIO; the safe constructors require a statically known pin type. Containment: each is gated by pin-range validation and the single-owner invariant from main — none of the presence pin, the LED-power pin, the USR-LED pin, the wake pin, nor the four panel control pins is ever handed to another driver. Two checks hold that jointly: the LED data pin is resolved at runtime from the host-writable phy record, and the filter chain in main drops a value that names the presence pin, LED_POWER_PIN or USR_LED_PIN back to the build default — so a host cannot aim the data pin at a pad another driver owns. On top of that, compile-time assert!s reject a build that collides LED_POWER_PIN or USR_LED_PIN with the LED data pin or a GPIO PRESENCE_PIN (and refuse USR_LED_PIN outright on a display build, whose panel owns those pads), rejects a WAKE_PIN in the LCD/touch range (10..=18), and rejects any of CS/DC/RST/TP_RST/BL colliding with each other, with the hard-wired PIO serial output (PIN_10/11) or I2C1 (PIN_6/7) lines, an enabled WAKE_PIN, or LED_PIN/LED_POWER_PIN when their LED driver is built — a collision silently drives one pad from two owners at runtime, so it is checked at build time.

Firmware dual-core keygen (firmware/src/core1.rs)

13–14. The per-core prime sieves

#![allow(unused)]
fn main() {
static mut CORE0_SIEVE: IncrementalSieve = IncrementalSieve::new();
static mut CORE1_SIEVE: IncrementalSieve = IncrementalSieve::new();
// …
let sieve = unsafe { &mut *core::ptr::addr_of_mut!(CORE1_SIEVE) }; // core1, in `search`
unsafe { (*core::ptr::addr_of_mut!(CORE1_SIEVE)).scrub() };        // core1, on the STOP edge
let sieve = unsafe { &mut *core::ptr::addr_of_mut!(CORE0_SIEVE) }; // core0, in `run_rsa_search`
}

The dual-core keygen runs one running small-prime sieve per core (each ~5 KiB of residues, too large to live on core1’s stack beside the Baillie-PSW bignum frames, so they are static). Each is single-core-exclusive: CORE0_SIEVE is taken &mut only inside run_rsa_search (core0), CORE1_SIEVE only inside search (core1), and the two cores never touch the same sieve. So the &mut never aliases and there is no cross-core race. Each keygen calls scrub() through the reference before use, forcing a fresh window; each core also scrubs its own sieve when its search ends, so the last candidate — which is the prime that was found — does not sit here until the next job. That end-of-search scrub deliberately stays on the owning core: STOP does not wait for core1, so a scrub issued from core0 (e.g. on the reboot path) would alias a live &mut while core1 is still inside try_candidate_le. Safe alternative: none that is free. A Mutex/critical-section cell would add a lock on a provably-uncontended access, and the sieve is reused across jobs so it cannot be a stack local. (Edition-2024 forbids implicit &mut to a static mut, hence the explicit addr_of_mut!.) Containment: three call sites — two on core1 (search, end-of-search scrub), one on core0; the partition (which core touches which sieve) is structural, and the data is non-secret (small-prime residues of a candidate, scrubbed at the top of every keygen). A wrong residue can only let a composite through to the strong-MR/Lucas test, which still rejects it.

15–16. The per-core stack limits (main.rs, core1.rs)

#![allow(unused)]
fn main() {
unsafe { cortex_m::register::msplim::write(&raw const _stack_end as u32) }; // core0, entering `main`
unsafe { cortex_m::register::msplim::write(stack_floor) };                  // core1, entering `core1_main`
}

Core1’s stack is an ordinary Stack<16384> array in .bss, so a push past its end writes into whichever statics the linker placed below — silently, and with no diagnostic. flip-link does not reach it: that guards core0’s stack by moving it to the bottom of RAM, and core1’s is a different stack. ARMv8-M’s MSPLIM traps the stack-pointer decrement itself, which a read-only MPU guard band would not: a frame big enough to step over the band writes past it and never faults, and the modexp chain on this core reserves ~6 KiB at a time. Programming it is a bare MSR that no safe API wraps. Core0 gets the same instruction for a weaker reason, and it is worth being plain about: flip-link already puts that stack at the bottom of RAM, so an overflow runs off into unmapped space and faults with or without this — there is no gap here to close. What the write buys is independence from the linker. Drop flip-link and that floor disappears silently, along with the only thing keeping this stack out of .bss; MSPLIM states the bound in code, where it can be read and where removing it is a visible edit. Safe alternative: none. cortex-m offers no checked form, and the MPU route is both weaker (above) and more unsafe, not less. Containment: one write per core, on the core that owns that stack, before anything has pushed, with that stack’s own base as the value — no legitimate frame sits below it. A violation raises UsageFault/HardFault on core1. That is not graceful: embassy_rp::multicore::pause_core1 spins unbounded on the inter-core FIFO, and a core1 sitting in a fault handler never answers it, so the next flash write hangs the device until it is replugged. That is the trade being made — a wedge a replug clears, rather than a silent write into whatever .bss the linker put below, issued by the routine that is at that moment generating and storing a key.

RSA assembly FFI (crates/rsk-rsa/src/lib.rs)

16–18. The modexp / CRT-sign calls

On-card RSA key generation needs hundreds of modular exponentiations over 1024–2048-bit candidates. The pure-Rust path was ~7× too slow on the Cortex-M33 (minutes per key, CCID timeouts). The crate wraps the vendored C+ARM-assembly routines behind three unsafe FFI calls — modexp_priv (keygen), sign_crt (the CRT private-key operation) and modexp_pub (the public-exponent side of blinding and the fault check) — each with fully owned, length-checked buffers on both sides. Safe alternative: tried (num-bigint). Functionally correct, unusably slow. Containment: both ends fail closed. Key generation is KAT-gated — a power-on known-answer self-test must pass or it refuses to run — and every signature is Bellcore-fault-checked (out^e == base) by the caller, so a miscompiled or corrupt routine cannot emit one. Inputs/outputs are fixed-size stack buffers zeroized after use. On the host the crate substitutes a pure-Rust fallback, so all host tests exercise the same API safely.

Flash wiper (rsk-wipe/src/main.rs)

19–20. Raw flash erase/program in a critical section

The wiper’s entire job is to erase the flash the firmware lives on, from a RAM-resident image. It calls the ROM flash-erase/program routines inside critical_section::with(|_| unsafe { ... }): interrupts off, XIP disabled, nothing else running. Safe alternative: none; erasing the chip out from under yourself is inherently unsafe and is the tool’s purpose. Containment: rsk-wipe is a separate opt-in UF2 you flash deliberately; it never ships inside the firmware.

Build-time (not runtime)

  • crates/rsk-rsa/build.rs: unsafe { env::set_var(...) } forces the ARM cross-compiler for the vendored C. Build scripts are single-threaded at that point (the call is host-side, never in the image).
  • firmware/build.rs: unsafe { env::set_var(k, v) } copies the selected board-config file’s values (BOARD=<name>) back into the env before the build reads them. Same single-threaded host-side build-script context; never reaches the firmware image.
  • Edition-2024 declarations: #[unsafe(link_section = ".start_block")] on the two bootrom image-definition statics and unsafe extern "C" on the linker-symbol/FFI declaration blocks. These mark declarations the compiler cannot check. The symbols are addresses read via addr_of!, never dereferenced as data.

What is not here

No unsafe in any parser, applet, crypto wrapper, or the flash filesystem. The attacker-facing surface is entirely safe Rust, and cargo clippy -D warnings plus the fuzz targets (testing.md) keep it that way.

Constant-time / timing side-channel audit

This is a source-level constant-time and timing side-channel audit of the RS-Key firmware (Rust, no_std, RP2350 / Cortex-M33). Its scope is every secret-dependent comparison, branch, memory access, and private-key arithmetic operation that an attacker holding the device can probe over USB (CCID / ISO-7816 APDUs and CTAPHID / CTAP2): PIN/PUK/password verifiers, the FIDO pinUvAuthToken MAC, OATH and OTP access codes, RSA private operations, and the hand-written rsk-rsa keygen primitives.

What this is and isn’t. This is a source/disassembly audit: it establishes that the generated machine code has no secret-dependent branch / early-exit / index on the audited paths. It is not a measured timing study and does not replace an instrumented hardware harness (TVLA / Welch t-test). See Coverage & limits.

Summary

42 candidate sites were examined. 3 were real findings, all fixed (no high/critical). The core authentication surface is sound. The project’s hand-rolled constant-time comparison is genuinely constant-time as compiled for the production target. The PIN/MAC/verifier paths compare one-way derived verifiers, not raw secrets. The defects were concentrated in two places the canonical helper did not reach: an unblinded RSA private-exponent exponentiation on an OpenPGP fallback path, and two raw short-circuiting comparisons of the OTP slot access code with no rate limit.

Methodology

Six analysis lenses were applied across the workspace. Each candidate was then put through adversarial verification (default-to-false; a finding survives only if a concrete secret → observable path is demonstrated at exact file:line with the exploitation model stated), and a completeness critic pass caught sites a single lens would miss. Several constant-time refutations were corroborated by disassembling the actual on-device LTO firmware ELF and by standalone thumbv8m compiles at the production opt-level=s and at opt-level=3.

  1. Hand-rolled constant-time comparator definitions.
  2. Secret-vs-attacker comparisons that bypass the comparator.
  3. Secret-dependent control flow / variable work between match and mismatch.
  4. Secret-indexed memory access / data-dependent arithmetic.
  5. Crypto-primitive usage (are the CT-by-design RustCrypto primitives wrapped non-CT? is the hand-written modexp safe?).
  6. Status-word / error-path / response-latency oracles.

Findings (fixed)

SeverityLocationIssueFix
Mediumcrates/rsk-rsa/src/pkcs1v15.rs (rsa_raw; was rsk-openpgp/src/keys.rs)Unblinded RSA private-exponent modexp. rsa_sign fell through to a raw m^d mod n for any input that is not a recognized DigestInfo or standard-length hash (reachable via PSO:CDS and INTERNAL AUTHENTICATE). Unlike the mainline sign/decipher paths, this fallback applied no blinding. A Marvin-class private-key timing path the documented residual did not cover.The raw operation is now base-blinded (m·rᵉ)ᵈ·r⁻¹ mod n with a fresh random r, so the variable-time exponentiation runs on a base unrelated to caller input. A unit test pins rsa_raw == m^d mod n and proves the result is independent of the blinding factor.
Mediumcrates/rsk-otp/src/lib.rs (cmd_configure)Non-constant-time compare of the 6-byte OTP slot access code via slice !=, a position-of-first-mismatch leak. Reachable over CCID and HID with no PIN gate and no retry counter, so the leak collapses brute force from ~2⁴⁸ to ~6·256 probes. The access code authorizes overwriting a slot’s key material.Replaced with the constant-time rsk_crypto::ct_eq.
Mediumcrates/rsk-otp/src/lib.rs (cmd_update)Second, byte-identical instance of the same non-CT access-code compare on the slot-update path.Same fix.

Constant-time confirmed

The assurance result. Sites checked and found correct:

  • The canonical comparator rsk_crypto::ct_eq is constant-time. Public length-equality early-return, then a full-width OR-accumulate with no in-loop branch on the accumulator. Verified in the on-device LTO ELF: the inlined copies lower to a loop whose only branch is governed by the public length counter. The secret accumulator is reduced branchlessly. Reproduced from source at opt-level=s and opt-level=3.
  • PIN/PUK/password verifier compares are CT and structurally non-amplifiable. Every verifier site compares 32-byte HKDF/HMAC-derived verifiers, not raw secret bytes. Even a hypothetical position oracle would reveal avalanche-hash bytes, not PIN digits, and the “10ᵏ → k·10” counter-defeat does not apply.
  • The pinUvAuthToken MAC verify, OATH access-code/HOTP verifies, and PIV mutual-auth all route through the constant-time comparator (PIV against a single-use per-session challenge, not the persistent management key).
  • The RSA sign/decipher mainline is blinded — one helper inside rsk-rsa draws a fresh r around every secret-exponent modexp, asm CRT and software alike, and with the fix above so is the raw fallback. (Re-checked at 0.4.12, when the operation moved off rsa 0.9.10’s own blind/unblind.)
  • RustCrypto primitives are CT-by-library and not wrapped non-CT: k256, ed25519-dalek, x25519-dalek, ML-KEM/ML-DSA, and the HMAC/HKDF/SHA-2 KDF.
  • Keygen primality primitives are not an attacker oracle: they operate on RNG-generated, single-use, never-disclosed candidates. Production keygen uses the branchless incremental sieve.

Defense-in-depth applied

The five hand-rolled comparators (one canonical plus four byte-identical duplicates across the applet crates) were consolidated onto the single rsk_crypto::ct_eq, and a core::hint::black_box barrier was added before its final reduction. The comparator was already constant-time on the audited toolchain. The barrier pins that property so a future LLVM/rustc cannot fold the accumulate into an early-exit branch. It does not change the code generated today.

Documented residuals

  • The RSA private operation is now entirely in-tree. The rsa crate left in 0.4.12 and RUSTSEC-2023-0071 (“Marvin”) with it, so that residual is closed as a dependency question — but the mitigation it named is what remains load-bearing: every private-key path, asm CRT and software alike, is base-blinded per operation, and none of them is exempt. See threat-model.md.
  • rsk-rsa keygen modexp secret-indexed window lookup: a genuine secret-dependent memory-access pattern over bits of the generated prime, but it is keygen-only, one-shot, and not USB-timing-observable. On the cacheless Cortex-M33 there is no microarchitectural channel. Exploitable only via physical EM/power capture of a single keygen event, already out of scope (threat-model.md). Optional hardening: build the asm with a constant memory-access pattern.

Coverage & limits

Covered: all hand-rolled comparator definitions and call sites; every PIN/PUK/password/MAC/verifier comparison across FIDO, PIV, OpenPGP, OATH; OTP slot access-code compares; HOTP/TOTP; RSA private sign/decrypt/raw paths; the rsk-rsa C/asm modexp, sieve, and primality primitives; secret-indexed lookups; and status-word/error-path oracles.

What a source/disassembly audit cannot prove:

  • No measured timing distributions. This shows the code has no secret-dependent branch/early-exit/index. It cannot rule out a microarchitectural channel (e.g. XIP-flash stall variance, data-dependent multiplier latency). A definitive statement needs an instrumented timing harness on hardware with a statistical leakage test (TVLA / Welch t-test).
  • Compiler stability is empirical, not contractual. The comparator is constant-time under the audited toolchain. The black_box barrier pins it, but the guarantee remains “verified on this build.”
  • Physical side channels (power/EM/fault) are explicitly out of scope and unverified here, including the keygen access pattern and any DPA on the secure-boot AES, both already noted in the threat model.
  • Third-party crate internals (RustCrypto, num-bigint-dig) were audited only at the usage boundary. Their own CT properties are inherited from upstream — num-bigint-dig’s exponentiation is variable-time, which is what the blinding above exists to answer for.

Formal model

formal/ in the repository holds a TLA+ model of the authenticator’s security state, the mutation matrix that keeps it falsifiable, and the registry that ties its properties to the code. This page is the map; the deep prose — every abstraction with its direction, every hole a review found and what closing it cost — lives in formal/README.md next to the model itself.

RS-Key is not formally verified, and the model’s own page opens by saying so. What exists is narrower and it is measured: the paragraph to quote is in Testing, under “Formal claims — what is and is not verified”.

The nine modules

RSKeySecurityState.tla models the FIDO security state: PIN retries, the pinUvAuthToken and its permissions, which transport owns the touch, which channel owns a stateful walk, the reset window, the persistent gate records, and the position at which power is lost inside a multi-write flash sequence. TLC checks its invariants exhaustively at small constants.

RSKeyAppletSeams.tla models what the first module deliberately leaves out: the applets’ access statuses — PIV, OpenPGP and OATH’s seven doors, what a SELECT means for each, what a refused authentication costs, and the access-code removal gate.

RSKeyStore.tla models the flash layer one level beneath both — rsk-fs’s key/value store over a Storage backend: whether a torn delete can orphan a file’s metadata, and whether the in-RAM present-cache can read a committed key as absent. It is a lift of the Rust power-cut oracle (powercut.rs) that had been reachable only by the fuzzer, and it is the store model the roadmap’s refinement pilot inducts its persistent-state invariant over.

RSKeyRetryLattice.tla models the retry & recovery budget lattice of the two applets that have one — PIV (PIN, PUK) and OpenPGP (PW1, PW3, RC): the finite counter behind each reference, the recovery reference that refills it, and the anti-bruteforce arithmetic that is identical at every one. It is the part of the applet surface with no safe oracle — exhausting a real PUK ladder blocks the card and the only way back takes the keys — so an exhaustive check of every verify/block/recover interleaving can run only in a model.

RSKeyAppletPolicies.tla covers the four applets’ remaining stateful doors: PIV NEVER/ONCE/ALWAYS slot policy and freshness spending, OpenPGP algorithm- attribute invalidation, OATH access-code plus touch gates, and Yubico OTP slot- code mutation plus its combined use/session replay position. OATH and OTP codes have no retry counter; keeping this separate avoids proving invented budgets. All four fit in one exhaustive graph: 2,268 distinct states at depth 14.

RSKeyAdminSurface.tla models the surface above all of them: the enabled-applications mask, the always-on carve-out that keeps ykman config usb --disable reversible, and the operator-presence gate on the privileged rescue commands. Two of its four mutants rebuild defects that actually shipped — the mask that was a DeviceInfo report rather than an enforcement, and the lock-code write that silently re-enabled every disabled application.

RSKeyTrustedDisplay.tla models the confirm ceremony — the display build’s anti-phishing promise, what is confirmed is what is shown, as three machine-checkable rules: an RP-naming operation completes only through the card that names it, a press that predates the card approves nothing, and no exit but a deliberate Allow ever reads as Confirmed. Two of its three mutants are shipped display-build defects.

RSKeyBootHardening.tla models the two machines at the reset line — the one-shot at-rest scrub lap (EF_HARDENED never lies about superseded weak-sealed copies, and every lazy re-key re-arms it) and the scratch-word lock carry (a warm reset moves the whole soft lock, never half of it). It exists because firmware/ has no host tests by construction: the model is the only instrument that exercises these interleavings. Its PowerOnClearsScratch2 assumption is deliberately explicit and still awaits an RP2350 hardware measurement; TLC does not turn it into a hardware fact.

RSKeyTransport.tla models the CTAPHID frame reassembler — the channel, sequence and length checks a multi-frame message passes before dispatch: one host application’s continuation never assembles into another’s message, an out-of-order frame aborts rather than fills the gap, and a declared length never overruns the buffer. It is already unit-tested and fuzzed per frame; the model checks the invariants that live in the interleaving, which those do not assert.

Refinement pilots

The token pilot connects a small abstract token machine to the detailed FIDO model and a bounded projection of FidoState. The cross-reset pilot closes the deliberately deferred reboot seam for ResetNeverWeakensSurvivingState: concrete reset phases share the production FID classifier, Kani proves the finite projection inductive, and the existing rsk-fs oracle drives the full reset through byte-granular power cuts. The real-board script is a destructive witness, not a proof, and its presence in the evidence graph does not assert that a current hardware run passed.

Trace validation

The models’ fidelity to the code is kept by hand — citations, mutants, co-refutation — and one thing none of that measures is whether the code as it runs stays inside a model’s behaviors. TraceSeams.tla closes that empirically: a real session recorded from the software emulator is replayed against the applet-seams model step by step, and a step the model refuses is a TLC deadlock at that exact position. A second, hand-written session the model must reject is required to go red, so the replay harness is proven able to refuse. A green replay is evidence about the recorded sessions, not a proof about all runs; coverage grows by recording richer sessions.

The checks of the checks

An invariant no defect can violate is the TLA+ analogue of a test that cannot fail, and this tree has been bitten by that class enough times to check for it mechanically:

  • every invariant carries mutants — each Bug* switch rebuilds a real RS-Key defect or removes a defence the tree has, and its Solo_*.cfg run must come back RED;
  • every green run has a floor (formal/floors.txt) — a GREEN that got smaller than its recorded distinct-state count is reported as FLOOR, because a collapsed state space passes every invariant vacuously and once did;
  • a vacuous run is named — a spec nothing enabled exits non-zero rather than reading as a pass;
  • every scope constant carries a measured minimum (formal/scopes.txt) — the floor watches the search, this watches the CONSTANTS the search runs over. Three mutants are GREEN one element below the shipped scope: two need a second channel to have somebody to splice into, one needs a second FID to have a record worth losing. A configuration below its recorded minimum is refused;
  • the source is linted first — two TLA+ traps that leave a spec well-formed and meaningless (a precedence slip turning an assignment into a guard, an action pinned to a no-op by its own UNCHANGED) are refused before TLC runs.

scripts/test_run_tlc.py keeps the runner itself falsifiable in the merge gate. Its four artificial corruptions are a broken jar, a Solo invariant that misses its mutant, a one-state VACUOUS run, and a muted Mut switch. Direct RED and FLOOR cases keep all three job verdict boundaries explicit.

Co-refutation

TLC proving that a model invariant rejects a defect does not show that the production tests reject the same defect. scripts/comutate.py closes that gap: each model mutant is an exact patch that re-injects the same semantic defect into Rust, then runs the smallest relevant host-test slice in a throwaway git worktree. A failing test is co-refuted; a green slice is an abstraction gap; a defect made impossible by a shipped structural fix is unreachable only with recorded evidence. A compile failure is never counted as a kill.

The roadmap’s fixed phase-2 denominator is the original 28 FIDO mutants. The generated table in formal/README.md records all 28, their target invariant, model verdict and code-level verdict: 26/28 are co-refuted, two are unreachable, and none is a gap. Deriving that roster found six real coverage gaps; each now has a regression harness. Later modules extend the live roster to 67 entries: all 63 executable patches are killed and four are unreachable.

The applet batch — the 24 seam, retry-lattice and policy mutants — was added because the roster had a measurable skew: 31 of its first 43 patches sat in three crates, and the four applet crates four of the nine modules are written about held none. Measuring them found three more coverage gaps — including the Rust half of SEC-SEAM-006, whose model half had been closed two revisions earlier — each now closed by a regression harness. Two further gap verdicts turned out to be the batch’s own defects rather than the tree’s: an adversarial review found both patches modelled a different defect from the switch they were named after, and the faithful versions are unreachable — defence in depth whose removal changes nothing observable. A red run is not evidence until the reason it went red is read.

The merge gate cheaply checks the closed roster, patch anchors, expectations, floors and generated table freshness. The expensive full measurement runs weekly next to cargo-mutants; run --write-readme publishes the 28-row table only after measuring every executable phase-2 patch.

The registry

Every property TLC checks has an entry in assurance/properties.toml — id, statement, source and status, nothing else hand-written. A check.sh row, scripts/assurance_gate.py, derives the rest per run: which module defines the property, which configurations check it, which mutants target it, which Kani harnesses, fuzz targets, Rust files and device tests carry its name. The gate holds the graph closed in both directions — nothing TLC checks may be unregistered, nothing registered may be unchecked — and a status must equal the evidence ceiling: a Kani harness carrying the property’s name forces BOUNDED, and PROVEN is refused until that evidence class exists in the tree.

The owner functions carry the property back into the code: a doc line of the form Refines `RSKeySecurityState!NoTokenAfterInvalidation` — SEC-FIDO-003 sits on each function the model’s ownership table names, the gate validates every tag, and every invariant in all nine shipped baseline configurations must be named in production Rust somewhere. Firmware sources count as owners for the boot module. The shared check runs from both assurance_gate.py and citation_gate.py.

The evidence table and 28-member workspace coverage ledger in formal/README.md are generated from that same audit. Cross-model Supports tags close the two FIDO properties whose persistent half is owned by the store module. The ordinary gate rejects a stale block; regenerate it after evidence moves with python scripts/assurance_gate.py --write-readme.

assurance/crates.toml is the same discipline one level up: all 28 workspace members classified — state modelled, modelled in part with the gap named, unmodelled with the roadmap module named, pure with the differential or proof files named, or out of scope with a reason. The ledger exists because enumerating crates from memory has already missed four of them.

Running it

nix develop                       # pins TLC and exports TLA2TOOLS_JAR
cd formal
./run-tlc.sh safety               # model + mutants + floors, ~30 min
./run-tlc.sh liveness             # the temporal half — needs a 12g heap
./run-tlc.sh all                  # both
./run-tlc.sh Shipped.cfg          # one configuration
./run-tlc.sh --tiers              # what each tier runs, for the gate
python3 ../scripts/assurance_gate.py   # the registry, held against the tree
python3 ../scripts/assurance_gate.py --write-readme  # refresh its README table
python3 ../scripts/comutate.py --lint  # closed roster + patch/table freshness
python3 ../scripts/comutate.py run     # re-inject the whole live defect roster
python3 ../scripts/comutate.py run --write-readme  # measure + refresh 28 rows

CI runs the safety tier weekly (deep-checks.yml, the formal job) and on any push touching formal/, so an edit to the model is checked at once. The liveness tier is deliberately not in CI: Liveness.cfg needs the 12 GB heap floors.txt records for it, and a hosted runner has already died under less. The registry and co-refutation lint gates run on every pull request as part of check.sh; the full co-refutation roster runs weekly.

Token refinement pilot

Phase 5 connects three deliberately different descriptions of one narrow property: the per-boot lifecycle of the FIDO pinUvAuthToken and its persistent credential-management grant. It does not establish whole-firmware formal verification, cross-reset refinement, or liveness.

The three tiers

  • A is formal/RSKeyTokenAbstract.tla: 44 states and an outcome-labelled AllowedEventRel. Next is its existential closure, not a second relation.
  • B is formal/RSKeySecurityState.tla. TLC checks native INSTANCE refinement in TokenRefinement.cfg, and checks labelled outcomes separately in TokenRefinementOutcome.cfg.
  • C is (FidoState, TokenPersistentView). The persistent view contains exactly EF_PIN and EF_PAUTHTOKEN. EF_ALWAYS_UV is excluded: it decides when UV is required, but it is not token-lifecycle state. A therefore over-approximates the no-PIN state when the runtime flag is enabled.

The TLA+ module owns AStates, Ops, Outcomes, and AllowedRelation. scripts/export_token_relation.py only captures TLA+-serialized values; scripts/generate_token_edges.py generates the Rust enums, AState, and exact bitset. The exhaustive host test checks all 63,888 tuples. The current export is 44 states, 11 operations, 3 outcomes, and 871 allowed edges; these are printed facts, not hand-maintained requirements.

Concrete domain and boot boundary

InitC is the real boot shape relevant to this projection: FidoState::new, the valid BootState lock/warm restoration performed by AppletHandler::new, and clientPIN initialization. Initialization rerolls secret bytes but leaves the abstract token retired. ValidBootInput bounds the restored mismatch byte by PIN_MISMATCH_LIMIT; the board decoder clamps older or corrupt scratch values to that range.

ValidPersistent admits all four presence combinations of EF_PIN and EF_PAUTHTOKEN. This is intentional, including grant && !pinSet: firmware before 0x08BF and a torn reset could leave that shape. Because A observes only record presence, not record contents, no older-firmware version assumption is needed for R0p. Every projected write and power cut remains inside those four states.

wf_concrete(F, P) requires the production permission shapes represented by B, live(F) => P.pin_set, a retired token to have no permission or rp binding, and an rp binding to imply a live token. R2a checks InitC; R2b checks inductiveness without kani::assume. R3a checks the initial map and R3b checks every bounded named concrete step against the generated relation.

Reset evidence table

Reset classRequired BootStateEvidence status
Cold power cyclewarm=false, lock clearDecoder and model agree; physical scratch2 clearing is M7-Q1
Host sys_resetwarm=true, whole lock restoredEncoded/decoded in firmware/src/pin_lock.rs; emulator boot tests cover the consumer
Return from BOOTSELCold semantics requiredM7-Q2: measure whether the ROM path clears watchdog scratch2 before relying on it

There is no Compatible premise. No additional boot restriction was found; R0a uses only ValidBootInput. M7-Q1 and M7-Q2 are hardware-evidence tasks, not assumptions silently added to the refinement theorem.

Outcomes and completeness

Outcomes are labels, not A variables. Authorized on a dead-token stutter is therefore still a distinct, forbidden tuple. R1o checks B action labels, while the emulator trace carries outcome_raw copied from the real CTAP response. The validator derives possible B outcomes independently and accepts only consensus: one singleton equal to δC. Multiple interpretations are AMBIGUOUS, never a selected witness; formal/floors.txt ratchets their count.

assurance/token_refinement.toml and scripts/token_refinement_gate.py enforce the three completeness axes across the tree: volatile A-visible writers, persistent writers for the keys derived from TokenPersistentView, and authorization outcome producers. assurance-trace exposes verification artifacts to the host emulator and is never a firmware feature. check.sh poisons every assurance-only module in a throwaway tree, proves the poison reaches a host feature build but not firmware, and requires the pristine and poisoned firmware images to be byte-identical.

Reproduce

nix develop -c ./scripts/token_refinement.sh --check
nix develop -c ./formal/run-tlc.sh TokenRefinement.cfg
nix develop -c ./formal/run-tlc.sh TokenRefinementOutcome.cfg
nix develop -c ./scripts/kani.sh state

The state/outcome mutants, wrong generated edge tests, broken-stop test, dead-token authorized stutter test, and ambiguous-consensus test are part of the same tree. Cross-reset behavior remains phase 6.

Cross-reset refinement pilot

This is the phase-6 bridge for SEC-FIDO-006, ResetNeverWeakensSurvivingState. It joins the existing TLA+ reset machine to the concrete FIDO reset order, the rsk-fs power-cut contract, and a real-board test. It does not make RS-Key formally verified: the C→B step is bounded, the flash composition has an executable oracle rather than a deductive proof of the storage backend, and a HIL result witnesses only the cut that was run.

The property

The registry requirement is relational: no prefix of authenticatorReset, whether it returns, aborts, or loses power, may keep an owner’s usable secret after removing the gate that protected it. The three independently named clauses are:

IDClauseSurviving factGate that must still exist
SEC-FIDO-006AResetKeepsThePinGateowner credential + owner seedEF_PIN
SEC-FIDO-006BResetKeepsTheAlwaysUvGateowner credential + owner seedEF_ALWAYS_UV
SEC-FIDO-006CResetKeepsTheBackupSealowner’s pre-reset seedEF_BACKUP_SEALED

The last gate reads backwards: deleting the seal is permissive because it re-opens the one-time seed-export window. A newly provisioned seed after reboot is therefore not the owner’s surviving seed and does not violate the clause.

Evidence ladder

LayerOwnerWhat it establishes
Requirementassurance/properties.tomlStable IDs, statements, and the bounded evidence ceiling
TLA+ Bformal/RSKeySecurityState.tlaArbitrary ordering within the secret and gate phases; abort, real power cut, boot-time seed provisioning, and all three clauses
Rust refinementcrates/rsk-fido/src/reset_assurance.rsThe concrete phase machine and abstraction of the persistent/volatile facts observed by the clauses
Kanicrates/rsk-fido/src/reset_refinement_kani.rsInitialization and one-step induction across begin, every relevant delete, phase advances, abort, finish, reboot, and an unrelated FID
Power-cut fuzzfuzz/fuzz_targets/power_cut.rsThe real rsk_fido::reset::reset over SeqStorage, with byte-granular cuts inside writes/erases, fresh caches, Fs::scan, ensure_seed, and a second boot
Real boardtests/29_reset_power_cut.pyThe same owner seed/PIN/alwaysUv/resident-credential scenario with physical USB power removed while RESET is in flight

The generated table in formal/README.md derives the Kani, fuzz, and runtime columns by property name. A runtime column of one means that the HIL harness is owned and discoverable; it is not a stored claim that a particular board run passed.

Concrete projection C→B

ResetPersistentView observes both seed records, one representative resident credential, the PIN and alwaysUv gates, and the backup seal. The credential is usable only while the owner’s old seed is reachable. ResetVolatileView observes only the RAM seed copy and live token; all other FidoState buffers are irrelevant to this property.

The transition projection uses production’s own reset_phase(fid) classifier. That classifier delegates to the shipped is_fido_seed_fid, is_fido_fid, and is_fido_gate_fid predicates, so the proof cannot silently acquire a second hand-written gate list. Its phases match the implementation:

  1. retire volatile state;
  2. delete FIDO_SEED_FIDS;
  3. sweep non-gate FIDO secrets until enumeration completes;
  4. sweep gates until enumeration completes;
  5. provision the next identity epoch.

well_formed is the induction domain. It requires retired volatile state once a reset is active, completed earlier phases before advancing, and all three relational clauses. Kani proves that construction starts inside this domain and that every modeled concrete step preserves it. Each harness has a satisfiable kani::cover!; the ordinary unit tests also inject one early-gate mutant per clause and require its exact property to fail.

This is a finite Boolean projection and a one-step induction proof, not an unbounded proof of the sweep loop. Loop termination and truncated enumeration remain production tests; byte-level atomicity is composed below.

The rsk-fs composition and reboot seam

The projection treats one completed force_delete as a record transition. The lower contract is the existing rsk-fs::powercut oracle and its Kani rules: delete_landed allows the old record or a fully deleted record, never a value gone behind surviving metadata. The power_cut target now feeds a separate input class through the complete real FIDO reset on the same cuttable flash stack rather than adding another fuzz target.

After a cut the target drops the dead store, rebuilds SeqStorage with fresh caches over the surviving bytes, scans it, runs boot-time ensure_seed, and checks the three clauses. It then boots and scans once more so a verdict cannot depend on the recovery mount’s cache. A local corpus run on 2026-08-16 completed 4,608 executions without a finding; that is sampled evidence, not an exhaustive result.

Real-power HIL

The test is deliberately destructive and is not run by the emulator:

nix develop -c python tests/29_reset_power_cut.py

Use a throwaway board running the no-touch test image. The script exports and seals the owner seed, sets a PIN, enables alwaysUv, creates a resident credential, starts RESET, and asks for a real cable pull. A relay can be supplied through RSK_POWER_CUT_CMD; RSK_POWER_CUT_DELAY_MS moves the cut point. A RESET that finishes before power disappears is INCONCLUSIVE, not PASS.

Flashing and operating the hardware remain maintainer actions. Consequently a fresh real-board PASS must be recorded separately before calling the roadmap’s hardware witness complete.

Verus / Creusot decision

No third verifier is added for this pilot. The decision is measured rather than based on modeling convenience:

  • a direct Kani run through the full FidoState::reset() was stopped after 72.45 s without a verdict while CBMC expanded zeroize loops through at least 398 iterations; the unrelated crypto buffers dominated the formula;
  • after projecting the two volatile facts the property actually observes, the parent harness solved in 0.52 s and the three clause harnesses in 0.45–0.48 s on Kani 0.67.0; all four covers were reachable;
  • the byte-level and full-state links are exercised by the real reset unit tests and power-cut fuzz, where the complete storage and FidoState are affordable.

This is a tractable bounded-proof boundary, not evidence that an unbounded loop or state-size limit prevents the required argument. Verus or Creusot would add a third toolchain without removing the abstraction obligation, so the decision is not now. Revisit it only if a later requirement needs an unbounded sweep or multi-step state space that cannot be reduced without dropping a security- visible fact.

Store refinement pilot

The third C→B pilot, and the smallest. It connects RSKeyStore’s cache half to the code that maintains it: the model’s present and decided variables, and the Fs primitives that write them.

Why only half

RSKeyStore has seven variables. Five of them — val, meta, dead, metaAbsent and the FID map they range over — are the persistent side, and that side already has evidence: crates/rsk-fs/src/powercut.rs’s four *_landed predicates are what the module was lifted from, powercut_kani.rs proves them, and the power_cut fuzz target replays a real medium through them.

present and decided had none. They are in-RAM, so no power-cut oracle sees them; they are private to fs.rs, so no other crate’s test reaches them; and the model’s clauses about them are the ones a reader would call obvious. One of them — a faulted read cached as a decided absence — is audit run-36, and it shipped.

What the harnesses claim

Six, in crates/rsk-fs/src/store_refinement_kani.rs, each naming its model action. The projection they run against is store_assurance.rs: it reads the real bitmaps and calls the real primitives, hooked as a #[path] child of fs.rs so the private methods are reachable without widening them.

Model actionConcrete stepThe clause
Put(f, v)mark_presentf is decided live; no other FID moves
Delete(f)mark_absentf is decided absent; a live neighbour stays live
Confirm(f), fault = FALSErecord_unless_faultedthe backend’s answer is cached as decided
Confirm(f), fault = TRUErecord_unless_faultednothing is cached — audit run-36
Init / RebootFs::newnothing cached, nothing decided
known_absenta clear present bit is trusted only once decided confirms it

Every harness carries a second symbolic FID. That is the content: the model’s clauses are [present EXCEPT ![f] = …] — one element moves, every other stands — while the code reaches its bit through fid >> 3 and 1 << (fid & 7). A shift that disagreed would alias two files onto one bit, and a mark_absent on one would then read as a decided absence for the other. That is NoFalseAbsent’s disaster reached through arithmetic rather than through a fault, and no single-FID harness can see it.

The scope, and what it cost

FID_PRESENT_BYTES is 3 under cfg(kani), against a shipped width of one bit per FID over the whole 0x0000..=0xFFFF space — 8 KiB. Measured at that full width, the writing harnesses cost 149 s, 273 s, 302 s, 520 s and 794 s — two of them over scripts/kani.sh’s 5-minute FAST cap, whose own rule is to move the crate to SLOW rather than raise the cap, and that would have taxed the four 0.5-second powercut rules for this pilot’s arithmetic. At three bytes every one of the six runs in 0.04–0.08 s and the whole rsk-fs set is under ten seconds.

Three bytes is not a round number: it is the smallest width at which both a within-byte neighbour and a cross-byte neighbour exist, which is what the aliasing clause needs. The harnesses take their domain from the constant (store_assurance::FID_LIMIT), so it follows the shrink instead of restating it.

What the shrink stops proving is that no FID can index past the map — at full width that fell out of the harnesses as a discharged bounds check. It is a compile-time assertion now:

#![allow(unused)]
fn main() {
#[cfg(not(kani))]
const _: () = assert!(((u16::MAX >> 3) as usize) < FID_PRESENT_BYTES);
}

which is the stronger form: it is about the shipped width, and a proof would only ever have covered the FIDs a harness enumerated.

What this is not

  • Not a Kani result for the persistent half — and the first version of this bullet was wrong about why. NoOrphanedMetadata, NoRecordLostToMetaWrite and NoFalseMetaAbsent have a bridge now — store_steps_tests.rs, below — but it is a host sweep, so the three stay MODELLED-ONLY: assurance_gate reads BOUNDED off a Kani harness name.

    A harness that does nothing but meta_add does fail, and the message is the present map:

    ** 1 of 164 failed (34 unreachable)
    Failed Checks: index out of bounds: the length is less than or equal to the given index
     File: "crates/rsk-fs/src/fs.rs", line 118, in fs::Fs::<…>::decided_bit
    Verification Time: 0.109 s
    

    From which this page concluded “no metadata path can run under cfg(kani) at all” and “there cannot be one”. Both are false, and the review measured it. The blocker is EF_META’s VALUE (0xE010, index 7170), not the map’s WIDTH, and the value takes the same one-line alias FID_PRESENT_BYTES already has: with #[cfg(kani)] EF_META = 0x0017 and nothing else changed, the same harness is 0 of 164 failed, SUCCESSFUL, 0.244 s — and two real obligations at the fault sites the model states verify over the existing FaultBackend in 0.107 s each. Widening the map, which is what the old bullet argued about, was answering a question nobody asked.

    What genuinely is out of reach is the clauses over a MEDIUM. With the alias, a single-blob backend and META_MAX shrunk 1024 → 32, both blob obligations time out at 420 s. That is the blob rebuild, not the bitmap.

    So the honest position: the two fault-site obligations are a cheap win this page has not taken, and taking them means a cfg(kani) redefinition of a PUBLIC constant plus a status change for two registry rows — its own change, with its own mutation table, not a footnote to this one. The medium-backed clauses stay the host sweep’s.

  • Not Scan. The model’s truncated-walk clause needs a backend that can truncate, which is a medium, not a bitmap. fs_tests.rs carries it; Kani does not.

  • Not a whole-behaviour result. The Kani harnesses are one-step obligations at every FID, which is why SEC-STORE-002 is BOUNDED and not more; the host sweep below is bounded by sequence LENGTH, which is the same kind of claim.

  • And it cannot be a per-FID state projection either. The tempting move is to write the model’s per-FID steps as Rust predicates and hold them against powercut.rs; it was tried and measured, and each predicate comes out as the same boolean function as its *_landed twin — 0 disagreements over a five-valued domain, which is a copy compared to itself. Two of the three are STEP recorders (a meta-only file legally has metadata and no value, so the violation is a record outliving a delete rather than a state) and the third is CROSS-FID (a meta_add dropping ANOTHER FID’s record). formal/README.md’s phase 7 has the numbers.

The persistent half, exhaustively on the host

store_steps_tests.rs drives the REAL Fs over a REAL medium at three FIDs and reads one of three step recorders after every step. Three FIDs because NoRecordLostToMetaWrite is about the records a rewrite drops: with a subject and one neighbour, “the write kept everything else” cannot be told from “the write kept the one file we looked at”.

SweepWhat it coversSize
every three-step sequencethe clauses over a fresh store12³ = 1728 orderings, 5184 steps
the same, then a reboot with no scanEF_META UNKNOWN rather than confirmed — the 0x077C door1728 × 12 more steps
every two-step sequence over a failing mediumthe FAULT path both meta recorders are about144 orderings
each recorder against the state its invariant forbidsthat a recorder can answer TRUE at all6 assertions
a live-read counter per recorderthat the sweeps are not a loop over nothing4 counters, all > 0

Two measurements decide whether this is worth anything.

It has teeth. comutants.toml’s BugDeleteMetaOnlyUnderPresent — the 0x077C databug verbatim — applied to fs.rs gives, in 0.00 s:

NoOrphanedMetadata: [MetaAdd(0)] then Delete(0) left a record over a gone value

which is the shortest witness there is: a meta-only file deleted. All three recorders have one now — BugMetaAddDropsOnFault gives NoRecordLostToMetaWrite: [] then MetaAdd(0) dropped a bystander's record and BugMetaDeleteDropsOnFault gives NoFalseMetaAbsent: [] then MetaDelete(0) cached absence over a live record. The first of those survived the first version of this sweep, because the faulting medium failed writes as well as reads and NoRecordLostToMetaWrite’s loss needs the read to fail while the rewrite LANDS. The counters are the reason that cannot happen twice: with put and meta_add made inert the sweeps used to pass ~26 000 dead steps in silence, and they now say NoOrphanedMetadata was never read from a state it could refuse.

And it does not have all of them. BugDeleteValueBeforeMeta — the two backend writes reversed — survives, and correctly. The tempting reason is that the completed end state is identical either way; measured, that is false — with a bystander’s record and a medium whose remove fails, Put(0), MetaAdd(0), MetaAdd(1), Delete(0) ends at meta=[false,true,false] shipped and meta=[true,true,false] reversed, no power cut involved. The narrow reason is the right one: NoOrphanedMetadata cannot separate them, because val[0] survives in both and an orphan is a record over a gone value. powercut.rs’s delete_landed is what owns the ordering, which is why both exist — and cargo test -p rsk-fs does kill this mutant, through powercut::tests::a_cut_never_leaves_metadata_behind_a_file_that_is_gone.

The one shape the sweep will not judge, and the defect behind it

A faulted Delete. MetaAdd and MetaDelete each carry a faulted disjunct in the model; Delete carries none — dead there is a power cut, not a medium error. Reading NoOrphanedMetadata at a faulted delete would be judging a step nothing states, so the fault is armed only for the two actions that have one.

That is a modelling decision, and it is standing in front of something real. The first version of this paragraph described it as a meta-only-file curiosity. It is not. Fs::delete used to swallow meta_delete’s error (a let _ = in fs.rs, deliberately quoted without a line — the fix moved it) and then remove the value; over a medium whose EF_META read failed ONCE and then worked, a delete of a file that has data returned Ok(()) with the value gone and the record standing:

delete returned : Ok(())
after: meta=[true, false, false]  val=[false, false, false]

That is the 0x077C databug’s end state, on the shipped tree, with no power cut and no meta-only file. It is reachable on hardware — rsk-store’s read and size set last_err straight from sequential-storage’s fetch_item, so last_error() is a flash read error and not a modelling device. And the tree already treats the consequence as a defect in one place: rsk-piv’s files.rs:302-310 reaches for force_delete precisely because “a stale AES-256 head left over a re-minted 24-byte DEFAULT_MGM wedges the slot on the length compare”. PIV’s other meta_add_slot sites have no such repair.

Closed in the code half, and not the way the first draft of this paragraph proposed. Propagating with self.meta_delete(fid)? before the value goes was the obvious repair and it is the wrong one: EF_META is one blob shared by every applet, a failed read of it means “cannot tell” rather than “no record”, and most callers spell the delete let _ = fs.delete(...). So a single flash-read fault would have stopped every delete on the device — a wipe included — while the callers that discard the result reported success, trading an orphaned record for a secret that outlives its erase.

What delete does now is remove the value regardless and return the metadata error, so Err names a state (the value is gone, a record may stand) instead of hiding it. The one caller in the tree that deletes a fid carrying a head is PIV’s MOVE with to = 0xFF, the slot delete — heads are minted by rsk-piv alone — and it reads the answer: the head gets a retry, because one read can fault where the next lands, and the key is read back, because a remove that failed leaves the source holding a live key. Both directions answer 6581.

The model’s half is the other item, and it is still open: RSKeyStore!Delete carries no faulted disjunct, so the sweep still cannot judge the shape, and NoOrphanedMetadata still reads as unconditional where the code now permits an orphan on an error it reports. Until that lands the invariant is stated more strongly than the code holds it, which is the direction that at least fails loudly.

The PR gate carries the same clauses at concrete FIDs (a_cache_write_moves_one_fid_and_no_other_across_three_bytes and a_faulted_confirm_caches_nothing_and_a_clean_one_caches_the_answer), because a proof that only runs weekly is a proof a rename can take away on a Monday. That test walks a window of 24 consecutive FIDs rather than checking a pair: >> 3 mistyped as >> 2 and & 7 as & 3 alias different pairs, so a test naming two FIDs catches whichever of them its two happen to meet.

Architecture

How the firmware is put together, for contributors and the curious. For what the design does and does not defend against, see the threat model.

The big picture

A composite USB device with three interfaces, eight smart-card applets and one storage layer. Day to day everything runs on one RP2350 core. The second wakes only to parallelize RSA keygen (below):

flowchart TD
    subgraph irq["Interrupt executor"]
      fido["FIDO HID<br/>(0xF1D0, CTAPHID)"]
      ccid["CCID<br/>(class 0x0B, APDU)"]
      kbd["Boot keyboard<br/>(OTP typing)"]
    end
    fido --> worker
    ccid --> worker
    kbd --> worker
    subgraph thread["Thread executor"]
      worker["worker task<br/>owns flash + TRNG"]
      worker --> applets["Applets<br/>FIDO2/U2F · OpenPGP · PIV · OATH<br/>OTP · mgmt · vendor + rescue"]
    end
    applets --> fs["flash KV store<br/>(rsk-fs over sequential-storage)"]

Two executors. USB and the transports live on a high-priority InterruptExecutor. The applet dispatch lives on the low-priority thread executor, in a single worker task that owns the flash and the TRNG outright. Long synchronous work (on-card RSA generation, flash compaction, a touch wait) blocks only the worker, while the interrupt executor keeps the bus enumerated, streams CCID/CTAPHID keepalives, and animates the LED. No mutexes: ownership does the synchronization.

Why (mostly) one core. The async executor provides the concurrency that the upstream design used a second core and hand-rolled queues for. Core 1 is kept out of the transport path and has exactly one job: during on-card RSA generation both cores race the prime search. Independent random candidates, each core with its own DRBG stream, feed one shared two-prime pool (firmware/src/core1.rs). Measured, RSA-2048 generation drops from ~8.9 s to ~4.3 s mean (2.07×).

Three details make that work:

  • The Fermat-filter modexp (C + asm) executes from SRAM. Two cores running it from XIP throttle each other on the shared flash cache (~40% per core, measured).
  • The key returns the moment the pool completes; core1’s last candidate finishes in the background.
  • A core1 that ever stops answering latches the engine into single-core mode rather than stalling the worker (on a --features core1-stats build, INS 0x12 on the vendor applet reads the engine’s counters and flags; a shipped image answers 6D00 — they time the prime search).

Outside keygen, core1 parks in WFE, and embassy-rp pauses it around every flash erase/program, so its XIP fetches never collide with flash writes.

Boot sequence

Getting to that runtime state has a strict order. The bootrom verifies the signed image, then the firmware provisions and recovers all persistent state (OTP keys, the KV store, the phy record, the TRNG, the seal migrations and the one-shot at-rest scrub) before it asserts the USB pull-up. That ordering is load-bearing: builder.build() starts host enumeration, and the task that answers control transfers must be spawned with no blocking work in between, or the host enumerates a mute device and times out. That is the “blink red / not recognised until several replugs” report that motivated attaching to the bus only after everything else is ready.

Boot sequence — bootrom secure-boot verify, then a provision-and-recover phase (OTP keys, KV mount, phy record, TRNG seed, seal migrations, at-rest scrub) that completes before the USB pull-up, then attach-and-serve (build, spawn usb_task, transports live, worker dispatches applets)

Crates

The workspace splits along a strict dependency gradient: the firmware binary is thin glue over the applet crates, which build on a handful of host-tested platform libraries. The per-crate detail is in the table. The shape is:

Crate dependency layers: 28 crates in 9 tiers, from the 2 flashable binaries at the top, down through the 8 applets, to the 4 algorithm crates. All 100 in-workspace dependencies point strictly downward, and applet-to-applet edges number 0.

No applet names another applet — the count is zero, in code and in the manifests. This page used to name three cross-edges (piv→openpgp, openpgp→rsa, piv→rsa), only the first of which was one applet reaching sideways; the manifests carried sixfido→mgmt, fido→rescue, openpgp→mgmt, piv→mgmt, piv→openpgp, otp→mgmt. The machinery under each moved down instead: the phy record into rsk-phy, the DeviceInfo record into rsk-devconf, RSA into rsk-rsa, and the EC key type both card applets seal (PrivKey over Curve, the [curve_id] ‖ scalar blob) into rsk-ec. Six rsk_<applet>:: mentions do survive across the applet tier, every one inside a comment where one applet explains its ordering by pointing at a sibling — a cross-reference, not a dependency.

What holds that now is deny.toml: each applet crate is banned except behind the composition roots that wire it, so a sideways edge fails the cargo deny row of the gate instead of waiting on a reviewer. The same stanza keeps a hash backend to rsk-crypto and puts rsk-ec/rsk-rsa behind a named allowlist. The drawing above is generated by scripts/crate_graph.py, which checks the shape from the other side — every member placed in a tier, every edge strictly downward — and fails the gate when the committed SVG has drifted from the manifests.

CrateContents
firmwarethe only crate that touches the HAL: board bring-up, USB descriptors, executors, the worker, OTP fuse access, LED, BOOTSEL touch
rsk-wipethe second flashable image: a RAM-only flash erase for clean-slate testing — wipes all of flash, leaves a NUKE eyecatcher, reboots to BOOTSEL. Runs from SRAM because erasing the sectors a flash-resident image executes from would crash on return
rsk-sdkAPDU parsing (cases 1–4, short + extended), BER-TLV, status words, the Applet trait + dispatcher, and the seams a board hands every applet: Rng, UserPresence/Presence
rsk-fsthe flash filesystem: 16-bit file ids over two sequential-storage KV partitions (main + high-churn counters), ACLs, metadata records
rsk-cryptoone wrapper over RustCrypto: hashes, HMAC/HKDF, AES-CBC/CFB/GCM, ChaCha20-Poly1305, PIN KDFs, HMAC-DRBG, ML-DSA-44/-65/-87 (rsk-mldsa) / ML-KEM, base64url, CRC
rsk-mldsastack-optimized ML-DSA (FIPS 204) for all three parameter sets: streams the matrix A on the fly (one polynomial resident, not the full k×l) so even ML-DSA-87 fits the RP2350 stack where the by-value fips204 crate’s -65 overflowed it. no_std, no alloc, no unsafe; checked byte-for-byte vs NIST ACVP KATs, with Kani proofs over the reductions and rounding
rsk-sha512SHA-512/384 for the Cortex-M33, byte-identical to sha2 but a compact rolled compression (~0.9 KB) that fits the XIP cache instead of sha2’s ~28 KB unrolled body — ~4× faster end-to-end on a FIDO getAssertion, with the identical digest, so hmac/hkdf compose over it byte-for-byte
rsk-usbthe CTAPHID reassembler/framer and the CCID state machine, transport-agnostic and fully host-testable
rsk-fidoFIDO2 (CTAP 2.1) + U2F: credentials, clientPIN (protocols 1+2), credManagement, extensions (hmac-secret, credProtect, credBlob, largeBlobs, minPinLength), enterprise attestation, seed backup + soft-lock vendor commands
rsk-openpgpOpenPGP card 3.4: DO model, PW1/RC/PW3, import/generate, PSO, AES PSO, certs — EC + RSA-2048/3072/4096
rsk-pivPIV: 24 key slots + F9 attestation, management-key auth, generate/import/sign/ECDH, on-card X.509 via a hand-rolled backward DER writer
rsk-oathYKOATH protocol: TOTP/HOTP, touch-required accounts, access codes
rsk-otpYubico OTP slots ×4: CCID command surface + the keyboard frame protocol and typed-ticket generation
rsk-mgmtthe YubiKey management applet: the CCID command surface for READ/WRITE CONFIG and the device-wide reset (the record itself is rsk-devconf), served over both CCID and CTAPHID
rsk-rescuerecovery/provisioning applet: identity, the phy config record (codec in rsk-phy), flash info, secure-boot status, attestation key, reboot, the one OTP-lock write
rsk-storethe rsk_fs::Storage backend the device runs: two sequential-storage map partitions (credentials vs. the hot counters), the counter-FID routing, and the scrub lap that physically destroys superseded secrets — generic over the flash, so the fuzzer can cut its power and the emulator can mount a file
rsk-devicethe applet wiring both the firmware and the emulator run: which applets exist, what capability gates each, and how a CTAPHID or CCID message reaches one — the board’s own parts behind a Hooks trait. Also the presence-scope arbitration (presence): which transport owns the one button, whose cancel may end its wait, and the spent latch, with the button and clock behind a Board seam
rsk-vendorthe vendor AID: the persisted test counter, SET/GET LED, the reboot request, and — gated out of every shipped image — core1 stats and the measurement benches; the hardware behind a Platform the firmware fills in
rsk-rsathe RSA family: the key type, key generation, the sealed CRT layout and its blinded, fault-checked private operation, PKCS#1 v1.5, the 7F49 public-key DO — over vendored C/ARM-asm modular exponentiation behind one FFI fn (host build uses a pure-Rust fallback). The OpenPGP and PIV applets add only their own framing and seal I/O
rsk-ecthe EC family: the private key both card applets seal (PrivKey over Curve — the persisted [curve_id] ‖ scalar blob), its ECDSA/EdDSA signing, d·G public-point derivation and ECDH, the 7F49 { 86 } public-key DO, and underneath them the fixed-base Lim–Lee comb k·G/d·G that FIDO, PIV and OpenPGP all sign on — bit-identical to the RustCrypto generic path, several× faster on the Cortex-M33
rsk-ledthe EF_LED_CONF codec for the status-LED config block, shared by the firmware and the rsk led host tool
rsk-devconfthe EF_DEV_CONF codec: the Yubico DeviceInfo record — which applications are enabled, the capability vocabulary, the validate/merge/trim write path and the READ CONFIG response built around it. Written by four command surfaces (CCID, the OTP keyboard slots, CTAPHID, the FIDO vendor config-write), so it sits below all of them rather than inside the management applet
rsk-phythe EF_PHY codec: the PicoForge-compatible device-config TLV record — USB identity, LED wiring, the interface mask — plus its clamped load and its read-modify-write save. Read by the rescue and FIDO applets, rsk-device, rsk-display and the boot path, so it sits below all of them rather than inside one
rsk-benchrobust summary statistics (median, MAD, a separate cold sample) for the on-device latency harness. Steady-state timing on the RP2350 is XIP-cache sensitive to ±~30 ms, so a mean fakes regressions; compiled in only under the bench feature, never into a shipped image
rsk-bip39BIP-39 mnemonic encode for the trusted display’s recovery-phrase screen; display build only
rsk-slip39SLIP-39 (Shamir) share encode for the same screen, mirroring the host shamir_mnemonic exactly so rsk backup restore recombines; display build only
rsk-uithe trusted-display UI model (operation prompts, untrusted relying-party-string sanitizing, Allow/Deny button geometry) plus its compact retained scene and 8-row rasterizer; compiled only into the display build
rsk-displaythe trusted display’s flow — which screen is shown when, the PIN pad, the browse modals, the Approve/Deny wait — over a panel and a touch controller it takes as type parameters, so the firmware drives an ST7789 with semantic damage + two-band DMA and the emulator replays the same retained scene into a window; display build only

Everything except the two binaries is hardware-agnostic and runs the full test suite on the host (testing.md).

Flash layout

RP2350-One flash map — a signed code region above two KV partitions (main store + counters) that survive a reflash

Two KV partitions at fixed offsets (firmware/memory.x): the main store, and a small separate partition for the per-operation counters so their churn never forces compaction of long-lived records. Files are 16-bit ids. Each applet owns disjoint ranges (FIDO 0x10xx/0xCxxx/0xCFxx/0xD0xx, OpenPGP DO mirrors, PIV 0xD1xx/0xD2xx, OTP slots 0xBBxx, phy/rescue 0xE0xx) and a reset wipes exactly its own predicate, never a range shared with another applet.

Both regions sit behind one RP2350 partition table entry carried in the shipped image: the USB bootloader is denied read and write over __kvmain_start..__kvcnt_end, while secure code — the running firmware, rsk-wipe, the rescue applet — keeps rw. The table is derived from those linker symbols rather than restated, so it follows FLASH_SIZE/KVMAIN on its own (build.md). What it is and is not worth: threat-model.md.

Key sealing at rest: kbase = HKDF(serial_hash, otp_master_key) keys AES-CBC for the FIDO seed (tagged formats: plain vs OTP-rooted generation) and AES-GCM for PIV keys. OpenPGP keys sit under the PIN-wrapped DEK chain. When the OTP master key gets provisioned later in a device’s life, a boot pass and lazy PIN-verify hooks migrate every sealed object to the new root without losing data. Until that burn the root derives from on-chip state alone, which an attacker with full flash and chip access could reconstruct. threat-model.md covers what at-rest sealing does and does not buy before provisioning.

One sealed object worth spelling out is the FIDO credential box. It doubles as the opaque credential ID a relying party stores, so its size caps the reported maxCredentialIdLength (748):

FIDO credential box — proto(4) iv(12) then a variable CBOR body, poly1305 tag(16) and silent tag(16); the four framing pieces are 48 bytes and the whole box is at most 748. Below it, the 42-byte resident id returned to the relying party

The 42-byte resident id carries a version byte at offset 8, a reserved header byte outside the [10..42] HMAC chain, so it never changes the id’s entropy. It is 1 (v2) for every resident credential created since RS-Key 0x0806: a v2 credential derives its signing key, hmac-secret and largeBlobKey from this stable id, so an updateUserInformation reseal (which draws a fresh box IV) no longer rotates them and the relying party’s stored public key keeps verifying. Resident credentials from older firmware carry an implicit 0 (v1) and keep deriving from the box, so an already-provisioned device stays compatible across the upgrade.

Capacity — why the flash is mostly empty

The KV store is 1.5 MB by default whatever the FLASH_SIZE, so a larger flash only grows the code region, most of which no firmware writes (build.md). That is deliberate. (A 2 MB board is the one exception: it shrinks the main partition via KVMAIN to leave room for code — still far more than a key ever fills.) A security key’s maximum logical state is small and hard-capped: MAX_RESIDENT_CREDENTIALS (256 passkeys), MAX_DYNAMIC_FILES (1280 files, one budget shared by every applet), MAX_OATH_CRED (255), plus a handful of OpenPGP/PIV slots. The passkeys dominate the bytes — a file-count ceiling is not a size ceiling — so a fully provisioned device fills only a few hundred KB, well under the 1408 KB main partition. Growing the store to “fill” a 16 MB board would buy nothing usable: it lengthens the sequential-storage scan behind every cold boot and absent-key probe (the present cache exists to dodge exactly that full-partition ~0.2 s cost), and forces the logical caps (and the RAM/stack buffers sized to them) up for capacity no one reaches. Empty flash here is headroom, not waste.

Device identity

USB VID/PID, product strings and the reported firmware version are compile-time knobs (build.md). The default is RS-Key’s own identity: VID:PID 0x1209:0x0001 (pid.codes), manufacturer “RS-Key”, product “RS-Key Security Key” (the RSKey preset). An opt-in VIDPID=Yubikey5 preset builds the Yubico interop flavor (0x1050:0x0407, reader name “Yubico YubiKey”) for the tools that auto-recognize the device purely by that reader name. A flash-resident phy record can override VID/PID and the product string at boot (the store mounts before the USB builder runs for exactly this reason). FIDO tools find the device by HID usage page, CCID tools by the reader name.

User presence

One presence button (BOOTSEL by default, or PRESENCE_PIN), shared by all applets through rsk_sdk::UserPresence, which the firmware implements once: FIDO operations, OpenPGP UIF, PIV touch policies, OATH touch accounts, and OTP slot typing (1–4 presses select the slot) all gate on it. The trait has two asks, because a screen answers them differently: request for a smartcard touch policy, one per signature, and request_ceremony for a ceremony a host raised — a CTAP2 command, or the pinpad’s “Allow host PIN entry?” gate — which alone can be cancelled mid-wait (CTAPHID_CANCEL) and so alone can answer Presence::Cancelled. The no-touch build (--features no-touch) auto-confirms. For test rigs, not for daily use.

Provenance

RS-Key reimplements the applet behaviour, file layouts and protocol surface of pico-keys (AGPL, see NOTICE) in Rust, replacing the C HAL/runtime/transport stack with embassy and RustCrypto:

was (C)is (Rust)
pico-sdk runtime + TinyUSBembassy-rp + embassy-usb
mbedTLSRustCrypto (p256/p384/p521/k256, ed25519-dalek, …) + our own rsk-rsa
TinyCBORminicbor
bespoke wear-leveled flash writersequential-storage
core0/core1 + queuesone async executor pair; core1 = a keygen math engine only

Where the two implementations deliberately differ (at-rest sealing, seed PIN-wrapping, OTP provisioning policy, several upstream bugs not carried over), the divergence is a documented design decision. See threat-model.md and the crate docs.

RS-Key host protocol reference

This document specifies the host-facing protocol of the RS-Key firmware: how a configuration/management tool talks to the device, what commands exist, the exact byte layout of each request and response, and what authenticates each one.

It exists so that third-party tooling (e.g. PicoForge) can configure and manage RS-Key devices without reverse-engineering the firmware. The canonical, runnable reference client is the rsk Python CLI. Every command below is implemented there; file/line pointers are given throughout.

Audience & scope. This is a wire spec, not a tutorial. It documents the commands a host sends and the bytes it gets back. It does not cover the on-device storage format, the crypto internals, or the build system. Those live in architecture.md and the crate sources.

Licensing. RS-Key firmware is AGPL-3.0-only. This protocol description is published as part of the same repository; you are free to implement a client against it under any license. Interop implementations do not inherit AGPL by talking to the device.

Stability. The two transports and the standard applets (FIDO2, U2F, PIV, OATH, OTP, OpenPGP, Yubico Management) are stable. They follow public specs. The RS-Key-specific surface (Rescue applet, Vendor/LED applet, CTAPHID authenticatorVendor 0x41) is versioned by bcdDevice and may grow; new tags/subcommands are added, existing ones are not silently repurposed. Probe the version handshakes (§3, §6.1) and treat unknown tags as skippable.


1. Transports

RS-Key is a USB composite device exposing two host-reachable transports:

TransportCarriesHost API
CTAPHID (FIDO HID, usage page 0xF1D0)CTAP1/U2F, CTAP2, and the authenticatorVendor 0x41 vendor commandhidapi
CCID (PC/SC smart-card)All ISO-7816 applets, selected by AIDpyscard / PC/SC

A keyboard (HID) interface also exists for Yubico OTP. On the default build it also carries the ykman OTP-HID admin writes (SET_DEVICE_INFO 0x15, DEVICE_CONFIG 0x11, SCAN_MAP 0x12, NDEF 0x08/0x09) — ungated, like a stock YubiKey; strict-config refuses them. SET_DEVICE_INFO shares the same EF_DEV_CONF DeviceInfo store as the CCID WRITE CONFIG (§6); the rest are inert on this USB-only board except SCAN_MAP (it remaps typed OTP output). Each admin write advances the status frame’s program-sequence byte — that increment is how ykman/yubikit confirm it (a write that left the sequence unchanged reports CommandRejectedError: No data, which is what blocked ykman config usb over this transport before). The frame codec itself is otherwise out of scope here.

The interfaces are presented in the stock YubiKey order — keyboard/OTP, FIDO HID, CCID — because some hosts address the OTP interface by index instead of by descriptor: the libusb backend ykpers/ykcore ships (KeePassXC, ykchalresp, pam_yubico) claims interface 0 and sends the OTP frame reports there whatever the descriptors say. An interface switched off in ENABLED_USB_ITF (§7) is omitted and the rest keep that relative order, so a host that assumes a fixed index only holds where the same interface set is enabled.

The OTP frame protocol answers a HID feature GET/SET_REPORT on the keyboard interface only. The keyboard interface being first (index 0) is what those index-addressing hosts need; serving the frames on the FIDO interface too was tried but reverted (audit run-30) — it gained no host that the keyboard interface does not already serve, and on macOS it removed a real privilege boundary (IOKit gates a keyboard-usage HID nub behind Input Monitoring while the 0xF1D0 FIDO nub opens to any console-user process). The FIDO interrupt endpoints carry CTAPHID and nothing else, and its report descriptor declares no feature report.

A slot programmed to require a touch answers its challenge only after a button press, and reports the wait in the status byte (0x20) meanwhile. Two things end that wait early, both matching a YubiKey: the host’s dummy write — a report whose sequence byte is out of range, 0x8f, which also resets the read mode after a response — and any new frame, which supersedes the pending challenge. A host that does neither waits out the touch timeout (§7 PRESENCE_TIMEOUT), during which the transport reports only that it is waiting.

Once announced, 0x20 holds for the rest of the command — the press itself does not clear it, only the response frame or the idle status frame does. ykpers reads a byte carrying neither the pending nor the waiting bit as a touch timeout, so a device that dropped the wait between the press and its answer would lose challenges it had in fact completed.

1.1 CCID APDU framing

Standard ISO-7816 short APDUs. SELECT is always 00 A4 04 00 Lc <AID> 00; the selected applet then receives CLA INS P1 P2 [Lc <data>] [Le]. There is no ISO master file, so the master-file SELECT (00 A4 00 0C …, GnuPG’s 3F00 probe) answers 6D00 like a YubiKey. The power-on ATR is T=1, its historical bytes labelled YubiKey on a Yubico-identity build and RS-Key on the default build (identical card capabilities). The reference transport is tools/rsk/ccid.py:

def select(conn, aid):
    return transmit(conn, [0x00, 0xA4, 0x04, 0x00, len(aid)] + list(aid) + [0x00])

The class byte is judged before the command, for every applet and for SELECT itself. Bit 0x10 marks a command-chaining segment and is looked at first, so 10, 1C, 90 and FF are all ordinary segments. Otherwise a class carrying a secure-messaging indication (CLA & 0x0C: 04, 0C, 84, 8C, …) answers 6E00no applet here implements secure messaging, and OpenPGP’s Extended Capabilities says so. Applets that additionally name a class of their own reject anything else themselves (OATH, management, OTP and U2F want 00; rescue wants 80). A chain is reassembled into a single command of at most 2038 bytes (one CCID frame); a segment that would reach or pass that is 6700, and the partial chain is dropped rather than dispatched — so a host that retries the segment is starting a new chain, not continuing the old one.

ISO-7816 short-APDU cases. Every command opens with the four-byte header CLA INS P1 P2. Case 1 is header only; Case 2 appends a one-byte Le (expected response length, 00 meaning up to 256); Case 3 appends Lc then Lc bytes of command data; Case 4 appends Lc, data, and Le. SELECT is a Case 4 command, VERIFY a Case 3 command

A success body longer than the request’s Le (including a Case-3 command that carries no Le at all, capped at 256) is returned with ISO-7816 response chaining: the first chunk ships with status 61 XX (XX = further bytes available, 00 = 256+), and the host issues GET RESPONSE (00 C0 00 00 <Le>) until 9000. Any GET DATA reading a certificate object over 256 bytes chains this way, so a host that sends GET DATA without an Le must still follow 61xx.

OATH LIST (0xA1) and CALCULATE ALL (0xA4) responses that outgrow one frame chain the YubiKey-OATH way instead: 61 XX followed by SEND REMAINING (00 A5 00 00) rather than GET RESPONSE, matching what ykman / Yubico Authenticator send. A host that stops at the first frame still sees a valid (shorter) list.

Seven OATH rules a host has to expect, all matching a YubiKey 5.7.4. PUT (0x01) is strict about the credential body — KEY TLV 16..=66 bytes, digits 6/7/8, type 0x10/0x20, algorithm 1/2/3, name 1..=64 bytes, the initial moving factor on HOTP only and exactly 4 bytes, the PROPERTY byte as the bare 78 vv pair, the four YKOATH tags in that order, no duplicate, no unknown tag and no trailing byte. Anything else is 6A80 with nothing stored, so a rejected PUT leaves an existing credential of that name working. (RS-Key also stores the password-safe fields 0x83/0x84/0x85, ≤255 bytes each, which may sit anywhere in the body.) SET CODE (0x03) holds its key to the same measured rule: the 73 TLV is one algorithm byte plus 14..=64 bytes of key material, or empty to remove the access code — anything else is 6A80 and whatever code was installed is left exactly as it was. Its proof travels over an exactly 8-byte 74 challenge (os.urandom(8), as ykman sends it); any other width is 6A80, and removing a code needs no challenge at all. VALIDATE (0xA3) then refuses a proof that does not match with 6A80 as well; 6984 from it means something else entirely — no access code is installed to match against. And PROPERTIES bit 0, only increasing, is enforced: a TOTP credential carrying it computes only for a challenge strictly greater than the highest one it has served, comparing the raw challenge bytes zero-extended on the right — plain numeric > for the usual 8-byte counter. A challenge at or below that mark is 6A80, and in CALCULATE ALL one such credential fails the whole command with an empty body. The one exception is a credential a build before this rule stored: its body can leave no room for the mark, and CALCULATE ALL then reports it with 77 (no response) and computes the rest of the store rather than failing — its own CALCULATE still answers 6A80.

The fourth is the challenge itself. CALCULATE (0xA2) and CALCULATE ALL (0xA4) take an opaque byte string of 0..=64 bytes in the 74 TLV and HMAC all of it; 65 or more is 6A80, judged before the credential is looked up and whatever the credential’s type, so a HOTP account that ignores the challenge refuses an over-wide one too. Both read paths therefore answer the same code for the same challenge at every accepted width — including across a SEND REMAINING page — and the usual 8-byte TOTP counter is simply the common case.

The fifth is how the bodies are read. CALCULATE, VALIDATE and SET CODE are parsed by position: exactly the documented TLVs, in the documented order, with nothing before, between or after them — 71 then 74 for CALCULATE, 75 then 74 for VALIDATE (the response comes first, as ykman sends it), 73 then 74 then 75 for SET CODE, or a lone 73 00 to remove the code — that spelling and no other, so a SET CODE carrying no body at all is 6A80 and the standing code goes on opening the applet, as on a YubiKey. A reordering, a repeated tag, an unknown tag or a trailing byte is 6A80, with nothing stored. CALCULATE ALL is the one exception the card makes: its 74 must be the first TLV, and whatever follows it is ignored.

The sixth is what a truncated response carries. With P2 = 0x01 the 76 TLV is [digits][code(4)], and the four bytes are the RFC 4226 dynamic truncation already reduced to that credential’s digit count — big-endian, so a 6-digit account never exceeds 000F 423F. A host must not reduce a second time expecting a different answer, and VERIFY CODE (0xB1) compares exactly the value CALCULATE sent. The untruncated form (P2 = 0x00, tag 75) is unaffected: it carries the whole HMAC.

The seventh runs across the whole table: the parameter bytes. Every OATH command is sent P1 = 00, P2 = 00. The 01 that selects the truncated form belongs to CALCULATE and CALCULATE ALL and to nothing else — on PUT, DELETE, SET CODE, RENAME and LIST it is refused like any other stray byte. RESET alone takes DE AD, and VALIDATE is the card’s own exception: it refuses only when both bytes are non-zero. Anything else is 6B00, judged before the command’s body and before the access-code gate so nothing is written; an instruction the applet does not implement still answers 6D00 first.

1.2 CTAPHID framing

64-byte HID reports. Init frame: CID(4) | CMD(1) | BCNT_HI | BCNT_LO | data[:57]; continuation frames: CID(4) | SEQ(1) | data[:59]. CTAPHID_INIT = 0x86, CTAPHID_CBOR = 0x90, CTAPHID_KEEPALIVE = 0xBB. A CTAP2 message is command_byte | CBOR_payload. Reference: tools/rsk/ctaphid.py.

Take the channel id from the CTAPHID_INIT response and use that one: every INIT on the broadcast CID allocates a fresh id, so an id hardcoded or cached across sessions will not be yours. CTAPHID_LOCK is honoured for the 1–10 seconds it asks for, and the INIT capability byte carries CAPABILITY_LOCK (0x02) to say so. Meanwhile every other channel gets ERR_CHANNEL_BUSY — including one sending CTAPHID_INIT to resynchronise itself. The exception is an INIT on the broadcast CID, which still gets through: a client arriving mid-lock is given an id, then turned away on it.

CTAPHID framing: a 64-byte init frame (CID, CMD, BCNT-hi/lo header then 57 payload bytes) and a continuation frame (CID, SEQ header then 59 payload bytes)

1.3 CCID secure PIN entry (pinpad) — display builds only

A trusted-display build advertises bPINSupport = 0x01 (VERIFY) in its CCID class descriptor (body byte 50 / full descriptor byte 52), so a host driver treats it as a pinpad reader and sends PC_to_RDR_Secure (0x69) instead of a plaintext VERIFY; the PIN is then typed on the device’s own screen. A standard (no-screen) build leaves bPINSupport = 0x00 and rejects 0x69. No control transfer is involved. The host CCID driver reads bPINSupport straight from the descriptor; the device only has to handle 0x69. The validated trigger is GnuPG’s internal CCID driver (keys solely off bPINSupport); PC/SC + libccid and macOS CryptoTokenKit also expose pinpad from the descriptor, but their FEATURE_VERIFY_PIN_DIRECT coverage varies, so treat the GnuPG-internal path as the reliable one.

Scope (honest): this keeps the PIN off the wire only when the host uses pinpad mode. The device still accepts a normal plaintext XfrBlock VERIFY (00 20 P1 P2 Lc <PIN>), so a host that chooses to send one puts the PIN on the wire. Standard pinpad enables on-device entry, it does not enforce it (a device-enforced mode is a planned opt-in follow-up).

Whatever the host driver, the bytes on the wire follow the CCID structure below, not the PC/SC v2 Part 10 IOCTL structure (the driver drops that structure’s bTimeOut2 and ulDataLength when it builds the 0x69), so the VERIFY template is always at abData offset 15. The 0x69 payload is the CCID abPINDataStructure for VERIFY:

bPINOperation(1)=0x00 verify | bTimeOut(1) | bmFormatString(1) | bmPINBlockString(1) |
bmPINLengthFormat(1) | wPINMaxExtraDigit(2 LE) | bEntryValidationCondition(1) |
bNumberMessage(1) | wLangId(2 LE) | bMsgIndex(1) | bTeoPrologue(3) |
abPINApdu = CLA INS=0x20 P1 P2 …   (the VERIFY template, at offset 15)

The device reads the template’s P2 (OpenPGP 0x81/0x82/0x83 = PW1-sign / PW1-other / PW3-admin; PIV 0x80 = application PIN), collects the PIN on the pad, builds the real VERIFY APDU (00 20 P1 P2 Lc <ASCII PIN>; PIV pads with 0xFF to 8 bytes), runs it through the selected applet, and replies with a normal RDR_to_PC_DataBlock (0x80) carrying only the status word:

  • success90 00, bStatus = 0, bError = 0.
  • wrong PIN → the card’s real 63 Cx (tries left, reported saturated at x = F so a larger configured retry total cannot wrap into 63 C0 = blocked) / 69 83 (blocked), bStatus = 0, bError = 0 (the command succeeded; the card said wrong).
  • user cancelbStatus = 0x40 (failed), bError = 0xEFSCARD_W_CANCELLED_BY_USER.
  • pad timeoutbStatus = 0x40, bError = 0xF0SCARD_E_TIMEOUT.

The transport streams T=1 time-extensions for the whole on-screen entry, so the host transaction does not time out. The device ignores the host’s format/offset bits and builds the APDU from its own buffers, so a crafted 0x69 can’t index out of bounds. Trigger from GnuPG: gpg-connect-agent "scd checkpin OPENPGP.1" /bye (internal CCID, no host config). PIN parse + APDU assembly: crates/rsk-usb/src/secure_pin.rs.


2. Status words & error codes

2.1 CCID status words (ISO-7816 SW1 SW2)

Source: crates/rsk-sdk/src/sw.rs.

SWNameMeaning
9000OKsuccess
6400EXEC_ERRORexecution error (internal)
6581MEMORY_FAILUREflash write failed
6700WRONG_LENGTHbad Lc/Le for this command
6883LAST_CHAIN_EXPECTEDan APDU arrived that neither continues nor closes the open command chain
6982SECURITY_STATUS_NOT_SATISFIEDauth/precondition missing
6984DATA_INVALIDmalformed payload (e.g. bad guard magic)
6985CONDITIONS_NOT_SATISFIEDstate precondition unmet (e.g. RTC unset)
6A80WRONG_DATAbad data field
6A86INCORRECT_P1P2unsupported P1/P2
6A88REFERENCE_NOT_FOUNDthe object, key or PIN the request names is absent (PIV GET METADATA, MOVE KEY, the PIN commands’ key reference; OpenPGP SELECT DATA and in-application SELECT)
6B00WRONG_P1P2P1/P2 outside what this command takes — including a DO that P1P2 addresses and the command does not serve (OpenPGP GET DATA, PUT DATA)
6D00INS_NOT_SUPPORTEDunknown INS for this applet
6E00CLA_NOT_SUPPORTEDwrong CLA for this applet

2.2 CTAP2 errors

Standard CTAP2 status bytes (0x00 = success), returned by CTAP2 and by the 0x41 vendor command. Source: crates/rsk-fido/src/error.rs. The ones the vendor surface returns:

ByteNameMeaning here
0x00OKsuccess
0x02INVALID_PARAMETERmalformed param / bad key / wrong blob length
0x12INVALID_CBORthe body is not exactly one CBOR item (trailing bytes)
0x14MISSING_PARAMETERrequired field absent (e.g. blob/pinUvAuthParam)
0x27OPERATION_DENIEDtouch declined / timed out
0x30NOT_ALLOWEDprecondition unmet (no MSE channel, one already spent or owned by another CTAPHID channel, an MSE while one is live (§9.1), sealed, soft-locked, or an authenticatorReset outside the §5.1 power-up window)
0x33PIN_AUTH_INVALIDpinUvAuthParam MAC or acfg permission wrong
0x36PUAT_REQUIREDa PIN is set but no pinUvAuthToken was supplied
0x39REQUEST_TOO_LARGEsubCommandParams over the limit
0x3DINTEGRITY_FAILUREblob failed authenticated decryption
0x3EINVALID_SUBCOMMANDunknown vendorCommandId under authenticatorConfig’s 0xFF. A 0x41 subcommand this build does not implement is 0x02, matching a YubiKey

3. Device identity & discovery

3.1 USB identity

The build picks a VID/PID preset (firmware/build.rs):

PresetVID:PIDManufacturer / Product stringsNotes
RSKey (default)1209:0001RS-Key / RS-Key Security Keypid.codes identity; not a masquerade
Yubikey5 (opt-in interop)1050:0407Yubico / YubiKey RSK OTP+FIDO+CCIDso ykman/Yubico Authenticator derive PID from the PC/SC reader name
othersNitroHSM, NitroFIDO2, GnuPG, Pico, Devlocal interop only

The VID/PID, the product string and the manufacturer string can all be overridden at runtime via the phy record (§7), taking effect at the next boot: VID/PID (tag 0x00), product (tag 0x09) and manufacturer (tag 0x0F). If a runtime product looks like a YubiKey (yubikey, any case) but omits the smartcard CCID token, the firmware appends OTP+FIDO+CCID before enumerating — a token-less Yubico YubiKey reader name otherwise crashes ykman / Yubico Authenticator on Windows (_pid_from_namePID.ofKeyError('YK4_'), which aborts the whole PC/SC scan). Source: normalize_usb_product in crates/rsk-phy/src/lib.rs.

Each string is resolved in precedence order: an explicit phy tag (0x0F / 0x09) wins; otherwise the effective VID picks a default — a Yubico VID (0x1050) yields Yubico / YubiKey RSK OTP+FIDO+CCID plus the Yubico OpenPGP AID vendor, so setting only the Yubico VID/PID makes the whole identity “just work” for ykman / Yubico Authenticator; otherwise the build const. The OpenPGP AID vendor id stays keyed on the effective VID (a registered number, not a free string). ⚠️ so a phy-repointed default key can present a full Yubico identity at runtime; the USB/smartcard identity is cosmetic and host-configurable, never a security or anti-counterfeiting control (see docs/threat-model.md).

Recognizing an RS-Key by PC/SC reader name: the reader name contains RS-Key (default build) or RSK (Yubico-interop build). Neither appears in a genuine YubiKey’s reader name. Reference: RSK_READER_TOKENS in tools/rsk/ccid.py.

3.2 Firmware version & bcdDevice

FieldValueWhere
firmwareVersion5.7.40x00050704CTAP getInfo 0x0E; Management/OTP DeviceInfo TAG_VERSION; Management SELECT ("5.7.4" ASCII)
bcdDevice0x0780 (build counter, increments per firmware change)USB device descriptor (firmware/src/main.rs device_release)
AAGUID2479c7bf-6b30-5683-9ec8-0e8171a918b7CTAP getInfo 0x03; one value across every VID/PID flavor of a build, overridable at build time with AAGUID=<uuid>

The firmware version is overridable at build time (FW_VERSION=X.Y.Z); 5.7.4 mirrors a current YubiKey 5 so Yubico tooling is satisfied under the Yubico VID.


4. AID registry

SELECT an applet with 00 A4 04 00 Lc <AID> 00.

How an AID is matched (RS-Key 0x088C+): ISO 7816-4 truncated select — the AID you send must be a prefix of a registered one, and the first applet it matches wins. So a shortened AID selects (PIV answers to A0 00 00 03 08), an AID with anything appended does not (earlier builds selected on registered AID ‖ junk, which let PIV answer to A0 00 00 03 08 00 00 00 00 — the AID SP 800-85A-4 C.1.1.2 names as invalid), and an empty AID is refused rather than treated as “select the default application”. Two consequences worth planning for: OpenPGP is selected by the 6-byte AID below, not by the 16-byte value it reports in DO 4F (that one carries the device serial and is longer, so it is not a prefix — a real YubiKey refuses it too); and a prefix short enough to match several applets resolves by registration order, which is the order of the table below, so probe with the full AID unless you mean to.

Where that SELECT works. The recipe above is CCID’s (§1.1), and two of the ten rows below are not CCID applets: the FIDO2 backup id and the standalone U2F AID answer 6A82 (FILE_NOT_FOUND) — registered identifiers rather than anything this build dispatches to. The FIDO2 AID is selectable over CCID and carries CTAP2 and U2F there (§5.2); over CTAPHID the same applet has no SELECT at all (§1.2), which is why its row names both. The other transport is narrower still: CTAPHID_MSG offers exactly one applet, the vendor one, and every other AID answers 6A82 there — which is why a U2F command arriving after a vendor SELECT on the same session was a real bug (tests/15_u2f_vendor_msg_isolation.py). Measured on both transports, all ten AIDs, and recorded in the Transport column.

AppletAIDTransportSpec statusConfig-relevant?
FIDO2 / U2FA0 00 00 06 47 2F 00 01CCID (§5.2) + CTAPHID (no SELECT)Standard (CTAP2 + CTAP1)identity only
FIDO2 (backup id)B0 00 00 06 47 2F 00 01none — unregisteredRS-Key
U2F (standalone id)A0 00 00 05 27 10 02none — unregistered; U2F rides the FIDO2 AIDStandard (CTAP1/U2F)
ManagementA0 00 00 05 27 47 11 17CCIDYubico-compatibleyes — §6
OATHA0 00 00 05 27 21 01CCIDYubico OATHdata only
OTPA0 00 00 05 27 20 01CCIDYubico OTPdata only
PIVA0 00 00 03 08 00 00 10 00 01 00CCIDNIST SP 800-73data only
OpenPGPD2 76 00 01 24 01CCIDOpenPGP card 3.xdata only
RescueA0 58 3F C1 9B 7E 4F 21CCIDRS-Key-specificyes — §7
Vendor / LEDF0 00 00 00 01CCID + CTAPHID_MSGRS-Key-specificyes — §8

Sources: crates/rsk-fido/src/consts.rs, crates/rsk-mgmt (+ crates/rsk-devconf for the DeviceInfo record), crates/rsk-oath, crates/rsk-otp, crates/rsk-piv, crates/rsk-openpgp/src/consts.rs, crates/rsk-rescue, firmware/src/vendor.rs. Which AID each transport actually offers is not in any of those: it is the applet list crates/rsk-device/src/ccid.rs builds for CCID and the one-entry list in crates/rsk-device/src/ctap.rs for CTAPHID_MSG.


5. Standard interfaces (pointers, not re-specified)

These follow public specifications; a tool that already speaks YubiKey/FIDO2 needs only the identifiers above. RS-Key implements:

  • FIDO2 / CTAP 2.1 (versions advertises up to FIDO_2_3; never FIDO_2_2, which CTAP 2.3 §6.4 says was never defined): getInfo, makeCredential, getAssertion, getNextAssertion, clientPIN, reset, selection, credentialManagement, authenticatorConfig, largeBlobs (writable without a pinUvAuthParam until a PIN is set or alwaysUv is on, per §6.10.2 — a --features largeblob-ext build serves the CTAP 2.3 §12.4 largeBlob extension in its place and answers 0x0C with CTAP1_ERR_INVALID_COMMAND, because §12.4 forbids supporting both; detect it from getInfo, which drops largeBlobs, maxSerializedLargeBlobArray and the largeBlobKey extension in that build). options.perCredMgmtRO is true, so a tool may request the pcmr permission (0x40, alone) and get the persistent pinUvAuthToken: it drives getCredsMetadata / enumerateRPs / enumerateCredentials, never the two writers, and survives replugs until a PIN change or a reset — a credential list can be refreshed without re-prompting for the PIN. The enumerate walk it opens is not that durable: the cursor behind getNextRP / getNextCredential retires after 30 s idle, and after any command that is not one of those two continuations (CTAP 2.3 §6), so draw a list in one uninterrupted pass and restart from the Begin if it stalls. The same 30 s applies between the fragments of a largeBlobs set; there an abandoned transfer answers CTAP2_ERR_INVALID_SEQ and the previously stored array is left intact. maxMsgSize = 7609. transportsForReset (0x1A) is ["usb"] — identical to transports (0x09), because the FIDO applet is on USB-HID only and a reset is reachable exactly where the applet is; it is an array of AuthenticatorTransport strings, not a bit field. pinComplexityPolicy (0x1B) is true only on a build that refuses a PIN beyond the length floor — the strong-pin and fips-profile images block a repeated code point and a ±1 run; the default build answers false, and the optional pinComplexityPolicyURL (0x1C) is never emitted. longTouchForReset (0x18) is false: a reset takes the same touch as any other presence check — CTAP 2.3 cut the long-touch hold from 2.2’s 10 s to 5 s, and RS-Key implements neither gesture. encIdentifier (0x19) is present only once a persistent pinUvAuthToken has been issued, and carries iv ‖ AES-128-CBC(k, id) — 32 bytes — where id is a 128-bit device identifier and k = HKDF-SHA-256(salt = 32 zero bytes, IKM = that token, info = "encIdentifier", L = 16). The IV is regenerated on every getInfo, so the bytes differ each time while the identifier under them does not: a tool holding the token recognises the device across sessions, and one without it learns nothing. The identifier is derived from the device seed, so authenticatorReset changes it. encCredStoreState (0x1E) is that same construction under the label encCredStoreState, over a 128-bit tag that moves whenever the set of discoverable credentials does — a create, a deleteCredential, an updateUserInformation, or a delete driven from the trusted display. Reads never move it. A platform holding the token caches the plaintext and re-enumerates only when it differs; one without the token sees bytes that change every call and learns nothing. The tag is stored, not counted in RAM, so a power cycle does not reset it — and it is written ahead of the change it describes, so what a torn write leaves is a tag that over-reports (one wasted re-enumeration) rather than one that under-reports (a stale cache). authenticatorReset clears it back to zero along with the credentials it summarises. makeCredential accepts attestationFormatsPreference (request 0x0B): a list of exactly ["none"] is answered with fmt:"none" and an empty — but present — attStmt, and nothing is signed. Any other list, an empty one, or an absent field leaves the usual packed statement, because choosing by lowest supported index needs more than one supported format and attestationFormats (0x16) stays ["packed"]. An enterprise attestation that was actually performed outranks the preference and is still returned in full. Vendor-facilitated (type 1) enterprise attestation reads a stored RP list. enterpriseAttestation: 1 returns ep and the org certificate only for an RP on that list; any other RP gets the ordinary packed statement with the device’s own certificate and no ep. The list holds up to 8 sha256(rpId) entries and is empty until written, so a device upgraded from firmware without it behaves exactly as before; authenticatorReset clears it. Write it with authenticatorConfig (0x0D) subCommand vendorPrototype (0xFF) and subCommandParams {1: 0x0e6841934e719be7, 4: [rpId…]} — the ids as text, hashed on the device — gated by an acfg pinUvAuthToken and no touch, the same authorization enableEnterpriseAttestation itself takes. An empty array clears the list; more than 8 entries is refused with CTAP2_ERR_KEY_STORE_FULL, never truncated. Type 2 (platform-managed) is unaffected — it applies to any RP once enterprise attestation is enabled. Supported COSE algorithms: ES256 -7, ES384 -35, ES512 -36, ES256K -47, EdDSA -8, ML-DSA-44 -48, ML-DSA-65 -49, ML-DSA-87 -50 (all three negotiable via pubKeyCredParams; advertised in getInfo only under the advertise-pqc build, in descending security order). ML-DSA-87’s 2592-byte public key and 4627-byte signature are the widest the device mints, and the org attestation chain cap (ATT_CHAIN_MAX) is derived so the worst-case makeCredential still fits the 7609-byte maxMsgSize. The curve-explicit ids ESP256 -9, Ed25519 -19, ESP384 -51 and ESP512 -52 are negotiable and unadvertised on the same terms, and the attested key carries the id the request selected rather than the classic spelling of the same curve. (crates/rsk-fido/src/consts.rs.)
  • CTAP1 / U2F 1.1/1.2.
  • PIV: NIST SP 800-73 (Yubico PIV extensions for metadata). GET DATA for the CHUID (5FC102) returns a synthesized default (non-federal FASC-N + a device-stable GUID = sha256(serial)[..16]) when the host has not written one, so the Windows minidriver can enumerate the card; a host-written CHUID overrides it.
  • OATH: Yubico OATH (TOTP/HOTP).
  • OTP: Yubico OTP / HOTP keyboard + CCID.
  • OpenPGP card 3.x. PUT DATA C1/C2/C3 changes a slot’s algorithm attribute; when the value changes, RS-Key invalidates that slot’s existing private/public key pair before the new attribute becomes visible. An idempotent same-value write preserves the pair.

The only RS-Key-specific bytes a config tool needs are §6 (Management config), §7 (Rescue), §8 (Vendor/LED) and §9 (CTAPHID 0x41).

5.1 Where a standard command answers differently

Three places where a host that works against other authenticators sees a status byte it may not expect. All are spec-permitted strictness, not extensions, and the third matches the reference this project is measured against.

authenticatorReset has a power-up window. CTAP 2.1 §6.6 lets an authenticator with no display refuse a reset that does not follow a fresh power-up. RS-Key does: more than 10 s after the device attached, command 0x07 answers 0x30 CTAP2_ERR_NOT_ALLOWED before the touch prompt, so a host waiting on a press gets an immediate refusal instead. Four properties a host implementation has to plan for:

  • The origin is the USB attach, not power-on. Boot spends seconds before the bus pull-up goes up (TRNG seeding, seal migrations, the one-shot at-rest hardening lap), and none of it is time a host could have used.
  • A warm reset closes the window, it does not reopen one. The vendor REBOOT (§8 INS 1F P1=0), its rescue twin, and the auto-reboot after a phy CONFIG_WRITE are all host-requestable without a credential, so a window a host can restart at will would be no window at all. Only a real power cycle opens one.
  • Trusted-display builds are exempt. Their prompt names the operation on screen, which is what the window substitutes for; a reset is accepted at any time there and still needs the on-screen confirmation.
  • The touch is unchanged. Inside the window the reset still requires user presence, and a decline or timeout answers 0x27 OPERATION_DENIED.

Practically: prompt the user to replug, then send the reset. rsk offboard does exactly that — it sends the reset, and only on 0x30 prints the unplug/replug prompt and retries in the new window (tools/rsk/offboard.py), so it stays correct against a display build and against pre-0x0854 firmware, which both accept the first attempt.

PIV refuses a one-byte command body, whatever the instruction. A PIV APDU carrying Lc = 1 answers 6A80 before anything else — before the PIN or the management key, before P1/P2, and before the instruction is looked up, so even an unimplemented INS answers 6A80 rather than 6D00. No PIV command takes a one-byte body, and a YubiKey 5.7.4 does the same on every instruction. The rule is PIV-only: OATH’s LIST takes a legitimate Lc = 1, and SELECT-by-AID with a one-byte AID prefix is served by the transport before any applet sees it.

U2F AUTHENTICATE rejects a reserved P1. U2F Raw Message Formats §7.2 assigns three control bytes; RS-Key accepts exactly those and answers 6A86 (INCORRECT_P1P2) to anything else, before parsing the request body.

P1NameBehaviour
03enforce-user-presence-and-signtouch required; TUP flag set in the response
07check-onlyvalid handle → 6985, unknown handle → 6A80; never touches
08don’t-enforce-user-presence-and-signsigns with no touch, TUP flag clear; rejected with 6A86 under --features strict-up, which promises a touch on every assertion

5.2 CTAP over CCID

The FIDO applet answers on the CCID interface as well as on CTAPHID, as ISO 7816 APDUs — the encoding CTAP 2.1 §11.2.1 defines for ISO7816 readers, which python-fido2’s CtapPcscDevice (and therefore ykman over PC/SC) speaks unchanged. PC/SC does not distinguish an NFC reader from the device’s own CCID interface, so this is reachable over plain USB.

StepAPDUAnswer
Select00 A4 04 00 08 A0000006472F0001 009000 with body U2F_V2
CTAP280 10 00 00 Lc <cmd ‖ CBOR> 009000 (or 61xx, below) with <status ‖ CBOR>
U2Fany interindustry-class APDU (00 01/02/03 …)the CTAP1 answer
Cancel80 11 11 009000

Chaining runs in both directions and a host needs both. A CTAP2 command longer than 255 bytes arrives in CLA|0x10 segments; a response longer than the short Le ships its first chunk with 61xx and the rest through GET RESPONSE (00 C0 00 00 <Le>). A bare getInfo is already ~520 bytes, so a client that does not follow 61xx sees nothing useful. Extended-length APDUs work too, in one exchange each way.

No 91 00 keep-alive is ever returned. A touch wait blocks inside the exchange while the CCID transport streams T=1 time extensions, exactly as an OATH touch-flagged CALCULATE and an OpenPGP UIF signature already do, so the NFCCTAP_GETRESPONSE poll loop never runs. The cancel is still answered, because a host that gave up on a wait sends it regardless.

The transport is smaller than CTAPHID. One CCID frame carries 2038 bytes, so that is the ceiling on a command and on a response here, against the 4078 that getInfo’s maxMsgSize reports for CTAPHID. Commands stay well inside it; a response that does not fit comes back as a CTAP error rather than truncated. An ML-DSA credential’s attestation does not fit and is CTAPHID-only in practice.

Both applications are gated separately. One AID serves CTAP2 and U2F, and ykman config usb --disable fido2 / --disable u2f name them apart, so the commands are gated rather than the SELECT: disabling one leaves the AID selectable for the other and answers the disabled half 6986. With neither enabled the AID is gone (6A82).

⚠️ On the default 0x1209:0x0001 identity most hosts never bind the CCID interface at all — the ccid driver whitelists USB ids and that one is not listed — so none of this is reachable there. A VIDPID=Yubikey5 build, or a host carrying the ccid-rs-key overlay, is what makes the interface appear.

6. Management applet (Yubico-compatible) — applet enable/disable

AID A0 00 00 05 27 47 11 17. CLA 00. This is what ykman / Yubico Authenticator SELECT first to identify the key and to read/write which applications are enabled. Source: crates/rsk-mgmt/src/lib.rs for the command surface, crates/rsk-devconf/src/lib.rs for the EF_DEV_CONF record it reads and writes.

SELECT returns the firmware version as an ASCII string, e.g. 35 2E 37 2E 34 ("5.7.4").

INSNameRequestResponse
1DREAD CONFIGDeviceInfo TLV (see below)
1CWRITE CONFIGdata[0] = inner length n, then n bytes of enabled-apps TLV (n ≤ 64)— (ungated by default; presence-gated under strict-config)
1E / 1FRESET / DEVICE RESETdevice-wide factory reset (presence-gated) on the default build; 6D00 under strict-config

6.1 DeviceInfo TLV (READ CONFIG 0x1D)

Response = one leading overall-length byte, then concatenated TAG LEN VALUE:

TagNameLenValue
01USB_SUPPORTED2capability bitmask (BE16) of applications the firmware implements
02SERIAL48-digit serial (chip-id[0..4], MSB masked & 0x03)
04FORM_FACTOR101 = USB-A keychain
05VERSION3major, minor, patch (05 07 04)
03USB_ENABLED2currently-enabled capability bitmask (BE16)
08DEVICE_FLAGS180 = eject
0ACONFIG_LOCK100 = unlocked

When no host config has been written, the device returns the defaults: USB_ENABLED = all-supported, DEVICE_FLAGS = 80, CONFIG_LOCK = 00. Once WRITE CONFIG has stored a blob, READ CONFIG echoes that blob after the fixed USB_SUPPORTED/SERIAL/FORM_FACTOR/VERSION prefix, then always appends CONFIG_LOCK = 00.

A stored blob is echoed only if it still satisfies the WRITE CONFIG rules — each tag at most once, USB_ENABLED exactly two bytes. One that does not (a record an older, laxer build accepted) is not echoed verbatim; the response instead carries a synthesised USB_ENABLED equal to the mask the device actually enforces. So READ CONFIG is always parseable and never contradicts enforcement, whatever is in flash (audit run-34 #25). The config-lock tags (0A set-code, 0B unlock) are write- only on real hardware; RS-Key does not implement the lock, so it strips them on write and never stores or echoes a lock code (audit run-30) — 0A on read is always the 1-byte 00.

Capability bits (USB_SUPPORTED / USB_ENABLED):

BitApplication
0x0001OTP
0x0002U2F
0x0008OpenPGP
0x0010PIV
0x0020OATH
0x0200FIDO2

USB_SUPPORTED is fixed at 0x023B (all six). To enable/disable applications, WRITE CONFIG a TAG_USB_ENABLED(03) TLV with the desired mask, e.g. enable only FIDO2+U2F → inner blob 03 02 02 02, full APDU 00 1C 00 00 05 04 03 02 02 02.

USB_ENABLED is enforced, not merely reported: a cleared bit makes that application’s applet stop answering — PIV/OpenPGP/OATH/OTP return 6A82 on CCID SELECT, FIDO2 (CBOR) and U2F (MSG) are refused over CTAPHID, and the OTP keyboard goes inert. The change is live (next command; no replug). Its ceiling is USB_SUPPORTED, so a wider host-written mask is clamped. The re-enable path is never gated — the Management applet (§6), the FIDO vendor CONFIG_WRITE (§9) and the OTP-HID identify/config slots stay reachable — so a disable is always reversible. The mask is; the flash it lives in is not, and a replay of these ungated writes spends erase cycles nothing returns (threat-model.md §1). Identical records are dropped before the write, so only distinct ones cost anything. Building --features strict-config gates the write on operator presence; the enforcement of a persisted mask is the same on both builds.

WRITE CONFIG validates the inner blob (6A80 otherwise): it must be well-formed TLV, carry only tags a host may write (03, 06, 07, 08, 0A, 0B, 0C, 0E, 17 — ykman’s DeviceConfig set), and fit the smallest transport’s response buffer. The device-owned identity tags (01 supported, 02 serial, 04 form factor, 05 version) are emitted by the card and refused on write: READ CONFIG echoes the stored blob after them, and host parsers take the last occurrence, so a stored duplicate would override the real identity and a malformed one would make the whole DeviceInfo unparseable — permanently, since this record survives authenticatorReset. On the default build the write is ungated (full ykman parity — any USB host can rewrite the reported config, matching a stock YubiKey with no config-lock code). Building --features strict-config restores an on-device user-presence confirmation (Approve on the trusted-display build, a BOOTSEL press otherwise), so a hostile host cannot rewrite it unattended (declined/timed-out → 6985). RESET (1E/1F) is a device-wide factory reset on the default build — presence-gated even there, since an ungated one-APDU wipe would be a footgun — and 6D00 under strict-config. Either way the identity is cosmetic, never an authenticity signal (see docs/threat-model.md §1/§3).


7. Rescue applet (RS-Key configuration conduit)

AID A0 58 3F C1 9B 7E 4F 21. CLA 80 for every INS below (SELECT itself is the standard 00 A4 …). This applet carries the phy device-config record (USB identity + LED hardware), RTC, flash/secure-boot status, the device attestation key, and the one-way OTP fuses. Source: crates/rsk-rescue/src/lib.rs.

SELECT response (identity): MCU(1) | PRODUCT(1) | SDK_MAJOR(1) | SDK_MINOR(1) | serial(8) = 01 02 08 06 <8-byte chip serial>. (MCU 1 = RP2350, PRODUCT 2 = FIDO, SDK 8.6 is the applet SDK version, distinct from the 5.7.4 firmware version.) Use this as the capability/version handshake: a non-9000 here means the firmware predates the rescue applet.

INSP1P2Request dataResponsePurpose
10010032-byte SHA-256 digest64-byte secp256k1 signatureKEYDEV: sign a digest with the device attestation key
10020065-byte uncompressed pubkey (04 ‖ X ‖ Y)KEYDEV: read the device attestation pubkey
100300X.509 DER certKEYDEV: store the device end-entity cert
1C0100phy TLV blob (§7.1)WRITE phy record
1C0201YYYY(BE2) Mon Day Wday Hour Min Sec (8 B)SET RTC (civil; Wday ignored)
1C0202epoch seconds (BE4)SET RTC (Unix)
1E0100phy TLV blob (§7.1)READ phy record
1E0200free ‖ used ‖ kv_total ‖ nfiles ‖ flash_size (5×BE4 = 20 B)READ flash usage
1E0300enabled(1) ‖ locked(1) ‖ bootkey_slot(1) (FF = none)READ secure-boot status
1E0401YYYY(BE2) Mon Day Wday Hour Min Sec (8 B)READ RTC (civil); 6985 if unset
1E0402epoch seconds (BE4)READ RTC (Unix); 6985 if unset
1E0600required(1) ‖ version(1) ‖ capacity(1)READ anti-rollback state
1B5800"LOCK58"⚠️ IRREVERSIBLE — burn page-58 access lock (user-presence-gated)
1B4800"ROLLBK"⚠️ IRREVERSIBLE — set ROLLBACK_REQUIRED fuse (user-presence-gated)
1F0000REBOOT (warm; device drops off bus)
1F0100REBOOT to BOOTSEL bootloader

Every P1 this applet implements is in that table. A P1 outside it answers 6A86 (RS-Key 0x088E+; earlier builds answered 9000 to an unimplemented 1C selector without writing anything, so a newer client could not tell a too-old firmware from a completed write). Use that, and the SELECT handshake above, to detect a firmware that predates a selector you send.

⚠️ Irreversible operations — handle with explicit confirmation

1B/58 ("LOCK58") permanently locks OTP page-58; 1B/48 ("ROLLBK") permanently sets the anti-rollback-required fuse. Both are one-way fuse burns that cannot be undone and can brick a device if misapplied. The firmware triple-guards each (exact P1, exact magic payload, and a provisioning precondition), both are idempotent, and the firmware now also requires an on-device user-presence confirmation before the burn (the magic payload is a source-visible constant, not authentication). A config tool must still put these behind an explicit, clearly-worded user confirmation: never a default action, never a bulk “apply”. Most management tools should not expose them at all. 1F/01 (BOOTSEL) drops the device into the bootloader for reflashing; also confirm.

In BOOTSEL, the KV store is fenced off

Since bcdDevice 0x0871 the image carries an RP2350 partition table: the bootloader is denied read and write over the KV store, so picotool save, load and erase across that range answer permission failure. A tool that offers “back up / restore the device’s flash” must expect that and must not present the failure as a device fault. Whole-image flashing is unaffected — the firmware partition stays bootloader-writable. Rationale, and why this is not a substitute for secure boot: threat-model.md.

User-presence gate (runtime)

The runtime-reachable privileged commands require an on-device user-presence confirmation (a button touch, or an Approve on the trusted-display build) before the firmware acts, and return 6985 (CONDITIONS_NOT_SATISFIED) if the operator declines or the wait times out. This gates 10/01 (attestation sign), 10/03 (store cert), 1C/01 (WRITE phy record), the irreversible OTP fuse burns 1B/58 and 1B/48, and 1F/01 (reboot to BOOTSEL) against a hostile USB host. Read-only status (1E/*), the pubkey read (10/02), SET RTC (1C/02) and a warm reboot (1F/00) stay ungated.

The Management applet’s WRITE CONFIG (§6, INS 1C) is gated the same way only under strict-config; the default build ungates it (§6). The rescue phy WRITE (1C/01), OTP-fuse burns and BOOTSEL reboot above stay gated in both builds — they are not part of the ykman admin-write flip.

The vendor applet (§8) exposes the same reboot verb, reachable over both the CCID and CTAPHID transports; its 1F/01 (BOOTSEL) is gated identically, so the gate cannot be bypassed via the vendor AID. Its warm reboot (1F/00) is ungated. Its test-counter write (01) is gated in every build — see §8.

7.1 The phy record (EF_PHY) — PicoForge-compatible

The phy record is the device-config TLV blob. It is the same format PicoForge already writes, so an existing PicoForge config path largely works as-is. Source: crates/rsk-phy/src/lib.rs.

Wire format: a flat sequence of TAG(1) LEN(1) VALUE(LEN) records, any order, all optional. An unknown tag is skipped; a record whose length runs past the buffer ends the parse. The firmware applies the record at boot (USB identity + LED hardware).

A write (CCID WRITE 0x1C §6, or FIDO CONFIG_WRITE 0x0C target 1 §9) is a read-modify-write merge: only the tags the blob carries are updated; every untouched tag keeps its stored value. So a host may send just the fields it changed without wiping the rest, and a tag is cleared only by an explicit zero/empty TLV. (A host may still do a full read-modify-write for clarity.)

EF_PHY record: a TAG(1) LEN(1) VALUE(LEN) triple, then a worked three-record blob concatenating VIDPID (1209:0001), LED_DRIVER (ws2812) and OPTS (LED_STEADY)

TagNameLenValue
00VIDPID4VID(BE2) ‖ PID(BE2)
04LED_GPIO1data-pin GPIO 0..=29
05LED_BRIGHTNESS1global channel max 0..=255
06OPTS2flags (BE16): WCID 0x1, DIMM 0x2, DISABLE_POWER_RESET 0x4, LED_STEADY 0x8
08PRESENCE_TIMEOUT1touch-wait timeout in seconds, bounding the whole ceremony on a button build — the press wait and the release debounce that follows a confirm share it; a trusted-display ceremony may add up to 3 s absorbing a resting finger (0/absent ⇒ firmware default 30 s; a non-zero value below 10 is raised to 10). Matches PicoForge PresenceTimeout.
09USB_PRODUCT1..33product string + trailing NUL (length includes the NUL). A 33-byte value with no terminating NUL is malformed and leaves the stored string unchanged; an empty value is the explicit clear
0AENABLED_CURVES4FIDO curve bitmask (BE32)
0BENABLED_USB_ITF1interface mask: CCID 0x1, WCID 0x2, HID 0x4, KB 0x8, LWIP 0x10
0CLED_DRIVER11 = gpio, 2 = pimoroni, 3 = ws2812 (follows PicoForge LedDriverType)
0DLED_ORDER1RS-Key extension — WS2812 wire order: 0 = rgb, 1 = grb
0ELED_NUM1RS-Key extension — addressable LEDs actually connected (1..=255; 0/absent = the build’s MAX_LEDS). Firmware saturates a value above its compiled MAX_LEDS ceiling.
0FUSB_MANUFACTURER1..33RS-Key extension — iManufacturer string + trailing NUL (length includes the NUL). Absent ⇒ the VID-derived default, then the build const.

Notes for a host implementation:

  • Read-modify-write. READ the record, change only your tags, WRITE it back. This is exactly what rsk hw does (tools/rsk/hw.py). Preserving tags you don’t recognize costs you nothing, but note the device does not: merge_save overlays your blob onto the parsed record and re-serializes only the tags this firmware knows, so a tag it does not recognize is dropped at the first write whatever the host sends.
  • RS-Key-specific tags PicoForge skips as unknown: 0x0B (ENABLED_USB_ITF), 0x0E (LED_NUM) and 0x0F (USB_MANUFACTURER). RS-Key’s own tools preserve them across a RMW; LED_NUM sets how many daisy-chained addressable LEDs are lit (the binary carries a compile-time MAX_LEDS ceiling and drives the first LED_NUM of it). The rest, including 0x08 (PRESENCE_TIMEOUT) and 0x0D (LED_ORDER), is shared with PicoForge.
  • PRESENCE_TIMEOUT has a floor. The record is host-writable and the touch wait reads it live, so a one-second window could expire in the middle of a press and let the next queued request inherit that same hold. The firmware raises any non-zero value below 10 seconds. The floor is applied at boot, on the way to the wait. The stored record keeps the value as written, so a read-modify-write round-trip is lossless; CONFIG_READ’s effective map (§9) reports the floored value, because that is the window the device actually waits.
  • ENABLED_USB_ITF: absent ⇒ ALL. A mask that would disable every interface the firmware actually builds (CCID | HID | KB) is rejected and falls back to ALL. Otherwise CCID would vanish and the rescue applet that could fix it would be unreachable. Never write a mask without CCID unless you intend that.
  • A never-written record reads back as the single zero-OPTS TLV 06 02 00 00.

8. Vendor / LED applet — per-status LED color & effects

AID F0 00 00 00 01. CLA 00. Live LED customization (color/brightness/effect per device status), persisted in flash and applied immediately. Source: crates/rsk-vendor/src/lib.rs, firmware/src/led.rs. Reference client: tools/rsk/led.py.

INSP1P2RequestResponsePurpose
01counter (BE4)INCREMENT test counter, return new value. User-presence-gated (6985 if declined)
02counter (BE4)GET test counter
10brightness 0..255color | steady | status<<4[effect[, speed]] opt.SET LED for one status
11000017-byte config blockGET LED config
1F00/0100REBOOT (warm / BOOTSEL). 01 is user-presence-gated (6985 if declined; see §7)

INS 12 (CORE1_STATS) and INS 13 (KEYGEN_BENCH) exist only in the measurement builds that ask for them (--features core1-stats / keygen-bench); a shipped image answers 6D00, and neither is part of the stable surface.

SET LED 0x10 gating: ungated by default (like the rest of the config surface), user-presence-gated under strict-config — the FIDO twin (CONFIG_WRITE/CONFIG_TARGET_LED) is gated there too, so the vendor AID cannot be used to bypass it.

SET LED 0x10 is idempotent. A request whose resulting 17-byte block already matches the stored EF_LED_CONF answers 9000 and writes no flash, exactly as the FIDO twin does. The LED is still applied live, so the two are indistinguishable from the host; what changes is the flash. Before this, a replayed SET LED appended 28.1 bytes of the main (credential) partition every time — measured over the device’s own store on the board’s 352-page ring — rising to 117.0 / 203.8 B on a 74.8 % / 85.2 % live ring, where reclaim had to migrate credential records past it. A record written by an older firmware (a 13/9/3/2-byte layout) is not a match and is upgraded on the next write. This bounds the replay only: the command stays ungated by default, so a host that varies the block on every call still churns those pages, as do the other ungated config writes beside it.

INCREMENT 0x01 gating: user-presence-gated in every build. The applet answers on both CCID and CTAPHID, so ungated this was a flash-write primitive for anything that can open either interface — measured at ~390 writes/s, 16 bytes of the counter partition each. A test hook with no product function should not offer one; the config-surface writes next to it (SET LED, and the FIDO CONFIG_WRITE twin) stay ungated by default on purpose, which is a separate decision (§9, strict-config). GET 0x02 reads and stays ungated. A no-touch build confirms without a button, which is what tests/01_flash_persistence.py and tests/30_ccid_transport.py run against; on a button build the CTAPHID caller sees KEEPALIVE(UPNEEDED) until the touch lands.

Touch-status normalization. The awaiting-touch indicator is the only consent signal on a build without the trusted display, so the EF_LED_CONF codec — not any one command handler — normalizes it on every decode: the vendor SET LED, the FIDO CONFIG_WRITE LED target (§9), and the boot reload of the stored record alike. Four rules, in that order:

  • color 0 (off) on the touch status becomes the default touch colour (yellow).
  • its brightness is raised to 8.
  • a non-zero speed is raised to 2 (speed 1 makes the breathing effect render an all-black frame every tick while the brightness byte still reads compliant).
  • the touch colour is the touch status’s alone. Any other status configured in that colour is reset to its own factory look — whatever its effect, brightness or speed. Uniqueness deliberately keys on colour rather than on the whole (effect, color, brightness, speed) quad: brightness and speed are continuous bytes, so a one-unit nudge is byte-unequal and eye-identical; steady mode renders a solid frame for every effect; and on a one-LED board bounce and flow both collapse to the same solid frame, so the effect byte carries no signal there.

The give-way case. If the status wearing the touch colour has that colour as its factory colour, resetting it would not resolve the clash, so the touch status reverts to its factory look (bounce / yellow) instead. Only boot (red) and idle/processing (green) can trigger this. A red touch status therefore sticks only while boot is not red, and a green one only while neither idle nor processing is green. Yellow is nobody else’s factory colour, which is what makes the fallback converge: enforcing twice is enforcing once.

What this does not guarantee. Two statuses in different colours can still be hard to tell apart. The gpio backend has no hue at all — the indicator is lit or unlit; red, green and yellow are mutually confusable under red-green colour blindness; and on a one-LED board the effect adds nothing. What separates the states there is the per-status blink timing (touch 1000/100 ms vs idle 500/500 ms), which is compile-time fixed and cannot be reconfigured — but the global steady toggle suppresses blinking altogether, and on a single-colour backend that leaves nothing at all to tell the states apart. A build with the trusted display remains the strong answer for consent signalling.

So the bytes you write are not always the bytes that render. SET LED 0x10 normalizes before it persists, and GET LED 0x11 always returns what is actually showing. The FIDO CONFIG_WRITE LED target (§9) stores the block you sent verbatim and CONFIG_READ echoes that, so after a CONFIG_WRITE read back with GET LED to see the rendered values.

SET LED 0x10 P2 layout: bits [2:0] = color, bit 3 (0x08) = steady (solid, no blink, a global toggle), bits [5:4] = status. P1 = per-channel brightness. The command data field is optional: data[0] sets the status’s effect, data[1] its speed (0 = the effect’s built-in default). They are independent: send no data to leave both unchanged, one byte to set only the effect (the current speed is kept), two bytes to set both.

GET LED 0x11 response (config_block, 17 bytes): steady(1) | (effect, color, brightness, speed) × 4 for statuses idle, processing, touch, boot in that order. (block[0] = steady; status s → effect block[1+4s], color block[2+4s], brightness block[3+4s], speed block[4+4s].) Read the response length: older firmware returns a 13-byte (effect, color, brightness) or 9-byte (color, brightness) block. The stride is (len − 1) / 4.

ColorCodeStatusCode
off0idle0
red1processing1
green2touch2
blue3boot3
yellow4
magenta5
cyan6
white7

Effects (the effect byte, ws2812 backend only; gpio/pimoroni always use the classic on/off blink). legacy reproduces the original blink; the rest animate across the connected LEDs and reduce gracefully on a single LED:

EffectCodeEffectCode
legacy (classic blink)0flow3
vapor (breathing)1sparkle4
bounce2

Example. Set the idle status to solid blue at brightness 0x20: P2 = color 3 | steady 0x08 | status 0<<4 = 0x0B, APDU 00 10 20 0B. Example. Set touch to yellow bounce at brightness 0x10, speed 0x0F: P2 = color 4 | status 2<<4 = 0x24, data = effect 2 ‖ speed 0x0F, APDU 00 10 10 24 02 02 0F.


9. CTAPHID authenticatorVendor (0x41) — seed backup, attestation, audit

A CTAP2 vendor command (command byte 0x41) carrying a CBOR map. This is the most security-sensitive surface: it can export the device master seed. Source: crates/rsk-fido/src/vendor.rs, constants in crates/rsk-fido/src/consts.rs.

Request map: {1: subcommand(uint), 2: subCommandParams(map), 3: pinUvAuthProtocol(uint), 4: pinUvAuthParam(bstr)}. Keys 3/4 are present only when a PIN is set (see gating).

SubNameParams (key 2)ResponseGate
01MSE{1: COSE_Key, 2: mlkem_ek?}{1: COSE_Key, 2: ct?}none (establishes channel)
02BACKUP_EXPORT{1: blob(60)}MSE + touch + PIN-token; refused if sealed
03BACKUP_LOAD{1: blob(60)}MSE + touch + PIN-token; refused if soft-locked. With no PIN set it additionally takes a distinct “Replace device seed?” confirmation — the PIN-token half is waived in that state, and a LOAD re-keys every existing credential
04BACKUP_FINALIZEtouch + PIN-token when a PIN is set (no MSE)
05BACKUP_STATE{1: sealed, 2: has_seed, 3: locked, 4: unlocked}ungated
06UNLOCK{1: blob(60)}MSE (the lock key is the auth)
07AUDIT_READjournal windowPIN-token; touch if no PIN
08AUDIT_CHECKPOINT{1: nonce ≤32}DEVK signature over chain head ‖ noncePIN-token + touch
09ATT_IMPORT{1: blob(60), 2: DER chain}MSE + touch + PIN-token. With no PIN set it additionally takes a distinct “Replace this identity?” confirmation — the PIN-token half is waived in that state, and an import replaces the identity every later U2F REGISTER signs with
0AATT_CLEARMSE + touch + PIN-token
0BATT_STATE{1: present, 2: sha256(chain)?}ungated
0CCONFIG_WRITE{1: target(uint), 2: blob(bstr)} — target 0=DEV_CONF, 1=PHY, 2=LEDungated by default; touch + PIN-token under strict-config; no MSE. A write that changes nothing is a no-op: no flash write, no journal entry, and for PHY no reboot latch
0DCONFIG_READ{1: target(uint)} — target 1=PHY, 2=LED{1: blob(bstr)[, 2: {phy_tag: uint}]}ungated
0EAUDIT_CONFIG{1: op(uint)}0=disable, 1=enable, 2=status{1: enabled(bool)}set: PIN-token + touch; status (2): ungated

Device configuration over FIDO (CONFIG_WRITE 0x0C)

The pcscd-free twin of the CCID device-config writes (§6 WRITE CONFIG and the §7/§8 phy/LED records): a host that cannot reach the CCID interface writes the same config over CTAPHID. target selects the record: 0x00 = the management enabled-apps TLV (EF_DEV_CONF, the §6 blob, ≤ 64 bytes → the same CTAP1_ERR_INVALID_LENGTH 0x03 cap); 0x01 = the phy record (EF_PHY, §7.1: VID/PID, USB interfaces, LED wiring, presence-timeout; a read-modify-write merge — only the TLV tags in the blob are updated, the rest preserved (the same merge_save the CCID path uses), effective on the next boot); 0x02 = the LED config block (EF_LED_CONF, §8, CONF_LEN bytes), persisted and then applied live by the firmware, which reloads the block after a 0x41 command (the LED atomics are firmware-side; CONFIG_READ 0x02 returns the current block, seeded with the build defaults on first boot, so a host can read-modify-write it — verbatim, so it can differ from what renders once §8’s touch normalization applies). No MSE channel. The config is not secret. On the default build this write is ungated (full ykman parity — any USB host process can rewrite it). Building --features strict-config gates it on a physical touch and, when a PIN is set, a pinUvAuthToken with the acfg permission (the MAC below): a stronger gate than the CCID path’s presence-only, because CTAPHID is reachable by any unprivileged host process. The write lands in the same EF_DEV_CONF, so a later CCID READ CONFIG echoes it.

Replays and the audit journal. A write whose result equals what is already stored returns 0x00 and does nothing at all — no flash write, no journal entry, and for PHY no auto-reboot latch. The comparison is against the merged record for PHY, so a partial blob that changes nothing is also a no-op; an absent or unreadable EF_PHY is never “unchanged”, so a host writing the default values to repair one is not answered 0x00 with nothing stored.

A write that does change something is journalled, and a run of them costs a single ring entry. CONFIG_WRITE is one of three journalled events an ungated host can drive on demand — the others are a getAssertion carrying up:false and a U2F AUTHENTICATE with P1=0x08, both of which take the spec-mandated silent path — so appending one per call would let any of them evict the 128-entry window. All three coalesce. For CONFIG_WRITE, when the newest entry is already a config write the device folds into it. The entry keeps its seq, its timestamp (the first write of the run) and its aux (the target that opened it), and its detail becomes repeats(2 LE) ‖ targets(1) — the number of further writes absorbed (saturating) and a 1 << target mask of every record the run touched. A run never folds across a power cycle, so the BOOT entry between two runs is never swallowed.

The two silent FIDO events use a different encoding: the entry keeps the detail of the first occurrence (the rpIdHash shown is the run’s first site) and the count of further folded occurrences is a saturating LE u16 in the entry’s trailing two bytes — previously reserved and always zero, so an older build’s entries decode as a single occurrence. That fold scans the whole window, not just the newest entry, so interleaving two silent classes does not defeat it. A gestured assertion never folds. For a host re-checking the chain: a fold changes the head without advancing seq_next, so the same seq_next with a different head is legitimate and is not a tamper signal (start, seq_next and the epoch are untouched, so the window still folds to the head exactly as before).

CONFIG_READ 0x0D PHY key 2 (RS-Key 0x0852+): the PHY read also returns an optional 2: map of the boot-effective values a host can’t otherwise know — keyed by phy tag: 4 = LED GPIO pin, 12 = LED driver, 8 = presence timeout (seconds) — resolved to the build default when the record has no override. Tag 8 reports the window the wait actually uses, i.e. with the 10 s floor of §7.1 already applied. It is display-only (the 1: blob stays the raw override record for read-modify-write); absent (empty map) on a headless led_kind="none" build, and older hosts ignore it.

⚠️ Seed export hands out a normally non-exportable key

BACKUP_EXPORT (0x02) returns the device’s 32-byte master seed (encrypted over the MSE channel). It is gated by a one-time setup window (re-opened only by an authenticatorReset) and physical touch and, when a PIN is set, a pinUvAuthToken. A management tool exposing this must treat it as a destructive-trust operation: clear warning, explicit confirm, and ideally a “show the mnemonic once” flow rather than storing the blob. BACKUP_FINALIZE seals the window. The fips-profile build refuses export entirely.

9.1 The MSE channel (0x41 / 0x01)

Establishes an encrypted channel for the seed-moving subcommands.

  • Request subCommandParams = {1: COSE_Key} where the COSE key is the host’s P-256 public key {1:2, 3:-25, -1:1, -2:X, -3:Y}. X and Y are each exactly 32 bytes — a coordinate whose leading zero your bignum dropped is 0x02 INVALID_PARAMETER, not left-padded (RS-Key 0x089C+; the same rule the clientPIN and hmac-secret COSE parses apply, and what a YubiKey 5.7.4 does). Optional key 2 = the host’s ML-KEM-768 encapsulation key (1184 B) to make the channel hybrid PQC.
  • Response {1: COSE_Key} = the device’s ephemeral P-256 public key (same COSE shape, -2:dx, -3:dy). If the request included an ML-KEM ek, the response adds key 2 = the 1088-byte ML-KEM ciphertext.
  • Channel key (32 bytes):
    • classical: HKDF-SHA256(salt="", ikm = ECDH_x(32), info = dev_pub(65))
    • hybrid: HKDF-SHA256(salt="RSK-MSE-PQ-v1", ikm = ECDH_x(32) ‖ ss_mlkem(32), info = dev_pub(65) ‖ ct(1088))
    • dev_pub is the 65-byte uncompressed device key 04 ‖ dx ‖ dy.

Blob format (the 60-byte blob in EXPORT/LOAD/UNLOCK/ATT_IMPORT): nonce(12) ‖ ciphertext(32) ‖ tag(16), ChaCha20-Poly1305 under the channel key with AAD = dev_pub (65 bytes).

Channel lifetime — the channel is ONE-SHOT: handshake, then immediately run the one subcommand it protects. (RS-Key 0x0866+.) The device holds one channel key per power cycle. Every gated consumer (BACKUP_EXPORT, BACKUP_LOAD, UNLOCK, ATT_IMPORT, ATT_CLEAR, authenticatorConfig AUT_ENABLE) spends it, whatever the outcome — a declined touch or a failed decrypt spends it too — and a second MSE while one is live answers 0x30 NOT_ALLOWED and drops the channel, so both parties must re-handshake.

This is the boundary, not the CID check below it. A CTAPHID channel id is a routing label the sender writes into its own frame header (CTAP 2.1 §11.2.5), so an interloper forges the victim’s CID rather than using its own, and comparing mse_cid to the request’s channel compares the attacker’s bytes against themselves. Refusing the re-key is what stops a co-resident process re-keying between your MSE and your BACKUP_EXPORT and receiving the master seed instead of you. The cost is that such a process can deny you a handshake; it can never redirect one.

For clients: run MSE immediately before each subcommand, on the same CTAPHID channel (a subcommand arriving on a different CID still answers 0x30), and never cache a channel across two operations. If a handshake answers 0x30, a previous run died between its MSE and its subcommand — the refusal has now cleared it, so retry once; a second 0x30 means another process is squatting. Unchanged on the wire; behaviour since bcdDevice 0x0862.

9.2 PIN gating

When a PIN is configured, seed-moving and audit subcommands require pinUvAuthProtocol (key 3) and pinUvAuthParam (key 4). The param is HMAC-SHA256(pinUvAuthToken, 0xFF×32 ‖ 0x41 ‖ subcommand ‖ rawSubCommandParams) and the token must carry the acfg permission (0x20). rawSubCommandParams is the verbatim CBOR bytes of the key-2 map. Reference flow: tools/rsk/backup.py.

An omitted pinUvAuthProtocol is 0x14 MISSING_PARAMETER; one this build does not support is 0x02 INVALID_PARAMETER, and a value of 0 counts as unsupported rather than omitted (it used to answer 0x14, as did 3 and every other unknown value). Since bcdDevice 0x0894.

An unsupported one is judged before the algorithm, the extensions and the remaining optionsmakeCredential and getAssertion included, where it used to be judged after all of them, so a request that got two things wrong was told about the wrong one (bcdDevice 0x08B6). Two things still outrank it, both because a YubiKey 5.7.4 puts them there:

  • the request map’s own shape — keys 1..=4 of makeCredential and 1..=2 of getAssertion are read in order, and an absent, empty or over-long one is answered before any value is validated;
  • the option values of §6.1.2/§6.2.2 step 4: up:false on makeCredential, and uv:true with no pinUvAuthParam on a build without built-in user verification, are 0x2C INVALID_OPTION whatever pinUvAuthProtocol says. options.rk is not in that class and loses to the protocol, on that card and here.

10. Worked examples

All bytes hex; shows the response (status word omitted when 9000).

Identify the device (Management DeviceInfo):

SELECT  00 A4 04 00 08 A0 00 00 05 27 47 11 17 00
READ    00 1D 00 00 00
→  <len> 01 02 023B 02 04 <serial> 04 01 01 05 03 050704 03 02 023B 08 01 80 0A 01 00

Read the phy record (Rescue):

SELECT  00 A4 04 00 08 A0 58 3F C1 9B 7E 4F 21 00
→  01 02 08 06 <8-byte serial>            # identity handshake
READ    80 1E 01 00 00
→  <phy TLV blob>                          # e.g. 06 02 00 00 on a virgin device

Switch USB identity to 1209:0001 (phy RMW): read the blob, upsert tag 00 with 12 09 00 01, write back:

WRITE   80 1C 01 00 <Lc> <…00 04 12 09 00 01…> 00
REBOOT  80 1F 00 00 00

Set processing-status LED to red, brightness 64 (Vendor/LED):

SELECT  00 A4 04 00 05 F0 00 00 00 01 00
SET     00 10 40 11        # P1=0x40 brightness, P2 = color 1 | status 1<<4 = 0x11

Read backup state (CTAPHID 0x41): CTAP2 message 41 A1 01 05 (0x41 + CBOR {1: 5}) → on a fresh provisioned device 00 A4 01 F4 02 F5 03 F4 04 F4 (status 00, then CBOR {1:sealed=false, 2:has_seed=true, 3:locked=false, 4:unlocked=false}; F5=true, F4=false).


11. Integration notes for PicoForge

  1. The phy record (§7.1) is your existing PicoForge config path. Same TLV layout, same LedDriverType numbering. The differences to handle: the Rescue AID is A0 58 3F C1 9B 7E 4F 21 (not the upstream one), the Rescue CLA is 0x80, and tag 0x0D (LED_ORDER) is an RS-Key extension you can skip on read. You need not re-send unmodelled/untouched tags: the phy write is a merge (§7.1), so omitting a tag no longer wipes it — send only the fields you changed.
  2. Hardware config over FIDO (no PC/SC) is supported: PicoForge’s legacy hardware-config path. Send authenticatorConfig (CTAP 0x0D) with subCommand vendorPrototype (0xFF) and subCommandParams {1: vendorCommandId(u64), 3: value(uint)}, gated by an acfg pinUvAuthToken (no touch). getInfo’s authenticatorConfigCommands (0x1F) lists 0xFF and vendorPrototypeConfigCommands (0x15) enumerates the IDs below, so the arm and its commands are both detectable without probing — §6.11.3 ties the two, so a build that hides one hides both. That array is the whole vendor arm, not only its hardware half — 0x0e6841934e719be7 is the enterprise-attestation RP list (§5), which takes an rpId array at key 4 and writes no hardware; treat an unrecognised id as one you do not drive. The phy IDs, the ones PicoForge writes, set the phy record and take effect on the next boot: PhysicalVidPid 0x6fcb19b0cbe3acfa (value (vid<<16)|pid), PhysicalLedGpio 0x7b392a394de9f948, PhysicalLedBrightness 0x76a85945985d02fd, PhysicalOptions 0x269f3b09eceb805f (bitmask 0x2 dimmable / 0x4 disable-power-reset / 0x8 led-steady — all three are honoured: dimmable gates the global boot-brightness override, led-steady forces a solid LED, and disable-power-reset — clear by default — lets a FIDO phy write auto-reboot so the change applies without a replug). Product name, touch-timeout, LED driver and curves stay Rescue-only. RS-Key reports firmware 5.x (< 7), so PicoForge enables its legacy hardware-config path. (RS-Key’s own rsk uses the CTAPHID 0x41 CONFIG_WRITE/READ path instead, see §9, which also covers those extras.)
  3. Applet enable/disable is the Yubico-compatible Management applet (§6), identical to how you’d configure a YubiKey’s USB applications — and enforced: a disabled application’s applet stops answering (see §6, USB_ENABLED).
  4. Version-gate on the Rescue SELECT identity (01 02 08 06 …, §7) and the Management SELECT version string. Treat unknown phy tags / 0x41 subcommands as skippable, not errors.
  5. Keep the dangerous surface behind explicit confirmation: the OTP fuse burns (§7), BOOTSEL reboot (§7/§8), and seed export (§9). Consider not exposing the fuse burns at all in a general management UI.
  6. Reference client: tools/rsk is a complete, runnable implementation of everything here: ccid.py (transport), ctaphid.py (CTAP), hw.py (phy), led.py (LED), backup.py (0x41), status.py/inventory.py (DeviceInfo). When in doubt, match its bytes.

Testing

Several layers, fastest first. The protocol and applet crates are hardware-agnostic on purpose (only firmware touches the HAL), so everything except board bring-up is tested and fuzzed on the host. The device is reserved for end-to-end integration.

LayerWhat it checksWhere
Host unit testsparsers, state machines, applets, crypto, the display flow (~1500 tests)#[cfg(test)] in each crate
Fuzzingthe same logic under adversarial bytesfuzz/
Mirithe fuzz targets’ logic under the UB checkerfuzz/tests/miri.rs
Kani proofsbounded model checking — every input, not a sample#[cfg(kani)] in the crates
no_std buildthe crates still link for the devicedefault thumbv8m target
On-device testsreal USB + flash on the boardtests/*.py
flowchart TD
    u["Host unit tests"] --> f["Fuzzing"] --> m["Miri"] --> k["Kani proofs"] --> n["no_std build"] --> d["On-device tests"]

Top to bottom: fast and host-only, tapering to slow and needs-a-board.

The one command

nix develop -c ./scripts/check.sh

runs fmt, clippy (embedded and host targets, -D warnings), rustdoc over every workspace (also -D warnings, so a broken intra-doc link fails the gate, private items included — but only links in /// and //!: a name in a plain // comment is not parsed, and rots unseen), all host tests, both firmware builds (touch + no-touch), the rsk-wipe build, a firmware flash-size ratchet (the shipping image must stay under a ceiling that hugs its current size, well below the 2560K code region), cargo-audit, cargo-deny, cargo-vet and gitleaks. Green check.sh is the bar for every commit.

Two of those rows hold the crate tiers of architecture.md rather than a dependency’s licence or CVEs. cargo-deny’s [bans] stanza is the enforcing one: an applet that names another applet, or any crate but rsk-crypto that names one of the four hash/signature backends, is a banned edge and the row exits 2. The -D unused-wrapper flag fails it the other way too, when an allowlisted edge is gone and its entry has quietly become decoration. The crate graph row regenerates docs/images/crate-graph.svg from the manifests and fails when the committed drawing has drifted from them; its mutation table is scripts/test_crate_graph.py.

Host tests

cargo test must target the host explicitly (the workspace defaults to thumbv8m):

nix develop -c cargo test --workspace --exclude firmware --exclude rsk-wipe \
    --target aarch64-apple-darwin

(The two excludes are the whole of the exclusion: they are the only workspace members not under crates/, and both are thumbv8m-only. HOST_TARGET env overrides the triple in check.sh, which selects the same way — this used to be a hand-written 24-crate -p list written out nine times over four files, and it had rotted to 16 crates here, 20 on the nightly coverage row and 12 in nix flake check. scripts/roster_gate.py now holds every copy of the selection to that pair, and finds the copies rather than being told where they are.) Crypto tests pin NIST/RFC vectors; applet tests drive full protocol flows (register → assert, PIN lockout ladders, OpenPGP import → sign → verify, PIV generate → attest → parse with x509-parser).

RSA has no second implementation in the tree to check itself against — the rsa crate that used to serve as one left with RUSTSEC-2023-0071 — so its ground truth is frozen instead: crates/rsk-rsa/src/vectors.rs holds OpenSSL signatures and ciphertexts under three fixed keys, and every signature the card produces is compared to them byte for byte. scripts/rsa_vectors.py regenerates that file from python-cryptography; run it inside nix develop.

Fixed vectors cannot say which imported (p, q, e) a key assembly refuses, so that half is settled by a differential against rsa 0.9.10 in a throwaway crate outside the workspace — rsk-rsa by path with test-util, plus rsa = "=0.9.10" — because the crate must not come back into any lockfile the SCA rows read. Rebuild it whenever the key assembly moves. Two things it teaches about itself: the comparison has to be reachable (a first attempt gated it behind ra.is_ok() || ra.is_err(), which is always true, and reported zero mismatches over zero comparisons — falsify each arm by perturbing one side), and upstream cannot be asked about an unbalanced key at all, because its CRT recombination is while m.is_negative() { m += p } and for q ≫ p that does not return.

rsk-display is the odd one: its subject is a screen, and it is tested by giving the flow a panel that records what was drawn, a touch pad that reads back a scripted sequence of samples, and a board whose backlight, wake button and presence flags are plain fields. The panel and the touch controller are type parameters and the rest sits behind Hooks, so the gestures that carry the security — the hold that approves a ceremony, the retry ladder behind the PIN pad, the auto-lock a host must not be able to postpone — run on the host at the same code the board runs. embassy-time’s std feature supplies the clock, so the deadlines and debounces are the real ones (see crates/rsk-display/src/tests.rs).

Fuzzing

Every parser and every applet’s full dispatch has a cargo-fuzz target. 30+ of them: APDU, BER-TLV, CTAPHID reassembly (+ round-trip property), CCID framing, all the FIDO command surfaces (CBOR dispatch, credentials, credMgmt, U2F, extensions, large blobs, the vendor backup/lock commands, half that corpus runs soft-locked), OpenPGP dispatch + the EC/RSA crypto parsers, OATH/OTP/PIV/management/rescue dispatch, the keyboard frame codec, the phy TLV codec (parse∘serialize round-trip is an asserted invariant), the PIN protocols, AEADs, the DRBG, ML-DSA (all three parameter sets: attacker-shaped verify decode, plus a keygen→sign→verify property that a one-bit tamper must break) / ML-KEM decoding, the FIDO post-quantum credential path (the (alg, curve) box codec + CredKey dispatch → sign / COSE-AKP encode), the trusted-display Label sanitizer (attacker rpId / account text must stay printable ASCII, no bidi / homoglyph escape, and the confirm screen must render without panic), and the seed-blob format/migration state machine.

Most targets drive one applet from a fresh state. Four are stateful. They replay an attacker-chosen sequence against persistent state, hunting the multi-step seams a fresh-state target can’t reach (both real bugs of this class, the largeBlobs overflow and the mgmt write→read mismatch, were multi-step):

  • cross_applet wires the real Dispatcher to the OpenPGP / Management / OATH / OTP / PIV set over a single shared Fs: SELECT switches, command chaining and the file system persist across APDUs. State leaking between applets, a SELECT mid-chain, FID collisions. (GENERATE is skipped, as on device the RSA prime search is fast-pathed off the dispatcher.)
  • fido_session replays a CTAPHID_CBOR message sequence against one FidoState + Fs with an all-permissions token armed and a resident credential provisioned. PIN/token state, the credential store, large blobs and the journal persist across commands. now_ms advances over the token-timeout edges. A mid-sequence reset wipes the store under the session’s feet. getInfo must still succeed after anything.
  • fs_ops drives put / read / delete / meta ops / reboot (into_storagescan) over one image against a HashMap shadow model: every read checks the full-length-returned / copy-clamped contract (the mgmt bug was a caller missing it), meta_add is checked against the exact META_MAX boundary, and the live key set must equal the model’s after any prefix of operations.
  • power_cut is the torture extension of fs_ops: the same op-sequence shadow model, but over the on-device storage stack itself — rsk-store, the two sequential-storage partitions with their counter-FID routing and caches — on a mock NOR flash whose power can be cut after any byte of any write or erase. It tortured a hand-written mirror of that stack until the backend moved into a crate; the mirror had drifted (no last_error, no compact, a missing counter FID), which is the argument for not having one. Once a cut fires, a dead-latch fails every further mutation (a dead device cannot keep writing), the stack is rebuilt with fresh caches over the surviving bytes, and the model checks atomicity (the torn op reads as old or new, never garbage; a torn delete never leaves the value gone but its metadata alive), durability (every committed file reads back exactly; a spurious “absent” is the on-device “seed lost” disaster), and the key set. Cuts landing inside the next mount’s own repair are survived by dying again. A dedicated input class also runs the real FIDO reset on that same store, checks ResetNeverWeakensSurvivingState after boot-time seed provisioning, then mounts a second time to cross the reboot boundary again.
nix develop .#fuzz -c cargo fuzz list
nix develop .#fuzz -c cargo fuzz run <target> -- -max_total_time=60

The fuzz workspace is separate (nightly + libfuzzer), but check.sh lints it on stable — the clippy (fuzz) row, --all-targets against the host target — so a shared type change that breaks a target fails the gate rather than the next nightly. The instrumented build still needs the nightly shell: nix develop .#fuzz -c cargo fuzz build. House rule: new attacker-facing parser or dispatch surface ⇒ new fuzz target in the same change.

Miri runs every target’s logic once more as plain tests under the UB checker, reporting undefined behavior instead of panics (fuzz/tests/miri.rs; the MIRIFLAGS policy is set by the .#fuzz shell):

nix develop .#fuzz -c cargo miri test --manifest-path fuzz/Cargo.toml

Neither suite gates a commit. CI runs both daily in the deep-checks workflow: the Miri suite, plus a timed libFuzzer pass over every target with the corpus carried between runs, crash artifacts uploaded. A separate fuzz-coverage job then measures per-target region/line coverage over that accumulated corpus (scripts/fuzz-coverage.sh, run it the same way locally), writing a summary table and uploading a per-target HTML report. A for t in $(cargo fuzz list) word list reports green when the list is empty, so both loops floor the roster first — FUZZ_TARGET_FLOOR in the workflow and the same number in the script. Lower it only in the commit that removes a target.

Coverage says which lines a corpus reached. scripts/fuzz-dimensions.py says which inputs it explored, for power_cut: how much of the storage was invalid before init, how many operations and distinct FIDs an exec drove, how many times the power went, how many erases and bytes the store spent. It replays a corpus with RSK_POWER_CUT_STATS=1 and prints one log-bucket row per axis.

nix develop .#fuzz -c ./scripts/fuzz-dimensions.py fuzz/corpus/power_cut

It gates nothing and is not in CI — there is no coverage floor anywhere in this tree, and a reporter that looks like a gate is worse than none.

Kani proofs

Where a fuzzer samples inputs, Kani (a bounded model checker over CBMC) checks every input up to a stated bound: no panic, no overflow, no out-of-bounds access, and the asserted invariants hold. The harnesses live next to the unit tests as #[cfg(kani)] mod proofs and cover the small, total, attacker- or crypto-critical helpers, where a proof genuinely beats a sample:

  • rsk-sdk: BER-TLV walk over arbitrary bytes — every yielded value is a sub-slice of the input, and successive values neither overlap nor run backwards; format_len round-trip for every u16; APDU case-1..4 parsing over every buffer up to the bound; and the dispatcher over every pair of raw APDUs up to six bytes each — the one harness here that applies a sequence to a stateful object, because command chaining’s three audit findings each needed two commands to express. It pins that the applet is never handed a body from a command it did not itself terminate, that a dropped chain leaves no bytes behind, that a secure-messaging class reaches no applet, and that a SELECT for a registered AID always arrives. It is also the tree’s only cfg(kani) change to production source; the shrink and its reasoning are in applet_kani.rs.
  • rsk-fs: the EF_META record-walk (rebuild_meta) over arbitrary (corrupt) blobs — nothing written past the length it reports, and the old record for the rebuilt fid is gone from the output, which is what meta_delete and meta_add’s replace both mean. Stated by feeding the output back through the same function rather than by a second decoder, which would only prove two copies of one walk agree.
  • rsk-rsa: mod_small proven functionally (== v % m, every dividend up to 2 bytes and every modulus) and panic-free / < m for every input up to 8 bytes; the IncrementalSieve residue invariant (res[i] == cand mod p_i after a step, verdict identical to the flat sieve) for every seed, plus the concrete-seed twin that keeps that invariant from holding over a sieve which never steps.
  • rsk-crypto: the base64url length helpers (encoded_len / decoded_len) panic-free (no overflow/underflow) and mutually inverse for every length up to 64 KiB; encode∘decode == id for every input up to 9 bytes (every len % 3 tail, with and without preceding full chunks); decode panic-free over every byte string up to 8 chars and writing exactly the length it reports, never a byte past it.
  • rsk-phy: the EF_PHY device-configuration record: parse total over every byte string up to 12 bytes, always materializing an interface mask and always yielding a record that serializes back into PHY_MAX_SIZE (the read-modify-write the rescue interface performs); overlay never turning a stored field back into “absent”, and leaving a field whose tag the host blob never mentions exactly as it was — the merge’s own promise, and the data-loss one; serialize∘parse == id for every PhyData (every field-presence combination and value, product strings up to 4 bytes), modulo the documented missing-ENABLED_USB_ITF→ALL normalization.
  • rsk-device: the presence-scope arbitration — one physical button, four transports. Over a symbolic interleaving of button samples and host cancels, a touch wait ends Cancelled only for the transport that owns it (so a CCID or on-panel wait cannot be cancelled at all), is advertised as pending to that transport and no other, and one unbroken hold satisfies at most one ceremony. Those are NoCrossTransportTouchConsumption’s TouchCancel and TouchConfirm clauses; the arbitration was lifted out of firmware/src/presence.rs so a harness could reach it, since no cargo kani -p builds a thumbv8m binary.
  • rsk-fido: the tree’s only state-sequence proofs. The others each check one call; these drive a symbolic four- to five-operation sequence over the real FidoState and check an invariant after every step — a pinUvAuthToken dies on each invalidation and only a fresh issuance brings one back (NoTokenAfterInvalidation, asserted both on the state the call sites read and on the real verify_cm_token with a replayed genuine MAC), and a credentialManagement enumerate walk is servable only to the channel whose Begin opened it (NoAuthorizationBypass). The names are the ones formal/RSKeySecurityState.tla uses, so one property can be traced model → code → harness by grep. Phase 6 adds four one-step induction harnesses over the reset’s security-visible concrete projection: initialization and every begin/delete/advance/abort/finish/power-cut step preserve ResetNeverWeakensSurvivingState and its three independently named clauses.

Kani is not in nixpkgs and its setup downloads a prebuilt CBMC bundle, so this is the one deliberately non-nix tool (install once, outside the dev shell):

cargo install --locked kani-verifier --version 0.67.0 && cargo kani setup
./scripts/kani.sh pr       # the fast tier — what every pull request runs
./scripts/kani.sh state    # rsk-fido + rsk-fs, the security-state sequences
./scripts/kani.sh all      # every harness — the roster, and the local command
./scripts/kani.sh light1   # one of the three weekly shards of "all but heavy"
./scripts/kani.sh light2
./scripts/kani.sh light3
./scripts/kani.sh heavy    # rsk-phy alone, in its own job

scripts/kani.sh owns the tier → crate table and nothing else does, and it floors the number of harnesses each tier has to come back with — a roster that selects nothing prints a summary and exits 0, the same shape as a cargo test name filter that matches no test. scripts/kani_gate.py reads that table back with --tiers and fails the merge gate on a crate that carries a #[kani::proof] and is on no tier.

It also reads back every kani::cover!, because Kani does not fail a harness on one nothing satisfies: 0.67.0 has no --fail-uncoverable, so an unsatisfiable or unreachable cover prints “N of M cover properties satisfied” and the run still reports SUCCESSFUL. Since a cover is what says a guarded assertion was reached at all, that made every “vacuity guard” in the tree a comment. The row groups Kani’s per-check verdicts by harness and source location and fails on a cover no execution reaches — grouped, not off that summary line, because one cover! becomes several CBMC properties wherever the enclosing MIR branches on something the condition re-tests, and the copies on the contradicting arms are dead by construction. rebuild_meta_any_blob is the worked example: its !with_new && … cover is reported twice, UNSATISFIABLE on the with_new arm and SATISFIED on the other, and the summary line says “2 of 3” over a cover that is genuinely reached. Reading the summary would have failed a correct harness and sent someone to repair it.

That grouping is why scripts/kani.sh refuses --jobs. Extra arguments go through to cargo kani, and parallel harnesses would interleave Checking harness with another one’s checks, filing every verdict under whichever printed last. On the pinned 0.67.0 that cannot actually happen: --jobs there requires --output-format=terse and refuses the combination otherwise, and a terse run carries no per-check listing at all — which the row already fails on, by name. So the refusal buys a message that says which flag and why, one step before a run that would otherwise die half an hour later on a confusing one. It is also the thing that has to be revisited if a later Kani lets the two combine, because then the interleaving becomes real and grouping by harness stops being safe.

The split is by measured cost, not by guess (kani 0.67.0, 18-core Apple Silicon under load, 2026-08-13; “solve” excludes compilation, which dominates a cold run):

TierCratesHarnessesCoversSolveSlowest harness
pr136131276 srsk-piv::set_protected_total_and_invariant, 47 s
state22426~10 minrsk-fido::…_at_call_site, ~7 min (9.3 GiB peak)
all178751~1 h 46rsk-phy::serialize_parse_roundtrip, 27 m 42 s
light142723not yet runrsk-fido::…_at_call_site, ~7 min (9.3 GiB peak)
light25278not yet runrsk-rsa’s division spec and sieve
light372819not yet runrsk-mldsa’s rounding round-trips
heavy151~55 minrsk-phy::serialize_parse_roundtrip, 55 min (11.1 GB peak)

pr and state are measured runs. all has never been run end to end here: its cover count is the two measured tiers plus rsk-phy’s one, so FLOOR_all is a number no run has reached. The rsk-phy times and the 11.1 GB peak are inherited, not re-run: they were taken while that harness lived in rsk-rescue, and 189f24c moved the file byte-identical.

None of the six figures in the Harnesses and Covers columns is kept by hand, and neither are kani.sh’s FLOOR_*/COVERS_*. scripts/kani_gate.py counts the tree’s #[kani::proof] and kani::cover! per tier — comments stripped, since two *_kani.rs files discuss kani::cover! in prose — and fails the merge gate on any of the four copies that disagrees, in either direction. They had been kept by the instruction “raise it in the commit that adds one”, and FLOOR_all drifted to 64 against a tree of 65: one harness could have gone missing under a floor that still passed.

pr passes --harness-timeout 5m, five times its slowest harness. That cap is the tripwire on the tier assignment: a fast-tier harness that grows past it fails the pull request instead of quietly making every one of them wait, and the answer is to move its crate to the slow list, never to raise the cap.

A harness that trips its cap ends the whole row, and it ends it above the floor checks: cargo kani exits 1, pipefail makes that the pipeline’s, and the script stops at the tee. Both measured on kani 0.67.0, 2026-08-13. That matters for TIMEOUT_all=30m, because the harness it is really about — serialize_parse_roundtrip — verified in 27 m 42 s here, an 8% margin, on an 18-core Apple Silicon under load. The ~80 min this page carried for it is not reproduced; if it is right for a slower runner then the daily row has been failing on a correct harness, and FLOOR_all and COVERS_all have never been read. The ~1 h 45 in the Solve column above still includes the old figure and no one has re-composed it.

Pin the version — a verdict belongs to the tool that gave it, and an unpinned install is not the one CI runs. --harness-timeout is experimental (hence the -Z) and applies per harness, not per run: one that stops converging is failed after half an hour and the rest still run, so a verdict comes back at all instead of the run hanging on it.

The proofs are bounded, and the bound is the honest fine print. A 16- to 20-byte symbolic buffer reaches every branch of the TLV/APDU parsers; bigger inputs are the fuzzers’ job. Big loops (a full modexp, Baillie–PSW) are out of CBMC’s reach by design and stay covered by the differential tests and on-device KATs.

For a sequence proof the bound is the sequence, and three more walls stand behind it. Cost: one HMAC-SHA-256 evaluation over concrete bytes costs CBMC ~130 s, so a harness that drives a real MAC-checking gate can afford it once at the end, never once per step. Codegen: a harness that reaches p256’s field arithmetic aborts in codegen — Kani 0.67.0 panics on crypto-bigint 0.7.5’s UintRef::lowest_u64 (“BinaryOperation Expression does not typecheck Plus … FlexibleArray”), upstream kani#2683 — whose ConstantIndex path main fixed in #4681, in no release, and without closing the issue. It is the build profile that selects that path, not the dependency: the crash needs a MIR ConstantIndex, which every opt-level but 0 produces (swept 0/1/2/3/s/z), so [profile.dev.package.crypto-bigint] opt-level = 0 removes it — measured, and this tree deliberately does not carry that override, because the wall behind it stands anyway. Merely holding a Ctx never triggers it either: Kani codegens what a harness reaches, not what its types mention. Reach: behind the ICE sits cmov 0.5.4’s asm! backend, reached via ctutils, which Kani cannot model on either host target — it answers VERIFICATION: FAILED on an unsupported reachable construct (measured), so the path is closed loudly, never by a silent pass. Hence three of the four token gates are represented by the state predicates they read rather than invoked. Each harness names what it does not prove.

The sharpest bound is on functional division specs. Proving mod_small == v % m makes the solver equate two division circuits (mod_small’s byte-wise Horner reduction against one wide %), which is the shape resolution-based SAT handles worst: it discharges in ~100 s at a 2-byte dividend, but the cost climbs steeply per added byte and a full u32 dividend (4 bytes) does not converge (it ran ~30 min without a verdict; the early SATISFIABLE lines are Kani’s reachability covers, not the property). So mod_small’s exact value is pinned exhaustively at 2 bytes (mod_small_matches_value), its panic-freedom and range over the full 8 (mod_small_in_range), and the full-width semantics by the 32-byte BigUint differential test plus the division-free IncrementalSieve proof. The earlier instinct, “never spec a division functionally”, was half right: avoid it at wide dividends; at a narrow width it is the strongest evidence there is. House rule: a small total helper in a parsing or arithmetic hot path gets a proof harness sized to what CBMC can swallow: functional where it converges, structural (< m, panic-free) where it doesn’t, or relational against a division-free reformulation. Anything bigger gets a fuzz target.

CI runs the tiers above, from this same script (rustup-based, version pinned, ~/.kani cached — Kani is the one tool outside the nix shell). ci.yml’s proofs job runs pr on any change under crates/, and adds state when the diff reaches rsk-fido, rsk-fs, rsk-store or rsk-wipe — the surface those sequence proofs are about (scripts/ci-scope.sh, PROOFS / PROOFS_STATE, both covered by its --self-test). deep-checks.yml’s weekly kani job runs the three light* shards and heavy, one runner each, which together are all.

scripts/kani_gate.py is in the merge gate and holds the tiers to their word: the all tier must be exactly the crates carrying a #[kani::proof] less the exclusion below, every other tier a non-empty subset of it, every tier both run by a CI row and written on this page, and no workflow or page may hand-write a cargo kani … -p … roster of its own. That guard exists because the row named “prove every harness” was running 29 of 49, and because commenting the run: line out once left the file’s other copies agreeing with each other over a job that proved nothing. Its own mutation table is scripts/test_kani_gate.py.

One crate is deliberately off the tiers. rsk-bench’s summarize sorts samples[warmup..], whose length is symbolic, so CBMC unwinds it unbounded and returns no verdict — not in 5 minutes, and not with --default-unwind 5. The exclusion and its reason live in that guard, next to the roster it belongs to.

The security-state model (TLA+)

formal/RSKeySecurityState.tla models the authenticator’s security state machine — PIN retries, the pinUvAuthToken and its permissions, which transport owns the touch, which channel owns a stateful walk, the reset window, the persistent gate records, and the position at which power is lost inside a multi-write flash sequence. TLC checks six named invariants exhaustively at small constants; the names are the ones the rsk-fido Kani harnesses use, so one property reads model → code → harness by grep.

It exists because Kani proves a property over one call and RS-Key’s dangerous defects have lived in orderings. It is a design artefact, not a proof of the firmware: a green run is a statement about the model. formal/README.md is its scope statement — what it covers, where it departs from the firmware and in which direction, the mutation experiment that keeps its invariants falsifiable, and the counterexamples it has produced on the shipped tree. Read that before quoting a result from it.

nix develop            # exports TLA2TOOLS_JAR; the JVM comes with it
cd formal && ./gen-configs.sh && ./run-tlc.sh safety   # the tier CI runs

safety is the nine shipped models, their 71 mutation switches, floors and the vacuity check — deep-checks.yml’s weekly formal row, which also fires on any push touching formal/. liveness is the temporal half and is not in CI: it needs a 12g heap. all is both. Tier membership lives in formal/run-tlc.sh.

The emulator CI also records raw security-state snapshots from the real 21_pin_webauthn suite and replays them against RSKeySecurityState. R4a independently computes β from the raw fields; R4b compares the implementation’s untrusted abstract_token() hint with the canonical TLA+ γ. The gate floors the trace at 10 commands, 20 B steps and 12 distinct actions, reports model actions not reached by traffic, and keeps one β mutation plus one α-only mutation RED. See formal/README.md for the exact boundary and claim.

Phase 5 adds a narrower but connected refinement pilot for the token lifecycle. Its A relation and domains are exported by computation into Rust, TLC checks B→A, Kani checks bounded C→A obligations, and the emulator carries raw outcomes through a consensus validator. See Token refinement pilot for the exact InitC/wf boundary and the reset evidence table.

Phase 6 closes that pilot’s reset/reboot seam for ResetNeverWeakensSurvivingState. The bounded C→B projection uses the shipped reset classifier, the existing rsk-fs torn-delete rules compose underneath it, the power_cut target runs the real reset over byte-cuttable flash, and a destructive HIL script performs the same check across physical USB power loss. See Cross-reset refinement pilot for the abstraction boundary, measurements, and the still-required per-board HIL witness.

The companion co-refutation run asks whether production tests reject those same semantic defects. The original phase-2 baseline is fixed at 28 rows: 26 are killed by code-level harnesses, two are unreachable by construction, and none remains a gap. Its generated table is in formal/README.md; ordinary check.sh rejects drift, while the full 67-entry live roster runs weekly:

python scripts/comutate.py --lint
python scripts/comutate.py run
python scripts/comutate.py run --write-readme  # full run, then refresh 28 rows

Formal claims — what is and is not verified

This is the paragraph to quote, and it is deliberately narrow. Everything in it is measured; nothing in it is an aspiration.

RS-Key is not formally verified. Two narrow, bounded layers exist. With Kani (a bounded model checker) the tree proves specific properties of parsers, codecs, file metadata and arithmetic helpers — over all inputs up to a stated bound, not over all inputs — and three proofs about short sequences of security-state transitions on the real FidoState: a pinUvAuthToken retired by stopUsingPinUvAuthToken, a reroll, an authenticatorReset, a power cycle or its own usage timer never authorizes again, and a credentialManagement enumerate walk is servable only to the channel whose Begin opened it. Those hold for every four- or five-operation sequence from one starting state; longer sequences, other starting states and the flash-backed persistent grant are outside them. Four more harnesses prove initialization and one-step preservation of a finite reset projection across reset phases, abort and reboot; the complete FidoState and byte-level flash are linked by unit tests and sampled power-cut fuzz, not by that proof. On top of that sits a TLA+ model of the authenticator’s security state. TLC checks six named invariants exhaustively over 60,020,016 states at small constants. That is a result about the model, not about the firmware binary: it is only as good as the model’s fidelity to the code. Citations and co-refutation are maintained by hand; a bounded emulator trace also checks raw C-state → B and α(C) = γ(B) at recorded boundaries, but says nothing about unrecorded runs. Every invariant has been shown to be breakable by an injected defect, so none of them is a check that cannot fail — and the model has already produced two counterexamples on the shipped tree, both fixed and co-refuted since.

The hedging is load-bearing, and the tree’s own history is why. The model’s green run once rested on an abstraction that made it narrower than the firmware — a power cut left the device permanently seedless, where the real one regenerates the seed on every boot — so a class of reachable states was never explored at all. A green result over an unfaithful model proves nothing, and only a hand review found it. Hand-maintained fidelity is the weak link here, and saying so is part of the claim rather than a footnote to it.

On-device tests

Numbered, self-contained scripts under tests/, run from the dev shell against a flashed board:

nix develop -c python tests/10_fido_getinfo.py
nix develop -c python tests/80_piv.py
nix develop -c python tests/75_seed_backup.py --pin <your PIN>
  • Most need the no-touch build (--features no-touch): they cannot press the button. If the board runs secure boot, sign the test build too.
  • One key attached, or name the one you mean. A board built VIDPID=Yubikey5 answers on the same 1050:0407 as a real YubiKey, so a first match over the HID enumeration can run the suite against the wrong device and report its answers as your failures. tests/_device.py breaks the tie on the RSK marker, in the HID product string and in the PC/SC reader name alike, and stops the run instead of guessing when that is not enough. Name a target with RSK_TEST_SERIAL=rs-key-0001 (or RSK_TEST_PATH, when two boards answer to the same serial), and over CCID with RSK_TEST_READER=<part of the reader name>; every run prints the device it picked.
  • The destructive and reboot-polling suites want that marker. 80 and 90 rewrite the card, and 14, 51 and 76 ask “is the board back yet?” of a reader a real YubiKey would answer just as well (51 probes Yubico’s own management AID). Those five refuse an unmarked reader rather than accept a lone stranger, so a build whose USB_PRODUCT drops the marker has to name its reader with RSK_TEST_READER.
  • Version assertions follow FW_VERSION (default 5.7.4, build.md). An image built with an override needs the same value in the test environment: FW_VERSION=1.4.0 python tests/31_openpgp_select.py.
  • Numbering: 0x transport smoke, 1x FIDO basics, 2x FIDO full, 3x/4x/5x OpenPGP, 6x PQC, 7x management/OATH/OTP/backup/lock, 8x PIV/rescue, 9x OTP-fuse migration.
  • Tests that reboot the device do it hands-free over CCID and wait for re-enumeration; tests are idempotent where the applet allows it and say so in their docstring when they are destructive (resets).
  • A factory reset needs you at the desk. On a screenless build the firmware honours authenticatorReset only within 10 s of a USB attach, and a warm reboot does not reopen that window (protocol.md). So the eleven suites that reset (2227, 60, 61, 6365) prompt for a physical unplug/replug and send the reset the moment the key re-enumerates. The prompt lives in tests/replug.py, shared by both transports (reset for the raw-CTAPHID scripts, reset_fido2 for the python-fido2 ones); its docstring is the reference. On a trusted-display build the prompt is redundant — that build is exempt from the window.
  • tests/27_reset_window.py exercises the window itself: reset immediately after the replug (expects CTAP2_OK), then again past 10 s (expects 0x30 NOT_ALLOWED). It needs the no-touch, non-display image and it wipes FIDO state.
  • tests/28_ctap_spec_alignment.py covers the CTAP 2.1 spec-alignment surface the per-command suites do not reach: CTAPHID channel allocation and CTAPHID_LOCK, the uv/pinUvAuthParam precedence rule, makeCredUvNotRqd, the largeBlobs parameter validation, setMinPINLength overflow, the rpId-scoped credentialManagement token, and the U2F gate under alwaysUv. It neither resets nor replugs, but it does need --pin, and it toggles alwaysUv on and back off — so start it with alwaysUv off, which it checks.
  • tests/54_sram_residue.py measures what the reboot scrub is for, in two steps. control asks whether this board’s SRAM can be read back at all: it drops to BOOTSEL through the presence-gated reboot, reads a window of .text (which both proves picotool save -r works and pins the ELF to the image actually running), then checks main SRAM for the RAM-resident asm and SMALL_PRIMES table that live in .data — known byte-for-byte from the file, so the control is a priori and needs no key. residue then generates an RSA key on-card and hunts a factor of its modulus, reported per region (the main stack between _stack_end and _stack_start, core1’s stack, .bss, .data) with a zero assertion on each static the reboot claims to scrub. Neither reports “clean” from a dump that proves nothing: an all-zero read is equally consistent with a working scrub, with the platform clearing SRAM, and with picoboot refusing to serve it. The last two are separated by writing a pattern through picoboot and reading it back, so the exit code says which — 0 as expected, 1 expectation or setup failed, 2 INCONCLUSIVE, 3 settled without the scan. Run residue on a build without the scrub (--expect present) before trusting an absent result; a lone absent run is how audit run-34 #3 found a “HW-VERIFIED” claim resting on 520 KiB of zeros. Measured 2026-08-05 on RP2350 A4 (secure boot off): the platform clears main SRAM across the drop, so there is nothing to recover. Both subcommands leave the board in BOOTSEL, so reflash afterwards, and residue overwrites the OpenPGP signature key.
  • The FIDO PIN is never guessed: destructive PIN tests take --pin explicitly.

The vendored upstream suites

Two other ecosystems’ own conformance suites live in third_party/ — pico-fido’s and pico-openpgp/Gnuk’s — and tests/third_party.py runs them against RS-Key:

nix develop -c python tests/third_party.py openpgp   # over the emulator's card socket
nix develop -c python tests/third_party.py fido      # needs a board, or --usbip

No assertion in those directories is edited. The run is steered from outside by a pytest plugin that supplies the power cycle the CTAP 2.1 §6.6 reset window needs, names every deliberate divergence as a strict xfail, and deselects the modules that exercise a vendor extension RS-Key does not implement. Both lists carry a spec citation per entry, and strict means a divergence that gets fixed fails the run instead of staying listed for ever — which is how the last refresh caught one that upstream had corrected.

The one thing repaired in place is a suite’s own harness: a test that raises in its own Python before a byte reaches the device measures nothing, so listing it would record only that it is broken. Those edits are marked at the site and in third_party/README.md.

Running an upstream corpus shows conformance on the cases it covers; it is not a security audit.

Without a board — the emulator

tools/emu runs the applet crates on the host and serves CTAPHID and APDUs over TCP, so the suites above can run with no hardware attached:

nix develop -c cargo run --manifest-path tools/emu/Cargo.toml \
  --target "$HOST" -- --store ./emu.store
nix develop -c python tests/emu.py tests/11_fido_makecredential.py

tests/emu.py puts a fake hid module and a fake smartcard package in front of the target script and points the power-cycle helper at the emulator’s replug opcode, so no test file changes and neither hidapi nor pyscard need be installed. 42 of the 52 suites pass, FIDO and card alike (two want --pin, one wants --yubico; a 43rd needs an enrolled ed25519-sk key and skips without one); the other 9 are refused by name with their reason and exit 77 — they need raw USB, python-fido2, or hardware, and tools/emu/README.md lists which is which. The store underneath is the device’s own (crates/rsk-store) over a mock NOR flash with the board’s geometry, so the suites run against a log-structured ring that migrates and reclaims — not a map that overwrites in place. A harness that cannot tell “does not apply here” from “broken” hides the second one, which is the whole reason the refused suites are named rather than left to fail somewhere in the middle — and the reason the two that want --pin are refused the same way when it is not given, rather than dying in argparse where a sweep reads them as broken. --touch prompts for every presence on the terminal (and prints what a trusted display would have shown); --trace logs each command and its status.

One command runs everything that needs no board — the suites above plus the vendored OpenPGP conformance suite, each against a fresh flash image:

nix develop -c ./scripts/emu-suites.sh

That is what CI runs (.github/workflows/emulator.yml), on pull requests and nightly. It is the answer to the oldest gap in this table: tests/*.py were hand-run against a flashed key, so nothing caught a test that had rotted — and several had.

--usbip goes further: it serves the USB/IP protocol, so a Linux host’s vhci_hcd attaches the emulator as a genuine USB device — /dev/hidraw*, a PC/SC reader, something a browser can talk to. What enumerates there is the device’s own stack (the same embassy_usb::Builder, the same rsk-usb transports, over a driver written against URBs), so the descriptors and the interface order are the real ones. The suites this shim refuses for wanting raw USB — 02_usb_interfaces, 61/65 (python-fido2’s own transport), 73_otp_keyboard, 77_otp_touch_wait — run there instead, as ordinary hardware suites with nothing faked, and so does the pico-fido conformance suite. Needs Linux and root; the emulator itself can stay on a Mac, because USB/IP is network-transparent. See tools/emu/README.md.

scripts/usbip-suites.sh is that run in one command, and it is what CI calls:

nix develop -c ./scripts/usbip-suites.sh   # Linux only

A GitHub-hosted runner cannot supply vhci_hcd — it cannot load a module, and has no reliable /dev/kvm either — so the script boots a QEMU guest that can (nix build .#usbip-vm, defined in nix/usbip-vm.nix) and attaches the emulator to it over the network. The emulator itself stays outside the guest: it is a TCP peer, not a device, which keeps the guest a fixed appliance — kernel, usbip, pcscd, Python — that a firmware change cannot invalidate. There is no KVM, so everything inside runs on software emulation; budget minutes, not seconds.

What it buys is the run these suites otherwise never get: they are hand-run against a flashed board, so nothing catches a test that has rotted. What it cannot stand in for is the hardware under the applet layer — no secure boot, no OTP, no fuses — and the flash is a mock: the log structure and the --power-cut injector are real, the medium’s wear and partial-erase physics are not. The USB stack is real under --usbip and absent otherwise, so a plain run proves nothing about enumeration or interface order. The applet wiring is shared (crates/rsk-device), so a routing or gating bug does show up here; what is still written twice is the worker’s sequencing and the board’s own firmware/src/{main,worker,presence,led}.rs (tools/emu/README.md lists the gaps). A green emulator run is a protocol result, not a device result.

Latency harness

Timing a crypto primitive from the host is noisy. On the RP2350 the hot working set (the variable-base P-256 scalar multiply is ~34 KB) overflows the 16 KB XIP cache, so which cache lines evict depends on where the linker placed the code. Steady-state EC latency then swings ±~30 ms from an innocent code move, and a host-timed mean over a few USB round-trips reports that swing as a regression.

rsk bench measures on the device instead. The bench firmware feature adds a vendor command (like keygen-bench, never shipped) that times a primitive with the RP2350’s own timer, so there is no USB jitter, and returns a robust summary: a median and MAD over the warm samples plus a separate cold first sample (the ~1.4x cold-cache op right after a power-cycle). The summary is computed on-device by the Kani-proved rsk-bench crate, so the number is not re-derived host-side.

# build + flash a bench image (it is a --features bench build, so never ship it)
cargo build --release -p firmware --features bench,no-touch
# then, from the dev shell or the venv that has pyscard:
rsk bench ecdh                 # variable-base P-256 ECDH (the layout-sensitive one)
rsk bench sign                 # P-256 comb sign (the getAssertion hot path)
rsk bench ratchet              # the HKDF-SHA512 key-derivation ratchet

To A/B two builds without the cross-session trap that faked a “-33%” during the 0.14 EC migration: measure one build with --save a.json, flash the other, measure with --save b.json, then rsk bench --compare a.json b.json prints whether the median moved by more than the pooled noise. Always compare in one sitting; comparing raw numbers across sessions or builds reads cache-layout luck as a real change.

FIDO conformance

RS-Key is run against the FIDO Alliance Conformance Tools (v1.8.5.1), the same protocol test suites the FIDO certification programs are built on, and passes them clean:

SuiteResult
CTAP2.3 (profile_featureful — the strictest profile)235 / 0
U2F 1.1 / 1.255 / 0

A green run exercises the full CTAP2/U2F wire surface: makeCredential / getAssertion validation and up/uv privacy, clientPIN protocols 1 and 2 (including the force-PIN-change and PIN-policy edge cases), credential management, large blobs, authenticatorConfig (alwaysUv, setMinPINLength, enterprise attestation), CTAPHID framing + CANCEL, and U2F register / authenticate with batch attestation.

Two honest caveats:

  • This is a self-run pass, not a “FIDO Certified” mark. Those are the publicly available conformance tools (the same ones a lab uses), so a clean result is strong evidence the protocol behaviour is spec-correct, but RS-Key is not listed in the FIDO Metadata Service and claims no certification. That is a deliberate non-goal (membership + a lab + fees, not a code change). See AAGUID & metadata.
  • The full enterprise-attestation suite needs a conformance-only build. It asserts against the suite’s own test RP ID, which a build flag (ea-conformance-rpid) whitelists; the shipping build does not bake it in (build options). Everything else runs on the normal firmware.

As with any corpus, this shows conformance on the cases the tools cover. It is not a security audit.

Real-world interop

Protocol conformance is necessary but not sufficient: a response can be spec-arguable yet still trip a strict third-party parser. The layer above drives the real consumer software (gpg, ssh, libfido2, ykman, OpenSC, browsers) and records whether the device works end to end. The ykman and Yubico Authenticator cells gate on the “Yubico YubiKey” reader name, so they run against the opt-in VIDPID=Yubikey5 interop flavor (never distributed); the default RS-Key build (0x1209:0x0001) does not expose itself to them. The sweep tests/interop/run.py automates the read-only CLI cells; the full matrix (including the GUI/ceremony cells) lives in interop.md. It is how the ykman openpgp info GET DATA 6E wrapper bug was caught: every protocol test passed, only the real ykman parser rejected the reply.

For the same reason, the matrix carries an untested row for Telesma and its go-ctap/ctap client stack: every FIDO cell above reads the device through libfido2 or python-fido2, so a divergence both of them tolerate is invisible here. It is the cheapest available third reader of our CTAP replies, and the only one that claims 2.3.

CI parity

check.sh is plain bash over the Nix dev shell. A CI job is nix develop -c ./scripts/check.sh plus the proofs job — scripts/kani.sh, which cannot join check.sh because Kani is not in the dev shell — plus, on a runner with the board attached, the tests/ scripts. The scheduled deep-checks workflow runs on two cadences. Daily: the Miri and fuzz commands from this page, both sharded across runners, a repro job that builds the hermetic firmware twice and requires bit-identical outputs (build.md), and an llvm-cov job that floors host-crate line coverage. Weekly, on Sunday: the full Kani roster, one runner per tier, an advisory cargo-mutants sweep, the semantic co-refutation roster and TLC’s formal safety tier. No hidden state.

flowchart TB
    a["Merge gate — every commit / PR<br/>check.sh: fmt · clippy · host tests · firmware builds · size ratchet · audit · deny · vet · gitleaks<br/>proofs: Kani pr tier (+ state tier when the diff reaches it)"]
    b["Daily — deep-checks<br/>Miri (3 shards) · timed libFuzzer (4 shards) · repro (bit-identical build) · llvm-cov (coverage floor)"]
    c["Weekly — deep-checks<br/>Kani all roster · cargo-mutants (advisory)<br/>semantic co-refutation · TLC safety tier"]
    a ~~~ b ~~~ c

One more workflow reports on a pull request and is deliberately absent from that diagram. codeql.yml runs GitHub’s CodeQL over the Rust and Python sources — buildless (build-mode: none), since firmware/ does not build on a host runner at all. It is advisory, not a gate: check.sh is still the whole bar. It runs on pull requests and on demand only, so there is no default-branch baseline and findings surface on the PR itself.

Not over all of them: .github/codeql/codeql-config.yml keeps the test suites, the Kani siblings, fuzz/, tests/ and third_party/ out. Those are where the KATs and fixtures live, and a hard-coded-key query cannot tell a test vector from a secret — measured, they were 229 of 289 first-run alerts. The exclusion is at extraction, so a defect in a test helper is not found rather than found and filtered.

Refactor metrics (advisory)

scripts/metrics.sh is reconnaissance, not a gate. Run it to decide where to refactor. It reports the heaviest functions by cognitive/cyclomatic complexity (rust-code-analysis), firmware size by crate and function (cargo-bloat), and generic monomorphization (cargo-llvm-lines). The tools are pulled ad-hoc via nix shell nixpkgs#…, so they never join the pinned dev shell or a shipping build:

nix develop -c ./scripts/metrics.sh            # applet handlers by default
nix develop -c ./scripts/metrics.sh crates/rsk-piv/src

Read the cognitive column, not cyclomatic: a high cyclomatic with a low cognitive is a flat serializer (a long match that just encodes), not a refactor target.

The same signal has a ratcheted, automated sibling. scripts/complexity_gate.sh runs inside check.sh, on every pull request, and fails if any crate-library function crosses a cognitive-complexity ceiling (COGNITIVE_CEILING), catching a new hotspot the day it lands. Lower the ceiling as the peak falls; raise it only for a justified growth, in the same commit. firmware/ is out of scope: it is embedded glue plus the trusted-display UI state machines, whose complexity is a separate concern.

Interop — does it actually work with the real tools?

RS-Key has three test layers below this one (tests/, the vendored third_party/ suites, and the host cargo test / fuzz / Kani stack, see testing.md). All of them drive the device at the protocol level: APDUs, CBOR, CTAPHID frames. They prove the wire format is correct against our reading of the specs and against two upstream suites.

flowchart BT
    a["Host cargo test · fuzz · Kani<br/>(protocol logic)"] --> c
    b["Vendored third_party suites<br/>(python-fido2, OpenPGP card)"] --> c
    c["Interop — this page<br/>real gpg / ssh / ykman / browsers, on hardware"]

This document is the layer above: does the device work end-to-end with the software a real user actually runs (gpg, ssh, a browser’s WebAuthn stack, ykman, fido2-token, OpenSC), not with our own scripts. Protocol conformance is necessary but not sufficient: a response can be spec-arguable yet still trip a strict third-party parser. (The canonical example is the ykman openpgp info crash below: our GET DATA 6E was readable by gpg but rejected by ykman’s stricter Tlv.unpack(0x6E, …).)

This is experimental firmware with no security audit. Most cells below run on the default RS-Key build (USB VID:PID 0x1209:0x0001, reader name “RS-Key”). gpg, ssh, browsers, OpenSC, and libfido2 bind the ATR / FIDO HID usage page, not the VID/PID, so they don’t care about branding. The ykman and Yubico Authenticator cells are the exception: they derive the device from the “Yubico YubiKey” reader name, so they need the opt-in VIDPID=Yubikey5 interop flavor (0x1050:0x0407), see build.md. A ✅ means the cell was observed working on the dated build; it is a record, not a guarantee of future builds or other hosts.

The matrix is a living artifact. A cell is evidence only once it has been run on hardware and dated; everything else is ⏳ untested. The 0758 / 0759 tags in the Status column are the firmware bcdDevice the cell was run against.

Baseline, 2026-06-13, firmware 0x0758 (tests/interop/run.py, live device): libfido2 enumeration + getInfo, gpg --card-status, and ykman piv/oath/otp info all ✅; ykman openpgp info ❌ (the GET DATA 6E wrapper bug below, reproduced live as ERROR: Incorrect TLV length).

Re-verified, 2026-06-13, firmware 0x0759 (the fix): the full CLI sweep is green: 7 passed, 0 failed, ssh-sk skipped (touch). ykman openpgp info now prints the card (OpenPGP version: 3.4, app 4.6.0, PIN counters) instead of the TLV error.

Touch / GUI round, 2026-06-13, firmware 0x07590x075A (--features up-button confirmed live): ssh-keygen -t ed25519-sk enrol, fido2-cred/ fido2-assert (assertion verified), and the OTP HID keyboard (short-tap typed the static slot verbatim) all ✅ with a real button press; Chrome / Firefox / Safari WebAuthn ✅ by user attestation. gpg --edit-card generate was the lone ❌ on 0x0759, not a crypto failure (the APDU suites GENERATE P-256/Ed25519/RSA-2048 and UIF-sign fine) but a GET DATA 6E short-Le overflow in scdaemon; fixed in 0x075A (dispatcher response chaining) and re-verified end-to-end. See Known issues.

Status legend

MarkMeaning
verified end-to-end on hardware (date + firmware in Notes)
⚠️works with caveats / partial coverage
broken — known defect (link the issue/fix)
not yet run on hardware
🚫not applicable on this platform / not implemented

A note on firmware builds

The CLI suites cannot press the BOOTSEL button, so anything touch-gated either needs the no-touch test build (cargo build -p firmware --features no-touch, see build.md) or a human. The matrix splits accordingly:

  • CLI sweep: run on the no-touch build; fully automatable (tests/interop/run.py).
  • GUI / ceremony: run on the touch build with a finger on the button (browser WebAuthn, ssh-keygen -t ed25519-sk, OpenPGP UIF signing).

Matrix

FIDO2 / WebAuthn / U2F

ConsumerWhat it exercisesBuildHowStatus
fido2-token -L / -I (libfido2)enumeration + getInfono-touchtests/interop/run.py0759
fido2-cred / fido2-assert (libfido2)make credential / get assertiontouchmanual (fido2-cred -Mfido2-assert -G)0759 (touch ×2, assertion verified 2026-06-13)
python-fido2 (Yubico)full CTAP2 flowsno-touch buildpytest third_party/pico-fido-tests/pico-fido⚠️ 075A — 191 passed / 4 failed / 9 errored; all test-side, not firmware defects
Telesma (go-ctap/ctap)inspect + manage over a CTAP client stack that is neither libfido2 nor python-fido2, and claims 2.0–2.3touchmanual (desktop app)⏳ untested
Chrome WebAuthnregister + authenticatetouchwebauthn.io (manual)✅ user-attested (macOS/Linux/Win, 2026-06-13)
Firefox WebAuthnregister + authenticatetouchwebauthn.io (manual)✅ user-attested (macOS/Linux/Win, 2026-06-13)
Safari WebAuthnregister + authenticatetouchwebauthn.io (manual)✅ user-attested (2026-06-13)
ssh-keygen -t ed25519-sk + sshsk-key enrol + authtouchtests/interop/run.py --touch0759 (touch, ed25519-sk enrolled 2026-06-13)

OpenPGP card

ConsumerWhat it exercisesBuildHowStatus
gpg --card-statusapplication-related-data readeithertests/interop/run.py0759
gpg --edit-card keygen/sign/encryptfull card lifecycletouch (UIF)manual075A (EC+RSA generate land on-card after the GET DATA short-Le fix; was ❌ on 0759)
ykman openpgp infoTlv.unpack(0x6E, …) strict parseeither (needs VIDPID=Yubikey5)tests/interop/run.py0759 (was ❌ on 0758)
openpgp-card-tests (Gnuk-derived)spec suiteno-touchpytest third_party/openpgp-card-tests/…⚠️ 075A001_initial_check 31/34; 3 fails, one root, not a defect

PIV

ConsumerWhat it exercisesBuildHowStatus
ykman piv infodiscovery + slot stateno-touch (needs VIDPID=Yubikey5)tests/interop/run.py0759
OpenSC pkcs11-toolPKCS#11 module load + enumerateno-touchpkcs11-tool --module …/opensc-pkcs11.so -L -O075A (loads + enumerates; OpenSC auto-selects the OpenPGP app via PKCS#15 emulation — 2 slots, metadata + object store read clean; sign/cert untested on a fresh card)
macOS native (sc_auth, Keychain)system smartcard discoveryno-touchsc_auth identities, system_profiler SPSmartCardsDataType075A (CryptoTokenKit sees the reader + ATR, binds pivtoken.appex; no paired identity on a fresh card)

OATH / OTP

ConsumerWhat it exercisesBuildHowStatus
ykman oath accounts listOATH credential listingno-touch (needs VIDPID=Yubikey5)tests/interop/run.py0759
Yubico Authenticator (app)TOTP/HOTP GUIno-touch (needs VIDPID=Yubikey5)manual (desktop app)075A — detects the key + all 6 apps; OATH add/calc/delete work; TOTP crypto-verified (2026-06-13)
ykman otp infoOTP slot stateno-touch (needs VIDPID=Yubikey5)tests/interop/run.py0759
OTP keyboard (types the code)USB-HID keyboard emulationtouchmanual (focus a text field)0759 (short-tap typed the static slot verbatim, 2026-06-13)

Differential against a real YubiKey

The matrix above proves each RS-Key cell works. This layer asks a sharper question: run the same reads against RS-Key and a genuine YubiKey with the same fill, then diff every field. Anything that is not a documented, expected divergence is a fidelity gap. The harness lives in tests/interop/ (capture.py → snapshot, diff.py → classify against the divergences.py allow-list, parity.py → OATH crypto known-answer); see its README to run it.

Both keys stay plugged: the VIDPID=Yubikey5 build carries an RSK marker in its USB product string, FIDO HID descriptor and PC/SC reader name that a real key never has, and ykman cells target by --device <serial>. An identity guard refuses a snapshot whose FIDO AAGUID does not match its --label.

Result, 2026-07-16, macOS 27 — RS-Key VIDPID=Yubikey5 vs a real YubiKey 5C NFC, both fw 5.7.4, 162 canonical fields. The first run (bcd 0x081b) surfaced 1 unexpected field — the usbEnabled mask, a real fidelity gap (fixed on 0x081C). The re-run on the fixed build (bcd 0x081C) is clean: 85 identical, 77 expected-divergence, 0 rule-violations, 0 unexpected. Representative expected divergences:

FieldRealRS-KeyWhy it is expected
ccid.atr3bfd13…5900sameRS-Key reproduces the YubiKey ATR byte-for-byte (a MATCH, not a diff)
fido.getinfo.aaguid(Yubico’s model AAGUID)2479c7bf-…RS-Key self-assigns its AAGUID, deliberately not Yubico’s
fido.getinfo.versions…FIDO_2_1_PRE…FIDO_2_3RS-Key targets the final specs; drops the legacy _PRE (and never claims FIDO_2_2 — CTAP 2.3 §6.4 forbids that string)
fido.getinfo.algorithms / extensionsES256/EdDSA/…supersetRS-Key adds ES384/512 (+ML-DSA, credBlob, thirdPartyPayment)
fido.getinfo.maxMsgSize etc.15367609RS-Key’s buffers/capacities are larger
fido.getinfo.transportsnfc, usbusbRS-Key is USB-only, no NFC
mgmt.formFactorUSB-C (3)USB-A (1)RS-Key reports the 5A form factor; the reference is a 5C
mgmt.usbSupported0x033f0x023bthe real 5C also has YubiHSM Auth, which RS-Key does not implement
openpgp.application_version5.7.44.6.0RS-Key’s OpenPGP app is pico-openpgp 4.6.x
usb.serialNumber / bcdDevice(none) / fwrs-key-0001 / 0x081bRS-Key’s USB serial is fixed; bcdDevice is a build counter

Notable matches (not just structural, but exact): the CCID ATR, PIN retry budget (8), minPINLength (4), pinUvAuthProtocols ([2, 1]), the six USB capability bits, and every unprivileged FIDO option (rk, up, clientPin, credMgmt, pinUvAuthToken, …).

Crypto parity (OATH)parity.py provisions the RFC 4226 appendix-D known-answer credential and reads it back: RS-Key returns 755224 → 287082 → 359152, byte-identical to the RFC vector every conforming YubiKey produces, so the HMAC-SHA1 + dynamic-truncation engine matches. The same control could not run on the reference key here because its OATH store was full (64/64, the YubiKey 5.7 cap) — itself a capacity divergence, RS-Key holds more.

Suite triage

Detail for the ⚠️ / multi-result cells above.

python-fido2 (Yubico): 075A, 191 passed / 4 failed / 9 errored (8m26s). All four failures are test-side, not firmware defects:

  • test_lockout / test_pin_attempts need a manual device.reboot() (conftest.py:205 human prompt, unanswered headless) → our spec-correct PIN_AUTH_BLOCKED correctly persists.
  • test_option_up calls doGA(options=…), no such kwarg — a broken upstream test, since repaired in our fork of it (third_party/README.md); it passes.
  • test_bad_auth expects the upstream 0xE0 for an invalid (0,0) EC keyAgreement, where our INVALID_PARAMETER is spec-reasonable.
  • The 9 errors are test_070_oath fixture setup, not core CTAP2.

openpgp-card-tests (Gnuk-derived): 075A, 001_initial_check 31/34. The 3 fails (6E, 65, 7A) share one root and are not a defect: util.get_data_object strips the constructed-DO wrapper only when is_yubikey=True (never set in this Gnuk config), so our deliberately-wrapped templates (the bug-#1 ykman/real-Yubikey requirement) fail the Gnuk “unwrapped” asserts. Wrapping is mandatory for ykman; the two expectations are mutually exclusive.

Yubico Authenticator (app): 075A (built VIDPID=Yubikey5; the GUI gates on the “Yubico YubiKey” reader name). Detects the key + all 6 apps (OTP/PIV/OATH/OpenPGP/U2F/FIDO2); OATH add → calculate → delete all work in-GUI. The displayed TOTP 111429 then 629022 cryptographically matched an independent software HMAC-SHA1 TOTP of the same secret/window (2026-06-13).

Known issues

ykman openpgp info rejected our GET DATA 6E — FIXED (0x0759)

ykman/yubikit parse the application-related-data response with ApplicationRelatedData.parse, which calls Tlv.unpack(0x6E, response): it requires the whole GET DATA 6E reply to be a single TLV tagged 6E. RS-Key stripped the outer 6E 82 LL LL wrapper for every non-flash DO, returning the bare nested 4F …, so ykman failed with ERROR: Incorrect TLV length (the 4F TLV parses but leaves a trailing remainder Tlv.unpack rejects) while gpg (which tolerates either form) worked. Fixed by keeping the wrapper on constructed template DOs (6E/65/73/7A/FA, BER constructed bit 0x20) and stripping only primitive DOs, which is what real OpenPGP cards do. See crates/rsk-openpgp/src/getdata.rs. Verified on hardware 2026-06-13 (firmware 0x0759): ykman openpgp info prints the card data (OpenPGP version: 3.4, app 4.6.0, PIN counters) instead of ERROR: Incorrect TLV length.

ykman openpgp info     # prints card data, no TLV traceback

GET DATA short-Le chaining FIXED on 0x075A

Was (0x0759): gpg/scdaemon read the application-related-data template with the short APDU 00 CA 00 6E 00 (Le = 256). Once keys are present the 6E template is 269 bytes (> 256); the firmware returned the whole 269-byte body instead of truncating to 256 with 61 0D (“13 more bytes”) for a GET RESPONSE follow-up. scdaemon’s 256-byte buffer overflowed → PC/SC SCARD_E_INSUFFICIENT_BUFFER (0x80100008)apdu_send_simple … failed: invalid value, so key enumeration and gpg --edit-card generate aborted with card_key_generate … General error / KEY_NOT_CREATED. Reproduced on two boards. The on-card crypto was never the problem: tests/36_openpgp_keygen.py GENERATEs P-256/Ed25519/ECDH/RSA-2048 (3.7 s) and tests/52_openpgp_uif_touch.py UIF-signs with a real touch, both fine, because they use extended Le and so never overran the buffer (which masked the bug). Likely surfaced by the bug-#1 fix, which restored the 6E wrapper and pushed the template past 256 bytes.

Fix: the dispatcher (crates/rsk-sdk/src/applet.rs) now does ISO 7816-4 outgoing response chaining: when an opted-in applet’s body exceeds the command’s short Le it ships the first Le bytes with 61xx and serves the remainder on GET RESPONSE (0xC0); the held tail is zeroized after delivery. OpenPGP and PIV opt in via Applet::response_chaining; OATH (own 0xA5 SEND REMAINING) and the vendor/rescue tools (extended Le) are untouched, so every extended-Le consumer (ykman, the APDU suites) is byte-for-byte unchanged.

Verified on hardware 2026-06-13 (0x075A): with EC keys present the 6E read returns 256 + 61 0D then GET RESPONSE → 13 + 9000 (0 insufficient buffer in the scdaemon log), gpg --card-status prints the full card, and both EC and RSA gpg --edit-card generate complete (KEY_CREATED, keys land on-card). The RSA GENERATE response itself never needed chaining: scdaemon issues GENERATE with extended Le (em=1), so its 270-byte pubkey is fine.

# before (0x0759): short-Le 6E with keys present
send apdu: c=00 i=CA p1=00 p2=6E  le=256 em=0  ->  pcsc: insufficient buffer (0x80100008)
# after (0x075A): chained per ISO 7816-4
send apdu: c=00 i=CA p1=00 p2=6E  le=256 em=0  ->  response sw=610D datalen=256
send apdu: c=00 i=C0 00 00 ...                 ->  response sw=9000 (remaining 13 B)
sequenceDiagram
    participant S as scdaemon
    participant F as firmware
    S->>F: 00 CA 00 6E  (short Le = 256)
    F-->>S: 256 bytes + 61 0D  ("13 more")
    S->>F: 00 C0 …  (GET RESPONSE)
    F-->>S: 13 bytes + 9000

READ CONFIG usbEnabled clamped to supported — FIXED (0x081C)

The two-device diff (2026-07-16) surfaced one unexpected divergence. The management applet READ CONFIG (0x1D) reported usbEnabled = 0x3a3b on the test board while usbSupported = 0x023b — the enabled mask carries bits (0x3800) the supported mask does not, so enabled ⊄ supported. A real YubiKey guarantees enabled ⊆ supported (both were 0x033f on the reference).

Root cause was not the default: config_tlv (crates/rsk-devconf/src/lib.rs) sets the default USB_ENABLED to SUPPORTED_CAPS (0x023b), which is correct. But once a host had written an enabled-applications config, READ CONFIG echoed the persisted EF_DEV_CONF blob verbatim without clamping the mask. A host that once wrote a wider mask (a newer ykman that knows capability bits RS-Key lacks) left the device advertising enabled apps it does not support.

Fixed on 0x081C: config_tlv now masks the USB_ENABLED TLV against SUPPORTED_CAPS on read, so enabled ⊆ supported always holds and an already-provisioned board heals without a rewrite. The dated differential result above was recorded against 0x081b, before the fix.

How to run the CLI sweep

# Flash the no-touch build first (signed, if secure boot is on).
nix develop -c python tests/interop/run.py            # automatable cells only
nix develop -c python tests/interop/run.py --touch    # also the touch cells (presses needed)
nix develop -c python tests/interop/run.py --json      # machine-readable

The runner discovers the device via fido2-token -L (HID) and ykman info (CCID), runs each probe, and prints this matrix’s automatable rows with live results. The ykman-based probes only see the device on the opt-in VIDPID=Yubikey5 build (they gate on the “Yubico YubiKey” reader name); on the default RS-Key build the HID and gpg/PC/SC probes still run. It never mutates state by default (read-only probes); destructive cells (enrol/keygen) are opt-in.

Versions

What the firmware advertises itself as, per protocol: the place to look when a host tool gates a feature on a version number. The build knobs that change any of this are documented in Build options; what has actually been checked against real host software is in the Interop matrix.

Firmware version

5.7.4 (rsk_sdk::FIRMWARE_VERSION) is reported everywhere a tool reads a device firmware version: FIDO getInfo and CTAPHID INIT, the YubiKey Management DeviceInfo (ykman info), and the OATH / OTP / PIV version fields. It mimics a current YubiKey 5 so Yubico tooling unlocks its feature gates. Override it with the FW_VERSION build variable. It is not the OpenPGP card version and not the USB bcdDevice.

SurfaceAdvertised versionSpec implemented
FIDO / CTAPHIDgetInfo versions = U2F_V2, FIDO_2_0, FIDO_2_1, FIDO_2_3; device version 5.7.4CTAP2 (FIDO2) + CTAP1 (U2F)
OpenPGP card3.4OpenPGP Smart Card Application 3.4
PIV5.7.4NIST SP 800-73-4 (command subset)
OATHSELECT version 5.7.4YKOATH (Yubico OATH over CCID), AID A0 00 00 05 27 21 01
ManagementDeviceInfo version 5.7.4YubiKey Management over the FIDO / CCID transports
USB bcdDevicea four-digit hex counter (current value: the top of the changelog)internal build counter — bumped on every behaviour change, not a protocol version

U2F_V2 drops out of versions on a build with alwaysUv enabled (CTAP 2.1 §7.2.4 disables U2F there). FIDO_2_2 is deliberately absent: CTAP 2.3 §6.4 says that string “MUST not be present”, and the 2.2 surface is discovered through option ids and getInfo members instead.

Which build is on this device?

Not the firmware version — 5.7.4 is a compatibility constant, identical on every build of every release, so it answers “what will Yubico tooling unlock”, never “what is flashed here”. The build identity is the bcdDevice counter:

rsk-tui --once          # prints "bcdDevice 0x…" alongside the applet state
lsusb -v -d 1209:0001 | grep bcdDevice          # Linux
ioreg -p IOUSB -l | grep -i bcdDevice           # macOS

Map that value to a release through the changelog; each entry names the counter it shipped at. A host tool that shows bcdDevice and calls it “the version” is reading the right field — it is the only number that changes between builds.

The image file itself carries no version. nix build output is unsigned and unstamped, so nothing distinguishes two .uf2s but their bytes. If you need a version readable from the file, picotool seal --major/--minor stamps one into the RP2350 boot metadata that picotool info reads back without flashing (production.md); that is part of the signing path, not the plain build.

Algorithms, by build knob

The default algorithm menu and the two feature flags that change it (full mechanics in Build options):

Default--features advertise-pqc--features fips-profile
FIDO signature algorithms (COSE)ES256 (−7), ES384 (−35), ES512 (−36), ES256K (−47), EdDSA / Ed25519 (−8)ML-DSA-87 (−50), ML-DSA-65 (−49) and ML-DSA-44 (−48) prepended to the getInfo algorithms listES256K (−47) removed from the menu
Post-quantumall three ML-DSA sets negotiable from pubKeyCredParams (capability always on); not advertisedadvertised in getInfosame as default
Minimum PIN length446
Vendor seed exportallowedallowedrefused
PIV3DES management keys + RSA-1024 import allowedsamenew 3DES management keys + RSA-1024 refused
  • PQC capability is on in every build. advertise-pqc only controls whether the three ML-DSA sets appear in the getInfo algorithms list, in descending security order. It is off by default because released Firefox aborts the entire getInfo parse on an unknown COSE id. makeCredential negotiates −50 / −49 / −48 from the request’s pubKeyCredParams regardless — the RP lists the one it wants first, since the first supported entry is selected (CTAP 2.1 §6.1.2).
  • fips-profile is a locked policy, not a FIPS validation. See the FIPS-style profile guide.

Toolchain

The project tracks the latest stable Rust, pinned hermetically rather than to a declared MSRV. nix develop / nix build take the toolchain from the flake (fenix stable, frozen in flake.lock, currently rustc 1.96), on Rust edition 2024. rust-toolchain.toml (channel = "stable") is only the fallback for the non-Nix rustup path. “Which Rust built this image” is answered by the committed flake.lock, so there is no separate minimum-version commitment to drift.

Motivation

The short version

Commercial security keys are excellent, and closed. The firmware is a black box. The capacity limits are product-segmentation choices. When a key dies or a vendor discontinues a line, your enrolled identity dies with it. RS-Key exists to see how far an open, auditable, memory-safe implementation can get on a $5 board.

The honest long version is a story about how one open-source project first amazed me, then demonstrated, very concretely, what trust in a security project actually rests on.

The story

In the spring of 2025 I came across pico-fido, a firmware that turns a Raspberry Pi Pico into a FIDO2 key. A five-dollar board passed for a YubiKey. The ecosystem tooling (browsers, ssh-keygen -t ed25519-sk, ykman) worked with it like with the real thing. Next to it lived pico-openpgp. Together they looked like a small miracle: a complete security key you assemble yourself and can read down to the last byte.

I was hooked and got involved right away. WebAuthn was broken in Firefox on Linux. Chromium worked, Firefox silently refused. I dug in with RUST_LOG=authenticator=debug and traced it down: Firefox’s strict CTAP2 parser was rejecting the device’s responses over garbage zero bytes trailing the CBOR payload (pico-fido#129). The fix was accepted and everything worked. For the next year the key was simply part of my daily life.

The turn

On October 26, 2025, this commit landed: “Update license models and add ENTERPRISE.md”.

Point by point, without emotion:

  • The project split into a “Community Edition” and a proprietary “Enterprise Edition”, priced on request.
  • The list of paid enterprise options includes post-quantum cryptography support. Protection from a quantum adversary became a premium feature.
  • The combined firmware, pico-fido2 (FIDO + OpenPGP), moved into a repository consisting of three markdown files. There is no source code. Releases v7.0 and v7.2 exist, with zero attached files. The README, meanwhile, lists “Open source” among the features, and its build instructions begin with git clone https://github.com/youruser/pico-fido2, an address with nothing behind it to clone.
  • There is exactly one way to get the firmware: through the companion app, under a per-device license: €29.49 for a single key, €49.49 for “Primary + Backup”. A firmware license for a five-dollar board, priced in the range of an entry-level commercial key (which at least ships with a secure element and audits behind it).

I am not arguing the maintainer has no right to earn money. He does; it is his code and his time. A security key is a special case. The only thing separating a DIY key from a no-name dongle off AliExpress is that you can read the firmware down to the last byte. A binary-only security-key firmware is a “trust me” from a single person: no source, no audit, no guarantees, exactly the thing this project had been an escape from. That day I understood the project was over for me, and that I would have to write the replacement myself.

The principle

The irony is that Raspberry Pi themselves champion the opposite approach for the RP2350: security through transparency. Open hacking challenges against their own chip, public write-ups of the attacks that succeeded, and a second round on fixed silicon. TROPIC01 does the same at the secure-element level: an open, auditable architecture instead of an NDA. That is the right side of this story, and RS-Key intends to stay on it.

What RS-Key does differently

  • AGPL-3.0-only, irreversibly. RS-Key is a derivative work of the AGPL-licensed pico-keys (see NOTICE), so the “relicense it proprietary” trick is legally impossible here, for anyone, me included. There is no CLA; contributors keep their copyright. This is not a promise of good behaviour. It is how the licensing is built.
  • Post-quantum in the open tree. ML-DSA-44, ML-DSA-65 and ML-DSA-87 credentials work today, for free, with the source in front of you.
  • The “enterprise” features live in the public tree. Attestation, secure boot, backup, audit: the things usually kept behind a paywall land in the open repository.
  • Transparency as artifacts, not as a slogan. Every external-facing parser has a fuzz target, every unsafe is documented and justified, and the threat model was written down before anyone is asked to trust the key with something real.
  • Accessibility. A Trezor or a Nitrokey is hard and expensive to get in Russia. An RP2350 board is 500 rubles and a friend with a 3D printer.

And yes, this is still a love letter to writing real systems software in Rust on a ten-dollar board. Only now it comes with a moral: the openness of a security project is tested not by years of honest work, but by a single commit. RS-Key is built so that the relicensing in that commit cannot happen here.