v0.0.29 - Feather firmware: auto-approve sign_event and always return explicit JSON-RPC errors for unhandled/oversized requests
This commit is contained in:
434
plans/feather_signer_ui.md
Normal file
434
plans/feather_signer_ui.md
Normal file
@@ -0,0 +1,434 @@
|
||||
# Plan: Feather S3 signer interactive UI (splash, main menu, generate, view, enter, working)
|
||||
|
||||
Status: ready to implement.
|
||||
Related firmware: [`firmware/feather_s3_tft/main/main.c`](../firmware/feather_s3_tft/main/main.c:1), [`firmware/feather_s3_tft/main/display.c`](../firmware/feather_s3_tft/main/display.c:1), [`firmware/feather_s3_tft/main/display.h`](../firmware/feather_s3_tft/main/display.h:1), [`firmware/feather_s3_tft/main/mnemonic.c`](../firmware/feather_s3_tft/main/mnemonic.c:1), [`firmware/feather_s3_tft/main/mnemonic_wordlist.h`](../firmware/feather_s3_tft/main/mnemonic_wordlist.h:1), [`firmware/feather_s3_tft/main/key_derivation.c`](../firmware/feather_s3_tft/main/key_derivation.c:1).
|
||||
Companion: persistence will be added in a follow-up plan (option 3 from design discussion: PIN-derived encryption of NVS-stored seed).
|
||||
|
||||
## 0. Color palette (global)
|
||||
|
||||
Strict 4-color palette for ALL screens (no other colors used):
|
||||
|
||||
| Color | Constant | Use |
|
||||
|---|---|---|
|
||||
| Black | `RGB565_BLACK` | Background everywhere |
|
||||
| White | `RGB565_WHITE` | Default text / active labels |
|
||||
| Red | `RGB565_RED` | Selection / focus / important call-outs (e.g., `N_SIGNER` title) |
|
||||
| Dim gray | `RGB565_DIM_GRAY` (`0x4208`) | Disabled / unselectable items, hints, footers |
|
||||
|
||||
No yellow, cyan, green, magenta, blue. Existing screens (`show_idle_screen`, `show_approval_prompt_with_caller`) get retro-fitted to this palette in §5.4. The `RGB565_*` constants for unused colors stay defined in [`display.h`](../firmware/feather_s3_tft/main/display.h:14) (harmless) but the new code never references them.
|
||||
|
||||
Selection convention:
|
||||
- **Selected** menu item: red text on black background (no inverse-video fill needed in the 4-color scheme; red is bright enough as the focus color). A leading `> ` prefix is also drawn for redundancy.
|
||||
- **Non-selected** items: white on black.
|
||||
- **Disabled** items (e.g., `View` when no mnemonic is loaded): dim gray on black.
|
||||
- **Footers / hint text**: dim gray on black.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Replace the current "generate-random-mnemonic-on-boot, immediately go to working screen" proof of concept with a navigable on-device UI that lets the operator:
|
||||
|
||||
1. See a splash screen at power-on.
|
||||
2. Choose at the main menu between **Generate** a fresh mnemonic, **Enter** an existing mnemonic, or **View** the currently loaded mnemonic.
|
||||
3. After Generate or successful Enter, transition into the existing **Working Page** (sign / approve flow that already exists today).
|
||||
4. Persistence is **out of scope** for this plan — every boot starts at the splash, mnemonic lives only in RAM. A follow-up plan will add PIN-protected NVS storage.
|
||||
|
||||
## 2. Hardware / display constraints
|
||||
|
||||
From the existing firmware ([`display.c`](../firmware/feather_s3_tft/main/display.c:38)):
|
||||
|
||||
- Panel: 240×135, ST7789, landscape.
|
||||
- Font cell at `CHAR_SCALE = 2`: `CHAR_W = 12`, `CHAR_H = 16`.
|
||||
- Usable text grid: **20 columns × 8 rows** (with ~0px margin) or **19 columns × 6 rows** with the comfortable insets the user noted. Plan assumes 19×6 so we have ≥4px breathing room on every edge.
|
||||
- 3 buttons (current physical mapping in [`main.c`](../firmware/feather_s3_tft/main/main.c:31)):
|
||||
- `D0` = GPIO0, active **LOW** (boot button, internal pull-up)
|
||||
- `D1` = GPIO1, active **HIGH** (external pull-down)
|
||||
- `D2` = GPIO2, active **HIGH** (external pull-down)
|
||||
|
||||
### 2.1 New menu-button semantics
|
||||
|
||||
| Button | Role in new UI | Existing role (working page) |
|
||||
|---|---|---|
|
||||
| `D2` | **Up** (scroll up) | "always allow" approval |
|
||||
| `D1` | **Down** (scroll down) | "approve once" |
|
||||
| `D0` | **Enter / select** | "deny" |
|
||||
|
||||
The working-page button semantics intentionally **stay the same** as today (D0=deny, D1=approve once, D2=always). Only menu screens reinterpret them as Up/Down/Enter. The button-handling code is split into two layers (see §5.2) so the working-page approval flow does not have to change behavior.
|
||||
|
||||
## 3. Screen flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Power on] --> B[Splash: N_SIGNER]
|
||||
B -- D0 or D1 or D2 --> C[Main Menu]
|
||||
C -- Generate selected --> D[Generate page: random 12 words]
|
||||
D -- auto --> E[View page]
|
||||
C -- Enter selected --> F[Enter page: pick letter]
|
||||
F -- letter chosen --> G[Pick word starting with letter]
|
||||
G -- word chosen --> H[Phrase preview: continue or delete]
|
||||
H -- continue and len 12 --> I[Save prompt]
|
||||
H -- continue and len lt 12 --> F
|
||||
H -- delete --> H2[Drop last word]
|
||||
H2 --> H
|
||||
I -- save --> W[Working page]
|
||||
I -- back --> C
|
||||
C -- View selected --> V[View page read-only]
|
||||
V -- any button --> W
|
||||
E -- any button --> W
|
||||
W -. menu hotkey not in scope .-> C
|
||||
```
|
||||
|
||||
Notes:
|
||||
- From the **Working page** there is currently no "back to menu" hotkey. That is **out of scope** for this plan; once entered, the working page is the run-mode terminus until reboot. Adding a long-press-to-menu is left as a tracked TODO at the end of this doc.
|
||||
- The View page is reachable from two places: post-Generate (auto), and from the main menu (read-only). Both behave the same — "any button returns to caller". Caller is tracked so that View-from-Generate goes to Working, and View-from-Menu returns to Menu.
|
||||
|
||||
## 4. Per-screen specification
|
||||
|
||||
All screens use `display_clear(RGB565_BLACK)` as the base, then draw text with the existing 12×16 cell. Coordinates below are pixel offsets from top-left.
|
||||
|
||||
### 4.1 Splash screen
|
||||
|
||||
- Centered horizontally and vertically: the literal string `N_SIGNER`, color `RGB565_RED`, on black background.
|
||||
- 8 chars × 12px = 96px wide → x = (240−96)/2 = 72.
|
||||
- 1 row × 16px → y = (135−16)/2 ≈ 60.
|
||||
- Below the title (y = 60 + 24 = 84), a hint line `"press any button"` in `RGB565_DIM_GRAY`, centered (small font).
|
||||
- Wait for **any** of the three buttons (debounced, see §5.1). The "drain held buttons first, then read fresh edge" pattern from [`wait_for_approval()`](../firmware/feather_s3_tft/main/main.c:122) is reused.
|
||||
- **No timeout** — strictly button-only advance, per user direction.
|
||||
|
||||
### 4.2 Main menu
|
||||
|
||||
- Title row at y=4: `"MAIN MENU"` in `RGB565_WHITE` (centered).
|
||||
- Three menu items at y=28, y=52, y=76:
|
||||
- `Generate`
|
||||
- `Enter`
|
||||
- `View`
|
||||
- The selected item is rendered as red text on black with a leading `> ` prefix. Non-selected items are white on black with no prefix (a 2-char gap so positions don't shift). Disabled items are dim gray.
|
||||
- Footer at y=120: `"D2 up D1 dn D0 ok"` in `RGB565_DIM_GRAY` (small font).
|
||||
- Behavior:
|
||||
- `D2` (Up): selection moves up, wrapping from Generate → View at the top edge.
|
||||
- `D1` (Down): selection moves down, wrapping from View → Generate at the bottom edge.
|
||||
- `D0` (Enter): commit the selected item.
|
||||
- **View** is drawn in `RGB565_DIM_GRAY` and **not selectable** when no mnemonic is loaded. The cursor skips it on Up/Down navigation in that state.
|
||||
|
||||
### 4.3 Generate page
|
||||
|
||||
- Renders the title `"GENERATING..."` centered in `RGB565_WHITE` while [`generate_mnemonic_12()`](../firmware/feather_s3_tft/main/mnemonic.h:6) is called.
|
||||
- On success: store the resulting mnemonic in `s_mnemonic` (already exists in [`main.c`](../firmware/feather_s3_tft/main/main.c:63)), then transition to the View page with `caller=GENERATE`.
|
||||
- On failure: show `"GEN FAILED"` in `RGB565_RED` for ~2 seconds, then return to main menu.
|
||||
- This page has no button input loop of its own — it's a transient pass-through.
|
||||
|
||||
### 4.4 View page
|
||||
|
||||
- Title row at y=2: `"YOUR MNEMONIC"` in `RGB565_WHITE` (small font), centered. Only shown if there is enough vertical room — see layout below.
|
||||
- 12 words laid out as **3 columns × 4 rows**.
|
||||
- Column widths: each column gets ⌊240/3⌋ = 80px.
|
||||
- Each cell shows `"NN word"` where `NN` is 1-based index zero-padded to 2 digits and `word` is the BIP-39 word.
|
||||
- Longest BIP-39 word is 8 chars, plus `"NN "` prefix = 11 chars total. 11 × 12 = 132px — that does NOT fit in an 80px column.
|
||||
- **Resolution:** drop the index prefix from the cell and rely on row-major order (top-left = #1, top-right = #3, bottom-left = #10, bottom-right = #12). Give a tiny index legend in the footer ("L→R, top→bottom") instead. 8 chars × 12 = 96px still > 80px column.
|
||||
- **Real resolution:** drop `CHAR_SCALE` to 1 (6×8 cells) for the View page only. That gives 40 cols × 16 rows. 12 words in 3×4 grid then becomes trivial: each cell is up to 13 chars (`"NN. word "`) at 6px = 78px, fits in the 80px column with margin. We add a `display_draw_text_small()` variant (see §6.1) that uses `CHAR_SCALE=1`.
|
||||
- Final layout (small font):
|
||||
- Title at y=2, height 8.
|
||||
- Grid starts at y=14. Row pitch = 24px (8px cell + 16px gap for readability). Last row at y=14+3·24 = 86.
|
||||
- Footer at y=110: `"any key = back"` in `RGB565_DIM_GRAY`.
|
||||
- Cell text format: `"%2d. %s"` (e.g., `" 1. abandon"`), color `RGB565_WHITE` on black.
|
||||
- Behavior: any button (debounced) returns to the caller (`GENERATE` → working page; `MENU` → main menu).
|
||||
- The working-page button semantics are *not* active here.
|
||||
|
||||
### 4.5 Enter page (multi-stage)
|
||||
|
||||
This page is the most complex. It has three sub-states implemented in a sub-state machine:
|
||||
|
||||
#### 4.5.1 Stage A — Letter picker
|
||||
|
||||
- Top line: `"WORD %d/12"` (e.g., `"WORD 3/12"`) in `RGB565_WHITE`.
|
||||
- Second line: `"PICK FIRST LETTER"` in `RGB565_DIM_GRAY` (small font).
|
||||
- A 26-letter grid of the alphabet:
|
||||
- With small font (6×8): a row of 26 chars = 156px, fits in one row. But we want a visible cursor on the selected letter, so use 2 rows of 13 letters each (`A..M` / `N..Z`) at large font (12×16 with extra spacing) — 13 × 14px = 182px wide, fits.
|
||||
- At y=40 row 1 (`A B C D E F G H I J K L M`).
|
||||
- At y=64 row 2 (`N O P Q R S T U V W X Y Z`).
|
||||
- Non-selected letters: `RGB565_WHITE` on black. Selected letter: `RGB565_RED` on black (no fill — pure color cue).
|
||||
- Footer at y=120: `"D2 up D1 dn D0 ok"` in `RGB565_DIM_GRAY` (small font).
|
||||
- Behavior:
|
||||
- `D2`: move cursor up — A↔N, B↔O, …, M↔Z (wrap top-to-bottom within column).
|
||||
- `D1`: move cursor right (and wrap onto next row); from `Z` wrap to `A`.
|
||||
- **Note:** with only 3 buttons we cannot have separate Left/Right/Up/Down. The Up/Down semantics here mean "move within the alphabet" — `D2` = previous letter, `D1` = next letter, with wrap. The visual 2-row layout is purely cosmetic (so the operator can see the whole alphabet at a glance); navigation is **linear**, not 2D. The selected letter is highlighted wherever it lands in the 2-row display.
|
||||
- `D0`: commit selected letter, move to Stage B.
|
||||
- A **back** mechanic is needed too. Convention: holding `D0` for >1 second cancels Stage A and returns to the main menu (only meaningful if no words have been entered yet); otherwise it returns to Stage C of the previous word. See §4.5.4.
|
||||
|
||||
#### 4.5.2 Stage B — Word picker
|
||||
|
||||
- Top line: `"WORD %d/12 [%c]"` showing the chosen letter (e.g., `"WORD 3/12 [P]"`) in `RGB565_WHITE`.
|
||||
- Below: the list of all BIP-39 words starting with that letter, scrollable. Word counts per letter range from 1 (e.g., `x`) up to ~150 (most common letters). Use small font (6×8).
|
||||
- Layout with small font: 16 visible word rows at row pitch 8+1=9px starting at y=14, last row at y=14+15·9=149 — overflows. Cap visible rows at **12**, row pitch 9px, y=14..14+11·9=113.
|
||||
- 12 rows × 1 column is fine; longest word ≤ 8 chars × 6px = 48px, leaves plenty of room. We can also do **2 columns of 12** if needed for letters like `s` (~250 words won't happen — BIP-39 max per letter is around 91 for `s`). 1 column × 12 visible with scroll keeps it simple.
|
||||
- Cursor: highlighted row drawn in `RGB565_RED` on black (with a leading `> ` prefix for redundancy). Non-cursor rows in `RGB565_WHITE` on black.
|
||||
- Footer at y=120: `"D2 up D1 dn D0 ok"` in `RGB565_DIM_GRAY` (small font).
|
||||
- Behavior:
|
||||
- `D2`: prev word (with scroll up at top of viewport, wrap from first to last word in that letter group).
|
||||
- `D1`: next word (with scroll down at bottom of viewport, wrap last → first).
|
||||
- `D0`: commit selected word, append to the in-progress mnemonic buffer, move to Stage C.
|
||||
|
||||
The full BIP-39 wordlist from [`mnemonic_wordlist.h`](../firmware/feather_s3_tft/main/mnemonic_wordlist.h:1) is already linked in. We add a small helper that produces `(start_index, count)` pairs for each letter on first use and caches them in a static array of 26 `(uint16_t start, uint16_t count)`. See §6.2.
|
||||
|
||||
#### 4.5.3 Stage C — Phrase preview / continue / delete
|
||||
|
||||
- Top line: `"WORDS SO FAR (%d/12)"` in `RGB565_WHITE`.
|
||||
- Body: the entered words rendered in a 3-column grid using small font, same as the View page (§4.4). Empty slots are blank. The most recently added word is drawn in `RGB565_RED` (visual confirmation of the just-committed entry); previously confirmed words in `RGB565_WHITE`.
|
||||
- Footer (two lines) at y=104, y=120, small font:
|
||||
- y=104: `"D2 continue D1 delete"` in `RGB565_DIM_GRAY`.
|
||||
- y=120: `"D0 save"` in `RGB565_WHITE` when `count == 12`, in `RGB565_DIM_GRAY` otherwise.
|
||||
- Behavior:
|
||||
- `D2` (continue): if `count < 12`, go back to Stage A for word `count+1`. If `count == 12`, no-op.
|
||||
- `D1` (delete): drop the last word from the buffer, redraw. If `count` was 1, drop to 0. If `count` becomes 0, this stays on Stage C with an empty grid; pressing continue again restarts at word 1.
|
||||
- `D0` (save): only valid if `count == 12`. Otherwise no-op. On save, validate the full mnemonic via the BIP-39 checksum check ([`mnemonic_validate()`](../firmware/feather_s3_tft/main/mnemonic.h:1), see §6.3). On valid: copy buffer into `s_mnemonic`, transition to working page. On invalid: show `"BAD CHECKSUM"` in `RGB565_RED` for 2s and return to Stage C with all 12 words still present (so user can delete and try again).
|
||||
|
||||
#### 4.5.4 Cancel / back from Enter
|
||||
|
||||
- Long-press `D0` (>1 second) at any stage of Enter cancels and returns to **main menu** without saving; the in-progress buffer is wiped via `secure_memzero`-equivalent (we will add `secure_memzero` for the firmware — see §6.4).
|
||||
- Short-press `D0` is the normal "select / save" semantic.
|
||||
|
||||
### 4.6 Working page (existing, retro-fitted to 4-color palette)
|
||||
|
||||
- This is the existing approval-loop screen. Behavior change in this plan: we **arrive** here from the menu/save flow rather than from boot directly. Button semantics remain unchanged (D0=deny, D1=once, D2=always).
|
||||
- Existing [`show_idle_screen()`](../firmware/feather_s3_tft/main/main.c:201) and [`show_approval_prompt_with_caller()`](../firmware/feather_s3_tft/main/main.c:163) currently use yellow/cyan/green/magenta. They are **retro-fitted** to the 4-color palette:
|
||||
- All informational labels → `RGB565_WHITE`.
|
||||
- The "APPROVE sign_event?" header → `RGB565_RED` (it's a focus/attention call-out).
|
||||
- All hints/footers ("D1=allow once" etc.) → `RGB565_DIM_GRAY`.
|
||||
- Status counters at the bottom of the running screen → `RGB565_DIM_GRAY`.
|
||||
- The existing key derivation (mnemonic → seed → privkey → pubkey → npub) is moved out of `app_main()` into a function `apply_mnemonic_and_enter_working()` that is called from both `Generate→View→Working` and `Enter→Save→Working` paths. See §5.4.
|
||||
|
||||
## 5. Code structure changes
|
||||
|
||||
### 5.1 Button input layer
|
||||
|
||||
Add a small input module: [`firmware/feather_s3_tft/main/buttons.h`](../firmware/feather_s3_tft/main/buttons.h:1), [`firmware/feather_s3_tft/main/buttons.c`](../firmware/feather_s3_tft/main/buttons.c:1).
|
||||
|
||||
Public API:
|
||||
```c
|
||||
typedef enum {
|
||||
BTN_NONE = 0,
|
||||
BTN_D0, /* aka ENTER in menu mode, DENY in working mode */
|
||||
BTN_D1, /* aka DOWN in menu mode, APPROVE_ONCE in working mode */
|
||||
BTN_D2, /* aka UP in menu mode, APPROVE_ALWAYS in working mode */
|
||||
} btn_id_t;
|
||||
|
||||
typedef enum {
|
||||
BTN_EVT_NONE = 0,
|
||||
BTN_EVT_PRESS, /* short press (release before LONG_MS) */
|
||||
BTN_EVT_LONG_PRESS, /* held for >= LONG_MS, fires on release */
|
||||
} btn_event_kind_t;
|
||||
|
||||
typedef struct {
|
||||
btn_id_t id;
|
||||
btn_event_kind_t kind;
|
||||
} btn_event_t;
|
||||
|
||||
void buttons_init(void);
|
||||
/* Block until a press/long-press event arrives, or timeout_ms expires.
|
||||
* Returns BTN_EVT_NONE on timeout. timeout_ms == 0xFFFFFFFFu = wait forever. */
|
||||
btn_event_t buttons_wait(uint32_t timeout_ms);
|
||||
/* Non-blocking: returns BTN_EVT_NONE if no event ready. */
|
||||
btn_event_t buttons_poll(void);
|
||||
```
|
||||
|
||||
The existing `wait_for_approval()` in [`main.c`](../firmware/feather_s3_tft/main/main.c:122) is rewritten as a thin wrapper over `buttons_wait()` so the existing approval semantics still work without code duplication.
|
||||
|
||||
Constants:
|
||||
- `BTN_DEBOUNCE_MS = 30` (existing)
|
||||
- `BTN_LONG_MS = 1000`
|
||||
- `BTN_POLL_MS = 10`
|
||||
|
||||
### 5.2 UI state machine
|
||||
|
||||
Add [`firmware/feather_s3_tft/main/ui.h`](../firmware/feather_s3_tft/main/ui.h:1), [`firmware/feather_s3_tft/main/ui.c`](../firmware/feather_s3_tft/main/ui.c:1).
|
||||
|
||||
```c
|
||||
typedef enum {
|
||||
UI_SPLASH,
|
||||
UI_MAIN_MENU,
|
||||
UI_GENERATE,
|
||||
UI_VIEW,
|
||||
UI_ENTER_LETTER,
|
||||
UI_ENTER_WORD,
|
||||
UI_ENTER_PREVIEW,
|
||||
UI_WORKING, /* delegates to existing main loop */
|
||||
} ui_screen_t;
|
||||
|
||||
typedef enum { VIEW_FROM_GENERATE, VIEW_FROM_MENU } view_caller_t;
|
||||
|
||||
typedef struct {
|
||||
char words[12][12]; /* room for 8-char word + nul, padded for safety */
|
||||
uint8_t count; /* 0..12 */
|
||||
int8_t letter_idx; /* 0..25, current cursor in Stage A */
|
||||
uint16_t word_cursor; /* cursor index inside current letter group */
|
||||
uint16_t word_scroll; /* top of viewport in word list */
|
||||
view_caller_t view_caller;
|
||||
} ui_state_t;
|
||||
|
||||
/* Run the UI until a mnemonic is loaded and the user requests transition to working. */
|
||||
void ui_run_until_working(char out_mnemonic[256]);
|
||||
```
|
||||
|
||||
`ui_run_until_working()` is called from `app_main()` after display init and before the main USB/sign loop. It returns when the user has either:
|
||||
- generated a mnemonic and dismissed the View screen, or
|
||||
- entered a valid 12-word mnemonic and pressed Save.
|
||||
|
||||
The mnemonic is written into `out_mnemonic`. Caller is responsible for zeroing the local UI buffer (this is done inside `ui.c` before return, but main.c also keeps its own copy ephemeral).
|
||||
|
||||
Each screen is a function `ui_screen_splash()`, `ui_screen_main_menu()`, etc. Each one returns the next `ui_screen_t` enum, plus mutates `ui_state_t` as needed. The dispatcher is a simple `while (current != UI_WORKING) { current = handlers[current](&state); }`.
|
||||
|
||||
### 5.3 Display additions
|
||||
|
||||
Add to [`display.h`](../firmware/feather_s3_tft/main/display.h:1):
|
||||
|
||||
```c
|
||||
/* CHAR_SCALE=1 variant (6x8 cells, 40 cols x 16 rows). */
|
||||
esp_err_t display_draw_text_small(int x, int y, const char *text, uint16_t fg, uint16_t bg);
|
||||
|
||||
/* Convenience: centered text using the regular (12x16) font. */
|
||||
esp_err_t display_draw_text_centered(int y, const char *text, uint16_t fg, uint16_t bg);
|
||||
esp_err_t display_draw_text_small_centered(int y, const char *text, uint16_t fg, uint16_t bg);
|
||||
|
||||
/* Inverse-video helper used by selected menu items.
|
||||
* Draws a filled rect of the bg color sized to the text, then text on top. */
|
||||
esp_err_t display_draw_text_inverse(int x, int y, const char *text, uint16_t bg, uint16_t fg);
|
||||
```
|
||||
|
||||
Implementation notes:
|
||||
- The existing [`display_draw_text()`](../firmware/feather_s3_tft/main/display.c:204) (line approximate, locate via search) hardcodes `CHAR_SCALE = 2`. Refactor it so the scale is parameterized in an internal helper, then `display_draw_text` and `display_draw_text_small` are thin wrappers.
|
||||
- Centered helpers compute `x = (DISPLAY_WIDTH - char_w * strlen) / 2` and call the underlying drawer.
|
||||
|
||||
Add a dim color constant for grayed-out menu items:
|
||||
```c
|
||||
#define RGB565_DIM_GRAY 0x4208 /* ~25% white */
|
||||
```
|
||||
|
||||
### 5.4 main.c refactor
|
||||
|
||||
Sketch of the new `app_main()`:
|
||||
|
||||
```c
|
||||
void app_main(void) {
|
||||
display_init();
|
||||
buttons_init();
|
||||
/* draw splash, main menu, etc. */
|
||||
ui_run_until_working(s_mnemonic);
|
||||
/* now s_mnemonic holds 12 words, derive keys */
|
||||
if (apply_mnemonic_and_enter_working(s_mnemonic) != 0) {
|
||||
/* show error, halt; future plan adds "back to menu" */
|
||||
}
|
||||
/* zero s_mnemonic immediately after key derivation, same as today */
|
||||
secure_memzero(s_mnemonic, sizeof(s_mnemonic));
|
||||
transport_init();
|
||||
/* existing USB/sign loop */
|
||||
run_signing_loop();
|
||||
}
|
||||
```
|
||||
|
||||
Where `apply_mnemonic_and_enter_working()` does what lines 800–836 of [`main.c`](../firmware/feather_s3_tft/main/main.c:800) currently do (mnemonic→seed→privkey→pubkey→npub→idle screen draw), refactored into one function.
|
||||
|
||||
The big `while (1)` USB loop body (currently lines 852–1004) is moved into `run_signing_loop()` for readability. No behavior change.
|
||||
|
||||
The existing global statics (`s_pubkey_hex`, `s_privkey`, `s_npub`, `s_npub_short`, `s_mnemonic_short`, `s_frame_buf`, `s_response_buf`, `s_signed_event_buf`) keep their current scope.
|
||||
|
||||
## 6. Helpers / library additions
|
||||
|
||||
### 6.1 Small font drawing
|
||||
|
||||
Already covered in §5.3. The existing `font5x7.h` is 5×7 inside a 6×8 cell, so `display_draw_text_small()` literally just drops the `CHAR_SCALE` multiplier. The existing scale-2 path is preserved.
|
||||
|
||||
### 6.2 BIP-39 letter index
|
||||
|
||||
Add to [`mnemonic.h`](../firmware/feather_s3_tft/main/mnemonic.h:1):
|
||||
|
||||
```c
|
||||
/* Returns the number of BIP-39 words starting with `letter` (a-z, case insensitive),
|
||||
* and writes the start index in the global wordlist into *out_start. */
|
||||
size_t mnemonic_words_for_letter(char letter, size_t *out_start);
|
||||
|
||||
/* Returns pointer to the i-th BIP-39 word (0..2047) in the global list. */
|
||||
const char *mnemonic_word_at(size_t index);
|
||||
```
|
||||
|
||||
Implementation: iterate the wordlist once on first call, cache 26 `(start, count)` pairs in a `static` array.
|
||||
|
||||
### 6.3 Mnemonic checksum validation
|
||||
|
||||
The current [`mnemonic.c`](../firmware/feather_s3_tft/main/mnemonic.c:1) provides `generate_mnemonic_12` and `mnemonic_to_seed`. For Enter mode we need to validate user-typed input independently of seed derivation (so we can show a checksum error before committing keys).
|
||||
|
||||
Add to [`mnemonic.h`](../firmware/feather_s3_tft/main/mnemonic.h:1):
|
||||
|
||||
```c
|
||||
/* Validate a space-separated 12-word mnemonic against BIP-39 checksum.
|
||||
* Returns 0 on valid, negative on word-not-in-list / wrong-count / bad-checksum. */
|
||||
int mnemonic_validate(const char *mnemonic);
|
||||
```
|
||||
|
||||
If `mnemonic_to_seed()` already returns nonzero on bad checksum, `mnemonic_validate()` can be a thin wrapper that runs the checksum half but skips PBKDF2. Confirm this during implementation by reading [`mnemonic.c`](../firmware/feather_s3_tft/main/mnemonic.c:1) — if the existing function only does PBKDF2 and trusts the input, we add the proper BIP-39 checksum check (entropy bytes from word indices + last 4 bits = SHA256(entropy)[0] >> 4).
|
||||
|
||||
### 6.4 Secure memzero
|
||||
|
||||
Add a tiny `secure_memzero()` to a new [`firmware/feather_s3_tft/main/secure_mem.h`](../firmware/feather_s3_tft/main/secure_mem.h:1) using `volatile` writes, modeled on the existing [`src/secure_mem.c`](../src/secure_mem.c:1). Not strictly new functionality (we already do `memset(buf,0,sizeof)` in many places) but the in-progress mnemonic buffer in `ui_state_t` and word stage buffers should use it for clarity.
|
||||
|
||||
## 7. CMakeLists updates
|
||||
|
||||
[`firmware/feather_s3_tft/main/CMakeLists.txt`](../firmware/feather_s3_tft/main/CMakeLists.txt:1) adds the new sources:
|
||||
|
||||
- `buttons.c`
|
||||
- `ui.c`
|
||||
- `secure_mem.c`
|
||||
|
||||
`SRCS` list grows by these three files. No new component dependencies.
|
||||
|
||||
## 8. Out of scope (tracked TODOs)
|
||||
|
||||
These are intentionally deferred and listed here so they don't get lost:
|
||||
|
||||
1. **Persistence (PIN-protected NVS storage of the seed).** Will be a separate plan, [`plans/feather_signer_persistence.md`](feather_signer_persistence.md:1) — to be authored after this UI plan lands. It will reuse the same letter/digit scroll UI for PIN entry.
|
||||
2. **"Back to menu" hotkey from the working page.** Today, once you arrive at working, you're stuck until reboot. A long-press gesture (e.g., D0+D2 simultaneously, or D0 long-press during idle screen only) could re-enter the menu and lock the loaded mnemonic.
|
||||
3. **Lock screen / re-auth.** Without PIN, the device is fully unlocked once a mnemonic is entered. The persistence plan will add a "lock" hotkey that wipes RAM keys and returns to the splash/PIN-entry screen.
|
||||
4. **Display blanking / screensaver.** ST7789 doesn't burn-in dramatically but reducing the backlight after idle would be polite.
|
||||
5. **Test plan additions.** The current firmware has no automated tests on-device; we rely on host-side tests in [`tests/`](../tests/). UI logic in `ui.c` should be written so that the screen-transition function `ui_next_screen(state, event)` is **pure** (no display/button calls inside) and can be unit-tested with a host-side harness. This is a soft target — recommend doing it but not blocking on it.
|
||||
|
||||
## 9. Acceptance criteria
|
||||
|
||||
- [ ] Power-on shows the red `N_SIGNER` splash; pressing any of D0/D1/D2 advances to the main menu.
|
||||
- [ ] Main menu lists `Generate`, `Enter`, `View`. `View` is dimmed and unselectable when no mnemonic is loaded.
|
||||
- [ ] In the main menu, D2 moves selection up with wrap, D1 moves down with wrap, D0 commits. Selected row is shown in inverse video.
|
||||
- [ ] Selecting `Generate` produces a fresh random 12-word BIP-39 mnemonic, displays it on the View page in a 3×4 grid, and any button press transitions into the working page (key derivation + USB/sign loop).
|
||||
- [ ] Selecting `View` (after a mnemonic is loaded) shows the same View page, and any button press returns to main menu.
|
||||
- [ ] Selecting `Enter`:
|
||||
- Stage A presents the alphabet with a movable highlight; D2/D1 navigate, D0 picks.
|
||||
- Stage B presents all BIP-39 words for the chosen letter, scrollable; D2/D1 navigate, D0 picks.
|
||||
- Stage C shows the running phrase preview as a 3×4 grid; D2 continues to next word, D1 deletes last word, D0 saves (only when 12 words are present).
|
||||
- Long-press D0 cancels and returns to main menu, wiping the partial buffer.
|
||||
- Save with valid checksum transitions to working page; save with invalid checksum shows `"BAD CHECKSUM"` for 2s and stays on Stage C.
|
||||
- [ ] Working-page button semantics (D0=deny, D1=once, D2=always) remain identical to today.
|
||||
- [ ] No mnemonic persistence: a reboot starts at the splash with no loaded mnemonic.
|
||||
- [ ] All in-progress buffers (`ui_state_t.words[][]`, the working `s_mnemonic`) are wiped via `secure_memzero` on transition out of the screens that use them.
|
||||
|
||||
## 10. Mermaid: button semantics by screen
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Splash
|
||||
Sp[D0 D1 D2 advance]
|
||||
end
|
||||
subgraph MainMenu
|
||||
M2[D2 up] --- M1[D1 down] --- M0[D0 enter]
|
||||
end
|
||||
subgraph EnterLetter
|
||||
EL2[D2 prev letter] --- EL1[D1 next letter] --- EL0[D0 select / long=cancel]
|
||||
end
|
||||
subgraph EnterWord
|
||||
EW2[D2 prev word] --- EW1[D1 next word] --- EW0[D0 select / long=cancel]
|
||||
end
|
||||
subgraph EnterPreview
|
||||
EP2[D2 continue] --- EP1[D1 delete last] --- EP0[D0 save / long=cancel]
|
||||
end
|
||||
subgraph View
|
||||
V[any button = back]
|
||||
end
|
||||
subgraph Working
|
||||
W2[D2 always allow] --- W1[D1 approve once] --- W0[D0 deny]
|
||||
end
|
||||
```
|
||||
Reference in New Issue
Block a user