From fdf662f8f1a1a3b38fe4d251982fffab8e9bf664 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 17 May 2026 05:27:06 +0530 Subject: Phase 7: MCP handler (25 tests), NIP-04 encrypt/decrypt (15 tests), CVM server skeleton - mcp_handler.c/h: 4 tools (get_config, set_config, get_balance, wallet_send) - nip04.c/h: AES-256-CBC + ECDH with 0x02 compressed pubkey prefix - Fixed IV copy bug: mbedTLS AES-CBC modifies IV in-place - Base64 encode/decode for ciphertext transport - PKCS7 padding - cvm_server.c/h: Nostr DM listener with FreeRTOS task - config: cvm_enabled, cvm_relays fields - 156 total tests passing across 10 test binaries --- main/CMakeLists.txt | 3 + main/config.c | 12 +++ main/config.h | 3 + main/cvm_server.c | 238 ++++++++++++++++++++++++++++++++++++++++++ main/cvm_server.h | 10 ++ main/mcp_handler.c | 175 +++++++++++++++++++++++++++++++ main/mcp_handler.h | 36 +++++++ main/nip04.c | 201 +++++++++++++++++++++++++++++++++++ main/nip04.h | 13 +++ main/tollgate_main.c | 8 ++ tests/unit/Makefile | 9 +- tests/unit/stubs/esp_log.h | 1 + tests/unit/stubs/esp_system.h | 10 ++ tests/unit/test_mcp_handler.c | 148 ++++++++++++++++++++++++++ tests/unit/test_nip04.c | 107 +++++++++++++++++++ 15 files changed, 973 insertions(+), 1 deletion(-) create mode 100644 main/cvm_server.c create mode 100644 main/cvm_server.h create mode 100644 main/mcp_handler.c create mode 100644 main/mcp_handler.h create mode 100644 main/nip04.c create mode 100644 main/nip04.h create mode 100644 tests/unit/test_mcp_handler.c create mode 100644 tests/unit/test_nip04.c diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index c2aaeb2..91748f2 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -13,6 +13,9 @@ idf_component_register(SRCS "tollgate_main.c" "tollgate_client.c" "lnurl_pay.c" "lightning_payout.c" + "nip04.c" + "mcp_handler.c" + "cvm_server.c" INCLUDE_DIRS "." REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server lwip json esp_http_client mbedtls esp-tls log spiffs diff --git a/main/config.c b/main/config.c index 3e01efc..2ee8637 100644 --- a/main/config.c +++ b/main/config.c @@ -33,6 +33,8 @@ esp_err_t tollgate_config_init(void) g_config.payout.check_interval_s = 60; g_config.payout.recipient_count = 0; g_config.payout.mint_count = 0; + g_config.cvm_enabled = false; + strncpy(g_config.cvm_relays, "wss://relay.damus.io", sizeof(g_config.cvm_relays) - 1); esp_vfs_spiffs_conf_t conf = { .base_path = "/spiffs", @@ -235,6 +237,16 @@ esp_err_t tollgate_config_init(void) } } + cJSON *cvm = cJSON_GetObjectItem(root, "cvm"); + if (cvm && cJSON_IsObject(cvm)) { + cJSON *cvm_en = cJSON_GetObjectItem(cvm, "enabled"); + if (cvm_en && cJSON_IsBool(cvm_en)) g_config.cvm_enabled = cJSON_IsTrue(cvm_en); + cJSON *cvm_relays = cJSON_GetObjectItem(cvm, "relays"); + if (cvm_relays && cJSON_IsString(cvm_relays)) { + strncpy(g_config.cvm_relays, cvm_relays->valuestring, sizeof(g_config.cvm_relays) - 1); + } + } + if (g_config.payout.mint_count == 0 && g_config.mint_url[0] != '\0') { strncpy(g_config.payout.mints[0].url, g_config.mint_url, sizeof(g_config.payout.mints[0].url) - 1); diff --git a/main/config.h b/main/config.h index 86b5e1a..fa4d95c 100644 --- a/main/config.h +++ b/main/config.h @@ -60,6 +60,9 @@ typedef struct { int client_retry_interval_ms; payout_config_t payout; + + bool cvm_enabled; + char cvm_relays[256]; } tollgate_config_t; void tollgate_config_derive_unique(tollgate_config_t *cfg); diff --git a/main/cvm_server.c b/main/cvm_server.c new file mode 100644 index 0000000..5addd88 --- /dev/null +++ b/main/cvm_server.c @@ -0,0 +1,238 @@ +#include "cvm_server.h" +#include "mcp_handler.h" +#include "nip04.h" +#include "identity.h" +#include "config.h" +#include "nucula_wallet.h" +#include "cJSON.h" +#include "esp_log.h" +#include "esp_http_client.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include +#include + +static const char *TAG = "cvm_server"; + +static bool g_running = false; +static TaskHandle_t g_task = NULL; + +static const char *DEFAULT_RELAY = "wss://relay.damus.io"; + +static char *fetch_relays(void) +{ + const tollgate_config_t *cfg = tollgate_config_get(); + if (cfg && cfg->cvm_relays[0]) { + return cfg->cvm_relays; + } + return (char *)DEFAULT_RELAY; +} + +static char *http_get(const char *url, int timeout_ms) +{ + char *buf = malloc(8192); + if (!buf) return NULL; + int total = 0; + + esp_http_client_config_t config = { + .url = url, + .method = HTTP_METHOD_GET, + .timeout_ms = timeout_ms, + }; + esp_http_client_handle_t client = esp_http_client_init(&config); + if (!client) { free(buf); return NULL; } + + esp_err_t err = esp_http_client_open(client, 0); + if (err != ESP_OK) { + esp_http_client_cleanup(client); + free(buf); + return NULL; + } + + int content_length = esp_http_client_fetch_headers(client); + int max_read = content_length > 0 ? content_length : 8191; + + while (total < max_read) { + int n = esp_http_client_read(client, buf + total, max_read - total); + if (n <= 0) break; + total += n; + } + buf[total] = '\0'; + esp_http_client_cleanup(client); + return buf; +} + +static cJSON *build_filter(const char *npub) +{ + cJSON *filter = cJSON_CreateObject(); + cJSON *kinds = cJSON_CreateArray(); + cJSON_AddItemToArray(kinds, cJSON_CreateNumber(4)); + cJSON_AddItemToObject(filter, "kinds", kinds); + cJSON_AddStringToObject(filter, "#p", npub); + cJSON_AddNumberToObject(filter, "limit", 10); + return filter; +} + +static cJSON *build_subscription(const char *npub) +{ + cJSON *sub = cJSON_CreateArray(); + cJSON_AddItemToArray(sub, cJSON_CreateString("REQ")); + cJSON_AddItemToArray(sub, cJSON_CreateString("cvm_sub_01")); + cJSON_AddItemToArray(sub, build_filter(npub)); + return sub; +} + +static void process_dm(const char *sender_pubkey, const char *encrypted_content) +{ + const tollgate_identity_t *id = identity_get(); + if (!id || !id->initialized) { + ESP_LOGE(TAG, "Identity not initialized"); + return; + } + + uint8_t sender_pk[64]; + for (int i = 0; i < 64; i++) { + char hex[3] = { sender_pubkey[i*2], sender_pubkey[i*2+1], 0 }; + sender_pk[i] = (uint8_t)strtol(hex, NULL, 16); + } + + char plaintext[2048]; + int pt_len = nip04_decrypt(id->nsec, sender_pk, encrypted_content, plaintext, sizeof(plaintext)); + if (pt_len < 0) { + ESP_LOGE(TAG, "Failed to decrypt DM from %.8s", sender_pubkey); + return; + } + + ESP_LOGI(TAG, "Decrypted DM from %.8s: %s", sender_pubkey, plaintext); + + cJSON *msg = cJSON_Parse(plaintext); + if (!msg) { + ESP_LOGE(TAG, "Invalid JSON in DM"); + return; + } + + cJSON *method = cJSON_GetObjectItem(msg, "method"); + cJSON *params = cJSON_GetObjectItem(msg, "params"); + if (!method || !cJSON_IsString(method)) { + cJSON_Delete(msg); + ESP_LOGE(TAG, "Missing 'method' in CVM request"); + return; + } + + mcp_request_t req = {0}; + req.tool = mcp_parse_tool(method->valuestring); + strncpy(req.method, method->valuestring, sizeof(req.method) - 1); + if (params && cJSON_IsString(params)) { + strncpy(req.params_json, params->valuestring, sizeof(req.params_json) - 1); + } else if (params) { + char *pjson = cJSON_PrintUnformatted(params); + strncpy(req.params_json, pjson, sizeof(req.params_json) - 1); + cJSON_free(pjson); + } + + mcp_response_t resp = mcp_dispatch(&req); + cJSON_Delete(msg); + + cJSON *response_msg = cJSON_CreateObject(); + if (resp.success) { + cJSON_AddStringToObject(response_msg, "status", "ok"); + cJSON_AddItemToObject(response_msg, "result", cJSON_Parse(resp.result_json)); + } else { + cJSON_AddStringToObject(response_msg, "status", "error"); + cJSON_AddStringToObject(response_msg, "error", resp.error); + } + + char *response_str = cJSON_PrintUnformatted(response_msg); + cJSON_Delete(response_msg); + + uint8_t response_ct[4096]; + size_t ct_len = 0; + nip04_encrypt(id->nsec, sender_pk, response_str, response_ct, &ct_len); + free(response_str); + + ESP_LOGI(TAG, "CVM response prepared (%zu bytes encrypted), would send to %.8s", ct_len, sender_pubkey); +} + +static void parse_nostr_events(const char *data) +{ + cJSON *arr = cJSON_Parse(data); + if (!arr || !cJSON_IsArray(arr)) { + if (arr) cJSON_Delete(arr); + return; + } + + cJSON *item = NULL; + cJSON_ArrayForEach(item, arr) { + if (!cJSON_IsArray(item)) continue; + int arr_size = cJSON_GetArraySize(item); + if (arr_size < 3) continue; + + cJSON *cmd = cJSON_GetArrayItem(item, 0); + if (!cmd || !cJSON_IsString(cmd) || strcmp(cmd->valuestring, "EVENT") != 0) continue; + + cJSON *event = cJSON_GetArrayItem(item, 2); + if (!event) continue; + + cJSON *kind = cJSON_GetObjectItem(event, "kind"); + if (!kind || kind->valueint != 4) continue; + + cJSON *pubkey = cJSON_GetObjectItem(event, "pubkey"); + cJSON *content = cJSON_GetObjectItem(event, "content"); + if (pubkey && content) { + process_dm(pubkey->valuestring, content->valuestring); + } + } + cJSON_Delete(arr); +} + +static void cvm_task(void *arg) +{ + const tollgate_identity_t *id = identity_get(); + if (!id || !id->initialized) { + ESP_LOGE(TAG, "Cannot start: identity not initialized"); + vTaskDelete(NULL); + return; + } + + char *relays = fetch_relays(); + ESP_LOGI(TAG, "CVM server started, relays: %s", relays); + + while (g_running) { + ESP_LOGI(TAG, "Polling for DMs..."); + + cJSON *sub = build_subscription(id->npub_hex); + char *sub_json = cJSON_PrintUnformatted(sub); + cJSON_Delete(sub); + + char url[256]; + snprintf(url, sizeof(url), "%s/cvm_poll", relays); + free(sub_json); + + vTaskDelay(pdMS_TO_TICKS(30000)); + } + + ESP_LOGI(TAG, "CVM server stopped"); + vTaskDelete(NULL); +} + +esp_err_t cvm_server_init(void) +{ + ESP_LOGI(TAG, "CVM server initialized"); + return ESP_OK; +} + +void cvm_server_start(void) +{ + if (g_running) return; + g_running = true; + xTaskCreate(cvm_task, "cvm_server", 8192, NULL, 5, &g_task); +} + +void cvm_server_stop(void) +{ + g_running = false; + if (g_task) { + vTaskDelay(pdMS_TO_TICKS(500)); + g_task = NULL; + } +} diff --git a/main/cvm_server.h b/main/cvm_server.h new file mode 100644 index 0000000..d336514 --- /dev/null +++ b/main/cvm_server.h @@ -0,0 +1,10 @@ +#ifndef CVM_SERVER_H +#define CVM_SERVER_H + +#include "esp_err.h" + +esp_err_t cvm_server_init(void); +void cvm_server_start(void); +void cvm_server_stop(void); + +#endif diff --git a/main/mcp_handler.c b/main/mcp_handler.c new file mode 100644 index 0000000..f40c1bd --- /dev/null +++ b/main/mcp_handler.c @@ -0,0 +1,175 @@ +#include "mcp_handler.h" +#include "config.h" +#include "nucula_wallet.h" +#include "cJSON.h" +#include +#include + +static const char *TAG = "mcp_handler"; + +mcp_tool_t mcp_parse_tool(const char *method) +{ + if (!method) return MCP_TOOL_UNKNOWN; + if (strcmp(method, "get_config") == 0) return MCP_TOOL_GET_CONFIG; + if (strcmp(method, "set_config") == 0) return MCP_TOOL_SET_CONFIG; + if (strcmp(method, "get_balance") == 0) return MCP_TOOL_GET_BALANCE; + if (strcmp(method, "wallet_send") == 0) return MCP_TOOL_WALLET_SEND; + return MCP_TOOL_UNKNOWN; +} + +mcp_response_t mcp_handle_get_config(void) +{ + mcp_response_t resp = {0}; + const tollgate_config_t *cfg = tollgate_config_get(); + if (!cfg) { + resp.success = false; + snprintf(resp.error, sizeof(resp.error), "Config not loaded"); + return resp; + } + + cJSON *root = cJSON_CreateObject(); + cJSON_AddStringToObject(root, "ssid", cfg->ap_ssid); + cJSON_AddStringToObject(root, "metric", cfg->metric); + cJSON_AddNumberToObject(root, "price_per_step", cfg->price_per_step); + cJSON_AddNumberToObject(root, "step_size_ms", cfg->step_size_ms); + cJSON_AddNumberToObject(root, "step_size_bytes", cfg->step_size_bytes); + cJSON_AddStringToObject(root, "mint_url", cfg->mint_url); + cJSON_AddBoolToObject(root, "client_enabled", cfg->client_enabled); + cJSON_AddBoolToObject(root, "payout_enabled", cfg->payout.enabled); + cJSON_AddStringToObject(root, "wifi_ssid", + cfg->network_count > 0 ? cfg->networks[0].ssid : ""); + + char *json = cJSON_PrintUnformatted(root); + snprintf(resp.result_json, sizeof(resp.result_json), "%s", json); + cJSON_free(json); + cJSON_Delete(root); + resp.success = true; + return resp; +} + +mcp_response_t mcp_handle_set_config(const char *params_json) +{ + mcp_response_t resp = {0}; + cJSON *root = cJSON_Parse(params_json); + if (!root) { + resp.success = false; + snprintf(resp.error, sizeof(resp.error), "Invalid JSON params"); + return resp; + } + + tollgate_config_t *cfg = (tollgate_config_t *)tollgate_config_get(); + if (!cfg) { + cJSON_Delete(root); + resp.success = false; + snprintf(resp.error, sizeof(resp.error), "Config not loaded"); + return resp; + } + + cJSON *item; + item = cJSON_GetObjectItem(root, "price_per_step"); + if (item && cJSON_IsNumber(item)) cfg->price_per_step = item->valueint; + item = cJSON_GetObjectItem(root, "step_size_ms"); + if (item && cJSON_IsNumber(item)) cfg->step_size_ms = item->valueint; + item = cJSON_GetObjectItem(root, "step_size_bytes"); + if (item && cJSON_IsNumber(item)) cfg->step_size_bytes = item->valueint; + item = cJSON_GetObjectItem(root, "client_enabled"); + if (item && cJSON_IsBool(item)) cfg->client_enabled = cJSON_IsTrue(item); + item = cJSON_GetObjectItem(root, "payout_enabled"); + if (item && cJSON_IsBool(item)) cfg->payout.enabled = cJSON_IsTrue(item); + item = cJSON_GetObjectItem(root, "metric"); + if (item && cJSON_IsString(item)) { + strncpy(cfg->metric, item->valuestring, sizeof(cfg->metric) - 1); + } + + cJSON_Delete(root); + resp.success = true; + snprintf(resp.result_json, sizeof(resp.result_json), "{\"status\":\"ok\"}"); + return resp; +} + +mcp_response_t mcp_handle_get_balance(void) +{ + mcp_response_t resp = {0}; + uint64_t balance = nucula_wallet_balance(); + int proof_count = nucula_wallet_proof_count(); + + cJSON *root = cJSON_CreateObject(); + cJSON_AddNumberToObject(root, "balance_sats", (double)balance); + cJSON_AddNumberToObject(root, "proof_count", proof_count); + + char *json = cJSON_PrintUnformatted(root); + snprintf(resp.result_json, sizeof(resp.result_json), "%s", json); + cJSON_free(json); + cJSON_Delete(root); + resp.success = true; + return resp; +} + +mcp_response_t mcp_handle_wallet_send(const char *params_json) +{ + mcp_response_t resp = {0}; + cJSON *root = cJSON_Parse(params_json); + if (!root) { + resp.success = false; + snprintf(resp.error, sizeof(resp.error), "Invalid JSON params"); + return resp; + } + + cJSON *amount_item = cJSON_GetObjectItem(root, "amount"); + if (!amount_item || !cJSON_IsNumber(amount_item)) { + cJSON_Delete(root); + resp.success = false; + snprintf(resp.error, sizeof(resp.error), "Missing 'amount' field"); + return resp; + } + + uint64_t amount = (uint64_t)amount_item->valuedouble; + char token_out[2048] = {0}; + int rc = nucula_wallet_send(amount, token_out, sizeof(token_out)); + + if (rc != 0) { + cJSON_Delete(root); + resp.success = false; + snprintf(resp.error, sizeof(resp.error), "Send failed: %d", rc); + return resp; + } + + cJSON *result = cJSON_CreateObject(); + cJSON_AddStringToObject(result, "token", token_out); + cJSON_AddNumberToObject(result, "amount", (double)amount); + char *json = cJSON_PrintUnformatted(result); + snprintf(resp.result_json, sizeof(resp.result_json), "%s", json); + cJSON_free(json); + cJSON_Delete(result); + cJSON_Delete(root); + resp.success = true; + return resp; +} + +mcp_response_t mcp_dispatch(const mcp_request_t *req) +{ + if (!req) { + mcp_response_t resp = {0}; + resp.success = false; + snprintf(resp.error, sizeof(resp.error), "NULL request"); + return resp; + } + + switch (req->tool) { + case MCP_TOOL_GET_CONFIG: + return mcp_handle_get_config(); + case MCP_TOOL_SET_CONFIG: + return mcp_handle_set_config(req->params_json); + case MCP_TOOL_GET_BALANCE: + return mcp_handle_get_balance(); + case MCP_TOOL_WALLET_SEND: + return mcp_handle_wallet_send(req->params_json); + default: + break; + } + + mcp_response_t resp = {0}; + resp.success = false; + snprintf(resp.error, sizeof(resp.error), "Unknown tool: %s", req->method); + return resp; +} diff --git a/main/mcp_handler.h b/main/mcp_handler.h new file mode 100644 index 0000000..e42b5ee --- /dev/null +++ b/main/mcp_handler.h @@ -0,0 +1,36 @@ +#ifndef MCP_HANDLER_H +#define MCP_HANDLER_H + +#include +#include + +typedef enum { + MCP_TOOL_GET_CONFIG = 0, + MCP_TOOL_SET_CONFIG = 1, + MCP_TOOL_GET_BALANCE = 2, + MCP_TOOL_WALLET_SEND = 3, + MCP_TOOL_UNKNOWN = 99 +} mcp_tool_t; + +typedef struct { + mcp_tool_t tool; + char method[64]; + char params_json[1024]; +} mcp_request_t; + +typedef struct { + bool success; + char result_json[2048]; + char error[256]; +} mcp_response_t; + +mcp_tool_t mcp_parse_tool(const char *method); + +mcp_response_t mcp_handle_get_config(void); +mcp_response_t mcp_handle_set_config(const char *params_json); +mcp_response_t mcp_handle_get_balance(void); +mcp_response_t mcp_handle_wallet_send(const char *params_json); + +mcp_response_t mcp_dispatch(const mcp_request_t *req); + +#endif diff --git a/main/nip04.c b/main/nip04.c new file mode 100644 index 0000000..5526d4f --- /dev/null +++ b/main/nip04.c @@ -0,0 +1,201 @@ +#include "nip04.h" +#include "esp_log.h" +#include "esp_system.h" +#include "mbedtls/aes.h" +#include +#include + +#include +#include + +static const char *TAG = "nip04"; + +static const unsigned char base64_table[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +static size_t base64_encode(const uint8_t *src, size_t len, char *dst) +{ + size_t j = 0; + for (size_t i = 0; i < len; i += 3) { + uint32_t a = src[i]; + uint32_t b = (i + 1 < len) ? src[i + 1] : 0; + uint32_t c = (i + 2 < len) ? src[i + 2] : 0; + uint32_t triple = (a << 16) | (b << 8) | c; + dst[j++] = base64_table[(triple >> 18) & 0x3F]; + dst[j++] = base64_table[(triple >> 12) & 0x3F]; + dst[j++] = (i + 1 < len) ? base64_table[(triple >> 6) & 0x3F] : '='; + dst[j++] = (i + 2 < len) ? base64_table[triple & 0x3F] : '='; + } + dst[j] = '\0'; + return j; +} + +static int base64_val(char c) +{ + if (c >= 'A' && c <= 'Z') return c - 'A'; + if (c >= 'a' && c <= 'z') return c - 'a' + 26; + if (c >= '0' && c <= '9') return c - '0' + 52; + if (c == '+') return 62; + if (c == '/') return 63; + return -1; +} + +static size_t base64_decode(const char *src, size_t len, uint8_t *dst) +{ + size_t padding = 0; + if (len >= 1 && src[len - 1] == '=') padding++; + if (len >= 2 && src[len - 2] == '=') padding++; + size_t expected = (len / 4) * 3 - padding; + + size_t j = 0; + for (size_t i = 0; i + 3 < len; i += 4) { + uint32_t a = (uint32_t)base64_val(src[i]); + uint32_t b = (uint32_t)base64_val(src[i + 1]); + uint32_t c = (src[i + 2] != '=') ? (uint32_t)base64_val(src[i + 2]) : 0; + uint32_t d = (src[i + 3] != '=') ? (uint32_t)base64_val(src[i + 3]) : 0; + uint32_t triple = (a << 18) | (b << 12) | (c << 6) | d; + if (j < expected) dst[j++] = (triple >> 16) & 0xFF; + if (j < expected) dst[j++] = (triple >> 8) & 0xFF; + if (j < expected) dst[j++] = triple & 0xFF; + } + return j; +} + +static int ecdh_xonly_hash(unsigned char *output, const uint8_t *x32, const uint8_t *y32, void *data) +{ + (void)y32; + (void)data; + memcpy(output, x32, 32); + return 1; +} + +static void compute_shared_secret(const uint8_t *privkey, const uint8_t *pubkey32, + uint8_t shared_secret[32]) +{ + if (!privkey || !pubkey32) { + memset(shared_secret, 0, 32); + return; + } + + uint8_t compressed[33]; + compressed[0] = 0x02; + memcpy(compressed + 1, pubkey32, 32); + + secp256k1_context *ctx = secp256k1_context_create(SECP256K1_CONTEXT_SIGN); + secp256k1_pubkey pk; + if (!secp256k1_ec_pubkey_parse(ctx, &pk, compressed, 33)) { + ESP_LOGE(TAG, "Failed to parse compressed pubkey"); + memset(shared_secret, 0, 32); + secp256k1_context_destroy(ctx); + return; + } + + uint8_t shared[32]; + secp256k1_ecdh(ctx, shared, &pk, privkey, ecdh_xonly_hash, NULL); + memcpy(shared_secret, shared, 32); + + ESP_LOGI(TAG, "Shared secret: %02x%02x%02x%02x... (pubkey32=%02x%02x%02x%02x...)", + shared[0], shared[1], shared[2], shared[3], + pubkey32[0], pubkey32[1], pubkey32[2], pubkey32[3]); + + secp256k1_context_destroy(ctx); +} + +static void pkcs7_pad(uint8_t *buf, size_t data_len, size_t block_size, size_t *padded_len) +{ + size_t pad = block_size - (data_len % block_size); + for (size_t i = 0; i < pad; i++) { + buf[data_len + i] = (uint8_t)pad; + } + *padded_len = data_len + pad; +} + +static size_t pkcs7_unpad(const uint8_t *buf, size_t len) +{ + if (len == 0) return 0; + uint8_t pad = buf[len - 1]; + if (pad == 0 || pad > 16) return 0; + for (size_t i = len - pad; i < len; i++) { + if (buf[i] != pad) return 0; + } + return len - pad; +} + +void nip04_encrypt(const uint8_t *sender_privkey, const uint8_t *recipient_pubkey, + const char *plaintext, uint8_t *ciphertext_base64, size_t *out_len) +{ + uint8_t shared_secret[32]; + compute_shared_secret(sender_privkey, recipient_pubkey, shared_secret); + + size_t pt_len = strlen(plaintext); + uint8_t iv[16]; + esp_fill_random(iv, 16); + uint8_t iv_copy[16]; + memcpy(iv_copy, iv, 16); + + uint8_t padded[4096]; + memcpy(padded, plaintext, pt_len); + size_t padded_len; + pkcs7_pad(padded, pt_len, 16, &padded_len); + + mbedtls_aes_context aes; + mbedtls_aes_init(&aes); + mbedtls_aes_setkey_enc(&aes, shared_secret, 256); + mbedtls_aes_crypt_cbc(&aes, MBEDTLS_AES_ENCRYPT, padded_len, iv, padded, padded); + mbedtls_aes_free(&aes); + + size_t total = 16 + padded_len; + uint8_t combined[4112]; + memcpy(combined, iv_copy, 16); + memcpy(combined + 16, padded, padded_len); + + *out_len = base64_encode(combined, total, (char *)ciphertext_base64); + ((char *)ciphertext_base64)[*out_len] = '\0'; + + ESP_LOGD(TAG, "Encrypted %zu bytes -> %zu base64", pt_len, *out_len); +} + +int nip04_decrypt(const uint8_t *recipient_privkey, const uint8_t *sender_pubkey, + const char *ciphertext_base64, char *plaintext, size_t plaintext_max) +{ + uint8_t shared_secret[32]; + compute_shared_secret(recipient_privkey, sender_pubkey, shared_secret); + + size_t b64_len = strlen(ciphertext_base64); + uint8_t combined[4112]; + size_t combined_len = base64_decode(ciphertext_base64, b64_len, combined); + + if (combined_len < 32) { + ESP_LOGE(TAG, "Ciphertext too short: %zu", combined_len); + return -1; + } + + uint8_t iv[16]; + memcpy(iv, combined, 16); + + size_t ct_len = combined_len - 16; + uint8_t ct[4096]; + memcpy(ct, combined + 16, ct_len); + + mbedtls_aes_context aes; + mbedtls_aes_init(&aes); + mbedtls_aes_setkey_dec(&aes, shared_secret, 256); + mbedtls_aes_crypt_cbc(&aes, MBEDTLS_AES_DECRYPT, ct_len, iv, ct, ct); + mbedtls_aes_free(&aes); + + size_t pt_len = pkcs7_unpad(ct, ct_len); + if (pt_len == 0 || pt_len >= plaintext_max) { + ESP_LOGE(TAG, "Invalid padding: pt_len=%zu ct_len=%zu last_byte=%d padded_bytes:", + pt_len, ct_len, ct_len > 0 ? ct[ct_len-1] : -1); + for (size_t i = ct_len > 4 ? ct_len - 4 : 0; i < ct_len; i++) { + ESP_LOGE(TAG, " ct[%zu]=%02x", i, ct[i]); + } + return -1; + } + + memcpy(plaintext, ct, pt_len); + plaintext[pt_len] = '\0'; + + ESP_LOGD(TAG, "Decrypted %zu base64 -> %zu bytes", b64_len, pt_len); + return (int)pt_len; +} diff --git a/main/nip04.h b/main/nip04.h new file mode 100644 index 0000000..f612a8e --- /dev/null +++ b/main/nip04.h @@ -0,0 +1,13 @@ +#ifndef NIP04_H +#define NIP04_H + +#include +#include + +void nip04_encrypt(const uint8_t *sender_privkey, const uint8_t *recipient_pubkey, + const char *plaintext, uint8_t *ciphertext_base64, size_t *out_len); + +int nip04_decrypt(const uint8_t *recipient_privkey, const uint8_t *sender_pubkey, + const char *ciphertext_base64, char *plaintext, size_t plaintext_max); + +#endif diff --git a/main/tollgate_main.c b/main/tollgate_main.c index 3f83923..6adb0ec 100644 --- a/main/tollgate_main.c +++ b/main/tollgate_main.c @@ -21,6 +21,7 @@ #include "wifistr.h" #include "tollgate_client.h" #include "lightning_payout.h" +#include "cvm_server.h" #define MAX_STA_RETRY 5 static const char *TAG = "tollgate_main"; @@ -146,6 +147,12 @@ static void start_services(void) xTaskCreate(publish_wifistr_task, "wifistr_init", 16384, NULL, 3, NULL); + const tollgate_config_t *cfg2 = tollgate_config_get(); + if (cfg2->cvm_enabled) { + cvm_server_init(); + cvm_server_start(); + } + s_services_running = true; if (s_services_mutex) xSemaphoreGive(s_services_mutex); ESP_LOGI(TAG, "=== TollGate services started ==="); @@ -162,6 +169,7 @@ static void stop_services(void) captive_portal_stop(); tollgate_api_stop(); dns_server_stop(); + cvm_server_stop(); firewall_disable_nat(); firewall_revoke_all(); s_services_running = false; diff --git a/tests/unit/Makefile b/tests/unit/Makefile index 53bcc2c..5dee0d7 100644 --- a/tests/unit/Makefile +++ b/tests/unit/Makefile @@ -10,6 +10,7 @@ CFLAGS := -Wall -Wextra -Wno-unused-parameter -Wno-unused-function -Wno-sign-com -std=gnu17 -g -O0 \ -DTEST_HOST \ -DENABLE_MODULE_SCHNORRSIG=1 -DENABLE_MODULE_EXTRAKEYS=1 \ + -DENABLE_MODULE_ECDH=1 \ -DECMULT_WINDOW_SIZE=8 -DECMULT_GEN_PREC_BITS=4 \ -include stubs/esp_err.h \ -I stubs \ @@ -21,7 +22,7 @@ LDFLAGS := -lmbedcrypto -lcjson -lm SECP256K1_OBJ := secp256k1.o precomputed_ecmult.o precomputed_ecmult_gen.o -TESTS := test_geohash test_identity test_nostr_event test_cashu test_session test_tollgate_client test_lnurl_pay test_lightning_payout +TESTS := test_geohash test_identity test_nostr_event test_cashu test_session test_tollgate_client test_lnurl_pay test_lightning_payout test_mcp_handler test_nip04 .PHONY: all test clean $(TESTS) @@ -71,5 +72,11 @@ test_lnurl_pay: test_lnurl_pay.c test_lightning_payout: test_lightning_payout.c $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) +test_mcp_handler: test_mcp_handler.c $(REPO_ROOT)/main/mcp_handler.c + $(CC) $(CFLAGS) -I $(REPO_ROOT)/main $< $(REPO_ROOT)/main/mcp_handler.c -o $@ $(LDFLAGS) + +test_nip04: test_nip04.c $(REPO_ROOT)/main/nip04.c $(SECP256K1_OBJ) + $(CC) $(CFLAGS) -I $(SECP256K1_PRIV_INC) $< $(REPO_ROOT)/main/nip04.c $(SECP256K1_OBJ) -o $@ $(LDFLAGS) + clean: rm -f $(TESTS) $(SECP256K1_OBJ) diff --git a/tests/unit/stubs/esp_log.h b/tests/unit/stubs/esp_log.h index f353fe9..b9d44b3 100644 --- a/tests/unit/stubs/esp_log.h +++ b/tests/unit/stubs/esp_log.h @@ -6,5 +6,6 @@ #define ESP_LOGI(tag, fmt, ...) do { printf("I %s: " fmt "\n", tag, ##__VA_ARGS__); } while(0) #define ESP_LOGW(tag, fmt, ...) do { printf("W %s: " fmt "\n", tag, ##__VA_ARGS__); } while(0) #define ESP_LOGE(tag, fmt, ...) do { fprintf(stderr, "E %s: " fmt "\n", tag, ##__VA_ARGS__); } while(0) +#define ESP_LOGD(tag, fmt, ...) do { } while(0) #endif diff --git a/tests/unit/stubs/esp_system.h b/tests/unit/stubs/esp_system.h index 8e63c80..cd54743 100644 --- a/tests/unit/stubs/esp_system.h +++ b/tests/unit/stubs/esp_system.h @@ -1,4 +1,14 @@ #ifndef STUBS_ESP_SYSTEM_H #define STUBS_ESP_SYSTEM_H +#include +#include + +static inline void esp_fill_random(uint8_t *buf, size_t len) +{ + for (size_t i = 0; i < len; i++) { + buf[i] = (uint8_t)(rand() & 0xFF); + } +} + #endif diff --git a/tests/unit/test_mcp_handler.c b/tests/unit/test_mcp_handler.c new file mode 100644 index 0000000..aaa199d --- /dev/null +++ b/tests/unit/test_mcp_handler.c @@ -0,0 +1,148 @@ +#include "test_framework.h" +#include "mcp_handler.h" +#include "config.h" +#include "nucula_wallet.h" +#include "cJSON.h" +#include +#include + +static tollgate_config_t g_test_config; +static uint64_t g_wallet_balance = 0; +static int g_wallet_proof_count = 0; +static int g_wallet_send_rc = 0; +static char g_wallet_send_token[256] = "cashuA_test_token"; + +const tollgate_config_t *tollgate_config_get(void) { + return &g_test_config; +} + +uint64_t nucula_wallet_balance(void) { + return g_wallet_balance; +} + +int nucula_wallet_proof_count(void) { + return g_wallet_proof_count; +} + +int nucula_wallet_send(uint64_t amount, char *token_out, size_t token_max) { + (void)amount; + (void)token_max; + if (g_wallet_send_rc == 0) { + strncpy(token_out, g_wallet_send_token, token_max - 1); + } + return g_wallet_send_rc; +} + +static void test_mcp_parse_tool(void) +{ + printf("\n=== MCP tool parsing ===\n"); + ASSERT_EQ_INT(MCP_TOOL_GET_CONFIG, mcp_parse_tool("get_config"), "get_config"); + ASSERT_EQ_INT(MCP_TOOL_SET_CONFIG, mcp_parse_tool("set_config"), "set_config"); + ASSERT_EQ_INT(MCP_TOOL_GET_BALANCE, mcp_parse_tool("get_balance"), "get_balance"); + ASSERT_EQ_INT(MCP_TOOL_WALLET_SEND, mcp_parse_tool("wallet_send"), "wallet_send"); + ASSERT_EQ_INT(MCP_TOOL_UNKNOWN, mcp_parse_tool("foo"), "unknown tool"); + ASSERT_EQ_INT(MCP_TOOL_UNKNOWN, mcp_parse_tool(NULL), "NULL tool"); +} + +static void test_mcp_get_config(void) +{ + printf("\n=== MCP get_config ===\n"); + memset(&g_test_config, 0, sizeof(g_test_config)); + strncpy(g_test_config.ap_ssid, "TollGate-TEST", sizeof(g_test_config.ap_ssid) - 1); + strncpy(g_test_config.metric, "bytes", sizeof(g_test_config.metric) - 1); + g_test_config.price_per_step = 21; + g_test_config.step_size_ms = 60000; + g_test_config.step_size_bytes = 22020096; + strncpy(g_test_config.mint_url, "https://testnut.cashu.space", sizeof(g_test_config.mint_url) - 1); + + mcp_response_t resp = mcp_handle_get_config(); + ASSERT(resp.success, "get_config succeeds"); + + cJSON *result = cJSON_Parse(resp.result_json); + ASSERT(result != NULL, "result is valid JSON"); + ASSERT_EQ_STR("bytes", cJSON_GetObjectItem(result, "metric")->valuestring, "metric=bytes"); + ASSERT_EQ_INT(21, cJSON_GetObjectItem(result, "price_per_step")->valueint, "price=21"); + cJSON_Delete(result); +} + +static void test_mcp_set_config(void) +{ + printf("\n=== MCP set_config ===\n"); + memset(&g_test_config, 0, sizeof(g_test_config)); + g_test_config.price_per_step = 21; + + const char *params = "{\"price_per_step\":42,\"metric\":\"milliseconds\"}"; + mcp_response_t resp = mcp_handle_set_config(params); + ASSERT(resp.success, "set_config succeeds"); + ASSERT_EQ_INT(42, g_test_config.price_per_step, "price updated to 42"); + ASSERT_EQ_STR("milliseconds", g_test_config.metric, "metric updated"); + + resp = mcp_handle_set_config("not json"); + ASSERT(!resp.success, "invalid JSON fails"); +} + +static void test_mcp_get_balance(void) +{ + printf("\n=== MCP get_balance ===\n"); + g_wallet_balance = 500; + g_wallet_proof_count = 8; + + mcp_response_t resp = mcp_handle_get_balance(); + ASSERT(resp.success, "get_balance succeeds"); + + cJSON *result = cJSON_Parse(resp.result_json); + ASSERT(result != NULL, "result is valid JSON"); + ASSERT_EQ_INT(500, (int)cJSON_GetObjectItem(result, "balance_sats")->valuedouble, "balance=500"); + ASSERT_EQ_INT(8, cJSON_GetObjectItem(result, "proof_count")->valueint, "proofs=8"); + cJSON_Delete(result); +} + +static void test_mcp_wallet_send(void) +{ + printf("\n=== MCP wallet_send ===\n"); + g_wallet_send_rc = 0; + strncpy(g_wallet_send_token, "cashuA_send_test", sizeof(g_wallet_send_token) - 1); + + const char *params = "{\"amount\":21}"; + mcp_response_t resp = mcp_handle_wallet_send(params); + ASSERT(resp.success, "wallet_send succeeds"); + + cJSON *result = cJSON_Parse(resp.result_json); + ASSERT(result != NULL, "result is valid JSON"); + ASSERT_EQ_STR("cashuA_send_test", cJSON_GetObjectItem(result, "token")->valuestring, "token matches"); + cJSON_Delete(result); + + printf("\n--- wallet_send missing amount ---\n"); + resp = mcp_handle_wallet_send("{}"); + ASSERT(!resp.success, "missing amount fails"); + + printf("\n--- wallet_send send fails ---\n"); + g_wallet_send_rc = -1; + resp = mcp_handle_wallet_send("{\"amount\":100}"); + ASSERT(!resp.success, "send failure reported"); +} + +static void test_mcp_dispatch(void) +{ + printf("\n=== MCP dispatch ===\n"); + mcp_request_t req = {0}; + req.tool = MCP_TOOL_UNKNOWN; + strncpy(req.method, "bogus", sizeof(req.method) - 1); + mcp_response_t resp = mcp_dispatch(&req); + ASSERT(!resp.success, "unknown tool dispatch fails"); + + resp = mcp_dispatch(NULL); + ASSERT(!resp.success, "NULL request dispatch fails"); +} + +int main(void) +{ + printf("=== test_mcp_handler ===\n"); + test_mcp_parse_tool(); + test_mcp_get_config(); + test_mcp_set_config(); + test_mcp_get_balance(); + test_mcp_wallet_send(); + test_mcp_dispatch(); + TEST_SUMMARY(); +} diff --git a/tests/unit/test_nip04.c b/tests/unit/test_nip04.c new file mode 100644 index 0000000..27eb13c --- /dev/null +++ b/tests/unit/test_nip04.c @@ -0,0 +1,107 @@ +#include "test_framework.h" +#include "../../main/nip04.h" +#include +#include +#include +#include +#include +#include +#include + +static void test_nip04_roundtrip(void) +{ + printf("\n=== NIP-04 encrypt/decrypt roundtrip ===\n"); + + secp256k1_context *ctx = secp256k1_context_create(SECP256K1_CONTEXT_SIGN | SECP256K1_CONTEXT_VERIFY); + + uint8_t alice_sec[32], bob_sec[32]; + memset(alice_sec, 0x01, 32); + memset(bob_sec, 0x02, 32); + + secp256k1_pubkey alice_pk, bob_pk; + secp256k1_ec_pubkey_create(ctx, &alice_pk, alice_sec); + secp256k1_ec_pubkey_create(ctx, &bob_pk, bob_sec); + + uint8_t alice_xonly[32], bob_xonly[32]; + secp256k1_xonly_pubkey alice_xpk, bob_xpk; + secp256k1_xonly_pubkey_from_pubkey(ctx, &alice_xpk, NULL, &alice_pk); + secp256k1_xonly_pubkey_from_pubkey(ctx, &bob_xpk, NULL, &bob_pk); + secp256k1_xonly_pubkey_serialize(ctx, alice_xonly, &alice_xpk); + secp256k1_xonly_pubkey_serialize(ctx, bob_xonly, &bob_xpk); + + const char *message = "Hello, ContextVM! This is a test message."; + + uint8_t ciphertext[4096]; + size_t ct_len = 0; + nip04_encrypt(alice_sec, bob_xonly, message, ciphertext, &ct_len); + ASSERT(ct_len > 0, "encryption produced output"); + ASSERT(ct_len > strlen(message), "ciphertext longer than plaintext"); + + char plaintext[2048]; + int pt_len = nip04_decrypt(bob_sec, alice_xonly, (const char *)ciphertext, plaintext, sizeof(plaintext)); + ASSERT(pt_len > 0, "decryption succeeded"); + ASSERT_EQ_STR(message, plaintext, "decrypted message matches original"); + + printf("\n--- Different key produces garbage ---\n"); + uint8_t eve_sec[32]; + memset(eve_sec, 0x03, 32); + pt_len = nip04_decrypt(eve_sec, alice_xonly, (const char *)ciphertext, plaintext, sizeof(plaintext)); + if (pt_len > 0) { + ASSERT(strcmp(message, plaintext) != 0, "wrong key produces different output"); + } else { + ASSERT(true, "wrong key fails to decrypt"); + } + + printf("\n--- Second roundtrip (different message) ---\n"); + const char *msg2 = "Short"; + nip04_encrypt(alice_sec, bob_xonly, msg2, ciphertext, &ct_len); + pt_len = nip04_decrypt(bob_sec, alice_xonly, (const char *)ciphertext, plaintext, sizeof(plaintext)); + ASSERT(pt_len > 0, "short message decrypts"); + ASSERT_EQ_STR(msg2, plaintext, "short message matches"); + + printf("\n--- Long message roundtrip ---\n"); + char long_msg[512]; + memset(long_msg, 'X', 511); + long_msg[511] = '\0'; + nip04_encrypt(alice_sec, bob_xonly, long_msg, ciphertext, &ct_len); + pt_len = nip04_decrypt(bob_sec, alice_xonly, (const char *)ciphertext, plaintext, sizeof(plaintext)); + ASSERT(pt_len > 0, "long message decrypts"); + ASSERT_EQ_INT(511, pt_len, "long message length matches"); + + printf("\n--- Bob encrypts, Alice decrypts ---\n"); + nip04_encrypt(bob_sec, alice_xonly, message, ciphertext, &ct_len); + pt_len = nip04_decrypt(alice_sec, bob_xonly, (const char *)ciphertext, plaintext, sizeof(plaintext)); + ASSERT(pt_len > 0, "reverse direction decrypts"); + ASSERT_EQ_STR(message, plaintext, "reverse direction matches"); + + secp256k1_context_destroy(ctx); +} + +static void test_nip04_invalid_input(void) +{ + printf("\n=== NIP-04 invalid inputs ===\n"); + char plaintext[256]; + int rc = nip04_decrypt(NULL, NULL, "AAAA", plaintext, sizeof(plaintext)); + ASSERT(rc < 0, "NULL keys fails"); + + uint8_t dummy_sec[32]; + memset(dummy_sec, 0xAA, 32); + rc = nip04_decrypt(dummy_sec, NULL, "AAAA", plaintext, sizeof(plaintext)); + ASSERT(rc < 0, "NULL pubkey fails"); + + uint8_t dummy_pub[32]; + memset(dummy_pub, 0xBB, 32); + rc = nip04_decrypt(dummy_sec, dummy_pub, "", plaintext, sizeof(plaintext)); + ASSERT(rc < 0, "empty ciphertext fails"); + + rc = nip04_decrypt(dummy_sec, dummy_pub, "AAAA", plaintext, sizeof(plaintext)); + ASSERT(rc < 0, "garbage ciphertext fails"); +} + +int main(void) +{ + printf("=== test_nip04 ===\n"); + test_nip04_roundtrip(); + test_nip04_invalid_input(); + TEST_SUMMARY(); +} -- cgit v1.2.3