diff --git a/README.md b/README.md index 8efa9d4..319cb6a 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,11 @@ Skills compose by adoption-list order (`10123`) and trigger tags carry runtime e Didactyl will support local inference, which is very privacy preserving. Remote inference does however have it's advantages, and in those cases Didactyl supports using Bitcoin Lightning and eCash inference providers. -## Current Status — v0.2.53 +## Current Status — v0.2.54 **Active build — this project is barely working. Experiment at your own risk.** -> Last release update: v0.2.53 — Add signer_health.c to Dockerfile.alpine-musl source list (was missing, causing static build failure) +> Last release update: v0.2.54 — Full n_signer transport support in setup wizard: added nsigner_serial, nsigner_fds, and nsigner_qrexec (Qubes cross-qube) modes. Updated nostr_core_lib to v0.6.10 (qrexec transport). Extended signer_config_t with serial_device, fds_read_fd/fds_write_fd, target_qube, service_name fields. Added --signer-serial, --signer-fds, --signer-qrexec, --signer-service CLI flags. Wizard transport picker now offers all 5 remote transports with auto-discovery for unix/serial. Existing-agent flow supports signer-based identity (no nsec required) with NIP-44 decrypt/encrypt routed through signer verbs. Systemd install supports serial/qrexec; fds rejected with clear message. Connectivity-check errors now pause for Enter before re-rendering. Deleted nostr_core_lib.old. Added docs/SIGNER.md. - Connects to configured relays with auto-reconnect and relay state transition logging - Publishes configured startup events per relay as each relay becomes connected diff --git a/docs/SIGNER.md b/docs/SIGNER.md new file mode 100644 index 0000000..ef3bf35 --- /dev/null +++ b/docs/SIGNER.md @@ -0,0 +1,138 @@ +# Signer Providers + +Didactyl can sign Nostr events and perform NIP-04/NIP-44 encryption either with +an in-process private key (`local`) or by delegating to a running +[`n_signer`](../n_signer) process over one of five transports. In any +`nsigner_*` mode the agent process holds **no nsec** — the private key lives +only in `n_signer`'s locked memory, and the agent pubkey is discovered at +startup via `nostr_signer_get_public_key()`. + +## Modes + +| Mode | Transport | Holds nsec in agent? | Persistable to genesis? | Installable as systemd? | +|------|-----------|----------------------|-------------------------|-------------------------| +| `local` | in-process | yes | yes | yes | +| `nsigner_unix` | AF_UNIX abstract socket | no | yes | yes | +| `nsigner_tcp` | TCP `host:port` | no | yes | yes | +| `nsigner_serial` | USB CDC-ACM serial | no | yes | yes | +| `nsigner_fds` | pre-connected fd pair | no | **no** (runtime-only) | **no** | +| `nsigner_qrexec` | Qubes qrexec (cross-qube) | no | yes | yes | + +### `local` +Default, backward-compatible. The 32-byte private key is derived from +`keys.nsec` and held in `cfg.keys.private_key`. Use this when you accept the +nsec living in the agent process. + +### `nsigner_unix` +Delegates to `n_signer` over an abstract namespace Unix socket +(`@`). If `socket_name` is empty, the agent auto-discovers running +`n_signer` sockets via `nsigner_transport_list_unix()`. Best for same-host +deployments where `n_signer` runs in an attended terminal. + +### `nsigner_tcp` +Delegates to `n_signer` over TCP. Requires `auth_privkey_hex` (64-char hex) for +the kind-27235 auth envelope that `n_signer` enforces on TCP listeners. Use for +cross-host or FIPS-mode deployments. + +### `nsigner_serial` +Delegates to `n_signer` over a USB CDC-ACM serial device (e.g. `/dev/ttyACM0`). +The wizard auto-discovers `/dev/ttyACM*` devices via +`nsigner_transport_list_serial()`. Use for air-gapped/hardware-token signing. + +### `nsigner_fds` +Delegates to `n_signer` over a pre-connected file-descriptor pair +(`read_fd`, `write_fd`). Intended for qrexec-style fd-passing deployments. +**Cannot be persisted to genesis** (fds are runtime-only) and **cannot be +installed as a systemd service** (fds cannot be embedded in `ExecStart`). Supply +via `--signer-fds ` at boot. + +### `nsigner_qrexec` +Delegates to `n_signer` running in **another Qubes OS qube** via qrexec. The +agent spawns `qrexec-client-vm ` as a subprocess +and pipes framed JSON-RPC through its stdin/stdout. No network; caller identity +is hypervisor-vouched (`QREXEC_REMOTE_DOMAIN` → `qubes:` on the +signer side). This is the recommended Qubes cross-qube signing path. + +Prerequisites (in the signer qube, e.g. `nostr_signer`): +- `n_signer` running persistently: `nsigner --listen unix --socket-name nsigner --bridge-source-trusted` +- The `qubes.NsignerRpc` qrexec service installed (see `n_signer/packaging/qubes/`) +- dom0 qrexec policy allowing the agent qube to call the service + +The service name defaults to `qubes.NsignerRpc`; override with `--signer-service` +or the `signer.service_name` config field. + +## Configuration + +### Genesis (`signer` block) + +```jsonc +"signer": { + "mode": "nsigner_unix", + "socket_name": "", // nsigner_unix: "" = auto-discover + "role": "main", + "timeout_ms": 15000, + "auth_privkey_hex": "", // nsigner_tcp only + "tcp_host": "127.0.0.1", // nsigner_tcp + "tcp_port": 11111, // nsigner_tcp + "serial_device": "/dev/ttyACM0", // nsigner_serial + "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 +} +``` + +### CLI flags + +| Flag | Purpose | +|------|---------| +| `--signer ` | Select signer mode (overrides config/env). | +| `--signer-socket ` | Abstract socket name for `nsigner_unix`. | +| `--signer-role ` | `n_signer` role selector (default `main`). | +| `--signer-timeout ` | Per-call timeout (default `15000`). | +| `--signer-tcp ` | Shorthand for `--signer nsigner_tcp` + host/port. | +| `--signer-serial ` | Shorthand for `--signer nsigner_serial` + device. | +| `--signer-fds ` | Shorthand for `--signer nsigner_fds` + fd pair. | +| `--signer-qrexec ` | Shorthand for `--signer nsigner_qrexec` + target qube. | +| `--signer-service ` | qrexec service name (default `qubes.NsignerRpc`). | + +Precedence: CLI flag > `DIDACTYL_SIGNER` env var > genesis `signer.mode` > +default (`local`). + +In any `nsigner_*` mode, `--nsec` / `DIDACTYL_NSEC` are not required. + +## Interactive setup wizard + +The wizard's **New agent → sign with a running n_signer** path and the +**Existing agent** flow both present a transport picker: + +- `u` nix socket (auto-discovers `@nsigner*` abstract sockets) +- `t` cp (prompts for `host:port` + optional auth privkey) +- `s` erial (auto-discovers `/dev/ttyACM*`) +- `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. + +The **Existing agent** flow additionally lets you recover an agent **without +entering an nsec** — kind-30078 config recall/publish is routed through the +signer verbs (`nostr_signer_nip44_decrypt` / `nostr_signer_nip44_encrypt`), so +the nsec never enters the agent process during recovery. + +## Systemd install + +`install_system_service_with_dedicated_user()` embeds the signer flags in +`ExecStart` for `nsigner_unix`, `nsigner_tcp`, `nsigner_serial`, and +`nsigner_qrexec`. +`nsigner_fds` is rejected with a clear message (fds are runtime-only and cannot +be embedded in `ExecStart`); choose **boot now** or wire fd-passing yourself +via a wrapper unit. + +## Security tradeoff + +`local` keeps the nsec in the agent process. If the agent is compromised, the +nsec is exposed. Remote (`nsigner_*`) modes keep the nsec in `n_signer`'s +mlock'd memory; a compromised agent can request signatures but cannot extract +the raw key. The tradeoff is operational: `n_signer` must be running and +reachable when the agent needs to sign. diff --git a/genesis.jsonc.example b/genesis.jsonc.example index 185d23b..79ab11c 100644 --- a/genesis.jsonc.example +++ b/genesis.jsonc.example @@ -14,6 +14,25 @@ "pubkey": "npub1REPLACE_WITH_ADMIN_PUBKEY" }, + // ─── Signer Provider (optional) ──────────────────────────────────── + // Omit this block (or set mode="local") to use the nsec above in-process. + // In any nsigner_* mode the nsec is optional/ignored; the agent pubkey is + // learned from the signer via nostr_signer_get_public_key(). See docs/SIGNER.md. + // + // "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 + // "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 + // "tcp_port": 11111, // nsigner_tcp port + // "serial_device": "/dev/ttyACM0", // nsigner_serial device path + // "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 + // }, + // ─── Encrypted Startup Config Events ─────────────────────────────── // These are published as NIP-44 encrypted kind 30078 self-events on first run. "encrypted_events": [ diff --git a/nostr_core_lib.old b/nostr_core_lib.old deleted file mode 160000 index af06f23..0000000 --- a/nostr_core_lib.old +++ /dev/null @@ -1 +0,0 @@ -Subproject commit af06f23939d97b81f91603931844500b7a91d13a diff --git a/plans/wizard_full_nsigner_transports.md b/plans/wizard_full_nsigner_transports.md new file mode 100644 index 0000000..c85841a --- /dev/null +++ b/plans/wizard_full_nsigner_transports.md @@ -0,0 +1,248 @@ +# Full n_signer Transport Support in the Setup Wizard + +## Context + +`n_signer` (upgraded to v0.1.5) now exposes four transports and a new +`nostr_mine_event` verb. `nostr_core_lib` (v0.6.6, the client lib didactyl links +against) already exposes all four transport factories and discovery helpers, but +didactyl's interactive setup wizard and config/main wiring only recognize +`nsigner_unix` and `nsigner_tcp`. The existing-agent recovery path still +requires an nsec and uses raw-key NIP-44, which defeats the point of remote +signer modes. + +This plan closes those gaps so an operator can stand up a new agent or recover +an existing one using any n_signer transport, with the nsec never entering the +agent process. + +## Current State (verified) + +- `nostr_core_lib/nostr_core/nostr_signer.h` exposes: + - `nostr_signer_nsigner_unix`, `nostr_signer_nsigner_tcp`, + `nostr_signer_nsigner_serial`, `nostr_signer_nsigner_fds` + - `nostr_signer_nsigner_set_auth` (TCP auth envelope) +- `nostr_core_lib/nostr_core/nsigner_transport.h` exposes: + - `nsigner_transport_list_unix` (enumerate `@nsigner*` abstract sockets) + - `nsigner_transport_list_serial` (enumerate `/dev/ttyACM*`) +- `nostr_core_lib/nostr_core/nostr_signer.c` routes the four core verbs + (get_public_key, sign_event, nip04/nip44 encrypt/decrypt) through + `nostr_signer_t` for all backends. **`nostr_mine_event` is NOT wired.** +- `src/config.h` `signer_config_t` has fields for `mode`, `socket_name`, + `role`, `timeout_ms`, `auth_privkey_hex`, `tcp_host`, `tcp_port`, + `emergency_local_nsec`. **No serial device or fds fields.** +- `src/config.c` `signer_mode_is_known()` / `signer_mode_is_remote()` only + accept `local`, `nsigner_unix`, `nsigner_tcp`. +- `src/main.c` `apply_signer_overrides()` and `construct_signer()` only handle + `local`, `nsigner_unix`, `nsigner_tcp`. +- `src/setup_wizard.c` `new_agent_identity_step()` option `s` hardcodes + `nsigner_unix`, prompts for socket/role/timeout, does a connectivity check, + and offers no auto-discovery or TCP/serial/fds choice. +- `src/setup_wizard.c` `existing_agent_flow()` requires an nsec and uses + `nostr_nip44_decrypt`/`nostr_nip44_encrypt` with the raw private key for + kind-30078 config recovery (Phase 3 item 13 DEFERRED notes). +- `src/setup_wizard.c` `install_system_service_with_dedicated_user()` builds + `key_args` for `nsigner_unix` and `nsigner_tcp` only. + +## Architecture + +```mermaid +flowchart TD + A[Wizard Main Menu] --> B{New or Existing?} + B -->|New| C[Identity Step] + B -->|Existing| D[Signer-or-nsec Step] + C --> E[Signer Transport Picker] + D --> E + E --> F{Transport} + F -->|Unix| G[List @nsigner sockets via nsigner_transport_list_unix] + F -->|TCP| H[Prompt host:port + optional auth privkey] + F -->|Serial| I[List /dev/ttyACM* via nsigner_transport_list_serial] + F -->|FDs| J[Prompt read_fd/write_fd] + F -->|Local nsec| K[Legacy raw-key path] + G --> L[Connectivity check via nostr_signer_get_public_key] + H --> L + I --> L + J --> L + K --> M[Derive keys from nsec] + L --> N[Populate cfg.signer + cfg.keys.public_key_hex] + M --> N + N --> O[Continue wizard flow] +``` + +## Changes by File + +### 1. `src/config.h` — extend `signer_config_t` + +- Update the `mode` comment to list all five modes: + `local | nsigner_unix | nsigner_tcp | nsigner_serial | nsigner_fds`. +- Add fields: + - `char serial_device[OW_MAX_URL_LEN]` — path for `nsigner_serial`. + - `int fds_read_fd`, `int fds_write_fd` — for `nsigner_fds` (CLI-only; not + persisted to genesis since fds are runtime-only). +- Add a new `OW_MAX_SIGNER_DEVICE_LEN` constant (reuse `OW_MAX_URL_LEN`). + +### 2. `src/config.c` — accept the new modes + +- `signer_mode_is_known()`: add `nsigner_serial`, `nsigner_fds`. +- `signer_mode_is_remote()`: add `nsigner_serial`, `nsigner_fds`. +- `parse_signer_config()`: parse `serial_device` string. Do NOT parse + `fds_read_fd`/`fds_write_fd` from JSON (they are CLI-only); document this. +- Update the error message string to list all five modes. +- `config_set_defaults()` (signer block): leave `mode="local"`, zero the new + fields. + +### 3. `src/main.c` — construct signer for all transports + +- `signer_mode_is_remote_local()`: add `nsigner_serial`, `nsigner_fds`. +- `apply_signer_overrides()`: + - Add `--signer-serial ` CLI flag → sets mode `nsigner_serial` and + `cfg->signer.serial_device`. + - Add `--signer-fds ` CLI flag → sets mode `nsigner_fds` + and parses the two fds. + - Update the validation block and error message to list all five modes. +- `construct_signer()`: + - Add `nsigner_serial` branch → `nostr_signer_nsigner_serial( + cfg->signer.serial_device, role, timeout_ms)`. + - Add `nsigner_fds` branch → `nostr_signer_nsigner_fds( + cfg->signer.fds_read_fd, cfg->signer.fds_write_fd, role, timeout_ms)`. + - Update the "Unknown signer mode" fallback message. + - The existing connectivity-check + pubkey-discovery block already works for + all remote backends (it calls `nostr_signer_get_public_key`), so no change + needed there. +- Update `print_usage()` help text to document `--signer-serial` and + `--signer-fds`. + +### 4. `src/setup_wizard.c` — new signer transport picker + +Replace the hardcoded `nsigner_unix` block in `new_agent_identity_step()` +option `s` with a new helper `prompt_signer_transport(cfg)` that: + +1. Renders a sub-menu: + - `u` nix 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) + - `b` ack +2. For `u`: call `nsigner_transport_list_unix()` to enumerate running n_signer + abstract sockets; if any are found, list them numbered and let the operator + pick one or type a custom name; if none found, prompt for a name (empty = + auto-discover at boot). +3. For `t`: prompt `host:port` and an optional 64-char hex auth privkey + (echo-suppressed). Store into `cfg->signer.tcp_host/tcp_port/ + auth_privkey_hex`. +4. For `s`: call `nsigner_transport_list_serial()` to enumerate + `/dev/ttyACM*`; list them and let the operator pick or type a custom path. + Store into `cfg->signer.serial_device`. +5. For `f`: prompt `read_fd:write_fd` (advanced; mainly for qrexec-style + deployments). Store into `cfg->signer.fds_read_fd/fds_write_fd`. +6. Common: prompt `role` (default `main`) and `timeout_ms` (default `15000`). +7. Set `cfg->signer.mode` to the chosen mode. +8. Connectivity check: construct an ephemeral `nostr_signer_t*` via the + matching factory, call `nostr_signer_get_public_key()` to populate + `cfg->keys.public_key_hex`, then `nostr_signer_free()`. On failure, print + the error and loop back to the transport picker. +9. Guard all nsigner_* branches with `#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)` + and emit the existing "compiled without n_signer client support" warning + otherwise. + +Add a parallel entry point to `existing_agent_flow()` so the operator can +choose "recover via a running n_signer" instead of entering an nsec. See item 5. + +### 5. `src/setup_wizard.c` — existing-agent recovery via signer verbs + +Refactor `existing_agent_flow()`: + +- New first step: choose identity source: + - `n` sec (legacy) + - `s` ign with a running n_signer (calls `prompt_signer_transport(cfg)`) +- When the signer path is chosen: + - The connectivity check populates `cfg->keys.public_key_hex` from + `nostr_signer_get_public_key()`. + - Keep the ephemeral signer handle alive for the recovery queries instead + of freeing it immediately (pass it through to the recovery helpers). +- Replace the raw-key NIP-44 decrypt in `fetch_and_decrypt_self_config_wizard()` + with a signer-aware variant: when a signer is available, call + `nostr_signer_nip44_decrypt(signer, cfg->keys.public_key_hex, ciphertext, + &plaintext)`. Fall back to the raw-key path only when `signer == NULL` + (local mode). The peer pubkey for self-encrypt is the agent's own pubkey. +- Replace the raw-key NIP-44 encrypt in + `publish_encrypted_self_config_wizard()` the same way. +- Remove/replace the Phase 3 item 13 DEFERRED comments at those two sites + since the signer path is now implemented for the wizard. +- The `nostr_handler_init(&tmp)` calls in the recovery helpers currently copy + the config and re-derive keys; ensure they pick up `cfg->signer` so the + handler uses the process signer for its own encrypt/decrypt. (Verify + `nostr_handler` already threads the signer through; if not, pass the + ephemeral wizard signer via a new `nostr_handler_set_signer()` call for the + duration of the recovery queries, then clear it before `cleanup()`.) + +### 6. `src/setup_wizard.c` — systemd unit key_args for new modes + +In `install_system_service_with_dedicated_user()`, extend the `key_args` +builder: +- `nsigner_serial`: `--signer nsigner_serial --signer-serial + --signer-role --signer-timeout ` +- `nsigner_fds`: skip systemd install (fds are runtime-only and cannot be + embedded in ExecStart). Print a clear message that fds mode is not + installable as a systemd service and the operator must wire the fd-passing + themselves (e.g. via a wrapper unit with `Sockets=` or qrexec). Return + non-fatal error so the wizard offers "boot now" instead. +- Update the summary print block (`Signer: %s ...`) to mention serial/fds. + +### 7. `src/main.c` — `--signer-serial` / `--signer-fds` arg parsing + +Already covered in item 3. Ensure `argv` parsing loop handles the two new +flags and their values, and that `--signer-fds` parses `read:write` with +`atoi` validation (both >= 0). + +### 8. `nostr_core_lib` — wire `nostr_mine_event` (optional, stretch) + +This is a library-side change. If we want didactyl to be able to mine PoW +events through a remote signer: +- Add `int nostr_signer_mine_event(nostr_signer_t*, const cJSON* unsigned_event, + int difficulty, int threads, int timeout_sec, cJSON** signed_event_out)` to + `nostr_signer.h`. +- Implement the remote branch in `nostr_signer.c` calling + `nsigner_client_call(client, "nostr_mine_event", params, &result)` with the + options object `{difficulty, threads, timeout_sec}`. +- Local branch: call the existing `nostr_mine_event` helper in `nip013.c`. +- This is **not required** for the wizard task; include only if the operator + wants PoW through n_signer. Recommend deferring to a follow-up. + +### 9. Docs + +- Update `docs/GENESIS.md` signer block example to show all five modes and the + new `serial_device` field. +- Update `README.md` quick-start example #5 to mention TCP/serial variants. +- Add a short section to `docs/CONTEXT.md` (or a new `docs/SIGNER.md`) + describing the five modes, when to use each, and the security tradeoff + (local keeps nsec in-process; remote modes keep it in n_signer). + +## Verification + +1. `make deps && make` — must compile cleanly with + `-DNOSTR_ENABLE_NSIGNER_CLIENT=1`. +2. Run `./didactyl --help` — confirm `--signer-serial` and `--signer-fds` + appear. +3. Start a local n_signer on an abstract unix socket; run `./didactyl` + (interactive), choose New agent → sign with n_signer → unix → pick the + discovered socket → confirm connectivity check prints the pubkey. +4. Repeat with `--signer-tcp 127.0.0.1:11111` style flow via the wizard TCP + option. +5. Existing-agent flow: with a running n_signer, choose Existing → sign with + n_signer → confirm kind-30078 config is decrypted via the signer (no nsec + entered). Verify the recovered LLM/admin fields appear. +6. Negative: kill n_signer mid-wizard → connectivity check fails → wizard + loops back to the transport picker. +7. `nsigner_serial` / `nsigner_fds` modes: validate config load via a + genesis file with `signer.mode: "nsigner_serial"` and + `serial_device: "/dev/ttyACM0"`; confirm `construct_signer()` attempts the + serial factory (will fail without hardware, but the mode must be accepted + and reach the factory, not be rejected as "unknown signer mode"). + +## Out of Scope + +- Wiring `nostr_mine_event` into didactyl call sites (only the lib hook in + item 8, and only if explicitly requested). +- Changing the `nostr_handler` signer threading model beyond what the wizard + recovery path needs. +- Windows support for abstract sockets / serial enumeration (Linux-only, as + today). diff --git a/src/config.c b/src/config.c index f760ba4..583839c 100644 --- a/src/config.c +++ b/src/config.c @@ -221,7 +221,10 @@ static int signer_mode_is_known(const char* mode) { } return strcmp(mode, "local") == 0 || strcmp(mode, "nsigner_unix") == 0 || - strcmp(mode, "nsigner_tcp") == 0; + strcmp(mode, "nsigner_tcp") == 0 || + strcmp(mode, "nsigner_serial") == 0 || + strcmp(mode, "nsigner_fds") == 0 || + strcmp(mode, "nsigner_qrexec") == 0; } /* Returns 1 if mode is a remote (nsigner_*) mode that does not require an @@ -231,7 +234,10 @@ static int signer_mode_is_remote(const char* mode) { return 0; } return strcmp(mode, "nsigner_unix") == 0 || - strcmp(mode, "nsigner_tcp") == 0; + strcmp(mode, "nsigner_tcp") == 0 || + strcmp(mode, "nsigner_serial") == 0 || + strcmp(mode, "nsigner_fds") == 0 || + strcmp(mode, "nsigner_qrexec") == 0; } static int parse_signer_config(cJSON* root, didactyl_config_t* config) { @@ -248,7 +254,8 @@ static int parse_signer_config(cJSON* root, didactyl_config_t* config) { } if (config->signer.mode[0] != '\0' && !signer_mode_is_known(config->signer.mode)) { config_set_error("signer.mode '%s' is not a known signer mode " - "(expected: local, nsigner_unix, nsigner_tcp)", + "(expected: local, nsigner_unix, nsigner_tcp, " + "nsigner_serial, nsigner_fds, nsigner_qrexec)", config->signer.mode); return -1; } @@ -293,6 +300,28 @@ static int parse_signer_config(cJSON* root, didactyl_config_t* config) { } } + /* nsigner_serial: device path (e.g. "/dev/ttyACM0"). fds_read_fd/fds_write_fd + * are intentionally NOT parsed from JSON — they are CLI/runtime-only and + * cannot be persisted to genesis. */ + if (copy_json_string(signer, "serial_device", config->signer.serial_device, + sizeof(config->signer.serial_device), 0) != 0) { + config_set_error("signer.serial_device must be a string when provided"); + return -1; + } + + /* nsigner_qrexec: target Qubes qube name + qrexec service name. + * service_name defaults to "qubes.NsignerRpc" when empty. */ + if (copy_json_string(signer, "target_qube", config->signer.target_qube, + sizeof(config->signer.target_qube), 0) != 0) { + config_set_error("signer.target_qube must be a string when provided"); + return -1; + } + if (copy_json_string(signer, "service_name", config->signer.service_name, + sizeof(config->signer.service_name), 0) != 0) { + config_set_error("signer.service_name must be a string when provided"); + return -1; + } + /* Phase 4 item 20: optional emergency local nsec for admin-alert fallback. * Parsed and stored only; no active fallback behavior is wired yet. */ if (copy_json_string(signer, "emergency_local_nsec", @@ -1590,6 +1619,11 @@ int config_load(const char* path, didactyl_config_t* config) { config->signer.auth_privkey_hex[0] = '\0'; config->signer.tcp_host[0] = '\0'; config->signer.tcp_port = 0; + config->signer.serial_device[0] = '\0'; + config->signer.fds_read_fd = -1; + config->signer.fds_write_fd = -1; + config->signer.target_qube[0] = '\0'; + config->signer.service_name[0] = '\0'; /* Phase 4 item 20: emergency local nsec placeholder. Off by default; * no active fallback behavior yet (documentation/feature-flag only). */ config->signer.emergency_local_nsec[0] = '\0'; diff --git a/src/config.h b/src/config.h index baff6a9..cca7f7c 100644 --- a/src/config.h +++ b/src/config.h @@ -124,19 +124,29 @@ typedef struct { /* Signer provider configuration. * * mode selects how signing/encryption operations are routed: - * "local" - raw 32-byte key held in cfg.keys.private_key (default, - * backward compatible, output-equivalent to legacy path). - * "nsigner_unix" - delegate to a running n_signer over an AF_UNIX abstract - * socket. The agent process holds no nsec. - * "nsigner_tcp" - delegate to a running n_signer over TCP (host:port). - * Requires auth_privkey_hex for the kind-27235 auth envelope. + * "local" - raw 32-byte key held in cfg.keys.private_key (default, + * backward compatible, output-equivalent to legacy path). + * "nsigner_unix" - delegate to a running n_signer over an AF_UNIX abstract + * socket. The agent process holds no nsec. + * "nsigner_tcp" - delegate to a running n_signer over TCP (host:port). + * Requires auth_privkey_hex for the kind-27235 auth envelope. + * "nsigner_serial" - delegate to a running n_signer over a USB CDC-ACM serial + * device (e.g. /dev/ttyACM0). The agent process holds no nsec. + * "nsigner_fds" - delegate to a running n_signer over a pre-connected fd + * pair (read_fd, write_fd). CLI/runtime-only: the fds cannot + * be persisted to genesis and must be supplied via --signer-fds + * at boot. Intended for qrexec-style fd-passing deployments. + * "nsigner_qrexec" - delegate to a running n_signer in another Qubes OS qube + * via qrexec (spawns `qrexec-client-vm + * `). No network; hypervisor-vouched caller + * identity. The agent process holds no nsec. * * Missing signer block behaves as mode="local" using keys.nsec. * In any nsigner_* mode, keys.nsec is optional and ignored; the agent pubkey * is learned from the signer via nostr_signer_get_public_key(). */ typedef struct { - char mode[OW_MAX_SIGNER_MODE_LEN]; /* "local" | "nsigner_unix" | "nsigner_tcp" */ + 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 timeout_ms; /* per-call timeout (default 15000) */ @@ -144,6 +154,19 @@ typedef struct { /* nsigner_tcp parsed host/port (populated from --signer-tcp or config) */ char tcp_host[OW_MAX_SIGNER_HOST_LEN]; int tcp_port; + /* nsigner_serial: device path (e.g. "/dev/ttyACM0"). Populated from + * --signer-serial or the signer.serial_device config field. */ + char serial_device[OW_MAX_URL_LEN]; + /* nsigner_fds: pre-connected file descriptors. CLI/runtime-only — NOT + * persisted to genesis. Populated from --signer-fds . */ + int fds_read_fd; + int fds_write_fd; + /* nsigner_qrexec: target Qubes qube name + qrexec service name. + * Populated from --signer-qrexec or the signer.target_qube / + * signer.service_name config fields. service_name defaults to + * "qubes.NsignerRpc" when empty. */ + char target_qube[OW_MAX_SIGNER_HOST_LEN]; + char service_name[OW_MAX_SIGNER_SOCKET_LEN]; /* Optional emergency local nsec for admin-alert fallback (Phase 4 item 20). * * OFF by default (empty string). When populated AND a remote signer is diff --git a/src/main.c b/src/main.c index 6dbb3b7..1f15ee0 100644 --- a/src/main.c +++ b/src/main.c @@ -165,7 +165,8 @@ static void print_usage(const char* prog) { " --debug <0-5>\n" " Log level (0=fatal, 1=error, 2=warn, 3=info, 4=debug, 5=trace).\n" " --signer \n" - " Signer provider: local | nsigner_unix | nsigner_tcp.\n" + " Signer provider: local | nsigner_unix | nsigner_tcp |\n" + " nsigner_serial | nsigner_fds | nsigner_qrexec.\n" " Overrides signer.mode in config and DIDACTYL_SIGNER.\n" " In nsigner_* modes --nsec/DIDACTYL_NSEC are not required.\n" " --signer-socket \n" @@ -176,6 +177,17 @@ static void print_usage(const char* prog) { " Per-call signer timeout in milliseconds (default: 15000).\n" " --signer-tcp \n" " Shorthand for --signer nsigner_tcp with host/port parsed.\n" + " --signer-serial \n" + " Shorthand for --signer nsigner_serial with device path\n" + " (e.g. /dev/ttyACM0).\n" + " --signer-fds \n" + " Shorthand for --signer nsigner_fds with a pre-connected\n" + " fd pair (runtime-only; not persisted to genesis).\n" + " --signer-qrexec \n" + " Shorthand for --signer nsigner_qrexec with the target Qubes\n" + " qube name (service defaults to qubes.NsignerRpc).\n" + " --signer-service \n" + " qrexec service name for nsigner_qrexec (default: qubes.NsignerRpc).\n" " --dump-schemas\n" " Print tool schemas JSON and exit.\n" " --test-tool \n" @@ -206,10 +218,16 @@ static void print_usage(const char* prog) { " 6) n_signer via TCP\n" " %s --signer-tcp 127.0.0.1:7777 --admin npub1...\n" "\n" - " 7) Dump tool schemas\n" + " 7) n_signer via USB serial\n" + " %s --signer-serial /dev/ttyACM0 --admin npub1...\n" + "\n" + " 8) n_signer in another Qubes qube (qrexec, no network)\n" + " %s --signer-qrexec nostr_signer --admin npub1...\n" + "\n" + " 9) Dump tool schemas\n" " %s --dump-schemas\n" "\n" - " 8) Test a tool invocation\n" + " 10) Test a tool invocation\n" " %s --test-tool config_recall '{\"config_name\":\"llm_config\"}'\n", p, p, @@ -219,6 +237,8 @@ static void print_usage(const char* prog) { p, p, p, + p, + p, p); } @@ -252,7 +272,28 @@ static int signer_mode_is_remote_local(const char* mode) { return 0; } return strcmp(mode, "nsigner_unix") == 0 || - strcmp(mode, "nsigner_tcp") == 0; + strcmp(mode, "nsigner_tcp") == 0 || + strcmp(mode, "nsigner_serial") == 0 || + strcmp(mode, "nsigner_fds") == 0 || + strcmp(mode, "nsigner_qrexec") == 0; +} + +/* Parse a "read_fd:write_fd" pair string into two ints. + * Returns 0 on success, -1 on malformed input. */ +static int parse_fd_pair(const char* s, int* read_fd_out, int* write_fd_out) { + if (!s || !read_fd_out || !write_fd_out) return -1; + const char* colon = strchr(s, ':'); + if (!colon) return -1; + char read_buf[16] = {0}; + size_t read_len = (size_t)(colon - s); + if (read_len == 0 || read_len >= sizeof(read_buf)) return -1; + memcpy(read_buf, s, read_len); + int r = atoi(read_buf); + int w = atoi(colon + 1); + if (r < 0 || w < 0) return -1; + *read_fd_out = r; + *write_fd_out = w; + return 0; } /* Apply CLI/env signer overrides onto cfg->signer. @@ -263,7 +304,11 @@ static int apply_signer_overrides(didactyl_config_t* cfg, const char* cli_socket, const char* cli_role, int cli_timeout, - const char* cli_tcp) { + const char* cli_tcp, + const char* cli_serial, + const char* cli_fds, + const char* cli_qrexec, + const char* cli_service) { if (!cfg) { return -1; } @@ -277,6 +322,21 @@ static int apply_signer_overrides(didactyl_config_t* cfg, return -1; } snprintf(cfg->signer.mode, sizeof(cfg->signer.mode), "%s", "nsigner_tcp"); + } else if (cli_serial && cli_serial[0] != '\0') { + /* --signer-serial shorthand: forces mode=nsigner_serial. */ + snprintf(cfg->signer.serial_device, sizeof(cfg->signer.serial_device), "%s", cli_serial); + snprintf(cfg->signer.mode, sizeof(cfg->signer.mode), "%s", "nsigner_serial"); + } else if (cli_fds && cli_fds[0] != '\0') { + /* --signer-fds shorthand: forces mode=nsigner_fds and parses the pair. */ + if (parse_fd_pair(cli_fds, &cfg->signer.fds_read_fd, &cfg->signer.fds_write_fd) != 0) { + fprintf(stderr, "Invalid --signer-fds value (expected read_fd:write_fd, got '%s')\n", cli_fds); + return -1; + } + snprintf(cfg->signer.mode, sizeof(cfg->signer.mode), "%s", "nsigner_fds"); + } else if (cli_qrexec && cli_qrexec[0] != '\0') { + /* --signer-qrexec shorthand: forces mode=nsigner_qrexec. */ + snprintf(cfg->signer.target_qube, sizeof(cfg->signer.target_qube), "%s", cli_qrexec); + snprintf(cfg->signer.mode, sizeof(cfg->signer.mode), "%s", "nsigner_qrexec"); } else if (cli_mode && cli_mode[0] != '\0') { snprintf(cfg->signer.mode, sizeof(cfg->signer.mode), "%s", cli_mode); } else { @@ -286,17 +346,48 @@ static int apply_signer_overrides(didactyl_config_t* cfg, } } + /* --signer-service overrides the qrexec service name regardless of mode. */ + if (cli_service && cli_service[0] != '\0') { + snprintf(cfg->signer.service_name, sizeof(cfg->signer.service_name), "%s", cli_service); + } + /* Validate the final mode. */ const char* mode = cfg->signer.mode; if (mode[0] == '\0' || (strcmp(mode, "local") != 0 && strcmp(mode, "nsigner_unix") != 0 && - strcmp(mode, "nsigner_tcp") != 0)) { - fprintf(stderr, "Unknown signer mode '%s' (expected: local, nsigner_unix, nsigner_tcp)\n", + strcmp(mode, "nsigner_tcp") != 0 && + strcmp(mode, "nsigner_serial") != 0 && + strcmp(mode, "nsigner_fds") != 0 && + strcmp(mode, "nsigner_qrexec") != 0)) { + fprintf(stderr, "Unknown signer mode '%s' (expected: local, nsigner_unix, " + "nsigner_tcp, nsigner_serial, nsigner_fds, nsigner_qrexec)\n", mode[0] ? mode : ""); return -1; } + /* Mode-specific required-field checks. */ + if (strcmp(mode, "nsigner_serial") == 0 && cfg->signer.serial_device[0] == '\0') { + fprintf(stderr, "nsigner_serial mode requires --signer-serial or " + "signer.serial_device in config (mode=nsigner_serial)\n"); + return -1; + } + if (strcmp(mode, "nsigner_fds") == 0 && + (cfg->signer.fds_read_fd < 0 || cfg->signer.fds_write_fd < 0)) { + fprintf(stderr, "nsigner_fds mode requires --signer-fds " + "(mode=nsigner_fds); fds cannot be loaded from genesis\n"); + return -1; + } + if (strcmp(mode, "nsigner_qrexec") == 0 && cfg->signer.target_qube[0] == '\0') { + fprintf(stderr, "nsigner_qrexec mode requires --signer-qrexec or " + "signer.target_qube in config (mode=nsigner_qrexec)\n"); + return -1; + } + /* Default the qrexec service name if unset. */ + if (strcmp(mode, "nsigner_qrexec") == 0 && cfg->signer.service_name[0] == '\0') { + snprintf(cfg->signer.service_name, sizeof(cfg->signer.service_name), "%s", "qubes.NsignerRpc"); + } + if (cli_socket && cli_socket[0] != '\0') { snprintf(cfg->signer.socket_name, sizeof(cfg->signer.socket_name), "%s", cli_socket); } @@ -369,8 +460,43 @@ static nostr_signer_t* construct_signer(didactyl_config_t* cfg) { cfg->signer.tcp_host, cfg->signer.tcp_port); } } + } else if (strcmp(mode, "nsigner_serial") == 0) { + if (cfg->signer.serial_device[0] == '\0') { + fprintf(stderr, "nsigner_serial mode requires --signer-serial or " + "signer.serial_device in config (mode=nsigner_serial)\n"); + signer_health_set_mode("nsigner_serial"); + signer_health_record_failure(NOSTR_ERROR_INVALID_INPUT, + "missing serial_device"); + return NULL; + } + signer = nostr_signer_nsigner_serial(cfg->signer.serial_device, + cfg->signer.role, cfg->signer.timeout_ms); + } else if (strcmp(mode, "nsigner_fds") == 0) { + if (cfg->signer.fds_read_fd < 0 || cfg->signer.fds_write_fd < 0) { + fprintf(stderr, "nsigner_fds mode requires --signer-fds " + "(mode=nsigner_fds); fds cannot be loaded from genesis\n"); + signer_health_set_mode("nsigner_fds"); + signer_health_record_failure(NOSTR_ERROR_INVALID_INPUT, + "missing fds_read_fd/fds_write_fd"); + return NULL; + } + signer = nostr_signer_nsigner_fds(cfg->signer.fds_read_fd, cfg->signer.fds_write_fd, + cfg->signer.role, cfg->signer.timeout_ms); + } else if (strcmp(mode, "nsigner_qrexec") == 0) { + if (cfg->signer.target_qube[0] == '\0') { + fprintf(stderr, "nsigner_qrexec mode requires --signer-qrexec or " + "signer.target_qube in config (mode=nsigner_qrexec)\n"); + signer_health_set_mode("nsigner_qrexec"); + signer_health_record_failure(NOSTR_ERROR_INVALID_INPUT, + "missing target_qube"); + return NULL; + } + const char* svc = cfg->signer.service_name[0] ? cfg->signer.service_name : "qubes.NsignerRpc"; + signer = nostr_signer_nsigner_qrexec(cfg->signer.target_qube, svc, + cfg->signer.role, cfg->signer.timeout_ms); } else { - fprintf(stderr, "Unknown signer mode '%s' (expected: local, nsigner_unix, nsigner_tcp)\n", mode); + fprintf(stderr, "Unknown signer mode '%s' (expected: local, nsigner_unix, " + "nsigner_tcp, nsigner_serial, nsigner_fds, nsigner_qrexec)\n", mode); signer_health_set_mode(mode); signer_health_record_failure(NOSTR_ERROR_INVALID_INPUT, "unknown signer mode"); return NULL; @@ -381,7 +507,7 @@ static nostr_signer_t* construct_signer(didactyl_config_t* cfg) { const char* sock = cfg->signer.socket_name[0] ? cfg->signer.socket_name : ""; fprintf(stderr, "Failed to construct remote signer (mode=%s, socket=%s, role=%s, " "timeout_ms=%d): nostr_signer_nsigner_* returned NULL\n", - mode, strcmp(mode, "nsigner_unix") == 0 ? sock : "", + mode, strcmp(mode, "nsigner_unix") == 0 ? sock : "", cfg->signer.role[0] ? cfg->signer.role : "main", cfg->signer.timeout_ms); signer_health_record_failure(NOSTR_ERROR_IO_FAILED, "signer construction returned NULL"); @@ -396,7 +522,7 @@ static nostr_signer_t* construct_signer(didactyl_config_t* cfg) { 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", - mode, strcmp(mode, "nsigner_unix") == 0 ? sock : "", + mode, strcmp(mode, "nsigner_unix") == 0 ? sock : "", cfg->signer.role[0] ? cfg->signer.role : "main", cfg->signer.timeout_ms, gpk_rc); signer_health_record_failure(gpk_rc, "startup connectivity check (get_public_key)"); @@ -1455,7 +1581,11 @@ int main(int argc, char** argv) { const char* cli_signer_socket = NULL; const char* cli_signer_role = NULL; int cli_signer_timeout = 0; - const char* cli_signer_tcp = NULL; /* host:port shorthand */ + const char* cli_signer_tcp = NULL; /* host:port shorthand */ + const char* cli_signer_serial = NULL; /* device path shorthand */ + 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 */ didactyl_config_t cfg; memset(&cfg, 0, sizeof(cfg)); nostr_signer_t* g_signer = NULL; @@ -1530,6 +1660,14 @@ int main(int argc, char** argv) { cli_signer_timeout = atoi(argv[++i]); } else if (strcmp(argv[i], "--signer-tcp") == 0 && i + 1 < argc) { cli_signer_tcp = argv[++i]; + } else if (strcmp(argv[i], "--signer-serial") == 0 && i + 1 < argc) { + cli_signer_serial = argv[++i]; + } else if (strcmp(argv[i], "--signer-fds") == 0 && i + 1 < argc) { + cli_signer_fds = argv[++i]; + } else if (strcmp(argv[i], "--signer-qrexec") == 0 && i + 1 < argc) { + 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], "--dump-schemas") == 0) { dump_schemas = 1; } else if (strcmp(argv[i], "--test-tool") == 0 && i + 2 < argc) { @@ -1578,7 +1716,9 @@ int main(int argc, char** argv) { * Done before the nsec-required check so remote modes can skip the nsec. */ if (apply_signer_overrides(&cfg, cli_signer_mode, cli_signer_socket, cli_signer_role, cli_signer_timeout, - cli_signer_tcp) != 0) { + cli_signer_tcp, cli_signer_serial, + cli_signer_fds, cli_signer_qrexec, + cli_signer_service) != 0) { config_free(&cfg); nostr_cleanup(); return 1; diff --git a/src/main.h b/src/main.h index dbe624c..26abb3e 100644 --- a/src/main.h +++ b/src/main.h @@ -12,8 +12,8 @@ // Using DIDACTYL_ prefix to avoid conflicts with nostr_core_lib VERSION macros #define DIDACTYL_VERSION_MAJOR 0 #define DIDACTYL_VERSION_MINOR 2 -#define DIDACTYL_VERSION_PATCH 53 -#define DIDACTYL_VERSION "v0.2.53" +#define DIDACTYL_VERSION_PATCH 54 +#define DIDACTYL_VERSION "v0.2.54" // Agent metadata #define DIDACTYL_NAME "Didactyl" diff --git a/src/setup_wizard.c b/src/setup_wizard.c index 6257d4d..ed732b6 100644 --- a/src/setup_wizard.c +++ b/src/setup_wizard.c @@ -22,6 +22,9 @@ #include "nostr_handler.h" #include "default_events.h" #include "../../nostr_core_lib/nostr_core/nostr_core.h" +#if defined(NOSTR_ENABLE_NSIGNER_CLIENT) +#include "../../nostr_core_lib/nostr_core/nsigner_transport.h" +#endif #define ANSI_UNDERLINE "\x1b[4m" #define ANSI_RESET "\x1b[0m" @@ -947,7 +950,10 @@ static int query_self_kind0_name(const didactyl_config_t* cfg, return 0; } -static int fetch_and_decrypt_self_config_wizard(const didactyl_config_t* cfg, const char* d_tag, char** out_plaintext) { +static int fetch_and_decrypt_self_config_wizard(const didactyl_config_t* cfg, + const char* d_tag, + nostr_signer_t* signer, + char** out_plaintext) { if (!cfg || !d_tag || !out_plaintext || cfg->keys.public_key_hex[0] == '\0') { return -1; } @@ -1001,20 +1007,33 @@ static int fetch_and_decrypt_self_config_wizard(const didactyl_config_t* cfg, co return -1; } - /* Phase 3 item 13: DEFERRED — the setup wizard runs before any - * process-lifetime signer is constructed. In local mode (the only mode - * the wizard persists keys for) the nsec is held directly by the wizard, - * so the raw-key path is correct and output-equivalent. The nsigner_unix - * wizard path only does an ephemeral connectivity check and frees its - * signer immediately; it does not recall/persist encrypted user-settings. - * Migrating this site would require threading an ephemeral signer through - * the wizard for no separation benefit, so it is intentionally left on - * the legacy raw-key path. */ - int dec_rc = nostr_nip44_decrypt(cfg->keys.private_key, - cfg->keys.public_key, - content->valuestring, - plaintext, - out_cap); + /* Route NIP-44 decrypt through the signer when one is available so + * remote signer modes keep the nsec out of the agent process. The peer + * pubkey for self-encrypt is the agent's own pubkey. Falls back to the + * raw-key path when signer == NULL (local mode / legacy). */ + int dec_rc = NOSTR_ERROR_CRYPTO_FAILED; + if (signer) { + char* signer_plaintext = NULL; + dec_rc = nostr_signer_nip44_decrypt(signer, + cfg->keys.public_key_hex, + content->valuestring, + &signer_plaintext); + cJSON_Delete(arr); + if (dec_rc == NOSTR_SUCCESS && signer_plaintext) { + free(plaintext); + *out_plaintext = signer_plaintext; + return 0; + } + free(signer_plaintext); + free(plaintext); + return -1; + } + + dec_rc = nostr_nip44_decrypt(cfg->keys.private_key, + cfg->keys.public_key, + content->valuestring, + plaintext, + out_cap); cJSON_Delete(arr); if (dec_rc != NOSTR_SUCCESS) { free(plaintext); @@ -1109,6 +1128,7 @@ static int apply_recalled_user_settings_wizard(didactyl_config_t* cfg, const cha } static int recover_full_config_from_nostr(didactyl_config_t* cfg, + nostr_signer_t* signer, char* out_agent_name, size_t out_agent_name_size, int* out_agent_name_found) { @@ -1123,13 +1143,16 @@ static int recover_full_config_from_nostr(didactyl_config_t* cfg, if (nostr_handler_init(&tmp) != 0) { return -1; } + if (signer) { + nostr_handler_set_signer(signer); + } (void)wait_connected_relays_ms(5000); (void)query_self_kind0_name(cfg, out_agent_name, out_agent_name_size, out_agent_name_found); char* settings_plaintext = NULL; - if (fetch_and_decrypt_self_config_wizard(cfg, "user-settings", &settings_plaintext) == 0 && settings_plaintext) { + if (fetch_and_decrypt_self_config_wizard(cfg, "user-settings", signer, &settings_plaintext) == 0 && settings_plaintext) { (void)apply_recalled_user_settings_wizard(cfg, settings_plaintext); } free(settings_plaintext); @@ -1150,6 +1173,7 @@ static const char* dm_protocol_to_string_local(dm_protocol_t protocol) { static int publish_encrypted_self_config_wizard(const didactyl_config_t* cfg, const char* d_tag, + nostr_signer_t* signer, const char* content_json) { if (!cfg || !d_tag || !content_json || d_tag[0] == '\0') return -1; @@ -1157,18 +1181,34 @@ static int publish_encrypted_self_config_wizard(const didactyl_config_t* cfg, char* ciphertext = (char*)malloc(cipher_cap); if (!ciphertext) return -1; - /* Phase 3 item 13: DEFERRED — see the matching note in the wizard's - * fetch_self_config_plaintext. The wizard holds the nsec directly in - * local mode and has no process-lifetime signer, so the raw-key path is - * correct here. */ - int enc_rc = nostr_nip44_encrypt(cfg->keys.private_key, + /* Route NIP-44 encrypt through the signer when one is available so + * remote signer modes keep the nsec out of the agent process. Falls back + * to the raw-key path when signer == NULL (local mode / legacy). */ + int enc_rc = NOSTR_ERROR_CRYPTO_FAILED; + if (signer) { + char* signer_cipher = NULL; + enc_rc = nostr_signer_nip44_encrypt(signer, + cfg->keys.public_key_hex, + content_json, + &signer_cipher); + if (enc_rc == NOSTR_SUCCESS && signer_cipher) { + free(ciphertext); + ciphertext = signer_cipher; + } else { + free(signer_cipher); + free(ciphertext); + return -1; + } + } else { + enc_rc = nostr_nip44_encrypt(cfg->keys.private_key, cfg->keys.public_key, content_json, ciphertext, cipher_cap); - if (enc_rc != NOSTR_SUCCESS) { - free(ciphertext); - return -1; + if (enc_rc != NOSTR_SUCCESS) { + free(ciphertext); + return -1; + } } cJSON* tags = cJSON_CreateArray(); @@ -1199,7 +1239,8 @@ static int publish_encrypted_self_config_wizard(const didactyl_config_t* cfg, return rc; } -static int persist_runtime_config_to_nostr_wizard(const didactyl_config_t* cfg) { +static int persist_runtime_config_to_nostr_wizard(const didactyl_config_t* cfg, + nostr_signer_t* signer) { if (!cfg) return -1; cJSON* user_settings = cJSON_CreateObject(); @@ -1228,34 +1269,38 @@ static int persist_runtime_config_to_nostr_wizard(const didactyl_config_t* cfg) cJSON_AddNumberToObject(didactyl, "max_turns", cfg->tools.max_turns > 0 ? cfg->tools.max_turns : 40); cJSON_AddItemToObject(user_settings, "didactyl", didactyl); - cJSON* signer = cJSON_CreateObject(); - if (signer) { - cJSON_AddStringToObject(signer, "mode", cfg->signer.mode); - cJSON_AddStringToObject(signer, "socket_name", cfg->signer.socket_name); - cJSON_AddStringToObject(signer, "role", cfg->signer.role); - cJSON_AddNumberToObject(signer, "timeout_ms", cfg->signer.timeout_ms); - cJSON_AddItemToObject(user_settings, "signer", signer); + cJSON* signer_obj = cJSON_CreateObject(); + if (signer_obj) { + 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); + cJSON_AddNumberToObject(signer_obj, "timeout_ms", cfg->signer.timeout_ms); + cJSON_AddItemToObject(user_settings, "signer", signer_obj); } char* settings_json = cJSON_PrintUnformatted(user_settings); cJSON_Delete(user_settings); if (!settings_json) return -1; - int rc = publish_encrypted_self_config_wizard(cfg, "user-settings", settings_json); + int rc = publish_encrypted_self_config_wizard(cfg, "user-settings", signer, settings_json); free(settings_json); return rc; } -static int persist_runtime_config_to_nostr_wizard_online(const didactyl_config_t* cfg) { +static int persist_runtime_config_to_nostr_wizard_online(const didactyl_config_t* cfg, + nostr_signer_t* signer) { if (!cfg) return -1; didactyl_config_t tmp = *cfg; if (nostr_handler_init(&tmp) != 0) { return -1; } + if (signer) { + nostr_handler_set_signer(signer); + } (void)wait_connected_relays_ms(5000); - int rc = persist_runtime_config_to_nostr_wizard(cfg); + int rc = persist_runtime_config_to_nostr_wizard(cfg, signer); /* Give relays time to acknowledge the published events before tearing down the connection. Without this, the fire-and-forget publish may @@ -2044,6 +2089,10 @@ static int install_system_service_with_dedicated_user(const didactyl_config_t* c * - nsigner_unix mode: passes --signer/--signer-socket/--signer-role/--signer-timeout * so the service process reconstructs the remote signer without holding the nsec. * - nsigner_tcp mode: passes --signer-tcp host:port plus role/timeout. + * - nsigner_serial mode: passes --signer-serial plus role/timeout. + * - nsigner_qrexec mode: passes --signer-qrexec plus role/timeout. + * - nsigner_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}; if (strcmp(cfg->signer.mode, "nsigner_unix") == 0) { @@ -2061,6 +2110,25 @@ 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); + } 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", + cfg->signer.serial_device, + cfg->signer.role[0] ? cfg->signer.role : "main", + cfg->signer.timeout_ms > 0 ? cfg->signer.timeout_ms : 15000); + } 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), + "--signer nsigner_qrexec --signer-qrexec %s --signer-service %s --signer-role %s --signer-timeout %d", + cfg->signer.target_qube, + svc, + cfg->signer.role[0] ? cfg->signer.role : "main", + cfg->signer.timeout_ms > 0 ? cfg->signer.timeout_ms : 15000); + } 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 " + "(file descriptors are runtime-only).%s\n", ANSI_RED, ANSI_RESET); + return -1; } else { /* local mode (default): embed the nsec directly in ExecStart. */ snprintf(key_args, sizeof(key_args), "--nsec %s", cfg->keys.nsec); @@ -2176,6 +2244,310 @@ static __attribute__((unused)) int maybe_prompt_write_path(char* path_out, size_ return 0; } +/* Parse a "read_fd:write_fd" pair string into two ints. + * Returns 0 on success, -1 on malformed input. */ +static int wizard_parse_fd_pair(const char* s, int* read_fd_out, int* write_fd_out) { + if (!s || !read_fd_out || !write_fd_out) return -1; + const char* colon = strchr(s, ':'); + if (!colon) return -1; + char read_buf[16] = {0}; + size_t read_len = (size_t)(colon - s); + if (read_len == 0 || read_len >= sizeof(read_buf)) return -1; + memcpy(read_buf, s, read_len); + int r = atoi(read_buf); + int w = atoi(colon + 1); + if (r < 0 || w < 0) return -1; + *read_fd_out = r; + *write_fd_out = w; + return 0; +} + +/* Prompt the operator to choose an n_signer transport and configure + * cfg->signer accordingly. Performs a connectivity check via + * nostr_signer_get_public_key and populates cfg->keys.public_key_hex. + * + * If signer_out is non-NULL, the successfully-constructed signer handle is + * returned to the caller (who becomes responsible for nostr_signer_free()). + * If signer_out is NULL, the ephemeral signer is freed here. + * + * Returns: + * 0 -> a transport was chosen and the connectivity check succeeded + * 1 -> operator chose "back" + * -1 -> operator chose "quit" / fatal error + */ +static int prompt_signer_transport(didactyl_config_t* cfg, nostr_signer_t** signer_out) { + if (!cfg) return -1; + if (signer_out) *signer_out = NULL; + + for (;;) { + render_wizard_page_header("n_signer Transport", "Sign with a running n_signer"); + print_option('u', "nix abstract socket (auto-discover or named)"); + print_option('t', "cp (host:port + optional auth privkey)"); + print_option('s', "erial USB device (auto-discover or path)"); + print_option('f', "d pair (read_fd:write_fd -- advanced)"); + print_option('e', "xec -- Qubes cross-qube qrexec (target qube + service)"); + print_option('b', "ack"); + print_option('q', "uit"); + wizard_option_t opts[] = {{'u', ""}, {'t', ""}, {'s', ""}, {'f', ""}, {'e', ""}, {'b', ""}, {'q', ""}}; + char c = read_menu_choice(opts, 7); + if (c == 'q') return -1; + if (c == 'b') return 1; + + char chosen_mode[OW_MAX_SIGNER_MODE_LEN] = {0}; + + if (c == 'u') { + snprintf(chosen_mode, sizeof(chosen_mode), "%s", "nsigner_unix"); +#if defined(NOSTR_ENABLE_NSIGNER_CLIENT) + char names[16][64] = {{0}}; + int found = nsigner_transport_list_unix(names, 16); + if (found > 0) { + fprintf(stderr, "\n Discovered n_signer abstract sockets:\n"); + for (int i = 0; i < found; i++) { + fprintf(stderr, " %d. %s\n", i + 1, names[i]); + } + fprintf(stderr, "\n Pick a number, type a custom name, or press Enter for auto-discovery at boot.\n"); + } else { + fprintf(stderr, "\n No running n_signer abstract sockets discovered.\n"); + fprintf(stderr, " Type a socket name or press Enter for auto-discovery at boot.\n"); + } + char input[WIZARD_LINE_MAX] = {0}; + if (read_line_prompt("> ", input, sizeof(input)) != 0) return -1; + if (line_is_quit(input)) return -1; + if (input[0] == '\0') { + cfg->signer.socket_name[0] = '\0'; + } else { + int n = atoi(input); + if (n > 0 && n <= found) { + snprintf(cfg->signer.socket_name, sizeof(cfg->signer.socket_name), "%s", names[n - 1]); + } else { + snprintf(cfg->signer.socket_name, sizeof(cfg->signer.socket_name), "%s", input); + } + } +#else + fprintf(stderr, "%sThis build was compiled without n_signer client support (NOSTR_ENABLE_NSIGNER_CLIENT).%s\n", + ANSI_RED, ANSI_RESET); + continue; +#endif + } + + if (c == 't') { + snprintf(chosen_mode, sizeof(chosen_mode), "%s", "nsigner_tcp"); + char hp[WIZARD_LINE_MAX] = {0}; + if (read_line_prompt(" host:port: ", hp, sizeof(hp)) != 0) return -1; + if (line_is_quit(hp)) return -1; + const char* colon = strchr(hp, ':'); + if (!colon || hp[0] == '\0') { + fprintf(stderr, "%sInvalid host:port.%s\n", ANSI_RED, ANSI_RESET); + continue; + } + size_t hlen = (size_t)(colon - hp); + if (hlen == 0 || hlen >= sizeof(cfg->signer.tcp_host)) { + fprintf(stderr, "%sInvalid host:port.%s\n", ANSI_RED, ANSI_RESET); + continue; + } + memcpy(cfg->signer.tcp_host, hp, hlen); + cfg->signer.tcp_host[hlen] = '\0'; + cfg->signer.tcp_port = atoi(colon + 1); + if (cfg->signer.tcp_port <= 0 || cfg->signer.tcp_port > 65535) { + fprintf(stderr, "%sInvalid port.%s\n", ANSI_RED, ANSI_RESET); + continue; + } + char auth_hex[OW_MAX_SIGNER_AUTH_HEX_LEN] = {0}; + if (read_secret_prompt(" Auth privkey hex (optional, for kind-27235 envelope): ", + auth_hex, sizeof(auth_hex)) != 0) return -1; + if (line_is_quit(auth_hex)) return -1; + if (auth_hex[0] != '\0') { + snprintf(cfg->signer.auth_privkey_hex, sizeof(cfg->signer.auth_privkey_hex), "%s", auth_hex); + } else { + cfg->signer.auth_privkey_hex[0] = '\0'; + } + } + + if (c == 's') { + snprintf(chosen_mode, sizeof(chosen_mode), "%s", "nsigner_serial"); +#if defined(NOSTR_ENABLE_NSIGNER_CLIENT) + char paths[16][64] = {{0}}; + int found = nsigner_transport_list_serial(paths, 16); + if (found > 0) { + fprintf(stderr, "\n Discovered serial devices:\n"); + for (int i = 0; i < found; i++) { + fprintf(stderr, " %d. %s\n", i + 1, paths[i]); + } + fprintf(stderr, "\n Pick a number, type a custom path, or press Enter to skip.\n"); + } else { + fprintf(stderr, "\n No serial devices discovered.\n"); + fprintf(stderr, " Type a device path (e.g. /dev/ttyACM0).\n"); + } + char input[WIZARD_LINE_MAX] = {0}; + if (read_line_prompt("> ", input, sizeof(input)) != 0) return -1; + if (line_is_quit(input)) return -1; + if (input[0] == '\0') { + fprintf(stderr, "%sA serial device path is required.%s\n", ANSI_RED, ANSI_RESET); + continue; + } + int n = atoi(input); + if (n > 0 && n <= found) { + snprintf(cfg->signer.serial_device, sizeof(cfg->signer.serial_device), "%s", paths[n - 1]); + } else { + snprintf(cfg->signer.serial_device, sizeof(cfg->signer.serial_device), "%s", input); + } +#else + fprintf(stderr, "%sThis build was compiled without n_signer client support (NOSTR_ENABLE_NSIGNER_CLIENT).%s\n", + ANSI_RED, ANSI_RESET); + continue; +#endif + } + + if (c == 'f') { + snprintf(chosen_mode, sizeof(chosen_mode), "%s", "nsigner_fds"); + char fds[WIZARD_LINE_MAX] = {0}; + if (read_line_prompt(" read_fd:write_fd: ", fds, sizeof(fds)) != 0) return -1; + if (line_is_quit(fds)) return -1; + if (wizard_parse_fd_pair(fds, &cfg->signer.fds_read_fd, &cfg->signer.fds_write_fd) != 0) { + fprintf(stderr, "%sInvalid read_fd:write_fd.%s\n", ANSI_RED, ANSI_RESET); + continue; + } + } + + if (c == 'e') { + snprintf(chosen_mode, sizeof(chosen_mode), "%s", "nsigner_qrexec"); + char qube[WIZARD_LINE_MAX] = {0}; + if (read_line_prompt(" Target qube (e.g. nostr_signer): ", qube, sizeof(qube)) != 0) return -1; + if (line_is_quit(qube)) return -1; + if (qube[0] == '\0') { + fprintf(stderr, "%sA target qube name is required.%s\n", ANSI_RED, ANSI_RESET); + continue; + } + snprintf(cfg->signer.target_qube, sizeof(cfg->signer.target_qube), "%s", qube); + char svc[WIZARD_LINE_MAX] = {0}; + if (read_line_prompt(" qrexec service [qubes.NsignerRpc]: ", svc, sizeof(svc)) != 0) return -1; + if (line_is_quit(svc)) return -1; + snprintf(cfg->signer.service_name, sizeof(cfg->signer.service_name), "%s", svc[0] ? svc : "qubes.NsignerRpc"); + } + + /* Common: role + timeout. */ + char role_buf[OW_MAX_SIGNER_ROLE_LEN] = {0}; + if (read_line_prompt(" Role [main]: ", 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 : "main"); + + char timeout_buf[32] = {0}; + if (read_line_prompt(" Timeout ms [15000]: ", timeout_buf, sizeof(timeout_buf)) != 0) return -1; + if (line_is_quit(timeout_buf)) return -1; + cfg->signer.timeout_ms = (timeout_buf[0] != '\0') ? atoi(timeout_buf) : 15000; + if (cfg->signer.timeout_ms <= 0) cfg->signer.timeout_ms = 15000; + + snprintf(cfg->signer.mode, sizeof(cfg->signer.mode), "%s", chosen_mode); + + /* Connectivity check. */ + fprintf(stderr, " Checking connectivity to n_signer (mode=%s)...\n", chosen_mode); +#if defined(NOSTR_ENABLE_NSIGNER_CLIENT) + nostr_signer_t* signer = NULL; + if (strcmp(chosen_mode, "nsigner_unix") == 0) { + signer = nostr_signer_nsigner_unix(cfg->signer.socket_name[0] ? cfg->signer.socket_name : NULL, + cfg->signer.role, cfg->signer.timeout_ms); + } else if (strcmp(chosen_mode, "nsigner_tcp") == 0) { + signer = nostr_signer_nsigner_tcp(cfg->signer.tcp_host, cfg->signer.tcp_port, + cfg->signer.role, cfg->signer.timeout_ms); + if (signer && cfg->signer.auth_privkey_hex[0] != '\0') { + unsigned char auth_key[32]; + if (nostr_hex_to_bytes(cfg->signer.auth_privkey_hex, auth_key, 32) == 0) { + (void)nostr_signer_nsigner_set_auth(signer, auth_key, "didactyl"); + } + } + } else if (strcmp(chosen_mode, "nsigner_serial") == 0) { + signer = nostr_signer_nsigner_serial(cfg->signer.serial_device, + cfg->signer.role, cfg->signer.timeout_ms); + } else if (strcmp(chosen_mode, "nsigner_fds") == 0) { + signer = nostr_signer_nsigner_fds(cfg->signer.fds_read_fd, cfg->signer.fds_write_fd, + cfg->signer.role, cfg->signer.timeout_ms); + } else if (strcmp(chosen_mode, "nsigner_qrexec") == 0) { + const char* svc = cfg->signer.service_name[0] ? cfg->signer.service_name : "qubes.NsignerRpc"; + signer = nostr_signer_nsigner_qrexec(cfg->signer.target_qube, svc, + cfg->signer.role, cfg->signer.timeout_ms); + } + + if (!signer) { + fprintf(stderr, "%sFailed to initialize n_signer client (mode=%s).%s\n", + ANSI_RED, chosen_mode, ANSI_RESET); + 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)); + if (line_is_quit(pause_buf)) return -1; + continue; + } + + char pubkey_hex[65] = {0}; + if (nostr_signer_get_public_key(signer, pubkey_hex) != 0) { + 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"); + 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)); + if (line_is_quit(pause_buf)) return -1; + continue; + } + snprintf(cfg->keys.public_key_hex, sizeof(cfg->keys.public_key_hex), "%s", pubkey_hex); + fprintf(stderr, "%sConnectivity check: OK. Pubkey: %s%s\n", + ANSI_YELLOW, cfg->keys.public_key_hex, ANSI_RESET); + + if (signer_out) { + *signer_out = signer; + } else { + nostr_signer_free(signer); + } + return 0; +#else + fprintf(stderr, "%sThis build was compiled without n_signer client support (NOSTR_ENABLE_NSIGNER_CLIENT). " + "Connectivity check skipped; the agent will fail at startup in this mode.%s\n", + ANSI_RED, ANSI_RESET); + continue; +#endif + } +} + +/* Construct an ephemeral nostr_signer_t* from cfg->signer for wizard publish/ + * recovery paths. Returns NULL for local mode (caller uses raw-key path) or + * on construction failure. Caller must nostr_signer_free() the result. */ +static nostr_signer_t* wizard_construct_ephemeral_signer(const didactyl_config_t* cfg) { + if (!cfg) return NULL; +#if defined(NOSTR_ENABLE_NSIGNER_CLIENT) + const char* mode = cfg->signer.mode; + if (strcmp(mode, "nsigner_unix") == 0) { + return nostr_signer_nsigner_unix(cfg->signer.socket_name[0] ? cfg->signer.socket_name : NULL, + cfg->signer.role, cfg->signer.timeout_ms); + } else if (strcmp(mode, "nsigner_tcp") == 0) { + if (cfg->signer.tcp_host[0] == '\0' || cfg->signer.tcp_port <= 0) return NULL; + nostr_signer_t* s = nostr_signer_nsigner_tcp(cfg->signer.tcp_host, cfg->signer.tcp_port, + cfg->signer.role, cfg->signer.timeout_ms); + if (s && cfg->signer.auth_privkey_hex[0] != '\0') { + unsigned char auth_key[32]; + if (nostr_hex_to_bytes(cfg->signer.auth_privkey_hex, auth_key, 32) == 0) { + (void)nostr_signer_nsigner_set_auth(s, auth_key, "didactyl"); + } + } + return s; + } else if (strcmp(mode, "nsigner_serial") == 0) { + if (cfg->signer.serial_device[0] == '\0') return NULL; + return nostr_signer_nsigner_serial(cfg->signer.serial_device, + cfg->signer.role, cfg->signer.timeout_ms); + } else if (strcmp(mode, "nsigner_fds") == 0) { + if (cfg->signer.fds_read_fd < 0 || cfg->signer.fds_write_fd < 0) return NULL; + return nostr_signer_nsigner_fds(cfg->signer.fds_read_fd, cfg->signer.fds_write_fd, + cfg->signer.role, cfg->signer.timeout_ms); + } else if (strcmp(mode, "nsigner_qrexec") == 0) { + if (cfg->signer.target_qube[0] == '\0') return NULL; + const char* svc = cfg->signer.service_name[0] ? cfg->signer.service_name : "qubes.NsignerRpc"; + return nostr_signer_nsigner_qrexec(cfg->signer.target_qube, svc, + cfg->signer.role, cfg->signer.timeout_ms); + } +#else + (void)cfg; +#endif + return NULL; +} + static int new_agent_identity_step(didactyl_config_t* cfg) { for (;;) { render_wizard_page_header("Step 2 of 7", "New Agent Setup -- Identity"); @@ -2236,35 +2608,10 @@ static int new_agent_identity_step(didactyl_config_t* cfg) { } if (c == 's') { - snprintf(cfg->signer.mode, sizeof(cfg->signer.mode), "%s", "nsigner_unix"); - if (read_line_prompt(" Socket name (empty for auto-discovery): ", cfg->signer.socket_name, sizeof(cfg->signer.socket_name)) != 0) return -1; - if (read_line_prompt(" Role [main]: ", cfg->signer.role, sizeof(cfg->signer.role)) != 0) return -1; - if (cfg->signer.role[0] == '\0') snprintf(cfg->signer.role, sizeof(cfg->signer.role), "%s", "main"); - char timeout_buf[32] = {0}; - if (read_line_prompt(" Timeout ms [15000]: ", timeout_buf, sizeof(timeout_buf)) != 0) return -1; - cfg->signer.timeout_ms = (timeout_buf[0] != '\0') ? atoi(timeout_buf) : 15000; - if (cfg->signer.timeout_ms <= 0) cfg->signer.timeout_ms = 15000; - - fprintf(stderr, " Checking connectivity to n_signer...\n"); -#if defined(NOSTR_ENABLE_NSIGNER_CLIENT) - nostr_signer_t* signer = nostr_signer_nsigner_unix(cfg->signer.socket_name[0] ? cfg->signer.socket_name : NULL, - cfg->signer.role, - cfg->signer.timeout_ms); - if (!signer) { - fprintf(stderr, "%sFailed to initialize n_signer client.%s\n", ANSI_RED, ANSI_RESET); - continue; - } - if (nostr_signer_get_public_key(signer, cfg->keys.public_key_hex) != 0) { - fprintf(stderr, "%sFailed to retrieve public key from n_signer.%s\n", ANSI_RED, ANSI_RESET); - nostr_signer_free(signer); - continue; - } - nostr_signer_free(signer); - fprintf(stderr, "%sConnectivity check: OK. Pubkey: %s%s\n", ANSI_YELLOW, cfg->keys.public_key_hex, ANSI_RESET); -#else - fprintf(stderr, "%sThis build was compiled without n_signer client support (NOSTR_ENABLE_NSIGNER_CLIENT). Connectivity check skipped; the agent will fail at startup in this mode.%s\n", - ANSI_RED, ANSI_RESET); -#endif + int rc = prompt_signer_transport(cfg, NULL); + if (rc < 0) return -1; + if (rc == 1) continue; /* back -> re-render identity menu */ + /* Connectivity check already populated cfg->keys.public_key_hex. */ } return 0; @@ -2350,32 +2697,51 @@ static int new_agent_flow(didactyl_config_t* cfg, char* genesis_path_out, size_t char c = read_menu_choice(opts, 4); if (c == 'q') return -1; if (c == 's') 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. */ - if (persist_runtime_config_to_nostr_wizard_online(cfg) != 0) { - fprintf(stderr, "%sWarning: failed to publish runtime config to Nostr (will retry on boot).%s\n", - ANSI_YELLOW, ANSI_RESET); - } - fprintf(stderr, "%sStep 7 of 7 -- Booting new agent. Check your messages for initial greeting.%s\n", +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); - return 0; + } + 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); + 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", + ANSI_RED, ANSI_RESET); + return -1; } - if (c == 'i') { - /* 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. */ - if (persist_runtime_config_to_nostr_wizard_online(cfg) != 0) { - 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); - 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); + 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; @@ -2417,13 +2783,38 @@ static int existing_agent_flow(didactyl_config_t* cfg) { char agent_name[OW_MAX_NAME_LEN] = {0}; int agent_name_found = 0; int relay_changed = 0; + /* 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; render_wizard_page_header("Existing Agent", "Identity"); - if (read_secret_prompt("Enter your agent nsec (nsec1... or 64-char hex): ", nsec_in, sizeof(nsec_in)) != 0) return -1; - if (line_is_quit(nsec_in)) return -1; - if (derive_keys_from_nsec_local(nsec_in, cfg) != 0) { - fprintf(stderr, "%sInvalid nsec.%s\n", ANSI_RED, ANSI_RESET); - return -1; + print_option('n', "sec -- enter the agent private key"); + print_option('s', "ign with a running n_signer (no nsec required)"); + print_option('q', "uit"); + wizard_option_t id_opts[] = {{'n', ""}, {'s', ""}, {'q', ""}}; + char idc = read_menu_choice(id_opts, 3); + if (idc == 'q') return -1; + + if (idc == 's') { + int rc = prompt_signer_transport(cfg, &flow_signer); + if (rc < 0) { + return -1; + } + if (rc == 1) { + /* back -> treat as quit to main menu */ + return -1; + } + /* cfg->keys.public_key_hex populated by the connectivity check. */ + } else { + if (read_secret_prompt("Enter your agent nsec (nsec1... or 64-char hex): ", nsec_in, sizeof(nsec_in)) != 0) { + return -1; + } + if (line_is_quit(nsec_in)) return -1; + if (derive_keys_from_nsec_local(nsec_in, cfg) != 0) { + fprintf(stderr, "%sInvalid nsec.%s\n", ANSI_RED, ANSI_RESET); + return -1; + } } int relay_found = 0; @@ -2437,15 +2828,17 @@ static int existing_agent_flow(didactyl_config_t* cfg) { wizard_option_t opts[] = {{'n', ""}, {'q', ""}}; char c = read_menu_choice(opts, 2); if (c == 'n') { + if (flow_signer) nostr_signer_free(flow_signer); int rc = new_agent_flow(cfg, NULL, 0); if (rc == 0) return SETUP_WIZARD_RC_BOOTSTRAP; if (rc == 1) return SETUP_WIZARD_RC_EXIT; return -1; } + if (flow_signer) nostr_signer_free(flow_signer); return -1; } - if (recover_full_config_from_nostr(cfg, agent_name, sizeof(agent_name), &agent_name_found) != 0) { + if (recover_full_config_from_nostr(cfg, flow_signer, agent_name, sizeof(agent_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); @@ -2548,23 +2941,38 @@ static int existing_agent_flow(didactyl_config_t* cfg) { "%sFailed to stage updated relay list for startup publish.%s\n", ANSI_RED, ANSI_RESET); + if (flow_signer) nostr_signer_free(flow_signer); return -1; } } /* Always return BOOTSTRAP so main() re-publishes kind 30078. The wizard has the authoritative config in memory. */ + if (flow_signer) nostr_signer_free(flow_signer); return SETUP_WIZARD_RC_BOOTSTRAP; } if (lc == '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", + ANSI_RED, ANSI_RESET); + if (flow_signer) nostr_signer_free(flow_signer); + return -1; + } + /* Always publish kind 30078 before installing the service, even if config was not changed. The service depends on recovering these from relays and the event may have been purged. */ - if (persist_runtime_config_to_nostr_wizard_online(cfg) != 0) { + if (persist_runtime_config_to_nostr_wizard_online(cfg, flow_signer) != 0) { fprintf(stderr, "%sFailed to publish runtime config to Nostr; refusing install to avoid stale admin/LLM on first boot.%s\n", ANSI_RED, ANSI_RESET); + if (flow_signer) nostr_signer_free(flow_signer); return -1; } if (relay_changed) { @@ -2573,12 +2981,14 @@ static int existing_agent_flow(didactyl_config_t* cfg) { "%sFailed to publish updated relay list (kind 10002) to Nostr; refusing install.%s\n", ANSI_RED, ANSI_RESET); + if (flow_signer) nostr_signer_free(flow_signer); 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; } @@ -2604,9 +3014,11 @@ static int existing_agent_flow(didactyl_config_t* cfg) { ANSI_RESET); } + if (flow_signer) nostr_signer_free(flow_signer); return SETUP_WIZARD_RC_EXIT; } + if (flow_signer) nostr_signer_free(flow_signer); return -1; }