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 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.

For the same reason the OTP frame protocol answers a HID feature GET/SET_REPORT on both HID interfaces — the keyboard one and the FIDO one — which is what a 5.7.4 YubiKey does. The FIDO report descriptor stays the CTAP-exact one and declares no feature report (again as on a YubiKey), so a host reading descriptors sees no change; the interrupt endpoints carry CTAPHID and nothing else. Reaching the frames there needs a deliberate feature transfer — measured working both from raw libusb and through macOS IOKit. Both interfaces marshal one frame state machine, so the two are alternative doors to the same OTP application, not independent sessions. Disabling the keyboard interface in ENABLED_USB_ITF removes the protocol from both.

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.

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])

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.

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 other channels get ERR_CHANNEL_BUSY meanwhile.

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) / 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
6982SECURITY_STATUS_NOT_SATISFIEDauth/precondition missing
6984DATA_INVALIDmalformed payload (e.g. bad guard magic)
6985CONDITIONS_NOT_SATISFIEDstate precondition unmet (e.g. RTC unset)
6A80INCORRECT_PARAMSbad data field
6A86INCORRECT_P1P2unsupported P1/P2
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
0x14MISSING_PARAMETERrequired field absent (e.g. blob/pinUvAuthParam)
0x27OPERATION_DENIEDtouch declined / timed out
0x30NOT_ALLOWEDprecondition unmet (no MSE channel, 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

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 phy.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.

AppletAIDSpec statusConfig-relevant?
FIDO2A0 00 00 06 47 2F 00 01Standard (CTAP2)identity only
FIDO2 (backup id)B0 00 00 06 47 2F 00 01RS-Key
U2FA0 00 00 05 27 10 02Standard (CTAP1/U2F)
ManagementA0 00 00 05 27 47 11 17Yubico-compatibleyes — §6
OATHA0 00 00 05 27 21 01Yubico OATHdata only
OTPA0 00 00 05 27 20 01Yubico OTPdata only
PIVA0 00 00 03 08NIST SP 800-73data only
OpenPGPD2 76 00 01 24 01OpenPGP card 3.xdata only
RescueA0 58 3F C1 9B 7E 4F 21RS-Key-specificyes — §7
Vendor / LEDF0 00 00 00 01RS-Key-specificyes — §8

Sources: crates/rsk-fido/src/consts.rs, crates/rsk-mgmt, crates/rsk-oath, crates/rsk-otp, crates/rsk-piv, crates/rsk-openpgp/src/consts.rs, crates/rsk-rescue, firmware/src/vendor.rs.


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: 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). maxMsgSize = 7609. Supported COSE algorithms: ES256 -7, ES384 -35, ES512 -36, ES256K -47, EdDSA -8, ML-DSA-44 -48, ML-DSA-65 -49 (both negotiable via pubKeyCredParams; advertised in getInfo only under the advertise-pqc build). ML-DSA-87 -50 is recognised but unsupported: its response overruns maxMsgSize. (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.

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

Two places where a host that works against other authenticators sees a status byte it may not expect. Both are spec-permitted strictness, not extensions.

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.

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

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.

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 verbatim after the fixed USB_SUPPORTED/SERIAL/FORM_FACTOR/VERSION prefix.

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. Building --features strict-config gates the write on operator presence; the enforcement of a persisted mask is the same on both builds.

WRITE CONFIG refuses an inner blob > 64 bytes (6A80) so a malformed config can’t wedge later reads. 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

⚠️ 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.

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.

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-rescue/src/phy.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 (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)
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). Preserve tags you don’t recognize.
  • 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: firmware/src/vendor.rs, firmware/src/led.rs. Reference client: tools/rsk/led.py.

INSP1P2RequestResponsePurpose
01counter (BE4)INCREMENT test counter, return new value
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 debug/bench builds and are not 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.

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 attestation 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 the only journalled event an ungated host can drive on demand, so appending one per call would let it evict the 128-entry window; instead, 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. 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}. 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).

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.


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). The supported 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.