Files
n_signer/plans/caller_token_identity.md

33 KiB
Raw Blame History

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 0600 file on both ends. That draft was rejected on two grounds: (1) it violated documents/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

  1. One approval per program-keypair, not per TCP connection. Two requests carrying the same caller_pubkey resolve to the same policy entry regardless of source port, transport jitter, or reconnect.
  2. 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.
  3. Replay-resistant within a bounded window. A captured request cannot be replayed against the signer indefinitely.
  4. 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.
  5. Stronger transports keep their stronger primitives. AF_UNIX still uses SO_PEERCRED; qrexec still uses QREXEC_REMOTE_DOMAIN. Neither acquires a new spoofable claim field, and neither is required to send signatures.
  6. Use only nostr_core_lib and existing dependencies. No new crypto libraries. No new hash functions. The auth envelope is a Nostr event, verified by a primitive nostr_verify_event_signature the 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.md applies 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. Its caller_id form is pubkey:<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_NEVER row 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:

  1. Extracts the auth object. If absent, reject with auth_envelope_required (code 2014).
  2. Validates the envelope is structurally a NIP-01 event (id is sha256 of canonical event header; pubkey, sig, created_at, kind, tags, content present and well-typed). Library function nostr_validate_event_structure handles this.
  3. Verifies the Schnorr signature: nostr_verify_event_signature(auth_event). Reject with auth_signature_invalid (code 2012) on failure.
  4. Checks kind == 27235. Reject with auth_kind_invalid (code 2013) otherwise.
  5. Confirms nsigner_rpc tag matches request id and nsigner_method tag matches request method. Reject with auth_envelope_mismatch (code 2015) on either mismatch.
  6. Computes SHA-256(JCS(params)) and confirms it matches nsigner_body_hash. Reject with auth_body_mismatch (code 2011) on mismatch.
  7. Applies replay protection — see §6.5.
  8. 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_t per request (already exists; widened).
  • An in-memory auth_nonce_cache_t per server context (new, ring buffer, RAM only).
  • New entries in the existing in-memory policy_table_t keyed on pubkey:<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:

  1. 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."
  2. 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.
  3. 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:

  1. Builds the regular JSON-RPC envelope (id, method, params).
  2. Computes body_hash = SHA-256(JCS(params)).
  3. Constructs an auth event with kind 27235, the three required tags, optional content label, current created_at.
  4. 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).
  5. Embeds the signed event under an auth field in the request.
  6. 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, different created_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_SEC and the in-memory nonce cache.
  • Tampered request bodies. nsigner_body_hash covers params, 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_method and nsigner_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 params is 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.

  1. src/auth_envelope.c and tests/test_auth_envelope.c. Pure data-structure and crypto wrapper around nostr_validate_event_structure + nostr_verify_event_signature + tag/body/timestamp/nonce checks. No transport coupling. Test with hand-crafted JSON and fixed test keypairs.
  2. Nonce cache primitive. Ring buffer; trivial unit tests for insert / lookup / eviction.
  3. caller_identity_t widening. Mechanical change across all NSIGNER_HEADERLESS_DECLS_BEGIN blocks. Compile-only step; existing tests should still pass.
  4. 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 without auth and confirm rejection with code 2014; fire one with a valid envelope and confirm normal flow.
  5. --preapprove caller=pubkey:<hex> parsing. Extension to parse_preapprove_spec.
  6. --auth-skew-seconds flag. Plumbing.
  7. Documentation.
    • documents/SECURITY.md: replace tcp_remote (planned) and fips (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>.
  8. Deprecation handling for --preapprove caller=tcp:... on TCP listeners. Warning at startup; entries effectively dead.
  9. 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.

  1. Skew window default. This plan says 30 seconds. Confirm. Tighter (5s) means fewer replay opportunities but more clock-drift errors; looser (120s) the reverse.
  2. 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.
  3. Should the auth envelope's content field carry a label? This plan says yes, displayed at prompt as a hint, max 63 bytes UTF-8. Alternative: leave content empty 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.
  4. 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.
  5. Should AF_UNIX accept an auth envelope as an additional identity claim? Today this plan says no — AF_UNIX uses SO_PEERCRED and ignores auth. Alternative: allow a client over AF_UNIX to ALSO send an envelope and have the signer record both uid:1000 AND pubkey:<hex> as identities for the same caller. Adds complexity for a use case nobody has asked for. Default = AF_UNIX ignores auth.

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 --preapprove mechanics all continue. Only the form of caller_id for 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_remote and fips move 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.
  • documents/CLIENT_IMPLEMENTATION.md: new chapter "Authenticating over TCP/URL transport" defining the auth envelope, JCS rule, and error codes 20102017.
  • 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.