v0.0.20 - Implement TCP auth envelope verification with signed caller pubkey identity, replay protection, tests, and deferred NIP-46 bunker plan

This commit is contained in:
Laan Tungir
2026-05-05 11:22:58 -04:00
parent 17a1b3f020
commit cc797a16df
27 changed files with 1364 additions and 47 deletions

View File

@@ -20,7 +20,8 @@ SOURCES := \
$(SRC_DIR)/server.c \ $(SRC_DIR)/server.c \
$(SRC_DIR)/transport_frame.c \ $(SRC_DIR)/transport_frame.c \
$(SRC_DIR)/key_store.c \ $(SRC_DIR)/key_store.c \
$(SRC_DIR)/socket_name.c $(SRC_DIR)/socket_name.c \
$(SRC_DIR)/auth_envelope.c
HEADERS := HEADERS :=
@@ -33,8 +34,9 @@ TEST_DISPATCHER_TARGET := $(BUILD_DIR)/test_dispatcher
TEST_POLICY_TARGET := $(BUILD_DIR)/test_policy TEST_POLICY_TARGET := $(BUILD_DIR)/test_policy
TEST_INTEGRATION_TARGET := $(BUILD_DIR)/test_integration TEST_INTEGRATION_TARGET := $(BUILD_DIR)/test_integration
TEST_SOCKET_NAME_TARGET := $(BUILD_DIR)/test_socket_name TEST_SOCKET_NAME_TARGET := $(BUILD_DIR)/test_socket_name
TEST_AUTH_ENVELOPE_TARGET := $(BUILD_DIR)/test_auth_envelope
.PHONY: all lib dev static static-debug static-arm64 test test-integration test-mnemonic test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name clean .PHONY: all lib dev static static-debug static-arm64 test test-integration test-mnemonic test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope clean
all: dev all: dev
@@ -59,7 +61,7 @@ static-arm64:
chmod +x ./build_static.sh chmod +x ./build_static.sh
./build_static.sh --arch arm64 ./build_static.sh --arch arm64
test: lib test-mnemonic test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test: lib test-mnemonic test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope
test-integration: $(TEST_INTEGRATION_TARGET) $(TARGET_DEV) test-integration: $(TEST_INTEGRATION_TARGET) $(TARGET_DEV)
./$(TEST_INTEGRATION_TARGET) ./$(TEST_INTEGRATION_TARGET)
@@ -85,6 +87,9 @@ test-policy: $(TEST_POLICY_TARGET)
test-socket-name: $(TEST_SOCKET_NAME_TARGET) test-socket-name: $(TEST_SOCKET_NAME_TARGET)
./$(TEST_SOCKET_NAME_TARGET) ./$(TEST_SOCKET_NAME_TARGET)
test-auth-envelope: $(TEST_AUTH_ENVELOPE_TARGET)
./$(TEST_AUTH_ENVELOPE_TARGET)
$(TEST_MNEMONIC_TARGET): $(TEST_DIR)/test_mnemonic.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c $(TEST_MNEMONIC_TARGET): $(TEST_DIR)/test_mnemonic.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(TEST_DIR)/test_mnemonic.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c -o $(TEST_MNEMONIC_TARGET) $(LDFLAGS) $(CC) $(CFLAGS) $(TEST_DIR)/test_mnemonic.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c -o $(TEST_MNEMONIC_TARGET) $(LDFLAGS)
@@ -117,5 +122,9 @@ $(TEST_SOCKET_NAME_TARGET): $(TEST_DIR)/test_socket_name.c $(SRC_DIR)/socket_nam
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(TEST_DIR)/test_socket_name.c $(SRC_DIR)/socket_name.c -o $(TEST_SOCKET_NAME_TARGET) $(LDFLAGS) $(CC) $(CFLAGS) $(TEST_DIR)/test_socket_name.c $(SRC_DIR)/socket_name.c -o $(TEST_SOCKET_NAME_TARGET) $(LDFLAGS)
$(TEST_AUTH_ENVELOPE_TARGET): $(TEST_DIR)/test_auth_envelope.c $(SRC_DIR)/auth_envelope.c
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(TEST_DIR)/test_auth_envelope.c $(SRC_DIR)/auth_envelope.c -o $(TEST_AUTH_ENVELOPE_TARGET) $(LDFLAGS)
clean: clean:
rm -rf $(BUILD_DIR) rm -rf $(BUILD_DIR)

View File

@@ -10,10 +10,11 @@ It is written for agent/tool authors implementing robust request flows against t
## 2. Discovery and socket targeting ## 2. Discovery and socket targeting
`nsigner` currently supports two transport families: `nsigner` currently supports three transport families:
- Linux AF_UNIX **abstract namespace** sockets. - Linux AF_UNIX **abstract namespace** sockets.
- Stdio framed mode (`--listen stdio` and `--listen qrexec`) for one request/response exchange. - Stdio framed mode (`--listen stdio` and `--listen qrexec`) for one request/response exchange.
- TCP framed mode (`--listen tcp:IPv4:PORT` or `--listen tcp:[IPv6]:PORT`).
For AF_UNIX: For AF_UNIX:
@@ -54,6 +55,14 @@ In server mode:
This mode is server-side only in the current CLI (the `client` subcommand still targets AF_UNIX). This mode is server-side only in the current CLI (the `client` subcommand still targets AF_UNIX).
### 2.4 TCP mode authentication (required)
For TCP transport, requests MUST include an `auth` object containing a signed Nostr-style event envelope.
- Missing `auth` returns `{"error":{"code":2014,"message":"auth_envelope_required"}}`.
- Signature verification, method/id/body binding, timestamp skew checks, and replay checks are enforced before policy lookup.
- On success, caller identity is normalized to `pubkey:<hex>` for policy checks.
--- ---
## 3. Transport framing ## 3. Transport framing
@@ -147,6 +156,14 @@ Representative error names clients must handle:
- `unauthorized` - `unauthorized`
- `approval_denied` - `approval_denied`
- `internal_error` - `internal_error`
- `auth_envelope_malformed` (2010)
- `auth_body_mismatch` (2011)
- `auth_signature_invalid` (2012)
- `auth_kind_invalid` (2013)
- `auth_envelope_required` (2014)
- `auth_envelope_mismatch` (2015)
- `auth_envelope_stale` (2016)
- `auth_replay_detected` (2017)
### 5.1 Recovery guidance ### 5.1 Recovery guidance
@@ -158,6 +175,7 @@ Representative error names clients must handle:
- `unauthorized`: caller identity disallowed by policy; do not blind-retry. - `unauthorized`: caller identity disallowed by policy; do not blind-retry.
- `approval_denied`: user rejected prompt; treat as final unless user initiates retry. - `approval_denied`: user rejected prompt; treat as final unless user initiates retry.
- `internal_error`: bounded retry with backoff; surface diagnostics. - `internal_error`: bounded retry with backoff; surface diagnostics.
- `auth_envelope_*` / `auth_*` (2010-2017): fix request signing/auth envelope generation; do not blind-retry unchanged payloads.
--- ---

