16 KiB
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 listenumerates abstract sockets via/proc/net/unix(lines beginning with@nsigneror@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_keysign_eventnip04_encrypt/nip04_decryptnip44_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 treatapproval_deniedas 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 withnsigner(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 byid.- 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_hexetc. populated only wheng_signer.kind == NT_SIGNER_LOCAL. - For
NT_SIGNER_NSIGNER:npub_hex/npub_bech32populated fromsigner_get_public_key.nsec_hex,nsec_bech32,seed_phraseleft empty.
- Move existing
state_nip44_self_encrypt/state_nip44_self_decryptto thin wrappers oversigner_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:
- Call
nsigner_list(). - If 0 instances: print "No running n_signer found. Start
nsignerand try again." and loop back to the login menu. - If 1 instance: auto-select. Show banner.
- If >1: list and prompt for index.
- Call
nsigner_get_public_key()with rolemain. The signer may prompt the user in its own TUI for approval. - On success:
signer_init_nsigner(name)(role hard-coded to"main"internally).- Populate
g_state.npub_hex, derivenpub_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), callmenu_add_new_user()so the user can fill in profile + relays. Every event published in that flow is signed by n_signer throughsigner_create_and_sign.
- On
approval_deniedor other error: print message, loop back to the login menu. If the user picksq/xfrom 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→ usesigner_create_and_signand post the returned JSON.src/menu_dm.cDM send →- Build NIP-17 rumor JSON (unsigned).
- Call
signer_sign_event_jsonfor the gift-wrap shell only if signer supports it; otherwise (and simpler for v1) implement gift-wrap construction client-side usingsigner_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_jsonfor 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.cauth/ephemeral signing: usesigner_sign_event_jsonagainst the auth challenge event.
4.6 UI/UX adjustments
src/menu_profile.c: wheng_signer.kind == NT_SIGNER_NSIGNER, hidensecHex/nsec/ seed phrase rows and show instead:Signer: n_signer (@nsigner_hairy_dog, role=main) npub: ...- Login menu: add
sshortcut 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_urlopens once;signer_shutdowncloses). - 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_keythrough 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_fdthroughnsigner_session_*helpers. - URL reconnect policy: retry once on transport I/O failure (
nsigner_rpc_with_retrycloses dead fd, reconnects, replays request). - Socket options for URL connections include
SO_KEEPALIVE(plusTCP_NODELAYwhen 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= requestidnsigner_method= RPC method namensigner_body_hash= SHA-256 of compact JSONparams
- Event
contentcarries 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_deniedand/or interactive approval prompts depending on its runtime mode.
Client-side error mapping updates:
policy_deniedis treated as a user-deny class (same handling family asapproval_denied).auth_envelope_*errors and numeric auth codes2010..2017map to a dedicated auth error code (NT_NSIGNER_E_AUTH).
5. Error handling and security
- Treat
approval_deniedas 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
- Scaffolding
- Add
include/signer.h,src/signer.cwith the local backend only (forwards to existingnostr_*calls). All call sites still compile and behave identically.
- Add
- 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.
- Update
- n_signer client transport
- Add
include/nsigner_client.h,src/nsigner_client.cwith framing, list, connect, RPC. - Unit-test against a running
nsignerinstance usingget_public_key.
- Add
- Wire signer abstraction to n_signer backend
- Implement
signer_init_nsigner, all signer_* methods routed tonsigner_*.
- Implement
- Login UI
- Add
s→ signer-login flow inmenu_login.c.
- Add
- 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.
- Either (a) require all gift-wrap layers to be signed via
- Profile UI cleanup
- Hide nsec when
kind == NT_SIGNER_NSIGNER; show signer banner.
- Hide nsec when
- Build verification
cmake --build build -j4clean.- 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
- Approval feedback — Display an inline "Waiting for n_signer approval..." line before each signer call that may prompt the user.
- Selector strategy — Prompt for seed phrase index on every signer transport (local socket
s, URLu, qrexecS). Usenostr_indexselector in requests; blank input defaults to index0. - 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.
- 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 successfulget_public_key, if no kind-0 / kind-3 / kind-10002 exists on relays, run the existing "add new user" flow but route everynostr_create_and_sign_eventthroughsigner_create_and_signso the signer signs kind 0 and kind 10002. The existingmenu_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