// Teensy 4.1 n_signer — main firmware entry. // // Phase 7 (final) of plans/teensy41_signer_implementation.md. // // This is the real, integrated signer firmware. It boots, shows a startup // menu (generate or enter mnemonic), derives the secp256k1 keypair, encodes // the npub, zeroizes the mnemonic text, initializes the dispatch + USB CDC // transport layers, shows the idle screen, and enters the signing loop where // it pumps LVGL and polls transport_read_frame() for incoming JSON-RPC // requests. // // Signer state (g_seed / g_privkey / g_pubkey / g_npub / g_pubkey_hex / // g_signer_ready / g_seed_len) is *defined* in src/dispatch.cpp (declared // extern in src/dispatch.h) so dispatch.cpp can read them directly. This // file only populates them after the startup menu applies the mnemonic. // // See plans/teensy41_signer_implementation.md for the full plan. // Debug aid: skip the startup menu and auto-generate a fresh random mnemonic // each boot. Mirrors CYD_DEBUG_AUTO_GENERATE in // firmware/cyd_esp32_2432s028/main/main.c. Set to 0 for the normal interactive // boot flow (show the startup menu, wait for the user to tap Generate/Enter). #define DEBUG_AUTO_GENERATE 0 #include #include #include // ---- Component headers (Arduino auto-discovers the .cpp files in src/, // but the .h files must be explicitly included here). ---- #include "src/mnemonic.h" #include "src/key_derivation.h" #include "src/bech32.h" #include "src/transport.h" #include "src/dispatch.h" #include "src/ui.h" #include "src/secure_mem.h" #include "src/otp_pad_sd.h" #include "src/nostr_core/nostr_common.h" // ---- Pin map (from firmware/teensy41/WIRING.md) ---- #define TFT_CS 5 #define TFT_RST 6 #define TFT_DC 7 #define TFT_BL 8 #define T_CS 9 #define T_IRQ 2 // NOT pin 10 (SPI0 CS0 conflict) #define SPI_MOSI 11 #define SPI_MISO 12 #define SPI_SCK 13 // ---- Display constants ---- #define SCREEN_W 480 #define SCREEN_H 320 ST7796_t3 tft = ST7796_t3(TFT_CS, TFT_DC, TFT_RST); // ---- LVGL draw buffer (in DMAMEM/RAM2 to save RAM1). // Reduced from /10 to /20 (23 KB each instead of 46 KB) to fit the 512 KB // RAM1 budget alongside the secp256k1 + PQClean code in ITCM. The flush // callback uses drawPixel (slow), so partial-render mode handles this fine. static const uint32_t LV_BUF_PIXELS = SCREEN_W * SCREEN_H / 20; DMAMEM static lv_color_t lv_buf1[LV_BUF_PIXELS]; DMAMEM static lv_color_t lv_buf2[LV_BUF_PIXELS]; // ---- Aesthetics colors (from ~/lt/aesthetics/WEB.md) ---- #define UI_BG 0x000000 // black #define UI_FG 0xFFFFFF // white #define UI_ACCENT 0xFF0000 // red #define UI_MUTED 0x888888 // grey // ---- Touch calibration (from firmware/teensy41/README.md) ---- struct TouchCal { int x_min, x_max, y_min, y_max; bool invert_x, invert_y; }; static TouchCal s_cal = { 207, 1909, 168, 1798, true, true }; #define XPT_X 0xD0 #define XPT_Y 0x90 #define XPT_Z1 0xB0 #define XPT_Z2 0xC0 #define T_SPI_SPEED 2000000 static uint16_t xpt_read(uint8_t ctrl) { SPI.beginTransaction(SPISettings(T_SPI_SPEED, MSBFIRST, SPI_MODE0)); digitalWrite(T_CS, LOW); SPI.transfer(ctrl); uint8_t hi = SPI.transfer(0x00); uint8_t lo = SPI.transfer(0x00); digitalWrite(T_CS, HIGH); SPI.endTransaction(); return (((uint16_t)hi << 8) | lo) >> 4; } static bool touch_read_raw(uint16_t *x, uint16_t *y, uint16_t *z) { if (digitalRead(T_IRQ) != 0) return false; uint32_t sx=0, sy=0, sz=0; int valid=0; for (int i=0; i<7; i++) { uint16_t z1=xpt_read(XPT_Z1), z2=xpt_read(XPT_Z2); uint16_t p = (z1>0 && z2>z1) ? (uint16_t)(z1+(4095-z2)) : 0; if (p < 80) continue; sx += xpt_read(XPT_X); sy += xpt_read(XPT_Y); sz += p; ++valid; } if (!valid) return false; *x=sx/valid; *y=sy/valid; *z=sz/valid; return true; } static int map_clamped(int v, int imn, int imx, int omn, int omx) { if (vimx) v=imx; int d=imx-imn; if (!d) return omn; return omn + (v-imn)*(omx-omn)/d; } static void raw_to_screen(uint16_t rx, uint16_t ry, int *sx, int *sy) { *sx = map_clamped((int)ry, s_cal.x_min, s_cal.x_max, s_cal.invert_x ? (SCREEN_W-1) : 0, s_cal.invert_x ? 0 : (SCREEN_W-1)); *sy = map_clamped((int)rx, s_cal.y_min, s_cal.y_max, s_cal.invert_y ? (SCREEN_H-1) : 0, s_cal.invert_y ? 0 : (SCREEN_H-1)); } // ---- LVGL callbacks ---- static void lvgl_flush_cb(lv_display_t *disp, const lv_area_t *area, uint8_t *px_map) { uint16_t w = area->x2 - area->x1 + 1; uint16_t h = area->y2 - area->y1 + 1; uint16_t *px = (uint16_t *)px_map; for (uint16_t y=0; yx1+x, area->y1+y, px[y*w+x]); lv_display_flush_ready(disp); } static void lvgl_touch_cb(lv_indev_t *indev, lv_indev_data_t *data) { uint16_t rx, ry, rz; if (touch_read_raw(&rx, &ry, &rz)) { int sx, sy; raw_to_screen(rx, ry, &sx, &sy); data->point.x = sx; data->point.y = sy; data->state = LV_INDEV_STATE_PRESSED; } else { data->state = LV_INDEV_STATE_RELEASED; } } // ---- Aesthetics: style helpers ---- static void style_screen(lv_obj_t *scr) { lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0); lv_obj_set_style_bg_color(scr, lv_color_hex(UI_BG), 0); } static void style_button(lv_obj_t *btn) { lv_obj_set_style_radius(btn, 6, 0); lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0); lv_obj_set_style_bg_color(btn, lv_color_hex(UI_BG), 0); lv_obj_set_style_border_width(btn, 2, 0); lv_obj_set_style_border_color(btn, lv_color_hex(UI_FG), 0); lv_obj_set_style_text_color(btn, lv_color_hex(UI_FG), 0); lv_obj_set_style_border_color(btn, lv_color_hex(UI_ACCENT), LV_STATE_PRESSED); lv_obj_set_style_text_color(btn, lv_color_hex(UI_ACCENT), LV_STATE_PRESSED); lv_obj_set_style_border_color(btn, lv_color_hex(UI_ACCENT), LV_STATE_FOCUSED); lv_obj_set_style_text_color(btn, lv_color_hex(UI_ACCENT), LV_STATE_FOCUSED); } // ---- Startup menu state ---- typedef enum { MODE_NONE, MODE_GENERATE, MODE_ENTER } signer_mode_t; static signer_mode_t s_mode = MODE_NONE; static volatile bool s_mode_selected = false; // Mnemonic working buffer (zeroized after seed derivation). static char s_mnemonic[256]; // ---- Startup menu ---- static void on_generate(lv_event_t *e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) { s_mode = MODE_GENERATE; s_mode_selected = true; Serial.println("Generate selected"); } } static void on_enter(lv_event_t *e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED) { s_mode = MODE_ENTER; s_mode_selected = true; Serial.println("Enter selected"); } } static void build_startup_menu() { lv_obj_t *scr = lv_screen_active(); lv_obj_clean(scr); style_screen(scr); // Title lv_obj_t *title = lv_label_create(scr); lv_label_set_text(title, "n_signer"); lv_obj_set_style_text_color(title, lv_color_hex(UI_FG), 0); lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0); lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 30); // Subtitle lv_obj_t *sub = lv_label_create(scr); lv_label_set_text(sub, "Hardware signer"); lv_obj_set_style_text_color(sub, lv_color_hex(UI_MUTED), 0); lv_obj_align(sub, LV_ALIGN_TOP_MID, 0, 60); // Generate button lv_obj_t *btn_gen = lv_button_create(scr); style_button(btn_gen); lv_obj_set_size(btn_gen, 360, 50); lv_obj_align(btn_gen, LV_ALIGN_CENTER, 0, -40); lv_obj_add_event_cb(btn_gen, on_generate, LV_EVENT_ALL, NULL); lv_obj_t *lbl_gen = lv_label_create(btn_gen); lv_label_set_text(lbl_gen, "Generate New Mnemonic"); lv_obj_center(lbl_gen); // Enter button lv_obj_t *btn_ent = lv_button_create(scr); style_button(btn_ent); lv_obj_set_size(btn_ent, 360, 50); lv_obj_align(btn_ent, LV_ALIGN_CENTER, 0, 30); lv_obj_add_event_cb(btn_ent, on_enter, LV_EVENT_ALL, NULL); lv_obj_t *lbl_ent = lv_label_create(btn_ent); lv_label_set_text(lbl_ent, "Enter Existing Mnemonic"); lv_obj_center(lbl_ent); // Footer lv_obj_t *footer = lv_label_create(scr); lv_label_set_text(footer, "v0.1.0-teensy41"); lv_obj_set_style_text_color(footer, lv_color_hex(UI_MUTED), 0); lv_obj_align(footer, LV_ALIGN_BOTTOM_MID, 0, -8); } // ---- "Busy" screen shown while deriving keys (so the user sees something // during the PBKDF2 + secp256k1 work, which takes a moment). ---- static void build_busy_screen(const char *msg) { lv_obj_t *scr = lv_screen_active(); lv_obj_clean(scr); style_screen(scr); lv_obj_t *lbl = lv_label_create(scr); lv_label_set_text(lbl, msg); lv_obj_set_style_text_color(lbl, lv_color_hex(UI_FG), 0); lv_obj_align(lbl, LV_ALIGN_CENTER, 0, 0); // Render at least one frame so the message is actually visible. lv_tick_inc(5); lv_timer_handler(); } // ---- Hex helper (lowercase) for the 32-byte pubkey -> g_pubkey_hex. ---- static void bytes_to_hex(const uint8_t *in, size_t n, char *out) { static const char hexdigits[] = "0123456789abcdef"; for (size_t i = 0; i < n; i++) { out[i * 2] = hexdigits[(in[i] >> 4) & 0xF]; out[i * 2 + 1] = hexdigits[in[i] & 0xF]; } out[n * 2] = '\0'; } // ---- Apply the mnemonic: derive seed + secp256k1 keys + npub, zeroize the // mnemonic text, and mark the signer ready. Returns 0 on success. ---- static int apply_mnemonic() { Serial.println("Deriving seed..."); if (mnemonic_to_seed(s_mnemonic, g_seed) != 0) { Serial.println("mnemonic_to_seed failed"); return -1; } g_seed_len = 64; // The OTP pad is bound from the SD card AFTER transport_init() (see below), // so that USB CDC is up before any SD I/O — a fault in the SD path can't // prevent the device from enumerating. Serial.println("Deriving secp256k1 keys..."); if (derive_secp256k1_keys(g_seed, g_seed_len, g_privkey, g_pubkey) != 0) { Serial.println("derive_secp256k1_keys failed"); return -1; } Serial.println("Encoding npub..."); if (npub_encode(g_pubkey, g_npub, sizeof(g_npub)) != 0) { Serial.println("npub_encode failed"); return -1; } bytes_to_hex(g_pubkey, 32, g_pubkey_hex); // The mnemonic text is no longer needed — wipe it. The seed stays in RAM // (zeroized on power-off) so we can derive per-algorithm keys on demand. secure_memzero(s_mnemonic, sizeof(s_mnemonic)); g_signer_ready = 1; Serial.print("Ready. npub: "); Serial.println(g_npub); return 0; } // ---- Boot flow: block on the startup menu, then run the generate/enter // subscreen, then apply the mnemonic. Returns 0 on success. ---- // // When DEBUG_AUTO_GENERATE is 1, the interactive startup menu is skipped: a // random 12-word mnemonic is generated immediately, the mnemonic display is // skipped, and the seed + keys are derived so the signer can enter the // signing loop without any user interaction. This is intended for testing. static int run_boot_flow() { #if DEBUG_AUTO_GENERATE Serial.println("DEBUG_AUTO_GENERATE=1: skipping startup menu."); Serial.println("Generating 12-word mnemonic..."); if (generate_mnemonic_12(s_mnemonic, sizeof(s_mnemonic)) != 0) { Serial.println("generate_mnemonic_12 failed"); return -1; } Serial.println("Mnemonic generated; skipping display (DEBUG_AUTO_GENERATE)."); // Skip ui_show_mnemonic — proceed straight to key derivation. #else // Pump LVGL until the user picks Generate or Enter. while (!s_mode_selected) { lv_tick_inc(5); lv_timer_handler(); delay(5); } s_mode_selected = false; if (s_mode == MODE_GENERATE) { Serial.println("Generating 12-word mnemonic..."); if (generate_mnemonic_12(s_mnemonic, sizeof(s_mnemonic)) != 0) { Serial.println("generate_mnemonic_12 failed"); return -1; } Serial.println("Mnemonic generated; showing to user."); // ui_show_mnemonic blocks (pumps LVGL) until the user taps to continue. ui_show_mnemonic(s_mnemonic); } else { Serial.println("Awaiting mnemonic entry on keyboard..."); // ui_enter_mnemonic blocks until 12 valid words + checksum, or cancel. if (ui_enter_mnemonic(s_mnemonic, sizeof(s_mnemonic)) != 0) { Serial.println("Mnemonic entry cancelled."); return -1; } } #endif build_busy_screen("Deriving keys..."); if (apply_mnemonic() != 0) { return -1; } return 0; } // ---- Crash diagnostics (debug) ---- // These run at the very start of setup(), before transport_init(), so Serial // output here does NOT corrupt the framed transport stream. They print the // CrashReport from any prior hard fault (fault type, PC, LR, SP) plus the // last-action marker so we can diagnose the get_public_key crash. // Persistent operation marker (DMAMEM survives soft reboot). Stamped before // each verb's crypto work so a post-crash reboot reports which op faulted. DMAMEM volatile uint32_t g_last_op = 0; DMAMEM volatile uint32_t g_last_op_seq = 0; DMAMEM volatile uint32_t g_stack_min = 0xFFFFFFFFu; DMAMEM volatile uint32_t g_heap_free_at_crash = 0; // ML-DSA-65 sign rejection-loop iteration counter. Written each iteration of // crypto_sign()'s rejection loop (mldsa65_sign.c) so a post-crash/hang reboot // reports how far the loop got without corrupting the framed transport. DMAMEM volatile uint32_t g_mldsa65_reject_count = 0; #define OP_ID_GET_INFO 1 #define OP_ID_GPK 2 #define OP_ID_SIGN 3 #define OP_ID_VERIFY 4 #define OP_ID_DERIVE_SHARED 5 #define OP_ID_DERIVE 6 #define OP_ID_NOSTR_GPK 7 #define OP_ID_NOSTR_SIGN 8 #define OP_ID_NOSTR_MINE 9 #define OP_ID_NIP04 10 #define OP_ID_NIP44 11 #define OP_ID_ENCAPS 12 #define OP_ID_DECAPS 13 #define OP_ID_OTP 14 extern char _estack; extern char _ebss; static uint32_t stack_free_bytes(void) { uint32_t sp; asm volatile ("mov %0, sp\n" : "=r"(sp)); uint32_t bot = (uint32_t)&_ebss; return (sp > bot) ? (sp - bot) : 0; } static uint32_t stack_high_water_free(void) { uint32_t top = (uint32_t)&_estack; return (g_stack_min <= top) ? (top - g_stack_min) : 0; } void setup() { Serial.begin(115200); while (!Serial && millis() < 3000) ; pinMode(LED_BUILTIN, OUTPUT); pinMode(TFT_BL, OUTPUT); digitalWrite(TFT_BL, HIGH); // Print any pending crash report from a prior fault (survives soft reboot). if (CrashReport) { Serial.print("CRASH REPORT FROM PRIOR FAULT:"); Serial.println(); Serial.print(CrashReport); Serial.println(); CrashReport.clear(); } // Report the last operation marker from before the crash. if (g_last_op != 0) { Serial.print("LAST OP BEFORE CRASH: id="); Serial.print((int)g_last_op); Serial.print(" seq="); Serial.print((int)g_last_op_seq); Serial.print(" stack_hw_free="); Serial.print((int)stack_high_water_free()); Serial.print(" heap_free_at_crash="); Serial.print((int)g_heap_free_at_crash); Serial.println(); } // Report the ML-DSA-65 sign rejection-loop count from before the crash/hang. if (g_mldsa65_reject_count > 0) { Serial.print("ML-DSA-65 LAST REJECT COUNT: "); Serial.println((int)g_mldsa65_reject_count); } Serial.println("n_signer booting..."); tft.init(320, 480); tft.setRotation(1); tft.fillScreen(0x0000); pinMode(T_CS, OUTPUT); digitalWrite(T_CS, HIGH); pinMode(T_IRQ, INPUT); lv_init(); lv_display_t *disp = lv_display_create(SCREEN_W, SCREEN_H); lv_display_set_buffers(disp, lv_buf1, lv_buf2, LV_BUF_PIXELS * sizeof(lv_color_t), LV_DISPLAY_RENDER_MODE_PARTIAL); lv_display_set_flush_cb(disp, lvgl_flush_cb); lv_indev_t *indev = lv_indev_create(); lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER); lv_indev_set_read_cb(indev, lvgl_touch_cb); build_startup_menu(); Serial.println("Startup menu shown."); // Run the boot flow (blocks until a mnemonic is loaded or entry cancelled). if (run_boot_flow() != 0) { // Entry cancelled or derivation failed — return to the menu and retry. Serial.println("Boot flow failed; returning to startup menu."); s_mode = MODE_NONE; build_startup_menu(); // Re-run the boot flow. On a second cancel this recurses via loop(); // to keep it simple we just loop here until we get a valid mnemonic. while (run_boot_flow() != 0) { s_mode = MODE_NONE; build_startup_menu(); } } // Initialize the nostr_core library (creates the secp256k1 context used by // NIP-04/NIP-44 ECDH, event signing, and event verification). if (nostr_init() != 0) { Serial.println("nostr_init failed!"); } else { Serial.println("nostr_core initialized."); } // Initialize the dispatch layer (nonce cache, etc). dispatch_init(); Serial.println("Dispatch initialized."); // Initialize the USB CDC transport. transport_init(); Serial.println("Transport initialized."); // Bind the OTP pad from the SD card. Give USB CDC a moment to enumerate // with the host first, so that even if the SD path faults, the device has // already appeared as /dev/ttyACM0 and we can see the boot output. delay(2000); Serial.println("Mounting SD card for OTP pad..."); if (otp_pad_sd_mount() != 0) { Serial.println("otp_pad_sd_mount failed — encrypt/decrypt unavailable"); } else { #if DEBUG_AUTO_GENERATE Serial.println("DEBUG_AUTO_GENERATE=1: auto-binding first SD pad..."); if (otp_pad_sd_bind_first() != 0) { Serial.println("otp_pad_sd_bind_first failed — no pad bound"); } #else // Interactive pad selection: scan /pads, show the list on the display, // let the user pick one (or skip). See ui_pick_pad() in ui.cpp. Serial.println("Scanning /pads for OTP pads..."); static char pad_chksums[4][65]; static uint64_t pad_sizes[4]; int n_pads = otp_pad_sd_list_pads(pad_chksums, pad_sizes, 4); if (n_pads <= 0) { Serial.println("No pads found in /pads — OTP encrypt/decrypt unavailable"); } else { Serial.print("Found "); Serial.print(n_pads); Serial.println(" pad(s):"); for (int i = 0; i < n_pads; i++) { Serial.print(" ["); Serial.print(i); Serial.print("] "); Serial.print(pad_chksums[i]); Serial.print(" ("); Serial.print((unsigned long)pad_sizes[i]); Serial.println(" bytes)"); } Serial.println("Showing selection screen..."); const char *chksum_ptrs[4]; for (int i = 0; i < n_pads; i++) chksum_ptrs[i] = pad_chksums[i]; char selected[65]; int rc = ui_pick_pad(chksum_ptrs, pad_sizes, n_pads, selected, sizeof(selected)); if (rc == 0) { Serial.print("User selected pad: "); Serial.println(selected); if (otp_pad_sd_bind(selected) != 0) { Serial.println("otp_pad_sd_bind failed for selected pad"); } } else { Serial.println("User skipped OTP pad selection (or timeout)"); } } #endif } Serial.println("OTP pad init complete."); // Show the idle screen with the npub + version. ui_show_idle(g_npub, "v0.1.0-teensy41"); Serial.println("Idle screen shown. Signer ready."); } // ---- Signing loop buffers (in DMAMEM/RAM2 to save RAM1) ---- // Increased for OTP encrypt/decrypt: a 4 KB chunk produces ~5.5 KB of ASCII // armor, and the base64-encoded request can be ~5.5 KB. With 110 KB of free // RAM2 heap, 8 KB + 8 KB is comfortable. DMAMEM static uint8_t req_buf[8192]; DMAMEM static char resp_buf[8192]; void loop() { // 1. Keep LVGL responsive (idle screen + any approval prompts). lv_tick_inc(5); lv_timer_handler(); // 2. Non-blocking poll for an incoming JSON-RPC request frame. int len = transport_read_frame(req_buf, sizeof(req_buf), 0); if (len > 0) { // NUL-terminate so dispatch can treat it as a string. if ((size_t)len < sizeof(req_buf)) { req_buf[len] = '\0'; } else { req_buf[sizeof(req_buf) - 1] = '\0'; } // 3. Dispatch the request -> JSON-RPC response. // dispatch_handle_request may call ui_approve() for signing verbs, // which runs its own LVGL loop until the user decides. int resp_len = dispatch_handle_request((const char *)req_buf, resp_buf, sizeof(resp_buf)); if (resp_len > 0) { // 4. Send the framed response back over USB CDC. // Do NOT use Serial.print here — it corrupts the framed transport // stream (debug text gets mixed with framed response bytes). transport_write_frame((const uint8_t *)resp_buf, resp_len); transport_flush(); } // Refresh the idle screen after handling a request (in case an approval // prompt replaced it). ui_show_idle does not block. ui_show_idle(g_npub, "v0.1.0-teensy41"); } delay(5); }