449 lines
23 KiB
Markdown
449 lines
23 KiB
Markdown
# 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.
|