v0.0.29 - Feather firmware: auto-approve sign_event and always return explicit JSON-RPC errors for unhandled/oversized requests

This commit is contained in:
Laan Tungir
2026-05-11 13:43:14 -04:00
parent e4fa743654
commit bd23b674d6
19 changed files with 2514 additions and 222 deletions

View File

@@ -5,29 +5,233 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>n_signer Feather WebUSB Demo</title>
<style>
body { font-family: sans-serif; margin: 16px; max-width: 840px; }
button { margin-right: 8px; margin-bottom: 8px; }
pre { background: #111; color: #ddd; padding: 12px; border-radius: 6px; overflow: auto; }
:root {
--bg: #0b0f14;
--panel: #121821;
--panel-2: #182231;
--text: #e6edf3;
--muted: #9fb0c3;
--accent: #58a6ff;
--good: #3fb950;
--bad: #f85149;
--border: #263448;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.35;
}
.wrap {
max-width: 980px;
margin: 20px auto;
padding: 0 14px 24px;
}
h1 { margin: 0 0 8px; font-size: 1.45rem; }
p.note { margin: 0 0 14px; color: var(--muted); }
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 12px;
margin-top: 12px;
}
.card {
background: linear-gradient(180deg, var(--panel), var(--panel-2));
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px;
}
.card h2 {
font-size: 1rem;
margin: 0 0 10px;
}
label {
font-size: 0.86rem;
color: var(--muted);
display: block;
margin: 6px 0 4px;
}
input, textarea, button {
font: inherit;
border-radius: 8px;
border: 1px solid var(--border);
}
input, textarea {
width: 100%;
background: #0c131d;
color: var(--text);
padding: 8px 10px;
}
textarea { min-height: 84px; resize: vertical; }
input[type="number"] { max-width: 130px; }
button {
background: #1f6feb;
color: white;
padding: 8px 12px;
cursor: pointer;
border: 0;
}
button[disabled] {
opacity: 0.55;
cursor: not-allowed;
}
.secondary { background: #334155; }
.status {
padding: 6px 10px;
border-radius: 999px;
background: #2a3648;
color: var(--muted);
font-size: 0.85rem;
border: 1px solid var(--border);
}
.status.ok { color: var(--good); border-color: #2f5a3a; }
.status.err { color: var(--bad); border-color: #6a3131; }
pre {
margin: 8px 0 0;
background: #0a1018;
border: 1px solid #1b2636;
color: #d7e2ee;
padding: 10px;
border-radius: 8px;
overflow: auto;
max-height: 220px;
font-size: 12px;
}
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
</style>
</head>
<body>
<h1>n_signer Feather WebUSB Demo</h1>
<p>Connect to the ESP32-S3 WebUSB interface, send authenticated <code>get_public_key</code>, and display the result.</p>
<div class="wrap">
<h1>n_signer Feather WebUSB Demo</h1>
<p class="note">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.</p>
<button id="connectBtn">Connect</button>
<button id="pubkeyBtn" disabled>Get Public Key</button>
<div class="card">
<div class="row">
<button id="connectBtn">Connect WebUSB</button>
<span id="connStatus" class="status">Disconnected</span>
</div>
<pre id="log" class="mono"></pre>
</div>
<pre id="log"></pre>
<div class="grid">
<section class="card">
<h2>Public Key</h2>
<label for="keyIndex">Key index (nostr_index)</label>
<input id="keyIndex" type="text" inputmode="numeric" pattern="[0-9]*" value="0" />
<div class="row" style="margin-top:10px">
<button id="pubkeyBtn" disabled>Get Public Key</button>
</div>
<pre id="pubkeyOut" class="mono"></pre>
</section>
<section class="card">
<h2>Sign Kind 1 Event</h2>
<label for="kind1Content">Content</label>
<textarea id="kind1Content">hello from feather webusb demo</textarea>
<label for="kind1Tags">Tags JSON (array)</label>
<input id="kind1Tags" value="[]" />
<div class="row" style="margin-top:10px">
<button id="signKind1Btn" disabled>Create + Sign kind 1</button>
</div>
<pre id="signOut" class="mono"></pre>
</section>
<section class="card">
<h2>NIP-04 Encrypt</h2>
<label for="nip04Peer">Peer pubkey (hex, 32-byte x-only)</label>
<input id="nip04Peer" placeholder="e.g. 64 hex chars" />
<label for="nip04Msg">Plaintext</label>
<textarea id="nip04Msg">hello via nip04</textarea>
<div class="row" style="margin-top:10px">
<button id="nip04EncBtn" disabled>Encrypt (nip04_encrypt)</button>
</div>
<pre id="nip04Out" class="mono"></pre>
</section>
<section class="card">
<h2>NIP-04 Decrypt</h2>
<label for="nip04DecPeer">Peer pubkey (hex, 32-byte x-only)</label>
<input id="nip04DecPeer" placeholder="e.g. 64 hex chars" />
<label for="nip04Cipher">Ciphertext</label>
<textarea id="nip04Cipher" placeholder="ciphertext?iv=..."></textarea>
<div class="row" style="margin-top:10px">
<button id="nip04DecBtn" disabled>Decrypt (nip04_decrypt)</button>
</div>
<pre id="nip04DecOut" class="mono"></pre>
</section>
<section class="card">
<h2>NIP-44 Encrypt</h2>
<label for="nip44Peer">Peer pubkey (hex, 32-byte x-only)</label>
<input id="nip44Peer" placeholder="e.g. 64 hex chars" />
<label for="nip44Msg">Plaintext</label>
<textarea id="nip44Msg">hello via nip44</textarea>
<div class="row" style="margin-top:10px">
<button id="nip44EncBtn" disabled>Encrypt (nip44_encrypt)</button>
</div>
<pre id="nip44Out" class="mono"></pre>
</section>
<section class="card">
<h2>NIP-44 Decrypt</h2>
<label for="nip44DecPeer">Peer pubkey (hex, 32-byte x-only)</label>
<input id="nip44DecPeer" placeholder="e.g. 64 hex chars" />
<label for="nip44Cipher">Ciphertext</label>
<textarea id="nip44Cipher" placeholder="base64 payload"></textarea>
<div class="row" style="margin-top:10px">
<button id="nip44DecBtn" disabled>Decrypt (nip44_decrypt)</button>
</div>
<pre id="nip44DecOut" class="mono"></pre>
</section>
</div>
</div>
<script type="module">
import { schnorr } from "https://esm.sh/@noble/curves@1.5.0/secp256k1?bundle";
const logEl = document.getElementById("log");
const connStatusEl = document.getElementById("connStatus");
const connectBtn = document.getElementById("connectBtn");
const pubkeyBtn = document.getElementById("pubkeyBtn");
const signKind1Btn = document.getElementById("signKind1Btn");
const nip04EncBtn = document.getElementById("nip04EncBtn");
const nip04DecBtn = document.getElementById("nip04DecBtn");
const nip44EncBtn = document.getElementById("nip44EncBtn");
const nip44DecBtn = document.getElementById("nip44DecBtn");
const keyIndexEl = document.getElementById("keyIndex");
const pubkeyOutEl = document.getElementById("pubkeyOut");
const kind1ContentEl = document.getElementById("kind1Content");
const kind1TagsEl = document.getElementById("kind1Tags");
const signOutEl = document.getElementById("signOut");
const nip04PeerEl = document.getElementById("nip04Peer");
const nip04MsgEl = document.getElementById("nip04Msg");
const nip04OutEl = document.getElementById("nip04Out");
const nip04DecPeerEl = document.getElementById("nip04DecPeer");
const nip04CipherEl = document.getElementById("nip04Cipher");
const nip04DecOutEl = document.getElementById("nip04DecOut");
const nip44PeerEl = document.getElementById("nip44Peer");
const nip44MsgEl = document.getElementById("nip44Msg");
const nip44OutEl = document.getElementById("nip44Out");
const nip44DecPeerEl = document.getElementById("nip44DecPeer");
const nip44CipherEl = document.getElementById("nip44Cipher");
const nip44DecOutEl = document.getElementById("nip44DecOut");
let dev = null;
let iface = null;
let ownPubkey = "";
const EP_OUT = 1;
const EP_IN = 1;
@@ -36,6 +240,11 @@
logEl.scrollTop = logEl.scrollHeight;
}
function setStatus(text, mode = "") {
connStatusEl.textContent = text;
connStatusEl.className = `status ${mode}`.trim();
}
function hex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
}
@@ -44,18 +253,34 @@
return new TextEncoder().encode(s);
}
function be32(n) {
return new Uint8Array([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
}
async function sha256Hex(dataBytes) {
const h = await crypto.subtle.digest("SHA-256", dataBytes);
return hex(new Uint8Array(h));
}
function be32(n) {
return new Uint8Array([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
function getIndexOptions() {
const raw = Number.parseInt(String(keyIndexEl.value ?? "0"), 10);
const index = Number.isFinite(raw) && raw >= 0 ? raw : 0;
return { nostr_index: index };
}
function pretty(value) {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
async function buildAuth(method, params) {
// Demo caller key only (not secret in browser demo).
const callerPriv = Uint8Array.from({ length: 32 }, (_, i) => i + 1);
const callerPubX = hex(schnorr.getPublicKey(callerPriv));
const createdAt = Math.floor(Date.now() / 1000);
const paramsJson = JSON.stringify(params);
const bodyHash = await sha256Hex(utf8(paramsJson));
@@ -64,12 +289,22 @@
["nsigner_method", method],
["nsigner_body_hash", bodyHash],
];
const content = "webusb-demo";
const ser = JSON.stringify([0, callerPubX, createdAt, 27235, tags, content]);
const id = await sha256Hex(utf8(ser));
const sigBytes = await schnorr.sign(id, callerPriv, new Uint8Array(32));
const sigHex = typeof sigBytes === "string" ? sigBytes : hex(sigBytes);
return { id, pubkey: callerPubX, created_at: createdAt, kind: 27235, tags, content, sig: sigHex };
return {
id,
pubkey: callerPubX,
created_at: createdAt,
kind: 27235,
tags,
content,
sig: sigHex,
};
}
async function sendRpc(reqObj) {
@@ -83,9 +318,8 @@
let ring = new Uint8Array(0);
while (Date.now() < deadline) {
const r = await dev.transferIn(EP_IN, 512);
if (!r.data || r.data.byteLength === 0) {
continue;
}
if (!r.data || r.data.byteLength === 0) continue;
const chunk = new Uint8Array(r.data.buffer, r.data.byteOffset, r.data.byteLength);
const next = new Uint8Array(ring.length + chunk.length);
next.set(ring, 0);
@@ -98,9 +332,8 @@
ring = ring.slice(1);
continue;
}
if (ring.length < 4 + n) {
break;
}
if (ring.length < 4 + n) break;
const payload = ring.slice(4, 4 + n);
ring = ring.slice(4 + n);
const txt = new TextDecoder().decode(payload);
@@ -110,6 +343,45 @@
throw new Error("Timed out waiting for framed response");
}
async function rpcCall(method, params, id = "web-1") {
const auth = await buildAuth(method, params);
const req = { jsonrpc: "2.0", id, method, params, auth };
log("→", method, JSON.stringify(params));
const resp = await sendRpc(req);
log("←", method, JSON.stringify(resp));
return resp;
}
function requirePeerHex(peer) {
const v = String(peer || "").trim().toLowerCase();
if (!/^[0-9a-f]{64}$/.test(v)) {
throw new Error("Peer pubkey must be exactly 64 hex chars (x-only pubkey)");
}
return v;
}
function requireStringResult(resp, what) {
if (resp && typeof resp.result === "string") {
return resp.result;
}
throw new Error(`${what} failed: ${pretty(resp)}`);
}
async function fetchOwnPubkeyAndFillPeers() {
const params = [getIndexOptions()];
const resp = await rpcCall("get_public_key", params, "web-own-pubkey");
if (resp && typeof resp.result === "string") {
ownPubkey = resp.result.trim().toLowerCase();
pubkeyOutEl.textContent = pretty(resp);
if (/^[0-9a-f]{64}$/.test(ownPubkey)) {
nip04PeerEl.value = ownPubkey;
nip04DecPeerEl.value = ownPubkey;
nip44PeerEl.value = ownPubkey;
nip44DecPeerEl.value = ownPubkey;
}
}
}
async function connect() {
dev = await navigator.usb.requestDevice({ filters: [{ vendorId: 0x303a }] });
await dev.open();
@@ -121,6 +393,7 @@
i.alternates.some(a => a.interfaceClass === 0xff)
);
if (!intf) throw new Error("No vendor WebUSB interface found");
iface = intf.interfaceNumber;
await dev.claimInterface(iface);
const alt = intf.alternates.find(a => a.interfaceClass === 0xff);
@@ -135,26 +408,118 @@
});
pubkeyBtn.disabled = false;
signKind1Btn.disabled = false;
nip04EncBtn.disabled = false;
nip04DecBtn.disabled = false;
nip44EncBtn.disabled = false;
nip44DecBtn.disabled = false;
setStatus(`Connected (iface ${iface})`, "ok");
log("Connected. Interface", String(iface));
try {
await fetchOwnPubkeyAndFillPeers();
log("Default peer pubkeys set to selected signer pubkey");
} catch (e) {
log("Auto pubkey fetch failed:", String(e));
}
}
connectBtn.addEventListener("click", async () => {
try {
await connect();
} catch (e) {
setStatus("Connect failed", "err");
log("Connect failed:", String(e));
}
});
pubkeyBtn.addEventListener("click", async () => {
try {
const params = [];
const auth = await buildAuth("get_public_key", params);
const req = { jsonrpc: "2.0", id: "web-1", method: "get_public_key", params, auth };
const resp = await sendRpc(req);
log("Response:", JSON.stringify(resp));
await fetchOwnPubkeyAndFillPeers();
} catch (e) {
log("RPC failed:", String(e));
pubkeyOutEl.textContent = String(e);
}
});
signKind1Btn.addEventListener("click", async () => {
try {
let tags = [];
try {
tags = JSON.parse(kind1TagsEl.value || "[]");
if (!Array.isArray(tags)) throw new Error("tags must be an array");
} catch (e) {
throw new Error(`Invalid tags JSON: ${String(e)}`);
}
const unsignedEvent = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags,
content: String(kind1ContentEl.value || ""),
};
const params = [unsignedEvent, getIndexOptions()];
const resp = await rpcCall("sign_event", params, "web-sign-kind1");
signOutEl.textContent = pretty(resp?.result ?? resp);
} catch (e) {
signOutEl.textContent = String(e);
}
});
nip04EncBtn.addEventListener("click", async () => {
try {
const peer = requirePeerHex(nip04PeerEl.value);
const msg = String(nip04MsgEl.value || "");
const params = [peer, msg, getIndexOptions()];
const resp = await rpcCall("nip04_encrypt", params, "web-nip04-enc");
nip04OutEl.textContent = pretty(resp?.result ?? resp);
if (resp && typeof resp.result === "string") {
nip04DecPeerEl.value = peer;
nip04CipherEl.value = resp.result;
}
} catch (e) {
nip04OutEl.textContent = String(e);
}
});
nip04DecBtn.addEventListener("click", async () => {
try {
const peer = requirePeerHex(nip04DecPeerEl.value);
const ciphertext = String(nip04CipherEl.value || "");
const params = [peer, ciphertext, getIndexOptions()];
const resp = await rpcCall("nip04_decrypt", params, "web-nip04-dec");
nip04DecOutEl.textContent = requireStringResult(resp, "NIP-04 decrypt");
} catch (e) {
nip04DecOutEl.textContent = String(e);
}
});
nip44EncBtn.addEventListener("click", async () => {
try {
const peer = requirePeerHex(nip44PeerEl.value);
const msg = String(nip44MsgEl.value || "");
const params = [peer, msg, getIndexOptions()];
const resp = await rpcCall("nip44_encrypt", params, "web-nip44-enc");
nip44OutEl.textContent = pretty(resp?.result ?? resp);
if (resp && typeof resp.result === "string") {
nip44DecPeerEl.value = peer;
nip44CipherEl.value = resp.result;
}
} catch (e) {
nip44OutEl.textContent = String(e);
}
});
nip44DecBtn.addEventListener("click", async () => {
try {
const peer = requirePeerHex(nip44DecPeerEl.value);
const ciphertext = String(nip44CipherEl.value || "");
const params = [peer, ciphertext, getIndexOptions()];
const resp = await rpcCall("nip44_decrypt", params, "web-nip44-dec");
nip44DecOutEl.textContent = requireStringResult(resp, "NIP-44 decrypt");
} catch (e) {
nip44DecOutEl.textContent = String(e);
}
});
</script>

View File

@@ -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

View File

@@ -0,0 +1,155 @@
#include "buttons.h"
#include <stdbool.h>
#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;
}

