This is a source-level constant-time and timing side-channel audit of the
RS-Key firmware (Rust, no_std, RP2350 / Cortex-M33). Its scope is every
secret-dependent comparison, branch, memory access, and private-key arithmetic
operation that an attacker holding the device can probe over USB — CCID /
ISO-7816 APDUs and CTAPHID / CTAP2: PIN/PUK/password verifiers, the FIDO
pinUvAuthToken MAC, OATH and OTP access codes, RSA private operations, and the
hand-written rsk-rsa-asm keygen primitives.
What this is and isn't. This is a source/disassembly audit: it establishes that the generated machine code has no secret-dependent branch / early-exit / index on the audited paths. It is not a measured timing study and does not replace an instrumented hardware harness (TVLA / Welch t-test); see Coverage & limits.
42 candidate sites were examined; 3 were real findings, all fixed (no high/critical). The core authentication surface is sound: the project's hand-rolled constant-time comparison is genuinely constant-time as compiled for the production target, and the PIN/MAC/verifier paths compare one-way derived verifiers rather than raw secrets. The defects were concentrated in two places the canonical helper did not reach — an unblinded RSA private-exponent exponentiation on an OpenPGP fallback path, and two raw short-circuiting comparisons of the OTP slot access code with no rate limit.
Six analysis lenses were applied across the workspace, then each candidate was
put through adversarial verification (default-to-false; a finding survives
only if a concrete secret → observable path is demonstrated at exact
file:line with the exploitation model stated), and a completeness critic
pass caught sites a single lens would miss. Several constant-time refutations
were corroborated by disassembling the actual on-device LTO firmware ELF and
by standalone thumbv8m compiles at the production opt-level=s and at
opt-level=3.
- Hand-rolled constant-time comparator definitions.
- Secret-vs-attacker comparisons that bypass the comparator.
- Secret-dependent control flow / variable work between match and mismatch.
- Secret-indexed memory access / data-dependent arithmetic.
- Crypto-primitive usage (are the CT-by-design RustCrypto primitives wrapped non-CT? is the hand-written modexp safe?).
- Status-word / error-path / response-latency oracles.
| Severity | Location | Issue | Fix |
|---|---|---|---|
| Medium | crates/rsk-openpgp/src/keys.rs (rsa_raw) |
Unblinded RSA private-exponent modexp. rsa_sign fell through to a raw m^d mod n for any input that is not a recognized DigestInfo or standard-length hash (reachable via PSO:CDS and INTERNAL AUTHENTICATE). Unlike the mainline sign/decipher paths, this fallback applied no blinding — a Marvin-class private-key timing path the documented residual did not cover. |
The raw operation is now base-blinded (m·rᵉ)ᵈ·r⁻¹ mod n with a fresh random r, so the variable-time exponentiation runs on a base unrelated to caller input. A unit test pins rsa_raw == m^d mod n and proves the result is independent of the blinding factor. |
| Medium | crates/rsk-otp/src/lib.rs (cmd_configure) |
Non-constant-time compare of the 6-byte OTP slot access code via slice != — a position-of-first-mismatch leak. Reachable over CCID and HID with no PIN gate and no retry counter, so the leak collapses brute force from ~2⁴⁸ to ~6·256 probes; the access code authorizes overwriting a slot's key material. |
Replaced with the constant-time rsk_crypto::ct_eq. |
| Medium | crates/rsk-otp/src/lib.rs (cmd_update) |
Second, byte-identical instance of the same non-CT access-code compare on the slot-update path. | Same fix. |
The assurance result — sites checked and found correct:
- The canonical comparator
rsk_crypto::ct_eqis constant-time. Public length-equality early-return, then a full-width OR-accumulate with no in-loop branch on the accumulator. Verified in the on-device LTO ELF: the inlined copies lower to a loop whose only branch is governed by the public length counter; the secret accumulator is reduced branchlessly. Reproduced from source atopt-level=sandopt-level=3. - PIN/PUK/password verifier compares are CT and structurally non-amplifiable. Every verifier site compares 32-byte HKDF/HMAC-derived verifiers, not raw secret bytes — so even a hypothetical position oracle would reveal avalanche-hash bytes, not PIN digits, and the "10ᵏ → k·10" counter-defeat does not apply.
- The
pinUvAuthTokenMAC verify, OATH access-code/HOTP verifies, and PIV mutual-auth all route through the constant-time comparator (PIV against a single-use per-session challenge, not the persistent management key). - The RSA sign/decipher mainline is blinded (verified through
rsa0.9.10'sblind/unblindaround the secret-exponent CRT modexp), and — with the fix above — so is the raw fallback. - RustCrypto primitives are CT-by-library and not wrapped non-CT: k256, ed25519-dalek, x25519-dalek, ML-KEM/ML-DSA, and the HMAC/HKDF/SHA-2 KDF.
- Keygen primality primitives are not an attacker oracle: they operate on RNG-generated, single-use, never-disclosed candidates; production keygen uses the branchless incremental sieve.
The five hand-rolled comparators (one canonical plus four byte-identical
duplicates across the applet crates) were consolidated onto the single
rsk_crypto::ct_eq, and a core::hint::black_box barrier was added before its
final reduction. The comparator was already constant-time on the audited
toolchain; the barrier pins that property so a future LLVM/rustc cannot fold the
accumulate into an early-exit branch. It does not change the code generated
today.
- RUSTSEC-2023-0071 "Marvin" timing channel in the
rsacrate — accepted as mitigated by per-operation base blinding on all private-key paths (the finding above closed the one path the blinding did not previously cover). See threat-model.md. rsk-rsa-asmkeygen modexp secret-indexed window lookup — a genuine secret-dependent memory-access pattern over bits of the generated prime, but it is keygen-only, one-shot, and not USB-timing-observable; on the cacheless Cortex-M33 there is no microarchitectural channel. Exploitable only via physical EM/power capture of a single keygen event — already out of scope (threat-model.md). Optional hardening: build the asm with a constant memory-access pattern.
Covered: all hand-rolled comparator definitions and call sites; every
PIN/PUK/password/MAC/verifier comparison across FIDO, PIV, OpenPGP, OATH; OTP
slot access-code compares; HOTP/TOTP; RSA private sign/decrypt/raw paths; the
rsk-rsa-asm C/asm modexp, sieve, and primality primitives; secret-indexed
lookups; and status-word/error-path oracles.
What a source/disassembly audit cannot prove:
- No measured timing distributions. This shows the code has no secret-dependent branch/early-exit/index; it cannot rule out a microarchitectural channel (e.g. XIP-flash stall variance, data-dependent multiplier latency). A definitive statement needs an instrumented timing harness on hardware with a statistical leakage test (TVLA / Welch t-test).
- Compiler stability is empirical, not contractual. The comparator is
constant-time under the audited toolchain; the
black_boxbarrier pins it, but the guarantee remains "verified on this build." - Physical side channels (power/EM/fault) are explicitly out of scope and unverified here — including the keygen access pattern and any DPA on the secure-boot AES, both already noted in the threat model.
- Third-party crate internals (RustCrypto,
rsa/num-bigint-dig) were audited only at the usage boundary; their own CT properties are inherited from upstream and the documented RUSTSEC residual.