A USB HID keyboard proxy for Raspberry Pi Pico (or Pico W) that intercepts and processes keystrokes between a physical keyboard and host computer. Provides encrypted text expansion/macros with optional NFC tag authentication and WiFi-based configuration (Pico W only).
Core features (Pico and Pico W):
- Pass-through mode: Keystrokes normally pass directly from physical keyboard to host
- Text expansion: Define single keystrokes that expand to sequences (macros)
- Encrypted storage: Key definitions stored in flash using pico-kvstore with AES-128-GCM encryption
- Public/Private keydefs: Public macros work when sealed; private macros require unseal
- On-demand loading: Reduced memory usage - keydefs loaded from flash as needed
- Passphrase unlock: Decrypt private key definitions with password (PBKDF2 key derivation)
- NFC authentication: Optional, disabled by default. Store/read encryption keys from NFC tags (enable with
--nfcbuild flag) - Auto-seal: Automatically locks after 120 minutes of inactivity
Pico W exclusive features:
- WiFi/HTTP configuration: Edit macros via HTTP API without USB re-enumeration
- Serial WiFi setup: Configure WiFi credentials via UART console (both-shifts+F12)
- Physical web unlock: Both-shifts+SPACE enables 5-minute web access window
- mDNS support: Access device at
hidproxy-XXXX.local(XXXX = last 4 digits of board ID) - MQTT integration: Publish seal/unseal events to Home Assistant (optional, with TLS support)
- Password change: Change encryption password without erasing device (both-shifts+INSERT)
- Raspberry Pi Pico (RP2040) - basic functionality
- Or Raspberry Pi Pico W (RP2040 with CYW43 WiFi) - for WiFi/HTTP features
- USB cables:
- One for connecting physical keyboard to Pico (requires USB-A to micro-USB adapter or cable)
- One for connecting Pico to host computer (micro-USB to USB-A/C)
- Physical keyboard (any USB HID keyboard)
- PN532 NFC Reader (for NFC tag authentication - requires
--nfcbuild flag) - Mifare Classic NFC tags (for storing encryption keys - requires
--nfcbuild flag)
USB Connections:
- Physical keyboard β Pico USB host (via PIO-USB on GPIO 6/7, default)
- Pico USB device β Host computer
Important: Put 22Ξ© or 27Ξ© series resistors on the D+ and Dβ lines between the keyboard USB socket and GPIO 6/7. These are required for correct USB signal levels with PIO-USB.
NFC Reader (optional, requires --nfc build flag):
- PN532 SDA β GPIO 4
- PN532 SCL β GPIO 5
- PN532 VCC β 3.3V
- PN532 GND β GND
Debug UART (optional):
- UART0 TX β GPIO 0
- UART0 RX β GPIO 1
The application uses both cores of the RP2040:
- Core 0: Main logic, TinyUSB device stack (acts as keyboard to host), NFC operations, encryption/decryption
- Core 1: TinyUSB host stack via PIO-USB (receives input from physical keyboard)
Communication between cores uses lock-free queues for HID reports and LED status.
- Pico SDK installed (update path in CMakeLists.txt if not at
/home/paul/pico/pico-sdk) - Git submodules (TinyUSB, Pico-PIO-USB, pico-kvstore, tiny-AES-c, tinycrypt)
- CMake and gcc-arm-none-eabi toolchain
# Basic build (default: Pico W, no NFC)
./build.sh
# Build with NFC support
./build.sh --nfc
# Build with diagnostic system (16KB RAM for keystroke history debugging)
./build.sh --diagnostics
# Build for regular Pico (no WiFi)
./build.sh --board pico
# Other options: --stdio (USB debugging), --debug, --clean
./build.sh --help# Clone repository
git clone <repository-url>
cd hid-proxy
# Initialize submodules
git submodule update --init --recursive
# Build
mkdir build && cd build
cmake .. # Default: no NFC
# cmake -DENABLE_NFC=ON .. # Enable NFC support
makeThis produces:
hid_proxy.elf- ELF binary for debugginghid_proxy.uf2- UF2 file for bootloader flashing
- Hold both shift keys + HOME simultaneously while device is running
- Device reboots into BOOTSEL mode
- Pico appears as USB drive
RPI-RP2 - Copy
hid_proxy.uf2to the drive
openocd -f interface/cmsis-dap.cfg -f target/rp2040.cfg \
-c "adapter speed 5000" \
-c "program hid_proxy.elf verify reset exit"Run "ocd" debug configuration from CLion or your IDE.
By default, all keystrokes pass through transparently from the physical keyboard to the host computer.
To access special functions:
- Press both shift keys simultaneously
- Release all keys
- Press one of the command keys below
| Key | Description |
|---|---|
ENTER |
When sealed: Start passphrase entry to unseal encrypted key definitions. Type your passphrase, then press ENTER again to submit. Wrong passwords are rejected. |
INSERT |
When unsealed: Change passphrase and re-encrypt all private key definitions with new password. New! Full password change now supported. |
ESC |
Cancel the current operation (e.g., exit passphrase entry or key definition mode). |
DEL |
Erase everything - immediately deletes encryption key and all key definitions from flash. No confirmation prompt. Cannot be undone! |
END |
Seal device and clear decrypted key definitions from memory. Encrypted data in flash is preserved. Re-enter passphrase (double-shift + ENTER) to unseal. |
= |
Start defining/redefining a key. Next keystroke is the trigger key, following keystrokes are the expansion. End with another double-shift. |
SPACE |
Print all current key definitions to serial console (debug output) and enable web access for 5 minutes. Useful for viewing configured macros via UART. |
F12 |
Pico W only: Start WiFi configuration console via serial UART. Configure WiFi SSID, password, and country code interactively. New! |
PRINT |
Write the current encryption key to an NFC tag (requires PN532 reader, Mifare Classic tag, and --nfc build flag). |
D |
Diagnostics: Dump last 256 keystrokes to serial console showing received vs. sent reports. Requires --diagnostics build flag. Useful for debugging corruption. |
HOME |
While holding both shifts: Reboot into bootloader mode for flashing new firmware. |
A freshly flashed device starts in the blank state with no passphrase or key definitions.
To set up encryption:
- Double-shift +
INSERTto start setting a passphrase - Type your desired passphrase (letters, numbers, symbols - any keys on your keyboard)
- Press
ENTERto save - The passphrase is used to derive an encryption key (via PBKDF2-SHA256) that protects your private key definitions in flash
- A SHA256 hash of the encryption key is stored for password validation on subsequent unlocks
Important:
- Passphrases support any keyboard characters (keycodes only, not multi-byte Unicode)
- Private keydefs (default) require unseal to access
- Public keydefs work even when sealed (useful for non-sensitive macros)
- Password validation works: Wrong passwords are rejected, keeping encryption key out of memory
- Password change not implemented: To change password, you must erase device (double-shift + DEL) and start over
- There's no password recovery - if you forget it, use double-shift +
DELto erase and start over
If you have a Pico W, use the WiFi/HTTP interface for easier macro editing. See WIFI_SETUP.md for complete guide.
Quick start:
- Configure WiFi (see WIFI_SETUP.md for initial setup)
- Press both shift keys + SPACE on your keyboard to enable web access (5 minutes)
- Download macros:
curl http://hidproxy-XXXX.local/macros.txt > macros.txt(replace XXXX with your board ID) - Edit the file in your favorite text editor
- Upload changes:
curl -X POST http://hidproxy-XXXX.local/macros.txt --data-binary @macros.txt
Note: The mDNS hostname includes the last 4 digits of your board's unique ID (e.g., hidproxy-a1b2.local). Check your serial console output for the exact hostname.
Advantages:
- No USB re-enumeration (keyboard stays functional)
- Edit from any device on your network
- No need to unplug/replug
- Faster iteration
Note: If using regular Pico (non-W), use the interactive "double shift + =" mode to define macros one at a time.
Macro File Format:
# Comments start with #
# Format: trigger { commands... }
a { "Hello, world!" } # 'a' types text
F1 { "Help text" ENTER } # F1 types text + Enter
F2 { ^C "pasted" ^V } # F2: Ctrl+C, type "pasted", Ctrl+V
0x04 { [01:06] [00:00] } # Hex trigger with raw HID reports
Syntax:
trigger { commands... }- Define a macro (whitespace flexible)"text"- Type text (use\"for quotes,\\for backslash)MNEMONIC- Special keys:ENTER,ESC,TAB,F1-F24,PAGEUP,PAGEDOWN, arrow keys, etc.^C- Ctrl+key shorthand (^A through ^Z for Ctrl+A through Ctrl+Z)[mod:key]- Raw HID report in hex (mod = modifier byte, key = keycode)- Triggers can be: single character (
a), mnemonic (F1), or hex (0x04)
Examples:
# Simple text expansion
m { "meeting@example.com" }
# Multi-line with special keys
F5 { "Date: " TAB "Time: " ENTER }
# Copy/paste with formatting
F6 { ^C PAGEDOWN ^V }
# Complex sequence with raw reports
F7 { "Starting..." [01:06] [00:00] "Done!" }
To save and exit:
- Save the file in your text editor
- Eject/unmount the USB drive from your operating system
- The device automatically reboots back to normal HID mode
- Your macros are now active!
Important Notes:
- The 23KB buffer holds approximately 46 full keydefs with 10 reports each
- If serialization fails (too many macros), the file shows an error message
- Macros are stored unencrypted in MSC mode - encryption happens after ejecting
- Any parse errors will be logged to serial console (connect to see errors)
Let's define the letter m to expand to "meeting@example.com":
- Double-shift (press both shifts, release all keys)
- Press
=(enters definition mode) - Press
m(this is the trigger key) - Type
meeting@example.com(this is the expansion) - Double-shift again to finish
Now whenever you press m (while unsealed), it will type out "meeting@example.com".
Note: Key definitions trigger on the keycode alone, ignoring modifier keys. So defining m will also trigger when you press Shift+M (instead of typing 'M').
NFC tags can store your encryption key for quick unlock without typing a passphrase.
Prerequisites:
- Firmware must be built with
--nfcflag - PN532 NFC reader connected to GPIO 4/5
- You must have already set up a passphrase (see First-Time Setup above)
-
Store key to tag:
- Ensure device is unsealed (if sealed, double-shift +
ENTER, type passphrase,ENTER) - Place Mifare Classic tag on PN532 reader
- Double-shift +
PRINT - Encryption key is written to tag block 0x3A
- Ensure device is unsealed (if sealed, double-shift +
-
Authenticate with tag:
- When device is sealed, place the NFC tag on the reader
- Device automatically reads key and unseals (if tag contains valid key)
- No passphrase needed!
Connect to serial console for debug output:
minicom -D /dev/ttyACM0 -b 115200Or use any serial terminal at 115200 baud on:
- Linux/Mac:
/dev/ttyACM0(or/dev/ttyUSB0for UART) - Windows: Check Device Manager for COM port
When connecting the device, macOS may show the Keyboard Setup Assistant. To reset:
sudo rm /Library/Preferences/com.apple.keyboardtype.plist
sudo rebootAfter reboot, go through the keyboard setup assistant when prompted.
- Check both USB cables are connected properly
- Verify the physical keyboard works when connected directly to host
- Check debug serial output for errors
- Try reflashing the firmware
- Ensure device is unsealed (double-shift +
ENTER, enter passphrase) - Check that key definition was saved (double-shift at end of definition)
- Verify not in sealed state (auto-seals after 120 minutes)
- Device provides no visual feedback for wrong passphrase - it just stays sealed
- Connect to serial console (see Debug/Serial Output section) to check for "Could not decrypt" errors
- If you've forgotten the passphrase:
- Double-shift +
DELto erase everything and start over - Warning: This permanently deletes all key definitions
- Double-shift +
Note: NFC support is disabled by default. You must build with ./build.sh --nfc to enable it.
If NFC is enabled but not working:
- Verify wiring: SDA β GPIO 4, SCL β GPIO 5, 3.3V power
- Check PN532 is in I2C mode (DIP switches)
- Use Mifare Classic tags (not Ultralight or DESFire)
- Ensure tag is positioned correctly on reader
If you see "Queue is full" errors:
- You may be sending key definitions too fast
- Try shorter expansions or slower typing
- Known issue - queue backpressure not implemented for passthrough mode
Key constants in hid_proxy.h:
| Constant | Value | Description |
|---|---|---|
FLASH_STORE_SIZE |
64 KB | Maximum size for key definitions |
IDLE_TIMEOUT_MILLIS |
120 min | Auto-seal timeout |
To change the amount of flash memory reserved for storing key definitions, you only need to edit one file:
memmap_custom.ld: This linker script defines the actual memory region in flash.
Example: Changing storage to 128KB
-
Edit
memmap_custom.ld:- Calculate the new start address: The total flash size is 2MB (0x200000). The storage is placed at the end of the flash. So, for 128KB (0x20000), the new start address will be
0x10200000 - 0x20000 = 0x101E0000. - Update the size: The size is
128 * 1024 = 131072. - Modify the
.flash_storagesection:SECTIONS { .flash_storage 0x101E0000 (NOLOAD) : { . = ALIGN(4); __flash_storage_start = .; . = . + 131072; __flash_storage_end = .; } }
- Calculate the new start address: The total flash size is 2MB (0x200000). The storage is placed at the end of the flash. So, for 128KB (0x20000), the new start address will be
-
Rebuild the project.
- Race conditions possible under heavy load
- Queue backpressure not implemented for passthrough mode (see Queue Full Panic above)
- Poor user interface with no visual status feedback for seal/unseal state
Status: π§ On hold (RP2040 PIO-USB issues resolved, Pico is working)
An experimental ESP32-S3 port exists in the esp32-port branch, developed as a contingency when RP2350 boards had PIO-USB compatibility issues. Now that the keystroke corruption bug was fixed in the RP2040 version (LED queue spam issue), the ESP32 port is on hold.
Architecture: Dual ESP32-S3 boards
- ESP32 #1 (USB Host side): Minimal forwarding, receives from keyboard via native USB OTG, sends raw HID to UART
- ESP32 #2 (USB Device side): All application logic (state machine, macros, storage, WiFi), receives UART, acts as keyboard to PC
- UART Protocol: 921600 baud, 1:1 HID forwarding with checksums
Why dual-device: ESP32-S3 can only be USB host OR device at a time (hardware limitation), not both simultaneously like RP2040 with PIO-USB.
Advantages over RP2040:
- β Native USB OTG (no PIO-USB compatibility issues)
- β More RAM (512KB vs 264KB)
- β Faster CPU (240MHz vs 125MHz)
- β Better WiFi (802.11n vs 802.11g on Pico W)
Disadvantages:
- β Requires two boards instead of one
- β UART adds latency (~1-2ms)
- β More complex wiring
- β Higher cost
Current status: Proof-of-concept passthrough working, state machine partially ported. See esp32-port branch for details and documentation (ARCHITECTURE_DECISION.md, ESP32_MIGRATION_PLAN.md).
Recommendation: Use RP2040 Pico/Pico W for production. ESP32 port remains as fallback if future RP2040/RP2350 issues arise.
For detailed technical information, see:
- CLAUDE.md - Architecture, code locations, building instructions (updated with current encryption design)
- docs/UNLOCK_OPTIONS_ANALYSIS.md - Comprehensive comparison of unlock methods, security trade-offs, and fundamental limitations
- docs/BLE_UNLOCK.md - BLE-based unlock design (not implemented, includes security analysis)
- docs/NFC_NDEF_SUPPORT.md - Adding NDEF support for Android NFC compatibility (not implemented)
- KVSTORE_STATUS.md - Current status, completed work, and remaining TODOs
- WIFI_SETUP.md - WiFi and HTTP API configuration guide (Pico W only)
- MQTT_SETUP.md - MQTT integration and Home Assistant setup (Pico W only)