Replace removed nostr_index selector with role+role_path

n_signer removed the bare nostr_index selector (error 2006) and index-on-
nostr-verbs (error 2007). The only accepted selector for nostr_* verbs is
now {"role","role_path"} sent together (2008/2009 if either is missing).

The remote signer backend was emitting {"nostr_index":N} or {"role"}
alone -- both now invalid. This commit:

- Replaces remote struct fields nostr_index/has_nostr_index with
  role_path[128]/has_role_path plus derive_index/has_derive_index
- Rewrites signer_remote_params_with_selector to emit both role and
  role_path in the options object
- Updates all three call sites (get_public_key, sign_event, encrypt_decrypt)
- Rewrites signer_remote_derive_hmac to emit {algorithm,index} using
  derive_index
- Adds nostr_signer_nsigner_set_role_path setter
- Repurposes nostr_signer_nsigner_set_nostr_index as a compatibility shim
  that expands N into role=main + role_path=m/44'/1237'/N'/0/0
- Updates nsigner_client_test to send and assert the role+role_path selector
- Updates NSIGNER_INTEGRATION.md (new section 2.1.1) and plan docs

Builds clean (38/39 tests; nip34_test failure is pre-existing/unrelated).
nsigner_client_test 6/6 pass, signer_modules_test 26/26 pass.
This commit is contained in:
Laan Tungir
2026-08-06 09:42:40 -04:00
parent feca911ac4
commit b235bd6585
7 changed files with 238 additions and 48 deletions
+38 -1
View File
@@ -49,13 +49,50 @@ nostr_signer_t* nostr_signer_nsigner_unix(const char* socket_name, const char* r
nostr_signer_t* nostr_signer_nsigner_serial(const char* device_path, const char* role, int timeout_ms);
nostr_signer_t* nostr_signer_nsigner_tcp(const char* host, int port, const char* role, int timeout_ms);
nostr_signer_t* nostr_signer_nsigner_fds(int read_fd, int write_fd, const char* role, int timeout_ms);
nostr_signer_t* nostr_signer_nsigner_qrexec(const char* target_qube, const char* service_name, const char* role, int timeout_ms);
/* TCP mode requires auth envelope per n_signer; set this before making calls. */
int nostr_signer_nsigner_set_auth(nostr_signer_t* signer,
const unsigned char auth_privkey[32],
const char* label);
/* Set the full BIP-44 role_path. n_signer requires BOTH role and role_path
* together for nostr_* verbs; set the role via the constructor and the path
* here. */
int nostr_signer_nsigner_set_role_path(nostr_signer_t* signer, const char* role_path);
/* Compatibility shim: expands NIP-06 index N into role="main" +
* role_path="m/44'/1237'/N'/0/0" and stores N as the derive index. */
int nostr_signer_nsigner_set_nostr_index(nostr_signer_t* signer, int nostr_index);
#endif
```
### 2.1.1 Key selector model
n_signer's `nostr_*` verbs select a key via a **combined** `role` + `role_path`
selector — both fields are required together. The remote backend emits:
```json
{"role":"<name>","role_path":"m/44'/1237'/N'/0/0"}
```
as the trailing options object of every `nostr_*` request. Omitting either
field is rejected by n_signer with `2008 role_required` / `2009 path_required`.
The bare `{"nostr_index":N}` selector is **removed** on the n_signer side
(error `2006 nostr_index_deprecated`); this library never emits it.
Two ways to set the selector:
1. **`nostr_signer_nsigner_set_role_path(signer, path)`** — explicit. Pass the
role name to the constructor and the full path here. This is the canonical
path for new code.
2. **`nostr_signer_nsigner_set_nostr_index(signer, N)`** — convenience. Expands
`N` into `role="main"` + `role_path="m/44'/1237'/N'/0/0"` and also stores `N`
as the algorithm `index` used by `nostr_signer_derive_hmac`. The signer must
have a `main` role whose template matches the expanded path.
For the algorithm-based `derive` verb (used by `nostr_signer_derive_hmac`), the
backend emits `{"algorithm":"secp256k1","index":N}`. The index comes from the
last `set_nostr_index` call; if unset, the request is sent without `index` and
n_signer rejects it with `missing_index`.
### 2.2 Transport constructors + framed I/O + discovery (`nostr_core/nsigner_transport.h`)
```c
@@ -132,7 +169,7 @@ The remote signer backend uses n_signer's role-based Nostr methods:
These wire names are distinct from both the public `nostr_signer_*` C API and the
unprefixed NIP-46 method names. In particular, n_signer's unprefixed `get_public_key`
is algorithm-based; this library uses `nostr_get_public_key` because remote signer
factories select keys by `role` or `nostr_index`.
factories select keys by `role` + `role_path` (see §2.1.1).
### Auth and framing notes
+82 -40
View File
@@ -1,5 +1,6 @@
#include "nostr_signer.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
@@ -30,8 +31,10 @@ struct nostr_signer {
struct {
nsigner_client_t* client;
char role[64];
int nostr_index; /* -1 = not set; when set, overrides role */
int has_nostr_index; /* 1 if nostr_index is set */
char role_path[128]; /* full BIP-44 path; required with role for nostr_* verbs */
int has_role_path; /* 1 if role_path was set explicitly */
int derive_index; /* algorithm index for the derive verb; -1 = not set */
int has_derive_index; /* 1 if derive_index was set */
} remote;
#endif
} u;
@@ -306,7 +309,10 @@ static int signer_local_derive_hmac(nostr_signer_t* signer,
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
static cJSON* signer_remote_params_with_selector(cJSON* params, const char* role, int has_nostr_index, int nostr_index) {
static cJSON* signer_remote_params_with_selector(cJSON* params,
const char* role,
const char* role_path,
int has_role_path) {
cJSON* selector;
if (params == NULL) {
@@ -316,19 +322,9 @@ static cJSON* signer_remote_params_with_selector(cJSON* params, const char* role
}
}
/* nostr_index takes precedence over role when set */
if (has_nostr_index) {
selector = cJSON_CreateObject();
if (selector == NULL) {
cJSON_Delete(params);
return NULL;
}
cJSON_AddNumberToObject(selector, "nostr_index", nostr_index);
cJSON_AddItemToArray(params, selector);
return params;
}
if (role == NULL || role[0] == '\0') {
/* n_signer requires BOTH role and role_path together for nostr_* verbs.
* If neither is set, emit nothing (caller handles the error). */
if ((role == NULL || role[0] == '\0') && !has_role_path) {
return params;
}
@@ -338,7 +334,12 @@ static cJSON* signer_remote_params_with_selector(cJSON* params, const char* role
return NULL;
}
cJSON_AddStringToObject(selector, "role", role);
if (role != NULL && role[0] != '\0') {
cJSON_AddStringToObject(selector, "role", role);
}
if (has_role_path && role_path != NULL && role_path[0] != '\0') {
cJSON_AddStringToObject(selector, "role_path", role_path);
}
cJSON_AddItemToArray(params, selector);
return params;
}
@@ -354,8 +355,8 @@ static int signer_remote_get_public_key(nostr_signer_t* signer, char out_pubkey_
}
params = signer_remote_params_with_selector(NULL, signer->u.remote.role,
signer->u.remote.has_nostr_index,
signer->u.remote.nostr_index);
signer->u.remote.role_path,
signer->u.remote.has_role_path);
if (params == NULL) {
return NOSTR_ERROR_MEMORY_FAILED;
}
@@ -406,8 +407,8 @@ static int signer_remote_sign_event(nostr_signer_t* signer, const cJSON* unsigne
free(event_json);
params = signer_remote_params_with_selector(params, signer->u.remote.role,
signer->u.remote.has_nostr_index,
signer->u.remote.nostr_index);
signer->u.remote.role_path,
signer->u.remote.has_role_path);
if (params == NULL) {
return NOSTR_ERROR_MEMORY_FAILED;
}
@@ -466,8 +467,8 @@ static int signer_remote_encrypt_decrypt(nostr_signer_t* signer,
cJSON_AddItemToArray(params, cJSON_CreateString(in));
params = signer_remote_params_with_selector(params, signer->u.remote.role,
signer->u.remote.has_nostr_index,
signer->u.remote.nostr_index);
signer->u.remote.role_path,
signer->u.remote.has_role_path);
if (params == NULL) {
return NOSTR_ERROR_MEMORY_FAILED;
}
@@ -546,22 +547,20 @@ static int signer_remote_derive_hmac(nostr_signer_t* signer,
}
cJSON_AddItemToArray(params, cJSON_CreateString(data));
/* Build the options object with algorithm:"secp256k1" and the selector
* (role or nostr_index). The derive verb requires index, so when
* has_nostr_index is set we include it; otherwise we include role (the
* nsigner derive handler requires index, so callers using the remote
* backend must set nostr_index via nostr_signer_nsigner_set_nostr_index
* before calling derive_hmac). */
/* Build the options object with algorithm:"secp256k1" and the index.
* The derive verb is algorithm-based and requires an explicit index
* (no default). The index is set via nostr_signer_nsigner_set_nostr_index
* (which stores it in derive_index) or directly via a future
* set_derive_index helper. Without an index the signer rejects the
* request with missing_index. */
opts = cJSON_CreateObject();
if (opts == NULL) {
cJSON_Delete(params);
return NOSTR_ERROR_MEMORY_FAILED;
}
cJSON_AddStringToObject(opts, "algorithm", "secp256k1");
if (signer->u.remote.has_nostr_index) {
cJSON_AddNumberToObject(opts, "index", signer->u.remote.nostr_index);
} else if (signer->u.remote.role[0] != '\0') {
cJSON_AddStringToObject(opts, "role", signer->u.remote.role);
if (signer->u.remote.has_derive_index) {
cJSON_AddNumberToObject(opts, "index", signer->u.remote.derive_index);
}
cJSON_AddItemToArray(params, opts);
@@ -627,6 +626,7 @@ void nostr_signer_free(nostr_signer_t* signer) {
signer->u.remote.client = NULL;
}
memset(signer->u.remote.role, 0, sizeof(signer->u.remote.role));
memset(signer->u.remote.role_path, 0, sizeof(signer->u.remote.role_path));
}
#endif
@@ -855,6 +855,32 @@ nostr_signer_t* nostr_signer_nsigner_qrexec(const char* target_qube, const char*
return nostr_signer_nsigner_from_transport(transport, role);
}
int nostr_signer_nsigner_set_role_path(nostr_signer_t* signer, const char* role_path) {
if (signer == NULL) {
return NOSTR_ERROR_INVALID_INPUT;
}
if (signer->backend != NOSTR_SIGNER_BACKEND_NSIGNER_REMOTE || signer->u.remote.client == NULL) {
return NOSTR_ERROR_INVALID_INPUT;
}
if (role_path == NULL || role_path[0] == '\0') {
/* Clear the role_path selector */
signer->u.remote.has_role_path = 0;
signer->u.remote.role_path[0] = '\0';
} else {
size_t n = strlen(role_path);
if (n >= sizeof(signer->u.remote.role_path)) {
return NOSTR_ERROR_INVALID_INPUT;
}
memcpy(signer->u.remote.role_path, role_path, n);
signer->u.remote.role_path[n] = '\0';
signer->u.remote.has_role_path = 1;
}
return NOSTR_SUCCESS;
}
int nostr_signer_nsigner_set_nostr_index(nostr_signer_t* signer, int nostr_index) {
if (signer == NULL) {
return NOSTR_ERROR_INVALID_INPUT;
@@ -865,14 +891,30 @@ int nostr_signer_nsigner_set_nostr_index(nostr_signer_t* signer, int nostr_index
}
if (nostr_index < 0) {
/* Clear the nostr_index selector, fall back to role */
signer->u.remote.has_nostr_index = 0;
signer->u.remote.nostr_index = -1;
/* Clear the selector */
signer->u.remote.has_role_path = 0;
signer->u.remote.role_path[0] = '\0';
signer->u.remote.has_derive_index = 0;
signer->u.remote.derive_index = -1;
} else {
/* Set nostr_index; clear role to avoid ambiguous selector */
signer->u.remote.has_nostr_index = 1;
signer->u.remote.nostr_index = nostr_index;
memset(signer->u.remote.role, 0, sizeof(signer->u.remote.role));
/* Expand the NIP-06 index into role="main" + the full derivation path.
* n_signer no longer accepts a bare {"nostr_index":N}; it requires
* {"role":"main","role_path":"m/44'/1237'/N'/0/0"}. We also store N as
* the algorithm derive index so nostr_signer_derive_hmac works. */
const char* main_role = "main";
size_t rlen = strlen(main_role);
if (rlen >= sizeof(signer->u.remote.role)) {
return NOSTR_ERROR_INVALID_INPUT;
}
memcpy(signer->u.remote.role, main_role, rlen);
signer->u.remote.role[rlen] = '\0';
snprintf(signer->u.remote.role_path, sizeof(signer->u.remote.role_path),
"m/44'/1237'/%d'/0/0", nostr_index);
signer->u.remote.has_role_path = 1;
signer->u.remote.derive_index = nostr_index;
signer->u.remote.has_derive_index = 1;
}
return NOSTR_SUCCESS;
+14 -4
View File
@@ -58,10 +58,20 @@ int nostr_signer_nsigner_set_auth(nostr_signer_t* signer,
const unsigned char auth_privkey[32],
const char* label);
/*
* Set the nostr_index selector for the remote nsigner backend. When set,
* requests use {"nostr_index":N} instead of {"role":"..."}. This is mutually
* exclusive with the role parameter (setting index clears role).
* Returns NOSTR_SUCCESS or an error code.
* Set the role_path selector for the remote nsigner backend. n_signer now
* requires BOTH "role" and "role_path" together for nostr_* verbs; set the
* role via the constructor (or keep the constructor default) and set the full
* BIP-44 derivation path here. Returns NOSTR_SUCCESS or an error code.
*/
int nostr_signer_nsigner_set_role_path(nostr_signer_t* signer, const char* role_path);
/*
* Compatibility shim: expands the NIP-06 index N into role="main" and
* role_path="m/44'/1237'/N'/0/0" and stores N as the algorithm derive index.
* n_signer no longer accepts a bare {"nostr_index":N} selector, so this is
* the supported way to select a key by NIP-06 account index. The signer must
* have a "main" role registered whose template matches the expanded path.
* Pass a negative value to clear the selector. Returns NOSTR_SUCCESS or an
* error code.
*/
int nostr_signer_nsigner_set_nostr_index(nostr_signer_t* signer, int nostr_index);
#endif
+73
View File
@@ -0,0 +1,73 @@
# Plan: n_signer Selector Rewrite for nostr_core_lib — COMPLETED
> **Status: Done.** The remote signer backend now emits the combined
> `role` + `role_path` selector required by current n_signer. The bare
> `nostr_index` selector is no longer emitted. `make` builds clean and the
> nsigner client test passes.
## Context
n_signer removed the `nostr_index` selector and `index`-on-nostr-verbs
([`n_signer/plans/role_path_authorization.md`](../../n_signer/plans/role_path_authorization.md)).
The only accepted selector for `nostr_*` verbs is now
`{"role":"<name>","role_path":"<full-path>"}` sent together. Either field
alone is rejected (`2008 role_required` / `2009 path_required`); a bare
`nostr_index` is rejected with `2006 nostr_index_deprecated`.
`nostr_core_lib`'s remote signer backend was emitting `{"nostr_index":N}` or
`{"role":"..."}` alone — both now invalid.
## Changes made
### `nostr_core/nostr_signer.h`
- Documented the new `nostr_signer_nsigner_set_role_path` setter.
- Repurposed `nostr_signer_nsigner_set_nostr_index` as a compatibility shim
that expands N into `role="main"` + `role_path="m/44'/1237'/N'/0/0"`.
### `nostr_core/nostr_signer.c`
- Replaced the `remote` struct fields `nostr_index`/`has_nostr_index` with
`role_path[128]`/`has_role_path` plus `derive_index`/`has_derive_index`
(the latter feeds the algorithm-based `derive` verb).
- Rewrote `signer_remote_params_with_selector` to emit both `role` and
`role_path` in the options object.
- Updated all three call sites (`get_public_key`, `sign_event`,
`encrypt_decrypt`) to the new signature.
- Rewrote `signer_remote_derive_hmac` to emit `{"algorithm":"secp256k1",
"index":N}` using `derive_index` (set by the `set_nostr_index` shim).
- Added `nostr_signer_nsigner_set_role_path`.
- Rewrote `nostr_signer_nsigner_set_nostr_index` to expand the NIP-06
template instead of storing a bare index.
- Added `<stdio.h>` for `snprintf`.
- `nostr_signer_free` now also zeroes `role_path`.
### `tests/nsigner_client_test.c`
- The mock-server fds test now sends a `{"role":"main",
"role_path":"m/44'/1237'/0'/0/0"}` selector and the child asserts both
fields are present in the framed request.
### Docs
- `NSIGNER_INTEGRATION.md` §2.1 and new §2.1.1 document the selector model.
- `plans/n_signer_verb_migration.md` and `plans/nsigner_integration_plan.md`
updated with notes pointing here.
## Verification
- `make` builds clean.
- `nsigner_client_test` passes (mock transport round-trip with the new
selector).
- `grep -rn 'nostr_index' nostr_core/nostr_signer.c` returns no bare-selector
emission (only the compatibility-shim function name and its doc comment).
## Downstream consumers still needing updates
These repos link `nostr_core_lib` and call `nostr_signer_nsigner_*`; the
`set_nostr_index` shim keeps them functional, but their UIs still expose only
an "index" input and should be updated to collect role + path explicitly:
- `sovereign_browser` — `src/login_dialog.c`, `src/agent_login.c`,
`src/key_store.c` (uses `set_nostr_index`; works via shim, UI needs role+path).
- `nostr_terminal` — has its own hand-rolled `nsigner_client.c` that still
emits `{"nostr_index":N}` directly (NOT via this lib); must be rewritten
independently.
- `laantungir_website` — raw JS JSON-RPC, emits `{"nostr_index":N}`; must be
rewritten independently.
+8 -2
View File
@@ -20,7 +20,13 @@ The Nostr protocol verbs gained a `nostr_` prefix. The algorithm-based verbs (`s
| `nip44_decrypt` | `nostr_nip44_decrypt` |
| `get_public_key` (role-based) | `nostr_get_public_key` |
**Important distinction:** `get_public_key` is now **algorithm-based** (takes `algorithm`+`index`). The role-based Nostr pubkey verb is `nostr_get_public_key` (takes `nostr_index`/`role`). Since `nostr_core_lib`'s remote signer always uses role-based selectors (`nostr_index`/`role`), the correct replacement is `nostr_get_public_key`.
**Important distinction:** `get_public_key` is now **algorithm-based** (takes `algorithm`+`index`). The role-based Nostr pubkey verb is `nostr_get_public_key` (takes `role`+`role_path`). Since `nostr_core_lib`'s remote signer always uses role-based selectors, the correct replacement is `nostr_get_public_key`.
> **Selector update (post-migration):** n_signer subsequently removed the
> `nostr_index` and `index` selectors for `nostr_*` verbs entirely. The only
> accepted selector is now `{"role":"<name>","role_path":"<full-path>"}` sent
> together. `nostr_core_lib` was updated to emit both fields; see
> `NSIGNER_INTEGRATION.md` §2.1.1 and `plans/n_signer_selector_rewrite.md`.
## What does NOT change
@@ -43,7 +49,7 @@ This is the only file that calls n_signer verbs via `nsigner_client_call()`. All
| 498 | `"nip44_encrypt"` | `"nostr_nip44_encrypt"` |
| 505 | `"nip44_decrypt"` | `"nostr_nip44_decrypt"` |
These are simple string-literal replacements. The `signer_remote_params_with_selector()` function (line 289) builds `{"nostr_index": N}` or `{"role": "..."}` selectors — these are still valid for the `nostr_*` verbs, so no selector changes needed.
These are simple string-literal replacements. The `signer_remote_params_with_selector()` function (line 289) builds `{"nostr_index": N}` or `{"role": "..."}` selectors — **these were subsequently invalidated** by n_signer's selector rewrite (the `nostr_index` selector is removed and `role` alone is rejected). That rewrite was handled in a follow-up; see `plans/n_signer_selector_rewrite.md`.
**Response format note:** `nostr_get_public_key` returns a plain 64-hex-char string by default (same as the old role-based `get_public_key`). The existing parsing at line 348 (`strlen(result->valuestring) != 64`) continues to work. If the caller ever passes `{"format":"structured"}`, the response would be a JSON object string — but the current code doesn't request structured format, so no change needed.
+8 -1
View File
@@ -9,9 +9,16 @@
> `nostr_get_public_key`, `nostr_sign_event`, `nostr_nip04_encrypt`,
> `nostr_nip04_decrypt`, `nostr_nip44_encrypt`, and `nostr_nip44_decrypt`.
> The unprefixed `get_public_key` method is algorithm-based and is not used by the
> role/`nostr_index` selectors in this library. Public `nostr_signer_*` C API names
> role/`role_path` selectors in this library. Public `nostr_signer_*` C API names
> and standard NIP-46 method names are unchanged.
>
> **Selector update:** n_signer removed the bare `nostr_index` selector and
> `index`-on-nostr-verbs. The only accepted selector for `nostr_*` verbs is now
> `{"role":"<name>","role_path":"<full-path>"}` sent together. This library
> emits both fields; `nostr_signer_nsigner_set_nostr_index` is retained as a
> compatibility shim that expands N into `role="main"` +
> `role_path="m/44'/1237'/N'/0/0"`. See `NSIGNER_INTEGRATION.md` §2.1.1.
>
> This plan originally described pulling the **caller-side** signer-integration glue out of per-project implementations and into `nostr_core_lib`,
> so any project that already links the library can sign **locally**, via a **running
> n_signer process**, or via a **USB hardware signer** — with the same code.
+15
View File
@@ -255,6 +255,12 @@ static int test_fds_transport_round_trip_via_client(void) {
free(req);
_exit(6);
}
/* The new n_signer wire contract requires both role and role_path. */
if (strstr(req, "\"role\":\"main\"") == NULL ||
strstr(req, "\"role_path\":\"m/44'/1237'/0'/0/0\"") == NULL) {
free(req);
_exit(9);
}
free(req);
out_hdr[0] = (unsigned char)((res_len >> 24) & 0xFFU);
@@ -293,6 +299,15 @@ static int test_fds_transport_round_trip_via_client(void) {
if (params == NULL) {
goto cleanup;
}
{
cJSON* selector = cJSON_CreateObject();
if (selector == NULL) {
goto cleanup;
}
cJSON_AddStringToObject(selector, "role", "main");
cJSON_AddStringToObject(selector, "role_path", "m/44'/1237'/0'/0/0");
cJSON_AddItemToArray(params, selector);
}
if (nsigner_client_call(client, "nostr_get_public_key", params, &result) != NOSTR_SUCCESS) {
params = NULL;