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

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.
  • A flash write can be interrupted, and a host picks which one is in flight. The device is bus-powered with no reserve, so the supply belongs to whoever holds the cable or the hub port, and because the host drives every operation it chooses which write a cut lands in. It needs no attacker — a knocked cable does the same thing — but it is cheap to aim, so treat it as aimed. There is no transaction underneath: one applet operation is several Fs writes, and a cut lands between any two of them. What the device owes across a cut is that nothing comes back weaker: an interrupted write left the old value or the new one, never a third; a record committed before the cut still reads back after it; a delete the cut caught halfway leaves behind no metadata describing the value it removed; and no gate comes up softer on the next boot than it went down — a torn authenticatorReset may not drop the PIN or alwaysUv record while leaving a credential usable. Write ORDER is what buys that, chosen per operation and stated where the code does it: Fs::delete drops the metadata before the value, registration writes the EF_RP entry before the credential, a reset deletes the seed before anything derived from it. Underneath the order sits an assumption about the silicon rather than a defence this firmware implements — that a torn NOR write leaves the old bytes or detectably bad ones, never a plausible wrong value. It is registered as PLAT-FLASH-001 and is discharged by a board measurement, not by code (platform-assumptions.md). Scope: the interrupted write. A flash read that comes back an error is a different condition and has its own clause below. Not that a cut cannot produce one: a half-programmed item header is deterministic and reads back as an error, and what keeps a store walk honest is that it skips that error on purpose rather than never meeting it.
  • A flash read can come back an error, and this firmware spells “not configured” as an absence. The backend answers a probe with the value, with no such key, or with a failure — and its own API folds the last two together: Storage::read and Storage::size return None for either, which is why a second call, last_error, exists to tell them apart. The fold is what makes this a security condition rather than a reliability one, because absence is how the tree spells no PIN set, no gate configured and not provisioned yet: a probe that merely failed then reads as a decision the owner never made. Measured, before it was fixed: one refused EF_PIN probe on an unauthenticated PIV SELECT re-seeded the factory PIN over the owner’s, after which VERIFY 123456 answered 9000 — and the same fault aimed at the management-key slot re-minted the default management key. What the device owes is that a refusal is never laundered into an answer, and three rules carry it. Two are owned by one function each and cited from the store’s refinement: a failed probe is never memoised as a decided absence, so one transient fault cannot become a permanent “file absent” for the rest of the boot; and an enumeration the medium truncated leaves the keys it never reached undecided, so a walk that stopped early cannot report them gone. The third is a per-site discipline and not a mechanical property, which is worth saying plainly: a probe whose absent arm would overwrite configured material or open a gate is answered fallibly — either propagating the failure, or resolving it to the restrictive arm where that is the safe answer — rather than collapsing it into an absence. Which class a site belongs in is decided by asking what becomes reachable if the probe answers absent where the truth is present, and it is held by a test at the site; no gate can check it, and the tree carries far more collapsing probes than fallible ones because most absences cost only a status field or a repeated repair. Scope: the read the medium refused, whoever caused it. How much of a lever a host has here is deliberately not claimed. A cut it aims leaves a half-programmed item header that reads back Error::Corrupted, and the store walk skips that one on purpose, so it truncates nothing; an interrupted page erase leaves a marker pair the backend also reports as Corrupted, and that one does reach a per-key probe in the source. Whether any of it is reachable on a board is unmeasured. The three rules hold either way, because none of them asks who caused the refusal.
  • You must be able to see and revoke every credential the device holds. Resident credentials live in EF_CRED, and the two surfaces that let you BROWSE them — enumerateRPs and the trusted-display Passkeys view — reach them through EF_RP. So a credential whose EF_RP entry is missing is one getAssertion signs with happily and neither surface will show you. enumerateCredentials and deleteCredential scan EF_CRED directly, on an rpIdHash the host supplies, so a caller who already knows the rp can still reach it: what a strand costs is DISCOVERY, and revocation only so far as revoking a thing means finding it first. It does not heal on its own either — a later registration of the same (rp, user) dedups onto the record already there instead of writing the missing entry (audit run-35). Registration and credential-management delete are therefore ordered to fail the harmless way round: the EF_RP entry is written before the credential, and the credential goes before the count that names it. The resets are not, and this page will not pretend otherwise. There the seed leads and the rest is swept in ring order, which reaches EF_RP before EF_CRED — a torn wipe can still strand a credential, and what the ordering buys is that the survivor is undecryptable rather than that it does not exist. The harmless direction is not free either: an EF_RP entry that outlives its credential keeps a count nothing reconciles, so it goes on listing an rp with no passkeys behind it until the next reset.
  • A key must not outlive the algorithm attribute it was made under. An OpenPGP slot’s algorithm attribute is one PUT DATA away for anyone holding PW3, and the card reads that record again at the moment of every private-key operation: PSO:CDS, the asymmetric arm of PSO:DECIPHER and INTERNAL AUTHENTICATE take one byte of it to decide whether the slot is RSA or EC before they touch the key. The symmetric arms read no attribute at all: a PSO:DECIPHER body opening 0x02 and every PSO:ENCIPHER route to EF_AES_KEY and have returned before that read, so nothing below is about them. Only that byte — the curve and the modulus size come from the stored blob itself — so what an attribute change under a surviving key does depends on which way it goes. Across the families it is caught by accident: the old blob does not parse as the new algorithm and the operation fails. Within a family nothing catches it, and that is the case worth stating. Swap Ed25519 for P-256, or RSA-2048 for RSA-4096, and the card goes on signing and deciphering with the old key while GET DATA publishes the new parameters over it — a slot whose published description no longer describes the secret behind it. So what the device owes here is a deletion and not a validation: a PUT DATA of C1/C2/C3 that resolves to an attribute different from the one the slot already holds drops that slot’s private key and the stored public key it would go on serving first — whenever the store reports the slot present — and only then writes the new value. A key the present cache reads absent while the backend still holds it is not dropped, and the attribute goes in over it. Every probe on that path is fallible on purpose: an unreadable attribute, an unreadable key slot, or a drop the store refused each answer 6581 without writing the attribute, rather than skipping an invalidation the card could not prove unnecessary. And the internal EF the attribute lives in is not addressable — PUT DATA answers 6B00 to it even under PW3 — so the C1/C2/C3 tags are the only door. Scope: the attribute write. A write that resolves to the attribute already in force is not a change and keeps the key, and an absent attribute reads as the RSA-2048 default on both sides of that comparison. Three things this does NOT claim. The fingerprint DOs are outside the drop, so a slot goes on publishing the old key’s fingerprint in C5 until something overwrites it. TERMINATE DF sweeps attributes and keys in flash-ring order, so a factory reset that fails partway can leave a key beside a cleared attribute, and what it owes there is the 6581 it returns rather than an order. And the binding at the other end — GENERATE and IMPORT reading the slot’s attribute as they mint or take the key — is one command for every arm but the two-step RSA generate, which reads the attribute when it starts the prime search and does not read it again when it stores the key. Nothing in the applet closes that window; what closes it is the worker dispatching one command at a time.
  • A Yubico OTP the host watched being typed must not validate twice. The OTP applet types its ticket as keystrokes into whatever has focus, so a hostile host reads every OTP the key emits; what stops one being replayed is not secrecy but position. Among the fields each press of a Yubico-OTP slot encrypts into its ticket is a pair — the slot’s persisted 15-bit use counter and a one-byte RAM session counter — and the protocol’s replay rule is that a validation server rejects any OTP whose pair does not exceed the last pair it saw for that public id. The pair is therefore the defence, and the rule is that a press emits the pair the slot holds and then moves it: the session counter on every press, and the persisted use counter whenever the session counter wraps past 255. One press does not read that way — the first on a freshly configured slot, whose stored zero is written up to one before the ticket is built, so what it types is the moved value and not the zero it held. The two paths that ADVANCE the persisted half are that press and the boot-time bump, and the step that can reach the 15-bit ceiling comes from one module for both, so the ceiling cannot be enforced two different ways. The first-press 0 → 1 write sits beside it in ticket.rs and cannot reach the ceiling. UPDATE and the boot seal migration carry a slot’s record forward and leave the counter alone. SWAP leaves the stored counter alone too — but it moves the record to the other slot index, and the RAM half travels with it, because the pair belongs to the public id inside the record and not to the slot number that record happens to sit at. Because the session counter lives in RAM and comes back at zero, a cold boot advances the persisted counter of every plain Yubico-OTP slot it can read and re-seal and that still has room to advance — never a HOTP, short or static slot, and never one already at the ceiling — before USB is up and any press can be served, which is what keeps one power cycle’s pairs out of the next one’s. Six residuals, and the rule above hides none of them. A host-requested warm reset is ungated and does not advance the counter — deliberately, since bumping on every reboot a host can ask for would let it walk the 15-bit counter to the ceiling — so the session counter restarts at zero over an unchanged use counter and the current power cycle’s pairs are typed again. At that ceiling neither path advances anything and the key goes on typing, so the pair repeats every 256 presses. Of the two counter WRITES, the press’s answer is read — a ticket whose advance the store would not take is not typed at all, because typing it re-emits: the next press reads the old counter back and pairs it with a session this cycle has already used. The boot bump’s is not, and cannot be: it is retried, and a refusal that outlasts the retries is dropped, leaving the last cycle’s positions typeable again. That one needs no fault at all — Fs::put answers NoMemory on a full store. It is a choice and not a limit, and the page will not dress it as one: the device could deny the press instead of typing a position it cannot move past, and two ways of doing that were built and measured. One carries the boot pass’s failure out to the applet; the other keeps it in the applet entirely, by making the first press of each slot in a power cycle perform the advance itself and refuse if the store will not take it. What is shipped is the other arm of the same choice — keep typing — because a store that cannot be written to would otherwise silence every slot on the key, and which of those two costs more is a decision for the maintainer rather than a fact about the code. A test pins the repeat, so whichever arm lands is visible. So is the boot read dropped, though only where the medium keeps refusing: a sealed read that faults is retried, and a slot the retries never reach is skipped by the bump entirely — which is the faulted-read clause above wearing this applet’s clothes. A press answers that same refusal by typing nothing, so what the skip costs is a fault that clears before the first press. And position is not monotone across re-provisioning: CONFIGURE writes a fresh record with a zeroed counter, gated by the slot access code whenever the existing slot reads back. And the move that carries the pair is not atomic: SWAP writes the two records one after the other and swaps the RAM halves only once both are through, with nothing to undo a step that failed. A refused second write answers 6581 with the first already landed, and so does a refused delete — the answer is read now, but where the record write has already gone in it buys the report and not the state. That direction still leaves one public id in two slots with only one of the two session halves its own, and the other slot can then type a position that id has already typed this power cycle — which a plain ykman otp swap reaches unauthenticated, an unprotected slot’s stored access code being all-zero.
  • 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 — for every record a boot pass can re-root. A PIN-derived one cannot be re-rooted without its secret, so it waits for its own reference to be presented; see limitations and PLAT-THREAT-002.
  • 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), and the persistent pcmr grant, which provisioning mints at the first boot under that same root. Left alone, a flash dump plus the chip id would recover them, 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.