View File

@@ -428,9 +428,10 @@ Different transports give different identity quality. The wire format (4-byte le
### 11.3 TCP (advanced / opt-in) ### 11.3 TCP (advanced / opt-in)
- Loopback addresses produce `tcp_local` identities. Remote addresses produce `tcp_remote` identities. - Loopback and remote TCP callers must present a valid signed auth envelope.
- Identity is **transport-asserted only**. The TCP peer address proves a route, not a principal. - Identity is normalized to caller `pubkey:<hex>` after signature verification; TCP peer address is context only.
- For TCP transports the **interactive approval prompt is the real authentication mechanism**. Do not enable TCP transports without a trustworthy local terminal. - The auth gate runs before policy: missing/invalid envelopes are rejected with `2010..2017` auth errors.
- Prompt-driven approval still applies after auth; auth answers "who is calling", policy answers "is this caller allowed".
### 11.4 Future transports ### 11.4 Future transports

View File

@@ -0,0 +1,483 @@
# 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.

View File

@@ -0,0 +1,98 @@
# Plan: Deferred NIP-46 bunker transport mode
Status: **deferred (design only, not in current implementation scope)**
This document records a future transport mode where `n_signer` can operate as a NIP-46-compatible bunker over relays, while keeping the current local/TCP framed JSON-RPC path intact.
Current implementation priority remains [`plans/caller_token_identity.md`](caller_token_identity.md): required per-request caller authentication for TCP/URL using signed auth envelopes and existing `n_signer` request flow.
---
## 1. Why this is deferred
NIP-46 adds more than caller authentication:
- Relay session lifecycle and routing
- NIP-44 encrypted request/response payloads
- `connect` ceremony and secret verification
- Signer service identity (`remote-signer-pubkey`) management
- Compatibility behavior for `nostrconnect://` and `bunker://`
That is a separate subsystem from the immediate bugfix (stable, authenticated caller identity over TCP reconnects). Combining both now would increase risk and delay rollout.
---
## 2. Future objective
Add an optional mode (example: `--listen nip46`) that accepts NIP-46 `kind:24133` requests and returns NIP-46 `kind:24133` responses as defined in [`resources/nips/46.md`](../resources/nips/46.md).
This mode should:
1. Authenticate client requests using NIP-46 client keypairs and encrypted content.
2. Map NIP-46 caller identity to the same deny-by-default approval model already used by current transports.
3. Reuse existing dispatcher/enforcement/policy machinery after transport decoding.
---
## 3. Compatibility stance (future)
- Keep current framed JSON-RPC transports (`AF_UNIX`, `qrexec`, `tcp`) as first-class for local deployments.
- Add NIP-46 as **additional** transport, not replacement.
- Shared internal core: once a request is decoded into `(caller_id, method, params, selector)`, the same policy and enforcement pipeline runs.
---
## 4. Planned phases
### Phase A — Transport shell
- Add `nip46` listen mode and relay I/O loop.
- Parse and validate incoming `kind:24133` envelope.
- Decrypt NIP-44 payload and extract JSON-RPC-like `method` + `params`.
### Phase B — Identity + approval binding
- Derive caller identity from NIP-46 `client-pubkey`.
- Represent as `caller_id = pubkey:<hex>` to align with the current TCP auth direction in [`plans/caller_token_identity.md`](caller_token_identity.md).
- Route to existing policy checks and prompt behavior.
### Phase C — Response path
- Convert dispatcher results into NIP-46 response payloads.
- Encrypt via NIP-44 and emit `kind:24133` response event.
- Preserve request `id` correlation and error mapping.
### Phase D — Connect / secret / perms polish
- Implement full `connect` handshake semantics from [`resources/nips/46.md`](../resources/nips/46.md:100).
- Validate optional secret behavior.
- Support requested permissions metadata and signer-side policy translation.
### Phase E — Operational hardening
- Relay switching behavior (`switch_relays`), lifecycle recovery.
- Rate limits and replay protections specific to relay transport.
- Audit/event logs for NIP-46 connections and requests.
---
## 5. Open design questions (for later)
1. Should NIP-46 mode require a dedicated signer identity key, or may it reuse the user signing identity?
2. How should `connect` permissions map to `n_signer` role/selector approvals?
3. Should NIP-46 callers and TCP-auth callers share the same `pubkey:<hex>` approval namespace by default?
4. Do we support both relay-initiated and direct-client initiation flows from day one?
5. What minimum relay trust assumptions are required in `documents/SECURITY.md`?
---
## 6. Out of scope for current code work
The current implementation task does **not** include:
- Adding relay networking
- NIP-44 tunnel encryption for requests
- NIP-46 `connect` handshake
- `nostrconnect://` URI flow
Those remain deferred to this plan.

338
src/auth_envelope.c Normal file
View File

@@ -0,0 +1,338 @@
#define _GNU_SOURCE
#include "auth_envelope.h"
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <cJSON.h>
#include <nostr_core/nip001.h>
#include <nostr_core/utils.h>
static int set_error(int *out_code,
const char **out_message,
int code,
const char *message) {
if (out_code != NULL) {
*out_code = code;
}
if (out_message != NULL) {
*out_message = message;
}
return -1;
}
void auth_nonce_cache_init(auth_nonce_cache_t *cache) {
if (cache == NULL) {
return;
}
memset(cache, 0, sizeof(*cache));
}
static int auth_nonce_cache_contains(const auth_nonce_cache_t *cache, const uint8_t id[32]) {
int i;
if (cache == NULL || id == NULL) {
return 0;
}
for (i = 0; i < cache->count; ++i) {
if (memcmp(cache->ids[i], id, 32) == 0) {
return 1;
}
}
return 0;
}
static void auth_nonce_cache_insert(auth_nonce_cache_t *cache, const uint8_t id[32]) {
if (cache == NULL || id == NULL) {
return;
}
if (cache->count < AUTH_NONCE_CACHE_SIZE) {
memcpy(cache->ids[cache->count], id, 32);
cache->count++;
return;
}
memcpy(cache->ids[cache->next], id, 32);
cache->next = (cache->next + 1) % AUTH_NONCE_CACHE_SIZE;
}
static int json_item_to_compact_string(const cJSON *item, char *out, size_t out_sz) {
char *printed;
if (out == NULL || out_sz == 0 || item == NULL) {
return -1;
}
if (cJSON_IsString(item) && item->valuestring != NULL) {
strncpy(out, item->valuestring, out_sz - 1);
out[out_sz - 1] = '\0';
return 0;
}
printed = cJSON_PrintUnformatted((cJSON *)item);
if (printed == NULL) {
return -1;
}
strncpy(out, printed, out_sz - 1);
out[out_sz - 1] = '\0';
free(printed);
return 0;
}
static int compute_params_hash_hex(cJSON *root, char *out_hex, size_t out_hex_sz) {
cJSON *params = cJSON_GetObjectItemCaseSensitive(root, "params");
unsigned char hash[32];
char *params_compact = NULL;
if (out_hex == NULL || out_hex_sz < 65) {
return -1;
}
if (params == NULL) {
params_compact = strdup("null");
} else {
params_compact = cJSON_PrintUnformatted(params);
}
if (params_compact == NULL) {
return -1;
}
if (nostr_sha256((const unsigned char *)params_compact, strlen(params_compact), hash) != 0) {
free(params_compact);
return -1;
}
nostr_bytes_to_hex(hash, sizeof(hash), out_hex);
out_hex[64] = '\0';
free(params_compact);
return 0;
}
static cJSON *find_tag_value(cJSON *tags, const char *name) {
int i;
if (!cJSON_IsArray(tags) || name == NULL) {
return NULL;
}
for (i = 0; i < cJSON_GetArraySize(tags); ++i) {
cJSON *tag = cJSON_GetArrayItem(tags, i);
cJSON *tag_name;
cJSON *tag_value;
if (!cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) {
continue;
}
tag_name = cJSON_GetArrayItem(tag, 0);
tag_value = cJSON_GetArrayItem(tag, 1);
if (!cJSON_IsString(tag_name) || tag_name->valuestring == NULL) {
continue;
}
if (strcmp(tag_name->valuestring, name) != 0) {
continue;
}
return tag_value;
}
return NULL;
}
int auth_envelope_verify_request(const char *request_json,
auth_nonce_cache_t *cache,
int skew_seconds,
char *out_pubkey_hex,
size_t out_pubkey_hex_sz,
char *out_label,
size_t out_label_sz,
int *out_error_code,
const char **out_error_message) {
cJSON *root = NULL;
cJSON *auth = NULL;
cJSON *method_item;
cJSON *id_item;
cJSON *kind_item;
cJSON *created_item;
cJSON *pubkey_item;
cJSON *content_item;
cJSON *id_hex_item;
cJSON *tags_item;
cJSON *tag_rpc;
cJSON *tag_method;
cJSON *tag_body_hash;
char request_id[128];
char body_hash_hex[65];
uint8_t nonce_bytes[32];
time_t now;
long long created;
if (out_error_code != NULL) {
*out_error_code = 0;
}
if (out_error_message != NULL) {
*out_error_message = NULL;
}
if (out_pubkey_hex == NULL || out_pubkey_hex_sz < 65 ||
out_label == NULL || out_label_sz == 0 ||
request_json == NULL || cache == NULL) {
return set_error(out_error_code, out_error_message,
AUTH_ERR_ENVELOPE_MALFORMED,
"auth_envelope_malformed");
}
out_pubkey_hex[0] = '\0';
out_label[0] = '\0';
root = cJSON_Parse(request_json);
if (root == NULL) {
return set_error(out_error_code, out_error_message,
AUTH_ERR_ENVELOPE_MALFORMED,
"auth_envelope_malformed");
}
method_item = cJSON_GetObjectItemCaseSensitive(root, "method");
id_item = cJSON_GetObjectItemCaseSensitive(root, "id");
if (!cJSON_IsString(method_item) || method_item->valuestring == NULL ||
json_item_to_compact_string(id_item, request_id, sizeof(request_id)) != 0) {
cJSON_Delete(root);
return set_error(out_error_code, out_error_message,
AUTH_ERR_ENVELOPE_MALFORMED,
"auth_envelope_malformed");
}
auth = cJSON_GetObjectItemCaseSensitive(root, "auth");
if (!cJSON_IsObject(auth)) {
cJSON_Delete(root);
return set_error(out_error_code, out_error_message,
AUTH_ERR_ENVELOPE_REQUIRED,
"auth_envelope_required");
}
if (nostr_validate_event_structure(auth) != 0) {
cJSON_Delete(root);
return set_error(out_error_code, out_error_message,
AUTH_ERR_ENVELOPE_MALFORMED,
"auth_envelope_malformed");
}
if (nostr_verify_event_signature(auth) != 0) {
cJSON_Delete(root);
return set_error(out_error_code, out_error_message,
AUTH_ERR_SIGNATURE_INVALID,
"auth_signature_invalid");
}
kind_item = cJSON_GetObjectItemCaseSensitive(auth, "kind");
if (!cJSON_IsNumber(kind_item) || kind_item->valueint != AUTH_EVENT_KIND) {
cJSON_Delete(root);
return set_error(out_error_code, out_error_message,
AUTH_ERR_KIND_INVALID,
"auth_kind_invalid");
}
tags_item = cJSON_GetObjectItemCaseSensitive(auth, "tags");
tag_rpc = find_tag_value(tags_item, "nsigner_rpc");
tag_method = find_tag_value(tags_item, "nsigner_method");
tag_body_hash = find_tag_value(tags_item, "nsigner_body_hash");
if (!cJSON_IsString(tag_rpc) || tag_rpc->valuestring == NULL ||
!cJSON_IsString(tag_method) || tag_method->valuestring == NULL ||
!cJSON_IsString(tag_body_hash) || tag_body_hash->valuestring == NULL) {
cJSON_Delete(root);
return set_error(out_error_code, out_error_message,
AUTH_ERR_ENVELOPE_MALFORMED,
"auth_envelope_malformed");
}
if (strcmp(tag_rpc->valuestring, request_id) != 0 ||
strcmp(tag_method->valuestring, method_item->valuestring) != 0) {
cJSON_Delete(root);
return set_error(out_error_code, out_error_message,
AUTH_ERR_ENVELOPE_MISMATCH,
"auth_envelope_mismatch");
}
if (compute_params_hash_hex(root, body_hash_hex, sizeof(body_hash_hex)) != 0 ||
strcasecmp(tag_body_hash->valuestring, body_hash_hex) != 0) {
cJSON_Delete(root);
return set_error(out_error_code, out_error_message,
AUTH_ERR_BODY_MISMATCH,
"auth_body_mismatch");
}
created_item = cJSON_GetObjectItemCaseSensitive(auth, "created_at");
if (!cJSON_IsNumber(created_item)) {
cJSON_Delete(root);
return set_error(out_error_code, out_error_message,
AUTH_ERR_ENVELOPE_MALFORMED,
"auth_envelope_malformed");
}
if (skew_seconds <= 0) {
skew_seconds = AUTH_DEFAULT_SKEW_SECONDS;
}
now = time(NULL);
created = (long long)created_item->valuedouble;
if (llabs((long long)now - created) > (long long)skew_seconds) {
cJSON_Delete(root);
return set_error(out_error_code, out_error_message,
AUTH_ERR_ENVELOPE_STALE,
"auth_envelope_stale");
}
id_hex_item = cJSON_GetObjectItemCaseSensitive(auth, "id");
if (!cJSON_IsString(id_hex_item) || id_hex_item->valuestring == NULL ||
strlen(id_hex_item->valuestring) != 64 ||
nostr_hex_to_bytes(id_hex_item->valuestring, nonce_bytes, sizeof(nonce_bytes)) != 0) {
cJSON_Delete(root);
return set_error(out_error_code, out_error_message,
AUTH_ERR_ENVELOPE_MALFORMED,
"auth_envelope_malformed");
}
if (auth_nonce_cache_contains(cache, nonce_bytes)) {
cJSON_Delete(root);
return set_error(out_error_code, out_error_message,
AUTH_ERR_REPLAY_DETECTED,
"auth_replay_detected");
}
auth_nonce_cache_insert(cache, nonce_bytes);
pubkey_item = cJSON_GetObjectItemCaseSensitive(auth, "pubkey");
if (!cJSON_IsString(pubkey_item) || pubkey_item->valuestring == NULL ||
strlen(pubkey_item->valuestring) != 64) {
cJSON_Delete(root);
return set_error(out_error_code, out_error_message,
AUTH_ERR_ENVELOPE_MALFORMED,
"auth_envelope_malformed");
}
strncpy(out_pubkey_hex, pubkey_item->valuestring, out_pubkey_hex_sz - 1);
out_pubkey_hex[out_pubkey_hex_sz - 1] = '\0';
content_item = cJSON_GetObjectItemCaseSensitive(auth, "content");
if (cJSON_IsString(content_item) && content_item->valuestring != NULL) {
size_t i;
for (i = 0; content_item->valuestring[i] != '\0' && i + 1 < out_label_sz; ++i) {
unsigned char ch = (unsigned char)content_item->valuestring[i];
out_label[i] = (char)((isprint(ch) && ch != '\n' && ch != '\r' && ch != '\t') ? ch : ' ');
}
out_label[i] = '\0';
} else {
out_label[0] = '\0';
}
cJSON_Delete(root);
return 0;
}

38
src/auth_envelope.h Normal file
View File

@@ -0,0 +1,38 @@
#ifndef NSIGNER_AUTH_ENVELOPE_H
#define NSIGNER_AUTH_ENVELOPE_H
#include <stddef.h>
#include <stdint.h>
#define AUTH_NONCE_CACHE_SIZE 1024
#define AUTH_DEFAULT_SKEW_SECONDS 30
#define AUTH_EVENT_KIND 27235
#define AUTH_ERR_ENVELOPE_MALFORMED 2010
#define AUTH_ERR_BODY_MISMATCH 2011
#define AUTH_ERR_SIGNATURE_INVALID 2012
#define AUTH_ERR_KIND_INVALID 2013
#define AUTH_ERR_ENVELOPE_REQUIRED 2014
#define AUTH_ERR_ENVELOPE_MISMATCH 2015
#define AUTH_ERR_ENVELOPE_STALE 2016
#define AUTH_ERR_REPLAY_DETECTED 2017
typedef struct {
uint8_t ids[AUTH_NONCE_CACHE_SIZE][32];
int count;
int next;
} auth_nonce_cache_t;
void auth_nonce_cache_init(auth_nonce_cache_t *cache);
int auth_envelope_verify_request(const char *request_json,
auth_nonce_cache_t *cache,
int skew_seconds,
char *out_pubkey_hex,
size_t out_pubkey_hex_sz,
char *out_label,
size_t out_label_sz,
int *out_error_code,
const char **out_error_message);
#endif

View File

@@ -229,7 +229,7 @@ const char *enforce_strerror(int err);
#define POLICY_MAX_ROLES 16 #define POLICY_MAX_ROLES 16
#define POLICY_MAX_PURPOSES 8 #define POLICY_MAX_PURPOSES 8
#define POLICY_VERB_MAX_LEN 32 #define POLICY_VERB_MAX_LEN 32
#define POLICY_CALLER_MAX_LEN 64 #define POLICY_CALLER_MAX_LEN 80
/* Prompt behavior */ /* Prompt behavior */
typedef enum { typedef enum {
@@ -396,7 +396,7 @@ typedef struct {
uid_t uid; uid_t uid;
gid_t gid; gid_t gid;
pid_t pid; pid_t pid;
char caller_id[64]; /* "uid:<n>" */ char caller_id[80]; /* "uid:<n>" */
} caller_identity_t; } caller_identity_t;
/* Server context */ /* Server context */

View File

@@ -229,7 +229,7 @@ const char *enforce_strerror(int err);
#define POLICY_MAX_ROLES 16 #define POLICY_MAX_ROLES 16
#define POLICY_MAX_PURPOSES 8 #define POLICY_MAX_PURPOSES 8
#define POLICY_VERB_MAX_LEN 32 #define POLICY_VERB_MAX_LEN 32
#define POLICY_CALLER_MAX_LEN 64 #define POLICY_CALLER_MAX_LEN 80
/* Prompt behavior */ /* Prompt behavior */
typedef enum { typedef enum {
@@ -384,7 +384,7 @@ typedef struct {
uid_t uid; uid_t uid;
gid_t gid; gid_t gid;
pid_t pid; pid_t pid;
char caller_id[64]; /* "uid:<n>" */ char caller_id[80]; /* "uid:<n>" */
} caller_identity_t; } caller_identity_t;
/* Server context */ /* Server context */

View File

@@ -231,7 +231,7 @@ const char *enforce_strerror(int err);
#define POLICY_MAX_ROLES 16 #define POLICY_MAX_ROLES 16
#define POLICY_MAX_PURPOSES 8 #define POLICY_MAX_PURPOSES 8
#define POLICY_VERB_MAX_LEN 32 #define POLICY_VERB_MAX_LEN 32
#define POLICY_CALLER_MAX_LEN 64 #define POLICY_CALLER_MAX_LEN 80
/* Prompt behavior */ /* Prompt behavior */
typedef enum { typedef enum {
@@ -389,7 +389,7 @@ typedef struct {
uid_t uid; uid_t uid;
gid_t gid; gid_t gid;
pid_t pid; pid_t pid;
char caller_id[64]; /* "uid:<n>" */ char caller_id[80]; /* "uid:<n>" */
} caller_identity_t; } caller_identity_t;
/* Server context */ /* Server context */

View File

@@ -234,7 +234,7 @@ const char *enforce_strerror(int err);
#define POLICY_MAX_ROLES 16 #define POLICY_MAX_ROLES 16
#define POLICY_MAX_PURPOSES 8 #define POLICY_MAX_PURPOSES 8
#define POLICY_VERB_MAX_LEN 32 #define POLICY_VERB_MAX_LEN 32
#define POLICY_CALLER_MAX_LEN 64 #define POLICY_CALLER_MAX_LEN 80
/* Prompt behavior */ /* Prompt behavior */
typedef enum { typedef enum {
@@ -401,7 +401,7 @@ typedef struct {
gid_t gid; gid_t gid;
pid_t pid; pid_t pid;
int kind; int kind;
char caller_id[64]; /* "uid:<n>" or "qubes:<vm>" */ char caller_id[80]; /* "uid:<n>" or "qubes:<vm>" */
char source_qube[64]; char source_qube[64];
} caller_identity_t; } caller_identity_t;
@@ -468,8 +468,8 @@ int socket_name_random(char *out, size_t out_len);
/* Version information (auto-updated by build/version tooling) */ /* Version information (auto-updated by build/version tooling) */
#define NSIGNER_VERSION_MAJOR 0 #define NSIGNER_VERSION_MAJOR 0
#define NSIGNER_VERSION_MINOR 0 #define NSIGNER_VERSION_MINOR 0
#define NSIGNER_VERSION_PATCH 19 #define NSIGNER_VERSION_PATCH 20
#define NSIGNER_VERSION "v0.0.19" #define NSIGNER_VERSION "v0.0.20"
/* NSIGNER_HEADERLESS_DECLS_END */ /* NSIGNER_HEADERLESS_DECLS_END */

View File

@@ -229,7 +229,7 @@ const char *enforce_strerror(int err);
#define POLICY_MAX_ROLES 16 #define POLICY_MAX_ROLES 16
#define POLICY_MAX_PURPOSES 8 #define POLICY_MAX_PURPOSES 8
#define POLICY_VERB_MAX_LEN 32 #define POLICY_VERB_MAX_LEN 32
#define POLICY_CALLER_MAX_LEN 64 #define POLICY_CALLER_MAX_LEN 80
/* Prompt behavior */ /* Prompt behavior */
typedef enum { typedef enum {
@@ -384,7 +384,7 @@ typedef struct {
uid_t uid; uid_t uid;
gid_t gid; gid_t gid;
pid_t pid; pid_t pid;
char caller_id[64]; /* "uid:<n>" */ char caller_id[80]; /* "uid:<n>" */
} caller_identity_t; } caller_identity_t;
/* Server context */ /* Server context */

View File

@@ -229,7 +229,7 @@ const char *enforce_strerror(int err);
#define POLICY_MAX_ROLES 16 #define POLICY_MAX_ROLES 16
#define POLICY_MAX_PURPOSES 8 #define POLICY_MAX_PURPOSES 8
#define POLICY_VERB_MAX_LEN 32 #define POLICY_VERB_MAX_LEN 32
#define POLICY_CALLER_MAX_LEN 64 #define POLICY_CALLER_MAX_LEN 80
/* Prompt behavior */ /* Prompt behavior */
typedef enum { typedef enum {
@@ -384,7 +384,7 @@ typedef struct {
uid_t uid; uid_t uid;
gid_t gid; gid_t gid;
pid_t pid; pid_t pid;
char caller_id[64]; /* "uid:<n>" */ char caller_id[80]; /* "uid:<n>" */
} caller_identity_t; } caller_identity_t;
/* Server context */ /* Server context */

View File

@@ -232,7 +232,7 @@ const char *enforce_strerror(int err);
#define POLICY_MAX_ROLES 16 #define POLICY_MAX_ROLES 16
#define POLICY_MAX_PURPOSES 8 #define POLICY_MAX_PURPOSES 8
#define POLICY_VERB_MAX_LEN 32 #define POLICY_VERB_MAX_LEN 32
#define POLICY_CALLER_MAX_LEN 64 #define POLICY_CALLER_MAX_LEN 80
/* Prompt behavior */ /* Prompt behavior */
typedef enum { typedef enum {
@@ -387,7 +387,7 @@ typedef struct {
uid_t uid; uid_t uid;
gid_t gid; gid_t gid;
pid_t pid; pid_t pid;
char caller_id[64]; /* "uid:<n>" */ char caller_id[80]; /* "uid:<n>" */
} caller_identity_t; } caller_identity_t;
/* Server context */ /* Server context */

View File

@@ -231,7 +231,7 @@ const char *enforce_strerror(int err);
#define POLICY_MAX_ROLES 16 #define POLICY_MAX_ROLES 16
#define POLICY_MAX_PURPOSES 8 #define POLICY_MAX_PURPOSES 8
#define POLICY_VERB_MAX_LEN 32 #define POLICY_VERB_MAX_LEN 32
#define POLICY_CALLER_MAX_LEN 64 #define POLICY_CALLER_MAX_LEN 80
/* Prompt behavior */ /* Prompt behavior */
typedef enum { typedef enum {
@@ -386,7 +386,7 @@ typedef struct {
uid_t uid; uid_t uid;
gid_t gid; gid_t gid;
pid_t pid; pid_t pid;
char caller_id[64]; /* "uid:<n>" */ char caller_id[80]; /* "uid:<n>" */
} caller_identity_t; } caller_identity_t;
/* Server context */ /* Server context */

View File

@@ -229,7 +229,7 @@ const char *enforce_strerror(int err);
#define POLICY_MAX_ROLES 16 #define POLICY_MAX_ROLES 16
#define POLICY_MAX_PURPOSES 8 #define POLICY_MAX_PURPOSES 8
#define POLICY_VERB_MAX_LEN 32 #define POLICY_VERB_MAX_LEN 32
#define POLICY_CALLER_MAX_LEN 64 #define POLICY_CALLER_MAX_LEN 80
/* Prompt behavior */ /* Prompt behavior */
typedef enum { typedef enum {
@@ -384,7 +384,7 @@ typedef struct {
uid_t uid; uid_t uid;
gid_t gid; gid_t gid;
pid_t pid; pid_t pid;
char caller_id[64]; /* "uid:<n>" */ char caller_id[80]; /* "uid:<n>" */
} caller_identity_t; } caller_identity_t;
/* Server context */ /* Server context */

View File

@@ -234,7 +234,7 @@ const char *enforce_strerror(int err);
#define POLICY_MAX_ROLES 16 #define POLICY_MAX_ROLES 16
#define POLICY_MAX_PURPOSES 8 #define POLICY_MAX_PURPOSES 8
#define POLICY_VERB_MAX_LEN 32 #define POLICY_VERB_MAX_LEN 32
#define POLICY_CALLER_MAX_LEN 64 #define POLICY_CALLER_MAX_LEN 80
/* Prompt behavior */ /* Prompt behavior */
typedef enum { typedef enum {
@@ -403,8 +403,11 @@ typedef struct {
gid_t gid; gid_t gid;
pid_t pid; pid_t pid;
int kind; int kind;
char caller_id[64]; /* "uid:<n>" or "qubes:<vm>" */ char caller_id[80]; /* "uid:<n>", "qubes:<vm>", or "pubkey:<hex>" */
char source_qube[64]; char source_qube[64];
int auth_present;
char auth_pubkey_hex[65];
char auth_label[64];
} caller_identity_t; } caller_identity_t;
/* Server context */ /* Server context */
@@ -474,6 +477,8 @@ int socket_name_random(char *out, size_t out_len);
int transport_send_framed(int fd, const char *payload); int transport_send_framed(int fd, const char *payload);
int transport_recv_framed(int fd, char **out_payload, size_t max_size); int transport_recv_framed(int fd, char **out_payload, size_t max_size);
#include "auth_envelope.h"
#include <arpa/inet.h> #include <arpa/inet.h>
#include <ctype.h> #include <ctype.h>
#include <errno.h> #include <errno.h>
@@ -487,6 +492,8 @@ int transport_recv_framed(int fd, char **out_payload, size_t max_size);
static int g_prompt_always_allow = 0; static int g_prompt_always_allow = 0;
static int g_noninteractive_prompt_default = -1; static int g_noninteractive_prompt_default = -1;
static auth_nonce_cache_t g_auth_nonce_cache;
static int g_auth_nonce_cache_inited = 0;
static int caller_id_extract_ipv6(const char *caller_id, char *out_ipv6, size_t out_sz) { static int caller_id_extract_ipv6(const char *caller_id, char *out_ipv6, size_t out_sz) {
const char *start; const char *start;
@@ -825,6 +832,45 @@ static void json_copy_string(char *dst, size_t dst_sz, const char *src, const ch
dst[dst_sz - 1] = '\0'; dst[dst_sz - 1] = '\0';
} }
static int extract_request_id_compact(const char *json, char *out_id, size_t out_id_sz) {
cJSON *root;
cJSON *id_item;
char *printed = NULL;
if (json == NULL || out_id == NULL || out_id_sz == 0) {
return -1;
}
out_id[0] = '\0';
root = cJSON_Parse(json);
if (root == NULL) {
return -1;
}
id_item = cJSON_GetObjectItemCaseSensitive(root, "id");
if (id_item == NULL) {
cJSON_Delete(root);
return -1;
}
if (cJSON_IsString(id_item) && id_item->valuestring != NULL) {
json_copy_string(out_id, out_id_sz, id_item->valuestring, "null");
cJSON_Delete(root);
return 0;
}
printed = cJSON_PrintUnformatted(id_item);
if (printed != NULL) {
json_copy_string(out_id, out_id_sz, printed, "null");
free(printed);
cJSON_Delete(root);
return 0;
}
cJSON_Delete(root);
return -1;
}
static int extract_method_and_selector(const char *json, static int extract_method_and_selector(const char *json,
char *method, char *method,
size_t method_sz, size_t method_sz,
@@ -905,6 +951,10 @@ void server_init(server_ctx_t *ctx, const char *socket_name, int socket_name_exp
ctx->dispatcher = dispatcher; ctx->dispatcher = dispatcher;
ctx->policy = policy; ctx->policy = policy;
ctx->socket_name_explicit = socket_name_explicit ? 1 : 0; ctx->socket_name_explicit = socket_name_explicit ? 1 : 0;
if (!g_auth_nonce_cache_inited) {
auth_nonce_cache_init(&g_auth_nonce_cache);
g_auth_nonce_cache_inited = 1;
}
} }
int server_start(server_ctx_t *ctx) { int server_start(server_ctx_t *ctx) {
@@ -1195,6 +1245,9 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
char *request = NULL; char *request = NULL;
char *response = NULL; char *response = NULL;
char method[64]; char method[64];
char request_id[128];
char auth_pubkey[65];
char auth_label[64];
char role_name[ROLE_NAME_MAX]; char role_name[ROLE_NAME_MAX];
char purpose[ROLE_PURPOSE_MAX]; char purpose[ROLE_PURPOSE_MAX];
selector_request_t selector_req; selector_request_t selector_req;
@@ -1208,6 +1261,8 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
char activity[256]; char activity[256];
const char *verdict = "DENIED"; const char *verdict = "DENIED";
const char *source_label = "no-match"; const char *source_label = "no-match";
int auth_err_code = 0;
const char *auth_err_msg = NULL;
if (ctx == NULL || ctx->listen_fd < 0 || ctx->dispatcher == NULL || ctx->policy == NULL) { if (ctx == NULL || ctx->listen_fd < 0 || ctx->dispatcher == NULL || ctx->policy == NULL) {
return -1; return -1;
@@ -1251,6 +1306,56 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
json_copy_string(method, sizeof(method), "unknown", "unknown"); json_copy_string(method, sizeof(method), "unknown", "unknown");
json_copy_string(role_name, sizeof(role_name), "unknown", "unknown"); json_copy_string(role_name, sizeof(role_name), "unknown", "unknown");
json_copy_string(purpose, sizeof(purpose), "unknown", "unknown"); json_copy_string(purpose, sizeof(purpose), "unknown", "unknown");
json_copy_string(request_id, sizeof(request_id), "null", "null");
auth_pubkey[0] = '\0';
auth_label[0] = '\0';
if (caller.kind == NSIGNER_LISTEN_TCP) {
if (auth_envelope_verify_request(request,
&g_auth_nonce_cache,
AUTH_DEFAULT_SKEW_SECONDS,
auth_pubkey,
sizeof(auth_pubkey),
auth_label,
sizeof(auth_label),
&auth_err_code,
&auth_err_msg) != 0) {
(void)extract_request_id_compact(request, request_id, sizeof(request_id));
if (auth_err_msg == NULL) {
auth_err_msg = "auth_envelope_malformed";
}
{
char errbuf[256];
(void)snprintf(errbuf,
sizeof(errbuf),
"{\"id\":\"%s\",\"error\":{\"code\":%d,\"message\":\"%s\"}}",
request_id,
(auth_err_code != 0) ? auth_err_code : AUTH_ERR_ENVELOPE_MALFORMED,
auth_err_msg);
response = strdup(errbuf);
}
if (response != NULL) {
(void)transport_send_framed((client_fd == STDIN_FILENO) ? STDOUT_FILENO : client_fd, response);
free(response);
}
free(request);
if (client_fd != STDIN_FILENO) {
close(client_fd);
}
return 1;
}
caller.auth_present = 1;
json_copy_string(caller.auth_pubkey_hex,
sizeof(caller.auth_pubkey_hex),
auth_pubkey,
"");
json_copy_string(caller.auth_label,
sizeof(caller.auth_label),
auth_label,
"");
(void)snprintf(caller.caller_id, sizeof(caller.caller_id), "pubkey:%s", auth_pubkey);
}
if (extract_method_and_selector(request, method, sizeof(method), &selector_req) == 0) { if (extract_method_and_selector(request, method, sizeof(method), &selector_req) == 0) {
if (ctx->dispatcher->role_table != NULL) { if (ctx->dispatcher->role_table != NULL) {

View File

@@ -231,7 +231,7 @@ const char *enforce_strerror(int err);
#define POLICY_MAX_ROLES 16 #define POLICY_MAX_ROLES 16
#define POLICY_MAX_PURPOSES 8 #define POLICY_MAX_PURPOSES 8
#define POLICY_VERB_MAX_LEN 32 #define POLICY_VERB_MAX_LEN 32
#define POLICY_CALLER_MAX_LEN 64 #define POLICY_CALLER_MAX_LEN 80
/* Prompt behavior */ /* Prompt behavior */
typedef enum { typedef enum {
@@ -386,7 +386,7 @@ typedef struct {
uid_t uid; uid_t uid;
gid_t gid; gid_t gid;
pid_t pid; pid_t pid;
char caller_id[64]; /* "uid:<n>" */ char caller_id[80]; /* "uid:<n>" */
} caller_identity_t; } caller_identity_t;
/* Server context */ /* Server context */

227
tests/test_auth_envelope.c Normal file
View File

@@ -0,0 +1,227 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <cJSON.h>
#include <nostr_core/nip001.h>
#include <nostr_core/utils.h>
#include "../src/auth_envelope.h"
static int g_failures = 0;
static void check_condition(const char *name, int ok) {
if (ok) {
printf("[PASS] %s\n", name);
} else {
printf("[FAIL] %s\n", name);
g_failures++;
}
}
static int compute_params_hash_hex(cJSON *params, char out_hex[65]) {
char *compact;
unsigned char hash[32];
if (params == NULL || out_hex == NULL) {
return -1;
}
compact = cJSON_PrintUnformatted(params);
if (compact == NULL) {
return -1;
}
if (nostr_sha256((const unsigned char *)compact, strlen(compact), hash) != 0) {
free(compact);
return -1;
}
nostr_bytes_to_hex(hash, 32, out_hex);
out_hex[64] = '\0';
free(compact);
return 0;
}
static cJSON *tag_pair(const char *k, const char *v) {
cJSON *arr = cJSON_CreateArray();
cJSON_AddItemToArray(arr, cJSON_CreateString(k));
cJSON_AddItemToArray(arr, cJSON_CreateString(v));
return arr;
}
static char *make_request_json(const unsigned char privkey[32],
const char *req_id,
const char *method,
int created_at) {
cJSON *params = cJSON_CreateArray();
cJSON *options = cJSON_CreateObject();
cJSON *tags = cJSON_CreateArray();
cJSON *auth;
cJSON *root;
char body_hash[65];
char *out;
cJSON_AddItemToArray(params, cJSON_CreateString("{\"kind\":1,\"content\":\"x\",\"tags\":[],\"created_at\":1700000000}"));
cJSON_AddStringToObject(options, "role", "main");
cJSON_AddItemToArray(params, options);
if (compute_params_hash_hex(params, body_hash) != 0) {
cJSON_Delete(params);
return NULL;
}
cJSON_AddItemToArray(tags, tag_pair("nsigner_rpc", req_id));
cJSON_AddItemToArray(tags, tag_pair("nsigner_method", method));
cJSON_AddItemToArray(tags, tag_pair("nsigner_body_hash", body_hash));
auth = nostr_create_and_sign_event(AUTH_EVENT_KIND, "test-client", tags, privkey, (time_t)created_at);
if (auth == NULL) {
cJSON_Delete(params);
return NULL;
}
root = cJSON_CreateObject();
cJSON_AddStringToObject(root, "id", req_id);
cJSON_AddStringToObject(root, "method", method);
cJSON_AddItemToObject(root, "params", params);
cJSON_AddItemToObject(root, "auth", auth);
out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return out;
}
int main(void) {
auth_nonce_cache_t cache;
unsigned char privkey[32] = {
0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
};
char pubkey_hex[65];
char label[64];
const char *err_msg = NULL;
int err_code = 0;
char *req;
check_condition("nostr_crypto_init", nostr_crypto_init() == 0);
auth_nonce_cache_init(&cache);
req = make_request_json(privkey, "req-1", "sign_event", (int)time(NULL));
check_condition("build valid request", req != NULL);
if (req != NULL) {
check_condition("valid auth envelope accepted",
auth_envelope_verify_request(req,
&cache,
AUTH_DEFAULT_SKEW_SECONDS,
pubkey_hex,
sizeof(pubkey_hex),
label,
sizeof(label),
&err_code,
&err_msg) == 0);
check_condition("pubkey output length 64", strlen(pubkey_hex) == 64);
free(req);
}
check_condition("missing auth rejected with required code",
auth_envelope_verify_request("{\"id\":\"x\",\"method\":\"get_public_key\",\"params\":[]}",
&cache,
AUTH_DEFAULT_SKEW_SECONDS,
pubkey_hex,
sizeof(pubkey_hex),
label,
sizeof(label),
&err_code,
&err_msg) != 0 &&
err_code == AUTH_ERR_ENVELOPE_REQUIRED);
req = make_request_json(privkey, "req-2", "get_public_key", (int)time(NULL));
check_condition("build second request", req != NULL);
if (req != NULL) {
cJSON *root = cJSON_Parse(req);
cJSON *method_item;
char *tampered;
check_condition("parse second request", root != NULL);
method_item = (root != NULL) ? cJSON_GetObjectItemCaseSensitive(root, "method") : NULL;
if (method_item != NULL) {
cJSON_SetValuestring(method_item, "sign_event");
}
tampered = (root != NULL) ? cJSON_PrintUnformatted(root) : NULL;
cJSON_Delete(root);
check_condition("tamper method mismatch rejected",
tampered != NULL &&
auth_envelope_verify_request(tampered,
&cache,
AUTH_DEFAULT_SKEW_SECONDS,
pubkey_hex,
sizeof(pubkey_hex),
label,
sizeof(label),
&err_code,
&err_msg) != 0 &&
err_code == AUTH_ERR_ENVELOPE_MISMATCH);
free(tampered);
free(req);
}
req = make_request_json(privkey, "req-3", "sign_event", (int)time(NULL));
check_condition("build replay request", req != NULL);
if (req != NULL) {
int first_ok = auth_envelope_verify_request(req,
&cache,
AUTH_DEFAULT_SKEW_SECONDS,
pubkey_hex,
sizeof(pubkey_hex),
label,
sizeof(label),
&err_code,
&err_msg);
int second_ok = auth_envelope_verify_request(req,
&cache,
AUTH_DEFAULT_SKEW_SECONDS,
pubkey_hex,
sizeof(pubkey_hex),
label,
sizeof(label),
&err_code,
&err_msg);
check_condition("first replay request accepted", first_ok == 0);
check_condition("second replay request rejected", second_ok != 0 && err_code == AUTH_ERR_REPLAY_DETECTED);
free(req);
}
req = make_request_json(privkey, "req-4", "sign_event", (int)time(NULL) - 1000);
check_condition("build stale request", req != NULL);
if (req != NULL) {
check_condition("stale request rejected",
auth_envelope_verify_request(req,
&cache,
AUTH_DEFAULT_SKEW_SECONDS,
pubkey_hex,
sizeof(pubkey_hex),
label,
sizeof(label),
&err_code,
&err_msg) != 0 &&
err_code == AUTH_ERR_ENVELOPE_STALE);
free(req);
}
nostr_crypto_cleanup();
if (g_failures == 0) {
printf("ALL TESTS PASSED\n");
return 0;
}
printf("TESTS FAILED: %d\n", g_failures);
return 1;
}

View File

@@ -232,7 +232,7 @@ const char *enforce_strerror(int err);
#define POLICY_MAX_ROLES 16 #define POLICY_MAX_ROLES 16
#define POLICY_MAX_PURPOSES 8 #define POLICY_MAX_PURPOSES 8
#define POLICY_VERB_MAX_LEN 32 #define POLICY_VERB_MAX_LEN 32
#define POLICY_CALLER_MAX_LEN 64 #define POLICY_CALLER_MAX_LEN 80
/* Prompt behavior */ /* Prompt behavior */
typedef enum { typedef enum {
@@ -390,7 +390,7 @@ typedef struct {
uid_t uid; uid_t uid;
gid_t gid; gid_t gid;
pid_t pid; pid_t pid;
char caller_id[64]; /* "uid:<n>" */ char caller_id[80]; /* "uid:<n>" */
} caller_identity_t; } caller_identity_t;
/* Server context */ /* Server context */

View File

@@ -229,7 +229,7 @@ const char *enforce_strerror(int err);
#define POLICY_MAX_ROLES 16 #define POLICY_MAX_ROLES 16
#define POLICY_MAX_PURPOSES 8 #define POLICY_MAX_PURPOSES 8
#define POLICY_VERB_MAX_LEN 32 #define POLICY_VERB_MAX_LEN 32
#define POLICY_CALLER_MAX_LEN 64 #define POLICY_CALLER_MAX_LEN 80
/* Prompt behavior */ /* Prompt behavior */
typedef enum { typedef enum {
@@ -384,7 +384,7 @@ typedef struct {
uid_t uid; uid_t uid;
gid_t gid; gid_t gid;
pid_t pid; pid_t pid;
char caller_id[64]; /* "uid:<n>" */ char caller_id[80]; /* "uid:<n>" */
} caller_identity_t; } caller_identity_t;
/* Server context */ /* Server context */

View File

@@ -231,7 +231,7 @@ const char *enforce_strerror(int err);
#define POLICY_MAX_ROLES 16 #define POLICY_MAX_ROLES 16
#define POLICY_MAX_PURPOSES 8 #define POLICY_MAX_PURPOSES 8
#define POLICY_VERB_MAX_LEN 32 #define POLICY_VERB_MAX_LEN 32
#define POLICY_CALLER_MAX_LEN 64 #define POLICY_CALLER_MAX_LEN 80
/* Prompt behavior */ /* Prompt behavior */
typedef enum { typedef enum {
@@ -386,7 +386,7 @@ typedef struct {
uid_t uid; uid_t uid;
gid_t gid; gid_t gid;
pid_t pid; pid_t pid;
char caller_id[64]; /* "uid:<n>" */ char caller_id[80]; /* "uid:<n>" */
} caller_identity_t; } caller_identity_t;
/* Server context */ /* Server context */

View File

@@ -229,7 +229,7 @@ const char *enforce_strerror(int err);
#define POLICY_MAX_ROLES 16 #define POLICY_MAX_ROLES 16
#define POLICY_MAX_PURPOSES 8 #define POLICY_MAX_PURPOSES 8
#define POLICY_VERB_MAX_LEN 32 #define POLICY_VERB_MAX_LEN 32
#define POLICY_CALLER_MAX_LEN 64 #define POLICY_CALLER_MAX_LEN 80
/* Prompt behavior */ /* Prompt behavior */
typedef enum { typedef enum {
@@ -384,7 +384,7 @@ typedef struct {
uid_t uid; uid_t uid;
gid_t gid; gid_t gid;
pid_t pid; pid_t pid;
char caller_id[64]; /* "uid:<n>" */ char caller_id[80]; /* "uid:<n>" */
} caller_identity_t; } caller_identity_t;
/* Server context */ /* Server context */

View File

@@ -229,7 +229,7 @@ const char *enforce_strerror(int err);
#define POLICY_MAX_ROLES 16 #define POLICY_MAX_ROLES 16
#define POLICY_MAX_PURPOSES 8 #define POLICY_MAX_PURPOSES 8
#define POLICY_VERB_MAX_LEN 32 #define POLICY_VERB_MAX_LEN 32
#define POLICY_CALLER_MAX_LEN 64 #define POLICY_CALLER_MAX_LEN 80
/* Prompt behavior */ /* Prompt behavior */
typedef enum { typedef enum {
@@ -390,7 +390,7 @@ typedef struct {
uid_t uid; uid_t uid;
gid_t gid; gid_t gid;
pid_t pid; pid_t pid;
char caller_id[64]; /* "uid:<n>" */ char caller_id[80]; /* "uid:<n>" */
} caller_identity_t; } caller_identity_t;
/* Server context */ /* Server context */

View File

@@ -232,7 +232,7 @@ const char *enforce_strerror(int err);
#define POLICY_MAX_ROLES 16 #define POLICY_MAX_ROLES 16
#define POLICY_MAX_PURPOSES 8 #define POLICY_MAX_PURPOSES 8
#define POLICY_VERB_MAX_LEN 32 #define POLICY_VERB_MAX_LEN 32
#define POLICY_CALLER_MAX_LEN 64 #define POLICY_CALLER_MAX_LEN 80
/* Prompt behavior */ /* Prompt behavior */
typedef enum { typedef enum {
@@ -387,7 +387,7 @@ typedef struct {
uid_t uid; uid_t uid;
gid_t gid; gid_t gid;
pid_t pid; pid_t pid;
char caller_id[64]; /* "uid:<n>" */ char caller_id[80]; /* "uid:<n>" */
} caller_identity_t; } caller_identity_t;
/* Server context */ /* Server context */

View File

@@ -229,7 +229,7 @@ const char *enforce_strerror(int err);
#define POLICY_MAX_ROLES 16 #define POLICY_MAX_ROLES 16
#define POLICY_MAX_PURPOSES 8 #define POLICY_MAX_PURPOSES 8
#define POLICY_VERB_MAX_LEN 32 #define POLICY_VERB_MAX_LEN 32
#define POLICY_CALLER_MAX_LEN 64 #define POLICY_CALLER_MAX_LEN 80
/* Prompt behavior */ /* Prompt behavior */
typedef enum { typedef enum {
@@ -384,7 +384,7 @@ typedef struct {
uid_t uid; uid_t uid;
gid_t gid; gid_t gid;
pid_t pid; pid_t pid;
char caller_id[64]; /* "uid:<n>" */ char caller_id[80]; /* "uid:<n>" */
} caller_identity_t; } caller_identity_t;
/* Server context */ /* Server context */

View File

@@ -229,7 +229,7 @@ const char *enforce_strerror(int err);
#define POLICY_MAX_ROLES 16 #define POLICY_MAX_ROLES 16
#define POLICY_MAX_PURPOSES 8 #define POLICY_MAX_PURPOSES 8
#define POLICY_VERB_MAX_LEN 32 #define POLICY_VERB_MAX_LEN 32
#define POLICY_CALLER_MAX_LEN 64 #define POLICY_CALLER_MAX_LEN 80
/* Prompt behavior */ /* Prompt behavior */
typedef enum { typedef enum {
@@ -384,7 +384,7 @@ typedef struct {
uid_t uid; uid_t uid;
gid_t gid; gid_t gid;
pid_t pid; pid_t pid;
char caller_id[64]; /* "uid:<n>" */ char caller_id[80]; /* "uid:<n>" */
} caller_identity_t; } caller_identity_t;
/* Server context */ /* Server context */