Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
697a79dc25 | ||
|
|
e65ed5c5d6 | ||
|
|
821245ac1d | ||
|
|
7cbefe13ec | ||
|
|
d7fb3787e6 | ||
|
|
f0e90e0ea6 | ||
|
|
86a97aee01 |
+25
-3
@@ -55,11 +55,13 @@ RUN if [ "$(uname -m)" = "aarch64" ] && ! command -v aarch64-linux-gnu-gcc >/dev
|
||||
|
||||
# Copy source files
|
||||
COPY src/ /build/src/
|
||||
COPY client/ /build/client/
|
||||
COPY libotppad/ /build/libotppad/
|
||||
COPY resources/tui_continuous/ /build/resources/tui_continuous/
|
||||
COPY resources/pqclean/ /build/resources/pqclean/
|
||||
|
||||
# Build nsigner as a fully static binary
|
||||
# Also build nsigner_client as a fully static binary
|
||||
RUN ARCH="$(uname -m)"; \
|
||||
case "$ARCH" in \
|
||||
aarch64|arm64) NOSTR_LIB="/build/nostr_core_lib/libnostr_core_arm64.a" ;; \
|
||||
@@ -124,9 +126,29 @@ RUN ARCH="$(uname -m)"; \
|
||||
$(pkg-config --static --libs libcurl openssl) \
|
||||
-lsecp256k1 -lsqlite3 -lz -lpthread -lm
|
||||
|
||||
RUN strip /build/nsigner_static || true
|
||||
RUN file /build/nsigner_static && \
|
||||
(ldd /build/nsigner_static 2>&1 || true)
|
||||
# Build nsigner_client (links nostr_core_lib, much smaller than nsigner)
|
||||
RUN ARCH="$(uname -m)"; \
|
||||
case "$ARCH" in \
|
||||
aarch64|arm64) NOSTR_LIB="/build/nostr_core_lib/libnostr_core_arm64.a" ;; \
|
||||
x86_64|amd64) NOSTR_LIB="/build/nostr_core_lib/libnostr_core_x64.a" ;; \
|
||||
*) echo "Unsupported build arch: $ARCH"; exit 1 ;; \
|
||||
esac; \
|
||||
gcc -static -Os -ffunction-sections -fdata-sections -Wl,--gc-sections -s -Wall -Wextra -std=c99 \
|
||||
-DNOSTR_ENABLE_NSIGNER_CLIENT=1 -D_GNU_SOURCE \
|
||||
-I/build/nostr_core_lib \
|
||||
-I/build/nostr_core_lib/nostr_core \
|
||||
-I/build/nostr_core_lib/cjson \
|
||||
/build/client/n_signer_client.c \
|
||||
"$NOSTR_LIB" \
|
||||
-o /build/nsigner_client_static \
|
||||
$(pkg-config --static --libs libcurl openssl) \
|
||||
-lsecp256k1 -lz -lpthread -lm
|
||||
|
||||
RUN strip /build/nsigner_static /build/nsigner_client_static || true
|
||||
RUN file /build/nsigner_static /build/nsigner_client_static && \
|
||||
(ldd /build/nsigner_static 2>&1 || true) && \
|
||||
(ldd /build/nsigner_client_static 2>&1 || true)
|
||||
|
||||
FROM scratch AS output
|
||||
COPY --from=builder /build/nsigner_static /nsigner_static
|
||||
COPY --from=builder /build/nsigner_client_static /nsigner_client_static
|
||||
|
||||
@@ -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
|
||||
@@ -88,10 +89,11 @@ EXAMPLE_PQ_SIGN_TARGET := $(BUILD_DIR)/example_pq_sign
|
||||
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
|
||||
N_SIGNER_CLIENT_TARGET := $(BUILD_DIR)/nsigner_client
|
||||
|
||||
.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 test-n-signer-client examples clients test-client clean
|
||||
|
||||
all: dev
|
||||
all: dev clients
|
||||
|
||||
lib:
|
||||
cd resources/nostr_core_lib && ./build.sh --nips=1,4,6,13,19,44
|
||||
@@ -117,7 +119,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-n-signer-client test-client
|
||||
|
||||
test-integration: $(TEST_INTEGRATION_TARGET) $(TARGET_DEV)
|
||||
./$(TEST_INTEGRATION_TARGET)
|
||||
@@ -176,10 +178,22 @@ test-pubkey-format: $(TEST_PUBKEY_FORMAT_TARGET)
|
||||
test-algorithm-api: $(TEST_ALGORITHM_API_TARGET)
|
||||
./$(TEST_ALGORITHM_API_TARGET)
|
||||
|
||||
test-client: examples
|
||||
test-path-whitelist: $(TEST_PATH_WHITELIST_TARGET)
|
||||
./$(TEST_PATH_WHITELIST_TARGET)
|
||||
|
||||
test-n-signer-client: clients $(TARGET_DEV)
|
||||
./$(TEST_DIR)/test_n_signer_client.sh
|
||||
|
||||
test-client: examples clients
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
$(TEST_MNEMONIC_TARGET): $(TEST_DIR)/test_mnemonic.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(CC) $(CFLAGS) $(TEST_DIR)/test_mnemonic.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c $(SRC_DIR)/otp_pad.c libotppad/libotppad.c -o $(TEST_MNEMONIC_TARGET) $(LDFLAGS)
|
||||
@@ -256,6 +270,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)
|
||||
|
||||
@@ -60,13 +60,12 @@ When started, `n_signer` immediately enters terminal input mode:
|
||||
1. Choose mnemonic source: `[E]nter existing mnemonic` (default) or `[G]enerate new mnemonic`.
|
||||
- On `E`: prompt for mnemonic with terminal echo disabled, then validate.
|
||||
- On `G`: generate a fresh 12-word BIP-39 mnemonic from `getrandom(2)`, display it numbered with a "WRITE THIS DOWN — IT WILL NOT BE SHOWN AGAIN" warning, then continue. There is no confirmation step.
|
||||
2. Build in-memory role/selector state from the mnemonic.
|
||||
2. **Define roles** (mandatory): interactive preset menu wizard — choose from 10 presets (Standard Nostr, hardened range, agent range, SSH, Age, ML-DSA-65, SLH-DSA-128s, ML-KEM-768, OTP, Custom). At least one role must be defined. See [`documents/nsigner_menus.md`](documents/nsigner_menus.md) for the full menu reference.
|
||||
3. **Interactive transport selection** (if no `--listen` flag given and stdin is a TTY): choose one or more of: Local Unix socket, Qubes qrexec bridge, FIPS/TCP listener (framed JSON), HTTP listener (curl-friendly).
|
||||
4. **Index whitelist** (optional): restrict which `nostr_index` values this session can access.
|
||||
5. **OTP pad selection** (optional): auto-scans attached USB drives for OTP pads and offers to bind one. See [`plans/otp_nostr_integration.md`](plans/otp_nostr_integration.md).
|
||||
6. Pick the abstract socket name (random BIP-39 pair, or `--socket-name` / `--name` / `-n` override).
|
||||
7. Initialize transport endpoints and bind the socket.
|
||||
8. Switch to running status display.
|
||||
4. Pick the abstract socket name (random BIP-39 pair, or `--socket-name` / `--name` / `-n` override).
|
||||
5. Initialize transport endpoints and bind the socket.
|
||||
6. Switch to running status display.
|
||||
7. (Optional) Bind an OTP pad via `--otp-pad-dir` / `--otp-pad` flags if an OTP role was defined. See [`plans/otp_nostr_integration.md`](plans/otp_nostr_integration.md).
|
||||
|
||||
No startup files are read or written. The mnemonic — typed or generated — lives only in `mlock`'d memory and is zeroized on shutdown or crash.
|
||||
|
||||
@@ -87,25 +86,24 @@ n_signer v0.0.53 > Main Menu
|
||||
Roles:
|
||||
Role Purpose Curve Derivation path
|
||||
main nostr secp256k1 m/44'/1237'/0'/0/0
|
||||
nostr_idx_1 nostr secp256k1 m/44'/1237'/1'/0/0
|
||||
backup bitcoin secp256k1 m/84'/0'/0'/0/5
|
||||
nostr_agent nostr secp256k1 m/44'/1237'/*'/1'/0'
|
||||
ssh ssh ed25519 m/44'/102001'/0'/0'/0'
|
||||
|
||||
Activity (latest first):
|
||||
16:03:11 allow caller=uid:1000 method=nostr_get_public_key role=main
|
||||
16:02:44 prompt caller=uid:1000 method=nostr_sign_event role=ops
|
||||
16:02:46 allow caller=uid:1000 method=nostr_sign_event role=ops
|
||||
16:03:11 allow caller=uid:1000 method=nostr_get_public_key role=main path=m/44'/1237'/0'/0/0
|
||||
16:02:44 prompt caller=uid:1000 method=nostr_sign_event role=main path=m/44'/1237'/0'/0/0
|
||||
16:02:46 allow caller=uid:1000 method=nostr_sign_event role=main path=m/44'/1237'/0'/0/0
|
||||
15:59:10 deny caller=uid:1001 method=nostr_sign_event error=unauthorized
|
||||
|
||||
session=unlocked (12 words) signer=nsigner_hairy_dog derived=3 auto-approve=OFF
|
||||
session=unlocked (12 words) signer=nsigner_hairy_dog derived=3
|
||||
|
||||
l lock/reunlock
|
||||
r refresh
|
||||
a toggle auto-approve
|
||||
d display connections
|
||||
q/x quit
|
||||
```
|
||||
|
||||
The **Derivation path** column shows the full BIP-44 path for each role's key. For `nostr_index` roles this is `m/44'/1237'/<n>'/0/0` (NIP-06); for `role_path` roles it's the explicit path.
|
||||
The **Derivation path** column shows the full BIP-44 path for each role's key. For roles derived via `role_path` this is the explicit path; for named path-roles it's the template path.
|
||||
|
||||
The signer's name (`nsigner_hairy_dog` in this example) appears in the **status line** at the bottom (`signer=nsigner_hairy_dog`). See [§4.1](#41-linux-desktop-abstract-namespace-unix-socket) for how the name is generated.
|
||||
|
||||
@@ -115,7 +113,6 @@ Pressing `d` clears the screen and shows each active transport as a titled block
|
||||
|
||||
Hotkeys (active while the status display is shown):
|
||||
|
||||
- `a` — toggle auto-approve (prompt) for this session
|
||||
- `r` — refresh the display
|
||||
- `d` — display connection instructions (press any key to return)
|
||||
- `l` — lock / re-unlock the session
|
||||
@@ -129,10 +126,11 @@ When a request needs confirmation, `n_signer` interrupts the status view with a
|
||||
Approval required
|
||||
caller: uid:1000
|
||||
method: nostr_sign_event
|
||||
selector: role=ops
|
||||
purpose/curve: nostr/secp256k1
|
||||
role: main
|
||||
path: m/44'/1237'/0'/0/0
|
||||
purpose: nostr
|
||||
|
||||
[y] allow once [n] deny [a] always allow this session
|
||||
y: allow once n: deny e: allow this caller+role+verb for session a: allow this caller+role for session (all verbs)
|
||||
```
|
||||
|
||||
No response is emitted to caller until the local user decides.
|
||||
@@ -191,10 +189,16 @@ 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. |
|
||||
| 2003 | `path_not_allowed` | `role_path` does not match any registered role or allowed path. |
|
||||
| 2005 | `index_out_of_range` | `index` outside the named role's `[lo,hi]` range. |
|
||||
| 2006 | `nostr_index_deprecated` | `nostr_index` is removed — use `role` + `role_path` instead. |
|
||||
| 2007 | `index_deprecated` | `index` is removed for nostr verbs — use `role_path` with the full path. |
|
||||
| 2008 | `role_required` | `role` is required when using `role_path`. |
|
||||
| 2009 | `path_required` | `role_path` is required for roles with variable path templates. |
|
||||
|
||||
### 4.3 Verbs
|
||||
|
||||
All verbs take their arguments as positional `params` and their options in a trailing options object. Most verbs select a key via the `algorithm` + `index` options (see [§4.4](#44-algorithms)). The `nostr_*` verbs select a secp256k1 NIP-06 key via `nostr_index` and implement Nostr-protocol-specific serialization on top of the raw crypto.
|
||||
All verbs take their arguments as positional `params` and their options in a trailing options object. Most verbs select a key via the `algorithm` + `index` options (see [§4.4](#44-algorithms)). The `nostr_*` verbs select a secp256k1 NIP-06 key via `role` + `role_path` and implement Nostr-protocol-specific serialization on top of the raw crypto.
|
||||
|
||||
| Verb | Algorithms | Positional params | Options |
|
||||
|-------------------------|-----------------------------------------------|----------------------------------|----------------------------------|
|
||||
@@ -208,13 +212,13 @@ All verbs take their arguments as positional `params` and their options in a tra
|
||||
| `derive` | secp256k1 | `<data>` | `algorithm`, `index` (required) |
|
||||
| `encrypt` | otp | `<plaintext_base64>` | `algorithm`, `encoding` |
|
||||
| `decrypt` | otp | `<ciphertext>` | `algorithm`, `encoding` |
|
||||
| `nostr_get_public_key` | secp256k1 (NIP-06) | — | `nostr_index`, `format` |
|
||||
| `nostr_sign_event` | secp256k1 (NIP-06) | `<event_json>` | `nostr_index` |
|
||||
| `nostr_mine_event` | secp256k1 (NIP-06) | `<event_json>` | `nostr_index`, `difficulty`, `timeout_sec`, `threads` |
|
||||
| `nostr_nip04_encrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<plaintext>` | `nostr_index` |
|
||||
| `nostr_nip04_decrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<ciphertext>` | `nostr_index` |
|
||||
| `nostr_nip44_encrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<plaintext>` | `nostr_index` |
|
||||
| `nostr_nip44_decrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<ciphertext>` | `nostr_index` |
|
||||
| `nostr_get_public_key` | secp256k1 (NIP-06) | — | `role`, `role_path`, `format` |
|
||||
| `nostr_sign_event` | secp256k1 (NIP-06) | `<event_json>` | `role`, `role_path` |
|
||||
| `nostr_mine_event` | secp256k1 (NIP-06) | `<event_json>` | `role`, `role_path`, `difficulty`, `timeout_sec`, `threads` |
|
||||
| `nostr_nip04_encrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<plaintext>` | `role`, `role_path` |
|
||||
| `nostr_nip04_decrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<ciphertext>` | `role`, `role_path` |
|
||||
| `nostr_nip44_encrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<plaintext>` | `role`, `role_path` |
|
||||
| `nostr_nip44_decrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<ciphertext>` | `role`, `role_path` |
|
||||
|
||||
\* `scheme` is secp256k1-only: `"schnorr"` (default, BIP-340) or `"ecdsa"`.
|
||||
|
||||
@@ -401,7 +405,7 @@ If no pad is bound at startup, the error is `-32601` `otp_pad_not_bound`.
|
||||
#### `nostr_get_public_key`
|
||||
|
||||
```json
|
||||
{ "id": "10", "method": "nostr_get_public_key", "params": [ { "nostr_index": 0 } ] }
|
||||
{ "id": "10", "method": "nostr_get_public_key", "params": [ { "role": "main" } ] }
|
||||
```
|
||||
|
||||
Response (default): a plain 64-hex-char secp256k1 public key string.
|
||||
@@ -412,7 +416,7 @@ Response with `{"format":"structured"}` in options: `{"algorithm":"secp256k1","p
|
||||
Serializes the event to canonical form (`[0, pubkey, created_at, kind, tags, content]`), SHA-256 hashes it to produce the event `id`, signs the hash with BIP-340 Schnorr, and returns the complete signed event.
|
||||
|
||||
```json
|
||||
{ "id": "11", "method": "nostr_sign_event", "params": [ "<event_json>", { "nostr_index": 0 } ] }
|
||||
{ "id": "11", "method": "nostr_sign_event", "params": [ "<event_json>", { "role": "main" } ] }
|
||||
```
|
||||
|
||||
`<event_json>` is the unsigned event object:
|
||||
@@ -430,7 +434,7 @@ Mines NIP-13 proof-of-work (adds a `nonce` tag) and signs the event in one step.
|
||||
{
|
||||
"id": "12",
|
||||
"method": "nostr_mine_event",
|
||||
"params": [ "<event_json>", { "difficulty": 20, "threads": 4, "timeout_sec": 30, "nostr_index": 0 } ]
|
||||
"params": [ "<event_json>", { "difficulty": 20, "threads": 4, "timeout_sec": 30, "role": "main" } ]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -459,8 +463,8 @@ Errors:
|
||||
NIP-04 encryption (deprecated in Nostr but still widely used): ECDH + AES-256-CBC, base64 payload.
|
||||
|
||||
```json
|
||||
{ "id": "13", "method": "nostr_nip04_encrypt", "params": [ "<peer_pubkey_hex>", "<plaintext>", { "nostr_index": 0 } ] }
|
||||
{ "id": "14", "method": "nostr_nip04_decrypt", "params": [ "<peer_pubkey_hex>", "<ciphertext>", { "nostr_index": 0 } ] }
|
||||
{ "id": "13", "method": "nostr_nip04_encrypt", "params": [ "<peer_pubkey_hex>", "<plaintext>", { "role": "main" } ] }
|
||||
{ "id": "14", "method": "nostr_nip04_decrypt", "params": [ "<peer_pubkey_hex>", "<ciphertext>", { "role": "main" } ] }
|
||||
```
|
||||
|
||||
`encrypt` returns the NIP-04 ciphertext string; `decrypt` returns the plaintext string.
|
||||
@@ -470,8 +474,8 @@ NIP-04 encryption (deprecated in Nostr but still widely used): ECDH + AES-256-CB
|
||||
NIP-44 encryption (current Nostr standard): ECDH + HKDF + ChaCha20-Poly1305 + specific payload format.
|
||||
|
||||
```json
|
||||
{ "id": "15", "method": "nostr_nip44_encrypt", "params": [ "<peer_pubkey_hex>", "<plaintext>", { "nostr_index": 0 } ] }
|
||||
{ "id": "16", "method": "nostr_nip44_decrypt", "params": [ "<peer_pubkey_hex>", "<ciphertext>", { "nostr_index": 0 } ] }
|
||||
{ "id": "15", "method": "nostr_nip44_encrypt", "params": [ "<peer_pubkey_hex>", "<plaintext>", { "role": "main" } ] }
|
||||
{ "id": "16", "method": "nostr_nip44_decrypt", "params": [ "<peer_pubkey_hex>", "<ciphertext>", { "role": "main" } ] }
|
||||
```
|
||||
|
||||
`encrypt` returns the NIP-44 ciphertext string; `decrypt` returns the plaintext string.
|
||||
@@ -482,11 +486,68 @@ The `nostr_*` verbs select a secp256k1 NIP-06 key via the options object. Suppor
|
||||
|
||||
| Selector | Meaning |
|
||||
|----------------|--------------------------------------------------|
|
||||
| `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` | Name of a pre-registered role entry (required) |
|
||||
| `role_path` | Full BIP-44 derivation path (required) |
|
||||
|
||||
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).
|
||||
**Selector resolution**: both `role` and `role_path` are required together — they form a single combined selector. The server verifies that the supplied `role_path` matches the role's registered template (expanding any wildcard). There is no resolution order and no default role: omitting either field is rejected (`2008 role_required` / `2009 path_required`). 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 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 template is hidden from clients — they only know the role name and send the full concrete `role_path` with each request.
|
||||
|
||||
The wizard presents a **preset menu** of 10 options covering the common role types. You can still define custom roles manually via the "Custom path" option.
|
||||
|
||||
```
|
||||
Wizard preset menu:
|
||||
1. Standard Nostr (NIP-06): secp256k1, m/44'/1237'/0'/0/0
|
||||
2. Standard Nostr hardened range: secp256k1, m/44'/1237'/*'/0'/0'
|
||||
3. Nostr agent range (hardened): secp256k1, m/44'/1237'/*'/1'/0'
|
||||
4. SSH role: ed25519, m/44'/102001'/0'/0'/0'
|
||||
5. Age/x25519 role: x25519, m/44'/102002'/0'/0'/0'
|
||||
6. ML-DSA-65 role: post-quantum signatures, m/44'/102003'/0'/0'/0'
|
||||
7. SLH-DSA-128s role: post-quantum signatures, m/44'/102004'/0'/0'/0'
|
||||
8. ML-KEM-768 role: post-quantum KEM, m/44'/102005'/0'/0'/0'
|
||||
9. OTP role (one-time pad encryption)
|
||||
10. Custom path
|
||||
```
|
||||
|
||||
Purpose is auto-detected from the path prefix (e.g. `m/44'/1237'` → nostr, `m/44'/102001'` → ssh). The path template is pre-filled from the chosen preset and can be edited inline with arrow keys, backspace, and delete.
|
||||
|
||||
**Path template syntax:**
|
||||
- **Wildcard**: `m/44'/1237'/*'/0'/0'` — any non-negative integer, hardened. No range limit.
|
||||
- **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
|
||||
- The first segment that is a plain number, range (`N-M`), set (`A+B+C`), or wildcard (`*`) becomes the variable. Segments with `'` (like `44'`, `1237'`) are treated as literal hardened constants.
|
||||
|
||||
**`requires_approval`**: Each named path-role is marked in the wizard as requiring explicit approval at the signer terminal before any operation is performed (`Requires approval? [y/N]`). Roles with `requires_approval=false` skip the prompt — the role name itself acts as a password: any caller that knows the role name and supplies a matching `role_path` is served without attendant interaction. Roles with `requires_approval=true` always prompt the attendant.
|
||||
|
||||
Clients request keys by supplying both `role` and the full concrete `role_path`:
|
||||
|
||||
```json
|
||||
{"id":"1","method":"nostr_get_public_key","params":[{"role":"myrole","role_path":"m/44'/1237'/0'/1/0"}]}
|
||||
```
|
||||
→ derives `m/44'/1237'/0'/1/0`, verified against the `myrole` template.
|
||||
|
||||
```json
|
||||
{"id":"2","method":"nostr_get_public_key","params":[{"role":"myrole","role_path":"m/44'/1237'/5'/1/0"}]}
|
||||
```
|
||||
→ `2003 path_not_allowed` (5 is outside the registered template, if the template was a fixed path or limited range).
|
||||
|
||||
```json
|
||||
{"id":"3","method":"nostr_get_public_key","params":[{"role":"unknown","role_path":"m/44'/1237'/0'/0/0"}]}
|
||||
```
|
||||
→ `1002 unknown_role` (name not registered).
|
||||
|
||||
```json
|
||||
{"id":"4","method":"nostr_get_public_key","params":[{"role":"myrole"}]}
|
||||
```
|
||||
→ `2009 path_required` (`role_path` is required).
|
||||
|
||||
```json
|
||||
{"id":"5","method":"nostr_get_public_key","params":[{"role_path":"m/44'/1237'/0'/0/0"}]}
|
||||
```
|
||||
→ `2008 role_required` (`role` is required when using `role_path`).
|
||||
|
||||
### 4.7 Pre-approval
|
||||
|
||||
@@ -500,10 +561,10 @@ nsigner --preapprove caller=uid:1000,algorithm=ml-kem-768,index=0,verb=decapsula
|
||||
|
||||
Nostr (role-based):
|
||||
```bash
|
||||
nsigner --preapprove caller=uid:1000,nostr_index=0,verb=nostr_sign_event,nostr_get_public_key
|
||||
nsigner --preapprove caller=uid:1000,role=main,verb=nostr_sign_event,nostr_get_public_key
|
||||
```
|
||||
|
||||
A `*` wildcard matches any caller, role, or verb. Index ranges use `min-max` syntax. Unmatched requests fall through to the default policy (prompt for same-uid, deny for others).
|
||||
A `*` wildcard matches any caller, role, or verb. Unmatched requests fall through to the default policy (prompt for same-uid, deny for others).
|
||||
|
||||
## 5. Transports
|
||||
|
||||
@@ -533,7 +594,7 @@ curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
|
||||
Sign a Nostr event:
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
|
||||
-d '{"id":"1","method":"nostr_sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"},{"nostr_index":0}]}'
|
||||
-d '{"id":"1","method":"nostr_sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"},{"role":"main"}]}'
|
||||
```
|
||||
|
||||
OTP encrypt:
|
||||
@@ -551,7 +612,7 @@ nsigner --socket-name nsigner client \
|
||||
|
||||
# Sign a Nostr event
|
||||
nsigner --socket-name nsigner client \
|
||||
'{"id":"2","method":"nostr_sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"},{"nostr_index":0}]}'
|
||||
'{"id":"2","method":"nostr_sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"},{"role":"main"}]}'
|
||||
|
||||
# ed25519 sign
|
||||
nsigner --socket-name nsigner client \
|
||||
@@ -676,16 +737,22 @@ nsigner --listen unix --socket-name nsigner --bridge-source-trusted
|
||||
|
||||
### 7.2 Send a request (client mode)
|
||||
|
||||
From another terminal, target the signer by its socket name:
|
||||
The standalone `nsigner_client` binary is the recommended client. See [`client/n_signer_client_README.md`](client/n_signer_client_README.md) for full documentation.
|
||||
|
||||
```bash
|
||||
nsigner --socket-name nsigner_hairy_dog client '{"id":"1","method":"nostr_get_public_key","params":[]}'
|
||||
nsigner_client --role main --path "m/44'/1237'/0'/0/0" get-public-key
|
||||
```
|
||||
|
||||
If only one signer is running you can omit the override and the client will use the default discovery rule.
|
||||
If only one signer is running you can omit the `--socket-name` override and the client will use the default discovery rule.
|
||||
|
||||
Example signing request:
|
||||
```bash
|
||||
nsigner -n nsigner_hairy_dog client '{"id":"2","method":"nostr_sign_event","params":["<event_json>",{"role":"main"}]}'
|
||||
nsigner_client --role main --path "m/44'/1237'/0'/0/0" sign-event '<event_json>'
|
||||
```
|
||||
|
||||
The raw `nsigner client` subcommand (sending a hand-built JSON-RPC object over the socket) is still available for scripting:
|
||||
```bash
|
||||
nsigner --socket-name nsigner_hairy_dog client '{"id":"2","method":"nostr_sign_event","params":["<event_json>",{"role":"main","role_path":"m/44'"'"'1237'"'"'/0'"'"'/0'"'"'/0"}]}'
|
||||
```
|
||||
|
||||
### 7.3 List running signers
|
||||
@@ -694,25 +761,27 @@ nsigner -n nsigner_hairy_dog client '{"id":"2","method":"nostr_sign_event","para
|
||||
nsigner list
|
||||
```
|
||||
|
||||
Prints the abstract socket names of any currently running `nsigner` instances, e.g.:
|
||||
Prints the names of any currently running `nsigner` instances, e.g.:
|
||||
```text
|
||||
@nsigner_hairy_dog
|
||||
@nsigner_brave_canyon
|
||||
nsigner_hairy_dog
|
||||
nsigner_brave_canyon
|
||||
```
|
||||
|
||||
`nsigner_client list` is an equivalent alternative that uses the same discovery mechanism.
|
||||
|
||||
### 7.4 Example session
|
||||
|
||||
Terminal A:
|
||||
```text
|
||||
$ nsigner
|
||||
[unlock] enter mnemonic:
|
||||
System is ready and waiting for connections on @nsigner_hairy_dog.
|
||||
[prompt] caller=uid:1000 method=nostr_sign_event role=main -> allow? (y/n)
|
||||
System is ready and waiting for connections on nsigner_hairy_dog.
|
||||
[prompt] caller=uid:1000 method=nostr_sign_event role=main path=m/44'/1237'/0'/0/0 -> allow? (y/n)
|
||||
```
|
||||
|
||||
Terminal B:
|
||||
```text
|
||||
$ nsigner --socket-name nsigner_hairy_dog client '{"id":"2","method":"nostr_sign_event","params":["<event_json>",{"role":"main"}]}'
|
||||
$ nsigner_client --role main --path "m/44'/1237'/0'/0/0" sign-event '<event_json>'
|
||||
{"id":"2","result":"<signed_event_json>"}
|
||||
```
|
||||
|
||||
@@ -738,3 +807,9 @@ Static build:
|
||||
./build_static.sh
|
||||
./build/nsigner_static_x86_64 --version
|
||||
```
|
||||
|
||||
Client build (`nsigner_client`):
|
||||
```bash
|
||||
make clients
|
||||
```
|
||||
See [`client/n_signer_client_README.md`](client/n_signer_client_README.md) for client build details.
|
||||
|
||||
+24
-5
@@ -70,14 +70,17 @@ case "$ARCH" in
|
||||
x86_64)
|
||||
PLATFORM="linux/amd64"
|
||||
OUTPUT_NAME="nsigner_static_x86_64"
|
||||
CLIENT_NAME="nsigner_client_static_x86_64"
|
||||
;;
|
||||
arm64)
|
||||
PLATFORM="linux/arm64"
|
||||
OUTPUT_NAME="nsigner_static_arm64"
|
||||
CLIENT_NAME="nsigner_client_static_arm64"
|
||||
;;
|
||||
armv7)
|
||||
PLATFORM="linux/arm/v7"
|
||||
OUTPUT_NAME="nsigner_static_armv7"
|
||||
CLIENT_NAME="nsigner_client_static_armv7"
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unsupported target architecture '$ARCH'"
|
||||
@@ -103,6 +106,7 @@ echo "Project root: $SCRIPT_DIR"
|
||||
echo "Dockerfile: $DOCKERFILE"
|
||||
echo "Platform: $PLATFORM"
|
||||
echo "Output: $BUILD_DIR/$OUTPUT_NAME"
|
||||
echo "Client: $BUILD_DIR/$CLIENT_NAME"
|
||||
echo ""
|
||||
|
||||
if [ "$ARCH" != "$HOST_ARCH" ]; then
|
||||
@@ -130,22 +134,37 @@ docker buildx build \
|
||||
--load \
|
||||
"$SCRIPT_DIR"
|
||||
|
||||
echo "[2/3] Extracting static binary"
|
||||
echo "[2/3] Extracting static binaries"
|
||||
CONTAINER_NAME="$(docker create "$IMAGE_TAG")"
|
||||
docker cp "$CONTAINER_NAME:/build/nsigner_static" "$BUILD_DIR/$OUTPUT_NAME"
|
||||
chmod +x "$BUILD_DIR/$OUTPUT_NAME"
|
||||
strip "$BUILD_DIR/$OUTPUT_NAME" >/dev/null 2>&1 || true
|
||||
|
||||
echo "[3/3] Verifying static binary"
|
||||
docker cp "$CONTAINER_NAME:/build/nsigner_client_static" "$BUILD_DIR/$CLIENT_NAME"
|
||||
chmod +x "$BUILD_DIR/$CLIENT_NAME"
|
||||
strip "$BUILD_DIR/$CLIENT_NAME" >/dev/null 2>&1 || true
|
||||
|
||||
echo "[3/3] Verifying static binaries"
|
||||
file "$BUILD_DIR/$OUTPUT_NAME"
|
||||
file "$BUILD_DIR/$CLIENT_NAME"
|
||||
|
||||
LDD_OUTPUT="$(ldd "$BUILD_DIR/$OUTPUT_NAME" 2>&1 || true)"
|
||||
echo "$LDD_OUTPUT"
|
||||
if echo "$LDD_OUTPUT" | grep -Eq "not a dynamic executable|statically linked"; then
|
||||
echo "Static check: PASS"
|
||||
echo "nsigner static check: PASS"
|
||||
else
|
||||
echo "Static check: WARNING (verify manually)"
|
||||
echo "nsigner static check: WARNING (verify manually)"
|
||||
fi
|
||||
|
||||
LDD_OUTPUT_CLIENT="$(ldd "$BUILD_DIR/$CLIENT_NAME" 2>&1 || true)"
|
||||
echo "$LDD_OUTPUT_CLIENT"
|
||||
if echo "$LDD_OUTPUT_CLIENT" | grep -Eq "not a dynamic executable|statically linked"; then
|
||||
echo "nsigner_client static check: PASS"
|
||||
else
|
||||
echo "nsigner_client static check: WARNING (verify manually)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Build complete: $BUILD_DIR/$OUTPUT_NAME"
|
||||
echo "Build complete:"
|
||||
echo " $BUILD_DIR/$OUTPUT_NAME"
|
||||
echo " $BUILD_DIR/$CLIENT_NAME"
|
||||
|
||||
@@ -0,0 +1,944 @@
|
||||
/*
|
||||
* n_signer_client.c — standalone Linux CLI for n_signer JSON-RPC API.
|
||||
*
|
||||
* Connects to a running n_signer process over its abstract UNIX socket
|
||||
* (or TCP/serial/qrexec) and exposes the full verb surface over stdin/stdout
|
||||
* so that signed events can be piped directly into `nak publish`.
|
||||
*
|
||||
* Build: make clients
|
||||
* Usage: n_signer_client [global options] <verb> [verb args...]
|
||||
*
|
||||
* See client/n_signer_client_README.md for full documentation.
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <ctype.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
|
||||
#include "nostr_common.h"
|
||||
#include "nsigner_transport.h"
|
||||
#include "nsigner_client.h"
|
||||
#include "../cjson/cJSON.h"
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void print_usage(FILE *fp, const char *prog) {
|
||||
fprintf(fp,
|
||||
"Usage: %s [global options] <verb> [verb args...]\n"
|
||||
"\n"
|
||||
"Global options:\n"
|
||||
" -n, --socket-name <name> Abstract socket name (default: auto-discover)\n"
|
||||
" --timeout <ms> Transport timeout (default 5000)\n"
|
||||
" --tcp <host:port> TCP transport (requires --auth-privkey)\n"
|
||||
" --serial <device> USB CDC-ACM serial transport\n"
|
||||
" --qrexec <qube:svc> Qubes qrexec transport\n"
|
||||
" --auth-privkey <hex> Auth envelope privkey (32 bytes hex)\n"
|
||||
" --auth-label <text> Auth envelope label\n"
|
||||
"\n"
|
||||
"Selector options (for nostr_* verbs):\n"
|
||||
" --role <name> Named path-role\n"
|
||||
" --path <path> Full BIP-44 derivation path\n"
|
||||
"\n"
|
||||
"Algorithm options (for algorithm-based verbs):\n"
|
||||
" -a, --algorithm <alg> secp256k1/ed25519/x25519/ml-dsa-65/\n"
|
||||
" slh-dsa-128s/ml-kem-768/otp\n"
|
||||
" --scheme <schnorr|ecdsa> secp256k1 sign/verify scheme (default schnorr)\n"
|
||||
" --encoding <base64|hex> OTP encoding (default base64)\n"
|
||||
" --format <plain|structured> get-public-key output (default plain)\n"
|
||||
" --index <N> Algorithm derivation index\n"
|
||||
"\n"
|
||||
"Mine-event options:\n"
|
||||
" --difficulty <N> Target leading zero bits\n"
|
||||
" --threads <N> Mining threads (default 1)\n"
|
||||
" --timeout-sec <N> Mining timeout in seconds\n"
|
||||
"\n"
|
||||
"Verbs:\n"
|
||||
" list List running n_signer sockets\n"
|
||||
" get-info\n"
|
||||
" get-public-key\n"
|
||||
" sign-event\n"
|
||||
" mine-event\n"
|
||||
" nip04-encrypt <peer-pubkey>\n"
|
||||
" nip04-decrypt <peer-pubkey>\n"
|
||||
" nip44-encrypt <peer-pubkey>\n"
|
||||
" nip44-decrypt <peer-pubkey>\n"
|
||||
" sign <msg-hex>\n"
|
||||
" verify <msg-hex> <sig-hex>\n"
|
||||
" derive <data>\n"
|
||||
" encapsulate <peer-pubkey-hex>\n"
|
||||
" decapsulate <ciphertext-hex>\n"
|
||||
" derive-shared-secret <peer-pubkey-hex>\n"
|
||||
" encrypt <plaintext>\n"
|
||||
" decrypt <ciphertext>\n"
|
||||
" call <method>\n"
|
||||
"\n"
|
||||
"Examples:\n"
|
||||
" # List running n_signer sockets\n"
|
||||
" %s list\n"
|
||||
"\n"
|
||||
" # Get a Nostr public key by role and path\n"
|
||||
" %s --role main --path \"m/44'/1237'/0'/0/0\" get-public-key\n"
|
||||
"\n"
|
||||
" # Get a key by named path-role\n"
|
||||
" %s --role role1 --path \"m/44'/1237'/1'/1/0\" get-public-key\n"
|
||||
"\n"
|
||||
" # Sign a Nostr event from stdin and pipe to nak for publishing\n"
|
||||
" echo '{\"kind\":1,\"content\":\"hello world\",\"tags\":[],\"created_at\":1700000000}' \\\n"
|
||||
" | %s --role main --path \"m/44'/1237'/0'/0/0\" sign-event | nak publish\n"
|
||||
"\n"
|
||||
" # Sign an event from argv\n"
|
||||
" %s --role main --path \"m/44'/1237'/0'/0/0\" sign-event '{\"kind\":1,\"content\":\"hi\",\"tags\":[],\"created_at\":1700000000}'\n"
|
||||
"\n"
|
||||
" # Mine an event with proof-of-work (difficulty 20)\n"
|
||||
" %s --role main --path \"m/44'/1237'/0'/0/0\" --difficulty 20 mine-event '{\"kind\":1,\"content\":\"mined\",\"tags\":[],\"created_at\":1700000000}'\n"
|
||||
"\n"
|
||||
" # NIP-44 encrypt then decrypt a round-trip\n"
|
||||
" %s --role main --path \"m/44'/1237'/0'/0/0\" nip44-encrypt <peer-pubkey> 'secret message'\n"
|
||||
" %s --role main --path \"m/44'/1237'/0'/0/0\" nip44-decrypt <peer-pubkey> '<ciphertext>'\n"
|
||||
"\n"
|
||||
" # Ed25519 sign (SSH-style)\n"
|
||||
" %s --algorithm ed25519 --index 0 sign 68656c6c6f\n"
|
||||
"\n"
|
||||
" # Get signer metadata\n"
|
||||
" %s get-info\n",
|
||||
prog, prog, prog, prog, prog, prog, prog, prog, prog, prog, prog);
|
||||
}
|
||||
|
||||
/* Read one line from stdin (newline stripped). Returns malloc'd string or NULL on EOF/error. */
|
||||
static char *read_stdin_line(void) {
|
||||
size_t cap = 4096;
|
||||
size_t len = 0;
|
||||
char *buf = malloc(cap);
|
||||
if (!buf) return NULL;
|
||||
|
||||
int c;
|
||||
while ((c = fgetc(stdin)) != EOF && c != '\n') {
|
||||
if (len + 1 >= cap) {
|
||||
cap *= 2;
|
||||
char *tmp = realloc(buf, cap);
|
||||
if (!tmp) { free(buf); return NULL; }
|
||||
buf = tmp;
|
||||
}
|
||||
buf[len++] = (char)c;
|
||||
}
|
||||
if (len == 0 && c == EOF) { free(buf); return NULL; }
|
||||
buf[len] = '\0';
|
||||
return buf;
|
||||
}
|
||||
|
||||
/* Convert a hex string to raw bytes. Returns number of bytes written, or -1 on error. */
|
||||
static int hex_to_bytes(const char *hex, unsigned char *out, size_t out_sz) {
|
||||
size_t len = strlen(hex);
|
||||
if (len % 2 != 0 || len / 2 > out_sz) return -1;
|
||||
for (size_t i = 0; i < len / 2; i++) {
|
||||
unsigned int byte;
|
||||
if (sscanf(hex + 2 * i, "%2x", &byte) != 1) return -1;
|
||||
out[i] = (unsigned char)byte;
|
||||
}
|
||||
return (int)(len / 2);
|
||||
}
|
||||
|
||||
/* Parse "host:port" string. Returns 0 on success. */
|
||||
static int parse_host_port(const char *s, char **out_host, int *out_port) {
|
||||
const char *colon = strrchr(s, ':');
|
||||
if (!colon || colon == s) return -1;
|
||||
size_t host_len = (size_t)(colon - s);
|
||||
*out_host = malloc(host_len + 1);
|
||||
if (!*out_host) return -1;
|
||||
memcpy(*out_host, s, host_len);
|
||||
(*out_host)[host_len] = '\0';
|
||||
char *end = NULL;
|
||||
long p = strtol(colon + 1, &end, 10);
|
||||
if (end == colon + 1 || *end != '\0' || p < 1 || p > 65535) {
|
||||
free(*out_host);
|
||||
*out_host = NULL;
|
||||
return -1;
|
||||
}
|
||||
*out_port = (int)p;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Parse "qube:service" string. Returns 0 on success. */
|
||||
static int parse_qube_service(const char *s, char **out_qube, char **out_service) {
|
||||
const char *colon = strchr(s, ':');
|
||||
if (!colon || colon == s) return -1;
|
||||
size_t qube_len = (size_t)(colon - s);
|
||||
*out_qube = malloc(qube_len + 1);
|
||||
if (!*out_qube) return -1;
|
||||
memcpy(*out_qube, s, qube_len);
|
||||
(*out_qube)[qube_len] = '\0';
|
||||
*out_service = strdup(colon + 1);
|
||||
if (!*out_service) { free(*out_qube); *out_qube = NULL; return -1; }
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Result printing helper */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/*
|
||||
* Print a cJSON result value to stdout as a single newline-terminated line.
|
||||
* Returns 0 for "valid" / 1 for "invalid" on verify verbs, -1 otherwise.
|
||||
*/
|
||||
static int print_result(cJSON *result, int is_verify) {
|
||||
if (!result) {
|
||||
printf("null\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (is_verify) {
|
||||
/* verify result: cJSON string containing JSON object like {"valid":true,...}
|
||||
* or a plain string "valid"/"invalid" */
|
||||
if (cJSON_IsString(result)) {
|
||||
const char *s = result->valuestring;
|
||||
/* Try parsing as JSON object */
|
||||
cJSON *parsed = cJSON_Parse(s);
|
||||
if (parsed) {
|
||||
cJSON *v = cJSON_GetObjectItemCaseSensitive(parsed, "valid");
|
||||
if (v && cJSON_IsBool(v)) {
|
||||
printf("%s\n", cJSON_IsTrue(v) ? "valid" : "invalid");
|
||||
cJSON_Delete(parsed);
|
||||
return cJSON_IsTrue(v) ? 0 : 1;
|
||||
}
|
||||
cJSON_Delete(parsed);
|
||||
}
|
||||
/* Fallback: check string value */
|
||||
if (strcmp(s, "valid") == 0 || strcmp(s, "true") == 0) {
|
||||
printf("valid\n");
|
||||
return 0;
|
||||
}
|
||||
printf("invalid\n");
|
||||
return 1;
|
||||
}
|
||||
if (cJSON_IsBool(result)) {
|
||||
printf("%s\n", cJSON_IsTrue(result) ? "valid" : "invalid");
|
||||
return cJSON_IsTrue(result) ? 0 : 1;
|
||||
}
|
||||
printf("invalid\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (cJSON_IsString(result)) {
|
||||
printf("%s\n", result->valuestring);
|
||||
} else if (cJSON_IsObject(result) || cJSON_IsArray(result)) {
|
||||
char *json = cJSON_PrintUnformatted(result);
|
||||
if (json) {
|
||||
printf("%s\n", json);
|
||||
free(json);
|
||||
}
|
||||
} else if (cJSON_IsNumber(result)) {
|
||||
/* Use valuedouble for all numbers; cJSON stores ints as doubles internally */
|
||||
double d = result->valuedouble;
|
||||
if (d == (double)(int)d) {
|
||||
printf("%d\n", (int)d);
|
||||
} else {
|
||||
printf("%g\n", d);
|
||||
}
|
||||
} else if (cJSON_IsTrue(result)) {
|
||||
printf("true\n");
|
||||
} else if (cJSON_IsFalse(result)) {
|
||||
printf("false\n");
|
||||
} else if (cJSON_IsNull(result)) {
|
||||
printf("null\n");
|
||||
} else {
|
||||
printf("\n");
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
/* ---- globals ---- */
|
||||
const char *socket_name = NULL;
|
||||
int timeout_ms = 5000;
|
||||
const char *tcp_arg = NULL;
|
||||
const char *serial_arg = NULL;
|
||||
const char *qrexec_arg = NULL;
|
||||
const char *auth_privkey_hex = NULL;
|
||||
const char *auth_label = NULL;
|
||||
|
||||
/* ---- selectors (nostr verbs) ---- */
|
||||
const char *role = NULL;
|
||||
const char *path = NULL;
|
||||
int has_index = 0;
|
||||
int index_val = 0;
|
||||
|
||||
/* ---- algorithm options ---- */
|
||||
const char *algorithm = NULL;
|
||||
int alg_index = 0;
|
||||
int has_alg_index = 0;
|
||||
const char *scheme = NULL;
|
||||
const char *encoding = NULL;
|
||||
const char *format = NULL;
|
||||
|
||||
/* ---- mine-event options ---- */
|
||||
int has_difficulty = 0;
|
||||
int difficulty_val = 0;
|
||||
int has_threads = 0;
|
||||
int threads_val = 1;
|
||||
int has_timeout_sec = 0;
|
||||
int timeout_sec_val = 0;
|
||||
|
||||
const char *prog = argv[0];
|
||||
|
||||
/* ---- parse global options ---- */
|
||||
int i = 1;
|
||||
while (i < argc && argv[i][0] == '-') {
|
||||
const char *arg = argv[i];
|
||||
|
||||
if (strcmp(arg, "--help") == 0 || strcmp(arg, "-h") == 0) {
|
||||
print_usage(stderr, prog);
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (strcmp(arg, "--socket-name") == 0 || strcmp(arg, "-n") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --socket-name requires an argument\n"); return 2; }
|
||||
socket_name = argv[++i];
|
||||
} else if (strcmp(arg, "--timeout") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --timeout requires an argument\n"); return 2; }
|
||||
timeout_ms = atoi(argv[++i]);
|
||||
if (timeout_ms <= 0) { fprintf(stderr, "error: --timeout must be positive\n"); return 2; }
|
||||
} else if (strcmp(arg, "--tcp") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --tcp requires <host:port>\n"); return 2; }
|
||||
tcp_arg = argv[++i];
|
||||
} else if (strcmp(arg, "--serial") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --serial requires a device path\n"); return 2; }
|
||||
serial_arg = argv[++i];
|
||||
} else if (strcmp(arg, "--qrexec") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --qrexec requires <qube:service>\n"); return 2; }
|
||||
qrexec_arg = argv[++i];
|
||||
} else if (strcmp(arg, "--auth-privkey") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --auth-privkey requires a 32-byte hex key\n"); return 2; }
|
||||
auth_privkey_hex = argv[++i];
|
||||
} else if (strcmp(arg, "--auth-label") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --auth-label requires a label\n"); return 2; }
|
||||
auth_label = argv[++i];
|
||||
} else if (strcmp(arg, "--role") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --role requires a name\n"); return 2; }
|
||||
role = argv[++i];
|
||||
} else if (strcmp(arg, "--path") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --path requires a BIP-44 derivation path\n"); return 2; }
|
||||
path = argv[++i];
|
||||
} else if (strcmp(arg, "--index") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --index requires a number\n"); return 2; }
|
||||
has_index = 1;
|
||||
index_val = atoi(argv[++i]);
|
||||
} else if (strcmp(arg, "--algorithm") == 0 || strcmp(arg, "-a") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --algorithm requires a name\n"); return 2; }
|
||||
algorithm = argv[++i];
|
||||
} else if (strcmp(arg, "--scheme") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --scheme requires schnorr or ecdsa\n"); return 2; }
|
||||
scheme = argv[++i];
|
||||
} else if (strcmp(arg, "--encoding") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --encoding requires base64 or hex\n"); return 2; }
|
||||
encoding = argv[++i];
|
||||
} else if (strcmp(arg, "--format") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --format requires plain or structured\n"); return 2; }
|
||||
format = argv[++i];
|
||||
} else if (strcmp(arg, "--difficulty") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --difficulty requires a number\n"); return 2; }
|
||||
has_difficulty = 1;
|
||||
difficulty_val = atoi(argv[++i]);
|
||||
} else if (strcmp(arg, "--threads") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --threads requires a number\n"); return 2; }
|
||||
has_threads = 1;
|
||||
threads_val = atoi(argv[++i]);
|
||||
} else if (strcmp(arg, "--timeout-sec") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --timeout-sec requires a number\n"); return 2; }
|
||||
has_timeout_sec = 1;
|
||||
timeout_sec_val = atoi(argv[++i]);
|
||||
} else {
|
||||
fprintf(stderr, "error: unknown option: %s\n", arg);
|
||||
fprintf(stderr, "Try '%s --help' for usage.\n", prog);
|
||||
return 2;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
/* ---- verb ---- */
|
||||
if (i >= argc) {
|
||||
fprintf(stderr, "error: no verb specified\n");
|
||||
fprintf(stderr, "Try '%s --help' for usage.\n", prog);
|
||||
return 2;
|
||||
}
|
||||
const char *verb = argv[i++];
|
||||
|
||||
/* ---- verb args ---- */
|
||||
const char *arg1 = (i < argc) ? argv[i++] : NULL;
|
||||
const char *arg2 = (i < argc) ? argv[i++] : NULL;
|
||||
|
||||
/* ---- validate --index usage (algorithm-only now) ---- */
|
||||
if (has_index && !algorithm) {
|
||||
fprintf(stderr, "error: --index is only valid with --algorithm (for algorithm verbs)\n");
|
||||
return 2;
|
||||
}
|
||||
|
||||
/* ---- determine if this is an algorithm verb ---- */
|
||||
int is_algorithm_verb = (algorithm != NULL);
|
||||
|
||||
/* ---- nostr_get_public_key with --format structured uses algorithm path too,
|
||||
* but it's still a nostr verb. The --format flag only applies to nostr_get_public_key.
|
||||
* If --algorithm is set, get-public-key becomes an algorithm verb. */
|
||||
int is_nostr_get_pubkey_structured = 0;
|
||||
if (!is_algorithm_verb && format && strcmp(format, "structured") == 0) {
|
||||
is_nostr_get_pubkey_structured = 1;
|
||||
}
|
||||
|
||||
/* ---- validate --role and --path for nostr verbs ---- */
|
||||
int is_nostr_verb = (strcmp(verb, "get-public-key") == 0 ||
|
||||
strcmp(verb, "sign-event") == 0 ||
|
||||
strcmp(verb, "mine-event") == 0 ||
|
||||
strcmp(verb, "nip04-encrypt") == 0 ||
|
||||
strcmp(verb, "nip04-decrypt") == 0 ||
|
||||
strcmp(verb, "nip44-encrypt") == 0 ||
|
||||
strcmp(verb, "nip44-decrypt") == 0);
|
||||
if (is_nostr_verb && !is_algorithm_verb) {
|
||||
if (!role) {
|
||||
fprintf(stderr, "error: --role is required for nostr verbs\n");
|
||||
return 2;
|
||||
}
|
||||
if (!path) {
|
||||
fprintf(stderr, "error: --path is required for nostr verbs\n");
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- nostr_init ---- */
|
||||
if (nostr_init() != NOSTR_SUCCESS) {
|
||||
fprintf(stderr, "error: failed to initialize crypto subsystem\n");
|
||||
return 2;
|
||||
}
|
||||
|
||||
/* ---- list verb (no connection needed) ---- */
|
||||
if (strcmp(verb, "list") == 0) {
|
||||
char names[64][64];
|
||||
int count = nsigner_transport_list_unix(names, 64);
|
||||
if (count == 0) {
|
||||
printf("no n_signer sockets found\n");
|
||||
} else {
|
||||
for (int j = 0; j < count; j++) {
|
||||
printf("%s\n", names[j]);
|
||||
}
|
||||
}
|
||||
nostr_cleanup();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---- transport setup ---- */
|
||||
nsigner_transport_t *transport = NULL;
|
||||
nsigner_client_t *client = NULL;
|
||||
cJSON *params = NULL;
|
||||
cJSON *result = NULL;
|
||||
int rc = 2;
|
||||
int is_verify = 0;
|
||||
|
||||
/* Determine transport type */
|
||||
int transport_count = (tcp_arg ? 1 : 0) + (serial_arg ? 1 : 0) + (qrexec_arg ? 1 : 0) + (socket_name ? 1 : 0);
|
||||
if (transport_count > 1) {
|
||||
fprintf(stderr, "error: --tcp, --serial, --qrexec, and --socket-name are mutually exclusive\n");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (tcp_arg) {
|
||||
/* TCP transport */
|
||||
if (!auth_privkey_hex) {
|
||||
fprintf(stderr, "error: --tcp requires --auth-privkey\n");
|
||||
goto cleanup;
|
||||
}
|
||||
char *host = NULL;
|
||||
int port = 0;
|
||||
if (parse_host_port(tcp_arg, &host, &port) != 0) {
|
||||
fprintf(stderr, "error: invalid --tcp format (expected host:port)\n");
|
||||
goto cleanup;
|
||||
}
|
||||
transport = nsigner_transport_open_tcp(host, port, timeout_ms);
|
||||
free(host);
|
||||
if (!transport) {
|
||||
fprintf(stderr, "error: cannot open TCP transport to %s\n", tcp_arg);
|
||||
goto cleanup;
|
||||
}
|
||||
} else if (serial_arg) {
|
||||
transport = nsigner_transport_open_serial(serial_arg, timeout_ms);
|
||||
if (!transport) {
|
||||
fprintf(stderr, "error: cannot open serial transport on %s\n", serial_arg);
|
||||
goto cleanup;
|
||||
}
|
||||
} else if (qrexec_arg) {
|
||||
char *qube = NULL, *service = NULL;
|
||||
if (parse_qube_service(qrexec_arg, &qube, &service) != 0) {
|
||||
fprintf(stderr, "error: invalid --qrexec format (expected qube:service)\n");
|
||||
goto cleanup;
|
||||
}
|
||||
transport = nsigner_transport_open_qrexec(qube, service, timeout_ms);
|
||||
free(qube);
|
||||
free(service);
|
||||
if (!transport) {
|
||||
fprintf(stderr, "error: cannot open qrexec transport to %s\n", qrexec_arg);
|
||||
goto cleanup;
|
||||
}
|
||||
} else if (socket_name) {
|
||||
transport = nsigner_transport_open_unix(socket_name, timeout_ms);
|
||||
if (!transport) {
|
||||
fprintf(stderr, "error: cannot open unix transport @%s\n", socket_name);
|
||||
goto cleanup;
|
||||
}
|
||||
} else {
|
||||
/* Auto-discover: enumerate abstract UNIX sockets */
|
||||
char names[64][64];
|
||||
int count = nsigner_transport_list_unix(names, 64);
|
||||
if (count == 0) {
|
||||
fprintf(stderr, "error: no n_signer sockets found. Is n_signer running?\n");
|
||||
goto cleanup;
|
||||
}
|
||||
if (count > 1) {
|
||||
fprintf(stderr, "error: multiple n_signer sockets found. Use --socket-name to select one:\n");
|
||||
for (int j = 0; j < count; j++) {
|
||||
fprintf(stderr, " %s\n", names[j]);
|
||||
}
|
||||
goto cleanup;
|
||||
}
|
||||
socket_name = names[0];
|
||||
transport = nsigner_transport_open_unix(socket_name, timeout_ms);
|
||||
if (!transport) {
|
||||
fprintf(stderr, "error: cannot open unix transport @%s\n", socket_name);
|
||||
goto cleanup;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- create client ---- */
|
||||
client = nsigner_client_new(transport);
|
||||
if (!client) {
|
||||
fprintf(stderr, "error: cannot create nsigner client\n");
|
||||
transport->close(transport);
|
||||
goto cleanup;
|
||||
}
|
||||
transport = NULL; /* owned by client */
|
||||
|
||||
/* ---- auth envelope (TCP) ---- */
|
||||
if (auth_privkey_hex) {
|
||||
unsigned char privkey[32];
|
||||
if (hex_to_bytes(auth_privkey_hex, privkey, 32) != 32) {
|
||||
fprintf(stderr, "error: --auth-privkey must be 32 bytes (64 hex chars)\n");
|
||||
goto cleanup;
|
||||
}
|
||||
if (nsigner_client_set_auth(client, privkey, auth_label ? auth_label : "") != NOSTR_SUCCESS) {
|
||||
fprintf(stderr, "error: failed to set auth envelope\n");
|
||||
goto cleanup;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- build params and call ---- */
|
||||
const char *method = NULL;
|
||||
int is_call_verb = 0;
|
||||
|
||||
if (strcmp(verb, "get-info") == 0) {
|
||||
method = "get_info";
|
||||
params = cJSON_CreateArray();
|
||||
if (!params) { fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
} else if (strcmp(verb, "get-public-key") == 0) {
|
||||
if (is_algorithm_verb) {
|
||||
method = "get_public_key";
|
||||
params = cJSON_CreateArray();
|
||||
if (!params) { fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
cJSON *opts = cJSON_CreateObject();
|
||||
if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
cJSON_AddStringToObject(opts, "algorithm", algorithm);
|
||||
if (has_alg_index) {
|
||||
cJSON_AddNumberToObject(opts, "index", alg_index);
|
||||
} else if (has_index) {
|
||||
cJSON_AddNumberToObject(opts, "index", index_val);
|
||||
} else {
|
||||
cJSON_AddNumberToObject(opts, "index", 0);
|
||||
}
|
||||
cJSON_AddItemToArray(params, opts);
|
||||
} else {
|
||||
method = "nostr_get_public_key";
|
||||
params = cJSON_CreateArray();
|
||||
if (!params) { fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
cJSON *opts = cJSON_CreateObject();
|
||||
if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
if (role) cJSON_AddStringToObject(opts, "role", role);
|
||||
if (path) cJSON_AddStringToObject(opts, "role_path", path);
|
||||
if (is_nostr_get_pubkey_structured) cJSON_AddStringToObject(opts, "format", "structured");
|
||||
cJSON_AddItemToArray(params, opts);
|
||||
}
|
||||
} else if (strcmp(verb, "sign-event") == 0) {
|
||||
method = "nostr_sign_event";
|
||||
const char *event_json = arg1;
|
||||
if (!event_json) {
|
||||
event_json = read_stdin_line();
|
||||
if (!event_json) {
|
||||
fprintf(stderr, "error: no event JSON provided (pass as argument or pipe to stdin)\n");
|
||||
goto cleanup;
|
||||
}
|
||||
}
|
||||
params = cJSON_CreateArray();
|
||||
if (!params) { fprintf(stderr, "error: out of memory\n"); free((char*)event_json); goto cleanup; }
|
||||
cJSON_AddItemToArray(params, cJSON_CreateString(event_json));
|
||||
if (!arg1) free((char*)event_json);
|
||||
|
||||
cJSON *opts = cJSON_CreateObject();
|
||||
if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
if (role) cJSON_AddStringToObject(opts, "role", role);
|
||||
if (path) cJSON_AddStringToObject(opts, "role_path", path);
|
||||
cJSON_AddItemToArray(params, opts);
|
||||
} else if (strcmp(verb, "mine-event") == 0) {
|
||||
method = "nostr_mine_event";
|
||||
const char *event_json = arg1;
|
||||
if (!event_json) {
|
||||
event_json = read_stdin_line();
|
||||
if (!event_json) {
|
||||
fprintf(stderr, "error: no event JSON provided (pass as argument or pipe to stdin)\n");
|
||||
goto cleanup;
|
||||
}
|
||||
}
|
||||
params = cJSON_CreateArray();
|
||||
if (!params) { fprintf(stderr, "error: out of memory\n"); free((char*)event_json); goto cleanup; }
|
||||
cJSON_AddItemToArray(params, cJSON_CreateString(event_json));
|
||||
if (!arg1) free((char*)event_json);
|
||||
|
||||
cJSON *opts = cJSON_CreateObject();
|
||||
if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
if (role) cJSON_AddStringToObject(opts, "role", role);
|
||||
if (path) cJSON_AddStringToObject(opts, "role_path", path);
|
||||
if (has_difficulty) cJSON_AddNumberToObject(opts, "difficulty", difficulty_val);
|
||||
if (has_threads) cJSON_AddNumberToObject(opts, "threads", threads_val);
|
||||
if (has_timeout_sec) cJSON_AddNumberToObject(opts, "timeout_sec", timeout_sec_val);
|
||||
cJSON_AddItemToArray(params, opts);
|
||||
} else if (strcmp(verb, "nip04-encrypt") == 0) {
|
||||
method = "nostr_nip04_encrypt";
|
||||
if (!arg1) { fprintf(stderr, "error: nip04-encrypt requires <peer-pubkey>\n"); goto cleanup; }
|
||||
const char *peer = arg1;
|
||||
const char *plaintext = arg2;
|
||||
if (!plaintext) {
|
||||
plaintext = read_stdin_line();
|
||||
if (!plaintext) {
|
||||
fprintf(stderr, "error: no plaintext provided (pass as argument or pipe to stdin)\n");
|
||||
goto cleanup;
|
||||
}
|
||||
}
|
||||
params = cJSON_CreateArray();
|
||||
if (!params) { fprintf(stderr, "error: out of memory\n"); if (!arg2) free((char*)plaintext); goto cleanup; }
|
||||
cJSON_AddItemToArray(params, cJSON_CreateString(peer));
|
||||
cJSON_AddItemToArray(params, cJSON_CreateString(plaintext));
|
||||
if (!arg2) free((char*)plaintext);
|
||||
|
||||
cJSON *opts = cJSON_CreateObject();
|
||||
if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
if (role) cJSON_AddStringToObject(opts, "role", role);
|
||||
if (path) cJSON_AddStringToObject(opts, "role_path", path);
|
||||
cJSON_AddItemToArray(params, opts);
|
||||
} else if (strcmp(verb, "nip04-decrypt") == 0) {
|
||||
method = "nostr_nip04_decrypt";
|
||||
if (!arg1) { fprintf(stderr, "error: nip04-decrypt requires <peer-pubkey>\n"); goto cleanup; }
|
||||
const char *peer = arg1;
|
||||
const char *ciphertext = arg2;
|
||||
if (!ciphertext) {
|
||||
ciphertext = read_stdin_line();
|
||||
if (!ciphertext) {
|
||||
fprintf(stderr, "error: no ciphertext provided (pass as argument or pipe to stdin)\n");
|
||||
goto cleanup;
|
||||
}
|
||||
}
|
||||
params = cJSON_CreateArray();
|
||||
if (!params) { fprintf(stderr, "error: out of memory\n"); if (!arg2) free((char*)ciphertext); goto cleanup; }
|
||||
cJSON_AddItemToArray(params, cJSON_CreateString(peer));
|
||||
cJSON_AddItemToArray(params, cJSON_CreateString(ciphertext));
|
||||
if (!arg2) free((char*)ciphertext);
|
||||
|
||||
cJSON *opts = cJSON_CreateObject();
|
||||
if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
if (role) cJSON_AddStringToObject(opts, "role", role);
|
||||
if (path) cJSON_AddStringToObject(opts, "role_path", path);
|
||||
cJSON_AddItemToArray(params, opts);
|
||||
} else if (strcmp(verb, "nip44-encrypt") == 0) {
|
||||
method = "nostr_nip44_encrypt";
|
||||
if (!arg1) { fprintf(stderr, "error: nip44-encrypt requires <peer-pubkey>\n"); goto cleanup; }
|
||||
const char *peer = arg1;
|
||||
const char *plaintext = arg2;
|
||||
if (!plaintext) {
|
||||
plaintext = read_stdin_line();
|
||||
if (!plaintext) {
|
||||
fprintf(stderr, "error: no plaintext provided (pass as argument or pipe to stdin)\n");
|
||||
goto cleanup;
|
||||
}
|
||||
}
|
||||
params = cJSON_CreateArray();
|
||||
if (!params) { fprintf(stderr, "error: out of memory\n"); if (!arg2) free((char*)plaintext); goto cleanup; }
|
||||
cJSON_AddItemToArray(params, cJSON_CreateString(peer));
|
||||
cJSON_AddItemToArray(params, cJSON_CreateString(plaintext));
|
||||
if (!arg2) free((char*)plaintext);
|
||||
|
||||
cJSON *opts = cJSON_CreateObject();
|
||||
if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
if (role) cJSON_AddStringToObject(opts, "role", role);
|
||||
if (path) cJSON_AddStringToObject(opts, "role_path", path);
|
||||
cJSON_AddItemToArray(params, opts);
|
||||
} else if (strcmp(verb, "nip44-decrypt") == 0) {
|
||||
method = "nostr_nip44_decrypt";
|
||||
if (!arg1) { fprintf(stderr, "error: nip44-decrypt requires <peer-pubkey>\n"); goto cleanup; }
|
||||
const char *peer = arg1;
|
||||
const char *ciphertext = arg2;
|
||||
if (!ciphertext) {
|
||||
ciphertext = read_stdin_line();
|
||||
if (!ciphertext) {
|
||||
fprintf(stderr, "error: no ciphertext provided (pass as argument or pipe to stdin)\n");
|
||||
goto cleanup;
|
||||
}
|
||||
}
|
||||
params = cJSON_CreateArray();
|
||||
if (!params) { fprintf(stderr, "error: out of memory\n"); if (!arg2) free((char*)ciphertext); goto cleanup; }
|
||||
cJSON_AddItemToArray(params, cJSON_CreateString(peer));
|
||||
cJSON_AddItemToArray(params, cJSON_CreateString(ciphertext));
|
||||
if (!arg2) free((char*)ciphertext);
|
||||
|
||||
cJSON *opts = cJSON_CreateObject();
|
||||
if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
if (role) cJSON_AddStringToObject(opts, "role", role);
|
||||
if (path) cJSON_AddStringToObject(opts, "role_path", path);
|
||||
cJSON_AddItemToArray(params, opts);
|
||||
} else if (strcmp(verb, "sign") == 0) {
|
||||
method = "sign";
|
||||
if (!arg1) { fprintf(stderr, "error: sign requires <msg-hex>\n"); goto cleanup; }
|
||||
params = cJSON_CreateArray();
|
||||
if (!params) { fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
cJSON_AddItemToArray(params, cJSON_CreateString(arg1));
|
||||
cJSON *opts = cJSON_CreateObject();
|
||||
if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
cJSON_AddStringToObject(opts, "algorithm", algorithm ? algorithm : "secp256k1");
|
||||
if (has_alg_index) {
|
||||
cJSON_AddNumberToObject(opts, "index", alg_index);
|
||||
} else if (has_index) {
|
||||
cJSON_AddNumberToObject(opts, "index", index_val);
|
||||
} else {
|
||||
cJSON_AddNumberToObject(opts, "index", 0);
|
||||
}
|
||||
if (scheme) cJSON_AddStringToObject(opts, "scheme", scheme);
|
||||
cJSON_AddItemToArray(params, opts);
|
||||
} else if (strcmp(verb, "verify") == 0) {
|
||||
method = "verify";
|
||||
is_verify = 1;
|
||||
if (!arg1 || !arg2) { fprintf(stderr, "error: verify requires <msg-hex> <sig-hex>\n"); goto cleanup; }
|
||||
params = cJSON_CreateArray();
|
||||
if (!params) { fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
cJSON_AddItemToArray(params, cJSON_CreateString(arg1));
|
||||
cJSON_AddItemToArray(params, cJSON_CreateString(arg2));
|
||||
cJSON *opts = cJSON_CreateObject();
|
||||
if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
cJSON_AddStringToObject(opts, "algorithm", algorithm ? algorithm : "secp256k1");
|
||||
if (has_alg_index) {
|
||||
cJSON_AddNumberToObject(opts, "index", alg_index);
|
||||
} else if (has_index) {
|
||||
cJSON_AddNumberToObject(opts, "index", index_val);
|
||||
} else {
|
||||
cJSON_AddNumberToObject(opts, "index", 0);
|
||||
}
|
||||
if (scheme) cJSON_AddStringToObject(opts, "scheme", scheme);
|
||||
cJSON_AddItemToArray(params, opts);
|
||||
} else if (strcmp(verb, "derive") == 0) {
|
||||
method = "derive";
|
||||
const char *data = arg1;
|
||||
if (!data) {
|
||||
data = read_stdin_line();
|
||||
if (!data) {
|
||||
fprintf(stderr, "error: no data provided (pass as argument or pipe to stdin)\n");
|
||||
goto cleanup;
|
||||
}
|
||||
}
|
||||
params = cJSON_CreateArray();
|
||||
if (!params) { fprintf(stderr, "error: out of memory\n"); if (!arg1) free((char*)data); goto cleanup; }
|
||||
cJSON_AddItemToArray(params, cJSON_CreateString(data));
|
||||
if (!arg1) free((char*)data);
|
||||
|
||||
cJSON *opts = cJSON_CreateObject();
|
||||
if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
cJSON_AddStringToObject(opts, "algorithm", algorithm ? algorithm : "secp256k1");
|
||||
if (has_alg_index) {
|
||||
cJSON_AddNumberToObject(opts, "index", alg_index);
|
||||
} else if (has_index) {
|
||||
cJSON_AddNumberToObject(opts, "index", index_val);
|
||||
} else {
|
||||
cJSON_AddNumberToObject(opts, "index", 0);
|
||||
}
|
||||
cJSON_AddItemToArray(params, opts);
|
||||
} else if (strcmp(verb, "encapsulate") == 0) {
|
||||
method = "encapsulate";
|
||||
if (!arg1) { fprintf(stderr, "error: encapsulate requires <peer-pubkey-hex>\n"); goto cleanup; }
|
||||
params = cJSON_CreateArray();
|
||||
if (!params) { fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
cJSON_AddItemToArray(params, cJSON_CreateString(arg1));
|
||||
cJSON *opts = cJSON_CreateObject();
|
||||
if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
cJSON_AddStringToObject(opts, "algorithm", algorithm ? algorithm : "ml-kem-768");
|
||||
cJSON_AddItemToArray(params, opts);
|
||||
} else if (strcmp(verb, "decapsulate") == 0) {
|
||||
method = "decapsulate";
|
||||
if (!arg1) { fprintf(stderr, "error: decapsulate requires <ciphertext-hex>\n"); goto cleanup; }
|
||||
params = cJSON_CreateArray();
|
||||
if (!params) { fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
cJSON_AddItemToArray(params, cJSON_CreateString(arg1));
|
||||
cJSON *opts = cJSON_CreateObject();
|
||||
if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
cJSON_AddStringToObject(opts, "algorithm", algorithm ? algorithm : "ml-kem-768");
|
||||
if (has_alg_index) {
|
||||
cJSON_AddNumberToObject(opts, "index", alg_index);
|
||||
} else if (has_index) {
|
||||
cJSON_AddNumberToObject(opts, "index", index_val);
|
||||
} else {
|
||||
cJSON_AddNumberToObject(opts, "index", 0);
|
||||
}
|
||||
cJSON_AddItemToArray(params, opts);
|
||||
} else if (strcmp(verb, "derive-shared-secret") == 0) {
|
||||
method = "derive_shared_secret";
|
||||
if (!arg1) { fprintf(stderr, "error: derive-shared-secret requires <peer-pubkey-hex>\n"); goto cleanup; }
|
||||
params = cJSON_CreateArray();
|
||||
if (!params) { fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
cJSON_AddItemToArray(params, cJSON_CreateString(arg1));
|
||||
cJSON *opts = cJSON_CreateObject();
|
||||
if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
cJSON_AddStringToObject(opts, "algorithm", algorithm ? algorithm : "x25519");
|
||||
if (has_alg_index) {
|
||||
cJSON_AddNumberToObject(opts, "index", alg_index);
|
||||
} else if (has_index) {
|
||||
cJSON_AddNumberToObject(opts, "index", index_val);
|
||||
} else {
|
||||
cJSON_AddNumberToObject(opts, "index", 0);
|
||||
}
|
||||
cJSON_AddItemToArray(params, opts);
|
||||
} else if (strcmp(verb, "encrypt") == 0) {
|
||||
method = "encrypt";
|
||||
const char *plaintext = arg1;
|
||||
if (!plaintext) {
|
||||
plaintext = read_stdin_line();
|
||||
if (!plaintext) {
|
||||
fprintf(stderr, "error: no plaintext provided (pass as argument or pipe to stdin)\n");
|
||||
goto cleanup;
|
||||
}
|
||||
}
|
||||
params = cJSON_CreateArray();
|
||||
if (!params) { fprintf(stderr, "error: out of memory\n"); if (!arg1) free((char*)plaintext); goto cleanup; }
|
||||
cJSON_AddItemToArray(params, cJSON_CreateString(plaintext));
|
||||
if (!arg1) free((char*)plaintext);
|
||||
|
||||
cJSON *opts = cJSON_CreateObject();
|
||||
if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
cJSON_AddStringToObject(opts, "algorithm", "otp");
|
||||
if (encoding) cJSON_AddStringToObject(opts, "encoding", encoding);
|
||||
cJSON_AddItemToArray(params, opts);
|
||||
} else if (strcmp(verb, "decrypt") == 0) {
|
||||
method = "decrypt";
|
||||
const char *ciphertext = arg1;
|
||||
if (!ciphertext) {
|
||||
ciphertext = read_stdin_line();
|
||||
if (!ciphertext) {
|
||||
fprintf(stderr, "error: no ciphertext provided (pass as argument or pipe to stdin)\n");
|
||||
goto cleanup;
|
||||
}
|
||||
}
|
||||
params = cJSON_CreateArray();
|
||||
if (!params) { fprintf(stderr, "error: out of memory\n"); if (!arg1) free((char*)ciphertext); goto cleanup; }
|
||||
cJSON_AddItemToArray(params, cJSON_CreateString(ciphertext));
|
||||
if (!arg1) free((char*)ciphertext);
|
||||
|
||||
cJSON *opts = cJSON_CreateObject();
|
||||
if (!opts) { fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
cJSON_AddStringToObject(opts, "algorithm", "otp");
|
||||
if (encoding) cJSON_AddStringToObject(opts, "encoding", encoding);
|
||||
cJSON_AddItemToArray(params, opts);
|
||||
} else if (strcmp(verb, "call") == 0) {
|
||||
is_call_verb = 1;
|
||||
if (!arg1) { fprintf(stderr, "error: call requires <method>\n"); goto cleanup; }
|
||||
method = arg1;
|
||||
/* Params: from remaining argv or stdin */
|
||||
if (arg2) {
|
||||
/* Use remaining argv as the params JSON */
|
||||
/* Reconstruct the JSON array string from remaining args */
|
||||
size_t total = 0;
|
||||
for (int j = i - 1; j < argc; j++) {
|
||||
total += strlen(argv[j]) + 1;
|
||||
}
|
||||
char *json_str = malloc(total + 1);
|
||||
if (!json_str) { fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
json_str[0] = '\0';
|
||||
for (int j = i - 1; j < argc; j++) {
|
||||
strcat(json_str, argv[j]);
|
||||
if (j + 1 < argc) strcat(json_str, " ");
|
||||
}
|
||||
params = cJSON_Parse(json_str);
|
||||
free(json_str);
|
||||
if (!params) {
|
||||
fprintf(stderr, "error: failed to parse params JSON from argv\n");
|
||||
goto cleanup;
|
||||
}
|
||||
} else {
|
||||
/* Read from stdin */
|
||||
char *line = read_stdin_line();
|
||||
if (!line) {
|
||||
fprintf(stderr, "error: no params JSON on stdin\n");
|
||||
goto cleanup;
|
||||
}
|
||||
params = cJSON_Parse(line);
|
||||
free(line);
|
||||
if (!params) {
|
||||
fprintf(stderr, "error: failed to parse params JSON from stdin\n");
|
||||
goto cleanup;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fprintf(stderr, "error: unknown verb: %s\n", verb);
|
||||
fprintf(stderr, "Try '%s --help' for usage.\n", prog);
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
/* ---- make the RPC call ---- */
|
||||
if (nsigner_client_call(client, method, params, &result) != NOSTR_SUCCESS) {
|
||||
fprintf(stderr, "error: %s\n", nsigner_client_last_error(client));
|
||||
params = NULL; /* ownership transferred even on failure */
|
||||
goto cleanup;
|
||||
}
|
||||
params = NULL; /* ownership transferred */
|
||||
|
||||
/* ---- print result ---- */
|
||||
if (is_call_verb || strcmp(verb, "get-info") == 0) {
|
||||
/* Raw JSON output for get_info and call */
|
||||
if (result) {
|
||||
char *json = cJSON_PrintUnformatted(result);
|
||||
if (json) {
|
||||
printf("%s\n", json);
|
||||
free(json);
|
||||
}
|
||||
}
|
||||
rc = 0;
|
||||
} else {
|
||||
int prc = print_result(result, is_verify);
|
||||
if (is_verify) {
|
||||
rc = (prc == 0 || prc == 1) ? prc : 2;
|
||||
} else {
|
||||
rc = 0;
|
||||
}
|
||||
}
|
||||
|
||||
cleanup:
|
||||
if (rc != 0 && params) {
|
||||
/* If we still own params and there was an error, free it.
|
||||
* nsigner_client_call takes ownership on success, so we only
|
||||
* free params here if we never called nsigner_client_call. */
|
||||
cJSON_Delete(params);
|
||||
}
|
||||
cJSON_Delete(result);
|
||||
if (client) {
|
||||
nsigner_client_free(client); /* also closes/frees the transport */
|
||||
} else if (transport) {
|
||||
transport->close(transport);
|
||||
}
|
||||
nostr_cleanup();
|
||||
return rc;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
# 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 & placement
|
||||
|
||||
The project lives in [`client/`](.) alongside the existing demo clients
|
||||
(`demo_c99.c`, `demo_javascript.js`, `demo_python.py`):
|
||||
|
||||
- New file: [`client/n_signer_client.c`](n_signer_client.c) — single-file C99 program.
|
||||
- New file: [`client/n_signer_client_README.md`](n_signer_client_README.md) — dedicated README just for this client (usage, verbs, pipe-to-nak recipes, build instructions). The existing [`client/README.md`](README.md) stays as-is (it documents the nostr_core_lib migration).
|
||||
- New Makefile target producing `build/n_signer_client`.
|
||||
|
||||
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` (and optionally `_tcp`, `_serial`, `_qrexec`) from `nostr_core_lib/nostr_core/nsigner_transport.h`
|
||||
- `nsigner_client_new` / `nsigner_client_free` from `nostr_core_lib/nostr_core/nsigner_client.h`
|
||||
- `nsigner_client_call` (takes ownership of `params`)
|
||||
- `nsigner_client_set_auth` 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 `@` |
|
||||
| `--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 |
|
||||
|
||||
Selector options (apply to `nostr_*` verbs; `--role` and `--path` are mutually exclusive):
|
||||
|
||||
| Flag | Meaning | JSON emitted |
|
||||
|---|---|---|
|
||||
| `--role <name>` | Named path-role registered in the signer's wizard | `{"role":"<name>"}` |
|
||||
| `--path <full-path>` | Full BIP-44 derivation path | `{"role_path":"<full-path>"}` |
|
||||
| `--index <N>` | Optional variable-segment index for a named path-role (only valid with `--role`) | adds `"index":N` to the role object |
|
||||
|
||||
Algorithm options (apply to algorithm-based verbs):
|
||||
|
||||
| Flag | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `--algorithm <alg>` | none | `secp256k1`/`ed25519`/`x25519`/`ml-dsa-65`/`slh-dsa-128s`/`ml-kem-768`/`otp` |
|
||||
| `--index <N>` | `0` | Algorithm derivation index (substituted into the alg's path) |
|
||||
| `--scheme <schnorr\|ecdsa>` | `schnorr` | secp256k1 `sign`/`verify` only |
|
||||
| `--encoding <base64\|hex>` | `base64` | OTP `encrypt`/`decrypt` only |
|
||||
| `--format <plain\|structured>` | `plain` | `nostr_get_public_key` output shape |
|
||||
|
||||
Note on `--index` overload: when `--algorithm` is set, `--index` is the
|
||||
algorithm derivation index. When `--role` is set (and no `--algorithm`),
|
||||
`--index` is the named path-role's variable-segment index. These two contexts
|
||||
never overlap because algorithm verbs and `nostr_*` verbs are distinct.
|
||||
|
||||
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)
|
||||
|
||||
Per [`README.md`](../README.md) §4.3 verb table. The options object is always
|
||||
the trailing element of the `params` array.
|
||||
|
||||
### Metadata
|
||||
|
||||
| Verb | RPC method | stdout |
|
||||
|---|---|---|
|
||||
| `get-info` | `get_info` | raw `result` JSON (name, version, verbs, algorithms) |
|
||||
|
||||
### Nostr verbs (role-based; selector from `--role` / `--path`)
|
||||
|
||||
| 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; options `--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 | structured JSON `{"signature":...,"algorithm":...,"key_id":...}` |
|
||||
| `verify <msg-hex> <sig-hex>` | `verify` | hex bytes | `valid` / `invalid` (exit 0/1) |
|
||||
| `derive <data>` | `derive` | UTF-8 data (argv or stdin) | structured JSON `{"algorithm":...,"key_id":...,"digest":...}` |
|
||||
| `encapsulate <peer-pubkey-hex>` | `encapsulate` | hex | structured JSON `{"ciphertext":...,"shared_secret":...}` |
|
||||
| `decapsulate <ciphertext-hex>` | `decapsulate` | hex | structured JSON `{"shared_secret":...}` |
|
||||
| `derive-shared-secret <peer-pubkey-hex>` | `derive_shared_secret` | hex | shared secret hex |
|
||||
| `encrypt <plaintext>` | `encrypt` | plaintext (base64 by default; `--encoding hex`) | ciphertext |
|
||||
| `decrypt <ciphertext>` | `decrypt` | ciphertext | plaintext |
|
||||
|
||||
### 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). For `verify`, exit `0` =
|
||||
valid, `1` = invalid, `2` = error.
|
||||
- `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 --role main 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.
|
||||
- `encrypt` / `decrypt` take their payload from argv or stdin (base64 by
|
||||
default per the server contract).
|
||||
- `call` reads a JSON `params` array from stdin (one line) or argv.
|
||||
|
||||
## Selector handling (per README §4.6)
|
||||
|
||||
The `nostr_*` verbs select a secp256k1 NIP-06 key via the options object. The
|
||||
client builds the options object from the selector flags:
|
||||
|
||||
- `--role <name>` → `{"role":"<name>"}` (named path-role; the derivation path
|
||||
is hidden from the client by the signer).
|
||||
- `--role <name> --index <N>` → `{"role":"<name>","index":N}` (named path-role
|
||||
with variable-segment index; rejected with `2005 index_out_of_range` if out
|
||||
of the role's range).
|
||||
- `--path <full-path>` → `{"role_path":"<full-path>"}` (raw BIP-44 path; must
|
||||
match a registered role's path template or be explicitly allowed).
|
||||
- Default (no selector): server uses the default role `main`.
|
||||
- Conflicting selectors → client-side error (do not send; the server would
|
||||
reject with `ambiguous_role_selector` 1001).
|
||||
|
||||
Resolution order on the server: `role` → `role_path` → default `main`. The
|
||||
client enforces mutual exclusivity of the selector flags before sending.
|
||||
|
||||
For algorithm verbs, `--algorithm` and `--index` populate the options object
|
||||
instead; `--scheme` adds `"scheme"` for secp256k1 sign/verify; `--encoding`
|
||||
adds `"encoding"` for OTP encrypt/decrypt.
|
||||
|
||||
## 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-info` → signer metadata JSON.
|
||||
- `n_signer_client --role main get-public-key` → 64-hex pubkey.
|
||||
- `echo '{"kind":1,"content":"hello","tags":[],"created_at":1}' | n_signer_client --role main 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 --index 0 sign 68656c6c6f` → structured sig JSON.
|
||||
- `n_signer_client --role myrole get-public-key` → pubkey for the named path-role.
|
||||
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`](../plans/nip46_bunker_mode.md)).
|
||||
@@ -0,0 +1,171 @@
|
||||
# `nsigner_client` — Linux CLI for n_signer
|
||||
|
||||
A standalone Linux command-line client that connects to a running [`n_signer`](https://github.com/your-org/n_signer) process and calls its JSON-RPC verbs over stdin/stdout. Designed for pipe-to-`nak` workflows.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
make clients
|
||||
```
|
||||
|
||||
Produces `build/nsigner_client`. Links `nostr_core_lib` exactly like the existing examples.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
nsigner_client [global options] <verb> [verb args...]
|
||||
```
|
||||
|
||||
### Global options
|
||||
|
||||
| Flag | Default | Meaning |
|
||||
|------|---------|---------|
|
||||
| `--socket-name`, `-n <name>` | auto-discover | Abstract socket name without `@` |
|
||||
| `--timeout <ms>` | `5000` | Transport timeout |
|
||||
| `--tcp <host:port>` | none | TCP transport (requires `--auth-privkey`) |
|
||||
| `--serial <device>` | none | USB CDC-ACM serial transport |
|
||||
| `--qrexec <qube:service>` | none | Qubes qrexec transport |
|
||||
| `--auth-privkey <32-byte hex>` | none | Auth envelope privkey for TCP |
|
||||
| `--auth-label <text>` | none | Auth envelope label |
|
||||
|
||||
### Selector options (for `nostr_*` verbs)
|
||||
|
||||
| Flag | Meaning | JSON emitted |
|
||||
|------|---------|-------------|
|
||||
| `--role <name>` | Named path-role registered in the signer | `{"role":"<name>"}` |
|
||||
| `--path <path>` | Full BIP-44 derivation path | `{"role_path":"<path>"}` |
|
||||
|
||||
### Algorithm options (for algorithm-based verbs)
|
||||
|
||||
| Flag | Default | Meaning |
|
||||
|------|---------|---------|
|
||||
| `--algorithm <alg>` | none | `secp256k1`/`ed25519`/`x25519`/`ml-dsa-65`/`slh-dsa-128s`/`ml-kem-768`/`otp` |
|
||||
| `--index <N>` | `0` | Algorithm derivation index |
|
||||
| `--scheme <schnork\|ecdsa>` | `schnorr` | secp256k1 `sign`/`verify` only |
|
||||
| `--encoding <base64\|hex>` | `base64` | OTP `encrypt`/`decrypt` only |
|
||||
| `--format <plain\|structured>` | `plain` | `nostr_get_public_key` output shape |
|
||||
|
||||
### Mine-event options
|
||||
|
||||
| Flag | Meaning |
|
||||
|------|---------|
|
||||
| `--difficulty <N>` | Target leading zero bits |
|
||||
| `--threads <N>` | Mining threads (default 1) |
|
||||
| `--timeout-sec <N>` | Mining timeout in seconds |
|
||||
|
||||
## Verb reference
|
||||
|
||||
### Utility
|
||||
|
||||
| Verb | stdout |
|
||||
|------|--------|
|
||||
| `list` | Lists running n_signer abstract sockets (one `@name` per line) |
|
||||
|
||||
### Metadata
|
||||
|
||||
| Verb | RPC method | stdout |
|
||||
|------|------------|--------|
|
||||
| `get-info` | `get_info` | raw result JSON (name, version, verbs, algorithms) |
|
||||
|
||||
### Nostr verbs (role-based)
|
||||
|
||||
| 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 stdin | signed event JSON |
|
||||
| `mine-event` | `nostr_mine_event` | event JSON from argv or stdin | signed mined event JSON |
|
||||
| `nip04-encrypt <peer>` | `nostr_nip04_encrypt` | plaintext from argv or stdin | ciphertext |
|
||||
| `nip04-decrypt <peer>` | `nostr_nip04_decrypt` | ciphertext from argv or stdin | plaintext |
|
||||
| `nip44-encrypt <peer>` | `nostr_nip44_encrypt` | plaintext from argv or stdin | ciphertext |
|
||||
| `nip44-decrypt <peer>` | `nostr_nip44_decrypt` | ciphertext from argv or stdin | plaintext |
|
||||
|
||||
### Algorithm-based verbs
|
||||
|
||||
| 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 | structured JSON `{"signature":...,"algorithm":...,"key_id":...}` |
|
||||
| `verify <msg-hex> <sig-hex>` | `verify` | hex bytes | `valid` / `invalid` (exit 0/1) |
|
||||
| `derive <data>` | `derive` | UTF-8 data (argv or stdin) | structured JSON |
|
||||
| `encapsulate <peer-pubkey-hex>` | `encapsulate` | hex | structured JSON |
|
||||
| `decapsulate <ciphertext-hex>` | `decapsulate` | hex | structured JSON |
|
||||
| `derive-shared-secret <peer-pubkey-hex>` | `derive_shared_secret` | hex | shared secret hex |
|
||||
| `encrypt <plaintext>` | `encrypt` | plaintext (base64 by default) | ciphertext |
|
||||
| `decrypt <ciphertext>` | `decrypt` | ciphertext | plaintext |
|
||||
|
||||
### Generic escape hatch
|
||||
|
||||
| Verb | RPC method | input | stdout |
|
||||
|------|------------|-------|--------|
|
||||
| `call <method>` | `<method>` | JSON params array from stdin or argv | raw result JSON |
|
||||
|
||||
## Selector explanation
|
||||
|
||||
The `nostr_*` verbs select a key via the options object using both `--role` and `--path`:
|
||||
|
||||
- **`--role <name> --path <path>`** — Both are required for all `nostr_*` verbs. The role authorizes the request and determines the encryption scheme. The path selects the specific key to derive. Sends `{"role":"<name>","role_path":"<path>"}` to the server.
|
||||
- **`--role` without `--path`** — Client-side error: `--path is required for nostr verbs`.
|
||||
- **`--path` without `--role`** — Client-side error: `--role is required for nostr verbs`.
|
||||
|
||||
For algorithm verbs, `--algorithm` and `--index` populate the options object instead.
|
||||
|
||||
## Pipe-to-nak recipes
|
||||
|
||||
```bash
|
||||
# Get public key
|
||||
nsigner_client --role main --path "m/44'/1237'/0'/0/0" get-public-key
|
||||
|
||||
# Sign an event and publish via nak
|
||||
echo '{"kind":1,"content":"hello nostr","tags":[],"created_at":1700000000}' \
|
||||
| nsigner_client --role main --path "m/44'/1237'/0'/0/0" sign-event \
|
||||
| nak publish
|
||||
|
||||
# Mine a proof-of-work event
|
||||
echo '{"kind":1,"content":"pow","tags":[],"created_at":1700000000}' \
|
||||
| nsigner_client --role main --path "m/44'/1237'/0'/0/0" mine-event --difficulty 20 --threads 4
|
||||
|
||||
# NIP-44 encrypt
|
||||
nsigner_client --role main --path "m/44'/1237'/0'/0/0" nip44-encrypt <peer-pubkey> "secret message"
|
||||
|
||||
# Algorithm-based signing
|
||||
nsigner_client --algorithm ed25519 --index 0 sign 68656c6c6f
|
||||
|
||||
# Verify a signature
|
||||
nsigner_client --algorithm secp256k1 verify <msg-hex> <sig-hex> && echo "valid"
|
||||
|
||||
# Get signer info
|
||||
nsigner_client get-info
|
||||
```
|
||||
|
||||
## Transport options
|
||||
|
||||
| Transport | Flag | Notes |
|
||||
|-----------|------|-------|
|
||||
| UNIX abstract socket | `--socket-name <name>` or auto-discover | Default. Auto-discovers if exactly one `@nsigner*` socket exists. |
|
||||
| TCP | `--tcp <host:port>` | Requires `--auth-privkey` for auth envelope. |
|
||||
| Serial (USB CDC-ACM) | `--serial <device>` | e.g. `--serial /dev/ttyACM0` |
|
||||
| Qubes qrexec | `--qrexec <qube:service>` | e.g. `--qrexec sys-signer:qubes.NsignerRpc` |
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | Success |
|
||||
| 1 | Invalid (verify verb only — signature is invalid) |
|
||||
| 2 | Error (transport, RPC, or usage error) |
|
||||
|
||||
For `verify`: exit 0 = valid signature, exit 1 = invalid signature, exit 2 = error.
|
||||
|
||||
## stdin/stdout contract
|
||||
|
||||
- All payload output goes to stdout as a single line, newline-terminated.
|
||||
- All diagnostics (errors, warnings) go to stderr.
|
||||
- `sign-event`, `nip04-*`, `nip44-*`, `derive`, `encrypt`, `decrypt` read their payload from argv if present, otherwise from stdin (one line).
|
||||
- `sign`, `verify`, `encapsulate`, `decapsulate`, `derive-shared-secret` take hex from argv only (binary payloads).
|
||||
- `call` reads a JSON params array from stdin (one line) or argv.
|
||||
|
||||
## See also
|
||||
|
||||
- [`n_signer_client_PLAN.md`](n_signer_client_PLAN.md) — the full implementation plan
|
||||
- [`README.md`](../README.md) — n_signer main documentation (API §4)
|
||||
- [`examples/sign_event_client.c`](../examples/sign_event_client.c) — reference example
|
||||
Executable
+164
@@ -0,0 +1,164 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# deploy_local.sh — Build static nsigner + nsigner_client binaries
|
||||
# and install them to /usr/local/bin/
|
||||
#
|
||||
# Usage:
|
||||
# ./deploy_local.sh # build + install (prompts for sudo)
|
||||
# ./deploy_local.sh --no-build # install existing build/ binaries only
|
||||
# ./deploy_local.sh --force # skip confirmation prompt
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BUILD_DIR="$SCRIPT_DIR/build"
|
||||
INSTALL_PREFIX="/usr/local/bin"
|
||||
|
||||
HOST_UNAME="$(uname -m)"
|
||||
case "$HOST_UNAME" in
|
||||
x86_64) ARCH="x86_64" ;;
|
||||
aarch64|arm64) ARCH="arm64" ;;
|
||||
armv7l|armv7) ARCH="armv7" ;;
|
||||
*)
|
||||
echo "ERROR: Unsupported host architecture '$HOST_UNAME'"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$ARCH" in
|
||||
x86_64)
|
||||
SIGNER_BIN="$BUILD_DIR/nsigner_static_x86_64"
|
||||
CLIENT_BIN="$BUILD_DIR/nsigner_client_static_x86_64"
|
||||
;;
|
||||
arm64)
|
||||
SIGNER_BIN="$BUILD_DIR/nsigner_static_arm64"
|
||||
CLIENT_BIN="$BUILD_DIR/nsigner_client_static_arm64"
|
||||
;;
|
||||
armv7)
|
||||
SIGNER_BIN="$BUILD_DIR/nsigner_static_armv7"
|
||||
CLIENT_BIN="$BUILD_DIR/nsigner_client_static_armv7"
|
||||
;;
|
||||
esac
|
||||
|
||||
DO_BUILD=true
|
||||
FORCE=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--no-build)
|
||||
DO_BUILD=false
|
||||
shift
|
||||
;;
|
||||
--force|-f)
|
||||
FORCE=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
echo "deploy_local.sh — Build and install nsigner + nsigner_client to $INSTALL_PREFIX"
|
||||
echo ""
|
||||
echo "Usage: $0 [OPTIONS]"
|
||||
echo ""
|
||||
echo "OPTIONS:"
|
||||
echo " --no-build Skip build step; install existing binaries from build/"
|
||||
echo " --force, -f Skip confirmation prompt"
|
||||
echo " -h, --help Show this help message"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unknown argument '$1'"
|
||||
echo "Usage: $0 [--no-build] [--force]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "=========================================="
|
||||
echo "nsigner local deploy"
|
||||
echo "=========================================="
|
||||
echo "Architecture: $ARCH"
|
||||
echo "Install dir: $INSTALL_PREFIX"
|
||||
echo "Signer binary: $SIGNER_BIN"
|
||||
echo "Client binary: $CLIENT_BIN"
|
||||
echo ""
|
||||
|
||||
# --- Build step ---------------------------------------------------------------
|
||||
if $DO_BUILD; then
|
||||
echo "[1/3] Building static binaries via build_static.sh"
|
||||
echo ""
|
||||
bash "$SCRIPT_DIR/build_static.sh" --arch "$ARCH"
|
||||
echo ""
|
||||
else
|
||||
echo "[1/3] Skipping build (--no-build)"
|
||||
fi
|
||||
|
||||
# --- Verify binaries exist ----------------------------------------------------
|
||||
echo "[2/3] Verifying binaries"
|
||||
if [[ ! -f "$SIGNER_BIN" ]]; then
|
||||
echo "ERROR: Signer binary not found: $SIGNER_BIN"
|
||||
echo " Run without --no-build, or run build_static.sh first."
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -x "$SIGNER_BIN" ]]; then
|
||||
echo "ERROR: Signer binary is not executable: $SIGNER_BIN"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$CLIENT_BIN" ]]; then
|
||||
echo "ERROR: Client binary not found: $CLIENT_BIN"
|
||||
echo " Run without --no-build, or run build_static.sh first."
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -x "$CLIENT_BIN" ]]; then
|
||||
echo "ERROR: Client binary is not executable: $CLIENT_BIN"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " OK: $SIGNER_BIN ($(du -h "$SIGNER_BIN" | cut -f1))"
|
||||
echo " OK: $CLIENT_BIN ($(du -h "$CLIENT_BIN" | cut -f1))"
|
||||
|
||||
# Quick smoke test
|
||||
SIGNER_VERSION="$("$SIGNER_BIN" --version 2>&1 || echo "unknown")"
|
||||
echo " Signer version: $SIGNER_VERSION"
|
||||
|
||||
# --- Confirm ------------------------------------------------------------------
|
||||
if ! $FORCE; then
|
||||
echo ""
|
||||
echo "About to install:"
|
||||
echo " $SIGNER_BIN -> $INSTALL_PREFIX/nsigner"
|
||||
echo " $CLIENT_BIN -> $INSTALL_PREFIX/nsigner_client"
|
||||
echo ""
|
||||
read -r -p "Proceed? [y/N] " response
|
||||
case "$response" in
|
||||
[yY][eE][sS]|[yY]) ;;
|
||||
*)
|
||||
echo "Aborted."
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# --- Install ------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "[3/3] Installing to $INSTALL_PREFIX"
|
||||
|
||||
SUDO=""
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
if ! command -v sudo >/dev/null 2>&1; then
|
||||
echo "ERROR: Need root privileges to write to $INSTALL_PREFIX but sudo is not available"
|
||||
exit 1
|
||||
fi
|
||||
SUDO="sudo"
|
||||
fi
|
||||
|
||||
$SUDO install -m 0755 "$SIGNER_BIN" "$INSTALL_PREFIX/nsigner"
|
||||
$SUDO install -m 0755 "$CLIENT_BIN" "$INSTALL_PREFIX/nsigner_client"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Deploy complete!"
|
||||
echo "=========================================="
|
||||
echo " $INSTALL_PREFIX/nsigner"
|
||||
echo " $INSTALL_PREFIX/nsigner_client"
|
||||
echo ""
|
||||
echo "Verify:"
|
||||
echo " nsigner --version"
|
||||
echo " nsigner_client --help"
|
||||
@@ -151,16 +151,14 @@ See [README.md §4c](../README.md) for full details.
|
||||
|
||||
The last param may include selector options:
|
||||
|
||||
- `role`
|
||||
- `nostr_index`
|
||||
- `role_path`
|
||||
- `role` — name of a pre-registered role entry
|
||||
- `role_path` — full BIP-44 derivation path
|
||||
|
||||
Resolution order:
|
||||
|
||||
1. `role`
|
||||
2. `nostr_index`
|
||||
3. `role_path`
|
||||
4. default role `main`
|
||||
2. `role_path`
|
||||
3. default role `main`
|
||||
|
||||
Conflicting selector fields must be rejected as `ambiguous_role_selector`.
|
||||
|
||||
@@ -457,7 +455,7 @@ Request:
|
||||
"method": "sign_event",
|
||||
"params": [
|
||||
"<event_json>",
|
||||
{ "role": "main", "nostr_index": 0 }
|
||||
{ "role": "main", "role_path": "m/44'/1237'/0'/0/0" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
# Derivation Paths — A Simple Explanation
|
||||
|
||||
## The seed
|
||||
|
||||
When you create a mnemonic (seed phrase), it generates a single master key. Think of it as the root of a tree — one key that controls everything below it.
|
||||
|
||||
## The tree
|
||||
|
||||
From that master key, you can derive **child keys**. Each child key can have its own children, and so on. This creates a tree of keys, all derived from the same seed.
|
||||
|
||||
## The path
|
||||
|
||||
A **derivation path** is just a set of directions for walking down the tree. It tells you which branches to take, starting from the master key (`m`).
|
||||
|
||||
```
|
||||
m / 44' / 1237' / 0' / 0 / 0
|
||||
```
|
||||
|
||||
Read it left to right:
|
||||
|
||||
| Segment | Meaning |
|
||||
|---------|---------|
|
||||
| `m` | The master key (your seed) |
|
||||
| `44'` | Purpose: "this is a BIP-44 wallet" |
|
||||
| `1237'` | Coin type: "this is Nostr" (1237 is Nostr's registered coin type) |
|
||||
| `0'` | Account: "account #0" |
|
||||
| `0` | Change: "external/receive" (0) vs "internal/change" (1) |
|
||||
| `0` | Address index: "address #0" |
|
||||
|
||||
Each segment derives a child key from the parent. Change any segment and you get a completely different key.
|
||||
|
||||
## The apostrophe (hardened vs unhardened)
|
||||
|
||||
The `'` after a number means **hardened**. It's the most important detail in the path.
|
||||
|
||||
### Without the apostrophe (unhardened)
|
||||
|
||||
```
|
||||
m / 44' / 1237' / 0' / 0 / 0
|
||||
^
|
||||
no apostrophe = unhardened
|
||||
```
|
||||
|
||||
Unhardened means: you can derive this child's **public key** from just the parent's **public key** — you don't need the private key.
|
||||
|
||||
This is useful for **watch-only wallets**: you can share the parent's extended public key with someone, and they can derive all the child public keys (addresses) without ever seeing your private key.
|
||||
|
||||
**The risk:** if a child **private key** leaks, and someone has the parent's extended public key, they can work backwards and derive **all sibling private keys**. So if address #5's private key leaks, addresses #0-4 and #6-99 are also compromised.
|
||||
|
||||
### With the apostrophe (hardened)
|
||||
|
||||
```
|
||||
m / 44' / 1237' / 0' / 0' / 0'
|
||||
^
|
||||
apostrophe = hardened
|
||||
```
|
||||
|
||||
Hardened means: you **need the parent's private key** to derive this child. You cannot derive it from the public key alone.
|
||||
|
||||
**The benefit:** if a child private key leaks, the attacker **cannot** derive sibling keys. Each hardened child is isolated. Compromising one doesn't compromise the others.
|
||||
|
||||
### Simple analogy
|
||||
|
||||
Imagine a building with floors and rooms:
|
||||
|
||||
- **Unhardened** = a glass door. Anyone with the floor key can see into all rooms on that floor. If someone picks the lock on room #5, they can figure out how to open rooms #0-4 and #6-99 too.
|
||||
- **Hardened** = a steel door. You need the master floor key to open any room. Picking the lock on room #5 tells you nothing about the other rooms.
|
||||
|
||||
### Visual: what happens when one key leaks
|
||||
|
||||
**Unhardened** (no apostrophe) — one leak compromises ALL siblings:
|
||||
|
||||
```
|
||||
m/44'/1237'/0'/0/0 ✓ safe
|
||||
m/44'/1237'/0'/0/1 ✓ safe
|
||||
m/44'/1237'/0'/0/2 ✓ safe
|
||||
m/44'/1237'/0'/0/3 ✓ safe
|
||||
m/44'/1237'/0'/0/4 ✓ safe
|
||||
m/44'/1237'/0'/0/5 ✗ COMPROMISED (leaked)
|
||||
m/44'/1237'/0'/0/6 ✗ COMPROMISED (derived from leak + parent pubkey)
|
||||
m/44'/1237'/0'/0/7 ✗ COMPROMISED (derived from leak + parent pubkey)
|
||||
...
|
||||
m/44'/1237'/0'/0/99 ✗ COMPROMISED (derived from leak + parent pubkey)
|
||||
|
||||
Parent extended public key (m/44'/1237'/0'/0) is public
|
||||
+ one child private key (address #5) leaks
|
||||
= ALL 100 sibling private keys are compromised
|
||||
```
|
||||
|
||||
**Hardened** (with apostrophe) — one leak only affects that one key:
|
||||
|
||||
```
|
||||
m/44'/1237'/0'/0'/0' ✓ safe
|
||||
m/44'/1237'/0'/0'/1' ✓ safe
|
||||
m/44'/1237'/0'/0'/2' ✓ safe
|
||||
m/44'/1237'/0'/0'/3' ✓ safe
|
||||
m/44'/1237'/0'/0'/4' ✓ safe
|
||||
m/44'/1237'/0'/0'/5' ✗ COMPROMISED (leaked)
|
||||
m/44'/1237'/0'/0'/6' ✓ safe (cannot be derived without parent PRIVATE key)
|
||||
m/44'/1237'/0'/0'/7' ✓ safe (cannot be derived without parent PRIVATE key)
|
||||
...
|
||||
m/44'/1237'/0'/0'/99' ✓ safe (cannot be derived without parent PRIVATE key)
|
||||
|
||||
One child private key (address #5) leaks
|
||||
= ONLY address #5 is compromised
|
||||
= siblings are safe because hardened derivation requires the parent PRIVATE key
|
||||
```
|
||||
|
||||
## Why NIP-06 uses unhardened last segments
|
||||
|
||||
NIP-06 (Nostr's key derivation standard) uses `m/44'/1237'/<account>'/0/0` — the first three segments are hardened, the last two are unhardened.
|
||||
|
||||
This is because NIP-06 copied the BIP-44 pattern from Bitcoin, where:
|
||||
- The **account** segment is hardened (so different accounts are isolated)
|
||||
- The **change** and **address** segments are unhardened (so watch-only wallets can derive addresses without the private key)
|
||||
|
||||
For Bitcoin, this makes sense: you want to share your extended public key with a payment processor so they can generate receive addresses for you.
|
||||
|
||||
For Nostr, it's less useful — but it means Nostr tools can derive your public keys from your extended public key, which some key management software uses.
|
||||
|
||||
## What this means for n_signer
|
||||
|
||||
n_signer always holds your private key and derives everything itself. You never share extended public keys with anyone. So:
|
||||
|
||||
- **Unhardened segments give you no benefit** — you don't need watch-only derivation
|
||||
- **Unhardened segments add risk** — the child key compromise vulnerability
|
||||
|
||||
### Recommendation
|
||||
|
||||
**Harden everything** if you don't need NIP-06 compatibility:
|
||||
|
||||
```
|
||||
m/44'/1237'/0'/0'/0' ← all hardened, maximum isolation
|
||||
m/44'/1237'/0-99'/0'/0' ← all hardened, 100 isolated agent keys
|
||||
```
|
||||
|
||||
**Use NIP-06 paths** if you want compatibility with standard Nostr tools:
|
||||
|
||||
```
|
||||
m/44'/1237'/0'/0/0 ← NIP-06 standard (last two unhardened)
|
||||
m/44'/1237'/0-99'/0/0 ← NIP-06 compatible, 100 agent keys
|
||||
```
|
||||
|
||||
## Common path patterns
|
||||
|
||||
### Standard Nostr (NIP-06)
|
||||
|
||||
```
|
||||
m/44'/1237'/0'/0/0
|
||||
```
|
||||
|
||||
One key. The default Nostr key that tools like `nak keygen` produce.
|
||||
|
||||
### Multiple Nostr accounts (NIP-06)
|
||||
|
||||
```
|
||||
m/44'/1237'/0'/0/0 ← account 0
|
||||
m/44'/1237'/1'/0/0 ← account 1
|
||||
m/44'/1237'/2'/0/0 ← account 2
|
||||
```
|
||||
|
||||
Change the account segment (hardened) to get different Nostr identities.
|
||||
|
||||
### Multiple Nostr agents (hardened, maximum isolation)
|
||||
|
||||
```
|
||||
m/44'/1237'/0'/0'/0' ← agent 0
|
||||
m/44'/1237'/1'/0'/0' ← agent 1
|
||||
m/44'/1237'/2'/0'/0' ← agent 2
|
||||
```
|
||||
|
||||
Same as above but with the last two segments hardened. Each agent is fully isolated — compromising one doesn't compromise the others.
|
||||
|
||||
### Range and wildcard syntax (n_signer wizard)
|
||||
|
||||
In n_signer's role wizard, you can use range syntax or wildcard for the variable segment:
|
||||
|
||||
```
|
||||
m/44'/1237'/0-99'/0/0 ← agents 0-99, NIP-06 compatible
|
||||
m/44'/1237'/0-99'/0'/0' ← agents 0-99, all hardened
|
||||
m/44'/1237'/*'/0'/0' ← any agent index, all hardened (wildcard)
|
||||
```
|
||||
|
||||
- `0-99'` means "this segment can be any value from 0 to 99, hardened"
|
||||
- `*'` means "this segment can be any non-negative integer, hardened" (wildcard — no range limit)
|
||||
- `*` (without `'`) means "any non-negative integer, unhardened"
|
||||
|
||||
The client specifies the exact path (e.g. `m/44'/1237'/5'/0/0` for agent #5), and the server verifies it's within the role's allowed range (or accepts any value for `*`).
|
||||
|
||||
### SSH keys (ed25519)
|
||||
|
||||
```
|
||||
m/44'/102001'/0'/0'/0'
|
||||
```
|
||||
|
||||
SLIP-0010 derivation for ed25519. All segments are hardened (SLIP-0010 requires this for ed25519).
|
||||
|
||||
### Age / x25519 keys
|
||||
|
||||
```
|
||||
m/44'/102002'/0'/0'/0'
|
||||
```
|
||||
|
||||
Key agreement keys for Age encryption or X25519 ECDH.
|
||||
|
||||
### Post-quantum keys
|
||||
|
||||
```
|
||||
m/44'/102003'/0'/0'/0' ← ML-DSA-65 (signatures, FIPS 204)
|
||||
m/44'/102004'/0'/0'/0' ← SLH-DSA-128s (signatures, FIPS 205)
|
||||
m/44'/102005'/0'/0'/0' ← ML-KEM-768 (KEM, FIPS 203)
|
||||
```
|
||||
|
||||
All hardened. The mnemonic-derived seed feeds a SHAKE-256 DRBG that replaces PQClean's `randombytes()` during keygen.
|
||||
|
||||
## Summary
|
||||
|
||||
| Concept | Simple explanation |
|
||||
|---------|-------------------|
|
||||
| `m` | The master key (your seed) |
|
||||
| Numbers | Which branch to take at each level |
|
||||
| `'` (apostrophe) | "Hardened" — need private key to derive, isolates siblings |
|
||||
| No `'` | "Unhardened" — can derive from public key, but siblings can be compromised |
|
||||
| Path | A set of directions from the master key to a specific key |
|
||||
| Different path | Different key (always, no exceptions) |
|
||||
|
||||
**Golden rule:** If you don't need watch-only derivation (and n_signer doesn't), harden everything.
|
||||
@@ -0,0 +1,333 @@
|
||||
# nsigner Menu Reference
|
||||
|
||||
This document describes every interactive menu and screen in the `nsigner` TUI, in the order they appear during a session. Use this as the authoritative reference when discussing changes to the user experience.
|
||||
|
||||
## Startup sequence
|
||||
|
||||
The menus appear in this order during interactive (TUI) startup:
|
||||
|
||||
1. **Unlock — Mnemonic source**
|
||||
2. **Define a role — Role preset menu** (loops)
|
||||
3. **Transport — Transport selection**
|
||||
4. **Running phase — Main status display**
|
||||
|
||||
Non-interactive startup (`--mnemonic-stdin`, `--mnemonic-fd`, or piped input) skips menus 1–4 and creates a default `main` role automatically.
|
||||
|
||||
---
|
||||
|
||||
## 1. Unlock — Mnemonic source
|
||||
|
||||
**When:** First screen, before anything else.
|
||||
|
||||
**Title:** `> Unlock`
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
Mnemonic source: [E]nter existing or [G]enerate new
|
||||
Default is E; you can also paste full mnemonic here.
|
||||
>
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Input | Action |
|
||||
|-------|--------|
|
||||
| `E` (default) | Prompt for an existing mnemonic (echo disabled) |
|
||||
| `G` | Generate a fresh 12-word BIP-39 mnemonic from `getrandom(2)`, display it numbered with a "WRITE THIS DOWN — IT WILL NOT BE SHOWN AGAIN" warning |
|
||||
| Paste full mnemonic | If the input contains spaces and doesn't start with `G`, it's treated as a mnemonic and validated directly |
|
||||
| `q` / `x` | Exit |
|
||||
|
||||
**After `E`:** Prompts for the mnemonic phrase with terminal echo disabled. Validates BIP-39 checksum. Up to 10 invalid attempts before exit.
|
||||
|
||||
**After `G`:** Displays the generated mnemonic numbered 1–12, then continues.
|
||||
|
||||
---
|
||||
|
||||
## 2. Define a role — Role preset menu
|
||||
|
||||
**When:** After mnemonic is loaded, in TUI mode only.
|
||||
|
||||
**Title:** `Define a role — bind a role name to a derivation path template`
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
Define a role:
|
||||
1. Standard Nostr (NIP-06): secp256k1, m/44'/1237'/0'/0/0
|
||||
2. Standard Nostr hardened range: secp256k1, m/44'/1237'/*'/0'/0'
|
||||
3. Nostr agent range (hardened): secp256k1, m/44'/1237'/*'/1'/0'
|
||||
4. SSH role: ed25519, m/44'/102001'/0'/0'/0'
|
||||
5. Age/x25519 role: x25519, m/44'/102002'/0'/0'/0'
|
||||
6. ML-DSA-65 role: post-quantum signatures, m/44'/102003'/0'/0'/0'
|
||||
7. SLH-DSA-128s role: post-quantum signatures, m/44'/102004'/0'/0'/0'
|
||||
8. ML-KEM-768 role: post-quantum KEM, m/44'/102005'/0'/0'/0'
|
||||
9. OTP role (one-time pad encryption)
|
||||
10. Custom path
|
||||
Select [1]:
|
||||
```
|
||||
|
||||
**Preset defaults:**
|
||||
|
||||
| Choice | Default name | Default path | Curve | Purpose |
|
||||
|--------|-------------|-------------|-------|---------|
|
||||
| 1 | `main` | `m/44'/1237'/0'/0/0` | secp256k1 | nostr |
|
||||
| 2 | `nostr_hardened` | `m/44'/1237'/*'/0'/0'` | secp256k1 | nostr |
|
||||
| 3 | `nostr_agent` | `m/44'/1237'/*'/1'/0'` | secp256k1 | nostr |
|
||||
| 4 | `ssh` | `m/44'/102001'/0'/0'/0'` | ed25519 | ssh |
|
||||
| 5 | `age` | `m/44'/102002'/0'/0'/0'` | x25519 | age |
|
||||
| 6 | `ml_dsa_65` | `m/44'/102003'/0'/0'/0'` | ml-dsa-65 | pq_sig |
|
||||
| 7 | `slh_dsa_128s` | `m/44'/102004'/0'/0'/0'` | slh-dsa-128s | pq_sig |
|
||||
| 8 | `ml_kem_768` | `m/44'/102005'/0'/0'/0'` | ml-kem-768 | pq_kem |
|
||||
| 9 | `otp` | (pad file) | otp | n/a |
|
||||
| 10 | `custom` | `m/44'/1237'/0'/0/0` | (prompted) | (auto-detected) |
|
||||
|
||||
**After selecting a preset, the user is prompted for:**
|
||||
|
||||
### 2a. Role name
|
||||
```
|
||||
Role name [main]:
|
||||
```
|
||||
Editable line (arrow keys, backspace). Defaults to the preset's default name. If the name already exists, it's skipped.
|
||||
|
||||
### 2b. Curve (only for choice 10 — Custom)
|
||||
```
|
||||
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]:
|
||||
```
|
||||
For presets 1–8, the curve is set automatically. For OTP (9), no curve is needed.
|
||||
|
||||
### 2c. Path template (only for choice 10 — Custom)
|
||||
```
|
||||
Path template [m/44'/1237'/0'/0/0]:
|
||||
|
||||
```
|
||||
Editable line. Pre-filled with the default path (updated to match the selected curve). Supports range syntax (`0-1000'`), set syntax (`1+34+54`), and wildcard (`*'` for any index).
|
||||
|
||||
For presets 1–8, the path is set automatically from the preset — no prompt. For OTP (9), no path is needed.
|
||||
|
||||
### 2c-otp. OTP pad file (only for choice 9 — OTP)
|
||||
```
|
||||
OTP pad directory (e.g. /media/usb0):
|
||||
OTP pad name (e.g. mypad):
|
||||
```
|
||||
Prompts for the pad directory and pad name. The pad is bound immediately. If binding fails, the role is skipped.
|
||||
|
||||
### 2d. Requires interactive approval
|
||||
```
|
||||
Require interactive approval for each request? [Y/n]:
|
||||
```
|
||||
- `Y` (default) → `requires_approval = 1` — human attendant must approve each request
|
||||
- `n` → `requires_approval = 0` — role name is the password, no prompt (role-as-password)
|
||||
|
||||
### 2e. Confirmation
|
||||
```
|
||||
Role 'main' registered: curve=secp256k1 path=m/44'/1237'/0'/0/0 (fixed, requires_approval=1).
|
||||
```
|
||||
Or for templated paths:
|
||||
```
|
||||
Role 'nostr_agent' registered: curve=secp256k1 path=m/44'/1237'/%d'/1'/0' (range 0-1000, requires_approval=1).
|
||||
```
|
||||
|
||||
### 2f. Loop
|
||||
```
|
||||
Define another role? [y/N]:
|
||||
```
|
||||
- `y` → back to the preset menu
|
||||
- `N` (default) → continue to transport selection
|
||||
|
||||
**Mandatory:** At least one role must be created. If the user exits without creating any roles, the signer prints "At least one role must be defined." and exits.
|
||||
|
||||
---
|
||||
|
||||
## 3. Transport — Transport selection
|
||||
|
||||
**When:** After role definition, in TUI mode with no `--listen` flag.
|
||||
|
||||
**Title:** `Transport — how should other programs reach this signer?`
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
Select one or more (type a number to toggle, 'a' for all, Enter to confirm):
|
||||
|
||||
[x] 1. Local Unix socket (same machine/qube)
|
||||
[ ] 2. Qubes qrexec bridge (other qubes via qrexec, no network)
|
||||
[ ] 3. FIPS/TCP listener (framed JSON, FIPS mesh or local network)
|
||||
[ ] 4. HTTP listener (curl-friendly, localhost by default)
|
||||
|
||||
[a] select all Enter = confirm
|
||||
>
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Input | Action |
|
||||
|-------|--------|
|
||||
| `1` | Toggle Local Unix socket |
|
||||
| `2` | Toggle Qubes qrexec bridge |
|
||||
| `3` | Toggle FIPS/TCP listener |
|
||||
| `4` | Toggle HTTP listener |
|
||||
| `a` | Select all |
|
||||
| Enter | Confirm current selection (at least one required) |
|
||||
|
||||
Default: Unix socket only.
|
||||
|
||||
---
|
||||
|
||||
## 4. Running phase — Main status display
|
||||
|
||||
**When:** After all startup menus, this is the main screen.
|
||||
|
||||
**Title:** `> Main Menu`
|
||||
|
||||
**Layout:**
|
||||
```
|
||||
┌ n_signer v<version> — Main Menu ─────────────────────────┐
|
||||
│ │
|
||||
│ Roles: │
|
||||
│ Role Purpose Curve Derivation path
|
||||
│ -------------------- ------------ ------------ ------------------------
|
||||
│ main nostr secp256k1 m/44'/1237'/0'/0/0
|
||||
│ role1 nostr secp256k1 m/44'/1237'/1-100'/0/0
|
||||
│ │
|
||||
│ Activity (latest first): │
|
||||
│ 14:51:05 uid:1000 nostr_get_public_key(pathrole_2) ALLOWED:prompt
|
||||
│ 14:50:54 uid:1000 nostr_get_public_key(pathrole_2) ALLOWED:prompt
|
||||
│ │
|
||||
│ session=unlocked (12 words) signer=nsigner derived=2 │
|
||||
│ │
|
||||
│ l lock/reunlock r refresh d display connections q/x quit
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
>
|
||||
```
|
||||
|
||||
**Status line:** `session=<locked|unlocked> (<N> words) signer=<name> derived=<count>`
|
||||
|
||||
**Menu items:**
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `l` | Lock / re-unlock (re-prompt for mnemonic) |
|
||||
| `r` | Refresh display |
|
||||
| `d` | Display connections (show transport details + example client commands) |
|
||||
| `q` / `x` | Quit |
|
||||
|
||||
**Note:** The `a toggle auto-approve` menu item has been **removed**. Authorization is now per-role via the `requires_approval` flag set during role definition.
|
||||
|
||||
---
|
||||
|
||||
## 5. Approval prompt
|
||||
|
||||
**When:** A client request arrives for a role with `requires_approval = 1`, and the request is not pre-approved by policy.
|
||||
|
||||
**Title:** `> Approval`
|
||||
|
||||
**Layout:**
|
||||
```
|
||||
┌ n_signer v<version> — Approval ──────────────────────────┐
|
||||
│ │
|
||||
│ Approval required │
|
||||
│ caller: uid:1000 │
|
||||
│ method: nostr_sign_event │
|
||||
│ role: main │
|
||||
│ purpose: nostr │
|
||||
│ ** NEW IDENTITY — will be derived if approved ** │
|
||||
│ │
|
||||
│ y: allow once │
|
||||
│ n: deny │
|
||||
│ e: allow this caller+role+verb for session │
|
||||
│ a: allow this caller+role for session (all verbs) │
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
>
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Key | Action | Policy result |
|
||||
|-----|--------|---------------|
|
||||
| `y` | Allow this one request | `POLICY_ALLOW` |
|
||||
| `n` | Deny this request | `POLICY_DENY` |
|
||||
| `e` | Allow this caller+role+verb for the rest of the session | `POLICY_ALLOW_SESSION_VERB` |
|
||||
| `a` | Allow this caller+role for all verbs for the session | `POLICY_ALLOW_SESSION_ALL` |
|
||||
|
||||
The `** NEW IDENTITY — will be derived if approved **` line appears only when the requested key hasn't been derived yet.
|
||||
|
||||
**Fields shown:**
|
||||
- `caller` — the caller identity (e.g. `uid:1000`, `qubes:vm-name`, `pubkey:<hex>`)
|
||||
- `fips peer` — (TCP/FIPS mode only) the peer's npub and optional name
|
||||
- `method` — the JSON-RPC verb (e.g. `nostr_sign_event`, `nostr_get_public_key`)
|
||||
- `role` — the role name from the request
|
||||
- `purpose` — the role's purpose (nostr, ssh, age, pq_sig, pq_kem)
|
||||
|
||||
---
|
||||
|
||||
## 6. Display connections
|
||||
|
||||
**When:** Pressed `d` from the main status display.
|
||||
|
||||
**Shows:** For each active transport, a section with:
|
||||
- Transport name and description
|
||||
- Socket address / port / URL
|
||||
- Example client command
|
||||
|
||||
After any keypress, returns to the main status display.
|
||||
|
||||
---
|
||||
|
||||
## Non-interactive mode
|
||||
|
||||
When started with `--mnemonic-stdin` or `--mnemonic-fd`, or when stdin is not a TTY:
|
||||
|
||||
- Menus 1–4 are skipped
|
||||
- A default `main` role is created automatically: `secp256k1`, `m/44'/1237'/0'/0/0`, `requires_approval=1`
|
||||
- The `--allow-all` flag sets `server_set_prompt_always_allow(1)` which bypasses approval prompts (used by tests and automated setups)
|
||||
- The `NSIGNER_TEST_NONINTERACTIVE_PROMPT` env var can be set to `allow` or `deny` to control the non-interactive prompt fallback
|
||||
|
||||
---
|
||||
|
||||
## CLI flags that affect menus
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `--listen <mode>` | Skips transport selection menu (menu 3) |
|
||||
| `--mnemonic-stdin` | Skips mnemonic menu (menu 1), reads from stdin |
|
||||
| `--mnemonic-fd <N>` | Skips mnemonic menu (menu 1), reads from fd N |
|
||||
| `--allow-all` | Skips approval prompts (sets `prompt_always_allow`) |
|
||||
| `--socket-name <name>` | Sets the socket name (skips random name generation) |
|
||||
| `--preapprove <SPEC>` | Pre-approves specific caller+role+verb combinations |
|
||||
|
||||
---
|
||||
|
||||
## Summary flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Start] --> B{stdin is TTY?}
|
||||
B -- No --> C[Non-interactive: create default main role]
|
||||
B -- Yes --> D[Menu 1: Unlock — mnemonic source]
|
||||
D --> E[Menu 2: Define a role — preset menu]
|
||||
E --> F{Another role?}
|
||||
F -- Yes --> E
|
||||
F -- No --> G{At least one role?}
|
||||
G -- No --> H[Error: at least one role required]
|
||||
G -- Yes --> I{--listen flag?}
|
||||
I -- No --> J[Menu 3: Transport selection]
|
||||
I -- Yes --> K[Use --listen mode]
|
||||
J --> N[Menu 4: Main status display]
|
||||
K --> N
|
||||
C --> N
|
||||
N --> O{Request arrives}
|
||||
O --> P{requires_approval?}
|
||||
P -- No --> Q[Authorize immediately]
|
||||
P -- Yes --> R{Pre-approved?}
|
||||
R -- Yes --> Q
|
||||
R -- No --> S[Menu 6: Approval prompt]
|
||||
S -- y/e/a --> Q
|
||||
S -- n --> T[Deny]
|
||||
```
|
||||
+23
-21
@@ -179,7 +179,8 @@ build_release_binary() {
|
||||
fi
|
||||
|
||||
# Prevent stale artifacts from previous builds being uploaded.
|
||||
rm -f build/nsigner_static_x86_64 build/nsigner_static_arm64
|
||||
rm -f build/nsigner_static_x86_64 build/nsigner_static_arm64 \
|
||||
build/nsigner_client_static_x86_64 build/nsigner_client_static_arm64
|
||||
|
||||
print_status "Building x86_64 static binary (this may take a few minutes with PQ algorithms)..."
|
||||
./build_static.sh 2>&1 | tail -5 || return 1
|
||||
@@ -251,6 +252,8 @@ upload_release_assets() {
|
||||
local binary_path_x86="$2"
|
||||
local tarball_path="$3"
|
||||
local binary_path_arm64="$4"
|
||||
local client_path_x86="${5:-}"
|
||||
local client_path_arm64="${6:-}"
|
||||
|
||||
if [[ ! -f "$HOME/.gitea_token" ]]; then
|
||||
print_warning "No ~/.gitea_token found. Skipping asset uploads."
|
||||
@@ -262,26 +265,23 @@ upload_release_assets() {
|
||||
local api_url="https://git.laantungir.net/api/v1/repos/laantungir/n_signer"
|
||||
local assets_url="$api_url/releases/$release_id/assets"
|
||||
|
||||
if [[ -f "$binary_path_x86" ]]; then
|
||||
curl -s -X POST "$assets_url" \
|
||||
-H "Authorization: token $token" \
|
||||
-F "attachment=@$binary_path_x86;filename=$(basename "$binary_path_x86")" \
|
||||
-F "name=$(basename "$binary_path_x86")" > /dev/null
|
||||
fi
|
||||
# Helper to upload a single asset
|
||||
upload_asset() {
|
||||
local path="$1"
|
||||
if [[ -f "$path" ]]; then
|
||||
print_status "Uploading $(basename "$path")..."
|
||||
curl -s -X POST "$assets_url" \
|
||||
-H "Authorization: token $token" \
|
||||
-F "attachment=@$path;filename=$(basename "$path")" \
|
||||
-F "name=$(basename "$path")" > /dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ -f "$binary_path_arm64" ]]; then
|
||||
curl -s -X POST "$assets_url" \
|
||||
-H "Authorization: token $token" \
|
||||
-F "attachment=@$binary_path_arm64;filename=$(basename "$binary_path_arm64")" \
|
||||
-F "name=$(basename "$binary_path_arm64")" > /dev/null
|
||||
fi
|
||||
|
||||
if [[ -f "$tarball_path" ]]; then
|
||||
curl -s -X POST "$assets_url" \
|
||||
-H "Authorization: token $token" \
|
||||
-F "attachment=@$tarball_path;filename=$(basename "$tarball_path")" \
|
||||
-F "name=$(basename "$tarball_path")" > /dev/null
|
||||
fi
|
||||
upload_asset "$binary_path_x86"
|
||||
upload_asset "$binary_path_arm64"
|
||||
upload_asset "$client_path_x86"
|
||||
upload_asset "$client_path_arm64"
|
||||
upload_asset "$tarball_path"
|
||||
}
|
||||
|
||||
main() {
|
||||
@@ -306,6 +306,8 @@ main() {
|
||||
|
||||
local binary_path_x86="build/nsigner_static_x86_64"
|
||||
local binary_path_arm64="build/nsigner_static_arm64"
|
||||
local client_path_x86="build/nsigner_client_static_x86_64"
|
||||
local client_path_arm64="build/nsigner_client_static_arm64"
|
||||
local tarball_path=""
|
||||
tarball_path=$(create_source_tarball || true)
|
||||
|
||||
@@ -313,7 +315,7 @@ main() {
|
||||
release_id=$(create_gitea_release || true)
|
||||
|
||||
if [[ -n "$release_id" ]]; then
|
||||
upload_release_assets "$release_id" "$binary_path_x86" "$tarball_path" "$binary_path_arm64"
|
||||
upload_release_assets "$release_id" "$binary_path_x86" "$tarball_path" "$binary_path_arm64" "$client_path_x86" "$client_path_arm64"
|
||||
fi
|
||||
|
||||
print_success "Release flow completed"
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Moved
|
||||
|
||||
This plan has moved to [`client/n_signer_client_PLAN.md`](../client/n_signer_client_PLAN.md)
|
||||
so it lives alongside the client project it describes.
|
||||
@@ -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,228 @@
|
||||
# Plan: Role + Path Authorization Model
|
||||
|
||||
## Status: Finalized — ready for implementation
|
||||
|
||||
## Hardened vs unhardened derivation paths
|
||||
|
||||
BIP-32 derivation paths use `'` (or `h`) to mark **hardened** segments. The presence or absence of `'` changes the math and produces completely different keys.
|
||||
|
||||
- **Hardened** (`m/44'/1237'/0'/0'/0'`): requires the parent private key; compromising one child key does NOT compromise siblings. Best for agent isolation.
|
||||
- **Unhardened** (`m/44'/1237'/0/0/0`): can derive public keys from the parent public key alone; but compromising one child private key + the extended public key compromises all siblings.
|
||||
|
||||
### Recommendation for multi-agent setups
|
||||
|
||||
Use **all-hardened** paths like `m/44'/1237'/0-99'/0'/0'` for 100 agents. This gives full isolation — if agent #5 is compromised, agents #0-4 and #6-99 are safe. Since n_signer always holds the private key, there's no benefit from unhardened derivation's "watching-only" capability.
|
||||
|
||||
### NIP-06 compatibility
|
||||
|
||||
NIP-06 defines `m/44'/1237'/<account>'/0/0` — the account segment is hardened, the last two are unhardened. If you need NIP-06 compatibility (keys work with standard Nostr tools), use `m/44'/1237'/0-99'/0/0`. If you don't care about NIP-06, harden everything.
|
||||
|
||||
### Current code support
|
||||
|
||||
The signer already supports arbitrary hardened paths. The path parser in [`src/key_store.c`](src/key_store.c:684) (`parse_derivation_path`) handles both `'` and `h`/`H` as hardened markers and sets the `0x80000000` bit accordingly. The secp256k1 derivation uses standard BIP-32 (`nostr_bip32_derive_path`). Non-secp256k1 curves (ed25519, x25519, PQ algorithms) already use all-hardened SLIP-0010 paths. So you can use `m/44'/1237'/0-99'/0'/0'` (all hardened) right now — no code changes needed for the derivation itself.
|
||||
|
||||
### Role preset menu implications
|
||||
|
||||
The role preset menu in the wizard should offer both NIP-06-compatible and all-hardened options:
|
||||
- "Standard Nostr (NIP-06): secp256k1, m/44'/1237'/0'/0/0"
|
||||
- "Standard Nostr hardened: secp256k1, m/44'/1237'/0'/0'/0'"
|
||||
- "Nostr agent range (NIP-06): secp256k1, m/44'/1237'/0-99'/0/0"
|
||||
- "Nostr agent range (hardened): secp256k1, m/44'/1237'/0-99'/0'/0'"
|
||||
|
||||
## Problem
|
||||
|
||||
The current selector model has three independent selectors (`nostr_index`, `role`, `role_path`) that are mutually exclusive and confusing:
|
||||
|
||||
- `nostr_index` bypasses the role system entirely — the server doesn't know which role's encryption scheme applies.
|
||||
- `role_path` bypasses the role system for authorization.
|
||||
- `index` is ambiguous when a role template has multiple variable segments (e.g. `m/44'/1237'/0-10'/0-10/0-10` — which "index"?).
|
||||
- The `--allow-index` path whitelist is a separate authorization mechanism that duplicates what roles already do.
|
||||
|
||||
## New model
|
||||
|
||||
### Core principle
|
||||
|
||||
**Every request specifies both a role and a full path.** The role authorizes the request (acts as a password) and determines the encryption scheme. The path selects the specific key to derive. No exceptions, no backward compatibility for the old selectors.
|
||||
|
||||
### Client-side selectors (`n_signer_client`)
|
||||
|
||||
| Flag | Meaning |
|
||||
|---|---|
|
||||
| `--role <name> --path <full-bip44-path>` | **The only selector.** Both required for `nostr_*` verbs. Sends `{"role":"<name>","role_path":"<path>"}`. |
|
||||
| `--nostr-index <N>` | **Removed.** |
|
||||
| `--index <N>` | **Removed.** |
|
||||
| `--role-path <path>` | **Removed** (replaced by `--path`). |
|
||||
|
||||
Example commands:
|
||||
```bash
|
||||
# Standard Nostr key (role "main", path m/44'/1237'/0'/0/0)
|
||||
n_signer_client --role main --path "m/44'/1237'/0'/0/0" get-public-key
|
||||
|
||||
# A specific key from role1
|
||||
n_signer_client --role role1 --path "m/44'/1237'/1'/1/0" get-public-key
|
||||
|
||||
# Sign an event
|
||||
echo '{"kind":1,"content":"hello","tags":[],"created_at":1700000000}' \
|
||||
| n_signer_client --role main --path "m/44'/1237'/0'/0/0" sign-event | nak publish
|
||||
```
|
||||
|
||||
### Server-side authorization (`nsigner`)
|
||||
|
||||
#### Role-as-password with per-role approval flag
|
||||
|
||||
Each role defined in the wizard has a property: **`requires_approval`** (boolean, default `true`).
|
||||
|
||||
- **`requires_approval = false`**: Knowing the role name is sufficient authorization. If the client provides a valid role name and a path that matches the role's registered template, the request is authorized immediately — no interactive prompt. This is "role-as-password."
|
||||
- **`requires_approval = true`**: The role name identifies the request, but the human attendant must approve each request interactively (existing approval flow). Use this for roles given to agents where you want to see and approve everything they do.
|
||||
|
||||
#### Path verification
|
||||
|
||||
When the client sends `{"role":"<name>","role_path":"<path>"}`:
|
||||
|
||||
1. Look up the role by name. If not found → `1002 unknown_role`.
|
||||
2. Check if the requested path matches the role's registered template (substitute the variable segments and compare). If it doesn't match → `2003 path_not_allowed`.
|
||||
3. If `requires_approval = false` → authorize immediately.
|
||||
4. If `requires_approval = true` → prompt the human attendant (existing approval flow).
|
||||
|
||||
#### What's removed
|
||||
|
||||
- **`--allow-index`** flag and path whitelist — removed entirely. All access goes through roles now.
|
||||
- **`--index`** selector option — removed. Use `--path` with the full path.
|
||||
- **`--nostr-index`** selector — removed. Use `--role main --path "m/44'/1237'/N'/0/0"`.
|
||||
- **`--role-path`** as a standalone selector (without `--role`) — removed. Use `--role` + `--path` together.
|
||||
- **No backward compatibility** for `{"nostr_index":N}` or `{"role":"<name>","index":N}` — these are rejected with an error message explaining the new model.
|
||||
|
||||
#### `--allow-all` behavior
|
||||
|
||||
`--allow-all` still works for same-uid callers without a role — but only for the algorithm-based verbs (sign, verify, derive, etc.) that use `--algorithm` + `--index` (the algorithm index, not the nostr index). For `nostr_*` verbs, `--role` + `--path` are always required.
|
||||
|
||||
### Wizard changes
|
||||
|
||||
#### Mandatory role creation
|
||||
|
||||
Upon startup, the user is **required** to create at least one role. If no role is created, the signer exits with an error message: "At least one role must be defined."
|
||||
|
||||
#### Role preset menu
|
||||
|
||||
Instead of starting from a blank template, the wizard presents a menu of common presets:
|
||||
|
||||
```
|
||||
Define a role:
|
||||
1. Standard Nostr role (secp256k1, m/44'/1237'/0'/0/0)
|
||||
2. Standard Nostr role with range (secp256k1, m/44'/1237'/0-100'/0/0)
|
||||
3. SSH role (ed25519, m/44'/102001'/0'/0/0)
|
||||
4. Age/x25519 role (x25519, m/44'/102002'/0'/0/0)
|
||||
5. ML-DSA-65 role (post-quantum signatures, m/44'/102003'/0'/0/0)
|
||||
6. SLH-DSA-128s role (post-quantum signatures, m/44'/102004'/0'/0/0)
|
||||
7. ML-KEM-768 role (post-quantum KEM, m/44'/102005'/0'/0/0)
|
||||
8. Custom path
|
||||
Select [1]:
|
||||
```
|
||||
|
||||
After selecting a preset, the user can:
|
||||
- Edit the role name (default: `main` for option 1, `ssh` for option 3, etc.)
|
||||
- Edit the path template (pre-filled from the preset)
|
||||
- Set `requires_approval` (default: `true`)
|
||||
|
||||
Then the wizard loops: "Define another role? [y/N]"
|
||||
|
||||
#### Default role
|
||||
|
||||
The first role created is the default role. If the user selects preset 1 and keeps the name `main`, that becomes the default. The default role is used when a client sends a request without specifying a role — but since the new model requires both `--role` and `--path`, the "default role" concept only applies to the `--allow-all` algorithm-verb path.
|
||||
|
||||
### Verb-level granularity
|
||||
|
||||
**Not implemented.** All verbs within a role have the same authorization level. Future expansion.
|
||||
|
||||
## What changes in the code
|
||||
|
||||
### `client/n_signer_client.c`
|
||||
- Add `--path <path>` flag (replaces `--role-path`).
|
||||
- Remove `--nostr-index` flag.
|
||||
- Remove `--index` flag (for nostr verbs; keep it for algorithm verbs where it's the algorithm derivation index).
|
||||
- Remove `--role-path` flag.
|
||||
- For `nostr_*` verbs: require both `--role` and `--path`. Error if either is missing.
|
||||
- Update `--help` text and examples.
|
||||
- Update `client/n_signer_client_README.md`.
|
||||
|
||||
### `src/role_table.c` / `src/role_table.h`
|
||||
- Add `requires_approval` field to the role entry struct.
|
||||
- Add role preset menu to the wizard.
|
||||
- Make role creation mandatory (at least one role).
|
||||
- Add function to check a path against a role's template (path matching).
|
||||
|
||||
### `src/selector.c`
|
||||
- When both `role` and `role_path` are present: look up the role, verify the path matches the template, set the role index for key derivation.
|
||||
- When `nostr_index` is present: reject with error (removed).
|
||||
- When `index` is present without `--algorithm`: reject with error (removed for nostr verbs).
|
||||
- When only `role_path` is present (no role): reject with error.
|
||||
- When only `role` is present (no path): reject with error (unless the role has a fixed single path with no variable segments — in that case, use the role's default path).
|
||||
|
||||
### `src/policy.c`
|
||||
- Add "role-as-password" authorization: if the role is known, the path matches, and `requires_approval = false`, allow without prompting.
|
||||
- If `requires_approval = true`, use the existing approval flow.
|
||||
- Remove `--allow-index` handling and the path whitelist.
|
||||
|
||||
### `src/main.c`
|
||||
- Remove `--allow-index` flag parsing.
|
||||
- Remove `--nostr-index` references in help text.
|
||||
- Update the wizard to use the preset menu and prompt for `requires_approval`.
|
||||
- Make role creation mandatory.
|
||||
|
||||
### `src/dispatcher.c`
|
||||
- Update selector resolution to use the new role+path model.
|
||||
- Remove the old `--allow-index` path whitelist checks.
|
||||
- Reject `nostr_index` and `index` (for nostr verbs) with clear error messages.
|
||||
|
||||
### Tests
|
||||
- Update `tests/test_n_signer_client.sh` to use `--role` + `--path` instead of `--nostr-index` / `--index`.
|
||||
- Update `tests/test_integration.c` to use the new selector model.
|
||||
- Remove or repurpose `tests/test_path_whitelist.c` (path whitelist is gone).
|
||||
- Add tests for the `requires_approval` flag (both true and false).
|
||||
- Add tests for the role preset menu.
|
||||
|
||||
### Documentation
|
||||
- Update `README.md` §4.6 (role-based selectors) to describe the new model.
|
||||
- Update `client/n_signer_client_README.md`.
|
||||
- Update `client/n_signer_client_PLAN.md`.
|
||||
- Update `documents/CLIENT_IMPLEMENTATION.md`.
|
||||
|
||||
## Mermaid: new authorization flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Client request with role + path] --> B{Role known?}
|
||||
B -- No --> E[Reject: unknown_role]
|
||||
B -- Yes --> C{Path matches role template?}
|
||||
C -- No --> F[Reject: path_not_allowed]
|
||||
C -- Yes --> D{requires_approval?}
|
||||
D -- No --> G[Authorize — no prompt]
|
||||
D -- Yes --> H[Prompt human attendant]
|
||||
H -- allow --> G
|
||||
H -- deny --> I[Reject: unauthorized]
|
||||
```
|
||||
|
||||
## Mermaid: wizard role creation
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Startup] --> B[Show role preset menu]
|
||||
B --> C[User selects preset]
|
||||
C --> D[Pre-fill path template]
|
||||
D --> E[User edits role name]
|
||||
E --> F[User edits path template]
|
||||
F --> G[User sets requires_approval]
|
||||
G --> H[Register role]
|
||||
H --> I{Define another role?}
|
||||
I -- Yes --> B
|
||||
I -- No --> J{At least one role defined?}
|
||||
J -- No --> K[Error: at least one role required]
|
||||
J -- Yes --> L[Continue to transport selection]
|
||||
```
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Verb-level granularity (can-see-pubkey-but-cant-sign) — future expansion.
|
||||
- Role revocation / rotation — not needed yet.
|
||||
- Role names as actual cryptographic tokens (currently just plain text names) — future hardening.
|
||||
- Backward compatibility for `nostr_index` / `index` — intentionally removed.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Archived
|
||||
|
||||
This was an intermediate planning document. The authoritative plan is at [`client/n_signer_client_PLAN.md`](../client/n_signer_client_PLAN.md).
|
||||
@@ -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,12 @@ 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 */
|
||||
int requires_approval; /* 0 = role-as-password (no prompt), 1 = require interactive approval */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -143,6 +149,12 @@ role_entry_t *role_table_find_by_path(role_table_t *table, const char *path);
|
||||
|
||||
/* Get the default role (named "main"). Returns pointer or NULL if no "main" role. */
|
||||
role_entry_t *role_table_get_default(role_table_t *table);
|
||||
/*
|
||||
* Check whether a concrete derivation path matches a role's path template.
|
||||
* The template may contain a "%d" placeholder (with optional "'" hardened marker).
|
||||
* Returns 1 if the path matches the template, 0 if not.
|
||||
*/
|
||||
int role_path_matches_template(const char *path, const char *template);
|
||||
|
||||
/* Parse purpose string to enum */
|
||||
role_purpose_t role_purpose_from_str(const char *s);
|
||||
@@ -165,6 +177,11 @@ const char *role_curve_to_str(role_curve_t c);
|
||||
#define SELECTOR_ERR_AMBIGUOUS -1 /* multiple selectors specified */
|
||||
#define SELECTOR_ERR_NOT_FOUND -2 /* no matching role in table */
|
||||
#define SELECTOR_ERR_NO_DEFAULT -3 /* no selector given and no "main" role exists */
|
||||
#define SELECTOR_ERR_PATH_MISMATCH -4 /* role_path doesn't match role's template */
|
||||
#define SELECTOR_ERR_NOSTR_INDEX_DEPRECATED -5 /* nostr_index is deprecated */
|
||||
#define SELECTOR_ERR_INDEX_DEPRECATED -6 /* index is deprecated for nostr verbs */
|
||||
#define SELECTOR_ERR_ROLE_REQUIRED -7 /* --role is required when using --path */
|
||||
#define SELECTOR_ERR_PATH_REQUIRED -8 /* --path is required for roles with variable path templates */
|
||||
|
||||
/* Parsed selector from a request's options object */
|
||||
typedef struct {
|
||||
@@ -176,6 +193,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) */
|
||||
@@ -318,9 +337,12 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||
*/
|
||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
|
||||
|
||||
/* Check whether caller_id is allowed to invoke `verb` with the given
|
||||
* algorithm and index (algorithm-based policy). Returns POLICY_ALLOW,
|
||||
* POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH. */
|
||||
@@ -1787,6 +1809,21 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
|
||||
if (rc == SELECTOR_ERR_NO_DEFAULT) {
|
||||
return make_error_response(id_str, 1003, "no_default_role");
|
||||
}
|
||||
if (rc == SELECTOR_ERR_PATH_MISMATCH) {
|
||||
return make_error_response(id_str, 2003, "path_not_allowed");
|
||||
}
|
||||
if (rc == SELECTOR_ERR_NOSTR_INDEX_DEPRECATED) {
|
||||
return make_error_response(id_str, 2006, "nostr_index is deprecated — use --role main --path m/44'/1237'/N'/0/0 instead");
|
||||
}
|
||||
if (rc == SELECTOR_ERR_INDEX_DEPRECATED) {
|
||||
return make_error_response(id_str, 2007, "index is deprecated for nostr verbs — use --path with the full path instead");
|
||||
}
|
||||
if (rc == SELECTOR_ERR_ROLE_REQUIRED) {
|
||||
return make_error_response(id_str, 2008, "--role is required when using --path");
|
||||
}
|
||||
if (rc == SELECTOR_ERR_PATH_REQUIRED) {
|
||||
return make_error_response(id_str, 2009, "--path is required for roles with variable path templates");
|
||||
}
|
||||
return make_error_response(id_str, -32602, "invalid_params");
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,12 @@ 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 */
|
||||
int requires_approval; /* 0 = role-as-password (no prompt), 1 = require interactive approval */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -174,6 +180,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) */
|
||||
|
||||
+211
-9
@@ -118,6 +118,12 @@ 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 */
|
||||
int requires_approval; /* 0 = role-as-password (no prompt), 1 = require interactive approval */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -176,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) */
|
||||
@@ -673,6 +681,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 +812,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 +827,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 +884,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 +947,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 +1008,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 +1066,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 +1124,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) {
|
||||
@@ -1047,15 +1209,48 @@ int crypto_derive_all(key_store_t *store, role_table_t *table, const mnemonic_st
|
||||
for (i = 0; i < table->count; ++i) {
|
||||
role_entry_t *role = &table->entries[i];
|
||||
derived_key_t *dst = &store->keys[i];
|
||||
char saved_path[ROLE_PATH_MAX];
|
||||
int substituted = 0;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* Template roles (path contains "%d") cannot be derived as-is because
|
||||
* "%d" is not a valid BIP-44 segment. If the role has a default index,
|
||||
* substitute it into the path temporarily so a key can be pre-derived
|
||||
* for the default index. Roles without a default index are skipped
|
||||
* here (they will be derived on-demand when a client supplies a path). */
|
||||
if (role->selector_type == SELECTOR_ROLE_PATH &&
|
||||
strstr(role->role_path, "%d") != NULL) {
|
||||
if (role->path_default_index < 0) {
|
||||
continue;
|
||||
}
|
||||
/* Save the template and substitute the default index */
|
||||
strncpy(saved_path, role->role_path, sizeof(saved_path) - 1);
|
||||
saved_path[sizeof(saved_path) - 1] = '\0';
|
||||
snprintf(role->role_path, sizeof(role->role_path),
|
||||
"%s", saved_path);
|
||||
/* Format the template (in saved_path) with the default index */
|
||||
{
|
||||
char concrete[ROLE_PATH_MAX];
|
||||
snprintf(concrete, sizeof(concrete), saved_path, role->path_default_index);
|
||||
strncpy(role->role_path, concrete, sizeof(role->role_path) - 1);
|
||||
role->role_path[sizeof(role->role_path) - 1] = '\0';
|
||||
}
|
||||
substituted = 1;
|
||||
}
|
||||
|
||||
if (derive_for_role(dst, role, mnemonic) != 0) {
|
||||
if (substituted) {
|
||||
/* Restore the template path */
|
||||
strncpy(role->role_path, saved_path, sizeof(role->role_path) - 1);
|
||||
role->role_path[sizeof(role->role_path) - 1] = '\0';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1063,6 +1258,12 @@ int crypto_derive_all(key_store_t *store, role_table_t *table, const mnemonic_st
|
||||
role->pubkey_hex[sizeof(role->pubkey_hex) - 1] = '\0';
|
||||
role->derived = 1;
|
||||
|
||||
if (substituted) {
|
||||
/* Restore the template path (keep derived=1 + pubkey) */
|
||||
strncpy(role->role_path, saved_path, sizeof(role->role_path) - 1);
|
||||
role->role_path[sizeof(role->role_path) - 1] = '\0';
|
||||
}
|
||||
|
||||
derived_count++;
|
||||
}
|
||||
|
||||
@@ -1099,7 +1300,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;
|
||||
}
|
||||
|
||||
|
||||
+1002
-108
File diff suppressed because it is too large
Load Diff
@@ -116,6 +116,12 @@ 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 */
|
||||
int requires_approval; /* 0 = role-as-password (no prompt), 1 = require interactive approval */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -174,6 +180,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,12 @@ 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 */
|
||||
int requires_approval; /* 0 = role-as-password (no prompt), 1 = require interactive approval */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -141,6 +147,12 @@ role_entry_t *role_table_find_by_path(role_table_t *table, const char *path);
|
||||
|
||||
/* Get the default role (named "main"). Returns pointer or NULL if no "main" role. */
|
||||
role_entry_t *role_table_get_default(role_table_t *table);
|
||||
/*
|
||||
* Check whether a concrete derivation path matches a role's path template.
|
||||
* The template may contain a "%d" placeholder (with optional "'" hardened marker).
|
||||
* Returns 1 if the path matches the template, 0 if not.
|
||||
*/
|
||||
int role_path_matches_template(const char *path, const char *template);
|
||||
|
||||
/* Parse purpose string to enum */
|
||||
role_purpose_t role_purpose_from_str(const char *s);
|
||||
@@ -163,6 +175,11 @@ const char *role_curve_to_str(role_curve_t c);
|
||||
#define SELECTOR_ERR_AMBIGUOUS -1 /* multiple selectors specified */
|
||||
#define SELECTOR_ERR_NOT_FOUND -2 /* no matching role in table */
|
||||
#define SELECTOR_ERR_NO_DEFAULT -3 /* no selector given and no "main" role exists */
|
||||
#define SELECTOR_ERR_PATH_MISMATCH -4 /* role_path doesn't match role's template */
|
||||
#define SELECTOR_ERR_NOSTR_INDEX_DEPRECATED -5 /* nostr_index is deprecated */
|
||||
#define SELECTOR_ERR_INDEX_DEPRECATED -6 /* index is deprecated for nostr verbs */
|
||||
#define SELECTOR_ERR_ROLE_REQUIRED -7 /* --role is required when using --path */
|
||||
#define SELECTOR_ERR_PATH_REQUIRED -8 /* --path is required for roles with variable path templates *//
|
||||
|
||||
/* Parsed selector from a request's options object */
|
||||
typedef struct {
|
||||
@@ -174,6 +191,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) */
|
||||
@@ -321,6 +340,16 @@ int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
/*
|
||||
* Role-aware policy check: if the role has requires_approval=0 (role-as-password),
|
||||
* returns POLICY_ALLOW immediately without checking policy entries.
|
||||
* Otherwise falls through to policy_check().
|
||||
*/
|
||||
int policy_check_with_role(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
const role_entry_t *role,
|
||||
policy_source_t *out_source);
|
||||
|
||||
/* Check whether caller_id is allowed to invoke `verb` with the given
|
||||
* algorithm and index (algorithm-based policy). Returns POLICY_ALLOW,
|
||||
* POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH. */
|
||||
@@ -1260,3 +1289,27 @@ int policy_check_algorithm(const policy_table_t *table, const char *caller_id,
|
||||
|
||||
return POLICY_NO_MATCH;
|
||||
}
|
||||
|
||||
int policy_check_with_role(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
const role_entry_t *role,
|
||||
policy_source_t *out_source) {
|
||||
if (out_source != NULL) {
|
||||
*out_source = POLICY_SOURCE_DEFAULT;
|
||||
}
|
||||
|
||||
if (table == NULL || caller_id == NULL || verb == NULL || role_name == NULL || purpose == NULL) {
|
||||
return POLICY_NO_MATCH;
|
||||
}
|
||||
|
||||
/* Role-as-password: if the role has requires_approval=0, authorize immediately */
|
||||
if (role != NULL && role->requires_approval == 0) {
|
||||
if (out_source != NULL) {
|
||||
*out_source = POLICY_SOURCE_DEFAULT;
|
||||
}
|
||||
return POLICY_ALLOW;
|
||||
}
|
||||
|
||||
/* Otherwise, fall through to the standard policy check */
|
||||
return policy_check(table, caller_id, verb, role_name, purpose, out_source);
|
||||
}
|
||||
|
||||
+13
-1
@@ -126,6 +126,12 @@ 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 */
|
||||
int requires_approval; /* 0 = role-as-password (no prompt), 1 = require interactive approval */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -167,7 +173,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 +197,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,12 @@ 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 */
|
||||
int requires_approval; /* 0 = role-as-password (no prompt), 1 = require interactive approval */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -142,6 +148,31 @@ role_entry_t *role_table_find_by_path(role_table_t *table, const char *path);
|
||||
/* Get the default role (named "main"). Returns pointer or NULL if no "main" role. */
|
||||
role_entry_t *role_table_get_default(role_table_t *table);
|
||||
|
||||
/*
|
||||
* Check whether a concrete derivation path matches a role's path template.
|
||||
* The template may contain a "%d" placeholder (with optional "'" hardened marker).
|
||||
* Returns 1 if the path matches the template, 0 if not.
|
||||
* For fixed paths (no %d), does an exact string comparison.
|
||||
*/
|
||||
int role_path_matches_template(const char *path, const char *template);
|
||||
|
||||
/*
|
||||
* Extract the numeric index from a concrete derivation path that matches
|
||||
* a role's path template (containing a single "%d" placeholder).
|
||||
* Returns the extracted index on success, or -1 if the path does not match
|
||||
* the template or no %d placeholder exists in the template.
|
||||
* For fixed paths (no %d), returns -1 (no variable index).
|
||||
*/
|
||||
int role_path_extract_index(const char *path, const char *template);
|
||||
|
||||
/*
|
||||
* Check whether a concrete derivation path matches a role's path template
|
||||
* AND the extracted index falls within the role's allowed range/set.
|
||||
* Returns 1 if the path matches and the index is allowed, 0 otherwise.
|
||||
* For fixed paths (no %d), this is equivalent to role_path_matches_template().
|
||||
*/
|
||||
int role_path_matches_with_range(const char *path, const role_entry_t *role);
|
||||
|
||||
/* Parse purpose string to enum */
|
||||
role_purpose_t role_purpose_from_str(const char *s);
|
||||
|
||||
@@ -166,6 +197,11 @@ int role_table_register_nostr_index(role_table_t *table, int nostr_index);
|
||||
#define SELECTOR_ERR_AMBIGUOUS -1 /* multiple selectors specified */
|
||||
#define SELECTOR_ERR_NOT_FOUND -2 /* no matching role in table */
|
||||
#define SELECTOR_ERR_NO_DEFAULT -3 /* no selector given and no "main" role exists */
|
||||
#define SELECTOR_ERR_PATH_MISMATCH -4 /* role_path doesn't match role's template */
|
||||
#define SELECTOR_ERR_NOSTR_INDEX_DEPRECATED -5 /* nostr_index is deprecated */
|
||||
#define SELECTOR_ERR_INDEX_DEPRECATED -6 /* index is deprecated for nostr verbs */
|
||||
#define SELECTOR_ERR_ROLE_REQUIRED -7 /* --role is required when using --path */
|
||||
#define SELECTOR_ERR_PATH_REQUIRED -8 /* --path is required for roles with variable path templates *//
|
||||
|
||||
/* Parsed selector from a request's options object */
|
||||
typedef struct {
|
||||
@@ -177,6 +213,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) */
|
||||
@@ -321,9 +359,12 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||
*/
|
||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
|
||||
|
||||
/* Check whether caller_id is allowed to invoke `verb` with the given
|
||||
* algorithm and index (algorithm-based policy). Returns POLICY_ALLOW,
|
||||
* POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH. */
|
||||
@@ -709,6 +750,7 @@ int socket_name_random(char *out, size_t out_len);
|
||||
/* NSIGNER_HEADERLESS_DECLS_END */
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static int str_eq(const char *a, const char *b) {
|
||||
@@ -832,6 +874,241 @@ 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);
|
||||
}
|
||||
|
||||
/*
|
||||
* Check whether a concrete derivation path matches a role's path template.
|
||||
* The template may contain a "%d" placeholder (with optional "'" hardened marker).
|
||||
* Returns 1 if the path matches the template, 0 if not.
|
||||
* For fixed paths (no %d), does an exact string comparison.
|
||||
*
|
||||
* Examples:
|
||||
* template "m/44'/1237'/0'/0/0" matches path "m/44'/1237'/0'/0/0" only
|
||||
* template "m/44'/1237'/%d'/0/0" matches "m/44'/1237'/5'/0/0" for any %d value
|
||||
* template "m/44'/1237'/%d/0/0" matches "m/44'/1237'/5/0/0" (unhardened)
|
||||
*/
|
||||
int role_path_matches_template(const char *path, const char *template) {
|
||||
const char *p = path;
|
||||
const char *t = template;
|
||||
|
||||
if (path == NULL || template == NULL) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
while (*t != '\0' && *p != '\0') {
|
||||
if (*t == '%' && *(t + 1) == 'd') {
|
||||
/* %d placeholder — skip one path segment in the path */
|
||||
t += 2; /* skip "%d" */
|
||||
/* Skip optional hardened marker after %d */
|
||||
if (*t == '\'' || *t == 'h' || *t == 'H') {
|
||||
t++;
|
||||
}
|
||||
/* Skip the corresponding segment in the path (digits, possibly with ' or h) */
|
||||
if (*p == '/') {
|
||||
/* Path has a slash where we expect a segment — mismatch */
|
||||
return 0;
|
||||
}
|
||||
while (*p != '\0' && *p != '/') {
|
||||
p++;
|
||||
}
|
||||
/* If template has more after %d, it should start with '/' */
|
||||
if (*t == '/' && *p == '/') {
|
||||
t++;
|
||||
p++;
|
||||
} else if (*t == '\0' && *p == '\0') {
|
||||
/* Both at end — exact match */
|
||||
return 1;
|
||||
} else if (*t == '\0' && *p == '/') {
|
||||
/* Template ended but path has trailing slash — no match */
|
||||
return 0;
|
||||
} else if (*t == '/' && *p == '\0') {
|
||||
/* Path ended but template has more — no match */
|
||||
return 0;
|
||||
}
|
||||
/* If one has a separator and the other doesn't, let the loop continue */
|
||||
} else if (*t == *p) {
|
||||
t++;
|
||||
p++;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Both should be at the end */
|
||||
return (*t == '\0' && *p == '\0') ? 1 : 0;
|
||||
}
|
||||
|
||||
int role_path_extract_index(const char *path, const char *template) {
|
||||
const char *p = path;
|
||||
const char *t = template;
|
||||
const char *seg_start;
|
||||
char seg_buf[32];
|
||||
size_t seg_len;
|
||||
long val;
|
||||
char *endp;
|
||||
|
||||
if (path == NULL || template == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* If template has no %d, there is no variable index to extract */
|
||||
if (strstr(template, "%d") == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
while (*t != '\0' && *p != '\0') {
|
||||
if (*t == '%' && *(t + 1) == 'd') {
|
||||
/* %d placeholder — extract the corresponding path segment */
|
||||
t += 2; /* skip "%d" */
|
||||
/* Skip optional hardened marker after %d in template */
|
||||
if (*t == '\'' || *t == 'h' || *t == 'H') {
|
||||
t++;
|
||||
}
|
||||
/* Extract the segment from the path (up to next '/' or end) */
|
||||
if (*p == '/') {
|
||||
return -1; /* path has a slash where a segment is expected */
|
||||
}
|
||||
seg_start = p;
|
||||
while (*p != '\0' && *p != '/') {
|
||||
p++;
|
||||
}
|
||||
seg_len = (size_t)(p - seg_start);
|
||||
if (seg_len == 0 || seg_len >= sizeof(seg_buf)) {
|
||||
return -1;
|
||||
}
|
||||
memcpy(seg_buf, seg_start, seg_len);
|
||||
seg_buf[seg_len] = '\0';
|
||||
/* Strip optional trailing hardened marker from the segment */
|
||||
if (seg_len > 0 &&
|
||||
(seg_buf[seg_len - 1] == '\'' || seg_buf[seg_len - 1] == 'h' ||
|
||||
seg_buf[seg_len - 1] == 'H')) {
|
||||
seg_buf[seg_len - 1] = '\0';
|
||||
}
|
||||
endp = NULL;
|
||||
val = strtol(seg_buf, &endp, 10);
|
||||
if (*endp != '\0' || val < 0) {
|
||||
return -1;
|
||||
}
|
||||
return (int)val;
|
||||
} else if (*t == *p) {
|
||||
t++;
|
||||
p++;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
int role_path_matches_with_range(const char *path, const role_entry_t *role) {
|
||||
int index;
|
||||
|
||||
if (path == NULL || role == NULL) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Fixed path (no %d) — just check structural match */
|
||||
if (strstr(role->role_path, "%d") == NULL) {
|
||||
return role_path_matches_template(path, role->role_path);
|
||||
}
|
||||
|
||||
/* Template path — check structural match first */
|
||||
if (!role_path_matches_template(path, role->role_path)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Extract the index and check it against the allowed range/set */
|
||||
index = role_path_extract_index(path, role->role_path);
|
||||
if (index < 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (role->path_allowed_count > 0) {
|
||||
/* Set form: check if index is in the allowed set */
|
||||
int j;
|
||||
for (j = 0; j < role->path_allowed_count; j++) {
|
||||
if (role->path_allowed_indices[j] == index) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Range form: check lo..hi */
|
||||
if (role->path_range_lo < 0 || role->path_range_hi < 0) {
|
||||
/* No range configured — deny (fail-closed) */
|
||||
return 0;
|
||||
}
|
||||
return (index >= role->path_range_lo && index <= role->path_range_hi) ? 1 : 0;
|
||||
}
|
||||
|
||||
role_purpose_t role_purpose_from_str(const char *s) {
|
||||
if (str_eq(s, "nostr")) {
|
||||
return PURPOSE_NOSTR;
|
||||
|
||||
@@ -118,6 +118,12 @@ 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 */
|
||||
int requires_approval; /* 0 = role-as-password (no prompt), 1 = require interactive approval */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -176,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) */
|
||||
|
||||
+81
-14
@@ -116,6 +116,12 @@ 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 */
|
||||
int requires_approval; /* 0 = role-as-password (no prompt), 1 = require interactive approval */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -142,6 +148,17 @@ role_entry_t *role_table_find_by_path(role_table_t *table, const char *path);
|
||||
/* Get the default role (named "main"). Returns pointer or NULL if no "main" role. */
|
||||
role_entry_t *role_table_get_default(role_table_t *table);
|
||||
|
||||
/*
|
||||
* Check whether a concrete derivation path matches a role's path template.
|
||||
* The template may contain a "%d" placeholder (with optional "'" hardened marker).
|
||||
* Returns 1 if the path matches the template, 0 if not.
|
||||
*/
|
||||
int role_path_matches_template(const char *path, const char *template);
|
||||
|
||||
/* Check whether a concrete path matches a role's template AND the extracted
|
||||
* index is within the role's allowed range/set. Returns 1 if allowed, 0 not. */
|
||||
int role_path_matches_with_range(const char *path, const role_entry_t *role);
|
||||
|
||||
/* Parse purpose string to enum */
|
||||
role_purpose_t role_purpose_from_str(const char *s);
|
||||
|
||||
@@ -163,6 +180,11 @@ const char *role_curve_to_str(role_curve_t c);
|
||||
#define SELECTOR_ERR_AMBIGUOUS -1 /* multiple selectors specified */
|
||||
#define SELECTOR_ERR_NOT_FOUND -2 /* no matching role in table */
|
||||
#define SELECTOR_ERR_NO_DEFAULT -3 /* no selector given and no "main" role exists */
|
||||
#define SELECTOR_ERR_PATH_MISMATCH -4 /* role_path doesn't match role's template */
|
||||
#define SELECTOR_ERR_NOSTR_INDEX_DEPRECATED -5 /* nostr_index is deprecated */
|
||||
#define SELECTOR_ERR_INDEX_DEPRECATED -6 /* index is deprecated for nostr verbs */
|
||||
#define SELECTOR_ERR_ROLE_REQUIRED -7 /* --role is required when using --path */
|
||||
#define SELECTOR_ERR_PATH_REQUIRED -8 /* --path is required for roles with variable path templates */
|
||||
|
||||
/* Parsed selector from a request's options object */
|
||||
typedef struct {
|
||||
@@ -174,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) */
|
||||
@@ -318,9 +342,12 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||
*/
|
||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
|
||||
|
||||
/* Check whether caller_id is allowed to invoke `verb` with the given
|
||||
* algorithm and index (algorithm-based policy). Returns POLICY_ALLOW,
|
||||
* POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH. */
|
||||
@@ -716,7 +743,6 @@ void selector_request_init(selector_request_t *req) {
|
||||
}
|
||||
|
||||
int selector_resolve(const selector_request_t *req, role_table_t *table, role_entry_t **out) {
|
||||
int selector_count = 0;
|
||||
role_entry_t *match = NULL;
|
||||
|
||||
if (out != NULL) {
|
||||
@@ -727,31 +753,62 @@ int selector_resolve(const selector_request_t *req, role_table_t *table, role_en
|
||||
return SELECTOR_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
selector_count += req->has_role ? 1 : 0;
|
||||
selector_count += req->has_nostr_index ? 1 : 0;
|
||||
selector_count += req->has_role_path ? 1 : 0;
|
||||
/* ---- Deprecated selectors: reject with clear error messages ---- */
|
||||
|
||||
if (selector_count > 1) {
|
||||
return SELECTOR_ERR_AMBIGUOUS;
|
||||
/* nostr_index is deprecated */
|
||||
if (req->has_nostr_index) {
|
||||
return SELECTOR_ERR_NOSTR_INDEX_DEPRECATED;
|
||||
}
|
||||
|
||||
if (selector_count == 1) {
|
||||
if (req->has_role) {
|
||||
match = role_table_find_by_name(table, req->role_name);
|
||||
} else if (req->has_nostr_index) {
|
||||
match = role_table_find_by_nostr_index(table, req->nostr_index);
|
||||
} else if (req->has_role_path) {
|
||||
match = role_table_find_by_path(table, req->role_path);
|
||||
}
|
||||
/* index without role is deprecated for nostr verbs (handled in dispatcher) */
|
||||
if (req->has_index && !req->has_role) {
|
||||
return SELECTOR_ERR_INDEX_DEPRECATED;
|
||||
}
|
||||
|
||||
/* role_path without role is not allowed */
|
||||
if (req->has_role_path && !req->has_role) {
|
||||
return SELECTOR_ERR_ROLE_REQUIRED;
|
||||
}
|
||||
|
||||
/* ---- New model: role + role_path combined ---- */
|
||||
|
||||
if (req->has_role && req->has_role_path) {
|
||||
/* Combined selector: look up role by name, verify path matches template */
|
||||
match = role_table_find_by_name(table, req->role_name);
|
||||
if (match == NULL) {
|
||||
return SELECTOR_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
/* Verify the requested path matches the role's template AND that the
|
||||
* extracted index falls within the role's allowed range/set. This
|
||||
* rejects paths like m/44'/1237'/0'/0/0 against a template
|
||||
* m/44'/1237'/%d'/0/0 with range 1-100. */
|
||||
if (!role_path_matches_with_range(req->role_path, match)) {
|
||||
return SELECTOR_ERR_PATH_MISMATCH;
|
||||
}
|
||||
|
||||
*out = match;
|
||||
return SELECTOR_OK;
|
||||
}
|
||||
|
||||
if (req->has_role && !req->has_role_path) {
|
||||
/* Role specified without path — check if role has a fixed path (no %d) */
|
||||
match = role_table_find_by_name(table, req->role_name);
|
||||
if (match == NULL) {
|
||||
return SELECTOR_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
/* If the role has a fixed path (no variable segments), use it */
|
||||
if (strstr(match->role_path, "%d") == NULL) {
|
||||
*out = match;
|
||||
return SELECTOR_OK;
|
||||
}
|
||||
|
||||
/* Role has variable path template — path is required */
|
||||
return SELECTOR_ERR_PATH_REQUIRED;
|
||||
}
|
||||
|
||||
/* No selectors at all — try default role */
|
||||
match = role_table_get_default(table);
|
||||
if (match == NULL) {
|
||||
return SELECTOR_ERR_NO_DEFAULT;
|
||||
@@ -771,6 +828,16 @@ const char *selector_strerror(int err) {
|
||||
return "role_not_found";
|
||||
case SELECTOR_ERR_NO_DEFAULT:
|
||||
return "no_default_role";
|
||||
case SELECTOR_ERR_PATH_MISMATCH:
|
||||
return "path_mismatch";
|
||||
case SELECTOR_ERR_NOSTR_INDEX_DEPRECATED:
|
||||
return "nostr_index is deprecated — use --role main --path m/44'/1237'/N'/0/0 instead";
|
||||
case SELECTOR_ERR_INDEX_DEPRECATED:
|
||||
return "index is deprecated for nostr verbs — use --path with the full path instead";
|
||||
case SELECTOR_ERR_ROLE_REQUIRED:
|
||||
return "--role is required when using --path";
|
||||
case SELECTOR_ERR_PATH_REQUIRED:
|
||||
return "--path is required for roles with variable path templates";
|
||||
default:
|
||||
return "unknown_selector_error";
|
||||
}
|
||||
|
||||
+572
-23
@@ -119,6 +119,12 @@ 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 */
|
||||
int requires_approval; /* 0 = role-as-password (no prompt), 1 = require interactive approval */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -144,6 +150,20 @@ role_entry_t *role_table_find_by_path(role_table_t *table, const char *path);
|
||||
|
||||
/* Get the default role (named "main"). Returns pointer or NULL if no "main" role. */
|
||||
role_entry_t *role_table_get_default(role_table_t *table);
|
||||
/*
|
||||
* Check whether a concrete derivation path matches a role's path template.
|
||||
* The template may contain a "%d" placeholder (with optional "'" hardened marker).
|
||||
* Returns 1 if the path matches the template, 0 if not.
|
||||
*/
|
||||
int role_path_matches_template(const char *path, const char *template);
|
||||
|
||||
/* Extract the numeric index from a concrete path matching a %d template.
|
||||
* Returns the index, or -1 if no %d or no match. */
|
||||
int role_path_extract_index(const char *path, const char *template);
|
||||
|
||||
/* Check whether a concrete path matches a role's template AND the extracted
|
||||
* index is within the role's allowed range/set. Returns 1 if allowed, 0 not. */
|
||||
int role_path_matches_with_range(const char *path, const role_entry_t *role);
|
||||
|
||||
/* Parse purpose string to enum */
|
||||
role_purpose_t role_purpose_from_str(const char *s);
|
||||
@@ -160,7 +180,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 */
|
||||
|
||||
|
||||
@@ -169,6 +193,11 @@ int role_table_register_nostr_index(role_table_t *table, int nostr_index);
|
||||
#define SELECTOR_ERR_AMBIGUOUS -1 /* multiple selectors specified */
|
||||
#define SELECTOR_ERR_NOT_FOUND -2 /* no matching role in table */
|
||||
#define SELECTOR_ERR_NO_DEFAULT -3 /* no selector given and no "main" role exists */
|
||||
#define SELECTOR_ERR_PATH_MISMATCH -4 /* role_path doesn't match role's template */
|
||||
#define SELECTOR_ERR_NOSTR_INDEX_DEPRECATED -5 /* nostr_index is deprecated */
|
||||
#define SELECTOR_ERR_INDEX_DEPRECATED -6 /* index is deprecated for nostr verbs */
|
||||
#define SELECTOR_ERR_ROLE_REQUIRED -7 /* --role is required when using --path */
|
||||
#define SELECTOR_ERR_PATH_REQUIRED -8 /* --path is required for roles with variable path templates */
|
||||
|
||||
/* Parsed selector from a request's options object */
|
||||
typedef struct {
|
||||
@@ -180,6 +209,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) */
|
||||
@@ -332,6 +363,16 @@ int policy_check(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
policy_source_t *out_source);
|
||||
|
||||
/*
|
||||
* Role-aware policy check: if the role has requires_approval=0 (role-as-password),
|
||||
* returns POLICY_ALLOW immediately without checking policy entries.
|
||||
* Otherwise falls through to policy_check().
|
||||
*/
|
||||
int policy_check_with_role(const policy_table_t *table, const char *caller_id,
|
||||
const char *verb, const char *role_name, const char *purpose,
|
||||
const role_entry_t *role,
|
||||
policy_source_t *out_source);
|
||||
|
||||
/* Check whether caller_id is allowed to invoke `verb` with the given
|
||||
* algorithm and index (algorithm-based policy). Returns POLICY_ALLOW,
|
||||
* POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH. */
|
||||
@@ -677,6 +718,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 +751,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 +1361,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 +1400,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 +1495,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 +2183,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 +2404,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,9 +2416,89 @@ 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_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.
|
||||
* For role+role_path requests, selector_resolve already verified
|
||||
* the path matches the template exactly. For role-only requests
|
||||
* on a fixed-path role, the role's own path is used. */
|
||||
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. Two sub-cases:
|
||||
* (a) role + role_path: the client supplied a concrete path.
|
||||
* selector_resolve already verified it matches the template
|
||||
* AND the extracted index is within the allowed range/set.
|
||||
* We use the client's path directly for derivation.
|
||||
* (b) role only (no role_path): use --index or path_default_index,
|
||||
* range-check it, and format the concrete path. */
|
||||
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 (selector_req.has_role_path) {
|
||||
/* Case (a): client supplied a concrete path that was already
|
||||
* validated by selector_resolve. Use it directly. */
|
||||
snprintf(concrete_path, sizeof(concrete_path),
|
||||
"%s", selector_req.role_path);
|
||||
if (!role->derived) {
|
||||
pending_derivation = 1;
|
||||
}
|
||||
} else {
|
||||
/* Case (b): role only — resolve index from --index or default */
|
||||
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_PATH_MISMATCH) {
|
||||
/* role + role_path supplied, but the path doesn't match the
|
||||
* role's template or the index is outside the allowed range.
|
||||
* Reject — do NOT auto-create a new pathrole entry. */
|
||||
hard_selector_error = -200; /* path_not_allowed sentinel */
|
||||
} else if (selector_rc == SELECTOR_ERR_AMBIGUOUS ||
|
||||
selector_rc == SELECTOR_ERR_NOT_FOUND ||
|
||||
selector_rc == SELECTOR_ERR_NO_DEFAULT) {
|
||||
selector_rc == SELECTOR_ERR_NO_DEFAULT ||
|
||||
selector_rc == SELECTOR_ERR_ROLE_REQUIRED ||
|
||||
selector_rc == SELECTOR_ERR_PATH_REQUIRED) {
|
||||
hard_selector_error = selector_rc;
|
||||
}
|
||||
}
|
||||
@@ -2074,9 +2531,24 @@ 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 == SELECTOR_ERR_ROLE_REQUIRED) {
|
||||
response = strdup("{\"id\":\"null\",\"error\":{\"code\":2006,\"message\":\"role_required\"}}");
|
||||
pchk = POLICY_DENY;
|
||||
} else if (hard_selector_error == SELECTOR_ERR_PATH_REQUIRED) {
|
||||
response = strdup("{\"id\":\"null\",\"error\":{\"code\":2007,\"message\":\"path_required\"}}");
|
||||
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);
|
||||
/* Normal path: run policy_check_with_role (role-as-password if requires_approval=0) */
|
||||
pchk = policy_check_with_role(ctx->policy, caller.caller_id, method, role_name, purpose, role, &policy_src);
|
||||
}
|
||||
/* else: hard_selector_error == -100 (whitelist deny) — keep pchk=POLICY_DENY */
|
||||
|
||||
@@ -2110,8 +2582,61 @@ 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 (role != NULL && role->selector_type == SELECTOR_ROLE_PATH &&
|
||||
strstr(role->role_path, "%d") == NULL) {
|
||||
/* Fixed-path named role (found by role name, or by role+role_path
|
||||
* which selector_resolve verified matches the fixed template) —
|
||||
* derive directly using the role's own path. */
|
||||
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);
|
||||
@@ -2157,14 +2682,26 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
|
||||
response = NULL;
|
||||
verdict = "ALLOWED";
|
||||
source_label = "async-mine";
|
||||
(void)snprintf(activity,
|
||||
sizeof(activity),
|
||||
"%s %s(%s) %s:%s",
|
||||
caller.caller_id,
|
||||
method,
|
||||
role_name,
|
||||
"ALLOWED",
|
||||
"async-mine");
|
||||
if (concrete_path[0] != '\0') {
|
||||
(void)snprintf(activity,
|
||||
sizeof(activity),
|
||||
"%s %s(%s,%s) %s:%s",
|
||||
caller.caller_id,
|
||||
method,
|
||||
role_name,
|
||||
concrete_path,
|
||||
"ALLOWED",
|
||||
"async-mine");
|
||||
} else {
|
||||
(void)snprintf(activity,
|
||||
sizeof(activity),
|
||||
"%s %s(%s) %s:%s",
|
||||
caller.caller_id,
|
||||
method,
|
||||
role_name,
|
||||
"ALLOWED",
|
||||
"async-mine");
|
||||
}
|
||||
if (cb != NULL) {
|
||||
cb(activity, cb_data);
|
||||
}
|
||||
@@ -2211,14 +2748,26 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
|
||||
source_label = "no-match";
|
||||
}
|
||||
|
||||
(void)snprintf(activity,
|
||||
sizeof(activity),
|
||||
"%s %s(%s) %s:%s",
|
||||
caller.caller_id,
|
||||
method,
|
||||
role_name,
|
||||
verdict,
|
||||
source_label);
|
||||
if (concrete_path[0] != '\0') {
|
||||
(void)snprintf(activity,
|
||||
sizeof(activity),
|
||||
"%s %s(%s,%s) %s:%s",
|
||||
caller.caller_id,
|
||||
method,
|
||||
role_name,
|
||||
concrete_path,
|
||||
verdict,
|
||||
source_label);
|
||||
} else {
|
||||
(void)snprintf(activity,
|
||||
sizeof(activity),
|
||||
"%s %s(%s) %s:%s",
|
||||
caller.caller_id,
|
||||
method,
|
||||
role_name,
|
||||
verdict,
|
||||
source_label);
|
||||
}
|
||||
|
||||
if (cb != NULL) {
|
||||
cb(activity, cb_data);
|
||||
|
||||
@@ -118,6 +118,12 @@ 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 */
|
||||
int requires_approval; /* 0 = role-as-password (no prompt), 1 = require interactive approval */
|
||||
} role_entry_t;
|
||||
|
||||
/* The role table */
|
||||
@@ -176,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) */
|
||||
|
||||
@@ -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) */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+821
@@ -0,0 +1,821 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# test_n_signer_client.sh — Integration test suite for n_signer_client CLI.
|
||||
#
|
||||
# Spawns a dedicated nsigner server with a known test mnemonic, runs the
|
||||
# full verb surface through build/n_signer_client, and tears down.
|
||||
#
|
||||
# Usage:
|
||||
# make test-n-signer-client
|
||||
# # or directly:
|
||||
# bash tests/test_n_signer_client.sh
|
||||
#
|
||||
# Prerequisites:
|
||||
# - make dev clients (or at least build/nsigner and build/nsigner_client)
|
||||
# - jq (optional, falls back to python3/grep)
|
||||
#
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
BUILD_DIR="$PROJECT_DIR/build"
|
||||
CLIENT="$BUILD_DIR/nsigner_client"
|
||||
SERVER="$BUILD_DIR/nsigner"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
MNEMONIC="abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
SOCKET_NAME="nsigner_test_client_$$"
|
||||
SERVER_PID=""
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
SKIP_COUNT=0
|
||||
|
||||
# Test event JSON (kind 1, deterministic created_at)
|
||||
EVENT_JSON='{"kind":1,"content":"hello from test","tags":[],"created_at":1700000000}'
|
||||
|
||||
# A known secp256k1 public key for NIP-04/44 tests (64 hex chars).
|
||||
# We'll derive this from the signer at runtime, but we also need a peer key.
|
||||
# Use a well-known test vector pubkey (any valid 64-hex secp256k1 pubkey).
|
||||
# This is the pubkey for "abandon..." mnemonic, role=main path=m/44'/1237'/0'/0/0,
|
||||
# but we'll discover it dynamically. For peer operations we can use the same
|
||||
# pubkey (encrypt to self).
|
||||
PEER_PUBKEY="" # filled at runtime
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool detection
|
||||
# ---------------------------------------------------------------------------
|
||||
HAS_JQ=0
|
||||
HAS_PYTHON=0
|
||||
if command -v jq &>/dev/null; then
|
||||
HAS_JQ=1
|
||||
elif command -v python3 &>/dev/null; then
|
||||
HAS_PYTHON=1
|
||||
fi
|
||||
|
||||
json_get() {
|
||||
# Usage: json_get <key> <json-string>
|
||||
# Returns the string value of <key> from the JSON.
|
||||
local key="$1"
|
||||
local json="$2"
|
||||
if [ "$HAS_JQ" -eq 1 ]; then
|
||||
echo "$json" | jq -r ".$key // empty"
|
||||
elif [ "$HAS_PYTHON" -eq 1 ]; then
|
||||
python3 -c "import sys,json; d=json.loads('$json'); print(d.get('$key',''))"
|
||||
else
|
||||
# Fallback: grep for "key":"value" pattern
|
||||
echo "$json" | grep -o "\"$key\":\"[^\"]*\"" | sed "s/\"$key\":\"//;s/\"//" | head -1
|
||||
fi
|
||||
}
|
||||
|
||||
json_has_key() {
|
||||
local key="$1"
|
||||
local json="$2"
|
||||
if [ "$HAS_JQ" -eq 1 ]; then
|
||||
echo "$json" | jq -e ". | has(\"$key\")" &>/dev/null
|
||||
elif [ "$HAS_PYTHON" -eq 1 ]; then
|
||||
python3 -c "import sys,json; d=json.loads('$json'); sys.exit(0 if '$key' in d else 1)"
|
||||
else
|
||||
echo "$json" | grep -q "\"$key\""
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test harness
|
||||
# ---------------------------------------------------------------------------
|
||||
print_result() {
|
||||
local name="$1"
|
||||
local status="$2"
|
||||
local detail="${3:-}"
|
||||
if [ "$status" = "PASS" ]; then
|
||||
echo " PASS $name"
|
||||
elif [ "$status" = "SKIP" ]; then
|
||||
echo " SKIP $name${detail:+: $detail}"
|
||||
else
|
||||
echo " FAIL $name${detail:+: $detail}"
|
||||
fi
|
||||
}
|
||||
|
||||
pass() {
|
||||
local name="$1"
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
print_result "$name" "PASS"
|
||||
}
|
||||
|
||||
fail() {
|
||||
local name="$1"
|
||||
local detail="${2:-}"
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
print_result "$name" "FAIL" "$detail"
|
||||
}
|
||||
|
||||
skip() {
|
||||
local name="$1"
|
||||
local reason="${2:-}"
|
||||
SKIP_COUNT=$((SKIP_COUNT + 1))
|
||||
print_result "$name" "SKIP" "$reason"
|
||||
}
|
||||
|
||||
# Run a command, check exit code, and optionally grep stdout.
|
||||
# Usage: check_test <test-name> <expected-exit> [<grep-pattern>...]
|
||||
check_test() {
|
||||
local name="$1"
|
||||
local expected_exit="$2"
|
||||
shift 2
|
||||
local patterns=("$@")
|
||||
|
||||
# Build the command from remaining args (everything after patterns)
|
||||
# We need to be careful: the caller passes the command as the last arguments
|
||||
# But we already consumed name and expected_exit. The remaining args are
|
||||
# patterns + command. We need to separate them.
|
||||
# Actually, let's use a different approach: capture patterns and command separately.
|
||||
# We'll use a sentinel approach: patterns end before '--'
|
||||
# But that's awkward. Let's just use a simpler helper.
|
||||
|
||||
# For now, we'll use a simpler inline approach in each test.
|
||||
:
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server management
|
||||
# ---------------------------------------------------------------------------
|
||||
start_server() {
|
||||
echo "Starting nsigner server (socket: @$SOCKET_NAME)..."
|
||||
|
||||
# Build the server if not present
|
||||
if [ ! -x "$SERVER" ]; then
|
||||
echo "Building nsigner server..."
|
||||
(cd "$PROJECT_DIR" && make dev) || {
|
||||
echo "ERROR: failed to build nsigner server"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
# Build the client if not present
|
||||
if [ ! -x "$CLIENT" ]; then
|
||||
echo "Building n_signer_client..."
|
||||
(cd "$PROJECT_DIR" && make clients) || {
|
||||
echo "ERROR: failed to build n_signer_client"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
# Start the server with --mnemonic-stdin and --allow-all
|
||||
# Set test env vars so non-interactive prompts auto-allow
|
||||
# Note: export is needed so the backgrounded server process inherits it
|
||||
export NSIGNER_TEST_NONINTERACTIVE_PROMPT=allow
|
||||
echo "$MNEMONIC" | "$SERVER" \
|
||||
--socket-name "$SOCKET_NAME" \
|
||||
--allow-all \
|
||||
--listen unix \
|
||||
--mnemonic-stdin &
|
||||
SERVER_PID=$!
|
||||
|
||||
# Wait for the server to be ready by polling /proc/net/unix
|
||||
local max_attempts=50
|
||||
local attempt=0
|
||||
while [ $attempt -lt $max_attempts ]; do
|
||||
if grep -q "$SOCKET_NAME" /proc/net/unix 2>/dev/null; then
|
||||
echo "Server ready (PID $SERVER_PID, socket @$SOCKET_NAME)"
|
||||
return 0
|
||||
fi
|
||||
sleep 0.1
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
# Fallback: try connecting with the client
|
||||
if $CLIENT --socket-name "$SOCKET_NAME" --timeout 2000 get-info &>/dev/null; then
|
||||
echo "Server ready (PID $SERVER_PID, socket @$SOCKET_NAME)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "ERROR: server did not become ready within ${max_attempts} attempts"
|
||||
kill "$SERVER_PID" 2>/dev/null
|
||||
SERVER_PID=""
|
||||
return 1
|
||||
}
|
||||
|
||||
stop_server() {
|
||||
if [ -n "$SERVER_PID" ]; then
|
||||
echo "Stopping server (PID $SERVER_PID)..."
|
||||
kill "$SERVER_PID" 2>/dev/null
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
SERVER_PID=""
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
stop_server
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
test_get_info() {
|
||||
echo ""
|
||||
echo "=== Basic connectivity ==="
|
||||
|
||||
# get-info
|
||||
local name="get-info returns server metadata"
|
||||
local output
|
||||
output=$($CLIENT --socket-name "$SOCKET_NAME" get-info 2>/dev/null) || {
|
||||
fail "$name" "exit code $?"
|
||||
return
|
||||
}
|
||||
# The server returns the result as a JSON string with escaped quotes.
|
||||
# Grep for field names without surrounding quotes to handle both cases.
|
||||
if printf '%s\n' "$output" | grep -q 'name' && \
|
||||
printf '%s\n' "$output" | grep -q 'verbs' && \
|
||||
printf '%s\n' "$output" | grep -q 'algorithms'; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "missing expected fields: $output"
|
||||
fi
|
||||
}
|
||||
|
||||
test_get_public_key_nostr() {
|
||||
local name="get-public-key (nostr, default role) returns 64 hex chars"
|
||||
local output
|
||||
output=$($CLIENT --socket-name "$SOCKET_NAME" --role main --path "m/44'/1237'/0'/0/0" get-public-key 2>/dev/null) || {
|
||||
fail "$name" "exit code $?"
|
||||
return
|
||||
}
|
||||
# Should be 64 hex characters
|
||||
if printf '%s\n' "$output" | grep -qE '^[0-9a-f]{64}$'; then
|
||||
pass "$name"
|
||||
PEER_PUBKEY="$output"
|
||||
else
|
||||
fail "$name" "expected 64 hex chars, got: $output"
|
||||
fi
|
||||
|
||||
name="get-public-key --role main --path \"m/44'/1237'/0'/0/0\" returns 64 hex chars"
|
||||
output=$($CLIENT --socket-name "$SOCKET_NAME" --role main --path "m/44'/1237'/0'/0/0" get-public-key 2>/dev/null) || {
|
||||
fail "$name" "exit code $?"
|
||||
return
|
||||
}
|
||||
if printf '%s\n' "$output" | grep -qE '^[0-9a-f]{64}$'; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "expected 64 hex chars, got: $output"
|
||||
fi
|
||||
|
||||
name="get-public-key --role main --path \"m/44'/1237'/0'/0/0\" --format structured returns JSON"
|
||||
output=$($CLIENT --socket-name "$SOCKET_NAME" --role main --path "m/44'/1237'/0'/0/0" --format structured get-public-key 2>/dev/null) || {
|
||||
fail "$name" "exit code $?"
|
||||
return
|
||||
}
|
||||
if printf '%s\n' "$output" | grep -q '"algorithm"' && \
|
||||
printf '%s\n' "$output" | grep -q '"public_key"'; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "expected structured JSON, got: $output"
|
||||
fi
|
||||
}
|
||||
|
||||
test_sign_event() {
|
||||
echo ""
|
||||
echo "=== Sign event ==="
|
||||
|
||||
local name="sign-event from stdin (pipe)"
|
||||
local output
|
||||
output=$(echo "$EVENT_JSON" | $CLIENT --socket-name "$SOCKET_NAME" --role main --path "m/44'/1237'/0'/0/0" sign-event 2>/dev/null) || {
|
||||
fail "$name" "exit code $?"
|
||||
return
|
||||
}
|
||||
if printf '%s\n' "$output" | grep -q '"id"' && \
|
||||
printf '%s\n' "$output" | grep -q '"pubkey"' && \
|
||||
printf '%s\n' "$output" | grep -q '"sig"'; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "expected signed event JSON, got: $output"
|
||||
return
|
||||
fi
|
||||
|
||||
# Verify pubkey matches get-public-key output
|
||||
local signed_pubkey
|
||||
signed_pubkey=$(echo "$output" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('pubkey',''))" 2>/dev/null)
|
||||
local expected_pubkey
|
||||
expected_pubkey=$($CLIENT --socket-name "$SOCKET_NAME" --role main --path "m/44'/1237'/0'/0/0" get-public-key 2>/dev/null)
|
||||
if [ "$signed_pubkey" = "$expected_pubkey" ]; then
|
||||
pass "sign-event pubkey matches get-public-key"
|
||||
else
|
||||
fail "sign-event pubkey matches get-public-key" "expected $expected_pubkey, got $signed_pubkey"
|
||||
fi
|
||||
|
||||
# Verify sig is 128 hex chars (schnorr signature)
|
||||
local sig
|
||||
sig=$(echo "$output" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('sig',''))" 2>/dev/null)
|
||||
if echo "$sig" | grep -qE '^[0-9a-f]{128}$'; then
|
||||
pass "sign-event sig is 128 hex chars"
|
||||
else
|
||||
fail "sign-event sig is 128 hex chars" "got length ${#sig}: $sig"
|
||||
fi
|
||||
|
||||
# sign-event from argv
|
||||
name="sign-event from argv"
|
||||
output=$($CLIENT --socket-name "$SOCKET_NAME" --role main --path "m/44'/1237'/0'/0/0" sign-event "$EVENT_JSON" 2>/dev/null) || {
|
||||
fail "$name" "exit code $?"
|
||||
return
|
||||
}
|
||||
if printf '%s\n' "$output" | grep -q '"id"' && \
|
||||
printf '%s\n' "$output" | grep -q '"pubkey"' && \
|
||||
printf '%s\n' "$output" | grep -q '"sig"'; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "expected signed event JSON, got: $output"
|
||||
fi
|
||||
}
|
||||
|
||||
test_mine_event() {
|
||||
echo ""
|
||||
echo "=== Mine event ==="
|
||||
|
||||
local name="mine-event with difficulty 4"
|
||||
local output
|
||||
# Use a short timeout to avoid hanging
|
||||
output=$(echo "$EVENT_JSON" | $CLIENT --socket-name "$SOCKET_NAME" --role main --path "m/44'/1237'/0'/0/0" --difficulty 4 mine-event 2>/dev/null) || {
|
||||
fail "$name" "exit code $?"
|
||||
return
|
||||
}
|
||||
# The mine-event result wraps the signed event in an "event" field as a JSON string.
|
||||
# Check for the wrapper fields and also verify the inner event has id/pubkey/sig.
|
||||
if printf '%s\n' "$output" | grep -q '"event"' && \
|
||||
printf '%s\n' "$output" | grep -q '"achieved_difficulty"' && \
|
||||
printf '%s\n' "$output" | grep -q '"target_reached"'; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "expected mined event JSON with event/achieved_difficulty/target_reached, got: $output"
|
||||
fi
|
||||
}
|
||||
|
||||
test_nip04_roundtrip() {
|
||||
echo ""
|
||||
echo "=== NIP-04 encrypt/decrypt round-trip ==="
|
||||
|
||||
local plaintext="hello_nip04_test"
|
||||
local name="nip04-encrypt returns ciphertext"
|
||||
local ciphertext
|
||||
ciphertext=$($CLIENT --socket-name "$SOCKET_NAME" --role main --path "m/44'/1237'/0'/0/0" nip04-encrypt "$PEER_PUBKEY" "$plaintext" 2>/dev/null) || {
|
||||
fail "$name" "exit code $?"
|
||||
return
|
||||
}
|
||||
if [ -n "$ciphertext" ]; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "empty ciphertext"
|
||||
return
|
||||
fi
|
||||
|
||||
name="nip04-decrypt recovers plaintext"
|
||||
local decrypted
|
||||
decrypted=$(echo "$ciphertext" | $CLIENT --socket-name "$SOCKET_NAME" --role main --path "m/44'/1237'/0'/0/0" nip04-decrypt "$PEER_PUBKEY" 2>/dev/null) || {
|
||||
fail "$name" "exit code $?"
|
||||
return
|
||||
}
|
||||
if [ "$decrypted" = "$plaintext" ]; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "expected '$plaintext', got '$decrypted'"
|
||||
fi
|
||||
}
|
||||
|
||||
test_nip44_roundtrip() {
|
||||
echo ""
|
||||
echo "=== NIP-44 encrypt/decrypt round-trip ==="
|
||||
|
||||
local plaintext="hello_nip44_test"
|
||||
local name="nip44-encrypt returns ciphertext"
|
||||
local ciphertext
|
||||
ciphertext=$($CLIENT --socket-name "$SOCKET_NAME" --role main --path "m/44'/1237'/0'/0/0" nip44-encrypt "$PEER_PUBKEY" "$plaintext" 2>/dev/null) || {
|
||||
fail "$name" "exit code $?"
|
||||
return
|
||||
}
|
||||
if [ -n "$ciphertext" ]; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "empty ciphertext"
|
||||
return
|
||||
fi
|
||||
|
||||
name="nip44-decrypt recovers plaintext"
|
||||
local decrypted
|
||||
decrypted=$(echo "$ciphertext" | $CLIENT --socket-name "$SOCKET_NAME" --role main --path "m/44'/1237'/0'/0/0" nip44-decrypt "$PEER_PUBKEY" 2>/dev/null) || {
|
||||
fail "$name" "exit code $?"
|
||||
return
|
||||
}
|
||||
if [ "$decrypted" = "$plaintext" ]; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "expected '$plaintext', got '$decrypted'"
|
||||
fi
|
||||
}
|
||||
|
||||
test_algorithm_verbs() {
|
||||
echo ""
|
||||
echo "=== Algorithm-based verbs ==="
|
||||
|
||||
local name="get-public-key --algorithm secp256k1 --index 0"
|
||||
local output
|
||||
output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm secp256k1 --index 0 get-public-key 2>/dev/null) || {
|
||||
fail "$name" "exit code $?"
|
||||
return
|
||||
}
|
||||
if printf '%s\n' "$output" | grep -q '"algorithm":"secp256k1"' && \
|
||||
printf '%s\n' "$output" | grep -q '"public_key"'; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "got: $output"
|
||||
fi
|
||||
|
||||
name="get-public-key --algorithm ed25519 --index 0"
|
||||
output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm ed25519 --index 0 get-public-key 2>/dev/null) || {
|
||||
fail "$name" "exit code $?"
|
||||
return
|
||||
}
|
||||
if printf '%s\n' "$output" | grep -q '"algorithm":"ed25519"'; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "got: $output"
|
||||
fi
|
||||
|
||||
name="sign --algorithm ed25519 --index 0 68656c6c6f"
|
||||
output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm ed25519 --index 0 sign "68656c6c6f" 2>/dev/null) || {
|
||||
fail "$name" "exit code $?"
|
||||
return
|
||||
}
|
||||
if printf '%s\n' "$output" | grep -q '"signature"'; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "got: $output"
|
||||
return
|
||||
fi
|
||||
|
||||
# Extract the signature for verify test
|
||||
local ed_sig
|
||||
ed_sig=$(echo "$output" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('signature',''))" 2>/dev/null)
|
||||
|
||||
name="verify --algorithm ed25519 --index 0 68656c6c6f (valid)"
|
||||
output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm ed25519 --index 0 verify "68656c6c6f" "$ed_sig" 2>/dev/null) || {
|
||||
local rc=$?
|
||||
if [ $rc -eq 1 ]; then
|
||||
fail "$name" "signature reported as invalid"
|
||||
else
|
||||
fail "$name" "exit code $rc"
|
||||
fi
|
||||
return
|
||||
}
|
||||
if printf '%s\n' "$output" | grep -q "valid"; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "expected 'valid', got: $output"
|
||||
fi
|
||||
|
||||
name="verify --algorithm ed25519 --index 0 68656c6c6f (invalid sig)"
|
||||
local wrong_sig="abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef01"
|
||||
set +e
|
||||
output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm ed25519 --index 0 verify "68656c6c6f" "$wrong_sig" 2>/dev/null)
|
||||
local rc=$?
|
||||
set -e
|
||||
if [ $rc -eq 1 ] && printf '%s\n' "$output" | grep -q "invalid"; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "expected exit 1 + 'invalid', got exit $rc: $output"
|
||||
fi
|
||||
|
||||
name="derive --algorithm secp256k1 --index 0 'test-data'"
|
||||
output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm secp256k1 --index 0 derive "test-data" 2>/dev/null) || {
|
||||
fail "$name" "exit code $?"
|
||||
return
|
||||
}
|
||||
if printf '%s\n' "$output" | grep -q '"digest"'; then
|
||||
local digest
|
||||
digest=$(printf '%s\n' "$output" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('digest',''))" 2>/dev/null)
|
||||
if printf '%s\n' "$digest" | grep -qE '^[0-9a-f]{64}$'; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "digest not 64 hex chars: $digest"
|
||||
fi
|
||||
else
|
||||
fail "$name" "no digest field: $output"
|
||||
fi
|
||||
|
||||
name="derive-shared-secret --algorithm x25519 --index 0"
|
||||
# Use the nostr pubkey as the peer (it's a valid secp256k1 point, which x25519 can work with)
|
||||
output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm x25519 --index 0 derive-shared-secret "$PEER_PUBKEY" 2>/dev/null) || {
|
||||
fail "$name" "exit code $?"
|
||||
return
|
||||
}
|
||||
# The server returns structured JSON for derive-shared-secret
|
||||
if printf '%s\n' "$output" | grep -q '"shared_secret"' && \
|
||||
printf '%s\n' "$output" | grep -q '"algorithm"'; then
|
||||
local ss
|
||||
ss=$(printf '%s\n' "$output" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('shared_secret',''))" 2>/dev/null)
|
||||
if printf '%s\n' "$ss" | grep -qE '^[0-9a-f]{64}$'; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "shared_secret not 64 hex chars: $ss"
|
||||
fi
|
||||
else
|
||||
fail "$name" "expected structured JSON with shared_secret, got: $output"
|
||||
fi
|
||||
}
|
||||
|
||||
test_ml_kem_roundtrip() {
|
||||
echo ""
|
||||
echo "=== ML-KEM-768 encapsulate/decapsulate round-trip ==="
|
||||
|
||||
local name="get-public-key --algorithm ml-kem-768 --index 0"
|
||||
local output
|
||||
output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm ml-kem-768 --index 0 get-public-key 2>/dev/null) || {
|
||||
fail "$name" "exit code $?"
|
||||
return
|
||||
}
|
||||
if printf '%s\n' "$output" | grep -q '"algorithm":"ml-kem-768"' && \
|
||||
printf '%s\n' "$output" | grep -q '"public_key"'; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "got: $output"
|
||||
return
|
||||
fi
|
||||
|
||||
# Extract the ML-KEM public key
|
||||
local mlkem_pubkey
|
||||
mlkem_pubkey=$(echo "$output" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('public_key',''))" 2>/dev/null)
|
||||
if [ -z "$mlkem_pubkey" ]; then
|
||||
fail "extract ml-kem-768 pubkey" "empty"
|
||||
return
|
||||
fi
|
||||
|
||||
name="encapsulate --algorithm ml-kem-768 with peer pubkey"
|
||||
output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm ml-kem-768 encapsulate "$mlkem_pubkey" 2>/dev/null) || {
|
||||
fail "$name" "exit code $?"
|
||||
return
|
||||
}
|
||||
if printf '%s\n' "$output" | grep -q '"ciphertext"' && \
|
||||
printf '%s\n' "$output" | grep -q '"shared_secret"'; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "got: $output"
|
||||
return
|
||||
fi
|
||||
|
||||
# Extract ciphertext and shared_secret from encapsulate
|
||||
local ct enc_ss
|
||||
ct=$(echo "$output" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('ciphertext',''))" 2>/dev/null)
|
||||
enc_ss=$(echo "$output" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('shared_secret',''))" 2>/dev/null)
|
||||
|
||||
name="decapsulate --algorithm ml-kem-768 --index 0 with ciphertext"
|
||||
output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm ml-kem-768 --index 0 decapsulate "$ct" 2>/dev/null) || {
|
||||
fail "$name" "exit code $?"
|
||||
return
|
||||
}
|
||||
if printf '%s\n' "$output" | grep -q '"shared_secret"'; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "got: $output"
|
||||
return
|
||||
fi
|
||||
|
||||
# Verify shared secrets match
|
||||
local dec_ss
|
||||
dec_ss=$(echo "$output" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('shared_secret',''))" 2>/dev/null)
|
||||
if [ "$enc_ss" = "$dec_ss" ]; then
|
||||
pass "ML-KEM-768 encapsulate/decapsulate shared secrets match"
|
||||
else
|
||||
fail "ML-KEM-768 encapsulate/decapsulate shared secrets match" "enc=$enc_ss dec=$dec_ss"
|
||||
fi
|
||||
}
|
||||
|
||||
test_otp_encrypt_decrypt() {
|
||||
echo ""
|
||||
echo "=== OTP encrypt/decrypt ==="
|
||||
|
||||
local name="encrypt --algorithm otp (base64 plaintext)"
|
||||
local plaintext_b64="SGVsbG8gT1RQIQ==" # "Hello OTP!" in base64
|
||||
local rc=0
|
||||
local stdout_file
|
||||
local stderr_file
|
||||
|
||||
stdout_file=$(mktemp /tmp/otp_stdout_XXXXXX)
|
||||
stderr_file=$(mktemp /tmp/otp_stderr_XXXXXX)
|
||||
|
||||
# Run the command, capturing stdout and stderr separately
|
||||
set +e
|
||||
"$CLIENT" --socket-name "$SOCKET_NAME" --algorithm otp encrypt "$plaintext_b64" >"$stdout_file" 2>"$stderr_file"
|
||||
rc=$?
|
||||
set -e
|
||||
|
||||
if [ $rc -ne 0 ]; then
|
||||
local stderr_text
|
||||
stderr_text=$(cat "$stderr_file")
|
||||
rm -f "$stdout_file" "$stderr_file"
|
||||
# Check if the error is about missing OTP pad
|
||||
if printf '%s\n' "$stderr_text" | grep -qi "otp_pad\|pad_not_bound\|no pad\|not available\|not supported\|not configured"; then
|
||||
skip "$name" "OTP pad not available on server"
|
||||
return
|
||||
fi
|
||||
fail "$name" "exit code $rc stderr: $stderr_text"
|
||||
return
|
||||
fi
|
||||
|
||||
local ciphertext
|
||||
ciphertext=$(cat "$stdout_file")
|
||||
rm -f "$stdout_file" "$stderr_file"
|
||||
|
||||
if [ -n "$ciphertext" ]; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "empty output"
|
||||
return
|
||||
fi
|
||||
|
||||
name="decrypt --algorithm otp"
|
||||
stdout_file=$(mktemp /tmp/otp_stdout_XXXXXX)
|
||||
stderr_file=$(mktemp /tmp/otp_stderr_XXXXXX)
|
||||
|
||||
set +e
|
||||
"$CLIENT" --socket-name "$SOCKET_NAME" --algorithm otp decrypt "$ciphertext" >"$stdout_file" 2>"$stderr_file"
|
||||
rc=$?
|
||||
set -e
|
||||
|
||||
local decrypted
|
||||
decrypted=$(cat "$stdout_file")
|
||||
rm -f "$stdout_file" "$stderr_file"
|
||||
|
||||
if [ $rc -eq 0 ] && [ -n "$decrypted" ]; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "exit code $rc output: $decrypted"
|
||||
fi
|
||||
}
|
||||
|
||||
test_call_verb() {
|
||||
echo ""
|
||||
echo "=== Generic call verb ==="
|
||||
|
||||
local name="call get_info via stdin"
|
||||
local output
|
||||
output=$(echo '[]' | $CLIENT --socket-name "$SOCKET_NAME" call get_info 2>/dev/null) || {
|
||||
fail "$name" "exit code $?"
|
||||
return
|
||||
}
|
||||
if printf '%s\n' "$output" | grep -q 'name' && \
|
||||
printf '%s\n' "$output" | grep -q 'verbs'; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "got: $output"
|
||||
fi
|
||||
}
|
||||
|
||||
test_error_cases() {
|
||||
echo ""
|
||||
echo "=== Error cases ==="
|
||||
|
||||
local name="No socket found (bogus socket name)"
|
||||
local rc=0
|
||||
local stderr_file
|
||||
stderr_file=$(mktemp /tmp/err_stderr_XXXXXX)
|
||||
set +e
|
||||
$CLIENT --socket-name "nonexistent_socket_$$" --timeout 1000 get-info 2>"$stderr_file" >/dev/null
|
||||
rc=$?
|
||||
set -e
|
||||
local stderr_text
|
||||
stderr_text=$(cat "$stderr_file")
|
||||
rm -f "$stderr_file"
|
||||
if [ $rc -ne 0 ] && [ -n "$stderr_text" ]; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "expected non-zero exit + stderr, got exit $rc stderr: $stderr_text"
|
||||
fi
|
||||
|
||||
name="--index 5 without --algorithm"
|
||||
stderr_file=$(mktemp /tmp/err_stderr_XXXXXX)
|
||||
set +e
|
||||
$CLIENT --socket-name "$SOCKET_NAME" --index 5 get-public-key 2>"$stderr_file" >/dev/null
|
||||
rc=$?
|
||||
set -e
|
||||
stderr_text=$(cat "$stderr_file")
|
||||
rm -f "$stderr_file"
|
||||
if [ $rc -ne 0 ] && printf '%s\n' "$stderr_text" | grep -qi "index.*only valid\|--index"; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "expected error about --index, got exit $rc: $stderr_text"
|
||||
fi
|
||||
|
||||
name="Unknown verb"
|
||||
stderr_file=$(mktemp /tmp/err_stderr_XXXXXX)
|
||||
set +e
|
||||
$CLIENT --socket-name "$SOCKET_NAME" nonexistent-verb 2>"$stderr_file" >/dev/null
|
||||
rc=$?
|
||||
set -e
|
||||
stderr_text=$(cat "$stderr_file")
|
||||
rm -f "$stderr_file"
|
||||
if [ $rc -ne 0 ] && printf '%s\n' "$stderr_text" | grep -qi "unknown verb"; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "expected unknown verb error, got exit $rc: $stderr_text"
|
||||
fi
|
||||
|
||||
name="verify with malformed signature (exit 2)"
|
||||
stderr_file=$(mktemp /tmp/err_stderr_XXXXXX)
|
||||
set +e
|
||||
$CLIENT --socket-name "$SOCKET_NAME" --algorithm ed25519 --index 0 verify "68656c6c6f" "nothex" 2>"$stderr_file"
|
||||
rc=$?
|
||||
set -e
|
||||
stderr_text=$(cat "$stderr_file")
|
||||
rm -f "$stderr_file"
|
||||
# Should be exit 2 (error), not exit 1 (invalid)
|
||||
if [ $rc -eq 2 ]; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "expected exit 2 (error), got exit $rc: $stderr_text"
|
||||
fi
|
||||
}
|
||||
|
||||
test_auto_discovery() {
|
||||
echo ""
|
||||
echo "=== Auto-discovery ==="
|
||||
|
||||
local name="Auto-discover socket (only test signer running)"
|
||||
# This is best-effort: if only our test signer is running, it should work.
|
||||
# If other signers are running, skip.
|
||||
local rc=0
|
||||
local stdout_file
|
||||
local stderr_file
|
||||
stdout_file=$(mktemp /tmp/auto_stdout_XXXXXX)
|
||||
stderr_file=$(mktemp /tmp/auto_stderr_XXXXXX)
|
||||
|
||||
set +e
|
||||
$CLIENT get-info >"$stdout_file" 2>"$stderr_file"
|
||||
rc=$?
|
||||
set -e
|
||||
|
||||
local output
|
||||
output=$(cat "$stdout_file")
|
||||
local stderr_text
|
||||
stderr_text=$(cat "$stderr_file")
|
||||
rm -f "$stdout_file" "$stderr_file"
|
||||
|
||||
if [ $rc -eq 0 ]; then
|
||||
if printf '%s\n' "$output" | grep -q 'name'; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "got output but missing 'name' field: $output"
|
||||
fi
|
||||
else
|
||||
if printf '%s\n' "$stderr_text" | grep -qi "multiple"; then
|
||||
skip "$name" "multiple signer sockets found"
|
||||
else
|
||||
skip "$name" "auto-discovery failed: $stderr_text"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
echo "============================================"
|
||||
echo " n_signer_client Integration Test Suite"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# Start the server
|
||||
start_server || {
|
||||
echo "FATAL: could not start nsigner server"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Run tests
|
||||
test_get_info
|
||||
test_get_public_key_nostr
|
||||
test_sign_event
|
||||
test_mine_event
|
||||
test_nip04_roundtrip
|
||||
test_nip44_roundtrip
|
||||
test_algorithm_verbs
|
||||
test_ml_kem_roundtrip
|
||||
test_otp_encrypt_decrypt
|
||||
test_call_verb
|
||||
test_error_cases
|
||||
test_auto_discovery
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " Results"
|
||||
echo "============================================"
|
||||
echo " PASS: $PASS_COUNT"
|
||||
echo " FAIL: $FAIL_COUNT"
|
||||
echo " SKIP: $SKIP_COUNT"
|
||||
echo " TOTAL: $((PASS_COUNT + FAIL_COUNT + SKIP_COUNT))"
|
||||
echo "============================================"
|
||||
|
||||
if [ "$FAIL_COUNT" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
@@ -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