Files
nostr_terminal/plans/n_signer_integration.md

16 KiB
Raw Permalink Blame History

n_signer Integration Plan

1. Goal

Allow nostr_terminal to operate against an external n_signer process as the signing/encryption authority, in addition to the current local-mnemonic mode.

At login, the user picks one of:

  • Local mnemonic (current behavior): private key held in g_state.nsec_hex.
  • n_signer: private key never leaves the signer process; nostr_terminal talks to it over an AF_UNIX abstract-namespace socket using length-prefixed JSON-RPC frames.

To make this clean, we abstract the signing/encryption surface behind a small signer interface and migrate every direct use of g_state.nsec_hex to that interface.

2. n_signer wire summary (from n_signer/CLIENT_IMPLEMENTATION.md)

  • Transport: AF_UNIX abstract namespace, name like @nsigner_hairy_dog (clients pass the name without @).
  • Discovery: nsigner list enumerates abstract sockets via /proc/net/unix (lines beginning with @nsigner or @nsigner_*).
  • Framing: 4-byte big-endian length prefix + UTF-8 JSON body; one JSON-RPC object per frame.
  • Methods used by nostr_terminal:
    • get_public_key
    • sign_event
    • nip04_encrypt / nip04_decrypt
    • nip44_encrypt / nip44_decrypt
  • Selector (last param): prefer { "nostr_index": N }; fallback { "role": "main" } remains supported.
  • Client default selector for nostr_terminal is { "nostr_index": 0 } to match local mnemonic index UX.
  • Errors include approval_denied, unauthorized, unknown_role, internal_error, etc. We must treat approval_denied as a normal user-cancel result.

3. Current signing/encryption surface in nostr_terminal

Direct uses of g_state.nsec_hex (the local private key hex) — these all need to route through the new abstraction:

File Operation
src/publish.c Build + sign event via nostr_create_and_sign_event(...)
src/state.c state_nip44_self_encrypt (NIP-44 encrypt to self for kind 30078)
src/state.c state_nip44_self_decrypt (NIP-44 decrypt from self)
src/menu_dm.c NIP-17 gift-wrap signing via nostr_nip17_send_dm
src/menu_dm.c NIP-04 decrypt of legacy kind-4 DMs
src/menu_dm.c NIP-17 gift-wrap unwrap (uses priv internally)
src/menu_diary.c NIP-44 encrypt to self (diary)
src/menu_diary.c NIP-44 decrypt from self
src/menu_todo.c NIP-44 encrypt to self (todo)
src/menu_todo.c NIP-44 decrypt from self
src/menu_posts.c NIP-04 decrypt
src/menu_posts.c NIP-04 encrypt to self
src/net.c Auth/ephemeral signing for relay flow
src/menu_login.c Source of truth: nsec_hex written here
src/menu_profile.c Display nsecHex/nsec (must hide for signer-mode users)

Login itself needs a third entry path: "Sign in with n_signer" → connect, list signers, pick one, call get_public_key.

4. Proposed architecture

4.1 Signer interface (new include/signer.h, src/signer.c)

typedef enum {
    NT_SIGNER_LOCAL = 0,   // uses g_state.nsec_hex
    NT_SIGNER_NSIGNER = 1, // uses external n_signer over AF_UNIX
} nt_signer_kind_t;

typedef struct {
    nt_signer_kind_t kind;
    char npub_hex[65];     // resolved cached pubkey (hex)
    char socket_name[128]; // n_signer abstract name, no leading '@', empty if local
    char role[64];         // n_signer role, default "main"
} nt_signer_t;

extern nt_signer_t g_signer;

// Lifecycle
int  signer_init_local(const char *nsec_hex);
int  signer_init_nsigner(const char *socket_name, const char *role);
void signer_shutdown(void);

// Identity
int  signer_get_public_key(char *out_hex_65);

