Compare commits

..
6 Commits
36 changed files with 3991 additions and 43 deletions
+199
View File
@@ -0,0 +1,199 @@
---
description: "Call n_signer to sign Nostr events, get public keys, encrypt/decrypt, and perform crypto operations across all transports (qrexec, unix socket, TCP, HTTP, USB/serial)."
---
# n_signer Client Skill
This skill tells an agent how to call [`n_signer`](../README.md) — a hardware/software signing oracle that holds BIP-39 keys in locked memory. The signer may be running on the same machine (Unix socket), in another Qubes qube (qrexec), on a hardware device (USB/serial), or reachable over TCP/HTTP.
## 1. Transport overview
| Transport | Scope | Auth required | Best for |
|-----------|-------|---------------|----------|
| **qrexec** | Cross-qube (Qubes OS) | No (identity from `QREXEC_REMOTE_DOMAIN`) | Agents in caller qubes |
| **Unix abstract socket** | Same machine | No (identity from `SO_PEERCRED`) | Local processes |
| **TCP (FIPS mesh)** | Cross-qube or network | Yes (kind-27235 auth envelope) | Remote callers, FIPS networks |
| **HTTP** | Cross-qube or network | Yes (kind-27235 auth envelope) | curl-friendly, REST clients |
| **USB/serial** | Hardware signer (Feather, Teensy, CYD) | No (physical possession) | Embedded/air-gap |
| **Stdio** | One-shot via pipe | No | Scripted one-off calls |
## 2. Wire protocol (all transports)
Every request/response uses **length-prefixed framing**:
```
[4-byte big-endian payload length][UTF-8 JSON payload]
```
The JSON payload is a JSON-RPC 2.0-style object:
```json
{ "id": "<string>", "method": "<verb>", "params": [<arg0>, <arg1>, ..., {<options>}] }
```
Response (success):
```json
{ "id": "<string>", "result": "<value>" }
```
Response (error):
```json
{ "id": "<string>", "error": { "code": <int>, "message": "<string>" } }
```
## 3. Transport-specific invocation
### 3.1 Qubes qrexec (easiest cross-qube)
```bash
# Pipe framed JSON-RPC through qrexec-client-vm
printf '\x00\x00\x00\x3f'"$(echo '{"id":"1","method":"get_public_key","params":[{"nostr_index":0}]}')" | qrexec-client-vm <signer_qube> qubes.NsignerRpc | tail -c +5
```
Python (stdlib only, zero deps):
```python
import json, struct, subprocess
def call_nsigner(target_qube, request):
payload = json.dumps(request, separators=(",", ":")).encode()
frame = struct.pack(">I", len(payload)) + payload
proc = subprocess.Popen(
["qrexec-client-vm", target_qube, "qubes.NsignerRpc"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
out, _ = proc.communicate(frame)
length = struct.unpack(">I", out[:4])[0]
return json.loads(out[4:4+length])
```
See [`client/demo_python.py`](../client/demo_python.py) for a full working demo (get_public_key → sign_event → nip44 → mine_event).
### 3.2 Local Unix abstract socket (same machine)
```bash
# Find running signers
nsigner list
# Connect via socat or the nsigner client subcommand
nsigner client '<json>' --socket-name <name>
```
C (using `nostr_core_lib`):
```c
nsigner_transport_t *t = nsigner_transport_open_unix("nsigner", 5000);
nsigner_client_t *c = nsigner_client_new(t);
cJSON *result = NULL;
nsigner_client_call(c, "nostr_get_public_key", params, &result);
```
See [`examples/get_public_key_client.c`](../examples/get_public_key_client.c) and [`examples/sign_event_client.c`](../examples/sign_event_client.c).
### 3.3 TCP (FIPS mesh, cross-qube)
Requires a **kind-27235 auth envelope** (signed Nostr event proving caller identity).
Python (with `coincurve`):
```python
import hashlib, json, socket, struct, time
from coincurve import PrivateKey
# Build auth envelope
sk = PrivateKey(caller_privkey_bytes)
pubkey_x = sk.public_key.format(compressed=False)[1:33].hex()
body_hash = hashlib.sha256(json.dumps(params, separators=(",",":")).encode()).hexdigest()
tags = [["nsigner_rpc","1"],["nsigner_method","get_public_key"],["nsigner_body_hash",body_hash]]
serialized = json.dumps([0, pubkey_x, created_at, 27235, tags, content], separators=(",",":")).encode()
event_id = hashlib.sha256(serialized).hexdigest()
sig = sk.sign_schnorr(bytes.fromhex(event_id), aux_randomness=b"\x00"*32).hex()
request = {"id":"1","method":"get_public_key","params":params,"auth":{"id":event_id,"pubkey":pubkey_x,"created_at":created_at,"kind":27235,"tags":tags,"content":"py-min","sig":sig}}
```
See [`examples/get_pubkey_fips.py`](../examples/get_pubkey_fips.py) and [`examples/n_signer_qube_example_fips.js`](../examples/n_signer_qube_example_fips.js).
### 3.4 HTTP listener
```bash
curl -X POST http://<host>:<port>/ \
-H "Content-Type: application/json" \
-d '{"id":"1","method":"get_public_key","params":[],"auth":{...}}'
```
### 3.5 USB/serial (hardware signers)
For Feather S3, Teensy 4.1, CYD ESP32, etc. — connect over USB CDC serial with the same framing.
See [`examples/feather_get_public_key.py`](../examples/feather_get_public_key.py) and [`examples/feather_sign_event.py`](../examples/feather_sign_event.py).
## 4. Key verbs
| Verb | What it does | Params |
|------|-------------|--------|
| `get_public_key` | Get pubkey for algorithm+index | `[{"algorithm":"secp256k1","index":0}]` |
| `nostr_get_public_key` | Get secp256k1 pubkey by nostr_index | `[{"nostr_index":0}]` |
| `nostr_sign_event` | Sign a Nostr event | `["<event_json>",{"nostr_index":0}]` |
| `sign` | Sign arbitrary bytes (any algorithm) | `["<hex>",{"algorithm":"ed25519","index":0}]` |
| `nostr_nip44_encrypt` | NIP-44 encrypt | `["<peer_hex>","<plaintext>",{"nostr_index":0}]` |
| `nostr_nip44_decrypt` | NIP-44 decrypt | `["<peer_hex>","<ciphertext>",{"nostr_index":0}]` |
| `nostr_mine_event` | NIP-13 PoW mine + sign | `["<event_json>",{"nostr_index":0,"difficulty":4}]` |
| `encapsulate` | ML-KEM-768 encapsulate | `["<peer_pubkey_hex>",{"algorithm":"ml-kem-768"}]` |
| `decapsulate` | ML-KEM-768 decapsulate | `["<ciphertext_hex>",{"algorithm":"ml-kem-768","index":0}]` |
| `derive` | HMAC-SHA256(privkey, data) | `["<data>",{"algorithm":"secp256k1","index":0}]` |
| `get_info` | Signer metadata | `[]` |
Full verb table: [`README.md §4.3`](../README.md#43-verbs)
## 5. Algorithms
| Algorithm | Key type | FIPS | Derivation path |
|-----------|----------|------|-----------------|
| `secp256k1` | Signature (Nostr) | — | `m/44'/1237'/<n>'/0/0` |
| `ed25519` | Signature (SSH) | — | `m/44'/102001'/<n>'/0/0'` |
| `x25519` | Key agreement (age) | — | `m/44'/102002'/<n>'/0/0'` |
| `ml-dsa-65` | PQ signature | FIPS 204 | DRBG from seed |
| `slh-dsa-128s` | PQ hash-based sig | FIPS 205 | DRBG from seed |
| `ml-kem-768` | PQ KEM | FIPS 203 | DRBG from seed |
| `otp` | One-time pad | — | USB pad, no derivation |
## 6. Publishing a Nostr event (end-to-end)
```python
import json, struct, subprocess, time
def call_nsigner(target_qube, request):
payload = json.dumps(request, separators=(",", ":")).encode()
frame = struct.pack(">I", len(payload)) + payload
proc = subprocess.Popen(
["qrexec-client-vm", target_qube, "qubes.NsignerRpc"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
out, _ = proc.communicate(frame)
length = struct.unpack(">I", out[:4])[0]
return json.loads(out[4:4+length])
# 1. Get pubkey
pub = call_nsigner("nostr_signer", {"id":"1","method":"get_public_key","params":[{"nostr_index":0}]})["result"]
# 2. Build and sign event
event = {"kind":1,"content":"Hello from my agent!","created_at":int(time.time()),"tags":[],"pubkey":pub}
result = call_nsigner("nostr_signer", {"id":"2","method":"nostr_sign_event","params":[json.dumps(event),{"nostr_index":0}]})
signed = json.loads(result["result"])
# 3. Broadcast to relay(s)
# signed["id"] and signed["sig"] are now populated
```
## 7. Reference files
| File | What it shows |
|------|---------------|
| [`client/demo_python.py`](../client/demo_python.py) | Full Python demo (qrexec, stdlib only) |
| [`client/demo_javascript.js`](../client/demo_javascript.js) | Full Node.js demo (qrexec) |
| [`client/demo_c99.c`](../client/demo_c99.c) | Full C99 demo (qrexec, nostr_core_lib) |
| [`examples/get_pubkey_fips.py`](../examples/get_pubkey_fips.py) | Minimal TCP/FIPS with auth envelope |
| [`examples/get_pubkey_qrexec.c`](../examples/get_pubkey_qrexec.c) | Minimal qrexec in C |
| [`examples/get_pubkey_tcp.c`](../examples/get_pubkey_tcp.c) | Minimal TCP in C with auth envelope |
| [`examples/n_signer_qube_example_fips.js`](../examples/n_signer_qube_example_fips.js) | TCP/FIPS in Node.js |
| [`examples/n_signer_qube_example_qrexec.js`](../examples/n_signer_qube_example_qrexec.js) | qrexec in Node.js |
| [`documents/CLIENT_IMPLEMENTATION.md`](../documents/CLIENT_IMPLEMENTATION.md) | Full wire contract spec |
| [`documents/AGENT_CLIENT.md`](../documents/AGENT_CLIENT.md) | Comprehensive agent reference |
+10 -2
View File
@@ -80,6 +80,7 @@ TEST_SLH_DSA_128S_TARGET := $(BUILD_DIR)/test_slh_dsa_128s
TEST_ML_KEM_768_TARGET := $(BUILD_DIR)/test_ml_kem_768
TEST_PUBKEY_FORMAT_TARGET := $(BUILD_DIR)/test_pubkey_format
TEST_ALGORITHM_API_TARGET := $(BUILD_DIR)/test_algorithm_api
TEST_PATH_WHITELIST_TARGET := $(BUILD_DIR)/test_path_whitelist
EXAMPLE_GET_PUBLIC_KEY_TARGET := $(BUILD_DIR)/example_get_public_key_client
EXAMPLE_SIGN_EVENT_TARGET := $(BUILD_DIR)/example_sign_event_client
EXAMPLE_GET_PUBKEY_TCP_TARGET := $(BUILD_DIR)/example_get_pubkey_tcp
@@ -89,7 +90,7 @@ EXAMPLE_PQ_KEM_TARGET := $(BUILD_DIR)/example_pq_kem
EXAMPLE_SSH_SIGN_TARGET := $(BUILD_DIR)/example_ssh_sign
DEMO_C99_TARGET := $(BUILD_DIR)/demo_c99
.PHONY: all lib dev static static-debug static-arm64 firmware-feather test test-integration test-mnemonic test-mnemonic-input test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth test-mine-event test-pq-crypto test-ed25519-x25519 test-ml-dsa-65 test-slh-dsa-128s test-ml-kem-768 test-pubkey-format test-algorithm-api examples test-client clean
.PHONY: all lib dev static static-debug static-arm64 firmware-feather test test-integration test-mnemonic test-mnemonic-input test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth test-mine-event test-pq-crypto test-ed25519-x25519 test-ml-dsa-65 test-slh-dsa-128s test-ml-kem-768 test-pubkey-format test-algorithm-api test-path-whitelist examples test-client clean
all: dev
@@ -117,7 +118,7 @@ static-arm64:
firmware-feather:
cd firmware/feather_s3_tft && idf.py build
test: lib test-mnemonic test-mnemonic-input test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth test-mine-event test-pq-crypto test-ed25519-x25519 test-ml-dsa-65 test-slh-dsa-128s test-ml-kem-768 test-pubkey-format test-client
test: lib test-mnemonic test-mnemonic-input test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth test-mine-event test-pq-crypto test-ed25519-x25519 test-ml-dsa-65 test-slh-dsa-128s test-ml-kem-768 test-pubkey-format test-path-whitelist test-client
test-integration: $(TEST_INTEGRATION_TARGET) $(TARGET_DEV)
./$(TEST_INTEGRATION_TARGET)
@@ -176,6 +177,9 @@ test-pubkey-format: $(TEST_PUBKEY_FORMAT_TARGET)
test-algorithm-api: $(TEST_ALGORITHM_API_TARGET)
./$(TEST_ALGORITHM_API_TARGET)
test-path-whitelist: $(TEST_PATH_WHITELIST_TARGET)
./$(TEST_PATH_WHITELIST_TARGET)
test-client: examples
examples: $(EXAMPLE_GET_PUBLIC_KEY_TARGET) $(EXAMPLE_SIGN_EVENT_TARGET) $(EXAMPLE_GET_PUBKEY_TCP_TARGET) $(EXAMPLE_GET_PUBKEY_QREXEC_TARGET) $(EXAMPLE_PQ_SIGN_TARGET) $(EXAMPLE_PQ_KEM_TARGET) $(EXAMPLE_SSH_SIGN_TARGET) $(DEMO_C99_TARGET)
@@ -256,6 +260,10 @@ $(TEST_ALGORITHM_API_TARGET): $(TEST_DIR)/test_algorithm_api.c $(SRC_DIR)/pq_cry
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(TEST_DIR)/test_algorithm_api.c $(SRC_DIR)/pq_crypto.c $(SRC_DIR)/pq_drbg.c $(PQCLEAN_SOURCES) $(SRC_DIR)/key_store.c $(SRC_DIR)/dispatcher.c $(SRC_DIR)/miner.c $(SRC_DIR)/selector.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c $(SRC_DIR)/policy.c $(SRC_DIR)/otp_pad.c libotppad/libotppad.c -o $(TEST_ALGORITHM_API_TARGET) $(LDFLAGS)
$(TEST_PATH_WHITELIST_TARGET): $(TEST_DIR)/test_path_whitelist.c $(SRC_DIR)/server.c $(SRC_DIR)/pq_crypto.c $(SRC_DIR)/pq_drbg.c $(PQCLEAN_SOURCES) $(SRC_DIR)/key_store.c $(SRC_DIR)/dispatcher.c $(SRC_DIR)/miner.c $(SRC_DIR)/selector.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c $(SRC_DIR)/policy.c $(SRC_DIR)/auth_envelope.c $(SRC_DIR)/transport_frame.c $(SRC_DIR)/socket_name.c $(SRC_DIR)/http_listener.c $(SRC_DIR)/otp_pad.c libotppad/libotppad.c
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(TEST_DIR)/test_path_whitelist.c $(SRC_DIR)/server.c $(SRC_DIR)/pq_crypto.c $(SRC_DIR)/pq_drbg.c $(PQCLEAN_SOURCES) $(SRC_DIR)/key_store.c $(SRC_DIR)/dispatcher.c $(SRC_DIR)/miner.c $(SRC_DIR)/selector.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c $(SRC_DIR)/policy.c $(SRC_DIR)/auth_envelope.c $(SRC_DIR)/transport_frame.c $(SRC_DIR)/socket_name.c $(SRC_DIR)/http_listener.c $(SRC_DIR)/otp_pad.c libotppad/libotppad.c -o $(TEST_PATH_WHITELIST_TARGET) $(LDFLAGS)
$(EXAMPLE_GET_PUBLIC_KEY_TARGET): $(EXAMPLES_DIR)/get_public_key_client.c
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(EXAMPLES_DIR)/get_public_key_client.c -o $(EXAMPLE_GET_PUBLIC_KEY_TARGET) $(LDFLAGS)
+87 -1
View File
@@ -191,6 +191,10 @@ Error codes:
| 1008 | `mining_failed` | Internal error during proof-of-work mining. |
| 1009 | `not_yet_implemented` | Verb+algorithm combination is reserved but not yet implemented. |
| 1010 | `algorithm_not_supported_for_verb` | The `algorithm` value is not valid for this verb. |
| 2002 | `index_not_allowed` | `nostr_index` not in the index whitelist. |
| 2003 | `path_not_allowed` | `role_path` not in the path whitelist. |
| 2004 | `index_required` | Named path-role has no default index and none given. |
| 2005 | `index_out_of_range` | `index` outside the named role's `[lo,hi]` range. |
### 4.3 Verbs
@@ -484,10 +488,92 @@ The `nostr_*` verbs select a secp256k1 NIP-06 key via the options object. Suppor
|----------------|--------------------------------------------------|
| `nostr_index` | NIP-06 index `n` → path `m/44'/1237'/<n>'/0/0` |
| `role` | Name of a pre-registered role entry |
| `role_path` | Full BIP-44 derivation path (must match a registered role) |
| `role_path` | Full BIP-44 derivation path (must be on the path whitelist or match a registered role) |
| `index` | Optional: index for a named path-role template (see below) |
Selector resolution order: `role``nostr_index``role_path` → default role `main`. Conflicting selectors are rejected with `ambiguous_role_selector` (1001). The role's `(purpose, curve)` must be `(nostr, secp256k1)` — any other combination is rejected with `purpose_mismatch` (1004) or `curve_mismatch` (1005).
#### Named path-roles
In the interactive wizard, you can define **named path-roles** that bind a role name (which acts as an access token for clients) to a derivation path template. The derivation path is hidden from clients — they only know the role name.
```
Wizard:
Define a named path role? [y/N] y
Role name: myrole
Curve:
1) secp256k1 (Nostr, Bitcoin)
2) ed25519 (SSH)
3) x25519 (key agreement, Age)
4) ml-dsa-65 (post-quantum signatures)
5) slh-dsa-128s (post-quantum signatures)
6) ml-kem-768 (post-quantum KEM)
Select [1]: 1
Path template (use N-M for range, A+B+C for set, e.g. m/44'/1237'/1-100/2/0):
(arrow keys to edit, Enter to accept):
m/44'/1237'/0-3/1/0
Default index [0]:
```
Purpose is auto-detected from the path prefix (e.g. `m/44'/1237'` → nostr, `m/44'/102001'` → ssh). The path template is pre-filled with `m/44'/1237'/0'/0/0` and can be edited inline with arrow keys, backspace, and delete.
**Path template syntax:**
- **Range**: `m/44'/1237'/0-3/1/0` — index 0..3, hardened if segment ends with `'` (e.g. `0-3'`)
- **Set**: `m/44'/1237'/1+34+54/1/0` — specific indices 1, 34, 54
- **Fixed path**: `m/44'/1237'/0'/0/0` — no variable segment, single fixed key (no index needed)
- The first segment that is a plain number, range (`N-M`), or set (`A+B+C`) becomes the variable (`%d`). Segments with `'` (like `44'`, `1237'`) are treated as literal hardened constants.
**Default index**: Defaults to 0 if 0 is within the allowed range/set, otherwise the first element. The user can override at the prompt.
When named path-roles are defined, the path whitelist prompt is skipped (the roles themselves define the allowed paths).
Clients then request keys by role name, optionally with an `index` within the allowed range:
```json
{"id":"1","method":"nostr_get_public_key","params":[{"role":"myrole"}]}
```
→ derives `m/44'/1237'/0/1/0` (default index 0).
```json
{"id":"2","method":"nostr_get_public_key","params":[{"role":"myrole","index":2}]}
```
→ derives `m/44'/1237'/2/1/0` (index 2, within range 0-3).
```json
{"id":"3","method":"nostr_get_public_key","params":[{"role":"myrole","index":5}]}
```
`2005 index_out_of_range` (5 is outside 0-3).
```json
{"id":"4","method":"nostr_get_public_key","params":[{"role":"unknown"}]}
```
`1002 unknown_role` (name not registered).
For fixed-path roles (no range/set), clients omit the `index` field:
```json
{"id":"5","method":"nostr_get_public_key","params":[{"role":"fixedrole"}]}
```
→ derives the single fixed path.
#### Path whitelist (`--allow-index`)
The `--allow-index` flag accepts both integer `nostr_index` tokens and path-template tokens. A `role_path` request is auto-registered and derived on demand if it matches a whitelisted template; otherwise it returns `2003 path_not_allowed`.
**Note**: When named path-roles are defined in the wizard, the path whitelist prompt is skipped — the roles themselves define the allowed paths. The `--allow-index` flag can still be used for additional raw `role_path` access.
```
nsigner --allow-index "m/44'/1237'/0-3/1/0"
```
Allowed syntax (comma-separated):
- `all` — no restriction (default)
- `0-3` / `0,1,3` — integer `nostr_index` values (backward compatible)
- `m/44'/1237'/0-3/0/0` — NIP-06 paths X=0..3
- `m/44'/1237'/0-3/1/0` — custom paths X=0..3, change=1
- `m/44'/1237'/1+34+54/1/0` — set of specific indices (1, 34, 54)
- `m/44'/1237'/0-3/0/0,m/44'/1237'/0-3/1/0` — multiple templates
### 4.7 Pre-approval
Pre-approval entries skip the interactive prompt for matching requests. They are configured at startup with `--preapprove`.
+736
View File
@@ -0,0 +1,736 @@
# Agent Client Reference — n_signer
This document is the complete reference for **agents, AI tools, and automated processes** that need to call [`n_signer`](../README.md) — a signing oracle that holds BIP-39 keys in locked memory and exposes them over multiple transports.
## Table of Contents
1. [Architecture overview](#1-architecture-overview)
2. [Wire protocol (all transports)](#2-wire-protocol-all-transports)
3. [Transport: Qubes qrexec (recommended for cross-qube)](#3-transport-qubes-qrexec-recommended-for-cross-qube)
4. [Transport: Local Unix abstract socket](#4-transport-local-unix-abstract-socket)
5. [Transport: TCP (FIPS mesh)](#5-transport-tcp-fips-mesh)
6. [Transport: HTTP](#6-transport-http)
7. [Transport: USB/serial (hardware signers)](#7-transport-usbserial-hardware-signers)
8. [Transport: Stdio (one-shot)](#8-transport-stdio-one-shot)
9. [Auth envelope (kind-27235)](#9-auth-envelope-kind-27235)
10. [Complete verb reference](#10-complete-verb-reference)
11. [Algorithm reference](#11-algorithm-reference)
12. [Error codes](#12-error-codes)
13. [End-to-end: publish a Nostr event](#13-end-to-end-publish-a-nostr-event)
14. [End-to-end: sign arbitrary data](#14-end-to-end-sign-arbitrary-data)
15. [End-to-end: PQ KEM encapsulate/decapsulate](#15-end-to-end-pq-kem-encapsulatedecapsulate)
16. [Discovery: finding running signers](#16-discovery-finding-running-signers)
17. [Prerequisites and setup](#17-prerequisites-and-setup)
18. [Reference files in this repo](#18-reference-files-in-this-repo)
---
## 1. Architecture overview
```
┌─────────────────────────────────────────────────────────────┐
│ Caller (agent) │
│ Python / Node / C / Shell / any language │
│ Builds framed JSON-RPC → sends over transport → reads resp │
└──────────┬──────────────────────────────────────┬───────────┘
│ │
┌─────┴──────┐ ┌──────────┴──────────┐
│ qrexec │ │ TCP / HTTP / Unix │
│ (Qubes) │ │ Socket / USB │
└─────┬──────┘ └──────────┬──────────┘
│ │
┌─────┴──────────────────────────────────────┴───────────┐
│ n_signer │
│ BIP-39 mnemonic in mlock'd RAM │
│ Derives keys on demand (secp256k1, ed25519, PQ, etc.) │
│ Enforces policy + approval per caller │
└─────────────────────────────────────────────────────────┘
```
The signer is a **single foreground process** attached to a terminal. It holds the mnemonic in locked memory only — nothing touches disk. When the process exits, all state is destroyed.
---
## 2. Wire protocol (all transports)
Every transport uses the same framing and JSON-RPC contract.
### 2.1 Framing
```
[4 bytes: big-endian payload length N][N bytes: UTF-8 JSON payload]
```
- Length prefix is `uint32_t` in network byte order.
- Payload is exactly `N` bytes of UTF-8 JSON.
- One request frame → one response frame per connection (except HTTP which uses standard HTTP request/response).
### 2.2 Request shape
```json
{
"id": "<caller-supplied string, echoed in response>",
"method": "<verb name>",
"params": [ <arg0>, <arg1>, ..., { <options> } ]
}
```
- `id` — any string. Used to match async responses. Echoed verbatim.
- `method` — one of the verbs in §10.
- `params` — JSON array. Positional args first; last element is conventionally an options object.
### 2.3 Response shape
Success:
```json
{ "id": "<string>", "result": "<value>" }
```
Error:
```json
{ "id": "<string>", "error": { "code": <int>, "message": "<string>" } }
```
`result` is always a JSON string. For structured verbs (like `get_public_key` with algorithm), the string is itself serialized JSON — parse it again.
### 2.4 Auth envelope (required for TCP/HTTP, optional for qrexec)
TCP and HTTP listeners require a kind-27235 auth envelope in the `auth` field. See [§9](#9-auth-envelope-kind-27235).
---
## 3. Transport: Qubes qrexec (recommended for cross-qube)
**Best for**: Agents running in a caller qube, talking to n_signer in a dedicated signer qube.
**Auth**: Not required — caller identity comes from `QREXEC_REMOTE_DOMAIN` as `qubes:<source-vm>`.
### 3.1 Shell one-liner
```bash
# Get public key for nostr_index 0
printf '\x00\x00\x00\x3f'"$(echo '{"id":"1","method":"get_public_key","params":[{"nostr_index":0}]}')" | \
qrexec-client-vm nostr_signer qubes.NsignerRpc | tail -c +5
```
### 3.2 Python (stdlib only)
```python
import json, struct, subprocess
def call_nsigner(target_qube, request):
"""Send one framed JSON-RPC request via qrexec, return parsed response."""
payload = json.dumps(request, separators=(",", ":")).encode("utf-8")
frame = struct.pack(">I", len(payload)) + payload
proc = subprocess.Popen(
["qrexec-client-vm", target_qube, "qubes.NsignerRpc"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
out, err = proc.communicate(frame)
if proc.returncode != 0:
raise RuntimeError(f"qrexec failed: {err.decode()}")
length = struct.unpack(">I", out[:4])[0]
return json.loads(out[4:4+length])
```
Full demo: [`client/demo_python.py`](../client/demo_python.py)
### 3.3 Node.js
```javascript
const { spawn } = require("child_process");
function callNsigner(targetQube, request) {
return new Promise((resolve, reject) => {
const payload = Buffer.from(JSON.stringify(request), "utf8");
const header = Buffer.alloc(4);
header.writeUInt32BE(payload.length, 0);
const framed = Buffer.concat([header, payload]);
const proc = spawn("qrexec-client-vm", [targetQube, "qubes.NsignerRpc"], {
stdio: ["pipe", "pipe", "pipe"],
});
const chunks = [];
proc.stdout.on("data", (c) => chunks.push(c));
proc.on("close", (code) => {
if (code !== 0) return reject(new Error(`exit code ${code}`));
const buf = Buffer.concat(chunks);
const len = buf.readUInt32BE(0);
resolve(JSON.parse(buf.subarray(4, 4 + len).toString()));
});
proc.stdin.write(framed);
proc.stdin.end();
});
}
```
Full demo: [`client/demo_javascript.js`](../client/demo_javascript.js)
### 3.4 C (using nostr_core_lib)
```c
#include "nostr_signer.h"
nostr_signer_t *signer = nostr_signer_nsigner_qrexec("nostr_signer", "qubes.NsignerRpc", NULL, 30000);
nostr_signer_nsigner_set_nostr_index(signer, 0);
char pubkey[65];
nostr_signer_get_public_key(signer, pubkey);
```
Full demo: [`client/demo_c99.c`](../client/demo_c99.c)
### 3.5 Prerequisites
1. n_signer running in the signer qube: `nsigner --listen unix --socket-name nsigner --bridge-source-trusted`
2. qrexec service installed at `/etc/qubes-rpc/qubes.NsignerRpc`: `exec nsigner bridge --to nsigner`
3. dom0 policy allowing caller qube → signer qube (see [`packaging/qubes/policy.d/40-nsigner.policy`](../packaging/qubes/policy.d/40-nsigner.policy))
---
## 4. Transport: Local Unix abstract socket
**Best for**: Agents on the same machine as the signer.
**Auth**: Not required — identity from `SO_PEERCRED` (kernel-verified UID/PID).
### 4.1 Discovery
```bash
nsigner list
# Output: @nsigner, @nsigner_hairy_dog, etc.
```
### 4.2 Using the nsigner CLI
```bash
nsigner client '{"id":"1","method":"get_public_key","params":[{"nostr_index":0}]}' --socket-name nsigner
```
### 4.3 C (using nostr_core_lib)
```c
#include "nsigner_transport.h"
#include "nsigner_client.h"
nsigner_transport_t *t = nsigner_transport_open_unix("nsigner", 5000);
nsigner_client_t *c = nsigner_client_new(t);
cJSON *params = cJSON_CreateArray();
cJSON *result = NULL;
nsigner_client_call(c, "nostr_get_public_key", params, &result);
```
See [`examples/get_public_key_client.c`](../examples/get_public_key_client.c).
### 4.4 Python (raw socket)
```python
import json, socket, struct
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect("\0nsigner") # \0 prefix for abstract namespace
payload = json.dumps({"id":"1","method":"get_public_key","params":[{"nostr_index":0}]}).encode()
sock.sendall(struct.pack(">I", len(payload)) + payload)
hdr = sock.recv(4)
length = struct.unpack(">I", hdr)[0]
body = sock.recv(length)
print(json.loads(body))
```
---
## 5. Transport: TCP (FIPS mesh)
**Best for**: Cross-qube or network callers where qrexec is not available.
**Auth**: **Required** — kind-27235 auth envelope (see [§9](#9-auth-envelope-kind-27235)).
### 5.1 Python (with coincurve)
```python
import hashlib, json, socket, struct, time
from coincurve import PrivateKey
HOST, PORT = "192.168.1.100", 11111
CALLER_PRIVKEY = bytes(range(1, 33)) # Replace with your key
params = [{"nostr_index": 0}]
body_hash = hashlib.sha256(json.dumps(params, separators=(",",":")).encode()).hexdigest()
sk = PrivateKey(CALLER_PRIVKEY)
pubkey_x = sk.public_key.format(compressed=False)[1:33].hex()
created_at = int(time.time())
tags = [["nsigner_rpc","1"],["nsigner_method","get_public_key"],["nsigner_body_hash",body_hash]]
serialized = json.dumps([0, pubkey_x, created_at, 27235, tags, "tcp-agent"], separators=(",",":")).encode()
event_id = hashlib.sha256(serialized).hexdigest()
sig = sk.sign_schnorr(bytes.fromhex(event_id), aux_randomness=b"\x00"*32).hex()
request = {
"id": "1", "method": "get_public_key", "params": params,
"auth": {"id": event_id, "pubkey": pubkey_x, "created_at": created_at,
"kind": 27235, "tags": tags, "content": "tcp-agent", "sig": sig},
}
payload = json.dumps(request, separators=(",",":")).encode()
frame = struct.pack(">I", len(payload)) + payload
with socket.create_connection((HOST, PORT), timeout=10) as s:
s.sendall(frame)
hdr = s.recv(4)
ln = struct.unpack(">I", hdr)[0]
body = s.recv(ln)
print(json.loads(body))
```
See [`examples/get_pubkey_fips.py`](../examples/get_pubkey_fips.py) and [`examples/n_signer_qube_example_fips.js`](../examples/n_signer_qube_example_fips.js).
### 5.2 C (using nostr_core_lib)
```c
nsigner_transport_t *t = nsigner_transport_open_tcp("192.168.1.100", 11111, 10000);
nsigner_client_t *c = nsigner_client_new(t);
nsigner_client_set_auth(c, caller_privkey, "tcp-agent");
// ... call as usual
```
See [`examples/get_pubkey_tcp.c`](../examples/get_pubkey_tcp.c).
---
## 6. Transport: HTTP
**Best for**: curl-friendly callers, REST clients.
**Auth**: **Required** — kind-27235 auth envelope in the JSON body.
```bash
curl -X POST http://192.168.1.100:11112/ \
-H "Content-Type: application/json" \
-d '{
"id":"1",
"method":"get_public_key",
"params":[{"nostr_index":0}],
"auth":{...}
}'
```
The HTTP listener uses the same framing internally but presents a standard HTTP interface. One request per connection.
---
## 7. Transport: USB/serial (hardware signers)
**Best for**: Embedded/air-gap signers (Feather S3, Teensy 4.1, CYD ESP32).
**Auth**: Not required — physical possession is the trust anchor.
### 7.1 Python (pyserial)
```python
import json, struct, serial
ser = serial.Serial("/dev/ttyACM0", 115200, timeout=5)
payload = json.dumps({"id":"1","method":"get_public_key","params":[{"nostr_index":0}]}).encode()
ser.write(struct.pack(">I", len(payload)) + payload)
hdr = ser.read(4)
length = struct.unpack(">I", hdr)[0]
body = ser.read(length)
print(json.loads(body))
```
See [`examples/feather_get_public_key.py`](../examples/feather_get_public_key.py) and [`examples/feather_sign_event.py`](../examples/feather_sign_event.py).
---
## 8. Transport: Stdio (one-shot)
**Best for**: Scripted one-off calls via pipe.
The signer can be started with `--listen stdio` to handle exactly one framed request on stdin and write one framed response to stdout.
```bash
echo -n '<framed request>' | nsigner --listen stdio --allow-all
```
Or via the qrexec bridge service (which uses stdio internally to relay to the persistent signer's Unix socket).
---
## 9. Auth envelope (kind-27235)
TCP and HTTP listeners require a **kind-27235 Nostr event** in the `auth` field of every request. This proves the caller controls a keypair.
### 9.1 Auth envelope structure
```json
{
"auth": {
"id": "<sha256 of serialized event>",
"pubkey": "<caller's secp256k1 x-only pubkey hex>",
"created_at": <unix timestamp>,
"kind": 27235,
"tags": [
["nsigner_rpc", "<request id>"],
["nsigner_method", "<method name>"],
["nsigner_body_hash", "<sha256 of canonical params JSON>"]
],
"content": "<arbitrary string>",
"sig": "<schnorr signature hex>"
}
}
```
### 9.2 Building the auth envelope (pseudocode)
```
1. Compute body_hash = SHA256(JSON.stringify(params, separators=(",",":")))
2. Build tags: [["nsigner_rpc", id], ["nsigner_method", method], ["nsigner_body_hash", body_hash]]
3. Serialize event for signing: JSON.stringify([0, pubkey, created_at, 27235, tags, content])
4. Compute event_id = SHA256(serialized)
5. Sign event_id with caller's secp256k1 key (Schnorr/BIP-340)
6. Include full event as the "auth" field in the request
```
### 9.3 Validation rules (server-side)
| Check | Error code |
|-------|-----------|
| Missing `auth` field | 2014 `auth_envelope_required` |
| Malformed auth JSON | 2010 `auth_envelope_malformed` |
| `kind` != 27235 | 2013 `auth_kind_invalid` |
| Signature doesn't verify | 2012 `auth_signature_invalid` |
| `nsigner_rpc` tag != request `id` | 2011 `auth_body_mismatch` |
| `nsigner_method` tag != request `method` | 2011 `auth_body_mismatch` |
| `nsigner_body_hash` != SHA256(params) | 2011 `auth_body_mismatch` |
| Timestamp skew > 300 seconds | 2015 `auth_envelope_mismatch` |
| Replay (same event_id seen before) | 2015 `auth_envelope_mismatch` |
### 9.4 Python example (full)
See [`examples/get_pubkey_fips.py`](../examples/get_pubkey_fips.py) for a complete working example.
### 9.5 JavaScript example (full)
See [`examples/n_signer_qube_example_fips.js`](../examples/n_signer_qube_example_fips.js) for a complete working example.
---
## 10. Complete verb reference
### 10.1 Algorithm-based verbs
These select a key by `algorithm` + `index` (see [§11](#11-algorithm-reference) for derivation paths).
| Verb | Algorithms | Params | Options |
|------|-----------|--------|---------|
| `get_public_key` | All key-deriving | `[]` | `algorithm`, `index` |
| `sign` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | `[<message_hex>]` | `algorithm`, `index`, `scheme`* |
| `verify` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | `[<message_hex>, <sig_hex>]` | `algorithm`, `index`, `scheme`* |
| `encapsulate` | ml-kem-768 | `[<peer_pubkey_hex>]` | `algorithm` |
| `decapsulate` | ml-kem-768 | `[<ciphertext_hex>]` | `algorithm`, `index` |
| `derive_shared_secret` | x25519 | `[<peer_pubkey_hex>]` | `algorithm`, `index` |
| `derive` | secp256k1 | `[<data>]` | `algorithm`, `index` (required) |
| `encrypt` | otp | `[<plaintext_base64>]` | `algorithm`, `encoding` |
| `decrypt` | otp | `[<ciphertext>]` | `algorithm`, `encoding` |
\* `scheme`: `"schnorr"` (default) or `"ecdsa"` for secp256k1.
### 10.2 Nostr protocol verbs
These select a secp256k1 NIP-06 key via `nostr_index` (or `role`/`role_path`).
| Verb | Params | Options |
|------|--------|---------|
| `nostr_get_public_key` | `[]` | `nostr_index`, `format` |
| `nostr_sign_event` | `[<event_json>]` | `nostr_index` |
| `nostr_mine_event` | `[<event_json>]` | `nostr_index`, `difficulty`, `timeout_sec`, `threads` |
| `nostr_nip04_encrypt` | `[<peer_pubkey_hex>, <plaintext>]` | `nostr_index` |
| `nostr_nip04_decrypt` | `[<peer_pubkey_hex>, <ciphertext>]` | `nostr_index` |
| `nostr_nip44_encrypt` | `[<peer_pubkey_hex>, <plaintext>]` | `nostr_index` |
| `nostr_nip44_decrypt` | `[<peer_pubkey_hex>, <ciphertext>]` | `nostr_index` |
### 10.3 Metadata
| Verb | Params | Description |
|------|--------|-------------|
| `get_info` | `[]` | Returns signer metadata (name, version, supported verbs/algorithms). Safe to call before mnemonic is loaded. |
### 10.4 Example requests
```json
// Get public key for ML-DSA-65 index 0
{"id":"1","method":"get_public_key","params":[{"algorithm":"ml-dsa-65","index":0}]}
// Sign "hello" with ed25519 index 0
{"id":"2","method":"sign","params":["68656c6c6f",{"algorithm":"ed25519","index":0}]}
// Sign a Nostr event with nostr_index 0
{"id":"3","method":"nostr_sign_event","params":["{\"kind\":1,\"content\":\"Hello\",\"tags\":[],\"created_at\":1700000000,\"pubkey\":\"<hex>\"}",{"nostr_index":0}]}
// NIP-44 encrypt
{"id":"4","method":"nostr_nip44_encrypt","params":["<peer_pubkey_hex>","secret message",{"nostr_index":0}]}
// ML-KEM-768 encapsulate
{"id":"5","method":"encapsulate","params":["<peer_pubkey_hex>",{"algorithm":"ml-kem-768"}]}
// Derive HMAC-SHA256(privkey, data) for opaque identifiers
{"id":"6","method":"derive","params":["<data_hex>",{"algorithm":"secp256k1","index":0}]}
```
---
## 11. Algorithm reference
| Algorithm | Key type | FIPS | Derivation path | Pubkey | Privkey | Signature |
|-----------|----------|------|-----------------|--------|---------|-----------|
| `secp256k1` | Signature (Nostr) | — | `m/44'/1237'/<n>'/0/0` | 32 B | 32 B | 64 B |
| `ed25519` | Signature (SSH) | — | `m/44'/102001'/<n>'/0/0'` | 32 B | 32 B | 64 B |
| `x25519` | Key agreement (age) | — | `m/44'/102002'/<n>'/0/0'` | 32 B | 32 B | — |
| `ml-dsa-65` | PQ signature | FIPS 204 | DRBG from seed | 1952 B | 4032 B | 3309 B |
| `slh-dsa-128s` | PQ hash-based sig | FIPS 205 | DRBG from seed | 32 B | 64 B | 7856 B |
| `ml-kem-768` | PQ KEM | FIPS 203 | DRBG from seed | 1184 B | 2400 B | — |
| `otp` | One-time pad | — | USB pad (no derivation) | — | — | — |
### 11.1 `get_public_key` response format
Algorithm-based `get_public_key` returns structured JSON:
```json
{"algorithm":"ml-dsa-65","public_key":"<hex>","key_id":"<first 16 hex chars of pubkey>"}
```
Nostr `nostr_get_public_key` returns a plain 64-hex-char string by default, or structured JSON with `{"format":"structured"}`.
---
## 12. Error codes
| Code | Message | Meaning |
|------|---------|---------|
| -32700 | `parse_error` | Request is not valid JSON |
| -32600 | `invalid_request` | Missing `id`, `method`, or `params` |
| -32601 | `method_not_found` | Unknown verb |
| -32602 | `invalid_params` | Malformed arguments |
| 1001 | `ambiguous_role_selector` | Multiple role selectors given |
| 1002 | `unknown_role` | No role matched selector |
| 1003 | `no_default_role` | No selector and no `main` role |
| 1004 | `purpose_mismatch` | Role purpose not valid for verb |
| 1005 | `curve_mismatch` | Role curve not valid for verb |
| 1006 | `mnemonic_not_loaded` | No mnemonic loaded |
| 1007 | `no_termination_condition` | `nostr_mine_event` without difficulty/timeout |
| 1008 | `mining_failed` | Internal PoW error |
| 1009 | `not_yet_implemented` | Verb+algorithm not yet implemented |
| 1010 | `algorithm_not_supported_for_verb` | Algorithm not valid for verb |
| 2010 | `auth_envelope_malformed` | Auth JSON is malformed |
| 2011 | `auth_body_mismatch` | Auth tags don't match request |
| 2012 | `auth_signature_invalid` | Auth signature doesn't verify |
| 2013 | `auth_kind_invalid` | Auth kind != 27235 |
| 2014 | `auth_envelope_required` | Auth missing (TCP/HTTP) |
| 2015 | `auth_envelope_mismatch` | Timestamp skew or replay |
---
## 13. End-to-end: publish a Nostr event
This is the most common agent task. Here's the complete flow using qrexec (simplest cross-qube transport):
```python
#!/usr/bin/env python3
"""Publish a Nostr event via n_signer over qrexec."""
import json, struct, subprocess, sys, time
def call_nsigner(target_qube, request):
payload = json.dumps(request, separators=(",", ":")).encode()
frame = struct.pack(">I", len(payload)) + payload
proc = subprocess.Popen(
["qrexec-client-vm", target_qube, "qubes.NsignerRpc"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
out, _ = proc.communicate(frame)
length = struct.unpack(">I", out[:4])[0]
return json.loads(out[4:4+length])
target = sys.argv[1] if len(sys.argv) > 1 else "nostr_signer"
idx = int(sys.argv[2]) if len(sys.argv) > 2 else 0
# Step 1: Get public key
pub = call_nsigner(target, {"id":"1","method":"get_public_key","params":[{"nostr_index":idx}]})["result"]
print(f"Pubkey: {pub}")
# Step 2: Build and sign event
event = {
"kind": 1,
"content": "Hello from my agent!",
"created_at": int(time.time()),
"tags": [],
"pubkey": pub,
}
result = call_nsigner(target, {
"id": "2",
"method": "nostr_sign_event",
"params": [json.dumps(event, separators=(",",":")), {"nostr_index": idx}],
})
signed = json.loads(result["result"])
print(f"Event ID: {signed['id']}")
print(f"Signature: {signed['sig']}")
# Step 3: Broadcast to relay(s)
# signed is a complete Nostr event with id and sig — send to any relay
```
---
## 14. End-to-end: sign arbitrary data
Using algorithm-based verbs (any algorithm, any index):
```python
# Sign "hello" with ed25519 index 0
result = call_nsigner(target, {
"id": "1",
"method": "sign",
"params": ["68656c6c6f", {"algorithm": "ed25519", "index": 0}],
})
signature = result["result"] # hex string
# Verify
result = call_nsigner(target, {
"id": "2",
"method": "verify",
"params": ["68656c6c6f", signature, {"algorithm": "ed25519", "index": 0}],
})
print(f"Verified: {result['result']}") # "true" or "false"
```
---
## 15. End-to-end: PQ KEM encapsulate/decapsulate
```python
# Get ML-KEM-768 public key for index 0
pub_result = call_nsigner(target, {
"id": "1",
"method": "get_public_key",
"params": [{"algorithm": "ml-kem-768", "index": 0}],
})
pub_info = json.loads(pub_result["result"])
print(f"ML-KEM-768 pubkey: {pub_info['public_key'][:32]}...")
# Encapsulate (generate a shared secret + ciphertext for that pubkey)
enc_result = call_nsigner(target, {
"id": "2",
"method": "encapsulate",
"params": [pub_info["public_key"], {"algorithm": "ml-kem-768"}],
})
enc_data = json.loads(enc_result["result"])
print(f"Ciphertext: {enc_data['ciphertext'][:32]}...")
print(f"Shared secret: {enc_data['shared_secret'][:32]}...")
# Decapsulate (recover shared secret from ciphertext using private key)
dec_result = call_nsigner(target, {
"id": "3",
"method": "decapsulate",
"params": [enc_data["ciphertext"], {"algorithm": "ml-kem-768", "index": 0}],
})
dec_data = json.loads(dec_result["result"])
print(f"Decapsulated secret: {dec_data['shared_secret'][:32]}...")
assert dec_data["shared_secret"] == enc_data["shared_secret"]
print("✓ KEM round-trip verified")
```
---
## 16. Discovery: finding running signers
### 16.1 List running signers
```bash
nsigner list
```
Output (one per line):
```
@nsigner
@nsigner_hairy_dog
@nsigner_brave_canyon
```
### 16.2 Check if a specific signer is running
```bash
nsigner list | grep -q @nsigner && echo "running" || echo "not running"
```
### 16.3 Programmatic discovery (Python)
```python
import subprocess
result = subprocess.run(["nsigner", "list"], capture_output=True, text=True)
signers = [s.strip() for s in result.stdout.split("\n") if s.strip()]
print(f"Found {len(signers)} signer(s): {signers}")
```
---
## 17. Prerequisites and setup
### 17.1 On the signer qube
```bash
# Install nsigner
# Start the persistent signer
nsigner --listen unix --socket-name nsigner --bridge-source-trusted
# Install qrexec service
sudo cp packaging/qubes/rpc/qubes.NsignerRpc /etc/qubes-rpc/qubes.NsignerRpc
sudo chmod 0755 /etc/qubes-rpc/qubes.NsignerRpc
```
### 17.2 In dom0
```bash
# Install policy
sudo cp packaging/qubes/policy.d/40-nsigner.policy /etc/qubes/policy.d/40-nsigner.policy
# Tag the signer qube
qvm-tags nostr_signer add nsigner-signer
```
### 17.3 On the caller qube
No special setup needed — just `qrexec-client-vm` (pre-installed in all Qubes templates).
---
## 18. Reference files in this repo
| File | What it shows |
|------|---------------|
| [`client/demo_python.py`](../client/demo_python.py) | Full Python demo (qrexec, stdlib only) — get_public_key, sign_event, nip44, mine_event |
| [`client/demo_javascript.js`](../client/demo_javascript.js) | Full Node.js demo (qrexec) — same operations |
| [`client/demo_c99.c`](../client/demo_c99.c) | Full C99 demo (qrexec, nostr_core_lib) — same operations |
| [`examples/get_pubkey_fips.py`](../examples/get_pubkey_fips.py) | Minimal TCP/FIPS with auth envelope (Python) |
| [`examples/get_pubkey_qrexec.c`](../examples/get_pubkey_qrexec.c) | Minimal qrexec in C |
| [`examples/get_pubkey_tcp.c`](../examples/get_pubkey_tcp.c) | Minimal TCP in C with auth envelope |
| [`examples/n_signer_qube_example_fips.js`](../examples/n_signer_qube_example_fips.js) | TCP/FIPS in Node.js with auth envelope |
| [`examples/n_signer_qube_example_qrexec.js`](../examples/n_signer_qube_example_qrexec.js) | qrexec in Node.js |
| [`examples/get_public_key_client.c`](../examples/get_public_key_client.c) | Unix socket in C |
| [`examples/sign_event_client.c`](../examples/sign_event_client.c) | Unix socket sign event in C |
| [`examples/feather_get_public_key.py`](../examples/feather_get_public_key.py) | USB/serial hardware signer (Python) |
| [`examples/feather_sign_event.py`](../examples/feather_sign_event.py) | USB/serial sign event (Python) |
| [`examples/pq_sign_example.c`](../examples/pq_sign_example.c) | ML-DSA-65 sign in C |
| [`examples/pq_kem_example.c`](../examples/pq_kem_example.c) | ML-KEM-768 encaps/decaps in C |
| [`examples/ssh_sign_example.c`](../examples/ssh_sign_example.c) | ed25519 SSH sign in C |
| [`documents/CLIENT_IMPLEMENTATION.md`](CLIENT_IMPLEMENTATION.md) | Full wire contract spec (733 lines) |
| [`README.md`](../README.md) | Full n_signer documentation (741 lines) |
| [`packaging/qubes/setup_signer_qube.sh`](../packaging/qubes/setup_signer_qube.sh) | Automated signer qube setup |
| [`.roo/n_signer_client.md`](../.roo/n_signer_client.md) | Roo skill file (agent-invocable) |
+179
View File
@@ -0,0 +1,179 @@
# Plan: `n_signer_client` — Linux CLI for n_signer
## Goal
A standalone Linux command-line client `n_signer_client` that connects to a
running `n_signer` process over its abstract UNIX socket (and optionally the
other framed transports) and exposes the full verb surface over stdin/stdout so
that signed events can be piped directly into `nak publish`.
## Deliverable
- New file: [`client/n_signer_client.c`](../client/n_signer_client.c) — single-file C99 program.
- New Makefile target producing `build/n_signer_client`.
- Updated [`client/README.md`](../client/README.md) with usage and the pipe-to-nak recipe.
The binary links `nostr_core_lib` exactly like the existing examples
[`examples/sign_event_client.c`](../examples/sign_event_client.c) and
[`examples/get_public_key_client.c`](../examples/get_public_key_client.c). It
uses:
- [`nsigner_transport_open_unix`](../resources/nostr_core_lib/nostr_core/nsigner_transport.h) (and optionally `_tcp`, `_serial`, `_qrexec`)
- [`nsigner_client_new`](../resources/nostr_core_lib/nostr_core/nsigner_client.h) / [`nsigner_client_free`](../resources/nostr_core_lib/nostr_core/nsigner_client.h)
- [`nsigner_client_call`](../resources/nostr_core_lib/nostr_core/nsigner_client.h) (takes ownership of `params`)
- [`nsigner_client_set_auth`](../resources/nostr_core_lib/nostr_core/nsigner_client.h) for TCP mode
## CLI shape
```
n_signer_client [global options] <verb> [verb args...]
```
Global options:
| Flag | Default | Meaning |
|---|---|---|
| `--socket-name`, `-n <name>` | auto-discover | Abstract socket name without `@` |
| `--role <name>` | none | Selector `{"role":"<name>"}` (Nostr verbs) |
| `--nostr-index <N>` | none | Selector `{"nostr_index":N}` (mutually exclusive with `--role`) |
| `--algorithm <alg>` | none | Algorithm-based verbs: `secp256k1`/`ed25519`/`x25519`/`ml-dsa-65`/`slh-dsa-128s`/`ml-kem-768`/`otp` |
| `--index <N>` | `0` | Algorithm key index |
| `--scheme <schnorr\|ecdsa>` | `schnorr` | secp256k1 sign/verify scheme |
| `--format <plain\|structured>` | `plain` | `get-public-key` output shape |
| `--timeout <ms>` | `5000` | Transport timeout |
| `--tcp <host:port>` | none | Use TCP transport (requires `--auth-privkey`) |
| `--serial <device>` | none | Use USB CDC-ACM serial transport |
| `--qrexec <qube:service>` | none | Use Qubes qrexec transport |
| `--auth-privkey <32-byte hex>` | none | Auth envelope privkey for TCP |
| `--auth-label <text>` | none | Auth envelope label |
Auto-discovery: when no `--socket-name` and no explicit transport is given,
enumerate via `nsigner_transport_list_unix` and proceed only if exactly one
`nsigner*` socket exists (mirror `discover_single_socket_name` in
[`src/main.c`](../src/main.c)).
## Verb surface (full)
### Nostr verbs (role-based; selector from `--role` / `--nostr-index`)
| Verb | RPC method | stdin/argv | stdout |
|---|---|---|---|
| `get-public-key` | `nostr_get_public_key` | none | pubkey hex (or structured JSON with `--format structured`) |
| `sign-event` | `nostr_sign_event` | event JSON from argv or one stdin line | signed event JSON, one line |
| `mine-event` | `nostr_mine_event` | event JSON from argv or stdin; `--difficulty`, `--threads`, `--timeout-sec` | signed mined event JSON |
| `nip04-encrypt <peer-pubkey>` | `nostr_nip04_encrypt` | plaintext from argv or stdin | ciphertext |
| `nip04-decrypt <peer-pubkey>` | `nostr_nip04_decrypt` | ciphertext from argv or stdin | plaintext |
| `nip44-encrypt <peer-pubkey>` | `nostr_nip44_encrypt` | plaintext from argv or stdin | ciphertext |
| `nip44-decrypt <peer-pubkey>` | `nostr_nip44_decrypt` | ciphertext from argv or stdin | plaintext |
### Algorithm-based verbs (use `--algorithm` and `--index`)
| Verb | RPC method | argv | stdout |
|---|---|---|---|
| `get-public-key` | `get_public_key` | none | structured JSON `{"algorithm":...,"public_key":...,"key_id":...}` |
| `sign <msg-hex>` | `sign` | hex bytes | signature hex |
| `verify <msg-hex> <sig-hex>` | `verify` | hex bytes | `valid` / `invalid` (exit 0/1) |
| `derive <data>` | `derive` | UTF-8 data (argv or stdin) | `{"algorithm":...,"key_id":...,"digest":...}` |
| `encapsulate <peer-pubkey-hex>` | `encapsulate` | hex | `{"ciphertext":...,"shared_secret":...}` |
| `decapsulate <ciphertext-hex>` | `decapsulate` | hex | `{"shared_secret":...}` |
| `derive-shared-secret <peer-pubkey-hex>` | `derive_shared_secret` | hex | shared secret hex |
### Generic escape hatch
| Verb | RPC method | input | stdout |
|---|---|---|---|
| `call <method>` | `<method>` | JSON `params` array from stdin (one line) or argv | raw `result` JSON |
This keeps the client future-proof for any new server verb without a CLI rewrite.
## stdin/stdout contract (pipe-friendly)
- All payload output goes to stdout as a single line, newline-terminated.
- All diagnostics go to stderr.
- Exit code: `0` on success, non-zero on transport/RPC error (use
`nsigner_client_last_error` for the message).
- `sign-event` reads event JSON from argv if present, else reads exactly one
line from stdin. This is the pipe-to-nak path:
```bash
echo '{"kind":1,"content":"hello","tags":[],"created_at":1700000000}' \
| n_signer_client sign-event \
| nak publish
```
- `nip04-encrypt` / `nip44-encrypt` read plaintext from argv or stdin.
- `nip04-decrypt` / `nip44-decrypt` read ciphertext from argv or stdin.
- `sign` / `verify` / `encapsulate` / `decapsulate` / `derive-shared-secret`
take hex from argv (binary payloads, not pipe-friendly text).
- `derive` takes UTF-8 data from argv or stdin.
- `call` reads a JSON `params` array from stdin (one line) or argv.
## Selector handling
- `--role <name>``{"role":"<name>"}` in the options object.
- `--nostr-index <N>``{"nostr_index":N}` (mutually exclusive with `--role`).
- Default: no selector (server picks default role `main`).
- For algorithm verbs, `--algorithm` and `--index` populate the options object
instead; `--scheme` adds `"scheme"` for secp256k1 sign/verify.
## Transport
- Default: UNIX abstract socket via `nsigner_transport_open_unix(name, timeout_ms)`.
- `--tcp host:port``nsigner_transport_open_tcp` (requires `--auth-privkey`
32-byte hex; calls `nsigner_client_set_auth` with `--auth-label`).
- `--serial /dev/ttyACM0``nsigner_transport_open_serial`.
- `--qrexec qube:service``nsigner_transport_open_qrexec`.
- The vtable is uniform so all four transports share the same call path after
construction.
## Build
Add to [`Makefile`](../Makefile):
```make
N_SIGNER_CLIENT_TARGET := $(BUILD_DIR)/n_signer_client
clients: $(N_SIGNER_CLIENT_TARGET)
$(N_SIGNER_CLIENT_TARGET): $(CLIENT_DIR)/n_signer_client.c
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(CLIENT_DIR)/n_signer_client.c -o $(N_SIGNER_CLIENT_TARGET) $(LDFLAGS)
```
Add `clients` to the `all` aggregate and to the `test-client` target so it is
built alongside the examples.
## Testing
1. Manual smoke test against a running `nsigner`:
- `n_signer_client get-public-key` → 64-hex pubkey.
- `echo '{"kind":1,"content":"hello","tags":[],"created_at":1}' | n_signer_client sign-event` → signed event with `id`, `pubkey`, `sig`.
- Pipe to `nak event` / `nak publish` to verify the signed event is well-formed.
- `n_signer_client --algorithm ed25519 sign 68656c6c6f` → 64-byte sig hex.
2. Optional bash script `tests/test_n_signer_client.sh` that:
- Spawns `nsigner --socket-name nsigner_test --listen unix --mnemonic-stdin` with a fixed test mnemonic.
- Runs each verb and asserts on stdout shape.
- Tears down the server.
## Mermaid flow
```mermaid
flowchart LR
A[stdin or argv event JSON] --> B[n_signer_client sign-event]
B --> C[nsigner_transport_open_unix]
C --> D[nsigner_client_call nostr_sign_event]
D --> E[nsigner @nsigner socket]
E --> F[signed event JSON result]
F --> G[stdout one line]
G --> H[nak publish]
```
## Out of scope
- No TUI, no approval UI — the human attendant lives in the running `nsigner`
process; the client is just a thin wire caller.
- No key storage, no mnemonic handling.
- No HTTP listener client (the `http_listener` is server-side; the client uses
the framed transports).
- No NIP-46 bunker mode (covered separately by
[`plans/nip46_bunker_mode.md`](nip46_bunker_mode.md)).
+406
View File
@@ -0,0 +1,406 @@
# Plan: Named path-roles + path-template whitelist in the wizard
## Goal
Let the user define **named roles bound to a derivation path template** in the
interactive wizard. The client then selects a key by **role name** (not by raw
path), and optionally by an **index within the role's allowed range**. The
derivation path stays hidden on the signer side — the role name acts as an
access token: if the client doesn't know the name, they can't get the key.
Example wizard session:
```
Define a named path role? [y/N] y
Role name: myrole
Purpose [nostr]: nostr
Curve [secp256k1]: secp256k1
Path template: m/44'/1237'/0-3/1/0
Default index: 1 (optional — press Enter to require explicit index)
Role 'myrole' registered: purpose=nostr curve=secp256k1 path=m/44'/1237'/0-3/1/0 (index 0..3, default 1).
Define another? [y/N] n
```
The purpose + curve combination must be valid per `crypto_alg_from_role()`
(see [`src/key_store.c`](src/key_store.c) / [`src/enforcement.c`](src/enforcement.c)).
The wizard validates the combination and re-prompts on invalid input. Valid
combinations:
| Purpose | Curve | Algorithm | Typical path prefix |
|-----------|----------------|----------------|----------------------------|
| nostr | secp256k1 | secp256k1 | m/44'/1237'/... |
| bitcoin | secp256k1 | secp256k1 | m/84'/0'/... / m/86'/... |
| ssh | ed25519 | ed25519 | m/44'/102001'/... |
| age | x25519 | x25519 | m/44'/102002'/... |
| fips | secp256k1 | secp256k1 | (FIPS mode) |
| pq-sig | ml-dsa-65 | ml-dsa-65 | m/44'/102003'/... |
| pq-sig | slh-dsa-128s | slh-dsa-128s | m/44'/102004'/... |
| pq-kem | ml-kem-768 | ml-kem-768 | m/44'/102005'/... |
The curve determines which `derive_*` function runs
([`derive_for_role`](src/key_store.c:1004)). The path template is passed
verbatim to `crypto_derive_seed_from_mnemonic` for all curves except
`secp256k1`+`nostr`, which uses the NIP-06 helper when the path matches the
NIP-06 form and the new `nostr_derive_keys_from_path` helper otherwise.
Client requests:
```json
{"id":"1","method":"nostr_get_public_key","params":[{},{"role":"myrole"}]}
```
→ derives `m/44'/1237'/1/1/0` (default index 1) and returns the pubkey.
```json
{"id":"2","method":"nostr_get_public_key","params":[{},{"role":"myrole","index":2}]}
```
→ derives `m/44'/1237'/2/1/0` (index 2, within allowed range 0-3).
```json
{"id":"3","method":"nostr_get_public_key","params":[{},{"role":"myrole","index":5}]}
```
`2003 index_out_of_range` (5 is outside 0-3).
```json
{"id":"4","method":"nostr_get_public_key","params":[{},{"role":"unknown"}]}
```
`1002 unknown_role` (name not registered).
## Why this design
The user's insight: a **role name is a password**. The client never sees the
derivation path; they only know the role name the operator gave them. This:
1. **Hides the path** from the client — they can't enumerate or guess paths.
2. **Acts as access control** — must know the name to get the key.
3. **Enforces a range** — the server only derives paths within the template's
range, so even a knowing client can't escape to `m/44'/1237'/99/1/0`.
4. **Is backward compatible** — existing `nostr_index` and `role_path`
selectors still work; named path-roles are an additive feature.
## Root cause recap (3 compounding defects this plan fixes)
1. No code path registers `SELECTOR_ROLE_PATH` roles at runtime — only
`SELECTOR_NOSTR_INDEX` roles are created
([`role_table_register_nostr_index`](src/role_table.c:805),
[`setup_default_role`](src/main.c:1708)).
2. [`crypto_derive_all`](src/key_store.c:1054) / [`crypto_derive_one`](src/key_store.c:1102)
explicitly skip roles where `selector_type != SELECTOR_NOSTR_INDEX`.
3. [`derive_secp256k1`](src/key_store.c:699) builds the path from `role->nostr_index`,
ignoring `role->role_path` entirely. The other derive_* functions
(ed25519, x25519, ml_dsa_65, slh_dsa_128s, ml_kem_768) do the same via
`snprintf(..., "m/44'/10200X'/%d'/0'/0'", role->nostr_index)`.
The "auto approve all" setting ([`g_prompt_always_allow`](src/server.c:953)) only
bypasses the approval prompt — it never runs because the 1002 hard selector error
fires first at [`server.c:2074`](src/server.c:2074) /
[`dispatcher.c:1784`](src/dispatcher.c:1784).
## Design
### New: path-template role entry
Extend `role_entry_t` (in `src/role_table.c` and mirrored decls) with two
fields:
```c
/* In role_entry_t, added after role_path[]: */
int path_range_lo; /* for SELECTOR_ROLE_PATH roles: inclusive lower bound
for the %d placeholder in role_path; -1 = no range
(single fixed path) */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index to use when client sends {"role":...}
without "index"; -1 = require explicit index */
```
A path-template role stores its template in `role_path` with a `%d`-style
placeholder segment, e.g. `role_path = "m/44'/1237'/%d/1/0"`,
`path_range_lo = 0`, `path_range_hi = 3`, `path_default_index = 1`.
### Path-template data model for the whitelist
(Kept from the previous plan — the whitelist is the underlying mechanism the
wizard uses to validate, but the user-facing UX is the named-role prompt.)
```c
#define PATH_WHITELIST_MAX_TEMPLATES 16
#define PATH_TEMPLATE_MAX_LEN 128
typedef struct {
char template[PATH_TEMPLATE_MAX_LEN]; /* "m/44'/1237'/%d/1/0" */
int range_lo;
int range_hi;
} path_template_t;
typedef struct {
int active;
int count;
path_template_t templates[PATH_WHITELIST_MAX_TEMPLATES];
} path_whitelist_t;
```
Add `path_whitelist_t path_whitelist;` to `server_ctx_t`.
### Spec syntax (for `--allow-index` CLI flag and raw whitelist input)
Each comma-separated token may be:
- `all` → no restriction
- `0-3` / `0,1,3` → existing integer `nostr_index` syntax (backward compat)
- `m/44'/1237'/0-3/0/0` → path template, range 0..3
- `m/44'/1237'/0-3/1/0` → path template, range 0..3 (the user's case)
- `m/44'/1237'/0-3/0/0,m/44'/1237'/0-3/1/0` → multiple templates
A token containing `/` is a path template; the first segment matching
`^[0-9]+(-[0-9]+)?$` is the range placeholder.
### Named-role wizard syntax (primary UX)
The wizard prompt offers two modes:
1. **Quick mode** (existing): enter a whitelist spec as above. Roles are
auto-registered on demand when a client sends a matching `role_path`.
2. **Named mode** (new): define named roles bound to path templates. The
client uses `{"role":"name"}` (optionally with `"index":N`).
## Implementation steps
### Step 1 — Extend `role_entry_t` with path-range fields
Files: `src/role_table.c` (definition), and every .c with headerless decls
mirroring `role_entry_t` (search for `selector_type` field to find all copies).
Add `path_range_lo`, `path_range_hi`, `path_default_index` after `role_path[]`.
### Step 2 — Add `path_whitelist_t` struct + field to `server_ctx_t`
Files: `src/server.c` (definition + field), `src/main.c` (headerless decls
mirror), and any other .c declaring `server_ctx_t` (search for
`index_whitelist_active`). Add constants `PATH_WHITELIST_MAX_TEMPLATES`,
`PATH_TEMPLATE_MAX_LEN`.
### Step 3 — Implement `server_set_path_whitelist()` parser in `src/server.c`
```c
int server_set_path_whitelist(server_ctx_t *ctx, const char *spec);
```
Unified parser: integer tokens → existing bitmap; path-template tokens →
`path_whitelist.templates[]`. `"all"` clears both. Returns 0 / -1.
Keep `server_set_index_whitelist` as a thin wrapper (backward compat).
### Step 4 — Implement `server_path_whitelist_allows()` in `src/server.c`
```c
int server_path_whitelist_allows(const server_ctx_t *ctx, const char *role_path);
```
Iterate templates, format each candidate with the range, `strcmp`. Return 1/0.
### Step 5 — Add `role_table_register_role_path()` helper in `src/role_table.c`
```c
int role_table_register_role_path(role_table_t *table, const char *path,
role_purpose_t purpose, role_curve_t curve,
int range_lo, int range_hi, int default_index);
```
- `purpose` and `curve` are caller-supplied (from the wizard prompt), not
hardcoded. The caller must validate the combination via
`crypto_alg_from_role(curve, purpose) != CRYPTO_ALG_UNKNOWN` before calling.
- Idempotent via `role_table_find_by_path` (compare template + range).
- Sets `selector_type = SELECTOR_ROLE_PATH`, copies `path` (with `%d`)
into `role_path`, sets `purpose`/`curve`/`purpose_str`/`curve_str` from the
enum + string forms, sets the range fields, `derived = 0`.
- Add the prototype to the headerless-decls block in every .c that includes
role_table decls.
### Step 6 — Make `derive_secp256k1` honor `role_path` in `src/key_store.c`
- When `role->selector_type == SELECTOR_ROLE_PATH`:
- If `role_path` contains `%d`, the caller must have already resolved the
concrete path (see Step 8 — the server formats `role_path` with the
chosen index before calling `crypto_derive_one`). So `derive_secp256k1`
just uses `role->role_path` directly as the full BIP-32 path.
- Call `crypto_derive_seed_from_mnemonic(phrase, role->role_path, seed, 32)`
then derive secp256k1 priv/pub from that seed.
- Add helper `nostr_derive_keys_from_path(const char *mnemonic, const char *path,
unsigned char *priv, unsigned char *pub)` (or inline using the existing
BIP-32 seed→key derivation that `nostr_derive_keys_from_mnemonic` uses).
- When `SELECTOR_NOSTR_INDEX`, keep existing behavior.
- Apply the same `SELECTOR_ROLE_PATH` branch to the other derive_* functions.
### Step 7 — Remove the `SELECTOR_NOSTR_INDEX`-only guards in `src/key_store.c`
- [`crypto_derive_all`](src/key_store.c:1054): allow `SELECTOR_ROLE_PATH`.
- [`crypto_derive_one`](src/key_store.c:1102): allow `SELECTOR_ROLE_PATH`.
### Step 8 — Wire named path-roles + whitelist into `src/server.c` request handling
In the selector-resolution block ([`server.c:2028-2066`](src/server.c:2028)):
**Case A — client sends `{"role":"myrole"}` (named path-role):**
- `selector_resolve` finds the role by name (already works for registered roles).
- If the role is a path-template role (`SELECTOR_ROLE_PATH` with `%d`):
- Read optional `"index"` from the request options.
- If no `index` and `path_default_index >= 0` → use `path_default_index`.
- If no `index` and `path_default_index < 0` → `2004 index_required`.
- Validate `index` is in `[path_range_lo, path_range_hi]` → else `2003 index_out_of_range`.
- Format the concrete path: `snprintf(concrete, ..., role_path, index)`.
- Set `pending_derivation = 1` if the role isn't derived yet, with the
concrete path stored for `crypto_derive_one`.
- If the role is a `nostr_index` role → existing behavior.
**Case B — client sends `{"role_path":"m/44'/1237'/1/1/0"}` (raw path):**
- If `server_path_whitelist_allows(ctx, role_path)` → set
`pending_derivation = 1`, synthesize role name, `purpose=nostr`,
`curve=secp256k1`.
- Else → `2003 path_not_allowed`.
**Case C — client sends `{"nostr_index":N}`:** existing behavior unchanged.
In the `if (pchk == POLICY_ALLOW && pending_derivation)` block
([`server.c:2106`](src/server.c:2106)):
- For named path-roles: the role already exists in the table; just call
`crypto_derive_one` with the concrete path (temporarily set
`role->role_path` to the concrete path, or pass the path via a side channel).
- For raw `role_path`: `role_table_register_role_path` (no `%d`, fixed path)
→ `crypto_derive_one`.
### Step 9 — Add the named-role wizard prompt in `src/main.c`
New function `prompt_named_path_roles(role_table_t *role_table)`:
```
Define a named path role? [y/N] y
Role name: myrole
Purpose [nostr]: nostr
Curve [secp256k1]: secp256k1
Path template (use 0-3 for a range, or a single number): m/44'/1237'/0-3/1/0
Default index [1]: 1
Role 'myrole' registered: purpose=nostr curve=secp256k1 path=m/44'/1237'/0-3/1/0 (index 0..3, default 1).
Define another? [y/N] n
```
- **Purpose** prompt: default `nostr`; accept any of
`nostr|bitcoin|ssh|age|fips|pq-sig|pq-kem`; parse via
`role_purpose_from_str()`.
- **Curve** prompt: default `secp256k1`; accept any of
`secp256k1|ed25519|x25519|ml-dsa-65|slh-dsa-128s|ml-kem-768`; parse via
`role_curve_from_str()`.
- **Validate** the purpose+curve combination:
`crypto_alg_from_role(curve, purpose) != CRYPTO_ALG_UNKNOWN`; re-prompt on
invalid combo (e.g. `nostr`+`ed25519` is invalid).
- Parse the path template: find the range segment, extract `range_lo`/`range_hi`,
store template with `%d`.
- Call `role_table_register_role_path(table, template, purpose, curve,
range_lo, range_hi, default_index)`.
- Loop until user declines.
- Call this after [`setup_default_role`](src/main.c:1708) and before
`crypto_derive_all` (so named roles are pre-derived at startup using their
default index).
Also update [`prompt_index_whitelist()`](src/main.c:2088) to mention that
named path-roles bypass the raw-path whitelist (they're explicitly registered).
### Step 10 — Update `--allow-index` flag + wizard text in `src/main.c`
- Update `--allow-index` help ([`main.c:1109`](src/main.c:1109)) to mention
path templates.
- Update call sites at [`main.c:2902`](src/main.c:2902) /
[`main.c:2945`](src/main.c:2945) / [`main.c:2973`](src/main.c:2973) to call
`server_set_path_whitelist`.
### Step 11 — (Optional) Also handle `role_path` in `src/dispatcher.c`
[`dispatcher.c:1778-1791`](src/dispatcher.c:1778) returns 1002 on
`SELECTOR_ERR_NOT_FOUND`. **Decision**: scope to `server.c` only for now;
stdio/qrexec still returns 1002 for unknown `role_path` (future work). Named
roles registered at startup work everywhere because they're in the role table
before any request arrives.
### Step 12 — Tests
- [`tests/test_role_table.c`](tests/test_role_table.c): test
`role_table_register_role_path` (idempotent, range fields stored).
- [`tests/test_integration.c`](tests/test_integration.c) or new
`tests/test_path_whitelist.c`:
- Parse `m/44'/1237'/0-3/0/0` → assert `server_path_whitelist_allows` returns
1 for `m/44'/1237'/2/0/0` and 0 for `m/44'/1237'/5/0/0`.
- Parse `m/44'/1237'/0-3/1/0` → assert allows `m/44'/1237'/1/1/0` (the user's
exact case), denies `m/44'/1237'/1/0/0`.
- End-to-end (named role): register `myrole` with template
`m/44'/1237'/%d/1/0`, range 0-3, default 1. Send
`{"role":"myrole"}` → assert pubkey for `m/44'/1237'/1/1/0`.
Send `{"role":"myrole","index":2}` → assert pubkey for
`m/44'/1237'/2/1/0`. Send `{"role":"myrole","index":5}` → assert
`2003 index_out_of_range`.
- End-to-end (raw path): start server with
`--allow-index "m/44'/1237'/0-3/1/0"`, send
`{"role_path":"m/44'/1237'/1/1/0"}` → assert valid pubkey.
Send `{"role_path":"m/44'/1237'/1/0/0"}` → assert `2003 path_not_allowed`.
- Backward compat: `--allow-index "0-3"` still works for `nostr_index`.
### Step 13 — Docs
- [`README.md`](README.md) §4.6: document named path-roles, the `"index"`
option, and the `2003`/`2004` error codes.
- [`README.md`](README.md) §3 (wizard): document the named-role prompt.
- [`api.md`](api.md): add error codes `2003 path_not_allowed` /
`2003 index_out_of_range` / `2004 index_required`.
- [`README.md`](README.md) error table: add the new codes.
## New error codes
| Code | Message | Meaning |
|-------|----------------------|------------------------------------------------------|
| 2003 | `path_not_allowed` | `role_path` not on the path whitelist. |
| 2003 | `index_out_of_range` | `index` outside the named role's `[lo,hi]` range. |
| 2004 | `index_required` | Named path-role has no default index and none given. |
(2003 is reused for both path-not-allowed and index-out-of-range since they're
both "whitelist range" violations; the message distinguishes them. If you
prefer distinct codes, use 2005 for `index_out_of_range`.)
## Open questions / decisions
- **Placeholder detection**: first path segment matching `^[0-9]+(-[0-9]+)?$`
is the range. No explicit `X` char needed.
- **Default purpose/curve**: `nostr` / `secp256k1` for now. Inferring from path
prefix is future work.
- **Flag name**: keep `--allow-index` for backward compat; path syntax accepted
by the same flag.
- **Pre-derivation**: named roles with a default index are pre-derived at
startup (in `crypto_derive_all`); roles without a default are derived on
first request.
- **dispatcher.c scope**: stdio/qrexec gets named roles (they're in the table
at startup) but not raw-path auto-registration (future work).
- **Distinct error codes for 2003**: decision pending — reuse 2003 with
different messages, or split into 2003/2005.
## Mermaid: request flow after implementation
```mermaid
flowchart TD
A[Client request] --> B{selector type?}
B -- role name --> C[role_table_find_by_name]
C --> D{found?}
D -- no --> E[1002 unknown_role]
D -- yes --> F{is path-template role?}
F -- no, nostr_index --> G[existing nostr_index path]
F -- yes --> H{index in options?}
H -- yes --> I{index in range lo..hi?}
H -- no --> J{default_index set?}
J -- no --> K[2004 index_required]
J -- yes --> I
I -- no --> L[2003 index_out_of_range]
I -- yes --> M[format concrete path with index]
M --> N[derive + execute verb]
G --> N
B -- role_path --> O[server_path_whitelist_allows]
O -- no --> P[2003 path_not_allowed]
O -- yes --> Q[auto-register + derive]
Q --> N
B -- nostr_index --> R[existing index whitelist check]
R --> N
```
+291
View File
@@ -0,0 +1,291 @@
# Plan: Unified hardware-signer broker for Qubes OS
Status: design / ready for review.
Related:
- [`plans/kb2040_qubes_signing_bridge.md`](kb2040_qubes_signing_bridge.md) — prior per-device bridge design (KB2040 only)
- [`plans/qrexec_persistent_bridge.md`](qrexec_persistent_bridge.md) — the analogous bridge for the *software* signer
- [`plans/auth_envelope_other_transports.md`](auth_envelope_other_transports.md) — per-program identity inside one qube
- [`plans/nsigner_browser_extension.md`](nsigner_browser_extension.md) — NIP-07 extension that should target this broker
- [`documents/QUBES_OS.md`](../documents/QUBES_OS.md) — AppVM-persistence pattern, usbguard notes
- [`firmware/README.md`](../firmware/README.md) — per-variant USB identities and validation flows
- [`examples/kb2040_hidden_signer_client.py`](../examples/kb2040_hidden_signer_client.py) — proven host-side framing logic to reuse
---
## 1. Goal
Use **any** hardware n_signer variant, plugged into the machine once, as a shared signer reachable from **any qube** and **any application** — without one qube/application capturing the USB device and locking out the rest.
This generalizes [`plans/kb2040_qubes_signing_bridge.md`](kb2040_qubes_signing_bridge.md) from a single device to a unified broker that covers every hardware variant in `firmware/`.
---
## 2. The core problem (why sharing is non-trivial on Qubes)
Two Qubes constraints combine to make "share one USB signer" hard:
1. **USB is routed at whole-device granularity.** `qvm-usb attach` moves the entire USB device (all interfaces) to one qube. For composite devices (KB2040 HID+CDC, Feather CDC+WebUSB), attaching the signing interface to an app qube also detaches the HID interface from dom0's input proxy → media keys die globally.
2. **A USB endpoint is exclusively owned by one process in one qube.** Two qubes cannot each open the CDC/WebUSB node at the same time. Whichever qube opens it captures it.
A third constraint applies specifically to the browser:
3. **Browser WebUSB / Web Serial can only open a device attached to the browser's own qube.** A device owned by `sys-usb` is invisible to a browser in `personal`/`work`. So a browser using WebUSB is *forced* to capture the device — which is exactly the behavior the user wants to escape.
The only way to share is: **no app qube opens the device directly.** Keep the device in one owner qube, run a broker there that holds the single exclusive handle, and multiplex all callers over qrexec.
---
## 3. Chosen design: a unified broker in the USB-owner qube
One long-lived **broker daemon** runs in the owner qube (default `sys-usb`). It:
- discovers and opens the hardware signer's serial/WebUSB endpoint by **VID:PID** (or BLE address, future),
- holds the **single exclusive handle** for the device lifetime,
- listens on a local UNIX socket (`/run/nsigner-hw.sock`),
- accepts one framed JSON-RPC request per qrexec connection,
- **serializes** concurrent callers with an internal lock/queue so frames never interleave on the wire,
- forwards the frame to the device, relays the framed response back,
- reopens the device on re-enumeration (unplug/replug, 1200-baud touch, CH340 re-enumeration).
The broker is **hardware-agnostic at the JSON-RPC layer**: every variant speaks the same algorithm-based API ([`README.md`](../README.md) §4) over the same `4-byte big-endian length + UTF-8 JSON` framing. Per-variant logic is isolated in a small **transport adapter**.
```mermaid
flowchart TD
HW[Hardware signer: KB2040, Feather, CYD, Teensy, IR dongle]
subgraph OWNER[owner qube: sys-usb default]
ADAPT[transport adapter: open by VID:PID]
BRK[broker: exclusive handle, serialize, reopen]
SVC[qrexec service qubes.NsignerHwRpc]
end
subgraph DOM0[dom0]
INPUT[input proxy: media keys for composite HID]
POL[qrexec policy: ask plus deny-by-default]
end
subgraph Q[any caller qube]
APP[CLI, nostr_terminal, NIP-07 extension native helper]
end
HW --> ADAPT
ADAPT --> BRK
HW -. composite HID .-> INPUT
APP -->|qrexec framed JSON-RPC| POL --> SVC --> BRK --> ADAPT --> HW
ADAPT --> BRK --> SVC --> POL --> APP
```
---
## 4. Unified transport adapter model
The broker core talks to a registry of adapters. Each adapter implements a tiny interface (open / read_frame / write_frame / close / status). Most variants collapse to "open a serial node by VID:PID":
| Variant | USB identity | Node | Adapter notes |
|---|---|---|---|
| KB2040 hidden signer | composite HID + CDC, `239a:cafe` | `/dev/ttyACM*` | CDC-ACM; HID stays on dom0 input proxy because device never leaves sys-usb |
| Feather S3 TFT | TinyUSB composite CDC + WebUSB vendor, `303a:4001` | `/dev/ttyACM*` | CDC-ACM (preferred); WebUSB vendor endpoint is an alternative adapter, not needed when broker owns CDC |
| CYD ESP32-2432S028 | CH340 serial, `1a86:7523` | `/dev/ttyUSB*` | serial; **clear DTR/RTS on open** to avoid ESP32 auto-reset (see [`firmware/README.md`](../firmware/README.md) §CYD note) |
| Teensy 4.1 | USB CDC | `/dev/ttyACM*` | CDC-ACM |
| IR air-gap dongle | USB CDC dumb pipe | `/dev/ttyACM*` | CDC-ACM; dongle is a transparent byte pipe |
| BLE wearable (concept) | BLE GATT | n/a | future adapter: BLE scan + GATT characteristic; stub for now |
Adapter selection: broker config lists one or more `(VID, PID)` tuples (or a BLE address) and tries them in order until one opens. This lets the operator point the broker at whichever device is plugged in, without changing the broker core.
Reference logic to reuse: [`examples/kb2040_hidden_signer_client.py`](../examples/kb2040_hidden_signer_client.py) already does VID:PID discovery + framed read/write. The broker is essentially that logic plus a unix-socket server and a serialize lock.
---
## 5. The browser path (the crux of the capture problem)
**Recommendation: the browser must NOT use WebUSB/Web Serial in the shared model.** It should reach the broker via qrexec through a NIP-07 native-messaging extension.
Why this is the only sharing-compatible path:
- WebUSB/Web Serial can only see a device `qvm-usb`-attached to the browser's own qube. That attach captures the whole device (and kills media keys for composite devices). It is the capture the user is trying to eliminate.
- The NIP-07 extension already planned in [`plans/nsigner_browser_extension.md`](nsigner_browser_extension.md) has a "native messaging bridge" transport. Point that native helper at `qrexec-client-vm sys-usb qubes.NsignerHwRpc` and the browser joins the shared model with zero USB capture.
Supported modes (both documented, qrexec is the default):
| Mode | How | Sharing? | Media keys (composite)? |
|---|---|---|---|
| **qrexec / NIP-07** (recommended) | browser extension native helper → `qrexec-client-vm sys-usb qubes.NsignerHwRpc` | ✅ all qubes share | ✅ preserved |
| **WebUSB direct-attach** (opt-out) | `qvm-usb attach personal <device>`, browser opens WebUSB | ❌ browser qube captures device | ❌ media dies globally while attached |
The direct-attach mode is documented as "this opts out of sharing; use only for isolated single-qube workflows." The default and recommended path is qrexec.
### 5.1 Concrete finding: nostr_login_lite is the capture problem
`nostr_login_lite` (sibling project at `~/lt/nostr_login_lite`) is the concrete instance of the browser-capture problem. Its `nsigner` auth method opens the hardware signer **directly** via browser USB APIs — there is no intermediary:
- [`src/signers/nsigner-webusb.js`](../nostr_login_lite/src/signers/nsigner-webusb.js:11) calls `navigator.usb.requestDevice(...)` then `device.open()` / `claimInterface()` — raw WebUSB.
- [`src/signers/nsigner-webserial.js`](../nostr_login_lite/src/signers/nsigner-webserial.js:10) calls `navigator.serial.requestPort()` then `port.open({baudRate:115200,...})` — raw Web Serial.
Both APIs can only see USB devices routed to the qube the browser runs in. On a normal Linux host the browser sees every USB device; on Qubes the browser sees **only** devices `qvm-usb attach`ed to its qube. So when `nostr_login_lite` connects via the `nsigner` method, it **forces** the device to be attached to the browser's qube — which is exactly the capture this plan exists to eliminate. The library is doing the capturing; it is not a workaround for it.
Implication for this plan: `nostr_login_lite` needs a **new transport** — a `nsigner-qrexec` signer module that shells out to `qrexec-client-vm sys-usb qubes.NsignerHwRpc` with framed JSON-RPC, instead of opening USB directly. Its public RPC surface (`getPublicKey`, `signEvent`, `nip04Encrypt/Decrypt`, `nip44Encrypt/Decrypt`) is already transport-agnostic — the existing WebUSB and Web Serial classes are two transports implementing the same surface; a qrexec transport would be a third. This is a small, well-scoped addition to `nostr_login_lite` and is the bridge between this broker plan and the browser.
---
## 6. Owner-qube decision
**Recommendation: `sys-usb` (default).** Offer a dedicated `nsigner-usb` qube as a hardened variant.
| | `sys-usb` (default) | dedicated `nsigner-usb` |
|---|---|---|
| Media-key input proxy | unchanged — device stays in sys-usb, HID flows to dom0 as today | must re-proxy HID from `nsigner-usb` to dom0 via qrexec input policy (larger change) |
| Isolation | broker shares sys-usb's broader USB visibility | broker in a minimal qube that owns only the signer |
| Setup complexity | lowest | higher (per-device auto-attach + input-policy migration) |
| Trust scope | sys-usb can see sign requests; mitigated by on-device approval + dom0 `ask` | smaller blast radius |
Decision: **default to `sys-usb`** because (a) it preserves media keys for composite devices with no input-policy migration, (b) it matches the prior per-device plan, and (c) the user's chosen approval model (physical button every signature) is the real trust anchor, making sys-usb's visibility acceptable. Document `nsigner-usb` as an optional hardened path for users who want stronger isolation and are willing to migrate the input proxy (mainly relevant for composite devices).
---
## 7. Approval UX
User chose: **physical button press on the device for every signature** (highest assurance).
Implications:
- Device must be in **signer mode** for `sign_event` to work (e.g. KB2040 PLAY+PREV chord). `get_public_key` works in either mode.
- Each remote qrexec `sign_event` call **blocks** at the broker until the user physically approves at the hardware.
- dom0 `ask` adds a **second, per-call Qubes prompt** identifying the calling qube — defense in depth. Keep it.
- The broker must surface, to the caller qube:
- `2015 "not in signer mode"` (and any other device error) clearly and actionable,
- a "waiting for physical approval" state so the caller knows why it is blocking (optional: a heartbeat/progress frame; v1 can simply block with a timeout).
- Optional firmware enhancement (later): forward the source-qube name to the device so the OLED shows "approve kind 1 from qubes:personal?" — requires a firmware caller-field addition; not needed for v1.
---
## 8. Identity and enforcement layers
Unlike the software-signer bridge ([`plans/qrexec_persistent_bridge.md`](qrexec_persistent_bridge.md)), there is **no separate persistent nsigner process with a mnemonic** — the hardware holds the keys and performs approval. So the enforcement stack is:
1. **dom0 qrexec policy** (`ask`/`deny`, per calling qube) — first gate.
2. **Hardware physical approval** — final gate, per signature.
The broker is a **dumb relay**: it does not run n_signer's policy/approval engine, because the hardware is the approval surface. The broker reads `QREXEC_REMOTE_DOMAIN` only to (optionally) log/forward the source qube for display; it is not an enforcement point.
Per-application granularity inside one qube (the auth-envelope story in [`plans/auth_envelope_other_transports.md`](auth_envelope_other_transports.md)) would require the **firmware** to verify kind-27235 envelopes — a future firmware enhancement, out of scope for v1.
---
## 9. Concurrency and re-enumeration
- **Concurrency:** the broker holds one exclusive device handle. An internal mutex + request queue guarantees that concurrent qrexec calls never interleave frames on the wire. Calls are serviced one at a time; others wait.
- **Re-enumeration:** unplug/replug, 1200-baud touch reset, or CH340 re-enumeration changes `/dev/ttyACM*` or `/dev/ttyUSB*`. The broker rediscovers by VID:PID and reopens transparently. A call in flight when the device drops returns a clear "device disconnected" error.
- **CYD auto-reset:** opening `/dev/ttyUSB*` can reset the ESP32 via CH340 DTR/RTS. The broker clears DTR/RTS immediately after open. Document the 10 µF EN↔GND capacitor mod ([`firmware/README.md`](../firmware/README.md) §CYD) as the hardware-level fix.
---
## 10. Components
### A. Broker daemon (runs in owner qube)
- Discovers/opens the device via the adapter registry (VID:PID list or BLE address).
- Holds the single exclusive handle for the device lifetime.
- Listens on `/run/nsigner-hw.sock`.
- Accepts one framed JSON-RPC request per connection, forwards to device, returns framed response.
- Serializes access with a mutex + queue.
- Reopens on re-enumeration.
- v1 implementation: Python (pragmatic, reuses [`examples/kb2040_hidden_signer_client.py`](../examples/kb2040_hidden_signer_client.py)), at `packaging/qubes/hw_bridge/nsigner_hw_broker.py`.
- Long-term: a `nsigner hw-broker` C subcommand that ships in the static binary and reuses the existing framing code (mirrors the `nsigner bridge` subcommand in [`plans/qrexec_persistent_bridge.md`](qrexec_persistent_bridge.md) §5.2).
### B. qrexec service entrypoint (runs in owner qube)
`packaging/qubes/rpc/qubes.NsignerHwRpc` — a thin stateless relay:
1. Read one framed request from qrexec stdin.
2. Connect to `/run/nsigner-hw.sock`, relay the frame, read the framed reply.
3. Write the framed reply to qrexec stdout.
4. Exit.
Mirrors the shape of [`packaging/qubes/rpc/qubes.NsignerRpc`](../packaging/qubes/rpc/qubes.NsignerRpc). Distinct service name (`qubes.NsignerHwRpc`) so the hardware and software paths never collide.
### C. dom0 policy
`packaging/qubes/policy.d/41-nsigner-hw.policy`:
```
qubes.NsignerHwRpc * @anyvm @tag:nsigner-hw-bridge ask default_target=sys-usb
qubes.NsignerHwRpc * @anyvm @anyvm deny
```
- `ask` + deny-by-default mirrors [`40-nsigner.policy`](../packaging/qubes/policy.d/40-nsigner.policy).
- Tag the owner qube: `qvm-tags sys-usb add nsigner-hw-bridge`.
### D. Caller helper (any qube)
Extend [`documents/qubes_client_examples.md`](../documents/qubes_client_examples.md) with `qubes.NsignerHwRpc` examples (shell + Python): framed `get_public_key` / `sign_event` over `qrexec-client-vm sys-usb qubes.NsignerHwRpc`.
### E. Browser integration (recommended path)
Wire the NIP-07 extension's native-messaging helper ([`plans/nsigner_browser_extension.md`](nsigner_browser_extension.md) §5) to call `qrexec-client-vm sys-usb qubes.NsignerHwRpc`. Document WebUSB direct-attach as an opt-out that breaks sharing.
### F. Install scripts
- `install-hw-bridge.sh` (owner-qube side): broker + service + udev rules + autostart, AppVM-persistent via `/rw/config/rc.local` + template package (pattern in [`documents/QUBES_OS.md`](../documents/QUBES_OS.md) §5.5).
- `install-hw-policy.sh` (dom0 side): install `41-nsigner-hw.policy`.
---
## 11. Packaging and persistence
`sys-usb` is usually an AppVM: root filesystem resets at reboot. Persist via:
- broker + service installed into the template (or `/rw/bind`-mounted),
- udev rules for the CDC/serial node permissions inside sys-usb (reuse the `99-rp2040.rules` / `99-nsigner-webusb.rules` approach in [`firmware/README.md`](../firmware/README.md)),
- `/rw/config/rc.local` starts the broker at boot,
- `qvm-tags sys-usb add nsigner-hw-bridge` and dom0 policy persist in dom0.
---
## 12. Security model
- **Private keys never leave the hardware.** The broker only relays opaque frames; it cannot extract keys.
- **Owner-qube trust scope:** sys-usb can see what you ask to sign and could deny/forge requests. Mitigated by (a) on-device physical approval per signature, (b) dom0 `ask` per calling qube, (c) deny-by-default policy.
- **No mnemonic on disk/argv/env:** the broker holds no key material at all — the hardware is the key store.
- **No off-host connectivity:** qrexec is intra-host IPC; no network.
- **Hardened variant:** a dedicated `nsigner-usb` qube shrinks the broker's blast radius at the cost of input-proxy migration for composite devices.
---
## 13. Risks and edge cases
- **Signer-mode requirement:** remote `sign_event` fails with `2015` unless the device is in signer mode; broker returns a clear, actionable error.
- **Device re-enumeration:** broker rediscover by VID:PID; in-flight call returns "device disconnected."
- **Concurrency:** mutex + queue in broker; concurrent qube calls serialized.
- **CYD DTR/RTS reset:** clear DTR/RTS on open; document 10 µF capacitor mod.
- **Composite HID:** keep device in sys-usb; do **not** `qvm-usb attach` to app qubes or media dies.
- **Browser WebUSB capture:** documented as opt-out; recommended path is qrexec/NIP-07.
- **Blocking approval UX:** a sign call blocks until physical approval; broker should expose a timeout and a "waiting for approval" state so callers do not hang silently.
- **sys-usb AppVM persistence:** broker install must survive reboot via template + `/rw/config/rc.local`.
---
## 14. Implementation checklist
Code:
- [ ] Broker daemon `packaging/qubes/hw_bridge/nsigner_hw_broker.py`: adapter registry (VID:PID open), exclusive handle, unix socket, serialize lock/queue, reopen-on-reenumerate; reuse framing from [`examples/kb2040_hidden_signer_client.py`](../examples/kb2040_hidden_signer_client.py).
- [ ] Adapter config covering KB2040 `239a:cafe`, Feather `303a:4001`, CYD `1a86:7523`, Teensy CDC, IR dongle CDC; CYD adapter clears DTR/RTS on open.
- [ ] (Optional, later) `nsigner hw-broker` C subcommand replacing the Python broker.
Packaging:
- [ ] qrexec service `packaging/qubes/rpc/qubes.NsignerHwRpc`: relay one frame stdin→socket→stdout.
- [ ] dom0 policy `packaging/qubes/policy.d/41-nsigner-hw.policy`: `ask` + deny-by-default, target `sys-usb`.
- [ ] `install-hw-bridge.sh` (owner qube: broker + service + udev + autostart, AppVM-persistent).
- [ ] `install-hw-policy.sh` (dom0).
Docs and callers:
- [ ] Extend [`documents/qubes_client_examples.md`](../documents/qubes_client_examples.md) with `qubes.NsignerHwRpc` shell + Python examples.
- [ ] Document the browser qrexec/NIP-07 path and the WebUSB direct-attach opt-out.
- [ ] Document owner-qube choice (sys-usb default, nsigner-usb hardened variant) and the composite-HID input-proxy tradeoff.
Verification runbook:
- [ ] Media keys still work globally (composite device stays in sys-usb).
- [ ] `get_public_key` from a caller qube succeeds.
- [ ] `sign_event` from a caller qube blocks until physical approval, then succeeds.
- [ ] `sign_event` with device not in signer mode returns clear `2015` error.
- [ ] Deny from an untagged/unsupported qube.
- [ ] Two qubes signing concurrently are serialized (no frame interleaving).
- [ ] Survive unplug/replug: broker reopens, next call succeeds.
- [ ] Browser via NIP-07 native helper → qrexec signs without capturing USB.
+7
View File
@@ -118,6 +118,11 @@ typedef struct {
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
int derived; /* 1 if pubkey_hex has been populated */
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
/* The role table */
@@ -176,6 +181,8 @@ typedef struct {
int has_role_path; /* 1 if "role_path" field was present */
char role_path[ROLE_PATH_MAX];
int has_index; /* 1 if "index" field was present (for named path-roles) */
int index; /* index value for named path-role template */
} selector_request_t;
/* Initialize a selector request (all fields zeroed/unset) */
+7
View File
@@ -116,6 +116,11 @@ typedef struct {
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
int derived; /* 1 if pubkey_hex has been populated */
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
/* The role table */
@@ -174,6 +179,8 @@ typedef struct {
int has_role_path; /* 1 if "role_path" field was present */
char role_path[ROLE_PATH_MAX];
int has_index; /* 1 if "index" field was present (for named path-roles) */
int index; /* index value for named path-role template */
} selector_request_t;
/* Initialize a selector request (all fields zeroed/unset) */
+172 -9
View File
@@ -118,6 +118,11 @@ typedef struct {
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
int derived; /* 1 if pubkey_hex has been populated */
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
/* The role table */
@@ -176,6 +181,8 @@ typedef struct {
int has_role_path; /* 1 if "role_path" field was present */
char role_path[ROLE_PATH_MAX];
int has_index; /* 1 if "index" field was present (for named path-roles) */
int index; /* index value for named path-role template */
} selector_request_t;
/* Initialize a selector request (all fields zeroed/unset) */
@@ -673,6 +680,128 @@ int socket_name_random(char *out, size_t out_len);
#define NSIGNER_ENCRYPT_OUTPUT_MAX 65536
/*
* Parse a BIP-44 derivation path string (e.g. "m/44'/1237'/1/1/0") into a
* uint32_t array suitable for nostr_bip32_derive_path(). Hardened segments
* are indicated by a trailing ' (or h). Returns the number of path components
* on success, or -1 on parse error. max_path is the max number of entries
* in the path_out array.
*/
static int parse_bip44_path(const char *path_str, uint32_t *path_out, int max_path) {
char buf[ROLE_PATH_MAX];
char *p;
int count = 0;
if (path_str == NULL || path_out == NULL || max_path <= 0) {
return -1;
}
strncpy(buf, path_str, sizeof(buf) - 1);
buf[sizeof(buf) - 1] = '\0';
/* Skip leading "m" or "m/" */
p = buf;
if (*p == 'm' || *p == 'M') {
p++;
if (*p == '/') {
p++;
} else if (*p != '\0') {
return -1; /* "m" must be followed by '/' or end */
}
}
while (*p != '\0' && count < max_path) {
char *slash = strchr(p, '/');
char seg[24];
size_t seg_len;
int hardened = 0;
char *endptr = NULL;
long val;
if (slash != NULL) {
seg_len = (size_t)(slash - p);
} else {
seg_len = strlen(p);
}
if (seg_len == 0 || seg_len >= sizeof(seg)) {
return -1;
}
memcpy(seg, p, seg_len);
seg[seg_len] = '\0';
/* Check for hardened marker ' or h at end */
if (seg[seg_len - 1] == '\'' || seg[seg_len - 1] == 'h' || seg[seg_len - 1] == 'H') {
hardened = 1;
seg[seg_len - 1] = '\0';
}
val = strtol(seg, &endptr, 10);
if (*endptr != '\0' || val < 0 || val > 0x7FFFFFFF) {
return -1;
}
path_out[count] = (uint32_t)val;
if (hardened) {
path_out[count] |= 0x80000000u;
}
count++;
p = (slash != NULL) ? slash + 1 : "";
if (*p == '\0') {
break;
}
}
return count;
}
/*
* Derive a secp256k1 key from an explicit BIP-44 path string.
* Uses BIP-32 derivation (nostr_bip32_key_from_seed + nostr_bip32_derive_path).
* priv_out and pub_out must each be at least 32 bytes. Returns 0 on success,
* -1 on failure.
*/
static int derive_secp256k1_from_path(const char *mnemonic, const char *path_str,
unsigned char *priv_out, unsigned char *pub_out) {
unsigned char bip39_seed[64];
nostr_hd_key_t master_key;
nostr_hd_key_t derived_key;
uint32_t path[16];
int path_len;
if (mnemonic == NULL || path_str == NULL || priv_out == NULL || pub_out == NULL) {
return -1;
}
path_len = parse_bip44_path(path_str, path, (int)(sizeof(path) / sizeof(path[0])));
if (path_len <= 0) {
return -1;
}
if (nostr_bip39_mnemonic_to_seed(mnemonic, "", bip39_seed, sizeof(bip39_seed)) != 0) {
return -1;
}
if (nostr_bip32_key_from_seed(bip39_seed, sizeof(bip39_seed), &master_key) != 0) {
secure_memzero(bip39_seed, sizeof(bip39_seed));
return -1;
}
if (nostr_bip32_derive_path(&master_key, path, (size_t)path_len, &derived_key) != 0) {
secure_memzero(bip39_seed, sizeof(bip39_seed));
secure_memzero(&master_key, sizeof(master_key));
return -1;
}
memcpy(priv_out, derived_key.private_key, 32);
memcpy(pub_out, derived_key.public_key + 1, 32); /* x-only (drop compression prefix) */
secure_memzero(bip39_seed, sizeof(bip39_seed));
secure_memzero(&master_key, sizeof(master_key));
secure_memzero(&derived_key, sizeof(derived_key));
return 0;
}
/*
* Derive a secp256k1 (Nostr) key for a role into the variable-length
* derived_key_t. Returns 0 on success, -1 on failure.
@@ -682,6 +811,7 @@ static int derive_secp256k1(derived_key_t *dst, const role_entry_t *role,
unsigned char priv[32];
unsigned char pub[32];
const crypto_alg_sizes_t *sz;
int rc;
sz = crypto_alg_get_sizes(CRYPTO_ALG_SECP256K1);
if (sz == NULL) {
@@ -696,8 +826,14 @@ static int derive_secp256k1(derived_key_t *dst, const role_entry_t *role,
return -1;
}
if (nostr_derive_keys_from_mnemonic(mnemonic_get_phrase(mnemonic),
role->nostr_index, priv, pub) != 0) {
if (role->selector_type == SELECTOR_ROLE_PATH) {
rc = derive_secp256k1_from_path(mnemonic_get_phrase(mnemonic),
role->role_path, priv, pub);
} else {
rc = nostr_derive_keys_from_mnemonic(mnemonic_get_phrase(mnemonic),
role->nostr_index, priv, pub);
}
if (rc != 0) {
secure_memzero(priv, sizeof(priv));
secure_memzero(pub, sizeof(pub));
secure_buf_free(&dst->private_key);
@@ -747,7 +883,12 @@ static int derive_ed25519(derived_key_t *dst, const role_entry_t *role,
return -1;
}
snprintf(path, sizeof(path), "m/44'/102001'/%d'/0'/0'", role->nostr_index);
if (role->selector_type == SELECTOR_ROLE_PATH) {
strncpy(path, role->role_path, sizeof(path) - 1);
path[sizeof(path) - 1] = '\0';
} else {
snprintf(path, sizeof(path), "m/44'/102001'/%d'/0'/0'", role->nostr_index);
}
if (crypto_derive_seed_from_mnemonic(mnemonic_get_phrase(mnemonic), path,
seed, sizeof(seed)) != 0) {
@@ -805,7 +946,12 @@ static int derive_x25519(derived_key_t *dst, const role_entry_t *role,
return -1;
}
snprintf(path, sizeof(path), "m/44'/102002'/%d'/0'/0'", role->nostr_index);
if (role->selector_type == SELECTOR_ROLE_PATH) {
strncpy(path, role->role_path, sizeof(path) - 1);
path[sizeof(path) - 1] = '\0';
} else {
snprintf(path, sizeof(path), "m/44'/102002'/%d'/0'/0'", role->nostr_index);
}
if (crypto_derive_seed_from_mnemonic(mnemonic_get_phrase(mnemonic), path,
seed, sizeof(seed)) != 0) {
@@ -861,7 +1007,12 @@ static int derive_ml_dsa_65(derived_key_t *dst, const role_entry_t *role,
return -1;
}
snprintf(path, sizeof(path), "m/44'/102003'/%d'/0'/0'", role->nostr_index);
if (role->selector_type == SELECTOR_ROLE_PATH) {
strncpy(path, role->role_path, sizeof(path) - 1);
path[sizeof(path) - 1] = '\0';
} else {
snprintf(path, sizeof(path), "m/44'/102003'/%d'/0'/0'", role->nostr_index);
}
if (crypto_derive_seed_from_mnemonic(mnemonic_get_phrase(mnemonic), path,
seed, sizeof(seed)) != 0) {
@@ -914,7 +1065,12 @@ static int derive_slh_dsa_128s(derived_key_t *dst, const role_entry_t *role,
return -1;
}
snprintf(path, sizeof(path), "m/44'/102004'/%d'/0'/0'", role->nostr_index);
if (role->selector_type == SELECTOR_ROLE_PATH) {
strncpy(path, role->role_path, sizeof(path) - 1);
path[sizeof(path) - 1] = '\0';
} else {
snprintf(path, sizeof(path), "m/44'/102004'/%d'/0'/0'", role->nostr_index);
}
if (crypto_derive_seed_from_mnemonic(mnemonic_get_phrase(mnemonic), path,
seed, sizeof(seed)) != 0) {
@@ -967,7 +1123,12 @@ static int derive_ml_kem_768(derived_key_t *dst, const role_entry_t *role,
return -1;
}
snprintf(path, sizeof(path), "m/44'/102005'/%d'/0'/0'", role->nostr_index);
if (role->selector_type == SELECTOR_ROLE_PATH) {
strncpy(path, role->role_path, sizeof(path) - 1);
path[sizeof(path) - 1] = '\0';
} else {
snprintf(path, sizeof(path), "m/44'/102005'/%d'/0'/0'", role->nostr_index);
}
if (crypto_derive_seed_from_mnemonic(mnemonic_get_phrase(mnemonic), path,
seed, sizeof(seed)) != 0) {
@@ -1051,7 +1212,8 @@ int crypto_derive_all(key_store_t *store, role_table_t *table, const mnemonic_st
role->derived = 0;
role->pubkey_hex[0] = '\0';
if (role->selector_type != SELECTOR_NOSTR_INDEX) {
if (role->selector_type != SELECTOR_NOSTR_INDEX &&
role->selector_type != SELECTOR_ROLE_PATH) {
continue;
}
@@ -1099,7 +1261,8 @@ int crypto_derive_one(key_store_t *store, role_table_t *table, const mnemonic_st
dst->alg = CRYPTO_ALG_UNKNOWN;
dst->valid = 0;
if (role->selector_type != SELECTOR_NOSTR_INDEX) {
if (role->selector_type != SELECTOR_NOSTR_INDEX &&
role->selector_type != SELECTOR_ROLE_PATH) {
return -1;
}
+706 -20
View File
@@ -119,6 +119,11 @@ typedef struct {
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
int derived; /* 1 if pubkey_hex has been populated */
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
/* The role table */
@@ -160,7 +165,11 @@ const char *role_curve_to_str(role_curve_t c);
/* Register a nostr-index role if missing. Returns 0 on success, -1 on error. */
int role_table_register_nostr_index(role_table_t *table, int nostr_index);
/* Register a SELECTOR_ROLE_PATH role bound to an explicit derivation path template. */
int role_table_register_role_path(role_table_t *table, const char *name, const char *path,
role_purpose_t purpose, role_curve_t curve,
int range_lo, int range_hi, int default_index,
const int *allowed_indices, int allowed_count);
/* from selector.h */
@@ -180,6 +189,8 @@ typedef struct {
int has_role_path; /* 1 if "role_path" field was present */
char role_path[ROLE_PATH_MAX];
int has_index; /* 1 if "index" field was present (for named path-roles) */
int index; /* index value for named path-role template */
} selector_request_t;
/* Initialize a selector request (all fields zeroed/unset) */
@@ -674,6 +685,24 @@ typedef struct {
#define INDEX_WHITELIST_MAX 256
#define INDEX_WHITELIST_BITMAP_SIZE (INDEX_WHITELIST_MAX / 8)
#define PATH_WHITELIST_MAX_TEMPLATES 16
#define PATH_TEMPLATE_MAX_LEN 128
#define PATH_TEMPLATE_MAX_INDICES 64 /* max allowed indices per template (for sets) */
typedef struct {
char template[PATH_TEMPLATE_MAX_LEN]; /* e.g. "m/44'/1237'/%d/1/0" — one %d placeholder */
int range_lo; /* inclusive lower bound (for range form) */
int range_hi; /* inclusive upper bound (== range_lo for single) */
int allowed_indices[PATH_TEMPLATE_MAX_INDICES]; /* explicit set of allowed indices */
int allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} path_template_t;
typedef struct {
int active; /* 1 if any path templates are configured */
int count;
path_template_t templates[PATH_WHITELIST_MAX_TEMPLATES];
} path_whitelist_t;
typedef struct {
char socket_name[SERVER_SOCKET_NAME_MAX]; /* abstract namespace name (without \0 prefix) */
char last_error[256];
@@ -689,6 +718,7 @@ typedef struct {
int bridge_source_trusted;
int index_whitelist_active;
unsigned char index_whitelist[INDEX_WHITELIST_BITMAP_SIZE];
path_whitelist_t path_whitelist; /* path-template whitelist for role_path requests */
} server_ctx_t;
/* Initialize server context. socket_name is the abstract namespace name (e.g. "nsigner").
@@ -725,6 +755,14 @@ void server_set_bridge_source_trusted(server_ctx_t *ctx, int enabled);
/* Set the nostr_index whitelist from a spec string ("all", "1,3,4", "0-3", "0-3,7,9") */
int server_set_index_whitelist(server_ctx_t *ctx, const char *spec);
/* Set the unified whitelist (integer nostr_index + path templates) from a spec string.
* Spec: "all", or comma-separated tokens. Integer tokens ("0-3","1,3,4") set the
* nostr_index bitmap. Path-template tokens ("m/44'/1237'/0-3/1/0") set the path whitelist. */
int server_set_path_whitelist(server_ctx_t *ctx, const char *spec);
/* Check if a role_path is allowed by the path whitelist. Returns 1 if allowed, 0 if not. */
int server_path_whitelist_allows(const server_ctx_t *ctx, const char *role_path);
/* Configure non-interactive prompt fallback: -1 disabled, POLICY_ALLOW, or POLICY_DENY */
void server_set_noninteractive_prompt_default(int decision);
@@ -762,8 +800,8 @@ int socket_name_random(char *out, size_t out_len);
/* Version information (auto-updated by build/version tooling) */
#define NSIGNER_VERSION_MAJOR 0
#define NSIGNER_VERSION_MINOR 1
#define NSIGNER_VERSION_PATCH 8
#define NSIGNER_VERSION "v0.1.8"
#define NSIGNER_VERSION_PATCH 14
#define NSIGNER_VERSION "v0.1.14"
/* NSIGNER_HEADERLESS_DECLS_END */
@@ -931,6 +969,204 @@ static int read_line_stdin(char *buf, size_t buf_sz) {
return 0;
}
/*
* Read a line from stdin with inline editing support, using termios raw mode.
* Pre-fills the buffer with `prefill` (if non-NULL), positions the cursor at
* the end, and allows arrow-key navigation, backspace, delete, home, end,
* and regular character insertion. On Enter, returns the edited string in
* `buf`. Returns 0 on success, -1 on error/EOF.
*
* Only works when stdin is a TTY. Falls back to read_line_stdin if not a TTY
* (in which case prefill is ignored).
*/
static int read_line_editable(char *buf, size_t buf_sz, const char *prefill) {
struct termios old_term, new_term;
size_t len = 0; /* current text length */
size_t pos = 0; /* cursor position (0..len) */
int fd = STDIN_FILENO;
int was_raw = 0;
if (buf == NULL || buf_sz == 0) {
return -1;
}
/* If not a TTY, fall back to plain fgets */
if (!isatty(fd)) {
return read_line_stdin(buf, buf_sz);
}
/* Pre-fill */
if (prefill != NULL) {
len = strlen(prefill);
if (len >= buf_sz) len = buf_sz - 1;
memcpy(buf, prefill, len);
buf[len] = '\0';
pos = len;
} else {
buf[0] = '\0';
}
/* Enter raw mode */
if (tcgetattr(fd, &old_term) == 0) {
new_term = old_term;
new_term.c_lflag &= ~(ICANON | ECHO);
new_term.c_cc[VMIN] = 1;
new_term.c_cc[VTIME] = 0;
if (tcsetattr(fd, TCSANOW, &new_term) == 0) {
was_raw = 1;
}
}
/* Draw the initial pre-filled text */
if (len > 0) {
fputs(buf, stdout);
fflush(stdout);
}
for (;;) {
char ch;
ssize_t n = read(fd, &ch, 1);
if (n <= 0) {
if (was_raw) tcsetattr(fd, TCSANOW, &old_term);
return -1;
}
if (ch == '\n' || ch == '\r') {
/* Enter — done */
buf[len] = '\0';
fputc('\n', stdout);
fflush(stdout);
break;
} else if (ch == 0x7f || ch == 0x08) {
/* Backspace (DEL or BS) — delete char before cursor */
if (pos > 0) {
size_t i;
for (i = pos - 1; i < len - 1; i++) {
buf[i] = buf[i + 1];
}
len--;
pos--;
buf[len] = '\0';
/* Redraw: move to start of field, clear line, redraw, reposition */
fputs("\r\033[K", stdout); /* CR + clear to end of line */
fputs(buf, stdout);
if (pos < len) {
/* Move cursor left to pos */
printf("\033[%zuD", len - pos);
}
fflush(stdout);
}
} else if (ch == 0x1b) {
/* Escape sequence — arrow keys, etc. */
char seq[2];
if (read(fd, &seq[0], 1) <= 0) continue;
if (read(fd, &seq[1], 1) <= 0) continue;
if (seq[0] == '[') {
if (seq[1] == 'D') {
/* Left arrow */
if (pos > 0) {
pos--;
fputs("\033[D", stdout);
fflush(stdout);
}
} else if (seq[1] == 'C') {
/* Right arrow */
if (pos < len) {
pos++;
fputs("\033[C", stdout);
fflush(stdout);
}
} else if (seq[1] == 'A' || seq[1] == 'B') {
/* Up/Down — ignore */
} else if (seq[1] == 'H') {
/* Home — move to start */
if (pos > 0) {
printf("\033[%zuD", pos);
pos = 0;
fflush(stdout);
}
} else if (seq[1] == 'F') {
/* End — move to end */
if (pos < len) {
printf("\033[%zuC", len - pos);
pos = len;
fflush(stdout);
}
} else if (seq[1] == '3') {
/* Delete (Delete key = ESC [ 3 ~ ) */
char tilde;
if (read(fd, &tilde, 1) <= 0) continue;
if (tilde == '~' && pos < len) {
size_t i;
for (i = pos; i < len - 1; i++) {
buf[i] = buf[i + 1];
}
len--;
buf[len] = '\0';
fputs("\r\033[K", stdout);
fputs(buf, stdout);
if (pos < len) {
printf("\033[%zuD", len - pos);
}
fflush(stdout);
}
}
}
} else if (ch == 0x01) {
/* Ctrl-A — home */
if (pos > 0) {
printf("\033[%zuD", pos);
pos = 0;
fflush(stdout);
}
} else if (ch == 0x05) {
/* Ctrl-E — end */
if (pos < len) {
printf("\033[%zuC", len - pos);
pos = len;
fflush(stdout);
}
} else if (ch == 0x15) {
/* Ctrl-U — clear entire line */
if (pos > 0) {
fputs("\r\033[K", stdout);
len = 0;
pos = 0;
buf[0] = '\0';
fflush(stdout);
}
} else if ((unsigned char)ch >= 0x20 && (unsigned char)ch < 0x7f) {
/* Regular printable character — insert at cursor */
if (len < buf_sz - 1) {
size_t i;
/* Shift characters right to make room */
for (i = len; i > pos; i--) {
buf[i] = buf[i - 1];
}
buf[pos] = ch;
len++;
buf[len] = '\0';
/* Redraw from cursor position */
fputs("\r\033[K", stdout);
fputs(buf, stdout);
pos++;
if (pos < len) {
printf("\033[%zuD", len - pos);
}
fflush(stdout);
}
}
/* Ignore other control characters */
}
/* Restore terminal */
if (was_raw) {
tcsetattr(fd, TCSANOW, &old_term);
}
return 0;
}
static int read_cmd_output_local(const char *cmd, char **out_buf) {
FILE *fp;
@@ -1106,7 +1342,7 @@ static void print_usage(const char *program_name) {
tui_print(" --mnemonic-fd N Read mnemonic from inherited fd N (one line) at startup");
tui_print(" --allow-all, -A Allow all policy prompts for this server session");
tui_print(" --bridge-source-trusted Accept qrexec_source preamble on unix connections (bridge mode)");
tui_print(" --allow-index SPEC Restrict which nostr_index values this session can access");
tui_print(" --allow-index SPEC Restrict which nostr_index / role_path values this session can access");
tui_print(" SPEC: 'all' (default), '1,3,4', '0-3', or '0-3,7,9'");
tui_print(" --otp-pad-dir DIR Bind an OTP pad directory at startup (one pad per session)");
tui_print(" --otp-pad SPEC Pad chksum (64 hex) or unique prefix; required with --otp-pad-dir");
@@ -1481,8 +1717,44 @@ static void role_table_get_cell(int row, int col, char *out, size_t out_size, vo
case 3:
if (r->selector_type == SELECTOR_NOSTR_INDEX) {
(void)snprintf(out, out_size, "m/44'/1237'/%d'/0/0", r->nostr_index);
} else {
} else if (r->path_range_lo < 0 && r->path_allowed_count == 0) {
/* Fixed path (no %d placeholder) */
(void)snprintf(out, out_size, "%s", r->role_path);
} else {
/* Template path — replace %d with range or set description */
char range_str[64];
char display[ROLE_PATH_MAX + 64];
const char *pct;
const char *tail;
if (r->path_allowed_count > 0) {
/* Set: e.g. "1+34+54" */
int si;
int off = 0;
for (si = 0; si < r->path_allowed_count && off < (int)sizeof(range_str) - 12; ++si) {
off += snprintf(range_str + off, sizeof(range_str) - off,
"%s%d", (si == 0) ? "" : "+", r->path_allowed_indices[si]);
}
range_str[off] = '\0';
} else if (r->path_range_lo == r->path_range_hi) {
/* Single index */
snprintf(range_str, sizeof(range_str), "%d", r->path_range_lo);
} else {
/* Range */
snprintf(range_str, sizeof(range_str), "%d-%d", r->path_range_lo, r->path_range_hi);
}
/* Replace first %d in role_path with range_str */
pct = strstr(r->role_path, "%d");
if (pct != NULL) {
size_t prefix_len = (size_t)(pct - r->role_path);
tail = pct + 2; /* skip "%d" */
snprintf(display, sizeof(display), "%.*s%s%s",
(int)prefix_len, r->role_path, range_str, tail);
} else {
snprintf(display, sizeof(display), "%s", r->role_path);
}
(void)snprintf(out, out_size, "%s", display);
}
break;
default:
@@ -1725,6 +1997,403 @@ static int setup_default_role(role_table_t *role_table) {
return role_table_add(role_table, &role);
}
/*
* Parse a path template token (e.g. "m/44'/1237'/0-3/1/0" or
* "m/44'/1237'/1+34+54/1/0") into a template with %d placeholder and
* allowed indices. Returns 0 on success, -1 on parse error.
*
* On success:
* template_out — the path with %d replacing the numeric/range/set segment
* range_lo/range_hi — set to the min/max of the allowed indices
* allowed_indices_out / allowed_count_out — the explicit set (if set form
* was used); allowed_count_out is 0 for pure range/single form
*/
static int parse_path_template_for_role(const char *token,
char *template_out, size_t template_sz,
int *range_lo, int *range_hi,
int *allowed_indices_out, int max_allowed,
int *allowed_count_out) {
char buf[ROLE_PATH_MAX];
char *p;
int found_range = 0;
if (token == NULL || template_out == NULL || range_lo == NULL || range_hi == NULL ||
allowed_indices_out == NULL || allowed_count_out == NULL) {
return -1;
}
strncpy(buf, token, sizeof(buf) - 1);
buf[sizeof(buf) - 1] = '\0';
*range_lo = 0;
*range_hi = 0;
*allowed_count_out = 0;
template_out[0] = '\0';
p = buf;
/* Copy up to and including the first '/' */
{
char *first_slash = strchr(p, '/');
if (first_slash == NULL) {
return -1;
}
size_t prefix_len = (size_t)(first_slash - p) + 1;
if (prefix_len >= template_sz) {
return -1;
}
memcpy(template_out, p, prefix_len);
template_out[prefix_len] = '\0';
p = first_slash + 1;
}
while (p != NULL && *p != '\0') {
char *next_slash = strchr(p, '/');
char seg[64];
size_t seg_len;
if (next_slash != NULL) {
seg_len = (size_t)(next_slash - p);
} else {
seg_len = strlen(p);
}
if (seg_len == 0 || seg_len >= sizeof(seg)) {
return -1;
}
memcpy(seg, p, seg_len);
seg[seg_len] = '\0';
if (!found_range) {
/* Check for range/set markers first. Only strip hardened marker
* (' or h) from segments that contain - or + (range/set forms).
* A plain number with ' (like 44') is a literal hardened constant,
* NOT a variable. */
char *plus = strchr(seg, '+');
char *dash = strchr(seg, '-');
int is_range_or_set = (plus != NULL || dash != NULL);
int seg_hardened = 0;
if (is_range_or_set && seg_len > 0 &&
(seg[seg_len - 1] == '\'' || seg[seg_len - 1] == 'h' || seg[seg_len - 1] == 'H')) {
seg_hardened = 1;
seg[seg_len - 1] = '\0';
seg_len--;
}
if (plus != NULL) {
/* Set form: "1+34+54" or "1+3-5+10" */
int set_count = 0;
char *tok = seg;
int set_ok = 1;
while (tok != NULL && *tok != '\0') {
char *next_plus = strchr(tok, '+');
if (next_plus != NULL) *next_plus = '\0';
char *sub_dash = strchr(tok, '-');
if (sub_dash != NULL) {
*sub_dash = '\0';
char *e1 = NULL, *e2 = NULL;
long lo = strtol(tok, &e1, 10);
long hi = strtol(sub_dash + 1, &e2, 10);
if (*e1 != '\0' || *e2 != '\0' || lo < 0 || hi < 0 || lo > hi) {
set_ok = 0; break;
}
for (long vi = lo; vi <= hi && set_count < max_allowed; vi++) {
allowed_indices_out[set_count++] = (int)vi;
}
} else {
char *e = NULL;
long val = strtol(tok, &e, 10);
if (*e != '\0' || val < 0) { set_ok = 0; break; }
if (set_count < max_allowed) {
allowed_indices_out[set_count++] = (int)val;
}
}
tok = (next_plus != NULL) ? next_plus + 1 : NULL;
}
if (set_ok && set_count > 0) {
found_range = 1;
*allowed_count_out = set_count;
*range_lo = allowed_indices_out[0];
*range_hi = allowed_indices_out[set_count - 1];
if (strlen(template_out) + 5 >= template_sz) return -1;
strcat(template_out, "%d");
if (seg_hardened) strcat(template_out, "'");
strcat(template_out, "/");
} else {
if (strlen(template_out) + seg_len + 3 >= template_sz) return -1;
strcat(template_out, seg);
strcat(template_out, "/");
}
} else if (dash != NULL) {
/* Range form: "N-M" */
*dash = '\0';
char *e1 = NULL, *e2 = NULL;
long lo = strtol(seg, &e1, 10);
long hi = strtol(dash + 1, &e2, 10);
if (*e1 != '\0' || *e2 != '\0' || lo < 0 || hi < 0 || lo > hi) {
*dash = '-'; /* restore dash */
if (strlen(template_out) + seg_len + 3 >= template_sz) return -1;
strcat(template_out, seg);
strcat(template_out, "/");
} else {
found_range = 1;
*range_lo = (int)lo;
*range_hi = (int)hi;
if (strlen(template_out) + 5 >= template_sz) return -1;
strcat(template_out, "%d");
if (seg_hardened) strcat(template_out, "'");
strcat(template_out, "/");
}
} else {
/* Single number — only treat as variable if NO hardened marker.
* A segment like "44'" is a literal hardened constant. */
char *e = NULL;
long v = strtol(seg, &e, 10);
if (*e != '\0' || v < 0) {
/* Not a plain number (has ' or other chars) — literal */
if (strlen(template_out) + seg_len + 3 >= template_sz) return -1;
strcat(template_out, seg);
strcat(template_out, "/");
} else {
/* Plain number without ' — this is the variable */
found_range = 1;
*range_lo = (int)v;
*range_hi = (int)v;
if (strlen(template_out) + 5 >= template_sz) return -1;
strcat(template_out, "%d");
strcat(template_out, "/");
}
}
} else {
if (strlen(template_out) + seg_len + 2 >= template_sz) return -1;
strcat(template_out, seg);
strcat(template_out, "/");
}
p = (next_slash != NULL) ? next_slash + 1 : NULL;
}
/* Remove trailing '/' */
{
size_t tlen = strlen(template_out);
if (tlen > 0 && template_out[tlen - 1] == '/') {
template_out[tlen - 1] = '\0';
}
}
if (!found_range) {
/* Fixed path — no variable segment. Treat as a single fixed key. */
*range_lo = -1;
*range_hi = -1;
*allowed_count_out = 0;
}
return 0;
}
/*
* Auto-detect purpose from a derivation path prefix.
* m/44'/1237' → nostr, m/44'/102001' → ssh, etc.
* Falls back to PURPOSE_NOSTR for unrecognized prefixes.
*/
static role_purpose_t purpose_from_path(const char *path) {
if (path == NULL) return PURPOSE_NOSTR;
if (strncmp(path, "m/44'/1237'", 11) == 0) return PURPOSE_NOSTR;
if (strncmp(path, "m/44'/102001'", 13) == 0) return PURPOSE_SSH;
if (strncmp(path, "m/44'/102002'", 13) == 0) return PURPOSE_AGE;
if (strncmp(path, "m/44'/102003'", 13) == 0) return PURPOSE_PQ_SIG;
if (strncmp(path, "m/44'/102004'", 13) == 0) return PURPOSE_PQ_SIG;
if (strncmp(path, "m/44'/102005'", 13) == 0) return PURPOSE_PQ_KEM;
if (strncmp(path, "m/84'", 4) == 0) return PURPOSE_BITCOIN;
if (strncmp(path, "m/86'", 4) == 0) return PURPOSE_BITCOIN;
return PURPOSE_NOSTR; /* default */
}
/*
* Interactive prompt to define named path-roles. Each role binds a name
* (which acts as an access token for clients) to a derivation path template
* with an optional range and default index. The path is hidden from clients.
*
* Only shown when stdin is a TTY and mnemonic was loaded via TUI.
*/
static void prompt_named_path_roles(role_table_t *role_table) {
char input[256];
if (role_table == NULL) {
return;
}
for (;;) {
tui_render_content_screen(NULL, "Named path roles — bind a role name to a derivation path template");
printf("Define a named path role? [y/N] ");
fflush(stdout);
if (read_line_stdin(input, sizeof(input)) != 0) {
return;
}
if (tolower((unsigned char)input[0]) != 'y') {
return;
}
/* Role name */
char role_name[ROLE_NAME_MAX];
printf(" Role name: ");
fflush(stdout);
if (read_line_stdin(role_name, sizeof(role_name)) != 0) return;
/* Trim trailing whitespace */
{
size_t len = strlen(role_name);
while (len > 0 && (role_name[len-1] == '\n' || role_name[len-1] == '\r' ||
role_name[len-1] == ' ' || role_name[len-1] == '\t')) {
role_name[--len] = '\0';
}
}
if (role_name[0] == '\0') {
printf(" Empty role name, skipping.\n");
continue;
}
if (role_table_find_by_name(role_table, role_name) != NULL) {
printf(" Role '%s' already exists, skipping.\n", role_name);
continue;
}
/* Curve — numbered selection */
printf(" Curve:\n");
printf(" 1) secp256k1 (Nostr, Bitcoin)\n");
printf(" 2) ed25519 (SSH)\n");
printf(" 3) x25519 (key agreement, Age)\n");
printf(" 4) ml-dsa-65 (post-quantum signatures)\n");
printf(" 5) slh-dsa-128s (post-quantum signatures)\n");
printf(" 6) ml-kem-768 (post-quantum KEM)\n");
printf(" Select [1]: ");
fflush(stdout);
char curve_choice[16];
if (read_line_stdin(curve_choice, sizeof(curve_choice)) != 0) return;
{
size_t len = strlen(curve_choice);
while (len > 0 && (curve_choice[len-1] == '\n' || curve_choice[len-1] == '\r' ||
curve_choice[len-1] == ' ' || curve_choice[len-1] == '\t')) {
curve_choice[--len] = '\0';
}
}
int cchoice = 1;
if (curve_choice[0] != '\0') {
cchoice = atoi(curve_choice);
if (cchoice < 1 || cchoice > 6) cchoice = 1;
}
role_curve_t curve;
switch (cchoice) {
case 2: curve = CURVE_ED25519; break;
case 3: curve = CURVE_X25519; break;
case 4: curve = CURVE_ML_DSA_65; break;
case 5: curve = CURVE_SLH_DSA_128S; break;
case 6: curve = CURVE_ML_KEM_768; break;
default: curve = CURVE_SECP256K1; break;
}
/* Path template — pre-filled with default, inline editing */
printf(" Path template (use N-M for range, A+B+C for set, e.g. m/44'/1237'/1-100/2/0):\n");
printf(" (arrow keys to edit, Enter to accept):\n ");
fflush(stdout);
char path_token[ROLE_PATH_MAX];
if (read_line_editable(path_token, sizeof(path_token),
"m/44'/1237'/0'/0/0") != 0) return;
{
size_t len = strlen(path_token);
while (len > 0 && (path_token[len-1] == '\n' || path_token[len-1] == '\r' ||
path_token[len-1] == ' ' || path_token[len-1] == '\t')) {
path_token[--len] = '\0';
}
}
if (path_token[0] == '\0') {
strncpy(path_token, "m/44'/1237'/0'/0/0", sizeof(path_token) - 1);
path_token[sizeof(path_token) - 1] = '\0';
}
char template[ROLE_PATH_MAX];
int range_lo, range_hi;
int allowed_indices[64];
int allowed_count = 0;
if (parse_path_template_for_role(path_token, template, sizeof(template),
&range_lo, &range_hi,
allowed_indices, 64, &allowed_count) != 0) {
printf(" Invalid path template: '%s'.\n", path_token);
continue;
}
/* Auto-detect purpose from path prefix (hidden from user) */
role_purpose_t purpose = purpose_from_path(template);
/* Validate purpose+curve combination */
if (crypto_alg_from_role(curve, purpose) == CRYPTO_ALG_UNKNOWN) {
printf(" Curve %s is not valid for path prefix of this template. Try a different curve.\n",
role_curve_to_str(curve));
continue;
}
int default_index = -1;
int is_fixed = (range_lo < 0); /* fixed path, no variable segment */
if (!is_fixed) {
/* Default index — only for templated paths.
* Default to 0 if it's in the allowed range/set, otherwise range_lo. */
int suggested_default;
if (allowed_count > 0) {
/* Set form: check if 0 is in the set */
int j, has_zero = 0;
for (j = 0; j < allowed_count; j++) {
if (allowed_indices[j] == 0) { has_zero = 1; break; }
}
suggested_default = has_zero ? 0 : range_lo;
} else {
/* Range form: 0 is valid if range_lo <= 0 <= range_hi */
suggested_default = (range_lo <= 0 && range_hi >= 0) ? 0 : range_lo;
}
char default_idx_str[16];
printf(" Default index [%d]: ", suggested_default);
fflush(stdout);
if (read_line_stdin(default_idx_str, sizeof(default_idx_str)) != 0) return;
{
size_t len = strlen(default_idx_str);
while (len > 0 && (default_idx_str[len-1] == '\n' || default_idx_str[len-1] == '\r' ||
default_idx_str[len-1] == ' ' || default_idx_str[len-1] == '\t')) {
default_idx_str[--len] = '\0';
}
}
if (default_idx_str[0] == '\0') {
default_index = suggested_default;
} else {
char *endp = NULL;
long di = strtol(default_idx_str, &endp, 10);
if (*endp != '\0' || di < range_lo || di > range_hi) {
printf(" Default index out of range [%d-%d], using %d.\n",
range_lo, range_hi, suggested_default);
default_index = suggested_default;
} else {
default_index = (int)di;
}
}
}
/* Register the role */
if (role_table_register_role_path(role_table, role_name, template,
purpose, curve,
range_lo, range_hi, default_index,
(allowed_count > 0) ? allowed_indices : NULL,
allowed_count) != 0) {
printf(" Failed to register role '%s' (table full?).\n", role_name);
} else if (is_fixed) {
printf(" Role '%s' registered: curve=%s path=%s (fixed).\n",
role_name, role_curve_to_str(curve), template);
} else {
printf(" Role '%s' registered: curve=%s path=%s (default index %d).\n",
role_name, role_curve_to_str(curve), template, default_index);
}
}
}
static int prompt_load_mnemonic_tui(mnemonic_state_t *mnemonic) {
char phrase[MNEMONIC_MAX_LEN];
char phrase_copy[MNEMONIC_MAX_LEN];
@@ -2089,14 +2758,15 @@ static char *prompt_index_whitelist(void) {
char input[256];
for (;;) {
tui_render_content_screen(NULL, "Index whitelist — restrict which nostr_index values this session can access");
printf("Enter allowed indices, or press Enter for 'all' (no restriction):\n\n");
tui_render_content_screen(NULL, "Whitelist — restrict which nostr_index / role_path values this session can access");
printf("Enter allowed indices/paths, or press Enter for 'all' (no restriction):\n\n");
printf(" Examples:\n");
printf(" all (default — allow all indices)\n");
printf(" 0 (only index 0)\n");
printf(" 0,1,3 (specific indices)\n");
printf(" 0-3 (range 0 through 3)\n");
printf(" 0,2-3,7 (mixed list and ranges)\n");
printf(" all (default — allow all)\n");
printf(" 0 (only nostr_index 0)\n");
printf(" 0-3 (nostr_index 0..3)\n");
printf(" m/44'/1237'/0-3/0/0 (NIP-06 paths X=0..3)\n");
printf(" m/44'/1237'/0-3/1/0 (custom paths X=0..3, change=1)\n");
printf(" m/44'/1237'/0-3/0/0,m/44'/1237'/0-3/1/0 (both)\n");
printf("\n Enter = all\n");
printf("> ");
fflush(stdout);
@@ -2123,7 +2793,7 @@ static char *prompt_index_whitelist(void) {
{
server_ctx_t tmp;
memset(&tmp, 0, sizeof(tmp));
if (server_set_index_whitelist(&tmp, input) != 0) {
if (server_set_path_whitelist(&tmp, input) != 0) {
printf("Invalid spec: '%s'. Try again or press Enter for 'all'.\n", input);
continue;
}
@@ -2696,6 +3366,11 @@ int main(int argc, char *argv[]) {
return 1;
}
/* Interactive named path-role definition (only in TUI mode) */
if (mnemonic_source.kind == MNEMONIC_SOURCE_TUI && isatty(STDIN_FILENO)) {
prompt_named_path_roles(&role_table);
}
memset(&key_store, 0, sizeof(key_store));
alg_key_cache_init(&alg_key_cache);
@@ -2801,11 +3476,22 @@ int main(int argc, char *argv[]) {
return 1;
}
/* Index whitelist prompt (only if --allow-index wasn't given on CLI) */
/* Index whitelist prompt (only if --allow-index wasn't given on CLI
* AND no named path-roles were defined — named roles are themselves
* the allowed set, so the separate whitelist is redundant. */
if (allow_index_spec == NULL) {
char *wl_spec = prompt_index_whitelist();
if (wl_spec != NULL) {
allow_index_spec = wl_spec; /* will be freed at program exit */
int has_path_roles = 0;
for (int i = 0; i < role_table.count; i++) {
if (role_table.entries[i].selector_type == SELECTOR_ROLE_PATH) {
has_path_roles = 1;
break;
}
}
if (!has_path_roles) {
char *wl_spec = prompt_index_whitelist();
if (wl_spec != NULL) {
allow_index_spec = wl_spec; /* will be freed at program exit */
}
}
}
@@ -2899,7 +3585,7 @@ int main(int argc, char *argv[]) {
server_set_bridge_source_trusted(&server, 1);
}
if (allow_index_spec != NULL) {
if (server_set_index_whitelist(&server, allow_index_spec) != 0) {
if (server_set_path_whitelist(&server, allow_index_spec) != 0) {
fprintf(stderr, "Invalid --allow-index spec: %s\n", allow_index_spec);
fprintf(stderr, "Expected: 'all', '1,3,4', '0-3', or '0-3,7,9'\n");
crypto_wipe(&key_store); alg_key_cache_wipe(&alg_key_cache);
@@ -2942,7 +3628,7 @@ int main(int argc, char *argv[]) {
&dispatcher,
&policy);
if (allow_index_spec != NULL) {
server_set_index_whitelist(&servers[tcp_server_idx], allow_index_spec);
server_set_path_whitelist(&servers[tcp_server_idx], allow_index_spec);
}
if (server_start(&servers[tcp_server_idx]) != 0) {
fprintf(stderr, "Failed to start FIPS/TCP server on %s: %s\n",
@@ -2970,7 +3656,7 @@ int main(int argc, char *argv[]) {
&dispatcher,
&policy);
if (allow_index_spec != NULL) {
server_set_index_whitelist(&servers[http_server_idx], allow_index_spec);
server_set_path_whitelist(&servers[http_server_idx], allow_index_spec);
}
if (server_start(&servers[http_server_idx]) != 0) {
fprintf(stderr, "Failed to start HTTP server on %s: %s\n",
+7
View File
@@ -116,6 +116,11 @@ typedef struct {
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
int derived; /* 1 if pubkey_hex has been populated */
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
/* The role table */
@@ -174,6 +179,8 @@ typedef struct {
int has_role_path; /* 1 if "role_path" field was present */
char role_path[ROLE_PATH_MAX];
int has_index; /* 1 if "index" field was present (for named path-roles) */
int index; /* index value for named path-role template */
} selector_request_t;
/* Initialize a selector request (all fields zeroed/unset) */
+7
View File
@@ -116,6 +116,11 @@ typedef struct {
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
int derived; /* 1 if pubkey_hex has been populated */
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
/* The role table */
@@ -174,6 +179,8 @@ typedef struct {
int has_role_path; /* 1 if "role_path" field was present */
char role_path[ROLE_PATH_MAX];
int has_index; /* 1 if "index" field was present (for named path-roles) */
int index; /* index value for named path-role template */
} selector_request_t;
/* Initialize a selector request (all fields zeroed/unset) */
+12 -1
View File
@@ -126,6 +126,11 @@ typedef struct {
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
int derived; /* 1 if pubkey_hex has been populated */
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
/* The role table */
@@ -167,7 +172,11 @@ const char *role_curve_to_str(role_curve_t c);
/* Register a nostr-index role if missing. Returns 0 on success, -1 on error. */
int role_table_register_nostr_index(role_table_t *table, int nostr_index);
/* Register a SELECTOR_ROLE_PATH role bound to an explicit derivation path template. */
int role_table_register_role_path(role_table_t *table, const char *name, const char *path,
role_purpose_t purpose, role_curve_t curve,
int range_lo, int range_hi, int default_index,
const int *allowed_indices, int allowed_count);
/* from selector.h */
@@ -187,6 +196,8 @@ typedef struct {
int has_role_path; /* 1 if "role_path" field was present */
char role_path[ROLE_PATH_MAX];
int has_index; /* 1 if "index" field was present (for named path-roles) */
int index; /* index value for named path-role template */
} selector_request_t;
/* Initialize a selector request (all fields zeroed/unset) */
+75
View File
@@ -116,6 +116,11 @@ typedef struct {
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
int derived; /* 1 if pubkey_hex has been populated */
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
/* The role table */
@@ -177,6 +182,8 @@ typedef struct {
int has_role_path; /* 1 if "role_path" field was present */
char role_path[ROLE_PATH_MAX];
int has_index; /* 1 if "index" field was present (for named path-roles) */
int index; /* index value for named path-role template */
} selector_request_t;
/* Initialize a selector request (all fields zeroed/unset) */
@@ -832,6 +839,74 @@ int role_table_register_nostr_index(role_table_t *table, int nostr_index) {
return role_table_add(table, &role);
}
/*
* Register a SELECTOR_ROLE_PATH role bound to an explicit derivation path
* template (with optional %d placeholder and range/set). Idempotent: if a role
* with the same path template already exists, returns 0.
*
* `path` may contain a "%d" placeholder (for ranged/set templates) or be a
* fixed path (no placeholder). range_lo/range_hi specify the allowed index
* range for the placeholder; for fixed paths, pass range_lo == range_hi == 0.
* If allowed_indices != NULL and allowed_count > 0, the set form is used
* instead of the range. default_index is the index used when a client sends
* {"role":"name"} without an explicit "index"; -1 means require an explicit
* index.
*/
int role_table_register_role_path(role_table_t *table, const char *name,
const char *path, role_purpose_t purpose,
role_curve_t curve,
int range_lo, int range_hi, int default_index,
const int *allowed_indices, int allowed_count) {
role_entry_t role;
int i;
if (table == NULL || name == NULL || path == NULL) {
return -1;
}
/* Idempotent: check if a role with this path template already exists */
for (i = 0; i < table->count; ++i) {
if (table->entries[i].selector_type == SELECTOR_ROLE_PATH &&
strcmp(table->entries[i].role_path, path) == 0) {
return 0;
}
}
memset(&role, 0, sizeof(role));
strncpy(role.name, name, sizeof(role.name) - 1);
role.name[sizeof(role.name) - 1] = '\0';
strncpy(role.purpose_str, role_purpose_to_str(purpose), sizeof(role.purpose_str) - 1);
role.purpose_str[sizeof(role.purpose_str) - 1] = '\0';
strncpy(role.curve_str, role_curve_to_str(curve), sizeof(role.curve_str) - 1);
role.curve_str[sizeof(role.curve_str) - 1] = '\0';
role.purpose = purpose;
role.curve = curve;
role.selector_type = SELECTOR_ROLE_PATH;
strncpy(role.role_path, path, sizeof(role.role_path) - 1);
role.role_path[sizeof(role.role_path) - 1] = '\0';
role.nostr_index = -1;
role.path_range_lo = range_lo;
role.path_range_hi = range_hi;
role.path_default_index = default_index;
if (allowed_indices != NULL && allowed_count > 0) {
int copy_n = allowed_count;
if (copy_n > (int)(sizeof(role.path_allowed_indices) / sizeof(role.path_allowed_indices[0]))) {
copy_n = (int)(sizeof(role.path_allowed_indices) / sizeof(role.path_allowed_indices[0]));
}
memcpy(role.path_allowed_indices, allowed_indices, (size_t)copy_n * sizeof(int));
role.path_allowed_count = copy_n;
} else {
role.path_allowed_count = 0;
}
role.derived = 0;
return role_table_add(table, &role);
}
role_purpose_t role_purpose_from_str(const char *s) {
if (str_eq(s, "nostr")) {
return PURPOSE_NOSTR;
+7
View File
@@ -118,6 +118,11 @@ typedef struct {
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
int derived; /* 1 if pubkey_hex has been populated */
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
/* The role table */
@@ -176,6 +181,8 @@ typedef struct {
int has_role_path; /* 1 if "role_path" field was present */
char role_path[ROLE_PATH_MAX];
int has_index; /* 1 if "index" field was present (for named path-roles) */
int index; /* index value for named path-role template */
} selector_request_t;
/* Initialize a selector request (all fields zeroed/unset) */
+7
View File
@@ -116,6 +116,11 @@ typedef struct {
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
int derived; /* 1 if pubkey_hex has been populated */
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
/* The role table */
@@ -174,6 +179,8 @@ typedef struct {
int has_role_path; /* 1 if "role_path" field was present */
char role_path[ROLE_PATH_MAX];
int has_index; /* 1 if "index" field was present (for named path-roles) */
int index; /* index value for named path-role template */
} selector_request_t;
/* Initialize a selector request (all fields zeroed/unset) */
+502 -4
View File
@@ -119,6 +119,11 @@ typedef struct {
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
int derived; /* 1 if pubkey_hex has been populated */
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
/* The role table */
@@ -160,7 +165,11 @@ const char *role_curve_to_str(role_curve_t c);
/* Register a nostr-index role if missing. Returns 0 on success, -1 on error. */
int role_table_register_nostr_index(role_table_t *table, int nostr_index);
/* Register a SELECTOR_ROLE_PATH role bound to an explicit derivation path template. */
int role_table_register_role_path(role_table_t *table, const char *name, const char *path,
role_purpose_t purpose, role_curve_t curve,
int range_lo, int range_hi, int default_index,
const int *allowed_indices, int allowed_count);
/* from selector.h */
@@ -180,6 +189,8 @@ typedef struct {
int has_role_path; /* 1 if "role_path" field was present */
char role_path[ROLE_PATH_MAX];
int has_index; /* 1 if "index" field was present (for named path-roles) */
int index; /* index value for named path-role template */
} selector_request_t;
/* Initialize a selector request (all fields zeroed/unset) */
@@ -677,6 +688,24 @@ typedef struct {
#define INDEX_WHITELIST_MAX 256 /* nostr_index range 0-255 */
#define INDEX_WHITELIST_BITMAP_SIZE (INDEX_WHITELIST_MAX / 8) /* 32 bytes */
#define PATH_WHITELIST_MAX_TEMPLATES 16
#define PATH_TEMPLATE_MAX_LEN 128
#define PATH_TEMPLATE_MAX_INDICES 64 /* max allowed indices per template (for sets) */
typedef struct {
char template[PATH_TEMPLATE_MAX_LEN]; /* e.g. "m/44'/1237'/%d/1/0" — one %d placeholder */
int range_lo; /* inclusive lower bound (for range form) */
int range_hi; /* inclusive upper bound (== range_lo for single) */
int allowed_indices[PATH_TEMPLATE_MAX_INDICES]; /* explicit set of allowed indices */
int allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} path_template_t;
typedef struct {
int active; /* 1 if any path templates are configured */
int count;
path_template_t templates[PATH_WHITELIST_MAX_TEMPLATES];
} path_whitelist_t;
typedef struct {
char socket_name[SERVER_SOCKET_NAME_MAX]; /* abstract namespace name (without \0 prefix) */
char last_error[256];
@@ -692,6 +721,7 @@ typedef struct {
int bridge_source_trusted; /* when set, unix connections send a qrexec_source preamble */
int index_whitelist_active; /* 1 if index whitelist is set (not "all") */
unsigned char index_whitelist[INDEX_WHITELIST_BITMAP_SIZE]; /* bitmap of allowed nostr_index values */
path_whitelist_t path_whitelist; /* path-template whitelist for role_path requests */
} server_ctx_t;
/* Initialize server context. socket_name is the abstract namespace name (e.g. "nsigner").
@@ -1301,6 +1331,12 @@ static int extract_method_and_selector(const char *json,
selector_req->has_role_path = 1;
json_copy_string(selector_req->role_path, sizeof(selector_req->role_path), tmp->valuestring, "");
}
tmp = cJSON_GetObjectItemCaseSensitive(options_item, "index");
if (cJSON_IsNumber(tmp)) {
selector_req->has_index = 1;
selector_req->index = tmp->valueint;
}
}
}
@@ -1334,6 +1370,7 @@ void server_init(server_ctx_t *ctx, const char *socket_name, int socket_name_exp
ctx->bridge_source_trusted = 0;
ctx->index_whitelist_active = 0;
memset(ctx->index_whitelist, 0, sizeof(ctx->index_whitelist));
memset(&ctx->path_whitelist, 0, sizeof(ctx->path_whitelist));
if (!g_auth_nonce_cache_inited) {
auth_nonce_cache_init(&g_auth_nonce_cache);
g_auth_nonce_cache_inited = 1;
@@ -1428,6 +1465,313 @@ int server_index_whitelist_allows(const server_ctx_t *ctx, int nostr_index) {
return whitelist_get_bit(ctx->index_whitelist, nostr_index);
}
/*
* Parse a single path-template token (e.g. "m/44'/1237'/0-3/1/0") into a
* path_template_t. The first path segment matching ^[0-9]+(-[0-9]+)?$ is
* treated as the range placeholder and replaced with "%d" in the stored
* template. Returns 0 on success, -1 on parse error.
*/
static int parse_path_template_token(path_template_t *out, const char *token) {
char buf[PATH_TEMPLATE_MAX_LEN];
char *p;
char *seg;
int found_range = 0;
if (out == NULL || token == NULL) {
return -1;
}
strncpy(buf, token, sizeof(buf) - 1);
buf[sizeof(buf) - 1] = '\0';
/* buf starts with "m/" — split by '/' and find the first numeric/range segment */
memset(out->template, 0, sizeof(out->template));
out->range_lo = 0;
out->range_hi = 0;
/* Build the output template, replacing the first numeric segment with %d */
p = buf;
seg = strchr(p, '/');
if (seg != NULL) {
/* copy up to and including the first '/' */
size_t prefix_len = (size_t)(seg - p) + 1;
if (prefix_len >= sizeof(out->template)) {
return -1;
}
memcpy(out->template, p, prefix_len);
out->template[prefix_len] = '\0';
p = seg + 1;
} else {
/* no '/' — not a valid path template */
return -1;
}
while (p != NULL && *p != '\0') {
char *next_slash = strchr(p, '/');
size_t seg_len;
char seg_buf[32];
if (next_slash != NULL) {
seg_len = (size_t)(next_slash - p);
} else {
seg_len = strlen(p);
}
if (seg_len >= sizeof(seg_buf)) {
return -1;
}
memcpy(seg_buf, p, seg_len);
seg_buf[seg_len] = '\0';
if (!found_range) {
/* Check if this segment is a number, range "N-M", or set "A+B+C" */
char *plus = strchr(seg_buf, '+');
char *dash = strchr(seg_buf, '-');
if (plus != NULL) {
/* Set form: "1+34+54" or "1+3-5+10" — parse each + separated entry */
int set_count = 0;
char *tok = seg_buf;
int set_ok = 1;
while (tok != NULL && *tok != '\0') {
char *next_plus = strchr(tok, '+');
if (next_plus != NULL) *next_plus = '\0';
/* Each token is either "N" or "N-M" */
char *sub_dash = strchr(tok, '-');
if (sub_dash != NULL) {
*sub_dash = '\0';
char *e1 = NULL, *e2 = NULL;
long lo = strtol(tok, &e1, 10);
long hi = strtol(sub_dash + 1, &e2, 10);
if (*e1 != '\0' || *e2 != '\0' || lo < 0 || hi < 0 || lo > hi) {
set_ok = 0; break;
}
for (long vi = lo; vi <= hi && set_count < PATH_TEMPLATE_MAX_INDICES; vi++) {
out->allowed_indices[set_count++] = (int)vi;
}
} else {
char *e = NULL;
long val = strtol(tok, &e, 10);
if (*e != '\0' || val < 0) { set_ok = 0; break; }
if (set_count < PATH_TEMPLATE_MAX_INDICES) {
out->allowed_indices[set_count++] = (int)val;
}
}
tok = (next_plus != NULL) ? next_plus + 1 : NULL;
}
if (set_ok && set_count > 0) {
found_range = 1;
out->allowed_count = set_count;
out->range_lo = out->allowed_indices[0];
out->range_hi = out->allowed_indices[set_count - 1];
if (strlen(out->template) + 3 >= sizeof(out->template)) return -1;
strcat(out->template, "%d");
strcat(out->template, "/");
} else {
/* not a valid set — treat as literal segment */
if (strlen(out->template) + seg_len + 2 >= sizeof(out->template)) return -1;
strcat(out->template, seg_buf);
strcat(out->template, "/");
}
} else if (dash != NULL) {
/* Range form: "N-M" */
*dash = '\0';
char *endptr1 = NULL, *endptr2 = NULL;
long lo = strtol(seg_buf, &endptr1, 10);
long hi = strtol(dash + 1, &endptr2, 10);
if (*endptr1 != '\0' || *endptr2 != '\0' || lo < 0 || hi < 0 || lo > hi) {
/* not a numeric range — treat as literal segment */
if (strlen(out->template) + seg_len + 2 >= sizeof(out->template)) {
return -1;
}
strcat(out->template, seg_buf);
strcat(out->template, "/");
} else {
found_range = 1;
out->range_lo = (int)lo;
out->range_hi = (int)hi;
if (strlen(out->template) + 3 >= sizeof(out->template)) {
return -1;
}
strcat(out->template, "%d");
strcat(out->template, "/");
}
} else {
/* Single number */
char *endptr = NULL;
long val = strtol(seg_buf, &endptr, 10);
if (*endptr != '\0' || val < 0) {
/* not a number — treat as literal segment */
if (strlen(out->template) + seg_len + 2 >= sizeof(out->template)) {
return -1;
}
strcat(out->template, seg_buf);
strcat(out->template, "/");
} else {
found_range = 1;
out->range_lo = (int)val;
out->range_hi = (int)val;
if (strlen(out->template) + 3 >= sizeof(out->template)) {
return -1;
}
strcat(out->template, "%d");
strcat(out->template, "/");
}
}
} else {
/* literal segment after the range */
if (strlen(out->template) + seg_len + 2 >= sizeof(out->template)) {
return -1;
}
strcat(out->template, seg_buf);
strcat(out->template, "/");
}
p = (next_slash != NULL) ? next_slash + 1 : NULL;
}
/* Remove trailing '/' from template */
{
size_t tlen = strlen(out->template);
if (tlen > 0 && out->template[tlen - 1] == '/') {
out->template[tlen - 1] = '\0';
}
}
if (!found_range) {
return -1; /* a path template must contain a numeric/range segment */
}
return 0;
}
/*
* Unified whitelist parser: accepts both integer nostr_index tokens
* ("0-3", "1,3,4") and path-template tokens ("m/44'/1237'/0-3/1/0").
* "all" clears both whitelists. Returns 0 on success, -1 on parse error.
*/
int server_set_path_whitelist(server_ctx_t *ctx, const char *spec) {
char buf[512];
char *p;
if (ctx == NULL || spec == NULL) {
return -1;
}
/* "all" means no restriction */
if (strcmp(spec, "all") == 0) {
ctx->index_whitelist_active = 0;
memset(ctx->index_whitelist, 0, sizeof(ctx->index_whitelist));
memset(&ctx->path_whitelist, 0, sizeof(ctx->path_whitelist));
return 0;
}
strncpy(buf, spec, sizeof(buf) - 1);
buf[sizeof(buf) - 1] = '\0';
/* Reset both whitelists before parsing */
memset(ctx->index_whitelist, 0, sizeof(ctx->index_whitelist));
ctx->index_whitelist_active = 0;
memset(&ctx->path_whitelist, 0, sizeof(ctx->path_whitelist));
p = buf;
while (p != NULL && *p != '\0') {
char *comma = strchr(p, ',');
if (comma != NULL) {
*comma = '\0';
}
/* Skip empty tokens */
if (*p == '\0') {
p = (comma != NULL) ? comma + 1 : NULL;
continue;
}
/* Is this a path template? (contains '/') */
if (strchr(p, '/') != NULL) {
if (ctx->path_whitelist.count >= PATH_WHITELIST_MAX_TEMPLATES) {
return -1;
}
if (parse_path_template_token(
&ctx->path_whitelist.templates[ctx->path_whitelist.count], p) != 0) {
return -1;
}
ctx->path_whitelist.count++;
ctx->path_whitelist.active = 1;
} else {
/* Integer nostr_index token: "N" or "N-M" */
char *dash = strchr(p, '-');
if (dash != NULL) {
*dash = '\0';
char *endptr1 = NULL, *endptr2 = NULL;
long lo = strtol(p, &endptr1, 10);
long hi = strtol(dash + 1, &endptr2, 10);
if (*endptr1 != '\0' || *endptr2 != '\0' || lo < 0 || hi < 0 ||
lo >= INDEX_WHITELIST_MAX || hi >= INDEX_WHITELIST_MAX || lo > hi) {
return -1;
}
for (long i = lo; i <= hi; i++) {
whitelist_set_bit(ctx->index_whitelist, (int)i);
}
} else {
char *endptr = NULL;
long idx = strtol(p, &endptr, 10);
if (*endptr != '\0' || idx < 0 || idx >= INDEX_WHITELIST_MAX) {
return -1;
}
whitelist_set_bit(ctx->index_whitelist, (int)idx);
}
ctx->index_whitelist_active = 1;
}
p = (comma != NULL) ? comma + 1 : NULL;
}
return 0;
}
/*
* Check if a role_path is allowed by the path whitelist.
* Returns 1 if allowed, 0 if not.
*/
int server_path_whitelist_allows(const server_ctx_t *ctx, const char *role_path) {
int i;
if (ctx == NULL || role_path == NULL) {
return 0;
}
if (!ctx->path_whitelist.active) {
/* No path whitelist configured — deny by default (fail-closed for paths) */
return 0;
}
for (i = 0; i < ctx->path_whitelist.count; i++) {
const path_template_t *tpl = &ctx->path_whitelist.templates[i];
char candidate[PATH_TEMPLATE_MAX_LEN];
if (tpl->allowed_count > 0) {
/* Set form: check each allowed index */
int j;
for (j = 0; j < tpl->allowed_count; j++) {
snprintf(candidate, sizeof(candidate), tpl->template, tpl->allowed_indices[j]);
if (strcmp(candidate, role_path) == 0) {
return 1;
}
}
} else {
/* Range form: iterate lo..hi */
int idx;
for (idx = tpl->range_lo; idx <= tpl->range_hi; idx++) {
snprintf(candidate, sizeof(candidate), tpl->template, idx);
if (strcmp(candidate, role_path) == 0) {
return 1;
}
}
}
}
return 0;
}
int server_start(server_ctx_t *ctx) {
int fd;
struct sockaddr_un addr;
@@ -1809,6 +2153,8 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
int pending_derivation = 0;
int hard_selector_error = 0;
int derivation_error = 0;
char concrete_path[ROLE_PATH_MAX]; /* concrete path for named path-role with index */
concrete_path[0] = '\0';
char activity[256];
const char *verdict = "DENIED";
const char *source_label = "no-match";
@@ -2028,7 +2374,8 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
if (extract_method_and_selector(request, method, sizeof(method), &selector_req) == 0) {
if (ctx->dispatcher->role_table != NULL) {
selector_rc = selector_resolve(&selector_req, ctx->dispatcher->role_table, &role);
if (selector_rc == SELECTOR_OK && role != NULL) {
if (selector_rc == SELECTOR_OK && role != NULL &&
role->selector_type == SELECTOR_NOSTR_INDEX) {
json_copy_string(role_name, sizeof(role_name), role->name, "main");
json_copy_string(purpose, sizeof(purpose), role_purpose_to_str(role->purpose), "nostr");
} else if (selector_rc == SELECTOR_ERR_NOT_FOUND && selector_req.has_nostr_index) {
@@ -2039,6 +2386,67 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
(void)snprintf(role_name, sizeof(role_name), "nostr_idx_%d", selector_req.nostr_index);
}
json_copy_string(purpose, sizeof(purpose), "nostr", "nostr");
} else if (selector_rc == SELECTOR_ERR_NOT_FOUND && selector_req.has_role_path) {
/* role_path not in table — check path whitelist for auto-registration */
if (server_path_whitelist_allows(ctx, selector_req.role_path)) {
pending_derivation = 1;
(void)snprintf(role_name, sizeof(role_name), "path_%s", selector_req.role_path);
json_copy_string(purpose, sizeof(purpose), "nostr", "nostr");
} else {
hard_selector_error = -200; /* path_not_allowed sentinel */
}
} else if (selector_rc == SELECTOR_OK && role != NULL &&
role->selector_type == SELECTOR_ROLE_PATH &&
strstr(role->role_path, "%d") == NULL) {
/* Fixed-path named role — no index needed, derive if not yet done */
json_copy_string(role_name, sizeof(role_name), role->name, "main");
json_copy_string(purpose, sizeof(purpose), role_purpose_to_str(role->purpose), "nostr");
if (!role->derived) {
pending_derivation = 1;
}
} else if (selector_rc == SELECTOR_OK && role != NULL &&
role->selector_type == SELECTOR_ROLE_PATH &&
strstr(role->role_path, "%d") != NULL) {
/* Named path-role with template — resolve the concrete path from index */
json_copy_string(role_name, sizeof(role_name), role->name, "main");
json_copy_string(purpose, sizeof(purpose), role_purpose_to_str(role->purpose), "nostr");
int chosen_index;
if (selector_req.has_index) {
chosen_index = selector_req.index;
} else if (role->path_default_index >= 0) {
chosen_index = role->path_default_index;
} else {
hard_selector_error = -201; /* index_required sentinel */
chosen_index = -1;
}
if (chosen_index >= 0) {
int index_ok;
if (role->path_allowed_count > 0) {
/* Set form: check if index is in the allowed set */
int j;
index_ok = 0;
for (j = 0; j < role->path_allowed_count; j++) {
if (role->path_allowed_indices[j] == chosen_index) {
index_ok = 1;
break;
}
}
} else {
/* Range form: check lo..hi */
index_ok = (chosen_index >= role->path_range_lo &&
chosen_index <= role->path_range_hi);
}
if (!index_ok) {
hard_selector_error = -202; /* index_out_of_range sentinel */
} else {
/* Format the concrete path and store it for derivation */
snprintf(concrete_path, sizeof(concrete_path),
role->role_path, chosen_index);
if (!role->derived) {
pending_derivation = 1;
}
}
}
} else if (selector_rc == SELECTOR_ERR_AMBIGUOUS ||
selector_rc == SELECTOR_ERR_NOT_FOUND ||
selector_rc == SELECTOR_ERR_NO_DEFAULT) {
@@ -2074,6 +2482,15 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
} else if (hard_selector_error == SELECTOR_ERR_NOT_FOUND) {
response = strdup("{\"id\":\"null\",\"error\":{\"code\":1002,\"message\":\"unknown_role\"}}");
pchk = POLICY_DENY;
} else if (hard_selector_error == -200) {
response = strdup("{\"id\":\"null\",\"error\":{\"code\":2003,\"message\":\"path_not_allowed\"}}");
pchk = POLICY_DENY;
} else if (hard_selector_error == -201) {
response = strdup("{\"id\":\"null\",\"error\":{\"code\":2004,\"message\":\"index_required\"}}");
pchk = POLICY_DENY;
} else if (hard_selector_error == -202) {
response = strdup("{\"id\":\"null\",\"error\":{\"code\":2005,\"message\":\"index_out_of_range\"}}");
pchk = POLICY_DENY;
} else if (hard_selector_error == 0) {
/* Normal path: run policy_check (skip if whitelist already denied) */
pchk = policy_check(ctx->policy, caller.caller_id, method, role_name, purpose, &policy_src);
@@ -2110,8 +2527,89 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
if (ctx->dispatcher == NULL ||
ctx->dispatcher->role_table == NULL ||
ctx->dispatcher->key_store == NULL ||
ctx->dispatcher->mnemonic == NULL ||
role_table_register_nostr_index(ctx->dispatcher->role_table, selector_req.nostr_index) != 0) {
ctx->dispatcher->mnemonic == NULL) {
derivation_error = 1;
} else if (selector_req.has_role_path) {
/* Auto-register a fixed-path role from the path whitelist */
char path_role_name[ROLE_NAME_MAX];
(void)snprintf(path_role_name, sizeof(path_role_name), "pathrole_%d",
ctx->dispatcher->role_table->count);
if (role_table_register_role_path(ctx->dispatcher->role_table,
path_role_name,
selector_req.role_path,
PURPOSE_NOSTR, CURVE_SECP256K1,
0, 0, -1, NULL, 0) != 0) {
derivation_error = 1;
} else {
new_role = role_table_find_by_path(ctx->dispatcher->role_table,
selector_req.role_path);
if (new_role == NULL) {
derivation_error = 1;
} else {
role_index = (int)(new_role - &ctx->dispatcher->role_table->entries[0]);
if (role_index < 0 || role_index >= ctx->dispatcher->role_table->count ||
crypto_derive_one(ctx->dispatcher->key_store,
ctx->dispatcher->role_table,
ctx->dispatcher->mnemonic,
role_index) != 0) {
derivation_error = 1;
} else {
json_copy_string(role_name, sizeof(role_name), new_role->name, role_name);
json_copy_string(purpose, sizeof(purpose), role_purpose_to_str(new_role->purpose), "nostr");
}
}
}
} else if (role != NULL && role->selector_type == SELECTOR_ROLE_PATH &&
strstr(role->role_path, "%d") == NULL) {
/* Fixed-path named role (found by role name) — derive directly */
new_role = role;
role_index = (int)(new_role - &ctx->dispatcher->role_table->entries[0]);
if (role_index < 0 || role_index >= ctx->dispatcher->role_table->count ||
crypto_derive_one(ctx->dispatcher->key_store,
ctx->dispatcher->role_table,
ctx->dispatcher->mnemonic,
role_index) != 0) {
derivation_error = 1;
} else {
json_copy_string(role_name, sizeof(role_name), new_role->name, role_name);
json_copy_string(purpose, sizeof(purpose), role_purpose_to_str(new_role->purpose), "nostr");
}
} else if (concrete_path[0] != '\0') {
/* Named path-role with template — derive the concrete path.
* The role already exists in the table; we temporarily set its
* role_path to the concrete path for derivation, then restore. */
char saved_path[ROLE_PATH_MAX];
new_role = role; /* the role resolved by selector_resolve */
if (new_role == NULL) {
derivation_error = 1;
} else {
role_index = (int)(new_role - &ctx->dispatcher->role_table->entries[0]);
if (role_index < 0 || role_index >= ctx->dispatcher->role_table->count) {
derivation_error = 1;
} else {
/* Swap in the concrete path */
strncpy(saved_path, new_role->role_path, sizeof(saved_path) - 1);
saved_path[sizeof(saved_path) - 1] = '\0';
strncpy(new_role->role_path, concrete_path, sizeof(new_role->role_path) - 1);
new_role->role_path[sizeof(new_role->role_path) - 1] = '\0';
new_role->derived = 0;
new_role->pubkey_hex[0] = '\0';
if (crypto_derive_one(ctx->dispatcher->key_store,
ctx->dispatcher->role_table,
ctx->dispatcher->mnemonic,
role_index) != 0) {
derivation_error = 1;
} else {
json_copy_string(role_name, sizeof(role_name), new_role->name, role_name);
json_copy_string(purpose, sizeof(purpose), role_purpose_to_str(new_role->purpose), "nostr");
}
/* Restore the template path (keep derived=1 + pubkey from concrete derivation) */
strncpy(new_role->role_path, saved_path, sizeof(new_role->role_path) - 1);
new_role->role_path[sizeof(new_role->role_path) - 1] = '\0';
}
}
} else if (role_table_register_nostr_index(ctx->dispatcher->role_table, selector_req.nostr_index) != 0) {
derivation_error = 1;
} else {
new_role = role_table_find_by_nostr_index(ctx->dispatcher->role_table, selector_req.nostr_index);
+7
View File
@@ -118,6 +118,11 @@ typedef struct {
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
int derived; /* 1 if pubkey_hex has been populated */
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
/* The role table */
@@ -176,6 +181,8 @@ typedef struct {
int has_role_path; /* 1 if "role_path" field was present */
char role_path[ROLE_PATH_MAX];
int has_index; /* 1 if "index" field was present (for named path-roles) */
int index; /* index value for named path-role template */
} selector_request_t;
/* Initialize a selector request (all fields zeroed/unset) */
+5
View File
@@ -55,6 +55,11 @@ typedef struct {
char role_path[ROLE_PATH_MAX];
char pubkey_hex[ROLE_PUBKEY_HEX_MAX];
int derived;
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
typedef struct { role_entry_t entries[ROLE_TABLE_MAX_ENTRIES]; int count; } role_table_t;
void role_table_init(role_table_t *table);
+12 -1
View File
@@ -116,6 +116,11 @@ typedef struct {
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
int derived; /* 1 if pubkey_hex has been populated */
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
/* The role table */
@@ -157,7 +162,11 @@ const char *role_curve_to_str(role_curve_t c);
/* Register a nostr-index role if missing. Returns 0 on success, -1 on error. */
int role_table_register_nostr_index(role_table_t *table, int nostr_index);
/* Register a SELECTOR_ROLE_PATH role bound to an explicit derivation path template. */
int role_table_register_role_path(role_table_t *table, const char *name, const char *path,
role_purpose_t purpose, role_curve_t curve,
int range_lo, int range_hi, int default_index,
const int *allowed_indices, int allowed_count);
/* from selector.h */
@@ -177,6 +186,8 @@ typedef struct {
int has_role_path; /* 1 if "role_path" field was present */
char role_path[ROLE_PATH_MAX];
int has_index; /* 1 if "index" field was present (for named path-roles) */
int index; /* index value for named path-role template */
} selector_request_t;
/* Initialize a selector request (all fields zeroed/unset) */
+8 -1
View File
@@ -101,6 +101,11 @@ typedef struct {
char role_path[ROLE_PATH_MAX];
char pubkey_hex[ROLE_PUBKEY_HEX_MAX];
int derived;
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
typedef struct {
@@ -118,7 +123,7 @@ role_purpose_t role_purpose_from_str(const char *s);
role_curve_t role_curve_from_str(const char *s);
const char *role_purpose_to_str(role_purpose_t p);
const char *role_curve_to_str(role_curve_t c);
int role_table_register_nostr_index(role_table_t *table, int nostr_index);
/* from selector.h */
@@ -136,6 +141,8 @@ typedef struct {
int nostr_index;
int has_role_path;
char role_path[ROLE_PATH_MAX];
int has_index; /* 1 if "index" field was present (for named path-roles) */
int index; /* index value for named path-role template */
} selector_request_t;
void selector_request_init(selector_request_t *req);
+7
View File
@@ -116,6 +116,11 @@ typedef struct {
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
int derived; /* 1 if pubkey_hex has been populated */
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
/* The role table */
@@ -174,6 +179,8 @@ typedef struct {
int has_role_path; /* 1 if "role_path" field was present */
char role_path[ROLE_PATH_MAX];
int has_index; /* 1 if "index" field was present (for named path-roles) */
int index; /* index value for named path-role template */
} selector_request_t;
/* Initialize a selector request (all fields zeroed/unset) */
+7
View File
@@ -118,6 +118,11 @@ typedef struct {
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
int derived; /* 1 if pubkey_hex has been populated */
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
/* The role table */
@@ -176,6 +181,8 @@ typedef struct {
int has_role_path; /* 1 if "role_path" field was present */
char role_path[ROLE_PATH_MAX];
int has_index; /* 1 if "index" field was present (for named path-roles) */
int index; /* index value for named path-role template */
} selector_request_t;
/* Initialize a selector request (all fields zeroed/unset) */
+8 -1
View File
@@ -80,6 +80,11 @@ typedef struct {
char role_path[ROLE_PATH_MAX];
char pubkey_hex[ROLE_PUBKEY_HEX_MAX];
int derived;
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
typedef struct {
@@ -97,7 +102,7 @@ role_purpose_t role_purpose_from_str(const char *s);
role_curve_t role_curve_from_str(const char *s);
const char *role_purpose_to_str(role_purpose_t p);
const char *role_curve_to_str(role_curve_t c);
int role_table_register_nostr_index(role_table_t *table, int nostr_index);
/* from selector.h */
@@ -113,6 +118,8 @@ typedef struct {
int nostr_index;
int has_role_path;
char role_path[ROLE_PATH_MAX];
int has_index; /* 1 if "index" field was present (for named path-roles) */
int index; /* index value for named path-role template */
} selector_request_t;
void selector_request_init(selector_request_t *req);
+7
View File
@@ -97,6 +97,11 @@ typedef struct {
char role_path[ROLE_PATH_MAX];
char pubkey_hex[ROLE_PUBKEY_HEX_MAX];
int derived;
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
typedef struct {
@@ -128,6 +133,8 @@ typedef struct {
int nostr_index;
int has_role_path;
char role_path[ROLE_PATH_MAX];
int has_index; /* 1 if "index" field was present (for named path-roles) */
int index; /* index value for named path-role template */
} selector_request_t;
void selector_request_init(selector_request_t *req);
+7
View File
@@ -73,6 +73,11 @@ typedef struct {
char role_path[ROLE_PATH_MAX];
char pubkey_hex[ROLE_PUBKEY_HEX_MAX];
int derived;
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
typedef struct {
@@ -101,6 +106,8 @@ typedef struct {
int nostr_index;
int has_role_path;
char role_path[ROLE_PATH_MAX];
int has_index; /* 1 if "index" field was present (for named path-roles) */
int index; /* index value for named path-role template */
} selector_request_t;
void selector_request_init(selector_request_t *req);
+7
View File
@@ -116,6 +116,11 @@ typedef struct {
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
int derived; /* 1 if pubkey_hex has been populated */
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
/* The role table */
@@ -174,6 +179,8 @@ typedef struct {
int has_role_path; /* 1 if "role_path" field was present */
char role_path[ROLE_PATH_MAX];
int has_index; /* 1 if "index" field was present (for named path-roles) */
int index; /* index value for named path-role template */
} selector_request_t;
/* Initialize a selector request (all fields zeroed/unset) */
+443
View File
@@ -0,0 +1,443 @@
/*
* test_path_whitelist.c tests for the path-template whitelist and
* named path-role functionality.
*
* Covers:
* - server_set_path_whitelist parsing (integer + path-template tokens)
* - server_path_whitelist_allows matching
* - role_table_register_role_path (idempotent, range fields)
* - derive_secp256k1_from_path (BIP-44 path parsing + derivation)
*/
#define _GNU_SOURCE
/* NSIGNER_HEADERLESS_DECLS_BEGIN */
#include <stddef.h>
#include <stdint.h>
#include <sys/types.h>
#include <cJSON.h>
/* from secure_mem.h */
typedef struct {
void *data;
size_t size;
int locked;
} secure_buf_t;
int secure_buf_alloc(secure_buf_t *buf, size_t size);
void secure_buf_free(secure_buf_t *buf);
void secure_memzero(void *ptr, size_t len);
/* from mnemonic.h */
#define MNEMONIC_MAX_LEN 256
typedef struct {
secure_buf_t buf;
int loaded;
int word_count;
} mnemonic_state_t;
void mnemonic_init(mnemonic_state_t *state);
int mnemonic_load(mnemonic_state_t *state, const char *phrase);
void mnemonic_unload(mnemonic_state_t *state);
int mnemonic_is_loaded(const mnemonic_state_t *state);
const char *mnemonic_get_phrase(const mnemonic_state_t *state);
/* from role_table.h */
#define ROLE_NAME_MAX 64
#define ROLE_PATH_MAX 128
#define ROLE_PURPOSE_MAX 32
#define ROLE_CURVE_MAX 16
#define ROLE_PUBKEY_HEX_MAX 66
#define ROLE_TABLE_MAX_ENTRIES 256
typedef enum {
PURPOSE_NOSTR = 0,
PURPOSE_BITCOIN,
PURPOSE_SSH,
PURPOSE_AGE,
PURPOSE_FIPS,
PURPOSE_PQ_SIG,
PURPOSE_PQ_KEM,
PURPOSE_UNKNOWN
} role_purpose_t;
typedef enum {
CURVE_SECP256K1 = 0,
CURVE_ED25519,
CURVE_X25519,
CURVE_ML_DSA_65,
CURVE_SLH_DSA_128S,
CURVE_ML_KEM_768,
CURVE_UNKNOWN
} role_curve_t;
typedef enum {
SELECTOR_NOSTR_INDEX,
SELECTOR_ROLE_PATH
} role_selector_type_t;
typedef struct {
char name[ROLE_NAME_MAX];
char purpose_str[ROLE_PURPOSE_MAX];
char curve_str[ROLE_CURVE_MAX];
role_purpose_t purpose;
role_curve_t curve;
role_selector_type_t selector_type;
int nostr_index;
char role_path[ROLE_PATH_MAX];
char pubkey_hex[ROLE_PUBKEY_HEX_MAX];
int derived;
int path_range_lo;
int path_range_hi;
int path_default_index;
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
typedef struct {
role_entry_t entries[ROLE_TABLE_MAX_ENTRIES];
int count;
} role_table_t;
void role_table_init(role_table_t *table);
int role_table_add(role_table_t *table, const role_entry_t *entry);
role_entry_t *role_table_find_by_name(role_table_t *table, const char *name);
role_entry_t *role_table_find_by_nostr_index(role_table_t *table, int index);
role_entry_t *role_table_find_by_path(role_table_t *table, const char *path);
role_purpose_t role_purpose_from_str(const char *s);
role_curve_t role_curve_from_str(const char *s);
const char *role_purpose_to_str(role_purpose_t p);
const char *role_curve_to_str(role_curve_t c);
int role_table_register_nostr_index(role_table_t *table, int nostr_index);
/* Register a SELECTOR_ROLE_PATH role bound to an explicit derivation path template. */
int role_table_register_role_path(role_table_t *table, const char *name, const char *path,
role_purpose_t purpose, role_curve_t curve,
int range_lo, int range_hi, int default_index,
const int *allowed_indices, int allowed_count);
/* from selector.h */
#define SELECTOR_OK 0
#define SELECTOR_ERR_AMBIGUOUS -1
#define SELECTOR_ERR_NOT_FOUND -2
#define SELECTOR_ERR_NO_DEFAULT -3
typedef struct {
int has_role;
char role_name[ROLE_NAME_MAX];
int has_nostr_index;
int nostr_index;
int has_role_path;
char role_path[ROLE_PATH_MAX];
int has_index;
int index;
} selector_request_t;
void selector_request_init(selector_request_t *req);
int selector_resolve(const selector_request_t *req, role_table_t *table, role_entry_t **out);
/* from enforcement.h */
#define ENFORCE_OK 0
#define ENFORCE_ERR_PURPOSE -1
#define ENFORCE_ERR_CURVE -2
#define ENFORCE_ERR_UNKNOWN_VERB -3
#define ENFORCE_ERR_ALGORITHM -4
#define VERB_SIGN "sign"
#define VERB_VERIFY "verify"
#define VERB_ENCAPSULATE "encapsulate"
#define VERB_DECAPSULATE "decapsulate"
#define VERB_DERIVE_SHARED "derive_shared_secret"
#define VERB_DERIVE "derive"
#define VERB_GET_PUBLIC_KEY "get_public_key"
#define VERB_NOSTR_GET_PUBLIC_KEY "nostr_get_public_key"
#define VERB_NOSTR_SIGN_EVENT "nostr_sign_event"
#define VERB_NOSTR_MINE_EVENT "nostr_mine_event"
#define VERB_NOSTR_NIP44_ENCRYPT "nostr_nip44_encrypt"
#define VERB_NOSTR_NIP44_DECRYPT "nostr_nip44_decrypt"
#define VERB_NOSTR_NIP04_ENCRYPT "nostr_nip04_encrypt"
#define VERB_NOSTR_NIP04_DECRYPT "nostr_nip04_decrypt"
#define VERB_ENCRYPT "encrypt"
#define VERB_DECRYPT "decrypt"
int enforce_verb_role(const char *verb, const role_entry_t *role);
/* from pq_crypto.h */
typedef enum {
CRYPTO_ALG_SECP256K1 = 0,
CRYPTO_ALG_ED25519,
CRYPTO_ALG_X25519,
CRYPTO_ALG_ML_DSA_65,
CRYPTO_ALG_SLH_DSA_128S,
CRYPTO_ALG_ML_KEM_768,
CRYPTO_ALG_UNKNOWN
} crypto_alg_t;
typedef struct {
size_t priv_key_len;
size_t pub_key_len;
size_t sig_len;
size_t ciphertext_len;
size_t shared_secret_len;
} crypto_alg_sizes_t;
const crypto_alg_sizes_t *crypto_alg_get_sizes(crypto_alg_t alg);
crypto_alg_t crypto_alg_from_role(role_curve_t curve, role_purpose_t purpose);
const char *crypto_alg_to_str(crypto_alg_t alg);
crypto_alg_t crypto_alg_from_str(const char *s);
/* from key_store.h */
#define KEY_STORE_MAX_ROLES ROLE_TABLE_MAX_ENTRIES
typedef struct {
secure_buf_t private_key;
secure_buf_t public_key;
char pubkey_hex[8192]; /* hex-encoded public key (PQ pubkeys are large) */
char npub[128]; /* bech32 npub (secp256k1 only, empty for others) */
crypto_alg_t alg;
int valid;
} derived_key_t;
typedef struct {
derived_key_t keys[KEY_STORE_MAX_ROLES];
int count;
} key_store_t;
int crypto_derive_all(key_store_t *store, role_table_t *table, const mnemonic_state_t *mnemonic);
int crypto_derive_one(key_store_t *store, role_table_t *table, const mnemonic_state_t *mnemonic, int role_index);
/* nostr init/cleanup */
int nostr_init(void);
void nostr_cleanup(void);
/* from server.h (minimal subset for whitelist tests) */
#define SERVER_SOCKET_NAME_MAX 108
#define INDEX_WHITELIST_MAX 256
#define INDEX_WHITELIST_BITMAP_SIZE (INDEX_WHITELIST_MAX / 8)
#define PATH_WHITELIST_MAX_TEMPLATES 16
#define PATH_TEMPLATE_MAX_LEN 128
typedef struct {
char template[PATH_TEMPLATE_MAX_LEN];
int range_lo;
int range_hi;
int allowed_indices[64];
int allowed_count;
} path_template_t;
typedef struct {
int active;
int count;
path_template_t templates[PATH_WHITELIST_MAX_TEMPLATES];
} path_whitelist_t;
typedef struct {
char socket_name[SERVER_SOCKET_NAME_MAX];
char last_error[256];
int listen_fd;
int running;
int listen_mode;
int stdio_handled;
void *dispatcher; /* dummy */
void *policy; /* dummy */
int socket_name_explicit;
int auth_mode;
int auth_skew_seconds;
int bridge_source_trusted;
int index_whitelist_active;
unsigned char index_whitelist[INDEX_WHITELIST_BITMAP_SIZE];
path_whitelist_t path_whitelist;
} server_ctx_t;
int server_set_index_whitelist(server_ctx_t *ctx, const char *spec);
int server_set_path_whitelist(server_ctx_t *ctx, const char *spec);
int server_path_whitelist_allows(const server_ctx_t *ctx, const char *role_path);
/* NSIGNER_HEADERLESS_DECLS_END */
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
static int tests_run = 0;
static int tests_passed = 0;
static void check(const char *desc, int condition) {
tests_run++;
if (condition) {
tests_passed++;
printf("PASS: %s\n", desc);
} else {
printf("FAIL: %s\n", desc);
}
}
int main(void) {
server_ctx_t ctx;
/* ---- Test 1: server_set_path_whitelist with "all" ---- */
memset(&ctx, 0, sizeof(ctx));
check("set_path_whitelist 'all' returns 0",
server_set_path_whitelist(&ctx, "all") == 0);
check("'all' sets index_whitelist_active=0",
ctx.index_whitelist_active == 0);
check("'all' sets path_whitelist.active=0",
ctx.path_whitelist.active == 0);
/* ---- Test 2: integer-only spec (backward compat) ---- */
memset(&ctx, 0, sizeof(ctx));
check("set_path_whitelist '0-3' returns 0",
server_set_path_whitelist(&ctx, "0-3") == 0);
check("'0-3' sets index_whitelist_active=1",
ctx.index_whitelist_active == 1);
check("'0-3' does not set path_whitelist.active",
ctx.path_whitelist.active == 0);
/* ---- Test 3: path-template spec ---- */
memset(&ctx, 0, sizeof(ctx));
check("set_path_whitelist 'm/44\\'/1237\\'/0-3/1/0' returns 0",
server_set_path_whitelist(&ctx, "m/44'/1237'/0-3/1/0") == 0);
check("path template sets path_whitelist.active=1",
ctx.path_whitelist.active == 1);
check("path template count=1",
ctx.path_whitelist.count == 1);
check("path template range_lo=0",
ctx.path_whitelist.templates[0].range_lo == 0);
check("path template range_hi=3",
ctx.path_whitelist.templates[0].range_hi == 3);
/* ---- Test 4: server_path_whitelist_allows matching ---- */
check("path_whitelist_allows m/44'/1237'/1/1/0 (in range)",
server_path_whitelist_allows(&ctx, "m/44'/1237'/1/1/0") == 1);
check("path_whitelist_allows m/44'/1237'/0/1/0 (in range)",
server_path_whitelist_allows(&ctx, "m/44'/1237'/0/1/0") == 1);
check("path_whitelist_allows m/44'/1237'/3/1/0 (in range)",
server_path_whitelist_allows(&ctx, "m/44'/1237'/3/1/0") == 1);
check("path_whitelist denies m/44'/1237'/4/1/0 (out of range)",
server_path_whitelist_allows(&ctx, "m/44'/1237'/4/1/0") == 0);
check("path_whitelist denies m/44'/1237'/1/0/0 (wrong change)",
server_path_whitelist_allows(&ctx, "m/44'/1237'/1/0/0") == 0);
/* ---- Test 5: multiple path templates ---- */
memset(&ctx, 0, sizeof(ctx));
check("set_path_whitelist with two templates returns 0",
server_set_path_whitelist(&ctx,
"m/44'/1237'/0-3/0/0,m/44'/1237'/0-3/1/0") == 0);
check("two templates: count=2",
ctx.path_whitelist.count == 2);
check("two templates: allows m/44'/1237'/2/0/0",
server_path_whitelist_allows(&ctx, "m/44'/1237'/2/0/0") == 1);
check("two templates: allows m/44'/1237'/2/1/0",
server_path_whitelist_allows(&ctx, "m/44'/1237'/2/1/0") == 1);
check("two templates: denies m/44'/1237'/2/2/0",
server_path_whitelist_allows(&ctx, "m/44'/1237'/2/2/0") == 0);
/* ---- Test 6: no path whitelist configured → deny ---- */
memset(&ctx, 0, sizeof(ctx));
check("no path whitelist denies all paths (fail-closed)",
server_path_whitelist_allows(&ctx, "m/44'/1237'/1/1/0") == 0);
/* ---- Test 7: role_table_register_role_path ---- */
{
role_table_t table;
role_table_init(&table);
check("register_role_path returns 0",
role_table_register_role_path(&table, "myrole",
"m/44'/1237'/%d/1/0",
PURPOSE_NOSTR, CURVE_SECP256K1,
0, 3, 1, NULL, 0) == 0);
role_entry_t *r = role_table_find_by_name(&table, "myrole");
check("registered role found by name", r != NULL);
check("registered role is SELECTOR_ROLE_PATH",
r != NULL && r->selector_type == SELECTOR_ROLE_PATH);
check("registered role path_range_lo=0",
r != NULL && r->path_range_lo == 0);
check("registered role path_range_hi=3",
r != NULL && r->path_range_hi == 3);
check("registered role path_default_index=1",
r != NULL && r->path_default_index == 1);
check("registered role purpose=NOSTR",
r != NULL && r->purpose == PURPOSE_NOSTR);
check("registered role curve=SECP256K1",
r != NULL && r->curve == CURVE_SECP256K1);
/* Idempotent: registering the same path again returns 0, no duplicate */
check("register_role_path idempotent returns 0",
role_table_register_role_path(&table, "other",
"m/44'/1237'/%d/1/0",
PURPOSE_NOSTR, CURVE_SECP256K1,
0, 3, 1, NULL, 0) == 0);
check("idempotent: no duplicate added",
table.count == 1);
}
/* ---- Test 8: end-to-end derivation with role_path ---- */
{
role_table_t table;
key_store_t key_store;
mnemonic_state_t mnemonic;
const char *valid_12 = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
int rc;
role_table_init(&table);
mnemonic_init(&mnemonic);
/* Load mnemonic */
rc = mnemonic_load(&mnemonic, valid_12);
check("mnemonic load succeeds", rc == 0);
/* Register a fixed-path role (no %d) */
rc = role_table_register_role_path(&table, "testpath",
"m/44'/1237'/1/1/0",
PURPOSE_NOSTR, CURVE_SECP256K1,
0, 0, -1, NULL, 0);
check("register fixed-path role returns 0", rc == 0);
/* Derive all keys */
if (nostr_init() != 0) {
check("nostr_init succeeds", 0);
mnemonic_unload(&mnemonic);
printf("\n%d/%d tests passed\n", tests_passed, tests_run);
return (tests_passed == tests_run) ? 0 : 1;
}
check("nostr_init succeeds", 1);
memset(&key_store, 0, sizeof(key_store));
rc = crypto_derive_all(&key_store, &table, &mnemonic);
check("crypto_derive_all with path role succeeds", rc >= 0);
/* Find the role and check it was derived */
role_entry_t *r = role_table_find_by_name(&table, "testpath");
check("testpath role found", r != NULL);
check("testpath role derived", r != NULL && r->derived == 1);
check("testpath pubkey is 64 hex chars",
r != NULL && strlen(r->pubkey_hex) == 64);
/* Verify the pubkey matches the expected NIP-06 index-1 derivation
* (m/44'/1237'/1'/0/0) this is a sanity check that the path
* derivation produces a valid key. The path m/44'/1237'/1/1/0 is
* different from NIP-06 so the pubkey should differ from index 1. */
{
role_table_t nip06_table;
key_store_t nip06_store;
role_table_init(&nip06_table);
role_table_register_nostr_index(&nip06_table, 1);
memset(&nip06_store, 0, sizeof(nip06_store));
crypto_derive_all(&nip06_store, &nip06_table, &mnemonic);
role_entry_t *nip06_r = role_table_find_by_nostr_index(&nip06_table, 1);
check("NIP-06 index 1 derived",
nip06_r != NULL && nip06_r->derived == 1);
check("path m/44'/1237'/1/1/0 differs from NIP-06 index 1 (m/44'/1237'/1'/0/0)",
r != NULL && nip06_r != NULL &&
strcmp(r->pubkey_hex, nip06_r->pubkey_hex) != 0);
}
mnemonic_unload(&mnemonic);
}
nostr_cleanup();
printf("\n%d/%d tests passed\n", tests_passed, tests_run);
return (tests_passed == tests_run) ? 0 : 1;
}
+7
View File
@@ -116,6 +116,11 @@ typedef struct {
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
int derived; /* 1 if pubkey_hex has been populated */
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
/* The role table */
@@ -174,6 +179,8 @@ typedef struct {
int has_role_path; /* 1 if "role_path" field was present */
char role_path[ROLE_PATH_MAX];
int has_index; /* 1 if "index" field was present (for named path-roles) */
int index; /* index value for named path-role template */
} selector_request_t;
/* Initialize a selector request (all fields zeroed/unset) */
+8 -1
View File
@@ -98,6 +98,11 @@ typedef struct {
char role_path[ROLE_PATH_MAX];
char pubkey_hex[ROLE_PUBKEY_HEX_MAX];
int derived;
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
typedef struct {
@@ -115,7 +120,7 @@ role_purpose_t role_purpose_from_str(const char *s);
role_curve_t role_curve_from_str(const char *s);
const char *role_purpose_to_str(role_purpose_t p);
const char *role_curve_to_str(role_curve_t c);
int role_table_register_nostr_index(role_table_t *table, int nostr_index);
/* from selector.h */
@@ -133,6 +138,8 @@ typedef struct {
int nostr_index;
int has_role_path;
char role_path[ROLE_PATH_MAX];
int has_index; /* 1 if "index" field was present (for named path-roles) */
int index; /* index value for named path-role template */
} selector_request_t;
void selector_request_init(selector_request_t *req);
+8 -1
View File
@@ -95,6 +95,11 @@ typedef struct {
char role_path[ROLE_PATH_MAX];
char pubkey_hex[ROLE_PUBKEY_HEX_MAX];
int derived;
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
typedef struct {
@@ -112,7 +117,7 @@ role_purpose_t role_purpose_from_str(const char *s);
role_curve_t role_curve_from_str(const char *s);
const char *role_purpose_to_str(role_purpose_t p);
const char *role_curve_to_str(role_curve_t c);
int role_table_register_nostr_index(role_table_t *table, int nostr_index);
/* from selector.h */
@@ -130,6 +135,8 @@ typedef struct {
int nostr_index;
int has_role_path;
char role_path[ROLE_PATH_MAX];
int has_index; /* 1 if "index" field was present (for named path-roles) */
int index; /* index value for named path-role template */
} selector_request_t;
void selector_request_init(selector_request_t *req);
+12 -1
View File
@@ -116,6 +116,11 @@ typedef struct {
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
int derived; /* 1 if pubkey_hex has been populated */
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
/* The role table */
@@ -157,7 +162,11 @@ const char *role_curve_to_str(role_curve_t c);
/* Register a nostr-index role if missing. Returns 0 on success, -1 on error. */
int role_table_register_nostr_index(role_table_t *table, int nostr_index);
/* Register a SELECTOR_ROLE_PATH role bound to an explicit derivation path template. */
int role_table_register_role_path(role_table_t *table, const char *name, const char *path,
role_purpose_t purpose, role_curve_t curve,
int range_lo, int range_hi, int default_index,
const int *allowed_indices, int allowed_count);
/* from selector.h */
@@ -177,6 +186,8 @@ typedef struct {
int has_role_path; /* 1 if "role_path" field was present */
char role_path[ROLE_PATH_MAX];
int has_index; /* 1 if "index" field was present (for named path-roles) */
int index; /* index value for named path-role template */
} selector_request_t;
/* Initialize a selector request (all fields zeroed/unset) */
+7
View File
@@ -116,6 +116,11 @@ typedef struct {
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
int derived; /* 1 if pubkey_hex has been populated */
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
/* The role table */
@@ -174,6 +179,8 @@ typedef struct {
int has_role_path; /* 1 if "role_path" field was present */
char role_path[ROLE_PATH_MAX];
int has_index; /* 1 if "index" field was present (for named path-roles) */
int index; /* index value for named path-role template */
} selector_request_t;
/* Initialize a selector request (all fields zeroed/unset) */
+7
View File
@@ -97,6 +97,11 @@ typedef struct {
char role_path[ROLE_PATH_MAX];
char pubkey_hex[ROLE_PUBKEY_HEX_MAX];
int derived;
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
typedef struct {
@@ -128,6 +133,8 @@ typedef struct {
int nostr_index;
int has_role_path;
char role_path[ROLE_PATH_MAX];
int has_index; /* 1 if "index" field was present (for named path-roles) */
int index; /* index value for named path-role template */
} selector_request_t;
void selector_request_init(selector_request_t *req);
+7
View File
@@ -116,6 +116,11 @@ typedef struct {
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
int derived; /* 1 if pubkey_hex has been populated */
int path_range_lo; /* for SELECTOR_ROLE_PATH: inclusive lower bound for %d; -1 = fixed path */
int path_range_hi; /* inclusive upper bound; == path_range_lo for single */
int path_default_index; /* default index when client sends {"role":...} without "index"; -1 = require explicit */
int path_allowed_indices[64]; /* explicit set of allowed indices (for sets); 0 = use range */
int path_allowed_count; /* 0 = use range_lo/range_hi; >0 = use allowed_indices */
} role_entry_t;
/* The role table */
@@ -174,6 +179,8 @@ typedef struct {
int has_role_path; /* 1 if "role_path" field was present */
char role_path[ROLE_PATH_MAX];
int has_index; /* 1 if "index" field was present (for named path-roles) */
int index; /* index value for named path-role template */
} selector_request_t;
/* Initialize a selector request (all fields zeroed/unset) */