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

This commit is contained in:
Didactyl User
2026-08-07 07:05:09 -04:00
parent 3516e0b6fd
commit 67055e03f8
24 changed files with 1737 additions and 235 deletions
+1
View File
@@ -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 \
+2 -2
View File
@@ -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
+55 -5
View File
@@ -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":"<name>",
> "role_path":"<full-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 <mode>` | Select signer mode (overrides config/env). |
| `--signer-socket <name>` | Abstract socket name for `nsigner_unix`. |
| `--signer-role <role>` | `n_signer` role selector (default `main`). |
| `--signer-role <role>` | `n_signer` role selector (default `main`). **Required** for all `nsigner_*` modes. |
| `--signer-path <bip44-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 <n>` | Algorithm index for the `derive` (HMAC) verb. Runtime-only; not persisted to genesis. |
| `--signer-timeout <ms>` | Per-call timeout (default `15000`). |
| `--signer-tcp <host:port>` | Shorthand for `--signer nsigner_tcp` + host/port. |
| `--signer-serial <device>` | 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
+351
View File
@@ -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: <admin name or NOT FOUND>
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: <agent_name>
Identity: <pubkey>...
Admin: <admin pubkey or (not set)>
LLM Provider: <provider or (not set)>
LLM Model: <model or (not set)>
LLM Base URL: <base_url or (not set)>
LLM API Key: <masked or (not set)>
Relays: <N> configured
DM Protocol: <nip04/nip17/both>
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: <agent_name>
Identity: <pubkey>...
Admin: <admin>...
LLM: <model> @ <base_url>
Relays: <N> 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: <hex>
```
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: <name>
Identity: <pubkey>...
Admin: <admin>
LLM Provider: <provider>
LLM Model: <model>
LLM Base URL: <base_url>
LLM API Key: <masked>
Relays: <N> 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: <name>
Identity: <pubkey>...
Admin: <admin>...
LLM: <model> @ <base_url>
Relays: <N> 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.
+3 -1
View File
@@ -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 ───────────────────────────────
+147
View File
@@ -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":"<name>","role_path":"<full-bip44-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 <bip44-path>`,
`--signer-derive-index <n>`
- `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 <name>
--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`
+13 -9
View File
@@ -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';
+6 -2
View File
@@ -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) */
+3 -1
View File
@@ -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])))
+79 -24
View File
@@ -188,8 +188,11 @@ static void print_usage(const char* prog) {
" qube name (service defaults to qubes.NsignerRpc).\n"
" --signer-service <name>\n"
" qrexec service name for nsigner_qrexec (default: qubes.NsignerRpc).\n"
" --signer-index <n>\n"
" n_signer key index selector (overrides --signer-role). -1 = use role.\n"
" --signer-path <bip44-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>\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 <name> <args_json>\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 <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 <bip44-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 : "<auto>";
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 : "<other>",
cfg->signer.role[0] ? cfg->signer.role : "main",
cfg->signer.role_path[0] ? cfg->signer.role_path : "<unset>",
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 : "<auto>";
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 : "<other>",
cfg->signer.role[0] ? cfg->signer.role : "main",
cfg->signer.role_path[0] ? cfg->signer.role_path : "<unset>",
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;
+2 -2
View File
@@ -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"
+6
View File
@@ -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 : "<no detail>");
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 : "<no detail>");
return -1;
}
*out_content = cipher;
+70 -5
View File
@@ -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 : "<no detail>");
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;
+7
View File
@@ -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(
+488 -175
View File
@@ -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 <device> plus role/timeout.
* - nsigner_qrexec mode: passes --signer-qrexec <target_qube> 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 <device> plus role/path/timeout.
* - nsigner_qrexec mode: passes --signer-qrexec <target_qube> 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 : "<n/a>",
cfg->signer.service_name[0] ? cfg->signer.service_name : "<n/a>",
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;
}
+13 -2
View File
@@ -7,6 +7,7 @@
#include <string.h>
#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 : "<no detail>");
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 : "<no detail>");
return -1;
}
*out_plaintext = signer_out;
return 0;
}
+13 -2
View File
@@ -8,6 +8,7 @@
#include <string.h>
#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 : "<no detail>");
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 : "<no detail>");
return -1;
}
*out_plaintext = signer_out;
return 0;
}
+13 -2
View File
@@ -6,6 +6,7 @@
#include <string.h>
#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 : "<no detail>");
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 : "<no detail>");
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];
+60 -1
View File
@@ -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 : "<no detail>");
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);
+283
View File
@@ -0,0 +1,283 @@
#define _POSIX_C_SOURCE 200809L
#include "tools_internal.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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;
}
+13 -2
View File
@@ -8,6 +8,7 @@
#include <time.h>
#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 : "<no detail>");
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 : "<no detail>");
return -1;
}
*out_plaintext = signer_out;
return 0;
}
+3
View File
@@ -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);
}
+1
View File
@@ -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);
+105
View File
@@ -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;