// Event signing: caller supplies an unsigned event JSON; signer returns
// the signed event JSON (id + sig + pubkey populated).
int  signer_sign_event_json(const char *unsigned_event_json, char **signed_event_json_out);

// Convenience: build + sign in one call (replaces nostr_create_and_sign_event in publish.c).
int  signer_create_and_sign(int kind,
                            const char *content,
                            cJSON *tags,
                            time_t timestamp,
                            char **signed_event_json_out);

// Encryption (NIP-04)
int  signer_nip04_encrypt(const char *peer_pub_hex, const char *plaintext, char **cipher_out);
int  signer_nip04_decrypt(const char *peer_pub_hex, const char *cipher, char **plain_out);

// Encryption (NIP-44)
int  signer_nip44_encrypt(const char *peer_pub_hex, const char *plaintext, char **cipher_out);
int  signer_nip44_decrypt(const char *peer_pub_hex, const char *cipher, char **plain_out);

// Convenience self-encryption (peer = self).
int  signer_nip44_self_encrypt(const char *plaintext, char **cipher_out);
int  signer_nip44_self_decrypt(const char *sender_pub_hex, const char *cipher, char **plain_out);

For kind == NT_SIGNER_LOCAL, every method just wraps the existing nostr_* library calls using g_state.nsec_hex.

For kind == NT_SIGNER_NSIGNER, every method dispatches a JSON-RPC request to the abstract socket.

4.2 n_signer client (new src/nsigner_client.c, include/nsigner_client.h)

Responsibilities:

  • nsigner_list(char ***names_out, int *count_out) — read /proc/net/unix, collect abstract names that begin with nsigner (with the @ stripped).
  • nsigner_connect(const char *name) -> fd — open AF_UNIX SOCK_STREAM and connect to abstract address (addr.sun_path[0] = '\0'; memcpy(addr.sun_path+1, name, n)).
  • nsigner_rpc(int fd, const cJSON *request, cJSON **response_out, int read_timeout_ms) — implement the 4-byte big-endian framing and response correlation by id.
  • Per-call helpers (one socket per RPC is fine for v1; we can pool later):
    • nsigner_get_public_key(name, role, out_hex)
    • nsigner_sign_event(name, role, unsigned_event_json, out_signed_json)
    • nsigner_nip04_encrypt/decrypt(name, role, peer_hex, in, out)
    • nsigner_nip44_encrypt/decrypt(name, role, peer_hex, in, out)
  • Common error mapping: approval_denied → NT_SIGNER_E_DENIED, unauthorized → NT_SIGNER_E_UNAUTH, transport errors → NT_SIGNER_E_IO.
  • Read timeouts: use a long read timeout (e.g. 60s) for any prompt-requiring method.

4.3 State changes (include/state.h, src/state.c)

  • Keep nsec_hex etc. populated only when g_signer.kind == NT_SIGNER_LOCAL.
  • For NT_SIGNER_NSIGNER:
    • npub_hex/npub_bech32 populated from signer_get_public_key.
    • nsec_hex, nsec_bech32, seed_phrase left empty.
  • Move existing state_nip44_self_encrypt / state_nip44_self_decrypt to thin wrappers over signer_nip44_self_* so call sites stay unchanged.

4.4 Login flow (src/menu_login.c)

Add a new option key on the login menu: s → "Sign in with n_signer".

Signer-login subflow:

  1. Call nsigner_list().
  2. If 0 instances: print "No running n_signer found. Start nsigner and try again." and loop back to the login menu.
  3. If 1 instance: auto-select. Show banner.
  4. If >1: list and prompt for index.
  5. Call nsigner_get_public_key() with role main. The signer may prompt the user in its own TUI for approval.
  6. On success:
    • signer_init_nsigner(name) (role hard-coded to "main" internally).
    • Populate g_state.npub_hex, derive npub_bech32.
    • g_state.logged_in = 1.
    • Run the existing state_load_user_info() to hydrate kind 0/3/10002/10096/17375 + user-settings (decryption now goes through the signer).
    • If state_load_user_info() reports no kind 0 / 3 / 10002 (a fresh key handed to us by n_signer), call menu_add_new_user() so the user can fill in profile + relays. Every event published in that flow is signed by n_signer through signer_create_and_sign.
  7. On approval_denied or other error: print message, loop back to the login menu. If the user picks q/x from the login menu, the program exits.

