diff --git a/Makefile b/Makefile index 6df98c0..2aafed9 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,7 @@ TEST_MNEMONIC_INPUT_TARGET := $(BUILD_DIR)/test_mnemonic_input 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 +EXAMPLE_GET_PUBKEY_QREXEC_TARGET := $(BUILD_DIR)/example_get_pubkey_qrexec .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 examples test-client clean @@ -109,7 +110,7 @@ test-qrexec-auth: $(TEST_QREXEC_AUTH_TARGET) $(TARGET_DEV) test-client: examples -examples: $(EXAMPLE_GET_PUBLIC_KEY_TARGET) $(EXAMPLE_SIGN_EVENT_TARGET) $(EXAMPLE_GET_PUBKEY_TCP_TARGET) +examples: $(EXAMPLE_GET_PUBLIC_KEY_TARGET) $(EXAMPLE_SIGN_EVENT_TARGET) $(EXAMPLE_GET_PUBKEY_TCP_TARGET) $(EXAMPLE_GET_PUBKEY_QREXEC_TARGET) $(TEST_MNEMONIC_TARGET): $(TEST_DIR)/test_mnemonic.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c @mkdir -p $(BUILD_DIR) @@ -167,5 +168,9 @@ $(EXAMPLE_GET_PUBKEY_TCP_TARGET): $(EXAMPLES_DIR)/get_pubkey_tcp.c @mkdir -p $(BUILD_DIR) $(CC) $(CFLAGS) $(EXAMPLES_DIR)/get_pubkey_tcp.c -o $(EXAMPLE_GET_PUBKEY_TCP_TARGET) $(LDFLAGS) +$(EXAMPLE_GET_PUBKEY_QREXEC_TARGET): $(EXAMPLES_DIR)/get_pubkey_qrexec.c + @mkdir -p $(BUILD_DIR) + $(CC) $(CFLAGS) $(EXAMPLES_DIR)/get_pubkey_qrexec.c -o $(EXAMPLE_GET_PUBKEY_QREXEC_TARGET) $(LDFLAGS) + clean: rm -rf $(BUILD_DIR) diff --git a/examples/get_pubkey_qrexec.c b/examples/get_pubkey_qrexec.c new file mode 100644 index 0000000..5968425 --- /dev/null +++ b/examples/get_pubkey_qrexec.c @@ -0,0 +1,110 @@ +/* + * get_pubkey_qrexec.c — connect to a running n_signer in another Qubes qube + * via qrexec, using the high-level nostr_signer API from nostr_core_lib. + * + * This demonstrates the new nostr_core_lib client features: + * - nostr_signer_nsigner_qrexec() — qrexec transport + * - nostr_signer_nsigner_set_nostr_index() — index-based key selection + * + * Usage: + * ./get_pubkey_qrexec [nostr_index] + * ./get_pubkey_qrexec nostr_signer 0 + * ./get_pubkey_qrexec nostr_signer 1 + * + * No auth envelope needed — qrexec identity comes from QREXEC_REMOTE_DOMAIN + * on the server side. + */ + +#include +#include +#include + +#include "nostr_common.h" +#include "nostr_signer.h" +#include "nip019.h" + +int main(int argc, char **argv) { + const char *target_qube; + const char *service_name = "qubes.NsignerRpc"; + int nostr_index = 0; + nostr_signer_t *signer = NULL; + char pubkey_hex[65]; + unsigned char pubkey_bytes[32]; + char npub[128]; + int rc; + + if (argc < 2) { + fprintf(stderr, "Usage: %s [nostr_index]\n", argv[0]); + return 1; + } + target_qube = argv[1]; + if (argc > 2) { + nostr_index = atoi(argv[2]); + } + + if (nostr_init() != NOSTR_SUCCESS) { + fprintf(stderr, "failed to initialize crypto subsystem\n"); + return 1; + } + + printf("Connecting to n_signer in qube \"%s\" via qrexec (index %d)...\n", + target_qube, nostr_index); + + /* Create a high-level signer backed by qrexec transport */ + signer = nostr_signer_nsigner_qrexec(target_qube, service_name, NULL, 30000); + if (signer == NULL) { + fprintf(stderr, "failed to create qrexec signer (is qrexec-client-vm available?)\n"); + nostr_cleanup(); + return 1; + } + + /* Select key by nostr_index (NIP-06 m/44'/1237'/N'/0/0) */ + if (nostr_signer_nsigner_set_nostr_index(signer, nostr_index) != NOSTR_SUCCESS) { + fprintf(stderr, "failed to set nostr_index\n"); + nostr_signer_free(signer); + nostr_cleanup(); + return 1; + } + + /* Request the public key */ + rc = nostr_signer_get_public_key(signer, pubkey_hex); + if (rc != NOSTR_SUCCESS) { + if (rc == NOSTR_ERROR_NSIGNER_INDEX_NOT_ALLOWED) { + fprintf(stderr, "DENIED: index %d is not in the signer's whitelist\n", nostr_index); + } else if (rc == NOSTR_ERROR_NSIGNER_POLICY_DENIED) { + fprintf(stderr, "DENIED: policy denied (caller not approved at signer terminal)\n"); + } else { + fprintf(stderr, "get_public_key failed: error code %d\n", rc); + } + nostr_signer_free(signer); + nostr_cleanup(); + return 1; + } + + /* Convert hex pubkey to npub (bech32) */ + { + int i; + for (i = 0; i < 32; i++) { + unsigned int byte; + if (sscanf(pubkey_hex + 2 * i, "%2x", &byte) != 1) { + fprintf(stderr, "failed to parse hex pubkey\n"); + nostr_signer_free(signer); + nostr_cleanup(); + return 1; + } + pubkey_bytes[i] = (unsigned char)byte; + } + } + if (nostr_key_to_bech32(pubkey_bytes, "npub", npub) != NOSTR_SUCCESS) { + fprintf(stderr, "failed to convert to npub\n"); + nostr_signer_free(signer); + nostr_cleanup(); + return 1; + } + + printf("index %d: hex=%s npub=%s\n", nostr_index, pubkey_hex, npub); + + nostr_signer_free(signer); + nostr_cleanup(); + return 0; +} diff --git a/plans/nostr_core_lib_client_updates.md b/plans/nostr_core_lib_client_updates.md new file mode 100644 index 0000000..7cc57c6 --- /dev/null +++ b/plans/nostr_core_lib_client_updates.md @@ -0,0 +1,123 @@ +# Plan: nostr_core_lib client updates for new n_signer features + +Status: design / ready for review. + +Related: +- [`../nostr_core_lib/nostr_core/nsigner_transport.h`](../../nostr_core_lib/nostr_core/nsigner_transport.h) — transport vtable +- [`../nostr_core_lib/nostr_core/nsigner_client.h`](../../nostr_core_lib/nostr_core/nsigner_client.h) — low-level client +- [`../nostr_core_lib/nostr_core/nostr_signer.h`](../../nostr_core_lib/nostr_core/nostr_signer.h) — high-level signer +- [`../nostr_core_lib/nostr_core/nostr_signer.c`](../../nostr_core_lib/nostr_core/nostr_signer.c) — `signer_remote_params_with_selector` +- [`plans/qrexec_persistent_bridge.md`](qrexec_persistent_bridge.md) — qrexec bridge design +- [`plans/interactive_transport_selection.md`](interactive_transport_selection.md) — index whitelist + +--- + +## 1. Gaps identified + +The `nostr_core_lib` client stack was built before the recent n_signer features. Three gaps need filling: + +### 1.1 No `nostr_index` selector in the high-level API + +`signer_remote_params_with_selector()` ([`nostr_signer.c:287`](../../nostr_core_lib/nostr_core/nostr_signer.c:287)) only emits `{"role":"..."}`. It does not support `{"nostr_index":N}`, which is n_signer's primary key-selection mechanism for Nostr identities (NIP-06 `m/44'/1237'/N'/0/0`). + +The `nostr_signer_nsigner_*` constructors take a `const char* role` parameter. There's no way to say "use nostr_index 3" through the high-level API. Callers who want a specific Nostr identity by index must drop down to the low-level `nsigner_client_call` and build params manually — which is exactly what [`examples/get_pubkey_tcp.c`](../examples/get_pubkey_tcp.c) and [`examples/n_signer_qube_example_fips.js`](../examples/n_signer_qube_example_fips.js) do. + +### 1.2 No qrexec transport + +`nsigner_transport_open_*` supports unix, tcp, serial, fds — but not qrexec. On Qubes OS, the primary inter-qube path is `qrexec-client-vm qubes.NsignerRpc`, which pipes framed I/O through stdin/stdout of a subprocess. There's no transport that wraps this. + +### 1.3 No error code mapping for `index_not_allowed` (2002) + +n_signer now returns `{"error":{"code":2002,"message":"index_not_allowed"}}` when the index whitelist denies a request. The client should surface this distinctly from `policy_denied` (2001) so callers can tell "this index isn't whitelisted" vs "this caller isn't approved". + +--- + +## 2. Proposed changes + +### 2.1 Add `nostr_index` selector to the high-level API + +**Option A (minimal):** Add a new constructor variant that takes `nostr_index` instead of `role`: + +```c +nostr_signer_t* nostr_signer_nsigner_unix_index(const char* socket_name, int nostr_index, int timeout_ms); +nostr_signer_t* nostr_signer_nsigner_tcp_index(const char* host, int port, int nostr_index, int timeout_ms); +/* etc. */ +``` + +**Option B (cleaner):** Replace the `role` string parameter with a selector struct: + +```c +typedef struct { + const char* role; /* NULL = use default */ + int nostr_index; /* -1 = not set */ + const char* role_path; /* NULL = not set */ +} nsigner_selector_t; + +nostr_signer_t* nostr_signer_nsigner_unix(const char* socket_name, const nsigner_selector_t* selector, int timeout_ms); +``` + +**Option C (additive, chosen):** Keep existing constructors, add a setter for nostr_index: + +```c +int nostr_signer_nsigner_set_nostr_index(nostr_signer_t* signer, int nostr_index); +``` + +This sets the index on the remote backend, and `signer_remote_params_with_selector` emits `{"nostr_index":N}` instead of `{"role":"..."}` when set. Existing callers using `role` are unaffected. + +**Decision: Option C** — additive, no API break, smallest change. + +### 2.2 Add qrexec transport + +New transport constructor: + +```c +nsigner_transport_t* nsigner_transport_open_qrexec(const char* target_qube, const char* service_name, int timeout_ms); +``` + +Implementation: `fork()` + `execvp("qrexec-client-vm", [target_qube, service_name])` with `stdin`/`stdout` pipes. The `send_framed`/`recv_framed` vtable methods write/read through the pipes. `reconnect` re-spawns the subprocess (qrexec handles one request per invocation, so reconnect is mandatory before each call — same as the existing per-request reconnect pattern). + +The transport is stateless per call: each `reconnect` spawns a fresh `qrexec-client-vm` process, sends one framed request, reads one framed response, and the process exits. This matches the qrexec execution model. + +### 2.3 Add `nostr_signer_nsigner_qrexec` high-level constructor + +```c +nostr_signer_t* nostr_signer_nsigner_qrexec(const char* target_qube, const char* service_name, const char* role, int timeout_ms); +``` + +Wraps `nsigner_transport_open_qrexec` + `nostr_signer_nsigner_from_transport`. No auth envelope needed — qrexec identity comes from `QREXEC_REMOTE_DOMAIN` on the server side. + +### 2.4 Surface `index_not_allowed` error distinctly + +Add to `nostr_common.h`: + +```c +#define NOSTR_ERROR_NSIGNER_INDEX_NOT_ALLOWED -2002 +``` + +In `nsigner_client_call` (or `nostr_signer` remote backend), when the response error code is 2002, map it to `NOSTR_ERROR_NSIGNER_INDEX_NOT_ALLOWED` so callers can distinguish it from generic policy denial. + +--- + +## 3. Implementation checklist + +In `nostr_core_lib`: + +- [ ] `nsigner_transport.h` / `nsigner_transport.c`: add `nsigner_transport_open_qrexec(target_qube, service_name, timeout_ms)` — fork+exec `qrexec-client-vm`, pipe-based framed I/O, reconnect re-spawns. +- [ ] `nostr_signer.h` / `nostr_signer.c`: add `nostr_signer_nsigner_qrexec(target_qube, service_name, role, timeout_ms)`. +- [ ] `nostr_signer.c`: add `nostr_signer_nsigner_set_nostr_index(signer, nostr_index)`; update `signer_remote_params_with_selector` to emit `{"nostr_index":N}` when index is set. +- [ ] `nostr_common.h`: add `NOSTR_ERROR_NSIGNER_INDEX_NOT_ALLOWED` error code. +- [ ] `nsigner_client.c`: map error code 2002 to the new constant. +- [ ] `NSIGNER_INTEGRATION.md`: document the qrexec transport, nostr_index selector, and index_not_allowed error. + +In `n_signer` (this repo): + +- [ ] Update [`examples/get_pubkey_tcp.c`](../examples/get_pubkey_tcp.c) to optionally use the high-level `nostr_signer` API with `nostr_index` selector (demonstrates the new client API). +- [ ] Add a C example for the qrexec path using the new `nostr_signer_nsigner_qrexec` constructor. + +--- + +## 4. Open questions + +1. **Should the qrexec transport live in `nostr_core_lib` or in a Qubes-specific addon?** The transport is only useful on Qubes OS, but `nostr_core_lib` is meant to be cross-platform. **Decision: put it in `nostr_core_lib` behind `NOSTR_ENABLE_NSIGNER_CLIENT` — it just won't work on non-Qubes hosts (execvp fails). No new feature flag needed.** + +2. **Should `nostr_signer_nsigner_set_nostr_index` override `role` or coexist?** n_signer rejects ambiguous selectors (both `role` and `nostr_index` set = `ambiguous_role_selector`). **Decision: setting nostr_index clears role, and vice versa. The setter is exclusive.** diff --git a/src/main.c b/src/main.c index b9a4caf..eb8781b 100644 --- a/src/main.c +++ b/src/main.c @@ -503,8 +503,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 0 -#define NSIGNER_VERSION_PATCH 41 -#define NSIGNER_VERSION "v0.0.41" +#define NSIGNER_VERSION_PATCH 42 +#define NSIGNER_VERSION "v0.0.42" /* NSIGNER_HEADERLESS_DECLS_END */