472 lines
16 KiB
Arduino
472 lines
16 KiB
Arduino
// 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 1
|
|
|
|
#include <lvgl.h>
|
|
#include <ST7796_t3.h>
|
|
#include <SPI.h>
|
|
|
|
// ---- 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.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 (v<imn) v=imn; if (v>imx) 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; y<h; y++)
|
|
for (uint16_t x=0; x<w; x++)
|
|
tft.drawPixel(area->x1+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;
|
|
|
|
// Initialize the OTP pad from the seed so encrypt/decrypt verbs work.
|
|
Serial.println("Initializing OTP pad...");
|
|
if (otp_pad_init(g_seed, g_seed_len) != 0) {
|
|
Serial.println("otp_pad_init failed");
|
|
return -1;
|
|
}
|
|
|
|
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.
|
|
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();
|
|
}
|
|
|
|
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.");
|
|
|
|
// 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) ----
|
|
DMAMEM static uint8_t req_buf[2048];
|
|
DMAMEM static char resp_buf[4096];
|
|
|
|
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);
|
|
}
|