23 KiB
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 forrole_tablepath matching). The firmware code compiles pending an on-device build (build_signer.sh) and the hardware test suite (test_signer.pyetc.) needs a Teensy 4.1 flash to verify end-to-end. TheALLOW_DEPRECATED_NOSTR_INDEXflag 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 and
plans/role_as_password_default.md):
nostr_indexis deprecated — the host returns error2006with the message "nostr_index is deprecated — use --role main --path m/44'/1237'/N'/0/0 instead" (src/selector.c:759).- Clients must send
{"role":"<name>","role_path":"m/44'/1237'/0'/0/0"}for allnostr_*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 (
%dplaceholders), 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—parse_nostr_index_from_params()is the only selector parser.firmware/teensy41/signer/src/dispatch.cpp:1958— everynostr_*verb callsderive_request_key(nostr_index, ...).firmware/teensy41/signer/src/key_derivation.h:31— onlyderive_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_approvalflag. Everynostr_*verb prompts for approval viaui_approve().
The CYD firmware
(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:
- Accept
{"role":"<name>","role_path":"<path>"}and derive the key from the explicit BIP-44 path (not anostr_indexinteger). - Maintain a role table populated at boot via an LVGL role-preset wizard (the touch-screen equivalent of the host's terminal wizard).
- Implement role-as-password: roles with
requires_approval = 0authorize immediately; onlyrequires_approval = 1roles prompt viaui_approve(). - Reject
nostr_indexwith the same2006error the host returns. - Keep the algorithm-based verbs (
sign,get_public_key,derive, etc.) unchanged — they usealgorithm+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()andnostr_bip32_derive_path()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 fixednostr_index. - Path parsing: the host's
parse_bip44_path()is a ~75-line pure-C function that splitsm/44'/1237'/0'/0/0into auint32_t[]with hardened-bit handling. It ports directly (it uses onlystrtol,strlen, and the'/hmarkers). - LVGL UI:
ui.halready 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.mdSolution A) left 130.9 KB of free stack — plenty of headroom for a role table and path strings.
Architecture
flowchart TD
Boot[signer.ino boot] --> Menu[startup menu<br/>generate or enter mnemonic]
Menu --> Wizard[role_wizard UI<br/>LVGL preset menu]
Wizard --> Table[role_table_t<br/>in-RAM, DMAMEM]
Table --> Idle[ui_show_idle<br/>show npub + role count]
Idle --> Frame[transport_read_frame]
Frame --> Parse[dispatch.cpp<br/>parse JSON-RPC]
Parse --> Sel{nostr_* verb?}
Sel -- Yes --> Role[selector_resolve<br/>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<br/>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()tokey_derivation.cpp— port fromsrc/key_store.c:685. Pure C, ~75 lines. Handlesm/,'/h/Hhardened markers, up to 16 segments. Mark it__attribute__((section(".flashmem")))to keep ITCM small. - Add
derive_secp256k1_from_path()tokey_derivation.cpp— port fromsrc/key_store.c:765. Callsnostr_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: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 withHOST_TESTagainst the nostr_core C files) and verifiesderive_secp256k1_from_path("m/44'/1237'/0'/0/0")produces the same pubkey asderive_secp256k1_keys_index(0)(the existing NIP-06 path ism/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, sized for the Teensy's memory.
-
Create
role_table.hwith a slimmed-downrole_entry_t(the host's struct has 256-entry arrays and 64-int allowed-indices sets — too big for the Teensy; cap atROLE_TABLE_MAX_ENTRIES 16andpath_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.cppwith: -role_table_init(),role_table_add(),role_table_find_by_name(). -role_path_matches_template()— port fromsrc/role_table.c:956. Handles one%dplaceholder. -role_path_extract_index()— port fromsrc/role_table.c:1007. -role_path_matches_with_range()— port fromsrc/role_table.c:1070. Combines template match + range check. -role_table_get_default()— returns the role named"main". - All marked.flashmemwhere reasonable. -
Host-side unit test: link
role_table.cppwithHOST_TESTand 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 decision tree.
-
Create
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.cppwithselector_resolve()— port the decision tree fromsrc/selector.c:745: -has_nostr_index→ returnSELECTOR_ERR_NOSTR_INDEX_DEPRECATED. -has_role_pathwithouthas_role→SELECTOR_ERR_ROLE_REQUIRED. -has_role+has_role_path→ find role, verify path matches template + range, return the role entry. -has_roleonly → if fixed path (no%d), use it; elseSELECTOR_ERR_PATH_REQUIRED. - Neither → use default role ("main"), elseSELECTOR_ERR_NO_DEFAULT. -
Add a parser in
selector.cppthat extractsrole/role_path/nostr_index/indexfrom the trailing cJSON options object of anostr_*verb's params array (replacingparse_nostr_index_from_params()).
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 use the
selector + role table instead of nostr_index.
- Add a global
role_table_t g_roles(inDMAMEM) declaredexternindispatch.h, populated by the boot flow (Phase 6). - Replace
parse_nostr_index_from_params()+derive_request_key()in eachnostr_*verb handler with: 1. Parse the selector request from params. 2. Callselector_resolve(&req, &g_roles, &role). 3. OnSELECTOR_ERR_NOSTR_INDEX_DEPRECATED→ return error2006with the host's exact message. 4. OnSELECTOR_ERR_NOT_FOUND→ error1002 unknown_role. 5. OnSELECTOR_ERR_PATH_MISMATCH→ error2003 path_not_allowed. 6. On success → derive the key viaderive_secp256k1_from_path(g_seed, g_seed_len, req.role_path, ...). 7. Ifrole->requires_approval == 1→ callui_approve(); else skip the prompt (role-as-password). - The verbs to update (all in
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_infoto report the configured roles in the response (the host'sget_infolists roles; the Teensy currently does not). - Keep
nostr_indexworking as a hidden fallback behind a#define ALLOW_DEPRECATED_NOSTR_INDEX 0compile flag, default off, so the oldtest_signer.pycan 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).
- Add
ui_role_wizard()toui.h/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, perplans/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.
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 between mnemonic entry
and the idle screen.
- In
signer.inosetup()/apply_mnemonic(), after the mnemonic is applied and the seed is derived: 1. Callrole_table_init(&g_roles). 2. IfDEBUG_AUTO_GENERATE == 1: auto-populateg_roleswith 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. IfDEBUG_AUTO_GENERATE == 0: callui_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()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: - 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 error2006. - Add a test that sends{"role":"nonexistent","role_path":"..."}and asserts error1002. - Add a test that sends{"role":"main","role_path":"m/44'/1237'/999'/0/0"}(out of range) and asserts error2003. - Add a test that verifiesrequires_approval = 0roles do NOT triggerui_approve(the response comes back immediately, no 30s prompt). - Update
firmware/teensy41/test_classical.pyandfirmware/teensy41/test_nip04.pyto use role+path selectors for thenostr_*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 hostn_signerproduce the same npub and the samenostr_sign_eventsignature. - 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.shto 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). Withrole_entry_tat ~240 bytes, that's ~3.8 KB. Place it inDMAMEM(RAM2) — there is 110 KB free heap and 413 KB of.bss.dmaalready; 3.8 KB is negligible. - The path parser and
derive_secp256k1_from_pathare pure code — mark them.flashmemso 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_pathuses the samenostr_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
- 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.
requires_approvaldefault:0(role-as-password), matchingplans/role_as_password_default.md. The wizard lets the user toggle it per role. Confirm.nostr_indexremoval: fully reject with error 2006 (no silent fallback), matching the host. A compile flagALLOW_DEPRECATED_NOSTR_INDEXis provided temporarily for the migration period only. Confirm.- 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.mdPhase 6). The wizard's OTP preset should triggerui_pick_pad()instead of path entry. Confirm. - Algorithm-based verbs:
sign,get_public_key,derive,encapsulate,decapsulate,derive_shared_secret,encrypt,decryptare unchanged — they usealgorithm+index, not roles. Only thenostr_*verbs migrate. Confirm.
Out of scope
- CYD firmware migration: the CYD
(
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%dplaceholder; we match that limitation.