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

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, seven 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 (INS 0x12 on the vendor applet reads the engine’s counters and flags).

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 — the firmware binary on top, then the seven applet crates (fido, openpgp, piv, oath, otp, mgmt, rescue), then the platform libraries (sdk, fs, crypto, usb, rsa-asm, led); each tier depends on the one below, with piv→openpgp, otp→mgmt and openpgp→rsa-asm as the cross-edges

CrateContents
firmwarethe only crate that touches the HAL: board bring-up, USB descriptors, executors, the worker, OTP fuse access, LED, BOOTSEL touch
rsk-sdkAPDU parsing (cases 1–4, short + extended), BER-TLV, status words, the Applet trait + dispatcher
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 (rsk-mldsa) / ML-KEM, base64url, CRC
rsk-mldsastack-optimized ML-DSA (FIPS 204) for ML-DSA-44/-65: streams the matrix A on the fly (one polynomial resident, not the full k×l) so ML-DSA-65 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-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 (DeviceInfo, interface toggles) served over both CCID and CTAPHID
rsk-rescuerecovery/provisioning applet: identity, phy config record, flash info, secure-boot status, attestation key, reboot, the one OTP-lock write
rsk-rsa-asmvendored C/ARM-asm modular exponentiation behind one FFI fn (host build uses a pure-Rust fallback)
rsk-ledthe EF_LED_CONF codec for the status-LED config block, shared by the firmware and the rsk led host tool
rsk-uithe trusted-display UI model (operation prompts, untrusted relying-party-string sanitizing, Allow/Deny button geometry); compiled only into the display build

Everything except firmware 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.

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 (256 files across all applets), MAX_OATH_CRED (255), plus a handful of OpenPGP/PIV slots. 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 a UserPresence trait 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 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, rsa, ed25519-dalek, …)
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.