v0.1.11 - Fix named path-role display and allow fixed paths without range/set
This commit is contained in:
@@ -0,0 +1,406 @@
|
||||
# Plan: Named path-roles + path-template whitelist in the wizard
|
||||
|
||||
## Goal
|
||||
|
||||
Let the user define **named roles bound to a derivation path template** in the
|
||||
interactive wizard. The client then selects a key by **role name** (not by raw
|
||||
path), and optionally by an **index within the role's allowed range**. The
|
||||
derivation path stays hidden on the signer side — the role name acts as an
|
||||
access token: if the client doesn't know the name, they can't get the key.
|
||||
|
||||
Example wizard session:
|
||||
|
||||
```
|
||||
Define a named path role? [y/N] y
|
||||
Role name: myrole
|
||||
Purpose [nostr]: nostr
|
||||
Curve [secp256k1]: secp256k1
|
||||
Path template: m/44'/1237'/0-3/1/0
|
||||
Default index: 1 (optional — press Enter to require explicit index)
|
||||
|
||||
Role 'myrole' registered: purpose=nostr curve=secp256k1 path=m/44'/1237'/0-3/1/0 (index 0..3, default 1).
|
||||
Define another? [y/N] n
|
||||
```
|
||||
|
||||
The purpose + curve combination must be valid per `crypto_alg_from_role()`
|
||||
(see [`src/key_store.c`](src/key_store.c) / [`src/enforcement.c`](src/enforcement.c)).
|
||||
The wizard validates the combination and re-prompts on invalid input. Valid
|
||||
combinations:
|
||||
|
||||
| Purpose | Curve | Algorithm | Typical path prefix |
|
||||
|-----------|----------------|----------------|----------------------------|
|
||||
| nostr | secp256k1 | secp256k1 | m/44'/1237'/... |
|
||||
| bitcoin | secp256k1 | secp256k1 | m/84'/0'/... / m/86'/... |
|
||||
| ssh | ed25519 | ed25519 | m/44'/102001'/... |
|
||||
| age | x25519 | x25519 | m/44'/102002'/... |
|
||||
| fips | secp256k1 | secp256k1 | (FIPS mode) |
|
||||
| pq-sig | ml-dsa-65 | ml-dsa-65 | m/44'/102003'/... |
|
||||
| pq-sig | slh-dsa-128s | slh-dsa-128s | m/44'/102004'/... |
|
||||
| pq-kem | ml-kem-768 | ml-kem-768 | m/44'/102005'/... |
|
||||
|
||||
The curve determines which `derive_*` function runs
|
||||
([`derive_for_role`](src/key_store.c:1004)). The path template is passed
|
||||
verbatim to `crypto_derive_seed_from_mnemonic` for all curves except
|
||||
`secp256k1`+`nostr`, which uses the NIP-06 helper when the path matches the
|
||||
NIP-06 form and the new `nostr_derive_keys_from_path` helper otherwise.
|
||||
|
||||
Client requests:
|
||||
|
||||
```json
|
||||
{"id":"1","method":"nostr_get_public_key","params":[{},{"role":"myrole"}]}
|
||||
```
|
||||
→ derives `m/44'/1237'/1/1/0` (default index 1) and returns the pubkey.
|
||||
|
||||
```json
|
||||
{"id":"2","method":"nostr_get_public_key","params":[{},{"role":"myrole","index":2}]}
|
||||
```
|
||||
→ derives `m/44'/1237'/2/1/0` (index 2, within allowed range 0-3).
|
||||
|
||||
```json
|
||||
{"id":"3","method":"nostr_get_public_key","params":[{},{"role":"myrole","index":5}]}
|
||||
```
|
||||
→ `2003 index_out_of_range` (5 is outside 0-3).
|
||||
|
||||
```json
|
||||
{"id":"4","method":"nostr_get_public_key","params":[{},{"role":"unknown"}]}
|
||||
```
|
||||
→ `1002 unknown_role` (name not registered).
|
||||
|
||||
## Why this design
|
||||
|
||||
The user's insight: a **role name is a password**. The client never sees the
|
||||
derivation path; they only know the role name the operator gave them. This:
|
||||
|
||||
1. **Hides the path** from the client — they can't enumerate or guess paths.
|
||||
2. **Acts as access control** — must know the name to get the key.
|
||||
3. **Enforces a range** — the server only derives paths within the template's
|
||||
range, so even a knowing client can't escape to `m/44'/1237'/99/1/0`.
|
||||
4. **Is backward compatible** — existing `nostr_index` and `role_path`
|
||||
selectors still work; named path-roles are an additive feature.
|
||||
|
||||
## Root cause recap (3 compounding defects this plan fixes)
|
||||
|
||||
1. No code path registers `SELECTOR_ROLE_PATH` roles at runtime — only
|
||||
`SELECTOR_NOSTR_INDEX` roles are created
|
||||
([`role_table_register_nostr_index`](src/role_table.c:805),
|
||||
[`setup_default_role`](src/main.c:1708)).
|
||||
2. [`crypto_derive_all`](src/key_store.c:1054) / [`crypto_derive_one`](src/key_store.c:1102)
|
||||
explicitly skip roles where `selector_type != SELECTOR_NOSTR_INDEX`.
|
||||
3. [`derive_secp256k1`](src/key_store.c:699) builds the path from `role->nostr_index`,
|
||||
ignoring `role->role_path` entirely. The other derive_* functions
|
||||
(ed25519, x25519, ml_dsa_65, slh_dsa_128s, ml_kem_768) do the same via
|
||||
`snprintf(..., "m/44'/10200X'/%d'/0'/0'", role->nostr_index)`.
|
||||
|
||||
The "auto approve all" setting ([`g_prompt_always_allow`](src/server.c:953)) only
|
||||
bypasses the approval prompt — it never runs because the 1002 hard selector error
|
||||
fires first at [`server.c:2074`](src/server.c:2074) /
|
||||
[`dispatcher.c:1784`](src/dispatcher.c:1784).
|
||||
|
||||
## Design
|
||||
|
||||
### New: path-template role entry
|
||||
|
||||
Extend `role_entry_t` (in `src/role_table.c` and mirrored decls) with two
|
||||
fields:
|
||||
|
||||
```c
|
||||
/* In role_entry_t, added after role_path[]: */
|
||||
int path_range_lo; /* for SELECTOR_ROLE_PATH roles: inclusive lower bound
|
||||
for the %d placeholder in role_path; -1 = no range
|
||||
(single fixed path) */
|
||||
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
|
||||
int path_default_index; /* default index to use when client sends {"role":...}
|
||||
without "index"; -1 = require explicit index */
|
||||
```
|
||||
|
||||
A path-template role stores its template in `role_path` with a `%d`-style
|
||||
placeholder segment, e.g. `role_path = "m/44'/1237'/%d/1/0"`,
|
||||
`path_range_lo = 0`, `path_range_hi = 3`, `path_default_index = 1`.
|
||||
|
||||
### Path-template data model for the whitelist
|
||||
|
||||
(Kept from the previous plan — the whitelist is the underlying mechanism the
|
||||
wizard uses to validate, but the user-facing UX is the named-role prompt.)
|
||||
|
||||
```c
|
||||
#define PATH_WHITELIST_MAX_TEMPLATES 16
|
||||
#define PATH_TEMPLATE_MAX_LEN 128
|
||||
|
||||
typedef struct {
|
||||
char template[PATH_TEMPLATE_MAX_LEN]; /* "m/44'/1237'/%d/1/0" */
|
||||
int range_lo;
|
||||
int range_hi;
|
||||
} path_template_t;
|
||||
|
||||
typedef struct {
|
||||
int active;
|
||||
int count;
|
||||
path_template_t templates[PATH_WHITELIST_MAX_TEMPLATES];
|
||||
} path_whitelist_t;
|
||||
```
|
||||
|
||||
Add `path_whitelist_t path_whitelist;` to `server_ctx_t`.
|
||||
|
||||
### Spec syntax (for `--allow-index` CLI flag and raw whitelist input)
|
||||
|
||||
Each comma-separated token may be:
|
||||
|
||||
- `all` → no restriction
|
||||
- `0-3` / `0,1,3` → existing integer `nostr_index` syntax (backward compat)
|
||||
- `m/44'/1237'/0-3/0/0` → path template, range 0..3
|
||||
- `m/44'/1237'/0-3/1/0` → path template, range 0..3 (the user's case)
|
||||
- `m/44'/1237'/0-3/0/0,m/44'/1237'/0-3/1/0` → multiple templates
|
||||
|
||||
A token containing `/` is a path template; the first segment matching
|
||||
`^[0-9]+(-[0-9]+)?$` is the range placeholder.
|
||||
|
||||
### Named-role wizard syntax (primary UX)
|
||||
|
||||
The wizard prompt offers two modes:
|
||||
|
||||
1. **Quick mode** (existing): enter a whitelist spec as above. Roles are
|
||||
auto-registered on demand when a client sends a matching `role_path`.
|
||||
2. **Named mode** (new): define named roles bound to path templates. The
|
||||
client uses `{"role":"name"}` (optionally with `"index":N`).
|
||||
|
||||
## Implementation steps
|
||||
|
||||
### Step 1 — Extend `role_entry_t` with path-range fields
|
||||
|
||||
Files: `src/role_table.c` (definition), and every .c with headerless decls
|
||||
mirroring `role_entry_t` (search for `selector_type` field to find all copies).
|
||||
Add `path_range_lo`, `path_range_hi`, `path_default_index` after `role_path[]`.
|
||||
|
||||
### Step 2 — Add `path_whitelist_t` struct + field to `server_ctx_t`
|
||||
|
||||
Files: `src/server.c` (definition + field), `src/main.c` (headerless decls
|
||||
mirror), and any other .c declaring `server_ctx_t` (search for
|
||||
`index_whitelist_active`). Add constants `PATH_WHITELIST_MAX_TEMPLATES`,
|
||||
`PATH_TEMPLATE_MAX_LEN`.
|
||||
|
||||
### Step 3 — Implement `server_set_path_whitelist()` parser in `src/server.c`
|
||||
|
||||
```c
|
||||
int server_set_path_whitelist(server_ctx_t *ctx, const char *spec);
|
||||
```
|
||||
|
||||
Unified parser: integer tokens → existing bitmap; path-template tokens →
|
||||
`path_whitelist.templates[]`. `"all"` clears both. Returns 0 / -1.
|
||||
|
||||
Keep `server_set_index_whitelist` as a thin wrapper (backward compat).
|
||||
|
||||
### Step 4 — Implement `server_path_whitelist_allows()` in `src/server.c`
|
||||
|
||||
```c
|
||||
int server_path_whitelist_allows(const server_ctx_t *ctx, const char *role_path);
|
||||
```
|
||||
|
||||
Iterate templates, format each candidate with the range, `strcmp`. Return 1/0.
|
||||
|
||||
### Step 5 — Add `role_table_register_role_path()` helper in `src/role_table.c`
|
||||
|
||||
```c
|
||||
int role_table_register_role_path(role_table_t *table, const char *path,
|
||||
role_purpose_t purpose, role_curve_t curve,
|
||||
int range_lo, int range_hi, int default_index);
|
||||
```
|
||||
|
||||
- `purpose` and `curve` are caller-supplied (from the wizard prompt), not
|
||||
hardcoded. The caller must validate the combination via
|
||||
`crypto_alg_from_role(curve, purpose) != CRYPTO_ALG_UNKNOWN` before calling.
|
||||
- Idempotent via `role_table_find_by_path` (compare template + range).
|
||||
- Sets `selector_type = SELECTOR_ROLE_PATH`, copies `path` (with `%d`)
|
||||
into `role_path`, sets `purpose`/`curve`/`purpose_str`/`curve_str` from the
|
||||
enum + string forms, sets the range fields, `derived = 0`.
|
||||
- Add the prototype to the headerless-decls block in every .c that includes
|
||||
role_table decls.
|
||||
|
||||
### Step 6 — Make `derive_secp256k1` honor `role_path` in `src/key_store.c`
|
||||
|
||||
- When `role->selector_type == SELECTOR_ROLE_PATH`:
|
||||
- If `role_path` contains `%d`, the caller must have already resolved the
|
||||
concrete path (see Step 8 — the server formats `role_path` with the
|
||||
chosen index before calling `crypto_derive_one`). So `derive_secp256k1`
|
||||
just uses `role->role_path` directly as the full BIP-32 path.
|
||||
- Call `crypto_derive_seed_from_mnemonic(phrase, role->role_path, seed, 32)`
|
||||
then derive secp256k1 priv/pub from that seed.
|
||||
- Add helper `nostr_derive_keys_from_path(const char *mnemonic, const char *path,
|
||||
unsigned char *priv, unsigned char *pub)` (or inline using the existing
|
||||
BIP-32 seed→key derivation that `nostr_derive_keys_from_mnemonic` uses).
|
||||
- When `SELECTOR_NOSTR_INDEX`, keep existing behavior.
|
||||
- Apply the same `SELECTOR_ROLE_PATH` branch to the other derive_* functions.
|
||||
|
||||
### Step 7 — Remove the `SELECTOR_NOSTR_INDEX`-only guards in `src/key_store.c`
|
||||
|
||||
- [`crypto_derive_all`](src/key_store.c:1054): allow `SELECTOR_ROLE_PATH`.
|
||||
- [`crypto_derive_one`](src/key_store.c:1102): allow `SELECTOR_ROLE_PATH`.
|
||||
|
||||
### Step 8 — Wire named path-roles + whitelist into `src/server.c` request handling
|
||||
|
||||
In the selector-resolution block ([`server.c:2028-2066`](src/server.c:2028)):
|
||||
|
||||
**Case A — client sends `{"role":"myrole"}` (named path-role):**
|
||||
- `selector_resolve` finds the role by name (already works for registered roles).
|
||||
- If the role is a path-template role (`SELECTOR_ROLE_PATH` with `%d`):
|
||||
- Read optional `"index"` from the request options.
|
||||
- If no `index` and `path_default_index >= 0` → use `path_default_index`.
|
||||
- If no `index` and `path_default_index < 0` → `2004 index_required`.
|
||||
- Validate `index` is in `[path_range_lo, path_range_hi]` → else `2003 index_out_of_range`.
|
||||
- Format the concrete path: `snprintf(concrete, ..., role_path, index)`.
|
||||
- Set `pending_derivation = 1` if the role isn't derived yet, with the
|
||||
concrete path stored for `crypto_derive_one`.
|
||||
- If the role is a `nostr_index` role → existing behavior.
|
||||
|
||||
**Case B — client sends `{"role_path":"m/44'/1237'/1/1/0"}` (raw path):**
|
||||
- If `server_path_whitelist_allows(ctx, role_path)` → set
|
||||
`pending_derivation = 1`, synthesize role name, `purpose=nostr`,
|
||||
`curve=secp256k1`.
|
||||
- Else → `2003 path_not_allowed`.
|
||||
|
||||
**Case C — client sends `{"nostr_index":N}`:** existing behavior unchanged.
|
||||
|
||||
In the `if (pchk == POLICY_ALLOW && pending_derivation)` block
|
||||
([`server.c:2106`](src/server.c:2106)):
|
||||
- For named path-roles: the role already exists in the table; just call
|
||||
`crypto_derive_one` with the concrete path (temporarily set
|
||||
`role->role_path` to the concrete path, or pass the path via a side channel).
|
||||
- For raw `role_path`: `role_table_register_role_path` (no `%d`, fixed path)
|
||||
→ `crypto_derive_one`.
|
||||
|
||||
### Step 9 — Add the named-role wizard prompt in `src/main.c`
|
||||
|
||||
New function `prompt_named_path_roles(role_table_t *role_table)`:
|
||||
|
||||
```
|
||||
Define a named path role? [y/N] y
|
||||
Role name: myrole
|
||||
Purpose [nostr]: nostr
|
||||
Curve [secp256k1]: secp256k1
|
||||
Path template (use 0-3 for a range, or a single number): m/44'/1237'/0-3/1/0
|
||||
Default index [1]: 1
|
||||
Role 'myrole' registered: purpose=nostr curve=secp256k1 path=m/44'/1237'/0-3/1/0 (index 0..3, default 1).
|
||||
Define another? [y/N] n
|
||||
```
|
||||
|
||||
- **Purpose** prompt: default `nostr`; accept any of
|
||||
`nostr|bitcoin|ssh|age|fips|pq-sig|pq-kem`; parse via
|
||||
`role_purpose_from_str()`.
|
||||
- **Curve** prompt: default `secp256k1`; accept any of
|
||||
`secp256k1|ed25519|x25519|ml-dsa-65|slh-dsa-128s|ml-kem-768`; parse via
|
||||
`role_curve_from_str()`.
|
||||
- **Validate** the purpose+curve combination:
|
||||
`crypto_alg_from_role(curve, purpose) != CRYPTO_ALG_UNKNOWN`; re-prompt on
|
||||
invalid combo (e.g. `nostr`+`ed25519` is invalid).
|
||||
- Parse the path template: find the range segment, extract `range_lo`/`range_hi`,
|
||||
store template with `%d`.
|
||||
- Call `role_table_register_role_path(table, template, purpose, curve,
|
||||
range_lo, range_hi, default_index)`.
|
||||
- Loop until user declines.
|
||||
- Call this after [`setup_default_role`](src/main.c:1708) and before
|
||||
`crypto_derive_all` (so named roles are pre-derived at startup using their
|
||||
default index).
|
||||
|
||||
Also update [`prompt_index_whitelist()`](src/main.c:2088) to mention that
|
||||
named path-roles bypass the raw-path whitelist (they're explicitly registered).
|
||||
|
||||
### Step 10 — Update `--allow-index` flag + wizard text in `src/main.c`
|
||||
|
||||
- Update `--allow-index` help ([`main.c:1109`](src/main.c:1109)) to mention
|
||||
path templates.
|
||||
- Update call sites at [`main.c:2902`](src/main.c:2902) /
|
||||
[`main.c:2945`](src/main.c:2945) / [`main.c:2973`](src/main.c:2973) to call
|
||||
`server_set_path_whitelist`.
|
||||
|
||||
### Step 11 — (Optional) Also handle `role_path` in `src/dispatcher.c`
|
||||
|
||||
[`dispatcher.c:1778-1791`](src/dispatcher.c:1778) returns 1002 on
|
||||
`SELECTOR_ERR_NOT_FOUND`. **Decision**: scope to `server.c` only for now;
|
||||
stdio/qrexec still returns 1002 for unknown `role_path` (future work). Named
|
||||
roles registered at startup work everywhere because they're in the role table
|
||||
before any request arrives.
|
||||
|
||||
### Step 12 — Tests
|
||||
|
||||
- [`tests/test_role_table.c`](tests/test_role_table.c): test
|
||||
`role_table_register_role_path` (idempotent, range fields stored).
|
||||
- [`tests/test_integration.c`](tests/test_integration.c) or new
|
||||
`tests/test_path_whitelist.c`:
|
||||
- Parse `m/44'/1237'/0-3/0/0` → assert `server_path_whitelist_allows` returns
|
||||
1 for `m/44'/1237'/2/0/0` and 0 for `m/44'/1237'/5/0/0`.
|
||||
- Parse `m/44'/1237'/0-3/1/0` → assert allows `m/44'/1237'/1/1/0` (the user's
|
||||
exact case), denies `m/44'/1237'/1/0/0`.
|
||||
- End-to-end (named role): register `myrole` with template
|
||||
`m/44'/1237'/%d/1/0`, range 0-3, default 1. Send
|
||||
`{"role":"myrole"}` → assert pubkey for `m/44'/1237'/1/1/0`.
|
||||
Send `{"role":"myrole","index":2}` → assert pubkey for
|
||||
`m/44'/1237'/2/1/0`. Send `{"role":"myrole","index":5}` → assert
|
||||
`2003 index_out_of_range`.
|
||||
- End-to-end (raw path): start server with
|
||||
`--allow-index "m/44'/1237'/0-3/1/0"`, send
|
||||
`{"role_path":"m/44'/1237'/1/1/0"}` → assert valid pubkey.
|
||||
Send `{"role_path":"m/44'/1237'/1/0/0"}` → assert `2003 path_not_allowed`.
|
||||
- Backward compat: `--allow-index "0-3"` still works for `nostr_index`.
|
||||
|
||||
### Step 13 — Docs
|
||||
|
||||
- [`README.md`](README.md) §4.6: document named path-roles, the `"index"`
|
||||
option, and the `2003`/`2004` error codes.
|
||||
- [`README.md`](README.md) §3 (wizard): document the named-role prompt.
|
||||
- [`api.md`](api.md): add error codes `2003 path_not_allowed` /
|
||||
`2003 index_out_of_range` / `2004 index_required`.
|
||||
- [`README.md`](README.md) error table: add the new codes.
|
||||
|
||||
## New error codes
|
||||
|
||||
| Code | Message | Meaning |
|
||||
|-------|----------------------|------------------------------------------------------|
|
||||
| 2003 | `path_not_allowed` | `role_path` not on the path whitelist. |
|
||||
| 2003 | `index_out_of_range` | `index` outside the named role's `[lo,hi]` range. |
|
||||
| 2004 | `index_required` | Named path-role has no default index and none given. |
|
||||
|
||||
(2003 is reused for both path-not-allowed and index-out-of-range since they're
|
||||
both "whitelist range" violations; the message distinguishes them. If you
|
||||
prefer distinct codes, use 2005 for `index_out_of_range`.)
|
||||
|
||||
## Open questions / decisions
|
||||
|
||||
- **Placeholder detection**: first path segment matching `^[0-9]+(-[0-9]+)?$`
|
||||
is the range. No explicit `X` char needed.
|
||||
- **Default purpose/curve**: `nostr` / `secp256k1` for now. Inferring from path
|
||||
prefix is future work.
|
||||
- **Flag name**: keep `--allow-index` for backward compat; path syntax accepted
|
||||
by the same flag.
|
||||
- **Pre-derivation**: named roles with a default index are pre-derived at
|
||||
startup (in `crypto_derive_all`); roles without a default are derived on
|
||||
first request.
|
||||
- **dispatcher.c scope**: stdio/qrexec gets named roles (they're in the table
|
||||
at startup) but not raw-path auto-registration (future work).
|
||||
- **Distinct error codes for 2003**: decision pending — reuse 2003 with
|
||||
different messages, or split into 2003/2005.
|
||||
|
||||
## Mermaid: request flow after implementation
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Client request] --> B{selector type?}
|
||||
B -- role name --> C[role_table_find_by_name]
|
||||
C --> D{found?}
|
||||
D -- no --> E[1002 unknown_role]
|
||||
D -- yes --> F{is path-template role?}
|
||||
F -- no, nostr_index --> G[existing nostr_index path]
|
||||
F -- yes --> H{index in options?}
|
||||
H -- yes --> I{index in range lo..hi?}
|
||||
H -- no --> J{default_index set?}
|
||||
J -- no --> K[2004 index_required]
|
||||
J -- yes --> I
|
||||
I -- no --> L[2003 index_out_of_range]
|
||||
I -- yes --> M[format concrete path with index]
|
||||
M --> N[derive + execute verb]
|
||||
G --> N
|
||||
B -- role_path --> O[server_path_whitelist_allows]
|
||||
O -- no --> P[2003 path_not_allowed]
|
||||
O -- yes --> Q[auto-register + derive]
|
||||
Q --> N
|
||||
B -- nostr_index --> R[existing index whitelist check]
|
||||
R --> N
|
||||
```
|
||||
Reference in New Issue
Block a user