From 9afbb8fcbd502d647388db15c4b44632f9dd1a8e Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Thu, 6 Aug 2026 10:17:47 -0400 Subject: [PATCH] Refactor n_signer_client to use nostr_core_lib high-level wrappers The CLI was hand-building cJSON params and calling the low-level nsigner_client_call for all 16 verbs. Now it uses the high-level nostr_signer_t typed wrappers from nostr_core_lib for 14 of 16 verbs: get_info, get_public_key (alg + nostr), sign_event, mine_event, nip04/44 encrypt+decrypt, sign, verify, derive, encapsulate, decapsulate, derive_shared_secret, otp encrypt/decrypt. The 'call' verb (raw passthrough) and 'derive --algorithm' still use the low-level nsigner_client_call on the shared connection (created via nostr_signer_nsigner_from_client). The 'list' verb uses nsigner_transport_list_unix directly. The client shrank from 945 to ~840 lines, with the per-verb cJSON building logic now in the library. Error messages use nostr_signer_last_error() to surface the raw n_signer RPC error text (path_not_allowed, unknown_role, etc.). Also adds two plan docs: - plans/client_breaking_change_audit.md: audit of n_signer breaking changes vs all ~/lt/ client repos - plans/nostr_core_lib_full_verb_coverage.md: analysis of the library verb coverage gap that motivated this refactor Tests: 45/45 pass, 0 fail, 2 skip (test_n_signer_client.sh). --- client/n_signer_client.c | 810 +++++++++------------ plans/client_breaking_change_audit.md | 179 +++++ plans/nostr_core_lib_full_verb_coverage.md | 197 +++++ 3 files changed, 735 insertions(+), 451 deletions(-) create mode 100644 plans/client_breaking_change_audit.md create mode 100644 plans/nostr_core_lib_full_verb_coverage.md diff --git a/client/n_signer_client.c b/client/n_signer_client.c index 993bfaa..063f723 100644 --- a/client/n_signer_client.c +++ b/client/n_signer_client.c @@ -5,6 +5,10 @@ * (or TCP/serial/qrexec) and exposes the full verb surface over stdin/stdout * so that signed events can be piped directly into `nak publish`. * + * This client uses the high-level nostr_signer_t API from nostr_core_lib + * for all typed verbs. The per-verb cJSON-building logic lives in the + * library, not here. The CLI is mostly argv parsing + result printing. + * * Build: make clients * Usage: n_signer_client [global options] [verb args...] * @@ -21,6 +25,7 @@ #include "nostr_common.h" #include "nsigner_transport.h" #include "nsigner_client.h" +#include "nostr_signer.h" #include "../cjson/cJSON.h" /* ------------------------------------------------------------------ */ @@ -48,7 +53,7 @@ static void print_usage(FILE *fp, const char *prog) { " -a, --algorithm secp256k1/ed25519/x25519/ml-dsa-65/\n" " slh-dsa-128s/ml-kem-768/otp\n" " --scheme secp256k1 sign/verify scheme (default schnorr)\n" - " --encoding OTP encoding (default base64)\n" + " --encoding OTP encoding (default ascii)\n" " --format get-public-key output (default plain)\n" " --index Algorithm derivation index\n" "\n" @@ -181,74 +186,99 @@ static int parse_qube_service(const char *s, char **out_qube, char **out_service /* Result printing helper */ /* ------------------------------------------------------------------ */ -/* - * Print a cJSON result value to stdout as a single newline-terminated line. - * Returns 0 for "valid" / 1 for "invalid" on verify verbs, -1 otherwise. - */ -static int print_result(cJSON *result, int is_verify) { - if (!result) { +/* Print a raw string result (from the *_result_json_out wrappers). */ +static void print_result_str(const char *s) { + if (s) { + printf("%s\n", s); + } else { printf("null\n"); + } +} + +/* ------------------------------------------------------------------ */ +/* Transport setup helper */ +/* ------------------------------------------------------------------ */ + +/* Opens a transport based on the CLI args. Returns 0 on success. + * On success, *out_transport is set (caller must not free if handed to signer). */ +static int open_transport(const char *socket_name, int timeout_ms, + const char *tcp_arg, const char *serial_arg, + const char *qrexec_arg, + const char *auth_privkey_hex, + nsigner_transport_t **out_transport) { + int transport_count = (tcp_arg ? 1 : 0) + (serial_arg ? 1 : 0) + (qrexec_arg ? 1 : 0) + (socket_name ? 1 : 0); + if (transport_count > 1) { + fprintf(stderr, "error: --tcp, --serial, --qrexec, and --socket-name are mutually exclusive\n"); return -1; } - if (is_verify) { - /* verify result: cJSON string containing JSON object like {"valid":true,...} - * or a plain string "valid"/"invalid" */ - if (cJSON_IsString(result)) { - const char *s = result->valuestring; - /* Try parsing as JSON object */ - cJSON *parsed = cJSON_Parse(s); - if (parsed) { - cJSON *v = cJSON_GetObjectItemCaseSensitive(parsed, "valid"); - if (v && cJSON_IsBool(v)) { - printf("%s\n", cJSON_IsTrue(v) ? "valid" : "invalid"); - cJSON_Delete(parsed); - return cJSON_IsTrue(v) ? 0 : 1; - } - cJSON_Delete(parsed); - } - /* Fallback: check string value */ - if (strcmp(s, "valid") == 0 || strcmp(s, "true") == 0) { - printf("valid\n"); - return 0; - } - printf("invalid\n"); - return 1; + *out_transport = NULL; + + if (tcp_arg) { + if (!auth_privkey_hex) { + fprintf(stderr, "error: --tcp requires --auth-privkey\n"); + return -1; } - if (cJSON_IsBool(result)) { - printf("%s\n", cJSON_IsTrue(result) ? "valid" : "invalid"); - return cJSON_IsTrue(result) ? 0 : 1; + char *host = NULL; + int port = 0; + if (parse_host_port(tcp_arg, &host, &port) != 0) { + fprintf(stderr, "error: invalid --tcp format (expected host:port)\n"); + return -1; + } + *out_transport = nsigner_transport_open_tcp(host, port, timeout_ms); + free(host); + if (!*out_transport) { + fprintf(stderr, "error: cannot open TCP transport to %s\n", tcp_arg); + return -1; + } + } else if (serial_arg) { + *out_transport = nsigner_transport_open_serial(serial_arg, timeout_ms); + if (!*out_transport) { + fprintf(stderr, "error: cannot open serial transport on %s\n", serial_arg); + return -1; + } + } else if (qrexec_arg) { + char *qube = NULL, *service = NULL; + if (parse_qube_service(qrexec_arg, &qube, &service) != 0) { + fprintf(stderr, "error: invalid --qrexec format (expected qube:service)\n"); + return -1; + } + *out_transport = nsigner_transport_open_qrexec(qube, service, timeout_ms); + free(qube); + free(service); + if (!*out_transport) { + fprintf(stderr, "error: cannot open qrexec transport to %s\n", qrexec_arg); + return -1; + } + } else if (socket_name) { + *out_transport = nsigner_transport_open_unix(socket_name, timeout_ms); + if (!*out_transport) { + fprintf(stderr, "error: cannot open unix transport %s\n", socket_name); + return -1; + } + } else { + /* Auto-discover: enumerate abstract UNIX sockets */ + char names[64][64]; + int count = nsigner_transport_list_unix(names, 64); + if (count == 0) { + fprintf(stderr, "error: no n_signer sockets found. Is n_signer running?\n"); + return -1; + } + if (count > 1) { + fprintf(stderr, "error: multiple n_signer sockets found. Use --socket-name to select one:\n"); + for (int j = 0; j < count; j++) { + fprintf(stderr, " %s\n", names[j]); + } + return -1; + } + *out_transport = nsigner_transport_open_unix(names[0], timeout_ms); + if (!*out_transport) { + fprintf(stderr, "error: cannot open unix transport %s\n", names[0]); + return -1; } - printf("invalid\n"); - return 1; } - if (cJSON_IsString(result)) { - printf("%s\n", result->valuestring); - } else if (cJSON_IsObject(result) || cJSON_IsArray(result)) { - char *json = cJSON_PrintUnformatted(result); - if (json) { - printf("%s\n", json); - free(json); - } - } else if (cJSON_IsNumber(result)) { - /* Use valuedouble for all numbers; cJSON stores ints as doubles internally */ - double d = result->valuedouble; - if (d == (double)(int)d) { - printf("%d\n", (int)d); - } else { - printf("%g\n", d); - } - } else if (cJSON_IsTrue(result)) { - printf("true\n"); - } else if (cJSON_IsFalse(result)) { - printf("false\n"); - } else if (cJSON_IsNull(result)) { - printf("null\n"); - } else { - printf("\n"); - } - return -1; + return 0; } /* ------------------------------------------------------------------ */ @@ -335,10 +365,10 @@ int main(int argc, char **argv) { if (i + 1 >= argc) { fprintf(stderr, "error: --algorithm requires a name\n"); return 2; } algorithm = argv[++i]; } else if (strcmp(arg, "--scheme") == 0) { - if (i + 1 >= argc) { fprintf(stderr, "error: --scheme requires schnorr or ecdsa\n"); return 2; } + if (i + 1 >= argc) { fprintf(stderr, "error: --scheme requires schnorr or edsa\n"); return 2; } scheme = argv[++i]; } else if (strcmp(arg, "--encoding") == 0) { - if (i + 1 >= argc) { fprintf(stderr, "error: --encoding requires base64 or hex\n"); return 2; } + if (i + 1 >= argc) { fprintf(stderr, "error: --encoding requires ascii or binary\n"); return 2; } encoding = argv[++i]; } else if (strcmp(arg, "--format") == 0) { if (i + 1 >= argc) { fprintf(stderr, "error: --format requires plain or structured\n"); return 2; } @@ -384,14 +414,6 @@ int main(int argc, char **argv) { /* ---- determine if this is an algorithm verb ---- */ int is_algorithm_verb = (algorithm != NULL); - /* ---- nostr_get_public_key with --format structured uses algorithm path too, - * but it's still a nostr verb. The --format flag only applies to nostr_get_public_key. - * If --algorithm is set, get-public-key becomes an algorithm verb. */ - int is_nostr_get_pubkey_structured = 0; - if (!is_algorithm_verb && format && strcmp(format, "structured") == 0) { - is_nostr_get_pubkey_structured = 1; - } - /* ---- validate --role and --path for nostr verbs ---- */ int is_nostr_verb = (strcmp(verb, "get-public-key") == 0 || strcmp(verb, "sign-event") == 0 || @@ -432,435 +454,347 @@ int main(int argc, char **argv) { return 0; } - /* ---- transport setup ---- */ + /* ---- open transport ---- */ nsigner_transport_t *transport = NULL; - nsigner_client_t *client = NULL; - cJSON *params = NULL; - cJSON *result = NULL; - int rc = 2; - int is_verify = 0; - - /* Determine transport type */ - int transport_count = (tcp_arg ? 1 : 0) + (serial_arg ? 1 : 0) + (qrexec_arg ? 1 : 0) + (socket_name ? 1 : 0); - if (transport_count > 1) { - fprintf(stderr, "error: --tcp, --serial, --qrexec, and --socket-name are mutually exclusive\n"); - goto cleanup; + if (open_transport(socket_name, timeout_ms, tcp_arg, serial_arg, qrexec_arg, + auth_privkey_hex, &transport) != 0) { + nostr_cleanup(); + return 2; } - if (tcp_arg) { - /* TCP transport */ - if (!auth_privkey_hex) { - fprintf(stderr, "error: --tcp requires --auth-privkey\n"); - goto cleanup; - } - char *host = NULL; - int port = 0; - if (parse_host_port(tcp_arg, &host, &port) != 0) { - fprintf(stderr, "error: invalid --tcp format (expected host:port)\n"); - goto cleanup; - } - transport = nsigner_transport_open_tcp(host, port, timeout_ms); - free(host); - if (!transport) { - fprintf(stderr, "error: cannot open TCP transport to %s\n", tcp_arg); - goto cleanup; - } - } else if (serial_arg) { - transport = nsigner_transport_open_serial(serial_arg, timeout_ms); - if (!transport) { - fprintf(stderr, "error: cannot open serial transport on %s\n", serial_arg); - goto cleanup; - } - } else if (qrexec_arg) { - char *qube = NULL, *service = NULL; - if (parse_qube_service(qrexec_arg, &qube, &service) != 0) { - fprintf(stderr, "error: invalid --qrexec format (expected qube:service)\n"); - goto cleanup; - } - transport = nsigner_transport_open_qrexec(qube, service, timeout_ms); - free(qube); - free(service); - if (!transport) { - fprintf(stderr, "error: cannot open qrexec transport to %s\n", qrexec_arg); - goto cleanup; - } - } else if (socket_name) { - transport = nsigner_transport_open_unix(socket_name, timeout_ms); - if (!transport) { - fprintf(stderr, "error: cannot open unix transport %s\n", socket_name); - goto cleanup; - } - } else { - /* Auto-discover: enumerate abstract UNIX sockets */ - char names[64][64]; - int count = nsigner_transport_list_unix(names, 64); - if (count == 0) { - fprintf(stderr, "error: no n_signer sockets found. Is n_signer running?\n"); - goto cleanup; - } - if (count > 1) { - fprintf(stderr, "error: multiple n_signer sockets found. Use --socket-name to select one:\n"); - for (int j = 0; j < count; j++) { - fprintf(stderr, " %s\n", names[j]); - } - goto cleanup; - } - socket_name = names[0]; - transport = nsigner_transport_open_unix(socket_name, timeout_ms); - if (!transport) { - fprintf(stderr, "error: cannot open unix transport %s\n", socket_name); - goto cleanup; - } - } - - /* ---- create client ---- */ - client = nsigner_client_new(transport); + /* ---- create low-level client (owns transport) ---- */ + nsigner_client_t *client = nsigner_client_new(transport); if (!client) { fprintf(stderr, "error: cannot create nsigner client\n"); transport->close(transport); - goto cleanup; + nostr_cleanup(); + return 2; } transport = NULL; /* owned by client */ + /* ---- create high-level signer from client (shares the connection) ---- */ + nostr_signer_t *signer = nostr_signer_nsigner_from_client(client, role); + if (!signer) { + fprintf(stderr, "error: cannot create nsigner signer\n"); + nsigner_client_free(client); + nostr_cleanup(); + return 2; + } + /* signer now owns client; don't free it separately */ + + /* ---- set role_path selector for nostr verbs ---- */ + if (is_nostr_verb && !is_algorithm_verb && path) { + if (nostr_signer_nsigner_set_role_path(signer, path) != NOSTR_SUCCESS) { + fprintf(stderr, "error: failed to set role_path\n"); + nostr_signer_free(signer); + nostr_cleanup(); + return 2; + } + } + /* ---- auth envelope (TCP) ---- */ if (auth_privkey_hex) { unsigned char privkey[32]; if (hex_to_bytes(auth_privkey_hex, privkey, 32) != 32) { fprintf(stderr, "error: --auth-privkey must be 32 bytes (64 hex chars)\n"); - goto cleanup; + nostr_signer_free(signer); + nostr_cleanup(); + return 2; } - if (nsigner_client_set_auth(client, privkey, auth_label ? auth_label : "") != NOSTR_SUCCESS) { + if (nostr_signer_nsigner_set_auth(signer, privkey, auth_label ? auth_label : "") != NOSTR_SUCCESS) { fprintf(stderr, "error: failed to set auth envelope\n"); - goto cleanup; + nostr_signer_free(signer); + nostr_cleanup(); + return 2; } } - /* ---- build params and call ---- */ - const char *method = NULL; - int is_call_verb = 0; + /* ---- resolve algorithm index ---- */ + int eff_index = has_alg_index ? alg_index : (has_index ? index_val : 0); + int rc = 2; + char *result_str = NULL; + cJSON *result_obj = NULL; + + /* ---- dispatch verbs via high-level library wrappers ---- */ if (strcmp(verb, "get-info") == 0) { - method = "get_info"; - params = cJSON_CreateArray(); - if (!params) { fprintf(stderr, "error: out of memory\n"); goto cleanup; } + rc = nostr_signer_get_info(signer, &result_obj); + if (rc != NOSTR_SUCCESS) { + fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); + goto cleanup; + } + char *json = cJSON_PrintUnformatted(result_obj); + if (json) { printf("%s\n", json); free(json); } + rc = 0; } else if (strcmp(verb, "get-public-key") == 0) { if (is_algorithm_verb) { - method = "get_public_key"; - params = cJSON_CreateArray(); - if (!params) { fprintf(stderr, "error: out of memory\n"); goto cleanup; } - cJSON *opts = cJSON_CreateObject(); - if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; } - cJSON_AddStringToObject(opts, "algorithm", algorithm); - if (has_alg_index) { - cJSON_AddNumberToObject(opts, "index", alg_index); - } else if (has_index) { - cJSON_AddNumberToObject(opts, "index", index_val); - } else { - cJSON_AddNumberToObject(opts, "index", 0); + rc = nostr_signer_get_public_key_alg(signer, algorithm, eff_index, &result_str); + if (rc != NOSTR_SUCCESS) { + fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); + goto cleanup; } - cJSON_AddItemToArray(params, opts); - } else { - method = "nostr_get_public_key"; - params = cJSON_CreateArray(); - if (!params) { fprintf(stderr, "error: out of memory\n"); goto cleanup; } + print_result_str(result_str); + rc = 0; + } else if (format && strcmp(format, "structured") == 0) { + /* Structured format: use low-level client to pass the format option. */ + cJSON *params = cJSON_CreateArray(); cJSON *opts = cJSON_CreateObject(); - if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; } if (role) cJSON_AddStringToObject(opts, "role", role); if (path) cJSON_AddStringToObject(opts, "role_path", path); - if (is_nostr_get_pubkey_structured) cJSON_AddStringToObject(opts, "format", "structured"); + cJSON_AddStringToObject(opts, "format", "structured"); cJSON_AddItemToArray(params, opts); + cJSON *presult = NULL; + rc = nsigner_client_call(client, "nostr_get_public_key", params, &presult); + if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nsigner_client_last_error(client)); goto cleanup; } + if (cJSON_IsString(presult)) { + print_result_str(presult->valuestring); + } else if (presult) { + char *json = cJSON_PrintUnformatted(presult); + if (json) { printf("%s\n", json); free(json); } + } + cJSON_Delete(presult); + rc = 0; + } else { + char pubkey_hex[65]; + rc = nostr_signer_get_public_key(signer, pubkey_hex); + if (rc != NOSTR_SUCCESS) { + fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); + goto cleanup; + } + printf("%s\n", pubkey_hex); + rc = 0; } } else if (strcmp(verb, "sign-event") == 0) { - method = "nostr_sign_event"; const char *event_json = arg1; + char *event_buf = NULL; if (!event_json) { - event_json = read_stdin_line(); - if (!event_json) { + event_buf = read_stdin_line(); + if (!event_buf) { fprintf(stderr, "error: no event JSON provided (pass as argument or pipe to stdin)\n"); goto cleanup; } + event_json = event_buf; } - params = cJSON_CreateArray(); - if (!params) { fprintf(stderr, "error: out of memory\n"); free((char*)event_json); goto cleanup; } - cJSON_AddItemToArray(params, cJSON_CreateString(event_json)); - if (!arg1) free((char*)event_json); - - cJSON *opts = cJSON_CreateObject(); - if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; } - if (role) cJSON_AddStringToObject(opts, "role", role); - if (path) cJSON_AddStringToObject(opts, "role_path", path); - cJSON_AddItemToArray(params, opts); + cJSON *event = cJSON_Parse(event_json); + free(event_buf); + if (!event) { + fprintf(stderr, "error: failed to parse event JSON\n"); + goto cleanup; + } + rc = nostr_signer_sign_event(signer, event, &result_obj); + cJSON_Delete(event); + if (rc != NOSTR_SUCCESS) { + fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); + goto cleanup; + } + char *json = cJSON_PrintUnformatted(result_obj); + if (json) { printf("%s\n", json); free(json); } + rc = 0; } else if (strcmp(verb, "mine-event") == 0) { - method = "nostr_mine_event"; const char *event_json = arg1; + char *event_buf = NULL; if (!event_json) { - event_json = read_stdin_line(); - if (!event_json) { + event_buf = read_stdin_line(); + if (!event_buf) { fprintf(stderr, "error: no event JSON provided (pass as argument or pipe to stdin)\n"); goto cleanup; } + event_json = event_buf; } - params = cJSON_CreateArray(); - if (!params) { fprintf(stderr, "error: out of memory\n"); free((char*)event_json); goto cleanup; } - cJSON_AddItemToArray(params, cJSON_CreateString(event_json)); - if (!arg1) free((char*)event_json); - - cJSON *opts = cJSON_CreateObject(); - if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; } - if (role) cJSON_AddStringToObject(opts, "role", role); - if (path) cJSON_AddStringToObject(opts, "role_path", path); - if (has_difficulty) cJSON_AddNumberToObject(opts, "difficulty", difficulty_val); - if (has_threads) cJSON_AddNumberToObject(opts, "threads", threads_val); - if (has_timeout_sec) cJSON_AddNumberToObject(opts, "timeout_sec", timeout_sec_val); - cJSON_AddItemToArray(params, opts); + cJSON *event = cJSON_Parse(event_json); + free(event_buf); + if (!event) { + fprintf(stderr, "error: failed to parse event JSON\n"); + goto cleanup; + } + rc = nostr_signer_mine_event(signer, event, + has_difficulty ? difficulty_val : 0, + has_timeout_sec ? timeout_sec_val : 0, + has_threads ? threads_val : 1, + &result_obj); + cJSON_Delete(event); + if (rc != NOSTR_SUCCESS) { + fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); + goto cleanup; + } + char *json = cJSON_PrintUnformatted(result_obj); + if (json) { printf("%s\n", json); free(json); } + rc = 0; } else if (strcmp(verb, "nip04-encrypt") == 0) { - method = "nostr_nip04_encrypt"; if (!arg1) { fprintf(stderr, "error: nip04-encrypt requires \n"); goto cleanup; } - const char *peer = arg1; const char *plaintext = arg2; + char *pt_buf = NULL; if (!plaintext) { - plaintext = read_stdin_line(); - if (!plaintext) { - fprintf(stderr, "error: no plaintext provided (pass as argument or pipe to stdin)\n"); - goto cleanup; - } + pt_buf = read_stdin_line(); + if (!pt_buf) { fprintf(stderr, "error: no plaintext provided\n"); goto cleanup; } + plaintext = pt_buf; } - params = cJSON_CreateArray(); - if (!params) { fprintf(stderr, "error: out of memory\n"); if (!arg2) free((char*)plaintext); goto cleanup; } - cJSON_AddItemToArray(params, cJSON_CreateString(peer)); - cJSON_AddItemToArray(params, cJSON_CreateString(plaintext)); - if (!arg2) free((char*)plaintext); - - cJSON *opts = cJSON_CreateObject(); - if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; } - if (role) cJSON_AddStringToObject(opts, "role", role); - if (path) cJSON_AddStringToObject(opts, "role_path", path); - cJSON_AddItemToArray(params, opts); + rc = nostr_signer_nip04_encrypt(signer, arg1, plaintext, &result_str); + free(pt_buf); + if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; } + print_result_str(result_str); + rc = 0; } else if (strcmp(verb, "nip04-decrypt") == 0) { - method = "nostr_nip04_decrypt"; if (!arg1) { fprintf(stderr, "error: nip04-decrypt requires \n"); goto cleanup; } - const char *peer = arg1; const char *ciphertext = arg2; + char *ct_buf = NULL; if (!ciphertext) { - ciphertext = read_stdin_line(); - if (!ciphertext) { - fprintf(stderr, "error: no ciphertext provided (pass as argument or pipe to stdin)\n"); - goto cleanup; - } + ct_buf = read_stdin_line(); + if (!ct_buf) { fprintf(stderr, "error: no ciphertext provided\n"); goto cleanup; } + ciphertext = ct_buf; } - params = cJSON_CreateArray(); - if (!params) { fprintf(stderr, "error: out of memory\n"); if (!arg2) free((char*)ciphertext); goto cleanup; } - cJSON_AddItemToArray(params, cJSON_CreateString(peer)); - cJSON_AddItemToArray(params, cJSON_CreateString(ciphertext)); - if (!arg2) free((char*)ciphertext); - - cJSON *opts = cJSON_CreateObject(); - if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; } - if (role) cJSON_AddStringToObject(opts, "role", role); - if (path) cJSON_AddStringToObject(opts, "role_path", path); - cJSON_AddItemToArray(params, opts); + rc = nostr_signer_nip04_decrypt(signer, arg1, ciphertext, &result_str); + free(ct_buf); + if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; } + print_result_str(result_str); + rc = 0; } else if (strcmp(verb, "nip44-encrypt") == 0) { - method = "nostr_nip44_encrypt"; if (!arg1) { fprintf(stderr, "error: nip44-encrypt requires \n"); goto cleanup; } - const char *peer = arg1; const char *plaintext = arg2; + char *pt_buf = NULL; if (!plaintext) { - plaintext = read_stdin_line(); - if (!plaintext) { - fprintf(stderr, "error: no plaintext provided (pass as argument or pipe to stdin)\n"); - goto cleanup; - } + pt_buf = read_stdin_line(); + if (!pt_buf) { fprintf(stderr, "error: no plaintext provided\n"); goto cleanup; } + plaintext = pt_buf; } - params = cJSON_CreateArray(); - if (!params) { fprintf(stderr, "error: out of memory\n"); if (!arg2) free((char*)plaintext); goto cleanup; } - cJSON_AddItemToArray(params, cJSON_CreateString(peer)); - cJSON_AddItemToArray(params, cJSON_CreateString(plaintext)); - if (!arg2) free((char*)plaintext); - - cJSON *opts = cJSON_CreateObject(); - if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; } - if (role) cJSON_AddStringToObject(opts, "role", role); - if (path) cJSON_AddStringToObject(opts, "role_path", path); - cJSON_AddItemToArray(params, opts); + rc = nostr_signer_nip44_encrypt(signer, arg1, plaintext, &result_str); + free(pt_buf); + if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; } + print_result_str(result_str); + rc = 0; } else if (strcmp(verb, "nip44-decrypt") == 0) { - method = "nostr_nip44_decrypt"; if (!arg1) { fprintf(stderr, "error: nip44-decrypt requires \n"); goto cleanup; } - const char *peer = arg1; const char *ciphertext = arg2; + char *ct_buf = NULL; if (!ciphertext) { - ciphertext = read_stdin_line(); - if (!ciphertext) { - fprintf(stderr, "error: no ciphertext provided (pass as argument or pipe to stdin)\n"); - goto cleanup; - } + ct_buf = read_stdin_line(); + if (!ct_buf) { fprintf(stderr, "error: no ciphertext provided\n"); goto cleanup; } + ciphertext = ct_buf; } - params = cJSON_CreateArray(); - if (!params) { fprintf(stderr, "error: out of memory\n"); if (!arg2) free((char*)ciphertext); goto cleanup; } - cJSON_AddItemToArray(params, cJSON_CreateString(peer)); - cJSON_AddItemToArray(params, cJSON_CreateString(ciphertext)); - if (!arg2) free((char*)ciphertext); - - cJSON *opts = cJSON_CreateObject(); - if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; } - if (role) cJSON_AddStringToObject(opts, "role", role); - if (path) cJSON_AddStringToObject(opts, "role_path", path); - cJSON_AddItemToArray(params, opts); + rc = nostr_signer_nip44_decrypt(signer, arg1, ciphertext, &result_str); + free(ct_buf); + if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; } + print_result_str(result_str); + rc = 0; } else if (strcmp(verb, "sign") == 0) { - method = "sign"; if (!arg1) { fprintf(stderr, "error: sign requires \n"); goto cleanup; } - params = cJSON_CreateArray(); - if (!params) { fprintf(stderr, "error: out of memory\n"); goto cleanup; } - cJSON_AddItemToArray(params, cJSON_CreateString(arg1)); - cJSON *opts = cJSON_CreateObject(); - if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; } - cJSON_AddStringToObject(opts, "algorithm", algorithm ? algorithm : "secp256k1"); - if (has_alg_index) { - cJSON_AddNumberToObject(opts, "index", alg_index); - } else if (has_index) { - cJSON_AddNumberToObject(opts, "index", index_val); - } else { - cJSON_AddNumberToObject(opts, "index", 0); - } - if (scheme) cJSON_AddStringToObject(opts, "scheme", scheme); - cJSON_AddItemToArray(params, opts); + size_t msg_len = strlen(arg1) / 2; + unsigned char *msg = malloc(msg_len ? msg_len : 1); + if (!msg) { fprintf(stderr, "error: out of memory\n"); goto cleanup; } + int n = hex_to_bytes(arg1, msg, msg_len); + if (n < 0) { free(msg); fprintf(stderr, "error: invalid hex message\n"); goto cleanup; } + rc = nostr_signer_sign(signer, algorithm ? algorithm : "secp256k1", + eff_index, scheme, msg, (size_t)n, &result_str); + free(msg); + if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; } + print_result_str(result_str); + rc = 0; } else if (strcmp(verb, "verify") == 0) { - method = "verify"; - is_verify = 1; if (!arg1 || !arg2) { fprintf(stderr, "error: verify requires \n"); goto cleanup; } - params = cJSON_CreateArray(); - if (!params) { fprintf(stderr, "error: out of memory\n"); goto cleanup; } - cJSON_AddItemToArray(params, cJSON_CreateString(arg1)); - cJSON_AddItemToArray(params, cJSON_CreateString(arg2)); - cJSON *opts = cJSON_CreateObject(); - if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; } - cJSON_AddStringToObject(opts, "algorithm", algorithm ? algorithm : "secp256k1"); - if (has_alg_index) { - cJSON_AddNumberToObject(opts, "index", alg_index); - } else if (has_index) { - cJSON_AddNumberToObject(opts, "index", index_val); - } else { - cJSON_AddNumberToObject(opts, "index", 0); - } - if (scheme) cJSON_AddStringToObject(opts, "scheme", scheme); - cJSON_AddItemToArray(params, opts); + size_t msg_len = strlen(arg1) / 2; + size_t sig_len = strlen(arg2) / 2; + unsigned char *msg = malloc(msg_len ? msg_len : 1); + unsigned char *sig = malloc(sig_len ? sig_len : 1); + if (!msg || !sig) { free(msg); free(sig); fprintf(stderr, "error: out of memory\n"); goto cleanup; } + int mn = hex_to_bytes(arg1, msg, msg_len); + int sn = hex_to_bytes(arg2, sig, sig_len); + if (mn < 0 || sn < 0) { free(msg); free(sig); fprintf(stderr, "error: invalid hex\n"); goto cleanup; } + int valid = 0; + rc = nostr_signer_verify(signer, algorithm ? algorithm : "secp256k1", + eff_index, scheme, msg, (size_t)mn, sig, (size_t)sn, &valid); + free(msg); + free(sig); + if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; } + printf("%s\n", valid ? "valid" : "invalid"); + rc = valid ? 0 : 1; } else if (strcmp(verb, "derive") == 0) { - method = "derive"; const char *data = arg1; + char *data_buf = NULL; if (!data) { - data = read_stdin_line(); - if (!data) { - fprintf(stderr, "error: no data provided (pass as argument or pipe to stdin)\n"); - goto cleanup; + data_buf = read_stdin_line(); + if (!data_buf) { fprintf(stderr, "error: no data provided\n"); goto cleanup; } + data = data_buf; + } + if (is_algorithm_verb) { + /* Algorithm-based derive: use low-level client to pass algorithm+index. */ + cJSON *params = cJSON_CreateArray(); + cJSON_AddItemToArray(params, cJSON_CreateString(data)); + cJSON *opts = cJSON_CreateObject(); + cJSON_AddStringToObject(opts, "algorithm", algorithm ? algorithm : "secp256k1"); + cJSON_AddNumberToObject(opts, "index", eff_index); + cJSON_AddItemToArray(params, opts); + cJSON *dresult = NULL; + rc = nsigner_client_call(client, "derive", params, &dresult); + free(data_buf); + if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nsigner_client_last_error(client)); goto cleanup; } + if (cJSON_IsString(dresult)) { + /* The derive result is a JSON object string like + * {"algorithm":"secp256k1","key_id":"...","digest":"<64hex>"}. + * Print the raw result string. */ + print_result_str(dresult->valuestring); } - } - params = cJSON_CreateArray(); - if (!params) { fprintf(stderr, "error: out of memory\n"); if (!arg1) free((char*)data); goto cleanup; } - cJSON_AddItemToArray(params, cJSON_CreateString(data)); - if (!arg1) free((char*)data); - - cJSON *opts = cJSON_CreateObject(); - if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; } - cJSON_AddStringToObject(opts, "algorithm", algorithm ? algorithm : "secp256k1"); - if (has_alg_index) { - cJSON_AddNumberToObject(opts, "index", alg_index); - } else if (has_index) { - cJSON_AddNumberToObject(opts, "index", index_val); + cJSON_Delete(dresult); + rc = 0; } else { - cJSON_AddNumberToObject(opts, "index", 0); + /* Nostr derive (HMAC): use the high-level wrapper. */ + char digest_hex[65]; + rc = nostr_signer_derive_hmac(signer, data, digest_hex); + free(data_buf); + if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; } + printf("%s\n", digest_hex); + rc = 0; } - cJSON_AddItemToArray(params, opts); } else if (strcmp(verb, "encapsulate") == 0) { - method = "encapsulate"; if (!arg1) { fprintf(stderr, "error: encapsulate requires \n"); goto cleanup; } - params = cJSON_CreateArray(); - if (!params) { fprintf(stderr, "error: out of memory\n"); goto cleanup; } - cJSON_AddItemToArray(params, cJSON_CreateString(arg1)); - cJSON *opts = cJSON_CreateObject(); - if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; } - cJSON_AddStringToObject(opts, "algorithm", algorithm ? algorithm : "ml-kem-768"); - cJSON_AddItemToArray(params, opts); + rc = nostr_signer_encapsulate(signer, arg1, &result_str); + if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; } + print_result_str(result_str); + rc = 0; } else if (strcmp(verb, "decapsulate") == 0) { - method = "decapsulate"; if (!arg1) { fprintf(stderr, "error: decapsulate requires \n"); goto cleanup; } - params = cJSON_CreateArray(); - if (!params) { fprintf(stderr, "error: out of memory\n"); goto cleanup; } - cJSON_AddItemToArray(params, cJSON_CreateString(arg1)); - cJSON *opts = cJSON_CreateObject(); - if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; } - cJSON_AddStringToObject(opts, "algorithm", algorithm ? algorithm : "ml-kem-768"); - if (has_alg_index) { - cJSON_AddNumberToObject(opts, "index", alg_index); - } else if (has_index) { - cJSON_AddNumberToObject(opts, "index", index_val); - } else { - cJSON_AddNumberToObject(opts, "index", 0); - } - cJSON_AddItemToArray(params, opts); + rc = nostr_signer_decapsulate(signer, eff_index, arg1, &result_str); + if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; } + print_result_str(result_str); + rc = 0; } else if (strcmp(verb, "derive-shared-secret") == 0) { - method = "derive_shared_secret"; if (!arg1) { fprintf(stderr, "error: derive-shared-secret requires \n"); goto cleanup; } - params = cJSON_CreateArray(); - if (!params) { fprintf(stderr, "error: out of memory\n"); goto cleanup; } - cJSON_AddItemToArray(params, cJSON_CreateString(arg1)); - cJSON *opts = cJSON_CreateObject(); - if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; } - cJSON_AddStringToObject(opts, "algorithm", algorithm ? algorithm : "x25519"); - if (has_alg_index) { - cJSON_AddNumberToObject(opts, "index", alg_index); - } else if (has_index) { - cJSON_AddNumberToObject(opts, "index", index_val); - } else { - cJSON_AddNumberToObject(opts, "index", 0); - } - cJSON_AddItemToArray(params, opts); + rc = nostr_signer_derive_shared_secret(signer, eff_index, arg1, &result_str); + if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; } + print_result_str(result_str); + rc = 0; } else if (strcmp(verb, "encrypt") == 0) { - method = "encrypt"; const char *plaintext = arg1; + char *pt_buf = NULL; if (!plaintext) { - plaintext = read_stdin_line(); - if (!plaintext) { - fprintf(stderr, "error: no plaintext provided (pass as argument or pipe to stdin)\n"); - goto cleanup; - } + pt_buf = read_stdin_line(); + if (!pt_buf) { fprintf(stderr, "error: no plaintext provided\n"); goto cleanup; } + plaintext = pt_buf; } - params = cJSON_CreateArray(); - if (!params) { fprintf(stderr, "error: out of memory\n"); if (!arg1) free((char*)plaintext); goto cleanup; } - cJSON_AddItemToArray(params, cJSON_CreateString(plaintext)); - if (!arg1) free((char*)plaintext); - - cJSON *opts = cJSON_CreateObject(); - if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; } - cJSON_AddStringToObject(opts, "algorithm", "otp"); - if (encoding) cJSON_AddStringToObject(opts, "encoding", encoding); - cJSON_AddItemToArray(params, opts); + rc = nostr_signer_otp_encrypt(signer, plaintext, encoding, &result_str); + free(pt_buf); + if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; } + print_result_str(result_str); + rc = 0; } else if (strcmp(verb, "decrypt") == 0) { - method = "decrypt"; const char *ciphertext = arg1; + char *ct_buf = NULL; if (!ciphertext) { - ciphertext = read_stdin_line(); - if (!ciphertext) { - fprintf(stderr, "error: no ciphertext provided (pass as argument or pipe to stdin)\n"); - goto cleanup; - } + ct_buf = read_stdin_line(); + if (!ct_buf) { fprintf(stderr, "error: no ciphertext provided\n"); goto cleanup; } + ciphertext = ct_buf; } - params = cJSON_CreateArray(); - if (!params) { fprintf(stderr, "error: out of memory\n"); if (!arg1) free((char*)ciphertext); goto cleanup; } - cJSON_AddItemToArray(params, cJSON_CreateString(ciphertext)); - if (!arg1) free((char*)ciphertext); - - cJSON *opts = cJSON_CreateObject(); - if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; } - cJSON_AddStringToObject(opts, "algorithm", "otp"); - if (encoding) cJSON_AddStringToObject(opts, "encoding", encoding); - cJSON_AddItemToArray(params, opts); + rc = nostr_signer_otp_decrypt(signer, ciphertext, encoding, &result_str); + free(ct_buf); + if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; } + print_result_str(result_str); + rc = 0; } else if (strcmp(verb, "call") == 0) { - is_call_verb = 1; + /* Raw passthrough using the low-level client (shared with signer). */ if (!arg1) { fprintf(stderr, "error: call requires \n"); goto cleanup; } - method = arg1; - /* Params: from remaining argv or stdin */ + const char *method = arg1; + + cJSON *params = NULL; if (arg2) { - /* Use remaining argv as the params JSON */ - /* Reconstruct the JSON array string from remaining args */ size_t total = 0; for (int j = i - 1; j < argc; j++) { total += strlen(argv[j]) + 1; @@ -879,7 +813,6 @@ int main(int argc, char **argv) { goto cleanup; } } else { - /* Read from stdin */ char *line = read_stdin_line(); if (!line) { fprintf(stderr, "error: no params JSON on stdin\n"); @@ -892,53 +825,28 @@ int main(int argc, char **argv) { goto cleanup; } } + + cJSON *call_result = NULL; + if (nsigner_client_call(client, method, params, &call_result) != NOSTR_SUCCESS) { + fprintf(stderr, "error: %s\n", nsigner_client_last_error(client)); + goto cleanup; + } + if (call_result) { + char *json = cJSON_PrintUnformatted(call_result); + if (json) { printf("%s\n", json); free(json); } + cJSON_Delete(call_result); + } + rc = 0; } else { fprintf(stderr, "error: unknown verb: %s\n", verb); fprintf(stderr, "Try '%s --help' for usage.\n", prog); goto cleanup; } - /* ---- make the RPC call ---- */ - if (nsigner_client_call(client, method, params, &result) != NOSTR_SUCCESS) { - fprintf(stderr, "error: %s\n", nsigner_client_last_error(client)); - params = NULL; /* ownership transferred even on failure */ - goto cleanup; - } - params = NULL; /* ownership transferred */ - - /* ---- print result ---- */ - if (is_call_verb || strcmp(verb, "get-info") == 0) { - /* Raw JSON output for get_info and call */ - if (result) { - char *json = cJSON_PrintUnformatted(result); - if (json) { - printf("%s\n", json); - free(json); - } - } - rc = 0; - } else { - int prc = print_result(result, is_verify); - if (is_verify) { - rc = (prc == 0 || prc == 1) ? prc : 2; - } else { - rc = 0; - } - } - cleanup: - if (rc != 0 && params) { - /* If we still own params and there was an error, free it. - * nsigner_client_call takes ownership on success, so we only - * free params here if we never called nsigner_client_call. */ - cJSON_Delete(params); - } - cJSON_Delete(result); - if (client) { - nsigner_client_free(client); /* also closes/frees the transport */ - } else if (transport) { - transport->close(transport); - } + if (result_str) free(result_str); + if (result_obj) cJSON_Delete(result_obj); + if (signer) nostr_signer_free(signer); nostr_cleanup(); return rc; } diff --git a/plans/client_breaking_change_audit.md b/plans/client_breaking_change_audit.md new file mode 100644 index 0000000..464aa6b --- /dev/null +++ b/plans/client_breaking_change_audit.md @@ -0,0 +1,179 @@ +# Audit: n_signer Breaking Changes vs Client Repos + +## 1. The breaking changes made to n_signer + +Three changes on the n_signer wire protocol are breaking for every existing +client. All three are landed in `src/` and documented in `README.md` §4. + +### 1.1 Verb renames (legacy names removed) + +Source: [`plans/legacy_verb_aliases.md`](legacy_verb_aliases.md) — COMPLETED. + +| Old wire verb | New wire verb | +|--------------------|----------------------------| +| `sign_event` | `nostr_sign_event` | +| `mine_event` | `nostr_mine_event` | +| `nip04_encrypt` | `nostr_nip04_encrypt` | +| `nip04_decrypt` | `nostr_nip04_decrypt` | +| `nip44_encrypt` | `nostr_nip44_encrypt` | +| `nip44_decrypt` | `nostr_nip44_decrypt` | +| `get_public_key` (role branch) | `nostr_get_public_key` | + +The role-based `get_public_key` was split: algorithm-based stays +`get_public_key`; Nostr-protocol key selection is now `nostr_get_public_key`. +The old alias names are **gone** — no shim, no fallthrough. + +### 1.2 Selector model rewrite (nostr_index / index removed for nostr verbs) + +Source: [`plans/role_path_authorization.md`](role_path_authorization.md). + +- `nostr_index` selector → **removed**, rejected with error `2006 + nostr_index_deprecated` (see [`src/dispatcher.c`](../src/dispatcher.c:1815)). +- `index` on `nostr_*` verbs → **removed**, rejected with `2007 + index_deprecated`. +- The **only** accepted selector for `nostr_*` verbs is now `{"role":"", + "role_path":""}` sent **together**. Either field alone is + rejected: `2008 role_required` / `2009 path_required` + ([`README.md`](../README.md) §4.6). +- No backward compatibility. `--nostr-index` / `--index` on the client CLI are + removed; replaced by `--role` + `--path`. + +### 1.3 OTP encoding values changed + +`encrypt` / `decrypt` (algorithm `otp`) now take `encoding` = +`"ascii"` (ASCII-armored, default) or `"binary"` (base64 raw `.otp` blob) +([`src/dispatcher.c`](../src/dispatcher.c:1452), [`src/otp_pad.c`](../src/otp_pad.c:347)). + +Note: [`client/n_signer_client.c`](../client/n_signer_client.c:51) help text +still advertises `--encoding ` — that is a **stale doc string** +inside n_signer's own client and should be fixed to `ascii|binary`. + +--- + +## 2. Are these reflected in the nostr_core_lib repo? — NO + +`nostr_core_lib` is the shared client library that every C-based n_signer +client links against. It is **out of date** and will fail against current +n_signer. Specific gaps: + +### 2.1 Still emits the removed `nostr_index` selector + +[`nostr_core_lib/nostr_core/nostr_signer.c`](../../nostr_core_lib/nostr_core/nostr_signer.c:309) +`signer_remote_params_with_selector()` emits `{"nostr_index":N}` when set +(lines 320–327). n_signer now rejects this with `2006 nostr_index_deprecated`. + +The public API +[`nostr_signer_nsigner_set_nostr_index()`](../../nostr_core_lib/nostr_core/nostr_signer.c:858) +still exists and is the documented way to select a key — it is now a dead end. + +### 2.2 Sends `role` without `role_path` + +When `nostr_index` is not set, the same helper emits only `{"role":"..."}` +(line 341) with no `role_path`. n_signer now requires both and rejects +role-only with `2009 path_required`. + +The `nostr_signer_nsigner_*` factory constructors +([`nostr_signer.h`](../../nostr_core_lib/nostr_core/nostr_signer.h:51)) take a +single `const char* role` parameter — there is no way to pass a `role_path` +through the high-level API at all. + +### 2.3 `derive` (HMAC) path is half-broken + +[`nostr_signer.c`](../../nostr_core_lib/nostr_core/nostr_signer.c:560) builds +`{"algorithm":"secp256k1","index":N}` for the `derive` verb. The +algorithm-based `derive` verb still accepts `index`, so the `nostr_index` branch +works. But the `role`-only branch (line 563) sends `{"role":"..."}` with no +`index` — `derive` requires `index` and will reject it. + +### 2.4 Documentation is stale + +[`NSIGNER_INTEGRATION.md`](../../nostr_core_lib/nostr_core/NSIGNER_INTEGRATION.md:123) +still tells integrators to use `nostr_index` and `role`-only selectors, and +[`plans/nostr_core_lib_client_updates.md`](../../nostr_core_lib/plans/nostr_core_lib_client_updates.md) +proposes `nostr_index` support as the chosen design — both predate the +selector rewrite. + +### 2.5 What needs to change in nostr_core_lib + +1. Replace the `role`-only + `nostr_index` selector model with a combined + `role` + `role_path` selector. Concretely: change the `nostr_signer_nsigner_*` + constructors (or add new ones / a selector struct) to accept both a role + name and a full path. +2. Remove `nostr_signer_nsigner_set_nostr_index` (or repurpose it to set + `role` + `role_path` from an index by expanding the NIP-06 template + `m/44'/1237'/N'/0/0` client-side). +3. Update `signer_remote_params_with_selector` to always emit both `role` and + `role_path`. +4. Fix the `derive` remote path to always include `index`. +5. Update `NSIGNER_INTEGRATION.md`, `nostr_core_lib_client_updates.md`, and + `tests/nsigner_client_test.c` (which sends `nostr_get_public_key` with a + `nostr_index` selector at line 297). + +--- + +## 3. Repos in ~/lt/ that need client edits + +### Tier 1 — Direct n_signer wire clients (BROKEN now) + +These talk the n_signer JSON-RPC protocol directly and will fail against +current n_signer: + +| Repo | Files | Problem | +|------|-------|---------| +| **nostr_core_lib** | `nostr_core/nostr_signer.c`, `nostr_signer.h`, `nsigner_client.c`, `NSIGNER_INTEGRATION.md`, `tests/nsigner_client_test.c`, `examples/note_poster.c` | Emits removed `nostr_index`; sends `role` without `role_path`. Shared lib — fixing this fixes all C clients that link it. | +| **nostr_terminal** | `src/nsigner_client.c`, `include/nsigner_client.h`, `src/signer.c`, `src/menu_login.c`, `src/menu_profile.c`, `plans/n_signer_integration.md` | Has its own hand-rolled `nsigner_client` that sends `{"nostr_index":N}` ([`nsigner_client.c`](../../nostr_terminal/src/nsigner_client.c:617)). Selector struct is `has_nostr_index`/`nostr_index`/`role` with no `role_path`. Login menu prompts for "index" only. | +| **sovereign_browser** | `src/login_dialog.c`, `src/agent_login.c`, `src/key_store.c`, `src/key_store.h` | Uses `nostr_signer_nsigner_*` from nostr_core_lib + `nostr_signer_nsigner_set_nostr_index`. UI has a nostr_index spin button. Breaks via the lib, and the UI needs a role+path input. | +| **laantungir_website** | `scripts/publish_nostr.js`, `scripts/get_nsigner_pubkey.js` | Raw JSON-RPC over qrexec sending `{"nostr_index": N}` ([`publish_nostr.js`](../../laantungir_website/scripts/publish_nostr.js:103)). Will get `2006`. | + +### Tier 2 — Indirect (breaks once Tier 1 lib is fixed, or uses nostr_core_lib local signing only) + +| Repo | Status | Action | +|------|--------|--------| +| **n_signer** (this repo) | `client/n_signer_client.c` help text says `--encoding ` but server wants `ascii\|binary`; the client itself already uses `--role`+`--path` correctly per [`role_path_authorization.md`](role_path_authorization.md). | Fix the stale `--encoding` help string. | + +### Not affected (use local nostr_core_lib signing, not n_signer remote) + +These call `nostr_create_and_sign_event` / `nostr_signer_local` with a local +private key — they do not speak the n_signer wire protocol and are unaffected: + +- `open_wire` (local `sign_event` helper, not n_signer RPC) +- `raspberry_pi_zero_nostr` (local `nostr_create_and_sign_event`) +- `esp32_playground` (local `nostr_create_and_sign_event`) + +### Not affected (NIP-46 to arbitrary remote signers, not n_signer) + +These use NIP-46 method names (`sign_event`, `nip04_encrypt`, …) per the NIP-46 +spec, targeting generic remote signers / browser extensions — not n_signer's +renamed verbs. No change needed unless they specifically add an n_signer +backend: + +- `primal-web-app` (`src/lib/nip46/nip46.ts`) +- `super_ball` (`web/nostr.bundle.js`) +- `nips` (spec docs) + +--- + +## 4. Recommended remediation order + +1. **nostr_core_lib** first — it is the shared dependency. Introduce a + `role` + `role_path` selector (struct or new constructors), remove + `nostr_index` emission, fix `derive`, update tests + integration doc. +2. **sovereign_browser** — update login UI to collect role + path instead of + index; switch to the new nostr_core_lib API. +3. **nostr_terminal** — rewrite its hand-rolled `nsigner_client` selector to + `role` + `role_path`; update login/profile menus and the integration plan. +4. **laantungir_website** — switch the two JS scripts from `nostr_index` to + `role` + `role_path`. +5. **n_signer** — fix the stale `--encoding` help string in + `client/n_signer_client.c`. + +A Mermaid overview of the dependency order: + +```mermaid +flowchart LR + NS[n_signer wire changes] --> NCL[nostr_core_lib] + NCL --> SB[sovereign_browser] + NCL --> NT[nostr_terminal] + NS --> LW[laantungir_website] + NS --> NSC[n_signer client help text] +``` diff --git a/plans/nostr_core_lib_full_verb_coverage.md b/plans/nostr_core_lib_full_verb_coverage.md new file mode 100644 index 0000000..1138baa --- /dev/null +++ b/plans/nostr_core_lib_full_verb_coverage.md @@ -0,0 +1,197 @@ +# Analysis: Does nostr_core_lib Fully Cover the n_signer Client Verb Surface? + +## Question + +> When we wrote `n_signer_client` in this project, did we utilize +> `nostr_core_lib` to the fullest? If a client wants to interface with +> nsigner, they can use the CLI, or write C utilizing the functions in +> `nostr_core_lib`. Did we fully put into nostr_core_lib the functionality +> of our client? I have a suspicion we wrote the client and didn't add back +> into nostr_core_lib. + +## Answer: Your suspicion is correct — the library covers less than half the verb surface. + +The CLI ([`client/n_signer_client.c`](../client/n_signer_client.c)) exposes +**16 verbs**. The `nostr_core_lib` high-level `nostr_signer_t` API +([`nostr_signer.h`](../../nostr_core_lib/nostr_core/nostr_signer.h)) exposes +only **6** of them. The CLI hand-builds cJSON params and calls the low-level +`nsigner_client_call()` for the other 10 verbs — none of which have a +high-level library wrapper. + +## Verb-by-verb coverage + +| n_signer wire verb | CLI verb | `nostr_signer_t` high-level API | Status | +|--------------------|----------|---------------------------------|--------| +| `get_info` | `get-info` | — | **Missing** | +| `get_public_key` (algorithm) | `get-public-key -a ` | — | **Missing** | +| `nostr_get_public_key` | `get-public-key --role --path` | `nostr_signer_get_public_key()` | Covered | +| `nostr_sign_event` | `sign-event` | `nostr_signer_sign_event()` | Covered | +| `nostr_mine_event` | `mine-event` | — | **Missing** | +| `nostr_nip04_encrypt` | `nip04-encrypt` | `nostr_signer_nip04_encrypt()` | Covered | +| `nostr_nip04_decrypt` | `nip04-decrypt` | `nostr_signer_nip04_decrypt()` | Covered | +| `nostr_nip44_encrypt` | `nip44-encrypt` | `nostr_signer_nip44_encrypt()` | Covered | +| `nostr_nip44_decrypt` | `nip44-decrypt` | `nostr_signer_nip44_decrypt()` | Covered | +| `sign` | `sign` | — | **Missing** | +| `verify` | `verify` | — | **Missing** | +| `derive` | `derive` | `nostr_signer_derive_hmac()` | **Partial** (lib wraps it as HMAC-only, hardcodes `algorithm:"secp256k1"`; the raw `derive` verb is not exposed) | +| `encapsulate` | `encapsulate` | — | **Missing** | +| `decapsulate` | `decapsulate` | — | **Missing** | +| `derive_shared_secret` | `derive-shared-secret` | — | **Missing** | +| `encrypt` (OTP) | `encrypt` | — | **Missing** | +| `decrypt` (OTP) | `decrypt` | — | **Missing** | +| (raw passthrough) | `call ` | `nsigner_client_call()` (low-level) | Covered at low level | + +**Score: 6 covered, 1 partial, 10 missing.** + +## What the CLI does that the library doesn't + +The CLI is essentially a thin argv-to-JSON-RPC mapper. For each verb it: +1. Builds a `cJSON` params array with the positional args + options object. +2. Calls `nsigner_client_call(client, method, params, &result)`. +3. Prints the result. + +This is exactly the kind of per-verb glue that belongs in the library, not +duplicated in every client. Today a C client that wants to call `sign` with +`ed25519` must either: +- drop down to the low-level `nsigner_client_call` and hand-build cJSON (what + the CLI does), or +- not use the library for that verb at all. + +## Two layers in nostr_core_lib today + +The library has two layers, and the gap is in the **high-level** layer: + +1. **Low-level** ([`nsigner_client.h`](../../nostr_core_lib/nostr_core/nsigner_client.h)): + `nsigner_client_call(client, method, params, &result)` — generic + JSON-RPC. This covers *everything* but forces the caller to build cJSON + params by hand and parse cJSON results by hand. The CLI uses this layer + exclusively. + +2. **High-level** ([`nostr_signer.h`](../../nostr_core_lib/nostr_core/nostr_signer.h)): + `nostr_signer_t` with typed verbs that take C strings/bytes and return + C strings/bytes. This is the layer a C client *wants* to use. It only + covers the 6 Nostr verbs + `derive_hmac`. + +## What's missing and where it should go + +The high-level `nostr_signer_t` API should gain typed wrappers for the +algorithm-based verbs. Proposed additions (all on `nostr_signer_t`, remote +backend routes to `nsigner_client_call` with the right method+params): + +### Metadata +```c +int nostr_signer_get_info(nostr_signer_t* signer, cJSON** info_out); +``` + +### Algorithm-based key/sign/verify (the `algorithm` + `index` selector) +```c +int nostr_signer_get_public_key_alg(nostr_signer_t* signer, + const char* algorithm, int index, + char** pubkey_hex_out); + +int nostr_signer_sign(nostr_signer_t* signer, + const char* algorithm, int index, + const char* scheme, /* "schnorr"|"ecdsa"|NULL */ + const unsigned char* msg, size_t msg_len, + char** sig_hex_out); + +int nostr_signer_verify(nostr_signer_t* signer, + const char* algorithm, int index, + const char* scheme, + const unsigned char* msg, size_t msg_len, + const unsigned char* sig, size_t sig_len, + int* valid_out); +``` + +### Post-quantum KEM +```c +int nostr_signer_encapsulate(nostr_signer_t* signer, + const char* peer_pubkey_hex, + char** ciphertext_hex_out, + char** shared_secret_hex_out); + +int nostr_signer_decapsulate(nostr_signer_t* signer, int index, + const char* ciphertext_hex, + char** shared_secret_hex_out); +``` + +### X25519 key agreement +```c +int nostr_signer_derive_shared_secret(nostr_signer_t* signer, int index, + const char* peer_pubkey_hex, + char** shared_secret_hex_out); +``` + +### OTP one-time pad +```c +int nostr_signer_otp_encrypt(nostr_signer_t* signer, + const char* plaintext_b64, + const char* encoding, /* "ascii"|"binary"|NULL */ + char** ciphertext_out); + +int nostr_signer_otp_decrypt(nostr_signer_t* signer, + const char* ciphertext, + const char* encoding, + char** plaintext_out); +``` + +### Nostr mine-event (POW) +```c +int nostr_signer_mine_event(nostr_signer_t* signer, + const cJSON* unsigned_event, + int difficulty, int timeout_sec, int threads, + cJSON** signed_event_out); +``` + +### Raw derive (the lib's `derive_hmac` is a specialization; expose the general verb) +The existing `nostr_signer_derive_hmac` is fine as a convenience; no change +needed, but the raw `derive` verb is already reachable through it. + +## Impact on the CLI + +If these wrappers are added to `nostr_core_lib`, the CLI +([`client/n_signer_client.c`](../client/n_signer_client.c)) shrinks +dramatically. Today it is ~945 lines, most of which is the per-verb +`cJSON_CreateArray` / `cJSON_AddStringToObject` / `cJSON_AddNumberToObject` +boilerplate. With the wrappers, each verb handler becomes a 3–5 line call to +the library + `print_result`. The CLI becomes what you envisioned: mostly +interface code (argv parsing + result printing) with the real logic in the +library. + +## Impact on other clients + +Every C client that currently hand-builds JSON-RPC for the missing verbs +(`nostr_terminal`'s `nsigner_client.c`, `sovereign_browser`, future embedded +clients) would get typed wrappers for free and could stop hand-rolling cJSON. + +## Recommendation + +1. **Add the 10 missing high-level wrappers** to `nostr_signer.h` / + `nostr_signer.c` in `nostr_core_lib` (remote backend only; the local + backend can return `NOSTR_ERROR_NOT_SUPPORTED` for the algorithm-based + verbs that are inherently signer-side). +2. **Refactor `n_signer_client.c`** to call the wrappers instead of + hand-building cJSON. This validates the API (the CLI becomes the first + consumer) and shrinks the client to mostly argv parsing + printing. +3. **Add tests** for the new wrappers in + [`nostr_core_lib/tests/nsigner_client_test.c`](../../nostr_core_lib/tests/nsigner_client_test.c) + using the mock-transport pattern already there. + +A Mermaid view of the target architecture: + +```mermaid +flowchart TD + CLI[n_signer_client CLI
argv parse + print] + LIB[nostr_core_lib
nostr_signer_t high-level
16 typed verbs] + LOW[nostr_core_lib
nsigner_client_call
low-level JSON-RPC] + NS[n_signer process
wire protocol] + + CLI --> LIB + LIB --> LOW + LOW -->|framed JSON-RPC| NS + + OtherC[other C clients
sovereign_browser
nostr_terminal] --> LIB +``` + +Today the `CLI --> LOW` arrow bypasses `LIB` for 10 of 16 verbs. The goal is +to make `CLI --> LIB` the only path.