From 67055e03f8506f2e3a2888f7b68cf5e1aac13651 Mon Sep 17 00:00:00 2001 From: Didactyl User Date: Fri, 7 Aug 2026 07:05:09 -0400 Subject: [PATCH] =?UTF-8?q?v0.2.56=20-=20nostr=5Fcore=5Flib=20v0.6.10?= =?UTF-8?q?=E2=86=92v0.6.15:=20replace=20nostr=5Findex=20with=20role=5Fpat?= =?UTF-8?q?h,=20add=20signer=5Fcrypto=20tool,=20nostr=5Fpost=20PoW,=20nost?= =?UTF-8?q?r=5Fsigner=5Flast=5Ferror=20surfacing.=20Wizard:=20toggle-based?= =?UTF-8?q?=20relay=20menu,=20admin=20keypair=20generation,=20reordered=20?= =?UTF-8?q?steps,=20existing-agent=20download/review,=20default=20role=20n?= =?UTF-8?q?ostr=5Frange,=20API=20key=20visible=20input?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 1 + README.md | 4 +- docs/SIGNER.md | 60 ++- docs/wizard_menus.md | 351 +++++++++++++ genesis.jsonc.example | 4 +- plans/update_nostr_core_lib_nsigner.md | 147 ++++++ src/config.c | 22 +- src/config.h | 8 +- src/default_events.h | 4 +- src/main.c | 103 +++- src/main.h | 4 +- src/nostr_block_list.c | 6 + src/nostr_handler.c | 75 ++- src/nostr_handler.h | 7 + src/setup_wizard.c | 663 ++++++++++++++++++------- src/tools/tool_config.c | 15 +- src/tools/tool_memory.c | 15 +- src/tools/tool_nostr_dm.c | 15 +- src/tools/tool_nostr_post.c | 61 ++- src/tools/tool_signer_crypto.c | 283 +++++++++++ src/tools/tool_task.c | 15 +- src/tools/tools_dispatch.c | 3 + src/tools/tools_internal.h | 1 + src/tools/tools_schema.c | 105 ++++ 24 files changed, 1737 insertions(+), 235 deletions(-) create mode 100644 docs/wizard_menus.md create mode 100644 plans/update_nostr_core_lib_nsigner.md create mode 100644 src/tools/tool_signer_crypto.c diff --git a/Makefile b/Makefile index 9656875..1a7c088 100644 --- a/Makefile +++ b/Makefile @@ -35,6 +35,7 @@ SRCS = \ $(SRC_DIR)/tools/tool_config.c \ $(SRC_DIR)/tools/tool_cashu_wallet.c \ $(SRC_DIR)/tools/tool_blossom.c \ + $(SRC_DIR)/tools/tool_signer_crypto.c \ $(SRC_DIR)/trigger_manager.c \ $(SRC_DIR)/prompt_template.c \ $(SRC_DIR)/http_api.c \ diff --git a/README.md b/README.md index d27cffb..51fcb90 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,11 @@ Skills compose by adoption-list order (`10123`) and trigger tags carry runtime e Didactyl will support local inference, which is very privacy preserving. Remote inference does however have it's advantages, and in those cases Didactyl supports using Bitcoin Lightning and eCash inference providers. -## Current Status — v0.2.55 +## Current Status — v0.2.56 **Active build — this project is barely working. Experiment at your own risk.** -> Last release update: v0.2.55 — Fix wizard quit bug, add nostr_index selector, fix relay flapping backoff defeat, add NOSTR_ENABLE_NSIGNER_CLIENT to Dockerfile +> Last release update: v0.2.56 — nostr_core_lib v0.6.10→v0.6.15: replace nostr_index with role_path, add signer_crypto tool, nostr_post PoW, nostr_signer_last_error surfacing. Wizard: toggle-based relay menu, admin keypair generation, reordered steps, existing-agent download/review, default role nostr_range, API key visible input - Connects to configured relays with auto-reconnect and relay state transition logging - Publishes configured startup events per relay as each relay becomes connected diff --git a/docs/SIGNER.md b/docs/SIGNER.md index ef3bf35..b440ecd 100644 --- a/docs/SIGNER.md +++ b/docs/SIGNER.md @@ -69,7 +69,8 @@ or the `signer.service_name` config field. "signer": { "mode": "nsigner_unix", "socket_name": "", // nsigner_unix: "" = auto-discover - "role": "main", + "role": "main", // REQUIRED for all nsigner_* modes + "role_path": "m/44'/1237'/0'/0/0", // REQUIRED for all nsigner_* modes: full BIP-44 path "timeout_ms": 15000, "auth_privkey_hex": "", // nsigner_tcp only "tcp_host": "127.0.0.1", // nsigner_tcp @@ -78,16 +79,26 @@ or the `signer.service_name` config field. "target_qube": "nostr_signer", // nsigner_qrexec: target Qubes qube "service_name": "qubes.NsignerRpc" // nsigner_qrexec: qrexec service (default qubes.NsignerRpc) // nsigner_fds: fds_read_fd/fds_write_fd are CLI-only, NOT persisted here + // derive_index: CLI-only (--signer-derive-index), NOT persisted here } ``` +> **Breaking change (n_signer v0.1.18+):** the `nostr_index` selector is +> **removed**. n_signer now rejects it with `2006 nostr_index_deprecated`. The +> only accepted selector for `nostr_*` verbs is `{"role":"", +> "role_path":""}` sent **together**. Role-only is rejected with +> `2009 path_required`; path-only with `2008 role_required`. Update any +> existing genesis configs that use `nostr_index` to use `role_path` instead. + ### CLI flags | Flag | Purpose | |------|---------| | `--signer ` | Select signer mode (overrides config/env). | | `--signer-socket ` | Abstract socket name for `nsigner_unix`. | -| `--signer-role ` | `n_signer` role selector (default `main`). | +| `--signer-role ` | `n_signer` role selector (default `main`). **Required** for all `nsigner_*` modes. | +| `--signer-path ` | Full BIP-44 derivation path (e.g. `m/44'/1237'/0'/0/0`). **Required** for all `nsigner_*` modes — n_signer rejects role-only with `2009 path_required`. | +| `--signer-derive-index ` | Algorithm index for the `derive` (HMAC) verb. Runtime-only; not persisted to genesis. | | `--signer-timeout ` | Per-call timeout (default `15000`). | | `--signer-tcp ` | Shorthand for `--signer nsigner_tcp` + host/port. | | `--signer-serial ` | Shorthand for `--signer nsigner_serial` + device. | @@ -99,6 +110,7 @@ Precedence: CLI flag > `DIDACTYL_SIGNER` env var > genesis `signer.mode` > default (`local`). In any `nsigner_*` mode, `--nsec` / `DIDACTYL_NSEC` are not required. +`--signer-role` and `--signer-path` are both required. ## Interactive setup wizard @@ -111,9 +123,13 @@ The wizard's **New agent → sign with a running n_signer** path and the - `f` d pair (prompts for `read_fd:write_fd` — advanced) - `e` xec qrexec (prompts for target qube + service name — Qubes cross-qube) -After a transport is chosen, the wizard runs a connectivity check -(`nostr_signer_get_public_key`) and populates the agent pubkey before -continuing. +After a transport is chosen, the wizard prompts for a **role** (default +`main`) and a **role_path** (BIP-44 derivation path, with presets for +NIP-06 standard `m/44'/1237'/0'/0/0`, all-hardened +`m/44'/1237'/0'/0'/0'`, or custom). Both are required — n_signer rejects +role-only with `2009 path_required`. Then the wizard runs a connectivity +check (`nostr_signer_get_public_key`) and populates the agent pubkey +before continuing. On failure, the n_signer error string is displayed. The **Existing agent** flow additionally lets you recover an agent **without entering an nsec** — kind-30078 config recall/publish is routed through the @@ -129,6 +145,40 @@ the nsec never enters the agent process during recovery. be embedded in `ExecStart`); choose **boot now** or wire fd-passing yourself via a wrapper unit. +## Agent tools (remote signer only) + +Two agent tools leverage the remote signer for advanced operations: + +### `nostr_post` — PoW mining (NIP-13) + +The `nostr_post` tool accepts optional `difficulty`, `threads`, and +`timeout_sec` arguments. When `difficulty > 0` and a remote signer is +available, the event is mined through `nostr_signer_mine_event` before +publishing. In local mode, PoW is not supported (the local backend does +not implement `mine_event`); the tool returns a clear error. + +### `signer_crypto` — algorithm-based n_signer verbs + +A single unified tool exposes all algorithm-based n_signer verbs through +an `operation` enum: + +| Operation | Algorithms | Key args | +|-----------|------------|----------| +| `get_info` | n/a | — | +| `get_public_key` | all | `algorithm`, `index` | +| `sign` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | `algorithm`, `index`, `scheme`, `message_hex` | +| `verify` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | `algorithm`, `index`, `scheme`, `message_hex`, `signature_hex` | +| `encapsulate` | ml-kem-768 | `peer_pubkey_hex` | +| `decapsulate` | ml-kem-768 | `index`, `ciphertext_hex` | +| `derive_shared_secret` | x25519 | `index`, `peer_pubkey_hex` | +| `derive_hmac` | secp256k1 | `data` (uses `--signer-derive-index`) | +| `otp_encrypt` | otp | `plaintext_b64`, `encoding` | +| `otp_decrypt` | otp | `ciphertext`, `encoding` | + +All operations require a remote n_signer; local mode returns +`NOSTR_ERROR_NOT_SUPPORTED`. See the n_signer README §4 for the full +algorithm reference. + ## Security tradeoff `local` keeps the nsec in the agent process. If the agent is compromised, the diff --git a/docs/wizard_menus.md b/docs/wizard_menus.md new file mode 100644 index 0000000..bbc7485 --- /dev/null +++ b/docs/wizard_menus.md @@ -0,0 +1,351 @@ +# Setup Wizard Menu Reference + +Proposed wizard flow. Each menu the interactive setup wizard presents, in the +order an operator encounters them. Use this as a design reference. + +--- + +## Main Menu + +``` +╔═ Main Menu ═══════════════════════════════════════════════════╗ +║ Choose Setup Mode ║ +╠═══════════════════════════════════════════════════════════════╣ +║ n ew agent -- create a fresh identity ║ +║ e xisting -- start an already set up agent ║ +║ l oad -- boot from genesis.jsonc ║ +║ q uit ║ +╚═══════════════════════════════════════════════════════════════╝ +``` + +--- + +## New Agent Flow (8 steps) + +### Step 1 of 8 — Agent Profile + +``` + Agent name [Didactyl]: _ +``` +Single prompt. Default `Didactyl`. + +### Step 2 of 8 — Relay Configuration + +``` + Current relays: + 1. ws://localhost:7777 + 2. wss://relay.laantungir.net + 3. wss://relay.primal.net + + a dd a relay + r emove a relay (by number) + d one -- use this list + b ack + q uit +``` + +- **a** — prompt: `Relay URL (wss:// or ws://):` +- **r** — prompt: `Remove which number?` (cannot remove last relay) +- **d** — accept and continue +- Starts from the default relay list; operator adds/removes before identity + validation queries the network + +### Step 3 of 8 — Identity + +``` + g enerate a new Nostr keypair + p rovide an existing nsec + s ign with a running n_signer + b ack + q uit +``` + +- **g** — generates a fresh keypair, shows npub+nsec, asks to confirm saved +- **p** — prompts for nsec (echo-suppressed), derives pubkey +- **s** — enters the **n_signer Transport** sub-menu (below) + +### Step 4 of 8 — Identity Validation & Existing Agent Detection + +After the identity is established (new keypair, imported nsec, or n_signer +pubkey), the wizard queries the configured relays for a kind 10002 relay list +and kind 30078 encrypted config for this pubkey. + +**If NO existing agent data is found:** + +``` + Agent kind 10002: NOT FOUND (new identity available) + Admin profile: N/A + + c ontinue to administrator setup + q uit +``` + +**If existing agent data IS found:** + +``` + Agent kind 10002: FOUND + Admin profile: + + This identity already has an agent configured on the network. + + d ownload existing config and review it + r estart with a fresh identity + c ontinue anyway (overwrite existing state) + q uit +``` + +- **d** — downloads all relevant events (kind 10002 relay list, kind 30078 + encrypted config, kind 0 profile, kind 3 contacts) from the relays, decrypts + the config, and presents everything on a single review page (see Step 4a + below). This merges the "existing agent" recovery path into the new agent + flow so the operator does not need to restart the wizard. +- **r** — returns to Step 3 (Identity) to pick a different identity +- **c** — continues to Step 5 (Administrator) as a fresh setup that will + overwrite the existing state + +#### Step 4a — Downloaded Config Review (only if `d` chosen) + +``` + Downloaded configuration for this identity: + + Name: + Identity: ... + Admin: + LLM Provider: + LLM Model: + LLM Base URL: + LLM API Key: + Relays: configured + DM Protocol: + + e dit these settings + b oot with this config now + i nstall dedicated-user systemd service and boot + q uit +``` + +- **e** — enters the review/edit loop (admin, LLM, relays — same as the + Existing Agent review loop), then returns here +- **b** — boots immediately with the downloaded config +- **i** — installs systemd service and boots + +If the operator chooses **b** or **i**, the wizard skips Steps 5–8 and goes +straight to launch — the downloaded config is already complete. + +### Step 5 of 8 — Administrator + +``` + Admin pubkey (npub1... or 64-char hex): _ +``` +Single prompt. Accepts npub or hex. + +### Step 6 of 8 — LLM Provider + +``` + LLM provider [openai]: _ + API key: _ + Model [gpt-4o-mini]: _ + Base URL [https://api.openai.org/v1]: _ + Max tokens [512]: _ + Temperature [0.7]: _ +``` + +### Step 7 of 8 — Default Skills + +(silent — prepares default startup skill events) + +### Step 8 of 8 — Review + +``` + Name: + Identity: ... + Admin: ... + LLM: @ + Relays: configured + DM Protocol: nip04 + + b oot the agent now + i nstall dedicated-user systemd service and boot + s tart over + q uit +``` + +- **b** — publishes kind-30078 config, boots immediately +- **i** — publishes config, installs systemd unit, starts service +- **s** — resets config and returns to Main Menu + +--- + +## n_signer Transport Sub-menu + +Reached from Step 3 (`s`) or Existing Agent (`s`). + +``` + u nix abstract socket (auto-discover or named) + t cp (host:port + optional auth privkey) + s erial USB device (auto-discover or path) + f d pair (read_fd:write_fd -- advanced) + e xec -- Qubes cross-qube qrexec (target qube + service) + b ack + q uit +``` + +### Per-transport prompts + +**u (unix)** +``` + Discovered n_signer abstract sockets: + 1. nsigner_hairy_dog + Pick a number, type a custom name, or press Enter for auto-discovery. + Socket name [1]: _ +``` + +**t (tcp)** +``` + host:port [127.0.0.1:7777]: _ + Auth privkey hex (64 chars, optional, echo-suppressed): _ +``` + +**s (serial)** +``` + Discovered serial devices: + 1. /dev/ttyACM0 + Pick a number or type a custom path. + Device [1]: _ +``` + +**f (fds)** +``` + read_fd:write_fd: _ +``` + +**e (qrexec)** +``` + Target qube [nostr_signer]: _ + qrexec service [qubes.NsignerRpc]: _ +``` + +### Common role + path prompts (after transport chosen) + +``` + Role [nostr_range]: _ + + Derivation path presets: + 1) m/44'/1237'/0'/0/0 (NIP-06 standard) + 2) m/44'/1237'/0'/0'/0' (all-hardened) + 3) custom + Select preset [1]: _ + role_path [m/44'/1237'/0'/0/0]: _ + + Timeout ms [15000]: _ +``` + +Then a connectivity check runs: +``` + Checking connectivity to n_signer (mode=nsigner_unix, ..., role=nostr_range, path=m/44'/1237'/0'/0/0)... + Connectivity check: OK. Pubkey: +``` +On failure, the n_signer error string is shown and the operator can retry. + +--- + +## Existing Agent Flow + +Kept as a separate entry point from the Main Menu for operators who know they +want to recover an existing agent and do not want to go through the new-agent +steps. + +### Identity + +``` + n sec -- enter the agent private key + s ign with a running n_signer (no nsec required) + q uit +``` + +- **n** — prompt: `Enter your agent nsec (nsec1... or 64-char hex):` (echo-suppressed) +- **s** — enters the **n_signer Transport** sub-menu (same as above) + +### Config Recovery + +If no kind 10002 relay list is found: +``` + Relay list (kind 10002): NOT FOUND + + n ew agent setup with this identity + q uit to main menu +``` + +### Recovered Configuration (review loop) + +``` + Your agent was found. Change any of the following: + + Name: + Identity: ... + Admin: + LLM Provider: + LLM Model: + LLM Base URL: + LLM API Key: + Relays: configured + + a dmin pubkey + l lm config + r elay configuration + c ontinue to launch options + q uit +``` + +- **a** — re-prompt admin pubkey +- **l** — re-prompt LLM config +- **r** — relay add/remove/done sub-menu (same as Step 2) +- **c** — proceed to launch + +### Launch Options + +``` + Name: + Identity: ... + Admin: ... + LLM: @ + Relays: configured + + b oot the agent now + i nstall dedicated-user systemd service and boot + q uit +``` + +--- + +## Load Genesis Flow + +``` + Path to genesis.jsonc: _ +``` +Single prompt. Loads and boots. + +--- + +## Default Relays + +``` + 1. ws://localhost:7777 + 2. wss://relay.laantungir.net + 3. wss://relay.primal.net +``` + +--- + +## Summary of changes from previous design + +1. **Default relays changed** to `ws://localhost:7777`, + `wss://relay.laantungir.net`, `wss://relay.primal.net` +2. **Step order changed**: Agent (1) → Relays (2) → Identity (3) → Validation + (4) → Admin (5) → LLM (6) → Skills (7) → Review (8) +3. **Step 4 redesigned**: if an existing agent is detected, the operator is + notified and offered three choices: download existing config and review it + (merges the recovery path inline), restart with a fresh identity, or + continue anyway (overwrite). The download path shows all recovered settings + on a single page and lets the operator edit or boot directly — skipping + Steps 5–8 since the config is already complete. diff --git a/genesis.jsonc.example b/genesis.jsonc.example index 79ab11c..8bbd057 100644 --- a/genesis.jsonc.example +++ b/genesis.jsonc.example @@ -22,7 +22,8 @@ // "signer": { // "mode": "nsigner_unix", // local | nsigner_unix | nsigner_tcp | nsigner_serial | nsigner_fds | nsigner_qrexec // "socket_name": "", // nsigner_unix: abstract socket name (without @); "" = auto-discover - // "role": "main", // n_signer role selector + // "role": "main", // n_signer role selector (required for all nsigner_* modes) + // "role_path": "m/44'/1237'/0'/0/0", // full BIP-44 derivation path (required for all nsigner_* modes) // "timeout_ms": 15000, // per-call timeout // "auth_privkey_hex": "", // nsigner_tcp only: kind-27235 auth envelope privkey // "tcp_host": "127.0.0.1", // nsigner_tcp host @@ -31,6 +32,7 @@ // "target_qube": "nostr_signer", // nsigner_qrexec: target Qubes qube name // "service_name": "qubes.NsignerRpc" // nsigner_qrexec: qrexec service name (default qubes.NsignerRpc) // // nsigner_fds: fds_read_fd/fds_write_fd are CLI-only (--signer-fds), NOT persisted here + // // derive_index: CLI-only (--signer-derive-index), NOT persisted here // }, // ─── Encrypted Startup Config Events ─────────────────────────────── diff --git a/plans/update_nostr_core_lib_nsigner.md b/plans/update_nostr_core_lib_nsigner.md new file mode 100644 index 0000000..e462eb3 --- /dev/null +++ b/plans/update_nostr_core_lib_nsigner.md @@ -0,0 +1,147 @@ +# Update nostr_core_lib + n_signer Integration + +## Context + +`n_signer` completed a breaking wire-protocol migration. Our vendored +`nostr_core_lib` (v0.6.10) is incompatible with current `n_signer`. The upstream +lib is now v0.6.15. This plan updates the vendored copy and adapts didactyl to +the new API, plus exposes the new additive features. + +## Breaking changes (mandatory) + +1. **Verb renames** — legacy names removed: `sign_event`→`nostr_sign_event`, + `nip04_encrypt`→`nostr_nip44_encrypt`, etc. (handled inside the lib update) +2. **Selector rewrite** — `nostr_index` is **removed** (rejected with error + `2006 nostr_index_deprecated`). The only accepted selector for `nostr_*` + verbs is now `{"role":"","role_path":""}` sent + **together**. Role-only → `2009 path_required`. + +## Our exposure + +- `nostr_signer_nsigner_set_nostr_index` in `src/main.c:515`, + `src/setup_wizard.c:2513` +- `nostr_index` config field in `src/config.h:152` +- `--signer-index` CLI flag in `src/main.c` +- `nostr_index` persisted to genesis + +All remote-signer operations fail against current n_signer. + +## New APIs (additive, full scope) + +- `nostr_signer_last_error()` — human-readable error from last failed call +- `nostr_signer_nsigner_set_role_path()` / `set_derive_index()` — replace + set_nostr_index +- `nostr_signer_nsigner_from_transport()` / `_from_client()` — flexible + constructors +- `nostr_signer_mine_event()` — NIP-13 PoW through the signer +- Algorithm verbs: `sign`/`verify`/`encapsulate`/`decapsulate`/ + `derive_shared_secret`/`derive`/`encrypt`/`decrypt` (secp256k1, ed25519, + x25519, ml-dsa-65, slh-dsa-128s, ml-kem-768, otp) + +## Architecture + +```mermaid +flowchart TD + A[Genesis/CLI] --> B[signer_config_t: role + role_path + derive_index] + B --> C[construct_signer in main.c] + C --> D[nostr_signer_nsigner_* factory] + D --> E[nostr_signer_nsigner_set_role_path] + E --> F[nostr_signer_get_public_key connectivity check] + F --> G{success?} + G -->|no| H[print nostr_signer_last_error + signer_health_record_failure] + G -->|yes| I[Process-lifetime signer handle] + I --> J[nostr_handler / tools_context / cashu_wallet / block_list] + I --> K[signer_crypto tool + nostr_post PoW] + K --> L{local mode?} + L -->|yes| M[return NOT_SUPPORTED error] + L -->|no| N[route through remote nsigner] +``` + +## Design decisions + +- **Phase 6:** Extend `nostr_post` with optional PoW args (no new tool). PoW is + a posting variant; keeps agent tool count unchanged. +- **Phase 7:** Single unified `signer_crypto` tool with `operation` enum + covering all 10 algorithm verbs. Net +1 tool instead of +10. +- **`role_path` required** for all `nsigner_*` modes (matches n_signer's hard + requirement). +- **`derive_index` is CLI-only** (like `fds`) — not persisted to genesis. +- **Algorithm tools require remote signer** — local backend returns + `NOSTR_ERROR_NOT_SUPPORTED`. + +## Phases + +### Phase 1 — Update vendored nostr_core_lib v0.6.10 → v0.6.15 + +- Copy upstream `nostr_core_lib/` over `didactyl/nostr_core_lib/` +- Verify VERSION=0.6.15 +- Rebuild: `cd nostr_core_lib && ./build.sh --nips=001,004,005,006,011,013,017,019,021,042,044,046,059,060,061` +- Confirm new headers present; `nostr_signer_nsigner_set_nostr_index` gone + +### Phase 2 — Replace nostr_index with role_path in config layer + +- `src/config.h`: remove `nostr_index`; add `role_path[OW_MAX_URL_LEN]`, + `derive_index` (-1 = unset) +- `src/config.c`: remove nostr_index parsing; add role_path parsing; + derive_index is CLI-only +- Update error messages + +### Phase 3 — Update main.c signer construction + CLI flags + +- Remove `--signer-index`; add `--signer-path `, + `--signer-derive-index ` +- `apply_signer_overrides()`: require both role + role_path for nsigner_* modes +- `construct_signer()`: `set_role_path` + `set_derive_index` instead of + `set_nostr_index` +- Surface `nostr_signer_last_error()` in failure paths +- Update `print_usage()` + +### Phase 4 — Update setup_wizard.c + +- `prompt_signer_transport()`: remove nostr_index prompt; add role_path prompt + with presets (NIP-06 default, hardened, custom) +- `wizard_construct_ephemeral_signer()`: set_role_path instead of set_nostr_index +- `install_system_service_with_dedicated_user()`: `--signer-path` in key_args; + shell-escape the path (contains `'`) +- `persist_runtime_config_to_nostr_wizard()`: role_path JSON field instead of + nostr_index + +### Phase 5 — Surface nostr_signer_last_error() everywhere + +- `src/main.c`, `src/nostr_handler.c`, `src/nostr_block_list.c`, + `src/tools/tool_nostr_dm.c`, `tool_memory.c`, `tool_config.c`, + `tool_task.c`, `cashu_wallet.c`, `src/signer_health.c` + +### Phase 6 — Extend nostr_post with optional PoW args + +- Add `difficulty` (default 0), `threads` (default 1), `timeout_sec` (default + 600) to nostr_post schema +- difficulty > 0 + remote signer → `nostr_signer_mine_event` +- difficulty > 0 + local mode → clear error (PoW requires remote n_signer) +- difficulty == 0 → unchanged + +### Phase 7 — Single unified signer_crypto tool + +- `src/tools/tool_signer_crypto.c`: one tool `signer_crypto` with `operation` + enum: get_info | get_public_key | sign | verify | encapsulate | decapsulate | + derive_shared_secret | derive_hmac | otp_encrypt | otp_decrypt +- Operation-specific optional args: algorithm, index, scheme, message_hex, + signature_hex, peer_pubkey_hex, ciphertext_hex, plaintext_b64, encoding, data +- Requires ctx->signer != NULL; clear error otherwise +- Add to tools_internal.h, tools_dispatch.c, tools_schema.c, Makefile SRCS + +### Phase 8 — Docs + schema + +- `genesis.jsonc.example`, `schemas.json`: role_path instead of nostr_index +- `docs/SIGNER.md`: role+role_path, --signer-path, signer_crypto tool, PoW, + nostr_index removed (breaking) +- `plans/wizard_full_nsigner_transports.md`: mark superseded + +### Phase 9 — Build, test, verify + +- `make clean && make deps && make` +- Smoke test with n_signer: `--signer nsigner_unix --signer-socket + --signer-role main --signer-path "m/44'/1237'/0'/0/0"` +- Test signer_crypto (get_info, sign+verify roundtrip) +- Test nostr_post with difficulty=4 +- Run `tests/run_tests.py` diff --git a/src/config.c b/src/config.c index 50aa30c..bdc7366 100644 --- a/src/config.c +++ b/src/config.c @@ -280,16 +280,19 @@ static int parse_signer_config(cJSON* root, didactyl_config_t* config) { } } - /* Optional nostr_index: numeric key index selector. When set (>= 0) it - * overrides role on the n_signer side. -1 (default) means use role. */ - cJSON* nostr_index = cJSON_GetObjectItemCaseSensitive(signer, "nostr_index"); - if (nostr_index && cJSON_IsNumber(nostr_index)) { - int idx = (int)nostr_index->valuedouble; - if (idx >= 0) { - config->signer.nostr_index = idx; - } + /* role_path: full BIP-44 derivation path (e.g. "m/44'/1237'/0'/0/0"). + * Required for all nsigner_* modes — n_signer now rejects role-only + * selectors with 2009 path_required and bare nostr_index with 2006 + * nostr_index_deprecated. The role+role_path pair is sent together. */ + if (copy_json_string(signer, "role_path", config->signer.role_path, + sizeof(config->signer.role_path), 0) != 0) { + config_set_error("signer.role_path must be a string when provided"); + return -1; } + /* derive_index is intentionally NOT parsed from JSON — it is CLI/runtime- + * only (like fds_read_fd/fds_write_fd) and cannot be persisted to genesis. */ + if (copy_json_string(signer, "auth_privkey_hex", config->signer.auth_privkey_hex, sizeof(config->signer.auth_privkey_hex), 0) != 0) { config_set_error("signer.auth_privkey_hex must be a string when provided"); @@ -1625,7 +1628,8 @@ int config_load(const char* path, didactyl_config_t* config) { snprintf(config->signer.mode, sizeof(config->signer.mode), "%s", "local"); config->signer.socket_name[0] = '\0'; snprintf(config->signer.role, sizeof(config->signer.role), "%s", "main"); - config->signer.nostr_index = -1; /* -1 = unset; use role selector */ + config->signer.role_path[0] = '\0'; /* required for nsigner_* modes; set via --signer-path or signer.role_path */ + config->signer.derive_index = -1; /* -1 = unset; CLI-only (--signer-derive-index) */ config->signer.timeout_ms = 15000; config->signer.auth_privkey_hex[0] = '\0'; config->signer.tcp_host[0] = '\0'; diff --git a/src/config.h b/src/config.h index a5c8fef..13a6b69 100644 --- a/src/config.h +++ b/src/config.h @@ -148,8 +148,12 @@ typedef struct { typedef struct { char mode[OW_MAX_SIGNER_MODE_LEN]; /* "local" | "nsigner_unix" | "nsigner_tcp" | "nsigner_serial" | "nsigner_fds" | "nsigner_qrexec" */ char socket_name[OW_MAX_SIGNER_SOCKET_LEN]; /* nsigner_unix: abstract socket name (without @); "" = auto-discover */ - char role[OW_MAX_SIGNER_ROLE_LEN]; /* n_signer role selector (default "main") */ - int nostr_index; /* n_signer key index selector (-1 = unset; use role). When set, overrides role. */ + char role[OW_MAX_SIGNER_ROLE_LEN]; /* n_signer role selector (default "main"). Required for all nsigner_* modes. */ + char role_path[OW_MAX_URL_LEN]; /* full BIP-44 derivation path (e.g. "m/44'/1237'/0'/0/0"). Required for + * all nsigner_* modes — n_signer rejects role-only with 2009 path_required + * and bare nostr_index with 2006 nostr_index_deprecated. */ + int derive_index; /* algorithm index for the derive verb (HMAC). -1 = unset (default). + * CLI/runtime-only — NOT persisted to genesis. Set via --signer-derive-index. */ int timeout_ms; /* per-call timeout (default 15000) */ char auth_privkey_hex[OW_MAX_SIGNER_AUTH_HEX_LEN]; /* optional, TCP auth envelope only */ /* nsigner_tcp parsed host/port (populated from --signer-tcp or config) */ diff --git a/src/default_events.h b/src/default_events.h index cd9d2b4..92bfad1 100644 --- a/src/default_events.h +++ b/src/default_events.h @@ -30,9 +30,11 @@ static const int DIDACTYL_DEFAULT_STARTUP_EVENT_KINDS[] = { #define DIDACTYL_DEFAULT_KIND10050_CONTENT "" static const char* DIDACTYL_DEFAULT_RELAYS[] = { + "ws://localhost:7777", "wss://relay.damus.io", "wss://nos.lol", - "wss://relay.primal.net" + "wss://relay.primal.net", + "wss://relay.zapstore.dev" }; #define DIDACTYL_DEFAULT_RELAY_COUNT ((int)(sizeof(DIDACTYL_DEFAULT_RELAYS) / sizeof(DIDACTYL_DEFAULT_RELAYS[0]))) diff --git a/src/main.c b/src/main.c index 138b8db..e3a2164 100644 --- a/src/main.c +++ b/src/main.c @@ -188,8 +188,11 @@ static void print_usage(const char* prog) { " qube name (service defaults to qubes.NsignerRpc).\n" " --signer-service \n" " qrexec service name for nsigner_qrexec (default: qubes.NsignerRpc).\n" - " --signer-index \n" - " n_signer key index selector (overrides --signer-role). -1 = use role.\n" + " --signer-path \n" + " Full BIP-44 derivation path (e.g. m/44'/1237'/0'/0/0). REQUIRED for\n" + " all nsigner_* modes — n_signer rejects role-only with 2009 path_required.\n" + " --signer-derive-index \n" + " Algorithm index for the derive (HMAC) verb. Runtime-only; not persisted.\n" " --dump-schemas\n" " Print tool schemas JSON and exit.\n" " --test-tool \n" @@ -215,16 +218,20 @@ static void print_usage(const char* prog) { " %s --config genesis.jsonc --api-bind 127.0.0.1 --api-port 8484\n" "\n" " 5) Sign with a running n_signer (separate the nsec from the agent)\n" - " %s --signer nsigner_unix --signer-socket nsigner_hairy_dog --admin npub1...\n" + " %s --signer nsigner_unix --signer-socket nsigner_hairy_dog \\\n" + " --signer-role main --signer-path \"m/44'/1237'/0'/0/0\" --admin npub1...\n" "\n" " 6) n_signer via TCP\n" - " %s --signer-tcp 127.0.0.1:7777 --admin npub1...\n" + " %s --signer-tcp 127.0.0.1:7777 --signer-role main \\\n" + " --signer-path \"m/44'/1237'/0'/0/0\" --admin npub1...\n" "\n" " 7) n_signer via USB serial\n" - " %s --signer-serial /dev/ttyACM0 --admin npub1...\n" + " %s --signer-serial /dev/ttyACM0 --signer-role main \\\n" + " --signer-path \"m/44'/1237'/0'/0/0\" --admin npub1...\n" "\n" " 8) n_signer in another Qubes qube (qrexec, no network)\n" - " %s --signer-qrexec nostr_signer --admin npub1...\n" + " %s --signer-qrexec nostr_signer --signer-role main \\\n" + " --signer-path \"m/44'/1237'/0'/0/0\" --admin npub1...\n" "\n" " 9) Dump tool schemas\n" " %s --dump-schemas\n" @@ -311,7 +318,8 @@ static int apply_signer_overrides(didactyl_config_t* cfg, const char* cli_fds, const char* cli_qrexec, const char* cli_service, - int cli_index) { + const char* cli_path, + int cli_derive_index) { if (!cfg) { return -1; } @@ -400,10 +408,36 @@ static int apply_signer_overrides(didactyl_config_t* cfg, if (cli_timeout > 0) { cfg->signer.timeout_ms = cli_timeout; } - /* --signer-index overrides the genesis nostr_index. -1 means unset (use role). - * Any non-negative value selects a specific key index on the n_signer side. */ - if (cli_index >= 0) { - cfg->signer.nostr_index = cli_index; + /* --signer-path overrides the genesis role_path. Required for all nsigner_* + * modes — n_signer now rejects role-only selectors with 2009 path_required + * and bare nostr_index with 2006 nostr_index_deprecated. */ + if (cli_path && cli_path[0] != '\0') { + snprintf(cfg->signer.role_path, sizeof(cfg->signer.role_path), "%s", cli_path); + } + /* --signer-derive-index sets the algorithm index for the derive (HMAC) verb. + * Runtime-only; not persisted to genesis. -1 means unset. */ + if (cli_derive_index >= 0) { + cfg->signer.derive_index = cli_derive_index; + } + + /* For all remote (nsigner_*) modes, require BOTH role and role_path. + * n_signer rejects role-only with 2009 path_required and path-only with + * 2008 role_required. This catches misconfiguration early with a clear + * message instead of a cryptic RPC error at first sign attempt. */ + if (signer_mode_is_remote_local(mode)) { + if (cfg->signer.role[0] == '\0') { + fprintf(stderr, "%s mode requires --signer-role or signer.role in " + "config (n_signer rejects path-only with 2008 role_required)\n", + mode); + return -1; + } + if (cfg->signer.role_path[0] == '\0') { + fprintf(stderr, "%s mode requires --signer-path or signer.role_path " + "in config (n_signer rejects role-only with 2009 path_required). " + "Example: --signer-path \"m/44'/1237'/0'/0/0\"\n", + mode); + return -1; + } } return 0; @@ -510,11 +544,20 @@ static nostr_signer_t* construct_signer(didactyl_config_t* cfg) { return NULL; } - /* Apply nostr_index selector when set (overrides role on the n_signer side). */ - if (signer && cfg->signer.nostr_index >= 0) { - if (nostr_signer_nsigner_set_nostr_index(signer, cfg->signer.nostr_index) != NOSTR_SUCCESS) { - fprintf(stderr, "Warning: failed to set nostr_index=%d on signer (mode=%s)\n", - cfg->signer.nostr_index, mode); + /* Apply role_path selector (required for all nsigner_* modes — n_signer + * rejects role-only with 2009 path_required). The role was already passed + * to the factory constructor; set_role_path adds the full BIP-44 path. */ + if (signer && cfg->signer.role_path[0] != '\0') { + if (nostr_signer_nsigner_set_role_path(signer, cfg->signer.role_path) != NOSTR_SUCCESS) { + fprintf(stderr, "Warning: failed to set role_path='%s' on signer (mode=%s)\n", + cfg->signer.role_path, mode); + } + } + /* Apply derive_index for the algorithm-based derive (HMAC) verb. -1 = unset. */ + if (signer && cfg->signer.derive_index >= 0) { + if (nostr_signer_nsigner_set_derive_index(signer, cfg->signer.derive_index) != NOSTR_SUCCESS) { + fprintf(stderr, "Warning: failed to set derive_index=%d on signer (mode=%s)\n", + cfg->signer.derive_index, mode); } } @@ -522,9 +565,10 @@ static nostr_signer_t* construct_signer(didactyl_config_t* cfg) { if (!signer) { const char* sock = cfg->signer.socket_name[0] ? cfg->signer.socket_name : ""; fprintf(stderr, "Failed to construct remote signer (mode=%s, socket=%s, role=%s, " - "timeout_ms=%d): nostr_signer_nsigner_* returned NULL\n", + "role_path=%s, timeout_ms=%d): nostr_signer_nsigner_* returned NULL\n", mode, strcmp(mode, "nsigner_unix") == 0 ? sock : "", cfg->signer.role[0] ? cfg->signer.role : "main", + cfg->signer.role_path[0] ? cfg->signer.role_path : "", cfg->signer.timeout_ms); signer_health_record_failure(NOSTR_ERROR_IO_FAILED, "signer construction returned NULL"); return NULL; @@ -535,13 +579,20 @@ static nostr_signer_t* construct_signer(didactyl_config_t* cfg) { int gpk_rc = nostr_signer_get_public_key(signer, pubkey_hex); if (gpk_rc != NOSTR_SUCCESS) { const char* sock = cfg->signer.socket_name[0] ? cfg->signer.socket_name : ""; + const char* last_err = nostr_signer_last_error(signer); fprintf(stderr, "Signer connectivity check failed (mode=%s, socket=%s, role=%s, " - "timeout_ms=%d, rc=%d): could not retrieve public key from n_signer. " - "Is the n_signer process running and reachable?\n", + "role_path=%s, timeout_ms=%d, rc=%d): could not retrieve public key " + "from n_signer. Is the n_signer process running and reachable?\n", mode, strcmp(mode, "nsigner_unix") == 0 ? sock : "", cfg->signer.role[0] ? cfg->signer.role : "main", + cfg->signer.role_path[0] ? cfg->signer.role_path : "", cfg->signer.timeout_ms, gpk_rc); - signer_health_record_failure(gpk_rc, "startup connectivity check (get_public_key)"); + if (last_err && last_err[0] != '\0') { + fprintf(stderr, " n_signer error: %s\n", last_err); + signer_health_record_failure(gpk_rc, last_err); + } else { + signer_health_record_failure(gpk_rc, "startup connectivity check (get_public_key)"); + } nostr_signer_free(signer); return NULL; } @@ -1602,7 +1653,8 @@ int main(int argc, char** argv) { const char* cli_signer_fds = NULL; /* read_fd:write_fd shorthand */ const char* cli_signer_qrexec = NULL; /* target qube shorthand */ const char* cli_signer_service = NULL; /* qrexec service name */ - int cli_signer_index = -1; /* n_signer key index (-1 = unset) */ + const char* cli_signer_path = NULL; /* full BIP-44 derivation path */ + int cli_signer_derive_index = -1; /* derive verb algorithm index (-1 = unset) */ didactyl_config_t cfg; memset(&cfg, 0, sizeof(cfg)); nostr_signer_t* g_signer = NULL; @@ -1685,8 +1737,10 @@ int main(int argc, char** argv) { cli_signer_qrexec = argv[++i]; } else if (strcmp(argv[i], "--signer-service") == 0 && i + 1 < argc) { cli_signer_service = argv[++i]; - } else if (strcmp(argv[i], "--signer-index") == 0 && i + 1 < argc) { - cli_signer_index = atoi(argv[++i]); + } else if (strcmp(argv[i], "--signer-path") == 0 && i + 1 < argc) { + cli_signer_path = argv[++i]; + } else if (strcmp(argv[i], "--signer-derive-index") == 0 && i + 1 < argc) { + cli_signer_derive_index = atoi(argv[++i]); } else if (strcmp(argv[i], "--dump-schemas") == 0) { dump_schemas = 1; } else if (strcmp(argv[i], "--test-tool") == 0 && i + 2 < argc) { @@ -1737,7 +1791,8 @@ int main(int argc, char** argv) { cli_signer_role, cli_signer_timeout, cli_signer_tcp, cli_signer_serial, cli_signer_fds, cli_signer_qrexec, - cli_signer_service, cli_signer_index) != 0) { + cli_signer_service, cli_signer_path, + cli_signer_derive_index) != 0) { config_free(&cfg); nostr_cleanup(); return 1; diff --git a/src/main.h b/src/main.h index 2530082..068e42e 100644 --- a/src/main.h +++ b/src/main.h @@ -12,8 +12,8 @@ // Using DIDACTYL_ prefix to avoid conflicts with nostr_core_lib VERSION macros #define DIDACTYL_VERSION_MAJOR 0 #define DIDACTYL_VERSION_MINOR 2 -#define DIDACTYL_VERSION_PATCH 55 -#define DIDACTYL_VERSION "v0.2.55" +#define DIDACTYL_VERSION_PATCH 56 +#define DIDACTYL_VERSION "v0.2.56" // Agent metadata #define DIDACTYL_NAME "Didactyl" diff --git a/src/nostr_block_list.c b/src/nostr_block_list.c index c68bbd4..015ab3b 100644 --- a/src/nostr_block_list.c +++ b/src/nostr_block_list.c @@ -204,6 +204,9 @@ static int decrypt_private_tags_array(const char* encrypted_content, cJSON** out encrypted_content, &plaintext); if (rc != NOSTR_SUCCESS || !plaintext) { + const char* le = nostr_signer_last_error(g_block_signer); + DEBUG_ERROR("[block_list] nip44_decrypt failed (rc=%d): %s", + rc, (le && le[0]) ? le : ""); return -1; } plain_src = plaintext; @@ -254,6 +257,9 @@ static int encrypt_private_tags_array(cJSON* private_tags_array, char** out_cont &cipher); free(plain); if (rc != NOSTR_SUCCESS || !cipher) { + const char* le = nostr_signer_last_error(g_block_signer); + DEBUG_ERROR("[block_list] nip44_encrypt failed (rc=%d): %s", + rc, (le && le[0]) ? le : ""); return -1; } *out_content = cipher; diff --git a/src/nostr_handler.c b/src/nostr_handler.c index 06c6faa..ff72a88 100644 --- a/src/nostr_handler.c +++ b/src/nostr_handler.c @@ -1063,7 +1063,8 @@ static int nip44_encrypt_self_local(const char* plaintext, char** out_ciphertext plain, &signer_out); if (rc != NOSTR_SUCCESS || !signer_out) { - signer_health_record_failure(rc, "nip44_encrypt(self)"); + const char* le = nostr_signer_last_error(g_auth_signer); + signer_health_record_failure(rc, (le && le[0]) ? le : "nip44_encrypt(self)"); return -1; } signer_health_record_ok(); @@ -1105,7 +1106,8 @@ static int nip44_decrypt_self_local(const char* ciphertext, char** out_plaintext ciphertext, &signer_out); if (rc != NOSTR_SUCCESS || !signer_out) { - signer_health_record_failure(rc, "nip44_decrypt(self)"); + const char* le = nostr_signer_last_error(g_auth_signer); + signer_health_record_failure(rc, (le && le[0]) ? le : "nip44_decrypt(self)"); return -1; } signer_health_record_ok(); @@ -1971,8 +1973,10 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) { content->valuestring, &signer_out); if (dec_rc != NOSTR_SUCCESS || !signer_out) { - signer_health_record_failure(dec_rc, "nip04_decrypt(incoming DM)"); - fprintf(stdout, "[didactyl] failed to decrypt incoming DM from %.16s...\n", sender_pubkey_hex); + const char* le = nostr_signer_last_error(g_auth_signer); + signer_health_record_failure(dec_rc, (le && le[0]) ? le : "nip04_decrypt(incoming DM)"); + fprintf(stdout, "[didactyl] failed to decrypt incoming DM from %.16s... (rc=%d, err=%s)\n", + sender_pubkey_hex, dec_rc, (le && le[0]) ? le : ""); return; } signer_health_record_ok(); @@ -2592,6 +2596,10 @@ void nostr_handler_set_signer(nostr_signer_t* signer) { g_auth_signer = signer; } +nostr_signer_t* nostr_handler_get_signer(void) { + return g_auth_signer; +} + int nostr_handler_init(didactyl_config_t* config) { if (!config) { return -1; @@ -3239,7 +3247,8 @@ int nostr_handler_send_dm_with_role(const char* recipient_pubkey_hex, message, &encrypted); if (enc_rc != NOSTR_SUCCESS || !encrypted) { - signer_health_record_failure(enc_rc, "nip04_encrypt(outgoing DM)"); + const char* le = nostr_signer_last_error(g_auth_signer); + signer_health_record_failure(enc_rc, (le && le[0]) ? le : "nip04_encrypt(outgoing DM)"); return -1; } signer_health_record_ok(); @@ -3513,6 +3522,62 @@ int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags, return rc; } +/* Publish a pre-signed event (e.g. from nostr_signer_mine_event) through the + * relay pool. Returns 0 on success, -1 on failure. */ +int nostr_handler_publish_event(cJSON* signed_event, nostr_publish_result_t* out_result) { + if (!g_cfg || !g_pool || !signed_event) { + return -1; + } + + log_publish_targets("publish pre-signed event"); + + const char** connected_relays = (const char**)calloc((size_t)g_cfg->relay_count, sizeof(char*)); + if (!connected_relays) { + return -1; + } + + int connected_count = 0; + for (int i = 0; i < g_cfg->relay_count; i++) { + if (nostr_relay_pool_get_relay_status(g_pool, g_cfg->relays[i]) == NOSTR_POOL_RELAY_CONNECTED) { + connected_relays[connected_count++] = g_cfg->relays[i]; + } + } + + if (connected_count <= 0) { + DEBUG_WARN("[didactyl] pre-signed event not queued: no connected relays"); + free(connected_relays); + return -1; + } + + cJSON* event_copy = cJSON_Duplicate(signed_event, 1); + if (!event_copy) { + free(connected_relays); + return -1; + } + + int sent = nostr_relay_pool_publish_async( + g_pool, + connected_relays, + connected_count, + event_copy, + NULL, + NULL); + + cJSON* kind_json = cJSON_GetObjectItemCaseSensitive(signed_event, "kind"); + int kind = (kind_json && cJSON_IsNumber(kind_json)) ? (int)kind_json->valuedouble : 0; + cJSON* event_id = cJSON_GetObjectItemCaseSensitive(signed_event, "id"); + const char* event_id_hex = (event_id && cJSON_IsString(event_id) && event_id->valuestring) + ? event_id->valuestring + : ""; + + fill_publish_result(out_result, kind, NULL, event_id_hex, + connected_relays, connected_count, sent); + + cJSON_Delete(event_copy); + free(connected_relays); + return sent > 0 ? 0 : -1; +} + char* nostr_handler_query_json(cJSON* filter, int timeout_ms) { if (!g_cfg || !g_pool || !filter) { return NULL; diff --git a/src/nostr_handler.h b/src/nostr_handler.h index 40dfb8d..1d81960 100644 --- a/src/nostr_handler.h +++ b/src/nostr_handler.h @@ -48,6 +48,9 @@ int nostr_handler_init(didactyl_config_t* config); * nostr_handler_cleanup. If not called, the legacy raw-key AUTH path is used * (backward compatible). */ void nostr_handler_set_signer(nostr_signer_t* signer); +/* Returns the process-lifetime signer handle (may be NULL in local mode). + * Safe to call at any point after nostr_handler_set_signer. */ +nostr_signer_t* nostr_handler_get_signer(void); void nostr_handler_set_trigger_manager(struct trigger_manager* trigger_manager); int nostr_handler_subscribe_admin_context(void); @@ -66,6 +69,10 @@ int nostr_handler_send_dm_auto_with_role(const char* recipient_pubkey_hex, const char* message, didactyl_dm_history_role_t role); int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags, nostr_publish_result_t* out_result); +/* Publish a pre-signed event (e.g. from nostr_signer_mine_event) through the + * relay pool. The event must have id, pubkey, sig, and all required fields + * already populated. Returns 0 on success, -1 on failure. */ +int nostr_handler_publish_event(cJSON* signed_event, nostr_publish_result_t* out_result); void nostr_handler_publish_result_free(nostr_publish_result_t* result); char* nostr_handler_query_json(cJSON* filter, int timeout_ms); nostr_pool_subscription_t* nostr_handler_subscribe_with_filter( diff --git a/src/setup_wizard.c b/src/setup_wizard.c index 46dc59d..a7daba4 100644 --- a/src/setup_wizard.c +++ b/src/setup_wizard.c @@ -331,7 +331,8 @@ static void config_set_defaults(didactyl_config_t* cfg) { * signer.mode after the wizard returns BOOTSTRAP for local paths. */ snprintf(cfg->signer.mode, sizeof(cfg->signer.mode), "%s", "local"); snprintf(cfg->signer.role, sizeof(cfg->signer.role), "%s", "main"); - cfg->signer.nostr_index = -1; /* -1 = unset; use role selector */ + cfg->signer.role_path[0] = '\0'; /* required for nsigner_* modes; set via --signer-path or signer.role_path */ + cfg->signer.derive_index = -1; /* -1 = unset; CLI-only (--signer-derive-index) */ cfg->signer.timeout_ms = 15000; } @@ -1281,8 +1282,8 @@ static int persist_runtime_config_to_nostr_wizard(const didactyl_config_t* cfg, cJSON_AddStringToObject(signer_obj, "mode", cfg->signer.mode); cJSON_AddStringToObject(signer_obj, "socket_name", cfg->signer.socket_name); cJSON_AddStringToObject(signer_obj, "role", cfg->signer.role); - if (cfg->signer.nostr_index >= 0) { - cJSON_AddNumberToObject(signer_obj, "nostr_index", cfg->signer.nostr_index); + if (cfg->signer.role_path[0] != '\0') { + cJSON_AddStringToObject(signer_obj, "role_path", cfg->signer.role_path); } cJSON_AddNumberToObject(signer_obj, "timeout_ms", cfg->signer.timeout_ms); cJSON_AddItemToObject(user_settings, "signer", signer_obj); @@ -1614,23 +1615,56 @@ static int prompt_admin_pubkey_with_header(didactyl_config_t* cfg, char input[WIZARD_LINE_MAX]; for (;;) { render_wizard_page_header(step_title, section_title); - if (read_line_prompt(" Enter the admin's Nostr public key (npub1... or hex):\n> ", input, sizeof(input)) != 0) { - return -1; - } - if (line_is_quit(input)) return -1; + fprintf(stderr, " Enter the admin's Nostr public key, or generate a fresh one.\n\n"); + print_option('e', "nter an existing npub1... or hex pubkey"); + print_option('g', "enerate a fresh admin keypair"); + print_option('q', "uit"); + wizard_option_t opts[] = {{'e', ""}, {'g', ""}, {'q', ""}}; + char c = read_menu_choice(opts, 3); + if (c == 'q') return -1; - char hex[65] = {0}; - if (decode_pubkey_hex_or_npub_local(input, hex) != 0) { - fprintf(stderr, "%sInvalid admin pubkey. Use npub1... or 64-char hex.%s\n", ANSI_RED, ANSI_RESET); - continue; + if (c == 'g') { + unsigned char priv[32], pub[32]; + if (nostr_generate_keypair(priv, pub) != 0) { + fprintf(stderr, "%sFailed to generate keypair.%s\n", ANSI_RED, ANSI_RESET); + continue; + } + char nsec[OW_MAX_KEY_LEN] = {0}; + char npub[OW_MAX_KEY_LEN] = {0}; + if (nostr_key_to_bech32(priv, "nsec", nsec) != 0 || nostr_key_to_bech32(pub, "npub", npub) != 0) { + fprintf(stderr, "%sFailed to encode generated keypair.%s\n", ANSI_RED, ANSI_RESET); + continue; + } + nostr_bytes_to_hex(pub, 32, cfg->admin.pubkey); + fprintf(stderr, "\n Generated admin identity:\n"); + fprintf(stderr, " npub: %s\n", npub); + fprintf(stderr, "%s nsec: %s%s\n", ANSI_YELLOW, nsec, ANSI_RESET); + fprintf(stderr, "%s Save this nsec now. It will not be persisted.%s\n", ANSI_YELLOW, ANSI_RESET); + char ok[WIZARD_LINE_MAX] = {0}; + if (read_line_prompt(" Type 'ok' when saved (or q to quit): ", ok, sizeof(ok)) != 0) return -1; + if (line_is_quit(ok)) return -1; + return 0; + } + + if (c == 'e') { + if (read_line_prompt(" Admin pubkey (npub1... or hex):\n> ", input, sizeof(input)) != 0) { + return -1; + } + if (line_is_quit(input)) return -1; + + char hex[65] = {0}; + if (decode_pubkey_hex_or_npub_local(input, hex) != 0) { + fprintf(stderr, "%sInvalid admin pubkey. Use npub1... or 64-char hex.%s\n", ANSI_RED, ANSI_RESET); + continue; + } + snprintf(cfg->admin.pubkey, sizeof(cfg->admin.pubkey), "%s", hex); + return 0; } - snprintf(cfg->admin.pubkey, sizeof(cfg->admin.pubkey), "%s", hex); - return 0; } } static int prompt_admin_pubkey(didactyl_config_t* cfg) { - return prompt_admin_pubkey_with_header(cfg, "Step 3 of 7", "New Agent Setup -- Administrator"); + return prompt_admin_pubkey_with_header(cfg, "Step 5 of 8", "New Agent Setup -- Administrator"); } static int prompt_llm_config_with_header(didactyl_config_t* cfg, @@ -1667,7 +1701,7 @@ static int prompt_llm_config_with_header(didactyl_config_t* cfg, } char api_key[OW_MAX_KEY_LEN] = {0}; - if (read_secret_prompt(" API Key: ", api_key, sizeof(api_key)) != 0) return -1; + if (read_line_prompt(" API Key: ", api_key, sizeof(api_key)) != 0) return -1; if (line_is_quit(api_key)) return -1; llm_config_t probe = cfg->llm; @@ -1759,71 +1793,160 @@ static int prompt_llm_config_with_header(didactyl_config_t* cfg, } static int prompt_llm_config(didactyl_config_t* cfg) { - return prompt_llm_config_with_header(cfg, "Step 5 of 7", "New Agent Setup -- LLM Provider"); + return prompt_llm_config_with_header(cfg, "Step 6 of 8", "New Agent Setup -- LLM Provider"); } +#define RELAY_TOGGLE_MAX 64 + static int prompt_relay_configuration_with_header(didactyl_config_t* cfg, const char* step_title, const char* section_title) { char input[WIZARD_LINE_MAX]; + /* Build a local list of all known relays with enabled/disabled state. + * Start with the current cfg->relays (all enabled). */ + char* all_relays[RELAY_TOGGLE_MAX]; + int relay_enabled[RELAY_TOGGLE_MAX]; + int all_count = 0; + + /* Seed from the current config relays (all enabled). */ + for (int i = 0; i < cfg->relay_count && all_count < RELAY_TOGGLE_MAX; i++) { + all_relays[all_count] = strdup(cfg->relays[i] ? cfg->relays[i] : ""); + relay_enabled[all_count] = 1; + all_count++; + } + + /* Add any default relays that aren't already in the list (disabled by default). */ + for (int i = 0; i < DIDACTYL_DEFAULT_RELAY_COUNT && all_count < RELAY_TOGGLE_MAX; i++) { + int found = 0; + for (int j = 0; j < all_count; j++) { + if (all_relays[j] && strcmp(all_relays[j], DIDACTYL_DEFAULT_RELAYS[i]) == 0) { + found = 1; + break; + } + } + if (!found) { + all_relays[all_count] = strdup(DIDACTYL_DEFAULT_RELAYS[i]); + relay_enabled[all_count] = 0; + all_count++; + } + } for (;;) { render_wizard_page_header(step_title, section_title); - fprintf(stderr, " Current relays:\n"); - for (int i = 0; i < cfg->relay_count; i++) { - fprintf(stderr, " %d. %s\n", i + 1, cfg->relays[i] ? cfg->relays[i] : ""); + fprintf(stderr, " Relays (enter a number to toggle, [X] = enabled):\n"); + for (int i = 0; i < all_count; i++) { + fprintf(stderr, " %d. [%c] %s\n", i + 1, + relay_enabled[i] ? 'X' : ' ', + all_relays[i] ? all_relays[i] : ""); } fprintf(stderr, "\n"); - print_option('a', "dd a relay"); - print_option('r', "emove a relay (by number)"); - print_option('d', "one -- use this list"); - print_option('b', "ack"); - print_option('q', "uit"); + fprintf(stderr, " Commands: a=add, d=done, b=back, q=quit\n"); + if (read_line_prompt("> ", input, sizeof(input)) != 0) return -1; + if (line_is_quit(input)) return -1; - wizard_option_t opts[] = { - {'a', "dd"}, {'r', "emove"}, {'d', "one"}, {'b', "ack"}, {'q', "uit"} - }; - char c = read_menu_choice(opts, 5); - if (c == 'q') return -1; - if (c == 'b') return 1; - if (c == 'd') { - if (cfg->relay_count <= 0) { - fprintf(stderr, "%sAt least one relay is required.%s\n", ANSI_RED, ANSI_RESET); - continue; - } - return 0; + /* Trim whitespace. */ + char* trimmed = input; + while (*trimmed == ' ') trimmed++; + if (trimmed[0] == '\0') continue; + + /* Check if it's a number (toggle a relay). */ + int is_number = 1; + for (size_t i = 0; trimmed[i] != '\0'; i++) { + if (!isdigit((unsigned char)trimmed[i])) { is_number = 0; break; } } - if (c == 'a') { - if (read_line_prompt("Relay URL (wss:// or ws://): ", input, sizeof(input)) != 0) return -1; - if (line_is_quit(input)) return -1; - if (!(strncmp(input, "wss://", 6) == 0 || strncmp(input, "ws://", 5) == 0)) { - fprintf(stderr, "%sRelay must start with wss:// or ws://%s\n", ANSI_RED, ANSI_RESET); - continue; - } - if (relay_add(cfg, input) != 0) { - fprintf(stderr, "%sFailed to add relay.%s\n", ANSI_RED, ANSI_RESET); + if (is_number) { + int idx = atoi(trimmed) - 1; + if (idx >= 0 && idx < all_count) { + relay_enabled[idx] = !relay_enabled[idx]; + } else { + fprintf(stderr, "%sInvalid relay number (1-%d).%s\n", ANSI_RED, all_count, ANSI_RESET); } continue; } - if (c == 'r') { - if (cfg->relay_count <= 1) { - fprintf(stderr, "%sCannot remove last relay.%s\n", ANSI_RED, ANSI_RESET); + + char c = (char)tolower((unsigned char)trimmed[0]); + + if (c == 'q') { + for (int i = 0; i < all_count; i++) free(all_relays[i]); + return -1; + } + if (c == 'b') { + for (int i = 0; i < all_count; i++) free(all_relays[i]); + return 1; + } + if (c == 'd') { + /* Count enabled relays. */ + int enabled_count = 0; + for (int i = 0; i < all_count; i++) { + if (relay_enabled[i]) enabled_count++; + } + if (enabled_count <= 0) { + fprintf(stderr, "%sAt least one relay must be enabled.%s\n", ANSI_RED, ANSI_RESET); continue; } - if (read_line_prompt("Remove which number? ", input, sizeof(input)) != 0) return -1; - if (line_is_quit(input)) return -1; - int idx = atoi(input) - 1; - if (relay_remove_index(cfg, idx) != 0) { - fprintf(stderr, "%sInvalid relay number.%s\n", ANSI_RED, ANSI_RESET); + /* Rebuild cfg->relays from enabled entries. */ + free_relays_only(cfg); + cfg->relays = (char**)calloc((size_t)enabled_count, sizeof(char*)); + if (!cfg->relays) { + for (int i = 0; i < all_count; i++) free(all_relays[i]); + return -1; } + int idx = 0; + for (int i = 0; i < all_count; i++) { + if (relay_enabled[i]) { + cfg->relays[idx] = strdup(all_relays[i]); + if (!cfg->relays[idx]) { + free_relays_only(cfg); + for (int j = 0; j < all_count; j++) free(all_relays[j]); + return -1; + } + idx++; + } + } + cfg->relay_count = enabled_count; + for (int i = 0; i < all_count; i++) free(all_relays[i]); + return 0; + } + if (c == 'a') { + if (read_line_prompt(" Relay URL (wss:// or ws://): ", input, sizeof(input)) != 0) { + for (int i = 0; i < all_count; i++) free(all_relays[i]); + return -1; + } + if (line_is_quit(input)) { + for (int i = 0; i < all_count; i++) free(all_relays[i]); + return -1; + } + /* Trim. */ + trimmed = input; + while (*trimmed == ' ') trimmed++; + if (trimmed[0] == '\0') continue; + if (!(strncmp(trimmed, "wss://", 6) == 0 || strncmp(trimmed, "ws://", 5) == 0)) { + fprintf(stderr, "%sRelay must start with wss:// or ws://%s\n", ANSI_RED, ANSI_RESET); + continue; + } + /* Check if already in the list. */ + int found = 0; + for (int i = 0; i < all_count; i++) { + if (all_relays[i] && strcmp(all_relays[i], trimmed) == 0) { + relay_enabled[i] = 1; + found = 1; + break; + } + } + if (!found) { + if (all_count >= RELAY_TOGGLE_MAX) { + fprintf(stderr, "%sMaximum relay count reached.%s\n", ANSI_RED, ANSI_RESET); + continue; + } + all_relays[all_count] = strdup(trimmed); + relay_enabled[all_count] = 1; + all_count++; + } + continue; } } } -static int prompt_relay_configuration(didactyl_config_t* cfg) { - return prompt_relay_configuration_with_header(cfg, "Step 6 of 7", "New Agent Setup -- Relay Configuration"); -} - static int run_command_local(char* const argv[]) { pid_t pid = fork(); if (pid < 0) return -1; @@ -2096,19 +2219,21 @@ static int install_system_service_with_dedicated_user(const didactyl_config_t* c /* Build the key/signer argument tail for ExecStart. * - local mode: embeds the nsec directly (legacy behavior). - * - nsigner_unix mode: passes --signer/--signer-socket/--signer-role/--signer-timeout - * so the service process reconstructs the remote signer without holding the nsec. - * - nsigner_tcp mode: passes --signer-tcp host:port plus role/timeout. - * - nsigner_serial mode: passes --signer-serial plus role/timeout. - * - nsigner_qrexec mode: passes --signer-qrexec plus role/timeout. + * - nsigner_unix mode: passes --signer/--signer-socket/--signer-role/--signer-path/ + * --signer-timeout so the service process reconstructs the remote signer without + * holding the nsec. + * - nsigner_tcp mode: passes --signer-tcp host:port plus role/path/timeout. + * - nsigner_serial mode: passes --signer-serial plus role/path/timeout. + * - nsigner_qrexec mode: passes --signer-qrexec plus role/path/timeout. * - nsigner_fds mode: NOT installable as a systemd service (fds are * runtime-only); rejected by the caller before reaching here. * The agent pubkey, admin, LLM and relays are recovered from Nostr at boot. */ - char key_args[768] = {0}; - /* Build the optional --signer-index tail once; appended to all remote modes. */ - char index_tail[32] = {0}; - if (cfg->signer.nostr_index >= 0) { - snprintf(index_tail, sizeof(index_tail), " --signer-index %d", cfg->signer.nostr_index); + char key_args[1024] = {0}; + /* Build the --signer-path tail once; appended to all remote modes. The + * path is single-quoted to survive the BIP-44 hardened markers ('). */ + char path_tail[OW_MAX_URL_LEN + 32] = {0}; + if (cfg->signer.role_path[0] != '\0') { + snprintf(path_tail, sizeof(path_tail), " --signer-path '%s'", cfg->signer.role_path); } if (strcmp(cfg->signer.mode, "nsigner_unix") == 0) { @@ -2118,7 +2243,7 @@ static int install_system_service_with_dedicated_user(const didactyl_config_t* c cfg->signer.timeout_ms > 0 ? cfg->signer.timeout_ms : 15000, cfg->signer.socket_name[0] ? " --signer-socket " : "", cfg->signer.socket_name[0] ? cfg->signer.socket_name : "", - index_tail); + path_tail); } else if (strcmp(cfg->signer.mode, "nsigner_tcp") == 0) { char host_port[OW_MAX_SIGNER_HOST_LEN + 16] = {0}; snprintf(host_port, sizeof(host_port), "%s:%d", cfg->signer.tcp_host, cfg->signer.tcp_port); @@ -2127,14 +2252,14 @@ static int install_system_service_with_dedicated_user(const didactyl_config_t* c host_port, cfg->signer.role[0] ? cfg->signer.role : "main", cfg->signer.timeout_ms > 0 ? cfg->signer.timeout_ms : 15000, - index_tail); + path_tail); } else if (strcmp(cfg->signer.mode, "nsigner_serial") == 0) { snprintf(key_args, sizeof(key_args), "--signer nsigner_serial --signer-serial %s --signer-role %s --signer-timeout %d%s", cfg->signer.serial_device, cfg->signer.role[0] ? cfg->signer.role : "main", cfg->signer.timeout_ms > 0 ? cfg->signer.timeout_ms : 15000, - index_tail); + path_tail); } else if (strcmp(cfg->signer.mode, "nsigner_qrexec") == 0) { const char* svc = cfg->signer.service_name[0] ? cfg->signer.service_name : "qubes.NsignerRpc"; snprintf(key_args, sizeof(key_args), @@ -2143,7 +2268,7 @@ static int install_system_service_with_dedicated_user(const didactyl_config_t* c svc, cfg->signer.role[0] ? cfg->signer.role : "main", cfg->signer.timeout_ms > 0 ? cfg->signer.timeout_ms : 15000, - index_tail); + path_tail); } else if (strcmp(cfg->signer.mode, "nsigner_fds") == 0) { /* Defensive: callers reject this before install, but guard anyway. */ fprintf(stderr, "%snsigner_fds mode cannot be installed as a systemd service " @@ -2453,10 +2578,43 @@ static int prompt_signer_transport(didactyl_config_t* cfg, nostr_signer_t** sign continue; } - /* Common: key index + timeout. The key index selects which managed - * key n_signer uses (default 0). Role is left empty since the - * nostr_signer qube addresses keys by index. */ - cfg->signer.role[0] = '\0'; + /* Common: role + role_path + timeout. n_signer now requires BOTH role + * and role_path together for nostr_* verbs — role-only is rejected + * with 2009 path_required, bare nostr_index with 2006 nostr_index_deprecated. */ + char role_buf[WIZARD_LINE_MAX] = {0}; + if (read_line_prompt(" Role [nostr_range]: ", role_buf, sizeof(role_buf)) != 0) return -1; + if (line_is_quit(role_buf)) return -1; + snprintf(cfg->signer.role, sizeof(cfg->signer.role), "%s", role_buf[0] ? role_buf : "nostr_range"); + + fprintf(stderr, " Derivation path presets:\n" + " 1) m/44'/1237'/0'/0/0 (NIP-06 standard)\n" + " 2) m/44'/1237'/0'/0'/0' (all-hardened)\n" + " 3) custom\n"); + char path_choice[8] = {0}; + if (read_line_prompt(" Select preset [1]: ", path_choice, sizeof(path_choice)) != 0) return -1; + if (line_is_quit(path_choice)) return -1; + const char* default_path = "m/44'/1237'/0'/0/0"; + if (path_choice[0] == '2') { + default_path = "m/44'/1237'/0'/0'/0'"; + } else if (path_choice[0] == '3') { + default_path = ""; + } + char path_buf[WIZARD_LINE_MAX] = {0}; + if (default_path[0] != '\0') { + char prompt[128] = {0}; + snprintf(prompt, sizeof(prompt), " role_path [%s]: ", default_path); + if (read_line_prompt(prompt, path_buf, sizeof(path_buf)) != 0) return -1; + } else { + if (read_line_prompt(" role_path: ", path_buf, sizeof(path_buf)) != 0) return -1; + } + if (line_is_quit(path_buf)) return -1; + snprintf(cfg->signer.role_path, sizeof(cfg->signer.role_path), "%s", + path_buf[0] ? path_buf : default_path); + if (cfg->signer.role_path[0] == '\0') { + fprintf(stderr, "%srole_path is required (n_signer rejects role-only with 2009 path_required).%s\n", + ANSI_RED, ANSI_RESET); + continue; + } char timeout_buf[32] = {0}; if (read_line_prompt(" Timeout ms [15000]: ", timeout_buf, sizeof(timeout_buf)) != 0) return -1; @@ -2464,23 +2622,15 @@ static int prompt_signer_transport(didactyl_config_t* cfg, nostr_signer_t** sign cfg->signer.timeout_ms = (timeout_buf[0] != '\0') ? atoi(timeout_buf) : 15000; if (cfg->signer.timeout_ms <= 0) cfg->signer.timeout_ms = 15000; - char index_buf[32] = {0}; - if (read_line_prompt(" Key index [0]: ", index_buf, sizeof(index_buf)) != 0) return -1; - if (line_is_quit(index_buf)) return -1; - cfg->signer.nostr_index = (index_buf[0] != '\0') ? atoi(index_buf) : 0; - if (cfg->signer.nostr_index < 0) { - fprintf(stderr, "%sIndex must be >= 0; using 0.%s\n", ANSI_RED, ANSI_RESET); - cfg->signer.nostr_index = 0; - } - snprintf(cfg->signer.mode, sizeof(cfg->signer.mode), "%s", chosen_mode); /* Connectivity check. */ - fprintf(stderr, " Checking connectivity to n_signer (mode=%s, target=%s, service=%s, index=%d)...\n", + fprintf(stderr, " Checking connectivity to n_signer (mode=%s, target=%s, service=%s, role=%s, path=%s)...\n", chosen_mode, cfg->signer.target_qube[0] ? cfg->signer.target_qube : "", cfg->signer.service_name[0] ? cfg->signer.service_name : "", - cfg->signer.nostr_index); + cfg->signer.role, + cfg->signer.role_path); fflush(stderr); #if defined(NOSTR_ENABLE_NSIGNER_CLIENT) nostr_signer_t* signer = NULL; @@ -2508,9 +2658,10 @@ static int prompt_signer_transport(didactyl_config_t* cfg, nostr_signer_t** sign cfg->signer.role, cfg->signer.timeout_ms); } - /* Apply nostr_index selector when set (overrides role on the n_signer side). */ - if (signer && cfg->signer.nostr_index >= 0) { - (void)nostr_signer_nsigner_set_nostr_index(signer, cfg->signer.nostr_index); + /* Apply role_path selector (required for all nsigner_* modes — n_signer + * rejects role-only with 2009 path_required). */ + if (signer && cfg->signer.role_path[0] != '\0') { + (void)nostr_signer_nsigner_set_role_path(signer, cfg->signer.role_path); } if (!signer) { @@ -2524,10 +2675,14 @@ static int prompt_signer_transport(didactyl_config_t* cfg, nostr_signer_t** sign char pubkey_hex[65] = {0}; if (nostr_signer_get_public_key(signer, pubkey_hex) != 0) { + const char* last_err = nostr_signer_last_error(signer); fprintf(stderr, "%sFailed to retrieve public key from n_signer (mode=%s).%s\n", ANSI_RED, chosen_mode, ANSI_RESET); fprintf(stderr, " Is the n_signer process running and reachable? " "For qrexec: is qrexec-client-vm available and the dom0 policy installed?\n"); + if (last_err && last_err[0] != '\0') { + fprintf(stderr, " n_signer error: %s\n", last_err); + } nostr_signer_free(signer); char pause_buf[WIZARD_LINE_MAX] = {0}; (void)read_line_prompt(" Press Enter to try again (or q to quit): ", pause_buf, sizeof(pause_buf)); @@ -2588,9 +2743,14 @@ static nostr_signer_t* wizard_construct_ephemeral_signer(const didactyl_config_t s = nostr_signer_nsigner_qrexec(cfg->signer.target_qube, svc, cfg->signer.role, cfg->signer.timeout_ms); } - /* Apply nostr_index selector when set (overrides role on the n_signer side). */ - if (s && cfg->signer.nostr_index >= 0) { - (void)nostr_signer_nsigner_set_nostr_index(s, cfg->signer.nostr_index); + /* Apply role_path selector (required for all nsigner_* modes — n_signer + * rejects role-only with 2009 path_required). */ + if (s && cfg->signer.role_path[0] != '\0') { + (void)nostr_signer_nsigner_set_role_path(s, cfg->signer.role_path); + } + /* Apply derive_index for the algorithm-based derive (HMAC) verb. */ + if (s && cfg->signer.derive_index >= 0) { + (void)nostr_signer_nsigner_set_derive_index(s, cfg->signer.derive_index); } return s; #else @@ -2601,7 +2761,7 @@ static nostr_signer_t* wizard_construct_ephemeral_signer(const didactyl_config_t static int new_agent_identity_step(didactyl_config_t* cfg) { for (;;) { - render_wizard_page_header("Step 2 of 7", "New Agent Setup -- Identity"); + render_wizard_page_header("Step 3 of 8", "New Agent Setup -- Identity"); print_option('g', "enerate a new Nostr keypair"); print_option('p', "rovide an existing nsec"); print_option('s', "ign with a running n_signer"); @@ -2673,65 +2833,238 @@ static int new_agent_flow(didactyl_config_t* cfg, char* genesis_path_out, size_t (void)genesis_path_out; (void)genesis_path_out_size; char agent_name[OW_MAX_NAME_LEN] = {0}; - render_wizard_page_header("Step 1 of 7", "New Agent Setup -- Agent Profile"); + int rc; + /* When the operator chooses a remote signer, this handle stays alive for + * the duration of the flow so kind-30078 NIP-44 decrypt/encrypt is routed + * through n_signer (no nsec in the agent process). Freed before return. */ + nostr_signer_t* flow_signer = NULL; + + /* Step 1 of 8 — Agent Profile */ + render_wizard_page_header("Step 1 of 8", "New Agent Setup -- Agent Profile"); if (read_line_prompt(" Agent name [Didactyl]: ", agent_name, sizeof(agent_name)) != 0) return -1; if (line_is_quit(agent_name)) return -1; if (agent_name[0] == '\0') { snprintf(agent_name, sizeof(agent_name), "%s", "Didactyl"); } - int rc = new_agent_identity_step(cfg); + /* Step 2 of 8 — Relay Configuration (before identity, so the operator + * can adjust relays before the validation step queries the network). */ + for (;;) { + rc = prompt_relay_configuration_with_header(cfg, "Step 2 of 8", "New Agent Setup -- Relay Configuration"); + if (rc < 0) return -1; + if (rc == 0 || rc == 1) break; + } + + /* Step 3 of 8 — Identity */ + rc = new_agent_identity_step(cfg); if (rc != 0) return rc < 0 ? -1 : 1; - if (prompt_admin_pubkey(cfg) != 0) return -1; - + /* Step 4 of 8 — Identity Validation & Existing Agent Detection */ int exists = 0; int admin_name_found = 0; char admin_name[128] = {0}; if (validate_new_agent_identity_and_admin(cfg, &exists, admin_name, sizeof(admin_name), &admin_name_found) != 0) { fprintf(stderr, "%sWarning: Unable to validate identity/admin against Nostr right now.%s\n", - ANSI_YELLOW, - ANSI_RESET); + ANSI_YELLOW, ANSI_RESET); } else { - render_wizard_page_header("Step 4 of 7", "New Agent Setup -- Identity Validation"); - fprintf(stderr, " Agent kind 10002: %s\n", exists ? "FOUND (may overwrite existing state)" : "Agent npub available for new setup"); + render_wizard_page_header("Step 4 of 8", "New Agent Setup -- Identity Validation"); + fprintf(stderr, " Agent kind 10002: %s\n", exists ? "FOUND" : "NOT FOUND (new identity available)"); if (admin_name_found) { fprintf(stderr, " Admin profile: %s\n", admin_name); } else { - fprintf(stderr, " Admin profile: NOT FOUND (you can still continue)\n"); + fprintf(stderr, " Admin profile: N/A\n"); } fprintf(stderr, "\n"); + if (exists) { - print_option('c', "ontinue anyway"); - print_option('a', "bort"); - wizard_option_t confirm[] = {{'c', ""}, {'a', ""}}; - char cc = read_menu_choice(confirm, 2); - if (cc != 'c') { - return -1; + fprintf(stderr, " This identity already has an agent configured on the network.\n\n"); + print_option('d', "ownload existing config and review it"); + print_option('r', "estart with a fresh identity"); + print_option('c', "ontinue anyway (overwrite existing state)"); + print_option('q', "uit"); + wizard_option_t confirm[] = {{'d', ""}, {'r', ""}, {'c', ""}, {'q', ""}}; + char cc = read_menu_choice(confirm, 4); + if (cc == 'q') return -1; + if (cc == 'r') { + /* Restart: reset config and go back to identity step. */ + if (flow_signer) nostr_signer_free(flow_signer); + flow_signer = NULL; + config_free(cfg); + config_set_defaults(cfg); + if (set_default_relays(cfg) != 0) return -1; + return 2; /* signals main loop to restart new_agent_flow */ } + if (cc == 'd') { + /* Download existing config and present it for review. */ + int agent_name_found = 0; + char recovered_name[OW_MAX_NAME_LEN] = {0}; + if (recover_full_config_from_nostr(cfg, flow_signer, recovered_name, sizeof(recovered_name), &agent_name_found) != 0) { + fprintf(stderr, "%sWarning: unable to recover full config from Nostr, continuing with partial recovery.%s\n", + ANSI_YELLOW, ANSI_RESET); + } + if (agent_name_found && recovered_name[0] != '\0') { + snprintf(agent_name, sizeof(agent_name), "%s", recovered_name); + } + + /* Show downloaded config on a single review page. */ + for (;;) { + char masked_key[64] = {0}; + size_t api_len = strlen(cfg->llm.api_key); + if (api_len >= 8U) { + snprintf(masked_key, sizeof(masked_key), "%.4s...%s", + cfg->llm.api_key, cfg->llm.api_key + api_len - 4U); + } else if (api_len > 0U) { + snprintf(masked_key, sizeof(masked_key), "****"); + } else { + snprintf(masked_key, sizeof(masked_key), "(not set)"); + } + + render_wizard_page_header("Step 4 of 8", "Downloaded Configuration"); + fprintf(stderr, " Name: %s\n", agent_name); + fprintf(stderr, " Identity: %.16s...\n", cfg->keys.public_key_hex); + fprintf(stderr, " Admin: %s\n", cfg->admin.pubkey[0] ? cfg->admin.pubkey : "(not set)"); + fprintf(stderr, " LLM Provider: %s\n", cfg->llm.provider[0] ? cfg->llm.provider : "(not set)"); + fprintf(stderr, " LLM Model: %s\n", cfg->llm.model[0] ? cfg->llm.model : "(not set)"); + fprintf(stderr, " LLM Base URL: %s\n", cfg->llm.base_url[0] ? cfg->llm.base_url : "(not set)"); + fprintf(stderr, " LLM API Key: %s\n", masked_key); + fprintf(stderr, " Relays: %d configured\n", cfg->relay_count); + fprintf(stderr, "\n"); + print_option('e', "dit these settings"); + print_option('b', "oot with this config now"); + print_option('i', "nstall dedicated-user systemd service and boot"); + print_option('q', "uit"); + wizard_option_t review_opts[] = {{'e', ""}, {'b', ""}, {'i', ""}, {'q', ""}}; + char rc2 = read_menu_choice(review_opts, 4); + if (rc2 == 'q') return -1; + if (rc2 == 'e') { + /* Enter the edit loop: admin, LLM, relays. */ + for (;;) { + render_wizard_page_header("Step 4 of 8", "Edit Downloaded Configuration"); + fprintf(stderr, " Admin: %s\n", cfg->admin.pubkey[0] ? cfg->admin.pubkey : "(not set)"); + fprintf(stderr, " LLM Provider: %s\n", cfg->llm.provider[0] ? cfg->llm.provider : "(not set)"); + fprintf(stderr, " LLM Model: %s\n", cfg->llm.model[0] ? cfg->llm.model : "(not set)"); + fprintf(stderr, " LLM Base URL: %s\n", cfg->llm.base_url[0] ? cfg->llm.base_url : "(not set)"); + fprintf(stderr, " LLM API Key: %s\n", masked_key); + fprintf(stderr, " Relays: %d configured\n", cfg->relay_count); + fprintf(stderr, "\n"); + print_option('a', "dmin pubkey"); + print_option('l', "lm config"); + print_option('r', "elay configuration"); + print_option('d', "one -- return to review"); + print_option('q', "uit"); + wizard_option_t edit_opts[] = {{'a', ""}, {'l', ""}, {'r', ""}, {'d', ""}, {'q', ""}}; + char ec = read_menu_choice(edit_opts, 5); + if (ec == 'q') return -1; + if (ec == 'd') break; + if (ec == 'a') { + if (prompt_admin_pubkey_with_header(cfg, "Step 4 of 8", "Edit -- Administrator") != 0) return -1; + } + if (ec == 'l') { + if (prompt_llm_config_with_header(cfg, "Step 4 of 8", "Edit -- LLM Provider") != 0) return -1; + } + if (ec == 'r') { + for (;;) { + int rrc = prompt_relay_configuration_with_header(cfg, "Step 4 of 8", "Edit -- Relay Configuration"); + if (rrc < 0) return -1; + if (rrc == 1) break; + break; + } + } + } + continue; /* back to review page */ + } + if (rc2 == 'b' || rc2 == 'i') { + /* Boot or install with the downloaded config. + * Skip Steps 5-8 since the config is already complete. */ + if (configure_default_skills_for_agent(cfg, agent_name) != 0) { + fprintf(stderr, "%sFailed to prepare default startup skill events.%s\n", ANSI_RED, ANSI_RESET); + return -1; + } + if (apply_default_startup_events(cfg, agent_name) != 0) { + fprintf(stderr, "%sFailed to prepare startup events from defaults.%s\n", ANSI_RED, ANSI_RESET); + return -1; + } + if (rc2 == 'b') { + nostr_signer_t* pub_signer = wizard_construct_ephemeral_signer(cfg); + if (persist_runtime_config_to_nostr_wizard_online(cfg, pub_signer) != 0) { + fprintf(stderr, "%sWarning: failed to publish runtime config to Nostr (will retry on boot).%s\n", + ANSI_YELLOW, ANSI_RESET); + } + if (pub_signer) nostr_signer_free(pub_signer); + if (flow_signer) nostr_signer_free(flow_signer); + fprintf(stderr, "%sBooting with downloaded config. Check your messages for initial greeting.%s\n", + ANSI_YELLOW, ANSI_RESET); + return 0; + } + if (rc2 == 'i') { + if (strcmp(cfg->signer.mode, "nsigner_fds") == 0) { + fprintf(stderr, "%snsigner_fds mode cannot be installed as a systemd service.%s\n", + ANSI_RED, ANSI_RESET); + if (flow_signer) nostr_signer_free(flow_signer); + return -1; + } + nostr_signer_t* pub_signer = wizard_construct_ephemeral_signer(cfg); + if (persist_runtime_config_to_nostr_wizard_online(cfg, pub_signer) != 0) { + if (pub_signer) nostr_signer_free(pub_signer); + fprintf(stderr, "%sFailed to publish runtime config to Nostr; refusing install.%s\n", + ANSI_RED, ANSI_RESET); + if (flow_signer) nostr_signer_free(flow_signer); + return -1; + } + if (pub_signer) nostr_signer_free(pub_signer); + if (install_system_service_with_dedicated_user(cfg, agent_name) != 0) { + fprintf(stderr, "%sFailed to install dedicated-user system service.%s\n", ANSI_RED, ANSI_RESET); + if (flow_signer) nostr_signer_free(flow_signer); + return -1; + } + char service_user[64] = {0}; + sanitize_service_user_from_name(agent_name, service_user, sizeof(service_user)); + char service_name[96] = {0}; + snprintf(service_name, sizeof(service_name), "%s.service", service_user); + sleep(2); + char* is_active_argv[] = {"systemctl", "is-active", "--quiet", service_name, NULL}; + if (run_privileged_command_local(is_active_argv) == 0) { + fprintf(stderr, "%sService %s is active. Setup complete.%s\n", ANSI_YELLOW, service_name, ANSI_RESET); + } else { + fprintf(stderr, "%sService installed but not active. Check: sudo systemctl status %s%s\n", + ANSI_RED, service_name, ANSI_RESET); + } + if (flow_signer) nostr_signer_free(flow_signer); + return 1; + } + } + } + /* unreachable */ + } + /* cc == 'c': continue anyway (overwrite), fall through to Steps 5-8 */ + } else { + /* No existing agent found. */ + print_option('c', "ontinue to administrator setup"); + print_option('q', "uit"); + wizard_option_t confirm[] = {{'c', ""}, {'q', ""}}; + char cc = read_menu_choice(confirm, 2); + if (cc != 'c') return -1; } } + /* Step 5 of 8 — Administrator */ + if (prompt_admin_pubkey(cfg) != 0) return -1; + + /* Step 6 of 8 — LLM Provider */ if (prompt_llm_config(cfg) != 0) return -1; - for (;;) { - rc = prompt_relay_configuration(cfg); - if (rc < 0) return -1; - if (rc == 0) break; - if (prompt_llm_config(cfg) != 0) return -1; - } - + /* Step 7 of 8 — Default Skills */ if (configure_default_skills_for_agent(cfg, agent_name) != 0) { fprintf(stderr, "%sFailed to prepare default startup skill events.%s\n", ANSI_RED, ANSI_RESET); return -1; } - if (apply_default_startup_events(cfg, agent_name) != 0) { fprintf(stderr, "%sFailed to prepare startup events from defaults.%s\n", ANSI_RED, ANSI_RESET); return -1; } - render_wizard_page_header("Step 7 of 7", "New Agent Setup -- Review"); + /* Step 8 of 8 — Review */ + render_wizard_page_header("Step 8 of 8", "New Agent Setup -- Review"); fprintf(stderr, " Name: %s\n", agent_name); fprintf(stderr, " Identity: %.16s...\n", cfg->keys.public_key_hex); fprintf(stderr, " Admin: %.16s...\n", cfg->admin.pubkey); @@ -2746,86 +3079,66 @@ static int new_agent_flow(didactyl_config_t* cfg, char* genesis_path_out, size_t wizard_option_t opts[] = {{'b', ""}, {'i', ""}, {'s', ""}, {'q', ""}}; char c = read_menu_choice(opts, 4); - if (c == 'q') return -1; - if (c == 's') return 2; + if (c == 'q') { + if (flow_signer) nostr_signer_free(flow_signer); + return -1; + } + if (c == 's') { + if (flow_signer) nostr_signer_free(flow_signer); + return 2; + } if (c == 'b') { - /* Publish kind 30078 agent_config + llm_config so the --nsec-only - restart path can recover them. Non-fatal: main() will also - publish during bootstrap, but doing it here is belt-and-suspenders. - In remote signer modes, construct an ephemeral signer so the - kind-30078 NIP-44 encrypt is routed through n_signer (no nsec in - the agent process). */ nostr_signer_t* pub_signer = wizard_construct_ephemeral_signer(cfg); if (persist_runtime_config_to_nostr_wizard_online(cfg, pub_signer) != 0) { fprintf(stderr, "%sWarning: failed to publish runtime config to Nostr (will retry on boot).%s\n", - ANSI_YELLOW, - ANSI_RESET); + ANSI_YELLOW, ANSI_RESET); } if (pub_signer) nostr_signer_free(pub_signer); - fprintf(stderr, "%sStep 7 of 7 -- Booting new agent. Check your messages for initial greeting.%s\n", - ANSI_YELLOW, - ANSI_RESET); + if (flow_signer) nostr_signer_free(flow_signer); + fprintf(stderr, "%sStep 8 of 8 -- Booting new agent. Check your messages for initial greeting.%s\n", + ANSI_YELLOW, ANSI_RESET); return 0; } if (c == 'i') { - /* nsigner_fds cannot be installed as a systemd service (fds are - * runtime-only and cannot be embedded in ExecStart). */ if (strcmp(cfg->signer.mode, "nsigner_fds") == 0) { - fprintf(stderr, - "%snsigner_fds mode cannot be installed as a systemd service (file descriptors are " - "runtime-only and cannot be embedded in ExecStart). Choose 'boot now' or wire the " - "fd-passing yourself via a wrapper unit.%s\n", + fprintf(stderr, "%snsigner_fds mode cannot be installed as a systemd service.%s\n", ANSI_RED, ANSI_RESET); + if (flow_signer) nostr_signer_free(flow_signer); return -1; } - - /* Publish kind 30078 agent_config + llm_config BEFORE installing the - service. The systemd service starts with --nsec only and depends - on recovering these from relays. */ nostr_signer_t* pub_signer = wizard_construct_ephemeral_signer(cfg); if (persist_runtime_config_to_nostr_wizard_online(cfg, pub_signer) != 0) { if (pub_signer) nostr_signer_free(pub_signer); - fprintf(stderr, - "%sFailed to publish runtime config to Nostr; refusing install to avoid missing admin/LLM on first boot.%s\n", - ANSI_RED, - ANSI_RESET); + fprintf(stderr, "%sFailed to publish runtime config to Nostr; refusing install.%s\n", + ANSI_RED, ANSI_RESET); + if (flow_signer) nostr_signer_free(flow_signer); return -1; } if (pub_signer) nostr_signer_free(pub_signer); - if (install_system_service_with_dedicated_user(cfg, agent_name) != 0) { - fprintf(stderr, "%sFailed to install dedicated-user system service.%s\n", ANSI_RED, ANSI_RESET); - return -1; - } - - char service_user[64] = {0}; - sanitize_service_user_from_name(agent_name, service_user, sizeof(service_user)); - - char service_name[96] = {0}; - snprintf(service_name, sizeof(service_name), "%s.service", service_user); - - sleep(2); - char* is_active_argv[] = {"systemctl", "is-active", "--quiet", service_name, NULL}; - if (run_privileged_command_local(is_active_argv) == 0) { - fprintf(stderr, - "%sStep 7 of 7 -- Service %s is active. Setup is complete; this wizard will now exit. " - "Expect a startup message in the admin inbox.%s\n", - ANSI_YELLOW, - service_name, - ANSI_RESET); - } else { - fprintf(stderr, - "%sStep 7 of 7 -- Service installed but not active yet. This wizard will now exit. " - "Check status with: sudo systemctl status %s ; logs with: sudo journalctl -u %s -f%s\n", - ANSI_RED, - service_name, - service_name, - ANSI_RESET); - } - - return 1; + if (install_system_service_with_dedicated_user(cfg, agent_name) != 0) { + fprintf(stderr, "%sFailed to install dedicated-user system service.%s\n", ANSI_RED, ANSI_RESET); + if (flow_signer) nostr_signer_free(flow_signer); + return -1; } + char service_user[64] = {0}; + sanitize_service_user_from_name(agent_name, service_user, sizeof(service_user)); + char service_name[96] = {0}; + snprintf(service_name, sizeof(service_name), "%s.service", service_user); + sleep(2); + char* is_active_argv[] = {"systemctl", "is-active", "--quiet", service_name, NULL}; + if (run_privileged_command_local(is_active_argv) == 0) { + fprintf(stderr, "%sStep 8 of 8 -- Service %s is active. Setup complete.%s\n", + ANSI_YELLOW, service_name, ANSI_RESET); + } else { + fprintf(stderr, "%sStep 8 of 8 -- Service installed but not active. Check: sudo systemctl status %s%s\n", + ANSI_RED, service_name, ANSI_RESET); + } + if (flow_signer) nostr_signer_free(flow_signer); + return 1; +} + if (flow_signer) nostr_signer_free(flow_signer); return -1; } diff --git a/src/tools/tool_config.c b/src/tools/tool_config.c index 6dd64b3..b280dda 100644 --- a/src/tools/tool_config.c +++ b/src/tools/tool_config.c @@ -7,6 +7,7 @@ #include #include "cjson/cJSON.h" +#include "../debug.h" #include "../nostr_handler.h" #include "../../nostr_core_lib/nostr_core/nostr_core.h" @@ -46,7 +47,12 @@ static int nip44_encrypt_self_local(tools_context_t* ctx, const char* plaintext, ctx->cfg->keys.public_key_hex, plain, &signer_out); - if (rc != NOSTR_SUCCESS || !signer_out) return -1; + if (rc != NOSTR_SUCCESS || !signer_out) { + const char* le = nostr_signer_last_error(ctx->signer); + DEBUG_ERROR("[config] nip44_encrypt failed (rc=%d): %s", + rc, (le && le[0]) ? le : ""); + return -1; + } *out_ciphertext = signer_out; return 0; } @@ -80,7 +86,12 @@ static int nip44_decrypt_self_local(tools_context_t* ctx, const char* ciphertext ctx->cfg->keys.public_key_hex, ciphertext, &signer_out); - if (rc != NOSTR_SUCCESS || !signer_out) return -1; + if (rc != NOSTR_SUCCESS || !signer_out) { + const char* le = nostr_signer_last_error(ctx->signer); + DEBUG_ERROR("[config] nip44_decrypt failed (rc=%d): %s", + rc, (le && le[0]) ? le : ""); + return -1; + } *out_plaintext = signer_out; return 0; } diff --git a/src/tools/tool_memory.c b/src/tools/tool_memory.c index 11e2949..f12cb4f 100644 --- a/src/tools/tool_memory.c +++ b/src/tools/tool_memory.c @@ -8,6 +8,7 @@ #include #include "cjson/cJSON.h" +#include "../debug.h" #include "../nostr_handler.h" #include "../../nostr_core_lib/nostr_core/nostr_core.h" @@ -68,7 +69,12 @@ static int nip44_encrypt_self_local(tools_context_t* ctx, const char* plaintext, ctx->cfg->keys.public_key_hex, plain, &signer_out); - if (rc != NOSTR_SUCCESS || !signer_out) return -1; + if (rc != NOSTR_SUCCESS || !signer_out) { + const char* le = nostr_signer_last_error(ctx->signer); + DEBUG_ERROR("[memory] nip44_encrypt failed (rc=%d): %s", + rc, (le && le[0]) ? le : ""); + return -1; + } *out_ciphertext = signer_out; return 0; } @@ -102,7 +108,12 @@ static int nip44_decrypt_self_local(tools_context_t* ctx, const char* ciphertext ctx->cfg->keys.public_key_hex, ciphertext, &signer_out); - if (rc != NOSTR_SUCCESS || !signer_out) return -1; + if (rc != NOSTR_SUCCESS || !signer_out) { + const char* le = nostr_signer_last_error(ctx->signer); + DEBUG_ERROR("[memory] nip44_decrypt failed (rc=%d): %s", + rc, (le && le[0]) ? le : ""); + return -1; + } *out_plaintext = signer_out; return 0; } diff --git a/src/tools/tool_nostr_dm.c b/src/tools/tool_nostr_dm.c index 532e86d..25537fb 100644 --- a/src/tools/tool_nostr_dm.c +++ b/src/tools/tool_nostr_dm.c @@ -6,6 +6,7 @@ #include #include "cjson/cJSON.h" +#include "../debug.h" #include "../nostr_handler.h" #include "../../nostr_core_lib/nostr_core/nostr_core.h" @@ -93,8 +94,13 @@ char* execute_nostr_encrypt(tools_context_t* ctx, const char* args_json) { plaintext->valuestring, &ciphertext); if (rc != NOSTR_SUCCESS || !ciphertext) { + const char* le = nostr_signer_last_error(ctx->signer); + char err_buf[256] = {0}; + snprintf(err_buf, sizeof(err_buf), "nostr_encrypt failed (rc=%d): %s", + rc, (le && le[0]) ? le : ""); + DEBUG_ERROR("[nostr_dm] %s", err_buf); cJSON_Delete(args); - return json_error_local("nostr_encrypt failed"); + return json_error_local(err_buf); } } else { unsigned char recipient_pubkey[32]; @@ -162,8 +168,13 @@ char* execute_nostr_decrypt(tools_context_t* ctx, const char* args_json) { ciphertext->valuestring, &plaintext); if (rc != NOSTR_SUCCESS || !plaintext) { + const char* le = nostr_signer_last_error(ctx->signer); + char err_buf[256] = {0}; + snprintf(err_buf, sizeof(err_buf), "nostr_decrypt failed (rc=%d): %s", + rc, (le && le[0]) ? le : ""); + DEBUG_ERROR("[nostr_dm] %s", err_buf); cJSON_Delete(args); - return json_error_local("nostr_decrypt failed"); + return json_error_local(err_buf); } } else { unsigned char sender_pubkey[32]; diff --git a/src/tools/tool_nostr_post.c b/src/tools/tool_nostr_post.c index 34bca31..fa481ec 100644 --- a/src/tools/tool_nostr_post.c +++ b/src/tools/tool_nostr_post.c @@ -631,6 +631,16 @@ char* execute_nostr_post(const char* args_json) { return json_error_local("nostr_post tags must be an array when provided"); } + /* Optional PoW (NIP-13) mining args. When difficulty > 0 and a remote + * signer is available, route through nostr_signer_mine_event instead of + * the normal sign path. */ + cJSON* difficulty_json = cJSON_GetObjectItemCaseSensitive(args, "difficulty"); + int difficulty = (difficulty_json && cJSON_IsNumber(difficulty_json)) ? (int)difficulty_json->valuedouble : 0; + cJSON* threads_json = cJSON_GetObjectItemCaseSensitive(args, "threads"); + int threads = (threads_json && cJSON_IsNumber(threads_json)) ? (int)threads_json->valuedouble : 1; + cJSON* timeout_json = cJSON_GetObjectItemCaseSensitive(args, "timeout_sec"); + int timeout_sec = (timeout_json && cJSON_IsNumber(timeout_json)) ? (int)timeout_json->valuedouble : 600; + cJSON* tags_dup = NULL; if (tags) { tags_dup = cJSON_Duplicate(tags, 1); @@ -646,10 +656,59 @@ char* execute_nostr_post(const char* args_json) { nostr_publish_result_t publish_result; memset(&publish_result, 0, sizeof(publish_result)); - int rc = nostr_handler_publish_kind_event((int)kind->valuedouble, + int rc; + if (difficulty > 0) { + /* PoW path: mine event through the remote signer, then publish. */ + nostr_signer_t* signer = nostr_handler_get_signer(); + if (!signer) { + cJSON_Delete(tags_dup); + cJSON_Delete(args); + free(repaired_args_json); + return json_error_local("nostr_post PoW requires a remote n_signer " + "(local mode does not support mine_event)"); + } + /* Build the unsigned event JSON. */ + cJSON* unsigned_event = cJSON_CreateObject(); + if (!unsigned_event) { + cJSON_Delete(tags_dup); + cJSON_Delete(args); + free(repaired_args_json); + return json_error_local("nostr_post PoW: memory allocation failed"); + } + cJSON_AddNumberToObject(unsigned_event, "kind", kind->valuedouble); + cJSON_AddStringToObject(unsigned_event, "content", content->valuestring); + if (tags_dup) { + cJSON_AddItemToObject(unsigned_event, "tags", cJSON_Duplicate(tags_dup, 1)); + } else { + cJSON_AddItemToObject(unsigned_event, "tags", cJSON_CreateArray()); + } + /* created_at is set by the signer. */ + + cJSON* mined_event = NULL; + int mine_rc = nostr_signer_mine_event(signer, unsigned_event, + difficulty, timeout_sec, threads, + &mined_event); + cJSON_Delete(unsigned_event); + if (mine_rc != NOSTR_SUCCESS || !mined_event) { + const char* le = nostr_signer_last_error(signer); + cJSON_Delete(tags_dup); + cJSON_Delete(args); + free(repaired_args_json); + char err_buf[256] = {0}; + snprintf(err_buf, sizeof(err_buf), "nostr_post PoW mining failed (rc=%d): %s", + mine_rc, (le && le[0]) ? le : ""); + return json_error_local(err_buf); + } + /* Publish the mined+signed event through the relay pool. */ + rc = nostr_handler_publish_event(mined_event, &publish_result); + cJSON_Delete(mined_event); + } else { + /* Normal (non-PoW) path. */ + rc = nostr_handler_publish_kind_event((int)kind->valuedouble, content->valuestring, tags_dup, &publish_result); + } cJSON_Delete(tags_dup); cJSON_Delete(args); free(repaired_args_json); diff --git a/src/tools/tool_signer_crypto.c b/src/tools/tool_signer_crypto.c new file mode 100644 index 0000000..200af54 --- /dev/null +++ b/src/tools/tool_signer_crypto.c @@ -0,0 +1,283 @@ +#define _POSIX_C_SOURCE 200809L + +#include "tools_internal.h" + +#include +#include +#include + +#include "cjson/cJSON.h" +#include "../debug.h" +#include "../nostr_handler.h" +#include "../../nostr_core_lib/nostr_core/nostr_core.h" + +static char* json_error_local(const char* msg) { + cJSON* root = cJSON_CreateObject(); + if (!root) return NULL; + cJSON_AddBoolToObject(root, "success", 0); + cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error"); + char* out = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + return out; +} + +/* Unified signer_crypto tool — exposes all algorithm-based n_signer verbs + * through a single tool with an "operation" enum. Requires a remote n_signer; + * returns a clear error in local mode since the local backend does not support + * algorithm-based verbs (PQ crypto, OTP, etc.). */ +char* execute_signer_crypto(tools_context_t* ctx, const char* args_json) { + if (!ctx) return json_error_local("tool context unavailable"); + + nostr_signer_t* signer = ctx->signer; + if (!signer) { + /* Fall back to the process-lifetime signer if the tools context + * doesn't have one wired (e.g. during startup). */ + signer = nostr_handler_get_signer(); + } + if (!signer) { + return json_error_local("signer_crypto requires a remote n_signer " + "(local mode does not support algorithm-based verbs)"); + } + + cJSON* args = cJSON_Parse(args_json ? args_json : "{}"); + if (!args) return json_error_local("invalid arguments JSON"); + + cJSON* op_json = cJSON_GetObjectItemCaseSensitive(args, "operation"); + if (!op_json || !cJSON_IsString(op_json) || !op_json->valuestring || op_json->valuestring[0] == '\0') { + cJSON_Delete(args); + return json_error_local("signer_crypto requires a string 'operation' field"); + } + const char* operation = op_json->valuestring; + + cJSON* algorithm = cJSON_GetObjectItemCaseSensitive(args, "algorithm"); + const char* alg_str = (algorithm && cJSON_IsString(algorithm) && algorithm->valuestring) + ? algorithm->valuestring : NULL; + cJSON* index_json = cJSON_GetObjectItemCaseSensitive(args, "index"); + int index = (index_json && cJSON_IsNumber(index_json)) ? (int)index_json->valuedouble : 0; + cJSON* scheme_json = cJSON_GetObjectItemCaseSensitive(args, "scheme"); + const char* scheme = (scheme_json && cJSON_IsString(scheme_json) && scheme_json->valuestring) + ? scheme_json->valuestring : NULL; + + cJSON* result = cJSON_CreateObject(); + if (!result) { cJSON_Delete(args); return NULL; } + cJSON_AddBoolToObject(result, "success", 1); + cJSON_AddStringToObject(result, "operation", operation); + + int rc; + char* result_str = NULL; + cJSON* info_out = NULL; + int valid_out = 0; + + if (strcmp(operation, "get_info") == 0) { + rc = nostr_signer_get_info(signer, &info_out); + if (rc == NOSTR_SUCCESS && info_out) { + char* info_json = cJSON_PrintUnformatted(info_out); + cJSON_AddStringToObject(result, "info", info_json ? info_json : "{}"); + free(info_json); + cJSON_Delete(info_out); + } else { + const char* le = nostr_signer_last_error(signer); + cJSON_AddBoolToObject(result, "success", 0); + cJSON_AddStringToObject(result, "error", (le && le[0]) ? le : "get_info failed"); + } + } else if (strcmp(operation, "get_public_key") == 0) { + if (!alg_str) { + cJSON_AddBoolToObject(result, "success", 0); + cJSON_AddStringToObject(result, "error", "get_public_key requires 'algorithm' (e.g. secp256k1, ed25519, ml-dsa-65)"); + } else { + rc = nostr_signer_get_public_key_alg(signer, alg_str, index, &result_str); + if (rc == NOSTR_SUCCESS && result_str) { + cJSON_AddStringToObject(result, "result", result_str); + free(result_str); + } else { + const char* le = nostr_signer_last_error(signer); + cJSON_AddBoolToObject(result, "success", 0); + cJSON_AddStringToObject(result, "error", (le && le[0]) ? le : "get_public_key failed"); + } + } + } else if (strcmp(operation, "sign") == 0) { + cJSON* msg_json = cJSON_GetObjectItemCaseSensitive(args, "message_hex"); + if (!msg_json || !cJSON_IsString(msg_json) || !msg_json->valuestring) { + cJSON_AddBoolToObject(result, "success", 0); + cJSON_AddStringToObject(result, "error", "sign requires 'message_hex' (hex-encoded message bytes)"); + } else if (!alg_str) { + cJSON_AddBoolToObject(result, "success", 0); + cJSON_AddStringToObject(result, "error", "sign requires 'algorithm' (e.g. secp256k1, ed25519, ml-dsa-65, slh-dsa-128s)"); + } else { + size_t msg_hex_len = strlen(msg_json->valuestring); + size_t msg_bytes_len = msg_hex_len / 2; + unsigned char* msg_bytes = (unsigned char*)malloc(msg_bytes_len + 1); + if (!msg_bytes) { + cJSON_AddBoolToObject(result, "success", 0); + cJSON_AddStringToObject(result, "error", "memory allocation failed"); + } else if (nostr_hex_to_bytes(msg_json->valuestring, msg_bytes, (int)msg_bytes_len) != 0) { + free(msg_bytes); + cJSON_AddBoolToObject(result, "success", 0); + cJSON_AddStringToObject(result, "error", "invalid message_hex"); + } else { + rc = nostr_signer_sign(signer, alg_str, index, scheme, msg_bytes, msg_bytes_len, &result_str); + free(msg_bytes); + if (rc == NOSTR_SUCCESS && result_str) { + cJSON_AddStringToObject(result, "result", result_str); + free(result_str); + } else { + const char* le = nostr_signer_last_error(signer); + cJSON_AddBoolToObject(result, "success", 0); + cJSON_AddStringToObject(result, "error", (le && le[0]) ? le : "sign failed"); + } + } + } + } else if (strcmp(operation, "verify") == 0) { + cJSON* msg_json = cJSON_GetObjectItemCaseSensitive(args, "message_hex"); + cJSON* sig_json = cJSON_GetObjectItemCaseSensitive(args, "signature_hex"); + if (!msg_json || !cJSON_IsString(msg_json) || !msg_json->valuestring || + !sig_json || !cJSON_IsString(sig_json) || !sig_json->valuestring) { + cJSON_AddBoolToObject(result, "success", 0); + cJSON_AddStringToObject(result, "error", "verify requires 'message_hex' and 'signature_hex'"); + } else if (!alg_str) { + cJSON_AddBoolToObject(result, "success", 0); + cJSON_AddStringToObject(result, "error", "verify requires 'algorithm'"); + } else { + size_t msg_hex_len = strlen(msg_json->valuestring); + size_t msg_bytes_len = msg_hex_len / 2; + unsigned char* msg_bytes = (unsigned char*)malloc(msg_bytes_len + 1); + size_t sig_hex_len = strlen(sig_json->valuestring); + size_t sig_bytes_len = sig_hex_len / 2; + unsigned char* sig_bytes = (unsigned char*)malloc(sig_bytes_len + 1); + if (!msg_bytes || !sig_bytes) { + free(msg_bytes); free(sig_bytes); + cJSON_AddBoolToObject(result, "success", 0); + cJSON_AddStringToObject(result, "error", "memory allocation failed"); + } else if (nostr_hex_to_bytes(msg_json->valuestring, msg_bytes, (int)msg_bytes_len) != 0 || + nostr_hex_to_bytes(sig_json->valuestring, sig_bytes, (int)sig_bytes_len) != 0) { + free(msg_bytes); free(sig_bytes); + cJSON_AddBoolToObject(result, "success", 0); + cJSON_AddStringToObject(result, "error", "invalid hex in message_hex or signature_hex"); + } else { + rc = nostr_signer_verify(signer, alg_str, index, scheme, + msg_bytes, msg_bytes_len, + sig_bytes, sig_bytes_len, + &valid_out); + free(msg_bytes); free(sig_bytes); + if (rc == NOSTR_SUCCESS) { + cJSON_AddBoolToObject(result, "valid", valid_out); + } else { + const char* le = nostr_signer_last_error(signer); + cJSON_AddBoolToObject(result, "success", 0); + cJSON_AddStringToObject(result, "error", (le && le[0]) ? le : "verify failed"); + } + } + } + } else if (strcmp(operation, "encapsulate") == 0) { + cJSON* peer_json = cJSON_GetObjectItemCaseSensitive(args, "peer_pubkey_hex"); + if (!peer_json || !cJSON_IsString(peer_json) || !peer_json->valuestring) { + cJSON_AddBoolToObject(result, "success", 0); + cJSON_AddStringToObject(result, "error", "encapsulate requires 'peer_pubkey_hex' (ML-KEM-768 public key)"); + } else { + rc = nostr_signer_encapsulate(signer, peer_json->valuestring, &result_str); + if (rc == NOSTR_SUCCESS && result_str) { + cJSON_AddStringToObject(result, "result", result_str); + free(result_str); + } else { + const char* le = nostr_signer_last_error(signer); + cJSON_AddBoolToObject(result, "success", 0); + cJSON_AddStringToObject(result, "error", (le && le[0]) ? le : "encapsulate failed"); + } + } + } else if (strcmp(operation, "decapsulate") == 0) { + cJSON* ct_json = cJSON_GetObjectItemCaseSensitive(args, "ciphertext_hex"); + if (!ct_json || !cJSON_IsString(ct_json) || !ct_json->valuestring) { + cJSON_AddBoolToObject(result, "success", 0); + cJSON_AddStringToObject(result, "error", "decapsulate requires 'ciphertext_hex' and 'index'"); + } else { + rc = nostr_signer_decapsulate(signer, index, ct_json->valuestring, &result_str); + if (rc == NOSTR_SUCCESS && result_str) { + cJSON_AddStringToObject(result, "result", result_str); + free(result_str); + } else { + const char* le = nostr_signer_last_error(signer); + cJSON_AddBoolToObject(result, "success", 0); + cJSON_AddStringToObject(result, "error", (le && le[0]) ? le : "decapsulate failed"); + } + } + } else if (strcmp(operation, "derive_shared_secret") == 0) { + cJSON* peer_json = cJSON_GetObjectItemCaseSensitive(args, "peer_pubkey_hex"); + if (!peer_json || !cJSON_IsString(peer_json) || !peer_json->valuestring) { + cJSON_AddBoolToObject(result, "success", 0); + cJSON_AddStringToObject(result, "error", "derive_shared_secret requires 'peer_pubkey_hex' (X25519) and 'index'"); + } else { + rc = nostr_signer_derive_shared_secret(signer, index, peer_json->valuestring, &result_str); + if (rc == NOSTR_SUCCESS && result_str) { + cJSON_AddStringToObject(result, "result", result_str); + free(result_str); + } else { + const char* le = nostr_signer_last_error(signer); + cJSON_AddBoolToObject(result, "success", 0); + cJSON_AddStringToObject(result, "error", (le && le[0]) ? le : "derive_shared_secret failed"); + } + } + } else if (strcmp(operation, "derive_hmac") == 0) { + cJSON* data_json = cJSON_GetObjectItemCaseSensitive(args, "data"); + if (!data_json || !cJSON_IsString(data_json) || !data_json->valuestring) { + cJSON_AddBoolToObject(result, "success", 0); + cJSON_AddStringToObject(result, "error", "derive_hmac requires 'data' (UTF-8 string)"); + } else { + rc = nostr_signer_derive_hmac(signer, data_json->valuestring, (char[65]){0}); + if (rc == NOSTR_SUCCESS) { + char digest_hex[65] = {0}; + nostr_signer_derive_hmac(signer, data_json->valuestring, digest_hex); + cJSON_AddStringToObject(result, "digest_hex", digest_hex); + } else { + const char* le = nostr_signer_last_error(signer); + cJSON_AddBoolToObject(result, "success", 0); + cJSON_AddStringToObject(result, "error", (le && le[0]) ? le : "derive_hmac failed"); + } + } + } else if (strcmp(operation, "otp_encrypt") == 0) { + cJSON* pt_json = cJSON_GetObjectItemCaseSensitive(args, "plaintext_b64"); + cJSON* enc_json = cJSON_GetObjectItemCaseSensitive(args, "encoding"); + const char* encoding = (enc_json && cJSON_IsString(enc_json) && enc_json->valuestring) + ? enc_json->valuestring : NULL; + if (!pt_json || !cJSON_IsString(pt_json) || !pt_json->valuestring) { + cJSON_AddBoolToObject(result, "success", 0); + cJSON_AddStringToObject(result, "error", "otp_encrypt requires 'plaintext_b64' (base64-encoded plaintext)"); + } else { + rc = nostr_signer_otp_encrypt(signer, pt_json->valuestring, encoding, &result_str); + if (rc == NOSTR_SUCCESS && result_str) { + cJSON_AddStringToObject(result, "result", result_str); + free(result_str); + } else { + const char* le = nostr_signer_last_error(signer); + cJSON_AddBoolToObject(result, "success", 0); + cJSON_AddStringToObject(result, "error", (le && le[0]) ? le : "otp_encrypt failed"); + } + } + } else if (strcmp(operation, "otp_decrypt") == 0) { + cJSON* ct_json = cJSON_GetObjectItemCaseSensitive(args, "ciphertext"); + cJSON* enc_json = cJSON_GetObjectItemCaseSensitive(args, "encoding"); + const char* encoding = (enc_json && cJSON_IsString(enc_json) && enc_json->valuestring) + ? enc_json->valuestring : NULL; + if (!ct_json || !cJSON_IsString(ct_json) || !ct_json->valuestring) { + cJSON_AddBoolToObject(result, "success", 0); + cJSON_AddStringToObject(result, "error", "otp_decrypt requires 'ciphertext' (ASCII-armored or base64 blob)"); + } else { + rc = nostr_signer_otp_decrypt(signer, ct_json->valuestring, encoding, &result_str); + if (rc == NOSTR_SUCCESS && result_str) { + cJSON_AddStringToObject(result, "result", result_str); + free(result_str); + } else { + const char* le = nostr_signer_last_error(signer); + cJSON_AddBoolToObject(result, "success", 0); + cJSON_AddStringToObject(result, "error", (le && le[0]) ? le : "otp_decrypt failed"); + } + } + } else { + cJSON_AddBoolToObject(result, "success", 0); + cJSON_AddStringToObject(result, "error", "unknown operation"); + } + + cJSON_Delete(args); + char* json = cJSON_PrintUnformatted(result); + cJSON_Delete(result); + return json; +} diff --git a/src/tools/tool_task.c b/src/tools/tool_task.c index c6983a2..822dd92 100644 --- a/src/tools/tool_task.c +++ b/src/tools/tool_task.c @@ -8,6 +8,7 @@ #include #include "cjson/cJSON.h" +#include "../debug.h" #include "../nostr_handler.h" #include "../../nostr_core_lib/nostr_core/nostr_core.h" @@ -69,7 +70,12 @@ static int nip44_encrypt_self_local(tools_context_t* ctx, const char* plaintext, ctx->cfg->keys.public_key_hex, plain, &signer_out); - if (rc != NOSTR_SUCCESS || !signer_out) return -1; + if (rc != NOSTR_SUCCESS || !signer_out) { + const char* le = nostr_signer_last_error(ctx->signer); + DEBUG_ERROR("[task] nip44_encrypt failed (rc=%d): %s", + rc, (le && le[0]) ? le : ""); + return -1; + } *out_ciphertext = signer_out; return 0; } @@ -103,7 +109,12 @@ static int nip44_decrypt_self_local(tools_context_t* ctx, const char* ciphertext ctx->cfg->keys.public_key_hex, ciphertext, &signer_out); - if (rc != NOSTR_SUCCESS || !signer_out) return -1; + if (rc != NOSTR_SUCCESS || !signer_out) { + const char* le = nostr_signer_last_error(ctx->signer); + DEBUG_ERROR("[task] nip44_decrypt failed (rc=%d): %s", + rc, (le && le[0]) ? le : ""); + return -1; + } *out_plaintext = signer_out; return 0; } diff --git a/src/tools/tools_dispatch.c b/src/tools/tools_dispatch.c index 7c7d2df..65efba2 100644 --- a/src/tools/tools_dispatch.c +++ b/src/tools/tools_dispatch.c @@ -47,6 +47,9 @@ char* tools_execute_legacy(tools_context_t* ctx, const char* tool_name, const ch if (strcmp(tool_name, "nostr_post") == 0) { return execute_nostr_post(args_json); } + if (strcmp(tool_name, "signer_crypto") == 0) { + return execute_signer_crypto(ctx, args_json); + } if (strcmp(tool_name, "nostr_delete") == 0) { return execute_nostr_delete(args_json); } diff --git a/src/tools/tools_internal.h b/src/tools/tools_internal.h index 53adcd1..dd9dcc0 100644 --- a/src/tools/tools_internal.h +++ b/src/tools/tools_internal.h @@ -77,6 +77,7 @@ char* execute_config_recall(tools_context_t* ctx, const char* args_json); char* execute_adopted_skills(tools_context_t* ctx, const char* args_json); char* execute_trigger_event(tools_context_t* ctx, const char* args_json); char* execute_nostr_dm_history(tools_context_t* ctx, const char* args_json); +char* execute_signer_crypto(tools_context_t* ctx, const char* args_json); char* execute_cashu_wallet_balance(tools_context_t* ctx, const char* args_json); char* execute_cashu_wallet_info(tools_context_t* ctx, const char* args_json); diff --git a/src/tools/tools_schema.c b/src/tools/tools_schema.c index e888133..1937538 100644 --- a/src/tools/tools_schema.c +++ b/src/tools/tools_schema.c @@ -41,6 +41,21 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) { cJSON_AddItemToObject(p_tags_items, "items", p_tag_item); cJSON_AddItemToObject(p_tags, "items", p_tags_items); cJSON_AddItemToObject(t1_props, "tags", p_tags); + /* Optional PoW (NIP-13) mining args. When difficulty > 0 and a remote + * n_signer is available, the event is mined through the signer before + * publishing. Requires --signer-path and --signer-role to be set. */ + cJSON* p_difficulty = cJSON_CreateObject(); + cJSON_AddStringToObject(p_difficulty, "type", "integer"); + cJSON_AddStringToObject(p_difficulty, "description", "NIP-13 PoW difficulty (leading zero bits). Requires a remote n_signer. Default: 0 (no mining)"); + cJSON_AddItemToObject(t1_props, "difficulty", p_difficulty); + cJSON* p_threads = cJSON_CreateObject(); + cJSON_AddStringToObject(p_threads, "type", "integer"); + cJSON_AddStringToObject(p_threads, "description", "Mining threads (default: 1, max: 32). Only used when difficulty > 0"); + cJSON_AddItemToObject(t1_props, "threads", p_threads); + cJSON* p_pow_timeout = cJSON_CreateObject(); + cJSON_AddStringToObject(p_pow_timeout, "type", "integer"); + cJSON_AddStringToObject(p_pow_timeout, "description", "Mining timeout in seconds (default: 600). Only used when difficulty > 0"); + cJSON_AddItemToObject(t1_props, "timeout_sec", p_pow_timeout); cJSON_AddItemToArray(t1_required, cJSON_CreateString("kind")); cJSON_AddItemToArray(t1_required, cJSON_CreateString("content")); @@ -2244,6 +2259,96 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) { cJSON_AddItemToObject(t67, "function", t67_fn); cJSON_AddItemToArray(tools, t67); + /* Tool 68: signer_crypto — unified access to all algorithm-based n_signer verbs. */ + cJSON* t68 = cJSON_CreateObject(); + cJSON* t68_fn = cJSON_CreateObject(); + cJSON* t68_params = cJSON_CreateObject(); + cJSON* t68_props = cJSON_CreateObject(); + cJSON* t68_required = cJSON_CreateArray(); + + cJSON_AddStringToObject(t68, "type", "function"); + cJSON_AddStringToObject(t68_fn, "name", "signer_crypto"); + cJSON_AddStringToObject(t68_fn, "description", + "Access algorithm-based n_signer crypto operations. Requires a remote n_signer " + "(local mode does not support algorithm verbs). Operations: get_info (signer metadata), " + "get_public_key (algorithm+index), sign (message_hex), verify (message_hex+signature_hex), " + "encapsulate (ML-KEM-768), decapsulate (ML-KEM-768), derive_shared_secret (X25519), " + "derive_hmac (secp256k1 HMAC-SHA256), otp_encrypt, otp_decrypt. " + "Algorithms: secp256k1, ed25519, x25519, ml-dsa-65, slh-dsa-128s, ml-kem-768, otp."); + cJSON_AddStringToObject(t68_params, "type", "object"); + cJSON_AddItemToObject(t68_params, "properties", t68_props); + cJSON_AddItemToObject(t68_params, "required", t68_required); + + cJSON* p68_op = cJSON_CreateObject(); + cJSON_AddStringToObject(p68_op, "type", "string"); + cJSON_AddStringToObject(p68_op, "description", + "Operation: get_info | get_public_key | sign | verify | encapsulate | decapsulate | " + "derive_shared_secret | derive_hmac | otp_encrypt | otp_decrypt"); + cJSON_AddItemToObject(t68_props, "operation", p68_op); + cJSON* p68_alg = cJSON_CreateObject(); + cJSON_AddStringToObject(p68_alg, "type", "string"); + cJSON_AddStringToObject(p68_alg, "description", + "Algorithm: secp256k1, ed25519, x25519, ml-dsa-65, slh-dsa-128s, ml-kem-768, otp. " + "Required for: get_public_key, sign, verify. Not used for: get_info, encapsulate, otp_*."); + cJSON_AddItemToObject(t68_props, "algorithm", p68_alg); + cJSON* p68_index = cJSON_CreateObject(); + cJSON_AddStringToObject(p68_index, "type", "integer"); + cJSON_AddStringToObject(p68_index, "description", + "Algorithm derivation index. Required for: get_public_key, sign, verify, decapsulate, " + "derive_shared_secret. Default: 0."); + cJSON_AddItemToObject(t68_props, "index", p68_index); + cJSON* p68_scheme = cJSON_CreateObject(); + cJSON_AddStringToObject(p68_scheme, "type", "string"); + cJSON_AddStringToObject(p68_scheme, "description", + "secp256k1-only signing scheme: 'schnorr' (default, BIP-340) or 'ecdsa'. Optional."); + cJSON_AddItemToObject(t68_props, "scheme", p68_scheme); + cJSON* p68_msg = cJSON_CreateObject(); + cJSON_AddStringToObject(p68_msg, "type", "string"); + cJSON_AddStringToObject(p68_msg, "description", + "Message bytes as hex string. Required for: sign, verify."); + cJSON_AddItemToObject(t68_props, "message_hex", p68_msg); + cJSON* p68_sig = cJSON_CreateObject(); + cJSON_AddStringToObject(p68_sig, "type", "string"); + cJSON_AddStringToObject(p68_sig, "description", + "Signature bytes as hex string. Required for: verify."); + cJSON_AddItemToObject(t68_props, "signature_hex", p68_sig); + cJSON* p68_peer = cJSON_CreateObject(); + cJSON_AddStringToObject(p68_peer, "type", "string"); + cJSON_AddStringToObject(p68_peer, "description", + "Peer public key as hex string. Required for: encapsulate (ML-KEM-768 pubkey, 2368 hex), " + "derive_shared_secret (X25519 pubkey, 64 hex)."); + cJSON_AddItemToObject(t68_props, "peer_pubkey_hex", p68_peer); + cJSON* p68_ct = cJSON_CreateObject(); + cJSON_AddStringToObject(p68_ct, "type", "string"); + cJSON_AddStringToObject(p68_ct, "description", + "Ciphertext as hex string. Required for: decapsulate."); + cJSON_AddItemToObject(t68_props, "ciphertext_hex", p68_ct); + cJSON* p68_data = cJSON_CreateObject(); + cJSON_AddStringToObject(p68_data, "type", "string"); + cJSON_AddStringToObject(p68_data, "description", + "Arbitrary UTF-8 data string. Required for: derive_hmac."); + cJSON_AddItemToObject(t68_props, "data", p68_data); + cJSON* p68_pt = cJSON_CreateObject(); + cJSON_AddStringToObject(p68_pt, "type", "string"); + cJSON_AddStringToObject(p68_pt, "description", + "Base64-encoded plaintext. Required for: otp_encrypt."); + cJSON_AddItemToObject(t68_props, "plaintext_b64", p68_pt); + cJSON* p68_enc = cJSON_CreateObject(); + cJSON_AddStringToObject(p68_enc, "type", "string"); + cJSON_AddStringToObject(p68_enc, "description", + "OTP encoding: 'ascii' (default) or 'binary'. Optional for: otp_encrypt, otp_decrypt."); + cJSON_AddItemToObject(t68_props, "encoding", p68_enc); + cJSON* p68_ct2 = cJSON_CreateObject(); + cJSON_AddStringToObject(p68_ct2, "type", "string"); + cJSON_AddStringToObject(p68_ct2, "description", + "OTP ciphertext (ASCII-armored or base64 blob). Required for: otp_decrypt."); + cJSON_AddItemToObject(t68_props, "ciphertext", p68_ct2); + + cJSON_AddItemToArray(t68_required, cJSON_CreateString("operation")); + cJSON_AddItemToObject(t68_fn, "parameters", t68_params); + cJSON_AddItemToObject(t68, "function", t68_fn); + cJSON_AddItemToArray(tools, t68); + char* out = cJSON_PrintUnformatted(tools); cJSON_Delete(tools); return out;