Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
821245ac1d | ||
|
|
7cbefe13ec | ||
|
|
d7fb3787e6 | ||
|
|
f0e90e0ea6 | ||
|
|
86a97aee01 | ||
|
|
d7eb6b5ec0 |
@@ -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 |
|
||||
@@ -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)
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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) |
|
||||
@@ -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)).
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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) */
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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
@@ -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) */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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
@@ -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) */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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) */
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) */
|
||||
|
||||
Reference in New Issue
Block a user