From 024b490ac13f586729eb8572e434908fdf0a569f Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Tue, 28 Jul 2026 06:45:05 -0400 Subject: [PATCH] Add nostr_signer_derive_hmac() (HMAC-SHA256 via local + nsigner derive verb) and ttyUSB serial enumeration Rescued from uncommitted work in sovereign_browser/nostr_core_lib: - nostr_signer_derive_hmac(): deterministic MAC over caller data using the signer's secp256k1 private key. Local backend computes HMAC-SHA256 directly; remote nsigner backend calls the 'derive' verb (algorithm:'secp256k1'). - nsigner_transport_list_serial(): enumerate /dev/ttyUSB* (FTDI/CH340/CP210x/ PL2303 USB-serial adapters) in addition to /dev/ttyACM* CDC-ACM devices. - tests/nsigner_client_test.c: local derive_hmac reference-match test. --- nostr_core/nostr_signer.c | 107 +++++++++++++++++++++++++++++++++ nostr_core/nostr_signer.h | 10 +++ nostr_core/nsigner_transport.c | 14 ++++- tests/nsigner_client_test.c | 62 +++++++++++++++++++ 4 files changed, 192 insertions(+), 1 deletion(-) diff --git a/nostr_core/nostr_signer.c b/nostr_core/nostr_signer.c index f1481250..3bddbfe2 100644 --- a/nostr_core/nostr_signer.c +++ b/nostr_core/nostr_signer.c @@ -284,6 +284,26 @@ static int signer_local_nip44_decrypt(nostr_signer_t* signer, return NOSTR_SUCCESS; } +static int signer_local_derive_hmac(nostr_signer_t* signer, + const char* data, + char out_digest_hex[65]) { + unsigned char mac[32]; + + if (!signer || !data || !out_digest_hex) { + return NOSTR_ERROR_INVALID_INPUT; + } + + if (nostr_hmac_sha256(signer->u.local.private_key, 32, + (const unsigned char*)data, strlen(data), + mac) != 0) { + return NOSTR_ERROR_CRYPTO_FAILED; + } + + nostr_bytes_to_hex(mac, 32, out_digest_hex); + memset(mac, 0, sizeof(mac)); + return NOSTR_SUCCESS; +} + #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) { @@ -505,6 +525,74 @@ static int signer_remote_nip44_decrypt(nostr_signer_t* signer, return signer_remote_encrypt_decrypt(signer, "nostr_nip44_decrypt", peer_pubkey_hex, ciphertext, plaintext_out); } +static int signer_remote_derive_hmac(nostr_signer_t* signer, + const char* data, + char out_digest_hex[65]) { + cJSON* params; + cJSON* opts = NULL; + cJSON* result = NULL; + cJSON* parsed = NULL; + cJSON* digest_item = NULL; + const char* digest_str = NULL; + int rc; + + if (!signer || !data || !out_digest_hex) { + return NOSTR_ERROR_INVALID_INPUT; + } + + params = cJSON_CreateArray(); + if (params == NULL) { + return NOSTR_ERROR_MEMORY_FAILED; + } + 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). */ + 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); + } + cJSON_AddItemToArray(params, opts); + + rc = nsigner_client_call(signer->u.remote.client, "derive", params, &result); + if (rc != NOSTR_SUCCESS) { + return rc; + } + + /* result is a JSON string: {"algorithm":"secp256k1","key_id":"...","digest":"<64hex>"} */ + if (!cJSON_IsString(result) || result->valuestring == NULL) { + cJSON_Delete(result); + return NOSTR_ERROR_NIP46_INVALID_RESPONSE; + } + parsed = cJSON_Parse(result->valuestring); + cJSON_Delete(result); + if (parsed == NULL) { + return NOSTR_ERROR_NIP46_INVALID_RESPONSE; + } + digest_item = cJSON_GetObjectItemCaseSensitive(parsed, "digest"); + if (!cJSON_IsString(digest_item) || digest_item->valuestring == NULL || + strlen(digest_item->valuestring) != 64) { + cJSON_Delete(parsed); + return NOSTR_ERROR_NIP46_INVALID_RESPONSE; + } + digest_str = digest_item->valuestring; + memcpy(out_digest_hex, digest_str, 64); + out_digest_hex[64] = '\0'; + cJSON_Delete(parsed); + return NOSTR_SUCCESS; +} + #endif /* NOSTR_ENABLE_NSIGNER_CLIENT */ nostr_signer_t* nostr_signer_local(const unsigned char private_key[32]) { @@ -659,6 +747,25 @@ int nostr_signer_nip44_decrypt(nostr_signer_t* signer, return NOSTR_ERROR_INVALID_INPUT; } +int nostr_signer_derive_hmac(nostr_signer_t* signer, + const char* data, + char out_digest_hex[65]) { + if (!signer || !data || !out_digest_hex) { + return NOSTR_ERROR_INVALID_INPUT; + } + + if (signer->backend == NOSTR_SIGNER_BACKEND_LOCAL) { + return signer_local_derive_hmac(signer, data, out_digest_hex); + } +#if defined(NOSTR_ENABLE_NSIGNER_CLIENT) + if (signer->backend == NOSTR_SIGNER_BACKEND_NSIGNER_REMOTE) { + return signer_remote_derive_hmac(signer, data, out_digest_hex); + } +#endif + + return NOSTR_ERROR_INVALID_INPUT; +} + #if defined(NOSTR_ENABLE_NSIGNER_CLIENT) static nostr_signer_t* nostr_signer_nsigner_from_transport(nsigner_transport_t* transport, const char* role) { nsigner_client_t* client = NULL; diff --git a/nostr_core/nostr_signer.h b/nostr_core/nostr_signer.h index c46e9ba8..a7937b13 100644 --- a/nostr_core/nostr_signer.h +++ b/nostr_core/nostr_signer.h @@ -19,6 +19,16 @@ void nostr_signer_free(nostr_signer_t* signer); int nostr_signer_get_public_key(nostr_signer_t* signer, char out_pubkey_hex[65]); int nostr_signer_sign_event(nostr_signer_t* signer, const cJSON* unsigned_event, cJSON** signed_event_out); +/* Derive a deterministic MAC over caller-supplied data using the signer's + * secp256k1 private key: out = HMAC-SHA256(privkey, data). + * data is a NUL-terminated UTF-8 string. out_digest_hex receives 64 lowercase + * hex chars + NUL. For the local backend this computes directly; for the + * nsigner remote backend it calls the "derive" verb (algorithm:"secp256k1"). + * Returns NOSTR_SUCCESS or an error code. */ +int nostr_signer_derive_hmac(nostr_signer_t* signer, + const char* data, + char out_digest_hex[65]); + /* Encryption verbs (for parity with upcoming remote backends) */ int nostr_signer_nip04_encrypt(nostr_signer_t* signer, const char* peer_pubkey_hex, diff --git a/nostr_core/nsigner_transport.c b/nostr_core/nsigner_transport.c index 37ada831..d681a1c8 100644 --- a/nostr_core/nsigner_transport.c +++ b/nostr_core/nsigner_transport.c @@ -1021,8 +1021,20 @@ int nsigner_transport_list_serial(char paths[][64], int max_paths) { while ((ent = readdir(dir)) != NULL) { int n; + int is_serial = 0; - if (strncmp(ent->d_name, "ttyACM", 6) != 0) { + /* USB CDC-ACM devices (e.g. n_signer firmware using TinyUSB CDC). */ + if (strncmp(ent->d_name, "ttyACM", 6) == 0) { + is_serial = 1; + } + /* USB-serial adapters (FTDI / CH340 / CP210x / PL2303). These show up + * as /dev/ttyUSB* and speak the same 115200 8N1 framing as CDC-ACM, + * so they are valid n_signer serial transports too. */ + if (strncmp(ent->d_name, "ttyUSB", 6) == 0) { + is_serial = 1; + } + + if (!is_serial) { continue; } diff --git a/tests/nsigner_client_test.c b/tests/nsigner_client_test.c index 8ee6394c..7bef0fc1 100644 --- a/tests/nsigner_client_test.c +++ b/tests/nsigner_client_test.c @@ -11,6 +11,7 @@ #include "../nostr_core/nsigner_client.h" #include "../nostr_core/utils.h" #include "../cjson/cJSON.h" +#include "../nostr_core/nostr_signer.h" static int test_count = 0; static int passed_count = 0; @@ -332,6 +333,66 @@ cleanup: return ok; } +/* Local-signer derive_hmac: HMAC-SHA256(privkey, data) via the high-level API + * must match nostr_hmac_sha256 computed directly with the same privkey. */ +static int test_local_derive_hmac_matches_reference(void) { + unsigned char privkey[32]; + nostr_signer_t* signer = NULL; + char got_hex[65]; + unsigned char ref_mac[32]; + char ref_hex[65]; + int ok = 0; + int i; + + for (i = 0; i < 32; i++) { + privkey[i] = (unsigned char)(i + 1); + } + + signer = nostr_signer_local(privkey); + if (signer == NULL) { + return 0; + } + + if (nostr_signer_derive_hmac(signer, "sovereign-browser/bookmarks-folder-id-v1:Work/Projects", got_hex) != NOSTR_SUCCESS) { + nostr_signer_free(signer); + return 0; + } + + if (nostr_hmac_sha256(privkey, 32, + (const unsigned char*)"sovereign-browser/bookmarks-folder-id-v1:Work/Projects", + strlen("sovereign-browser/bookmarks-folder-id-v1:Work/Projects"), + ref_mac) != 0) { + nostr_signer_free(signer); + return 0; + } + nostr_bytes_to_hex(ref_mac, 32, ref_hex); + + ok = (strlen(got_hex) == 64 && strcmp(got_hex, ref_hex) == 0); + + /* Determinism: same input -> same digest. */ + { + char got2_hex[65]; + if (nostr_signer_derive_hmac(signer, "sovereign-browser/bookmarks-folder-id-v1:Work/Projects", got2_hex) == NOSTR_SUCCESS) { + ok = ok && (strcmp(got_hex, got2_hex) == 0); + } else { + ok = 0; + } + } + + /* Opaqueness: different input -> different digest. */ + { + char other_hex[65]; + if (nostr_signer_derive_hmac(signer, "sovereign-browser/bookmarks-folder-id-v1:Personal", other_hex) == NOSTR_SUCCESS) { + ok = ok && (strcmp(got_hex, other_hex) != 0); + } else { + ok = 0; + } + } + + nostr_signer_free(signer); + return ok; +} + int main(void) { #if !defined(NOSTR_ENABLE_NSIGNER_CLIENT) printf("nsigner client disabled in this build; skipping\n"); @@ -342,6 +403,7 @@ int main(void) { run_result("auth body hash matches sha256(compact params)", test_body_hash_matches_sha256_compact_params()); run_result("/proc/net/unix discovery parser no-crash", test_unix_discovery_no_crash()); run_result("fds transport client round-trip over framed fd pair", test_fds_transport_round_trip_via_client()); + run_result("local signer derive_hmac matches reference HMAC-SHA256", test_local_derive_hmac_matches_reference()); printf("\nTotal: %d Passed: %d Failed: %d\n", test_count, passed_count, test_count - passed_count); return (test_count == passed_count) ? 0 : 1;