From 69c46ab2a7f37a7c252fe39f235836b08c8284bd Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Mon, 8 Jun 2026 20:24:02 -0400 Subject: [PATCH] v0.0.32 - Fix KB2040 Python WebUSB handshake and robust frame reassembly for get-public-key --- ...b2040_hidden_signer_client.cpython-313.pyc | Bin 0 -> 10059 bytes examples/kb2040_hidden_signer_client.py | 214 +++++ .../kb2040_dual_test/kb2040_dual_test.ino | 77 ++ firmware/kb2040_hidden_signer/README.md | 81 ++ firmware/kb2040_hidden_signer/display.cpp | 383 ++++++++ firmware/kb2040_hidden_signer/display.h | 37 + .../kb2040_hidden_signer.ino | 874 ++++++++++++++++++ firmware/kb2040_hidden_signer/mnemonic_kb.cpp | 154 +++ firmware/kb2040_hidden_signer/mnemonic_kb.h | 11 + .../kb2040_hidden_signer/mnemonic_wordlist.h | 261 ++++++ plans/kb2040_hidden_signer.md | 42 + src/main.c | 4 +- .../test_kb2040_dual.cpython-313.pyc | Bin 0 -> 6733 bytes tests/test_kb2040_dual.py | 151 +++ 14 files changed, 2287 insertions(+), 2 deletions(-) create mode 100644 examples/__pycache__/kb2040_hidden_signer_client.cpython-313.pyc create mode 100644 examples/kb2040_hidden_signer_client.py create mode 100644 firmware/kb2040_dual_test/kb2040_dual_test.ino create mode 100644 firmware/kb2040_hidden_signer/README.md create mode 100644 firmware/kb2040_hidden_signer/display.cpp create mode 100644 firmware/kb2040_hidden_signer/display.h create mode 100644 firmware/kb2040_hidden_signer/kb2040_hidden_signer.ino create mode 100644 firmware/kb2040_hidden_signer/mnemonic_kb.cpp create mode 100644 firmware/kb2040_hidden_signer/mnemonic_kb.h create mode 100644 firmware/kb2040_hidden_signer/mnemonic_wordlist.h create mode 100644 plans/kb2040_hidden_signer.md create mode 100644 tests/__pycache__/test_kb2040_dual.cpython-313.pyc create mode 100644 tests/test_kb2040_dual.py diff --git a/examples/__pycache__/kb2040_hidden_signer_client.cpython-313.pyc b/examples/__pycache__/kb2040_hidden_signer_client.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d8db2dbecc836615bc72fe24480ccea36180be2d GIT binary patch literal 10059 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 Z9w?RY@$=k48}+r#bFh>7dJhfx{{V$k2JHX< literal 0 HcmV?d00001 diff --git a/examples/kb2040_hidden_signer_client.py b/examples/kb2040_hidden_signer_client.py new file mode 100644 index 0000000..e3dc5d0 --- /dev/null +++ b/examples/kb2040_hidden_signer_client.py @@ -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()) diff --git a/firmware/kb2040_dual_test/kb2040_dual_test.ino b/firmware/kb2040_dual_test/kb2040_dual_test.ino new file mode 100644 index 0000000..54ff107 --- /dev/null +++ b/firmware/kb2040_dual_test/kb2040_dual_test.ino @@ -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 + +// 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); + } + } + } +} diff --git a/firmware/kb2040_hidden_signer/README.md b/firmware/kb2040_hidden_signer/README.md new file mode 100644 index 0000000..bb883d3 --- /dev/null +++ b/firmware/kb2040_hidden_signer/README.md @@ -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. diff --git a/firmware/kb2040_hidden_signer/display.cpp b/firmware/kb2040_hidden_signer/display.cpp new file mode 100644 index 0000000..e4998cd --- /dev/null +++ b/firmware/kb2040_hidden_signer/display.cpp @@ -0,0 +1,383 @@ +#include "display.h" + +#include +#include + +#include +#include + +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((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((static_cast(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; +} diff --git a/firmware/kb2040_hidden_signer/display.h b/firmware/kb2040_hidden_signer/display.h new file mode 100644 index 0000000..21385b2 --- /dev/null +++ b/firmware/kb2040_hidden_signer/display.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include + +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(); diff --git a/firmware/kb2040_hidden_signer/kb2040_hidden_signer.ino b/firmware/kb2040_hidden_signer/kb2040_hidden_signer.ino new file mode 100644 index 0000000..96dcc88 --- /dev/null +++ b/firmware/kb2040_hidden_signer/kb2040_hidden_signer.ino @@ -0,0 +1,874 @@ +#include + +#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((a << 1) | b); +} + +void on_encoder_edge() { + const int8_t curr_ab = read_ab(); + const uint8_t idx = static_cast((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(s_local_volume) + static_cast(delta); + if (next < 0) { + next = 0; + } else if (next > 100) { + next = 100; + } + + const uint8_t clamped = static_cast(next); + const int8_t applied = static_cast(static_cast(clamped) - static_cast(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 f t + // - 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); +} diff --git a/firmware/kb2040_hidden_signer/mnemonic_kb.cpp b/firmware/kb2040_hidden_signer/mnemonic_kb.cpp new file mode 100644 index 0000000..0717bdc --- /dev/null +++ b/firmware/kb2040_hidden_signer/mnemonic_kb.cpp @@ -0,0 +1,154 @@ +#include "mnemonic_kb.h" + +#include +#include + +#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; +} diff --git a/firmware/kb2040_hidden_signer/mnemonic_kb.h b/firmware/kb2040_hidden_signer/mnemonic_kb.h new file mode 100644 index 0000000..99b6803 --- /dev/null +++ b/firmware/kb2040_hidden_signer/mnemonic_kb.h @@ -0,0 +1,11 @@ +#pragma once + +#include +#include +#include + +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); diff --git a/firmware/kb2040_hidden_signer/mnemonic_wordlist.h b/firmware/kb2040_hidden_signer/mnemonic_wordlist.h new file mode 100644 index 0000000..b788488 --- /dev/null +++ b/firmware/kb2040_hidden_signer/mnemonic_wordlist.h @@ -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" +}; diff --git a/plans/kb2040_hidden_signer.md b/plans/kb2040_hidden_signer.md new file mode 100644 index 0000000..19196e3 --- /dev/null +++ b/plans/kb2040_hidden_signer.md @@ -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 ``. +* **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. diff --git a/src/main.c b/src/main.c index 83b7dbf..e36d322 100644 --- a/src/main.c +++ b/src/main.c @@ -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 */ diff --git a/tests/__pycache__/test_kb2040_dual.cpython-313.pyc b/tests/__pycache__/test_kb2040_dual.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1103f471a0aaa4b8f7fce8689fee415f08111ef1 GIT binary patch literal 6733 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 Op@$tEA|IY4ApajcQ@o}C literal 0 HcmV?d00001 diff --git a/tests/test_kb2040_dual.py b/tests/test_kb2040_dual.py new file mode 100644 index 0000000..ba3108c --- /dev/null +++ b/tests/test_kb2040_dual.py @@ -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())