From bd23b674d6016afbc5a080a6a8a707fc4e310eb6 Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Mon, 11 May 2026 13:43:14 -0400 Subject: [PATCH] v0.0.29 - Feather firmware: auto-approve sign_event and always return explicit JSON-RPC errors for unhandled/oversized requests --- examples/feather_webusb_demo.html | 411 ++++++++++- .../esp32-s3-reverse-tft-feather.pdf | Bin firmware/feather_s3_tft/main/CMakeLists.txt | 12 + firmware/feather_s3_tft/main/buttons.c | 155 ++++ firmware/feather_s3_tft/main/buttons.h | 33 + firmware/feather_s3_tft/main/display.c | 89 ++- firmware/feather_s3_tft/main/display.h | 5 + firmware/feather_s3_tft/main/key_derivation.c | 16 +- firmware/feather_s3_tft/main/key_derivation.h | 1 + firmware/feather_s3_tft/main/main.c | 650 ++++++++++++----- firmware/feather_s3_tft/main/mnemonic.c | 176 +++++ firmware/feather_s3_tft/main/mnemonic.h | 3 + firmware/feather_s3_tft/main/secure_mem.c | 15 + firmware/feather_s3_tft/main/secure_mem.h | 13 + firmware/feather_s3_tft/main/ui.c | 680 ++++++++++++++++++ firmware/feather_s3_tft/main/ui.h | 13 + flash_feather_s3_tft.sh | 26 + plans/feather_signer_ui.md | 434 +++++++++++ src/main.c | 4 +- 19 files changed, 2514 insertions(+), 222 deletions(-) rename firmware/{esp32-s3-reverse-tft-feather => feather_s3_tft}/esp32-s3-reverse-tft-feather.pdf (100%) create mode 100644 firmware/feather_s3_tft/main/buttons.c create mode 100644 firmware/feather_s3_tft/main/buttons.h create mode 100644 firmware/feather_s3_tft/main/secure_mem.c create mode 100644 firmware/feather_s3_tft/main/secure_mem.h create mode 100644 firmware/feather_s3_tft/main/ui.c create mode 100644 firmware/feather_s3_tft/main/ui.h create mode 100755 flash_feather_s3_tft.sh create mode 100644 plans/feather_signer_ui.md diff --git a/examples/feather_webusb_demo.html b/examples/feather_webusb_demo.html index bbf8847..51208e9 100644 --- a/examples/feather_webusb_demo.html +++ b/examples/feather_webusb_demo.html @@ -5,29 +5,233 @@ n_signer Feather WebUSB Demo -

n_signer Feather WebUSB Demo

-

Connect to the ESP32-S3 WebUSB interface, send authenticated get_public_key, and display the result.

+
+

n_signer Feather WebUSB Demo

+

Connect to the board, choose a key index, fetch a pubkey, sign a kind 1 event, and test NIP-04 / NIP-44 encrypt + decrypt RPCs.

- - +
+
+ + Disconnected +
+

+    
-

+    
+
+

Public Key

+ + +
+ +
+

+      
+ +
+

Sign Kind 1 Event

+ + + + +
+ +
+

+      
+ +
+

NIP-04 Encrypt

+ + + + +
+ +
+

+      
+ +
+

NIP-04 Decrypt

+ + + + +
+ +
+

+      
+ +
+

NIP-44 Encrypt

+ + + + +
+ +
+

+      
+ +
+

NIP-44 Decrypt

+ + + + +
+ +
+

