- 64
- Pixels driven
- 60 Hz
- Render rate
- 13
- Effect states
- 260 mA
- Current budget
What this is. A board port and an LED driver, not an authenticator. The FIDO2 / U2F firmware is pico-fido by polhenarejos — the cryptography, the CTAP stack and the credential storage are all its work, and none of it is mine. My contribution is the piece described below: getting a 64-pixel panel onto a board pico-fido already supports, without degrading the thing it exists to do.
The board
A Waveshare ESP32-S3-Matrix: an ESP32-S3FH4R2 with 4 MB flash and 2 MB PSRAM, 64 WS2812B LEDs, and a QMI8658 IMU, all on a board about the size of a large postage stamp.
| Thing | Value |
|---|---|
| LEDs | 64 × WS2812B on GPIO14, serpentine by column, panel mounted 180° |
| IMU | QMI8658, I²C SDA=GPIO11 / SCL=GPIO12, address 0x6B, INT1 GPIO10 |
| User presence | BOOT button on GPIO0 |
| USB | Native USB-OTG (GPIO19/20) — required; this is not a UART-bridge board |
pico-fido already targets the ESP32-S3, and for this firmware the S3 is the better host: the master key can live in eFuse behind flash encryption instead of in dumpable flash, secure boot is available, and AES/SHA/ECC are in hardware. So there was nothing to reimplement. The only thing genuinely missing upstream was a driver for this panel — pico-keys-sdk’s ESP32 path assumes a single NeoPixel on GPIO48, which this board does not have.
The actual problem
pico-keys-sdk exposes the status LED as a two-function vtable:
typedef struct {
void (*init)(void);
void (*set_color)(uint8_t color, uint32_t brightness, float progress);
} led_driver_t;
set_color() is called from the main loop on core 0 — the same core running
USB and the CTAP stack — whenever that loop happens to come around.
For one pixel that is fine. For 64 it is not, and the reason is timing: pushing 64 WS2812B costs roughly 1.9 ms of bit-banged RMT output. That is 1.9 ms spent inside the command path of a device whose entire job is to answer a security handshake promptly. Worse, it is 1.9 ms at an interval you do not control, so the animation would stutter every time the stack got busy doing ECDSA.
Both halves of that are unacceptable, and they pull in opposite directions — which is what makes it interesting rather than just annoying.
The fix
set_color() touches no hardware. It only latches what the authenticator
meant: colour, brightness, blink phase. A separate task, pinned to core 1,
renders and pushes frames at a fixed 60 Hz, reading that latched state to choose
an animation.
core 0: USB / CTAP / crypto ──▶ set_color() ──▶ [ latched intent ]
│
core 1: 60 Hz render task ◀──────────────────────────────┘
│
├─ fx_render() pure, stateless, host-testable
├─ gamma 2.2
├─ brightness cap (56/255)
├─ current limiter (260 mA whole panel)
└─ neopixel_SetPixel()
The CTAP stack is never blocked by pixel pushing, and the animation never stutters when the stack is busy. The two concerns stop sharing a deadline.
Installing without a fork
The driver installs itself by wrapping led_init() at link time:
-Wl,--wrap=led_init
No file in pico-keys-sdk or pico-fido is modified. Upstream can be pulled without rebasing a patch — which matters a great deal for firmware whose value depends on tracking security fixes.
Reading state from timing, not colour
Effects are selected from the timing signature of the SDK’s mode word rather than the colour it asks for, so re-theming the device cannot break the animation logic. Thirteen states are mapped, including:
| SDK mode | What you see |
|---|---|
| first 2.4 s | A shield assembles from scattered pixels — also a panel self-test, so a dead pixel is obvious |
MODE_MOUNTED | Padlock breathing, scan sweep every 8 s |
MODE_NOT_MOUNTED | Signal arcs pulsing out of the corner |
MODE_PROCESSING | Comet arc with a fading tail |
MODE_BUTTON | Fingerprint pulsing outward — and the perimeter depletes across the full 30 s CTAP window, amber to red |
MODE_SUSPENDED | Z’s drifting up and fading |
The touch countdown is the idea worth keeping. The user-presence window is a real 30-second deadline that the protocol enforces and normally shows you nothing about; putting it around the perimeter means you can see how much of it is left without counting.
Shapes sit on fractional coordinates and are bilinearly splatted. That one technique is the difference between icons and blobs at this resolution.
Not burning the board
Waveshare’s own wiki warns twice that running this panel bright causes a rapid temperature rise that can damage it. 64 WS2812B at full white is roughly 3.8 A — far beyond USB, and far beyond what this PCB can dissipate.
Two independent limits are enforced in the output stage, and no effect can override either:
MATRIX_MAX_LEVEL(56/255) caps any single channel after gamma.MATRIX_BUDGET_MA(260 mA) caps estimated whole-frame current; over-budget frames are scaled down proportionally.
Putting the limiter in the output stage rather than in each effect is the point. An effect cannot be written wrongly enough to exceed the budget, because it never gets to decide.
Silent enumeration
pico-fido ships with the OTP and OATH apps enabled, which add a USB keyboard
and a CCID smartcard interface. Those are what make the device announce
itself — macOS opens Keyboard Setup Assistant for any new keyboard, Windows tries
to install a smartcard minidriver. Turning both off also forces USB_ITF_CCID=0
and USB_ITF_WCID=0, leaving a single FIDO HID interface:
idf.py -DENABLE_OTP_APP=0 -DENABLE_OATH_APP=0 build
You lose Yubico-OTP, static passwords and TOTP/HOTP. You keep everything that makes it a passkey: FIDO2.1, U2F, resident credentials, hmac-secret, PIN.
Status
Prototype, and not yet published as a repository. fx_render() being pure and
stateless means frames can be rendered and inspected on a host machine without
flashing anything, which is how the effects were tuned.
More Embedded