diff --git a/Makefile b/Makefile index 1cf7bfd..8bf3506 100644 --- a/Makefile +++ b/Makefile @@ -80,6 +80,7 @@ TEST_SLH_DSA_128S_TARGET := $(BUILD_DIR)/test_slh_dsa_128s TEST_ML_KEM_768_TARGET := $(BUILD_DIR)/test_ml_kem_768 TEST_PUBKEY_FORMAT_TARGET := $(BUILD_DIR)/test_pubkey_format TEST_ALGORITHM_API_TARGET := $(BUILD_DIR)/test_algorithm_api +TEST_PATH_WHITELIST_TARGET := $(BUILD_DIR)/test_path_whitelist EXAMPLE_GET_PUBLIC_KEY_TARGET := $(BUILD_DIR)/example_get_public_key_client EXAMPLE_SIGN_EVENT_TARGET := $(BUILD_DIR)/example_sign_event_client EXAMPLE_GET_PUBKEY_TCP_TARGET := $(BUILD_DIR)/example_get_pubkey_tcp @@ -89,7 +90,7 @@ EXAMPLE_PQ_KEM_TARGET := $(BUILD_DIR)/example_pq_kem EXAMPLE_SSH_SIGN_TARGET := $(BUILD_DIR)/example_ssh_sign DEMO_C99_TARGET := $(BUILD_DIR)/demo_c99 -.PHONY: all lib dev static static-debug static-arm64 firmware-feather test test-integration test-mnemonic test-mnemonic-input test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth test-mine-event test-pq-crypto test-ed25519-x25519 test-ml-dsa-65 test-slh-dsa-128s test-ml-kem-768 test-pubkey-format test-algorithm-api examples test-client clean +.PHONY: all lib dev static static-debug static-arm64 firmware-feather test test-integration test-mnemonic test-mnemonic-input test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth test-mine-event test-pq-crypto test-ed25519-x25519 test-ml-dsa-65 test-slh-dsa-128s test-ml-kem-768 test-pubkey-format test-algorithm-api test-path-whitelist examples test-client clean all: dev @@ -117,7 +118,7 @@ static-arm64: firmware-feather: cd firmware/feather_s3_tft && idf.py build -test: lib test-mnemonic test-mnemonic-input test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth test-mine-event test-pq-crypto test-ed25519-x25519 test-ml-dsa-65 test-slh-dsa-128s test-ml-kem-768 test-pubkey-format test-client +test: lib test-mnemonic test-mnemonic-input test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth test-mine-event test-pq-crypto test-ed25519-x25519 test-ml-dsa-65 test-slh-dsa-128s test-ml-kem-768 test-pubkey-format test-path-whitelist test-client test-integration: $(TEST_INTEGRATION_TARGET) $(TARGET_DEV) ./$(TEST_INTEGRATION_TARGET) @@ -176,6 +177,9 @@ test-pubkey-format: $(TEST_PUBKEY_FORMAT_TARGET) test-algorithm-api: $(TEST_ALGORITHM_API_TARGET) ./$(TEST_ALGORITHM_API_TARGET) +test-path-whitelist: $(TEST_PATH_WHITELIST_TARGET) + ./$(TEST_PATH_WHITELIST_TARGET) + test-client: examples examples: $(EXAMPLE_GET_PUBLIC_KEY_TARGET) $(EXAMPLE_SIGN_EVENT_TARGET) $(EXAMPLE_GET_PUBKEY_TCP_TARGET) $(EXAMPLE_GET_PUBKEY_QREXEC_TARGET) $(EXAMPLE_PQ_SIGN_TARGET) $(EXAMPLE_PQ_KEM_TARGET) $(EXAMPLE_SSH_SIGN_TARGET) $(DEMO_C99_TARGET) @@ -256,6 +260,10 @@ $(TEST_ALGORITHM_API_TARGET): $(TEST_DIR)/test_algorithm_api.c $(SRC_DIR)/pq_cry @mkdir -p $(BUILD_DIR) $(CC) $(CFLAGS) $(TEST_DIR)/test_algorithm_api.c $(SRC_DIR)/pq_crypto.c $(SRC_DIR)/pq_drbg.c $(PQCLEAN_SOURCES) $(SRC_DIR)/key_store.c $(SRC_DIR)/dispatcher.c $(SRC_DIR)/miner.c $(SRC_DIR)/selector.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c $(SRC_DIR)/policy.c $(SRC_DIR)/otp_pad.c libotppad/libotppad.c -o $(TEST_ALGORITHM_API_TARGET) $(LDFLAGS) +$(TEST_PATH_WHITELIST_TARGET): $(TEST_DIR)/test_path_whitelist.c $(SRC_DIR)/server.c $(SRC_DIR)/pq_crypto.c $(SRC_DIR)/pq_drbg.c $(PQCLEAN_SOURCES) $(SRC_DIR)/key_store.c $(SRC_DIR)/dispatcher.c $(SRC_DIR)/miner.c $(SRC_DIR)/selector.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c $(SRC_DIR)/policy.c $(SRC_DIR)/auth_envelope.c $(SRC_DIR)/transport_frame.c $(SRC_DIR)/socket_name.c $(SRC_DIR)/http_listener.c $(SRC_DIR)/otp_pad.c libotppad/libotppad.c + @mkdir -p $(BUILD_DIR) + $(CC) $(CFLAGS) $(TEST_DIR)/test_path_whitelist.c $(SRC_DIR)/server.c $(SRC_DIR)/pq_crypto.c $(SRC_DIR)/pq_drbg.c $(PQCLEAN_SOURCES) $(SRC_DIR)/key_store.c $(SRC_DIR)/dispatcher.c $(SRC_DIR)/miner.c $(SRC_DIR)/selector.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c $(SRC_DIR)/policy.c $(SRC_DIR)/auth_envelope.c $(SRC_DIR)/transport_frame.c $(SRC_DIR)/socket_name.c $(SRC_DIR)/http_listener.c $(SRC_DIR)/otp_pad.c libotppad/libotppad.c -o $(TEST_PATH_WHITELIST_TARGET) $(LDFLAGS) + $(EXAMPLE_GET_PUBLIC_KEY_TARGET): $(EXAMPLES_DIR)/get_public_key_client.c @mkdir -p $(BUILD_DIR) $(CC) $(CFLAGS) $(EXAMPLES_DIR)/get_public_key_client.c -o $(EXAMPLE_GET_PUBLIC_KEY_TARGET) $(LDFLAGS) diff --git a/README.md b/README.md index e5d4e25..0415c67 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,10 @@ Error codes: | 1008 | `mining_failed` | Internal error during proof-of-work mining. | | 1009 | `not_yet_implemented` | Verb+algorithm combination is reserved but not yet implemented. | | 1010 | `algorithm_not_supported_for_verb` | The `algorithm` value is not valid for this verb. | +| 2002 | `index_not_allowed` | `nostr_index` not in the index whitelist. | +| 2003 | `path_not_allowed` | `role_path` not in the path whitelist. | +| 2004 | `index_required` | Named path-role has no default index and none given. | +| 2005 | `index_out_of_range` | `index` outside the named role's `[lo,hi]` range. | ### 4.3 Verbs @@ -484,10 +488,61 @@ The `nostr_*` verbs select a secp256k1 NIP-06 key via the options object. Suppor |----------------|--------------------------------------------------| | `nostr_index` | NIP-06 index `n` → path `m/44'/1237'/'/0/0` | | `role` | Name of a pre-registered role entry | -| `role_path` | Full BIP-44 derivation path (must match a registered role) | +| `role_path` | Full BIP-44 derivation path (must be on the path whitelist or match a registered role) | +| `index` | Optional: index for a named path-role template (see below) | Selector resolution order: `role` → `nostr_index` → `role_path` → default role `main`. Conflicting selectors are rejected with `ambiguous_role_selector` (1001). The role's `(purpose, curve)` must be `(nostr, secp256k1)` — any other combination is rejected with `purpose_mismatch` (1004) or `curve_mismatch` (1005). +#### Named path-roles + +In the interactive wizard, you can define **named path-roles** that bind a role name (which acts as an access token for clients) to a derivation path template with a range and default index. The derivation path is hidden from clients — they only know the role name. + +``` +Wizard: + Role name: myrole + Purpose [nostr]: nostr + Curve [secp256k1]: secp256k1 + Path template: m/44'/1237'/0-3/1/0 + Default index [0]: 1 +``` + +Clients then request keys by role name, optionally with an `index` within the allowed range: + +```json +{"id":"1","method":"nostr_get_public_key","params":[{},{"role":"myrole"}]} +``` +→ derives `m/44'/1237'/1/1/0` (default index 1). + +```json +{"id":"2","method":"nostr_get_public_key","params":[{},{"role":"myrole","index":2}]} +``` +→ derives `m/44'/1237'/2/1/0` (index 2, within range 0-3). + +```json +{"id":"3","method":"nostr_get_public_key","params":[{},{"role":"myrole","index":5}]} +``` +→ `2005 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). + +#### Path whitelist (`--allow-index`) + +The `--allow-index` flag (and the wizard's whitelist prompt) accepts both integer `nostr_index` tokens and path-template tokens. A `role_path` request is auto-registered and derived on demand if it matches a whitelisted template; otherwise it returns `2003 path_not_allowed`. + +``` +nsigner --allow-index "m/44'/1237'/0-3/1/0" +``` + +Allowed syntax (comma-separated): +- `all` — no restriction (default) +- `0-3` / `0,1,3` — integer `nostr_index` values (backward compatible) +- `m/44'/1237'/0-3/0/0` — NIP-06 paths X=0..3 +- `m/44'/1237'/0-3/1/0` — custom paths X=0..3, change=1 +- `m/44'/1237'/0-3/0/0,m/44'/1237'/0-3/1/0` — multiple templates + ### 4.7 Pre-approval Pre-approval entries skip the interactive prompt for matching requests. They are configured at startup with `--preapprove`. diff --git a/plans/path_whitelist.md b/plans/path_whitelist.md new file mode 100644 index 0000000..2469a2e --- /dev/null +++ b/plans/path_whitelist.md @@ -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 +``` diff --git a/src/dispatcher.c b/src/dispatcher.c index 71d0dc7..c14b6f0 100644 --- a/src/dispatcher.c +++ b/src/dispatcher.c @@ -118,6 +118,11 @@ typedef struct { char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */ char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */ int derived; /* 1 if pubkey_hex has been populated */ + int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */ + int path_range_hi; /* inclusive upper bound; == path_range_lo for single */ + int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */ + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ } role_entry_t; /* The role table */ @@ -176,6 +181,8 @@ typedef struct { int has_role_path; /* 1 if "role_path" field was present */ char role_path[ROLE_PATH_MAX]; + int has_index; /* 1 if "index" field was present (for named path-roles) */ + int index; /* index value for named path-role template */ } selector_request_t; /* Initialize a selector request (all fields zeroed/unset) */ diff --git a/src/enforcement.c b/src/enforcement.c index 822e745..e410bec 100644 --- a/src/enforcement.c +++ b/src/enforcement.c @@ -116,6 +116,11 @@ typedef struct { char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */ char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */ int derived; /* 1 if pubkey_hex has been populated */ + int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */ + int path_range_hi; /* inclusive upper bound; == path_range_lo for single */ + int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */ + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ } role_entry_t; /* The role table */ @@ -174,6 +179,8 @@ typedef struct { int has_role_path; /* 1 if "role_path" field was present */ char role_path[ROLE_PATH_MAX]; + int has_index; /* 1 if "index" field was present (for named path-roles) */ + int index; /* index value for named path-role template */ } selector_request_t; /* Initialize a selector request (all fields zeroed/unset) */ diff --git a/src/key_store.c b/src/key_store.c index c983b92..cab3855 100644 --- a/src/key_store.c +++ b/src/key_store.c @@ -118,6 +118,11 @@ typedef struct { char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */ char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */ int derived; /* 1 if pubkey_hex has been populated */ + int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */ + int path_range_hi; /* inclusive upper bound; == path_range_lo for single */ + int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */ + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ } role_entry_t; /* The role table */ @@ -176,6 +181,8 @@ typedef struct { int has_role_path; /* 1 if "role_path" field was present */ char role_path[ROLE_PATH_MAX]; + int has_index; /* 1 if "index" field was present (for named path-roles) */ + int index; /* index value for named path-role template */ } selector_request_t; /* Initialize a selector request (all fields zeroed/unset) */ @@ -673,6 +680,128 @@ int socket_name_random(char *out, size_t out_len); #define NSIGNER_ENCRYPT_OUTPUT_MAX 65536 +/* + * Parse a BIP-44 derivation path string (e.g. "m/44'/1237'/1/1/0") into a + * uint32_t array suitable for nostr_bip32_derive_path(). Hardened segments + * are indicated by a trailing ' (or h). Returns the number of path components + * on success, or -1 on parse error. max_path is the max number of entries + * in the path_out array. + */ +static int parse_bip44_path(const char *path_str, uint32_t *path_out, int max_path) { + char buf[ROLE_PATH_MAX]; + char *p; + int count = 0; + + if (path_str == NULL || path_out == NULL || max_path <= 0) { + return -1; + } + + strncpy(buf, path_str, sizeof(buf) - 1); + buf[sizeof(buf) - 1] = '\0'; + + /* Skip leading "m" or "m/" */ + p = buf; + if (*p == 'm' || *p == 'M') { + p++; + if (*p == '/') { + p++; + } else if (*p != '\0') { + return -1; /* "m" must be followed by '/' or end */ + } + } + + while (*p != '\0' && count < max_path) { + char *slash = strchr(p, '/'); + char seg[24]; + size_t seg_len; + int hardened = 0; + char *endptr = NULL; + long val; + + if (slash != NULL) { + seg_len = (size_t)(slash - p); + } else { + seg_len = strlen(p); + } + if (seg_len == 0 || seg_len >= sizeof(seg)) { + return -1; + } + memcpy(seg, p, seg_len); + seg[seg_len] = '\0'; + + /* Check for hardened marker ' or h at end */ + if (seg[seg_len - 1] == '\'' || seg[seg_len - 1] == 'h' || seg[seg_len - 1] == 'H') { + hardened = 1; + seg[seg_len - 1] = '\0'; + } + + val = strtol(seg, &endptr, 10); + if (*endptr != '\0' || val < 0 || val > 0x7FFFFFFF) { + return -1; + } + + path_out[count] = (uint32_t)val; + if (hardened) { + path_out[count] |= 0x80000000u; + } + count++; + + p = (slash != NULL) ? slash + 1 : ""; + if (*p == '\0') { + break; + } + } + + return count; +} + +/* + * Derive a secp256k1 key from an explicit BIP-44 path string. + * Uses BIP-32 derivation (nostr_bip32_key_from_seed + nostr_bip32_derive_path). + * priv_out and pub_out must each be at least 32 bytes. Returns 0 on success, + * -1 on failure. + */ +static int derive_secp256k1_from_path(const char *mnemonic, const char *path_str, + unsigned char *priv_out, unsigned char *pub_out) { + unsigned char bip39_seed[64]; + nostr_hd_key_t master_key; + nostr_hd_key_t derived_key; + uint32_t path[16]; + int path_len; + + if (mnemonic == NULL || path_str == NULL || priv_out == NULL || pub_out == NULL) { + return -1; + } + + path_len = parse_bip44_path(path_str, path, (int)(sizeof(path) / sizeof(path[0]))); + if (path_len <= 0) { + return -1; + } + + if (nostr_bip39_mnemonic_to_seed(mnemonic, "", bip39_seed, sizeof(bip39_seed)) != 0) { + return -1; + } + + if (nostr_bip32_key_from_seed(bip39_seed, sizeof(bip39_seed), &master_key) != 0) { + secure_memzero(bip39_seed, sizeof(bip39_seed)); + return -1; + } + + if (nostr_bip32_derive_path(&master_key, path, (size_t)path_len, &derived_key) != 0) { + secure_memzero(bip39_seed, sizeof(bip39_seed)); + secure_memzero(&master_key, sizeof(master_key)); + return -1; + } + + memcpy(priv_out, derived_key.private_key, 32); + memcpy(pub_out, derived_key.public_key + 1, 32); /* x-only (drop compression prefix) */ + + secure_memzero(bip39_seed, sizeof(bip39_seed)); + secure_memzero(&master_key, sizeof(master_key)); + secure_memzero(&derived_key, sizeof(derived_key)); + return 0; +} + /* * Derive a secp256k1 (Nostr) key for a role into the variable-length * derived_key_t. Returns 0 on success, -1 on failure. @@ -682,6 +811,7 @@ static int derive_secp256k1(derived_key_t *dst, const role_entry_t *role, unsigned char priv[32]; unsigned char pub[32]; const crypto_alg_sizes_t *sz; + int rc; sz = crypto_alg_get_sizes(CRYPTO_ALG_SECP256K1); if (sz == NULL) { @@ -696,8 +826,14 @@ static int derive_secp256k1(derived_key_t *dst, const role_entry_t *role, return -1; } - if (nostr_derive_keys_from_mnemonic(mnemonic_get_phrase(mnemonic), - role->nostr_index, priv, pub) != 0) { + if (role->selector_type == SELECTOR_ROLE_PATH) { + rc = derive_secp256k1_from_path(mnemonic_get_phrase(mnemonic), + role->role_path, priv, pub); + } else { + rc = nostr_derive_keys_from_mnemonic(mnemonic_get_phrase(mnemonic), + role->nostr_index, priv, pub); + } + if (rc != 0) { secure_memzero(priv, sizeof(priv)); secure_memzero(pub, sizeof(pub)); secure_buf_free(&dst->private_key); @@ -747,7 +883,12 @@ static int derive_ed25519(derived_key_t *dst, const role_entry_t *role, return -1; } - snprintf(path, sizeof(path), "m/44'/102001'/%d'/0'/0'", role->nostr_index); + if (role->selector_type == SELECTOR_ROLE_PATH) { + strncpy(path, role->role_path, sizeof(path) - 1); + path[sizeof(path) - 1] = '\0'; + } else { + snprintf(path, sizeof(path), "m/44'/102001'/%d'/0'/0'", role->nostr_index); + } if (crypto_derive_seed_from_mnemonic(mnemonic_get_phrase(mnemonic), path, seed, sizeof(seed)) != 0) { @@ -805,7 +946,12 @@ static int derive_x25519(derived_key_t *dst, const role_entry_t *role, return -1; } - snprintf(path, sizeof(path), "m/44'/102002'/%d'/0'/0'", role->nostr_index); + if (role->selector_type == SELECTOR_ROLE_PATH) { + strncpy(path, role->role_path, sizeof(path) - 1); + path[sizeof(path) - 1] = '\0'; + } else { + snprintf(path, sizeof(path), "m/44'/102002'/%d'/0'/0'", role->nostr_index); + } if (crypto_derive_seed_from_mnemonic(mnemonic_get_phrase(mnemonic), path, seed, sizeof(seed)) != 0) { @@ -861,7 +1007,12 @@ static int derive_ml_dsa_65(derived_key_t *dst, const role_entry_t *role, return -1; } - snprintf(path, sizeof(path), "m/44'/102003'/%d'/0'/0'", role->nostr_index); + if (role->selector_type == SELECTOR_ROLE_PATH) { + strncpy(path, role->role_path, sizeof(path) - 1); + path[sizeof(path) - 1] = '\0'; + } else { + snprintf(path, sizeof(path), "m/44'/102003'/%d'/0'/0'", role->nostr_index); + } if (crypto_derive_seed_from_mnemonic(mnemonic_get_phrase(mnemonic), path, seed, sizeof(seed)) != 0) { @@ -914,7 +1065,12 @@ static int derive_slh_dsa_128s(derived_key_t *dst, const role_entry_t *role, return -1; } - snprintf(path, sizeof(path), "m/44'/102004'/%d'/0'/0'", role->nostr_index); + if (role->selector_type == SELECTOR_ROLE_PATH) { + strncpy(path, role->role_path, sizeof(path) - 1); + path[sizeof(path) - 1] = '\0'; + } else { + snprintf(path, sizeof(path), "m/44'/102004'/%d'/0'/0'", role->nostr_index); + } if (crypto_derive_seed_from_mnemonic(mnemonic_get_phrase(mnemonic), path, seed, sizeof(seed)) != 0) { @@ -967,7 +1123,12 @@ static int derive_ml_kem_768(derived_key_t *dst, const role_entry_t *role, return -1; } - snprintf(path, sizeof(path), "m/44'/102005'/%d'/0'/0'", role->nostr_index); + if (role->selector_type == SELECTOR_ROLE_PATH) { + strncpy(path, role->role_path, sizeof(path) - 1); + path[sizeof(path) - 1] = '\0'; + } else { + snprintf(path, sizeof(path), "m/44'/102005'/%d'/0'/0'", role->nostr_index); + } if (crypto_derive_seed_from_mnemonic(mnemonic_get_phrase(mnemonic), path, seed, sizeof(seed)) != 0) { @@ -1051,7 +1212,8 @@ int crypto_derive_all(key_store_t *store, role_table_t *table, const mnemonic_st role->derived = 0; role->pubkey_hex[0] = '\0'; - if (role->selector_type != SELECTOR_NOSTR_INDEX) { + if (role->selector_type != SELECTOR_NOSTR_INDEX && + role->selector_type != SELECTOR_ROLE_PATH) { continue; } @@ -1099,7 +1261,8 @@ int crypto_derive_one(key_store_t *store, role_table_t *table, const mnemonic_st dst->alg = CRYPTO_ALG_UNKNOWN; dst->valid = 0; - if (role->selector_type != SELECTOR_NOSTR_INDEX) { + if (role->selector_type != SELECTOR_NOSTR_INDEX && + role->selector_type != SELECTOR_ROLE_PATH) { return -1; } diff --git a/src/main.c b/src/main.c index c1dedc0..c70240e 100644 --- a/src/main.c +++ b/src/main.c @@ -119,6 +119,11 @@ typedef struct { char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */ char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */ int derived; /* 1 if pubkey_hex has been populated */ + int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */ + int path_range_hi; /* inclusive upper bound; == path_range_lo for single */ + int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */ + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ } role_entry_t; /* The role table */ @@ -160,7 +165,11 @@ const char *role_curve_to_str(role_curve_t c); /* Register a nostr-index role if missing. Returns 0 on success, -1 on error. */ int role_table_register_nostr_index(role_table_t *table, int nostr_index); - +/* Register a SELECTOR_ROLE_PATH role bound to an explicit derivation path template. */ +int role_table_register_role_path(role_table_t *table, const char *name, const char *path, + role_purpose_t purpose, role_curve_t curve, + int range_lo, int range_hi, int default_index, + const int *allowed_indices, int allowed_count); /* from selector.h */ @@ -180,6 +189,8 @@ typedef struct { int has_role_path; /* 1 if "role_path" field was present */ char role_path[ROLE_PATH_MAX]; + int has_index; /* 1 if "index" field was present (for named path-roles) */ + int index; /* index value for named path-role template */ } selector_request_t; /* Initialize a selector request (all fields zeroed/unset) */ @@ -674,6 +685,24 @@ typedef struct { #define INDEX_WHITELIST_MAX 256 #define INDEX_WHITELIST_BITMAP_SIZE (INDEX_WHITELIST_MAX / 8) +#define PATH_WHITELIST_MAX_TEMPLATES 16 +#define PATH_TEMPLATE_MAX_LEN 128 +#define PATH_TEMPLATE_MAX_INDICES 64 /* max allowed indices per template (for sets) */ + +typedef struct { + char template[PATH_TEMPLATE_MAX_LEN]; /* e.g. "m/44'/1237'/%d/1/0" — one %d placeholder */ + int range_lo; /* inclusive lower bound (for range form) */ + int range_hi; /* inclusive upper bound (== range_lo for single) */ + int allowed_indices[PATH_TEMPLATE_MAX_INDICES]; /* explicit set of allowed indices */ + int allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ +} path_template_t; + +typedef struct { + int active; /* 1 if any path templates are configured */ + int count; + path_template_t templates[PATH_WHITELIST_MAX_TEMPLATES]; +} path_whitelist_t; + typedef struct { char socket_name[SERVER_SOCKET_NAME_MAX]; /* abstract namespace name (without \0 prefix) */ char last_error[256]; @@ -689,6 +718,7 @@ typedef struct { int bridge_source_trusted; int index_whitelist_active; unsigned char index_whitelist[INDEX_WHITELIST_BITMAP_SIZE]; + path_whitelist_t path_whitelist; /* path-template whitelist for role_path requests */ } server_ctx_t; /* Initialize server context. socket_name is the abstract namespace name (e.g. "nsigner"). @@ -725,6 +755,14 @@ void server_set_bridge_source_trusted(server_ctx_t *ctx, int enabled); /* Set the nostr_index whitelist from a spec string ("all", "1,3,4", "0-3", "0-3,7,9") */ int server_set_index_whitelist(server_ctx_t *ctx, const char *spec); +/* Set the unified whitelist (integer nostr_index + path templates) from a spec string. + * Spec: "all", or comma-separated tokens. Integer tokens ("0-3","1,3,4") set the + * nostr_index bitmap. Path-template tokens ("m/44'/1237'/0-3/1/0") set the path whitelist. */ +int server_set_path_whitelist(server_ctx_t *ctx, const char *spec); + +/* Check if a role_path is allowed by the path whitelist. Returns 1 if allowed, 0 if not. */ +int server_path_whitelist_allows(const server_ctx_t *ctx, const char *role_path); + /* Configure non-interactive prompt fallback: -1 disabled, POLICY_ALLOW, or POLICY_DENY */ void server_set_noninteractive_prompt_default(int decision); @@ -762,8 +800,8 @@ int socket_name_random(char *out, size_t out_len); /* Version information (auto-updated by build/version tooling) */ #define NSIGNER_VERSION_MAJOR 0 #define NSIGNER_VERSION_MINOR 1 -#define NSIGNER_VERSION_PATCH 10 -#define NSIGNER_VERSION "v0.1.10" +#define NSIGNER_VERSION_PATCH 11 +#define NSIGNER_VERSION "v0.1.11" /* NSIGNER_HEADERLESS_DECLS_END */ @@ -931,6 +969,204 @@ static int read_line_stdin(char *buf, size_t buf_sz) { return 0; } +/* + * Read a line from stdin with inline editing support, using termios raw mode. + * Pre-fills the buffer with `prefill` (if non-NULL), positions the cursor at + * the end, and allows arrow-key navigation, backspace, delete, home, end, + * and regular character insertion. On Enter, returns the edited string in + * `buf`. Returns 0 on success, -1 on error/EOF. + * + * Only works when stdin is a TTY. Falls back to read_line_stdin if not a TTY + * (in which case prefill is ignored). + */ +static int read_line_editable(char *buf, size_t buf_sz, const char *prefill) { + struct termios old_term, new_term; + size_t len = 0; /* current text length */ + size_t pos = 0; /* cursor position (0..len) */ + int fd = STDIN_FILENO; + int was_raw = 0; + + if (buf == NULL || buf_sz == 0) { + return -1; + } + + /* If not a TTY, fall back to plain fgets */ + if (!isatty(fd)) { + return read_line_stdin(buf, buf_sz); + } + + /* Pre-fill */ + if (prefill != NULL) { + len = strlen(prefill); + if (len >= buf_sz) len = buf_sz - 1; + memcpy(buf, prefill, len); + buf[len] = '\0'; + pos = len; + } else { + buf[0] = '\0'; + } + + /* Enter raw mode */ + if (tcgetattr(fd, &old_term) == 0) { + new_term = old_term; + new_term.c_lflag &= ~(ICANON | ECHO); + new_term.c_cc[VMIN] = 1; + new_term.c_cc[VTIME] = 0; + if (tcsetattr(fd, TCSANOW, &new_term) == 0) { + was_raw = 1; + } + } + + /* Draw the initial pre-filled text */ + if (len > 0) { + fputs(buf, stdout); + fflush(stdout); + } + + for (;;) { + char ch; + ssize_t n = read(fd, &ch, 1); + if (n <= 0) { + if (was_raw) tcsetattr(fd, TCSANOW, &old_term); + return -1; + } + + if (ch == '\n' || ch == '\r') { + /* Enter — done */ + buf[len] = '\0'; + fputc('\n', stdout); + fflush(stdout); + break; + } else if (ch == 0x7f || ch == 0x08) { + /* Backspace (DEL or BS) — delete char before cursor */ + if (pos > 0) { + size_t i; + for (i = pos - 1; i < len - 1; i++) { + buf[i] = buf[i + 1]; + } + len--; + pos--; + buf[len] = '\0'; + /* Redraw: move to start of field, clear line, redraw, reposition */ + fputs("\r\033[K", stdout); /* CR + clear to end of line */ + fputs(buf, stdout); + if (pos < len) { + /* Move cursor left to pos */ + printf("\033[%zuD", len - pos); + } + fflush(stdout); + } + } else if (ch == 0x1b) { + /* Escape sequence — arrow keys, etc. */ + char seq[2]; + if (read(fd, &seq[0], 1) <= 0) continue; + if (read(fd, &seq[1], 1) <= 0) continue; + if (seq[0] == '[') { + if (seq[1] == 'D') { + /* Left arrow */ + if (pos > 0) { + pos--; + fputs("\033[D", stdout); + fflush(stdout); + } + } else if (seq[1] == 'C') { + /* Right arrow */ + if (pos < len) { + pos++; + fputs("\033[C", stdout); + fflush(stdout); + } + } else if (seq[1] == 'A' || seq[1] == 'B') { + /* Up/Down — ignore */ + } else if (seq[1] == 'H') { + /* Home — move to start */ + if (pos > 0) { + printf("\033[%zuD", pos); + pos = 0; + fflush(stdout); + } + } else if (seq[1] == 'F') { + /* End — move to end */ + if (pos < len) { + printf("\033[%zuC", len - pos); + pos = len; + fflush(stdout); + } + } else if (seq[1] == '3') { + /* Delete (Delete key = ESC [ 3 ~ ) */ + char tilde; + if (read(fd, &tilde, 1) <= 0) continue; + if (tilde == '~' && pos < len) { + size_t i; + for (i = pos; i < len - 1; i++) { + buf[i] = buf[i + 1]; + } + len--; + buf[len] = '\0'; + fputs("\r\033[K", stdout); + fputs(buf, stdout); + if (pos < len) { + printf("\033[%zuD", len - pos); + } + fflush(stdout); + } + } + } + } else if (ch == 0x01) { + /* Ctrl-A — home */ + if (pos > 0) { + printf("\033[%zuD", pos); + pos = 0; + fflush(stdout); + } + } else if (ch == 0x05) { + /* Ctrl-E — end */ + if (pos < len) { + printf("\033[%zuC", len - pos); + pos = len; + fflush(stdout); + } + } else if (ch == 0x15) { + /* Ctrl-U — clear entire line */ + if (pos > 0) { + fputs("\r\033[K", stdout); + len = 0; + pos = 0; + buf[0] = '\0'; + fflush(stdout); + } + } else if ((unsigned char)ch >= 0x20 && (unsigned char)ch < 0x7f) { + /* Regular printable character — insert at cursor */ + if (len < buf_sz - 1) { + size_t i; + /* Shift characters right to make room */ + for (i = len; i > pos; i--) { + buf[i] = buf[i - 1]; + } + buf[pos] = ch; + len++; + buf[len] = '\0'; + /* Redraw from cursor position */ + fputs("\r\033[K", stdout); + fputs(buf, stdout); + pos++; + if (pos < len) { + printf("\033[%zuD", len - pos); + } + fflush(stdout); + } + } + /* Ignore other control characters */ + } + + /* Restore terminal */ + if (was_raw) { + tcsetattr(fd, TCSANOW, &old_term); + } + + return 0; +} + static int read_cmd_output_local(const char *cmd, char **out_buf) { FILE *fp; @@ -1106,7 +1342,7 @@ static void print_usage(const char *program_name) { tui_print(" --mnemonic-fd N Read mnemonic from inherited fd N (one line) at startup"); tui_print(" --allow-all, -A Allow all policy prompts for this server session"); tui_print(" --bridge-source-trusted Accept qrexec_source preamble on unix connections (bridge mode)"); - tui_print(" --allow-index SPEC Restrict which nostr_index values this session can access"); + tui_print(" --allow-index SPEC Restrict which nostr_index / role_path values this session can access"); tui_print(" SPEC: 'all' (default), '1,3,4', '0-3', or '0-3,7,9'"); tui_print(" --otp-pad-dir DIR Bind an OTP pad directory at startup (one pad per session)"); tui_print(" --otp-pad SPEC Pad chksum (64 hex) or unique prefix; required with --otp-pad-dir"); @@ -1481,8 +1717,44 @@ static void role_table_get_cell(int row, int col, char *out, size_t out_size, vo case 3: if (r->selector_type == SELECTOR_NOSTR_INDEX) { (void)snprintf(out, out_size, "m/44'/1237'/%d'/0/0", r->nostr_index); - } else { + } else if (r->path_range_lo < 0 && r->path_allowed_count == 0) { + /* Fixed path (no %d placeholder) */ (void)snprintf(out, out_size, "%s", r->role_path); + } else { + /* Template path — replace %d with range or set description */ + char range_str[64]; + char display[ROLE_PATH_MAX + 64]; + const char *pct; + const char *tail; + + if (r->path_allowed_count > 0) { + /* Set: e.g. "1+34+54" */ + int si; + int off = 0; + for (si = 0; si < r->path_allowed_count && off < (int)sizeof(range_str) - 12; ++si) { + off += snprintf(range_str + off, sizeof(range_str) - off, + "%s%d", (si == 0) ? "" : "+", r->path_allowed_indices[si]); + } + range_str[off] = '\0'; + } else if (r->path_range_lo == r->path_range_hi) { + /* Single index */ + snprintf(range_str, sizeof(range_str), "%d", r->path_range_lo); + } else { + /* Range */ + snprintf(range_str, sizeof(range_str), "%d-%d", r->path_range_lo, r->path_range_hi); + } + + /* Replace first %d in role_path with range_str */ + pct = strstr(r->role_path, "%d"); + if (pct != NULL) { + size_t prefix_len = (size_t)(pct - r->role_path); + tail = pct + 2; /* skip "%d" */ + snprintf(display, sizeof(display), "%.*s%s%s", + (int)prefix_len, r->role_path, range_str, tail); + } else { + snprintf(display, sizeof(display), "%s", r->role_path); + } + (void)snprintf(out, out_size, "%s", display); } break; default: @@ -1725,6 +1997,370 @@ static int setup_default_role(role_table_t *role_table) { return role_table_add(role_table, &role); } +/* + * Parse a path template token (e.g. "m/44'/1237'/0-3/1/0" or + * "m/44'/1237'/1+34+54/1/0") into a template with %d placeholder and + * allowed indices. Returns 0 on success, -1 on parse error. + * + * On success: + * template_out — the path with %d replacing the numeric/range/set segment + * range_lo/range_hi — set to the min/max of the allowed indices + * allowed_indices_out / allowed_count_out — the explicit set (if set form + * was used); allowed_count_out is 0 for pure range/single form + */ +static int parse_path_template_for_role(const char *token, + char *template_out, size_t template_sz, + int *range_lo, int *range_hi, + int *allowed_indices_out, int max_allowed, + int *allowed_count_out) { + char buf[ROLE_PATH_MAX]; + char *p; + int found_range = 0; + + if (token == NULL || template_out == NULL || range_lo == NULL || range_hi == NULL || + allowed_indices_out == NULL || allowed_count_out == NULL) { + return -1; + } + + strncpy(buf, token, sizeof(buf) - 1); + buf[sizeof(buf) - 1] = '\0'; + + *range_lo = 0; + *range_hi = 0; + *allowed_count_out = 0; + template_out[0] = '\0'; + + p = buf; + /* Copy up to and including the first '/' */ + { + char *first_slash = strchr(p, '/'); + if (first_slash == NULL) { + return -1; + } + size_t prefix_len = (size_t)(first_slash - p) + 1; + if (prefix_len >= template_sz) { + return -1; + } + memcpy(template_out, p, prefix_len); + template_out[prefix_len] = '\0'; + p = first_slash + 1; + } + + while (p != NULL && *p != '\0') { + char *next_slash = strchr(p, '/'); + char seg[64]; + size_t seg_len; + + if (next_slash != NULL) { + seg_len = (size_t)(next_slash - p); + } else { + seg_len = strlen(p); + } + if (seg_len == 0 || seg_len >= sizeof(seg)) { + return -1; + } + memcpy(seg, p, seg_len); + seg[seg_len] = '\0'; + + if (!found_range) { + char *plus = strchr(seg, '+'); + char *dash = strchr(seg, '-'); + + if (plus != NULL) { + /* Set form: "1+34+54" or "1+3-5+10" */ + int set_count = 0; + char *tok = seg; + int set_ok = 1; + + while (tok != NULL && *tok != '\0') { + char *next_plus = strchr(tok, '+'); + if (next_plus != NULL) *next_plus = '\0'; + + char *sub_dash = strchr(tok, '-'); + if (sub_dash != NULL) { + *sub_dash = '\0'; + char *e1 = NULL, *e2 = NULL; + long lo = strtol(tok, &e1, 10); + long hi = strtol(sub_dash + 1, &e2, 10); + if (*e1 != '\0' || *e2 != '\0' || lo < 0 || hi < 0 || lo > hi) { + set_ok = 0; break; + } + for (long vi = lo; vi <= hi && set_count < max_allowed; vi++) { + allowed_indices_out[set_count++] = (int)vi; + } + } else { + char *e = NULL; + long val = strtol(tok, &e, 10); + if (*e != '\0' || val < 0) { set_ok = 0; break; } + if (set_count < max_allowed) { + allowed_indices_out[set_count++] = (int)val; + } + } + tok = (next_plus != NULL) ? next_plus + 1 : NULL; + } + + if (set_ok && set_count > 0) { + found_range = 1; + *allowed_count_out = set_count; + *range_lo = allowed_indices_out[0]; + *range_hi = allowed_indices_out[set_count - 1]; + if (strlen(template_out) + 3 >= template_sz) return -1; + strcat(template_out, "%d"); + strcat(template_out, "/"); + } else { + if (strlen(template_out) + seg_len + 2 >= template_sz) return -1; + strcat(template_out, seg); + strcat(template_out, "/"); + } + } else if (dash != NULL) { + /* Range form: "N-M" */ + *dash = '\0'; + char *e1 = NULL, *e2 = NULL; + long lo = strtol(seg, &e1, 10); + long hi = strtol(dash + 1, &e2, 10); + if (*e1 != '\0' || *e2 != '\0' || lo < 0 || hi < 0 || lo > hi) { + if (strlen(template_out) + seg_len + 2 >= template_sz) return -1; + strcat(template_out, seg); + strcat(template_out, "/"); + } else { + found_range = 1; + *range_lo = (int)lo; + *range_hi = (int)hi; + if (strlen(template_out) + 3 >= template_sz) return -1; + strcat(template_out, "%d"); + strcat(template_out, "/"); + } + } else { + /* Single number */ + char *e = NULL; + long v = strtol(seg, &e, 10); + if (*e != '\0' || v < 0) { + if (strlen(template_out) + seg_len + 2 >= template_sz) return -1; + strcat(template_out, seg); + strcat(template_out, "/"); + } else { + found_range = 1; + *range_lo = (int)v; + *range_hi = (int)v; + if (strlen(template_out) + 3 >= template_sz) return -1; + strcat(template_out, "%d"); + strcat(template_out, "/"); + } + } + } else { + if (strlen(template_out) + seg_len + 2 >= template_sz) return -1; + strcat(template_out, seg); + strcat(template_out, "/"); + } + + p = (next_slash != NULL) ? next_slash + 1 : NULL; + } + + /* Remove trailing '/' */ + { + size_t tlen = strlen(template_out); + if (tlen > 0 && template_out[tlen - 1] == '/') { + template_out[tlen - 1] = '\0'; + } + } + + if (!found_range) { + /* Fixed path — no variable segment. Treat as a single fixed key. */ + *range_lo = -1; + *range_hi = -1; + *allowed_count_out = 0; + } + return 0; +} + +/* + * Auto-detect purpose from a derivation path prefix. + * m/44'/1237' → nostr, m/44'/102001' → ssh, etc. + * Falls back to PURPOSE_NOSTR for unrecognized prefixes. + */ +static role_purpose_t purpose_from_path(const char *path) { + if (path == NULL) return PURPOSE_NOSTR; + if (strncmp(path, "m/44'/1237'", 11) == 0) return PURPOSE_NOSTR; + if (strncmp(path, "m/44'/102001'", 13) == 0) return PURPOSE_SSH; + if (strncmp(path, "m/44'/102002'", 13) == 0) return PURPOSE_AGE; + if (strncmp(path, "m/44'/102003'", 13) == 0) return PURPOSE_PQ_SIG; + if (strncmp(path, "m/44'/102004'", 13) == 0) return PURPOSE_PQ_SIG; + if (strncmp(path, "m/44'/102005'", 13) == 0) return PURPOSE_PQ_KEM; + if (strncmp(path, "m/84'", 4) == 0) return PURPOSE_BITCOIN; + if (strncmp(path, "m/86'", 4) == 0) return PURPOSE_BITCOIN; + return PURPOSE_NOSTR; /* default */ +} + +/* + * Interactive prompt to define named path-roles. Each role binds a name + * (which acts as an access token for clients) to a derivation path template + * with an optional range and default index. The path is hidden from clients. + * + * Only shown when stdin is a TTY and mnemonic was loaded via TUI. + */ +static void prompt_named_path_roles(role_table_t *role_table) { + char input[256]; + + if (role_table == NULL) { + return; + } + + for (;;) { + tui_render_content_screen(NULL, "Named path roles — bind a role name to a derivation path template"); + printf("Define a named path role? [y/N] "); + fflush(stdout); + + if (read_line_stdin(input, sizeof(input)) != 0) { + return; + } + if (tolower((unsigned char)input[0]) != 'y') { + return; + } + + /* Role name */ + char role_name[ROLE_NAME_MAX]; + printf(" Role name: "); + fflush(stdout); + if (read_line_stdin(role_name, sizeof(role_name)) != 0) return; + /* Trim trailing whitespace */ + { + size_t len = strlen(role_name); + while (len > 0 && (role_name[len-1] == '\n' || role_name[len-1] == '\r' || + role_name[len-1] == ' ' || role_name[len-1] == '\t')) { + role_name[--len] = '\0'; + } + } + if (role_name[0] == '\0') { + printf(" Empty role name, skipping.\n"); + continue; + } + if (role_table_find_by_name(role_table, role_name) != NULL) { + printf(" Role '%s' already exists, skipping.\n", role_name); + continue; + } + + /* Curve — numbered selection */ + printf(" Curve:\n"); + printf(" 1) secp256k1 (Nostr, Bitcoin)\n"); + printf(" 2) ed25519 (SSH)\n"); + printf(" 3) x25519 (key agreement, Age)\n"); + printf(" 4) ml-dsa-65 (post-quantum signatures)\n"); + printf(" 5) slh-dsa-128s (post-quantum signatures)\n"); + printf(" 6) ml-kem-768 (post-quantum KEM)\n"); + printf(" Select [1]: "); + fflush(stdout); + char curve_choice[16]; + if (read_line_stdin(curve_choice, sizeof(curve_choice)) != 0) return; + { + size_t len = strlen(curve_choice); + while (len > 0 && (curve_choice[len-1] == '\n' || curve_choice[len-1] == '\r' || + curve_choice[len-1] == ' ' || curve_choice[len-1] == '\t')) { + curve_choice[--len] = '\0'; + } + } + int cchoice = 1; + if (curve_choice[0] != '\0') { + cchoice = atoi(curve_choice); + if (cchoice < 1 || cchoice > 6) cchoice = 1; + } + role_curve_t curve; + switch (cchoice) { + case 2: curve = CURVE_ED25519; break; + case 3: curve = CURVE_X25519; break; + case 4: curve = CURVE_ML_DSA_65; break; + case 5: curve = CURVE_SLH_DSA_128S; break; + case 6: curve = CURVE_ML_KEM_768; break; + default: curve = CURVE_SECP256K1; break; + } + + /* Path template — pre-filled with default, inline editing */ + printf(" Path template (use N-M for range, A+B+C for set, e.g. m/44'/1237'/1-100/2/0):\n"); + printf(" (arrow keys to edit, Enter to accept):\n "); + fflush(stdout); + char path_token[ROLE_PATH_MAX]; + if (read_line_editable(path_token, sizeof(path_token), + "m/44'/1237'/0'/0/0") != 0) return; + { + size_t len = strlen(path_token); + while (len > 0 && (path_token[len-1] == '\n' || path_token[len-1] == '\r' || + path_token[len-1] == ' ' || path_token[len-1] == '\t')) { + path_token[--len] = '\0'; + } + } + if (path_token[0] == '\0') { + strncpy(path_token, "m/44'/1237'/0'/0/0", sizeof(path_token) - 1); + path_token[sizeof(path_token) - 1] = '\0'; + } + + char template[ROLE_PATH_MAX]; + int range_lo, range_hi; + int allowed_indices[64]; + int allowed_count = 0; + if (parse_path_template_for_role(path_token, template, sizeof(template), + &range_lo, &range_hi, + allowed_indices, 64, &allowed_count) != 0) { + printf(" Invalid path template: '%s'.\n", path_token); + continue; + } + + /* Auto-detect purpose from path prefix (hidden from user) */ + role_purpose_t purpose = purpose_from_path(template); + + /* Validate purpose+curve combination */ + if (crypto_alg_from_role(curve, purpose) == CRYPTO_ALG_UNKNOWN) { + printf(" Curve %s is not valid for path prefix of this template. Try a different curve.\n", + role_curve_to_str(curve)); + continue; + } + + int default_index = -1; + int is_fixed = (range_lo < 0); /* fixed path, no variable segment */ + + if (!is_fixed) { + /* Default index — only for templated paths */ + char default_idx_str[16]; + printf(" Default index [%d]: ", range_lo); + fflush(stdout); + if (read_line_stdin(default_idx_str, sizeof(default_idx_str)) != 0) return; + { + size_t len = strlen(default_idx_str); + while (len > 0 && (default_idx_str[len-1] == '\n' || default_idx_str[len-1] == '\r' || + default_idx_str[len-1] == ' ' || default_idx_str[len-1] == '\t')) { + default_idx_str[--len] = '\0'; + } + } + if (default_idx_str[0] == '\0') { + default_index = range_lo; + } else { + char *endp = NULL; + long di = strtol(default_idx_str, &endp, 10); + if (*endp != '\0' || di < range_lo || di > range_hi) { + printf(" Default index out of range [%d-%d], using %d.\n", + range_lo, range_hi, range_lo); + default_index = range_lo; + } else { + default_index = (int)di; + } + } + } + + /* Register the role */ + if (role_table_register_role_path(role_table, role_name, template, + purpose, curve, + range_lo, range_hi, default_index, + (allowed_count > 0) ? allowed_indices : NULL, + allowed_count) != 0) { + printf(" Failed to register role '%s' (table full?).\n", role_name); + } else if (is_fixed) { + printf(" Role '%s' registered: curve=%s path=%s (fixed).\n", + role_name, role_curve_to_str(curve), template); + } else { + printf(" Role '%s' registered: curve=%s path=%s (default index %d).\n", + role_name, role_curve_to_str(curve), template, default_index); + } + } +} + static int prompt_load_mnemonic_tui(mnemonic_state_t *mnemonic) { char phrase[MNEMONIC_MAX_LEN]; char phrase_copy[MNEMONIC_MAX_LEN]; @@ -2089,14 +2725,15 @@ static char *prompt_index_whitelist(void) { char input[256]; for (;;) { - tui_render_content_screen(NULL, "Index whitelist — restrict which nostr_index values this session can access"); - printf("Enter allowed indices, or press Enter for 'all' (no restriction):\n\n"); + tui_render_content_screen(NULL, "Whitelist — restrict which nostr_index / role_path values this session can access"); + printf("Enter allowed indices/paths, or press Enter for 'all' (no restriction):\n\n"); printf(" Examples:\n"); - printf(" all (default — allow all indices)\n"); - printf(" 0 (only index 0)\n"); - printf(" 0,1,3 (specific indices)\n"); - printf(" 0-3 (range 0 through 3)\n"); - printf(" 0,2-3,7 (mixed list and ranges)\n"); + printf(" all (default — allow all)\n"); + printf(" 0 (only nostr_index 0)\n"); + printf(" 0-3 (nostr_index 0..3)\n"); + printf(" m/44'/1237'/0-3/0/0 (NIP-06 paths X=0..3)\n"); + printf(" m/44'/1237'/0-3/1/0 (custom paths X=0..3, change=1)\n"); + printf(" m/44'/1237'/0-3/0/0,m/44'/1237'/0-3/1/0 (both)\n"); printf("\n Enter = all\n"); printf("> "); fflush(stdout); @@ -2123,7 +2760,7 @@ static char *prompt_index_whitelist(void) { { server_ctx_t tmp; memset(&tmp, 0, sizeof(tmp)); - if (server_set_index_whitelist(&tmp, input) != 0) { + if (server_set_path_whitelist(&tmp, input) != 0) { printf("Invalid spec: '%s'. Try again or press Enter for 'all'.\n", input); continue; } @@ -2696,6 +3333,11 @@ int main(int argc, char *argv[]) { return 1; } + /* Interactive named path-role definition (only in TUI mode) */ + if (mnemonic_source.kind == MNEMONIC_SOURCE_TUI && isatty(STDIN_FILENO)) { + prompt_named_path_roles(&role_table); + } + memset(&key_store, 0, sizeof(key_store)); alg_key_cache_init(&alg_key_cache); @@ -2801,11 +3443,22 @@ int main(int argc, char *argv[]) { return 1; } - /* Index whitelist prompt (only if --allow-index wasn't given on CLI) */ + /* Index whitelist prompt (only if --allow-index wasn't given on CLI + * AND no named path-roles were defined — named roles are themselves + * the allowed set, so the separate whitelist is redundant. */ if (allow_index_spec == NULL) { - char *wl_spec = prompt_index_whitelist(); - if (wl_spec != NULL) { - allow_index_spec = wl_spec; /* will be freed at program exit */ + int has_path_roles = 0; + for (int i = 0; i < role_table.count; i++) { + if (role_table.entries[i].selector_type == SELECTOR_ROLE_PATH) { + has_path_roles = 1; + break; + } + } + if (!has_path_roles) { + char *wl_spec = prompt_index_whitelist(); + if (wl_spec != NULL) { + allow_index_spec = wl_spec; /* will be freed at program exit */ + } } } @@ -2899,7 +3552,7 @@ int main(int argc, char *argv[]) { server_set_bridge_source_trusted(&server, 1); } if (allow_index_spec != NULL) { - if (server_set_index_whitelist(&server, allow_index_spec) != 0) { + if (server_set_path_whitelist(&server, allow_index_spec) != 0) { fprintf(stderr, "Invalid --allow-index spec: %s\n", allow_index_spec); fprintf(stderr, "Expected: 'all', '1,3,4', '0-3', or '0-3,7,9'\n"); crypto_wipe(&key_store); alg_key_cache_wipe(&alg_key_cache); @@ -2942,7 +3595,7 @@ int main(int argc, char *argv[]) { &dispatcher, &policy); if (allow_index_spec != NULL) { - server_set_index_whitelist(&servers[tcp_server_idx], allow_index_spec); + server_set_path_whitelist(&servers[tcp_server_idx], allow_index_spec); } if (server_start(&servers[tcp_server_idx]) != 0) { fprintf(stderr, "Failed to start FIPS/TCP server on %s: %s\n", @@ -2970,7 +3623,7 @@ int main(int argc, char *argv[]) { &dispatcher, &policy); if (allow_index_spec != NULL) { - server_set_index_whitelist(&servers[http_server_idx], allow_index_spec); + server_set_path_whitelist(&servers[http_server_idx], allow_index_spec); } if (server_start(&servers[http_server_idx]) != 0) { fprintf(stderr, "Failed to start HTTP server on %s: %s\n", diff --git a/src/mnemonic.c b/src/mnemonic.c index cd309df..a1f9aa8 100644 --- a/src/mnemonic.c +++ b/src/mnemonic.c @@ -116,6 +116,11 @@ typedef struct { char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */ char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */ int derived; /* 1 if pubkey_hex has been populated */ + int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */ + int path_range_hi; /* inclusive upper bound; == path_range_lo for single */ + int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */ + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ } role_entry_t; /* The role table */ @@ -174,6 +179,8 @@ typedef struct { int has_role_path; /* 1 if "role_path" field was present */ char role_path[ROLE_PATH_MAX]; + int has_index; /* 1 if "index" field was present (for named path-roles) */ + int index; /* index value for named path-role template */ } selector_request_t; /* Initialize a selector request (all fields zeroed/unset) */ diff --git a/src/policy.c b/src/policy.c index 56450b9..f8ae102 100644 --- a/src/policy.c +++ b/src/policy.c @@ -116,6 +116,11 @@ typedef struct { char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */ char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */ int derived; /* 1 if pubkey_hex has been populated */ + int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */ + int path_range_hi; /* inclusive upper bound; == path_range_lo for single */ + int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */ + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ } role_entry_t; /* The role table */ @@ -174,6 +179,8 @@ typedef struct { int has_role_path; /* 1 if "role_path" field was present */ char role_path[ROLE_PATH_MAX]; + int has_index; /* 1 if "index" field was present (for named path-roles) */ + int index; /* index value for named path-role template */ } selector_request_t; /* Initialize a selector request (all fields zeroed/unset) */ diff --git a/src/pq_crypto.c b/src/pq_crypto.c index 31ae1ae..63b5db1 100644 --- a/src/pq_crypto.c +++ b/src/pq_crypto.c @@ -126,6 +126,11 @@ typedef struct { char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */ char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */ int derived; /* 1 if pubkey_hex has been populated */ + int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */ + int path_range_hi; /* inclusive upper bound; == path_range_lo for single */ + int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */ + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ } role_entry_t; /* The role table */ @@ -167,7 +172,11 @@ const char *role_curve_to_str(role_curve_t c); /* Register a nostr-index role if missing. Returns 0 on success, -1 on error. */ int role_table_register_nostr_index(role_table_t *table, int nostr_index); - +/* Register a SELECTOR_ROLE_PATH role bound to an explicit derivation path template. */ +int role_table_register_role_path(role_table_t *table, const char *name, const char *path, + role_purpose_t purpose, role_curve_t curve, + int range_lo, int range_hi, int default_index, + const int *allowed_indices, int allowed_count); /* from selector.h */ @@ -187,6 +196,8 @@ typedef struct { int has_role_path; /* 1 if "role_path" field was present */ char role_path[ROLE_PATH_MAX]; + int has_index; /* 1 if "index" field was present (for named path-roles) */ + int index; /* index value for named path-role template */ } selector_request_t; /* Initialize a selector request (all fields zeroed/unset) */ diff --git a/src/role_table.c b/src/role_table.c index d95c4c1..6e2cc47 100644 --- a/src/role_table.c +++ b/src/role_table.c @@ -116,6 +116,11 @@ typedef struct { char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */ char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */ int derived; /* 1 if pubkey_hex has been populated */ + int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */ + int path_range_hi; /* inclusive upper bound; == path_range_lo for single */ + int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */ + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ } role_entry_t; /* The role table */ @@ -177,6 +182,8 @@ typedef struct { int has_role_path; /* 1 if "role_path" field was present */ char role_path[ROLE_PATH_MAX]; + int has_index; /* 1 if "index" field was present (for named path-roles) */ + int index; /* index value for named path-role template */ } selector_request_t; /* Initialize a selector request (all fields zeroed/unset) */ @@ -832,6 +839,74 @@ int role_table_register_nostr_index(role_table_t *table, int nostr_index) { return role_table_add(table, &role); } +/* + * Register a SELECTOR_ROLE_PATH role bound to an explicit derivation path + * template (with optional %d placeholder and range/set). Idempotent: if a role + * with the same path template already exists, returns 0. + * + * `path` may contain a "%d" placeholder (for ranged/set templates) or be a + * fixed path (no placeholder). range_lo/range_hi specify the allowed index + * range for the placeholder; for fixed paths, pass range_lo == range_hi == 0. + * If allowed_indices != NULL and allowed_count > 0, the set form is used + * instead of the range. default_index is the index used when a client sends + * {"role":"name"} without an explicit "index"; -1 means require an explicit + * index. + */ +int role_table_register_role_path(role_table_t *table, const char *name, + const char *path, role_purpose_t purpose, + role_curve_t curve, + int range_lo, int range_hi, int default_index, + const int *allowed_indices, int allowed_count) { + role_entry_t role; + int i; + + if (table == NULL || name == NULL || path == NULL) { + return -1; + } + + /* Idempotent: check if a role with this path template already exists */ + for (i = 0; i < table->count; ++i) { + if (table->entries[i].selector_type == SELECTOR_ROLE_PATH && + strcmp(table->entries[i].role_path, path) == 0) { + return 0; + } + } + + memset(&role, 0, sizeof(role)); + + strncpy(role.name, name, sizeof(role.name) - 1); + role.name[sizeof(role.name) - 1] = '\0'; + + strncpy(role.purpose_str, role_purpose_to_str(purpose), sizeof(role.purpose_str) - 1); + role.purpose_str[sizeof(role.purpose_str) - 1] = '\0'; + + strncpy(role.curve_str, role_curve_to_str(curve), sizeof(role.curve_str) - 1); + role.curve_str[sizeof(role.curve_str) - 1] = '\0'; + + role.purpose = purpose; + role.curve = curve; + role.selector_type = SELECTOR_ROLE_PATH; + strncpy(role.role_path, path, sizeof(role.role_path) - 1); + role.role_path[sizeof(role.role_path) - 1] = '\0'; + role.nostr_index = -1; + role.path_range_lo = range_lo; + role.path_range_hi = range_hi; + role.path_default_index = default_index; + if (allowed_indices != NULL && allowed_count > 0) { + int copy_n = allowed_count; + if (copy_n > (int)(sizeof(role.path_allowed_indices) / sizeof(role.path_allowed_indices[0]))) { + copy_n = (int)(sizeof(role.path_allowed_indices) / sizeof(role.path_allowed_indices[0])); + } + memcpy(role.path_allowed_indices, allowed_indices, (size_t)copy_n * sizeof(int)); + role.path_allowed_count = copy_n; + } else { + role.path_allowed_count = 0; + } + role.derived = 0; + + return role_table_add(table, &role); +} + role_purpose_t role_purpose_from_str(const char *s) { if (str_eq(s, "nostr")) { return PURPOSE_NOSTR; diff --git a/src/secure_mem.c b/src/secure_mem.c index fdc7130..3022fa4 100644 --- a/src/secure_mem.c +++ b/src/secure_mem.c @@ -118,6 +118,11 @@ typedef struct { char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */ char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */ int derived; /* 1 if pubkey_hex has been populated */ + int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */ + int path_range_hi; /* inclusive upper bound; == path_range_lo for single */ + int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */ + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ } role_entry_t; /* The role table */ @@ -176,6 +181,8 @@ typedef struct { int has_role_path; /* 1 if "role_path" field was present */ char role_path[ROLE_PATH_MAX]; + int has_index; /* 1 if "index" field was present (for named path-roles) */ + int index; /* index value for named path-role template */ } selector_request_t; /* Initialize a selector request (all fields zeroed/unset) */ diff --git a/src/selector.c b/src/selector.c index 4545964..949fe1d 100644 --- a/src/selector.c +++ b/src/selector.c @@ -116,6 +116,11 @@ typedef struct { char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */ char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */ int derived; /* 1 if pubkey_hex has been populated */ + int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */ + int path_range_hi; /* inclusive upper bound; == path_range_lo for single */ + int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */ + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ } role_entry_t; /* The role table */ @@ -174,6 +179,8 @@ typedef struct { int has_role_path; /* 1 if "role_path" field was present */ char role_path[ROLE_PATH_MAX]; + int has_index; /* 1 if "index" field was present (for named path-roles) */ + int index; /* index value for named path-role template */ } selector_request_t; /* Initialize a selector request (all fields zeroed/unset) */ diff --git a/src/server.c b/src/server.c index 2d24d20..e233987 100644 --- a/src/server.c +++ b/src/server.c @@ -119,6 +119,11 @@ typedef struct { char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */ char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */ int derived; /* 1 if pubkey_hex has been populated */ + int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */ + int path_range_hi; /* inclusive upper bound; == path_range_lo for single */ + int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */ + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ } role_entry_t; /* The role table */ @@ -160,7 +165,11 @@ const char *role_curve_to_str(role_curve_t c); /* Register a nostr-index role if missing. Returns 0 on success, -1 on error. */ int role_table_register_nostr_index(role_table_t *table, int nostr_index); - +/* Register a SELECTOR_ROLE_PATH role bound to an explicit derivation path template. */ +int role_table_register_role_path(role_table_t *table, const char *name, const char *path, + role_purpose_t purpose, role_curve_t curve, + int range_lo, int range_hi, int default_index, + const int *allowed_indices, int allowed_count); /* from selector.h */ @@ -180,6 +189,8 @@ typedef struct { int has_role_path; /* 1 if "role_path" field was present */ char role_path[ROLE_PATH_MAX]; + int has_index; /* 1 if "index" field was present (for named path-roles) */ + int index; /* index value for named path-role template */ } selector_request_t; /* Initialize a selector request (all fields zeroed/unset) */ @@ -677,6 +688,24 @@ typedef struct { #define INDEX_WHITELIST_MAX 256 /* nostr_index range 0-255 */ #define INDEX_WHITELIST_BITMAP_SIZE (INDEX_WHITELIST_MAX / 8) /* 32 bytes */ +#define PATH_WHITELIST_MAX_TEMPLATES 16 +#define PATH_TEMPLATE_MAX_LEN 128 +#define PATH_TEMPLATE_MAX_INDICES 64 /* max allowed indices per template (for sets) */ + +typedef struct { + char template[PATH_TEMPLATE_MAX_LEN]; /* e.g. "m/44'/1237'/%d/1/0" — one %d placeholder */ + int range_lo; /* inclusive lower bound (for range form) */ + int range_hi; /* inclusive upper bound (== range_lo for single) */ + int allowed_indices[PATH_TEMPLATE_MAX_INDICES]; /* explicit set of allowed indices */ + int allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ +} path_template_t; + +typedef struct { + int active; /* 1 if any path templates are configured */ + int count; + path_template_t templates[PATH_WHITELIST_MAX_TEMPLATES]; +} path_whitelist_t; + typedef struct { char socket_name[SERVER_SOCKET_NAME_MAX]; /* abstract namespace name (without \0 prefix) */ char last_error[256]; @@ -692,6 +721,7 @@ typedef struct { int bridge_source_trusted; /* when set, unix connections send a qrexec_source preamble */ int index_whitelist_active; /* 1 if index whitelist is set (not "all") */ unsigned char index_whitelist[INDEX_WHITELIST_BITMAP_SIZE]; /* bitmap of allowed nostr_index values */ + path_whitelist_t path_whitelist; /* path-template whitelist for role_path requests */ } server_ctx_t; /* Initialize server context. socket_name is the abstract namespace name (e.g. "nsigner"). @@ -1301,6 +1331,12 @@ static int extract_method_and_selector(const char *json, selector_req->has_role_path = 1; json_copy_string(selector_req->role_path, sizeof(selector_req->role_path), tmp->valuestring, ""); } + + tmp = cJSON_GetObjectItemCaseSensitive(options_item, "index"); + if (cJSON_IsNumber(tmp)) { + selector_req->has_index = 1; + selector_req->index = tmp->valueint; + } } } @@ -1334,6 +1370,7 @@ void server_init(server_ctx_t *ctx, const char *socket_name, int socket_name_exp ctx->bridge_source_trusted = 0; ctx->index_whitelist_active = 0; memset(ctx->index_whitelist, 0, sizeof(ctx->index_whitelist)); + memset(&ctx->path_whitelist, 0, sizeof(ctx->path_whitelist)); if (!g_auth_nonce_cache_inited) { auth_nonce_cache_init(&g_auth_nonce_cache); g_auth_nonce_cache_inited = 1; @@ -1428,6 +1465,313 @@ int server_index_whitelist_allows(const server_ctx_t *ctx, int nostr_index) { return whitelist_get_bit(ctx->index_whitelist, nostr_index); } +/* + * Parse a single path-template token (e.g. "m/44'/1237'/0-3/1/0") into a + * path_template_t. The first path segment matching ^[0-9]+(-[0-9]+)?$ is + * treated as the range placeholder and replaced with "%d" in the stored + * template. Returns 0 on success, -1 on parse error. + */ +static int parse_path_template_token(path_template_t *out, const char *token) { + char buf[PATH_TEMPLATE_MAX_LEN]; + char *p; + char *seg; + int found_range = 0; + + if (out == NULL || token == NULL) { + return -1; + } + + strncpy(buf, token, sizeof(buf) - 1); + buf[sizeof(buf) - 1] = '\0'; + + /* buf starts with "m/" — split by '/' and find the first numeric/range segment */ + memset(out->template, 0, sizeof(out->template)); + out->range_lo = 0; + out->range_hi = 0; + + /* Build the output template, replacing the first numeric segment with %d */ + p = buf; + seg = strchr(p, '/'); + if (seg != NULL) { + /* copy up to and including the first '/' */ + size_t prefix_len = (size_t)(seg - p) + 1; + if (prefix_len >= sizeof(out->template)) { + return -1; + } + memcpy(out->template, p, prefix_len); + out->template[prefix_len] = '\0'; + p = seg + 1; + } else { + /* no '/' — not a valid path template */ + return -1; + } + + while (p != NULL && *p != '\0') { + char *next_slash = strchr(p, '/'); + size_t seg_len; + char seg_buf[32]; + + if (next_slash != NULL) { + seg_len = (size_t)(next_slash - p); + } else { + seg_len = strlen(p); + } + if (seg_len >= sizeof(seg_buf)) { + return -1; + } + memcpy(seg_buf, p, seg_len); + seg_buf[seg_len] = '\0'; + + if (!found_range) { + /* Check if this segment is a number, range "N-M", or set "A+B+C" */ + char *plus = strchr(seg_buf, '+'); + char *dash = strchr(seg_buf, '-'); + + if (plus != NULL) { + /* Set form: "1+34+54" or "1+3-5+10" — parse each + separated entry */ + int set_count = 0; + char *tok = seg_buf; + int set_ok = 1; + + while (tok != NULL && *tok != '\0') { + char *next_plus = strchr(tok, '+'); + if (next_plus != NULL) *next_plus = '\0'; + + /* Each token is either "N" or "N-M" */ + char *sub_dash = strchr(tok, '-'); + if (sub_dash != NULL) { + *sub_dash = '\0'; + char *e1 = NULL, *e2 = NULL; + long lo = strtol(tok, &e1, 10); + long hi = strtol(sub_dash + 1, &e2, 10); + if (*e1 != '\0' || *e2 != '\0' || lo < 0 || hi < 0 || lo > hi) { + set_ok = 0; break; + } + for (long vi = lo; vi <= hi && set_count < PATH_TEMPLATE_MAX_INDICES; vi++) { + out->allowed_indices[set_count++] = (int)vi; + } + } else { + char *e = NULL; + long val = strtol(tok, &e, 10); + if (*e != '\0' || val < 0) { set_ok = 0; break; } + if (set_count < PATH_TEMPLATE_MAX_INDICES) { + out->allowed_indices[set_count++] = (int)val; + } + } + + tok = (next_plus != NULL) ? next_plus + 1 : NULL; + } + + if (set_ok && set_count > 0) { + found_range = 1; + out->allowed_count = set_count; + out->range_lo = out->allowed_indices[0]; + out->range_hi = out->allowed_indices[set_count - 1]; + if (strlen(out->template) + 3 >= sizeof(out->template)) return -1; + strcat(out->template, "%d"); + strcat(out->template, "/"); + } else { + /* not a valid set — treat as literal segment */ + if (strlen(out->template) + seg_len + 2 >= sizeof(out->template)) return -1; + strcat(out->template, seg_buf); + strcat(out->template, "/"); + } + } else if (dash != NULL) { + /* Range form: "N-M" */ + *dash = '\0'; + char *endptr1 = NULL, *endptr2 = NULL; + long lo = strtol(seg_buf, &endptr1, 10); + long hi = strtol(dash + 1, &endptr2, 10); + if (*endptr1 != '\0' || *endptr2 != '\0' || lo < 0 || hi < 0 || lo > hi) { + /* not a numeric range — treat as literal segment */ + if (strlen(out->template) + seg_len + 2 >= sizeof(out->template)) { + return -1; + } + strcat(out->template, seg_buf); + strcat(out->template, "/"); + } else { + found_range = 1; + out->range_lo = (int)lo; + out->range_hi = (int)hi; + if (strlen(out->template) + 3 >= sizeof(out->template)) { + return -1; + } + strcat(out->template, "%d"); + strcat(out->template, "/"); + } + } else { + /* Single number */ + char *endptr = NULL; + long val = strtol(seg_buf, &endptr, 10); + if (*endptr != '\0' || val < 0) { + /* not a number — treat as literal segment */ + if (strlen(out->template) + seg_len + 2 >= sizeof(out->template)) { + return -1; + } + strcat(out->template, seg_buf); + strcat(out->template, "/"); + } else { + found_range = 1; + out->range_lo = (int)val; + out->range_hi = (int)val; + if (strlen(out->template) + 3 >= sizeof(out->template)) { + return -1; + } + strcat(out->template, "%d"); + strcat(out->template, "/"); + } + } + } else { + /* literal segment after the range */ + if (strlen(out->template) + seg_len + 2 >= sizeof(out->template)) { + return -1; + } + strcat(out->template, seg_buf); + strcat(out->template, "/"); + } + + p = (next_slash != NULL) ? next_slash + 1 : NULL; + } + + /* Remove trailing '/' from template */ + { + size_t tlen = strlen(out->template); + if (tlen > 0 && out->template[tlen - 1] == '/') { + out->template[tlen - 1] = '\0'; + } + } + + if (!found_range) { + return -1; /* a path template must contain a numeric/range segment */ + } + return 0; +} + +/* + * Unified whitelist parser: accepts both integer nostr_index tokens + * ("0-3", "1,3,4") and path-template tokens ("m/44'/1237'/0-3/1/0"). + * "all" clears both whitelists. Returns 0 on success, -1 on parse error. + */ +int server_set_path_whitelist(server_ctx_t *ctx, const char *spec) { + char buf[512]; + char *p; + + if (ctx == NULL || spec == NULL) { + return -1; + } + + /* "all" means no restriction */ + if (strcmp(spec, "all") == 0) { + ctx->index_whitelist_active = 0; + memset(ctx->index_whitelist, 0, sizeof(ctx->index_whitelist)); + memset(&ctx->path_whitelist, 0, sizeof(ctx->path_whitelist)); + return 0; + } + + strncpy(buf, spec, sizeof(buf) - 1); + buf[sizeof(buf) - 1] = '\0'; + + /* Reset both whitelists before parsing */ + memset(ctx->index_whitelist, 0, sizeof(ctx->index_whitelist)); + ctx->index_whitelist_active = 0; + memset(&ctx->path_whitelist, 0, sizeof(ctx->path_whitelist)); + + p = buf; + while (p != NULL && *p != '\0') { + char *comma = strchr(p, ','); + if (comma != NULL) { + *comma = '\0'; + } + + /* Skip empty tokens */ + if (*p == '\0') { + p = (comma != NULL) ? comma + 1 : NULL; + continue; + } + + /* Is this a path template? (contains '/') */ + if (strchr(p, '/') != NULL) { + if (ctx->path_whitelist.count >= PATH_WHITELIST_MAX_TEMPLATES) { + return -1; + } + if (parse_path_template_token( + &ctx->path_whitelist.templates[ctx->path_whitelist.count], p) != 0) { + return -1; + } + ctx->path_whitelist.count++; + ctx->path_whitelist.active = 1; + } else { + /* Integer nostr_index token: "N" or "N-M" */ + char *dash = strchr(p, '-'); + if (dash != NULL) { + *dash = '\0'; + char *endptr1 = NULL, *endptr2 = NULL; + long lo = strtol(p, &endptr1, 10); + long hi = strtol(dash + 1, &endptr2, 10); + if (*endptr1 != '\0' || *endptr2 != '\0' || lo < 0 || hi < 0 || + lo >= INDEX_WHITELIST_MAX || hi >= INDEX_WHITELIST_MAX || lo > hi) { + return -1; + } + for (long i = lo; i <= hi; i++) { + whitelist_set_bit(ctx->index_whitelist, (int)i); + } + } else { + char *endptr = NULL; + long idx = strtol(p, &endptr, 10); + if (*endptr != '\0' || idx < 0 || idx >= INDEX_WHITELIST_MAX) { + return -1; + } + whitelist_set_bit(ctx->index_whitelist, (int)idx); + } + ctx->index_whitelist_active = 1; + } + + p = (comma != NULL) ? comma + 1 : NULL; + } + + return 0; +} + +/* + * Check if a role_path is allowed by the path whitelist. + * Returns 1 if allowed, 0 if not. + */ +int server_path_whitelist_allows(const server_ctx_t *ctx, const char *role_path) { + int i; + + if (ctx == NULL || role_path == NULL) { + return 0; + } + if (!ctx->path_whitelist.active) { + /* No path whitelist configured — deny by default (fail-closed for paths) */ + return 0; + } + for (i = 0; i < ctx->path_whitelist.count; i++) { + const path_template_t *tpl = &ctx->path_whitelist.templates[i]; + char candidate[PATH_TEMPLATE_MAX_LEN]; + if (tpl->allowed_count > 0) { + /* Set form: check each allowed index */ + int j; + for (j = 0; j < tpl->allowed_count; j++) { + snprintf(candidate, sizeof(candidate), tpl->template, tpl->allowed_indices[j]); + if (strcmp(candidate, role_path) == 0) { + return 1; + } + } + } else { + /* Range form: iterate lo..hi */ + int idx; + for (idx = tpl->range_lo; idx <= tpl->range_hi; idx++) { + snprintf(candidate, sizeof(candidate), tpl->template, idx); + if (strcmp(candidate, role_path) == 0) { + return 1; + } + } + } + } + return 0; +} + int server_start(server_ctx_t *ctx) { int fd; struct sockaddr_un addr; @@ -1809,6 +2153,8 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) { int pending_derivation = 0; int hard_selector_error = 0; int derivation_error = 0; + char concrete_path[ROLE_PATH_MAX]; /* concrete path for named path-role with index */ + concrete_path[0] = '\0'; char activity[256]; const char *verdict = "DENIED"; const char *source_label = "no-match"; @@ -2039,6 +2385,63 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) { (void)snprintf(role_name, sizeof(role_name), "nostr_idx_%d", selector_req.nostr_index); } json_copy_string(purpose, sizeof(purpose), "nostr", "nostr"); + } else if (selector_rc == SELECTOR_ERR_NOT_FOUND && selector_req.has_role_path) { + /* role_path not in table — check path whitelist for auto-registration */ + if (server_path_whitelist_allows(ctx, selector_req.role_path)) { + pending_derivation = 1; + (void)snprintf(role_name, sizeof(role_name), "path_%s", selector_req.role_path); + json_copy_string(purpose, sizeof(purpose), "nostr", "nostr"); + } else { + hard_selector_error = -200; /* path_not_allowed sentinel */ + } + } else if (selector_rc == SELECTOR_OK && role != NULL && + role->selector_type == SELECTOR_ROLE_PATH && + strstr(role->role_path, "%d") == NULL) { + /* Fixed-path named role — no index needed, derive if not yet done */ + if (!role->derived) { + pending_derivation = 1; + } + } else if (selector_rc == SELECTOR_OK && role != NULL && + role->selector_type == SELECTOR_ROLE_PATH && + strstr(role->role_path, "%d") != NULL) { + /* Named path-role with template — resolve the concrete path from index */ + int chosen_index; + if (selector_req.has_index) { + chosen_index = selector_req.index; + } else if (role->path_default_index >= 0) { + chosen_index = role->path_default_index; + } else { + hard_selector_error = -201; /* index_required sentinel */ + chosen_index = -1; + } + if (chosen_index >= 0) { + int index_ok; + if (role->path_allowed_count > 0) { + /* Set form: check if index is in the allowed set */ + int j; + index_ok = 0; + for (j = 0; j < role->path_allowed_count; j++) { + if (role->path_allowed_indices[j] == chosen_index) { + index_ok = 1; + break; + } + } + } else { + /* Range form: check lo..hi */ + index_ok = (chosen_index >= role->path_range_lo && + chosen_index <= role->path_range_hi); + } + if (!index_ok) { + hard_selector_error = -202; /* index_out_of_range sentinel */ + } else { + /* Format the concrete path and store it for derivation */ + snprintf(concrete_path, sizeof(concrete_path), + role->role_path, chosen_index); + if (!role->derived) { + pending_derivation = 1; + } + } + } } else if (selector_rc == SELECTOR_ERR_AMBIGUOUS || selector_rc == SELECTOR_ERR_NOT_FOUND || selector_rc == SELECTOR_ERR_NO_DEFAULT) { @@ -2074,6 +2477,15 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) { } else if (hard_selector_error == SELECTOR_ERR_NOT_FOUND) { response = strdup("{\"id\":\"null\",\"error\":{\"code\":1002,\"message\":\"unknown_role\"}}"); pchk = POLICY_DENY; + } else if (hard_selector_error == -200) { + response = strdup("{\"id\":\"null\",\"error\":{\"code\":2003,\"message\":\"path_not_allowed\"}}"); + pchk = POLICY_DENY; + } else if (hard_selector_error == -201) { + response = strdup("{\"id\":\"null\",\"error\":{\"code\":2004,\"message\":\"index_required\"}}"); + pchk = POLICY_DENY; + } else if (hard_selector_error == -202) { + response = strdup("{\"id\":\"null\",\"error\":{\"code\":2005,\"message\":\"index_out_of_range\"}}"); + pchk = POLICY_DENY; } else if (hard_selector_error == 0) { /* Normal path: run policy_check (skip if whitelist already denied) */ pchk = policy_check(ctx->policy, caller.caller_id, method, role_name, purpose, &policy_src); @@ -2110,8 +2522,89 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) { if (ctx->dispatcher == NULL || ctx->dispatcher->role_table == NULL || ctx->dispatcher->key_store == NULL || - ctx->dispatcher->mnemonic == NULL || - role_table_register_nostr_index(ctx->dispatcher->role_table, selector_req.nostr_index) != 0) { + ctx->dispatcher->mnemonic == NULL) { + derivation_error = 1; + } else if (selector_req.has_role_path) { + /* Auto-register a fixed-path role from the path whitelist */ + char path_role_name[ROLE_NAME_MAX]; + (void)snprintf(path_role_name, sizeof(path_role_name), "pathrole_%d", + ctx->dispatcher->role_table->count); + if (role_table_register_role_path(ctx->dispatcher->role_table, + path_role_name, + selector_req.role_path, + PURPOSE_NOSTR, CURVE_SECP256K1, + 0, 0, -1, NULL, 0) != 0) { + derivation_error = 1; + } else { + new_role = role_table_find_by_path(ctx->dispatcher->role_table, + selector_req.role_path); + if (new_role == NULL) { + derivation_error = 1; + } else { + role_index = (int)(new_role - &ctx->dispatcher->role_table->entries[0]); + if (role_index < 0 || role_index >= ctx->dispatcher->role_table->count || + crypto_derive_one(ctx->dispatcher->key_store, + ctx->dispatcher->role_table, + ctx->dispatcher->mnemonic, + role_index) != 0) { + derivation_error = 1; + } else { + json_copy_string(role_name, sizeof(role_name), new_role->name, role_name); + json_copy_string(purpose, sizeof(purpose), role_purpose_to_str(new_role->purpose), "nostr"); + } + } + } + } else if (role != NULL && role->selector_type == SELECTOR_ROLE_PATH && + strstr(role->role_path, "%d") == NULL) { + /* Fixed-path named role (found by role name) — derive directly */ + new_role = role; + role_index = (int)(new_role - &ctx->dispatcher->role_table->entries[0]); + if (role_index < 0 || role_index >= ctx->dispatcher->role_table->count || + crypto_derive_one(ctx->dispatcher->key_store, + ctx->dispatcher->role_table, + ctx->dispatcher->mnemonic, + role_index) != 0) { + derivation_error = 1; + } else { + json_copy_string(role_name, sizeof(role_name), new_role->name, role_name); + json_copy_string(purpose, sizeof(purpose), role_purpose_to_str(new_role->purpose), "nostr"); + } + } else if (concrete_path[0] != '\0') { + /* Named path-role with template — derive the concrete path. + * The role already exists in the table; we temporarily set its + * role_path to the concrete path for derivation, then restore. */ + char saved_path[ROLE_PATH_MAX]; + new_role = role; /* the role resolved by selector_resolve */ + if (new_role == NULL) { + derivation_error = 1; + } else { + role_index = (int)(new_role - &ctx->dispatcher->role_table->entries[0]); + if (role_index < 0 || role_index >= ctx->dispatcher->role_table->count) { + derivation_error = 1; + } else { + /* Swap in the concrete path */ + strncpy(saved_path, new_role->role_path, sizeof(saved_path) - 1); + saved_path[sizeof(saved_path) - 1] = '\0'; + strncpy(new_role->role_path, concrete_path, sizeof(new_role->role_path) - 1); + new_role->role_path[sizeof(new_role->role_path) - 1] = '\0'; + new_role->derived = 0; + new_role->pubkey_hex[0] = '\0'; + + if (crypto_derive_one(ctx->dispatcher->key_store, + ctx->dispatcher->role_table, + ctx->dispatcher->mnemonic, + role_index) != 0) { + derivation_error = 1; + } else { + json_copy_string(role_name, sizeof(role_name), new_role->name, role_name); + json_copy_string(purpose, sizeof(purpose), role_purpose_to_str(new_role->purpose), "nostr"); + } + /* Restore the template path (keep derived=1 + pubkey from concrete derivation) */ + strncpy(new_role->role_path, saved_path, sizeof(new_role->role_path) - 1); + new_role->role_path[sizeof(new_role->role_path) - 1] = '\0'; + } + } + } else if (role_table_register_nostr_index(ctx->dispatcher->role_table, selector_req.nostr_index) != 0) { derivation_error = 1; } else { new_role = role_table_find_by_nostr_index(ctx->dispatcher->role_table, selector_req.nostr_index); diff --git a/src/socket_name.c b/src/socket_name.c index 708ccef..14b915e 100644 --- a/src/socket_name.c +++ b/src/socket_name.c @@ -118,6 +118,11 @@ typedef struct { char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */ char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */ int derived; /* 1 if pubkey_hex has been populated */ + int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */ + int path_range_hi; /* inclusive upper bound; == path_range_lo for single */ + int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */ + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ } role_entry_t; /* The role table */ @@ -176,6 +181,8 @@ typedef struct { int has_role_path; /* 1 if "role_path" field was present */ char role_path[ROLE_PATH_MAX]; + int has_index; /* 1 if "index" field was present (for named path-roles) */ + int index; /* index value for named path-role template */ } selector_request_t; /* Initialize a selector request (all fields zeroed/unset) */ diff --git a/tests/test_algorithm_api.c b/tests/test_algorithm_api.c index 45579ee..13b3448 100644 --- a/tests/test_algorithm_api.c +++ b/tests/test_algorithm_api.c @@ -55,6 +55,11 @@ typedef struct { char role_path[ROLE_PATH_MAX]; char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; int derived; + int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */ + int path_range_hi; /* inclusive upper bound; == path_range_lo for single */ + int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */ + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ } role_entry_t; typedef struct { role_entry_t entries[ROLE_TABLE_MAX_ENTRIES]; int count; } role_table_t; void role_table_init(role_table_t *table); diff --git a/tests/test_dispatcher.c b/tests/test_dispatcher.c index 76aaebc..05c1005 100644 --- a/tests/test_dispatcher.c +++ b/tests/test_dispatcher.c @@ -116,6 +116,11 @@ typedef struct { char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */ char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */ int derived; /* 1 if pubkey_hex has been populated */ + int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */ + int path_range_hi; /* inclusive upper bound; == path_range_lo for single */ + int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */ + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ } role_entry_t; /* The role table */ @@ -157,7 +162,11 @@ const char *role_curve_to_str(role_curve_t c); /* Register a nostr-index role if missing. Returns 0 on success, -1 on error. */ int role_table_register_nostr_index(role_table_t *table, int nostr_index); - +/* Register a SELECTOR_ROLE_PATH role bound to an explicit derivation path template. */ +int role_table_register_role_path(role_table_t *table, const char *name, const char *path, + role_purpose_t purpose, role_curve_t curve, + int range_lo, int range_hi, int default_index, + const int *allowed_indices, int allowed_count); /* from selector.h */ @@ -177,6 +186,8 @@ typedef struct { int has_role_path; /* 1 if "role_path" field was present */ char role_path[ROLE_PATH_MAX]; + int has_index; /* 1 if "index" field was present (for named path-roles) */ + int index; /* index value for named path-role template */ } selector_request_t; /* Initialize a selector request (all fields zeroed/unset) */ diff --git a/tests/test_ed25519_x25519.c b/tests/test_ed25519_x25519.c index ba9d707..522af58 100644 --- a/tests/test_ed25519_x25519.c +++ b/tests/test_ed25519_x25519.c @@ -101,6 +101,11 @@ typedef struct { char role_path[ROLE_PATH_MAX]; char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; int derived; + int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */ + int path_range_hi; /* inclusive upper bound; == path_range_lo for single */ + int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */ + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ } role_entry_t; typedef struct { @@ -118,7 +123,7 @@ role_purpose_t role_purpose_from_str(const char *s); role_curve_t role_curve_from_str(const char *s); const char *role_purpose_to_str(role_purpose_t p); const char *role_curve_to_str(role_curve_t c); -int role_table_register_nostr_index(role_table_t *table, int nostr_index); + /* from selector.h */ @@ -136,6 +141,8 @@ typedef struct { int nostr_index; int has_role_path; char role_path[ROLE_PATH_MAX]; + int has_index; /* 1 if "index" field was present (for named path-roles) */ + int index; /* index value for named path-role template */ } selector_request_t; void selector_request_init(selector_request_t *req); diff --git a/tests/test_enforcement.c b/tests/test_enforcement.c index 9c7c935..2e94c5e 100644 --- a/tests/test_enforcement.c +++ b/tests/test_enforcement.c @@ -116,6 +116,11 @@ typedef struct { char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */ char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */ int derived; /* 1 if pubkey_hex has been populated */ + int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */ + int path_range_hi; /* inclusive upper bound; == path_range_lo for single */ + int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */ + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ } role_entry_t; /* The role table */ @@ -174,6 +179,8 @@ typedef struct { int has_role_path; /* 1 if "role_path" field was present */ char role_path[ROLE_PATH_MAX]; + int has_index; /* 1 if "index" field was present (for named path-roles) */ + int index; /* index value for named path-role template */ } selector_request_t; /* Initialize a selector request (all fields zeroed/unset) */ diff --git a/tests/test_integration.c b/tests/test_integration.c index 0c7827b..57345b0 100644 --- a/tests/test_integration.c +++ b/tests/test_integration.c @@ -118,6 +118,11 @@ typedef struct { char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */ char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */ int derived; /* 1 if pubkey_hex has been populated */ + int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */ + int path_range_hi; /* inclusive upper bound; == path_range_lo for single */ + int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */ + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ } role_entry_t; /* The role table */ @@ -176,6 +181,8 @@ typedef struct { int has_role_path; /* 1 if "role_path" field was present */ char role_path[ROLE_PATH_MAX]; + int has_index; /* 1 if "index" field was present (for named path-roles) */ + int index; /* index value for named path-role template */ } selector_request_t; /* Initialize a selector request (all fields zeroed/unset) */ diff --git a/tests/test_mine_event.c b/tests/test_mine_event.c index 703c84a..c258d9a 100644 --- a/tests/test_mine_event.c +++ b/tests/test_mine_event.c @@ -80,6 +80,11 @@ typedef struct { char role_path[ROLE_PATH_MAX]; char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; int derived; + int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */ + int path_range_hi; /* inclusive upper bound; == path_range_lo for single */ + int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */ + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ } role_entry_t; typedef struct { @@ -97,7 +102,7 @@ role_purpose_t role_purpose_from_str(const char *s); role_curve_t role_curve_from_str(const char *s); const char *role_purpose_to_str(role_purpose_t p); const char *role_curve_to_str(role_curve_t c); -int role_table_register_nostr_index(role_table_t *table, int nostr_index); + /* from selector.h */ @@ -113,6 +118,8 @@ typedef struct { int nostr_index; int has_role_path; char role_path[ROLE_PATH_MAX]; + int has_index; /* 1 if "index" field was present (for named path-roles) */ + int index; /* index value for named path-role template */ } selector_request_t; void selector_request_init(selector_request_t *req); diff --git a/tests/test_ml_dsa_65.c b/tests/test_ml_dsa_65.c index 92989cd..aa46e2b 100644 --- a/tests/test_ml_dsa_65.c +++ b/tests/test_ml_dsa_65.c @@ -97,6 +97,11 @@ typedef struct { char role_path[ROLE_PATH_MAX]; char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; int derived; + int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */ + int path_range_hi; /* inclusive upper bound; == path_range_lo for single */ + int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */ + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ } role_entry_t; typedef struct { @@ -128,6 +133,8 @@ typedef struct { int nostr_index; int has_role_path; char role_path[ROLE_PATH_MAX]; + int has_index; /* 1 if "index" field was present (for named path-roles) */ + int index; /* index value for named path-role template */ } selector_request_t; void selector_request_init(selector_request_t *req); diff --git a/tests/test_ml_kem_768.c b/tests/test_ml_kem_768.c index 8c824c5..6a9aa85 100644 --- a/tests/test_ml_kem_768.c +++ b/tests/test_ml_kem_768.c @@ -73,6 +73,11 @@ typedef struct { char role_path[ROLE_PATH_MAX]; char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; int derived; + int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */ + int path_range_hi; /* inclusive upper bound; == path_range_lo for single */ + int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */ + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ } role_entry_t; typedef struct { @@ -101,6 +106,8 @@ typedef struct { int nostr_index; int has_role_path; char role_path[ROLE_PATH_MAX]; + int has_index; /* 1 if "index" field was present (for named path-roles) */ + int index; /* index value for named path-role template */ } selector_request_t; void selector_request_init(selector_request_t *req); diff --git a/tests/test_mnemonic.c b/tests/test_mnemonic.c index 3c7954d..994d5b9 100644 --- a/tests/test_mnemonic.c +++ b/tests/test_mnemonic.c @@ -116,6 +116,11 @@ typedef struct { char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */ char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */ int derived; /* 1 if pubkey_hex has been populated */ + int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */ + int path_range_hi; /* inclusive upper bound; == path_range_lo for single */ + int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */ + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ } role_entry_t; /* The role table */ @@ -174,6 +179,8 @@ typedef struct { int has_role_path; /* 1 if "role_path" field was present */ char role_path[ROLE_PATH_MAX]; + int has_index; /* 1 if "index" field was present (for named path-roles) */ + int index; /* index value for named path-role template */ } selector_request_t; /* Initialize a selector request (all fields zeroed/unset) */ diff --git a/tests/test_path_whitelist.c b/tests/test_path_whitelist.c new file mode 100644 index 0000000..969e43a --- /dev/null +++ b/tests/test_path_whitelist.c @@ -0,0 +1,443 @@ +/* + * test_path_whitelist.c — tests for the path-template whitelist and + * named path-role functionality. + * + * Covers: + * - server_set_path_whitelist parsing (integer + path-template tokens) + * - server_path_whitelist_allows matching + * - role_table_register_role_path (idempotent, range fields) + * - derive_secp256k1_from_path (BIP-44 path parsing + derivation) + */ + +#define _GNU_SOURCE + +/* NSIGNER_HEADERLESS_DECLS_BEGIN */ +#include +#include +#include +#include + +/* from secure_mem.h */ +typedef struct { + void *data; + size_t size; + int locked; +} secure_buf_t; + +int secure_buf_alloc(secure_buf_t *buf, size_t size); +void secure_buf_free(secure_buf_t *buf); +void secure_memzero(void *ptr, size_t len); + +/* from mnemonic.h */ +#define MNEMONIC_MAX_LEN 256 +typedef struct { + secure_buf_t buf; + int loaded; + int word_count; +} mnemonic_state_t; + +void mnemonic_init(mnemonic_state_t *state); +int mnemonic_load(mnemonic_state_t *state, const char *phrase); +void mnemonic_unload(mnemonic_state_t *state); +int mnemonic_is_loaded(const mnemonic_state_t *state); +const char *mnemonic_get_phrase(const mnemonic_state_t *state); + +/* from role_table.h */ +#define ROLE_NAME_MAX 64 +#define ROLE_PATH_MAX 128 +#define ROLE_PURPOSE_MAX 32 +#define ROLE_CURVE_MAX 16 +#define ROLE_PUBKEY_HEX_MAX 66 +#define ROLE_TABLE_MAX_ENTRIES 256 + +typedef enum { + PURPOSE_NOSTR = 0, + PURPOSE_BITCOIN, + PURPOSE_SSH, + PURPOSE_AGE, + PURPOSE_FIPS, + PURPOSE_PQ_SIG, + PURPOSE_PQ_KEM, + PURPOSE_UNKNOWN +} role_purpose_t; + +typedef enum { + CURVE_SECP256K1 = 0, + CURVE_ED25519, + CURVE_X25519, + CURVE_ML_DSA_65, + CURVE_SLH_DSA_128S, + CURVE_ML_KEM_768, + CURVE_UNKNOWN +} role_curve_t; + +typedef enum { + SELECTOR_NOSTR_INDEX, + SELECTOR_ROLE_PATH +} role_selector_type_t; + +typedef struct { + char name[ROLE_NAME_MAX]; + char purpose_str[ROLE_PURPOSE_MAX]; + char curve_str[ROLE_CURVE_MAX]; + role_purpose_t purpose; + role_curve_t curve; + role_selector_type_t selector_type; + int nostr_index; + char role_path[ROLE_PATH_MAX]; + char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; + int derived; + int path_range_lo; + int path_range_hi; + int path_default_index; + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ +} role_entry_t; + +typedef struct { + role_entry_t entries[ROLE_TABLE_MAX_ENTRIES]; + int count; +} role_table_t; + +void role_table_init(role_table_t *table); +int role_table_add(role_table_t *table, const role_entry_t *entry); +role_entry_t *role_table_find_by_name(role_table_t *table, const char *name); +role_entry_t *role_table_find_by_nostr_index(role_table_t *table, int index); +role_entry_t *role_table_find_by_path(role_table_t *table, const char *path); +role_purpose_t role_purpose_from_str(const char *s); +role_curve_t role_curve_from_str(const char *s); +const char *role_purpose_to_str(role_purpose_t p); +const char *role_curve_to_str(role_curve_t c); +int role_table_register_nostr_index(role_table_t *table, int nostr_index); + +/* Register a SELECTOR_ROLE_PATH role bound to an explicit derivation path template. */ +int role_table_register_role_path(role_table_t *table, const char *name, const char *path, + role_purpose_t purpose, role_curve_t curve, + int range_lo, int range_hi, int default_index, + const int *allowed_indices, int allowed_count); + +/* from selector.h */ +#define SELECTOR_OK 0 +#define SELECTOR_ERR_AMBIGUOUS -1 +#define SELECTOR_ERR_NOT_FOUND -2 +#define SELECTOR_ERR_NO_DEFAULT -3 + +typedef struct { + int has_role; + char role_name[ROLE_NAME_MAX]; + int has_nostr_index; + int nostr_index; + int has_role_path; + char role_path[ROLE_PATH_MAX]; + int has_index; + int index; +} selector_request_t; + +void selector_request_init(selector_request_t *req); +int selector_resolve(const selector_request_t *req, role_table_t *table, role_entry_t **out); + +/* from enforcement.h */ +#define ENFORCE_OK 0 +#define ENFORCE_ERR_PURPOSE -1 +#define ENFORCE_ERR_CURVE -2 +#define ENFORCE_ERR_UNKNOWN_VERB -3 +#define ENFORCE_ERR_ALGORITHM -4 + +#define VERB_SIGN "sign" +#define VERB_VERIFY "verify" +#define VERB_ENCAPSULATE "encapsulate" +#define VERB_DECAPSULATE "decapsulate" +#define VERB_DERIVE_SHARED "derive_shared_secret" +#define VERB_DERIVE "derive" +#define VERB_GET_PUBLIC_KEY "get_public_key" + +#define VERB_NOSTR_GET_PUBLIC_KEY "nostr_get_public_key" +#define VERB_NOSTR_SIGN_EVENT "nostr_sign_event" +#define VERB_NOSTR_MINE_EVENT "nostr_mine_event" +#define VERB_NOSTR_NIP44_ENCRYPT "nostr_nip44_encrypt" +#define VERB_NOSTR_NIP44_DECRYPT "nostr_nip44_decrypt" +#define VERB_NOSTR_NIP04_ENCRYPT "nostr_nip04_encrypt" +#define VERB_NOSTR_NIP04_DECRYPT "nostr_nip04_decrypt" + +#define VERB_ENCRYPT "encrypt" +#define VERB_DECRYPT "decrypt" + +int enforce_verb_role(const char *verb, const role_entry_t *role); + +/* from pq_crypto.h */ +typedef enum { + CRYPTO_ALG_SECP256K1 = 0, + CRYPTO_ALG_ED25519, + CRYPTO_ALG_X25519, + CRYPTO_ALG_ML_DSA_65, + CRYPTO_ALG_SLH_DSA_128S, + CRYPTO_ALG_ML_KEM_768, + CRYPTO_ALG_UNKNOWN +} crypto_alg_t; + +typedef struct { + size_t priv_key_len; + size_t pub_key_len; + size_t sig_len; + size_t ciphertext_len; + size_t shared_secret_len; +} crypto_alg_sizes_t; + +const crypto_alg_sizes_t *crypto_alg_get_sizes(crypto_alg_t alg); +crypto_alg_t crypto_alg_from_role(role_curve_t curve, role_purpose_t purpose); +const char *crypto_alg_to_str(crypto_alg_t alg); +crypto_alg_t crypto_alg_from_str(const char *s); + +/* from key_store.h */ +#define KEY_STORE_MAX_ROLES ROLE_TABLE_MAX_ENTRIES + +typedef struct { + secure_buf_t private_key; + secure_buf_t public_key; + char pubkey_hex[8192]; /* hex-encoded public key (PQ pubkeys are large) */ + char npub[128]; /* bech32 npub (secp256k1 only, empty for others) */ + crypto_alg_t alg; + int valid; +} derived_key_t; + +typedef struct { + derived_key_t keys[KEY_STORE_MAX_ROLES]; + int count; +} key_store_t; + +int crypto_derive_all(key_store_t *store, role_table_t *table, const mnemonic_state_t *mnemonic); +int crypto_derive_one(key_store_t *store, role_table_t *table, const mnemonic_state_t *mnemonic, int role_index); + +/* nostr init/cleanup */ +int nostr_init(void); +void nostr_cleanup(void); + +/* from server.h (minimal subset for whitelist tests) */ +#define SERVER_SOCKET_NAME_MAX 108 +#define INDEX_WHITELIST_MAX 256 +#define INDEX_WHITELIST_BITMAP_SIZE (INDEX_WHITELIST_MAX / 8) + +#define PATH_WHITELIST_MAX_TEMPLATES 16 +#define PATH_TEMPLATE_MAX_LEN 128 + +typedef struct { + char template[PATH_TEMPLATE_MAX_LEN]; + int range_lo; + int range_hi; + int allowed_indices[64]; + int allowed_count; +} path_template_t; + +typedef struct { + int active; + int count; + path_template_t templates[PATH_WHITELIST_MAX_TEMPLATES]; +} path_whitelist_t; + +typedef struct { + char socket_name[SERVER_SOCKET_NAME_MAX]; + char last_error[256]; + int listen_fd; + int running; + int listen_mode; + int stdio_handled; + void *dispatcher; /* dummy */ + void *policy; /* dummy */ + int socket_name_explicit; + int auth_mode; + int auth_skew_seconds; + int bridge_source_trusted; + int index_whitelist_active; + unsigned char index_whitelist[INDEX_WHITELIST_BITMAP_SIZE]; + path_whitelist_t path_whitelist; +} server_ctx_t; + +int server_set_index_whitelist(server_ctx_t *ctx, const char *spec); +int server_set_path_whitelist(server_ctx_t *ctx, const char *spec); +int server_path_whitelist_allows(const server_ctx_t *ctx, const char *role_path); + +/* NSIGNER_HEADERLESS_DECLS_END */ + +#include +#include +#include + +static int tests_run = 0; +static int tests_passed = 0; + +static void check(const char *desc, int condition) { + tests_run++; + if (condition) { + tests_passed++; + printf("PASS: %s\n", desc); + } else { + printf("FAIL: %s\n", desc); + } +} + +int main(void) { + server_ctx_t ctx; + + /* ---- Test 1: server_set_path_whitelist with "all" ---- */ + memset(&ctx, 0, sizeof(ctx)); + check("set_path_whitelist 'all' returns 0", + server_set_path_whitelist(&ctx, "all") == 0); + check("'all' sets index_whitelist_active=0", + ctx.index_whitelist_active == 0); + check("'all' sets path_whitelist.active=0", + ctx.path_whitelist.active == 0); + + /* ---- Test 2: integer-only spec (backward compat) ---- */ + memset(&ctx, 0, sizeof(ctx)); + check("set_path_whitelist '0-3' returns 0", + server_set_path_whitelist(&ctx, "0-3") == 0); + check("'0-3' sets index_whitelist_active=1", + ctx.index_whitelist_active == 1); + check("'0-3' does not set path_whitelist.active", + ctx.path_whitelist.active == 0); + + /* ---- Test 3: path-template spec ---- */ + memset(&ctx, 0, sizeof(ctx)); + check("set_path_whitelist 'm/44\\'/1237\\'/0-3/1/0' returns 0", + server_set_path_whitelist(&ctx, "m/44'/1237'/0-3/1/0") == 0); + check("path template sets path_whitelist.active=1", + ctx.path_whitelist.active == 1); + check("path template count=1", + ctx.path_whitelist.count == 1); + check("path template range_lo=0", + ctx.path_whitelist.templates[0].range_lo == 0); + check("path template range_hi=3", + ctx.path_whitelist.templates[0].range_hi == 3); + + /* ---- Test 4: server_path_whitelist_allows matching ---- */ + check("path_whitelist_allows m/44'/1237'/1/1/0 (in range)", + server_path_whitelist_allows(&ctx, "m/44'/1237'/1/1/0") == 1); + check("path_whitelist_allows m/44'/1237'/0/1/0 (in range)", + server_path_whitelist_allows(&ctx, "m/44'/1237'/0/1/0") == 1); + check("path_whitelist_allows m/44'/1237'/3/1/0 (in range)", + server_path_whitelist_allows(&ctx, "m/44'/1237'/3/1/0") == 1); + check("path_whitelist denies m/44'/1237'/4/1/0 (out of range)", + server_path_whitelist_allows(&ctx, "m/44'/1237'/4/1/0") == 0); + check("path_whitelist denies m/44'/1237'/1/0/0 (wrong change)", + server_path_whitelist_allows(&ctx, "m/44'/1237'/1/0/0") == 0); + + /* ---- Test 5: multiple path templates ---- */ + memset(&ctx, 0, sizeof(ctx)); + check("set_path_whitelist with two templates returns 0", + server_set_path_whitelist(&ctx, + "m/44'/1237'/0-3/0/0,m/44'/1237'/0-3/1/0") == 0); + check("two templates: count=2", + ctx.path_whitelist.count == 2); + check("two templates: allows m/44'/1237'/2/0/0", + server_path_whitelist_allows(&ctx, "m/44'/1237'/2/0/0") == 1); + check("two templates: allows m/44'/1237'/2/1/0", + server_path_whitelist_allows(&ctx, "m/44'/1237'/2/1/0") == 1); + check("two templates: denies m/44'/1237'/2/2/0", + server_path_whitelist_allows(&ctx, "m/44'/1237'/2/2/0") == 0); + + /* ---- Test 6: no path whitelist configured → deny ---- */ + memset(&ctx, 0, sizeof(ctx)); + check("no path whitelist denies all paths (fail-closed)", + server_path_whitelist_allows(&ctx, "m/44'/1237'/1/1/0") == 0); + + /* ---- Test 7: role_table_register_role_path ---- */ + { + role_table_t table; + role_table_init(&table); + check("register_role_path returns 0", + role_table_register_role_path(&table, "myrole", + "m/44'/1237'/%d/1/0", + PURPOSE_NOSTR, CURVE_SECP256K1, + 0, 3, 1, NULL, 0) == 0); + role_entry_t *r = role_table_find_by_name(&table, "myrole"); + check("registered role found by name", r != NULL); + check("registered role is SELECTOR_ROLE_PATH", + r != NULL && r->selector_type == SELECTOR_ROLE_PATH); + check("registered role path_range_lo=0", + r != NULL && r->path_range_lo == 0); + check("registered role path_range_hi=3", + r != NULL && r->path_range_hi == 3); + check("registered role path_default_index=1", + r != NULL && r->path_default_index == 1); + check("registered role purpose=NOSTR", + r != NULL && r->purpose == PURPOSE_NOSTR); + check("registered role curve=SECP256K1", + r != NULL && r->curve == CURVE_SECP256K1); + + /* Idempotent: registering the same path again returns 0, no duplicate */ + check("register_role_path idempotent returns 0", + role_table_register_role_path(&table, "other", + "m/44'/1237'/%d/1/0", + PURPOSE_NOSTR, CURVE_SECP256K1, + 0, 3, 1, NULL, 0) == 0); + check("idempotent: no duplicate added", + table.count == 1); + } + + /* ---- Test 8: end-to-end derivation with role_path ---- */ + { + role_table_t table; + key_store_t key_store; + mnemonic_state_t mnemonic; + const char *valid_12 = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + int rc; + + role_table_init(&table); + mnemonic_init(&mnemonic); + + /* Load mnemonic */ + rc = mnemonic_load(&mnemonic, valid_12); + check("mnemonic load succeeds", rc == 0); + + /* Register a fixed-path role (no %d) */ + rc = role_table_register_role_path(&table, "testpath", + "m/44'/1237'/1/1/0", + PURPOSE_NOSTR, CURVE_SECP256K1, + 0, 0, -1, NULL, 0); + check("register fixed-path role returns 0", rc == 0); + + /* Derive all keys */ + if (nostr_init() != 0) { + check("nostr_init succeeds", 0); + mnemonic_unload(&mnemonic); + printf("\n%d/%d tests passed\n", tests_passed, tests_run); + return (tests_passed == tests_run) ? 0 : 1; + } + check("nostr_init succeeds", 1); + memset(&key_store, 0, sizeof(key_store)); + rc = crypto_derive_all(&key_store, &table, &mnemonic); + check("crypto_derive_all with path role succeeds", rc >= 0); + + /* Find the role and check it was derived */ + role_entry_t *r = role_table_find_by_name(&table, "testpath"); + check("testpath role found", r != NULL); + check("testpath role derived", r != NULL && r->derived == 1); + check("testpath pubkey is 64 hex chars", + r != NULL && strlen(r->pubkey_hex) == 64); + + /* Verify the pubkey matches the expected NIP-06 index-1 derivation + * (m/44'/1237'/1'/0/0) — this is a sanity check that the path + * derivation produces a valid key. The path m/44'/1237'/1/1/0 is + * different from NIP-06 so the pubkey should differ from index 1. */ + { + role_table_t nip06_table; + key_store_t nip06_store; + role_table_init(&nip06_table); + role_table_register_nostr_index(&nip06_table, 1); + memset(&nip06_store, 0, sizeof(nip06_store)); + crypto_derive_all(&nip06_store, &nip06_table, &mnemonic); + role_entry_t *nip06_r = role_table_find_by_nostr_index(&nip06_table, 1); + check("NIP-06 index 1 derived", + nip06_r != NULL && nip06_r->derived == 1); + check("path m/44'/1237'/1/1/0 differs from NIP-06 index 1 (m/44'/1237'/1'/0/0)", + r != NULL && nip06_r != NULL && + strcmp(r->pubkey_hex, nip06_r->pubkey_hex) != 0); + } + + mnemonic_unload(&mnemonic); + } + + nostr_cleanup(); + printf("\n%d/%d tests passed\n", tests_passed, tests_run); + return (tests_passed == tests_run) ? 0 : 1; +} diff --git a/tests/test_policy.c b/tests/test_policy.c index f04fc5b..3040185 100644 --- a/tests/test_policy.c +++ b/tests/test_policy.c @@ -116,6 +116,11 @@ typedef struct { char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */ char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */ int derived; /* 1 if pubkey_hex has been populated */ + int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */ + int path_range_hi; /* inclusive upper bound; == path_range_lo for single */ + int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */ + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ } role_entry_t; /* The role table */ @@ -174,6 +179,8 @@ typedef struct { int has_role_path; /* 1 if "role_path" field was present */ char role_path[ROLE_PATH_MAX]; + int has_index; /* 1 if "index" field was present (for named path-roles) */ + int index; /* index value for named path-role template */ } selector_request_t; /* Initialize a selector request (all fields zeroed/unset) */ diff --git a/tests/test_pq_crypto.c b/tests/test_pq_crypto.c index 6764801..100a1a7 100644 --- a/tests/test_pq_crypto.c +++ b/tests/test_pq_crypto.c @@ -98,6 +98,11 @@ typedef struct { char role_path[ROLE_PATH_MAX]; char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; int derived; + int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */ + int path_range_hi; /* inclusive upper bound; == path_range_lo for single */ + int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */ + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ } role_entry_t; typedef struct { @@ -115,7 +120,7 @@ role_purpose_t role_purpose_from_str(const char *s); role_curve_t role_curve_from_str(const char *s); const char *role_purpose_to_str(role_purpose_t p); const char *role_curve_to_str(role_curve_t c); -int role_table_register_nostr_index(role_table_t *table, int nostr_index); + /* from selector.h */ @@ -133,6 +138,8 @@ typedef struct { int nostr_index; int has_role_path; char role_path[ROLE_PATH_MAX]; + int has_index; /* 1 if "index" field was present (for named path-roles) */ + int index; /* index value for named path-role template */ } selector_request_t; void selector_request_init(selector_request_t *req); diff --git a/tests/test_pubkey_format.c b/tests/test_pubkey_format.c index 1e61b0e..abed2cf 100644 --- a/tests/test_pubkey_format.c +++ b/tests/test_pubkey_format.c @@ -95,6 +95,11 @@ typedef struct { char role_path[ROLE_PATH_MAX]; char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; int derived; + int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */ + int path_range_hi; /* inclusive upper bound; == path_range_lo for single */ + int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */ + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ } role_entry_t; typedef struct { @@ -112,7 +117,7 @@ role_purpose_t role_purpose_from_str(const char *s); role_curve_t role_curve_from_str(const char *s); const char *role_purpose_to_str(role_purpose_t p); const char *role_curve_to_str(role_curve_t c); -int role_table_register_nostr_index(role_table_t *table, int nostr_index); + /* from selector.h */ @@ -130,6 +135,8 @@ typedef struct { int nostr_index; int has_role_path; char role_path[ROLE_PATH_MAX]; + int has_index; /* 1 if "index" field was present (for named path-roles) */ + int index; /* index value for named path-role template */ } selector_request_t; void selector_request_init(selector_request_t *req); diff --git a/tests/test_role_table.c b/tests/test_role_table.c index 8e91c00..3e2b08b 100644 --- a/tests/test_role_table.c +++ b/tests/test_role_table.c @@ -116,6 +116,11 @@ typedef struct { char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */ char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */ int derived; /* 1 if pubkey_hex has been populated */ + int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */ + int path_range_hi; /* inclusive upper bound; == path_range_lo for single */ + int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */ + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ } role_entry_t; /* The role table */ @@ -157,7 +162,11 @@ const char *role_curve_to_str(role_curve_t c); /* Register a nostr-index role if missing. Returns 0 on success, -1 on error. */ int role_table_register_nostr_index(role_table_t *table, int nostr_index); - +/* Register a SELECTOR_ROLE_PATH role bound to an explicit derivation path template. */ +int role_table_register_role_path(role_table_t *table, const char *name, const char *path, + role_purpose_t purpose, role_curve_t curve, + int range_lo, int range_hi, int default_index, + const int *allowed_indices, int allowed_count); /* from selector.h */ @@ -177,6 +186,8 @@ typedef struct { int has_role_path; /* 1 if "role_path" field was present */ char role_path[ROLE_PATH_MAX]; + int has_index; /* 1 if "index" field was present (for named path-roles) */ + int index; /* index value for named path-role template */ } selector_request_t; /* Initialize a selector request (all fields zeroed/unset) */ diff --git a/tests/test_selector.c b/tests/test_selector.c index 2a2d7e7..65e2d05 100644 --- a/tests/test_selector.c +++ b/tests/test_selector.c @@ -116,6 +116,11 @@ typedef struct { char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */ char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */ int derived; /* 1 if pubkey_hex has been populated */ + int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */ + int path_range_hi; /* inclusive upper bound; == path_range_lo for single */ + int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */ + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ } role_entry_t; /* The role table */ @@ -174,6 +179,8 @@ typedef struct { int has_role_path; /* 1 if "role_path" field was present */ char role_path[ROLE_PATH_MAX]; + int has_index; /* 1 if "index" field was present (for named path-roles) */ + int index; /* index value for named path-role template */ } selector_request_t; /* Initialize a selector request (all fields zeroed/unset) */ diff --git a/tests/test_slh_dsa_128s.c b/tests/test_slh_dsa_128s.c index 30c41d9..ba8cd2d 100644 --- a/tests/test_slh_dsa_128s.c +++ b/tests/test_slh_dsa_128s.c @@ -97,6 +97,11 @@ typedef struct { char role_path[ROLE_PATH_MAX]; char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; int derived; + int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */ + int path_range_hi; /* inclusive upper bound; == path_range_lo for single */ + int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */ + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ } role_entry_t; typedef struct { @@ -128,6 +133,8 @@ typedef struct { int nostr_index; int has_role_path; char role_path[ROLE_PATH_MAX]; + int has_index; /* 1 if "index" field was present (for named path-roles) */ + int index; /* index value for named path-role template */ } selector_request_t; void selector_request_init(selector_request_t *req); diff --git a/tests/test_socket_name.c b/tests/test_socket_name.c index 24e9655..61d728a 100644 --- a/tests/test_socket_name.c +++ b/tests/test_socket_name.c @@ -116,6 +116,11 @@ typedef struct { char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */ char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */ int derived; /* 1 if pubkey_hex has been populated */ + int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */ + int path_range_hi; /* inclusive upper bound; == path_range_lo for single */ + int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */ + int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */ + int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */ } role_entry_t; /* The role table */ @@ -174,6 +179,8 @@ typedef struct { int has_role_path; /* 1 if "role_path" field was present */ char role_path[ROLE_PATH_MAX]; + int has_index; /* 1 if "index" field was present (for named path-roles) */ + int index; /* index value for named path-role template */ } selector_request_t; /* Initialize a selector request (all fields zeroed/unset) */