From c847179ba4f629fccc3610ad76e6f08d2e173221 Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Tue, 9 Jun 2026 10:29:15 -0400 Subject: [PATCH] v0.0.33 - Implement in-place mnemonic entry grid with staged prefix selection and fix stage-3 candidate rendering --- documents/QUBES_OS.md | 52 +++ .../kb2040_hidden_signer/DISPLAY_SCREENS.md | 340 +++++++++++++++++ firmware/kb2040_hidden_signer/README.md | 21 +- firmware/kb2040_hidden_signer/display.cpp | 216 +++++++---- firmware/kb2040_hidden_signer/display.h | 3 +- .../kb2040_hidden_signer.ino | 353 ++++++++++-------- firmware/kb2040_hidden_signer/mnemonic_kb.cpp | 92 +++++ firmware/kb2040_hidden_signer/mnemonic_kb.h | 2 + src/main.c | 4 +- 9 files changed, 859 insertions(+), 224 deletions(-) create mode 100644 firmware/kb2040_hidden_signer/DISPLAY_SCREENS.md diff --git a/documents/QUBES_OS.md b/documents/QUBES_OS.md index f780d9d..3b287dc 100644 --- a/documents/QUBES_OS.md +++ b/documents/QUBES_OS.md @@ -160,6 +160,58 @@ Includes: - missing policy -> deny path - missing `QREXEC_REMOTE_DOMAIN` -> fallback identity path +## 5.5 USB keyboard firmware workflows in a PCI-USB-controller qube + +When a full USB controller is assigned directly to a qube via PCI passthrough, HID input does **not** flow through `sys-usb` input proxy policy. In this setup, keyboard-class USB blocking is usually caused by `usbguard` inside the owning qube. + +### Scope check (dom0) + +```bash +qvm-prefs AI klass +``` + +- `StandaloneVM`: service state changes in the qube root filesystem persist. +- `AppVM`: root filesystem state resets at reboot; use `/rw/config/rc.local` for per-qube persistence. + +### Disable usbguard immediately (inside AI qube) + +```bash +sudo systemctl disable --now usbguard +sudo systemctl mask usbguard +``` + +### Persist behavior across reboot + +- **StandaloneVM**: the mask persists as-is. +- **AppVM**: persist with `/rw/config/rc.local`: + +```bash +sudo tee /rw/config/rc.local >/dev/null <<'EOF' +#!/bin/sh +systemctl stop usbguard 2>/dev/null || true +systemctl mask usbguard 2>/dev/null || true +EOF +sudo chmod +x /rw/config/rc.local +``` + +### Restart + verify + +```bash +# dom0 +qvm-shutdown --wait AI && qvm-start AI + +# inside AI qube +systemctl is-enabled usbguard +lsusb +ls /dev/ttyACM* +``` + +Expected result: `usbguard` is `masked`, the keyboard-class device enumerates, and serial/WebUSB workflows remain usable. + +### Security trade-off + +Disabling `usbguard` removes an important BadUSB defense for that qube. Keep this configuration limited to a dedicated hardware-development qube and avoid using it for general browsing or untrusted USB devices. + --- ## 6. Security requirements diff --git a/firmware/kb2040_hidden_signer/DISPLAY_SCREENS.md b/firmware/kb2040_hidden_signer/DISPLAY_SCREENS.md new file mode 100644 index 0000000..ef8f23b --- /dev/null +++ b/firmware/kb2040_hidden_signer/DISPLAY_SCREENS.md @@ -0,0 +1,340 @@ +# KB2040 Hidden Signer — Display Screens + +Reference for the on-device UI so we can iterate on screen designs. + +- Panel: **SSD1327 128x128 grayscale OLED** (I2C, STEMMA QT, addr `0x3D`). +- Rendering source: [`display.cpp`](./display.cpp), driven by call sites in [`kb2040_hidden_signer.ino`](./kb2040_hidden_signer.ino). +- Grayscale levels in use: + - dim (headers / outlines): `0x04` + - bar fill: `0x08` + - bright text: `0x0F` +- Each screen historically ended with a bottom **status line** at `y=118`, drawn by `drawStatusLine()`. The status-line writes are currently commented out in the sketch (they were being overwritten by debug/diagnostic prints), so that line now stays effectively empty unless re-enabled. + +The boxes below are schematic (the panel is 128x128 px, not character-aligned). They show layout intent, relative position, and the text/labels actually printed. + +--- + +## 1. Volume View +Source: `drawVolumeView()` in [`display.cpp`](./display.cpp:60). +Top-level "media mode" screen. Header switches between `MODE: MEDIA` / `MODE: SIGNER`. + +``` ++--------------------------------+ +| MODE: MEDIA | <- tiny, dim (size 1) +| | +| VOL | <- size 2, dim +| | +| | +| #### #### | +| ## ## # ## | <- "50" centered, size 6 +| ## ## # ## | (huge digits) +| ## ## # ## | +| #### #### | +| | +| +------------------------+ | <- volume bar outline +| |########## | | fill = volume % +| +------------------------+ | +| | +| [status line] | <- currently disabled ++--------------------------------+ +``` + +--- + +## 2. Signer Menu +Source: `drawSignerMenu()` in [`display.cpp`](./display.cpp:102). +Two items; `>` marks the current selection (shown here on index 0). + +``` ++--------------------------------+ +| SIGNER MENU | <- size 1, dim +| | +| | +| > Generate new | <- size 2, selected +| | +| | +| Enter mnemonic | <- size 2 +| | +| | +| | +| | +| [status line] | <- currently disabled ++--------------------------------+ +``` + +--- + +## 3. Enter Mnemonic (in-place 2-column grid) +Source (planned): `drawEnterMnemonic()` in [`display.cpp`](./display.cpp). +Replaces the old separate `PICK FIRST LETTER` modal + `WORD PICK` modal with a +single grid that looks like the Generated Phrase screen but is filled in by the +user. Header reads `ENTER MNEMONIC`. + +Layout: 2 columns x 6 rows (slots 1-6 left, 7-12 right), same geometry as the +Generated Phrase screen. All 12 numbers are shown; words are blank until entered. +A cursor marks the active slot (starts at slot 1). + +``` ++--------------------------------+ +| ENTER MNEMONIC | <- size 1, dim +|> 1 m 7 | <- cursor '>' on active slot +| 2 8 | slot 1 building: "m" +| 3 9 | +| 4 10 | +| 5 11 | +| 6 12 | +| | +| back letter | <- footer legend (stage-dependent) ++--------------------------------+ +``` + +### Per-word 3-stage entry (in the active slot) + +Only the letters chosen so far are shown; a full word appears only at stage 3. + +| Stage | Scroll wheel cycles | Active slot shows | PLAY does | +| ----- | ---------------------------------- | ----------------- | -------------------------- | +| 1 | first letter `a`..`z` | `m` | lock letter 1 -> stage 2 | +| 2 | valid 2nd letters for letter 1 | `mo` | lock letter 2 -> stage 3 | +| 3 | words matching first two letters | `mosaic` | select word -> next slot | + +- After slot 12's word is selected: build phrase, run + `mnemonic_validate_basic()`, then go to Signer Ready (or INVALID modal). + +### BACK behavior (context-sensitive erase) + +`BACK` = the NEXT button (PREV stays unused here), consistent with other entry +screens. + +| Position | BACK action | +| -------------------------------- | --------------------------------------------- | +| Stage 3 (choosing word) | erase 2nd letter -> return to stage 2 | +| Stage 2 (choosing 2nd letter) | erase 1st letter -> return to stage 1 | +| Stage 1, slot N > 1 | clear previous slot's word, move cursor back to slot N-1 at stage 1 | +| Stage 1, slot 1 | exit to Signer Menu | + +### Footer legend (stage-dependent) + +| Stage | PREV (left) | PLAY (center) | NEXT (right) | +| ------- | ----------- | ------------- | ------------ | +| Stage 1 | `-` | `letter` | `back` | +| Stage 2 | `-` | `letter` | `back` | +| Stage 3 | `-` | `select` | `back` | + +### New wordlist helpers required (in `mnemonic_kb.cpp` / `.h`) + +- `mnemonic_second_letters(char first, char *out, uint8_t *count)` + -> set of valid 2nd letters given the 1st letter. +- `mnemonic_prefix_range(char first, char second, uint16_t *start, uint16_t *count)` + -> wordlist index range for all words starting with those two letters. + +(`mnemonic_letter_range()` already provides the first-letter range and can back +the stage-1 -> stage-2 transition.) + +--- + +## 4. Mnemonic Page (Generated Phrase) +Source: `drawMnemonicPage()` in [`display.cpp`](./display.cpp:152). +Shows generated phrase in one screen: 2 columns × 6 rows (all 12 words, no spacer rows). + +``` ++--------------------------------+ +| GENERATED PHRASE | <- size 1, dim +| 1 abandon 7 issue | <- size 1, tight rows +| 2 ability 8 jungle | +| 3 able 9 kitchen | +| 4 about 10 lemon | +| 5 above 11 mosquito | +| 6 absent 12 napkin | +| | +| PLAY confirm NEXT cancel | <- size 1 +| | +| [status line] | <- currently disabled ++--------------------------------+ +``` + +--- + +## 5. Message (generic 2-line modal) +Source: `drawMessage()` in [`display.cpp`](./display.cpp:193). +Generic title + 2 lines. Reused for several prompts. + +Example — **Approval prompt** (title `APPROVAL`): + +``` ++--------------------------------+ +| APPROVAL | <- size 1, dim +| | +| | +| PLAY=Allow | <- size 2 +| | +| PREV=Deny | <- size 2 +| | +| | +| | +| | +| | +| [status line] | <- currently disabled ++--------------------------------+ +``` + +Other current uses of this same screen: +- `PICK FIRST LETTER` with lines `First letter` / `Letter: a` +- `INVALID` with `Bad mnemonic` / `NEXT to return` +- `ERROR` with `Generate failed` / `Try again` + +--- + +## 6. Signer Ready +Source: `drawSignerReady()` in [`display.cpp`](./display.cpp:210). +Shown while waiting for the host to request signing. + +``` ++--------------------------------+ +| SIGNER READY | <- size 1, dim +| | +| +------------------------+ | +| | Awaiting | | <- size 2 +| | | | +| | host | | <- size 2 +| +------------------------+ | +| | +| NEXT/PLAY: menu | <- size 1 +| | +| [status line] | <- currently disabled ++--------------------------------+ +``` + +--- + +## Notes / observations for redesign + +- The bottom status line frequently **duplicated** the in-body hint text (e.g. Word Pick showed `ENC scroll PLAY select` both in the body and in the status line). With status writes disabled, that redundancy is gone. +- `PICK FIRST LETTER` reuses the generic Message screen rather than having a dedicated layout — a candidate for a purpose-built screen. +- All hint text (`ENC` / `PLAY` / `NEXT` / `PREV`) refers to the physical controls: rotary encoder + the PLAY/NEXT/PREV buttons. +``` + +--- + +## Screen ↔ render-function map + +| Screen | Render function | display.cpp | +| --------------- | --------------------- | ----------- | +| Volume View | `drawVolumeView()` | [60](./display.cpp:60) | +| Signer Menu | `drawSignerMenu()` | [102](./display.cpp:102) | +| Word Pick | `drawWordPick()` | [123](./display.cpp:123) | +| Mnemonic Page | `drawMnemonicPage()` | [152](./display.cpp:152) | +| Message (modal) | `drawMessage()` | [193](./display.cpp:193) | +| Signer Ready | `drawSignerReady()` | [210](./display.cpp:210) | +| Status line | `drawStatusLine()` | [53](./display.cpp:53) | + +--- + +# PROPOSAL: three-zone button legend footer + +The three physical buttons sit directly below the screen in this order: + +``` + PREV PLAY/PAUSE NEXT + ^ ^ ^ + (left) (center) (right) +``` + +Idea: replace the free-text status line with a fixed **3-zone legend** along the +bottom of every screen. Each zone shows a single short verb for what that button +does on the current screen — left-aligned, center-aligned, right-aligned so each +label sits over its physical button. No need to print the button name; position +implies it. An empty zone means that button does nothing on that screen. + +### Layout math (size 1 font, 6 px/char on a 128 px panel) + +- Left zone: left-aligned at `x = 2`. +- Center zone: centered around `x = 64` (use `getTextBounds` to center). +- Right zone: right-aligned to `x = 126` (compute `x = 126 - width`). +- Budget ~7 chars per zone worst case (7 x 6 = 42 px x 3 = 126 px). Keep verbs + <= 7 chars (`select`, `confirm`, `cancel`, `back`, `deny`, `allow`, `next`, + `prev`) to guarantee no overlap. +- Optional: a thin divider line above the legend at `y ~= 112`, and 1 px vertical + ticks between zones to reinforce the 3-button mapping. + +``` ++--------------------------------+ +| ...screen body... | +| | +| -------------------------------| <- optional divider (y~112) +| prev confirm next | <- 3 zones (left/center/right) ++--------------------------------+ + ^PREV ^PLAY ^NEXT (physical buttons below glass) +``` + +### Per-screen legend mapping (derived from `signer_mode_tick()`) + +Encoder still scrolls/selects on every screen; the legend only describes the 3 +buttons. `-` = button does nothing on that screen. + +| Screen / state | PREV (left) | PLAY (center) | NEXT (right) | +| -------------------------------------- | ----------- | ------------- | ------------ | +| Volume / Media view | `prev` | `play` | `next` | +| Signer Menu (`SIGNER_UI_MENU`) | `-` | `select` | `-` | +| Generated Phrase (`GENERATE_REVIEW`) | `cancel` | `confirm` | `new` | +| Signer Ready (`READY`) | `-` | `-` | `menu` | +| Letter Pick (`ENTER_LETTER`) | `-` | `confirm` | `back` | +| Word Pick (`ENTER_PICK`) | `-` | `select` | `back` | +| Approval prompt (Message modal) | `deny` | `allow` | `-` | + +Notes: +- `PREV` is currently unused in most signer entry screens; it is meaningful in + Media view (`prev` track), the Approval prompt (`deny`), and now the Generated + Phrase screen (`cancel`). + +### Behavior change required: Generated Phrase (`SIGNER_UI_GENERATE_REVIEW`) + +The legend wording above implies a **functional change** in +[`signer_mode_tick()`](./kb2040_hidden_signer.ino:657), not just labels: + +| Button | Current behavior | Proposed behavior | +| ------ | --------------------------- | ------------------------------------------ | +| PREV | (unused) | `cancel` -> return to Signer Menu | +| PLAY | confirm -> save seed | `confirm` -> save seed (unchanged) | +| NEXT | cancel -> Signer Menu | `new` -> regenerate a fresh 12-word phrase and refresh this screen | + +Implementation detail for `new`: +- On `s_evt_next_pressed`, call `generate_mnemonic_12(s_generated_mnemonic, ...)` + again, then `signer_enter_review_screen()` to redraw with the new words. +- On `s_evt_prev_pressed`, call `signer_enter_menu_screen()` (the old NEXT path). + +> Dependency check (resolved): `s_evt_prev_pressed` already exists and is wired +> the same way as PLAY/NEXT — declared at [line 112](./kb2040_hidden_signer.ino:112), +> reset in [`update_button_events()`](./kb2040_hidden_signer.ino:560), set on PREV +> press at [line 581](./kb2040_hidden_signer.ino:581). No new plumbing needed. + +### What this replaces on each screen + +- Word Pick body lines `ENC scroll PLAY select` / `NEXT back` -> removed; the + encoder action stays implicit, and PLAY/NEXT move to the footer legend. +- Mnemonic Page body line `PLAY confirm NEXT cancel` -> moves to the footer + legend (frees a body row). +- Signer Ready body line `NEXT/PLAY: menu` -> becomes a single `menu` in the + NEXT zone. +- Approval modal `PLAY=Allow` / `PREV=Deny` body lines -> footer legend + (`deny` left / `allow` center). + +### Implementation sketch + +1. Add a helper `drawButtonLegend(const char *left, const char *center, const char *right)` + in [`display.cpp`](./display.cpp) that renders the 3 aligned zones (plus the + optional divider/ticks) at the bottom. +2. Give each screen a small static legend (or pass it in) and call + `drawButtonLegend(...)` instead of `drawStatusLine()`. +3. Remove the now-redundant in-body hint rows listed above. +4. Keep `drawStatusLine()` available (commented/disabled) for transient debug. + +### Open design decisions (need your call) + +1. **NEXT "back" wording**: use the word `back` vs a glyph like `<` / `<<`. +2. **Divider/ticks**: include the thin divider line + inter-zone ticks, or keep + it text-only for a cleaner look. +3. **Empty zones**: render nothing, or a subtle `·` placeholder so the user can + tell the button is intentionally inactive. +4. **Media view**: apply the same legend (`prev` / `play` / `next`), or leave the + Volume view visually as-is since its meaning is already obvious. diff --git a/firmware/kb2040_hidden_signer/README.md b/firmware/kb2040_hidden_signer/README.md index bb883d3..ed55f26 100644 --- a/firmware/kb2040_hidden_signer/README.md +++ b/firmware/kb2040_hidden_signer/README.md @@ -35,7 +35,7 @@ This directory contains an Arduino firmware scaffold that implements the `kb2040 3. Wire exact `keyboard_modules` pin mapping and display abstraction. 4. Add secure memory wiping and persistent policy storage if required. -## Arduino setup +## Arduino setup (IDE) 1. Install Adafruit board index in Arduino IDE. 2. Install **Adafruit RP2040** boards package. @@ -44,6 +44,25 @@ This directory contains an Arduino firmware scaffold that implements the `kb2040 5. Install library **Adafruit TinyUSB Library**. 6. Open and flash [`kb2040_hidden_signer.ino`](./kb2040_hidden_signer.ino). +## Arduino CLI flashing (recommended repeatable command) + +Use this exact command when flashing from CLI: + +```bash +arduino-cli compile --upload -p /dev/ttyACM0 \ + --fqbn rp2040:rp2040:adafruit_kb2040:usbstack=tinyusb \ + firmware/kb2040_hidden_signer +``` + +Notes: +- `usbstack=tinyusb` is **required** for this firmware (TinyUSB composite device). +- If you omit that option, build fails with `TinyUSB is not selected`. +- If `/dev/ttyACM0` changes, find the current port with: + +```bash +arduino-cli board list +``` + ## Host validation Install dependency: diff --git a/firmware/kb2040_hidden_signer/display.cpp b/firmware/kb2040_hidden_signer/display.cpp index e4998cd..2a260b2 100644 --- a/firmware/kb2040_hidden_signer/display.cpp +++ b/firmware/kb2040_hidden_signer/display.cpp @@ -26,19 +26,20 @@ 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] = ""; +char g_enterSlots[12][16] = {{0}}; +uint8_t g_enterActiveSlot = 0; +uint8_t g_enterStage = 1; +char g_enterBuilding[16] = ""; +uint8_t g_enterCursorPos = 0; enum signer_screen_t { SIGNER_SCREEN_MENU = 0, - SIGNER_SCREEN_WORD_PICK = 1, + SIGNER_SCREEN_ENTER_GRID = 1, SIGNER_SCREEN_MNEMONIC_PAGE = 2, SIGNER_SCREEN_MESSAGE = 3, SIGNER_SCREEN_READY = 4, @@ -57,12 +58,38 @@ void drawStatusLine() { g_display.print(g_status); } +void drawButtonLegend(const char *left, const char *center, const char *right) { + g_display.setTextSize(1); + g_display.setTextColor(kGrayText); + g_display.setTextWrap(false); + + if (left && left[0] != '\0') { + g_display.setCursor(2, 118); + g_display.print(left); + } + + int16_t x1 = 0; + int16_t y1 = 0; + uint16_t w = 0; + uint16_t h = 0; + + if (center && center[0] != '\0') { + g_display.getTextBounds(center, 0, 0, &x1, &y1, &w, &h); + const int16_t x = static_cast((kDisplayWidth - w) / 2); + g_display.setCursor(x, 118); + g_display.print(center); + } + + if (right && right[0] != '\0') { + g_display.getTextBounds(right, 0, 0, &x1, &y1, &w, &h); + const int16_t x = static_cast(126 - w); + g_display.setCursor(x, 118); + g_display.print(right); + } +} + 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); @@ -96,7 +123,7 @@ void drawVolumeView() { g_display.fillRect(barX + 1, barY + 1, fillW, barH - 2, kGrayBar); } - drawStatusLine(); + drawButtonLegend("prev", "play", "next"); } void drawSignerMenu() { @@ -117,36 +144,76 @@ void drawSignerMenu() { g_display.print(items[i]); } - drawStatusLine(); + drawButtonLegend("", "select", ""); } -void drawWordPick() { +void drawEnterMnemonic() { 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); + g_display.print("ENTER MNEMONIC"); - 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); + for (uint8_t i = 0; i < 12; ++i) { + const uint8_t row = (i < 6) ? i : (i - 6); + const int16_t x = (i < 6) ? 0 : 64; + const int16_t y = static_cast(18 + row * 9); - 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"); + const bool isActive = (i == g_enterActiveSlot); + const char cursor = isActive ? '>' : ' '; - drawStatusLine(); + const char *slotWord = g_enterSlots[i]; + if (!slotWord) { + slotWord = ""; + } + + char activeBuf[16]; + const char *shownWord = slotWord; + if (isActive) { + const char *src = g_enterBuilding; + if (!src) { + src = ""; + } + + if (g_enterStage < 3) { + size_t srcLen = strlen(src); + if (srcLen > 14) { + srcLen = 14; + } + size_t pos = g_enterCursorPos; + if (pos > srcLen) { + pos = srcLen; + } + + size_t out = 0; + for (size_t j = 0; j < pos && out < sizeof(activeBuf) - 1; ++j) { + activeBuf[out++] = src[j]; + } + if (out < sizeof(activeBuf) - 1) { + activeBuf[out++] = '_'; + } + for (size_t j = pos; j < srcLen && out < sizeof(activeBuf) - 1; ++j) { + activeBuf[out++] = src[j]; + } + activeBuf[out] = '\0'; + shownWord = activeBuf; + } else if (src[0] != '\0') { + shownWord = src; + } + } + + char lineBuf[22]; + snprintf(lineBuf, sizeof(lineBuf), "%c%2u %s", cursor, (unsigned)(i + 1), shownWord); + g_display.setCursor(x, y); + g_display.print(lineBuf); + } + + if (g_enterStage == 3) { + drawButtonLegend("", "select", "back"); + } else { + drawButtonLegend("", "letter", "back"); + } } void drawMnemonicPage() { @@ -160,34 +227,28 @@ void drawMnemonicPage() { 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 *words[12] = {nullptr}; + uint8_t count = 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++; + while (tok && count < 12) { + words[count++] = tok; 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"); + g_display.setTextColor(kGrayText); + for (uint8_t i = 0; i < count; ++i) { + const uint8_t row = (i < 6) ? i : (i - 6); + const int16_t x = (i < 6) ? 0 : 64; + const int16_t y = static_cast(18 + row * 9); - drawStatusLine(); + char lineBuf[20]; + snprintf(lineBuf, sizeof(lineBuf), "%2u %s", (unsigned)(i + 1), words[i]); + g_display.setCursor(x, y); + g_display.print(lineBuf); + } + + drawButtonLegend("cancel", "confirm", "new"); } void drawMessage() { @@ -204,7 +265,21 @@ void drawMessage() { g_display.setCursor(6, 62); g_display.print(g_msgLine2); - drawStatusLine(); + const char *left = ""; + const char *center = ""; + const char *right = ""; + + if (strcmp(g_msgTitle, "APPROVAL") == 0) { + left = "deny"; + center = "allow"; + } else if (strcmp(g_msgTitle, "INVALID") == 0) { + center = "select"; + right = "back"; + } else if (strcmp(g_msgTitle, "ERROR") == 0) { + center = "select"; + } + + drawButtonLegend(left, center, right); } void drawSignerReady() { @@ -223,11 +298,7 @@ void drawSignerReady() { 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(); + drawButtonLegend("", "", "menu"); } void renderFrame() { @@ -244,8 +315,8 @@ void renderFrame() { case SIGNER_SCREEN_MENU: drawSignerMenu(); break; - case SIGNER_SCREEN_WORD_PICK: - drawWordPick(); + case SIGNER_SCREEN_ENTER_GRID: + drawEnterMnemonic(); break; case SIGNER_SCREEN_MNEMONIC_PAGE: drawMnemonicPage(); @@ -335,12 +406,22 @@ void display_show_signer_menu(uint8_t 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); +void display_show_enter_mnemonic(const char *const slots[12], uint8_t active_slot, uint8_t stage, + const char *building_text, uint8_t cursor_pos) { + g_signerScreen = SIGNER_SCREEN_ENTER_GRID; + g_enterActiveSlot = (active_slot > 11) ? 11 : active_slot; + g_enterStage = stage; + g_enterCursorPos = cursor_pos; + copy_text(g_enterBuilding, sizeof(g_enterBuilding), building_text); + + for (uint8_t i = 0; i < 12; ++i) { + const char *src = ""; + if (slots && slots[i]) { + src = slots[i]; + } + copy_text(g_enterSlots[i], sizeof(g_enterSlots[i]), src); + } + g_dirty = true; } @@ -364,6 +445,9 @@ void display_show_signer_ready() { g_dirty = true; } +// Note: status-line updates are currently disabled at call sites in +// kb2040_hidden_signer.ino by commenting out display_set_status(...) +// invocations to keep the bottom status line from being overwritten. void display_set_status(const char *status) { if (!status) { return; diff --git a/firmware/kb2040_hidden_signer/display.h b/firmware/kb2040_hidden_signer/display.h index 21385b2..77b3ca2 100644 --- a/firmware/kb2040_hidden_signer/display.h +++ b/firmware/kb2040_hidden_signer/display.h @@ -25,7 +25,8 @@ 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_enter_mnemonic(const char *const slots[12], uint8_t active_slot, uint8_t stage, + const char *building_text, uint8_t cursor_pos); 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(); diff --git a/firmware/kb2040_hidden_signer/kb2040_hidden_signer.ino b/firmware/kb2040_hidden_signer/kb2040_hidden_signer.ino index 96dcc88..55481eb 100644 --- a/firmware/kb2040_hidden_signer/kb2040_hidden_signer.ino +++ b/firmware/kb2040_hidden_signer/kb2040_hidden_signer.ino @@ -52,8 +52,7 @@ 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, + SIGNER_UI_ENTER_GRID = 3, }; static device_mode_t s_mode = MODE_MEDIA; @@ -67,12 +66,19 @@ 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 uint8_t s_signer_review_page = 0; // legacy placeholder (review now renders all 12 words at once) 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 uint8_t s_enter_slot = 0; // 0..11 +static uint8_t s_enter_stage = 1; // 1=first letter, 2=second letter, 3=word select +static uint8_t s_enter_first_letter_index = 0; // 0..25 => a..z +static char s_enter_second_letters[26] = {0}; +static uint8_t s_enter_second_count = 0; +static uint8_t s_enter_second_index = 0; +static uint16_t s_enter_prefix_start = 0; +static uint16_t s_enter_prefix_count = 0; +static uint16_t s_enter_candidate_offset = 0; static uint16_t s_enter_selected_indices[12] = {0}; +static bool s_enter_committed[12] = {false}; static void signer_enter_ready_screen(); @@ -128,8 +134,9 @@ static void led_blink(int n, int on_ms, int off_ms) { static void send_consumer_key(uint16_t usage) { if (!usb_hid.ready()) return; usb_hid.sendReport16(1, usage); - delay(2); + delay(8); // restore hold for better throughput while keeping reliability usb_hid.sendReport16(1, 0); + delay(4); // restore smaller inter-key gap for faster throughput } static int8_t read_ab() { @@ -252,30 +259,30 @@ static void signer_apply_seed(const char *mnemonic) { 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"); + // 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"); + display_show_message("APPROVAL", "Host requests", "signature"); + // 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"); + // display_set_status("Approved"); return true; } if (btn_pressed(PIN_BTN_PREV)) { while (btn_pressed(PIN_BTN_PREV)) delay(5); - display_set_status("Denied"); + // display_set_status("Denied"); return false; } display_tick(); delay(5); } - display_set_status("Approval timeout"); + // display_set_status("Approval timeout"); return false; } @@ -293,13 +300,13 @@ 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"); + // display_set_status("RPC invalid request"); return; } if (strcmp(method, "ping") == 0) { send_json_response("{\"result\":\"pong\"}"); - display_set_status("RPC ping"); + // display_set_status("RPC ping"); return; } @@ -311,7 +318,7 @@ static void handle_rpc(const char *req_json) { s_seed_loaded ? "true" : "false", s_auto_approve ? "true" : "false"); send_json_response(resp); - display_set_status("RPC get_status"); + // display_set_status("RPC get_status"); return; } @@ -319,7 +326,7 @@ static void handle_rpc(const char *req_json) { 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"); + // display_set_status("Mnemonic missing"); return; } @@ -335,14 +342,14 @@ static void handle_rpc(const char *req_json) { if (strstr(req_json, "\"value\":true") != nullptr) { s_auto_approve = true; send_json_response("{\"result\":true}"); - display_set_status("Auto-approve ON"); + // 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"); + // display_set_status("Auto-approve OFF"); } else { send_json_response("{\"error\":{\"code\":-32602,\"message\":\"missing value\"}}"); - display_set_status("Auto-approve invalid"); + // display_set_status("Auto-approve invalid"); } return; } @@ -350,7 +357,7 @@ static void handle_rpc(const char *req_json) { 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"); + // display_set_status("No seed"); return; } @@ -360,20 +367,20 @@ static void handle_rpc(const char *req_json) { char resp[192]; snprintf(resp, sizeof(resp), "{\"result\":{\"pubkey\":\"%s\"}}", pubhex); send_json_response(resp); - display_set_status("Pubkey served"); + // 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"); + // 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"); + // display_set_status("Sign blocked:mode"); return; } @@ -390,7 +397,7 @@ static void handle_rpc(const char *req_json) { } send_json_response("{\"result\":{\"sig\":\"dev_signature_placeholder\"}}"); - display_set_status("Event signed"); + // 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) { @@ -400,7 +407,7 @@ static void handle_rpc(const char *req_json) { } send_json_response("{\"error\":{\"code\":-32601,\"message\":\"method not found\"}}"); - display_set_status("RPC method missing"); + // display_set_status("RPC method missing"); } static void webusb_pump_frames() { @@ -426,7 +433,7 @@ static void webusb_pump_frames() { 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"); + // display_set_status("Frame too large"); return; } @@ -452,56 +459,96 @@ 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"); + // 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"); + // display_set_status("Ready to sign"); } -static char signer_current_letter() { - return (char)('a' + (s_enter_letter_index % 26)); +static char signer_first_letter() { + return (char)('a' + (s_enter_first_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 char signer_second_letter() { + if (s_enter_second_count == 0 || s_enter_second_index >= s_enter_second_count) { + return 'a'; + } + return s_enter_second_letters[s_enter_second_index]; } -static void signer_enter_word_pick_screen() { - s_signer_ui_state = SIGNER_UI_ENTER_PICK; +static void signer_reset_active_slot_state() { + s_enter_stage = 1; + s_enter_first_letter_index = 0; + s_enter_second_count = 0; + s_enter_second_index = 0; + s_enter_prefix_start = 0; + s_enter_prefix_count = 0; + s_enter_candidate_offset = 0; +} - uint16_t start = 0; - uint16_t count = 0; - if (!mnemonic_letter_range(signer_current_letter(), &start, &count) || count == 0) { - start = 0; - count = 2048; +static bool signer_prepare_second_letters() { + s_enter_second_count = 0; + s_enter_second_index = 0; + return mnemonic_second_letters(signer_first_letter(), s_enter_second_letters, &s_enter_second_count) && s_enter_second_count > 0; +} + +static bool signer_prepare_prefix_candidates() { + s_enter_prefix_start = 0; + s_enter_prefix_count = 0; + s_enter_candidate_offset = 0; + return mnemonic_prefix_range(signer_first_letter(), signer_second_letter(), &s_enter_prefix_start, &s_enter_prefix_count) && + s_enter_prefix_count > 0; +} + +static void signer_render_enter_grid() { + const char *slot_words[12] = {nullptr}; + for (uint8_t i = 0; i < 12; ++i) { + if (s_enter_committed[i]) { + slot_words[i] = mnemonic_word_at(s_enter_selected_indices[i]); + } else { + slot_words[i] = ""; + } } - if (s_enter_word_index < start || s_enter_word_index >= (uint16_t)(start + count)) { - s_enter_word_index = start; + char building[16] = ""; + uint8_t cursor_pos = 0; + + if (s_enter_stage == 1) { + building[0] = signer_first_letter(); + building[1] = '\0'; + cursor_pos = 1; + } else if (s_enter_stage == 2) { + building[0] = signer_first_letter(); + building[1] = signer_second_letter(); + building[2] = '\0'; + cursor_pos = 2; + } else { + const uint16_t idx = (uint16_t)(s_enter_prefix_start + s_enter_candidate_offset); + const char *word = mnemonic_word_at(idx); + if (word) { + strncpy(building, word, sizeof(building) - 1); + building[sizeof(building) - 1] = '\0'; + } + cursor_pos = (uint8_t)strlen(building); } - 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"); + display_show_enter_mnemonic(slot_words, s_enter_slot, s_enter_stage, building, cursor_pos); +} + +static void signer_enter_grid_screen() { + s_signer_ui_state = SIGNER_UI_ENTER_GRID; + display_set_view(DISPLAY_VIEW_SIGNER); + signer_render_enter_grid(); } 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"); + display_show_mnemonic_page(s_generated_mnemonic, 0); + // display_set_status("PLAY confirm NEXT cancel"); } static void build_entered_mnemonic(char *out, size_t out_sz) { @@ -513,6 +560,10 @@ static void build_entered_mnemonic(char *out, size_t out_sz) { size_t cursor = 0; for (uint8_t i = 0; i < 12; ++i) { + if (!s_enter_committed[i]) { + return; + } + const char *w = mnemonic_word_at(s_enter_selected_indices[i]); if (!w) w = "abandon"; @@ -544,10 +595,10 @@ static void handle_mode_switch_chord() { if (s_mode == MODE_SIGNER) { display_set_view(DISPLAY_VIEW_SIGNER); signer_enter_menu_screen(); - display_set_status("Signer mode"); + // display_set_status("Signer mode"); } else { display_set_view(DISPLAY_VIEW_VOLUME); - display_set_status("Media mode"); + // display_set_status("Media mode"); } led_blink(s_mode == MODE_SIGNER ? 3 : 1, 80, 80); @@ -588,36 +639,32 @@ 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); - } + volume_change_by(+1); // keep display clamped to 0..100 + send_consumer_key(HID_USAGE_CONSUMER_VOLUME_INCREMENT); // always emit HID step + display_set_volume(s_local_volume); } - display_set_status("Volume up"); + // 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); - } + volume_change_by(-1); // keep display clamped to 0..100 + send_consumer_key(HID_USAGE_CONSUMER_VOLUME_DECREMENT); // always emit HID step + display_set_volume(s_local_volume); } - display_set_status("Volume down"); + // display_set_status("Volume down"); } if (s_evt_play_pressed) { send_consumer_key(HID_USAGE_CONSUMER_PLAY_PAUSE); - display_set_status("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"); + // display_set_status("Prev track"); } if (s_evt_prev_pressed) { send_consumer_key(HID_USAGE_CONSUMER_SCAN_NEXT_TRACK); - display_set_status("Next track"); + // display_set_status("Next track"); } } @@ -646,27 +693,22 @@ static void signer_mode_tick() { signer_enter_review_screen(); } else { display_show_message("ERROR", "Generate failed", "Try again"); - display_set_status("Generate failed"); + // 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(); + for (uint8_t i = 0; i < 12; ++i) { + s_enter_committed[i] = false; + s_enter_selected_indices[i] = 0; + } + signer_reset_active_slot_state(); + signer_enter_grid_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(); @@ -674,7 +716,15 @@ static void signer_mode_tick() { if (s_evt_next_pressed) { signer_enter_menu_screen(); - display_set_status("Generate canceled"); + // display_set_status("Generate canceled"); + } + + if (s_evt_prev_pressed) { + if (generate_mnemonic_12(s_generated_mnemonic, sizeof(s_generated_mnemonic)) == 0) { + signer_enter_review_screen(); + } else { + display_show_message("ERROR", "Generate failed", "Try again"); + } } break; } @@ -686,85 +736,80 @@ static void signer_mode_tick() { break; } - case SIGNER_UI_ENTER_LETTER: { + case SIGNER_UI_ENTER_GRID: { 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'); + if (s_enter_stage == 1) { + int16_t idx = (int16_t)s_enter_first_letter_index + (steps > 0 ? 1 : -1); + while (idx < 0) idx += 26; + while (idx >= 26) idx -= 26; + s_enter_first_letter_index = (uint8_t)idx; + } else if (s_enter_stage == 2) { + if (s_enter_second_count > 0) { + int16_t idx = (int16_t)s_enter_second_index + (steps > 0 ? 1 : -1); + while (idx < 0) idx += s_enter_second_count; + while (idx >= s_enter_second_count) idx -= s_enter_second_count; + s_enter_second_index = (uint8_t)idx; } - s_enter_word_index = s_enter_selected_indices[s_enter_slot]; - signer_enter_word_pick_screen(); - display_set_status("Back one step"); + } else if (s_enter_prefix_count > 0) { + int32_t rel = (int32_t)s_enter_candidate_offset + steps; + while (rel < 0) rel += s_enter_prefix_count; + while (rel >= s_enter_prefix_count) rel -= s_enter_prefix_count; + s_enter_candidate_offset = (uint16_t)rel; } - } - 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(); + signer_render_enter_grid(); } 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(); + if (s_enter_stage == 1) { + if (signer_prepare_second_letters()) { + s_enter_stage = 2; + signer_render_enter_grid(); + } + } else if (s_enter_stage == 2) { + if (signer_prepare_prefix_candidates()) { + s_enter_stage = 3; + signer_render_enter_grid(); + } } else { - char phrase[256]; - build_entered_mnemonic(phrase, sizeof(phrase)); - if (mnemonic_validate_basic(phrase)) { - signer_apply_seed(phrase); - signer_enter_ready_screen(); + s_enter_selected_indices[s_enter_slot] = (uint16_t)(s_enter_prefix_start + s_enter_candidate_offset); + s_enter_committed[s_enter_slot] = true; + + if (s_enter_slot < 11) { + s_enter_slot++; + signer_reset_active_slot_state(); + signer_render_enter_grid(); } else { - display_show_message("INVALID", "Bad mnemonic", "NEXT to return"); - display_set_status("Validation failed"); + 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", "Fix and retry"); + } } } } if (s_evt_next_pressed) { - signer_enter_letter_pick_screen(); - display_set_status("Back to letter"); + if (s_enter_stage == 3) { + s_enter_stage = 2; + signer_render_enter_grid(); + } else if (s_enter_stage == 2) { + s_enter_stage = 1; + signer_render_enter_grid(); + } else { + if (s_enter_slot == 0) { + signer_enter_menu_screen(); + } else { + s_enter_slot--; + s_enter_committed[s_enter_slot] = false; + s_enter_selected_indices[s_enter_slot] = 0; + signer_reset_active_slot_state(); + signer_render_enter_grid(); + } + } } break; } @@ -808,7 +853,7 @@ void setup() { display_set_mode(false); display_set_view(DISPLAY_VIEW_VOLUME); - display_set_status("USB mounted"); + // display_set_status("USB mounted"); digitalWrite(LED_BUILTIN, HIGH); led_blink(2, 60, 60); @@ -821,7 +866,7 @@ void loop() { 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"); + // display_set_status("Ready"); if (s_mode == MODE_SIGNER) { signer_enter_menu_screen(); } @@ -860,7 +905,7 @@ void loop() { (unsigned long)s_diag_bytes_rx, (unsigned long)s_diag_frames_rx, (unsigned long)s_diag_responses_tx); - display_set_status(diag); + // 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. diff --git a/firmware/kb2040_hidden_signer/mnemonic_kb.cpp b/firmware/kb2040_hidden_signer/mnemonic_kb.cpp index 0717bdc..e8dd340 100644 --- a/firmware/kb2040_hidden_signer/mnemonic_kb.cpp +++ b/firmware/kb2040_hidden_signer/mnemonic_kb.cpp @@ -84,6 +84,98 @@ bool mnemonic_letter_range(char letter, uint16_t *out_start, uint16_t *out_count return true; } +bool mnemonic_second_letters(char first, char *out, uint8_t *count) { + if (!out || !count) { + return false; + } + + *count = 0; + + if (first >= 'A' && first <= 'Z') { + first = (char)(first - 'A' + 'a'); + } + if (first < 'a' || first > 'z') { + return false; + } + + uint16_t start = 0; + uint16_t range_count = 0; + if (!mnemonic_letter_range(first, &start, &range_count) || range_count == 0) { + return false; + } + + bool seen[26] = {false}; + uint8_t found = 0; + for (uint16_t i = 0; i < range_count; ++i) { + const char* w = BIP39_WORDLIST[start + i]; + char c = w[1]; + if (c >= 'A' && c <= 'Z') { + c = (char)(c - 'A' + 'a'); + } + if (c < 'a' || c > 'z') { + continue; + } + + const uint8_t idx = (uint8_t)(c - 'a'); + if (!seen[idx]) { + seen[idx] = true; + out[found++] = c; + } + } + + *count = found; + return found > 0; +} + +bool mnemonic_prefix_range(char first, char second, uint16_t *out_start, uint16_t *out_count) { + if (!out_start || !out_count) { + return false; + } + + if (first >= 'A' && first <= 'Z') { + first = (char)(first - 'A' + 'a'); + } + if (second >= 'A' && second <= 'Z') { + second = (char)(second - 'A' + 'a'); + } + if (first < 'a' || first > 'z' || second < 'a' || second > 'z') { + return false; + } + + uint16_t start = 0; + uint16_t range_count = 0; + if (!mnemonic_letter_range(first, &start, &range_count) || range_count == 0) { + return false; + } + + int first_match = -1; + int last_match = -1; + for (uint16_t i = 0; i < range_count; ++i) { + const char* w = BIP39_WORDLIST[start + i]; + char c = w[1]; + if (c >= 'A' && c <= 'Z') { + c = (char)(c - 'A' + 'a'); + } + + if (c == second) { + if (first_match < 0) { + first_match = (int)(start + i); + } + last_match = (int)(start + i); + } else if (first_match >= 0) { + break; + } + } + + if (first_match < 0 || last_match < first_match) { + return false; + } + + *out_start = (uint16_t)first_match; + *out_count = (uint16_t)(last_match - first_match + 1); + return true; +} + const char* mnemonic_word_at(uint16_t index) { if (index >= 2048) { return nullptr; diff --git a/firmware/kb2040_hidden_signer/mnemonic_kb.h b/firmware/kb2040_hidden_signer/mnemonic_kb.h index 99b6803..c7fa308 100644 --- a/firmware/kb2040_hidden_signer/mnemonic_kb.h +++ b/firmware/kb2040_hidden_signer/mnemonic_kb.h @@ -7,5 +7,7 @@ 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); +bool mnemonic_second_letters(char first, char *out, uint8_t *count); +bool mnemonic_prefix_range(char first, char second, 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/src/main.c b/src/main.c index e36d322..e08f538 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 32 -#define NSIGNER_VERSION "v0.0.32" +#define NSIGNER_VERSION_PATCH 33 +#define NSIGNER_VERSION "v0.0.33" /* NSIGNER_HEADERLESS_DECLS_END */