v0.0.32 - Fix KB2040 Python WebUSB handshake and robust frame reassembly for get-public-key

This commit is contained in:
Laan Tungir
2026-06-08 20:24:02 -04:00
parent 6fd09f521a
commit 69c46ab2a7
14 changed files with 2287 additions and 2 deletions

View File

@@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""
KB2040 Hidden Signer host client (WebUSB/vendor interface via pyusb).
Protocol:
4-byte big-endian length + UTF-8 JSON body.
Default target:
VID:PID 239a:8104
Examples:
python3 examples/kb2040_hidden_signer_client.py status
python3 examples/kb2040_hidden_signer_client.py set-mnemonic --mnemonic "abandon ... about"
python3 examples/kb2040_hidden_signer_client.py get-public-key
python3 examples/kb2040_hidden_signer_client.py sign-event --event '{"kind":1,"content":"hello"}'
"""
import argparse
import json
import struct
import sys
import time
try:
import usb.core
import usb.util
except ImportError:
print("Missing pyusb. Install with: pip install pyusb", file=sys.stderr)
sys.exit(2)
def find_vendor_interface(dev):
cfg = dev.get_active_configuration()
for itf in cfg:
if itf.bInterfaceClass == 0xFF:
ep_out = None
ep_in = None
for ep in itf:
direction = usb.util.endpoint_direction(ep.bEndpointAddress)
if direction == usb.util.ENDPOINT_OUT:
ep_out = ep
elif direction == usb.util.ENDPOINT_IN:
ep_in = ep
if ep_out is not None and ep_in is not None:
return itf, ep_out, ep_in
return None, None, None
def open_device(vid: int, pid: int):
dev = usb.core.find(idVendor=vid, idProduct=pid)
if dev is None:
raise RuntimeError(f"Device {vid:04x}:{pid:04x} not found")
try:
dev.set_configuration()
except usb.core.USBError:
pass
itf, ep_out, ep_in = find_vendor_interface(dev)
if itf is None:
raise RuntimeError("Vendor/WebUSB interface (class 0xFF) not found")
itf_num = itf.bInterfaceNumber
try:
if dev.is_kernel_driver_active(itf_num):
dev.detach_kernel_driver(itf_num)
except (NotImplementedError, usb.core.USBError):
pass
usb.util.claim_interface(dev, itf_num)
# WebUSB-style connect handshake (CDC SET_CONTROL_LINE_STATE / request 0x22).
# Firmware gates IN transfers on this "connected" state.
try:
dev.ctrl_transfer(0x21, 0x22, 0x0001, itf_num, None)
except usb.core.USBError:
# Some stacks may not require/implement this; continue and let RPC I/O decide.
pass
return dev, itf_num, ep_out, ep_in
def close_device(dev, itf_num: int):
try:
usb.util.release_interface(dev, itf_num)
except usb.core.USBError:
pass
_rx_remainder = bytearray()
def send_frame(ep_out, payload: bytes):
frame = struct.pack(">I", len(payload)) + payload
ep_out.write(frame)
def recv_exact(ep_in, n: int, timeout_ms: int):
global _rx_remainder
out = bytearray()
# Consume previously over-read bytes first.
if _rx_remainder:
take = min(n, len(_rx_remainder))
out.extend(_rx_remainder[:take])
del _rx_remainder[:take]
deadline = time.time() + max(1.0, timeout_ms / 1000.0)
packet_size = max(64, int(getattr(ep_in, "wMaxPacketSize", 64)))
while len(out) < n and time.time() < deadline:
chunk = ep_in.read(packet_size, timeout=timeout_ms)
out.extend(bytes(chunk))
if len(out) < n:
raise TimeoutError(f"Timeout while reading {n} bytes (got {len(out)})")
if len(out) > n:
_rx_remainder.extend(out[n:])
del out[n:]
return bytes(out)
def recv_frame(ep_in, timeout_ms: int):
hdr = recv_exact(ep_in, 4, timeout_ms)
body_len = struct.unpack(">I", hdr)[0]
if body_len > 64 * 1024:
raise ValueError(f"Refusing oversized frame: {body_len}")
body = recv_exact(ep_in, body_len, timeout_ms)
return body
def rpc(dev_args, method: str, params: dict):
global _rx_remainder
dev, itf_num, ep_out, ep_in = open_device(dev_args.vid, dev_args.pid)
try:
_rx_remainder.clear()
req = {"method": method}
req.update(params)
payload = json.dumps(req, separators=(",", ":")).encode("utf-8")
send_frame(ep_out, payload)
resp = recv_frame(ep_in, dev_args.timeout_ms)
return json.loads(resp.decode("utf-8", errors="replace"))
finally:
close_device(dev, itf_num)
def cmd_status(args):
print(json.dumps(rpc(args, "get_status", {}), indent=2))
def cmd_set_mnemonic(args):
print(json.dumps(rpc(args, "set_mnemonic", {"mnemonic": args.mnemonic}), indent=2))
def cmd_set_auto_approve(args):
value = args.value.lower() in ("1", "true", "yes", "on")
print(json.dumps(rpc(args, "set_auto_approve", {"value": value}), indent=2))
def cmd_get_public_key(args):
print(json.dumps(rpc(args, "get_public_key", {}), indent=2))
def cmd_sign_event(args):
event = json.loads(args.event)
print(json.dumps(rpc(args, "sign_event", {"event": event}), indent=2))
def build_parser():
p = argparse.ArgumentParser()
p.add_argument("--vid", type=lambda s: int(s, 16), default=0x239A, help="USB VID in hex (default: 239A)")
p.add_argument("--pid", type=lambda s: int(s, 16), default=0x8104, help="USB PID in hex (default: 8104)")
p.add_argument("--timeout-ms", type=int, default=3000, help="USB read timeout in ms")
sub = p.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("status")
s.set_defaults(func=cmd_status)
s = sub.add_parser("set-mnemonic")
s.add_argument("--mnemonic", required=True)
s.set_defaults(func=cmd_set_mnemonic)
s = sub.add_parser("set-auto-approve")
s.add_argument("--value", required=True, help="true|false")
s.set_defaults(func=cmd_set_auto_approve)
s = sub.add_parser("get-public-key")
s.set_defaults(func=cmd_get_public_key)
s = sub.add_parser("sign-event")
s.add_argument("--event", required=True, help="JSON object string")
s.set_defaults(func=cmd_sign_event)
return p
def main():
parser = build_parser()
args = parser.parse_args()
try:
args.func(args)
return 0
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,77 @@
// Proof-of-concept: KB2040 as composite HID + WebUSB using Adafruit TinyUSB
//
// Board/IDE requirements:
// - Board: Adafruit KB2040 (RP2040)
// - Tools -> USB Stack: Adafruit TinyUSB
// - Library: Adafruit TinyUSB Library
#include <Adafruit_TinyUSB.h>
// HID report descriptor: Consumer Control for media keys
static const uint8_t desc_hid_report[] = {
TUD_HID_REPORT_DESC_CONSUMER(HID_REPORT_ID(1))
};
Adafruit_USBD_HID usb_hid;
Adafruit_USBD_WebUSB usb_web;
WEBUSB_URL_DEF(landing_url, 1 /* https */, "example.com/nsigner/kb2040");
static uint32_t last_hid_ms = 0;
static uint8_t rx_buf[64];
void setup() {
// WebUSB vendor interface
usb_web.setLandingPage(&landing_url);
usb_web.setStringDescriptor("n_signer WebUSB");
usb_web.begin();
// HID interface (media keys)
usb_hid.setPollInterval(2);
usb_hid.setReportDescriptor(desc_hid_report, sizeof(desc_hid_report));
usb_hid.setStringDescriptor("KB2040 Media");
usb_hid.begin();
pinMode(LED_BUILTIN, OUTPUT);
digitalWrite(LED_BUILTIN, LOW);
// Wait for host enumeration
while (!TinyUSBDevice.mounted()) {
delay(1);
}
digitalWrite(LED_BUILTIN, HIGH);
}
void loop() {
// Demonstrate HID activity: send volume-up every 5 seconds
if (millis() - last_hid_ms >= 5000) {
last_hid_ms = millis();
if (usb_hid.ready()) {
uint16_t key = HID_USAGE_CONSUMER_VOLUME_INCREMENT;
usb_hid.sendReport16(1, key);
delay(30);
usb_hid.sendReport16(1, 0);
digitalWrite(LED_BUILTIN, LOW);
delay(80);
digitalWrite(LED_BUILTIN, HIGH);
}
}
// Demonstrate WebUSB activity: echo back received bytes with prefix
if (usb_web.available()) {
uint32_t n = usb_web.read(rx_buf, sizeof(rx_buf));
if (n > 0) {
usb_web.write((const uint8_t *)"KB2040:", 7);
usb_web.write(rx_buf, n);
usb_web.flush();
for (int i = 0; i < 2; i++) {
digitalWrite(LED_BUILTIN, LOW);
delay(50);
digitalWrite(LED_BUILTIN, HIGH);
delay(50);
}
}
}
}

View File

@@ -0,0 +1,81 @@
# KB2040 Hidden Signer (HID + WebUSB)
This directory contains an Arduino firmware scaffold that implements the `kb2040_hidden_signer` plan on top of a TinyUSB composite device.
- Firmware: [`kb2040_hidden_signer.ino`](./kb2040_hidden_signer.ino)
- Host client: [`examples/kb2040_hidden_signer_client.py`](../../examples/kb2040_hidden_signer_client.py)
## What is implemented
1. Composite USB device with two active interfaces:
- HID Consumer Control (media keys)
- Vendor/WebUSB transport (framed JSON)
2. Default media mode:
- Play/Pause, Next, Previous buttons emit HID consumer usages
- Rotary encoder emits volume up/down
3. Hidden mode toggle:
- Hold PLAY + NEXT for 3 seconds to toggle media/signer mode
4. WebUSB framed transport:
- 4-byte big-endian length + JSON payload
5. Signer RPC scaffold methods:
- `ping`
- `get_status`
- `set_mnemonic`
- `set_auto_approve`
- `get_public_key` (deterministic placeholder pubkey)
- `sign_event` (approval gate + placeholder signature)
## What remains to reach production n_signer parity
1. Replace placeholder crypto with real `n_signer` core integration:
- mnemonic -> seed -> key derivation
- secp256k1 schnorr signing
- full auth envelope verification
2. Replace naive JSON parsing with robust parsing (e.g. `ArduinoJson` or cJSON port).
3. Wire exact `keyboard_modules` pin mapping and display abstraction.
4. Add secure memory wiping and persistent policy storage if required.
## Arduino setup
1. Install Adafruit board index in Arduino IDE.
2. Install **Adafruit RP2040** boards package.
3. Select board **Adafruit KB2040**.
4. Set **Tools -> USB Stack -> Adafruit TinyUSB**.
5. Install library **Adafruit TinyUSB Library**.
6. Open and flash [`kb2040_hidden_signer.ino`](./kb2040_hidden_signer.ino).
## Host validation
Install dependency:
```bash
pip install pyusb
```
Basic dual-interface verification:
```bash
python3 tests/test_kb2040_dual.py
```
Signer RPC flow (WebUSB/vendor interface):
```bash
python3 examples/kb2040_hidden_signer_client.py status
python3 examples/kb2040_hidden_signer_client.py set-mnemonic --mnemonic "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
python3 examples/kb2040_hidden_signer_client.py get-public-key
python3 examples/kb2040_hidden_signer_client.py sign-event --event '{"kind":1,"content":"hello"}'
```
## Pin mapping
Current defaults in [`kb2040_hidden_signer.ino`](./kb2040_hidden_signer.ino):
- `PIN_BTN_PLAY = 2`
- `PIN_BTN_NEXT = 3`
- `PIN_BTN_PREV = 4`
- `PIN_ENC_A = 5`
- `PIN_ENC_B = 6`
- `PIN_ENC_SW = 7`
Update these constants to match your physical `keyboard_modules` build.

View File

@@ -0,0 +1,383 @@
#include "display.h"
#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1327.h>
namespace {
static constexpr uint8_t kDisplayWidth = 128;
static constexpr uint8_t kDisplayHeight = 128;
static constexpr uint8_t kDefaultI2cAddr = 0x3D;
static constexpr uint8_t kGrayDim = 0x04;
static constexpr uint8_t kGrayBar = 0x08;
static constexpr uint8_t kGrayText = 0x0F;
Adafruit_SSD1327 g_display(kDisplayWidth, kDisplayHeight, &Wire, -1, 400000, 100000);
bool g_displayPresent = false;
bool g_dirty = true;
display_view_t g_view = DISPLAY_VIEW_VOLUME;
uint8_t g_volume = 50;
bool g_signerMode = false;
char g_status[24] = "BOOT";
uint8_t g_menuSelected = 0;
uint8_t g_wordSlot = 0;
uint8_t g_wordTotal = 12;
uint16_t g_wordIndex = 0;
char g_word[16] = "abandon";
uint8_t g_mnemonicPage = 0;
char g_mnemonic[256] = "";
char g_msgTitle[18] = "";
char g_msgLine1[22] = "";
char g_msgLine2[22] = "";
enum signer_screen_t {
SIGNER_SCREEN_MENU = 0,
SIGNER_SCREEN_WORD_PICK = 1,
SIGNER_SCREEN_MNEMONIC_PAGE = 2,
SIGNER_SCREEN_MESSAGE = 3,
SIGNER_SCREEN_READY = 4,
};
signer_screen_t g_signerScreen = SIGNER_SCREEN_MENU;
uint8_t clampVolume(uint8_t v) {
return (v > 100) ? 100 : v;
}
void drawStatusLine() {
g_display.setTextSize(1);
g_display.setTextColor(kGrayText);
g_display.setCursor(2, 118);
g_display.print(g_status);
}
void drawVolumeView() {
g_display.setTextWrap(false);
g_display.setTextSize(1);
g_display.setTextColor(kGrayDim);
g_display.setCursor(6, 4);
g_display.print(g_signerMode ? "MODE: SIGNER" : "MODE: MEDIA");
g_display.setTextColor(kGrayDim);
g_display.setTextSize(2);
g_display.setCursor(40, 18);
g_display.print("VOL");
char volBuf[4];
snprintf(volBuf, sizeof(volBuf), "%u", g_volume);
int16_t x1 = 0;
int16_t y1 = 0;
uint16_t w = 0;
uint16_t h = 0;
g_display.setTextSize(6);
g_display.getTextBounds(volBuf, 0, 0, &x1, &y1, &w, &h);
const int16_t x = static_cast<int16_t>((kDisplayWidth - w) / 2);
const int16_t y = 42;
g_display.setTextColor(kGrayText);
g_display.setCursor(x, y);
g_display.print(volBuf);
const int16_t barX = 8;
const int16_t barY = 102;
const int16_t barW = kDisplayWidth - 16;
const int16_t barH = 8;
g_display.drawRect(barX, barY, barW, barH, kGrayDim);
const int16_t fillW = static_cast<int16_t>((static_cast<uint32_t>(barW - 2) * g_volume) / 100U);
if (fillW > 0) {
g_display.fillRect(barX + 1, barY + 1, fillW, barH - 2, kGrayBar);
}
drawStatusLine();
}
void drawSignerMenu() {
g_display.setTextWrap(false);
g_display.setTextSize(1);
g_display.setTextColor(kGrayDim);
g_display.setCursor(2, 2);
g_display.print("SIGNER MENU");
const char *items[2] = {"Generate new", "Enter mnemonic"};
g_display.setTextColor(kGrayText);
g_display.setTextSize(2);
for (uint8_t i = 0; i < 2; ++i) {
g_display.setCursor(6, 28 + i * 24);
g_display.print((i == g_menuSelected) ? ">" : " ");
g_display.setCursor(20, 28 + i * 24);
g_display.print(items[i]);
}
drawStatusLine();
}
void drawWordPick() {
g_display.setTextWrap(false);
g_display.setTextSize(1);
g_display.setTextColor(kGrayDim);
g_display.setCursor(2, 2);
char hdr[20];
snprintf(hdr, sizeof(hdr), "WORD %u/%u", (unsigned)(g_wordSlot + 1), (unsigned)g_wordTotal);
g_display.print(hdr);
char idxBuf[20];
snprintf(idxBuf, sizeof(idxBuf), "IDX %u", (unsigned)g_wordIndex);
g_display.setCursor(82, 2);
g_display.print(idxBuf);
g_display.drawRect(4, 24, 120, 56, kGrayDim);
g_display.setTextSize(2);
g_display.setTextColor(kGrayText);
g_display.setCursor(10, 44);
g_display.print(g_word);
g_display.setTextSize(1);
g_display.setCursor(6, 88);
g_display.print("ENC scroll PLAY select");
g_display.setCursor(6, 98);
g_display.print("NEXT back");
drawStatusLine();
}
void drawMnemonicPage() {
g_display.setTextWrap(false);
g_display.setTextSize(1);
g_display.setTextColor(kGrayDim);
g_display.setCursor(2, 2);
g_display.print("GENERATED PHRASE");
char copy[256];
strncpy(copy, g_mnemonic, sizeof(copy) - 1);
copy[sizeof(copy) - 1] = '\0';
const uint8_t perPage = 4;
const uint8_t startWord = g_mnemonicPage * perPage;
const uint8_t endWord = startWord + perPage;
uint8_t wordNum = 0;
char *saveptr = nullptr;
char *tok = strtok_r(copy, " ", &saveptr);
uint8_t line = 0;
while (tok) {
if (wordNum >= startWord && wordNum < endWord) {
char lineBuf[32];
snprintf(lineBuf, sizeof(lineBuf), "%2u %s", (unsigned)(wordNum + 1), tok);
g_display.setTextColor(kGrayText);
g_display.setCursor(8, 24 + line * 18);
g_display.print(lineBuf);
line++;
}
wordNum++;
tok = strtok_r(nullptr, " ", &saveptr);
}
g_display.setTextSize(1);
g_display.setCursor(6, 98);
g_display.print("ENC page PLAY confirm");
g_display.setCursor(6, 108);
g_display.print("NEXT cancel");
drawStatusLine();
}
void drawMessage() {
g_display.setTextWrap(false);
g_display.setTextSize(1);
g_display.setTextColor(kGrayDim);
g_display.setCursor(2, 2);
g_display.print(g_msgTitle);
g_display.setTextSize(2);
g_display.setTextColor(kGrayText);
g_display.setCursor(6, 34);
g_display.print(g_msgLine1);
g_display.setCursor(6, 62);
g_display.print(g_msgLine2);
drawStatusLine();
}
void drawSignerReady() {
g_display.setTextWrap(false);
g_display.setTextSize(1);
g_display.setTextColor(kGrayDim);
g_display.setCursor(2, 2);
g_display.print("SIGNER READY");
g_display.drawRect(4, 22, 120, 62, kGrayDim);
g_display.setTextSize(2);
g_display.setTextColor(kGrayText);
g_display.setCursor(16, 36);
g_display.print("Awaiting");
g_display.setCursor(30, 58);
g_display.print("host");
g_display.setTextSize(1);
g_display.setCursor(6, 96);
g_display.print("NEXT/PLAY: menu");
drawStatusLine();
}
void renderFrame() {
if (!g_displayPresent) {
return;
}
g_display.clearDisplay();
if (g_view == DISPLAY_VIEW_VOLUME) {
drawVolumeView();
} else {
switch (g_signerScreen) {
case SIGNER_SCREEN_MENU:
drawSignerMenu();
break;
case SIGNER_SCREEN_WORD_PICK:
drawWordPick();
break;
case SIGNER_SCREEN_MNEMONIC_PAGE:
drawMnemonicPage();
break;
case SIGNER_SCREEN_MESSAGE:
drawMessage();
break;
case SIGNER_SCREEN_READY:
drawSignerReady();
break;
default:
drawMessage();
break;
}
}
g_display.display();
}
void copy_text(char *dst, size_t dst_sz, const char *src) {
if (!dst || dst_sz == 0) {
return;
}
if (!src) {
dst[0] = '\0';
return;
}
strncpy(dst, src, dst_sz - 1);
dst[dst_sz - 1] = '\0';
}
} // namespace
void display_init() {
Wire.setClock(400000);
Wire.begin();
Wire.beginTransmission(kDefaultI2cAddr);
const uint8_t probeErr = Wire.endTransmission();
if (probeErr != 0) {
g_displayPresent = false;
return;
}
g_displayPresent = g_display.begin(kDefaultI2cAddr, true);
if (!g_displayPresent) {
return;
}
g_dirty = true;
renderFrame();
}
bool display_is_present() {
return g_displayPresent;
}
void display_set_view(display_view_t view) {
if (g_view == view) {
return;
}
g_view = view;
g_dirty = true;
}
void display_set_mode(bool signer_mode) {
if (g_signerMode == signer_mode) {
return;
}
g_signerMode = signer_mode;
g_dirty = true;
}
void display_set_volume(uint8_t volume) {
const uint8_t v = clampVolume(volume);
if (v == g_volume) {
return;
}
g_volume = v;
g_dirty = true;
}
void display_show_signer_menu(uint8_t selected_index) {
g_signerScreen = SIGNER_SCREEN_MENU;
g_menuSelected = selected_index > 1 ? 1 : selected_index;
g_dirty = true;
}
void display_show_word_pick(uint8_t slot_index, uint8_t total_slots, const char *word, uint16_t word_index) {
g_signerScreen = SIGNER_SCREEN_WORD_PICK;
g_wordSlot = slot_index;
g_wordTotal = total_slots;
g_wordIndex = word_index;
copy_text(g_word, sizeof(g_word), word);
g_dirty = true;
}
void display_show_mnemonic_page(const char *mnemonic, uint8_t page_index) {
g_signerScreen = SIGNER_SCREEN_MNEMONIC_PAGE;
g_mnemonicPage = page_index;
copy_text(g_mnemonic, sizeof(g_mnemonic), mnemonic);
g_dirty = true;
}
void display_show_message(const char *title, const char *line1, const char *line2) {
g_signerScreen = SIGNER_SCREEN_MESSAGE;
copy_text(g_msgTitle, sizeof(g_msgTitle), title);
copy_text(g_msgLine1, sizeof(g_msgLine1), line1);
copy_text(g_msgLine2, sizeof(g_msgLine2), line2);
g_dirty = true;
}
void display_show_signer_ready() {
g_signerScreen = SIGNER_SCREEN_READY;
g_dirty = true;
}
void display_set_status(const char *status) {
if (!status) {
return;
}
copy_text(g_status, sizeof(g_status), status);
g_dirty = true;
}
void display_tick() {
if (!g_displayPresent || !g_dirty) {
return;
}
renderFrame();
g_dirty = false;
}

View File

@@ -0,0 +1,37 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
enum display_view_t {
DISPLAY_VIEW_VOLUME = 0,
DISPLAY_VIEW_SIGNER = 1,
};
// Initialize the SSD1327 OLED on KB2040 STEMMA QT (Wire = Wire0, GPIO12/13).
void display_init();
// Returns true when the display was detected and initialized successfully.
bool display_is_present();
// Select top-level display view.
void display_set_view(display_view_t view);
// Set current mode text for volume view header.
void display_set_mode(bool signer_mode);
// Set local volume value shown on-screen. Input is clamped to 0..100.
void display_set_volume(uint8_t volume);
// Signer UI rendering helpers.
void display_show_signer_menu(uint8_t selected_index);
void display_show_word_pick(uint8_t slot_index, uint8_t total_slots, const char *word, uint16_t word_index);
void display_show_mnemonic_page(const char *mnemonic, uint8_t page_index);
void display_show_message(const char *title, const char *line1, const char *line2);
void display_show_signer_ready();
// Set short status text line (truncated to fit).
void display_set_status(const char *status);
// Non-blocking tick; renders only when state changed.
void display_tick();

View File

@@ -0,0 +1,874 @@
#include <Adafruit_TinyUSB.h>
#include "display.h"
#include "mnemonic_kb.h"
// ============================================================
// KB2040 Hidden Signer
// - Composite USB: HID Consumer + WebUSB vendor transport
// - Media mode default
// - Hidden signer mode via PLAY + PREV press chord
// ============================================================
static const uint8_t desc_hid_report[] = {
TUD_HID_REPORT_DESC_CONSUMER(HID_REPORT_ID(1))
};
Adafruit_USBD_HID usb_hid;
Adafruit_USBD_WebUSB usb_web;
WEBUSB_URL_DEF(landing_url, 1 /* https */, "example.com/nsigner/kb2040");
// ------------------ Hardware pin mapping --------------------
static constexpr uint8_t PIN_ENC_A = 2;
static constexpr uint8_t PIN_ENC_B = 3;
static constexpr uint8_t PIN_BTN_PLAY = 5;
static constexpr uint8_t PIN_BTN_NEXT = 6;
static constexpr uint8_t PIN_BTN_PREV = 7;
static constexpr int32_t DETENTS_PER_STEP = 4;
static constexpr uint32_t DEBOUNCE_MS = 20;
static bool s_display_init_done = false;
static constexpr uint32_t DISPLAY_INIT_DELAY_MS = 750;
// ------------------ Transport diagnostics -------------------
// These counters let us confirm, without any host tooling, whether
// request bytes actually reach the device and whether the WebUSB
// vendor interface believes it is "connected".
static uint32_t s_diag_bytes_rx = 0; // total bytes pulled from usb_web
static uint32_t s_diag_frames_rx = 0; // complete framed requests parsed
static uint32_t s_diag_responses_tx = 0; // responses written back
static bool s_diag_last_connected = false;
static uint32_t s_diag_last_report_ms = 0;
static constexpr uint32_t DIAG_REPORT_INTERVAL_MS = 500;
// ---------------------- Modes / state -----------------------
enum device_mode_t {
MODE_MEDIA = 0,
MODE_SIGNER = 1,
};
enum signer_ui_state_t {
SIGNER_UI_MENU = 0,
SIGNER_UI_READY = 1,
SIGNER_UI_GENERATE_REVIEW = 2,
SIGNER_UI_ENTER_LETTER = 3,
SIGNER_UI_ENTER_PICK = 4,
};
static device_mode_t s_mode = MODE_MEDIA;
static signer_ui_state_t s_signer_ui_state = SIGNER_UI_MENU;
static bool s_mode_switch_latched = false;
// Demo signer state (placeholder until full n_signer crypto integration)
static char s_mnemonic[256] = "";
static bool s_seed_loaded = false;
static bool s_auto_approve = false;
// Signer UI state
static uint8_t s_signer_menu_selected = 0; // 0=Generate, 1=Enter
static uint8_t s_signer_review_page = 0; // 0..2 for 12 words at 4/page
static char s_generated_mnemonic[256] = "";
static uint8_t s_enter_slot = 0; // 0..11
static uint8_t s_enter_letter_index = 0; // 0..25 => a..z
static uint16_t s_enter_word_index = 0; // current picker index
static uint16_t s_enter_selected_indices[12] = {0};
static void signer_enter_ready_screen();
// -------------------- Transport framing ----------------------
static uint8_t s_rx_buf[2048];
static size_t s_rx_len = 0;
static uint8_t s_tx_buf[2048];
// ---------------------- Encoder state ------------------------
volatile int8_t s_isr_last_ab = 0;
volatile int32_t s_detent_accumulator = 0;
uint8_t s_local_volume = 50;
static constexpr int8_t QUAD_TABLE[16] = {
0, -1, +1, 0,
+1, 0, 0, -1,
-1, 0, 0, +1,
0, +1, -1, 0
};
// ---------------------- Button state -------------------------
struct ButtonState {
uint8_t pin;
bool stable_level; // HIGH=released, LOW=pressed
bool last_sample;
uint32_t last_change_ms;
};
ButtonState s_buttons[] = {
{ PIN_BTN_PLAY, true, true, 0 },
{ PIN_BTN_NEXT, true, true, 0 },
{ PIN_BTN_PREV, true, true, 0 },
};
static bool s_evt_play_pressed = false;
static bool s_evt_next_pressed = false;
static bool s_evt_prev_pressed = false;
// ---------------------- Utilities ----------------------------
static bool btn_pressed(int pin) {
return digitalRead(pin) == LOW;
}
static void led_blink(int n, int on_ms, int off_ms) {
for (int i = 0; i < n; i++) {
digitalWrite(LED_BUILTIN, LOW);
delay(on_ms);
digitalWrite(LED_BUILTIN, HIGH);
delay(off_ms);
}
}
static void send_consumer_key(uint16_t usage) {
if (!usb_hid.ready()) return;
usb_hid.sendReport16(1, usage);
delay(2);
usb_hid.sendReport16(1, 0);
}
static int8_t read_ab() {
const int a = digitalRead(PIN_ENC_A) ? 1 : 0;
const int b = digitalRead(PIN_ENC_B) ? 1 : 0;
return static_cast<int8_t>((a << 1) | b);
}
void on_encoder_edge() {
const int8_t curr_ab = read_ab();
const uint8_t idx = static_cast<uint8_t>((s_isr_last_ab << 2) | curr_ab);
const int8_t delta = QUAD_TABLE[idx & 0x0F];
if (delta != 0) {
s_detent_accumulator += delta;
}
s_isr_last_ab = curr_ab;
}
// Pop full encoder detents from the ISR accumulator, preserving partial transitions.
static int32_t encoder_take_steps() {
int32_t transitions = 0;
noInterrupts();
transitions = s_detent_accumulator;
s_detent_accumulator = 0;
interrupts();
int32_t steps = 0;
while (transitions >= DETENTS_PER_STEP) {
transitions -= DETENTS_PER_STEP;
steps += 1;
}
while (transitions <= -DETENTS_PER_STEP) {
transitions += DETENTS_PER_STEP;
steps -= 1;
}
if (transitions != 0) {
noInterrupts();
s_detent_accumulator += transitions;
interrupts();
}
return steps;
}
static int8_t volume_change_by(int8_t delta) {
int16_t next = static_cast<int16_t>(s_local_volume) + static_cast<int16_t>(delta);
if (next < 0) {
next = 0;
} else if (next > 100) {
next = 100;
}
const uint8_t clamped = static_cast<uint8_t>(next);
const int8_t applied = static_cast<int8_t>(static_cast<int16_t>(clamped) - static_cast<int16_t>(s_local_volume));
s_local_volume = clamped;
return applied;
}
static void write_u32_be(uint8_t *p, uint32_t v) {
p[0] = (uint8_t)((v >> 24) & 0xFF);
p[1] = (uint8_t)((v >> 16) & 0xFF);
p[2] = (uint8_t)((v >> 8) & 0xFF);
p[3] = (uint8_t)(v & 0xFF);
}
static uint32_t read_u32_be(const uint8_t *p) {
return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | (uint32_t)p[3];
}
static bool json_extract_string(const char *json, const char *key, char *out, size_t out_sz) {
char needle[64];
snprintf(needle, sizeof(needle), "\"%s\"", key);
const char *k = strstr(json, needle);
if (!k) return false;
const char *colon = strchr(k, ':');
if (!colon) return false;
const char *q1 = strchr(colon, '"');
if (!q1) return false;
q1++;
const char *q2 = strchr(q1, '"');
if (!q2) return false;
size_t n = (size_t)(q2 - q1);
if (n >= out_sz) n = out_sz - 1;
memcpy(out, q1, n);
out[n] = '\0';
return true;
}
// Deterministic placeholder key derivation for transport integration testing.
static void pseudo_pubkey_hex(char out_hex_64[65]) {
uint32_t h = 2166136261u;
for (size_t i = 0; i < strlen(s_mnemonic); i++) {
h ^= (uint8_t)s_mnemonic[i];
h *= 16777619u;
}
for (int i = 0; i < 32; i++) {
uint8_t b = (uint8_t)((h >> ((i % 4) * 8)) ^ (i * 37));
static const char *hex = "0123456789abcdef";
out_hex_64[i * 2] = hex[(b >> 4) & 0x0F];
out_hex_64[i * 2 + 1] = hex[b & 0x0F];
}
out_hex_64[64] = '\0';
}
static void signer_apply_seed(const char *mnemonic) {
if (!mnemonic) {
return;
}
strncpy(s_mnemonic, mnemonic, sizeof(s_mnemonic) - 1);
s_mnemonic[sizeof(s_mnemonic) - 1] = '\0';
s_seed_loaded = strlen(s_mnemonic) > 0;
display_set_status(s_seed_loaded ? "Seed loaded" : "Seed cleared");
}
static bool wait_for_user_approval(uint32_t timeout_ms) {
display_show_message("APPROVAL", "PLAY=Allow", "PREV=Deny");
display_set_status("Waiting approval");
uint32_t start = millis();
while ((millis() - start) < timeout_ms) {
if (btn_pressed(PIN_BTN_PLAY)) {
while (btn_pressed(PIN_BTN_PLAY)) delay(5);
display_set_status("Approved");
return true;
}
if (btn_pressed(PIN_BTN_PREV)) {
while (btn_pressed(PIN_BTN_PREV)) delay(5);
display_set_status("Denied");
return false;
}
display_tick();
delay(5);
}
display_set_status("Approval timeout");
return false;
}
static void send_json_response(const char *json) {
size_t n = strlen(json);
if (n > (sizeof(s_tx_buf) - 4)) return;
write_u32_be(s_tx_buf, (uint32_t)n);
memcpy(s_tx_buf + 4, json, n);
usb_web.write(s_tx_buf, n + 4);
usb_web.flush();
s_diag_responses_tx++;
}
static void handle_rpc(const char *req_json) {
char method[64] = {0};
if (!json_extract_string(req_json, "method", method, sizeof(method))) {
send_json_response("{\"error\":{\"code\":-32600,\"message\":\"invalid request\"}}");
display_set_status("RPC invalid request");
return;
}
if (strcmp(method, "ping") == 0) {
send_json_response("{\"result\":\"pong\"}");
display_set_status("RPC ping");
return;
}
if (strcmp(method, "get_status") == 0) {
char resp[256];
snprintf(resp, sizeof(resp),
"{\"result\":{\"mode\":\"%s\",\"seed_loaded\":%s,\"auto_approve\":%s}}",
s_mode == MODE_MEDIA ? "media" : "signer",
s_seed_loaded ? "true" : "false",
s_auto_approve ? "true" : "false");
send_json_response(resp);
display_set_status("RPC get_status");
return;
}
if (strcmp(method, "set_mnemonic") == 0) {
char phrase[256] = {0};
if (!json_extract_string(req_json, "mnemonic", phrase, sizeof(phrase))) {
send_json_response("{\"error\":{\"code\":-32602,\"message\":\"missing mnemonic\"}}");
display_set_status("Mnemonic missing");
return;
}
signer_apply_seed(phrase);
if (s_mode == MODE_SIGNER && s_seed_loaded) {
signer_enter_ready_screen();
}
send_json_response("{\"result\":\"ok\"}");
return;
}
if (strcmp(method, "set_auto_approve") == 0) {
if (strstr(req_json, "\"value\":true") != nullptr) {
s_auto_approve = true;
send_json_response("{\"result\":true}");
display_set_status("Auto-approve ON");
} else if (strstr(req_json, "\"value\":false") != nullptr) {
s_auto_approve = false;
send_json_response("{\"result\":false}");
display_set_status("Auto-approve OFF");
} else {
send_json_response("{\"error\":{\"code\":-32602,\"message\":\"missing value\"}}");
display_set_status("Auto-approve invalid");
}
return;
}
if (strcmp(method, "get_public_key") == 0) {
if (!s_seed_loaded) {
send_json_response("{\"error\":{\"code\":2014,\"message\":\"seed not loaded\"}}");
display_set_status("No seed");
return;
}
char pubhex[65];
pseudo_pubkey_hex(pubhex);
char resp[192];
snprintf(resp, sizeof(resp), "{\"result\":{\"pubkey\":\"%s\"}}", pubhex);
send_json_response(resp);
display_set_status("Pubkey served");
return;
}
if (strcmp(method, "sign_event") == 0) {
if (!s_seed_loaded) {
send_json_response("{\"error\":{\"code\":2014,\"message\":\"seed not loaded\"}}");
display_set_status("Sign blocked:no seed");
return;
}
if (s_mode != MODE_SIGNER) {
send_json_response("{\"error\":{\"code\":2015,\"message\":\"not in signer mode\"}}");
display_set_status("Sign blocked:mode");
return;
}
bool approved = s_auto_approve ? true : wait_for_user_approval(30000);
if (!approved) {
send_json_response("{\"error\":{\"code\":2001,\"message\":\"user denied or timeout\"}}");
// Return signer UI screen after approval prompt
if (s_signer_ui_state == SIGNER_UI_MENU) {
display_show_signer_menu(s_signer_menu_selected);
} else if (s_signer_ui_state == SIGNER_UI_READY) {
signer_enter_ready_screen();
}
return;
}
send_json_response("{\"result\":{\"sig\":\"dev_signature_placeholder\"}}");
display_set_status("Event signed");
if (s_signer_ui_state == SIGNER_UI_MENU) {
display_show_signer_menu(s_signer_menu_selected);
} else if (s_signer_ui_state == SIGNER_UI_READY) {
signer_enter_ready_screen();
}
return;
}
send_json_response("{\"error\":{\"code\":-32601,\"message\":\"method not found\"}}");
display_set_status("RPC method missing");
}
static void webusb_pump_frames() {
while (usb_web.available()) {
if (s_rx_len >= sizeof(s_rx_buf)) {
s_rx_len = 0;
break;
}
int c = usb_web.read();
if (c < 0) break;
s_rx_buf[s_rx_len++] = (uint8_t)c;
// DIAG: a byte arrived from the host. Pulse LED + count it so we can
// confirm, with no host tooling, that request bytes reach the device.
s_diag_bytes_rx++;
digitalWrite(LED_BUILTIN, LOW);
}
while (s_rx_len >= 4) {
s_diag_frames_rx++;
uint32_t body_len = read_u32_be(s_rx_buf);
if (body_len > (sizeof(s_rx_buf) - 4)) {
s_rx_len = 0;
send_json_response("{\"error\":{\"code\":-32000,\"message\":\"frame too large\"}}");
display_set_status("Frame too large");
return;
}
if (s_rx_len < (size_t)(4 + body_len)) {
return;
}
char req[1536];
size_t n = body_len;
if (n >= sizeof(req)) n = sizeof(req) - 1;
memcpy(req, s_rx_buf + 4, n);
req[n] = '\0';
size_t remain = s_rx_len - (4 + body_len);
memmove(s_rx_buf, s_rx_buf + 4 + body_len, remain);
s_rx_len = remain;
handle_rpc(req);
}
}
static void signer_enter_menu_screen() {
s_signer_ui_state = SIGNER_UI_MENU;
display_set_view(DISPLAY_VIEW_SIGNER);
display_show_signer_menu(s_signer_menu_selected);
display_set_status("ENC scroll PLAY select");
}
static void signer_enter_ready_screen() {
s_signer_ui_state = SIGNER_UI_READY;
display_set_view(DISPLAY_VIEW_SIGNER);
display_show_signer_ready();
display_set_status("Ready to sign");
}
static char signer_current_letter() {
return (char)('a' + (s_enter_letter_index % 26));
}
static void signer_enter_letter_pick_screen() {
s_signer_ui_state = SIGNER_UI_ENTER_LETTER;
char line1[20];
char line2[24];
snprintf(line1, sizeof(line1), "Word %u/12", (unsigned)(s_enter_slot + 1));
snprintf(line2, sizeof(line2), "Letter: %c", signer_current_letter());
display_show_message("PICK FIRST LETTER", line1, line2);
display_set_status("ENC letter PLAY confirm");
}
static void signer_enter_word_pick_screen() {
s_signer_ui_state = SIGNER_UI_ENTER_PICK;
uint16_t start = 0;
uint16_t count = 0;
if (!mnemonic_letter_range(signer_current_letter(), &start, &count) || count == 0) {
start = 0;
count = 2048;
}
if (s_enter_word_index < start || s_enter_word_index >= (uint16_t)(start + count)) {
s_enter_word_index = start;
}
const char *word = mnemonic_word_at(s_enter_word_index);
if (!word) word = "abandon";
display_show_word_pick(s_enter_slot, 12, word, s_enter_word_index);
display_set_status("ENC word PLAY select");
}
static void signer_enter_review_screen() {
s_signer_ui_state = SIGNER_UI_GENERATE_REVIEW;
if (s_signer_review_page > 2) s_signer_review_page = 2;
display_show_mnemonic_page(s_generated_mnemonic, s_signer_review_page);
display_set_status("PLAY confirm NEXT cancel");
}
static void build_entered_mnemonic(char *out, size_t out_sz) {
if (!out || out_sz == 0) {
return;
}
out[0] = '\0';
size_t cursor = 0;
for (uint8_t i = 0; i < 12; ++i) {
const char *w = mnemonic_word_at(s_enter_selected_indices[i]);
if (!w) w = "abandon";
const size_t wlen = strlen(w);
if (cursor != 0) {
if (cursor + 1 >= out_sz) break;
out[cursor++] = ' ';
}
if (cursor + wlen >= out_sz) break;
memcpy(out + cursor, w, wlen);
cursor += wlen;
out[cursor] = '\0';
}
}
static void handle_mode_switch_chord() {
const bool play = btn_pressed(PIN_BTN_PLAY);
const bool prev = btn_pressed(PIN_BTN_PREV);
if (play && prev) {
if (s_mode_switch_latched) {
return;
}
s_mode_switch_latched = true;
s_mode = (s_mode == MODE_MEDIA) ? MODE_SIGNER : MODE_MEDIA;
display_set_mode(s_mode == MODE_SIGNER);
if (s_mode == MODE_SIGNER) {
display_set_view(DISPLAY_VIEW_SIGNER);
signer_enter_menu_screen();
display_set_status("Signer mode");
} else {
display_set_view(DISPLAY_VIEW_VOLUME);
display_set_status("Media mode");
}
led_blink(s_mode == MODE_SIGNER ? 3 : 1, 80, 80);
return;
}
s_mode_switch_latched = false;
}
static void update_button_events() {
s_evt_play_pressed = false;
s_evt_next_pressed = false;
s_evt_prev_pressed = false;
const uint32_t now = millis();
for (auto &btn : s_buttons) {
const bool sample = digitalRead(btn.pin);
if (sample != btn.last_sample) {
btn.last_sample = sample;
btn.last_change_ms = now;
}
if ((now - btn.last_change_ms) >= DEBOUNCE_MS && btn.stable_level != sample) {
btn.stable_level = sample;
// Active-low press edge
if (!btn.stable_level) {
if (btn.pin == PIN_BTN_PLAY) s_evt_play_pressed = true;
if (btn.pin == PIN_BTN_NEXT) s_evt_next_pressed = true;
if (btn.pin == PIN_BTN_PREV) s_evt_prev_pressed = true;
}
}
}
}
static void media_mode_tick() {
const int32_t steps = encoder_take_steps();
if (steps > 0) {
for (int32_t i = 0; i < steps; ++i) {
const int8_t applied = volume_change_by(+1);
if (applied > 0) {
send_consumer_key(HID_USAGE_CONSUMER_VOLUME_INCREMENT);
display_set_volume(s_local_volume);
}
}
display_set_status("Volume up");
} else if (steps < 0) {
for (int32_t i = 0; i < -steps; ++i) {
const int8_t applied = volume_change_by(-1);
if (applied < 0) {
send_consumer_key(HID_USAGE_CONSUMER_VOLUME_DECREMENT);
display_set_volume(s_local_volume);
}
}
display_set_status("Volume down");
}
if (s_evt_play_pressed) {
send_consumer_key(HID_USAGE_CONSUMER_PLAY_PAUSE);
display_set_status("Play/Pause");
}
if (s_evt_next_pressed) {
// Preserve original hardware remap: NEXT wiring is swapped.
send_consumer_key(HID_USAGE_CONSUMER_SCAN_PREVIOUS_TRACK);
display_set_status("Prev track");
}
if (s_evt_prev_pressed) {
send_consumer_key(HID_USAGE_CONSUMER_SCAN_NEXT_TRACK);
display_set_status("Next track");
}
}
static void signer_mode_tick() {
const int32_t steps = encoder_take_steps();
// Suppress actions while chord is held for mode switching.
if (btn_pressed(PIN_BTN_PLAY) && btn_pressed(PIN_BTN_PREV)) {
return;
}
switch (s_signer_ui_state) {
case SIGNER_UI_MENU: {
if (steps != 0) {
int16_t v = (int16_t)s_signer_menu_selected + (steps > 0 ? 1 : -1);
if (v < 0) v = 0;
if (v > 1) v = 1;
s_signer_menu_selected = (uint8_t)v;
display_show_signer_menu(s_signer_menu_selected);
}
if (s_evt_play_pressed) {
if (s_signer_menu_selected == 0) {
if (generate_mnemonic_12(s_generated_mnemonic, sizeof(s_generated_mnemonic)) == 0) {
s_signer_review_page = 0;
signer_enter_review_screen();
} else {
display_show_message("ERROR", "Generate failed", "Try again");
display_set_status("Generate failed");
}
} else {
s_enter_slot = 0;
s_enter_letter_index = 0;
s_enter_word_index = s_enter_selected_indices[s_enter_slot];
signer_enter_letter_pick_screen();
}
}
break;
}
case SIGNER_UI_GENERATE_REVIEW: {
if (steps != 0) {
int16_t p = (int16_t)s_signer_review_page + (steps > 0 ? 1 : -1);
if (p < 0) p = 0;
if (p > 2) p = 2;
s_signer_review_page = (uint8_t)p;
signer_enter_review_screen();
}
if (s_evt_play_pressed) {
signer_apply_seed(s_generated_mnemonic);
signer_enter_ready_screen();
}
if (s_evt_next_pressed) {
signer_enter_menu_screen();
display_set_status("Generate canceled");
}
break;
}
case SIGNER_UI_READY: {
if (s_evt_next_pressed) {
signer_enter_menu_screen();
}
break;
}
case SIGNER_UI_ENTER_LETTER: {
if (steps != 0) {
int16_t idx = (int16_t)s_enter_letter_index + (steps > 0 ? 1 : -1);
while (idx < 0) idx += 26;
while (idx >= 26) idx -= 26;
s_enter_letter_index = (uint8_t)idx;
signer_enter_letter_pick_screen();
}
if (s_evt_play_pressed) {
uint16_t start = 0;
uint16_t count = 0;
if (mnemonic_letter_range(signer_current_letter(), &start, &count) && count > 0) {
s_enter_word_index = start;
} else {
s_enter_word_index = 0;
}
signer_enter_word_pick_screen();
}
if (s_evt_next_pressed) {
if (s_enter_slot == 0) {
signer_enter_menu_screen();
display_set_status("Entry canceled");
} else {
s_enter_slot--;
const char *prev_word = mnemonic_word_at(s_enter_selected_indices[s_enter_slot]);
if (prev_word && prev_word[0] >= 'a' && prev_word[0] <= 'z') {
s_enter_letter_index = (uint8_t)(prev_word[0] - 'a');
}
s_enter_word_index = s_enter_selected_indices[s_enter_slot];
signer_enter_word_pick_screen();
display_set_status("Back one step");
}
}
break;
}
case SIGNER_UI_ENTER_PICK: {
uint16_t start = 0;
uint16_t count = 0;
if (!mnemonic_letter_range(signer_current_letter(), &start, &count) || count == 0) {
start = 0;
count = 2048;
}
if (steps != 0) {
int32_t rel = (int32_t)s_enter_word_index - (int32_t)start;
rel += steps;
while (rel < 0) rel += count;
while (rel >= count) rel -= count;
s_enter_word_index = (uint16_t)(start + rel);
signer_enter_word_pick_screen();
}
if (s_evt_play_pressed) {
s_enter_selected_indices[s_enter_slot] = s_enter_word_index;
if (s_enter_slot < 11) {
s_enter_slot++;
s_enter_letter_index = 0;
s_enter_word_index = s_enter_selected_indices[s_enter_slot];
signer_enter_letter_pick_screen();
} else {
char phrase[256];
build_entered_mnemonic(phrase, sizeof(phrase));
if (mnemonic_validate_basic(phrase)) {
signer_apply_seed(phrase);
signer_enter_ready_screen();
} else {
display_show_message("INVALID", "Bad mnemonic", "NEXT to return");
display_set_status("Validation failed");
}
}
}
if (s_evt_next_pressed) {
signer_enter_letter_pick_screen();
display_set_status("Back to letter");
}
break;
}
}
}
void setup() {
pinMode(LED_BUILTIN, OUTPUT);
digitalWrite(LED_BUILTIN, LOW);
pinMode(PIN_ENC_A, INPUT_PULLUP);
pinMode(PIN_ENC_B, INPUT_PULLUP);
pinMode(PIN_BTN_PLAY, INPUT_PULLUP);
pinMode(PIN_BTN_NEXT, INPUT_PULLUP);
pinMode(PIN_BTN_PREV, INPUT_PULLUP);
usb_web.setLandingPage(&landing_url);
usb_web.setStringDescriptor("n_signer WebUSB");
usb_web.begin();
usb_hid.setPollInterval(2);
usb_hid.setReportDescriptor(desc_hid_report, sizeof(desc_hid_report));
usb_hid.setStringDescriptor("KB2040 Media");
usb_hid.begin();
s_isr_last_ab = read_ab();
attachInterrupt(digitalPinToInterrupt(PIN_ENC_A), on_encoder_edge, CHANGE);
attachInterrupt(digitalPinToInterrupt(PIN_ENC_B), on_encoder_edge, CHANGE);
const uint32_t now = millis();
for (auto &btn : s_buttons) {
const bool s = digitalRead(btn.pin);
btn.stable_level = s;
btn.last_sample = s;
btn.last_change_ms = now;
}
while (!TinyUSBDevice.mounted()) {
delay(1);
}
display_set_mode(false);
display_set_view(DISPLAY_VIEW_VOLUME);
display_set_status("USB mounted");
digitalWrite(LED_BUILTIN, HIGH);
led_blink(2, 60, 60);
}
void loop() {
if (!s_display_init_done && millis() >= DISPLAY_INIT_DELAY_MS) {
s_display_init_done = true;
display_init();
display_set_mode(s_mode == MODE_SIGNER);
display_set_volume(s_local_volume);
display_set_view(s_mode == MODE_SIGNER ? DISPLAY_VIEW_SIGNER : DISPLAY_VIEW_VOLUME);
display_set_status("Ready");
if (s_mode == MODE_SIGNER) {
signer_enter_menu_screen();
}
}
update_button_events();
handle_mode_switch_chord();
if (s_mode == MODE_MEDIA) {
media_mode_tick();
} else {
signer_mode_tick();
}
webusb_pump_frames();
// DIAG: periodically surface transport activity on the OLED status line so
// we can confirm whether host request bytes reach the device, and whether
// the WebUSB vendor interface reports itself "connected". Format:
// D c<0|1> b<bytes> f<frames> t<tx>
// - c1 => usb_web.connected() true (host opened the WebUSB session)
// - b => total bytes received from host on the vendor OUT endpoint
// - f => complete framed requests parsed
// - t => responses written back
{
const bool connected = usb_web.connected();
const uint32_t now = millis();
if (connected != s_diag_last_connected ||
(now - s_diag_last_report_ms) >= DIAG_REPORT_INTERVAL_MS) {
s_diag_last_connected = connected;
s_diag_last_report_ms = now;
char diag[24];
snprintf(diag, sizeof(diag), "D c%d b%lu f%lu t%lu",
connected ? 1 : 0,
(unsigned long)s_diag_bytes_rx,
(unsigned long)s_diag_frames_rx,
(unsigned long)s_diag_responses_tx);
display_set_status(diag);
// Idle LED state: ON (HIGH) when connected, OFF otherwise. The RX path
// drives it LOW briefly on each byte, so a flicker == bytes arriving.
digitalWrite(LED_BUILTIN, connected ? HIGH : LOW);
}
}
display_tick();
delay(2);
}

View File

@@ -0,0 +1,154 @@
#include "mnemonic_kb.h"
#include <Arduino.h>
#include <string.h>
#include "mnemonic_wordlist.h"
namespace {
static bool s_rng_seeded = false;
void ensure_rng_seeded() {
if (s_rng_seeded) {
return;
}
uint32_t seed = micros() ^ (millis() << 16);
for (int i = 0; i < 16; ++i) {
seed ^= ((uint32_t)analogRead(A0) & 0x3FFu) << (i % 22);
delayMicroseconds(50);
}
randomSeed(seed);
s_rng_seeded = true;
}
bool append_word(char* out, size_t out_len, size_t* cursor, const char* word) {
const size_t wlen = strlen(word);
if (*cursor >= out_len) {
return false;
}
if (*cursor != 0) {
if (*cursor + 1 >= out_len) {
return false;
}
out[*cursor] = ' ';
(*cursor)++;
}
if (*cursor + wlen >= out_len) {
return false;
}
memcpy(out + *cursor, word, wlen);
*cursor += wlen;
out[*cursor] = '\0';
return true;
}
} // namespace
bool mnemonic_letter_range(char letter, uint16_t *out_start, uint16_t *out_count) {
if (!out_start || !out_count) {
return false;
}
if (letter >= 'A' && letter <= 'Z') {
letter = (char)(letter - 'A' + 'a');
}
if (letter < 'a' || letter > 'z') {
return false;
}
int first = -1;
int last = -1;
for (int i = 0; i < 2048; ++i) {
const char c = BIP39_WORDLIST[i][0];
if (c == letter) {
if (first < 0) first = i;
last = i;
} else if (first >= 0) {
break;
}
}
if (first < 0 || last < first) {
return false;
}
*out_start = (uint16_t)first;
*out_count = (uint16_t)(last - first + 1);
return true;
}
const char* mnemonic_word_at(uint16_t index) {
if (index >= 2048) {
return nullptr;
}
return BIP39_WORDLIST[index];
}
int mnemonic_word_index(const char* word) {
if (!word || !word[0]) {
return -1;
}
for (int i = 0; i < 2048; ++i) {
if (strcmp(word, BIP39_WORDLIST[i]) == 0) {
return i;
}
}
return -1;
}
int generate_mnemonic_12(char* out, size_t out_len) {
if (!out || out_len == 0) {
return -1;
}
out[0] = '\0';
ensure_rng_seeded();
size_t cursor = 0;
for (int i = 0; i < 12; ++i) {
const uint16_t idx = (uint16_t)random(0, 2048);
const char* word = mnemonic_word_at(idx);
if (!word) {
return -1;
}
if (!append_word(out, out_len, &cursor, word)) {
return -1;
}
}
return 0;
}
bool mnemonic_validate_basic(const char* mnemonic) {
if (!mnemonic || !mnemonic[0]) {
return false;
}
char buf[256];
strncpy(buf, mnemonic, sizeof(buf) - 1);
buf[sizeof(buf) - 1] = '\0';
int count = 0;
char* saveptr = nullptr;
char* tok = strtok_r(buf, " ", &saveptr);
while (tok) {
if (mnemonic_word_index(tok) < 0) {
return false;
}
++count;
tok = strtok_r(nullptr, " ", &saveptr);
}
return count == 12;
}

View File

@@ -0,0 +1,11 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
const char* mnemonic_word_at(uint16_t index);
int mnemonic_word_index(const char* word);
bool mnemonic_letter_range(char letter, uint16_t *out_start, uint16_t *out_count);
int generate_mnemonic_12(char* out, size_t out_len);
bool mnemonic_validate_basic(const char* mnemonic);

View File

@@ -0,0 +1,261 @@
#pragma once
/* BIP-39 English wordlist (2048 words) */
static const char *BIP39_WORDLIST[2048] = {
"abandon", "ability", "able", "about", "above", "absent", "absorb", "abstract",
"absurd", "abuse", "access", "accident", "account", "accuse", "achieve", "acid",
"acoustic", "acquire", "across", "act", "action", "actor", "actress", "actual",
"adapt", "add", "addict", "address", "adjust", "admit", "adult", "advance",
"advice", "aerobic", "affair", "afford", "afraid", "again", "age", "agent",
"agree", "ahead", "aim", "air", "airport", "aisle", "alarm", "album",
"alcohol", "alert", "alien", "all", "alley", "allow", "almost", "alone",
"alpha", "already", "also", "alter", "always", "amateur", "amazing", "among",
"amount", "amused", "analyst", "anchor", "ancient", "anger", "angle", "angry",
"animal", "ankle", "announce", "annual", "another", "answer", "antenna", "antique",
"anxiety", "any", "apart", "apology", "appear", "apple", "approve", "april",
"arch", "arctic", "area", "arena", "argue", "arm", "armed", "armor",
"army", "around", "arrange", "arrest", "arrive", "arrow", "art", "artefact",
"artist", "artwork", "ask", "aspect", "assault", "asset", "assist", "assume",
"asthma", "athlete", "atom", "attack", "attend", "attitude", "attract", "auction",
"audit", "august", "aunt", "author", "auto", "autumn", "average", "avocado",
"avoid", "awake", "aware", "away", "awesome", "awful", "awkward", "axis",
"baby", "bachelor", "bacon", "badge", "bag", "balance", "balcony", "ball",
"bamboo", "banana", "banner", "bar", "barely", "bargain", "barrel", "base",
"basic", "basket", "battle", "beach", "bean", "beauty", "because", "become",
"beef", "before", "begin", "behave", "behind", "believe", "below", "belt",
"bench", "benefit", "best", "betray", "better", "between", "beyond", "bicycle",
"bid", "bike", "bind", "biology", "bird", "birth", "bitter", "black",
"blade", "blame", "blanket", "blast", "bleak", "bless", "blind", "blood",
"blossom", "blouse", "blue", "blur", "blush", "board", "boat", "body",
"boil", "bomb", "bone", "bonus", "book", "boost", "border", "boring",
"borrow", "boss", "bottom", "bounce", "box", "boy", "bracket", "brain",
"brand", "brass", "brave", "bread", "breeze", "brick", "bridge", "brief",
"bright", "bring", "brisk", "broccoli", "broken", "bronze", "broom", "brother",
"brown", "brush", "bubble", "buddy", "budget", "buffalo", "build", "bulb",
"bulk", "bullet", "bundle", "bunker", "burden", "burger", "burst", "bus",
"business", "busy", "butter", "buyer", "buzz", "cabbage", "cabin", "cable",
"cactus", "cage", "cake", "call", "calm", "camera", "camp", "can",
"canal", "cancel", "candy", "cannon", "canoe", "canvas", "canyon", "capable",
"capital", "captain", "car", "carbon", "card", "cargo", "carpet", "carry",
"cart", "case", "cash", "casino", "castle", "casual", "cat", "catalog",
"catch", "category", "cattle", "caught", "cause", "caution", "cave", "ceiling",
"celery", "cement", "census", "century", "cereal", "certain", "chair", "chalk",
"champion", "change", "chaos", "chapter", "charge", "chase", "chat", "cheap",
"check", "cheese", "chef", "cherry", "chest", "chicken", "chief", "child",
"chimney", "choice", "choose", "chronic", "chuckle", "chunk", "churn", "cigar",
"cinnamon", "circle", "citizen", "city", "civil", "claim", "clap", "clarify",
"claw", "clay", "clean", "clerk", "clever", "click", "client", "cliff",
"climb", "clinic", "clip", "clock", "clog", "close", "cloth", "cloud",
"clown", "club", "clump", "cluster", "clutch", "coach", "coast", "coconut",
"code", "coffee", "coil", "coin", "collect", "color", "column", "combine",
"come", "comfort", "comic", "common", "company", "concert", "conduct", "confirm",
"congress", "connect", "consider", "control", "convince", "cook", "cool", "copper",
"copy", "coral", "core", "corn", "correct", "cost", "cotton", "couch",
"country", "couple", "course", "cousin", "cover", "coyote", "crack", "cradle",
"craft", "cram", "crane", "crash", "crater", "crawl", "crazy", "cream",
"credit", "creek", "crew", "cricket", "crime", "crisp", "critic", "crop",
"cross", "crouch", "crowd", "crucial", "cruel", "cruise", "crumble", "crunch",
"crush", "cry", "crystal", "cube", "culture", "cup", "cupboard", "curious",
"current", "curtain", "curve", "cushion", "custom", "cute", "cycle", "dad",
"damage", "damp", "dance", "danger", "daring", "dash", "daughter", "dawn",
"day", "deal", "debate", "debris", "decade", "december", "decide", "decline",
"decorate", "decrease", "deer", "defense", "define", "defy", "degree", "delay",
"deliver", "demand", "demise", "denial", "dentist", "deny", "depart", "depend",
"deposit", "depth", "deputy", "derive", "describe", "desert", "design", "desk",
"despair", "destroy", "detail", "detect", "develop", "device", "devote", "diagram",
"dial", "diamond", "diary", "dice", "diesel", "diet", "differ", "digital",
"dignity", "dilemma", "dinner", "dinosaur", "direct", "dirt", "disagree", "discover",
"disease", "dish", "dismiss", "disorder", "display", "distance", "divert", "divide",
"divorce", "dizzy", "doctor", "document", "dog", "doll", "dolphin", "domain",
"donate", "donkey", "donor", "door", "dose", "double", "dove", "draft",
"dragon", "drama", "drastic", "draw", "dream", "dress", "drift", "drill",
"drink", "drip", "drive", "drop", "drum", "dry", "duck", "dumb",
"dune", "during", "dust", "dutch", "duty", "dwarf", "dynamic", "eager",
"eagle", "early", "earn", "earth", "easily", "east", "easy", "echo",
"ecology", "economy", "edge", "edit", "educate", "effort", "egg", "eight",
"either", "elbow", "elder", "electric", "elegant", "element", "elephant", "elevator",
"elite", "else", "embark", "embody", "embrace", "emerge", "emotion", "employ",
"empower", "empty", "enable", "enact", "end", "endless", "endorse", "enemy",
"energy", "enforce", "engage", "engine", "enhance", "enjoy", "enlist", "enough",
"enrich", "enroll", "ensure", "enter", "entire", "entry", "envelope", "episode",
"equal", "equip", "era", "erase", "erode", "erosion", "error", "erupt",
"escape", "essay", "essence", "estate", "eternal", "ethics", "evidence", "evil",
"evoke", "evolve", "exact", "example", "excess", "exchange", "excite", "exclude",
"excuse", "execute", "exercise", "exhaust", "exhibit", "exile", "exist", "exit",
"exotic", "expand", "expect", "expire", "explain", "expose", "express", "extend",
"extra", "eye", "eyebrow", "fabric", "face", "faculty", "fade", "faint",
"faith", "fall", "false", "fame", "family", "famous", "fan", "fancy",
"fantasy", "farm", "fashion", "fat", "fatal", "father", "fatigue", "fault",
"favorite", "feature", "february", "federal", "fee", "feed", "feel", "female",
"fence", "festival", "fetch", "fever", "few", "fiber", "fiction", "field",
"figure", "file", "film", "filter", "final", "find", "fine", "finger",
"finish", "fire", "firm", "first", "fiscal", "fish", "fit", "fitness",
"fix", "flag", "flame", "flash", "flat", "flavor", "flee", "flight",
"flip", "float", "flock", "floor", "flower", "fluid", "flush", "fly",
"foam", "focus", "fog", "foil", "fold", "follow", "food", "foot",
"force", "forest", "forget", "fork", "fortune", "forum", "forward", "fossil",
"foster", "found", "fox", "fragile", "frame", "frequent", "fresh", "friend",
"fringe", "frog", "front", "frost", "frown", "frozen", "fruit", "fuel",
"fun", "funny", "furnace", "fury", "future", "gadget", "gain", "galaxy",
"gallery", "game", "gap", "garage", "garbage", "garden", "garlic", "garment",
"gas", "gasp", "gate", "gather", "gauge", "gaze", "general", "genius",
"genre", "gentle", "genuine", "gesture", "ghost", "giant", "gift", "giggle",
"ginger", "giraffe", "girl", "give", "glad", "glance", "glare", "glass",
"glide", "glimpse", "globe", "gloom", "glory", "glove", "glow", "glue",
"goat", "goddess", "gold", "good", "goose", "gorilla", "gospel", "gossip",
"govern", "gown", "grab", "grace", "grain", "grant", "grape", "grass",
"gravity", "great", "green", "grid", "grief", "grit", "grocery", "group",
"grow", "grunt", "guard", "guess", "guide", "guilt", "guitar", "gun",
"gym", "habit", "hair", "half", "hammer", "hamster", "hand", "happy",
"harbor", "hard", "harsh", "harvest", "hat", "have", "hawk", "hazard",
"head", "health", "heart", "heavy", "hedgehog", "height", "hello", "helmet",
"help", "hen", "hero", "hidden", "high", "hill", "hint", "hip",
"hire", "history", "hobby", "hockey", "hold", "hole", "holiday", "hollow",
"home", "honey", "hood", "hope", "horn", "horror", "horse", "hospital",
"host", "hotel", "hour", "hover", "hub", "huge", "human", "humble",
"humor", "hundred", "hungry", "hunt", "hurdle", "hurry", "hurt", "husband",
"hybrid", "ice", "icon", "idea", "identify", "idle", "ignore", "ill",
"illegal", "illness", "image", "imitate", "immense", "immune", "impact", "impose",
"improve", "impulse", "inch", "include", "income", "increase", "index", "indicate",
"indoor", "industry", "infant", "inflict", "inform", "inhale", "inherit", "initial",
"inject", "injury", "inmate", "inner", "innocent", "input", "inquiry", "insane",
"insect", "inside", "inspire", "install", "intact", "interest", "into", "invest",
"invite", "involve", "iron", "island", "isolate", "issue", "item", "ivory",
"jacket", "jaguar", "jar", "jazz", "jealous", "jeans", "jelly", "jewel",
"job", "join", "joke", "journey", "joy", "judge", "juice", "jump",
"jungle", "junior", "junk", "just", "kangaroo", "keen", "keep", "ketchup",
"key", "kick", "kid", "kidney", "kind", "kingdom", "kiss", "kit",
"kitchen", "kite", "kitten", "kiwi", "knee", "knife", "knock", "know",
"lab", "label", "labor", "ladder", "lady", "lake", "lamp", "language",
"laptop", "large", "later", "latin", "laugh", "laundry", "lava", "law",
"lawn", "lawsuit", "layer", "lazy", "leader", "leaf", "learn", "leave",
"lecture", "left", "leg", "legal", "legend", "leisure", "lemon", "lend",
"length", "lens", "leopard", "lesson", "letter", "level", "liar", "liberty",
"library", "license", "life", "lift", "light", "like", "limb", "limit",
"link", "lion", "liquid", "list", "little", "live", "lizard", "load",
"loan", "lobster", "local", "lock", "logic", "lonely", "long", "loop",
"lottery", "loud", "lounge", "love", "loyal", "lucky", "luggage", "lumber",
"lunar", "lunch", "luxury", "lyrics", "machine", "mad", "magic", "magnet",
"maid", "mail", "main", "major", "make", "mammal", "man", "manage",
"mandate", "mango", "mansion", "manual", "maple", "marble", "march", "margin",
"marine", "market", "marriage", "mask", "mass", "master", "match", "material",
"math", "matrix", "matter", "maximum", "maze", "meadow", "mean", "measure",
"meat", "mechanic", "medal", "media", "melody", "melt", "member", "memory",
"mention", "menu", "mercy", "merge", "merit", "merry", "mesh", "message",
"metal", "method", "middle", "midnight", "milk", "million", "mimic", "mind",
"minimum", "minor", "minute", "miracle", "mirror", "misery", "miss", "mistake",
"mix", "mixed", "mixture", "mobile", "model", "modify", "mom", "moment",
"monitor", "monkey", "monster", "month", "moon", "moral", "more", "morning",
"mosquito", "mother", "motion", "motor", "mountain", "mouse", "move", "movie",
"much", "muffin", "mule", "multiply", "muscle", "museum", "mushroom", "music",
"must", "mutual", "myself", "mystery", "myth", "naive", "name", "napkin",
"narrow", "nasty", "nation", "nature", "near", "neck", "need", "negative",
"neglect", "neither", "nephew", "nerve", "nest", "net", "network", "neutral",
"never", "news", "next", "nice", "night", "noble", "noise", "nominee",
"noodle", "normal", "north", "nose", "notable", "note", "nothing", "notice",
"novel", "now", "nuclear", "number", "nurse", "nut", "oak", "obey",
"object", "oblige", "obscure", "observe", "obtain", "obvious", "occur", "ocean",
"october", "odor", "off", "offer", "office", "often", "oil", "okay",
"old", "olive", "olympic", "omit", "once", "one", "onion", "online",
"only", "open", "opera", "opinion", "oppose", "option", "orange", "orbit",
"orchard", "order", "ordinary", "organ", "orient", "original", "orphan", "ostrich",
"other", "outdoor", "outer", "output", "outside", "oval", "oven", "over",
"own", "owner", "oxygen", "oyster", "ozone", "pact", "paddle", "page",
"pair", "palace", "palm", "panda", "panel", "panic", "panther", "paper",
"parade", "parent", "park", "parrot", "party", "pass", "patch", "path",
"patient", "patrol", "pattern", "pause", "pave", "payment", "peace", "peanut",
"pear", "peasant", "pelican", "pen", "penalty", "pencil", "people", "pepper",
"perfect", "permit", "person", "pet", "phone", "photo", "phrase", "physical",
"piano", "picnic", "picture", "piece", "pig", "pigeon", "pill", "pilot",
"pink", "pioneer", "pipe", "pistol", "pitch", "pizza", "place", "planet",
"plastic", "plate", "play", "please", "pledge", "pluck", "plug", "plunge",
"poem", "poet", "point", "polar", "pole", "police", "pond", "pony",
"pool", "popular", "portion", "position", "possible", "post", "potato", "pottery",
"poverty", "powder", "power", "practice", "praise", "predict", "prefer", "prepare",
"present", "pretty", "prevent", "price", "pride", "primary", "print", "priority",
"prison", "private", "prize", "problem", "process", "produce", "profit", "program",
"project", "promote", "proof", "property", "prosper", "protect", "proud", "provide",
"public", "pudding", "pull", "pulp", "pulse", "pumpkin", "punch", "pupil",
"puppy", "purchase", "purity", "purpose", "purse", "push", "put", "puzzle",
"pyramid", "quality", "quantum", "quarter", "question", "quick", "quit", "quiz",
"quote", "rabbit", "raccoon", "race", "rack", "radar", "radio", "rail",
"rain", "raise", "rally", "ramp", "ranch", "random", "range", "rapid",
"rare", "rate", "rather", "raven", "raw", "razor", "ready", "real",
"reason", "rebel", "rebuild", "recall", "receive", "recipe", "record", "recycle",
"reduce", "reflect", "reform", "refuse", "region", "regret", "regular", "reject",
"relax", "release", "relief", "rely", "remain", "remember", "remind", "remove",
"render", "renew", "rent", "reopen", "repair", "repeat", "replace", "report",
"require", "rescue", "resemble", "resist", "resource", "response", "result", "retire",
"retreat", "return", "reunion", "reveal", "review", "reward", "rhythm", "rib",
"ribbon", "rice", "rich", "ride", "ridge", "rifle", "right", "rigid",
"ring", "riot", "ripple", "risk", "ritual", "rival", "river", "road",
"roast", "robot", "robust", "rocket", "romance", "roof", "rookie", "room",
"rose", "rotate", "rough", "round", "route", "royal", "rubber", "rude",
"rug", "rule", "run", "runway", "rural", "sad", "saddle", "sadness",
"safe", "sail", "salad", "salmon", "salon", "salt", "salute", "same",
"sample", "sand", "satisfy", "satoshi", "sauce", "sausage", "save", "say",
"scale", "scan", "scare", "scatter", "scene", "scheme", "school", "science",
"scissors", "scorpion", "scout", "scrap", "screen", "script", "scrub", "sea",
"search", "season", "seat", "second", "secret", "section", "security", "seed",
"seek", "segment", "select", "sell", "seminar", "senior", "sense", "sentence",
"series", "service", "session", "settle", "setup", "seven", "shadow", "shaft",
"shallow", "share", "shed", "shell", "sheriff", "shield", "shift", "shine",
"ship", "shiver", "shock", "shoe", "shoot", "shop", "short", "shoulder",
"shove", "shrimp", "shrug", "shuffle", "shy", "sibling", "sick", "side",
"siege", "sight", "sign", "silent", "silk", "silly", "silver", "similar",
"simple", "since", "sing", "siren", "sister", "situate", "six", "size",
"skate", "sketch", "ski", "skill", "skin", "skirt", "skull", "slab",
"slam", "sleep", "slender", "slice", "slide", "slight", "slim", "slogan",
"slot", "slow", "slush", "small", "smart", "smile", "smoke", "smooth",
"snack", "snake", "snap", "sniff", "snow", "soap", "soccer", "social",
"sock", "soda", "soft", "solar", "soldier", "solid", "solution", "solve",
"someone", "song", "soon", "sorry", "sort", "soul", "sound", "soup",
"source", "south", "space", "spare", "spatial", "spawn", "speak", "special",
"speed", "spell", "spend", "sphere", "spice", "spider", "spike", "spin",
"spirit", "split", "spoil", "sponsor", "spoon", "sport", "spot", "spray",
"spread", "spring", "spy", "square", "squeeze", "squirrel", "stable", "stadium",
"staff", "stage", "stairs", "stamp", "stand", "start", "state", "stay",
"steak", "steel", "stem", "step", "stereo", "stick", "still", "sting",
"stock", "stomach", "stone", "stool", "story", "stove", "strategy", "street",
"strike", "strong", "struggle", "student", "stuff", "stumble", "style", "subject",
"submit", "subway", "success", "such", "sudden", "suffer", "sugar", "suggest",
"suit", "summer", "sun", "sunny", "sunset", "super", "supply", "supreme",
"sure", "surface", "surge", "surprise", "surround", "survey", "suspect", "sustain",
"swallow", "swamp", "swap", "swarm", "swear", "sweet", "swift", "swim",
"swing", "switch", "sword", "symbol", "symptom", "syrup", "system", "table",
"tackle", "tag", "tail", "talent", "talk", "tank", "tape", "target",
"task", "taste", "tattoo", "taxi", "teach", "team", "tell", "ten",
"tenant", "tennis", "tent", "term", "test", "text", "thank", "that",
"theme", "then", "theory", "there", "they", "thing", "this", "thought",
"three", "thrive", "throw", "thumb", "thunder", "ticket", "tide", "tiger",
"tilt", "timber", "time", "tiny", "tip", "tired", "tissue", "title",
"toast", "tobacco", "today", "toddler", "toe", "together", "toilet", "token",
"tomato", "tomorrow", "tone", "tongue", "tonight", "tool", "tooth", "top",
"topic", "topple", "torch", "tornado", "tortoise", "toss", "total", "tourist",
"toward", "tower", "town", "toy", "track", "trade", "traffic", "tragic",
"train", "transfer", "trap", "trash", "travel", "tray", "treat", "tree",
"trend", "trial", "tribe", "trick", "trigger", "trim", "trip", "trophy",
"trouble", "truck", "true", "truly", "trumpet", "trust", "truth", "try",
"tube", "tuition", "tumble", "tuna", "tunnel", "turkey", "turn", "turtle",
"twelve", "twenty", "twice", "twin", "twist", "two", "type", "typical",
"ugly", "umbrella", "unable", "unaware", "uncle", "uncover", "under", "undo",
"unfair", "unfold", "unhappy", "uniform", "unique", "unit", "universe", "unknown",
"unlock", "until", "unusual", "unveil", "update", "upgrade", "uphold", "upon",
"upper", "upset", "urban", "urge", "usage", "use", "used", "useful",
"useless", "usual", "utility", "vacant", "vacuum", "vague", "valid", "valley",
"valve", "van", "vanish", "vapor", "various", "vast", "vault", "vehicle",
"velvet", "vendor", "venture", "venue", "verb", "verify", "version", "very",
"vessel", "veteran", "viable", "vibrant", "vicious", "victory", "video", "view",
"village", "vintage", "violin", "virtual", "virus", "visa", "visit", "visual",
"vital", "vivid", "vocal", "voice", "void", "volcano", "volume", "vote",
"voyage", "wage", "wagon", "wait", "walk", "wall", "walnut", "want",
"warfare", "warm", "warrior", "wash", "wasp", "waste", "water", "wave",
"way", "wealth", "weapon", "wear", "weasel", "weather", "web", "wedding",
"weekend", "weird", "welcome", "west", "wet", "whale", "what", "wheat",
"wheel", "when", "where", "whip", "whisper", "wide", "width", "wife",
"wild", "will", "win", "window", "wine", "wing", "wink", "winner",
"winter", "wire", "wisdom", "wise", "wish", "witness", "wolf", "woman",
"wonder", "wood", "wool", "word", "work", "world", "worry", "worth",
"wrap", "wreck", "wrestle", "wrist", "write", "wrong", "yard", "year",
"yellow", "you", "young", "youth", "zebra", "zero", "zone", "zoo"
};

View File

@@ -0,0 +1,42 @@
# Hidden n_signer in KB2040 Media Controller
## Overview
The goal is to embed the `n_signer` functionality into the existing `keyboard_modules` KB2040 media controller project. The device will normally function as a media controller (volume knob, play/pause, next, prev), but a specific button combination will unlock the `n_signer` mode, allowing the user to enter a mnemonic and sign requests.
## Feasibility: Single USB Connection
**Yes, this is entirely possible over a single USB connection.**
The RP2040 microcontroller supports USB Composite Devices. By using a USB stack like **TinyUSB** (which is well-supported on the RP2040 via the Pico SDK or Adafruit TinyUSB for Arduino), the device can expose multiple USB interfaces simultaneously:
1. **HID Interface:** Acts as the standard media controller (Keyboard/Consumer Control).
2. **Vendor-Specific / WebUSB Interface (or CDC):** Acts as the communication channel for `n_signer` requests.
The host OS will see a single physical USB device with multiple virtual devices attached to it. The media keys will work normally, and the `n_signer` client software can connect to the custom interface concurrently.
## Implementation Plan
### 1. Firmware Environment & USB Stack
* **Current State:** The `keyboard_modules` project is an Arduino sketch using the standard `<Keyboard.h>`.
* **Action:** Migrate the USB handling to use **Adafruit TinyUSB** (if staying in Arduino) or port the project to the **Raspberry Pi Pico SDK** (C/C++). TinyUSB is required to easily set up a composite device with both HID and WebUSB/Custom endpoints.
### 2. Integrate n_signer Core
* **Action:** Bring the core `n_signer` C files (crypto, mnemonic handling, transport framing) into the KB2040 project.
* **Consideration:** Ensure the cryptographic libraries used by `n_signer` (e.g., secp256k1, hashing) compile and run efficiently on the RP2040 (ARM Cortex-M0+).
### 3. Secret Mode Activation (UI/UX)
* **Action:** Implement a state machine in the firmware.
* **State 1: Media Controller (Default).** Buttons and knob send HID events. Display shows volume.
* **State 2: n_signer Mode.** Triggered by a specific chord (e.g., holding Play/Pause + Next for 3 seconds).
* **Action:** In n_signer mode, repurpose the hardware:
* **Knob:** Scroll through letters/words for mnemonic entry, or scroll through request details.
* **Buttons:** Select, Cancel, Approve, Reject.
* **Display:** Show the `n_signer` UI (mnemonic entry, approval prompts) instead of the volume bar.
### 4. Display Management
* **Action:** Refactor `display.cpp` to support multiple "views" or "screens". It currently hardcodes the volume UI. It needs to be able to switch to rendering text menus for the signer.
### 5. Host-Side Client
* **Action:** The `n_signer` client on the host will need to be configured to look for the specific USB VID/PID and interface number of the composite KB2040 device.
## Next Steps
1. Verify the preferred development environment for the KB2040 (Arduino with Adafruit TinyUSB vs. Pico SDK).
2. Create a proof-of-concept composite USB device on the KB2040 that exposes both HID and a dummy WebUSB endpoint.

View File

@@ -491,8 +491,8 @@ int socket_name_random(char *out, size_t out_len);
/* Version information (auto-updated by build/version tooling) */
#define NSIGNER_VERSION_MAJOR 0
#define NSIGNER_VERSION_MINOR 0
#define NSIGNER_VERSION_PATCH 31
#define NSIGNER_VERSION "v0.0.31"
#define NSIGNER_VERSION_PATCH 32
#define NSIGNER_VERSION "v0.0.32"
/* NSIGNER_HEADERLESS_DECLS_END */

Binary file not shown.

151
tests/test_kb2040_dual.py Normal file
View File

@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""
Verify KB2040 composite USB exposes both HID and Vendor/WebUSB interfaces.
Usage:
python3 tests/test_kb2040_dual.py
Dependencies:
pip install pyusb
Notes:
- May require sudo unless udev permissions are configured.
- Defaults to VID:PID 239a:8104 per user report.
"""
import argparse
import sys
import time
try:
import usb.core
import usb.util
except ImportError:
print("ERROR: Missing dependency. Install with: pip install pyusb")
sys.exit(2)
def find_device(vid: int, pid: int):
return usb.core.find(idVendor=vid, idProduct=pid)
def claim_vendor_interface(dev, vendor_itf):
itf_num = vendor_itf.bInterfaceNumber
try:
if dev.is_kernel_driver_active(itf_num):
dev.detach_kernel_driver(itf_num)
except (NotImplementedError, usb.core.USBError):
pass
usb.util.claim_interface(dev, itf_num)
def release_vendor_interface(dev, vendor_itf):
itf_num = vendor_itf.bInterfaceNumber
try:
usb.util.release_interface(dev, itf_num)
except usb.core.USBError:
pass
def find_endpoints(vendor_itf):
ep_out = None
ep_in = None
for ep in vendor_itf:
direction = usb.util.endpoint_direction(ep.bEndpointAddress)
if direction == usb.util.ENDPOINT_OUT:
ep_out = ep
elif direction == usb.util.ENDPOINT_IN:
ep_in = ep
return ep_out, ep_in
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--vid", default="239a", help="USB Vendor ID in hex (default: 239a)")
parser.add_argument("--pid", default="8104", help="USB Product ID in hex (default: 8104)")
parser.add_argument("--payload", default="hello", help="Payload for vendor echo test")
parser.add_argument("--timeout-ms", type=int, default=2000, help="Read timeout in ms")
args = parser.parse_args()
vid = int(args.vid, 16)
pid = int(args.pid, 16)
passed = 0
failed = 0
dev = find_device(vid, pid)
if dev is None:
print(f"[FAIL] Device {vid:04x}:{pid:04x} not found")
return 1
print(f"[PASS] Device {dev.idVendor:04x}:{dev.idProduct:04x} found")
passed += 1
try:
dev.set_configuration()
except usb.core.USBError:
# Usually already configured; continue.
pass
cfg = dev.get_active_configuration()
hid_itf = None
vendor_itf = None
for itf in cfg:
if itf.bInterfaceClass == 0x03: # HID
hid_itf = itf
elif itf.bInterfaceClass == 0xFF: # Vendor-specific
vendor_itf = itf
if hid_itf is not None:
print(f"[PASS] HID interface found (if={hid_itf.bInterfaceNumber})")
passed += 1
else:
print("[FAIL] HID interface not found")
failed += 1
if vendor_itf is not None:
print(f"[PASS] Vendor/WebUSB interface found (if={vendor_itf.bInterfaceNumber})")
passed += 1
else:
print("[FAIL] Vendor/WebUSB interface not found")
failed += 1
if vendor_itf is not None:
try:
claim_vendor_interface(dev, vendor_itf)
ep_out, ep_in = find_endpoints(vendor_itf)
if ep_out is None or ep_in is None:
print("[FAIL] Could not find bulk IN/OUT endpoints on vendor interface")
failed += 1
else:
payload = args.payload.encode("utf-8")
ep_out.write(payload)
time.sleep(0.05)
data = ep_in.read(ep_in.wMaxPacketSize, timeout=args.timeout_ms)
got = bytes(data)
expected = b"KB2040:" + payload
if got == expected:
print(f"[PASS] Vendor echo OK: sent={payload!r} got={got!r}")
passed += 1
else:
print(f"[FAIL] Vendor echo mismatch: expected={expected!r} got={got!r}")
failed += 1
except usb.core.USBTimeoutError:
print("[FAIL] Vendor read timed out")
failed += 1
except usb.core.USBError as e:
print(f"[FAIL] USB error during vendor test: {e}")
failed += 1
finally:
release_vendor_interface(dev, vendor_itf)
print(f"\nSummary: {passed} passed, {failed} failed")
if failed == 0:
print("Dual interface support is working (HID + Vendor/WebUSB).")
return 0
return 1
if __name__ == "__main__":
sys.exit(main())