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
+
+
+
-
+
+
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 */