Files
n_signer/plans/teensy41_role_path_migration.md

449 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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":"<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:
- [`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":"<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()`](../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<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`](../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.