View File

@@ -0,0 +1,33 @@
#pragma once
#include <stdint.h>
#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

View File

@@ -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) {

View File

@@ -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

View File

@@ -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;

View File

@@ -3,4 +3,5 @@
#include <stdint.h>
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]);

View File

@@ -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();
}

View File

@@ -4,6 +4,7 @@
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <ctype.h>
#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;

View File

@@ -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);

View File

@@ -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--;
}
}

View File

@@ -0,0 +1,13 @@
#pragma once
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
void secure_memzero(void *ptr, size_t len);
#ifdef __cplusplus
}
#endif

View File

@@ -0,0 +1,680 @@
#include "ui.h"
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#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;
}
}

View File

@@ -0,0 +1,13 @@
#pragma once
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
int ui_run_until_working(char out_mnemonic[256]);
#ifdef __cplusplus
}
#endif

26
flash_feather_s3_tft.sh Executable file
View File

@@ -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

434
plans/feather_signer_ui.md Normal file
View File

@@ -0,0 +1,434 @@
# Plan: Feather S3 signer interactive UI (splash, main menu, generate, view, enter, working)
Status: ready to implement.
Related firmware: [`firmware/feather_s3_tft/main/main.c`](../firmware/feather_s3_tft/main/main.c:1), [`firmware/feather_s3_tft/main/display.c`](../firmware/feather_s3_tft/main/display.c:1), [`firmware/feather_s3_tft/main/display.h`](../firmware/feather_s3_tft/main/display.h:1), [`firmware/feather_s3_tft/main/mnemonic.c`](../firmware/feather_s3_tft/main/mnemonic.c:1), [`firmware/feather_s3_tft/main/mnemonic_wordlist.h`](../firmware/feather_s3_tft/main/mnemonic_wordlist.h:1), [`firmware/feather_s3_tft/main/key_derivation.c`](../firmware/feather_s3_tft/main/key_derivation.c:1).
Companion: persistence will be added in a follow-up plan (option 3 from design discussion: PIN-derived encryption of NVS-stored seed).
## 0. Color palette (global)
Strict 4-color palette for ALL screens (no other colors used):
| Color | Constant | Use |
|---|---|---|
| Black | `RGB565_BLACK` | Background everywhere |
| White | `RGB565_WHITE` | Default text / active labels |
| Red | `RGB565_RED` | Selection / focus / important call-outs (e.g., `N_SIGNER` title) |
| Dim gray | `RGB565_DIM_GRAY` (`0x4208`) | Disabled / unselectable items, hints, footers |
No yellow, cyan, green, magenta, blue. Existing screens (`show_idle_screen`, `show_approval_prompt_with_caller`) get retro-fitted to this palette in §5.4. The `RGB565_*` constants for unused colors stay defined in [`display.h`](../firmware/feather_s3_tft/main/display.h:14) (harmless) but the new code never references them.
Selection convention:
- **Selected** menu item: red text on black background (no inverse-video fill needed in the 4-color scheme; red is bright enough as the focus color). A leading `> ` prefix is also drawn for redundancy.
- **Non-selected** items: white on black.
- **Disabled** items (e.g., `View` when no mnemonic is loaded): dim gray on black.
- **Footers / hint text**: dim gray on black.
## 1. Goal
Replace the current "generate-random-mnemonic-on-boot, immediately go to working screen" proof of concept with a navigable on-device UI that lets the operator:
1. See a splash screen at power-on.
2. Choose at the main menu between **Generate** a fresh mnemonic, **Enter** an existing mnemonic, or **View** the currently loaded mnemonic.
3. After Generate or successful Enter, transition into the existing **Working Page** (sign / approve flow that already exists today).
4. Persistence is **out of scope** for this plan — every boot starts at the splash, mnemonic lives only in RAM. A follow-up plan will add PIN-protected NVS storage.
## 2. Hardware / display constraints
From the existing firmware ([`display.c`](../firmware/feather_s3_tft/main/display.c:38)):
- Panel: 240×135, ST7789, landscape.
- Font cell at `CHAR_SCALE = 2`: `CHAR_W = 12`, `CHAR_H = 16`.
- Usable text grid: **20 columns × 8 rows** (with ~0px margin) or **19 columns × 6 rows** with the comfortable insets the user noted. Plan assumes 19×6 so we have ≥4px breathing room on every edge.
- 3 buttons (current physical mapping in [`main.c`](../firmware/feather_s3_tft/main/main.c:31)):
- `D0` = GPIO0, active **LOW** (boot button, internal pull-up)
- `D1` = GPIO1, active **HIGH** (external pull-down)
- `D2` = GPIO2, active **HIGH** (external pull-down)
### 2.1 New menu-button semantics
| Button | Role in new UI | Existing role (working page) |
|---|---|---|
| `D2` | **Up** (scroll up) | "always allow" approval |
| `D1` | **Down** (scroll down) | "approve once" |
| `D0` | **Enter / select** | "deny" |
The working-page button semantics intentionally **stay the same** as today (D0=deny, D1=approve once, D2=always). Only menu screens reinterpret them as Up/Down/Enter. The button-handling code is split into two layers (see §5.2) so the working-page approval flow does not have to change behavior.
## 3. Screen flow
```mermaid
flowchart TD
A[Power on] --> B[Splash: N_SIGNER]
B -- D0 or D1 or D2 --> C[Main Menu]
C -- Generate selected --> D[Generate page: random 12 words]
D -- auto --> E[View page]
C -- Enter selected --> F[Enter page: pick letter]
F -- letter chosen --> G[Pick word starting with letter]
G -- word chosen --> H[Phrase preview: continue or delete]
H -- continue and len 12 --> I[Save prompt]
H -- continue and len lt 12 --> F
H -- delete --> H2[Drop last word]
H2 --> H
I -- save --> W[Working page]
I -- back --> C
C -- View selected --> V[View page read-only]
V -- any button --> W
E -- any button --> W
W -. menu hotkey not in scope .-> C
```
Notes:
- From the **Working page** there is currently no "back to menu" hotkey. That is **out of scope** for this plan; once entered, the working page is the run-mode terminus until reboot. Adding a long-press-to-menu is left as a tracked TODO at the end of this doc.
- The View page is reachable from two places: post-Generate (auto), and from the main menu (read-only). Both behave the same — "any button returns to caller". Caller is tracked so that View-from-Generate goes to Working, and View-from-Menu returns to Menu.
## 4. Per-screen specification
All screens use `display_clear(RGB565_BLACK)` as the base, then draw text with the existing 12×16 cell. Coordinates below are pixel offsets from top-left.
### 4.1 Splash screen
- Centered horizontally and vertically: the literal string `N_SIGNER`, color `RGB565_RED`, on black background.
- 8 chars × 12px = 96px wide → x = (24096)/2 = 72.
- 1 row × 16px → y = (13516)/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 800836 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 8521004) 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
```

View File

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