diff --git a/README.md b/README.md index 2e7b65c..2324d41 100644 --- a/README.md +++ b/README.md @@ -357,7 +357,18 @@ Implemented signer verbs in this build: | `derive_shared_secret` | x25519 | ECDH key agreement. Returns `{"shared_secret":"","algorithm":"x25519"}`. | | `get_public_key` | all algorithms | Returns the public key for the specified algorithm+index. | -**OTP verbs** (one-time pad encryption, requires `--otp-pad-dir` + `--otp-pad`): +**General encryption verbs** (use `curve` parameter to select encryption method): + +| Verb | `curve` value | Description | +|---|---|---| +| `encrypt` | `otp` | One-time pad encryption (requires `--otp-pad-dir` + `--otp-pad`). Returns ASCII-armored or binary ciphertext. | +| `decrypt` | `otp` | One-time pad decryption. Returns plaintext (base64). | +| `encrypt` | `secp256k1` | NIP-44 (default) or NIP-04 encryption. Params: `[peer_pubkey, message, {nip_version: 4\|44}]`. | +| `decrypt` | `secp256k1` | NIP-44 (default) or NIP-04 decryption. Params: `[peer_pubkey, ciphertext, {nip_version: 4\|44}]`. | +| `encrypt` | `x25519` | ECDH + symmetric encryption (not yet implemented — use `derive_shared_secret` + your own cipher). | +| `encrypt` | `ml-kem-768` | KEM-based hybrid encryption (not yet implemented — use `encapsulate`/`decapsulate` + your own cipher). | + +**OTP verb aliases** (also available, same as `encrypt`/`decrypt` with `curve: "otp"`): | Verb | Description | |---|---| @@ -536,18 +547,30 @@ curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \ -d '{"id":"1","method":"sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"}]}' ``` -OTP encrypt (requires `--otp-pad-dir` and `--otp-pad`): +General encrypt (OTP, requires `--otp-pad-dir` and `--otp-pad`): +```bash +curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \ + -d '{"id":"1","method":"encrypt","params":["SGVsbG8sIE9UUCB3b3JsZCE=",{"curve":"otp","encoding":"ascii"}]}' +``` + +General encrypt (NIP-44, secp256k1): +```bash +curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \ + -d '{"id":"1","method":"encrypt","params":["","Hello!",{"role":"main"}]}' +``` + +General decrypt (OTP): +```bash +curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \ + -d '{"id":"1","method":"decrypt","params":["-----BEGIN OTP MESSAGE-----...",{"curve":"otp","encoding":"ascii"}]}' +``` + +OTP verb aliases (same as encrypt/decrypt with curve=otp): ```bash curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \ -d '{"id":"1","method":"otp_encrypt","params":["SGVsbG8sIE9UUCB3b3JsZCE=",{"encoding":"ascii"}]}' ``` -OTP decrypt: -```bash -curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \ - -d '{"id":"1","method":"otp_decrypt","params":["-----BEGIN OTP MESSAGE-----...","{"encoding":"ascii"}]}' -``` - #### Unix socket examples (framed mode) ```bash @@ -557,8 +580,11 @@ nsigner --socket-name nsigner client '{"id":"1","method":"get_public_key","param # Sign event nsigner --socket-name nsigner client '{"id":"1","method":"sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"}]}' -# OTP encrypt -nsigner --socket-name nsigner client '{"id":"1","method":"otp_encrypt","params":["SGVsbG8=",{"encoding":"ascii"}]}' +# Encrypt (OTP) +nsigner --socket-name nsigner client '{"id":"1","method":"encrypt","params":["SGVsbG8=",{"curve":"otp","encoding":"ascii"}]}' + +# Encrypt (NIP-44) +nsigner --socket-name nsigner client '{"id":"1","method":"encrypt","params":["","Hello!",{"role":"main"}]}' ``` ### 7.2 ESP32 MCU: TinyUSB composite (CDC + WebUSB) diff --git a/src/dispatcher.c b/src/dispatcher.c index 329b6e6..2d1c76a 100644 --- a/src/dispatcher.c +++ b/src/dispatcher.c @@ -236,6 +236,8 @@ const char *selector_strerror(int err); #define VERB_KEM_DECAPS "kem_decapsulate" #define VERB_OTP_ENCRYPT "otp_encrypt" #define VERB_OTP_DECRYPT "otp_decrypt" +#define VERB_ENCRYPT "encrypt" +#define VERB_DECRYPT "decrypt" /* * Check whether `verb` is allowed to execute against `role`. @@ -1675,6 +1677,27 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request) return otp_response; } + /* General encrypt/decrypt verbs: route to OTP if curve is "otp", + * otherwise fall through to the role-based path (for secp256k1 NIP-04/44, + * x25519, ml-kem-768). */ + if (strcmp(method, VERB_ENCRYPT) == 0 || + strcmp(method, VERB_DECRYPT) == 0) { + if (cJSON_IsObject(options_item)) { + cJSON *curve_item = cJSON_GetObjectItemCaseSensitive(options_item, "curve"); + if (cJSON_IsString(curve_item) && curve_item->valuestring != NULL && + strcmp(curve_item->valuestring, "otp") == 0) { + /* Route to OTP handler — map encrypt→otp_encrypt, decrypt→otp_decrypt */ + const char *otp_method = (strcmp(method, VERB_ENCRYPT) == 0) + ? VERB_OTP_ENCRYPT : VERB_OTP_DECRYPT; + char *otp_response = handle_otp_verb(id_str, otp_method, + params_item, options_item); + cJSON_Delete(root); + return otp_response; + } + } + /* For other curves, fall through to role-based path below. */ + } + if (cJSON_IsObject(options_item)) { tmp = cJSON_GetObjectItemCaseSensitive(options_item, "role"); if (tmp != NULL) { @@ -1978,6 +2001,78 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request) return make_error_response(id_str, -32602, "invalid_params"); } result = owned_result; + } else if (strcmp(method, VERB_ENCRYPT) == 0 || + strcmp(method, VERB_DECRYPT) == 0) { + /* General encrypt/decrypt verbs (role-based path for non-OTP curves). + * The curve is determined by the role's curve. For secp256k1, the + * nip_version option (4 or 44, default 44) selects NIP-04 or NIP-44. + * For x25519 and ml-kem-768, the key agreement is available but + * direct encrypt/decrypt is not yet implemented. */ + const char *curve_str = role->curve_str; + int nip_version = 44; /* default to NIP-44 */ + + if (cJSON_IsObject(options_item)) { + cJSON *nv = cJSON_GetObjectItemCaseSensitive(options_item, "nip_version"); + if (cJSON_IsNumber(nv)) { + nip_version = nv->valueint; + } + } + + if (role->curve == CURVE_SECP256K1) { + /* secp256k1: route to NIP-04 or NIP-44 */ + cJSON *peer_item = cJSON_GetArrayItem(params_item, 0); + cJSON *msg_item = cJSON_GetArrayItem(params_item, 1); + if (!cJSON_IsString(peer_item) || peer_item->valuestring == NULL || + !cJSON_IsString(msg_item) || msg_item->valuestring == NULL) { + cJSON_Delete(root); + return make_error_response(id_str, -32602, "invalid_params"); + } + if (strcmp(method, VERB_ENCRYPT) == 0) { + if (nip_version == 4) { + owned_result = crypto_nip04_encrypt(ctx->key_store, role_index, + peer_item->valuestring, + msg_item->valuestring); + } else { + owned_result = crypto_nip44_encrypt(ctx->key_store, role_index, + peer_item->valuestring, + msg_item->valuestring); + } + } else { /* decrypt */ + if (nip_version == 4) { + owned_result = crypto_nip04_decrypt(ctx->key_store, role_index, + peer_item->valuestring, + msg_item->valuestring); + } else { + owned_result = crypto_nip44_decrypt(ctx->key_store, role_index, + peer_item->valuestring, + msg_item->valuestring); + } + } + if (owned_result == NULL) { + cJSON_Delete(root); + return make_error_response(id_str, -32602, "invalid_params"); + } + result = owned_result; + } else if (role->curve == CURVE_X25519) { + /* x25519: ECDH + symmetric encryption — not yet implemented. + * The derive_shared_secret verb is available for key agreement; + * the caller can use the shared secret with their own symmetric + * cipher. Direct encrypt/decrypt will be added in a future release. */ + cJSON_Delete(root); + return make_error_response(id_str, -32601, + "encrypt_decrypt_not_implemented_for_x25519"); + } else if (role->curve == CURVE_ML_KEM_768) { + /* ml-kem-768: KEM-based hybrid encryption — not yet implemented. + * The encapsulate/decapsulate verbs are available for key agreement; + * direct encrypt/decrypt will be added in a future release. */ + cJSON_Delete(root); + return make_error_response(id_str, -32601, + "encrypt_decrypt_not_implemented_for_ml_kem_768"); + } else { + cJSON_Delete(root); + return make_error_response(id_str, -32602, + "encrypt_decrypt_not_supported_for_curve"); + } } else if (strcmp(method, VERB_SIGN_DATA) == 0 || strcmp(method, VERB_SSH_SIGN) == 0) { /* sign_data / ssh_sign: params = [message_bytes_hex, options] diff --git a/src/enforcement.c b/src/enforcement.c index 8b48177..c03101e 100644 --- a/src/enforcement.c +++ b/src/enforcement.c @@ -232,6 +232,8 @@ const char *selector_strerror(int err); #define VERB_SSH_SIGN "ssh_sign" #define VERB_KEM_ENCAPS "kem_encapsulate" #define VERB_KEM_DECAPS "kem_decapsulate" +#define VERB_ENCRYPT "encrypt" +#define VERB_DECRYPT "decrypt" /* * Check whether `verb` is allowed to execute against `role`. @@ -823,6 +825,21 @@ int enforce_verb_role(const char *verb, const role_entry_t *role) { return enforce_kem_role(role); } + /* encrypt / decrypt: allowed for secp256k1 (NIP-04/44), x25519 (age), + * and ml-kem-768 (pq-kem). OTP is handled before role resolution. */ + if (strcmp(verb, VERB_ENCRYPT) == 0 || strcmp(verb, VERB_DECRYPT) == 0) { + if (role->curve == CURVE_SECP256K1 && role->purpose == PURPOSE_NOSTR) { + return ENFORCE_OK; + } + if (role->curve == CURVE_X25519 && role->purpose == PURPOSE_AGE) { + return ENFORCE_OK; + } + if (role->curve == CURVE_ML_KEM_768 && role->purpose == PURPOSE_PQ_KEM) { + return ENFORCE_OK; + } + return ENFORCE_ERR_CURVE; + } + return ENFORCE_ERR_UNKNOWN_VERB; } diff --git a/src/main.c b/src/main.c index 138a24b..177c645 100644 --- a/src/main.c +++ b/src/main.c @@ -759,8 +759,8 @@ int socket_name_random(char *out, size_t out_len); /* Version information (auto-updated by build/version tooling) */ #define NSIGNER_VERSION_MAJOR 0 #define NSIGNER_VERSION_MINOR 0 -#define NSIGNER_VERSION_PATCH 48 -#define NSIGNER_VERSION "v0.0.48" +#define NSIGNER_VERSION_PATCH 49 +#define NSIGNER_VERSION "v0.0.49" /* NSIGNER_HEADERLESS_DECLS_END */