I wanted a hardware security token I could actually audit end to end. Commercial tokens are fine until they aren’t — closed firmware, opaque key storage, supply-chain you can’t verify. The RP2350 is cheap, well-documented, and has an OTP bank and a boot ROM that can verify signed images. That’s enough to build something real.
What it does
The token signs 32-byte challenges with HMAC-SHA256. The signing key isn’t stored directly — it’s derived on demand from a provisioned master secret and the board’s unique UID using HKDF. Each domain (login service, API endpoint, whatever) gets its own derived key, so a compromise of one derived key reveals nothing about others or about the master secret.
User presence is gated through the BOOTSEL button. The device won’t sign until you press it. That’s intentional and deliberately low-tech — no capacitive sensor, no fingerprint reader, just a mechanical switch with a configurable approval timeout.
Key derivation
root_key = HKDF-Expand(master_secret, board_uid, 32)
domain_key = HKDF-Expand(root_key, domain_label, 32)
The board UID is burned into hardware and isn’t extractable over USB. Cloning the firmware binary to another board gives you nothing — the derived keys will differ because the UID differs. The master secret lives in OTP on release builds; on development builds it falls back to flash with a compile-time guard.
Dual-slot flash state
Flash has a finite write endurance. Signing is frequent; checkpointing every signature would wear the flash in months. Instead, the counter increments in RAM and flushes to flash every COUNTER_FLUSH_INTERVAL signatures (default 64). The counter value is stored in a dual-slot structure — two identical regions with a generation number and CRC. On boot, the slot with the higher valid generation wins.
If power is lost mid-flush the worst case is replaying up to 63 signatures, not losing the counter entirely. The monotonic counter is the replay-prevention mechanism, so losing a few increments is bounded and acceptable; losing the counter state entirely is not.
Release build: no-flash, OTP-locked
Release builds don’t run from flash. The binary is flagged no_flash — the RP2350 boot ROM copies it to SRAM and verifies a signature against a public key burned into OTP before jumping. Flashing a different image over USB won’t run unless it’s signed with the release key.
OTP bring-up is a one-way operation. Once the secure-boot key hash is burned and the OTP security bits are set, that board is locked to that key. I wrote a script (provision_release_device.sh) that does the full sequence atomically and logs every OTP row written before it sets the lock bits — because you want a record of exactly what was burned before you lose the ability to change it.
The host side
The host derives the same domain key independently (it knows the master secret and the board’s USB serial string, which exposes the UID). Challenge/response packets are 64 bytes over HID — no custom driver, no libusb for basic use, just a /dev/hidrawN read/write.
domain_key = hkdf_expand(root_key, domain.encode(), 32)
expected_mac = hmac_sha256(domain_key, challenge)
assert hmac.compare_digest(expected_mac, response_mac)
The full packet layout is in PROTOCOL.md. The test suite (tests/test_protocol.py) simulates the token state machine in pure Python — no hardware needed to verify the protocol logic.
What it isn’t
It isn’t a certified FIDO2 token. It doesn’t implement WebAuthn. It’s a signing primitive that does exactly one thing correctly — HMAC-SHA256 with per-domain keys, user presence, and a monotonic counter — and nothing else. That’s a feature, not a gap.