Projects Embedded

Matrix FIDO

A board port: driving a 64-pixel panel from pico-fido without ever blocking the CTAP path. The authenticator latches intent on core 0; a 60 Hz task on core 1 does the pushing.

Prototype since Jul 2026 #esp32-s3#fido2#ws2812b#freertos
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.

ThingValue
LEDs64 × WS2812B on GPIO14, serpentine by column, panel mounted 180°
IMUQMI8658, I²C SDA=GPIO11 / SCL=GPIO12, address 0x6B, INT1 GPIO10
User presenceBOOT button on GPIO0
USBNative 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 modeWhat you see
first 2.4 sA shield assembles from scattered pixels — also a panel self-test, so a dead pixel is obvious
MODE_MOUNTEDPadlock breathing, scan sweep every 8 s
MODE_NOT_MOUNTEDSignal arcs pulsing out of the corner
MODE_PROCESSINGComet arc with a fading tail
MODE_BUTTONFingerprint pulsing outward — and the perimeter depletes across the full 30 s CTAP window, amber to red
MODE_SUSPENDEDZ’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.