4.5 Migrating call sites

For each file in section 3, replace the local key + nostr_* call pattern with the corresponding signer_* call:

  • src/publish.c → use signer_create_and_sign and post the returned JSON.
  • src/menu_dm.c DM send →
    • Build NIP-17 rumor JSON (unsigned).
    • Call signer_sign_event_json for the gift-wrap shell only if signer supports it; otherwise (and simpler for v1) implement gift-wrap construction client-side using signer_nip44_encrypt(peer) to seal+wrap content, with the signer doing the sign step.
    • For v1 we can keep gift-wrap construction local, but require signer_nip44_encrypt / signer_sign_event_json for each layer. This is the highest-friction migration; document it as a separate sub-task.
  • DM legacy kind-4: signer_nip04_decrypt(peer, cipher).
  • Diary/Todo/AI-self: signer_nip44_self_encrypt / signer_nip44_self_decrypt.
  • Posts NIP-04 self: signer_nip04_encrypt(peer=self) / signer_nip04_decrypt(peer=self).
  • src/net.c auth/ephemeral signing: use signer_sign_event_json against the auth challenge event.

4.6 UI/UX adjustments

  • src/menu_profile.c: when g_signer.kind == NT_SIGNER_NSIGNER, hide nsecHex / nsec / seed phrase rows and show instead:
    Signer: n_signer (@nsigner_hairy_dog, role=main)
    npub:  ...
    
  • Login menu: add s shortcut and a one-line hint such as ^_S^:ign in with n_signer.
  • Surfacing approval waits: every signer call that may prompt should print a non-blocking line such as "Waiting for n_signer approval..." before the call so the user understands the pause.

4.7 Threading and reentrancy

  • All current call sites remain synchronous from the TUI thread.
  • For URL/FIPS transport, nostr_terminal now keeps a persistent TCP session fd for the signer session lifetime (signer_init_nsigner_url opens once; signer_shutdown closes).
  • AF_UNIX and qrexec transports remain request-scoped (open/send/close behavior unchanged).
  • No background polling.

4.8 URL persistent-session transport model

  • Login path opens the persistent URL session first, then runs get_public_key through that same session fd.
  • If login fails after opening, nostr_terminal closes the session fd immediately (no half-open socket leak).
  • After URL signer init, all URL RPC methods reuse g_signer.url_session_fd through nsigner_session_* helpers.
  • URL reconnect policy: retry once on transport I/O failure (nsigner_rpc_with_retry closes dead fd, reconnects, replays request).
  • Socket options for URL connections include SO_KEEPALIVE (plus TCP_NODELAY when available) to improve dead-peer detection and request latency.

4.9 TCP/URL auth envelope

For URL/TCP transport, nostr_terminal now attaches an auth event on every RPC request, matching n_signer’s auth_envelope_required model.

  • Auth object shape is a signed Nostr event of kind 27235.
  • Event tags bind the envelope to the exact RPC request:
    • nsigner_rpc = request id
    • nsigner_method = RPC method name
    • nsigner_body_hash = SHA-256 of compact JSON params
  • Event content carries a caller label (default: nostr_terminal).
  • Signature uses an ephemeral 32-byte caller private key generated at URL login time via getrandom.
  • The ephemeral caller key is session-only (RAM), stored in g_signer.url_auth, and zeroized on signer shutdown.
  • No auth material is persisted to DB/state files.

