Files
n_signer/plans/caller_token_identity.md

484 lines
33 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`](../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`](../resources/nostr_core_lib/nostr_core/nip001.h:20) 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`](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`](../resources/nostr_core_lib/nostr_core/nip001.h:20) 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`](../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`](../documents/SECURITY.md) §11's "tcp_remote (planned)" and "fips (planned)" rows ([`documents/SECURITY.md:98-99`](../documents/SECURITY.md:98)) 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`](../resources/nostr_core_lib/nostr_core/nip001.h:20) verifies it directly with no custom canonicalization code on the signer side:
```json
{
"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`](../resources/nostr_core_lib/nostr_core/utils.h:46).
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`](../resources/nostr_core_lib/nostr_core/nip001.h:19) handles this.
3. Verifies the Schnorr signature: [`nostr_verify_event_signature(auth_event)`](../resources/nostr_core_lib/nostr_core/nip001.h:20). 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:
```c
#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`](../src/server.c:404) currently has:
```c
typedef struct {
int kind;
char caller_id[64];
char source_qube[64];
} caller_identity_t;
```
Becomes:
```c
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:
```c
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`](../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`](../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`](../src/server.c) gains:
```c
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`](../src/policy.c:553) 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`](../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
```mermaid
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`](../src/policy.c:553).
6. **`--auth-skew-seconds` flag.** Plumbing.
7. **Documentation.**
- [`documents/SECURITY.md`](../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`](../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`](../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`](../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`](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`](nsigner.md): the future-work item "remote TCP mode only with mandatory TLS + authenticated caller key flow" ([`documents/FIPS_DEPLOYMENT.md:177`](../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`](../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`](../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`](../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`](../src/auth_envelope.c) and changing it is a one-line edit in both signer and client.
## Appendix C — Pseudocode: client signing one request
```c
/* 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.