33 KiB
Plan: Caller-pubkey identity with required Schnorr authentication for URL/TCP
Status: draft v2 — for review
History. Draft v1 of this document proposed a random 32-byte bearer token persisted to a
0600file on both ends. That draft was rejected on two grounds: (1) it violateddocuments/SECURITY.md:19— "Crash equals total wipe" — by writing tokens to disk, and (2) bearer credentials over a transport already chosen as the least trusted is the wrong security gradient. v2 replaces the bearer token with a Schnorr-authenticated public key, removes all disk persistence, and requires cryptographic proof-of-possession on every TCP/URL request from day one.
This plan addresses the same originating bug — every TCP reconnect re-fires the approval prompt because caller_id is built from the ephemeral addr:port pair — but does so by giving the weakest transport the strongest application-layer identity, not the weakest. AF_UNIX and qrexec retain their existing kernel- and hypervisor-vouched identities; TCP/URL gains BIP-340 Schnorr authentication using primitives the signer already links.
1. Goals
- One approval per program-keypair, not per TCP connection. Two requests carrying the same
caller_pubkeyresolve to the same policy entry regardless of source port, transport jitter, or reconnect. - Cryptographic proof-of-possession on every TCP/URL request. The wire identity is not just a name; it is backed by a signature that only the holder of the corresponding private key can produce.
- Replay-resistant within a bounded window. A captured request cannot be replayed against the signer indefinitely.
- No disk persistence — anywhere. No tokens on disk, no caller registry on disk, no policy on disk. "Crash equals total wipe" applies to this feature unmodified.
- Stronger transports keep their stronger primitives. AF_UNIX still uses
SO_PEERCRED; qrexec still usesQREXEC_REMOTE_DOMAIN. Neither acquires a new spoofable claim field, and neither is required to send signatures. - Use only
nostr_core_liband existing dependencies. No new crypto libraries. No new hash functions. The auth envelope is a Nostr event, verified by a primitivenostr_verify_event_signaturethe signer already trusts.
2. Non-goals
- No transport-level encryption / TLS. FIPS still provides confidentiality of the substrate. Application-layer auth and transport encryption are separate concerns.
- No mutual authentication of the signer to the client. This plan authenticates the client to the signer. The reverse direction is future work and intentionally out of scope.
- No persistence of any state. Approvals stay in RAM. A signer restart re-prompts every caller exactly once. A client restart re-prompts itself once if it lost its in-memory keypair, or zero times if the client retains its own identity in its own memory.
- No new transports. AF_UNIX, qrexec, stdio, TCP unchanged at the socket layer.
- No PID-as-identity. Documented rejection in §10.
- No bearer tokens. Explicit reversal of v1.
- No change to the deny-by-default rule. A new pubkey shows up exactly once at the prompt; everything in
plans/deny_by_default_approvals.mdapplies unchanged.
3. The model in one paragraph
For TCP/URL transport, every JSON-RPC request must carry an auth envelope — a small Nostr-shaped event signed by the caller's private key. The signer verifies that signature with nostr_verify_event_signature before any policy logic runs. The verified pubkey field becomes the caller_id for that request (pubkey:<hex> form), feeding straight into the existing policy table that already handles uid:1000, qubes:foo, tcp:[addr]:port. On first contact for a given pubkey, the existing deny-by-default prompt fires; the user sees the npub, an optional client-supplied label, and the verb/role; on [a] the pubkey is recorded as an approval entry the same way any other caller is. Subsequent requests from the same pubkey hit the approval and are served silently. AF_UNIX and qrexec are unaffected — they continue to use kernel/hypervisor identity and ignore any auth envelope they receive. Nothing is written to disk on either side at any point.
4. Concept additions to the existing model
documents/SECURITY.md:57 defines three concepts: identity, index, approval. This plan adds nothing visible to the user beyond an alternate identity kind. There is no fourth concept. Specifically:
- A pubkey-identified caller is a new identity kind, alongside
unix_peer,qubes,tcp_local,tcp_remote. Itscaller_idform ispubkey:<64 hex chars>. - An auth envelope is a wire-protocol artifact, not a user-facing concept. The user never sees the word "envelope"; they see "caller: npub1abc…xyz" at the prompt.
- An approval is unchanged: the same
(caller_id, role) → PROMPT_NEVERrow in the same in-memory policy table.
The only document-level change is that documents/SECURITY.md §11's "tcp_remote (planned)" and "fips (planned)" rows (documents/SECURITY.md:98-99) become "implemented; identity = caller pubkey verified by Schnorr signature."
5. Per-transport identity primitives — the unified picture
| Transport | Identity primitive | Validation | caller_id form |
Auth envelope required? |
|---|---|---|---|---|
| AF_UNIX | SO_PEERCRED.uid (and pid for UX) |
Kernel-attested. | uid:<n> |
No. Kernel vouches. |
| qrexec | QREXEC_REMOTE_DOMAIN |
Hypervisor-attested. | qubes:<vm> |
No. Xen vouches. |
| stdio | inherited environment (qrexec or interactive) | As above. | qubes:<vm> or uid:<n> |
No. |
| TCP / FIPS | caller_pubkey from auth envelope |
BIP-340 Schnorr signature verified by nostr_verify_event_signature. |
pubkey:<hex> |
Yes. Required on every request. |
A TCP request that arrives without a valid auth envelope is rejected before any policy logic runs. There is no fallback to tcp:[addr]:port identity; v2 retires that form entirely for the TCP listener. Operators who relied on --preapprove caller=tcp:... must migrate to --preapprove caller=pubkey:<hex> (see §7.5).
6. Wire protocol: the auth envelope
6.1 Shape
The auth envelope is a single JSON object placed at the top level of the request, alongside (not inside) method/params. It is structured exactly like a NIP-01 Nostr event so that nostr_verify_event_signature verifies it directly with no custom canonicalization code on the signer side:
{
"id": "rpc-42",
"method": "sign_event",
"params": { ... },
"auth": {
"id": "<32-byte event id, hex>",
"pubkey": "<32-byte x-only secp256k1 pubkey, hex>",
"created_at": 1730000000,
"kind": 27235,
"tags": [
["nsigner_rpc", "rpc-42"],
["nsigner_method", "sign_event"],
["nsigner_body_hash", "<sha256(canonical body), hex>"]
],
"content": "<optional client label, e.g. \"nostr_terminal@laptop\">",
"sig": "<64-byte Schnorr signature, hex>"
}
}
The kind value 27235 is the same kind NIP-42 uses for client authentication to relays. We are explicitly piggybacking on that semantic: this is "client authenticates to a service it wants to use," and reusing the existing kind keeps us inside Nostr conventions rather than inventing a new event kind. The signer accepts kind 27235 events whose tag set marks them as nsigner-bound.
6.2 Required tags
Three tags are required on every envelope. The signer rejects envelopes missing any of them with auth_envelope_malformed (code 2010).
| Tag name | Value | Purpose |
|---|---|---|
nsigner_rpc |
The request's id field, copied verbatim. |
Binds the signature to this specific request id; a captured envelope cannot be reused with a different request id. |
nsigner_method |
The request's method field, copied verbatim. |
Binds the signature to the verb. A captured get_public_key envelope cannot be replayed as sign_event. |
nsigner_body_hash |
Hex SHA-256 of the canonicalized params object — see §6.3. |
Binds the signature to the request payload. The signer recomputes this hash and rejects on mismatch with auth_body_mismatch (code 2011). |
6.3 Canonicalization of params
To make nsigner_body_hash deterministic across implementations, params must be hashed in a single canonical form. To avoid inventing a new canonicalizer, we adopt JCS (RFC 8785, JSON Canonicalization Scheme) — the same canonicalization Nostr already uses for event IDs in NIP-01. The body hash is SHA-256(JCS(params)), computed by both client and signer using nostr_sha256.
If params is absent from the request, nsigner_body_hash is SHA-256("null") (the canonicalization of JSON null), which is a fixed 64-char constant the signer can use without per-request hashing for the no-params case.
6.4 Signature verification
On receipt of a TCP request, the signer:
- Extracts the
authobject. If absent, reject withauth_envelope_required(code 2014). - Validates the envelope is structurally a NIP-01 event (id is sha256 of canonical event header;
pubkey,sig,created_at,kind,tags,contentpresent and well-typed). Library functionnostr_validate_event_structurehandles this. - Verifies the Schnorr signature:
nostr_verify_event_signature(auth_event). Reject withauth_signature_invalid(code 2012) on failure. - Checks
kind == 27235. Reject withauth_kind_invalid(code 2013) otherwise. - Confirms
nsigner_rpctag matches requestidandnsigner_methodtag matches requestmethod. Reject withauth_envelope_mismatch(code 2015) on either mismatch. - Computes
SHA-256(JCS(params))and confirms it matchesnsigner_body_hash. Reject withauth_body_mismatch(code 2011) on mismatch. - Applies replay protection — see §6.5.
- On success, sets
caller.caller_id = "pubkey:" + hex(pubkey). Policy lookup proceeds against this caller_id.
6.5 Replay protection
Two layered defenses, neither requiring disk:
6.5.1 Timestamp window
auth.created_at must satisfy |now - created_at| <= AUTH_CLOCK_SKEW_SEC. Default AUTH_CLOCK_SKEW_SEC = 30. Configurable via --auth-skew-seconds at startup. Reject with auth_envelope_stale (code 2016) if outside the window.
The 30-second window is small enough that a captured envelope is a poor reuse target and large enough to tolerate ordinary clock drift in the FIPS-mediated path. Operators with looser requirements can widen it; operators with NTP-locked deployments can narrow it.
6.5.2 Nonce / id cache
The signer maintains an in-memory ring buffer of recently-seen auth.id values:
#define AUTH_NONCE_CACHE_SIZE 1024
typedef struct {
uint8_t id[32];
uint64_t seen_at; /* monotonic seconds */
} auth_nonce_entry_t;
typedef struct {
auth_nonce_entry_t entries[AUTH_NONCE_CACHE_SIZE];
int head;
int count;
} auth_nonce_cache_t;
On verified envelope, the signer linearly scans the cache for auth.id. If found, reject with auth_replay_detected (code 2017). If not, insert at head, evicting the tail if full.
The cache size of 1024 paired with the 30-second window means at sustained 33+ requests/second from any caller would push older entries out before they expire — at which point the timestamp window catches the replay anyway. The two defenses overlap intentionally: the cache catches fast replays inside the window; the timestamp catches slow replays outside it.
The cache is purely in-memory. On signer restart it starts empty, which is safe because the timestamp window also resets the universe of acceptable envelopes — any envelope created before the restart is now outside the window and would be rejected on created_at grounds before reaching the nonce check.
6.6 Error codes added
| Code | Symbolic name | Meaning |
|---|---|---|
| 2010 | auth_envelope_malformed |
Required field or tag missing / wrong type. |
| 2011 | auth_body_mismatch |
nsigner_body_hash did not match recomputed hash of params. |
| 2012 | auth_signature_invalid |
Schnorr verification failed. |
| 2013 | auth_kind_invalid |
Auth event kind ≠ 27235. |
| 2014 | auth_envelope_required |
TCP request arrived with no auth object. |
| 2015 | auth_envelope_mismatch |
nsigner_rpc or nsigner_method tag did not match request envelope. |
| 2016 | auth_envelope_stale |
created_at outside skew window. |
| 2017 | auth_replay_detected |
auth.id already seen in nonce cache. |
These are placed in their own contiguous range so client code can identify "auth-layer error → bug in my signing code" vs "policy-layer error → user denied."
7. Signer-side data model and code changes
7.1 Extension to caller_identity_t
src/server.c:404 currently has:
typedef struct {
int kind;
char caller_id[64];
char source_qube[64];
} caller_identity_t;
Becomes:
typedef struct {
int kind;
char caller_id[80]; /* widened: "pubkey:" + 64 hex + NUL fits */
char source_qube[64];
/* Filled in only when an auth envelope was verified on this request. */
int auth_present; /* 0 or 1 */
uint8_t auth_pubkey[32]; /* present iff auth_present */
char auth_label[64]; /* envelope `content` field, sanitized */
/* UX context, never used as policy key. */
pid_t peer_pid; /* SO_PEERCRED.pid for AF_UNIX, else 0 */
char peer_addr[64]; /* tcp:[...]:port, used in prompt context */
} caller_identity_t;
The widened caller_id ripples through every translation unit's headerless declaration block. Mechanical change.
7.2 New translation unit: src/auth_envelope.c
A new file owns auth-envelope parsing, verification, and the nonce cache. Public surface:
typedef enum {
AUTH_OK = 0,
AUTH_ERR_MALFORMED,
AUTH_ERR_BODY_MISMATCH,
AUTH_ERR_SIG_INVALID,
AUTH_ERR_KIND_INVALID,
AUTH_ERR_REQUIRED,
AUTH_ERR_TAG_MISMATCH,
AUTH_ERR_STALE,
AUTH_ERR_REPLAY
} auth_result_t;
/* Verify the auth envelope embedded in `request_root` against the request's
* id, method, and params. On AUTH_OK, fills caller->auth_pubkey and
* caller->auth_label. Does not touch caller_id. */
auth_result_t auth_envelope_verify(cJSON *request_root,
auth_nonce_cache_t *nonce_cache,
int skew_seconds,
uint64_t now_unix,
caller_identity_t *caller);
void auth_nonce_cache_init(auth_nonce_cache_t *cache);
The implementation is thin: extract auth cJSON object, run library validations, run our tag/body/replay checks, return. All the heavy crypto lifting is nostr_verify_event_signature.
A corresponding tests/test_auth_envelope.c covers the eight error paths with hand-crafted envelopes (using a fixed test keypair) and the happy path.
7.3 Ordering in server_handle_one
src/server.c:1192 gains an auth gate between framed-receive and selector-resolve, but only on TCP. Pseudocode for the revised flow:
1. accept(); server_get_caller(fd, &caller); /* unchanged: kind, peer_addr */
2. transport_recv_framed(fd, &request);
3. if (caller.kind == NSIGNER_LISTEN_TCP) {
parse request as cJSON;
auth_result = auth_envelope_verify(root, &ctx->nonce_cache,
ctx->auth_skew, now, &caller);
if (auth_result != AUTH_OK) { send error 2010-2017; close; return; }
snprintf(caller.caller_id, ..., "pubkey:%s", hex(caller.auth_pubkey));
}
/* AF_UNIX/qrexec: caller.caller_id already set; auth envelope, if any,
is silently ignored. */
4. extract method + selector; /* unchanged */
5. resolve role; /* unchanged */
6. policy_check(caller.caller_id, ...); /* unchanged */
7. prompt if PROMPT; /* unchanged */
8. dispatch; /* unchanged */
The auth gate is a single function call. Existing code paths are touched only at the point where caller.caller_id is overwritten on success.
7.4 Server context additions
server_ctx_t gains:
auth_nonce_cache_t nonce_cache; /* in-memory, zeroed at startup, never persisted */
int auth_skew_seconds; /* default 30, settable via --auth-skew-seconds */
7.5 --preapprove extension
parse_preapprove_spec gains a new caller form:
--preapprove caller=pubkey:<64 hex>,role=main
--preapprove caller=pubkey:<64 hex>,nostr_index=1
The pubkey is the client's caller pubkey — a 32-byte x-only secp256k1 hex value. No file reference; the operator has the pubkey directly. There is no pubkey_file form because the pubkey is not secret and embedding it in the unit file is fine.
The tcp: caller form is deprecated for the TCP listener (not removed; AF_UNIX and qrexec preapprovals are unaffected — they never used tcp:). On a --preapprove caller=tcp:... for a TCP listener, the signer prints a deprecation warning at startup explaining that TCP requests now require auth envelopes and the entry will never match. This is documented in documents/N_OS_TR_AGENT_CHANGES.md.
7.6 No persistence — confirmed property
This plan introduces:
- An in-memory
caller_identity_tper request (already exists; widened). - An in-memory
auth_nonce_cache_tper server context (new, ring buffer, RAM only). - New entries in the existing in-memory
policy_table_tkeyed onpubkey:<hex>(no schema change).
It does not introduce:
- Any file written by the signer.
- Any file required at startup by the signer (beyond the already-required mnemonic input).
- Any state that survives
server_stop.
"Crash equals total wipe" remains literally true.
8. Client-side requirements (e.g. nostr_terminal)
8.1 Caller keypair
The client needs a secp256k1 keypair for the purpose of authenticating to n_signer. Three sourcing strategies are supported, and the choice is the client's:
- Reuse the program's existing Nostr identity. A program like nostr_terminal that already has a published Nostr pubkey can use that same keypair as its caller identity. The user sees a familiar npub at the prompt and recognizes "yes, that's nostr_terminal's pubkey."
- Derive a dedicated caller identity from the program's seed. A program with a stored seed can derive a separate
m/44'/1237'/<n>'/0/<m>key as a stable "RPC client identity" distinct from its public posting key. The user sees a unique-to-the-program-installation npub. - Generate ephemerally per session. A program that has no persistent identity at all generates a fresh secp256k1 key in memory at startup. Each client restart yields a new key, and the user re-prompts once per restart. This is the slowest UX but the simplest implementation.
n_signer does not know or care which strategy a client uses. The pubkey on the wire is the only thing the signer sees.
8.2 Signing each request
For every TCP request, the client:
- Builds the regular JSON-RPC envelope (
id,method,params). - Computes
body_hash = SHA-256(JCS(params)). - Constructs an auth event with kind 27235, the three required tags, optional
contentlabel, currentcreated_at. - Signs the event with its private key (BIP-340 Schnorr — for nostr_terminal this is the same
nostr_schnorr_sign-style call it already uses for kind-1 events, just with a different kind and tag set). - Embeds the signed event under an
authfield in the request. - Sends the framed bytes.
The signing operation is bounded — one Schnorr sign per RPC. Modern hardware does this in microseconds. Implementations should not cache or skip signing; the per-request created_at is what makes the nonce defense meaningful.
8.3 Reconnect behavior — the bug being fixed
- TCP socket drops mid-session → client reconnects → next RPC carries a fresh auth envelope (different
id, differentcreated_at) signed by the same pubkey. - Signer verifies envelope, computes the same
caller_id = "pubkey:<hex>", finds the existing approval entry, serves silently. No prompt. - Signer process restart → policy table empties → next RPC's envelope verifies fine but the policy entry is gone → user re-prompts once per pubkey, picks
[a], future requests served silently. One prompt per signer restart, exactly as for any other caller kind. - Client process restart → if the client persisted its keypair (its own choice), zero prompts. If the client used an ephemeral keypair, one prompt for the new pubkey.
8.4 Replay-window awareness
Clients must keep their clock synced within AUTH_CLOCK_SKEW_SEC (default 30s). This is normally trivial via NTP. The signer's response on stale-clock errors (auth_envelope_stale, code 2016) tells the client to fix its clock; clients should surface this clearly rather than retrying.
8.5 No on-disk requirement
The client is not required to store anything on disk. Whether it does is the client's policy. n_signer's plan imposes no client-side filesystem state.
9. Mermaid: TCP request decision tree
flowchart TD
A[TCP request arrives] --> B[parse JSON, extract auth]
B --> C{auth present?}
C -- no --> D[reject 2014 auth_envelope_required]
C -- yes --> E[validate event structure]
E -- bad --> F[reject 2010 auth_envelope_malformed]
E -- ok --> G[verify Schnorr signature]
G -- bad --> H[reject 2012 auth_signature_invalid]
G -- ok --> I{kind == 27235?}
I -- no --> J[reject 2013 auth_kind_invalid]
I -- yes --> K{tags match request id and method?}
K -- no --> L[reject 2015 auth_envelope_mismatch]
K -- yes --> M{body hash matches?}
M -- no --> N[reject 2011 auth_body_mismatch]
M -- yes --> O{within skew window?}
O -- no --> P[reject 2016 auth_envelope_stale]
O -- yes --> Q{auth.id in nonce cache?}
Q -- yes --> R[reject 2017 auth_replay_detected]
Q -- no --> S[insert nonce, set caller_id pubkey:hex]
S --> T[normal policy_check pipeline]
T --> U{verdict}
U -- ALLOW --> V[dispatch RPC]
U -- DENY --> W[2001 policy_denied]
U -- PROMPT --> X[user approves with a, registers caller]
X --> V
10. Identity primitives compared
| Primitive | Stable across reconnects | Forgeable on TCP/FIPS | Survives client restart | Replay-resistant | n_signer dependency added |
|---|---|---|---|---|---|
tcp:[addr]:port (today) |
No | n/a (it is the address) | n/a | n/a | none |
| PID claimed in JSON | Yes | Yes — any client claims any int | n/a | No | none |
| 32-byte random bearer token (v1 of this plan) | Yes | Yes if leaked from disk or wire | Only if disk-persisted (rejected) | No | none |
| Caller pubkey + Schnorr signature (this plan) | Yes | No — signature attests possession | Yes if client retains key (its choice) | Yes (nonce + skew window) | none — nostr_core_lib already linked |
The bottom row is the only one that satisfies all four columns and adds zero new transitive dependencies. PID and the v1 token are kept in the table as documentation of why we are not doing them.
PID retains a single legitimate use: prompt UX context for AF_UNIX. When the signer can read PID via SO_PEERCRED, it is displayed in the approval prompt as pid=18437 (exe=/usr/local/bin/nostr_terminal) so the user can ps -p 18437 and verify. PID is never the policy key.
11. Threats addressed and not addressed
Addressed
- Connection-churn re-prompting. The originating bug. One pubkey, one approval.
- Same-IP, different-program ambiguity. Two programs from the same FIPS endpoint hold different keypairs and produce distinct, verified
pubkey:caller_ids. - Drive-by signing from a process that lacks the privkey. A network adversary or local-but-unauthorized process cannot forge envelopes. Wire knowledge of the pubkey is non-sensitive; the privkey never leaves the legitimate client's memory.
- Replay of captured envelopes. Bounded by
AUTH_CLOCK_SKEW_SECand the in-memory nonce cache. - Tampered request bodies.
nsigner_body_hashcoversparams, so a man-in-the-middle who flips a kind-1 request to a kind-30000 request invalidates the signature and is rejected. - Tampered request methods or ids. Covered by required tags
nsigner_methodandnsigner_rpc.
Not addressed (explicit)
- A local attacker who reads the client's privkey out of process memory. They become the registered caller. This is the same threat as today's same-uid trust model and is handled by the OS, not by n_signer.
- A network attacker who can both passively observe AND inject AND has compromised the FIPS substrate. Without TLS at the n_signer layer, transport encryption is FIPS's job. If FIPS confidentiality is broken, the attacker can read envelopes — but they still cannot forge them (no privkey) and they cannot replay them outside the skew window. Confidentiality of
paramsis a separate concern from auth. - Signer authenticating to the client. This plan does the client → signer direction. Signer → client auth is future work; today the client trusts whichever endpoint the operator pointed it at.
- Long-term key rotation policy. Operators rotate by removing the old approval entry (TUI hotkey, future) and prompting the new pubkey. There is no automatic rotation.
12. Implementation order (suggested)
Each step is independently testable.
src/auth_envelope.candtests/test_auth_envelope.c. Pure data-structure and crypto wrapper aroundnostr_validate_event_structure+nostr_verify_event_signature+ tag/body/timestamp/nonce checks. No transport coupling. Test with hand-crafted JSON and fixed test keypairs.- Nonce cache primitive. Ring buffer; trivial unit tests for insert / lookup / eviction.
caller_identity_twidening. Mechanical change across allNSIGNER_HEADERLESS_DECLS_BEGINblocks. Compile-only step; existing tests should still pass.- Auth gate in
server_handle_one. Hook (1) into the request flow at TCP transport. Add the eight new error code responses. Integration test: fire a TCP request withoutauthand confirm rejection with code 2014; fire one with a valid envelope and confirm normal flow. --preapprove caller=pubkey:<hex>parsing. Extension toparse_preapprove_spec.--auth-skew-secondsflag. Plumbing.- Documentation.
documents/SECURITY.md: replacetcp_remote (planned)andfips (planned)rows in §11 with the verified-pubkey description; add a section on the auth envelope.documents/CLIENT_IMPLEMENTATION.md: full chapter on the auth envelope, JCS canonicalization rule, the eight error codes, a concrete signing example.documents/FIPS_DEPLOYMENT.md: note that TCP requests now require auth envelopes; update the deployment checklist; remove the "remote TCP mode only with mandatory TLS + authenticated caller key flow" future-work note since this plan delivers the second half.documents/N_OS_TR_AGENT_CHANGES.md: example with--preapprove caller=pubkey:<hex>.
- Deprecation handling for
--preapprove caller=tcp:...on TCP listeners. Warning at startup; entries effectively dead. - TUI revocation hotkey. UX-side, can land later.
13. Open questions for review
These are the judgment calls left in v2. None of them block the v2 → implementation move; defaults are stated for each.
- Skew window default. This plan says 30 seconds. Confirm. Tighter (5s) means fewer replay opportunities but more clock-drift errors; looser (120s) the reverse.
- Nonce cache size. This plan says 1024. With a 30s window that handles ~33 RPS sustained per signer, far above realistic load. Confirm the size is fine.
- Should the auth envelope's
contentfield carry a label? This plan says yes, displayed at prompt as a hint, max 63 bytes UTF-8. Alternative: leavecontentempty and force the operator to recognize the npub. The label is purely UX; the npub is the truth. Default = label allowed, but ignored by policy. - JCS as canonicalization vs. a simpler rule. JCS is the right answer (RFC standard, used by NIP-01) but not all client languages have a JCS library. Alternative: define a small subset rule ("sort keys lexicographically, no whitespace, JSON-stringify each value"). Default = JCS; revisit if a target client language lacks support.
- Should AF_UNIX accept an
authenvelope as an additional identity claim? Today this plan says no — AF_UNIX usesSO_PEERCREDand ignoresauth. Alternative: allow a client over AF_UNIX to ALSO send an envelope and have the signer record bothuid:1000ANDpubkey:<hex>as identities for the same caller. Adds complexity for a use case nobody has asked for. Default = AF_UNIX ignoresauth.
Appendix A — Diff against existing plans and docs
plans/deny_by_default_approvals.md: unchanged. The deny-by-default rule, prompt outcomes, on-demand role derivation, and--preapprovemechanics all continue. Only the form ofcaller_idfor one transport changes.plans/nsigner.md: the future-work item "remote TCP mode only with mandatory TLS + authenticated caller key flow" (documents/FIPS_DEPLOYMENT.md:177) is half answered: the auth side is done; TLS at the n_signer layer remains future work.documents/SECURITY.md:- §11 rows for
tcp_remoteandfipsmove from "planned" to "implemented; identity = caller pubkey verified by Schnorr signature." - New subsection in §5 ("the two checks") inserts an auth check ahead of the approval check, applicable only to TCP transport.
- §11 rows for
documents/CLIENT_IMPLEMENTATION.md: new chapter "Authenticating over TCP/URL transport" defining the auth envelope, JCS rule, and error codes 2010–2017.documents/N_OS_TR_AGENT_CHANGES.md: adds a--preapprove caller=pubkey:<hex>example for system services that have a stable identity keypair; notes that--preapprove caller=tcp:...is deprecated for TCP listeners.
Appendix B — Why kind 27235 specifically
NIP-42 already defines kind 22242 for "client authentication to relays" with HTTP-Auth-style semantics. Reusing 22242 verbatim would be wrong because the tag schema (relay, challenge) is relay-specific. Choosing a fresh kind in the parameterized-replaceable range used for HTTP/RPC auth (27235 is used elsewhere for HTTP request auth in some Nostr ecosystems — verify before locking in) lets us define our own tag schema (nsigner_rpc, nsigner_method, nsigner_body_hash) without colliding with relay-auth conventions.
If 27235 turns out to be already claimed by a NIP we do not want to inherit semantics from, the plan can pick a different kind in the same range. The kind is a constant in src/auth_envelope.c and changing it is a one-line edit in both signer and client.
Appendix C — Pseudocode: client signing one request
/* Pseudocode, illustrative only. */
cJSON *params = build_params_for_method(method);
/* 1. Compute body hash. */
char *params_jcs = jcs_canonicalize(params);
unsigned char body_hash[32];
nostr_sha256((const unsigned char *)params_jcs, strlen(params_jcs), body_hash);
char body_hash_hex[65];
nostr_bytes_to_hex(body_hash, 32, body_hash_hex);
/* 2. Build the auth event JSON. */
cJSON *auth = cJSON_CreateObject();
cJSON_AddStringToObject(auth, "pubkey", caller_pubkey_hex);
cJSON_AddNumberToObject(auth, "created_at", (double)time(NULL));
cJSON_AddNumberToObject(auth, "kind", 27235);
cJSON *tags = cJSON_CreateArray();
add_tag(tags, "nsigner_rpc", request_id);
add_tag(tags, "nsigner_method", method);
add_tag(tags, "nsigner_body_hash", body_hash_hex);
cJSON_AddItemToObject(auth, "tags", tags);
cJSON_AddStringToObject(auth, "content", optional_label_or_empty);
/* 3. Compute event id (sha256 of canonical event header) and sign. */
unsigned char event_id[32], sig[64];
compute_nostr_event_id(auth, event_id); /* helper from nostr_core_lib usage */
nostr_schnorr_sign(caller_privkey, event_id, sig);
char id_hex[65], sig_hex[129];
nostr_bytes_to_hex(event_id, 32, id_hex);
nostr_bytes_to_hex(sig, 64, sig_hex);
cJSON_AddStringToObject(auth, "id", id_hex);
cJSON_AddStringToObject(auth, "sig", sig_hex);
/* 4. Embed in the request and send. */
cJSON *root = cJSON_CreateObject();
cJSON_AddStringToObject(root, "id", request_id);
cJSON_AddStringToObject(root, "method", method);
cJSON_AddItemToObject(root, "params", params);
cJSON_AddItemToObject(root, "auth", auth);
send_framed(cJSON_PrintUnformatted(root));
The signer side is the same flow in reverse, replacing nostr_schnorr_sign with nostr_verify_event_signature on the embedded auth event.