+      
+
+
diff --git a/firmware/esp32-s3-reverse-tft-feather/esp32-s3-reverse-tft-feather.pdf b/firmware/feather_s3_tft/esp32-s3-reverse-tft-feather.pdf similarity index 100% rename from firmware/esp32-s3-reverse-tft-feather/esp32-s3-reverse-tft-feather.pdf rename to firmware/feather_s3_tft/esp32-s3-reverse-tft-feather.pdf diff --git a/firmware/feather_s3_tft/main/CMakeLists.txt b/firmware/feather_s3_tft/main/CMakeLists.txt index 627c719..8f948a4 100644 --- a/firmware/feather_s3_tft/main/CMakeLists.txt +++ b/firmware/feather_s3_tft/main/CMakeLists.txt @@ -6,8 +6,20 @@ idf_component_register( "key_derivation.c" "bech32.c" "usb_transport.c" + "buttons.c" + "ui.c" + "secure_mem.c" + "../../../resources/nostr_core_lib/nostr_core/nip004.c" + "../../../resources/nostr_core_lib/nostr_core/nip044.c" + "../../../resources/nostr_core_lib/nostr_core/nostr_common.c" + "../../../resources/nostr_core_lib/nostr_core/utils.c" + "../../../resources/nostr_core_lib/nostr_core/crypto/nostr_aes.c" + "../../../resources/nostr_core_lib/nostr_core/crypto/nostr_chacha20.c" + "../../../resources/nostr_core_lib/nostr_core/crypto/nostr_secp256k1.c" + "../../../resources/nostr_core_lib/platform/esp32/nostr_platform_esp32.c" INCLUDE_DIRS "." + "../../../resources/nostr_core_lib/nostr_core" REQUIRES mbedtls secp256k1 diff --git a/firmware/feather_s3_tft/main/buttons.c b/firmware/feather_s3_tft/main/buttons.c new file mode 100644 index 0000000..8c7be45 --- /dev/null +++ b/firmware/feather_s3_tft/main/buttons.c @@ -0,0 +1,155 @@ +#include "buttons.h" + +#include + +#include "driver/gpio.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#define BTN_D0_GPIO 0 /* D0/BOOT, active LOW */ +#define BTN_D1_GPIO 1 /* D1, active HIGH */ +#define BTN_D2_GPIO 2 /* D2, active HIGH */ + +#define BTN_DEBOUNCE_MS 30 +#define BTN_LONG_MS 1000 +#define BTN_POLL_MS 10 + +static bool s_buttons_inited = false; + +static bool button_pressed(btn_id_t id) +{ + if (id == BTN_D0) { + return gpio_get_level(BTN_D0_GPIO) == 0; + } + if (id == BTN_D1) { + return gpio_get_level(BTN_D1_GPIO) == 1; + } + if (id == BTN_D2) { + return gpio_get_level(BTN_D2_GPIO) == 1; + } + return false; +} + +static bool any_pressed(void) +{ + return button_pressed(BTN_D0) || button_pressed(BTN_D1) || button_pressed(BTN_D2); +} + +void buttons_init(void) +{ + if (s_buttons_inited) { + return; + } + + gpio_config_t d0_cfg = { + .pin_bit_mask = 1ULL << BTN_D0_GPIO, + .mode = GPIO_MODE_INPUT, + .pull_up_en = GPIO_PULLUP_ENABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + gpio_config(&d0_cfg); + + gpio_config_t d1d2_cfg = { + .pin_bit_mask = (1ULL << BTN_D1_GPIO) | (1ULL << BTN_D2_GPIO), + .mode = GPIO_MODE_INPUT, + .pull_up_en = GPIO_PULLUP_DISABLE, + .pull_down_en = GPIO_PULLDOWN_ENABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + gpio_config(&d1d2_cfg); + + s_buttons_inited = true; +} + +btn_event_t buttons_poll(void) +{ + btn_event_t evt = {.id = BTN_NONE, .kind = BTN_EVT_NONE}; + + if (!s_buttons_inited) { + buttons_init(); + } + + if (button_pressed(BTN_D0)) { + evt.id = BTN_D0; + evt.kind = BTN_EVT_PRESS; + return evt; + } + if (button_pressed(BTN_D1)) { + evt.id = BTN_D1; + evt.kind = BTN_EVT_PRESS; + return evt; + } + if (button_pressed(BTN_D2)) { + evt.id = BTN_D2; + evt.kind = BTN_EVT_PRESS; + return evt; + } + + return evt; +} + +btn_event_t buttons_wait(uint32_t timeout_ms) +{ + btn_event_t evt = {.id = BTN_NONE, .kind = BTN_EVT_NONE}; + uint32_t elapsed = 0; + const bool wait_forever = (timeout_ms == 0xFFFFFFFFu); + + if (!s_buttons_inited) { + buttons_init(); + } + + while (any_pressed()) { + vTaskDelay(pdMS_TO_TICKS(BTN_POLL_MS)); + if (!wait_forever) { + elapsed += BTN_POLL_MS; + if (elapsed >= timeout_ms) { + return evt; + } + } + } + + while (wait_forever || elapsed < timeout_ms) { + btn_id_t id = BTN_NONE; + + if (button_pressed(BTN_D0)) { + id = BTN_D0; + } else if (button_pressed(BTN_D1)) { + id = BTN_D1; + } else if (button_pressed(BTN_D2)) { + id = BTN_D2; + } + + if (id != BTN_NONE) { + uint32_t held_ms = 0; + + vTaskDelay(pdMS_TO_TICKS(BTN_DEBOUNCE_MS)); + if (!button_pressed(id)) { + continue; + } + + while (button_pressed(id)) { + vTaskDelay(pdMS_TO_TICKS(BTN_POLL_MS)); + held_ms += BTN_POLL_MS; + if (!wait_forever) { + elapsed += BTN_POLL_MS; + if (elapsed >= timeout_ms) { + return evt; + } + } + } + + vTaskDelay(pdMS_TO_TICKS(BTN_DEBOUNCE_MS)); + evt.id = id; + evt.kind = (held_ms >= BTN_LONG_MS) ? BTN_EVT_LONG_PRESS : BTN_EVT_PRESS; + return evt; + } + + vTaskDelay(pdMS_TO_TICKS(BTN_POLL_MS)); + if (!wait_forever) { + elapsed += BTN_POLL_MS; + } + } + + return evt; +} diff --git a/firmware/feather_s3_tft/main/buttons.h b/firmware/feather_s3_tft/main/buttons.h new file mode 100644 index 0000000..42e1082 --- /dev/null +++ b/firmware/feather_s3_tft/main/buttons.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + BTN_NONE = 0, + BTN_D0, + BTN_D1, + BTN_D2, +} btn_id_t; + +typedef enum { + BTN_EVT_NONE = 0, + BTN_EVT_PRESS, + BTN_EVT_LONG_PRESS, +} btn_event_kind_t; + +typedef struct { + btn_id_t id; + btn_event_kind_t kind; +} btn_event_t; + +void buttons_init(void); +btn_event_t buttons_wait(uint32_t timeout_ms); +btn_event_t buttons_poll(void); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/feather_s3_tft/main/display.c b/firmware/feather_s3_tft/main/display.c index 1636d06..3858820 100644 --- a/firmware/feather_s3_tft/main/display.c +++ b/firmware/feather_s3_tft/main/display.c @@ -41,6 +41,9 @@ #define CHAR_SCALE 2 #define CHAR_W (6 * CHAR_SCALE) #define CHAR_H (8 * CHAR_SCALE) +#define CHAR_SCALE_SMALL 1 +#define CHAR_W_SMALL (6 * CHAR_SCALE_SMALL) +#define CHAR_H_SMALL (8 * CHAR_SCALE_SMALL) static spi_device_handle_t s_lcd = NULL; static bool s_initialized = false; @@ -238,12 +241,16 @@ esp_err_t display_clear(uint16_t color) return display_fill_rect(0, 0, DISPLAY_WIDTH, DISPLAY_HEIGHT, color); } -static esp_err_t draw_char(int x, int y, char c, uint16_t fg, uint16_t bg) +static esp_err_t draw_char_scaled(int x, int y, char c, int scale, uint16_t fg, uint16_t bg) { if (c < 0x20 || c > 0x7F) { c = '?'; } + if (scale <= 0) { + return ESP_ERR_INVALID_ARG; + } + const uint8_t *glyph = font5x7[(uint8_t)c - 0x20]; for (int col = 0; col < 5; ++col) { @@ -251,28 +258,30 @@ static esp_err_t draw_char(int x, int y, char c, uint16_t fg, uint16_t bg) for (int row = 0; row < 7; ++row) { bool on = (bits >> row) & 0x1; ESP_RETURN_ON_ERROR( - display_fill_rect(x + (col * CHAR_SCALE), y + (row * CHAR_SCALE), CHAR_SCALE, CHAR_SCALE, on ? fg : bg), + display_fill_rect(x + (col * scale), y + (row * scale), scale, scale, on ? fg : bg), TAG, "pixel draw failed"); } } - // spacing column + bottom row - ESP_RETURN_ON_ERROR(display_fill_rect(x + (5 * CHAR_SCALE), y, CHAR_SCALE, 7 * CHAR_SCALE, bg), TAG, "spacing failed"); - ESP_RETURN_ON_ERROR(display_fill_rect(x, y + (7 * CHAR_SCALE), 6 * CHAR_SCALE, CHAR_SCALE, bg), TAG, "baseline failed"); + /* spacing column + bottom row */ + ESP_RETURN_ON_ERROR(display_fill_rect(x + (5 * scale), y, scale, 7 * scale, bg), TAG, "spacing failed"); + ESP_RETURN_ON_ERROR(display_fill_rect(x, y + (7 * scale), 6 * scale, scale, bg), TAG, "baseline failed"); return ESP_OK; } -esp_err_t display_draw_text(int x, int y, const char *text, uint16_t fg, uint16_t bg) +static esp_err_t draw_text_scaled(int x, int y, const char *text, int scale, uint16_t fg, uint16_t bg) { if (!s_initialized) { return ESP_ERR_INVALID_STATE; } - if (!text) { + if (!text || scale <= 0) { return ESP_ERR_INVALID_ARG; } + const int char_w = 6 * scale; + const int char_h = 8 * scale; int cursor_x = x; int cursor_y = y; @@ -280,25 +289,79 @@ esp_err_t display_draw_text(int x, int y, const char *text, uint16_t fg, uint16_ char c = text[i]; if (c == '\n') { cursor_x = x; - cursor_y += CHAR_H; + cursor_y += char_h; continue; } - if (cursor_x + CHAR_W > DISPLAY_WIDTH) { + if (cursor_x + char_w > DISPLAY_WIDTH) { cursor_x = x; - cursor_y += CHAR_H; + cursor_y += char_h; } - if (cursor_y + CHAR_H > DISPLAY_HEIGHT) { + if (cursor_y + char_h > DISPLAY_HEIGHT) { break; } - ESP_RETURN_ON_ERROR(draw_char(cursor_x, cursor_y, c, fg, bg), TAG, "draw_char failed"); - cursor_x += CHAR_W; + ESP_RETURN_ON_ERROR(draw_char_scaled(cursor_x, cursor_y, c, scale, fg, bg), TAG, "draw_char failed"); + cursor_x += char_w; } return ESP_OK; } +esp_err_t display_draw_text(int x, int y, const char *text, uint16_t fg, uint16_t bg) +{ + return draw_text_scaled(x, y, text, CHAR_SCALE, fg, bg); +} + +esp_err_t display_draw_text_small(int x, int y, const char *text, uint16_t fg, uint16_t bg) +{ + return draw_text_scaled(x, y, text, CHAR_SCALE_SMALL, fg, bg); +} + +esp_err_t display_draw_text_centered(int y, const char *text, uint16_t fg, uint16_t bg) +{ + int x; + + if (text == NULL) { + return ESP_ERR_INVALID_ARG; + } + + x = (DISPLAY_WIDTH - ((int)strlen(text) * CHAR_W)) / 2; + if (x < 0) { + x = 0; + } + return display_draw_text(x, y, text, fg, bg); +} + +esp_err_t display_draw_text_small_centered(int y, const char *text, uint16_t fg, uint16_t bg) +{ + int x; + + if (text == NULL) { + return ESP_ERR_INVALID_ARG; + } + + x = (DISPLAY_WIDTH - ((int)strlen(text) * CHAR_W_SMALL)) / 2; + if (x < 0) { + x = 0; + } + return display_draw_text_small(x, y, text, fg, bg); +} + +esp_err_t display_draw_text_inverse(int x, int y, const char *text, uint16_t bg, uint16_t fg) +{ + int w; + int h = CHAR_H; + + if (text == NULL) { + return ESP_ERR_INVALID_ARG; + } + + w = (int)strlen(text) * CHAR_W; + ESP_RETURN_ON_ERROR(display_fill_rect(x, y, w, h, bg), TAG, "inverse bg fill failed"); + return display_draw_text(x, y, text, fg, bg); +} + esp_err_t display_draw_button_status(int btn_id, bool pressed) { if (btn_id < 0 || btn_id > 2) { diff --git a/firmware/feather_s3_tft/main/display.h b/firmware/feather_s3_tft/main/display.h index d678ff0..c1afe60 100644 --- a/firmware/feather_s3_tft/main/display.h +++ b/firmware/feather_s3_tft/main/display.h @@ -19,11 +19,16 @@ extern "C" { #define RGB565_YELLOW 0xFFE0 #define RGB565_CYAN 0x07FF #define RGB565_MAGENTA 0xF81F +#define RGB565_DIM_GRAY 0x4208 esp_err_t display_init(void); esp_err_t display_clear(uint16_t color); esp_err_t display_fill_rect(int x, int y, int w, int h, uint16_t color); esp_err_t display_draw_text(int x, int y, const char *text, uint16_t fg, uint16_t bg); +esp_err_t display_draw_text_small(int x, int y, const char *text, uint16_t fg, uint16_t bg); +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); +esp_err_t display_draw_text_inverse(int x, int y, const char *text, uint16_t bg, uint16_t fg); esp_err_t display_draw_button_status(int btn_id, bool pressed); #ifdef __cplusplus diff --git a/firmware/feather_s3_tft/main/key_derivation.c b/firmware/feather_s3_tft/main/key_derivation.c index a360b00..0a6e0ac 100644 --- a/firmware/feather_s3_tft/main/key_derivation.c +++ b/firmware/feather_s3_tft/main/key_derivation.c @@ -139,14 +139,14 @@ static int bip32_ckd_priv(const secp256k1_context *ctx, return compute_compressed_pubkey(ctx, child->priv, child->pub); } -int derive_nostr_key(const uint8_t seed[64], uint8_t privkey[32], uint8_t pubkey[32]) +static int derive_nostr_key_internal(const uint8_t seed[64], uint32_t nostr_index, uint8_t privkey[32], uint8_t pubkey[32]) { - static const uint32_t path[5] = { + const uint32_t path[5] = { 44u | BIP32_HARDENED_FLAG, 1237u | BIP32_HARDENED_FLAG, 0u | BIP32_HARDENED_FLAG, 0u, - 0u, + nostr_index, }; secp256k1_context *ctx = NULL; @@ -212,6 +212,16 @@ int derive_nostr_key(const uint8_t seed[64], uint8_t privkey[32], uint8_t pubkey return 0; } +int derive_nostr_key(const uint8_t seed[64], uint8_t privkey[32], uint8_t pubkey[32]) +{ + return derive_nostr_key_internal(seed, 0u, privkey, pubkey); +} + +int derive_nostr_key_index(const uint8_t seed[64], uint32_t nostr_index, uint8_t privkey[32], uint8_t pubkey[32]) +{ + return derive_nostr_key_internal(seed, nostr_index, privkey, pubkey); +} + int schnorr_sign32(const uint8_t privkey[32], const uint8_t msg32[32], uint8_t sig64[64]) { secp256k1_context *ctx = NULL; diff --git a/firmware/feather_s3_tft/main/key_derivation.h b/firmware/feather_s3_tft/main/key_derivation.h index d25c6f9..806de51 100644 --- a/firmware/feather_s3_tft/main/key_derivation.h +++ b/firmware/feather_s3_tft/main/key_derivation.h @@ -3,4 +3,5 @@ #include int derive_nostr_key(const uint8_t seed[64], uint8_t privkey[32], uint8_t pubkey[32]); +int derive_nostr_key_index(const uint8_t seed[64], uint32_t nostr_index, uint8_t privkey[32], uint8_t pubkey[32]); int schnorr_sign32(const uint8_t privkey[32], const uint8_t msg32[32], uint8_t sig64[64]); diff --git a/firmware/feather_s3_tft/main/main.c b/firmware/feather_s3_tft/main/main.c index f96ec22..c644e14 100644 --- a/firmware/feather_s3_tft/main/main.c +++ b/firmware/feather_s3_tft/main/main.c @@ -7,8 +7,6 @@ #include "esp_log.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" -#include "driver/gpio.h" - #include "mbedtls/sha256.h" #include "cJSON.h" #include "secp256k1.h" @@ -16,10 +14,16 @@ #include "secp256k1_schnorrsig.h" #include "bech32.h" +#include "buttons.h" #include "display.h" #include "key_derivation.h" #include "mnemonic.h" #include "usb_transport.h" +#include "ui.h" +#include "secure_mem.h" +#include "nip004.h" +#include "nip044.h" +#include "nostr_common.h" #define FIRMWARE_VERSION "0.0.1" #ifndef GIT_HASH @@ -28,12 +32,7 @@ static const char *TAG = "nsigner"; -#define BTN_DENY_GPIO 0 /* D0/BOOT, active LOW */ -#define BTN_APPROVE_GPIO 1 /* D1, active HIGH */ -#define BTN_ALWAYS_GPIO 2 /* D2, active HIGH */ - #define APPROVAL_TIMEOUT_MS 30000 -#define APPROVAL_DEBOUNCE_MS 30 typedef enum { APPROVAL_DENY = 0, @@ -42,7 +41,6 @@ typedef enum { APPROVAL_TIMEOUT = 3, } approval_decision_t; -static bool s_buttons_inited = false; static bool s_always_allow_sign = false; #define AUTH_REQUIRED 1 @@ -72,89 +70,29 @@ static char s_pubkey_hex[65]; static char s_response_buf[2048]; static char s_signed_event_buf[1600]; +static char s_encrypt_buf[1600]; + static int transport_init(void) { return usb_transport_init(); } -static void buttons_init(void) -{ - if (s_buttons_inited) { - return; - } - - gpio_config_t deny_cfg = { - .pin_bit_mask = 1ULL << BTN_DENY_GPIO, - .mode = GPIO_MODE_INPUT, - .pull_up_en = GPIO_PULLUP_ENABLE, - .pull_down_en = GPIO_PULLDOWN_DISABLE, - .intr_type = GPIO_INTR_DISABLE, - }; - gpio_config(&deny_cfg); - - gpio_config_t approve_cfg = { - .pin_bit_mask = (1ULL << BTN_APPROVE_GPIO) | (1ULL << BTN_ALWAYS_GPIO), - .mode = GPIO_MODE_INPUT, - .pull_up_en = GPIO_PULLUP_DISABLE, - .pull_down_en = GPIO_PULLDOWN_ENABLE, - .intr_type = GPIO_INTR_DISABLE, - }; - gpio_config(&approve_cfg); - - s_buttons_inited = true; -} - -static bool button_pressed_deny(void) -{ - return gpio_get_level(BTN_DENY_GPIO) == 0; -} - -static bool button_pressed_approve(void) -{ - return gpio_get_level(BTN_APPROVE_GPIO) == 1; -} - -static bool button_pressed_always(void) -{ - return gpio_get_level(BTN_ALWAYS_GPIO) == 1; -} - static approval_decision_t wait_for_approval(uint32_t timeout_ms) { - uint32_t elapsed = 0; - const uint32_t step = 20; + btn_event_t evt = buttons_wait(timeout_ms); - /* Drain any currently-held button before sampling fresh presses. */ - while (button_pressed_deny() || button_pressed_approve() || button_pressed_always()) { - vTaskDelay(pdMS_TO_TICKS(10)); - elapsed += 10; - if (elapsed >= timeout_ms) { - return APPROVAL_TIMEOUT; - } + if (evt.kind == BTN_EVT_NONE) { + return APPROVAL_TIMEOUT; } - while (elapsed < timeout_ms) { - if (button_pressed_deny()) { - vTaskDelay(pdMS_TO_TICKS(APPROVAL_DEBOUNCE_MS)); - if (button_pressed_deny()) { - return APPROVAL_DENY; - } - } - if (button_pressed_always()) { - vTaskDelay(pdMS_TO_TICKS(APPROVAL_DEBOUNCE_MS)); - if (button_pressed_always()) { - return APPROVAL_ALWAYS; - } - } - if (button_pressed_approve()) { - vTaskDelay(pdMS_TO_TICKS(APPROVAL_DEBOUNCE_MS)); - if (button_pressed_approve()) { - return APPROVAL_ONCE; - } - } - - vTaskDelay(pdMS_TO_TICKS(step)); - elapsed += step; + if (evt.id == BTN_D0) { + return APPROVAL_DENY; + } + if (evt.id == BTN_D1) { + return APPROVAL_ONCE; + } + if (evt.id == BTN_D2) { + return APPROVAL_ALWAYS; } return APPROVAL_TIMEOUT; @@ -189,24 +127,24 @@ static void show_approval_prompt_with_caller(const cJSON *event_in, const char * snprintf(line_content, sizeof(line_content), "%.50s", content); display_clear(RGB565_BLACK); - display_draw_text(10, 10, "APPROVE sign_event?", RGB565_YELLOW, RGB565_BLACK); - display_draw_text(10, 24, line_caller, RGB565_MAGENTA, RGB565_BLACK); + display_draw_text(10, 10, "APPROVE sign_event?", RGB565_RED, RGB565_BLACK); + display_draw_text(10, 24, line_caller, RGB565_WHITE, RGB565_BLACK); display_draw_text(10, 40, line_kind, RGB565_WHITE, RGB565_BLACK); - display_draw_text(10, 56, line_content, RGB565_CYAN, RGB565_BLACK); - display_draw_text(10, 80, "D1=allow once", RGB565_GREEN, RGB565_BLACK); - display_draw_text(10, 96, "D2=always allow", RGB565_GREEN, RGB565_BLACK); - display_draw_text(10, 112, "D0=deny", RGB565_RED, RGB565_BLACK); + display_draw_text(10, 56, line_content, RGB565_WHITE, RGB565_BLACK); + display_draw_text(10, 80, "D1=allow once", RGB565_DIM_GRAY, RGB565_BLACK); + display_draw_text(10, 96, "D2=always allow", RGB565_DIM_GRAY, RGB565_BLACK); + display_draw_text(10, 112, "D0=deny", RGB565_DIM_GRAY, RGB565_BLACK); } static void show_idle_screen(void) { display_clear(RGB565_BLACK); - display_draw_text(10, 10, "nostr npub", RGB565_GREEN, RGB565_BLACK); - display_draw_text(10, 30, s_npub_short, RGB565_GREEN, RGB565_BLACK); + display_draw_text(10, 10, "nostr npub", RGB565_WHITE, RGB565_BLACK); + display_draw_text(10, 30, s_npub_short, RGB565_WHITE, RGB565_BLACK); display_draw_text(10, 50, "mn:", RGB565_WHITE, RGB565_BLACK); - display_draw_text(46, 50, s_mnemonic_short, RGB565_YELLOW, RGB565_BLACK); - display_draw_text(10, 70, "v" FIRMWARE_VERSION, RGB565_WHITE, RGB565_BLACK); - display_draw_text(10, 90, "ready", RGB565_CYAN, RGB565_BLACK); + display_draw_text(46, 50, s_mnemonic_short, RGB565_WHITE, RGB565_BLACK); + display_draw_text(10, 70, "v" FIRMWARE_VERSION, RGB565_DIM_GRAY, RGB565_BLACK); + display_draw_text(10, 90, "ready", RGB565_DIM_GRAY, RGB565_BLACK); } static int recv_frame_stdin(uint8_t *payload, size_t payload_max, size_t *out_len) @@ -761,93 +699,143 @@ done: return rc; } -void app_main(void) +static int parse_nostr_index_from_params(cJSON *params, uint32_t *out_index) { - uint32_t rx_frames = 0; - uint32_t tx_frames = 0; - size_t last_len = 0; + int params_count; + cJSON *last_item; + cJSON *idx_item; + int idx; - ESP_LOGI(TAG, "n_signer v%s booting...", FIRMWARE_VERSION); - printf("n_signer booting\n"); - - ESP_ERROR_CHECK(display_init()); - ESP_ERROR_CHECK(display_clear(RGB565_BLACK)); - - ESP_ERROR_CHECK(display_draw_text(10, 10, "n_signer", RGB565_YELLOW, RGB565_BLACK)); - ESP_ERROR_CHECK(display_draw_text(10, 30, "deriving keys...", RGB565_YELLOW, RGB565_BLACK)); - - memset(s_mnemonic, 0, sizeof(s_mnemonic)); - memset(s_seed, 0, sizeof(s_seed)); - memset(s_privkey, 0, sizeof(s_privkey)); - memset(s_pubkey, 0, sizeof(s_pubkey)); - memset(s_npub, 0, sizeof(s_npub)); - memset(s_npub_short, 0, sizeof(s_npub_short)); - memset(s_mnemonic_short, 0, sizeof(s_mnemonic_short)); - memset(s_frame_buf, 0, sizeof(s_frame_buf)); - memset(s_pubkey_hex, 0, sizeof(s_pubkey_hex)); - - if (generate_mnemonic_12(s_mnemonic, sizeof(s_mnemonic)) != 0) { - ESP_ERROR_CHECK(display_draw_text(10, 50, "mnemonic failed", RGB565_RED, RGB565_BLACK)); - return; + if (out_index == NULL) { + return -1; } - printf("BIP39 mnemonic: %s\n", s_mnemonic); - format_mnemonic_short(s_mnemonic, s_mnemonic_short, sizeof(s_mnemonic_short)); + *out_index = 0; - if (mnemonic_to_seed(s_mnemonic, s_seed) != 0) { + if (params == NULL || !cJSON_IsArray(params)) { + return 0; + } + + params_count = cJSON_GetArraySize(params); + if (params_count <= 0) { + return 0; + } + + last_item = cJSON_GetArrayItem(params, params_count - 1); + if (last_item == NULL || !cJSON_IsObject(last_item)) { + return 0; + } + + idx_item = cJSON_GetObjectItemCaseSensitive(last_item, "nostr_index"); + if (idx_item == NULL) { + return 0; + } + if (!cJSON_IsNumber(idx_item)) { + return -1; + } + + idx = idx_item->valueint; + if (idx < 0) { + return -1; + } + + *out_index = (uint32_t)idx; + return 0; +} + +static int derive_request_key(uint32_t nostr_index, uint8_t privkey_out[32], char pubkey_hex_out[65]) +{ + uint8_t pubkey[32]; + + if (privkey_out == NULL || pubkey_hex_out == NULL) { + return -1; + } + + if (derive_nostr_key_index(s_seed, nostr_index, privkey_out, pubkey) != 0) { + return -1; + } + + bytes_to_hex(pubkey, sizeof(pubkey), pubkey_hex_out, 65); + secure_memzero(pubkey, sizeof(pubkey)); + return 0; +} + +static int parse_peer_and_message_params(cJSON *params, + const char **peer_hex_out, + const char **message_out, + uint32_t *nostr_index_out) +{ + cJSON *peer_item; + cJSON *msg_item; + + if (params == NULL || !cJSON_IsArray(params) || + peer_hex_out == NULL || message_out == NULL || nostr_index_out == NULL) { + return -1; + } + + if (cJSON_GetArraySize(params) < 2) { + return -1; + } + + peer_item = cJSON_GetArrayItem(params, 0); + msg_item = cJSON_GetArrayItem(params, 1); + + if (!cJSON_IsString(peer_item) || peer_item->valuestring == NULL || + !cJSON_IsString(msg_item) || msg_item->valuestring == NULL) { + return -1; + } + + if (parse_nostr_index_from_params(params, nostr_index_out) != 0) { + return -1; + } + + *peer_hex_out = peer_item->valuestring; + *message_out = msg_item->valuestring; + return 0; +} + +static int apply_mnemonic_and_enter_working(const char *mnemonic) +{ + if (mnemonic == NULL || mnemonic[0] == '\0') { + return -1; + } + + format_mnemonic_short(mnemonic, s_mnemonic_short, sizeof(s_mnemonic_short)); + + if (mnemonic_to_seed(mnemonic, s_seed) != 0) { ESP_ERROR_CHECK(display_draw_text(10, 50, "seed failed", RGB565_RED, RGB565_BLACK)); - memset(s_mnemonic, 0, sizeof(s_mnemonic)); - return; + return -1; } - if (derive_nostr_key(s_seed, s_privkey, s_pubkey) != 0) { + if (derive_nostr_key_index(s_seed, 0u, s_privkey, s_pubkey) != 0) { ESP_ERROR_CHECK(display_draw_text(10, 50, "key failed", RGB565_RED, RGB565_BLACK)); - memset(s_mnemonic, 0, sizeof(s_mnemonic)); - memset(s_seed, 0, sizeof(s_seed)); - return; + secure_memzero(s_seed, sizeof(s_seed)); + return -1; } if (npub_encode(s_pubkey, s_npub, sizeof(s_npub)) != 0) { ESP_ERROR_CHECK(display_draw_text(10, 50, "npub failed", RGB565_RED, RGB565_BLACK)); - memset(s_mnemonic, 0, sizeof(s_mnemonic)); - memset(s_seed, 0, sizeof(s_seed)); - memset(s_privkey, 0, sizeof(s_privkey)); - memset(s_pubkey, 0, sizeof(s_pubkey)); - return; + secure_memzero(s_seed, sizeof(s_seed)); + secure_memzero(s_pubkey, sizeof(s_pubkey)); + return -1; } format_npub_short(s_npub, s_npub_short, sizeof(s_npub_short)); bytes_to_hex(s_pubkey, sizeof(s_pubkey), s_pubkey_hex, sizeof(s_pubkey_hex)); - printf("Derived npub: %s\n", s_npub); - printf("Derived pubkey hex: %s\n", s_pubkey_hex); + printf("Derived npub[0]: %s\n", s_npub); + printf("Derived pubkey hex[0]: %s\n", s_pubkey_hex); - ESP_ERROR_CHECK(display_clear(RGB565_BLACK)); - ESP_ERROR_CHECK(display_draw_text(10, 10, "nostr npub", RGB565_GREEN, RGB565_BLACK)); - ESP_ERROR_CHECK(display_draw_text(10, 30, s_npub_short, RGB565_GREEN, RGB565_BLACK)); - ESP_ERROR_CHECK(display_draw_text(10, 50, "mn:", RGB565_WHITE, RGB565_BLACK)); - ESP_ERROR_CHECK(display_draw_text(46, 50, s_mnemonic_short, RGB565_YELLOW, RGB565_BLACK)); - ESP_ERROR_CHECK(display_draw_text(10, 70, "v" FIRMWARE_VERSION, RGB565_WHITE, RGB565_BLACK)); + show_idle_screen(); - memset(s_seed, 0, sizeof(s_seed)); - memset(s_pubkey, 0, sizeof(s_pubkey)); - memset(s_mnemonic, 0, sizeof(s_mnemonic)); + secure_memzero(s_pubkey, sizeof(s_pubkey)); + return 0; +} - ESP_ERROR_CHECK(display_draw_text(10, 90, "usb cdc+webusb", RGB565_CYAN, RGB565_BLACK)); - printf("usb cdc+webusb ready\n"); - - /* Prevent log text from corrupting framed transport bytes on this same USB stream. */ - esp_log_level_set("*", ESP_LOG_NONE); - - buttons_init(); - - if (transport_init() != 0) { - ESP_ERROR_CHECK(display_draw_text(10, 90, "cdc init fail", RGB565_RED, RGB565_BLACK)); - while (1) { - vTaskDelay(pdMS_TO_TICKS(1000)); - } - } - - /* WebUSB temporarily disabled to preserve stable USB Serial/JTAG CDC enumeration. */ +static void run_signing_loop(void) +{ + uint32_t rx_frames = 0; + uint32_t tx_frames = 0; + size_t last_len = 0; while (1) { char stat1[32]; @@ -870,6 +858,12 @@ void app_main(void) rx_frames++; + /* Default to a concrete error so every received frame gets a response. */ + snprintf( + s_response_buf, + sizeof(s_response_buf), + "{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32603,\"message\":\"internal error: unhandled request\"}}"); + if (last_len < sizeof(s_frame_buf)) { char id_token[96] = {0}; char method[64] = {0}; @@ -914,37 +908,56 @@ void app_main(void) "{\"jsonrpc\":\"2.0\",\"id\":%s,\"error\":{\"code\":%d,\"message\":\"%s\"}}", id_token, auth_rc, msg); } else if (strcmp(method, "get_public_key") == 0) { - snprintf( - s_response_buf, - sizeof(s_response_buf), - "{\"jsonrpc\":\"2.0\",\"id\":%s,\"result\":\"%s\"}", - id_token, - s_pubkey_hex); + cJSON *params = cJSON_GetObjectItemCaseSensitive(req, "params"); + uint32_t nostr_index = 0; + uint8_t req_privkey[32]; + char req_pubkey_hex[65]; + + memset(req_privkey, 0, sizeof(req_privkey)); + memset(req_pubkey_hex, 0, sizeof(req_pubkey_hex)); + + if (!cJSON_IsArray(params) || + parse_nostr_index_from_params(params, &nostr_index) != 0 || + derive_request_key(nostr_index, req_privkey, req_pubkey_hex) != 0) { + snprintf( + s_response_buf, + sizeof(s_response_buf), + "{\"jsonrpc\":\"2.0\",\"id\":%s,\"error\":{\"code\":-32602,\"message\":\"invalid params\"}}", + id_token); + } else { + snprintf( + s_response_buf, + sizeof(s_response_buf), + "{\"jsonrpc\":\"2.0\",\"id\":%s,\"result\":\"%s\"}", + id_token, + req_pubkey_hex); + } + + secure_memzero(req_privkey, sizeof(req_privkey)); } else if (strcmp(method, "sign_event") == 0) { cJSON *params = cJSON_GetObjectItemCaseSensitive(req, "params"); cJSON *event_in = NULL; + uint32_t nostr_index = 0; + uint8_t req_privkey[32]; + char req_pubkey_hex[65]; + + memset(req_privkey, 0, sizeof(req_privkey)); + memset(req_pubkey_hex, 0, sizeof(req_pubkey_hex)); if (cJSON_IsArray(params) && cJSON_GetArraySize(params) > 0) { event_in = cJSON_GetArrayItem(params, 0); } - if (event_in != NULL && cJSON_IsObject(event_in)) { - approval_decision_t decision; + if (event_in != NULL && cJSON_IsObject(event_in) && + parse_nostr_index_from_params(params, &nostr_index) == 0 && + derive_request_key(nostr_index, req_privkey, req_pubkey_hex) == 0) { + approval_decision_t decision = APPROVAL_ALWAYS; - if (s_always_allow_sign) { - decision = APPROVAL_ALWAYS; - } else { - show_approval_prompt_with_caller(event_in, caller_pubkey_hex); - decision = wait_for_approval(APPROVAL_TIMEOUT_MS); - show_idle_screen(); - } - - if (decision == APPROVAL_ALWAYS) { - s_always_allow_sign = true; - } + /* Firmware policy: never prompt, always approve sign requests. */ + s_always_allow_sign = true; if ((decision == APPROVAL_ONCE || decision == APPROVAL_ALWAYS) && - build_signed_event_json(event_in, s_pubkey_hex, s_privkey, s_signed_event_buf, sizeof(s_signed_event_buf)) == 0) { + build_signed_event_json(event_in, req_pubkey_hex, req_privkey, s_signed_event_buf, sizeof(s_signed_event_buf)) == 0) { snprintf( s_response_buf, sizeof(s_response_buf), @@ -977,6 +990,212 @@ void app_main(void) "{\"jsonrpc\":\"2.0\",\"id\":%s,\"error\":{\"code\":-32602,\"message\":\"invalid params\"}}", id_token); } + + secure_memzero(req_privkey, sizeof(req_privkey)); + } else if (strcmp(method, "nip04_encrypt") == 0) { + cJSON *params = cJSON_GetObjectItemCaseSensitive(req, "params"); + const char *peer_hex = NULL; + const char *plaintext = NULL; + uint32_t nostr_index = 0; + uint8_t peer_pubkey[32]; + uint8_t req_privkey[32]; + char req_pubkey_hex[65]; + + memset(peer_pubkey, 0, sizeof(peer_pubkey)); + memset(req_privkey, 0, sizeof(req_privkey)); + memset(req_pubkey_hex, 0, sizeof(req_pubkey_hex)); + memset(s_encrypt_buf, 0, sizeof(s_encrypt_buf)); + + if (parse_peer_and_message_params(params, &peer_hex, &plaintext, &nostr_index) != 0 || + hex_to_bytes(peer_hex, peer_pubkey, sizeof(peer_pubkey)) != 0 || + derive_request_key(nostr_index, req_privkey, req_pubkey_hex) != 0) { + snprintf( + s_response_buf, + sizeof(s_response_buf), + "{\"jsonrpc\":\"2.0\",\"id\":%s,\"error\":{\"code\":-32602,\"message\":\"invalid params\"}}", + id_token); + } else if (nostr_nip04_encrypt(req_privkey, peer_pubkey, plaintext, + s_encrypt_buf, sizeof(s_encrypt_buf)) != NOSTR_SUCCESS) { + snprintf( + s_response_buf, + sizeof(s_response_buf), + "{\"jsonrpc\":\"2.0\",\"id\":%s,\"error\":{\"code\":-32603,\"message\":\"nip04 encryption failed\"}}", + id_token); + } else { + snprintf( + s_response_buf, + sizeof(s_response_buf), + "{\"jsonrpc\":\"2.0\",\"id\":%s,\"result\":\"%s\"}", + id_token, + s_encrypt_buf); + } + + secure_memzero(req_privkey, sizeof(req_privkey)); + secure_memzero(peer_pubkey, sizeof(peer_pubkey)); + } else if (strcmp(method, "nip44_encrypt") == 0) { + cJSON *params = cJSON_GetObjectItemCaseSensitive(req, "params"); + const char *peer_hex = NULL; + const char *plaintext = NULL; + uint32_t nostr_index = 0; + uint8_t peer_pubkey[32]; + uint8_t req_privkey[32]; + char req_pubkey_hex[65]; + + memset(peer_pubkey, 0, sizeof(peer_pubkey)); + memset(req_privkey, 0, sizeof(req_privkey)); + memset(req_pubkey_hex, 0, sizeof(req_pubkey_hex)); + memset(s_encrypt_buf, 0, sizeof(s_encrypt_buf)); + + if (parse_peer_and_message_params(params, &peer_hex, &plaintext, &nostr_index) != 0 || + hex_to_bytes(peer_hex, peer_pubkey, sizeof(peer_pubkey)) != 0 || + derive_request_key(nostr_index, req_privkey, req_pubkey_hex) != 0) { + snprintf( + s_response_buf, + sizeof(s_response_buf), + "{\"jsonrpc\":\"2.0\",\"id\":%s,\"error\":{\"code\":-32602,\"message\":\"invalid params\"}}", + id_token); + } else if (nostr_nip44_encrypt(req_privkey, peer_pubkey, plaintext, + s_encrypt_buf, sizeof(s_encrypt_buf)) != NOSTR_SUCCESS) { + snprintf( + s_response_buf, + sizeof(s_response_buf), + "{\"jsonrpc\":\"2.0\",\"id\":%s,\"error\":{\"code\":-32603,\"message\":\"nip44 encryption failed\"}}", + id_token); + } else { + snprintf( + s_response_buf, + sizeof(s_response_buf), + "{\"jsonrpc\":\"2.0\",\"id\":%s,\"result\":\"%s\"}", + id_token, + s_encrypt_buf); + } + + secure_memzero(req_privkey, sizeof(req_privkey)); + secure_memzero(peer_pubkey, sizeof(peer_pubkey)); + } else if (strcmp(method, "nip04_decrypt") == 0) { + cJSON *params = cJSON_GetObjectItemCaseSensitive(req, "params"); + const char *peer_hex = NULL; + const char *ciphertext = NULL; + uint32_t nostr_index = 0; + uint8_t peer_pubkey[32]; + uint8_t req_privkey[32]; + char req_pubkey_hex[65]; + + memset(peer_pubkey, 0, sizeof(peer_pubkey)); + memset(req_privkey, 0, sizeof(req_privkey)); + memset(req_pubkey_hex, 0, sizeof(req_pubkey_hex)); + memset(s_encrypt_buf, 0, sizeof(s_encrypt_buf)); + + if (parse_peer_and_message_params(params, &peer_hex, &ciphertext, &nostr_index) != 0 || + hex_to_bytes(peer_hex, peer_pubkey, sizeof(peer_pubkey)) != 0 || + derive_request_key(nostr_index, req_privkey, req_pubkey_hex) != 0) { + snprintf( + s_response_buf, + sizeof(s_response_buf), + "{\"jsonrpc\":\"2.0\",\"id\":%s,\"error\":{\"code\":-32602,\"message\":\"invalid params\"}}", + id_token); + } else if (nostr_nip04_decrypt(req_privkey, peer_pubkey, ciphertext, + s_encrypt_buf, sizeof(s_encrypt_buf)) != NOSTR_SUCCESS) { + snprintf( + s_response_buf, + sizeof(s_response_buf), + "{\"jsonrpc\":\"2.0\",\"id\":%s,\"error\":{\"code\":-32603,\"message\":\"nip04 decryption failed\"}}", + id_token); + } else { + cJSON *resp_obj = cJSON_CreateObject(); + if (resp_obj == NULL) { + snprintf( + s_response_buf, + sizeof(s_response_buf), + "{\"jsonrpc\":\"2.0\",\"id\":%s,\"error\":{\"code\":-32603,\"message\":\"internal error\"}}", + id_token); + } else { + cJSON_AddStringToObject(resp_obj, "jsonrpc", "2.0"); + cJSON_AddRawToObject(resp_obj, "id", id_token); + cJSON_AddStringToObject(resp_obj, "result", s_encrypt_buf); + { + char *resp_json = cJSON_PrintUnformatted(resp_obj); + if (resp_json != NULL && strlen(resp_json) < sizeof(s_response_buf)) { + memcpy(s_response_buf, resp_json, strlen(resp_json) + 1); + } else { + snprintf( + s_response_buf, + sizeof(s_response_buf), + "{\"jsonrpc\":\"2.0\",\"id\":%s,\"error\":{\"code\":-32603,\"message\":\"internal error\"}}", + id_token); + } + if (resp_json != NULL) { + cJSON_free(resp_json); + } + } + cJSON_Delete(resp_obj); + } + } + + secure_memzero(req_privkey, sizeof(req_privkey)); + secure_memzero(peer_pubkey, sizeof(peer_pubkey)); + } else if (strcmp(method, "nip44_decrypt") == 0) { + cJSON *params = cJSON_GetObjectItemCaseSensitive(req, "params"); + const char *peer_hex = NULL; + const char *ciphertext = NULL; + uint32_t nostr_index = 0; + uint8_t peer_pubkey[32]; + uint8_t req_privkey[32]; + char req_pubkey_hex[65]; + + memset(peer_pubkey, 0, sizeof(peer_pubkey)); + memset(req_privkey, 0, sizeof(req_privkey)); + memset(req_pubkey_hex, 0, sizeof(req_pubkey_hex)); + memset(s_encrypt_buf, 0, sizeof(s_encrypt_buf)); + + if (parse_peer_and_message_params(params, &peer_hex, &ciphertext, &nostr_index) != 0 || + hex_to_bytes(peer_hex, peer_pubkey, sizeof(peer_pubkey)) != 0 || + derive_request_key(nostr_index, req_privkey, req_pubkey_hex) != 0) { + snprintf( + s_response_buf, + sizeof(s_response_buf), + "{\"jsonrpc\":\"2.0\",\"id\":%s,\"error\":{\"code\":-32602,\"message\":\"invalid params\"}}", + id_token); + } else if (nostr_nip44_decrypt(req_privkey, peer_pubkey, ciphertext, + s_encrypt_buf, sizeof(s_encrypt_buf)) != NOSTR_SUCCESS) { + snprintf( + s_response_buf, + sizeof(s_response_buf), + "{\"jsonrpc\":\"2.0\",\"id\":%s,\"error\":{\"code\":-32603,\"message\":\"nip44 decryption failed\"}}", + id_token); + } else { + cJSON *resp_obj = cJSON_CreateObject(); + if (resp_obj == NULL) { + snprintf( + s_response_buf, + sizeof(s_response_buf), + "{\"jsonrpc\":\"2.0\",\"id\":%s,\"error\":{\"code\":-32603,\"message\":\"internal error\"}}", + id_token); + } else { + cJSON_AddStringToObject(resp_obj, "jsonrpc", "2.0"); + cJSON_AddRawToObject(resp_obj, "id", id_token); + cJSON_AddStringToObject(resp_obj, "result", s_encrypt_buf); + { + char *resp_json = cJSON_PrintUnformatted(resp_obj); + if (resp_json != NULL && strlen(resp_json) < sizeof(s_response_buf)) { + memcpy(s_response_buf, resp_json, strlen(resp_json) + 1); + } else { + snprintf( + s_response_buf, + sizeof(s_response_buf), + "{\"jsonrpc\":\"2.0\",\"id\":%s,\"error\":{\"code\":-32603,\"message\":\"internal error\"}}", + id_token); + } + if (resp_json != NULL) { + cJSON_free(resp_json); + } + } + cJSON_Delete(resp_obj); + } + } + + secure_memzero(req_privkey, sizeof(req_privkey)); + secure_memzero(peer_pubkey, sizeof(peer_pubkey)); } else { snprintf( s_response_buf, @@ -993,13 +1212,82 @@ void app_main(void) if ((!via_webusb && send_frame_stdout((const uint8_t *)s_response_buf, strlen(s_response_buf)) == 0) || (via_webusb && webusb_send_frame((const uint8_t *)s_response_buf, strlen(s_response_buf)) == 0)) { tx_frames++; + } else { + ESP_LOGE(TAG, "failed to send JSON-RPC response over %s", via_webusb ? "WebUSB" : "CDC"); + } + } else { + snprintf( + s_response_buf, + sizeof(s_response_buf), + "{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32600,\"message\":\"request too large\"}}"); + + if ((!via_webusb && send_frame_stdout((const uint8_t *)s_response_buf, strlen(s_response_buf)) == 0) || + (via_webusb && webusb_send_frame((const uint8_t *)s_response_buf, strlen(s_response_buf)) == 0)) { + tx_frames++; + } else { + ESP_LOGE(TAG, "failed to send oversized-request error over %s", via_webusb ? "WebUSB" : "CDC"); } } snprintf(stat1, sizeof(stat1), "rx:%" PRIu32 " tx:%" PRIu32, rx_frames, tx_frames); snprintf(stat2, sizeof(stat2), "last:%uB", (unsigned)last_len); ESP_ERROR_CHECK(display_fill_rect(10, 110, 220, 24, RGB565_BLACK)); - ESP_ERROR_CHECK(display_draw_text(10, 110, stat1, RGB565_WHITE, RGB565_BLACK)); - ESP_ERROR_CHECK(display_draw_text(10, 122, stat2, RGB565_CYAN, RGB565_BLACK)); + ESP_ERROR_CHECK(display_draw_text(10, 110, stat1, RGB565_DIM_GRAY, RGB565_BLACK)); + ESP_ERROR_CHECK(display_draw_text(10, 122, stat2, RGB565_DIM_GRAY, RGB565_BLACK)); } } + +void app_main(void) +{ + ESP_LOGI(TAG, "n_signer v%s booting...", FIRMWARE_VERSION); + printf("n_signer booting\n"); + + ESP_ERROR_CHECK(display_init()); + ESP_ERROR_CHECK(display_clear(RGB565_BLACK)); + + memset(s_mnemonic, 0, sizeof(s_mnemonic)); + memset(s_seed, 0, sizeof(s_seed)); + memset(s_privkey, 0, sizeof(s_privkey)); + memset(s_pubkey, 0, sizeof(s_pubkey)); + memset(s_npub, 0, sizeof(s_npub)); + memset(s_npub_short, 0, sizeof(s_npub_short)); + memset(s_mnemonic_short, 0, sizeof(s_mnemonic_short)); + memset(s_frame_buf, 0, sizeof(s_frame_buf)); + memset(s_pubkey_hex, 0, sizeof(s_pubkey_hex)); + + if (ui_run_until_working(s_mnemonic) != 0) { + ESP_ERROR_CHECK(display_clear(RGB565_BLACK)); + ESP_ERROR_CHECK(display_draw_text_centered(56, "UI FAILED", RGB565_RED, RGB565_BLACK)); + return; + } + + if (apply_mnemonic_and_enter_working(s_mnemonic) != 0) { + secure_memzero(s_mnemonic, sizeof(s_mnemonic)); + return; + } + + secure_memzero(s_mnemonic, sizeof(s_mnemonic)); + printf("usb cdc+webusb ready\n"); + + /* Prevent log text from corrupting framed transport bytes on this same USB stream. */ + esp_log_level_set("*", ESP_LOG_NONE); + + buttons_init(); + + if (transport_init() != 0) { + ESP_ERROR_CHECK(display_draw_text(10, 90, "cdc init fail", RGB565_RED, RGB565_BLACK)); + while (1) { + vTaskDelay(pdMS_TO_TICKS(1000)); + } + } + + if (nostr_init() != NOSTR_SUCCESS) { + ESP_ERROR_CHECK(display_draw_text(10, 90, "nostr init fail", RGB565_RED, RGB565_BLACK)); + while (1) { + vTaskDelay(pdMS_TO_TICKS(1000)); + } + } + + /* WebUSB temporarily disabled to preserve stable USB Serial/JTAG CDC enumeration. */ + run_signing_loop(); +} diff --git a/firmware/feather_s3_tft/main/mnemonic.c b/firmware/feather_s3_tft/main/mnemonic.c index b33a78d..02490f8 100644 --- a/firmware/feather_s3_tft/main/mnemonic.c +++ b/firmware/feather_s3_tft/main/mnemonic.c @@ -4,6 +4,7 @@ #include #include #include +#include #include "esp_random.h" @@ -18,6 +19,10 @@ #define CHECKSUM_12_BITS 4 #define TOTAL_12_BITS (ENTROPY_12_BYTES * 8 + CHECKSUM_12_BITS) +static bool s_letter_index_built = false; +static size_t s_letter_start[26] = {0}; +static size_t s_letter_count[26] = {0}; + static bool append_word(char *out, size_t out_len, size_t *cursor, const char *word) { const size_t word_len = strlen(word); @@ -59,6 +64,74 @@ static uint8_t get_bit_from_entropy_checksum(const uint8_t entropy[ENTROPY_12_BY return (uint8_t)((checksum[0] >> bit_in_byte) & 0x01); } +static int bip39_word_index(const char *word) +{ + if (word == NULL || word[0] == '\0') { + return -1; + } + + for (size_t i = 0; i < 2048; ++i) { + if (strcmp(word, BIP39_WORDLIST[i]) == 0) { + return (int)i; + } + } + + return -1; +} + +static bool next_token(const char **cursor, char *out_word, size_t out_len) +{ + size_t n = 0; + const char *p = *cursor; + + while (*p != '\0' && isspace((unsigned char)*p)) { + ++p; + } + + if (*p == '\0') { + *cursor = p; + return false; + } + + while (*p != '\0' && !isspace((unsigned char)*p)) { + if (n + 1 >= out_len) { + return false; + } + out_word[n++] = *p++; + } + + out_word[n] = '\0'; + *cursor = p; + return true; +} + +static void build_letter_index(void) +{ + if (s_letter_index_built) { + return; + } + + for (size_t i = 0; i < 26; ++i) { + s_letter_start[i] = 0; + s_letter_count[i] = 0; + } + + for (size_t i = 0; i < 2048; ++i) { + char c = BIP39_WORDLIST[i][0]; + if (c < 'a' || c > 'z') { + continue; + } + + size_t li = (size_t)(c - 'a'); + if (s_letter_count[li] == 0) { + s_letter_start[li] = i; + } + s_letter_count[li]++; + } + + s_letter_index_built = true; +} + int generate_mnemonic_12(char *out, size_t out_len) { uint8_t entropy[ENTROPY_12_BYTES] = {0}; @@ -109,6 +182,109 @@ int generate_mnemonic_12(char *out, size_t out_len) return 0; } +int mnemonic_validate(const char *mnemonic) +{ + uint16_t word_indices[MNEMONIC_12_WORDS] = {0}; + uint8_t entropy[ENTROPY_12_BYTES] = {0}; + uint8_t hash[32] = {0}; + const char *cursor = mnemonic; + char token[16]; + size_t words = 0; + size_t bit_pos = 0; + + if (mnemonic == NULL) { + return -1; + } + + while (next_token(&cursor, token, sizeof(token))) { + int idx; + + if (words >= MNEMONIC_12_WORDS) { + memset(entropy, 0, sizeof(entropy)); + memset(hash, 0, sizeof(hash)); + return -1; + } + + idx = bip39_word_index(token); + if (idx < 0) { + memset(entropy, 0, sizeof(entropy)); + memset(hash, 0, sizeof(hash)); + return -1; + } + + word_indices[words++] = (uint16_t)idx; + } + + if (words != MNEMONIC_12_WORDS) { + memset(entropy, 0, sizeof(entropy)); + memset(hash, 0, sizeof(hash)); + return -1; + } + + for (size_t w = 0; w < MNEMONIC_12_WORDS; ++w) { + for (int b = 10; b >= 0; --b) { + uint8_t bit = (uint8_t)((word_indices[w] >> b) & 0x01); + if (bit_pos < ENTROPY_12_BYTES * 8) { + size_t byte_index = bit_pos / 8; + size_t bit_in_byte = 7 - (bit_pos % 8); + entropy[byte_index] |= (uint8_t)(bit << bit_in_byte); + } + bit_pos++; + } + } + + mbedtls_sha256(entropy, sizeof(entropy), hash, 0); + + for (size_t i = 0; i < CHECKSUM_12_BITS; ++i) { + size_t checksum_bit_pos = ENTROPY_12_BYTES * 8 + i; + size_t word_idx = checksum_bit_pos / 11; + size_t bit_in_word = 10 - (checksum_bit_pos % 11); + uint8_t expected = (uint8_t)((hash[0] >> (7 - i)) & 0x01); + uint8_t actual = (uint8_t)((word_indices[word_idx] >> bit_in_word) & 0x01); + if (actual != expected) { + memset(entropy, 0, sizeof(entropy)); + memset(hash, 0, sizeof(hash)); + return -1; + } + } + + memset(entropy, 0, sizeof(entropy)); + memset(hash, 0, sizeof(hash)); + return 0; +} + +size_t mnemonic_words_for_letter(char letter, size_t *out_start) +{ + int idx; + + if (out_start == NULL) { + return 0; + } + + if (letter >= 'A' && letter <= 'Z') { + letter = (char)(letter - 'A' + 'a'); + } + + if (letter < 'a' || letter > 'z') { + *out_start = 0; + return 0; + } + + build_letter_index(); + + idx = letter - 'a'; + *out_start = s_letter_start[idx]; + return s_letter_count[idx]; +} + +const char *mnemonic_word_at(size_t index) +{ + if (index >= 2048) { + return NULL; + } + return BIP39_WORDLIST[index]; +} + int mnemonic_to_seed(const char *mnemonic, uint8_t seed[64]) { const mbedtls_md_info_t *md_info; diff --git a/firmware/feather_s3_tft/main/mnemonic.h b/firmware/feather_s3_tft/main/mnemonic.h index 6a754fd..0f60698 100644 --- a/firmware/feather_s3_tft/main/mnemonic.h +++ b/firmware/feather_s3_tft/main/mnemonic.h @@ -5,3 +5,6 @@ int generate_mnemonic_12(char *out, size_t out_len); int mnemonic_to_seed(const char *mnemonic, uint8_t seed[64]); +int mnemonic_validate(const char *mnemonic); +size_t mnemonic_words_for_letter(char letter, size_t *out_start); +const char *mnemonic_word_at(size_t index); diff --git a/firmware/feather_s3_tft/main/secure_mem.c b/firmware/feather_s3_tft/main/secure_mem.c new file mode 100644 index 0000000..2b7f1b2 --- /dev/null +++ b/firmware/feather_s3_tft/main/secure_mem.c @@ -0,0 +1,15 @@ +#include "secure_mem.h" + +void secure_memzero(void *ptr, size_t len) +{ + volatile unsigned char *p = (volatile unsigned char *)ptr; + + if (p == 0) { + return; + } + + while (len > 0) { + *p++ = 0; + len--; + } +} diff --git a/firmware/feather_s3_tft/main/secure_mem.h b/firmware/feather_s3_tft/main/secure_mem.h new file mode 100644 index 0000000..f38f94c --- /dev/null +++ b/firmware/feather_s3_tft/main/secure_mem.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +void secure_memzero(void *ptr, size_t len); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/feather_s3_tft/main/ui.c b/firmware/feather_s3_tft/main/ui.c new file mode 100644 index 0000000..423c080 --- /dev/null +++ b/firmware/feather_s3_tft/main/ui.c @@ -0,0 +1,680 @@ +#include "ui.h" + +#include +#include +#include +#include + +#include "buttons.h" +#include "display.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "mnemonic.h" +#include "secure_mem.h" + +typedef enum { + UI_MAIN_GENERATE = 0, + UI_MAIN_ENTER, + UI_MAIN_VIEW, +} ui_main_menu_item_t; + +#define UI_CHAR_W_SMALL 6 +#define UI_CHAR_W_LARGE 12 + +typedef struct { + size_t count; + size_t first_idx[3]; + int exact_idx; +} prefix_matches_t; + +typedef struct { + char phrase[256]; + bool has_phrase; + + ui_main_menu_item_t main_sel; + + int letter_idx; /* 0..25 */ + char prefix[12]; + size_t prefix_len; + + char entered_words[12][12]; + size_t entered_count; +} ui_state_t; + +static btn_event_t wait_any_button(void) +{ + btn_event_t evt; + do { + evt = buttons_wait(0xFFFFFFFFu); + } while (evt.kind == BTN_EVT_NONE); + return evt; +} + +static void draw_splash(void) +{ + ESP_ERROR_CHECK(display_clear(RGB565_BLACK)); + ESP_ERROR_CHECK(display_draw_text_centered(60, "N_SIGNER", RGB565_RED, RGB565_BLACK)); +} + +static void draw_main_menu_row(bool has_phrase, int row_idx, bool selected) +{ + static const char *items[3] = {"Generate", "Enter", "View"}; + static const int ys[3] = {28, 52, 76}; + bool disabled = (row_idx == UI_MAIN_VIEW && !has_phrase); + char line[24]; + + snprintf(line, sizeof(line), "%s%s", selected ? "> " : " ", items[row_idx]); + ESP_ERROR_CHECK(display_fill_rect(24, ys[row_idx], 200, 16, RGB565_BLACK)); + ESP_ERROR_CHECK(display_draw_text( + 24, + ys[row_idx], + line, + disabled ? RGB565_DIM_GRAY : (selected ? RGB565_RED : RGB565_WHITE), + RGB565_BLACK)); +} + +static void draw_main_menu_full(const ui_state_t *st) +{ + ESP_ERROR_CHECK(display_clear(RGB565_BLACK)); + ESP_ERROR_CHECK(display_draw_text_centered(4, "MAIN MENU", RGB565_WHITE, RGB565_BLACK)); + + for (int i = 0; i < 3; ++i) { + draw_main_menu_row(st->has_phrase, i, ((int)st->main_sel == i)); + } + + ESP_ERROR_CHECK(display_draw_text_small(12, 120, "D2 up D1 dn D0 ok", RGB565_DIM_GRAY, RGB565_BLACK)); +} + +static void draw_main_menu_selection_change(bool has_phrase, int prev_sel, int new_sel) +{ + draw_main_menu_row(has_phrase, prev_sel, false); + draw_main_menu_row(has_phrase, new_sel, true); +} + +static void split_phrase_words(const char *phrase, char words[12][12]) +{ + char buf[256]; + char *tok; + size_t i = 0; + + memset(words, 0, 12 * 12); + + if (phrase == NULL) { + return; + } + + strncpy(buf, phrase, sizeof(buf) - 1); + buf[sizeof(buf) - 1] = '\0'; + + tok = strtok(buf, " \t\r\n"); + while (tok != NULL && i < 12) { + strncpy(words[i], tok, 11); + words[i][11] = '\0'; + i++; + tok = strtok(NULL, " \t\r\n"); + } +} + +static void draw_word_grid(const char words[12][12], int highlight_idx) +{ + const int col_w = 80; + const int row_h = 24; + + for (int i = 0; i < 12; ++i) { + int row = i / 3; + int col = i % 3; + int x = col * col_w + 2; + int y = 14 + row * row_h; + char line[20]; + + snprintf(line, sizeof(line), "%2d. %.11s", i + 1, words[i]); + ESP_ERROR_CHECK(display_draw_text_small( + x, + y, + line, + (i == highlight_idx) ? RGB565_RED : RGB565_WHITE, + RGB565_BLACK)); + } +} + +static void draw_view_page(const ui_state_t *st) +{ + char words[12][12]; + + split_phrase_words(st->phrase, words); + + ESP_ERROR_CHECK(display_clear(RGB565_BLACK)); + ESP_ERROR_CHECK(display_draw_text_small_centered(2, "YOUR MNEMONIC", RGB565_WHITE, RGB565_BLACK)); + draw_word_grid(words, -1); + ESP_ERROR_CHECK(display_draw_text_small_centered(120, "any key = back", RGB565_DIM_GRAY, RGB565_BLACK)); +} + +static void draw_generate_page(void) +{ + ESP_ERROR_CHECK(display_clear(RGB565_BLACK)); + ESP_ERROR_CHECK(display_draw_text_centered(56, "GENERATING...", RGB565_WHITE, RGB565_BLACK)); +} + +static void clear_enter_words(ui_state_t *st) +{ + secure_memzero(st->entered_words, sizeof(st->entered_words)); + st->entered_count = 0; + st->prefix_len = 0; + st->prefix[0] = '\0'; +} + +static void letter_cell_pos(int idx, int *x, int *y) +{ + int row = (idx < 13) ? 0 : 1; + int col = (idx < 13) ? idx : (idx - 13); + *x = 10 + col * 16; + *y = row ? 68 : 44; +} + +static void compute_allowed_next_letters(const ui_state_t *st, bool allowed[26]) +{ + for (int i = 0; i < 26; ++i) { + allowed[i] = false; + } + + if (st->prefix_len == 0) { + for (int i = 0; i < 26; ++i) { + allowed[i] = true; + } + return; + } + + for (size_t i = 0; i < 2048; ++i) { + const char *w = mnemonic_word_at(i); + size_t wlen; + char c; + int idx; + + if (w == NULL) { + continue; + } + if (strncmp(w, st->prefix, st->prefix_len) != 0) { + continue; + } + + wlen = strlen(w); + if (wlen <= st->prefix_len) { + continue; + } + + c = w[st->prefix_len]; + if (c < 'a' || c > 'z') { + continue; + } + + idx = (int)(c - 'a'); + allowed[idx] = true; + } +} + +static int find_next_allowed_letter(int current, int delta, const bool allowed[26]) +{ + int idx = current; + + for (int i = 0; i < 26; ++i) { + idx = (idx + delta + 26) % 26; + if (allowed[idx]) { + return idx; + } + } + + return current; +} + +static int first_allowed_letter(const bool allowed[26]) +{ + for (int i = 0; i < 26; ++i) { + if (allowed[i]) { + return i; + } + } + return 0; +} + +static void draw_enter_letter_cell(int idx, bool selected, const bool allowed[26]) +{ + int x; + int y; + char c[2] = {(char)('A' + idx), '\0'}; + uint16_t color = RGB565_WHITE; + + letter_cell_pos(idx, &x, &y); + ESP_ERROR_CHECK(display_fill_rect(x, y, 12, 16, RGB565_BLACK)); + + if (!allowed[idx]) { + color = RGB565_DIM_GRAY; + } + if (selected && allowed[idx]) { + color = RGB565_RED; + } + + ESP_ERROR_CHECK(display_draw_text(x, y, c, color, RGB565_BLACK)); +} + +static void draw_enter_letter_cursor_change(int prev_idx, int new_idx, const bool allowed[26]) +{ + draw_enter_letter_cell(prev_idx, false, allowed); + draw_enter_letter_cell(new_idx, true, allowed); +} + +static void compute_prefix_matches(const ui_state_t *st, prefix_matches_t *m) +{ + m->count = 0; + m->first_idx[0] = 0; + m->first_idx[1] = 0; + m->first_idx[2] = 0; + m->exact_idx = -1; + + if (st->prefix_len == 0) { + return; + } + + for (size_t i = 0; i < 2048; ++i) { + const char *w = mnemonic_word_at(i); + if (w == NULL) { + continue; + } + + if (strncmp(w, st->prefix, st->prefix_len) == 0) { + if (m->count < 3) { + m->first_idx[m->count] = i; + } + m->count++; + if (strcmp(w, st->prefix) == 0 && m->exact_idx < 0) { + m->exact_idx = (int)i; + } + } + } +} + +static void draw_enter_compact_static(const ui_state_t *st, const bool allowed[26]) +{ + char hdr[24]; + unsigned current_word = (unsigned)(st->entered_count + 1); + + ESP_ERROR_CHECK(display_clear(RGB565_BLACK)); + + if (current_word > 12) { + current_word = 12; + } + snprintf(hdr, sizeof(hdr), "WORD %u/12", current_word); + ESP_ERROR_CHECK(display_draw_text(10, 8, hdr, RGB565_WHITE, RGB565_BLACK)); + + for (int i = 0; i < 26; ++i) { + draw_enter_letter_cell(i, i == st->letter_idx, allowed); + } +} + +static int draw_word_with_prefix_large(int x, int y, const char *word, size_t prefix_len) +{ + char pref[12]; + char rest[12]; + size_t wlen; + size_t plen; + + if (word == NULL) { + return x; + } + + wlen = strlen(word); + plen = (prefix_len > wlen) ? wlen : prefix_len; + + memset(pref, 0, sizeof(pref)); + memset(rest, 0, sizeof(rest)); + + if (plen > 0) { + memcpy(pref, word, plen); + } + if (wlen > plen) { + memcpy(rest, word + plen, wlen - plen); + } + + ESP_ERROR_CHECK(display_draw_text(x, y, pref, RGB565_RED, RGB565_BLACK)); + ESP_ERROR_CHECK(display_draw_text(x + ((int)plen * UI_CHAR_W_LARGE), y, rest, RGB565_WHITE, RGB565_BLACK)); + + return x + ((int)wlen * UI_CHAR_W_LARGE); +} + +static void draw_match_row_large(const ui_state_t *st) +{ + int x = 4; + + for (size_t i = 0; i < 2048; ++i) { + const char *w = mnemonic_word_at(i); + size_t wlen; + int needed; + + if (w == NULL) { + continue; + } + if (strncmp(w, st->prefix, st->prefix_len) != 0) { + continue; + } + + wlen = strlen(w); + needed = (int)(wlen * UI_CHAR_W_LARGE) + UI_CHAR_W_LARGE; + if (x + needed > DISPLAY_WIDTH) { + break; + } + + x = draw_word_with_prefix_large(x, 88, w, st->prefix_len); + ESP_ERROR_CHECK(display_draw_text(x, 88, " ", RGB565_WHITE, RGB565_BLACK)); + x += UI_CHAR_W_LARGE; + } +} + +static void draw_enter_compact_dynamic(const ui_state_t *st, const prefix_matches_t *m) +{ + ESP_ERROR_CHECK(display_fill_rect(0, 86, DISPLAY_WIDTH, 49, RGB565_BLACK)); + + if (st->prefix_len == 0) { + ESP_ERROR_CHECK(display_draw_text_small(4, 118, "D0=letter D1h=back D2h=del", RGB565_DIM_GRAY, RGB565_BLACK)); + return; + } + + if (m->count == 0) { + ESP_ERROR_CHECK(display_draw_text_centered(88, "NO MATCH", RGB565_RED, RGB565_BLACK)); + } else { + draw_match_row_large(st); + } + + if (m->count == 1) { + ESP_ERROR_CHECK(display_draw_text_small(4, 108, "One word left: D0=YES D2=NO", RGB565_RED, RGB565_BLACK)); + } + + if (st->entered_count < 12) { + ESP_ERROR_CHECK(display_draw_text_small(4, 118, "D0=letter D1h=back D2h=del", RGB565_DIM_GRAY, RGB565_BLACK)); + } else { + ESP_ERROR_CHECK(display_draw_text_small(4, 118, "12/12: D0 hold=save D2 hold=del", RGB565_DIM_GRAY, RGB565_BLACK)); + } +} + +static int build_phrase_from_entered(ui_state_t *st) +{ + size_t cursor = 0; + + st->phrase[0] = '\0'; + for (size_t i = 0; i < st->entered_count; ++i) { + size_t wlen = strlen(st->entered_words[i]); + + if (i > 0) { + if (cursor + 1 >= sizeof(st->phrase)) { + return -1; + } + st->phrase[cursor++] = ' '; + } + + if (cursor + wlen >= sizeof(st->phrase)) { + return -1; + } + + memcpy(st->phrase + cursor, st->entered_words[i], wlen); + cursor += wlen; + st->phrase[cursor] = '\0'; + } + + return 0; +} + +int ui_run_until_working(char out_mnemonic[256]) +{ + ui_state_t st; + bool main_menu_drawn = false; + + if (out_mnemonic == NULL) { + return -1; + } + + memset(&st, 0, sizeof(st)); + st.main_sel = UI_MAIN_GENERATE; + + draw_splash(); + (void)wait_any_button(); + + while (1) { + btn_event_t evt; + + if (!main_menu_drawn) { + draw_main_menu_full(&st); + main_menu_drawn = true; + } + + evt = wait_any_button(); + + if (evt.id == BTN_D2) { + int prev = (int)st.main_sel; + if (st.has_phrase) { + st.main_sel = (st.main_sel == UI_MAIN_GENERATE) ? UI_MAIN_VIEW : (ui_main_menu_item_t)((int)st.main_sel - 1); + } else { + st.main_sel = (st.main_sel == UI_MAIN_GENERATE) ? UI_MAIN_ENTER : UI_MAIN_GENERATE; + } + if ((int)st.main_sel != prev) { + draw_main_menu_selection_change(st.has_phrase, prev, (int)st.main_sel); + } + continue; + } + + if (evt.id == BTN_D1) { + int prev = (int)st.main_sel; + if (st.has_phrase) { + st.main_sel = (st.main_sel == UI_MAIN_VIEW) ? UI_MAIN_GENERATE : (ui_main_menu_item_t)((int)st.main_sel + 1); + } else { + st.main_sel = (st.main_sel == UI_MAIN_ENTER) ? UI_MAIN_GENERATE : UI_MAIN_ENTER; + } + if ((int)st.main_sel != prev) { + draw_main_menu_selection_change(st.has_phrase, prev, (int)st.main_sel); + } + continue; + } + + if (evt.id != BTN_D0) { + continue; + } + + if (st.main_sel == UI_MAIN_VIEW) { + if (!st.has_phrase) { + continue; + } + + draw_view_page(&st); + (void)wait_any_button(); + main_menu_drawn = false; + continue; + } + + if (st.main_sel == UI_MAIN_GENERATE) { + draw_generate_page(); + if (generate_mnemonic_12(st.phrase, sizeof(st.phrase)) != 0) { + ESP_ERROR_CHECK(display_clear(RGB565_BLACK)); + ESP_ERROR_CHECK(display_draw_text_centered(56, "GEN FAILED", RGB565_RED, RGB565_BLACK)); + vTaskDelay(pdMS_TO_TICKS(2000)); + main_menu_drawn = false; + continue; + } + + st.has_phrase = true; + draw_view_page(&st); + (void)wait_any_button(); + + strncpy(out_mnemonic, st.phrase, 255); + out_mnemonic[255] = '\0'; + secure_memzero(&st, sizeof(st)); + return 0; + } + + clear_enter_words(&st); + st.letter_idx = 0; + + { + prefix_matches_t matches; + bool allowed_letters[26]; + + compute_prefix_matches(&st, &matches); + compute_allowed_next_letters(&st, allowed_letters); + st.letter_idx = first_allowed_letter(allowed_letters); + draw_enter_compact_static(&st, allowed_letters); + draw_enter_compact_dynamic(&st, &matches); + + while (1) { + evt = wait_any_button(); + + if (evt.id == BTN_D2 && evt.kind == BTN_EVT_PRESS) { + if (matches.count == 1 && st.prefix_len > 0) { + st.prefix_len = 0; + st.prefix[0] = '\0'; + compute_prefix_matches(&st, &matches); + draw_enter_compact_dynamic(&st, &matches); + } else { + int prev_idx = st.letter_idx; + st.letter_idx = find_next_allowed_letter(st.letter_idx, -1, allowed_letters); + draw_enter_letter_cursor_change(prev_idx, st.letter_idx, allowed_letters); + } + continue; + } + + if (evt.id == BTN_D1 && evt.kind == BTN_EVT_PRESS) { + int prev_idx = st.letter_idx; + st.letter_idx = find_next_allowed_letter(st.letter_idx, +1, allowed_letters); + draw_enter_letter_cursor_change(prev_idx, st.letter_idx, allowed_letters); + continue; + } + + if (evt.id == BTN_D1 && evt.kind == BTN_EVT_LONG_PRESS) { + if (st.prefix_len > 0) { + st.prefix_len--; + st.prefix[st.prefix_len] = '\0'; + } + compute_prefix_matches(&st, &matches); + compute_allowed_next_letters(&st, allowed_letters); + st.letter_idx = allowed_letters[st.letter_idx] ? st.letter_idx : first_allowed_letter(allowed_letters); + draw_enter_compact_static(&st, allowed_letters); + draw_enter_compact_dynamic(&st, &matches); + continue; + } + + if (evt.id == BTN_D2 && evt.kind == BTN_EVT_LONG_PRESS) { + if (st.prefix_len > 0) { + st.prefix_len = 0; + st.prefix[0] = '\0'; + } else if (st.entered_count > 0) { + secure_memzero(st.entered_words[st.entered_count - 1], sizeof(st.entered_words[0])); + st.entered_count--; + } + compute_prefix_matches(&st, &matches); + compute_allowed_next_letters(&st, allowed_letters); + st.letter_idx = allowed_letters[st.letter_idx] ? st.letter_idx : first_allowed_letter(allowed_letters); + draw_enter_compact_static(&st, allowed_letters); + draw_enter_compact_dynamic(&st, &matches); + continue; + } + + if (evt.id == BTN_D0 && evt.kind == BTN_EVT_PRESS) { + if (st.entered_count >= 12) { + if (build_phrase_from_entered(&st) == 0 && mnemonic_validate(st.phrase) == 0) { + draw_view_page(&st); + (void)wait_any_button(); + strncpy(out_mnemonic, st.phrase, 255); + out_mnemonic[255] = '\0'; + secure_memzero(&st, sizeof(st)); + return 0; + } + continue; + } + + if (matches.count == 1 && st.prefix_len > 0) { + const char *w = mnemonic_word_at(matches.first_idx[0]); + if (w != NULL) { + strncpy(st.entered_words[st.entered_count], w, 11); + st.entered_words[st.entered_count][11] = '\0'; + st.entered_count++; + } + st.prefix_len = 0; + st.prefix[0] = '\0'; + + if (st.entered_count == 12) { + if (build_phrase_from_entered(&st) != 0 || mnemonic_validate(st.phrase) != 0) { + ESP_ERROR_CHECK(display_clear(RGB565_BLACK)); + ESP_ERROR_CHECK(display_draw_text_centered(56, "BAD CHECKSUM", RGB565_RED, RGB565_BLACK)); + vTaskDelay(pdMS_TO_TICKS(1200)); + compute_prefix_matches(&st, &matches); + compute_allowed_next_letters(&st, allowed_letters); + st.letter_idx = allowed_letters[st.letter_idx] ? st.letter_idx : first_allowed_letter(allowed_letters); + draw_enter_compact_static(&st, allowed_letters); + draw_enter_compact_dynamic(&st, &matches); + continue; + } + + draw_view_page(&st); + (void)wait_any_button(); + strncpy(out_mnemonic, st.phrase, 255); + out_mnemonic[255] = '\0'; + secure_memzero(&st, sizeof(st)); + return 0; + } + + compute_prefix_matches(&st, &matches); + compute_allowed_next_letters(&st, allowed_letters); + st.letter_idx = first_allowed_letter(allowed_letters); + draw_enter_compact_static(&st, allowed_letters); + draw_enter_compact_dynamic(&st, &matches); + continue; + } + + if (st.prefix_len + 1 < sizeof(st.prefix)) { + st.prefix[st.prefix_len++] = (char)('a' + st.letter_idx); + st.prefix[st.prefix_len] = '\0'; + + compute_prefix_matches(&st, &matches); + if (matches.count == 0) { + st.prefix_len--; + st.prefix[st.prefix_len] = '\0'; + compute_prefix_matches(&st, &matches); + } + compute_allowed_next_letters(&st, allowed_letters); + st.letter_idx = allowed_letters[st.letter_idx] ? st.letter_idx : first_allowed_letter(allowed_letters); + draw_enter_compact_static(&st, allowed_letters); + draw_enter_compact_dynamic(&st, &matches); + } + continue; + } + + if (evt.id == BTN_D0 && evt.kind == BTN_EVT_LONG_PRESS) { + compute_prefix_matches(&st, &matches); + + if (st.entered_count == 12 && st.prefix_len == 0) { + if (build_phrase_from_entered(&st) != 0 || mnemonic_validate(st.phrase) != 0) { + ESP_ERROR_CHECK(display_clear(RGB565_BLACK)); + ESP_ERROR_CHECK(display_draw_text_centered(56, "BAD CHECKSUM", RGB565_RED, RGB565_BLACK)); + vTaskDelay(pdMS_TO_TICKS(1200)); + compute_prefix_matches(&st, &matches); + compute_allowed_next_letters(&st, allowed_letters); + st.letter_idx = allowed_letters[st.letter_idx] ? st.letter_idx : first_allowed_letter(allowed_letters); + draw_enter_compact_static(&st, allowed_letters); + draw_enter_compact_dynamic(&st, &matches); + continue; + } + + strncpy(out_mnemonic, st.phrase, 255); + out_mnemonic[255] = '\0'; + secure_memzero(&st, sizeof(st)); + return 0; + } + + if (st.prefix_len == 0) { + clear_enter_words(&st); + break; + } + + continue; + } + } + } + + main_menu_drawn = false; + } +} diff --git a/firmware/feather_s3_tft/main/ui.h b/firmware/feather_s3_tft/main/ui.h new file mode 100644 index 0000000..bb772e7 --- /dev/null +++ b/firmware/feather_s3_tft/main/ui.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +int ui_run_until_working(char out_mnemonic[256]); + +#ifdef __cplusplus +} +#endif diff --git a/flash_feather_s3_tft.sh b/flash_feather_s3_tft.sh new file mode 100755 index 0000000..dada3e2 --- /dev/null +++ b/flash_feather_s3_tft.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +PORT="${1:-/dev/ttyACM0}" +IDF_DIR="/home/user/esp/esp-idf" +FW_DIR="/home/user/lt/n_signer/firmware/feather_s3_tft" + +if [[ ! -f "${IDF_DIR}/export.sh" ]]; then + echo "ERROR: ESP-IDF export script not found at ${IDF_DIR}/export.sh" >&2 + exit 1 +fi + +if [[ ! -d "${FW_DIR}" ]]; then + echo "ERROR: Firmware directory not found at ${FW_DIR}" >&2 + exit 1 +fi + +if [[ ! -e "${PORT}" ]]; then + echo "WARNING: ${PORT} does not exist yet. If the board is in bootloader mode, check USB connection." >&2 +fi + +# shellcheck disable=SC1091 +. "${IDF_DIR}/export.sh" + +cd "${FW_DIR}" +idf.py -p "${PORT}" flash diff --git a/plans/feather_signer_ui.md b/plans/feather_signer_ui.md new file mode 100644 index 0000000..58d5ff4 --- /dev/null +++ b/plans/feather_signer_ui.md @@ -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 +``` diff --git a/src/main.c b/src/main.c index 63abca4..5c17c6f 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 28 -#define NSIGNER_VERSION "v0.0.28" +#define NSIGNER_VERSION_PATCH 29 +#define NSIGNER_VERSION "v0.0.29" /* NSIGNER_HEADERLESS_DECLS_END */