One residual straddles this line, and it is written down rather than left to the reader. The RSA private operation picks a modexp window per four bits of dP/dQ, and the choice lands in an ADDRESS: PIV GENERAL AUTHENTICATE and OpenPGP PSO:CDS / INTERNAL AUTHENTICATE / DECIPHER all drive it over USB against a long-lived key, and the per-operation blinding randomises the base, never the exponent. Reading the window sequence back needs a time-resolved look inside a single operation, and the only observer holding one is the power/EM capture already listed above — so that half is out of scope for the reason this section already gives, not for a new one. The other half is not out of scope: a host that can time whole operations is the §1 attacker, and against it the pattern is one scalar per signature over an exponent that never changes. Severity, the reasoning and what would re-open it are registered as PLAT-CRYPTO-002, and the hardening that would remove it is PLAT-BUILD-005, deferred with a stated price (platform-assumptions.md, ct-audit.md). This paragraph adds a residual to the page; it excludes no observer the section did not already exclude.

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.

The PIN factor has two forms and the second is easy to miss: with a clientPIN set it is the PIN/UV token; with no clientPIN but a device PIN set, the same gate collects that PIN on the device’s own pad, so a display-flavor key whose owner never set a clientPIN is still two-factor. With neither set there is no PIN factor and the gate is the encrypted channel plus a touch — which is the state a factory-fresh key is in, and the reason finalize exists. A device PIN the running build cannot collect (the record survives a reflash to a screenless image, which has no pad) is refused, not waived: an owner who set a PIN does not silently drop to a touch. 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-09-03, secure boot off). That run is the one on the record: it carries the sha256 of the image it ran on, and it is read against an expectation committed before the board was powered. It discharges PLAT-MEM-001 in the platform registry (platform-assumptions.md) — read the row for the transcript, the picotool version, and what the result does not establish, which is that the firmware’s own scrub works. An earlier run on 2026-08-05 reached the same outcome and is superseded here because nobody kept the hash of the image it ran on. 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.