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.
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_stdRust end to end. The parsers and applet dispatch are safe code. The handful ofunsafesites are enumerated and justified in unsafe.md. - Fuzzing. Every parser and every applet’s full dispatch path has a
cargo-fuzztarget (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
Fswrites, 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 tornauthenticatorResetmay not drop the PIN oralwaysUvrecord while leaving a credential usable. Write ORDER is what buys that, chosen per operation and stated where the code does it:Fs::deletedrops the metadata before the value, registration writes theEF_RPentry 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 asPLAT-FLASH-001and 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::readandStorage::sizereturnNonefor 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 refusedEF_PINprobe on an unauthenticated PIVSELECTre-seeded the factory PIN over the owner’s, after whichVERIFY 123456answered9000— 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 backError::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 asCorrupted, 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 —enumerateRPsand the trusted-display Passkeys view — reach them throughEF_RP. So a credential whoseEF_RPentry is missing is onegetAssertionsigns with happily and neither surface will show you.enumerateCredentialsanddeleteCredentialscanEF_CREDdirectly, 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: theEF_RPentry 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 reachesEF_RPbeforeEF_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: anEF_RPentry 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 DATAaway for anyone holding PW3, and the card reads that record again at the moment of every private-key operation:PSO:CDS, the asymmetric arm ofPSO:DECIPHERandINTERNAL AUTHENTICATEtake 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: aPSO:DECIPHERbody opening0x02and everyPSO:ENCIPHERroute toEF_AES_KEYand 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 whileGET DATApublishes 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: aPUT DATAofC1/C2/C3that 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 answer6581without 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 DATAanswers6B00to it even under PW3 — so theC1/C2/C3tags 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 inC5until something overwrites it.TERMINATE DFsweeps 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 the6581it returns rather than an order. And the binding at the other end —GENERATEandIMPORTreading 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 → 1write sits beside it inticket.rsand cannot reach the ceiling.UPDATEand the boot seal migration carry a slot’s record forward and leave the counter alone.SWAPleaves 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::putanswersNoMemoryon 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:CONFIGUREwrites 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:SWAPwrites 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 answers6581with 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 plainykman otp swapreaches 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 vendorCONFIG_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, aSET LEDreplay 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/flashfirmware-strict-config, which restores the presence/PIN gates and refuses the ungated transport writes (build.md). It is not the runtime flash flagEF_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
pcscdcan 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 sameFidoState— one PIN/UV token, one credential-management walk, and crucially one per-boot PIN-mismatch budget. A second session state would have handed a hostPIN_MISMATCH_LIMITguesses 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 byboth_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 useykman config usb --disable fido2(or--disable u2f) if that is not wanted — the mask gates the two applications apart, over both transports. On the default0x1209:0x0001identity 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
rpIdon 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
pcmrgrant, 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 theEF_HARDENEDmarker, 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 OTP-burn migration supersedes the pre-OTP seed, which was sealed
under the chip-serial-only root (no fuse secret), and the persistent
- 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_REQUIREDfused, 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-configre-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 saveandpicotool loadover that range answerpermission failure— enforced by the bootrom, not by us — while the running firmware keepssecure: rwand 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)), withkbaserooted 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-profilebuilds (opt-in). Raise the clientPIN floor to six code points and refuse the most guessable PINs (a repeated digit, a123456-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_VDDrail, 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) andgitleaksrun inscripts/check.shand 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
rsacrate, which has no fixed release — went away with the crate itself in 0.4.12;rsk-rsaowns 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 fallbackrsa_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.