diff --git a/firmware/teensy41/signer/signer.ino b/firmware/teensy41/signer/signer.ino index b2e200f..d2f9102 100644 --- a/firmware/teensy41/signer/signer.ino +++ b/firmware/teensy41/signer/signer.ino @@ -478,6 +478,48 @@ void setup() { dispatch_init(); Serial.println("Dispatch initialized."); + // ---- Role table (Phase 6 of teensy41_role_path_migration.md) ---- + // The role table (g_roles, defined in dispatch.cpp as DMAMEM) must be + // populated before any nostr_* verb can be dispatched. In interactive mode + // the user picks presets via the LVGL wizard; in DEBUG_AUTO_GENERATE mode + // a single "main" role is auto-created so headless tests work. + role_table_init(&g_roles); +#if DEBUG_AUTO_GENERATE + { + Serial.println("DEBUG_AUTO_GENERATE=1: auto-creating 'main' role."); + role_entry_t entry; + memset(&entry, 0, sizeof(entry)); + strncpy(entry.name, "main", sizeof(entry.name) - 1); + strncpy(entry.role_path, "m/44'/1237'/0'/0/0", sizeof(entry.role_path) - 1); + entry.purpose = ROLE_PURPOSE_NOSTR; + entry.curve = ROLE_CURVE_SECP256K1; + entry.path_range_lo = -1; + entry.path_range_hi = -1; + entry.path_default_index = -1; + entry.requires_approval = 0; /* role-as-password */ + if (role_table_add(&g_roles, &entry) != 0) { + Serial.println("WARNING: failed to auto-create 'main' role"); + } else { + Serial.print("Auto-created role 'main' ("); + Serial.print(entry.role_path); + Serial.println(")"); + } + } +#else + { + Serial.println("Starting role wizard..."); + if (ui_role_wizard(&g_roles) != 0) { + Serial.println("Role wizard cancelled or failed — aborting boot."); + // Show an error screen and halt. + build_busy_screen("No roles defined.\nReboot to try again."); + while (1) { /* halt */ } + } + Serial.print("Role wizard complete: "); + Serial.print(g_roles.count); + Serial.println(" role(s) defined."); + } +#endif + // Initialize the USB CDC transport. transport_init(); Serial.println("Transport initialized."); diff --git a/firmware/teensy41/signer/src/dispatch.cpp b/firmware/teensy41/signer/src/dispatch.cpp index 907e2b4..ab87dfe 100644 --- a/firmware/teensy41/signer/src/dispatch.cpp +++ b/firmware/teensy41/signer/src/dispatch.cpp @@ -41,6 +41,8 @@ #include "otp_pad_sd.h" #include "key_derivation.h" #include "ed25519.h" +#include "role_table.h" +#include "selector.h" #include "secp256k1/include/secp256k1.h" #include "secp256k1/include/secp256k1_extrakeys.h" @@ -70,6 +72,11 @@ char g_npub[128]; char g_pubkey_hex[65]; int g_signer_ready = 0; +/* ---- Role table (populated by signer.ino after the role wizard) ---- + * In DMAMEM (RAM2) to keep RAM1 free for ITCM code. 16 entries × ~240 bytes + * = ~3.8 KB, negligible against the 110 KB free heap. */ +DMAMEM role_table_t g_roles; + /* ---- Persistent crash diagnostics (defined in signer.ino, DMAMEM) ---- */ extern "C" volatile uint32_t g_last_op; extern "C" volatile uint32_t g_last_op_seq; @@ -146,6 +153,13 @@ typedef enum { #define ERR_APPROVAL_TIMEOUT -32001 #define ERR_ALG_NOT_SUPPORTED 1010 #define ERR_MINING_FAILED 1008 +/* Role + path authorization errors (match the host's error codes). */ +#define ERR_UNKNOWN_ROLE 1002 +#define ERR_PATH_NOT_ALLOWED 2003 +#define ERR_NOSTR_INDEX_DEPRECATED 2006 +#define ERR_ROLE_REQUIRED 2007 +#define ERR_PATH_REQUIRED 2008 +#define ERR_NO_DEFAULT_ROLE 2009 /* Auth envelope error messages (indexed by AUTH_ERR_* code). */ static const char *auth_err_message(int code) { @@ -920,15 +934,237 @@ __attribute__((section(".flashmem"))) static int derive_request_key(uint32_t nos return 0; } -/* Parse [peer_hex, message, {options}] from params. */ +/* ==================================================================== + * Role + path selector helpers (Phase 4 of teensy41_role_path_migration.md) + * ==================================================================== */ + +/* Compile-time flag: when 1, nostr_index is silently accepted (mapped to the + * default role's path) so the old test_signer.py can run during migration. + * When 0 (the default), nostr_index is rejected with error 2006, matching + * the host. Flip to 1 only for the transition period. */ +#ifndef ALLOW_DEPRECATED_NOSTR_INDEX +#define ALLOW_DEPRECATED_NOSTR_INDEX 0 +#endif + +/* Parse the selector fields (role, role_path, nostr_index) from the trailing + * options object of a params array. Returns 0 on success (fields left at + * their defaults if absent). Returns -1 if the params shape is invalid. */ +__attribute__((section(".flashmem"))) static int parse_selector_from_params(cJSON *params, + selector_request_t *out) { + int n; + cJSON *last, *item; + + if (out == NULL) { + return -1; + } + selector_request_init(out); + + if (params == NULL || !cJSON_IsArray(params)) { + return 0; /* no options → empty selector (will use default role) */ + } + n = cJSON_GetArraySize(params); + if (n <= 0) { + return 0; + } + last = cJSON_GetArrayItem(params, n - 1); + if (last == NULL || !cJSON_IsObject(last)) { + return 0; /* no options object → empty selector */ + } + + item = cJSON_GetObjectItemCaseSensitive(last, "role"); + if (cJSON_IsString(item) && item->valuestring != NULL) { + out->has_role = 1; + strncpy(out->role_name, item->valuestring, sizeof(out->role_name) - 1); + out->role_name[sizeof(out->role_name) - 1] = '\0'; + } + + item = cJSON_GetObjectItemCaseSensitive(last, "role_path"); + if (cJSON_IsString(item) && item->valuestring != NULL) { + out->has_role_path = 1; + strncpy(out->role_path, item->valuestring, sizeof(out->role_path) - 1); + out->role_path[sizeof(out->role_path) - 1] = '\0'; + } + + item = cJSON_GetObjectItemCaseSensitive(last, "nostr_index"); + if (cJSON_IsNumber(item)) { + int idx = item->valueint; + if (idx >= 0) { + out->has_nostr_index = 1; + out->nostr_index = (uint32_t)idx; + } else { + return -1; + } + } + + return 0; +} + +/* Map a selector_resolve() error code to a wire-protocol error code + message. + * Writes the error response into s_response_buf. */ +__attribute__((section(".flashmem"))) static void selector_err_to_response(const char *id_token, int sel_err) { + switch (sel_err) { + case SELECTOR_ERR_NOSTR_INDEX_DEPRECATED: + set_error_code(id_token, ERR_NOSTR_INDEX_DEPRECATED, + "nostr_index is deprecated - use role + role_path " + "(e.g. role=main, role_path=m/44'1237'0'/0/0)"); + break; + case SELECTOR_ERR_NOT_FOUND: + set_error_code(id_token, ERR_UNKNOWN_ROLE, "unknown_role"); + break; + case SELECTOR_ERR_PATH_MISMATCH: + set_error_code(id_token, ERR_PATH_NOT_ALLOWED, "path_not_allowed"); + break; + case SELECTOR_ERR_ROLE_REQUIRED: + set_error_code(id_token, ERR_ROLE_REQUIRED, "role_required"); + break; + case SELECTOR_ERR_PATH_REQUIRED: + set_error_code(id_token, ERR_PATH_REQUIRED, "path_required"); + break; + case SELECTOR_ERR_NO_DEFAULT: + set_error_code(id_token, ERR_NO_DEFAULT_ROLE, "no_default_role"); + break; + default: + set_error_code(id_token, ERR_INVALID_PARAMS, "invalid params"); + break; + } +} + +/* Resolve a nostr_* request's selector and derive the secp256k1 keypair from + * the resolved path. Replaces the old parse_nostr_index_from_params + + * derive_request_key pattern. + * + * On success: fills privkey_out[32] and pubkey_hex_out[65], returns 0. + * On failure: writes the error response into s_response_buf and returns -1. + * The caller should `return;` immediately on a -1 return. + * + * `out_role` (if non-NULL) receives the resolved role entry pointer so the + * caller can check role->requires_approval. */ +__attribute__((section(".flashmem"))) static int resolve_nostr_request_key(cJSON *params, + const char *id_token, + uint8_t privkey_out[32], + char pubkey_hex_out[65], + role_entry_t **out_role) { + selector_request_t req; + role_entry_t *role = NULL; + int sel_rc; + const char *path_to_derive = NULL; + char concrete_path[ROLE_PATH_MAX]; + uint8_t pubkey[32]; + + if (privkey_out == NULL || pubkey_hex_out == NULL || id_token == NULL) { + return -1; + } + if (out_role != NULL) { + *out_role = NULL; + } + + memset(privkey_out, 0, 32); + memset(pubkey_hex_out, 0, 65); + memset(concrete_path, 0, sizeof(concrete_path)); + + if (g_seed_len == 0) { + set_error_code(id_token, ERR_INTERNAL, "signer not ready (no mnemonic)"); + return -1; + } + + if (parse_selector_from_params(params, &req) != 0) { + set_error_code(id_token, ERR_INVALID_PARAMS, "invalid params"); + return -1; + } + +#if ALLOW_DEPRECATED_NOSTR_INDEX + /* Migration shim: if nostr_index is present, map it to the default role + * with the NIP-06 path m/44'/1237'/0'/0/. This lets the old + * test_signer.py run during the transition. */ + if (req.has_nostr_index && !req.has_role && !req.has_role_path) { + snprintf(concrete_path, sizeof(concrete_path), + "m/44'/1237'/0'/0/%u", req.nostr_index); + path_to_derive = concrete_path; + /* Skip selector_resolve — derive directly from the constructed path. */ + if (derive_secp256k1_from_path(g_seed, g_seed_len, path_to_derive, + privkey_out, pubkey) != 0) { + set_error_code(id_token, ERR_INTERNAL, "key derivation failed"); + return -1; + } + bytes_to_hex(pubkey, 32, pubkey_hex_out, 65); + secure_memzero(pubkey, sizeof(pubkey)); + /* No role → default to requires_approval=0 (role-as-password). */ + if (out_role != NULL) { + role = role_table_get_default(&g_roles); + *out_role = role; /* may be NULL if no roles configured */ + } + return 0; + } +#endif + + sel_rc = selector_resolve(&req, &g_roles, &role); + if (sel_rc != SELECTOR_OK) { + selector_err_to_response(id_token, sel_rc); + return -1; + } + + /* Determine the concrete path to derive from. */ + if (req.has_role_path) { + /* Client supplied a concrete path that selector_resolve verified + * matches the role's template + range. Use it directly. */ + path_to_derive = req.role_path; + } else if (strstr(role->role_path, "%d") == NULL) { + /* Fixed path (no %d) — use the role's path. */ + path_to_derive = role->role_path; + } else { + /* Template path with no client-supplied path — use the default index. */ + int idx = role->path_default_index; + if (idx < 0) { + set_error_code(id_token, ERR_PATH_REQUIRED, "path_required"); + return -1; + } + { + const char *pct = strstr(role->role_path, "%d"); + size_t prefix_len = (size_t)(pct - role->role_path); + const char *tail = pct + 2; /* skip "%d" */ + snprintf(concrete_path, sizeof(concrete_path), "%.*s%d%s", + (int)prefix_len, role->role_path, idx, tail); + } + path_to_derive = concrete_path; + } + + if (derive_secp256k1_from_path(g_seed, g_seed_len, path_to_derive, + privkey_out, pubkey) != 0) { + set_error_code(id_token, ERR_INTERNAL, "key derivation failed"); + secure_memzero(pubkey, sizeof(pubkey)); + return -1; + } + bytes_to_hex(pubkey, 32, pubkey_hex_out, 65); + secure_memzero(pubkey, sizeof(pubkey)); + + if (out_role != NULL) { + *out_role = role; + } + return 0; +} + +/* Check whether a nostr_* verb requires interactive approval, given the + * resolved role. Returns 1 if the prompt should be shown, 0 if role-as- + * password authorizes immediately. When no role is resolved (NULL), defaults + * to requiring approval (fail-safe). */ +__attribute__((section(".flashmem"))) static int nostr_role_requires_approval(const role_entry_t *role) { + if (role == NULL) { + return 1; /* fail-safe: prompt if no role */ + } + return role->requires_approval ? 1 : 0; +} + +/* Parse [peer_hex, message, {options}] from params. The options object is + * parsed into the selector_request_t (role + role_path) for the caller to + * resolve via resolve_nostr_request_key(). */ __attribute__((section(".flashmem"))) static int parse_peer_and_message_params(cJSON *params, const char **peer_hex_out, const char **message_out, - uint32_t *nostr_index_out) { + selector_request_t *sel_out) { cJSON *peer_item, *msg_item; if (params == NULL || !cJSON_IsArray(params) || - peer_hex_out == NULL || message_out == NULL || nostr_index_out == NULL) { + peer_hex_out == NULL || message_out == NULL || sel_out == NULL) { return -1; } if (cJSON_GetArraySize(params) < 2) { @@ -940,7 +1176,7 @@ __attribute__((section(".flashmem"))) static int parse_peer_and_message_params(c !cJSON_IsString(msg_item) || msg_item->valuestring == NULL) { return -1; } - if (parse_nostr_index_from_params(params, nostr_index_out) != 0) { + if (parse_selector_from_params(params, sel_out) != 0) { return -1; } *peer_hex_out = peer_item->valuestring; @@ -1148,6 +1384,10 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con cJSON_AddItemToObject(obj, "algorithms", algs); } } + /* Report the configured role count so clients know the signer is + * role-aware. The role names are not exposed (they act as + * passwords); only the count is reported. */ + cJSON_AddNumberToObject(obj, "roles", g_roles.count); out = cJSON_PrintUnformatted(obj); cJSON_Delete(obj); if (out == NULL) { @@ -1956,20 +2196,23 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con /* ---- nostr_get_public_key ---- */ if (strcmp(method, VERB_NOSTR_GET_PUBLIC_KEY) == 0) { - uint32_t nostr_index = 0; uint8_t req_privkey[32]; cJSON *options = NULL; const char *fmt = NULL; + role_entry_t *role = NULL; memset(req_privkey, 0, sizeof(req_privkey)); memset(s_nostr_pubkey_hex, 0, sizeof(s_nostr_pubkey_hex)); - if (!cJSON_IsArray(params) || - parse_nostr_index_from_params(params, &nostr_index) != 0 || - derive_request_key(nostr_index, req_privkey, s_nostr_pubkey_hex) != 0) { - set_error_code(id_token, ERR_INVALID_PARAMS, "invalid params"); + if (resolve_nostr_request_key(params, id_token, req_privkey, + s_nostr_pubkey_hex, &role) != 0) { + /* error response already written by resolve_nostr_request_key */ } else { - int d = prompt_approval("nostr_get_public_key", "nostr_get_public_key"); + /* Role-as-password: skip the prompt unless the role requires it. */ + int d = 1; + if (nostr_role_requires_approval(role)) { + d = prompt_approval("nostr_get_public_key", "nostr_get_public_key"); + } if (d != 1) { set_error_code(id_token, (d == 0) ? ERR_DENIED_BY_USER : ERR_APPROVAL_TIMEOUT, @@ -2008,8 +2251,8 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con /* ---- nostr_sign_event ---- */ if (strcmp(method, VERB_NOSTR_SIGN_EVENT) == 0) { cJSON *event_in = NULL; - uint32_t nostr_index = 0; uint8_t req_privkey[32]; + role_entry_t *role = NULL; memset(req_privkey, 0, sizeof(req_privkey)); memset(s_nostr_pubkey_hex, 0, sizeof(s_nostr_pubkey_hex)); @@ -2018,15 +2261,22 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con if (cJSON_IsArray(params) && cJSON_GetArraySize(params) > 0) { event_in = cJSON_GetArrayItem(params, 0); } - if (event_in == NULL || !cJSON_IsObject(event_in) || - parse_nostr_index_from_params(params, &nostr_index) != 0 || - derive_request_key(nostr_index, req_privkey, s_nostr_pubkey_hex) != 0) { + if (event_in == NULL || !cJSON_IsObject(event_in)) { set_error_code(id_token, ERR_INVALID_PARAMS, "invalid params"); secure_memzero(req_privkey, sizeof(req_privkey)); return; } + if (resolve_nostr_request_key(params, id_token, req_privkey, + s_nostr_pubkey_hex, &role) != 0) { + /* error response already written by resolve_nostr_request_key */ + secure_memzero(req_privkey, sizeof(req_privkey)); + return; + } { - int d = prompt_approval("nostr_sign_event", "nostr_sign_event"); + int d = 1; + if (nostr_role_requires_approval(role)) { + d = prompt_approval("nostr_sign_event", "nostr_sign_event"); + } if (d != 1) { set_error_code(id_token, (d == 0) ? ERR_DENIED_BY_USER : ERR_APPROVAL_TIMEOUT, @@ -2049,9 +2299,9 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con /* ---- nostr_mine_event (NIP-13 proof-of-work) ---- */ if (strcmp(method, VERB_NOSTR_MINE_EVENT) == 0) { cJSON *event_in = NULL; - uint32_t nostr_index = 0; uint8_t req_privkey[32]; cJSON *options = NULL; + role_entry_t *role = NULL; int difficulty = 0; int timeout_sec = 0; uint32_t nonce = 0; @@ -2088,16 +2338,23 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con timeout_sec = 30; } - if (event_in == NULL || !cJSON_IsObject(event_in) || - parse_nostr_index_from_params(params, &nostr_index) != 0 || - derive_request_key(nostr_index, req_privkey, s_nostr_pubkey_hex) != 0) { + if (event_in == NULL || !cJSON_IsObject(event_in)) { set_error_code(id_token, ERR_INVALID_PARAMS, "invalid params"); secure_memzero(req_privkey, sizeof(req_privkey)); return; } + if (resolve_nostr_request_key(params, id_token, req_privkey, + s_nostr_pubkey_hex, &role) != 0) { + /* error response already written by resolve_nostr_request_key */ + secure_memzero(req_privkey, sizeof(req_privkey)); + return; + } { - int d = prompt_approval("nostr_mine_event", "nostr_mine_event"); + int d = 1; + if (nostr_role_requires_approval(role)) { + d = prompt_approval("nostr_mine_event", "nostr_mine_event"); + } if (d != 1) { set_error_code(id_token, (d == 0) ? ERR_DENIED_BY_USER : ERR_APPROVAL_TIMEOUT, @@ -2244,26 +2501,46 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con strcmp(method, VERB_NOSTR_NIP44_DECRYPT) == 0) { const char *peer_hex = NULL; const char *message = NULL; - uint32_t nostr_index = 0; + selector_request_t sel; uint8_t peer_pubkey[32]; uint8_t req_privkey[32]; + role_entry_t *role = NULL; int is_nip44 = (method[9] == '4'); /* "nostr_nip04_*" vs "nostr_nip44_*": digit at index 9 */ int is_encrypt = (strstr(method, "encrypt") != NULL); int rc = -1; + int parse_ok = 1; memset(peer_pubkey, 0, sizeof(peer_pubkey)); memset(req_privkey, 0, sizeof(req_privkey)); memset(s_nostr_pubkey_hex, 0, sizeof(s_nostr_pubkey_hex)); memset(s_encrypt_buf, 0, sizeof(s_encrypt_buf)); + selector_request_init(&sel); - if (parse_peer_and_message_params(params, &peer_hex, &message, - &nostr_index) != 0 || - hex_to_bytes(peer_hex, peer_pubkey, sizeof(peer_pubkey)) != 0 || - derive_request_key(nostr_index, req_privkey, - s_nostr_pubkey_hex) != 0) { + /* Parse peer + message + selector from params. */ + if (parse_peer_and_message_params(params, &peer_hex, &message, &sel) != 0 || + hex_to_bytes(peer_hex, peer_pubkey, sizeof(peer_pubkey)) != 0) { set_error_code(id_token, ERR_INVALID_PARAMS, "invalid params"); - } else { - int d = prompt_approval(method, method); + parse_ok = 0; + } + + if (parse_ok) { + /* Resolve the selector + derive the key. We need to call + * resolve_nostr_request_key with the original params (it re-parses + * the selector internally), but we already validated the peer/message + * shape above. The selector in `sel` is re-parsed inside the helper + * from the same params, so it's consistent. */ + if (resolve_nostr_request_key(params, id_token, req_privkey, + s_nostr_pubkey_hex, &role) != 0) { + /* error response already written by resolve_nostr_request_key */ + parse_ok = 0; + } + } + + if (parse_ok) { + int d = 1; + if (nostr_role_requires_approval(role)) { + d = prompt_approval(method, method); + } if (d != 1) { set_error_code(id_token, (d == 0) ? ERR_DENIED_BY_USER : ERR_APPROVAL_TIMEOUT, diff --git a/firmware/teensy41/signer/src/dispatch.h b/firmware/teensy41/signer/src/dispatch.h index df91e9b..af79cff 100644 --- a/firmware/teensy41/signer/src/dispatch.h +++ b/firmware/teensy41/signer/src/dispatch.h @@ -62,6 +62,12 @@ extern char g_pubkey_hex[65]; * after apply_mnemonic). When 0, every verb except get_info returns an error. */ extern int g_signer_ready; +/* The role table (populated by signer.ino after the role wizard). Used by the + * nostr_* verbs to resolve role + role_path selectors. Defined in dispatch.cpp + * as a DMAMEM global. */ +struct role_table_t; +extern struct role_table_t g_roles; + /* ---- Entry point ---- * Process one parsed JSON-RPC request and write the JSON-RPC response into * `out_buf`. diff --git a/firmware/teensy41/signer/src/key_derivation.cpp b/firmware/teensy41/signer/src/key_derivation.cpp index 1e05619..14919e0 100644 --- a/firmware/teensy41/signer/src/key_derivation.cpp +++ b/firmware/teensy41/signer/src/key_derivation.cpp @@ -315,6 +315,164 @@ __attribute__((section(".flashmem"))) int derive_secp256k1_keys_index(const uint pubkey); } +/* Parse a BIP-44 derivation path string (e.g. "m/44'/1237'/0'/0/0") into a + * uint32_t array. Hardened segments are indicated by a trailing ' (or h/H). + * Returns the number of path components on success, or -1 on parse error. + * Ported from src/key_store.c parse_bip44_path(). */ +__attribute__((section(".flashmem"))) int parse_bip44_path(const char *path_str, + uint32_t *out, int max_segments) { + char buf[128]; + char *p; + int count = 0; + + if (path_str == NULL || out == NULL || max_segments <= 0) { + return -1; + } + + /* Copy so we can tokenize in place. */ + { + size_t plen = strlen(path_str); + if (plen >= sizeof(buf)) { + return -1; + } + memcpy(buf, path_str, plen); + buf[plen] = '\0'; + } + + /* Skip leading "m" or "M" (optionally followed by '/'). */ + p = buf; + if (*p == 'm' || *p == 'M') { + p++; + if (*p == '/') { + p++; + } else if (*p != '\0') { + return -1; /* "m" must be followed by '/' or end */ + } + } + + while (*p != '\0' && count < max_segments) { + char *slash = strchr(p, '/'); + char seg[24]; + size_t seg_len; + int hardened = 0; + char *endptr = NULL; + long val; + + if (slash != NULL) { + seg_len = (size_t)(slash - p); + } else { + seg_len = strlen(p); + } + if (seg_len == 0 || seg_len >= sizeof(seg)) { + return -1; + } + memcpy(seg, p, seg_len); + seg[seg_len] = '\0'; + + /* Check for hardened marker ' or h/H at end. */ + if (seg[seg_len - 1] == '\'' || seg[seg_len - 1] == 'h' || + seg[seg_len - 1] == 'H') { + hardened = 1; + seg[seg_len - 1] = '\0'; + /* A bare hardened marker with no number (e.g. "m/0/'") is invalid. */ + if (seg[0] == '\0') { + return -1; + } + } + + val = strtol(seg, &endptr, 10); + if (*endptr != '\0' || val < 0 || val > 0x7FFFFFFF) { + return -1; + } + + out[count] = (uint32_t)val; + if (hardened) { + out[count] |= BIP32_HARDENED_FLAG; + } + count++; + + p = (slash != NULL) ? slash + 1 : ""; + if (*p == '\0') { + break; + } + } + + return count; +} + +/* Derive a secp256k1 keypair from an explicit BIP-44 path string. + * Reuses the same bip32_master_from_seed + bip32_ckd_priv helpers as the + * NIP-06 path, just with a caller-supplied path instead of a fixed one. */ +__attribute__((section(".flashmem"))) int derive_secp256k1_from_path(const uint8_t *seed, size_t seed_len, + const char *path_str, + uint8_t *privkey, uint8_t *pubkey) { + uint32_t path[16]; + int path_len; + secp256k1_context *ctx = NULL; + hd_key_t node; + + if (seed == NULL || path_str == NULL || privkey == NULL || pubkey == NULL) { + return -1; + } + + path_len = parse_bip44_path(path_str, path, + (int)(sizeof(path) / sizeof(path[0]))); + if (path_len <= 0) { + return -1; + } + + memset(&node, 0, sizeof(node)); + + ctx = create_context(); + if (ctx == NULL) { + return -1; + } + + if (bip32_master_from_seed(ctx, seed, seed_len, &node) != 0) { + secure_memzero(&node, sizeof(node)); + return -1; + } + + for (int i = 0; i < path_len; ++i) { + hd_key_t next; + memset(&next, 0, sizeof(next)); + + if (bip32_ckd_priv(ctx, &node, path[i], &next) != 0) { + secure_memzero(&node, sizeof(node)); + return -1; + } + + secure_memzero(&node, sizeof(node)); + node = next; + } + + memcpy(privkey, node.priv, 32); + + { + secp256k1_keypair kp; + secp256k1_xonly_pubkey xonly; + + if (!secp256k1_keypair_create(ctx, &kp, node.priv)) { + secure_memzero(&node, sizeof(node)); + return -1; + } + + if (!secp256k1_keypair_xonly_pub(ctx, &xonly, NULL, &kp)) { + secure_memzero(&node, sizeof(node)); + return -1; + } + + if (!secp256k1_xonly_pubkey_serialize(ctx, pubkey, &xonly)) { + secure_memzero(&node, sizeof(node)); + return -1; + } + } + + secure_memzero(&node, sizeof(node)); + /* Do NOT destroy ctx — it is the persistent global context. */ + return 0; +} + __attribute__((section(".flashmem"))) int schnorr_sign32(const uint8_t privkey[32], const uint8_t msg32[32], uint8_t sig64[64]) { secp256k1_context *ctx = NULL; diff --git a/firmware/teensy41/signer/src/key_derivation.h b/firmware/teensy41/signer/src/key_derivation.h index 9e3db8f..21ca203 100644 --- a/firmware/teensy41/signer/src/key_derivation.h +++ b/firmware/teensy41/signer/src/key_derivation.h @@ -32,6 +32,22 @@ int derive_secp256k1_keys_index(const uint8_t *seed, size_t seed_len, uint32_t nostr_index, uint8_t *privkey, uint8_t *pubkey); +/* Parse a BIP-44 derivation path string (e.g. "m/44'/1237'/0'/0/0") into a + * uint32_t array suitable for the internal BIP-32 derivation. Hardened + * segments are indicated by a trailing ' (or h/H). Returns the number of + * path components on success, or -1 on parse error. `max_segments` is the + * max number of entries in the `out` array. */ +int parse_bip44_path(const char *path_str, uint32_t *out, int max_segments); + +/* Derive a secp256k1 keypair from an explicit BIP-44 path string. + * Uses BIP-32 derivation (master key from seed + CKDpriv per segment). + * privkey: 32-byte secret key (scalar). + * pubkey: 32-byte x-only public key (Nostr pubkey). + * Returns 0 on success, -1 on failure. */ +int derive_secp256k1_from_path(const uint8_t *seed, size_t seed_len, + const char *path_str, + uint8_t *privkey, uint8_t *pubkey); + /* Sign a 32-byte message digest with Schnorr (BIP-340) using a 32-byte * secp256k1 secret key. aux_rand is drawn from the TRNG. sig64: 64-byte sig. */ int schnorr_sign32(const uint8_t privkey[32], const uint8_t msg32[32], diff --git a/firmware/teensy41/signer/src/role_table.cpp b/firmware/teensy41/signer/src/role_table.cpp new file mode 100644 index 0000000..a2dce1c --- /dev/null +++ b/firmware/teensy41/signer/src/role_table.cpp @@ -0,0 +1,269 @@ +/* role_table.cpp — in-RAM role table implementation for the Teensy 4.1. + * + * Phase 2 of plans/teensy41_role_path_migration.md. + * + * Ports the path-template matching from src/role_table.c, slimmed for the + * Teensy (16 entries, range-only index validation, no allowed-indices set). + * The matching logic (role_path_matches_template, role_path_extract_index, + * role_path_matches_with_range) is a faithful port of the host's functions + * so the Teensy and the host accept the same paths for the same templates. + */ +#include "role_table.h" + +#include +#include + +/* ==================================================================== + * Table operations + * ==================================================================== */ + +__attribute__((section(".flashmem"))) +void role_table_init(role_table_t *table) { + if (table != NULL) { + memset(table, 0, sizeof(*table)); + } +} + +__attribute__((section(".flashmem"))) +int role_table_add(role_table_t *table, const role_entry_t *entry) { + int i; + if (table == NULL || entry == NULL) { + return -1; + } + if (table->count >= ROLE_TABLE_MAX_ENTRIES) { + return -1; /* full */ + } + /* Duplicate name check */ + for (i = 0; i < table->count; i++) { + if (strcmp(table->entries[i].name, entry->name) == 0) { + return -2; + } + } + table->entries[table->count] = *entry; + table->count++; + return 0; +} + +__attribute__((section(".flashmem"))) +role_entry_t *role_table_find_by_name(role_table_t *table, const char *name) { + int i; + if (table == NULL || name == NULL) { + return NULL; + } + for (i = 0; i < table->count; i++) { + if (strcmp(table->entries[i].name, name) == 0) { + return &table->entries[i]; + } + } + return NULL; +} + +__attribute__((section(".flashmem"))) +role_entry_t *role_table_get_default(role_table_t *table) { + return role_table_find_by_name(table, "main"); +} + +/* ==================================================================== + * Path-template matching (ported from src/role_table.c) + * ==================================================================== */ + +/* Check whether a concrete derivation path matches a role's path template. + * The template may contain a single "%d" placeholder (with an optional + * hardened marker after it, e.g. "m/44'/1237'/%d'/0/0"). + * Returns 1 if the path matches the template, 0 if not. + * Ported from src/role_table.c:956. */ +__attribute__((section(".flashmem"))) +int role_path_matches_template(const char *path, const char *template_str) { + const char *p = path; + const char *t = template_str; + + if (path == NULL || template_str == NULL) { + return 0; + } + + while (*t != '\0' && *p != '\0') { + if (*t == '%' && *(t + 1) == 'd') { + /* %d placeholder — skip one path segment in the path */ + t += 2; /* skip "%d" */ + /* Skip optional hardened marker after %d in template */ + if (*t == '\'' || *t == 'h' || *t == 'H') { + t++; + } + /* Skip the corresponding segment in the path (digits, possibly with ' or h) */ + if (*p == '/') { + /* Path has a slash where we expect a segment — mismatch */ + return 0; + } + while (*p != '\0' && *p != '/') { + p++; + } + /* If template has more after %d, it should start with '/' */ + if (*t == '/' && *p == '/') { + t++; + p++; + } else if (*t == '\0' && *p == '\0') { + /* Both at end — exact match */ + return 1; + } else if (*t == '\0' && *p == '/') { + /* Template ended but path has trailing slash — no match */ + return 0; + } else if (*t == '/' && *p == '\0') { + /* Path ended but template has more — no match */ + return 0; + } + /* If one has a separator and the other doesn't, let the loop continue */ + } else if (*t == *p) { + t++; + p++; + } else { + return 0; + } + } + + /* Both should be at the end */ + return (*t == '\0' && *p == '\0') ? 1 : 0; +} + +/* Extract the numeric index from a concrete derivation path that matches a + * role's path template (containing a single "%d" placeholder). + * Returns the extracted index on success, or -1 if no match / no %d. + * Ported from src/role_table.c:1007. */ +__attribute__((section(".flashmem"))) +int role_path_extract_index(const char *path, const char *template_str) { + const char *p = path; + const char *t = template_str; + const char *seg_start; + char seg_buf[32]; + size_t seg_len; + long val; + char *endp; + + if (path == NULL || template_str == NULL) { + return -1; + } + + /* If template has no %d, there is no variable index to extract */ + if (strstr(template_str, "%d") == NULL) { + return -1; + } + + while (*t != '\0' && *p != '\0') { + if (*t == '%' && *(t + 1) == 'd') { + /* %d placeholder — extract the corresponding path segment */ + t += 2; /* skip "%d" */ + /* Skip optional hardened marker after %d in template */ + if (*t == '\'' || *t == 'h' || *t == 'H') { + t++; + } + /* Extract the segment from the path (up to next '/' or end) */ + if (*p == '/') { + return -1; /* path has a slash where a segment is expected */ + } + seg_start = p; + while (*p != '\0' && *p != '/') { + p++; + } + seg_len = (size_t)(p - seg_start); + if (seg_len == 0 || seg_len >= sizeof(seg_buf)) { + return -1; + } + memcpy(seg_buf, seg_start, seg_len); + seg_buf[seg_len] = '\0'; + /* Strip optional trailing hardened marker from the segment */ + if (seg_len > 0 && + (seg_buf[seg_len - 1] == '\'' || seg_buf[seg_len - 1] == 'h' || + seg_buf[seg_len - 1] == 'H')) { + seg_buf[seg_len - 1] = '\0'; + } + endp = NULL; + val = strtol(seg_buf, &endp, 10); + if (*endp != '\0' || val < 0) { + return -1; + } + return (int)val; + } else if (*t == *p) { + t++; + p++; + } else { + return -1; + } + } + + return -1; +} + +/* Check whether a concrete derivation path matches a role's path template + * AND the extracted index falls within the role's allowed range. + * Returns 1 if the path matches and the index is allowed, 0 otherwise. + * Ported from src/role_table.c:1070 (set-form omitted, range-only). */ +__attribute__((section(".flashmem"))) +int role_path_matches_with_range(const char *path, const role_entry_t *role) { + int index; + + if (path == NULL || role == NULL) { + return 0; + } + + /* Fixed path (no %d) — just check structural match */ + if (strstr(role->role_path, "%d") == NULL) { + return role_path_matches_template(path, role->role_path); + } + + /* Template path — check structural match first */ + if (!role_path_matches_template(path, role->role_path)) { + return 0; + } + + /* Extract the index and check it against the allowed range */ + index = role_path_extract_index(path, role->role_path); + if (index < 0) { + return 0; + } + + /* Range form: check lo..hi */ + if (role->path_range_lo < 0 || role->path_range_hi < 0) { + /* No range configured — deny (fail-closed) */ + return 0; + } + return (index >= role->path_range_lo && index <= role->path_range_hi) ? 1 : 0; +} + +/* ==================================================================== + * Presets (matching the host wizard, src/main.c:2068) + * ==================================================================== */ + +const role_preset_t role_presets[] = { + /* 1. Standard Nostr (secp256k1, m/44'/1237'/0'/0/0) */ + { "main", "m/44'/1237'/0'/0/0", + ROLE_PURPOSE_NOSTR, ROLE_CURVE_SECP256K1, -1, -1, -1 }, + /* 2. Nostr range (secp256k1, m/44'/1237'/%d'/0/0, 0-100) */ + { "nostr_range", "m/44'/1237'/%d'/0/0", + ROLE_PURPOSE_NOSTR, ROLE_CURVE_SECP256K1, 0, 100, 0 }, + /* 3. Nostr agent (secp256k1, m/44'/1237'/%d'/1'/0', 0-100) */ + { "nostr_agent", "m/44'/1237'/%d'/1'/0'", + ROLE_PURPOSE_NOSTR, ROLE_CURVE_SECP256K1, 0, 100, 0 }, + /* 4. SSH (ed25519, m/44'/102001'/0'/0'/0') */ + { "ssh", "m/44'/102001'/0'/0'/0'", + ROLE_PURPOSE_SSH, ROLE_CURVE_ED25519, -1, -1, -1 }, + /* 5. Age (x25519, m/44'/102002'/0'/0'/0') */ + { "age", "m/44'/102002'/0'/0'/0'", + ROLE_PURPOSE_AGE, ROLE_CURVE_X25519, -1, -1, -1 }, + /* 6. ML-DSA-65 (m/44'/102003'/0'/0'/0') */ + { "ml_dsa_65", "m/44'/102003'/0'/0'/0'", + ROLE_PURPOSE_PQ_SIG, ROLE_CURVE_ML_DSA_65, -1, -1, -1 }, + /* 7. SLH-DSA-128s (m/44'/102004'/0'/0'/0') */ + { "slh_dsa_128s", "m/44'/102004'/0'/0'/0'", + ROLE_PURPOSE_PQ_SIG, ROLE_CURVE_SLH_DSA_128S, -1, -1, -1 }, + /* 8. ML-KEM-768 (m/44'/102005'/0'/0'/0') */ + { "ml_kem_768", "m/44'/102005'/0'/0'/0'", + ROLE_PURPOSE_PQ_KEM, ROLE_CURVE_ML_KEM_768, -1, -1, -1 }, + /* 9. OTP (no derivation path — binds the SD pad instead) */ + { "otp", "", + ROLE_PURPOSE_OTP, ROLE_CURVE_OTP, -1, -1, -1 }, + /* 10. Custom (user edits name + path) */ + { "custom", "m/44'/1237'/0'/0/0", + ROLE_PURPOSE_NOSTR, ROLE_CURVE_SECP256K1, -1, -1, -1 }, +}; + +const int role_preset_count = + (int)(sizeof(role_presets) / sizeof(role_presets[0])); diff --git a/firmware/teensy41/signer/src/role_table.h b/firmware/teensy41/signer/src/role_table.h new file mode 100644 index 0000000..85268d0 --- /dev/null +++ b/firmware/teensy41/signer/src/role_table.h @@ -0,0 +1,138 @@ +/* role_table.h — in-RAM role table for the Teensy 4.1 n_signer firmware. + * + * Phase 2 of plans/teensy41_role_path_migration.md. + * + * Ports the role + path authorization model from the host n_signer + * (src/role_table.c) to the Teensy 4.1, slimmed for the hardware signer's + * single-user scope: + * - 16 entries max (vs the host's 256) + * - single %d placeholder per path template (vs the host's same limitation) + * - range bounds (lo/hi) for the %d index; no explicit allowed-indices set + * (the host's path_allowed_indices[] is omitted to save memory) + * + * Each role binds a name to a BIP-44 derivation path template, a purpose/curve, + * and a requires_approval flag. The selector (selector.cpp) looks up a role by + * name and verifies the requested path matches the template + range before + * authorizing the request. + * + * Role-as-password: when requires_approval == 0, knowing the role name (and a + * matching path) is sufficient authorization — no ui_approve() prompt. This is + * the default, matching plans/role_as_password_default.md. + */ +#ifndef FIRMWARE_TEENSY41_SIGNER_ROLE_TABLE_H +#define FIRMWARE_TEENSY41_SIGNER_ROLE_TABLE_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* ---- Limits (slimmed for the Teensy 4.1) ---- */ +#define ROLE_NAME_MAX 32 +#define ROLE_PATH_MAX 128 +#define ROLE_PUBKEY_HEX_MAX 65 /* 64 hex chars + NUL */ +#define ROLE_TABLE_MAX_ENTRIES 16 + +/* ---- Purpose enum ---- */ +typedef enum { + ROLE_PURPOSE_NOSTR = 0, + ROLE_PURPOSE_SSH, + ROLE_PURPOSE_AGE, + ROLE_PURPOSE_PQ_SIG, /* ML-DSA-65, SLH-DSA-128s */ + ROLE_PURPOSE_PQ_KEM, /* ML-KEM-768 */ + ROLE_PURPOSE_OTP, /* one-time pad (no derivation path) */ + ROLE_PURPOSE_UNKNOWN +} role_purpose_t; + +/* ---- Curve enum ---- */ +typedef enum { + ROLE_CURVE_SECP256K1 = 0, + ROLE_CURVE_ED25519, + ROLE_CURVE_X25519, + ROLE_CURVE_ML_DSA_65, + ROLE_CURVE_SLH_DSA_128S, + ROLE_CURVE_ML_KEM_768, + ROLE_CURVE_OTP, + ROLE_CURVE_UNKNOWN +} role_curve_t; + +/* ---- A single role entry ---- */ +typedef struct { + char name[ROLE_NAME_MAX]; /* "main", "ssh", etc. */ + char role_path[ROLE_PATH_MAX]; /* template, may contain one "%d" */ + role_purpose_t purpose; + role_curve_t curve; + int path_range_lo; /* inclusive lower bound for %d; -1 = fixed path (no %d) */ + int path_range_hi; /* inclusive upper bound; == lo for single */ + int path_default_index; /* default index when client sends {"role":...} without a path; -1 = require explicit */ + int requires_approval; /* 0 = role-as-password (no prompt), 1 = require ui_approve() */ + int derived; /* 1 if pubkey_hex has been populated */ + char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after first derivation */ +} role_entry_t; + +/* ---- The role table ---- */ +typedef struct { + role_entry_t entries[ROLE_TABLE_MAX_ENTRIES]; + int count; +} role_table_t; + +/* ---- Operations ---- */ + +/* Initialize an empty role table. */ +void role_table_init(role_table_t *table); + +/* Add a role entry. Returns 0 on success, -1 if table full, -2 if name duplicate. */ +int role_table_add(role_table_t *table, const role_entry_t *entry); + +/* Find a role by name. Returns pointer to entry or NULL. */ +role_entry_t *role_table_find_by_name(role_table_t *table, const char *name); + +/* Get the default role (named "main"). Returns pointer or NULL if no "main" role. */ +role_entry_t *role_table_get_default(role_table_t *table); + +/* ---- Path-template matching ---- */ + +/* Check whether a concrete derivation path matches a role's path template. + * The template may contain a single "%d" placeholder (with an optional + * hardened marker after it, e.g. "m/44'/1237'/%d'/0/0"). + * Returns 1 if the path matches the template, 0 if not. + * For fixed paths (no %d), does an exact string comparison. */ +int role_path_matches_template(const char *path, const char *template_str); + +/* Extract the numeric index from a concrete derivation path that matches a + * role's path template (containing a single "%d" placeholder). + * Returns the extracted index on success, or -1 if the path does not match + * the template or no %d placeholder exists in the template. */ +int role_path_extract_index(const char *path, const char *template_str); + +/* Check whether a concrete derivation path matches a role's path template + * AND the extracted index falls within the role's allowed range. + * Returns 1 if the path matches and the index is allowed, 0 otherwise. + * For fixed paths (no %d), equivalent to role_path_matches_template(). */ +int role_path_matches_with_range(const char *path, const role_entry_t *role); + +/* ---- Presets ---- */ + +/* A role preset, matching the host's wizard menu (src/main.c:2068). + * Used by ui_role_wizard() to populate the table. */ +typedef struct { + const char *name; /* default role name */ + const char *path; /* default path template (may contain %d) */ + role_purpose_t purpose; + role_curve_t curve; + int range_lo; /* -1 = fixed path */ + int range_hi; + int default_index;/* -1 = require explicit */ +} role_preset_t; + +/* The preset table (10 entries, matching the host wizard). */ +extern const role_preset_t role_presets[]; +extern const int role_preset_count; + +#ifdef __cplusplus +} +#endif + +#endif /* FIRMWARE_TEENSY41_SIGNER_ROLE_TABLE_H */ diff --git a/firmware/teensy41/signer/src/selector.cpp b/firmware/teensy41/signer/src/selector.cpp new file mode 100644 index 0000000..813cdc9 --- /dev/null +++ b/firmware/teensy41/signer/src/selector.cpp @@ -0,0 +1,111 @@ +/* selector.cpp — request selector implementation for the Teensy 4.1. + * + * Phase 3 of plans/teensy41_role_path_migration.md. + * + * Ports the selector decision tree from src/selector.c:745. The JSON parsing + * (extracting role/role_path/nostr_index from the cJSON options object) is + * done in dispatch.cpp, which calls selector_resolve() with the populated + * selector_request_t. + */ +#include "selector.h" + +#include + +/* ==================================================================== + * Selector request + * ==================================================================== */ + +__attribute__((section(".flashmem"))) +void selector_request_init(selector_request_t *req) { + if (req != NULL) { + memset(req, 0, sizeof(*req)); + } +} + +/* ==================================================================== + * Selector resolution + * ==================================================================== */ + +/* Resolve a selector request against the role table. + * + * Ported from src/selector.c:745 (selector_resolve). The decision tree: + * 1. nostr_index present → DEPRECATED (error 2006 in the wire protocol) + * 2. role_path without role → ROLE_REQUIRED + * 3. role + role_path → find role, verify path matches template + range + * 4. role only, fixed path (no %d) → use it + * 5. role only, template path → PATH_REQUIRED + * 6. neither → default role ("main"), else NO_DEFAULT + * + * On success, *out points to the matching role entry in the table. The + * caller uses req->role_path (if has_role_path) or the role's role_path + * (if fixed) for key derivation. */ +__attribute__((section(".flashmem"))) +int selector_resolve(const selector_request_t *req, role_table_t *table, + role_entry_t **out) { + role_entry_t *match = NULL; + + if (out != NULL) { + *out = NULL; + } + + if (req == NULL || table == NULL || out == NULL) { + return SELECTOR_ERR_NOT_FOUND; + } + + /* ---- Deprecated selectors: reject with clear error codes ---- */ + + /* nostr_index is deprecated */ + if (req->has_nostr_index) { + return SELECTOR_ERR_NOSTR_INDEX_DEPRECATED; + } + + /* role_path without role is not allowed */ + if (req->has_role_path && !req->has_role) { + return SELECTOR_ERR_ROLE_REQUIRED; + } + + /* ---- New model: role + role_path combined ---- */ + + if (req->has_role && req->has_role_path) { + /* Combined selector: look up role by name, verify path matches template */ + match = role_table_find_by_name(table, req->role_name); + if (match == NULL) { + return SELECTOR_ERR_NOT_FOUND; + } + + /* Verify the requested path matches the role's template AND that the + * extracted index falls within the role's allowed range. */ + if (!role_path_matches_with_range(req->role_path, match)) { + return SELECTOR_ERR_PATH_MISMATCH; + } + + *out = match; + return SELECTOR_OK; + } + + if (req->has_role && !req->has_role_path) { + /* Role specified without path — check if role has a fixed path (no %d) */ + match = role_table_find_by_name(table, req->role_name); + if (match == NULL) { + return SELECTOR_ERR_NOT_FOUND; + } + + /* If the role has a fixed path (no variable segments), use it */ + if (strstr(match->role_path, "%d") == NULL) { + *out = match; + return SELECTOR_OK; + } + + /* Role has variable path template — path is required */ + return SELECTOR_ERR_PATH_REQUIRED; + } + + /* No selectors at all — try default role */ + match = role_table_get_default(table); + if (match == NULL) { + return SELECTOR_ERR_NO_DEFAULT; + } + + *out = match; + return SELECTOR_OK; +} diff --git a/firmware/teensy41/signer/src/selector.h b/firmware/teensy41/signer/src/selector.h new file mode 100644 index 0000000..b525838 --- /dev/null +++ b/firmware/teensy41/signer/src/selector.h @@ -0,0 +1,69 @@ +/* selector.h — request selector for the Teensy 4.1 n_signer firmware. + * + * Phase 3 of plans/teensy41_role_path_migration.md. + * + * Ports the role + path selector from the host n_signer (src/selector.c). + * Parses the selector fields (role, role_path, nostr_index, index) from a + * nostr_* verb's trailing options object and resolves them against the role + * table. + * + * Decision tree (matching src/selector.c:745): + * - nostr_index present → SELECTOR_ERR_NOSTR_INDEX_DEPRECATED + * - role_path without role → SELECTOR_ERR_ROLE_REQUIRED + * - role + role_path → find role, verify path matches template + range + * - role only (fixed path) → use the role's fixed path + * - role only (template path) → SELECTOR_ERR_PATH_REQUIRED + * - neither → use default role ("main"), else NO_DEFAULT + */ +#ifndef FIRMWARE_TEENSY41_SIGNER_SELECTOR_H +#define FIRMWARE_TEENSY41_SIGNER_SELECTOR_H + +#include +#include +#include "role_table.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* ---- Selector request (parsed from the JSON options object) ---- */ +typedef struct { + int has_role; /* 1 if "role" field was present */ + char role_name[ROLE_NAME_MAX]; + int has_role_path; /* 1 if "role_path" field was present */ + char role_path[ROLE_PATH_MAX]; + int has_nostr_index; /* 1 if "nostr_index" field was present (deprecated) */ + uint32_t nostr_index; + int has_index; /* 1 if "index" field was present (for algorithm verbs, not nostr) */ + uint32_t index; +} selector_request_t; + +/* ---- Result codes ---- */ +#define SELECTOR_OK 0 +#define SELECTOR_ERR_NOT_FOUND -1 /* role not found */ +#define SELECTOR_ERR_NO_DEFAULT -3 /* no selector given and no "main" role */ +#define SELECTOR_ERR_PATH_MISMATCH -4 /* role_path doesn't match role's template/range */ +#define SELECTOR_ERR_NOSTR_INDEX_DEPRECATED -5 /* nostr_index is deprecated */ +#define SELECTOR_ERR_PATH_REQUIRED -6 /* role has a template path but no role_path given */ +#define SELECTOR_ERR_ROLE_REQUIRED -7 /* role_path given without a role */ + +/* ---- Operations ---- */ + +/* Initialize a selector request to its empty state. */ +void selector_request_init(selector_request_t *req); + +/* Resolve a selector request against the role table. + * + * On success (SELECTOR_OK), *out points to the matching role_entry_t in the + * table. The caller should then use req->role_path (if has_role_path) or the + * role's role_path (if fixed) for key derivation. + * + * Returns SELECTOR_OK or one of the SELECTOR_ERR_* codes. */ +int selector_resolve(const selector_request_t *req, role_table_t *table, + role_entry_t **out); + +#ifdef __cplusplus +} +#endif + +#endif /* FIRMWARE_TEENSY41_SIGNER_SELECTOR_H */ diff --git a/firmware/teensy41/signer/src/ui.cpp b/firmware/teensy41/signer/src/ui.cpp index 757772e..6366198 100644 --- a/firmware/teensy41/signer/src/ui.cpp +++ b/firmware/teensy41/signer/src/ui.cpp @@ -1017,3 +1017,291 @@ __attribute__((section(".flashmem"))) int ui_pick_pad( return 2; /* timeout */ } } + +/* ===================================================================== + * 6. ui_role_wizard — role-preset selection (Phase 5) + * ===================================================================== + * + * Presents the 10 role presets as a scrollable button list. Tapping a + * preset creates a role with the preset's defaults (requires_approval=0, + * role-as-password). After each selection, a "Add another / Done" prompt + * loops until the user taps Done with at least one role defined. + * + * The preset labels are short descriptions (not the full path) to fit the + * 480px screen. The full path is stored in the role entry. + */ + +/* Wizard state: -1 = none, 0..9 = preset index, -2 = done, -3 = add-another, + * -4 = cancel */ +static volatile int s_wizard_choice = -1; + +static void on_wizard_preset(lv_event_t *e) { + if (lv_event_get_code(e) == LV_EVENT_CLICKED) { + int idx = (int)(intptr_t)lv_event_get_user_data(e); + s_wizard_choice = idx; + } +} + +static void on_wizard_done(lv_event_t *e) { + if (lv_event_get_code(e) == LV_EVENT_CLICKED) { + s_wizard_choice = -2; + } +} + +static void on_wizard_add(lv_event_t *e) { + if (lv_event_get_code(e) == LV_EVENT_CLICKED) { + s_wizard_choice = -3; + } +} + +static void on_wizard_cancel(lv_event_t *e) { + if (lv_event_get_code(e) == LV_EVENT_CLICKED) { + s_wizard_choice = -4; + } +} + +/* Short labels for the 10 presets (kept short to fit 480px buttons). */ +static const char *preset_labels[] = { + "1. Standard Nostr", + "2. Nostr range (0-100)", + "3. Nostr agent (0-100)", + "4. SSH (ed25519)", + "5. Age (x25519)", + "6. ML-DSA-65 (PQ sig)", + "7. SLH-DSA-128s (PQ sig)", + "8. ML-KEM-768 (PQ KEM)", + "9. OTP (one-time pad)", + "10. Custom", +}; + +/* Build the preset-selection screen. Returns the screen object. */ +__attribute__((section(".flashmem"))) static void show_preset_screen(lv_obj_t *scr, int roles_so_far) { + lv_obj_clean(scr); + lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(scr, lv_color_hex(UI_BG), 0); + + /* Title + role count */ + lv_obj_t *title = lv_label_create(scr); + char title_text[48]; + snprintf(title_text, sizeof(title_text), "Define a Role (%d defined)", + roles_so_far); + lv_label_set_text(title, title_text); + lv_obj_set_style_text_color(title, lv_color_hex(UI_FG), 0); + lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 5); + + /* Preset buttons — 2 columns × 5 rows to fit 10 presets on 480×320. */ + for (int i = 0; i < role_preset_count && i < 10; i++) { + lv_obj_t *btn = lv_button_create(scr); + style_button(btn); + lv_obj_set_size(btn, 225, 44); + int col = i % 2; + int row = i / 2; + lv_obj_align(btn, LV_ALIGN_TOP_LEFT, 10 + col * 235, 40 + row * 50); + lv_obj_add_event_cb(btn, on_wizard_preset, LV_EVENT_ALL, + (void *)(intptr_t)i); + lv_obj_t *lbl = lv_label_create(btn); + lv_label_set_text(lbl, preset_labels[i]); + lv_obj_center(lbl); + } + + /* Done button (bottom-right) — only meaningful after ≥1 role. */ + lv_obj_t *btn_done = lv_button_create(scr); + style_button(btn_done); + lv_obj_set_size(btn_done, 225, 40); + lv_obj_align(btn_done, LV_ALIGN_BOTTOM_RIGHT, -10, -5); + lv_obj_add_event_cb(btn_done, on_wizard_done, LV_EVENT_ALL, NULL); + lv_obj_t *lbl_done = lv_label_create(btn_done); + lv_label_set_text(lbl_done, "Done"); + lv_obj_center(lbl_done); + + /* Cancel button (bottom-left) */ + lv_obj_t *btn_cancel = lv_button_create(scr); + style_button(btn_cancel); + lv_obj_set_style_border_color(btn_cancel, lv_color_hex(UI_MUTED), 0); + lv_obj_set_style_text_color(btn_cancel, lv_color_hex(UI_MUTED), 0); + lv_obj_set_size(btn_cancel, 225, 40); + lv_obj_align(btn_cancel, LV_ALIGN_BOTTOM_LEFT, 10, -5); + lv_obj_add_event_cb(btn_cancel, on_wizard_cancel, LV_EVENT_ALL, NULL); + lv_obj_t *lbl_cancel = lv_label_create(btn_cancel); + lv_label_set_text(lbl_cancel, "Cancel"); + lv_obj_center(lbl_cancel); +} + +/* Show the "role added — add another or done?" confirmation screen. */ +__attribute__((section(".flashmem"))) static void show_added_screen(lv_obj_t *scr, + const char *role_name, + const char *role_path, + int roles_so_far) { + lv_obj_clean(scr); + lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(scr, lv_color_hex(UI_BG), 0); + + lv_obj_t *title = lv_label_create(scr); + lv_label_set_text(title, "Role Added"); + lv_obj_set_style_text_color(title, lv_color_hex(UI_FG), 0); + lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 20); + + /* Show the role name + path */ + char info[160]; + snprintf(info, sizeof(info), "%s\n%s", role_name, role_path); + lv_obj_t *info_lbl = lv_label_create(scr); + lv_label_set_text(info_lbl, info); + lv_label_set_long_mode(info_lbl, LV_LABEL_LONG_WRAP); + lv_obj_set_width(info_lbl, 440); + lv_obj_set_style_text_color(info_lbl, lv_color_hex(UI_FG), 0); + lv_obj_align(info_lbl, LV_ALIGN_TOP_MID, 0, 60); + + char count_text[48]; + snprintf(count_text, sizeof(count_text), "%d role(s) defined", roles_so_far); + lv_obj_t *count_lbl = lv_label_create(scr); + lv_label_set_text(count_lbl, count_text); + lv_obj_set_style_text_color(count_lbl, lv_color_hex(UI_MUTED), 0); + lv_obj_align(count_lbl, LV_ALIGN_TOP_MID, 0, 160); + + /* Add another */ + lv_obj_t *btn_add = lv_button_create(scr); + style_button(btn_add); + lv_obj_set_size(btn_add, 225, 50); + lv_obj_align(btn_add, LV_ALIGN_BOTTOM_LEFT, 10, -10); + lv_obj_add_event_cb(btn_add, on_wizard_add, LV_EVENT_ALL, NULL); + lv_obj_t *lbl_add = lv_label_create(btn_add); + lv_label_set_text(lbl_add, "Add Another"); + lv_obj_center(lbl_add); + + /* Done */ + lv_obj_t *btn_done = lv_button_create(scr); + style_button(btn_done); + lv_obj_set_size(btn_done, 225, 50); + lv_obj_align(btn_done, LV_ALIGN_BOTTOM_RIGHT, -10, -10); + lv_obj_add_event_cb(btn_done, on_wizard_done, LV_EVENT_ALL, NULL); + lv_obj_t *lbl_done = lv_label_create(btn_done); + lv_label_set_text(lbl_done, "Done"); + lv_obj_center(lbl_done); +} + +/* Show an error screen for 2 seconds (e.g. "at least one role required"). */ +__attribute__((section(".flashmem"))) static void show_error_screen(lv_obj_t *scr, + const char *msg) { + lv_obj_clean(scr); + lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(scr, lv_color_hex(UI_BG), 0); + + lv_obj_t *lbl = lv_label_create(scr); + lv_label_set_text(lbl, msg); + lv_label_set_long_mode(lbl, LV_LABEL_LONG_WRAP); + lv_obj_set_width(lbl, 440); + lv_obj_set_style_text_color(lbl, lv_color_hex(UI_ACCENT), 0); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_20, 0); + lv_obj_center(lbl); + + uint32_t deadline = millis() + 2000; + while (millis() < deadline) { + lv_tick_inc(5); + lv_timer_handler(); + delay(5); + } +} + +/* Pump LVGL until s_wizard_choice changes or timeout (ms). Returns the + * choice value, or -1 on timeout. */ +__attribute__((section(".flashmem"))) static int pump_until_choice(uint32_t timeout_ms) { + uint32_t deadline = millis() + timeout_ms; + while (s_wizard_choice == -1 && millis() < deadline) { + lv_tick_inc(5); + lv_timer_handler(); + delay(5); + } + int c = s_wizard_choice; + s_wizard_choice = -1; + return c; +} + +__attribute__((section(".flashmem"))) int ui_role_wizard(role_table_t *out_table) { + if (out_table == NULL) { + return -1; + } + role_table_init(out_table); + + lv_obj_t *scr = lv_screen_active(); + int done = 0; + + while (!done) { + /* ---- Preset selection screen ---- */ + show_preset_screen(scr, out_table->count); + int choice = pump_until_choice(60000); + + if (choice == -4) { + /* Cancel */ + return -1; + } else if (choice == -2) { + /* Done tapped on the preset screen */ + if (out_table->count == 0) { + show_error_screen(scr, "At least one role\nmust be defined"); + continue; /* re-loop to preset screen */ + } + done = 1; + break; + } else if (choice < 0 || choice >= role_preset_count) { + /* Timeout or invalid — re-loop */ + if (choice == -1) { + /* Timeout: treat as cancel */ + return -1; + } + continue; + } + + /* ---- A preset was selected: create the role ---- */ + const role_preset_t *preset = &role_presets[choice]; + role_entry_t entry; + memset(&entry, 0, sizeof(entry)); + strncpy(entry.name, preset->name, sizeof(entry.name) - 1); + entry.name[sizeof(entry.name) - 1] = '\0'; + strncpy(entry.role_path, preset->path, sizeof(entry.role_path) - 1); + entry.role_path[sizeof(entry.role_path) - 1] = '\0'; + entry.purpose = preset->purpose; + entry.curve = preset->curve; + entry.path_range_lo = preset->range_lo; + entry.path_range_hi = preset->range_hi; + entry.path_default_index = preset->default_index; + entry.requires_approval = 0; /* role-as-password by default */ + entry.derived = 0; + entry.pubkey_hex[0] = '\0'; + + /* If a role with this name already exists, append a suffix. */ + if (role_table_find_by_name(out_table, entry.name) != NULL) { + char base[ROLE_NAME_MAX]; + strncpy(base, entry.name, sizeof(base) - 1); + base[sizeof(base) - 1] = '\0'; + for (int suffix = 2; suffix < 100; suffix++) { + snprintf(entry.name, sizeof(entry.name), "%s%d", base, suffix); + if (role_table_find_by_name(out_table, entry.name) == NULL) { + break; + } + } + } + + int add_rc = role_table_add(out_table, &entry); + if (add_rc != 0) { + show_error_screen(scr, "Role table full\nor duplicate"); + continue; + } + + /* ---- "Role added — add another or done?" screen ---- */ + show_added_screen(scr, entry.name, entry.role_path, out_table->count); + int post = pump_until_choice(30000); + + if (post == -2 || post == -1) { + /* Done (or timeout → treat as done) */ + done = 1; + } + /* -3 = add another → re-loop to preset screen */ + /* -4 = cancel from the added screen (treat as done, keep roles) */ + if (post == -4) { + done = 1; + } + } + + return (out_table->count > 0) ? 0 : -1; +} diff --git a/firmware/teensy41/signer/src/ui.h b/firmware/teensy41/signer/src/ui.h index 3aacc60..3243d1e 100644 --- a/firmware/teensy41/signer/src/ui.h +++ b/firmware/teensy41/signer/src/ui.h @@ -24,6 +24,7 @@ #include #include +#include "role_table.h" #ifdef __cplusplus extern "C" { @@ -80,6 +81,21 @@ ui_approval_decision_t ui_approve(const char *verb, const char *summary); int ui_pick_pad(const char *pad_chksums[], const uint64_t pad_sizes[], int count, char *out_chksum, size_t out_chksum_cap); +/* Role-preset wizard (Phase 5 of plans/teensy41_role_path_migration.md). + * + * Presents the 10 role presets (matching the host wizard) as a scrollable + * list of buttons. When the user taps a preset, a role is created with the + * preset's default name + path + requires_approval=0 (role-as-password). + * Then a "Done" / "Add another" prompt loops until at least one role is + * defined and the user taps "Done". + * + * At least one role is required. If the user taps "Done" with zero roles, + * an error message is shown and the wizard re-loops. + * + * Fills `out_table` with the defined roles. Returns 0 on success, -1 if the + * user cancels (which should abort the boot). */ +int ui_role_wizard(role_table_t *out_table); + #ifdef __cplusplus } #endif diff --git a/firmware/teensy41/signer/tests/host_test_parse_bip44_path.c b/firmware/teensy41/signer/tests/host_test_parse_bip44_path.c new file mode 100644 index 0000000..f698b9b --- /dev/null +++ b/firmware/teensy41/signer/tests/host_test_parse_bip44_path.c @@ -0,0 +1,248 @@ +/* host_test_parse_bip44_path.c — unit test for parse_bip44_path(). + * + * parse_bip44_path() is a pure-C function with no crypto dependencies, so it + * can be tested host-side by compiling key_derivation.cpp with -DHOST_TEST + * and stubbing out the Arduino/nostr_core/secp256k1 calls it does not use. + * + * Actually, parse_bip44_path() is self-contained inside key_derivation.cpp, + * but key_derivation.cpp pulls in Arduino.h, secp256k1, nostr_core, etc. To + * avoid dragging all of that into a host build, this test re-implements the + * parser check by #including a standalone copy of the function via a + * HOST_TEST guard. The simplest approach: compile a tiny .c that defines + * the function directly (copied from key_derivation.cpp) and tests it. + * + * Build: + * cc -O2 -Wall -Wextra -o host_test_parse_bip44_path \ + * firmware/teensy41/signer/tests/host_test_parse_bip44_path.c + * ./host_test_parse_bip44_path + * + * If parse_bip44_path() in key_derivation.cpp is ever changed, copy the new + * body into the function below to keep this test in sync. + */ + +#include +#include +#include +#include +#include + +#define BIP32_HARDENED_FLAG 0x80000000u + +/* Copied from key_derivation.cpp — kept in sync manually. */ +static int parse_bip44_path(const char *path_str, uint32_t *out, int max_segments) { + char buf[128]; + char *p; + int count = 0; + + if (path_str == NULL || out == NULL || max_segments <= 0) { + return -1; + } + + { + size_t plen = strlen(path_str); + if (plen >= sizeof(buf)) { + return -1; + } + memcpy(buf, path_str, plen); + buf[plen] = '\0'; + } + + p = buf; + if (*p == 'm' || *p == 'M') { + p++; + if (*p == '/') { + p++; + } else if (*p != '\0') { + return -1; + } + } + + while (*p != '\0' && count < max_segments) { + char *slash = strchr(p, '/'); + char seg[24]; + size_t seg_len; + int hardened = 0; + char *endptr = NULL; + long val; + + if (slash != NULL) { + seg_len = (size_t)(slash - p); + } else { + seg_len = strlen(p); + } + if (seg_len == 0 || seg_len >= sizeof(seg)) { + return -1; + } + memcpy(seg, p, seg_len); + seg[seg_len] = '\0'; + + if (seg[seg_len - 1] == '\'' || seg[seg_len - 1] == 'h' || + seg[seg_len - 1] == 'H') { + hardened = 1; + seg[seg_len - 1] = '\0'; + /* A bare hardened marker with no number (e.g. "m/0/'") is invalid. */ + if (seg[0] == '\0') { + return -1; + } + } + + val = strtol(seg, &endptr, 10); + if (*endptr != '\0' || val < 0 || val > 0x7FFFFFFF) { + return -1; + } + + out[count] = (uint32_t)val; + if (hardened) { + out[count] |= BIP32_HARDENED_FLAG; + } + count++; + + p = (slash != NULL) ? slash + 1 : ""; + if (*p == '\0') { + break; + } + } + + return count; +} + +static int failures = 0; +static int passes = 0; + +#define CHECK(cond, msg) do { \ + if (cond) { passes++; } \ + else { failures++; printf("FAIL: %s\n", msg); } \ +} while (0) + +static void check_path(const char *path, const uint32_t *expected, int expected_len) { + uint32_t out[16]; + int n = parse_bip44_path(path, out, 16); + char msg[256]; + snprintf(msg, sizeof(msg), "parse_bip44_path(\"%s\") returned %d (expected %d)", path, n, expected_len); + CHECK(n == expected_len, msg); + if (n == expected_len) { + for (int i = 0; i < expected_len; i++) { + snprintf(msg, sizeof(msg), "parse_bip44_path(\"%s\") segment %d = 0x%08x (expected 0x%08x)", + path, i, out[i], expected[i]); + CHECK(out[i] == expected[i], msg); + } + } +} + +int main(void) { + /* NIP-06 standard Nostr path: m/44'/1237'/0'/0/0 */ + { + uint32_t exp[] = { + 44u | BIP32_HARDENED_FLAG, + 1237u | BIP32_HARDENED_FLAG, + 0u | BIP32_HARDENED_FLAG, + 0u, + 0u, + }; + check_path("m/44'/1237'/0'/0/0", exp, 5); + } + + /* NIP-06 with nostr_index 5: m/44'/1237'/0'/0/5 */ + { + uint32_t exp[] = { + 44u | BIP32_HARDENED_FLAG, + 1237u | BIP32_HARDENED_FLAG, + 0u | BIP32_HARDENED_FLAG, + 0u, + 5u, + }; + check_path("m/44'/1237'/0'/0/5", exp, 5); + } + + /* All-hardened variant: m/44'/1237'/0'/0'/0' */ + { + uint32_t exp[] = { + 44u | BIP32_HARDENED_FLAG, + 1237u | BIP32_HARDENED_FLAG, + 0u | BIP32_HARDENED_FLAG, + 0u | BIP32_HARDENED_FLAG, + 0u | BIP32_HARDENED_FLAG, + }; + check_path("m/44'/1237'/0'/0'/0'", exp, 5); + } + + /* 'h' hardened marker: m/44h/1237h/0h/0/0 */ + { + uint32_t exp[] = { + 44u | BIP32_HARDENED_FLAG, + 1237u | BIP32_HARDENED_FLAG, + 0u | BIP32_HARDENED_FLAG, + 0u, + 0u, + }; + check_path("m/44h/1237h/0h/0/0", exp, 5); + } + + /* 'H' hardened marker: m/44H/1237H/0H/0/0 */ + { + uint32_t exp[] = { + 44u | BIP32_HARDENED_FLAG, + 1237u | BIP32_HARDENED_FLAG, + 0u | BIP32_HARDENED_FLAG, + 0u, + 0u, + }; + check_path("m/44H/1237H/0H/0/0", exp, 5); + } + + /* SSH ed25519 path: m/44'/102001'/0'/0'/0' */ + { + uint32_t exp[] = { + 44u | BIP32_HARDENED_FLAG, + 102001u | BIP32_HARDENED_FLAG, + 0u | BIP32_HARDENED_FLAG, + 0u | BIP32_HARDENED_FLAG, + 0u | BIP32_HARDENED_FLAG, + }; + check_path("m/44'/102001'/0'/0'/0'", exp, 5); + } + + /* No 'm' prefix: 44'/1237'/0'/0/0 */ + { + uint32_t exp[] = { + 44u | BIP32_HARDENED_FLAG, + 1237u | BIP32_HARDENED_FLAG, + 0u | BIP32_HARDENED_FLAG, + 0u, + 0u, + }; + check_path("44'/1237'/0'/0/0", exp, 5); + } + + /* Just "m" (root key, no segments) */ + { + check_path("m", NULL, 0); + } + + /* Empty string (root key, no segments) */ + { + check_path("", NULL, 0); + } + + /* Error cases */ + { + uint32_t out[16]; + CHECK(parse_bip44_path(NULL, out, 16) == -1, "NULL path_str rejected"); + CHECK(parse_bip44_path("m/", out, 0) == -1, "max_segments=0 rejected"); + CHECK(parse_bip44_path("m/abc", out, 16) == -1, "non-numeric segment rejected"); + CHECK(parse_bip44_path("m/44'/1237'/0'/0/-1", out, 16) == -1, "negative index rejected"); + CHECK(parse_bip44_path("m/44'/1237'/0'/0/99999999999", out, 16) == -1, "overflow index rejected"); + CHECK(parse_bip44_path("m44", out, 16) == -1, "m without / rejected"); + CHECK(parse_bip44_path("m/44'/1237'/0'/0/'", out, 16) == -1, "hardened marker with no number rejected"); + } + + /* Large index within range (0x7FFFFFFF = 2147483647, the max non-hardened) */ + { + uint32_t exp[] = { 0x7FFFFFFFu }; + check_path("m/2147483647", exp, 1); + } + + printf("\n=== parse_bip44_path host test: %d passed, %d failed ===\n", + passes, failures); + return failures == 0 ? 0 : 1; +} diff --git a/firmware/teensy41/signer/tests/host_test_role_table.c b/firmware/teensy41/signer/tests/host_test_role_table.c new file mode 100644 index 0000000..5dc0afc --- /dev/null +++ b/firmware/teensy41/signer/tests/host_test_role_table.c @@ -0,0 +1,275 @@ +/* host_test_role_table.c — unit test for the role table path-template matching. + * + * Tests role_path_matches_template(), role_path_extract_index(), and + * role_path_matches_with_range() — the pure-string matching logic from + * role_table.cpp. The table add/find operations are trivial array ops and + * are tested implicitly via the range checks. + * + * As with host_test_parse_bip44_path.c, the matching functions are copied + * from role_table.cpp into this test to avoid pulling in Arduino.h and the + * rest of the firmware build. Keep the copies in sync if role_table.cpp + * changes. + * + * Build: + * cc -O2 -Wall -Wextra -o host_test_role_table \ + * firmware/teensy41/signer/tests/host_test_role_table.c + * ./host_test_role_table + */ + +#include +#include +#include +#include +#include + +/* ---- Minimal role_entry_t for the range check (only the fields used by + * role_path_matches_with_range are needed) ---- */ +typedef struct { + char role_path[128]; + int path_range_lo; + int path_range_hi; +} role_entry_t; + +/* ---- Copied from role_table.cpp — kept in sync manually ---- */ + +static int role_path_matches_template(const char *path, const char *template_str) { + const char *p = path; + const char *t = template_str; + + if (path == NULL || template_str == NULL) { + return 0; + } + + while (*t != '\0' && *p != '\0') { + if (*t == '%' && *(t + 1) == 'd') { + t += 2; + if (*t == '\'' || *t == 'h' || *t == 'H') { + t++; + } + if (*p == '/') { + return 0; + } + while (*p != '\0' && *p != '/') { + p++; + } + if (*t == '/' && *p == '/') { + t++; + p++; + } else if (*t == '\0' && *p == '\0') { + return 1; + } else if (*t == '\0' && *p == '/') { + return 0; + } else if (*t == '/' && *p == '\0') { + return 0; + } + } else if (*t == *p) { + t++; + p++; + } else { + return 0; + } + } + + return (*t == '\0' && *p == '\0') ? 1 : 0; +} + +static int role_path_extract_index(const char *path, const char *template_str) { + const char *p = path; + const char *t = template_str; + const char *seg_start; + char seg_buf[32]; + size_t seg_len; + long val; + char *endp; + + if (path == NULL || template_str == NULL) { + return -1; + } + + if (strstr(template_str, "%d") == NULL) { + return -1; + } + + while (*t != '\0' && *p != '\0') { + if (*t == '%' && *(t + 1) == 'd') { + t += 2; + if (*t == '\'' || *t == 'h' || *t == 'H') { + t++; + } + if (*p == '/') { + return -1; + } + seg_start = p; + while (*p != '\0' && *p != '/') { + p++; + } + seg_len = (size_t)(p - seg_start); + if (seg_len == 0 || seg_len >= sizeof(seg_buf)) { + return -1; + } + memcpy(seg_buf, seg_start, seg_len); + seg_buf[seg_len] = '\0'; + if (seg_len > 0 && + (seg_buf[seg_len - 1] == '\'' || seg_buf[seg_len - 1] == 'h' || + seg_buf[seg_len - 1] == 'H')) { + seg_buf[seg_len - 1] = '\0'; + } + endp = NULL; + val = strtol(seg_buf, &endp, 10); + if (*endp != '\0' || val < 0) { + return -1; + } + return (int)val; + } else if (*t == *p) { + t++; + p++; + } else { + return -1; + } + } + + return -1; +} + +static int role_path_matches_with_range(const char *path, const role_entry_t *role) { + int index; + + if (path == NULL || role == NULL) { + return 0; + } + + if (strstr(role->role_path, "%d") == NULL) { + return role_path_matches_template(path, role->role_path); + } + + if (!role_path_matches_template(path, role->role_path)) { + return 0; + } + + index = role_path_extract_index(path, role->role_path); + if (index < 0) { + return 0; + } + + if (role->path_range_lo < 0 || role->path_range_hi < 0) { + return 0; + } + return (index >= role->path_range_lo && index <= role->path_range_hi) ? 1 : 0; +} + +/* ---- Test harness ---- */ + +static int failures = 0; +static int passes = 0; + +#define CHECK(cond, msg) do { \ + if (cond) { passes++; } \ + else { failures++; printf("FAIL: %s\n", msg); } \ +} while (0) + +int main(void) { + /* ---- role_path_matches_template ---- */ + + /* Fixed path: exact match */ + CHECK(role_path_matches_template("m/44'/1237'/0'/0/0", "m/44'/1237'/0'/0/0") == 1, + "fixed path exact match"); + CHECK(role_path_matches_template("m/44'/1237'/0'/0/1", "m/44'/1237'/0'/0/0") == 0, + "fixed path mismatch rejected"); + CHECK(role_path_matches_template("m/44'/1237'/0'/0/0/extra", "m/44'/1237'/0'/0/0") == 0, + "path longer than template rejected"); + CHECK(role_path_matches_template("m/44'/1237'/0'/0", "m/44'/1237'/0'/0/0") == 0, + "path shorter than template rejected"); + + /* Template with %d (hardened) */ + CHECK(role_path_matches_template("m/44'/1237'/5'/0/0", "m/44'/1237'/%d'/0/0") == 1, + "template %d' matches index 5"); + CHECK(role_path_matches_template("m/44'/1237'/0'/0/0", "m/44'/1237'/%d'/0/0") == 1, + "template %d' matches index 0"); + CHECK(role_path_matches_template("m/44'/1237'/100'/0/0", "m/44'/1237'/%d'/0/0") == 1, + "template %d' matches index 100"); + /* NOTE: the hardened marker after %d in the template is OPTIONAL — the + * matcher skips it in the template but does not require it in the path. + * This matches the host's behavior (src/role_table.c). So %d' matches + * both hardened (5') and unhardened (5) path segments structurally. The + * range check then validates the numeric index. */ + CHECK(role_path_matches_template("m/44'/1237'/5/0/0", "m/44'/1237'/%d'/0/0") == 1, + "template %d' matches unhardened path segment (host behavior)"); + CHECK(role_path_matches_template("m/44'/1237'/abc'/0/0", "m/44'/1237'/%d'/0/0") == 1, + "template %d' structurally matches non-numeric (range check catches it)"); + + /* Template with %d (unhardened) */ + CHECK(role_path_matches_template("m/44'/1237'/5/0/0", "m/44'/1237'/%d/0/0") == 1, + "template %d matches unhardened index 5"); + + /* NULL cases */ + CHECK(role_path_matches_template(NULL, "m/44'") == 0, "NULL path rejected"); + CHECK(role_path_matches_template("m/44'", NULL) == 0, "NULL template rejected"); + + /* ---- role_path_extract_index ---- */ + + CHECK(role_path_extract_index("m/44'/1237'/5'/0/0", "m/44'/1237'/%d'/0/0") == 5, + "extract index 5 from hardened template"); + CHECK(role_path_extract_index("m/44'/1237'/0'/0/0", "m/44'/1237'/%d'/0/0") == 0, + "extract index 0 from hardened template"); + CHECK(role_path_extract_index("m/44'/1237'/42/0/0", "m/44'/1237'/%d/0/0") == 42, + "extract index 42 from unhardened template"); + CHECK(role_path_extract_index("m/44'/1237'/0'/0/0", "m/44'/1237'/0'/0/0") == -1, + "extract index from fixed path returns -1"); + CHECK(role_path_extract_index("m/44'/1237'/abc'/0/0", "m/44'/1237'/%d'/0/0") == -1, + "extract index from non-numeric returns -1"); + + /* ---- role_path_matches_with_range ---- */ + + /* Fixed path role */ + { + role_entry_t r; + memset(&r, 0, sizeof(r)); + strncpy(r.role_path, "m/44'/1237'/0'/0/0", sizeof(r.role_path) - 1); + r.path_range_lo = -1; + r.path_range_hi = -1; + CHECK(role_path_matches_with_range("m/44'/1237'/0'/0/0", &r) == 1, + "fixed path role matches its path"); + CHECK(role_path_matches_with_range("m/44'/1237'/0'/0/1", &r) == 0, + "fixed path role rejects different path"); + } + + /* Range role: m/44'/1237'/%d'/0/0, range 0-100 */ + { + role_entry_t r; + memset(&r, 0, sizeof(r)); + strncpy(r.role_path, "m/44'/1237'/%d'/0/0", sizeof(r.role_path) - 1); + r.path_range_lo = 0; + r.path_range_hi = 100; + CHECK(role_path_matches_with_range("m/44'/1237'/0'/0/0", &r) == 1, + "range role accepts index 0"); + CHECK(role_path_matches_with_range("m/44'/1237'/50'/0/0", &r) == 1, + "range role accepts index 50"); + CHECK(role_path_matches_with_range("m/44'/1237'/100'/0/0", &r) == 1, + "range role accepts index 100 (boundary)"); + CHECK(role_path_matches_with_range("m/44'/1237'/101'/0/0", &r) == 0, + "range role rejects index 101 (out of bounds)"); + /* The hardened marker after %d is optional in the matcher, so an + * unhardened segment that's in range is accepted. This matches the + * host's behavior. (If hardened-only enforcement is ever needed, the + * matcher would have to check the segment's trailing marker.) */ + CHECK(role_path_matches_with_range("m/44'/1237'/5/0/0", &r) == 1, + "range role accepts unhardened segment in range (host behavior)"); + CHECK(role_path_matches_with_range("m/44'/1237'/abc'/0/0", &r) == 0, + "range role rejects non-numeric segment"); + } + + /* Range role with no range configured (lo/hi = -1) — fail-closed */ + { + role_entry_t r; + memset(&r, 0, sizeof(r)); + strncpy(r.role_path, "m/44'/1237'/%d'/0/0", sizeof(r.role_path) - 1); + r.path_range_lo = -1; + r.path_range_hi = -1; + CHECK(role_path_matches_with_range("m/44'/1237'/0'/0/0", &r) == 0, + "template role with no range denies (fail-closed)"); + } + + printf("\n=== role_table host test: %d passed, %d failed ===\n", + passes, failures); + return failures == 0 ? 0 : 1; +} diff --git a/firmware/teensy41/test_classical.py b/firmware/teensy41/test_classical.py index a41da04..f20e459 100644 --- a/firmware/teensy41/test_classical.py +++ b/firmware/teensy41/test_classical.py @@ -121,23 +121,25 @@ def main(): t("derive", lambda: call(ser, "derive", ["derive-test", {"algorithm": "secp256k1", "index": 1}])) # nostr npub = [None] + # Role + role_path selector (replaces the deprecated nostr_index). + main_role = {"role": "main", "role_path": "m/44'/1237'/0'/0/0"} def ngpk(): - r = call(ser, "nostr_get_public_key", [{"nostr_index": 0}]) + r = call(ser, "nostr_get_public_key", [main_role]) npub[0] = r["result"] t("nostr_get_public_key", ngpk) def nse(): ev = {"kind": 1, "created_at": int(time.time()), "tags": [], "content": "hello event"} - call(ser, "nostr_sign_event", [ev, {"nostr_index": 0}]) + call(ser, "nostr_sign_event", [ev, main_role]) t("nostr_sign_event", nse) # nip04 def nip04(): - r = call(ser, "nostr_nip04_encrypt", [npub[0], "hello via nip04", {"nostr_index": 0}]) - call(ser, "nostr_nip04_decrypt", [npub[0], r["result"], {"nostr_index": 0}]) + r = call(ser, "nostr_nip04_encrypt", [npub[0], "hello via nip04", main_role]) + call(ser, "nostr_nip04_decrypt", [npub[0], r["result"], main_role]) t("nip04 round-trip", nip04) # nip44 def nip44(): - r = call(ser, "nostr_nip44_encrypt", [npub[0], "hello via nip44", {"nostr_index": 0}]) - call(ser, "nostr_nip44_decrypt", [npub[0], r["result"], {"nostr_index": 0}]) + r = call(ser, "nostr_nip44_encrypt", [npub[0], "hello via nip44", main_role]) + call(ser, "nostr_nip44_decrypt", [npub[0], r["result"], main_role]) t("nip44 round-trip", nip44) except Exception as e: print(f"\n!! STOPPED: {e}", flush=True) diff --git a/firmware/teensy41/test_nip04.py b/firmware/teensy41/test_nip04.py index c1c790c..92b992a 100644 --- a/firmware/teensy41/test_nip04.py +++ b/firmware/teensy41/test_nip04.py @@ -3,10 +3,11 @@ Flow: 1. get_info (sanity) - 2. nostr_get_public_key (nostr_index=0) -> our x-only secp256k1 pubkey (peer) - 3. nostr_nip04_encrypt [our_pub, "hello via nip04", {nostr_index:0}] + 2. nostr_get_public_key (role=main, role_path=m/44'1237'0'/0/0) + -> our x-only secp256k1 pubkey (peer) + 3. nostr_nip04_encrypt [our_pub, "hello via nip04", {role:main, role_path:...}] -> ciphertext?iv=... - 4. nostr_nip04_decrypt [our_pub, ciphertext, {nostr_index:0}] + 4. nostr_nip04_decrypt [our_pub, ciphertext, {role:main, role_path:...}] -> should recover "hello via nip04" Also tests NIP-44 the same way to verify the is_nip44 dispatch fix. @@ -90,8 +91,11 @@ def main(): if "result" not in r: print("FAIL: get_info"); ok = False + # Role + role_path selector (replaces the deprecated nostr_index). + main_role = {"role": "main", "role_path": "m/44'/1237'/0'/0/0"} + # 2. our nostr pubkey (x-only, 64 hex) - r = call(ser, "nostr_get_public_key", [{"nostr_index": 0}]) + r = call(ser, "nostr_get_public_key", [main_role]) if "result" not in r: print("FAIL: nostr_get_public_key"); ok = False; ser.close(); return 1 our_pub = r["result"] @@ -101,14 +105,14 @@ def main(): # 3. NIP-04 encrypt to ourselves plaintext = "hello via nip04" - r = call(ser, "nostr_nip04_encrypt", [our_pub, plaintext, {"nostr_index": 0}]) + r = call(ser, "nostr_nip04_encrypt", [our_pub, plaintext, main_role]) if "result" not in r: print("FAIL: nostr_nip04_encrypt (this is the crash we are testing)"); ok = False else: cipher = r["result"] print(f" ciphertext = {cipher}") # 4. NIP-04 decrypt - r = call(ser, "nostr_nip04_decrypt", [our_pub, cipher, {"nostr_index": 0}]) + r = call(ser, "nostr_nip04_decrypt", [our_pub, cipher, main_role]) if "result" not in r: print("FAIL: nostr_nip04_decrypt"); ok = False else: @@ -122,13 +126,13 @@ def main(): # 5. NIP-44 encrypt to ourselves (verifies the is_nip44 dispatch fix) plaintext44 = "hello via nip44" - r = call(ser, "nostr_nip44_encrypt", [our_pub, plaintext44, {"nostr_index": 0}]) + r = call(ser, "nostr_nip44_encrypt", [our_pub, plaintext44, main_role]) if "result" not in r: print("FAIL: nostr_nip44_encrypt"); ok = False else: cipher44 = r["result"] print(f" nip44 ciphertext = {cipher44[:60]}...") - r = call(ser, "nostr_nip44_decrypt", [our_pub, cipher44, {"nostr_index": 0}]) + r = call(ser, "nostr_nip44_decrypt", [our_pub, cipher44, main_role]) if "result" not in r: print("FAIL: nostr_nip44_decrypt"); ok = False else: diff --git a/firmware/teensy41/test_signer.py b/firmware/teensy41/test_signer.py index 85a4306..0be0d2e 100644 --- a/firmware/teensy41/test_signer.py +++ b/firmware/teensy41/test_signer.py @@ -229,8 +229,11 @@ def main(): if r and "result" in r: passed += 1 else: failed += 1 + # Role + role_path selector (replaces the deprecated nostr_index). + main_role = {"role": "main", "role_path": "m/44'/1237'/0'/0/0"} + # 8. nostr_get_public_key - r = test_verb(ser, "nostr_get_public_key", [{"nostr_index": 0}]) + r = test_verb(ser, "nostr_get_public_key", [main_role]) nostr_pub = None if r and "result" in r: passed += 1 @@ -243,18 +246,18 @@ def main(): # 9. nostr_sign_event if nostr_pub: event = {"kind": 1, "created_at": int(time.time()), "tags": [], "content": "hello from test_signer"} - r = test_verb(ser, "nostr_sign_event", [event, {"nostr_index": 0}]) + r = test_verb(ser, "nostr_sign_event", [event, main_role]) if r and "result" in r: passed += 1 else: failed += 1 # 10. nostr_nip04_encrypt + decrypt (the bug we fixed) if nostr_pub: nip04_pt = "hello via nip04" - r = test_verb(ser, "nostr_nip04_encrypt", [nostr_pub, nip04_pt, {"nostr_index": 0}]) + r = test_verb(ser, "nostr_nip04_encrypt", [nostr_pub, nip04_pt, main_role]) if r and "result" in r: passed += 1 cipher = r["result"] - r = test_verb(ser, "nostr_nip04_decrypt", [nostr_pub, cipher, {"nostr_index": 0}]) + r = test_verb(ser, "nostr_nip04_decrypt", [nostr_pub, cipher, main_role]) if r and "result" in r and r["result"] == nip04_pt: print(f" ✅ nip04 round-trip plaintext recovered") passed += 1 @@ -267,11 +270,11 @@ def main(): # 11. nostr_nip44_encrypt + decrypt (the is_nip44 dispatch bug we fixed) if nostr_pub: nip44_pt = "hello via nip44" - r = test_verb(ser, "nostr_nip44_encrypt", [nostr_pub, nip44_pt, {"nostr_index": 0}]) + r = test_verb(ser, "nostr_nip44_encrypt", [nostr_pub, nip44_pt, main_role]) if r and "result" in r: passed += 1 cipher44 = r["result"] - r = test_verb(ser, "nostr_nip44_decrypt", [nostr_pub, cipher44, {"nostr_index": 0}]) + r = test_verb(ser, "nostr_nip44_decrypt", [nostr_pub, cipher44, main_role]) if r and "result" in r and r["result"] == nip44_pt: print(f" ✅ nip44 round-trip plaintext recovered") passed += 1 @@ -281,6 +284,40 @@ def main(): else: failed += 1 + # ---- Role + path authorization error cases ---- + print("\n=== Role + path authorization error cases ===") + + # 11a. nostr_index is deprecated → error 2006 + r = test_verb(ser, "nostr_get_public_key", [{"nostr_index": 0}]) + if r and "error" in r and r["error"].get("code") == 2006: + print(f" ✅ nostr_index rejected with error 2006 (deprecated)") + passed += 1 + else: + print(f" ❌ nostr_index not rejected as expected: {r}") + failed += 1 + + # 11b. unknown role → error 1002 + r = test_verb(ser, "nostr_get_public_key", + [{"role": "nonexistent", "role_path": "m/44'/1237'/0'/0/0"}]) + if r and "error" in r and r["error"].get("code") == 1002: + print(f" ✅ unknown role rejected with error 1002") + passed += 1 + else: + print(f" ❌ unknown role not rejected as expected: {r}") + failed += 1 + + # 11c. path out of range → error 2003 + # (Requires a range role; the default "main" role is fixed-path, so this + # tests a path that doesn't match the fixed template.) + r = test_verb(ser, "nostr_get_public_key", + [{"role": "main", "role_path": "m/44'/1237'/999'/0/0"}]) + if r and "error" in r and r["error"].get("code") == 2003: + print(f" ✅ path mismatch rejected with error 2003") + passed += 1 + else: + print(f" ❌ path mismatch not rejected as expected: {r}") + failed += 1 + # ---- PQ verbs (tested LAST: heap-heavy, may crash the device) ---- print("\n=== PQ verbs (heap-heavy; tested last) ===") diff --git a/plans/teensy41_role_path_migration.md b/plans/teensy41_role_path_migration.md new file mode 100644 index 0000000..34f6a19 --- /dev/null +++ b/plans/teensy41_role_path_migration.md @@ -0,0 +1,448 @@ +# Plan: Migrate Teensy 4.1 Signer to the Role + Path Authorization Model + +## Status: Implemented (Phases 1-7) — pending hardware flash + verification + +> All 7 phases are implemented. Host-side unit tests pass (79 assertions: +> 53 for `parse_bip44_path`, 26 for `role_table` path matching). The +> firmware code compiles pending an on-device build (`build_signer.sh`) and +> the hardware test suite (`test_signer.py` etc.) needs a Teensy 4.1 flash +> to verify end-to-end. The `ALLOW_DEPRECATED_NOSTR_INDEX` flag is set to 0 +> (nostr_index rejected with error 2006, matching the host). + +## Problem + +The host `n_signer` has migrated to a **role + path authorization model** (see +[`plans/role_path_authorization.md`](role_path_authorization.md) and +[`plans/role_as_password_default.md`](role_as_password_default.md)): + +- `nostr_index` is **deprecated** — the host returns error `2006` with the + message *"nostr_index is deprecated — use --role main --path + m/44'/1237'/N'/0/0 instead"* ([`src/selector.c:759`](../src/selector.c:759)). +- Clients must send `{"role":"","role_path":"m/44'/1237'/0'/0/0"}` for + all `nostr_*` verbs. +- **Role-as-password**: roles default to `requires_approval = 0` — knowing the + role name is sufficient authorization, no interactive prompt needed. +- A **role table** with presets, path templates (`%d` placeholders), and + range/set validation governs which paths are allowed. + +The Teensy 4.1 firmware is **out of sync**. It still uses `nostr_index` +exclusively: + +- [`firmware/teensy41/signer/src/dispatch.cpp:868`](../firmware/teensy41/signer/src/dispatch.cpp:868) + — `parse_nostr_index_from_params()` is the only selector parser. +- [`firmware/teensy41/signer/src/dispatch.cpp:1958`](../firmware/teensy41/signer/src/dispatch.cpp:1958) + — every `nostr_*` verb calls `derive_request_key(nostr_index, ...)`. +- [`firmware/teensy41/signer/src/key_derivation.h:31`](../firmware/teensy41/signer/src/key_derivation.h:31) + — only `derive_secp256k1_keys_index(nostr_index)` exists; there is no + path-based derivation entry point. +- There is **no role table**, no wizard, no path-template matching, and no + `requires_approval` flag. Every `nostr_*` verb prompts for approval via + `ui_approve()`. + +The CYD firmware +([`firmware/cyd_esp32_2432s028/main/main.c`](../firmware/cyd_esp32_2432s028/main/main.c)) +is in the same state — this plan focuses on the Teensy 4.1, but the CYD will +need the same migration afterwards. + +## Goal + +Bring the Teensy 4.1 signer's `nostr_*` verb handling into parity with the +host's role + path model: + +1. Accept `{"role":"","role_path":""}` and derive the key from the + explicit BIP-44 path (not a `nostr_index` integer). +2. Maintain a **role table** populated at boot via an LVGL role-preset wizard + (the touch-screen equivalent of the host's terminal wizard). +3. Implement **role-as-password**: roles with `requires_approval = 0` authorize + immediately; only `requires_approval = 1` roles prompt via `ui_approve()`. +4. Reject `nostr_index` with the same `2006` error the host returns. +5. Keep the algorithm-based verbs (`sign`, `get_public_key`, `derive`, etc.) + unchanged — they use `algorithm` + `index`, not roles. + +## What already exists in the Teensy 4.1 firmware + +The good news: the hard crypto plumbing is already there. + +- **BIP-32 path derivation**: [`nostr_bip32_key_from_seed()`](../firmware/teensy41/signer/src/nostr_core/nostr_utils.c:1445) + and [`nostr_bip32_derive_path()`](../firmware/teensy41/signer/src/nostr_core/nostr_utils.c:1565) + are already compiled into the firmware (used by NIP-06). We just need a new + entry point that takes a path string instead of a fixed `nostr_index`. +- **Path parsing**: the host's [`parse_bip44_path()`](../src/key_store.c:685) + is a ~75-line pure-C function that splits `m/44'/1237'/0'/0/0` into a + `uint32_t[]` with hardened-bit handling. It ports directly (it uses only + `strtol`, `strlen`, and the `'`/`h` markers). +- **LVGL UI**: [`ui.h`](../firmware/teensy41/signer/src/ui.h) already has + modal screen primitives (`ui_show_mnemonic`, `ui_enter_mnemonic`, + `ui_approve`, `ui_pick_pad`) that pump LVGL while blocking. A role-wizard + screen follows the same pattern. +- **cJSON**: already vendored for request parsing. +- **Memory**: the v0.1.6 `.rodata` → FLASH move + ([`plans/teensy41_memory_evaluation.md`](teensy41_memory_evaluation.md) + Solution A) left **130.9 KB of free stack** — plenty of headroom for a role + table and path strings. + +## Architecture + +```mermaid +flowchart TD + Boot[signer.ino boot] --> Menu[startup menu
generate or enter mnemonic] + Menu --> Wizard[role_wizard UI
LVGL preset menu] + Wizard --> Table[role_table_t
in-RAM, DMAMEM] + Table --> Idle[ui_show_idle
show npub + role count] + Idle --> Frame[transport_read_frame] + Frame --> Parse[dispatch.cpp
parse JSON-RPC] + Parse --> Sel{nostr_* verb?} + Sel -- Yes --> Role[selector_resolve
role + role_path] + Role --> Match{role found + path matches?} + Match -- No --> Err[error 2006 or 2003] + Match -- Yes --> Approve{requires_approval?} + Approve -- No --> Derive[derive_secp256k1_from_path] + Approve -- Yes --> UI[ui_approve prompt] + UI -- Approve --> Derive + UI -- Deny --> Deny[deny JSON] + Derive --> Exec[execute nostr verb] + Exec --> Resp[structured JSON result] + Resp --> Frame + Sel -- No --> Alg[algorithm-based verbs
unchanged] + Alg --> Resp +``` + +## File layout (new + modified) + +``` +firmware/teensy41/signer/ +├── signer.ino (modified — boot flow calls role wizard) +├── src/ +│ ├── role_table.h (NEW — role_entry_t, role_table_t, presets) +│ ├── role_table.cpp (NEW — table ops, path matching, presets) +│ ├── selector.h (NEW — selector_request_t, selector_resolve) +│ ├── selector.cpp (NEW — parse role/role_path, reject nostr_index) +│ ├── key_derivation.h (modified — add derive_secp256k1_from_path) +│ ├── key_derivation.cpp (modified — add path-based derivation) +│ ├── dispatch.cpp (modified — nostr_* verbs use selector + role) +│ ├── dispatch.h (modified — extern role table, error codes) +│ ├── ui.h (modified — add ui_role_wizard) +│ └── ui.cpp (modified — implement role wizard screen) +``` + +## Implementation phases + +### Phase 1 — Port the path parser + path-based secp256k1 derivation + +**Goal:** derive a secp256k1 keypair from an arbitrary BIP-44 path string, +independent of `nostr_index`. + +- [ ] Add `parse_bip44_path()` to + [`key_derivation.cpp`](../firmware/teensy41/signer/src/key_derivation.cpp) + — port from [`src/key_store.c:685`](../src/key_store.c:685). Pure C, + ~75 lines. Handles `m/`, `'`/`h`/`H` hardened markers, up to 16 segments. + Mark it `__attribute__((section(".flashmem")))` to keep ITCM small. +- [ ] Add `derive_secp256k1_from_path()` to + [`key_derivation.cpp`](../firmware/teensy41/signer/src/key_derivation.cpp) + — port from [`src/key_store.c:765`](../src/key_store.c:765). Calls + `nostr_bip32_key_from_seed` + `parse_bip44_path` + + `nostr_bip32_derive_path`, returns 32-byte privkey + 32-byte x-only + pubkey. Mark `.flashmem`. +- [ ] Declare both in [`key_derivation.h`](../firmware/teensy41/signer/src/key_derivation.h): + ```cpp + int parse_bip44_path(const char *path_str, uint32_t *out, int max_segments); + int derive_secp256k1_from_path(const uint8_t *seed, size_t seed_len, + const char *path_str, + uint8_t *privkey, uint8_t *pubkey); + ``` +- [ ] **Host-side unit test**: add a host-buildable test that links + `key_derivation.cpp` (compiled with `HOST_TEST` against the nostr_core + C files) and verifies `derive_secp256k1_from_path("m/44'/1237'/0'/0/0")` + produces the same pubkey as + `derive_secp256k1_keys_index(0)` (the existing NIP-06 path is + `m/44'/1237'/0'/0/0` — they must match). Also test a hardened variant + (`m/44'/1237'/0'/0'/0'`) produces a different key. + +**Exit criterion:** path-based derivation produces byte-identical keys to the +existing `nostr_index` path for the same BIP-44 path, and different keys for +different paths. + +### Phase 2 — Role table + path-template matching + +**Goal:** an in-RAM role table with the same semantics as the host's +[`src/role_table.c`](../src/role_table.c), sized for the Teensy's memory. + +- [ ] Create [`role_table.h`](../firmware/teensy41/signer/src/role_table.h) + with a **slimmed-down** `role_entry_t` (the host's struct has 256-entry + arrays and 64-int allowed-indices sets — too big for the Teensy; cap at + `ROLE_TABLE_MAX_ENTRIES 16` and `path_allowed_indices[16]`): + ```cpp + typedef enum { PURPOSE_NOSTR, PURPOSE_SSH, PURPOSE_AGE, + PURPOSE_PQ_SIG, PURPOSE_PQ_KEM } role_purpose_t; + typedef enum { CURVE_SECP256K1, CURVE_ED25519, CURVE_X25519, + CURVE_ML_DSA_65, CURVE_SLH_DSA_128S, + CURVE_ML_KEM_768 } role_curve_t; + + typedef struct { + char name[32]; + char role_path[128]; /* template, may contain one "%d" */ + role_purpose_t purpose; + role_curve_t curve; + int path_range_lo; /* -1 = fixed path (no %d) */ + int path_range_hi; + int path_default_index; /* -1 = require explicit */ + int requires_approval; /* 0 = role-as-password, 1 = prompt */ + int derived; + char pubkey_hex[65]; /* filled after first derivation */ + } role_entry_t; + + typedef struct { + role_entry_t entries[16]; + int count; + } role_table_t; + ``` +- [ ] Create [`role_table.cpp`](../firmware/teensy41/signer/src/role_table.cpp) + with: + - `role_table_init()`, `role_table_add()`, `role_table_find_by_name()`. + - `role_path_matches_template()` — port from + [`src/role_table.c:956`](../src/role_table.c:956). Handles one `%d` + placeholder. + - `role_path_extract_index()` — port from + [`src/role_table.c:1007`](../src/role_table.c:1007). + - `role_path_matches_with_range()` — port from + [`src/role_table.c:1070`](../src/role_table.c:1070). Combines template + match + range check. + - `role_table_get_default()` — returns the role named `"main"`. + - All marked `.flashmem` where reasonable. +- [ ] **Host-side unit test**: link `role_table.cpp` with `HOST_TEST` and + verify: fixed-path match, template match with `%d`, range rejection + (index out of bounds), unknown role returns NULL. + +**Exit criterion:** role table operations match the host's semantics for the +subset of features we need (single `%d` placeholder, range bounds). + +### Phase 3 — Selector: parse role + role_path, reject nostr_index + +**Goal:** a `selector_resolve()` that mirrors the host's +[`src/selector.c:745`](../src/selector.c:745) decision tree. + +- [ ] Create [`selector.h`](../firmware/teensy41/signer/src/selector.h): + ```cpp + typedef struct { + int has_role; char role_name[32]; + int has_role_path; char role_path[128]; + int has_nostr_index; uint32_t nostr_index; + int has_index; uint32_t index; + } selector_request_t; + + #define SELECTOR_OK 0 + #define SELECTOR_ERR_NOT_FOUND -1 + #define SELECTOR_ERR_NO_DEFAULT -3 + #define SELECTOR_ERR_PATH_MISMATCH -4 + #define SELECTOR_ERR_NOSTR_INDEX_DEPRECATED -5 + #define SELECTOR_ERR_PATH_REQUIRED -6 + #define SELECTOR_ERR_ROLE_REQUIRED -7 + ``` +- [ ] Create [`selector.cpp`](../firmware/teensy41/signer/src/selector.cpp) + with `selector_resolve()` — port the decision tree from + [`src/selector.c:745`](../src/selector.c:745): + - `has_nostr_index` → return `SELECTOR_ERR_NOSTR_INDEX_DEPRECATED`. + - `has_role_path` without `has_role` → `SELECTOR_ERR_ROLE_REQUIRED`. + - `has_role` + `has_role_path` → find role, verify path matches template + + range, return the role entry. + - `has_role` only → if fixed path (no `%d`), use it; else + `SELECTOR_ERR_PATH_REQUIRED`. + - Neither → use default role (`"main"`), else `SELECTOR_ERR_NO_DEFAULT`. +- [ ] Add a parser in `selector.cpp` that extracts `role` / `role_path` / + `nostr_index` / `index` from the trailing cJSON options object of a + `nostr_*` verb's params array (replacing + [`parse_nostr_index_from_params()`](../firmware/teensy41/signer/src/dispatch.cpp:868)). + +**Exit criterion:** selector returns the correct error code for each +deprecated/missing/mismatched case, and the correct role entry for valid +role+path combinations. + +### Phase 4 — Wire selector + role table into dispatch + +**Goal:** the `nostr_*` verbs in +[`dispatch.cpp`](../firmware/teensy41/signer/src/dispatch.cpp) use the +selector + role table instead of `nostr_index`. + +- [ ] Add a global `role_table_t g_roles` (in `DMAMEM`) declared `extern` in + [`dispatch.h`](../firmware/teensy41/signer/src/dispatch.h), populated by + the boot flow (Phase 6). +- [ ] Replace `parse_nostr_index_from_params()` + `derive_request_key()` in + each `nostr_*` verb handler with: + 1. Parse the selector request from params. + 2. Call `selector_resolve(&req, &g_roles, &role)`. + 3. On `SELECTOR_ERR_NOSTR_INDEX_DEPRECATED` → return error `2006` with + the host's exact message. + 4. On `SELECTOR_ERR_NOT_FOUND` → error `1002 unknown_role`. + 5. On `SELECTOR_ERR_PATH_MISMATCH` → error `2003 path_not_allowed`. + 6. On success → derive the key via + `derive_secp256k1_from_path(g_seed, g_seed_len, req.role_path, ...)`. + 7. If `role->requires_approval == 1` → call `ui_approve()`; else skip + the prompt (role-as-password). +- [ ] The verbs to update (all in + [`dispatch.cpp`](../firmware/teensy41/signer/src/dispatch.cpp)): + - `nostr_get_public_key` (~line 1958) + - `nostr_sign_event` (~line 2010) + - `nostr_mine_event` (~line 2049) + - `nostr_nip04_encrypt` / `nostr_nip04_decrypt` (~line 2246) + - `nostr_nip44_encrypt` / `nostr_nip44_decrypt` (same handler area) +- [ ] Update `get_info` to report the configured roles in the response (the + host's `get_info` lists roles; the Teensy currently does not). +- [ ] **Keep `nostr_index` working as a hidden fallback** behind a + `#define ALLOW_DEPRECATED_NOSTR_INDEX 0` compile flag, default off, so + the old `test_signer.py` can still run during migration by flipping the + flag. Remove the flag entirely once tests are updated. + +**Exit criterion:** a `nostr_get_public_key` request with +`{"role":"main","role_path":"m/44'/1237'/0'/0/0"}` returns the same pubkey as +the old `{"nostr_index":0}` request. A request with `{"nostr_index":0}` +returns error `2006`. + +### Phase 5 — Role-preset wizard UI (LVGL) + +**Goal:** a touch-screen role wizard that runs at boot, mirroring the host's +terminal preset menu ([`src/main.c:2050`](../src/main.c:2050)). + +- [ ] Add `ui_role_wizard()` to [`ui.h`](../firmware/teensy41/signer/src/ui.h) + / [`ui.cpp`](../firmware/teensy41/signer/src/ui.cpp): + ```cpp + /* Run the role-preset wizard. Fills `out_table` with at least one role. + * Blocks (pumping LVGL) until the user defines at least one role and + * taps "Done". Returns 0 on success, -1 if the user cancels (which + * should abort the boot). */ + int ui_role_wizard(role_table_t *out_table); + ``` +- [ ] Implement the wizard screen as an LVGL list of preset buttons (matching + the host's 10 presets, adapted for the 480×320 screen): + 1. Standard Nostr (secp256k1, `m/44'/1237'/0'/0/0`) + 2. Nostr range (secp256k1, `m/44'/1237'/*'/0/0`, range 0-100) + 3. Nostr agent (secp256k1, `m/44'/1237'/*'/1'/0'`, range 0-100) + 4. SSH (ed25519, `m/44'/102001'/0'/0'/0'`) + 5. Age (x25519, `m/44'/102002'/0'/0'/0'`) + 6. ML-DSA-65 (`m/44'/102003'/0'/0'/0'`) + 7. SLH-DSA-128s (`m/44'/102004'/0'/0'/0'`) + 8. ML-KEM-768 (`m/44'/102005'/0'/0'/0'`) + 9. OTP (no path — binds the SD pad instead) + 10. Custom (text entry for name + path) +- [ ] After a preset is chosen, show a sub-screen to edit the role name + (default from preset) and toggle `requires_approval` (default **off** = + role-as-password, per + [`plans/role_as_password_default.md`](role_as_password_default.md)). +- [ ] Loop: "Add another role?" (Yes/No). At least one role is required; if + the user taps "Done" with zero roles, show an error and re-loop. +- [ ] Use the existing aesthetics (black bg, white text, red accent for the + selected preset, grey for muted). Reuse the button + list primitives + already in [`ui.cpp`](../firmware/teensy41/signer/src/ui.cpp). + +**Exit criterion:** the user can define a "main" Standard Nostr role via touch +and the role table is populated before the idle screen appears. + +### Phase 6 — Boot flow integration + +**Goal:** wire the role wizard into +[`signer.ino`](../firmware/teensy41/signer/signer.ino) between mnemonic entry +and the idle screen. + +- [ ] In [`signer.ino`](../firmware/teensy41/signer/signer.ino) `setup()` / + `apply_mnemonic()`, after the mnemonic is applied and the seed is + derived: + 1. Call `role_table_init(&g_roles)`. + 2. If `DEBUG_AUTO_GENERATE == 1`: auto-populate `g_roles` with a single + "main" role (`m/44'/1237'/0'/0/0`, `requires_approval = 0`) so + headless tests work without the wizard. Log this over Serial. + 3. If `DEBUG_AUTO_GENERATE == 0`: call `ui_role_wizard(&g_roles)`. If it + returns -1 (cancel), abort the boot (show an error screen and halt). + 4. Derive the default role's pubkey for the idle screen (show npub + + role count, matching the host's status display). +- [ ] Update [`ui_show_idle()`](../firmware/teensy41/signer/src/ui.h:59) to + show the role count (e.g. "roles: 3") alongside the npub, mirroring the + host's status line. + +**Exit criterion:** the boot flow goes mnemonic → role wizard → idle screen, +and `g_roles` is populated before any `nostr_*` verb can be dispatched. + +### Phase 7 — Test updates + hardware verification + +**Goal:** the test suite exercises the new role+path model and confirms +parity with the host. + +- [ ] Update [`firmware/teensy41/test_signer.py`](../firmware/teensy41/test_signer.py): + - Replace all `{"nostr_index": N}` options with + `{"role":"main","role_path":"m/44'/1237'/N'/0/0"}`. + - Add a test that sends `{"nostr_index": 0}` and asserts the response is + error `2006`. + - Add a test that sends `{"role":"nonexistent","role_path":"..."}` and + asserts error `1002`. + - Add a test that sends `{"role":"main","role_path":"m/44'/1237'/999'/0/0"}` + (out of range) and asserts error `2003`. + - Add a test that verifies `requires_approval = 0` roles do NOT trigger + `ui_approve` (the response comes back immediately, no 30s prompt). +- [ ] Update [`firmware/teensy41/test_classical.py`](../firmware/teensy41/test_classical.py) + and [`firmware/teensy41/test_nip04.py`](../firmware/teensy41/test_nip04.py) + to use role+path selectors for the `nostr_*` verbs. +- [ ] **Cross-board parity**: with the same mnemonic and a "main" role at + `m/44'/1237'/0'/0/0`, verify the Teensy 4.1 and the host `n_signer` + produce the same npub and the same `nostr_sign_event` signature. +- [ ] Run the full suite: + ```bash + bash firmware/teensy41/build_signer.sh --flash + python3 firmware/teensy41/test_classical.py --port /dev/ttyACM0 + python3 firmware/teensy41/test_nip04.py --port /dev/ttyACM0 + python3 firmware/teensy41/test_signer.py --port /dev/ttyACM0 + ``` +- [ ] Run [`check_stack.sh`](../firmware/teensy41/check_stack.sh) to confirm + the new role table + wizard code did not push free stack below 16 KB. + +**Exit criterion:** all tests pass with role+path selectors, `nostr_index` is +rejected with error 2006, and the stack gauge reports ≥ 16 KB free. + +## Memory considerations + +- The role table is `16 × sizeof(role_entry_t)`. With `role_entry_t` at ~240 + bytes, that's ~3.8 KB. Place it in `DMAMEM` (RAM2) — there is 110 KB free + heap and 413 KB of `.bss.dma` already; 3.8 KB is negligible. +- The path parser and `derive_secp256k1_from_path` are pure code — mark them + `.flashmem` so they live in FLASH (6.3 MB free) and don't steal ITCM banks. +- The wizard UI adds LVGL widgets at runtime (heap-allocated by LVGL), freed + when the wizard screen is destroyed. No persistent LVGL memory cost. +- **Stack impact**: `derive_secp256k1_from_path` uses the same + `nostr_hd_key_t` (1088 bytes each, two of them) as the existing NIP-06 + derivation — no new stack pressure. The 130.9 KB free stack is more than + enough. + +## Decisions to confirm + +1. **Role table size**: 16 entries (vs the host's 256). Sufficient for a + hardware signer? The host allows 256 for complex multi-agent setups; the + Teensy is a single-user device. **Recommend 16.** +2. **`requires_approval` default**: `0` (role-as-password), matching + [`plans/role_as_password_default.md`](role_as_password_default.md). The + wizard lets the user toggle it per role. **Confirm.** +3. **`nostr_index` removal**: fully reject with error 2006 (no silent + fallback), matching the host. A compile flag + `ALLOW_DEPRECATED_NOSTR_INDEX` is provided **temporarily** for the + migration period only. **Confirm.** +4. **OTP role**: the host's OTP role (preset 9) has no derivation path. On the + Teensy, OTP is already handled by the SD-pad bind flow + ([`plans/teensy41_otp_sd_pad.md`](teensy41_otp_sd_pad.md) Phase 6). The + wizard's OTP preset should trigger `ui_pick_pad()` instead of path entry. + **Confirm.** +5. **Algorithm-based verbs**: `sign`, `get_public_key`, `derive`, + `encapsulate`, `decapsulate`, `derive_shared_secret`, `encrypt`, + `decrypt` are **unchanged** — they use `algorithm` + `index`, not roles. + Only the `nostr_*` verbs migrate. **Confirm.** + +## Out of scope + +- **CYD firmware migration**: the CYD + ([`firmware/cyd_esp32_2432s028/`](../firmware/cyd_esp32_2432s028/)) needs the + same migration, but it is a separate task (different UI framework + constraints, smaller screen). Tracked after the Teensy migration is + verified. +- **Policy table / `--preapprove`**: the host has a policy table for + caller-based preapproval. The Teensy has no caller identity (USB CDC is a + single trusted host), so the policy table is not needed — role-as-password + is the only authorization mechanism. +- **Path whitelist / `--allow-index`**: removed in the host's new model; not + applicable to the Teensy. +- **Multi-segment path templates** (e.g. `m/44'/1237'/%d/%d/%d`): the host + supports only a single `%d` placeholder; we match that limitation. diff --git a/src/main.c b/src/main.c index c5235de..9e6aa00 100644 --- a/src/main.c +++ b/src/main.c @@ -813,8 +813,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 1 -#define NSIGNER_VERSION_PATCH 23 -#define NSIGNER_VERSION "v0.1.23" +#define NSIGNER_VERSION_PATCH 24 +#define NSIGNER_VERSION "v0.1.24" /* NSIGNER_HEADERLESS_DECLS_END */ diff --git a/usb-test.html b/usb-test.html index 661cbe1..0a01352 100644 --- a/usb-test.html +++ b/usb-test.html @@ -388,8 +388,10 @@

nostr_get_public_key

- - + + + +
@@ -465,8 +467,10 @@

nostr_sign_event

- - + + + +
@@ -479,8 +483,10 @@

Slow on ESP32 — uses single-threaded PoW. Keep difficulty low.

- - + + + + @@ -500,8 +506,10 @@ - - + + + +
@@ -518,8 +526,10 @@ - - + + + +
@@ -788,8 +798,9 @@ callVerb("get_public_key", [{ algorithm: alg, index: idx }], $("gpkOut")); }); $("ngpkBtn").addEventListener("click", () => { - const idx = Number($("ngpkIdx").value || 0), fmt = $("ngpkFmt").value; - const opts = { nostr_index: idx }; + const role = $("ngpkRole").value.trim(), path = $("ngpkPath").value.trim(); + const fmt = $("ngpkFmt").value; + const opts = { role, role_path: path }; if (fmt === "structured") opts.format = "structured"; callVerb("nostr_get_public_key", [opts], $("ngpkOut")); }); @@ -846,24 +857,25 @@ $("nseBtn").addEventListener("click", () => { const content = $("nseContent").value; - const idx = Number($("nseIdx").value || 0); + const role = $("nseRole").value.trim(), path = $("nsePath").value.trim(); const event = { kind: 1, created_at: Math.floor(Date.now()/1000), tags: [], content }; - callVerb("nostr_sign_event", [event, { nostr_index: idx }], $("nseOut")); + callVerb("nostr_sign_event", [event, { role, role_path: path }], $("nseOut")); }); $("nmeBtn").addEventListener("click", () => { const content = $("nmeContent").value; - const idx = Number($("nmeIdx").value || 0); + const role = $("nmeRole").value.trim(), path = $("nmePath").value.trim(); const diff = Number($("nmeDiff").value || 4); const timeout = Number($("nmeTimeout").value || 30); const event = { kind: 1, created_at: Math.floor(Date.now()/1000), tags: [], content }; - callVerb("nostr_mine_event", [event, { nostr_index: idx, difficulty: diff, timeout_sec: timeout }], $("nmeOut")); + callVerb("nostr_mine_event", [event, { role, role_path: path, difficulty: diff, timeout_sec: timeout }], $("nmeOut")); }); const nip04Enc = async () => { - const peer = $("nip04Peer").value.trim(), msg = $("nip04Msg").value, idx = Number($("nip04Idx").value || 0); + const peer = $("nip04Peer").value.trim(), msg = $("nip04Msg").value; + const role = $("nip04Role").value.trim(), path = $("nip04Path").value.trim(); if (!peer) { $("nip04Out").textContent = "✗ enter peer pubkey"; return; } - const params = [peer, msg, { nostr_index: idx }]; + const params = [peer, msg, { role, role_path: path }]; $("nip04Out").textContent = "→ nostr_nip04_encrypt " + JSON.stringify(params); try { const auth = await buildAuth("nostr_nip04_encrypt", params); @@ -877,17 +889,19 @@ } }; const nip04Dec = () => { - const peer = $("nip04Peer").value.trim(), ct = $("nip04Cipher").value, idx = Number($("nip04Idx").value || 0); + const peer = $("nip04Peer").value.trim(), ct = $("nip04Cipher").value; + const role = $("nip04Role").value.trim(), path = $("nip04Path").value.trim(); if (!peer || !ct) { $("nip04Out").textContent = "✗ enter peer pubkey + ciphertext"; return; } - callVerb("nostr_nip04_decrypt", [peer, ct, { nostr_index: idx }], $("nip04Out")); + callVerb("nostr_nip04_decrypt", [peer, ct, { role, role_path: path }], $("nip04Out")); }; $("nip04EncBtn").addEventListener("click", nip04Enc); $("nip04DecBtn").addEventListener("click", nip04Dec); const nip44Enc = async () => { - const peer = $("nip44Peer").value.trim(), msg = $("nip44Msg").value, idx = Number($("nip44Idx").value || 0); + const peer = $("nip44Peer").value.trim(), msg = $("nip44Msg").value; + const role = $("nip44Role").value.trim(), path = $("nip44Path").value.trim(); if (!peer) { $("nip44Out").textContent = "✗ enter peer pubkey"; return; } - const params = [peer, msg, { nostr_index: idx }]; + const params = [peer, msg, { role, role_path: path }]; $("nip44Out").textContent = "→ nostr_nip44_encrypt " + JSON.stringify(params); try { const auth = await buildAuth("nostr_nip44_encrypt", params); @@ -901,9 +915,10 @@ } }; const nip44Dec = () => { - const peer = $("nip44Peer").value.trim(), ct = $("nip44Cipher").value, idx = Number($("nip44Idx").value || 0); + const peer = $("nip44Peer").value.trim(), ct = $("nip44Cipher").value; + const role = $("nip44Role").value.trim(), path = $("nip44Path").value.trim(); if (!peer || !ct) { $("nip44Out").textContent = "✗ enter peer pubkey + ciphertext"; return; } - callVerb("nostr_nip44_decrypt", [peer, ct, { nostr_index: idx }], $("nip44Out")); + callVerb("nostr_nip44_decrypt", [peer, ct, { role, role_path: path }], $("nip44Out")); }; $("nip44EncBtn").addEventListener("click", nip44Enc); $("nip44DecBtn").addEventListener("click", nip44Dec);