Files
n_signer/plans/teensy41_role_path_migration.md
T

23 KiB
Raw Blame History

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 and plans/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).
  • Clients must send {"role":"<name>","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:

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:

  1. Accept {"role":"<name>","role_path":"<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() and nostr_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 fixed nostr_index.
  • Path parsing: the host's parse_bip44_path() 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 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 Solution 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() to key_derivation.cpp — port from 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 — port from 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: 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, sized for the Teensy's memory.

  • Create 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 with: - role_table_init(), role_table_add(), role_table_find_by_name(). - role_path_matches_template() — port from src/role_table.c:956. Handles one %d placeholder. - role_path_extract_index() — port from src/role_table.c:1007. - role_path_matches_with_range() — port from 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 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.cpp with selector_resolve() — port the decision tree from src/selector.c:745: - has_nostr_index → return SELECTOR_ERR_NOSTR_INDEX_DEPRECATED. - has_role_path without has_roleSELECTOR_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()).

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 (in DMAMEM) declared extern in 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): - 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).

  • Add ui_role_wizard() to ui.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, per plans/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.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() 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 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 and 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 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. 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 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/) 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.