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.
This commit is contained in:
@@ -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
|
||||
|
||||
+138
@@ -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
|
||||
(`@<socket_name>`). 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 <read:write>` at boot.
|
||||
|
||||
### `nsigner_qrexec`
|
||||
Delegates to `n_signer` running in **another Qubes OS qube** via qrexec. The
|
||||
agent spawns `qrexec-client-vm <target_qube> <service_name>` as a subprocess
|
||||
and pipes framed JSON-RPC through its stdin/stdout. No network; caller identity
|
||||
is hypervisor-vouched (`QREXEC_REMOTE_DOMAIN` → `qubes:<source-vm>` 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 <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-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. |
|
||||
| `--signer-fds <read:write>` | Shorthand for `--signer nsigner_fds` + fd pair. |
|
||||
| `--signer-qrexec <target_qube>` | Shorthand for `--signer nsigner_qrexec` + target qube. |
|
||||
| `--signer-service <name>` | 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.
|
||||
@@ -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": [
|
||||
|
||||
Submodule nostr_core_lib.old deleted from af06f23939
@@ -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 <device>` CLI flag → sets mode `nsigner_serial` and
|
||||
`cfg->signer.serial_device`.
|
||||
- Add `--signer-fds <read_fd:write_fd>` 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 <device>
|
||||
--signer-role <role> --signer-timeout <ms>`
|
||||
- `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).
|
||||
+37
-3
@@ -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';
|
||||
|
||||
+30
-7
@@ -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 <target_qube>
|
||||
* <service_name>`). 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 <read:write>. */
|
||||
int fds_read_fd;
|
||||
int fds_write_fd;
|
||||
/* nsigner_qrexec: target Qubes qube name + qrexec service name.
|
||||
* Populated from --signer-qrexec <target_qube> 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
|
||||
|
||||
+152
-12
@@ -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 <mode>\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 <name>\n"
|
||||
@@ -176,6 +177,17 @@ static void print_usage(const char* prog) {
|
||||
" Per-call signer timeout in milliseconds (default: 15000).\n"
|
||||
" --signer-tcp <host:port>\n"
|
||||
" Shorthand for --signer nsigner_tcp with host/port parsed.\n"
|
||||
" --signer-serial <device>\n"
|
||||
" Shorthand for --signer nsigner_serial with device path\n"
|
||||
" (e.g. /dev/ttyACM0).\n"
|
||||
" --signer-fds <read_fd:write_fd>\n"
|
||||
" Shorthand for --signer nsigner_fds with a pre-connected\n"
|
||||
" fd pair (runtime-only; not persisted to genesis).\n"
|
||||
" --signer-qrexec <target_qube>\n"
|
||||
" Shorthand for --signer nsigner_qrexec with the target Qubes\n"
|
||||
" qube name (service defaults to qubes.NsignerRpc).\n"
|
||||
" --signer-service <name>\n"
|
||||
" qrexec service name for nsigner_qrexec (default: qubes.NsignerRpc).\n"
|
||||
" --dump-schemas\n"
|
||||
" Print tool schemas JSON and exit.\n"
|
||||
" --test-tool <name> <args_json>\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 : "<empty>");
|
||||
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 <device> 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 <read:write> "
|
||||
"(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 <target_qube> 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 <device> 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 <read:write> "
|
||||
"(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 <target_qube> 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 : "<auto>";
|
||||
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 : "<tcp>",
|
||||
mode, strcmp(mode, "nsigner_unix") == 0 ? sock : "<other>",
|
||||
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 : "<tcp>",
|
||||
mode, strcmp(mode, "nsigner_unix") == 0 ? sock : "<other>",
|
||||
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;
|
||||
|
||||
+2
-2
@@ -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"
|
||||
|
||||
+505
-93
@@ -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 <device> plus role/timeout.
|
||||
* - nsigner_qrexec mode: passes --signer-qrexec <target_qube> 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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user