Operational consequence:

  • Because caller identity is now pubkey:<hex> in n_signer TCP mode, deployments that rely on silent startup should preapprove that caller on the n_signer side:
    • --preapprove caller=pubkey:<ephemeral_pubkey>,nostr_index=<N>
  • If not preapproved, n_signer may return policy_denied and/or interactive approval prompts depending on its runtime mode.

Client-side error mapping updates:

  • policy_denied is treated as a user-deny class (same handling family as approval_denied).
  • auth_envelope_* errors and numeric auth codes 2010..2017 map to a dedicated auth error code (NT_NSIGNER_E_AUTH).

5. Error handling and security

  • Treat approval_denied as a normal cancel: surface "User denied approval in n_signer." and return user to the previous menu.
  • Treat connection failure (signer process gone) as fatal for the session: prompt user to re-login.
  • Never log NIP-44/NIP-04 plaintext or ciphertext bodies. Redact request params for encrypt/decrypt methods (per CLIENT_IMPLEMENTATION.md §8).
  • Do not write any nsigner socket name or role to persistent state files. Treat the binding as session-only.

6. Phased implementation steps

  1. Scaffolding
    • Add include/signer.h, src/signer.c with the local backend only (forwards to existing nostr_* calls). All call sites still compile and behave identically.
  2. Migrate call sites to the signer abstraction (local backend only)
    • Update publish.c, state.c, menu_diary.c, menu_todo.c, menu_posts.c, menu_dm.c (kind-4 path), net.c.
    • Build + run smoke test against existing local-mnemonic accounts.
  3. n_signer client transport
    • Add include/nsigner_client.h, src/nsigner_client.c with framing, list, connect, RPC.
    • Unit-test against a running nsigner instance using get_public_key.
  4. Wire signer abstraction to n_signer backend
    • Implement signer_init_nsigner, all signer_* methods routed to nsigner_*.
  5. Login UI
    • Add s → signer-login flow in menu_login.c.
  6. Migrate NIP-17 DMs
    • Either (a) require all gift-wrap layers to be signed via signer_sign_event_json, or (b) keep gift-wrap construction in-process but route every internal nip44/sign step through the signer.
  7. Profile UI cleanup
    • Hide nsec when kind == NT_SIGNER_NSIGNER; show signer banner.
  8. Build verification
    • cmake --build build -j4 clean.
    • Manual run: connect, login via signer, post kind 1, save user-settings, send NIP-17 DM, read NIP-17 DM, encrypt/decrypt diary entry.

7. Resolved decisions

  1. Approval feedback — Display an inline "Waiting for n_signer approval..." line before each signer call that may prompt the user.
  2. Selector strategy — Prompt for seed phrase index on every signer transport (local socket s, URL u, qrexec S). Use nostr_index selector in requests; blank input defaults to index 0.
  3. Login is mandatory before any relay activity — Successful signer (or local) login is a precondition for anything else. If signer login fails (no instance, denied, transport error), the program loops back to the login menu; if the user quits from there, the program stops. There is no "load events for this npub anyway" fallback.
  4. New-user creation under n_signer is supported — n_signer can hand us a brand-new key (e.g. user generates a fresh mnemonic inside nsigner). After successful get_public_key, if no kind-0 / kind-3 / kind-10002 exists on relays, run the existing "add new user" flow but route every nostr_create_and_sign_event through signer_create_and_sign so the signer signs kind 0 and kind 10002. The existing menu_add_new_user() path stays intact and works with either backend.

8. Architecture diagram

flowchart LR
    A[menu_login] -->|local| B[signer_local backend]
    A -->|n_signer| C[signer_nsigner backend]
    B --> D[nostr_core_lib calls]
    C --> E[nsigner_client]
    E -->|JSON-RPC frame| F[nsigner process abstract socket]
    G[publish, state, menu_dm, menu_diary, menu_todo, menu_posts, menu_ai, net] --> H[signer.h API]
    H --> B
    H --> C