Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e577de4e2d | ||
|
|
44a87432b7 | ||
|
|
6aa42d4387 | ||
|
|
0b1c3ea382 | ||
|
|
fd895db0c9 | ||
|
|
e4087441a5 | ||
|
|
5a16c4e1bf | ||
|
|
9afbb8fcbd | ||
|
|
fa98de12e4 | ||
|
|
e459e98beb | ||
|
|
32151b3ffd | ||
|
|
75dddac223 | ||
|
|
697a79dc25 | ||
|
|
e65ed5c5d6 | ||
|
|
821245ac1d |
@@ -4,3 +4,26 @@ build/
|
||||
/*.a
|
||||
!resources/
|
||||
!resources/**
|
||||
|
||||
# Exclude nested .git and bare repos inside resources/nostr_core_lib.
|
||||
# These are not needed for the build and add ~1.3 GB to the Docker context.
|
||||
resources/nostr_core_lib/.git/
|
||||
resources/nostr_core_lib/rewrite_mirror/
|
||||
resources/nostr_core_lib/verify_remote_size/
|
||||
resources/nostr_core_lib/verify_remote_size_now/
|
||||
resources/nostr_core_lib/backups/
|
||||
resources/nostr_core_lib/examples/
|
||||
resources/nostr_core_lib/tests/
|
||||
resources/nostr_core_lib/plans/
|
||||
resources/nostr_core_lib/pool.log
|
||||
resources/nostr_core_lib/Trash/
|
||||
resources/nostr_core_lib/node_modules/
|
||||
resources/nostr_core_lib/nips/
|
||||
resources/nostr_core_lib/nak/
|
||||
resources/nostr_core_lib/nostr-tools/
|
||||
resources/nostr_core_lib/libsodium/
|
||||
resources/nostr_core_lib/monocypher-4.0.2/
|
||||
resources/nostr_core_lib/tiny-AES-c/
|
||||
resources/nostr_core_lib/blossom/
|
||||
resources/nostr_core_lib/ndk/
|
||||
resources/nostr_core_lib/cline_history/
|
||||
|
||||
+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
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
CC := gcc
|
||||
CFLAGS := -Wall -Wextra -std=c99 -Os -ffunction-sections -fdata-sections -DNOSTR_ENABLE_NSIGNER_CLIENT=1 -D_GNU_SOURCE -Isrc -Ilibotppad -Iresources/nostr_core_lib -Iresources/nostr_core_lib/nostr_core -Iresources/nostr_core_lib/cjson -Iresources/tui_continuous -Iresources/pqclean -Iresources/pqclean/crypto_sign/ml-dsa-65 -Iresources/pqclean/crypto_sign/slh-dsa-128s -Iresources/pqclean/crypto_kem/ml-kem-768 -Iresources/pqclean/common
|
||||
LDFLAGS := -Wl,--gc-sections resources/nostr_core_lib/libnostr_core_x64.a -lz -ldl -lpthread -lm -lssl -lcrypto -lcurl -lsecp256k1
|
||||
CFLAGS := -Wall -Wextra -std=c99 -Os -ffunction-sections -fdata-sections \
|
||||
-fstack-protector-strong -D_FORTIFY_SOURCE=2 -fPIE \
|
||||
-fstack-clash-protection \
|
||||
-DNOSTR_ENABLE_NSIGNER_CLIENT=1 -D_GNU_SOURCE \
|
||||
-Isrc -Ilibotppad -Iresources/nostr_core_lib \
|
||||
-Iresources/nostr_core_lib/nostr_core -Iresources/nostr_core_lib/cjson \
|
||||
-Iresources/tui_continuous -Iresources/pqclean \
|
||||
-Iresources/pqclean/crypto_sign/ml-dsa-65 \
|
||||
-Iresources/pqclean/crypto_sign/slh-dsa-128s \
|
||||
-Iresources/pqclean/crypto_kem/ml-kem-768 -Iresources/pqclean/common
|
||||
LDFLAGS := -Wl,--gc-sections -pie -Wl,-z,relro -Wl,-z,now -Wl,-z,noexecstack \
|
||||
resources/nostr_core_lib/libnostr_core_x64.a \
|
||||
-lz -ldl -lpthread -lm -lssl -lcrypto -lcurl -lsecp256k1
|
||||
|
||||
SRC_DIR := src
|
||||
BUILD_DIR := build
|
||||
@@ -89,15 +100,42 @@ 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 test-path-whitelist 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
|
||||
# Guard for non-static build targets.
|
||||
# The canonical build is `make static` (runs build_static.sh).
|
||||
# To use dev/test targets, set NSIGNER_ALLOW_DEV_BUILD=1 in your environment.
|
||||
# This prevents AI agents from accidentally using the wrong build path.
|
||||
define BUILD_GUARD
|
||||
@if [ -z "$$NSIGNER_ALLOW_DEV_BUILD" ]; then \
|
||||
echo "=========================================================="; \
|
||||
echo "ERROR: This target is blocked for non-interactive agents."; \
|
||||
echo " For testing and deployment, use:"; \
|
||||
echo ""; \
|
||||
echo " ./build_static.sh"; \
|
||||
echo " or"; \
|
||||
echo " make static"; \
|
||||
echo ""; \
|
||||
echo " The static build produces the canonical binary that"; \
|
||||
echo " matches production deployments."; \
|
||||
echo ""; \
|
||||
echo " To override (human developers only):"; \
|
||||
echo " NSIGNER_ALLOW_DEV_BUILD=1 make <target>"; \
|
||||
echo "=========================================================="; \
|
||||
exit 1; \
|
||||
fi
|
||||
endef
|
||||
|
||||
all: dev clients
|
||||
|
||||
lib:
|
||||
$(BUILD_GUARD)
|
||||
cd resources/nostr_core_lib && ./build.sh --nips=1,4,6,13,19,44
|
||||
|
||||
dev: lib $(TARGET_DEV)
|
||||
$(BUILD_GUARD)
|
||||
|
||||
$(TARGET_DEV): $(SOURCES)
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@@ -118,7 +156,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-path-whitelist 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)
|
||||
@@ -180,10 +218,19 @@ test-algorithm-api: $(TEST_ALGORITHM_API_TARGET)
|
||||
test-path-whitelist: $(TEST_PATH_WHITELIST_TARGET)
|
||||
./$(TEST_PATH_WHITELIST_TARGET)
|
||||
|
||||
test-client: examples
|
||||
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)
|
||||
|
||||
@@ -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,14 +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. |
|
||||
| 2002 | `index_not_allowed` | `nostr_index` not in the index whitelist. |
|
||||
| 2003 | `path_not_allowed` | `role_path` not in the path whitelist. |
|
||||
| 2004 | `index_required` | Named path-role has no default index and none given. |
|
||||
| 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 |
|
||||
|-------------------------|-----------------------------------------------|----------------------------------|----------------------------------|
|
||||
@@ -212,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"`.
|
||||
|
||||
@@ -405,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.
|
||||
@@ -416,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:
|
||||
@@ -434,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" } ]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -463,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.
|
||||
@@ -474,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.
|
||||
@@ -486,62 +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 be on the path whitelist or match a registered role) |
|
||||
| `index` | Optional: index for a named path-role template (see below) |
|
||||
| `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 can define **named path-roles** that bind a role name (which acts as an access token for clients) to a derivation path template with a range and default index. The derivation path is hidden from clients — they only know the role name.
|
||||
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:
|
||||
Role name: myrole
|
||||
Purpose [nostr]: nostr
|
||||
Curve [secp256k1]: secp256k1
|
||||
Path template: m/44'/1237'/0-3/1/0
|
||||
Default index [0]: 1
|
||||
Wizard preset menu:
|
||||
1. Standard Nostr (NIP-06): secp256k1, m/44'/1237'/0'/0/0
|
||||
2. Standard Nostr 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
|
||||
```
|
||||
|
||||
Clients then request keys by role name, optionally with an `index` within the allowed range:
|
||||
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"}]}
|
||||
{"id":"1","method":"nostr_get_public_key","params":[{"role":"myrole","role_path":"m/44'/1237'/0'/1/0"}]}
|
||||
```
|
||||
→ derives `m/44'/1237'/1/1/0` (default index 1).
|
||||
→ derives `m/44'/1237'/0'/1/0`, verified against the `myrole` template.
|
||||
|
||||
```json
|
||||
{"id":"2","method":"nostr_get_public_key","params":[{},{"role":"myrole","index":2}]}
|
||||
{"id":"2","method":"nostr_get_public_key","params":[{"role":"myrole","role_path":"m/44'/1237'/5'/1/0"}]}
|
||||
```
|
||||
→ derives `m/44'/1237'/2/1/0` (index 2, within range 0-3).
|
||||
→ `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":"myrole","index":5}]}
|
||||
```
|
||||
→ `2005 index_out_of_range` (5 is outside 0-3).
|
||||
|
||||
```json
|
||||
{"id":"4","method":"nostr_get_public_key","params":[{},{"role":"unknown"}]}
|
||||
{"id":"3","method":"nostr_get_public_key","params":[{"role":"unknown","role_path":"m/44'/1237'/0'/0/0"}]}
|
||||
```
|
||||
→ `1002 unknown_role` (name not registered).
|
||||
|
||||
#### Path whitelist (`--allow-index`)
|
||||
|
||||
The `--allow-index` flag (and the wizard's whitelist prompt) accepts both integer `nostr_index` tokens and path-template tokens. A `role_path` request is auto-registered and derived on demand if it matches a whitelisted template; otherwise it returns `2003 path_not_allowed`.
|
||||
|
||||
```
|
||||
nsigner --allow-index "m/44'/1237'/0-3/1/0"
|
||||
```json
|
||||
{"id":"4","method":"nostr_get_public_key","params":[{"role":"myrole"}]}
|
||||
```
|
||||
→ `2009 path_required` (`role_path` is required).
|
||||
|
||||
Allowed syntax (comma-separated):
|
||||
- `all` — no restriction (default)
|
||||
- `0-3` / `0,1,3` — integer `nostr_index` values (backward compatible)
|
||||
- `m/44'/1237'/0-3/0/0` — NIP-06 paths X=0..3
|
||||
- `m/44'/1237'/0-3/1/0` — custom paths X=0..3, change=1
|
||||
- `m/44'/1237'/0-3/0/0,m/44'/1237'/0-3/1/0` — multiple templates
|
||||
```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
|
||||
|
||||
@@ -555,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
|
||||
|
||||
@@ -588,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:
|
||||
@@ -606,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 \
|
||||
@@ -731,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
|
||||
@@ -749,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>"}
|
||||
```
|
||||
|
||||
@@ -793,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.
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
# n_signer Security Audit — Consolidated Remediation Report
|
||||
|
||||
**Date:** 2026-08-13
|
||||
**Scope:** Full static security audit of [`src/`](../src/), [`client/`](../client/), [`libotppad/`](../libotppad/), build configuration, and entropy/key-derivation paths
|
||||
**Result:** 8 findings identified, all remediated and verified
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
A comprehensive security audit of the `n_signer` codebase identified **8 security findings** across memory safety, network parsing, authentication, build hardening, and entropy/key-derivation. All findings have been remediated, code-reviewed, and verified against the existing test suite.
|
||||
|
||||
| Severity | Count | Status |
|
||||
|----------|-------|--------|
|
||||
| High | 2 | ✅ All Remediated |
|
||||
| Medium | 5 | ✅ All Remediated |
|
||||
| Low | 1 | ✅ All Remediated |
|
||||
| **Total** | **8** | **All Fixed** |
|
||||
|
||||
---
|
||||
|
||||
## Findings and Remediations
|
||||
|
||||
### F-001: mlock Failure Silently Degraded to Pageable Memory
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Severity** | Medium |
|
||||
| **File** | [`src/secure_mem.c`](../src/secure_mem.c):762–798 |
|
||||
| **Status** | ✅ Remediated |
|
||||
|
||||
**Problem.** [`secure_buf_alloc()`](../src/secure_mem.c:762) called `mlock()` to pin secret material (mnemonic, private keys) in RAM. On failure (e.g., `RLIMIT_MEMLOCK` exhausted, missing `CAP_IPC_LOCK`), it printed a warning and **returned success with the buffer unlocked**. No caller checked `buf->locked`, so the process continued with secrets in pageable memory — silently undermining the "crash = total wipe" and "no filesystem footprint" guarantees. An attacker with disk access after the fact could recover key material from swap.
|
||||
|
||||
**Fix.** mlock failure is now **fatal by default**. The function prints a diagnostic with `strerror(errno)` and returns `-1`, causing startup to abort. A new opt-in escape hatch, `secure_buf_allow_unlocked()`, is wired to the `--allow-unlocked-memory` CLI flag in [`src/main.c`](../src/main.c):3653 for development/container environments where `mlock` is unavailable.
|
||||
|
||||
**Files changed:**
|
||||
- [`src/secure_mem.c`](../src/secure_mem.c) — fatal-by-default logic, `secure_buf_allow_unlocked()`, added `<errno.h>`
|
||||
- [`src/main.c`](../src/main.c) — `--allow-unlocked-memory` argument parsing + declaration
|
||||
|
||||
---
|
||||
|
||||
### F-002: HTTP Content-Length Parsed with `atol()` — No Error Detection
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Severity** | Medium |
|
||||
| **File** | [`src/http_listener.c`](../src/http_listener.c):75–163 |
|
||||
| **Status** | ✅ Remediated |
|
||||
|
||||
**Problem.** The HTTP request parser read `Content-Length` using `atol()`, which has **no error detection**: it returns `0` for non-numeric input (indistinguishable from a real `0`), and silently truncates values exceeding `LONG_MAX`. The value was stored in a signed `long` and compared against a `size_t` limit, creating signed/unsigned confusion. A `Content-Length` near `LONG_MAX` could trigger a giant allocation attempt (DoS via OOM or NULL-deref crash).
|
||||
|
||||
**Fix.** Replaced `atol()` with `strtoull()` and full validation:
|
||||
- Rejects empty/non-numeric values (`endptr == p`)
|
||||
- Rejects trailing garbage (only whitespace/CR allowed after digits)
|
||||
- Rejects values exceeding `SIZE_MAX`
|
||||
- Changed `content_length` from `long` to `size_t`, eliminating signed/unsigned confusion
|
||||
- Added a `has_content_length` flag to distinguish "missing header" from "zero length"
|
||||
|
||||
**Files changed:**
|
||||
- [`src/http_listener.c`](../src/http_listener.c) — safe parsing, type fix, drain loop type fix
|
||||
|
||||
---
|
||||
|
||||
### F-003: Auth Envelope Nonce Cache Replay After Wrap
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Severity** | **High** |
|
||||
| **Files** | [`src/auth_envelope.h`](../src/auth_envelope.h), [`src/auth_envelope.c`](../src/auth_envelope.c) |
|
||||
| **Status** | ✅ Remediated |
|
||||
|
||||
**Problem.** Replay protection used a **bounded FIFO cache of 1024 event IDs** ([`AUTH_NONCE_CACHE_SIZE`](../src/auth_envelope.h):10). When full, the oldest entry was evicted (circular overwrite). An attacker who captured 1024 valid auth envelopes could replay any of them after the cache wrapped — the evicted nonce would no longer be detected as a duplicate. Combined with the 30-second timestamp skew window, this allowed impersonation of any previously-seen caller.
|
||||
|
||||
**Fix.** Replaced the bounded FIFO cache with a **hybrid per-pubkey replay tracker**:
|
||||
|
||||
1. **Monotonic timestamp per pubkey** — tracks the highest `created_at` seen for each of up to 64 pubkeys. Any envelope with `created_at < max_seen` is rejected as a replay. This has **no wrap-around weakness**.
|
||||
2. **Event ID set for the current second** — because `created_at` has 1-second granularity, a per-(pubkey, second) set of up to 32 event IDs allows multiple legitimate concurrent requests within the same second while still rejecting exact duplicates.
|
||||
3. When `created_at > max_seen`, the event ID set is cleared and the timestamp advances.
|
||||
|
||||
The initial monotonic-only version was caught by the existing test suite ([`tests/test_auth_envelope.c`](../tests/test_auth_envelope.c)) which builds multiple same-second requests — the hybrid design passes all 13 tests.
|
||||
|
||||
**Files changed:**
|
||||
- [`src/auth_envelope.h`](../src/auth_envelope.h) — new `auth_pubkey_entry_t` structure with `max_created_at` + `event_ids[]`
|
||||
- [`src/auth_envelope.c`](../src/auth_envelope.c) — new `auth_nonce_cache_check_and_update()` implementing the hybrid check; event ID extracted from the signed envelope's `id` field
|
||||
|
||||
---
|
||||
|
||||
### F-004: OTP Binary Header Checksum Parsed with `sscanf` — Return Value Ignored
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Severity** | Low |
|
||||
| **File** | [`src/otp_pad.c`](../src/otp_pad.c):408–416 |
|
||||
| **Status** | ✅ Remediated |
|
||||
|
||||
**Problem.** When building a binary `.otp` output header, the hex pad checksum was converted to bytes using `sscanf("%02x")` in a loop, but the **return value was never checked**. If the checksum string were ever malformed, `sscanf` would leave the destination variable uninitialized, producing garbage in the output header.
|
||||
|
||||
**Fix.** Added a return-value check: if `sscanf` does not return exactly 1, the function zeroizes the scratch buffer and returns an error. (An earlier version of this fix incorrectly called `free(blob)` before `blob` was declared — this was caught in code review and corrected.)
|
||||
|
||||
**Files changed:**
|
||||
- [`src/otp_pad.c`](../src/otp_pad.c) — `sscanf` return value checked with proper error cleanup
|
||||
|
||||
---
|
||||
|
||||
### F-005: Missing Compiler Hardening Flags
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Severity** | Medium |
|
||||
| **File** | [`Makefile`](../Makefile):1–10 |
|
||||
| **Status** | ✅ Remediated |
|
||||
|
||||
**Problem.** The build used only `-Wall -Wextra -Os` with no exploit-mitigation flags. The resulting binary had no stack canaries, no `_FORTIFY_SOURCE` bounds checking, no PIE (fixed load address — trivial ROP), writable GOT (no RELRO), and a potentially executable stack. For a program that parses untrusted network input while holding signing keys, these gaps significantly raise the impact of any memory-corruption bug.
|
||||
|
||||
**Fix.** Added the standard hardening flag set to `CFLAGS` and `LDFLAGS`:
|
||||
|
||||
- `-fstack-protector-strong` — stack canaries
|
||||
- `-D_FORTIFY_SOURCE=2` — compile-time + runtime bounds checking for libc functions
|
||||
- `-fPIE` / `-pie` — position-independent executable (ASLR for code)
|
||||
- `-Wl,-z,relro -Wl,-z,now` — full RELRO (read-only GOT after startup)
|
||||
- `-Wl,-z,noexecstack` — non-executable stack (NX)
|
||||
- `-fstack-clash-protection` — stack-clash probing
|
||||
|
||||
**Verification.** The rebuilt binary is confirmed as `ELF 64-bit LSB pie executable`. Notably, the new `-fstack-protector-strong` flag **immediately caught a pre-existing latent buffer overflow** in [`tests/test_selector.c`](../tests/test_selector.c) (stack smashing detected at runtime) — a bug that was previously silent. This validates the value of the hardening flags.
|
||||
|
||||
**Files changed:**
|
||||
- [`Makefile`](../Makefile) — hardening flags in `CFLAGS` and `LDFLAGS`
|
||||
|
||||
---
|
||||
|
||||
### F-006: RP2040 Fallback RNG Uses Cryptographically Weak xorshift32
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Severity** | **High** |
|
||||
| **File** | [`firmware/kb2040_hidden_signer/src/platform/rp2040.c`](../firmware/kb2040_hidden_signer/src/platform/rp2040.c):31–78 |
|
||||
| **Status** | ✅ Remediated |
|
||||
|
||||
**Problem.** [`nostr_platform_random()`](../firmware/kb2040_hidden_signer/src/platform/rp2040.c:31) is the sole entropy source for the KB2040 hidden signer, used to generate private keys, mnemonic entropy, secp256k1 context randomization, NIP-04 IVs, and NIP-44 nonces. When the Pico SDK's hardware RNG (`get_rand_32()`) is unavailable, it fell through to a **deterministic xorshift32 PRNG** seeded from a hardcoded constant XOR'd with `micros()`, `millis()`, a stack address, and ADC temperature sensor readings. xorshift32 is not cryptographically secure — its 32-bit state is trivially brute-forceable. An attacker who observes boot timing could reconstruct all keys.
|
||||
|
||||
**Fix.** Removed the xorshift32 fallback entirely. If `get_rand_32()` is unavailable, the function now returns `-1` and refuses to generate keys. The Pico SDK's ring-oscillator-based RNG is available on all official RP2040 boards. The ADC and Arduino timing code was also removed since it was only used to seed the xorshift.
|
||||
|
||||
**Files changed:**
|
||||
- [`firmware/kb2040_hidden_signer/src/platform/rp2040.c`](../firmware/kb2040_hidden_signer/src/platform/rp2040.c) — removed xorshift32 fallback, fail closed on missing `get_rand_32()`
|
||||
|
||||
---
|
||||
|
||||
### F-007: PQ DRBG is Not a NIST SP 800-90A Compliant Construction
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Severity** | Medium |
|
||||
| **File** | [`src/pq_drbg.c`](../src/pq_drbg.c):1–134 |
|
||||
| **Status** | ✅ Remediated |
|
||||
|
||||
**Problem.** The PQ deterministic DRBG uses a custom `SHAKE-256(seed || counter)` construction rather than a NIST SP 800-90A DRBG. While cryptographically sound for single-shot keygen, it had issues: (1) the buffer-size comment confused SHAKE-256's rate (136 bytes) with SHAKE-128's rate (168 bytes); (2) the non-standard nature was documented but the rationale for why it's acceptable could be clearer.
|
||||
|
||||
**Fix.** Fixed the rate comment to correctly explain that 168 is the requested output length, not the SHAKE-256 rate (136 bytes). Strengthened the file header documentation with a detailed security argument listing 5 reasons why this non-standard construction is acceptable for this use case. Domain separation across algorithm types is not needed because the DRBG is initialized once per keygen and zeroized after — different algorithm types use different seeds.
|
||||
|
||||
**Files changed:**
|
||||
- [`src/pq_drbg.c`](../src/pq_drbg.c) — fixed comment, strengthened documentation
|
||||
|
||||
---
|
||||
|
||||
### F-008: SLH-DSA-128s SK.prf Used as Both PRF Key and DRBG Seed
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Severity** | Medium |
|
||||
| **File** | [`src/pq_crypto.c`](../src/pq_crypto.c):1457–1471 |
|
||||
| **Status** | ✅ Remediated |
|
||||
|
||||
**Problem.** In the SLH-DSA-128s signing path, `SK.prf` (bytes 16..31 of the secret key) was used for two distinct purposes: (1) as the PRF key for `PRF_msg()` per FIPS 205, and (2) as the raw seed for the deterministic DRBG that produces `opt_rand`. This dual use was non-standard — if the DRBG output were ever compromised, `SK.prf` would also be compromised, breaking the `PRF_msg` security guarantee.
|
||||
|
||||
**Fix.** Replaced the direct `pq_drbg_init(sk_prf, ...)` call with a domain-separated derivation:
|
||||
|
||||
```c
|
||||
drbg_seed = HMAC-SHA256(SK.prf, "slh-dsa-drbg-seed")
|
||||
pq_drbg_init(drbg_seed, 32)
|
||||
```
|
||||
|
||||
This ensures that even if the DRBG output is somehow compromised, `SK.prf` remains secret and `PRF_msg` remains secure. The HMAC key is `SK.prf` (16 bytes), the message is the ASCII string `"slh-dsa-drbg-seed"`, and the output is a 32-byte DRBG seed that is zeroized after initialization.
|
||||
|
||||
**Files changed:**
|
||||
- [`src/pq_crypto.c`](../src/pq_crypto.c):1457–1471 — domain-separated DRBG seed from SK.prf via HMAC-SHA256
|
||||
|
||||
---
|
||||
|
||||
## Post-Remediation Defects Caught in Review
|
||||
|
||||
During code review of the initial fixes, 5 defects were identified and corrected before final verification:
|
||||
|
||||
| # | Defect | File | Resolution |
|
||||
|---|--------|------|-----------|
|
||||
| 1 | `errno` used without `#include <errno.h>` (compile error) | [`src/secure_mem.c`](../src/secure_mem.c) | Added include |
|
||||
| 2 | `free(blob)` referenced before `blob` was declared (compile error) | [`src/otp_pad.c`](../src/otp_pad.c) | Removed erroneous `free()`; only `secure_memzero` needed on that path |
|
||||
| 3 | Duplicated pubkey validation block (dead code) | [`src/auth_envelope.c`](../src/auth_envelope.c) | Removed duplicate |
|
||||
| 4 | `secure_buf_allow_unlocked()` not declared in main.c's headerless block (compile error) | [`src/main.c`](../src/main.c) | Added declaration |
|
||||
| 5 | Monotonic-only timestamp rejected same-second requests (test failure) | [`src/auth_envelope.c`](../src/auth_envelope.c) | Upgraded to hybrid timestamp + event-ID design |
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
### Build
|
||||
- Compiles cleanly with all hardening flags enabled
|
||||
- Output binary confirmed as PIE: `ELF 64-bit LSB pie executable, x86-64`
|
||||
|
||||
### Test Suite
|
||||
|
||||
| Test | Result |
|
||||
|------|--------|
|
||||
| `test_auth_envelope` | ✅ 13/13 passed (validates F-003 hybrid design) |
|
||||
| `test_mnemonic` | ✅ All passed |
|
||||
| `test_role_table` | ✅ All passed |
|
||||
| `test_enforcement` | ✅ 10/10 passed |
|
||||
| `test_policy` | ✅ 43/43 passed |
|
||||
| `test_socket_name` | ✅ All passed |
|
||||
| `test_mnemonic_input` | ✅ All passed |
|
||||
| `test_path_whitelist` | ✅ 40/41 (1 pre-existing failure, unrelated) |
|
||||
| `test_selector` | ⚠️ Stack smashing detected — **hardening caught a pre-existing latent bug** (unrelated to remediations) |
|
||||
| `test_ml_dsa_65` | ✅ 26/26 passed |
|
||||
| `test_ml_kem_768` | ✅ 29/29 passed |
|
||||
| `test_pq_crypto` | ✅ All passed |
|
||||
|
||||
---
|
||||
|
||||
## Remaining Recommendations (Non-Blocking)
|
||||
|
||||
These items were noted during the audit but are not security findings:
|
||||
|
||||
1. **Fix the latent `test_selector` buffer overflow** now exposed by `-fstack-protector-strong`. This is a pre-existing bug in the test code, not in production code.
|
||||
2. **Apply the same hardening flags to the musl-static build** in [`Dockerfile.alpine-musl`](../Dockerfile.alpine-musl) / [`build_static.sh`](../build_static.sh) (verify musl-gcc supports `-fstack-clash-protection`, GCC 8+).
|
||||
3. **Pin vendored dependency versions** (cJSON, nostr_core_lib, PQClean, secp256k1) to specific commits and track known CVEs.
|
||||
4. **Add fuzz testing** for the HTTP parser and transport frame parser.
|
||||
5. **Document the `--allow-unlocked-memory` flag** in the README security section.
|
||||
|
||||
---
|
||||
|
||||
## Files Changed Summary
|
||||
|
||||
| File | Finding(s) |
|
||||
|------|-----------|
|
||||
| [`src/secure_mem.c`](../src/secure_mem.c) | F-001 |
|
||||
| [`src/main.c`](../src/main.c) | F-001 (flag wiring) |
|
||||
| [`src/http_listener.c`](../src/http_listener.c) | F-002 |
|
||||
| [`src/auth_envelope.h`](../src/auth_envelope.h) | F-003 |
|
||||
| [`src/auth_envelope.c`](../src/auth_envelope.c) | F-003 |
|
||||
| [`src/otp_pad.c`](../src/otp_pad.c) | F-004 |
|
||||
| [`Makefile`](../Makefile) | F-005 |
|
||||
| [`firmware/kb2040_hidden_signer/src/platform/rp2040.c`](../firmware/kb2040_hidden_signer/src/platform/rp2040.c) | F-006 |
|
||||
| [`firmware/kb2040_hidden_signer/src/nostr_core/nip006.c`](../firmware/kb2040_hidden_signer/src/nostr_core/nip006.c) | F-006 (propagation) |
|
||||
| [`src/pq_drbg.c`](../src/pq_drbg.c) | F-007 |
|
||||
| [`src/pq_crypto.c`](../src/pq_crypto.c) | F-008 |
|
||||
+103
-8
@@ -1,21 +1,26 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Build fully static MUSL binary for nsigner using Alpine Docker
|
||||
#
|
||||
# Speed optimization: if nothing changed since the last successful build,
|
||||
# skip the Docker build entirely and reuse the existing binaries.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BUILD_DIR="$SCRIPT_DIR/build"
|
||||
DOCKERFILE="$SCRIPT_DIR/Dockerfile.alpine-musl"
|
||||
HASH_FILE="$BUILD_DIR/.nsigner_build_hash"
|
||||
|
||||
TARGET_ARCH=""
|
||||
FORCE=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--arch)
|
||||
if [[ -z "${2:-}" ]]; then
|
||||
echo "ERROR: --arch requires a value"
|
||||
echo "Usage: $0 [--arch <x86_64|arm64|armv7>]"
|
||||
echo "Usage: $0 [--arch <x86_64|arm64|armv7>] [--force]"
|
||||
exit 1
|
||||
fi
|
||||
case "$2" in
|
||||
@@ -30,9 +35,13 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
shift 2
|
||||
;;
|
||||
--force)
|
||||
FORCE=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unknown argument '$1'"
|
||||
echo "Usage: $0 [--arch <x86_64|arm64|armv7>]"
|
||||
echo "Usage: $0 [--arch <x86_64|arm64|armv7>] [--force]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -70,14 +79,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"
|
||||
PLATFORM="linux/v7"
|
||||
OUTPUT_NAME="nsigner_static_armv7"
|
||||
CLIENT_NAME="nsigner_client_static_armv7"
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unsupported target architecture '$ARCH'"
|
||||
@@ -103,8 +115,65 @@ echo "Project root: $SCRIPT_DIR"
|
||||
echo "Dockerfile: $DOCKERFILE"
|
||||
echo "Platform: $PLATFORM"
|
||||
echo "Output: $BUILD_DIR/$OUTPUT_NAME"
|
||||
echo "Client: $BUILD_DIR/$CLIENT_NAME"
|
||||
echo ""
|
||||
|
||||
# ---- Change detection ----
|
||||
# Compute a hash of all files that feed into the Docker build.
|
||||
# If the hash matches the last successful build and the output binaries
|
||||
# exist, skip the Docker build entirely.
|
||||
compute_source_hash() {
|
||||
{
|
||||
# Dockerfile itself
|
||||
cat "$DOCKERFILE"
|
||||
# .dockerignore
|
||||
cat "$SCRIPT_DIR/.dockerignore" 2>/dev/null || true
|
||||
# All source files
|
||||
find "$SCRIPT_DIR/src" "$SCRIPT_DIR/client" "$SCRIPT_DIR/libotppad" \
|
||||
"$SCRIPT_DIR/resources/tui_continuous" "$SCRIPT_DIR/resources/pqclean" \
|
||||
-type f -not -path '*/.git/*' 2>/dev/null | sort | xargs cat 2>/dev/null
|
||||
# nostr_core_lib source (exclude .git, backups, bare repos, examples, tests)
|
||||
find "$SCRIPT_DIR/resources/nostr_core_lib" \
|
||||
-type f \
|
||||
-not -path '*/.git/*' \
|
||||
-not -path '*/rewrite_mirror/*' \
|
||||
-not -path '*/verify_remote_size*' \
|
||||
-not -path '*/backups/*' \
|
||||
-not -path '*/examples/*' \
|
||||
-not -path '*/tests/*' \
|
||||
-not -path '*/Trash/*' \
|
||||
-not -path '*/node_modules/*' \
|
||||
-not -path '*/nips/*' \
|
||||
-not -path '*/nak/*' \
|
||||
-not -path '*/nostr-tools/*' \
|
||||
-not -path '*/libsodium/*' \
|
||||
-not -path '*/monocypher*' \
|
||||
-not -path '*/tiny-AES-c/*' \
|
||||
-not -path '*/blossom/*' \
|
||||
-not -path '*/ndk/*' \
|
||||
-not -path '*/cline_history/*' \
|
||||
2>/dev/null | sort | xargs cat 2>/dev/null
|
||||
} | sha256sum | awk '{print $1}'
|
||||
}
|
||||
|
||||
CURRENT_HASH="$(compute_source_hash)"
|
||||
OUTPUT_PATH="$BUILD_DIR/$OUTPUT_NAME"
|
||||
CLIENT_PATH="$BUILD_DIR/$CLIENT_NAME"
|
||||
|
||||
if [[ "$FORCE" -eq 0 ]] && \
|
||||
[[ -f "$OUTPUT_PATH" ]] && \
|
||||
[[ -f "$CLIENT_PATH" ]] && \
|
||||
[[ -f "$HASH_FILE" ]] && \
|
||||
[[ "$(cat "$HASH_FILE" 2>/dev/null)" == "$CURRENT_HASH" ]]; then
|
||||
echo "No changes detected since last successful build."
|
||||
echo "Skipping Docker build. Existing binaries:"
|
||||
echo " $OUTPUT_PATH"
|
||||
echo " $CLIENT_PATH"
|
||||
echo ""
|
||||
echo "Use --force to rebuild anyway."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$ARCH" != "$HOST_ARCH" ]; then
|
||||
echo "[0/3] Preparing buildx + QEMU for cross-architecture build"
|
||||
if ! docker buildx inspect >/dev/null 2>&1; then
|
||||
@@ -122,6 +191,10 @@ if [ "$ARCH" != "$HOST_ARCH" ]; then
|
||||
fi
|
||||
|
||||
echo "[1/3] Building builder stage from project root context"
|
||||
# Note: we no longer docker rmi before building. The buildx cache handles
|
||||
# layer reuse, and the prune at the end prevents dangling images. Removing
|
||||
# the image here forced a full --load re-export (~370MB) every time even
|
||||
# when all layers were cache hits.
|
||||
docker buildx build \
|
||||
--platform "$PLATFORM" \
|
||||
--target builder \
|
||||
@@ -130,22 +203,44 @@ 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"
|
||||
|
||||
# Record the source hash so the next run can skip if nothing changed.
|
||||
echo "$CURRENT_HASH" > "$HASH_FILE"
|
||||
|
||||
# Prune stale build cache older than 24h to prevent unbounded cache growth
|
||||
# from repeated buildx builds. Recent layers are kept for fast rebuilds.
|
||||
docker builder prune -af --filter "until=24h" >/dev/null 2>&1 || true
|
||||
|
||||
@@ -0,0 +1,855 @@
|
||||
/*
|
||||
* 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`.
|
||||
*
|
||||
* This client uses the high-level nostr_signer_t API from nostr_core_lib
|
||||
* for all typed verbs. The per-verb cJSON-building logic lives in the
|
||||
* library, not here. The CLI is mostly argv parsing + result printing.
|
||||
*
|
||||
* 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 "nostr_signer.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 <ascii|binary> OTP encoding (default ascii)\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"
|
||||
"\n"
|
||||
" # Qubes qrexec: get the first pubkey from a signer in the nostr_signer qube\n"
|
||||
" %s --qrexec nostr_signer:qubes.NsignerRpc --role nostr_range --path \"m/44'/1237'/0'/0/0\" get-public-key\n",
|
||||
prog, 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 raw string result (from the *_result_json_out wrappers). */
|
||||
static void print_result_str(const char *s) {
|
||||
if (s) {
|
||||
printf("%s\n", s);
|
||||
} else {
|
||||
printf("null\n");
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Transport setup helper */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Opens a transport based on the CLI args. Returns 0 on success.
|
||||
* On success, *out_transport is set (caller must not free if handed to signer). */
|
||||
static int open_transport(const char *socket_name, int timeout_ms,
|
||||
const char *tcp_arg, const char *serial_arg,
|
||||
const char *qrexec_arg,
|
||||
const char *auth_privkey_hex,
|
||||
nsigner_transport_t **out_transport) {
|
||||
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");
|
||||
return -1;
|
||||
}
|
||||
|
||||
*out_transport = NULL;
|
||||
|
||||
if (tcp_arg) {
|
||||
if (!auth_privkey_hex) {
|
||||
fprintf(stderr, "error: --tcp requires --auth-privkey\n");
|
||||
return -1;
|
||||
}
|
||||
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");
|
||||
return -1;
|
||||
}
|
||||
*out_transport = nsigner_transport_open_tcp(host, port, timeout_ms);
|
||||
free(host);
|
||||
if (!*out_transport) {
|
||||
fprintf(stderr, "error: cannot open TCP transport to %s\n", tcp_arg);
|
||||
return -1;
|
||||
}
|
||||
} else if (serial_arg) {
|
||||
*out_transport = nsigner_transport_open_serial(serial_arg, timeout_ms);
|
||||
if (!*out_transport) {
|
||||
fprintf(stderr, "error: cannot open serial transport on %s\n", serial_arg);
|
||||
return -1;
|
||||
}
|
||||
} 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");
|
||||
return -1;
|
||||
}
|
||||
*out_transport = nsigner_transport_open_qrexec(qube, service, timeout_ms);
|
||||
free(qube);
|
||||
free(service);
|
||||
if (!*out_transport) {
|
||||
fprintf(stderr, "error: cannot open qrexec transport to %s\n", qrexec_arg);
|
||||
return -1;
|
||||
}
|
||||
} else if (socket_name) {
|
||||
*out_transport = nsigner_transport_open_unix(socket_name, timeout_ms);
|
||||
if (!*out_transport) {
|
||||
fprintf(stderr, "error: cannot open unix transport %s\n", socket_name);
|
||||
return -1;
|
||||
}
|
||||
} 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");
|
||||
return -1;
|
||||
}
|
||||
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]);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
*out_transport = nsigner_transport_open_unix(names[0], timeout_ms);
|
||||
if (!*out_transport) {
|
||||
fprintf(stderr, "error: cannot open unix transport %s\n", names[0]);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 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 edsa\n"); return 2; }
|
||||
scheme = argv[++i];
|
||||
} else if (strcmp(arg, "--encoding") == 0) {
|
||||
if (i + 1 >= argc) { fprintf(stderr, "error: --encoding requires ascii or binary\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);
|
||||
|
||||
/* ---- 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;
|
||||
}
|
||||
|
||||
/* ---- open transport ---- */
|
||||
nsigner_transport_t *transport = NULL;
|
||||
if (open_transport(socket_name, timeout_ms, tcp_arg, serial_arg, qrexec_arg,
|
||||
auth_privkey_hex, &transport) != 0) {
|
||||
nostr_cleanup();
|
||||
return 2;
|
||||
}
|
||||
|
||||
/* ---- create low-level client (owns transport) ---- */
|
||||
nsigner_client_t *client = nsigner_client_new(transport);
|
||||
if (!client) {
|
||||
fprintf(stderr, "error: cannot create nsigner client\n");
|
||||
transport->close(transport);
|
||||
nostr_cleanup();
|
||||
return 2;
|
||||
}
|
||||
transport = NULL; /* owned by client */
|
||||
|
||||
/* ---- create high-level signer from client (shares the connection) ---- */
|
||||
nostr_signer_t *signer = nostr_signer_nsigner_from_client(client, role);
|
||||
if (!signer) {
|
||||
fprintf(stderr, "error: cannot create nsigner signer\n");
|
||||
nsigner_client_free(client);
|
||||
nostr_cleanup();
|
||||
return 2;
|
||||
}
|
||||
/* signer now owns client; don't free it separately */
|
||||
|
||||
/* ---- set role_path selector for nostr verbs ---- */
|
||||
if (is_nostr_verb && !is_algorithm_verb && path) {
|
||||
if (nostr_signer_nsigner_set_role_path(signer, path) != NOSTR_SUCCESS) {
|
||||
fprintf(stderr, "error: failed to set role_path\n");
|
||||
nostr_signer_free(signer);
|
||||
nostr_cleanup();
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 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");
|
||||
nostr_signer_free(signer);
|
||||
nostr_cleanup();
|
||||
return 2;
|
||||
}
|
||||
if (nostr_signer_nsigner_set_auth(signer, privkey, auth_label ? auth_label : "") != NOSTR_SUCCESS) {
|
||||
fprintf(stderr, "error: failed to set auth envelope\n");
|
||||
nostr_signer_free(signer);
|
||||
nostr_cleanup();
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- resolve algorithm index ---- */
|
||||
int eff_index = has_alg_index ? alg_index : (has_index ? index_val : 0);
|
||||
|
||||
int rc = 2;
|
||||
char *result_str = NULL;
|
||||
cJSON *result_obj = NULL;
|
||||
|
||||
/* ---- dispatch verbs via high-level library wrappers ---- */
|
||||
if (strcmp(verb, "get-info") == 0) {
|
||||
rc = nostr_signer_get_info(signer, &result_obj);
|
||||
if (rc != NOSTR_SUCCESS) {
|
||||
fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer));
|
||||
goto cleanup;
|
||||
}
|
||||
char *json = cJSON_PrintUnformatted(result_obj);
|
||||
if (json) { printf("%s\n", json); free(json); }
|
||||
rc = 0;
|
||||
} else if (strcmp(verb, "get-public-key") == 0) {
|
||||
if (is_algorithm_verb) {
|
||||
rc = nostr_signer_get_public_key_alg(signer, algorithm, eff_index, &result_str);
|
||||
if (rc != NOSTR_SUCCESS) {
|
||||
fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer));
|
||||
goto cleanup;
|
||||
}
|
||||
print_result_str(result_str);
|
||||
rc = 0;
|
||||
} else if (format && strcmp(format, "structured") == 0) {
|
||||
/* Structured format: use low-level client to pass the format option. */
|
||||
cJSON *params = cJSON_CreateArray();
|
||||
cJSON *opts = cJSON_CreateObject();
|
||||
if (role) cJSON_AddStringToObject(opts, "role", role);
|
||||
if (path) cJSON_AddStringToObject(opts, "role_path", path);
|
||||
cJSON_AddStringToObject(opts, "format", "structured");
|
||||
cJSON_AddItemToArray(params, opts);
|
||||
cJSON *presult = NULL;
|
||||
rc = nsigner_client_call(client, "nostr_get_public_key", params, &presult);
|
||||
if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nsigner_client_last_error(client)); goto cleanup; }
|
||||
if (cJSON_IsString(presult)) {
|
||||
print_result_str(presult->valuestring);
|
||||
} else if (presult) {
|
||||
char *json = cJSON_PrintUnformatted(presult);
|
||||
if (json) { printf("%s\n", json); free(json); }
|
||||
}
|
||||
cJSON_Delete(presult);
|
||||
rc = 0;
|
||||
} else {
|
||||
char pubkey_hex[65];
|
||||
rc = nostr_signer_get_public_key(signer, pubkey_hex);
|
||||
if (rc != NOSTR_SUCCESS) {
|
||||
fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer));
|
||||
goto cleanup;
|
||||
}
|
||||
printf("%s\n", pubkey_hex);
|
||||
rc = 0;
|
||||
}
|
||||
} else if (strcmp(verb, "sign-event") == 0) {
|
||||
const char *event_json = arg1;
|
||||
char *event_buf = NULL;
|
||||
if (!event_json) {
|
||||
event_buf = read_stdin_line();
|
||||
if (!event_buf) {
|
||||
fprintf(stderr, "error: no event JSON provided (pass as argument or pipe to stdin)\n");
|
||||
goto cleanup;
|
||||
}
|
||||
event_json = event_buf;
|
||||
}
|
||||
cJSON *event = cJSON_Parse(event_json);
|
||||
free(event_buf);
|
||||
if (!event) {
|
||||
fprintf(stderr, "error: failed to parse event JSON\n");
|
||||
goto cleanup;
|
||||
}
|
||||
rc = nostr_signer_sign_event(signer, event, &result_obj);
|
||||
cJSON_Delete(event);
|
||||
if (rc != NOSTR_SUCCESS) {
|
||||
fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer));
|
||||
goto cleanup;
|
||||
}
|
||||
char *json = cJSON_PrintUnformatted(result_obj);
|
||||
if (json) { printf("%s\n", json); free(json); }
|
||||
rc = 0;
|
||||
} else if (strcmp(verb, "mine-event") == 0) {
|
||||
const char *event_json = arg1;
|
||||
char *event_buf = NULL;
|
||||
if (!event_json) {
|
||||
event_buf = read_stdin_line();
|
||||
if (!event_buf) {
|
||||
fprintf(stderr, "error: no event JSON provided (pass as argument or pipe to stdin)\n");
|
||||
goto cleanup;
|
||||
}
|
||||
event_json = event_buf;
|
||||
}
|
||||
cJSON *event = cJSON_Parse(event_json);
|
||||
free(event_buf);
|
||||
if (!event) {
|
||||
fprintf(stderr, "error: failed to parse event JSON\n");
|
||||
goto cleanup;
|
||||
}
|
||||
rc = nostr_signer_mine_event(signer, event,
|
||||
has_difficulty ? difficulty_val : 0,
|
||||
has_timeout_sec ? timeout_sec_val : 0,
|
||||
has_threads ? threads_val : 1,
|
||||
&result_obj);
|
||||
cJSON_Delete(event);
|
||||
if (rc != NOSTR_SUCCESS) {
|
||||
fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer));
|
||||
goto cleanup;
|
||||
}
|
||||
char *json = cJSON_PrintUnformatted(result_obj);
|
||||
if (json) { printf("%s\n", json); free(json); }
|
||||
rc = 0;
|
||||
} else if (strcmp(verb, "nip04-encrypt") == 0) {
|
||||
if (!arg1) { fprintf(stderr, "error: nip04-encrypt requires <peer-pubkey>\n"); goto cleanup; }
|
||||
const char *plaintext = arg2;
|
||||
char *pt_buf = NULL;
|
||||
if (!plaintext) {
|
||||
pt_buf = read_stdin_line();
|
||||
if (!pt_buf) { fprintf(stderr, "error: no plaintext provided\n"); goto cleanup; }
|
||||
plaintext = pt_buf;
|
||||
}
|
||||
rc = nostr_signer_nip04_encrypt(signer, arg1, plaintext, &result_str);
|
||||
free(pt_buf);
|
||||
if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; }
|
||||
print_result_str(result_str);
|
||||
rc = 0;
|
||||
} else if (strcmp(verb, "nip04-decrypt") == 0) {
|
||||
if (!arg1) { fprintf(stderr, "error: nip04-decrypt requires <peer-pubkey>\n"); goto cleanup; }
|
||||
const char *ciphertext = arg2;
|
||||
char *ct_buf = NULL;
|
||||
if (!ciphertext) {
|
||||
ct_buf = read_stdin_line();
|
||||
if (!ct_buf) { fprintf(stderr, "error: no ciphertext provided\n"); goto cleanup; }
|
||||
ciphertext = ct_buf;
|
||||
}
|
||||
rc = nostr_signer_nip04_decrypt(signer, arg1, ciphertext, &result_str);
|
||||
free(ct_buf);
|
||||
if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; }
|
||||
print_result_str(result_str);
|
||||
rc = 0;
|
||||
} else if (strcmp(verb, "nip44-encrypt") == 0) {
|
||||
if (!arg1) { fprintf(stderr, "error: nip44-encrypt requires <peer-pubkey>\n"); goto cleanup; }
|
||||
const char *plaintext = arg2;
|
||||
char *pt_buf = NULL;
|
||||
if (!plaintext) {
|
||||
pt_buf = read_stdin_line();
|
||||
if (!pt_buf) { fprintf(stderr, "error: no plaintext provided\n"); goto cleanup; }
|
||||
plaintext = pt_buf;
|
||||
}
|
||||
rc = nostr_signer_nip44_encrypt(signer, arg1, plaintext, &result_str);
|
||||
free(pt_buf);
|
||||
if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; }
|
||||
print_result_str(result_str);
|
||||
rc = 0;
|
||||
} else if (strcmp(verb, "nip44-decrypt") == 0) {
|
||||
if (!arg1) { fprintf(stderr, "error: nip44-decrypt requires <peer-pubkey>\n"); goto cleanup; }
|
||||
const char *ciphertext = arg2;
|
||||
char *ct_buf = NULL;
|
||||
if (!ciphertext) {
|
||||
ct_buf = read_stdin_line();
|
||||
if (!ct_buf) { fprintf(stderr, "error: no ciphertext provided\n"); goto cleanup; }
|
||||
ciphertext = ct_buf;
|
||||
}
|
||||
rc = nostr_signer_nip44_decrypt(signer, arg1, ciphertext, &result_str);
|
||||
free(ct_buf);
|
||||
if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; }
|
||||
print_result_str(result_str);
|
||||
rc = 0;
|
||||
} else if (strcmp(verb, "sign") == 0) {
|
||||
if (!arg1) { fprintf(stderr, "error: sign requires <msg-hex>\n"); goto cleanup; }
|
||||
size_t msg_len = strlen(arg1) / 2;
|
||||
unsigned char *msg = malloc(msg_len ? msg_len : 1);
|
||||
if (!msg) { fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
int n = hex_to_bytes(arg1, msg, msg_len);
|
||||
if (n < 0) { free(msg); fprintf(stderr, "error: invalid hex message\n"); goto cleanup; }
|
||||
rc = nostr_signer_sign(signer, algorithm ? algorithm : "secp256k1",
|
||||
eff_index, scheme, msg, (size_t)n, &result_str);
|
||||
free(msg);
|
||||
if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; }
|
||||
print_result_str(result_str);
|
||||
rc = 0;
|
||||
} else if (strcmp(verb, "verify") == 0) {
|
||||
if (!arg1 || !arg2) { fprintf(stderr, "error: verify requires <msg-hex> <sig-hex>\n"); goto cleanup; }
|
||||
size_t msg_len = strlen(arg1) / 2;
|
||||
size_t sig_len = strlen(arg2) / 2;
|
||||
unsigned char *msg = malloc(msg_len ? msg_len : 1);
|
||||
unsigned char *sig = malloc(sig_len ? sig_len : 1);
|
||||
if (!msg || !sig) { free(msg); free(sig); fprintf(stderr, "error: out of memory\n"); goto cleanup; }
|
||||
int mn = hex_to_bytes(arg1, msg, msg_len);
|
||||
int sn = hex_to_bytes(arg2, sig, sig_len);
|
||||
if (mn < 0 || sn < 0) { free(msg); free(sig); fprintf(stderr, "error: invalid hex\n"); goto cleanup; }
|
||||
int valid = 0;
|
||||
rc = nostr_signer_verify(signer, algorithm ? algorithm : "secp256k1",
|
||||
eff_index, scheme, msg, (size_t)mn, sig, (size_t)sn, &valid);
|
||||
free(msg);
|
||||
free(sig);
|
||||
if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; }
|
||||
printf("%s\n", valid ? "valid" : "invalid");
|
||||
rc = valid ? 0 : 1;
|
||||
} else if (strcmp(verb, "derive") == 0) {
|
||||
const char *data = arg1;
|
||||
char *data_buf = NULL;
|
||||
if (!data) {
|
||||
data_buf = read_stdin_line();
|
||||
if (!data_buf) { fprintf(stderr, "error: no data provided\n"); goto cleanup; }
|
||||
data = data_buf;
|
||||
}
|
||||
if (is_algorithm_verb) {
|
||||
/* Algorithm-based derive: use low-level client to pass algorithm+index. */
|
||||
cJSON *params = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(params, cJSON_CreateString(data));
|
||||
cJSON *opts = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(opts, "algorithm", algorithm ? algorithm : "secp256k1");
|
||||
cJSON_AddNumberToObject(opts, "index", eff_index);
|
||||
cJSON_AddItemToArray(params, opts);
|
||||
cJSON *dresult = NULL;
|
||||
rc = nsigner_client_call(client, "derive", params, &dresult);
|
||||
free(data_buf);
|
||||
if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nsigner_client_last_error(client)); goto cleanup; }
|
||||
if (cJSON_IsString(dresult)) {
|
||||
/* The derive result is a JSON object string like
|
||||
* {"algorithm":"secp256k1","key_id":"...","digest":"<64hex>"}.
|
||||
* Print the raw result string. */
|
||||
print_result_str(dresult->valuestring);
|
||||
}
|
||||
cJSON_Delete(dresult);
|
||||
rc = 0;
|
||||
} else {
|
||||
/* Nostr derive (HMAC): use the high-level wrapper. */
|
||||
char digest_hex[65];
|
||||
rc = nostr_signer_derive_hmac(signer, data, digest_hex);
|
||||
free(data_buf);
|
||||
if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; }
|
||||
printf("%s\n", digest_hex);
|
||||
rc = 0;
|
||||
}
|
||||
} else if (strcmp(verb, "encapsulate") == 0) {
|
||||
if (!arg1) { fprintf(stderr, "error: encapsulate requires <peer-pubkey-hex>\n"); goto cleanup; }
|
||||
rc = nostr_signer_encapsulate(signer, arg1, &result_str);
|
||||
if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; }
|
||||
print_result_str(result_str);
|
||||
rc = 0;
|
||||
} else if (strcmp(verb, "decapsulate") == 0) {
|
||||
if (!arg1) { fprintf(stderr, "error: decapsulate requires <ciphertext-hex>\n"); goto cleanup; }
|
||||
rc = nostr_signer_decapsulate(signer, eff_index, arg1, &result_str);
|
||||
if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; }
|
||||
print_result_str(result_str);
|
||||
rc = 0;
|
||||
} else if (strcmp(verb, "derive-shared-secret") == 0) {
|
||||
if (!arg1) { fprintf(stderr, "error: derive-shared-secret requires <peer-pubkey-hex>\n"); goto cleanup; }
|
||||
rc = nostr_signer_derive_shared_secret(signer, eff_index, arg1, &result_str);
|
||||
if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; }
|
||||
print_result_str(result_str);
|
||||
rc = 0;
|
||||
} else if (strcmp(verb, "encrypt") == 0) {
|
||||
const char *plaintext = arg1;
|
||||
char *pt_buf = NULL;
|
||||
if (!plaintext) {
|
||||
pt_buf = read_stdin_line();
|
||||
if (!pt_buf) { fprintf(stderr, "error: no plaintext provided\n"); goto cleanup; }
|
||||
plaintext = pt_buf;
|
||||
}
|
||||
rc = nostr_signer_otp_encrypt(signer, plaintext, encoding, &result_str);
|
||||
free(pt_buf);
|
||||
if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; }
|
||||
print_result_str(result_str);
|
||||
rc = 0;
|
||||
} else if (strcmp(verb, "decrypt") == 0) {
|
||||
const char *ciphertext = arg1;
|
||||
char *ct_buf = NULL;
|
||||
if (!ciphertext) {
|
||||
ct_buf = read_stdin_line();
|
||||
if (!ct_buf) { fprintf(stderr, "error: no ciphertext provided\n"); goto cleanup; }
|
||||
ciphertext = ct_buf;
|
||||
}
|
||||
rc = nostr_signer_otp_decrypt(signer, ciphertext, encoding, &result_str);
|
||||
free(ct_buf);
|
||||
if (rc != NOSTR_SUCCESS) { fprintf(stderr, "error: %s\n", nostr_signer_last_error(signer)); goto cleanup; }
|
||||
print_result_str(result_str);
|
||||
rc = 0;
|
||||
} else if (strcmp(verb, "call") == 0) {
|
||||
/* Raw passthrough using the low-level client (shared with signer). */
|
||||
if (!arg1) { fprintf(stderr, "error: call requires <method>\n"); goto cleanup; }
|
||||
const char *method = arg1;
|
||||
|
||||
cJSON *params = NULL;
|
||||
if (arg2) {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
cJSON *call_result = NULL;
|
||||
if (nsigner_client_call(client, method, params, &call_result) != NOSTR_SUCCESS) {
|
||||
fprintf(stderr, "error: %s\n", nsigner_client_last_error(client));
|
||||
goto cleanup;
|
||||
}
|
||||
if (call_result) {
|
||||
char *json = cJSON_PrintUnformatted(call_result);
|
||||
if (json) { printf("%s\n", json); free(json); }
|
||||
cJSON_Delete(call_result);
|
||||
}
|
||||
rc = 0;
|
||||
} else {
|
||||
fprintf(stderr, "error: unknown verb: %s\n", verb);
|
||||
fprintf(stderr, "Try '%s --help' for usage.\n", prog);
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
cleanup:
|
||||
if (result_str) free(result_str);
|
||||
if (result_obj) cJSON_Delete(result_obj);
|
||||
if (signer) nostr_signer_free(signer);
|
||||
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
+140
@@ -0,0 +1,140 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# deploy_local.sh — Build static nsigner + nsigner_client binaries
|
||||
# and install them to /usr/local/bin/
|
||||
#
|
||||
# Usage:
|
||||
# ./deploy_local.sh # build + install (uses sudo if needed)
|
||||
# ./deploy_local.sh --no-build # install existing build/ binaries only
|
||||
#
|
||||
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
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--no-build)
|
||||
DO_BUILD=false
|
||||
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 " -h, --help Show this help message"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unknown argument '$1'"
|
||||
echo "Usage: $0 [--no-build]"
|
||||
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"
|
||||
|
||||
# --- 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 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_range` | `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]
|
||||
```
|
||||
@@ -13,21 +13,8 @@
|
||||
# include "pico/rand.h"
|
||||
# define NOSTR_HAVE_PICO_RAND 1
|
||||
# endif
|
||||
# if __has_include("hardware/adc.h")
|
||||
# include "hardware/adc.h"
|
||||
# define NOSTR_HAVE_PICO_ADC 1
|
||||
# endif
|
||||
#endif
|
||||
|
||||
static uint32_t xorshift32(uint32_t *state) {
|
||||
uint32_t x = *state ? *state : 0xA5A5A5A5u;
|
||||
x ^= x << 13;
|
||||
x ^= x >> 17;
|
||||
x ^= x << 5;
|
||||
*state = x;
|
||||
return x;
|
||||
}
|
||||
|
||||
int nostr_platform_random(unsigned char *buf, size_t len) {
|
||||
if (!buf) {
|
||||
return -1;
|
||||
@@ -47,33 +34,14 @@ int nostr_platform_random(unsigned char *buf, size_t len) {
|
||||
}
|
||||
return 0;
|
||||
#else
|
||||
uint32_t seed = 0x13579BDFu;
|
||||
|
||||
#if defined(ARDUINO)
|
||||
seed ^= (uint32_t)micros();
|
||||
seed ^= ((uint32_t)millis() << 16);
|
||||
seed ^= (uint32_t)(uintptr_t)&seed;
|
||||
#endif
|
||||
|
||||
#if defined(NOSTR_HAVE_PICO_ADC)
|
||||
adc_init();
|
||||
adc_set_temp_sensor_enabled(true);
|
||||
adc_select_input(4);
|
||||
for (int k = 0; k < 16; ++k) {
|
||||
seed ^= ((uint32_t)adc_read() << ((k & 3) * 8));
|
||||
}
|
||||
#endif
|
||||
|
||||
while (i < len) {
|
||||
uint32_t r = xorshift32(&seed);
|
||||
#if defined(ARDUINO)
|
||||
r ^= (uint32_t)micros();
|
||||
#endif
|
||||
size_t take = (len - i >= 4) ? 4 : (len - i);
|
||||
memcpy(buf + i, &r, take);
|
||||
i += take;
|
||||
}
|
||||
|
||||
return 0;
|
||||
/* No hardware RNG available — fail closed.
|
||||
* The Pico SDK's get_rand_32() (ring-oscillator-based TRNG) is available
|
||||
* on all official RP2040 boards. Without it, we cannot provide secure
|
||||
* randomness for key generation. The previous xorshift32 fallback was
|
||||
* removed because it was cryptographically weak (32-bit state, predictable
|
||||
* from boot timing). See audit/F-006-rp2040-xorshift-fallback-rng.md. */
|
||||
(void)i;
|
||||
(void)len;
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -478,6 +478,48 @@ void setup() {
|
||||
dispatch_init();
|
||||
Serial.println("Dispatch initialized.");
|
||||
|
||||
// ---- Role table (Phase 6 of teensy41_role_path_migration.md) ----
|
||||
// The role table (g_roles, defined in dispatch.cpp as DMAMEM) must be
|
||||
// populated before any nostr_* verb can be dispatched. In interactive mode
|
||||
// the user picks presets via the LVGL wizard; in DEBUG_AUTO_GENERATE mode
|
||||
// a single "main" role is auto-created so headless tests work.
|
||||
role_table_init(&g_roles);
|
||||
#if DEBUG_AUTO_GENERATE
|
||||
{
|
||||
Serial.println("DEBUG_AUTO_GENERATE=1: auto-creating 'main' role.");
|
||||
role_entry_t entry;
|
||||
memset(&entry, 0, sizeof(entry));
|
||||
strncpy(entry.name, "main", sizeof(entry.name) - 1);
|
||||
strncpy(entry.role_path, "m/44'/1237'/0'/0/0", sizeof(entry.role_path) - 1);
|
||||
entry.purpose = ROLE_PURPOSE_NOSTR;
|
||||
entry.curve = ROLE_CURVE_SECP256K1;
|
||||
entry.path_range_lo = -1;
|
||||
entry.path_range_hi = -1;
|
||||
entry.path_default_index = -1;
|
||||
entry.requires_approval = 0; /* role-as-password */
|
||||
if (role_table_add(&g_roles, &entry) != 0) {
|
||||
Serial.println("WARNING: failed to auto-create 'main' role");
|
||||
} else {
|
||||
Serial.print("Auto-created role 'main' (");
|
||||
Serial.print(entry.role_path);
|
||||
Serial.println(")");
|
||||
}
|
||||
}
|
||||
#else
|
||||
{
|
||||
Serial.println("Starting role wizard...");
|
||||
if (ui_role_wizard(&g_roles) != 0) {
|
||||
Serial.println("Role wizard cancelled or failed — aborting boot.");
|
||||
// Show an error screen and halt.
|
||||
build_busy_screen("No roles defined.\nReboot to try again.");
|
||||
while (1) { /* halt */ }
|
||||
}
|
||||
Serial.print("Role wizard complete: ");
|
||||
Serial.print(g_roles.count);
|
||||
Serial.println(" role(s) defined.");
|
||||
}
|
||||
#endif
|
||||
|
||||
// Initialize the USB CDC transport.
|
||||
transport_init();
|
||||
Serial.println("Transport initialized.");
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
#include "otp_pad_sd.h"
|
||||
#include "key_derivation.h"
|
||||
#include "ed25519.h"
|
||||
#include "role_table.h"
|
||||
#include "selector.h"
|
||||
|
||||
#include "secp256k1/include/secp256k1.h"
|
||||
#include "secp256k1/include/secp256k1_extrakeys.h"
|
||||
@@ -70,6 +72,11 @@ char g_npub[128];
|
||||
char g_pubkey_hex[65];
|
||||
int g_signer_ready = 0;
|
||||
|
||||
/* ---- Role table (populated by signer.ino after the role wizard) ----
|
||||
* In DMAMEM (RAM2) to keep RAM1 free for ITCM code. 16 entries × ~240 bytes
|
||||
* = ~3.8 KB, negligible against the 110 KB free heap. */
|
||||
DMAMEM role_table_t g_roles;
|
||||
|
||||
/* ---- Persistent crash diagnostics (defined in signer.ino, DMAMEM) ---- */
|
||||
extern "C" volatile uint32_t g_last_op;
|
||||
extern "C" volatile uint32_t g_last_op_seq;
|
||||
@@ -146,6 +153,13 @@ typedef enum {
|
||||
#define ERR_APPROVAL_TIMEOUT -32001
|
||||
#define ERR_ALG_NOT_SUPPORTED 1010
|
||||
#define ERR_MINING_FAILED 1008
|
||||
/* Role + path authorization errors (match the host's error codes). */
|
||||
#define ERR_UNKNOWN_ROLE 1002
|
||||
#define ERR_PATH_NOT_ALLOWED 2003
|
||||
#define ERR_NOSTR_INDEX_DEPRECATED 2006
|
||||
#define ERR_ROLE_REQUIRED 2007
|
||||
#define ERR_PATH_REQUIRED 2008
|
||||
#define ERR_NO_DEFAULT_ROLE 2009
|
||||
|
||||
/* Auth envelope error messages (indexed by AUTH_ERR_* code). */
|
||||
static const char *auth_err_message(int code) {
|
||||
@@ -920,15 +934,237 @@ __attribute__((section(".flashmem"))) static int derive_request_key(uint32_t nos
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Parse [peer_hex, message, {options}] from params. */
|
||||
/* ====================================================================
|
||||
* Role + path selector helpers (Phase 4 of teensy41_role_path_migration.md)
|
||||
* ==================================================================== */
|
||||
|
||||
/* Compile-time flag: when 1, nostr_index is silently accepted (mapped to the
|
||||
* default role's path) so the old test_signer.py can run during migration.
|
||||
* When 0 (the default), nostr_index is rejected with error 2006, matching
|
||||
* the host. Flip to 1 only for the transition period. */
|
||||
#ifndef ALLOW_DEPRECATED_NOSTR_INDEX
|
||||
#define ALLOW_DEPRECATED_NOSTR_INDEX 0
|
||||
#endif
|
||||
|
||||
/* Parse the selector fields (role, role_path, nostr_index) from the trailing
|
||||
* options object of a params array. Returns 0 on success (fields left at
|
||||
* their defaults if absent). Returns -1 if the params shape is invalid. */
|
||||
__attribute__((section(".flashmem"))) static int parse_selector_from_params(cJSON *params,
|
||||
selector_request_t *out) {
|
||||
int n;
|
||||
cJSON *last, *item;
|
||||
|
||||
if (out == NULL) {
|
||||
return -1;
|
||||
}
|
||||
selector_request_init(out);
|
||||
|
||||
if (params == NULL || !cJSON_IsArray(params)) {
|
||||
return 0; /* no options → empty selector (will use default role) */
|
||||
}
|
||||
n = cJSON_GetArraySize(params);
|
||||
if (n <= 0) {
|
||||
return 0;
|
||||
}
|
||||
last = cJSON_GetArrayItem(params, n - 1);
|
||||
if (last == NULL || !cJSON_IsObject(last)) {
|
||||
return 0; /* no options object → empty selector */
|
||||
}
|
||||
|
||||
item = cJSON_GetObjectItemCaseSensitive(last, "role");
|
||||
if (cJSON_IsString(item) && item->valuestring != NULL) {
|
||||
out->has_role = 1;
|
||||
strncpy(out->role_name, item->valuestring, sizeof(out->role_name) - 1);
|
||||
out->role_name[sizeof(out->role_name) - 1] = '\0';
|
||||
}
|
||||
|
||||
item = cJSON_GetObjectItemCaseSensitive(last, "role_path");
|
||||
if (cJSON_IsString(item) && item->valuestring != NULL) {
|
||||
out->has_role_path = 1;
|
||||
strncpy(out->role_path, item->valuestring, sizeof(out->role_path) - 1);
|
||||
out->role_path[sizeof(out->role_path) - 1] = '\0';
|
||||
}
|
||||
|
||||
item = cJSON_GetObjectItemCaseSensitive(last, "nostr_index");
|
||||
if (cJSON_IsNumber(item)) {
|
||||
int idx = item->valueint;
|
||||
if (idx >= 0) {
|
||||
out->has_nostr_index = 1;
|
||||
out->nostr_index = (uint32_t)idx;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Map a selector_resolve() error code to a wire-protocol error code + message.
|
||||
* Writes the error response into s_response_buf. */
|
||||
__attribute__((section(".flashmem"))) static void selector_err_to_response(const char *id_token, int sel_err) {
|
||||
switch (sel_err) {
|
||||
case SELECTOR_ERR_NOSTR_INDEX_DEPRECATED:
|
||||
set_error_code(id_token, ERR_NOSTR_INDEX_DEPRECATED,
|
||||
"nostr_index is deprecated - use role + role_path "
|
||||
"(e.g. role=main, role_path=m/44'1237'0'/0/0)");
|
||||
break;
|
||||
case SELECTOR_ERR_NOT_FOUND:
|
||||
set_error_code(id_token, ERR_UNKNOWN_ROLE, "unknown_role");
|
||||
break;
|
||||
case SELECTOR_ERR_PATH_MISMATCH:
|
||||
set_error_code(id_token, ERR_PATH_NOT_ALLOWED, "path_not_allowed");
|
||||
break;
|
||||
case SELECTOR_ERR_ROLE_REQUIRED:
|
||||
set_error_code(id_token, ERR_ROLE_REQUIRED, "role_required");
|
||||
break;
|
||||
case SELECTOR_ERR_PATH_REQUIRED:
|
||||
set_error_code(id_token, ERR_PATH_REQUIRED, "path_required");
|
||||
break;
|
||||
case SELECTOR_ERR_NO_DEFAULT:
|
||||
set_error_code(id_token, ERR_NO_DEFAULT_ROLE, "no_default_role");
|
||||
break;
|
||||
default:
|
||||
set_error_code(id_token, ERR_INVALID_PARAMS, "invalid params");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* Resolve a nostr_* request's selector and derive the secp256k1 keypair from
|
||||
* the resolved path. Replaces the old parse_nostr_index_from_params +
|
||||
* derive_request_key pattern.
|
||||
*
|
||||
* On success: fills privkey_out[32] and pubkey_hex_out[65], returns 0.
|
||||
* On failure: writes the error response into s_response_buf and returns -1.
|
||||
* The caller should `return;` immediately on a -1 return.
|
||||
*
|
||||
* `out_role` (if non-NULL) receives the resolved role entry pointer so the
|
||||
* caller can check role->requires_approval. */
|
||||
__attribute__((section(".flashmem"))) static int resolve_nostr_request_key(cJSON *params,
|
||||
const char *id_token,
|
||||
uint8_t privkey_out[32],
|
||||
char pubkey_hex_out[65],
|
||||
role_entry_t **out_role) {
|
||||
selector_request_t req;
|
||||
role_entry_t *role = NULL;
|
||||
int sel_rc;
|
||||
const char *path_to_derive = NULL;
|
||||
char concrete_path[ROLE_PATH_MAX];
|
||||
uint8_t pubkey[32];
|
||||
|
||||
if (privkey_out == NULL || pubkey_hex_out == NULL || id_token == NULL) {
|
||||
return -1;
|
||||
}
|
||||
if (out_role != NULL) {
|
||||
*out_role = NULL;
|
||||
}
|
||||
|
||||
memset(privkey_out, 0, 32);
|
||||
memset(pubkey_hex_out, 0, 65);
|
||||
memset(concrete_path, 0, sizeof(concrete_path));
|
||||
|
||||
if (g_seed_len == 0) {
|
||||
set_error_code(id_token, ERR_INTERNAL, "signer not ready (no mnemonic)");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (parse_selector_from_params(params, &req) != 0) {
|
||||
set_error_code(id_token, ERR_INVALID_PARAMS, "invalid params");
|
||||
return -1;
|
||||
}
|
||||
|
||||
#if ALLOW_DEPRECATED_NOSTR_INDEX
|
||||
/* Migration shim: if nostr_index is present, map it to the default role
|
||||
* with the NIP-06 path m/44'/1237'/0'/0/<index>. This lets the old
|
||||
* test_signer.py run during the transition. */
|
||||
if (req.has_nostr_index && !req.has_role && !req.has_role_path) {
|
||||
snprintf(concrete_path, sizeof(concrete_path),
|
||||
"m/44'/1237'/0'/0/%u", req.nostr_index);
|
||||
path_to_derive = concrete_path;
|
||||
/* Skip selector_resolve — derive directly from the constructed path. */
|
||||
if (derive_secp256k1_from_path(g_seed, g_seed_len, path_to_derive,
|
||||
privkey_out, pubkey) != 0) {
|
||||
set_error_code(id_token, ERR_INTERNAL, "key derivation failed");
|
||||
return -1;
|
||||
}
|
||||
bytes_to_hex(pubkey, 32, pubkey_hex_out, 65);
|
||||
secure_memzero(pubkey, sizeof(pubkey));
|
||||
/* No role → default to requires_approval=0 (role-as-password). */
|
||||
if (out_role != NULL) {
|
||||
role = role_table_get_default(&g_roles);
|
||||
*out_role = role; /* may be NULL if no roles configured */
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
sel_rc = selector_resolve(&req, &g_roles, &role);
|
||||
if (sel_rc != SELECTOR_OK) {
|
||||
selector_err_to_response(id_token, sel_rc);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Determine the concrete path to derive from. */
|
||||
if (req.has_role_path) {
|
||||
/* Client supplied a concrete path that selector_resolve verified
|
||||
* matches the role's template + range. Use it directly. */
|
||||
path_to_derive = req.role_path;
|
||||
} else if (strstr(role->role_path, "%d") == NULL) {
|
||||
/* Fixed path (no %d) — use the role's path. */
|
||||
path_to_derive = role->role_path;
|
||||
} else {
|
||||
/* Template path with no client-supplied path — use the default index. */
|
||||
int idx = role->path_default_index;
|
||||
if (idx < 0) {
|
||||
set_error_code(id_token, ERR_PATH_REQUIRED, "path_required");
|
||||
return -1;
|
||||
}
|
||||
{
|
||||
const char *pct = strstr(role->role_path, "%d");
|
||||
size_t prefix_len = (size_t)(pct - role->role_path);
|
||||
const char *tail = pct + 2; /* skip "%d" */
|
||||
snprintf(concrete_path, sizeof(concrete_path), "%.*s%d%s",
|
||||
(int)prefix_len, role->role_path, idx, tail);
|
||||
}
|
||||
path_to_derive = concrete_path;
|
||||
}
|
||||
|
||||
if (derive_secp256k1_from_path(g_seed, g_seed_len, path_to_derive,
|
||||
privkey_out, pubkey) != 0) {
|
||||
set_error_code(id_token, ERR_INTERNAL, "key derivation failed");
|
||||
secure_memzero(pubkey, sizeof(pubkey));
|
||||
return -1;
|
||||
}
|
||||
bytes_to_hex(pubkey, 32, pubkey_hex_out, 65);
|
||||
secure_memzero(pubkey, sizeof(pubkey));
|
||||
|
||||
if (out_role != NULL) {
|
||||
*out_role = role;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Check whether a nostr_* verb requires interactive approval, given the
|
||||
* resolved role. Returns 1 if the prompt should be shown, 0 if role-as-
|
||||
* password authorizes immediately. When no role is resolved (NULL), defaults
|
||||
* to requiring approval (fail-safe). */
|
||||
__attribute__((section(".flashmem"))) static int nostr_role_requires_approval(const role_entry_t *role) {
|
||||
if (role == NULL) {
|
||||
return 1; /* fail-safe: prompt if no role */
|
||||
}
|
||||
return role->requires_approval ? 1 : 0;
|
||||
}
|
||||
|
||||
/* Parse [peer_hex, message, {options}] from params. The options object is
|
||||
* parsed into the selector_request_t (role + role_path) for the caller to
|
||||
* resolve via resolve_nostr_request_key(). */
|
||||
__attribute__((section(".flashmem"))) static int parse_peer_and_message_params(cJSON *params,
|
||||
const char **peer_hex_out,
|
||||
const char **message_out,
|
||||
uint32_t *nostr_index_out) {
|
||||
selector_request_t *sel_out) {
|
||||
cJSON *peer_item, *msg_item;
|
||||
|
||||
if (params == NULL || !cJSON_IsArray(params) ||
|
||||
peer_hex_out == NULL || message_out == NULL || nostr_index_out == NULL) {
|
||||
peer_hex_out == NULL || message_out == NULL || sel_out == NULL) {
|
||||
return -1;
|
||||
}
|
||||
if (cJSON_GetArraySize(params) < 2) {
|
||||
@@ -940,7 +1176,7 @@ __attribute__((section(".flashmem"))) static int parse_peer_and_message_params(c
|
||||
!cJSON_IsString(msg_item) || msg_item->valuestring == NULL) {
|
||||
return -1;
|
||||
}
|
||||
if (parse_nostr_index_from_params(params, nostr_index_out) != 0) {
|
||||
if (parse_selector_from_params(params, sel_out) != 0) {
|
||||
return -1;
|
||||
}
|
||||
*peer_hex_out = peer_item->valuestring;
|
||||
@@ -1148,6 +1384,10 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con
|
||||
cJSON_AddItemToObject(obj, "algorithms", algs);
|
||||
}
|
||||
}
|
||||
/* Report the configured role count so clients know the signer is
|
||||
* role-aware. The role names are not exposed (they act as
|
||||
* passwords); only the count is reported. */
|
||||
cJSON_AddNumberToObject(obj, "roles", g_roles.count);
|
||||
out = cJSON_PrintUnformatted(obj);
|
||||
cJSON_Delete(obj);
|
||||
if (out == NULL) {
|
||||
@@ -1956,20 +2196,23 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con
|
||||
|
||||
/* ---- nostr_get_public_key ---- */
|
||||
if (strcmp(method, VERB_NOSTR_GET_PUBLIC_KEY) == 0) {
|
||||
uint32_t nostr_index = 0;
|
||||
uint8_t req_privkey[32];
|
||||
cJSON *options = NULL;
|
||||
const char *fmt = NULL;
|
||||
role_entry_t *role = NULL;
|
||||
|
||||
memset(req_privkey, 0, sizeof(req_privkey));
|
||||
memset(s_nostr_pubkey_hex, 0, sizeof(s_nostr_pubkey_hex));
|
||||
|
||||
if (!cJSON_IsArray(params) ||
|
||||
parse_nostr_index_from_params(params, &nostr_index) != 0 ||
|
||||
derive_request_key(nostr_index, req_privkey, s_nostr_pubkey_hex) != 0) {
|
||||
set_error_code(id_token, ERR_INVALID_PARAMS, "invalid params");
|
||||
if (resolve_nostr_request_key(params, id_token, req_privkey,
|
||||
s_nostr_pubkey_hex, &role) != 0) {
|
||||
/* error response already written by resolve_nostr_request_key */
|
||||
} else {
|
||||
int d = prompt_approval("nostr_get_public_key", "nostr_get_public_key");
|
||||
/* Role-as-password: skip the prompt unless the role requires it. */
|
||||
int d = 1;
|
||||
if (nostr_role_requires_approval(role)) {
|
||||
d = prompt_approval("nostr_get_public_key", "nostr_get_public_key");
|
||||
}
|
||||
if (d != 1) {
|
||||
set_error_code(id_token,
|
||||
(d == 0) ? ERR_DENIED_BY_USER : ERR_APPROVAL_TIMEOUT,
|
||||
@@ -2008,8 +2251,8 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con
|
||||
/* ---- nostr_sign_event ---- */
|
||||
if (strcmp(method, VERB_NOSTR_SIGN_EVENT) == 0) {
|
||||
cJSON *event_in = NULL;
|
||||
uint32_t nostr_index = 0;
|
||||
uint8_t req_privkey[32];
|
||||
role_entry_t *role = NULL;
|
||||
|
||||
memset(req_privkey, 0, sizeof(req_privkey));
|
||||
memset(s_nostr_pubkey_hex, 0, sizeof(s_nostr_pubkey_hex));
|
||||
@@ -2018,15 +2261,22 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con
|
||||
if (cJSON_IsArray(params) && cJSON_GetArraySize(params) > 0) {
|
||||
event_in = cJSON_GetArrayItem(params, 0);
|
||||
}
|
||||
if (event_in == NULL || !cJSON_IsObject(event_in) ||
|
||||
parse_nostr_index_from_params(params, &nostr_index) != 0 ||
|
||||
derive_request_key(nostr_index, req_privkey, s_nostr_pubkey_hex) != 0) {
|
||||
if (event_in == NULL || !cJSON_IsObject(event_in)) {
|
||||
set_error_code(id_token, ERR_INVALID_PARAMS, "invalid params");
|
||||
secure_memzero(req_privkey, sizeof(req_privkey));
|
||||
return;
|
||||
}
|
||||
if (resolve_nostr_request_key(params, id_token, req_privkey,
|
||||
s_nostr_pubkey_hex, &role) != 0) {
|
||||
/* error response already written by resolve_nostr_request_key */
|
||||
secure_memzero(req_privkey, sizeof(req_privkey));
|
||||
return;
|
||||
}
|
||||
{
|
||||
int d = prompt_approval("nostr_sign_event", "nostr_sign_event");
|
||||
int d = 1;
|
||||
if (nostr_role_requires_approval(role)) {
|
||||
d = prompt_approval("nostr_sign_event", "nostr_sign_event");
|
||||
}
|
||||
if (d != 1) {
|
||||
set_error_code(id_token,
|
||||
(d == 0) ? ERR_DENIED_BY_USER : ERR_APPROVAL_TIMEOUT,
|
||||
@@ -2049,9 +2299,9 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con
|
||||
/* ---- nostr_mine_event (NIP-13 proof-of-work) ---- */
|
||||
if (strcmp(method, VERB_NOSTR_MINE_EVENT) == 0) {
|
||||
cJSON *event_in = NULL;
|
||||
uint32_t nostr_index = 0;
|
||||
uint8_t req_privkey[32];
|
||||
cJSON *options = NULL;
|
||||
role_entry_t *role = NULL;
|
||||
int difficulty = 0;
|
||||
int timeout_sec = 0;
|
||||
uint32_t nonce = 0;
|
||||
@@ -2088,16 +2338,23 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con
|
||||
timeout_sec = 30;
|
||||
}
|
||||
|
||||
if (event_in == NULL || !cJSON_IsObject(event_in) ||
|
||||
parse_nostr_index_from_params(params, &nostr_index) != 0 ||
|
||||
derive_request_key(nostr_index, req_privkey, s_nostr_pubkey_hex) != 0) {
|
||||
if (event_in == NULL || !cJSON_IsObject(event_in)) {
|
||||
set_error_code(id_token, ERR_INVALID_PARAMS, "invalid params");
|
||||
secure_memzero(req_privkey, sizeof(req_privkey));
|
||||
return;
|
||||
}
|
||||
if (resolve_nostr_request_key(params, id_token, req_privkey,
|
||||
s_nostr_pubkey_hex, &role) != 0) {
|
||||
/* error response already written by resolve_nostr_request_key */
|
||||
secure_memzero(req_privkey, sizeof(req_privkey));
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
int d = prompt_approval("nostr_mine_event", "nostr_mine_event");
|
||||
int d = 1;
|
||||
if (nostr_role_requires_approval(role)) {
|
||||
d = prompt_approval("nostr_mine_event", "nostr_mine_event");
|
||||
}
|
||||
if (d != 1) {
|
||||
set_error_code(id_token,
|
||||
(d == 0) ? ERR_DENIED_BY_USER : ERR_APPROVAL_TIMEOUT,
|
||||
@@ -2244,26 +2501,46 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con
|
||||
strcmp(method, VERB_NOSTR_NIP44_DECRYPT) == 0) {
|
||||
const char *peer_hex = NULL;
|
||||
const char *message = NULL;
|
||||
uint32_t nostr_index = 0;
|
||||
selector_request_t sel;
|
||||
uint8_t peer_pubkey[32];
|
||||
uint8_t req_privkey[32];
|
||||
role_entry_t *role = NULL;
|
||||
int is_nip44 = (method[9] == '4'); /* "nostr_nip04_*" vs "nostr_nip44_*": digit at index 9 */
|
||||
int is_encrypt = (strstr(method, "encrypt") != NULL);
|
||||
int rc = -1;
|
||||
int parse_ok = 1;
|
||||
|
||||
memset(peer_pubkey, 0, sizeof(peer_pubkey));
|
||||
memset(req_privkey, 0, sizeof(req_privkey));
|
||||
memset(s_nostr_pubkey_hex, 0, sizeof(s_nostr_pubkey_hex));
|
||||
memset(s_encrypt_buf, 0, sizeof(s_encrypt_buf));
|
||||
selector_request_init(&sel);
|
||||
|
||||
if (parse_peer_and_message_params(params, &peer_hex, &message,
|
||||
&nostr_index) != 0 ||
|
||||
hex_to_bytes(peer_hex, peer_pubkey, sizeof(peer_pubkey)) != 0 ||
|
||||
derive_request_key(nostr_index, req_privkey,
|
||||
s_nostr_pubkey_hex) != 0) {
|
||||
/* Parse peer + message + selector from params. */
|
||||
if (parse_peer_and_message_params(params, &peer_hex, &message, &sel) != 0 ||
|
||||
hex_to_bytes(peer_hex, peer_pubkey, sizeof(peer_pubkey)) != 0) {
|
||||
set_error_code(id_token, ERR_INVALID_PARAMS, "invalid params");
|
||||
} else {
|
||||
int d = prompt_approval(method, method);
|
||||
parse_ok = 0;
|
||||
}
|
||||
|
||||
if (parse_ok) {
|
||||
/* Resolve the selector + derive the key. We need to call
|
||||
* resolve_nostr_request_key with the original params (it re-parses
|
||||
* the selector internally), but we already validated the peer/message
|
||||
* shape above. The selector in `sel` is re-parsed inside the helper
|
||||
* from the same params, so it's consistent. */
|
||||
if (resolve_nostr_request_key(params, id_token, req_privkey,
|
||||
s_nostr_pubkey_hex, &role) != 0) {
|
||||
/* error response already written by resolve_nostr_request_key */
|
||||
parse_ok = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (parse_ok) {
|
||||
int d = 1;
|
||||
if (nostr_role_requires_approval(role)) {
|
||||
d = prompt_approval(method, method);
|
||||
}
|
||||
if (d != 1) {
|
||||
set_error_code(id_token,
|
||||
(d == 0) ? ERR_DENIED_BY_USER : ERR_APPROVAL_TIMEOUT,
|
||||
|
||||
@@ -62,6 +62,12 @@ extern char g_pubkey_hex[65];
|
||||
* after apply_mnemonic). When 0, every verb except get_info returns an error. */
|
||||
extern int g_signer_ready;
|
||||
|
||||
/* The role table (populated by signer.ino after the role wizard). Used by the
|
||||
* nostr_* verbs to resolve role + role_path selectors. Defined in dispatch.cpp
|
||||
* as a DMAMEM global. */
|
||||
struct role_table_t;
|
||||
extern struct role_table_t g_roles;
|
||||
|
||||
/* ---- Entry point ----
|
||||
* Process one parsed JSON-RPC request and write the JSON-RPC response into
|
||||
* `out_buf`.
|
||||
|
||||
@@ -315,6 +315,164 @@ __attribute__((section(".flashmem"))) int derive_secp256k1_keys_index(const uint
|
||||
pubkey);
|
||||
}
|
||||
|
||||
/* Parse a BIP-44 derivation path string (e.g. "m/44'/1237'/0'/0/0") into a
|
||||
* uint32_t array. Hardened segments are indicated by a trailing ' (or h/H).
|
||||
* Returns the number of path components on success, or -1 on parse error.
|
||||
* Ported from src/key_store.c parse_bip44_path(). */
|
||||
__attribute__((section(".flashmem"))) int parse_bip44_path(const char *path_str,
|
||||
uint32_t *out, int max_segments) {
|
||||
char buf[128];
|
||||
char *p;
|
||||
int count = 0;
|
||||
|
||||
if (path_str == NULL || out == NULL || max_segments <= 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Copy so we can tokenize in place. */
|
||||
{
|
||||
size_t plen = strlen(path_str);
|
||||
if (plen >= sizeof(buf)) {
|
||||
return -1;
|
||||
}
|
||||
memcpy(buf, path_str, plen);
|
||||
buf[plen] = '\0';
|
||||
}
|
||||
|
||||
/* Skip leading "m" or "M" (optionally followed by '/'). */
|
||||
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_segments) {
|
||||
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/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';
|
||||
/* A bare hardened marker with no number (e.g. "m/0/'") is invalid. */
|
||||
if (seg[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
val = strtol(seg, &endptr, 10);
|
||||
if (*endptr != '\0' || val < 0 || val > 0x7FFFFFFF) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
out[count] = (uint32_t)val;
|
||||
if (hardened) {
|
||||
out[count] |= BIP32_HARDENED_FLAG;
|
||||
}
|
||||
count++;
|
||||
|
||||
p = (slash != NULL) ? slash + 1 : "";
|
||||
if (*p == '\0') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/* Derive a secp256k1 keypair from an explicit BIP-44 path string.
|
||||
* Reuses the same bip32_master_from_seed + bip32_ckd_priv helpers as the
|
||||
* NIP-06 path, just with a caller-supplied path instead of a fixed one. */
|
||||
__attribute__((section(".flashmem"))) int derive_secp256k1_from_path(const uint8_t *seed, size_t seed_len,
|
||||
const char *path_str,
|
||||
uint8_t *privkey, uint8_t *pubkey) {
|
||||
uint32_t path[16];
|
||||
int path_len;
|
||||
secp256k1_context *ctx = NULL;
|
||||
hd_key_t node;
|
||||
|
||||
if (seed == NULL || path_str == NULL || privkey == NULL || pubkey == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
path_len = parse_bip44_path(path_str, path,
|
||||
(int)(sizeof(path) / sizeof(path[0])));
|
||||
if (path_len <= 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
memset(&node, 0, sizeof(node));
|
||||
|
||||
ctx = create_context();
|
||||
if (ctx == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (bip32_master_from_seed(ctx, seed, seed_len, &node) != 0) {
|
||||
secure_memzero(&node, sizeof(node));
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (int i = 0; i < path_len; ++i) {
|
||||
hd_key_t next;
|
||||
memset(&next, 0, sizeof(next));
|
||||
|
||||
if (bip32_ckd_priv(ctx, &node, path[i], &next) != 0) {
|
||||
secure_memzero(&node, sizeof(node));
|
||||
return -1;
|
||||
}
|
||||
|
||||
secure_memzero(&node, sizeof(node));
|
||||
node = next;
|
||||
}
|
||||
|
||||
memcpy(privkey, node.priv, 32);
|
||||
|
||||
{
|
||||
secp256k1_keypair kp;
|
||||
secp256k1_xonly_pubkey xonly;
|
||||
|
||||
if (!secp256k1_keypair_create(ctx, &kp, node.priv)) {
|
||||
secure_memzero(&node, sizeof(node));
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!secp256k1_keypair_xonly_pub(ctx, &xonly, NULL, &kp)) {
|
||||
secure_memzero(&node, sizeof(node));
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!secp256k1_xonly_pubkey_serialize(ctx, pubkey, &xonly)) {
|
||||
secure_memzero(&node, sizeof(node));
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
secure_memzero(&node, sizeof(node));
|
||||
/* Do NOT destroy ctx — it is the persistent global context. */
|
||||
return 0;
|
||||
}
|
||||
|
||||
__attribute__((section(".flashmem"))) int schnorr_sign32(const uint8_t privkey[32], const uint8_t msg32[32],
|
||||
uint8_t sig64[64]) {
|
||||
secp256k1_context *ctx = NULL;
|
||||
|
||||
@@ -32,6 +32,22 @@ int derive_secp256k1_keys_index(const uint8_t *seed, size_t seed_len,
|
||||
uint32_t nostr_index,
|
||||
uint8_t *privkey, uint8_t *pubkey);
|
||||
|
||||
/* Parse a BIP-44 derivation path string (e.g. "m/44'/1237'/0'/0/0") into a
|
||||
* uint32_t array suitable for the internal BIP-32 derivation. Hardened
|
||||
* segments are indicated by a trailing ' (or h/H). Returns the number of
|
||||
* path components on success, or -1 on parse error. `max_segments` is the
|
||||
* max number of entries in the `out` array. */
|
||||
int parse_bip44_path(const char *path_str, uint32_t *out, int max_segments);
|
||||
|
||||
/* Derive a secp256k1 keypair from an explicit BIP-44 path string.
|
||||
* Uses BIP-32 derivation (master key from seed + CKDpriv per segment).
|
||||
* privkey: 32-byte secret key (scalar).
|
||||
* pubkey: 32-byte x-only public key (Nostr pubkey).
|
||||
* Returns 0 on success, -1 on failure. */
|
||||
int derive_secp256k1_from_path(const uint8_t *seed, size_t seed_len,
|
||||
const char *path_str,
|
||||
uint8_t *privkey, uint8_t *pubkey);
|
||||
|
||||
/* Sign a 32-byte message digest with Schnorr (BIP-340) using a 32-byte
|
||||
* secp256k1 secret key. aux_rand is drawn from the TRNG. sig64: 64-byte sig. */
|
||||
int schnorr_sign32(const uint8_t privkey[32], const uint8_t msg32[32],
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/* role_table.cpp — in-RAM role table implementation for the Teensy 4.1.
|
||||
*
|
||||
* Phase 2 of plans/teensy41_role_path_migration.md.
|
||||
*
|
||||
* Ports the path-template matching from src/role_table.c, slimmed for the
|
||||
* Teensy (16 entries, range-only index validation, no allowed-indices set).
|
||||
* The matching logic (role_path_matches_template, role_path_extract_index,
|
||||
* role_path_matches_with_range) is a faithful port of the host's functions
|
||||
* so the Teensy and the host accept the same paths for the same templates.
|
||||
*/
|
||||
#include "role_table.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
/* ====================================================================
|
||||
* Table operations
|
||||
* ==================================================================== */
|
||||
|
||||
__attribute__((section(".flashmem")))
|
||||
void role_table_init(role_table_t *table) {
|
||||
if (table != NULL) {
|
||||
memset(table, 0, sizeof(*table));
|
||||
}
|
||||
}
|
||||
|
||||
__attribute__((section(".flashmem")))
|
||||
int role_table_add(role_table_t *table, const role_entry_t *entry) {
|
||||
int i;
|
||||
if (table == NULL || entry == NULL) {
|
||||
return -1;
|
||||
}
|
||||
if (table->count >= ROLE_TABLE_MAX_ENTRIES) {
|
||||
return -1; /* full */
|
||||
}
|
||||
/* Duplicate name check */
|
||||
for (i = 0; i < table->count; i++) {
|
||||
if (strcmp(table->entries[i].name, entry->name) == 0) {
|
||||
return -2;
|
||||
}
|
||||
}
|
||||
table->entries[table->count] = *entry;
|
||||
table->count++;
|
||||
return 0;
|
||||
}
|
||||
|
||||
__attribute__((section(".flashmem")))
|
||||
role_entry_t *role_table_find_by_name(role_table_t *table, const char *name) {
|
||||
int i;
|
||||
if (table == NULL || name == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
for (i = 0; i < table->count; i++) {
|
||||
if (strcmp(table->entries[i].name, name) == 0) {
|
||||
return &table->entries[i];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
__attribute__((section(".flashmem")))
|
||||
role_entry_t *role_table_get_default(role_table_t *table) {
|
||||
return role_table_find_by_name(table, "main");
|
||||
}
|
||||
|
||||
/* ====================================================================
|
||||
* Path-template matching (ported from src/role_table.c)
|
||||
* ==================================================================== */
|
||||
|
||||
/* Check whether a concrete derivation path matches a role's path template.
|
||||
* The template may contain a single "%d" placeholder (with an optional
|
||||
* hardened marker after it, e.g. "m/44'/1237'/%d'/0/0").
|
||||
* Returns 1 if the path matches the template, 0 if not.
|
||||
* Ported from src/role_table.c:956. */
|
||||
__attribute__((section(".flashmem")))
|
||||
int role_path_matches_template(const char *path, const char *template_str) {
|
||||
const char *p = path;
|
||||
const char *t = template_str;
|
||||
|
||||
if (path == NULL || template_str == 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 in template */
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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 no match / no %d.
|
||||
* Ported from src/role_table.c:1007. */
|
||||
__attribute__((section(".flashmem")))
|
||||
int role_path_extract_index(const char *path, const char *template_str) {
|
||||
const char *p = path;
|
||||
const char *t = template_str;
|
||||
const char *seg_start;
|
||||
char seg_buf[32];
|
||||
size_t seg_len;
|
||||
long val;
|
||||
char *endp;
|
||||
|
||||
if (path == NULL || template_str == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* If template has no %d, there is no variable index to extract */
|
||||
if (strstr(template_str, "%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;
|
||||
}
|
||||
|
||||
/* Check whether a concrete derivation path matches a role's path template
|
||||
* AND the extracted index falls within the role's allowed range.
|
||||
* Returns 1 if the path matches and the index is allowed, 0 otherwise.
|
||||
* Ported from src/role_table.c:1070 (set-form omitted, range-only). */
|
||||
__attribute__((section(".flashmem")))
|
||||
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 */
|
||||
index = role_path_extract_index(path, role->role_path);
|
||||
if (index < 0) {
|
||||
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;
|
||||
}
|
||||
|
||||
/* ====================================================================
|
||||
* Presets (matching the host wizard, src/main.c:2068)
|
||||
* ==================================================================== */
|
||||
|
||||
const role_preset_t role_presets[] = {
|
||||
/* 1. Standard Nostr (secp256k1, m/44'/1237'/0'/0/0) */
|
||||
{ "main", "m/44'/1237'/0'/0/0",
|
||||
ROLE_PURPOSE_NOSTR, ROLE_CURVE_SECP256K1, -1, -1, -1 },
|
||||
/* 2. Nostr range (secp256k1, m/44'/1237'/%d'/0/0, 0-100) */
|
||||
{ "nostr_range", "m/44'/1237'/%d'/0/0",
|
||||
ROLE_PURPOSE_NOSTR, ROLE_CURVE_SECP256K1, 0, 100, 0 },
|
||||
/* 3. Nostr agent (secp256k1, m/44'/1237'/%d'/1'/0', 0-100) */
|
||||
{ "nostr_agent", "m/44'/1237'/%d'/1'/0'",
|
||||
ROLE_PURPOSE_NOSTR, ROLE_CURVE_SECP256K1, 0, 100, 0 },
|
||||
/* 4. SSH (ed25519, m/44'/102001'/0'/0'/0') */
|
||||
{ "ssh", "m/44'/102001'/0'/0'/0'",
|
||||
ROLE_PURPOSE_SSH, ROLE_CURVE_ED25519, -1, -1, -1 },
|
||||
/* 5. Age (x25519, m/44'/102002'/0'/0'/0') */
|
||||
{ "age", "m/44'/102002'/0'/0'/0'",
|
||||
ROLE_PURPOSE_AGE, ROLE_CURVE_X25519, -1, -1, -1 },
|
||||
/* 6. ML-DSA-65 (m/44'/102003'/0'/0'/0') */
|
||||
{ "ml_dsa_65", "m/44'/102003'/0'/0'/0'",
|
||||
ROLE_PURPOSE_PQ_SIG, ROLE_CURVE_ML_DSA_65, -1, -1, -1 },
|
||||
/* 7. SLH-DSA-128s (m/44'/102004'/0'/0'/0') */
|
||||
{ "slh_dsa_128s", "m/44'/102004'/0'/0'/0'",
|
||||
ROLE_PURPOSE_PQ_SIG, ROLE_CURVE_SLH_DSA_128S, -1, -1, -1 },
|
||||
/* 8. ML-KEM-768 (m/44'/102005'/0'/0'/0') */
|
||||
{ "ml_kem_768", "m/44'/102005'/0'/0'/0'",
|
||||
ROLE_PURPOSE_PQ_KEM, ROLE_CURVE_ML_KEM_768, -1, -1, -1 },
|
||||
/* 9. OTP (no derivation path — binds the SD pad instead) */
|
||||
{ "otp", "",
|
||||
ROLE_PURPOSE_OTP, ROLE_CURVE_OTP, -1, -1, -1 },
|
||||
/* 10. Custom (user edits name + path) */
|
||||
{ "custom", "m/44'/1237'/0'/0/0",
|
||||
ROLE_PURPOSE_NOSTR, ROLE_CURVE_SECP256K1, -1, -1, -1 },
|
||||
};
|
||||
|
||||
const int role_preset_count =
|
||||
(int)(sizeof(role_presets) / sizeof(role_presets[0]));
|
||||
@@ -0,0 +1,138 @@
|
||||
/* role_table.h — in-RAM role table for the Teensy 4.1 n_signer firmware.
|
||||
*
|
||||
* Phase 2 of plans/teensy41_role_path_migration.md.
|
||||
*
|
||||
* Ports the role + path authorization model from the host n_signer
|
||||
* (src/role_table.c) to the Teensy 4.1, slimmed for the hardware signer's
|
||||
* single-user scope:
|
||||
* - 16 entries max (vs the host's 256)
|
||||
* - single %d placeholder per path template (vs the host's same limitation)
|
||||
* - range bounds (lo/hi) for the %d index; no explicit allowed-indices set
|
||||
* (the host's path_allowed_indices[] is omitted to save memory)
|
||||
*
|
||||
* Each role binds a name to a BIP-44 derivation path template, a purpose/curve,
|
||||
* and a requires_approval flag. The selector (selector.cpp) looks up a role by
|
||||
* name and verifies the requested path matches the template + range before
|
||||
* authorizing the request.
|
||||
*
|
||||
* Role-as-password: when requires_approval == 0, knowing the role name (and a
|
||||
* matching path) is sufficient authorization — no ui_approve() prompt. This is
|
||||
* the default, matching plans/role_as_password_default.md.
|
||||
*/
|
||||
#ifndef FIRMWARE_TEENSY41_SIGNER_ROLE_TABLE_H
|
||||
#define FIRMWARE_TEENSY41_SIGNER_ROLE_TABLE_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* ---- Limits (slimmed for the Teensy 4.1) ---- */
|
||||
#define ROLE_NAME_MAX 32
|
||||
#define ROLE_PATH_MAX 128
|
||||
#define ROLE_PUBKEY_HEX_MAX 65 /* 64 hex chars + NUL */
|
||||
#define ROLE_TABLE_MAX_ENTRIES 16
|
||||
|
||||
/* ---- Purpose enum ---- */
|
||||
typedef enum {
|
||||
ROLE_PURPOSE_NOSTR = 0,
|
||||
ROLE_PURPOSE_SSH,
|
||||
ROLE_PURPOSE_AGE,
|
||||
ROLE_PURPOSE_PQ_SIG, /* ML-DSA-65, SLH-DSA-128s */
|
||||
ROLE_PURPOSE_PQ_KEM, /* ML-KEM-768 */
|
||||
ROLE_PURPOSE_OTP, /* one-time pad (no derivation path) */
|
||||
ROLE_PURPOSE_UNKNOWN
|
||||
} role_purpose_t;
|
||||
|
||||
/* ---- Curve enum ---- */
|
||||
typedef enum {
|
||||
ROLE_CURVE_SECP256K1 = 0,
|
||||
ROLE_CURVE_ED25519,
|
||||
ROLE_CURVE_X25519,
|
||||
ROLE_CURVE_ML_DSA_65,
|
||||
ROLE_CURVE_SLH_DSA_128S,
|
||||
ROLE_CURVE_ML_KEM_768,
|
||||
ROLE_CURVE_OTP,
|
||||
ROLE_CURVE_UNKNOWN
|
||||
} role_curve_t;
|
||||
|
||||
/* ---- A single role entry ---- */
|
||||
typedef struct {
|
||||
char name[ROLE_NAME_MAX]; /* "main", "ssh", etc. */
|
||||
char role_path[ROLE_PATH_MAX]; /* template, may contain one "%d" */
|
||||
role_purpose_t purpose;
|
||||
role_curve_t curve;
|
||||
int path_range_lo; /* inclusive lower bound for %d; -1 = fixed path (no %d) */
|
||||
int path_range_hi; /* inclusive upper bound; == lo for single */
|
||||
int path_default_index; /* default index when client sends {"role":...} without a path; -1 = require explicit */
|
||||
int requires_approval; /* 0 = role-as-password (no prompt), 1 = require ui_approve() */
|
||||
int derived; /* 1 if pubkey_hex has been populated */
|
||||
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after first derivation */
|
||||
} role_entry_t;
|
||||
|
||||
/* ---- The role table ---- */
|
||||
typedef struct {
|
||||
role_entry_t entries[ROLE_TABLE_MAX_ENTRIES];
|
||||
int count;
|
||||
} role_table_t;
|
||||
|
||||
/* ---- Operations ---- */
|
||||
|
||||
/* Initialize an empty role table. */
|
||||
void role_table_init(role_table_t *table);
|
||||
|
||||
/* Add a role entry. Returns 0 on success, -1 if table full, -2 if name duplicate. */
|
||||
int role_table_add(role_table_t *table, const role_entry_t *entry);
|
||||
|
||||
/* Find a role by name. Returns pointer to entry or NULL. */
|
||||
role_entry_t *role_table_find_by_name(role_table_t *table, const char *name);
|
||||
|
||||
/* 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);
|
||||
|
||||
/* ---- Path-template matching ---- */
|
||||
|
||||
/* Check whether a concrete derivation path matches a role's path template.
|
||||
* The template may contain a single "%d" placeholder (with an optional
|
||||
* hardened marker after it, e.g. "m/44'/1237'/%d'/0/0").
|
||||
* 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_str);
|
||||
|
||||
/* 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. */
|
||||
int role_path_extract_index(const char *path, const char *template_str);
|
||||
|
||||
/* Check whether a concrete derivation path matches a role's path template
|
||||
* AND the extracted index falls within the role's allowed range.
|
||||
* Returns 1 if the path matches and the index is allowed, 0 otherwise.
|
||||
* For fixed paths (no %d), equivalent to role_path_matches_template(). */
|
||||
int role_path_matches_with_range(const char *path, const role_entry_t *role);
|
||||
|
||||
/* ---- Presets ---- */
|
||||
|
||||
/* A role preset, matching the host's wizard menu (src/main.c:2068).
|
||||
* Used by ui_role_wizard() to populate the table. */
|
||||
typedef struct {
|
||||
const char *name; /* default role name */
|
||||
const char *path; /* default path template (may contain %d) */
|
||||
role_purpose_t purpose;
|
||||
role_curve_t curve;
|
||||
int range_lo; /* -1 = fixed path */
|
||||
int range_hi;
|
||||
int default_index;/* -1 = require explicit */
|
||||
} role_preset_t;
|
||||
|
||||
/* The preset table (10 entries, matching the host wizard). */
|
||||
extern const role_preset_t role_presets[];
|
||||
extern const int role_preset_count;
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* FIRMWARE_TEENSY41_SIGNER_ROLE_TABLE_H */
|
||||
@@ -0,0 +1,111 @@
|
||||
/* selector.cpp — request selector implementation for the Teensy 4.1.
|
||||
*
|
||||
* Phase 3 of plans/teensy41_role_path_migration.md.
|
||||
*
|
||||
* Ports the selector decision tree from src/selector.c:745. The JSON parsing
|
||||
* (extracting role/role_path/nostr_index from the cJSON options object) is
|
||||
* done in dispatch.cpp, which calls selector_resolve() with the populated
|
||||
* selector_request_t.
|
||||
*/
|
||||
#include "selector.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
/* ====================================================================
|
||||
* Selector request
|
||||
* ==================================================================== */
|
||||
|
||||
__attribute__((section(".flashmem")))
|
||||
void selector_request_init(selector_request_t *req) {
|
||||
if (req != NULL) {
|
||||
memset(req, 0, sizeof(*req));
|
||||
}
|
||||
}
|
||||
|
||||
/* ====================================================================
|
||||
* Selector resolution
|
||||
* ==================================================================== */
|
||||
|
||||
/* Resolve a selector request against the role table.
|
||||
*
|
||||
* Ported from src/selector.c:745 (selector_resolve). The decision tree:
|
||||
* 1. nostr_index present → DEPRECATED (error 2006 in the wire protocol)
|
||||
* 2. role_path without role → ROLE_REQUIRED
|
||||
* 3. role + role_path → find role, verify path matches template + range
|
||||
* 4. role only, fixed path (no %d) → use it
|
||||
* 5. role only, template path → PATH_REQUIRED
|
||||
* 6. neither → default role ("main"), else NO_DEFAULT
|
||||
*
|
||||
* On success, *out points to the matching role entry in the table. The
|
||||
* caller uses req->role_path (if has_role_path) or the role's role_path
|
||||
* (if fixed) for key derivation. */
|
||||
__attribute__((section(".flashmem")))
|
||||
int selector_resolve(const selector_request_t *req, role_table_t *table,
|
||||
role_entry_t **out) {
|
||||
role_entry_t *match = NULL;
|
||||
|
||||
if (out != NULL) {
|
||||
*out = NULL;
|
||||
}
|
||||
|
||||
if (req == NULL || table == NULL || out == NULL) {
|
||||
return SELECTOR_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
/* ---- Deprecated selectors: reject with clear error codes ---- */
|
||||
|
||||
/* nostr_index is deprecated */
|
||||
if (req->has_nostr_index) {
|
||||
return SELECTOR_ERR_NOSTR_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. */
|
||||
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;
|
||||
}
|
||||
|
||||
*out = match;
|
||||
return SELECTOR_OK;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/* selector.h — request selector for the Teensy 4.1 n_signer firmware.
|
||||
*
|
||||
* Phase 3 of plans/teensy41_role_path_migration.md.
|
||||
*
|
||||
* Ports the role + path selector from the host n_signer (src/selector.c).
|
||||
* Parses the selector fields (role, role_path, nostr_index, index) from a
|
||||
* nostr_* verb's trailing options object and resolves them against the role
|
||||
* table.
|
||||
*
|
||||
* Decision tree (matching src/selector.c:745):
|
||||
* - nostr_index present → SELECTOR_ERR_NOSTR_INDEX_DEPRECATED
|
||||
* - role_path without role → SELECTOR_ERR_ROLE_REQUIRED
|
||||
* - role + role_path → find role, verify path matches template + range
|
||||
* - role only (fixed path) → use the role's fixed path
|
||||
* - role only (template path) → SELECTOR_ERR_PATH_REQUIRED
|
||||
* - neither → use default role ("main"), else NO_DEFAULT
|
||||
*/
|
||||
#ifndef FIRMWARE_TEENSY41_SIGNER_SELECTOR_H
|
||||
#define FIRMWARE_TEENSY41_SIGNER_SELECTOR_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include "role_table.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* ---- Selector request (parsed from the JSON options object) ---- */
|
||||
typedef struct {
|
||||
int has_role; /* 1 if "role" field was present */
|
||||
char role_name[ROLE_NAME_MAX];
|
||||
int has_role_path; /* 1 if "role_path" field was present */
|
||||
char role_path[ROLE_PATH_MAX];
|
||||
int has_nostr_index; /* 1 if "nostr_index" field was present (deprecated) */
|
||||
uint32_t nostr_index;
|
||||
int has_index; /* 1 if "index" field was present (for algorithm verbs, not nostr) */
|
||||
uint32_t index;
|
||||
} selector_request_t;
|
||||
|
||||
/* ---- Result codes ---- */
|
||||
#define SELECTOR_OK 0
|
||||
#define SELECTOR_ERR_NOT_FOUND -1 /* role not found */
|
||||
#define SELECTOR_ERR_NO_DEFAULT -3 /* no selector given and no "main" role */
|
||||
#define SELECTOR_ERR_PATH_MISMATCH -4 /* role_path doesn't match role's template/range */
|
||||
#define SELECTOR_ERR_NOSTR_INDEX_DEPRECATED -5 /* nostr_index is deprecated */
|
||||
#define SELECTOR_ERR_PATH_REQUIRED -6 /* role has a template path but no role_path given */
|
||||
#define SELECTOR_ERR_ROLE_REQUIRED -7 /* role_path given without a role */
|
||||
|
||||
/* ---- Operations ---- */
|
||||
|
||||
/* Initialize a selector request to its empty state. */
|
||||
void selector_request_init(selector_request_t *req);
|
||||
|
||||
/* Resolve a selector request against the role table.
|
||||
*
|
||||
* On success (SELECTOR_OK), *out points to the matching role_entry_t in the
|
||||
* table. The caller should then use req->role_path (if has_role_path) or the
|
||||
* role's role_path (if fixed) for key derivation.
|
||||
*
|
||||
* Returns SELECTOR_OK or one of the SELECTOR_ERR_* codes. */
|
||||
int selector_resolve(const selector_request_t *req, role_table_t *table,
|
||||
role_entry_t **out);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* FIRMWARE_TEENSY41_SIGNER_SELECTOR_H */
|
||||
@@ -1017,3 +1017,291 @@ __attribute__((section(".flashmem"))) int ui_pick_pad(
|
||||
return 2; /* timeout */
|
||||
}
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
* 6. ui_role_wizard — role-preset selection (Phase 5)
|
||||
* =====================================================================
|
||||
*
|
||||
* Presents the 10 role presets as a scrollable button list. Tapping a
|
||||
* preset creates a role with the preset's defaults (requires_approval=0,
|
||||
* role-as-password). After each selection, a "Add another / Done" prompt
|
||||
* loops until the user taps Done with at least one role defined.
|
||||
*
|
||||
* The preset labels are short descriptions (not the full path) to fit the
|
||||
* 480px screen. The full path is stored in the role entry.
|
||||
*/
|
||||
|
||||
/* Wizard state: -1 = none, 0..9 = preset index, -2 = done, -3 = add-another,
|
||||
* -4 = cancel */
|
||||
static volatile int s_wizard_choice = -1;
|
||||
|
||||
static void on_wizard_preset(lv_event_t *e) {
|
||||
if (lv_event_get_code(e) == LV_EVENT_CLICKED) {
|
||||
int idx = (int)(intptr_t)lv_event_get_user_data(e);
|
||||
s_wizard_choice = idx;
|
||||
}
|
||||
}
|
||||
|
||||
static void on_wizard_done(lv_event_t *e) {
|
||||
if (lv_event_get_code(e) == LV_EVENT_CLICKED) {
|
||||
s_wizard_choice = -2;
|
||||
}
|
||||
}
|
||||
|
||||
static void on_wizard_add(lv_event_t *e) {
|
||||
if (lv_event_get_code(e) == LV_EVENT_CLICKED) {
|
||||
s_wizard_choice = -3;
|
||||
}
|
||||
}
|
||||
|
||||
static void on_wizard_cancel(lv_event_t *e) {
|
||||
if (lv_event_get_code(e) == LV_EVENT_CLICKED) {
|
||||
s_wizard_choice = -4;
|
||||
}
|
||||
}
|
||||
|
||||
/* Short labels for the 10 presets (kept short to fit 480px buttons). */
|
||||
static const char *preset_labels[] = {
|
||||
"1. Standard Nostr",
|
||||
"2. Nostr range (0-100)",
|
||||
"3. Nostr agent (0-100)",
|
||||
"4. SSH (ed25519)",
|
||||
"5. Age (x25519)",
|
||||
"6. ML-DSA-65 (PQ sig)",
|
||||
"7. SLH-DSA-128s (PQ sig)",
|
||||
"8. ML-KEM-768 (PQ KEM)",
|
||||
"9. OTP (one-time pad)",
|
||||
"10. Custom",
|
||||
};
|
||||
|
||||
/* Build the preset-selection screen. Returns the screen object. */
|
||||
__attribute__((section(".flashmem"))) static void show_preset_screen(lv_obj_t *scr, int roles_so_far) {
|
||||
lv_obj_clean(scr);
|
||||
lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_bg_color(scr, lv_color_hex(UI_BG), 0);
|
||||
|
||||
/* Title + role count */
|
||||
lv_obj_t *title = lv_label_create(scr);
|
||||
char title_text[48];
|
||||
snprintf(title_text, sizeof(title_text), "Define a Role (%d defined)",
|
||||
roles_so_far);
|
||||
lv_label_set_text(title, title_text);
|
||||
lv_obj_set_style_text_color(title, lv_color_hex(UI_FG), 0);
|
||||
lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0);
|
||||
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 5);
|
||||
|
||||
/* Preset buttons — 2 columns × 5 rows to fit 10 presets on 480×320. */
|
||||
for (int i = 0; i < role_preset_count && i < 10; i++) {
|
||||
lv_obj_t *btn = lv_button_create(scr);
|
||||
style_button(btn);
|
||||
lv_obj_set_size(btn, 225, 44);
|
||||
int col = i % 2;
|
||||
int row = i / 2;
|
||||
lv_obj_align(btn, LV_ALIGN_TOP_LEFT, 10 + col * 235, 40 + row * 50);
|
||||
lv_obj_add_event_cb(btn, on_wizard_preset, LV_EVENT_ALL,
|
||||
(void *)(intptr_t)i);
|
||||
lv_obj_t *lbl = lv_label_create(btn);
|
||||
lv_label_set_text(lbl, preset_labels[i]);
|
||||
lv_obj_center(lbl);
|
||||
}
|
||||
|
||||
/* Done button (bottom-right) — only meaningful after ≥1 role. */
|
||||
lv_obj_t *btn_done = lv_button_create(scr);
|
||||
style_button(btn_done);
|
||||
lv_obj_set_size(btn_done, 225, 40);
|
||||
lv_obj_align(btn_done, LV_ALIGN_BOTTOM_RIGHT, -10, -5);
|
||||
lv_obj_add_event_cb(btn_done, on_wizard_done, LV_EVENT_ALL, NULL);
|
||||
lv_obj_t *lbl_done = lv_label_create(btn_done);
|
||||
lv_label_set_text(lbl_done, "Done");
|
||||
lv_obj_center(lbl_done);
|
||||
|
||||
/* Cancel button (bottom-left) */
|
||||
lv_obj_t *btn_cancel = lv_button_create(scr);
|
||||
style_button(btn_cancel);
|
||||
lv_obj_set_style_border_color(btn_cancel, lv_color_hex(UI_MUTED), 0);
|
||||
lv_obj_set_style_text_color(btn_cancel, lv_color_hex(UI_MUTED), 0);
|
||||
lv_obj_set_size(btn_cancel, 225, 40);
|
||||
lv_obj_align(btn_cancel, LV_ALIGN_BOTTOM_LEFT, 10, -5);
|
||||
lv_obj_add_event_cb(btn_cancel, on_wizard_cancel, LV_EVENT_ALL, NULL);
|
||||
lv_obj_t *lbl_cancel = lv_label_create(btn_cancel);
|
||||
lv_label_set_text(lbl_cancel, "Cancel");
|
||||
lv_obj_center(lbl_cancel);
|
||||
}
|
||||
|
||||
/* Show the "role added — add another or done?" confirmation screen. */
|
||||
__attribute__((section(".flashmem"))) static void show_added_screen(lv_obj_t *scr,
|
||||
const char *role_name,
|
||||
const char *role_path,
|
||||
int roles_so_far) {
|
||||
lv_obj_clean(scr);
|
||||
lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_bg_color(scr, lv_color_hex(UI_BG), 0);
|
||||
|
||||
lv_obj_t *title = lv_label_create(scr);
|
||||
lv_label_set_text(title, "Role Added");
|
||||
lv_obj_set_style_text_color(title, lv_color_hex(UI_FG), 0);
|
||||
lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0);
|
||||
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 20);
|
||||
|
||||
/* Show the role name + path */
|
||||
char info[160];
|
||||
snprintf(info, sizeof(info), "%s\n%s", role_name, role_path);
|
||||
lv_obj_t *info_lbl = lv_label_create(scr);
|
||||
lv_label_set_text(info_lbl, info);
|
||||
lv_label_set_long_mode(info_lbl, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_width(info_lbl, 440);
|
||||
lv_obj_set_style_text_color(info_lbl, lv_color_hex(UI_FG), 0);
|
||||
lv_obj_align(info_lbl, LV_ALIGN_TOP_MID, 0, 60);
|
||||
|
||||
char count_text[48];
|
||||
snprintf(count_text, sizeof(count_text), "%d role(s) defined", roles_so_far);
|
||||
lv_obj_t *count_lbl = lv_label_create(scr);
|
||||
lv_label_set_text(count_lbl, count_text);
|
||||
lv_obj_set_style_text_color(count_lbl, lv_color_hex(UI_MUTED), 0);
|
||||
lv_obj_align(count_lbl, LV_ALIGN_TOP_MID, 0, 160);
|
||||
|
||||
/* Add another */
|
||||
lv_obj_t *btn_add = lv_button_create(scr);
|
||||
style_button(btn_add);
|
||||
lv_obj_set_size(btn_add, 225, 50);
|
||||
lv_obj_align(btn_add, LV_ALIGN_BOTTOM_LEFT, 10, -10);
|
||||
lv_obj_add_event_cb(btn_add, on_wizard_add, LV_EVENT_ALL, NULL);
|
||||
lv_obj_t *lbl_add = lv_label_create(btn_add);
|
||||
lv_label_set_text(lbl_add, "Add Another");
|
||||
lv_obj_center(lbl_add);
|
||||
|
||||
/* Done */
|
||||
lv_obj_t *btn_done = lv_button_create(scr);
|
||||
style_button(btn_done);
|
||||
lv_obj_set_size(btn_done, 225, 50);
|
||||
lv_obj_align(btn_done, LV_ALIGN_BOTTOM_RIGHT, -10, -10);
|
||||
lv_obj_add_event_cb(btn_done, on_wizard_done, LV_EVENT_ALL, NULL);
|
||||
lv_obj_t *lbl_done = lv_label_create(btn_done);
|
||||
lv_label_set_text(lbl_done, "Done");
|
||||
lv_obj_center(lbl_done);
|
||||
}
|
||||
|
||||
/* Show an error screen for 2 seconds (e.g. "at least one role required"). */
|
||||
__attribute__((section(".flashmem"))) static void show_error_screen(lv_obj_t *scr,
|
||||
const char *msg) {
|
||||
lv_obj_clean(scr);
|
||||
lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_bg_color(scr, lv_color_hex(UI_BG), 0);
|
||||
|
||||
lv_obj_t *lbl = lv_label_create(scr);
|
||||
lv_label_set_text(lbl, msg);
|
||||
lv_label_set_long_mode(lbl, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_width(lbl, 440);
|
||||
lv_obj_set_style_text_color(lbl, lv_color_hex(UI_ACCENT), 0);
|
||||
lv_obj_set_style_text_font(lbl, &lv_font_montserrat_20, 0);
|
||||
lv_obj_center(lbl);
|
||||
|
||||
uint32_t deadline = millis() + 2000;
|
||||
while (millis() < deadline) {
|
||||
lv_tick_inc(5);
|
||||
lv_timer_handler();
|
||||
delay(5);
|
||||
}
|
||||
}
|
||||
|
||||
/* Pump LVGL until s_wizard_choice changes or timeout (ms). Returns the
|
||||
* choice value, or -1 on timeout. */
|
||||
__attribute__((section(".flashmem"))) static int pump_until_choice(uint32_t timeout_ms) {
|
||||
uint32_t deadline = millis() + timeout_ms;
|
||||
while (s_wizard_choice == -1 && millis() < deadline) {
|
||||
lv_tick_inc(5);
|
||||
lv_timer_handler();
|
||||
delay(5);
|
||||
}
|
||||
int c = s_wizard_choice;
|
||||
s_wizard_choice = -1;
|
||||
return c;
|
||||
}
|
||||
|
||||
__attribute__((section(".flashmem"))) int ui_role_wizard(role_table_t *out_table) {
|
||||
if (out_table == NULL) {
|
||||
return -1;
|
||||
}
|
||||
role_table_init(out_table);
|
||||
|
||||
lv_obj_t *scr = lv_screen_active();
|
||||
int done = 0;
|
||||
|
||||
while (!done) {
|
||||
/* ---- Preset selection screen ---- */
|
||||
show_preset_screen(scr, out_table->count);
|
||||
int choice = pump_until_choice(60000);
|
||||
|
||||
if (choice == -4) {
|
||||
/* Cancel */
|
||||
return -1;
|
||||
} else if (choice == -2) {
|
||||
/* Done tapped on the preset screen */
|
||||
if (out_table->count == 0) {
|
||||
show_error_screen(scr, "At least one role\nmust be defined");
|
||||
continue; /* re-loop to preset screen */
|
||||
}
|
||||
done = 1;
|
||||
break;
|
||||
} else if (choice < 0 || choice >= role_preset_count) {
|
||||
/* Timeout or invalid — re-loop */
|
||||
if (choice == -1) {
|
||||
/* Timeout: treat as cancel */
|
||||
return -1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
/* ---- A preset was selected: create the role ---- */
|
||||
const role_preset_t *preset = &role_presets[choice];
|
||||
role_entry_t entry;
|
||||
memset(&entry, 0, sizeof(entry));
|
||||
strncpy(entry.name, preset->name, sizeof(entry.name) - 1);
|
||||
entry.name[sizeof(entry.name) - 1] = '\0';
|
||||
strncpy(entry.role_path, preset->path, sizeof(entry.role_path) - 1);
|
||||
entry.role_path[sizeof(entry.role_path) - 1] = '\0';
|
||||
entry.purpose = preset->purpose;
|
||||
entry.curve = preset->curve;
|
||||
entry.path_range_lo = preset->range_lo;
|
||||
entry.path_range_hi = preset->range_hi;
|
||||
entry.path_default_index = preset->default_index;
|
||||
entry.requires_approval = 0; /* role-as-password by default */
|
||||
entry.derived = 0;
|
||||
entry.pubkey_hex[0] = '\0';
|
||||
|
||||
/* If a role with this name already exists, append a suffix. */
|
||||
if (role_table_find_by_name(out_table, entry.name) != NULL) {
|
||||
char base[ROLE_NAME_MAX];
|
||||
strncpy(base, entry.name, sizeof(base) - 1);
|
||||
base[sizeof(base) - 1] = '\0';
|
||||
for (int suffix = 2; suffix < 100; suffix++) {
|
||||
snprintf(entry.name, sizeof(entry.name), "%s%d", base, suffix);
|
||||
if (role_table_find_by_name(out_table, entry.name) == NULL) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int add_rc = role_table_add(out_table, &entry);
|
||||
if (add_rc != 0) {
|
||||
show_error_screen(scr, "Role table full\nor duplicate");
|
||||
continue;
|
||||
}
|
||||
|
||||
/* ---- "Role added — add another or done?" screen ---- */
|
||||
show_added_screen(scr, entry.name, entry.role_path, out_table->count);
|
||||
int post = pump_until_choice(30000);
|
||||
|
||||
if (post == -2 || post == -1) {
|
||||
/* Done (or timeout → treat as done) */
|
||||
done = 1;
|
||||
}
|
||||
/* -3 = add another → re-loop to preset screen */
|
||||
/* -4 = cancel from the added screen (treat as done, keep roles) */
|
||||
if (post == -4) {
|
||||
done = 1;
|
||||
}
|
||||
}
|
||||
|
||||
return (out_table->count > 0) ? 0 : -1;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include "role_table.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
@@ -80,6 +81,21 @@ ui_approval_decision_t ui_approve(const char *verb, const char *summary);
|
||||
int ui_pick_pad(const char *pad_chksums[], const uint64_t pad_sizes[],
|
||||
int count, char *out_chksum, size_t out_chksum_cap);
|
||||
|
||||
/* Role-preset wizard (Phase 5 of plans/teensy41_role_path_migration.md).
|
||||
*
|
||||
* Presents the 10 role presets (matching the host wizard) as a scrollable
|
||||
* list of buttons. When the user taps a preset, a role is created with the
|
||||
* preset's default name + path + requires_approval=0 (role-as-password).
|
||||
* Then a "Done" / "Add another" prompt loops until at least one role is
|
||||
* defined and the user taps "Done".
|
||||
*
|
||||
* At least one role is required. If the user taps "Done" with zero roles,
|
||||
* an error message is shown and the wizard re-loops.
|
||||
*
|
||||
* Fills `out_table` with the defined roles. Returns 0 on success, -1 if the
|
||||
* user cancels (which should abort the boot). */
|
||||
int ui_role_wizard(role_table_t *out_table);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
/* host_test_parse_bip44_path.c — unit test for parse_bip44_path().
|
||||
*
|
||||
* parse_bip44_path() is a pure-C function with no crypto dependencies, so it
|
||||
* can be tested host-side by compiling key_derivation.cpp with -DHOST_TEST
|
||||
* and stubbing out the Arduino/nostr_core/secp256k1 calls it does not use.
|
||||
*
|
||||
* Actually, parse_bip44_path() is self-contained inside key_derivation.cpp,
|
||||
* but key_derivation.cpp pulls in Arduino.h, secp256k1, nostr_core, etc. To
|
||||
* avoid dragging all of that into a host build, this test re-implements the
|
||||
* parser check by #including a standalone copy of the function via a
|
||||
* HOST_TEST guard. The simplest approach: compile a tiny .c that defines
|
||||
* the function directly (copied from key_derivation.cpp) and tests it.
|
||||
*
|
||||
* Build:
|
||||
* cc -O2 -Wall -Wextra -o host_test_parse_bip44_path \
|
||||
* firmware/teensy41/signer/tests/host_test_parse_bip44_path.c
|
||||
* ./host_test_parse_bip44_path
|
||||
*
|
||||
* If parse_bip44_path() in key_derivation.cpp is ever changed, copy the new
|
||||
* body into the function below to keep this test in sync.
|
||||
*/
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#define BIP32_HARDENED_FLAG 0x80000000u
|
||||
|
||||
/* Copied from key_derivation.cpp — kept in sync manually. */
|
||||
static int parse_bip44_path(const char *path_str, uint32_t *out, int max_segments) {
|
||||
char buf[128];
|
||||
char *p;
|
||||
int count = 0;
|
||||
|
||||
if (path_str == NULL || out == NULL || max_segments <= 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
{
|
||||
size_t plen = strlen(path_str);
|
||||
if (plen >= sizeof(buf)) {
|
||||
return -1;
|
||||
}
|
||||
memcpy(buf, path_str, plen);
|
||||
buf[plen] = '\0';
|
||||
}
|
||||
|
||||
p = buf;
|
||||
if (*p == 'm' || *p == 'M') {
|
||||
p++;
|
||||
if (*p == '/') {
|
||||
p++;
|
||||
} else if (*p != '\0') {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
while (*p != '\0' && count < max_segments) {
|
||||
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';
|
||||
|
||||
if (seg[seg_len - 1] == '\'' || seg[seg_len - 1] == 'h' ||
|
||||
seg[seg_len - 1] == 'H') {
|
||||
hardened = 1;
|
||||
seg[seg_len - 1] = '\0';
|
||||
/* A bare hardened marker with no number (e.g. "m/0/'") is invalid. */
|
||||
if (seg[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
val = strtol(seg, &endptr, 10);
|
||||
if (*endptr != '\0' || val < 0 || val > 0x7FFFFFFF) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
out[count] = (uint32_t)val;
|
||||
if (hardened) {
|
||||
out[count] |= BIP32_HARDENED_FLAG;
|
||||
}
|
||||
count++;
|
||||
|
||||
p = (slash != NULL) ? slash + 1 : "";
|
||||
if (*p == '\0') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
static int failures = 0;
|
||||
static int passes = 0;
|
||||
|
||||
#define CHECK(cond, msg) do { \
|
||||
if (cond) { passes++; } \
|
||||
else { failures++; printf("FAIL: %s\n", msg); } \
|
||||
} while (0)
|
||||
|
||||
static void check_path(const char *path, const uint32_t *expected, int expected_len) {
|
||||
uint32_t out[16];
|
||||
int n = parse_bip44_path(path, out, 16);
|
||||
char msg[256];
|
||||
snprintf(msg, sizeof(msg), "parse_bip44_path(\"%s\") returned %d (expected %d)", path, n, expected_len);
|
||||
CHECK(n == expected_len, msg);
|
||||
if (n == expected_len) {
|
||||
for (int i = 0; i < expected_len; i++) {
|
||||
snprintf(msg, sizeof(msg), "parse_bip44_path(\"%s\") segment %d = 0x%08x (expected 0x%08x)",
|
||||
path, i, out[i], expected[i]);
|
||||
CHECK(out[i] == expected[i], msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
/* NIP-06 standard Nostr path: m/44'/1237'/0'/0/0 */
|
||||
{
|
||||
uint32_t exp[] = {
|
||||
44u | BIP32_HARDENED_FLAG,
|
||||
1237u | BIP32_HARDENED_FLAG,
|
||||
0u | BIP32_HARDENED_FLAG,
|
||||
0u,
|
||||
0u,
|
||||
};
|
||||
check_path("m/44'/1237'/0'/0/0", exp, 5);
|
||||
}
|
||||
|
||||
/* NIP-06 with nostr_index 5: m/44'/1237'/0'/0/5 */
|
||||
{
|
||||
uint32_t exp[] = {
|
||||
44u | BIP32_HARDENED_FLAG,
|
||||
1237u | BIP32_HARDENED_FLAG,
|
||||
0u | BIP32_HARDENED_FLAG,
|
||||
0u,
|
||||
5u,
|
||||
};
|
||||
check_path("m/44'/1237'/0'/0/5", exp, 5);
|
||||
}
|
||||
|
||||
/* All-hardened variant: m/44'/1237'/0'/0'/0' */
|
||||
{
|
||||
uint32_t exp[] = {
|
||||
44u | BIP32_HARDENED_FLAG,
|
||||
1237u | BIP32_HARDENED_FLAG,
|
||||
0u | BIP32_HARDENED_FLAG,
|
||||
0u | BIP32_HARDENED_FLAG,
|
||||
0u | BIP32_HARDENED_FLAG,
|
||||
};
|
||||
check_path("m/44'/1237'/0'/0'/0'", exp, 5);
|
||||
}
|
||||
|
||||
/* 'h' hardened marker: m/44h/1237h/0h/0/0 */
|
||||
{
|
||||
uint32_t exp[] = {
|
||||
44u | BIP32_HARDENED_FLAG,
|
||||
1237u | BIP32_HARDENED_FLAG,
|
||||
0u | BIP32_HARDENED_FLAG,
|
||||
0u,
|
||||
0u,
|
||||
};
|
||||
check_path("m/44h/1237h/0h/0/0", exp, 5);
|
||||
}
|
||||
|
||||
/* 'H' hardened marker: m/44H/1237H/0H/0/0 */
|
||||
{
|
||||
uint32_t exp[] = {
|
||||
44u | BIP32_HARDENED_FLAG,
|
||||
1237u | BIP32_HARDENED_FLAG,
|
||||
0u | BIP32_HARDENED_FLAG,
|
||||
0u,
|
||||
0u,
|
||||
};
|
||||
check_path("m/44H/1237H/0H/0/0", exp, 5);
|
||||
}
|
||||
|
||||
/* SSH ed25519 path: m/44'/102001'/0'/0'/0' */
|
||||
{
|
||||
uint32_t exp[] = {
|
||||
44u | BIP32_HARDENED_FLAG,
|
||||
102001u | BIP32_HARDENED_FLAG,
|
||||
0u | BIP32_HARDENED_FLAG,
|
||||
0u | BIP32_HARDENED_FLAG,
|
||||
0u | BIP32_HARDENED_FLAG,
|
||||
};
|
||||
check_path("m/44'/102001'/0'/0'/0'", exp, 5);
|
||||
}
|
||||
|
||||
/* No 'm' prefix: 44'/1237'/0'/0/0 */
|
||||
{
|
||||
uint32_t exp[] = {
|
||||
44u | BIP32_HARDENED_FLAG,
|
||||
1237u | BIP32_HARDENED_FLAG,
|
||||
0u | BIP32_HARDENED_FLAG,
|
||||
0u,
|
||||
0u,
|
||||
};
|
||||
check_path("44'/1237'/0'/0/0", exp, 5);
|
||||
}
|
||||
|
||||
/* Just "m" (root key, no segments) */
|
||||
{
|
||||
check_path("m", NULL, 0);
|
||||
}
|
||||
|
||||
/* Empty string (root key, no segments) */
|
||||
{
|
||||
check_path("", NULL, 0);
|
||||
}
|
||||
|
||||
/* Error cases */
|
||||
{
|
||||
uint32_t out[16];
|
||||
CHECK(parse_bip44_path(NULL, out, 16) == -1, "NULL path_str rejected");
|
||||
CHECK(parse_bip44_path("m/", out, 0) == -1, "max_segments=0 rejected");
|
||||
CHECK(parse_bip44_path("m/abc", out, 16) == -1, "non-numeric segment rejected");
|
||||
CHECK(parse_bip44_path("m/44'/1237'/0'/0/-1", out, 16) == -1, "negative index rejected");
|
||||
CHECK(parse_bip44_path("m/44'/1237'/0'/0/99999999999", out, 16) == -1, "overflow index rejected");
|
||||
CHECK(parse_bip44_path("m44", out, 16) == -1, "m without / rejected");
|
||||
CHECK(parse_bip44_path("m/44'/1237'/0'/0/'", out, 16) == -1, "hardened marker with no number rejected");
|
||||
}
|
||||
|
||||
/* Large index within range (0x7FFFFFFF = 2147483647, the max non-hardened) */
|
||||
{
|
||||
uint32_t exp[] = { 0x7FFFFFFFu };
|
||||
check_path("m/2147483647", exp, 1);
|
||||
}
|
||||
|
||||
printf("\n=== parse_bip44_path host test: %d passed, %d failed ===\n",
|
||||
passes, failures);
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
/* host_test_role_table.c — unit test for the role table path-template matching.
|
||||
*
|
||||
* Tests role_path_matches_template(), role_path_extract_index(), and
|
||||
* role_path_matches_with_range() — the pure-string matching logic from
|
||||
* role_table.cpp. The table add/find operations are trivial array ops and
|
||||
* are tested implicitly via the range checks.
|
||||
*
|
||||
* As with host_test_parse_bip44_path.c, the matching functions are copied
|
||||
* from role_table.cpp into this test to avoid pulling in Arduino.h and the
|
||||
* rest of the firmware build. Keep the copies in sync if role_table.cpp
|
||||
* changes.
|
||||
*
|
||||
* Build:
|
||||
* cc -O2 -Wall -Wextra -o host_test_role_table \
|
||||
* firmware/teensy41/signer/tests/host_test_role_table.c
|
||||
* ./host_test_role_table
|
||||
*/
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
/* ---- Minimal role_entry_t for the range check (only the fields used by
|
||||
* role_path_matches_with_range are needed) ---- */
|
||||
typedef struct {
|
||||
char role_path[128];
|
||||
int path_range_lo;
|
||||
int path_range_hi;
|
||||
} role_entry_t;
|
||||
|
||||
/* ---- Copied from role_table.cpp — kept in sync manually ---- */
|
||||
|
||||
static int role_path_matches_template(const char *path, const char *template_str) {
|
||||
const char *p = path;
|
||||
const char *t = template_str;
|
||||
|
||||
if (path == NULL || template_str == NULL) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
while (*t != '\0' && *p != '\0') {
|
||||
if (*t == '%' && *(t + 1) == 'd') {
|
||||
t += 2;
|
||||
if (*t == '\'' || *t == 'h' || *t == 'H') {
|
||||
t++;
|
||||
}
|
||||
if (*p == '/') {
|
||||
return 0;
|
||||
}
|
||||
while (*p != '\0' && *p != '/') {
|
||||
p++;
|
||||
}
|
||||
if (*t == '/' && *p == '/') {
|
||||
t++;
|
||||
p++;
|
||||
} else if (*t == '\0' && *p == '\0') {
|
||||
return 1;
|
||||
} else if (*t == '\0' && *p == '/') {
|
||||
return 0;
|
||||
} else if (*t == '/' && *p == '\0') {
|
||||
return 0;
|
||||
}
|
||||
} else if (*t == *p) {
|
||||
t++;
|
||||
p++;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return (*t == '\0' && *p == '\0') ? 1 : 0;
|
||||
}
|
||||
|
||||
static int role_path_extract_index(const char *path, const char *template_str) {
|
||||
const char *p = path;
|
||||
const char *t = template_str;
|
||||
const char *seg_start;
|
||||
char seg_buf[32];
|
||||
size_t seg_len;
|
||||
long val;
|
||||
char *endp;
|
||||
|
||||
if (path == NULL || template_str == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (strstr(template_str, "%d") == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
while (*t != '\0' && *p != '\0') {
|
||||
if (*t == '%' && *(t + 1) == 'd') {
|
||||
t += 2;
|
||||
if (*t == '\'' || *t == 'h' || *t == 'H') {
|
||||
t++;
|
||||
}
|
||||
if (*p == '/') {
|
||||
return -1;
|
||||
}
|
||||
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';
|
||||
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;
|
||||
}
|
||||
|
||||
static int role_path_matches_with_range(const char *path, const role_entry_t *role) {
|
||||
int index;
|
||||
|
||||
if (path == NULL || role == NULL) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (strstr(role->role_path, "%d") == NULL) {
|
||||
return role_path_matches_template(path, role->role_path);
|
||||
}
|
||||
|
||||
if (!role_path_matches_template(path, role->role_path)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
index = role_path_extract_index(path, role->role_path);
|
||||
if (index < 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (role->path_range_lo < 0 || role->path_range_hi < 0) {
|
||||
return 0;
|
||||
}
|
||||
return (index >= role->path_range_lo && index <= role->path_range_hi) ? 1 : 0;
|
||||
}
|
||||
|
||||
/* ---- Test harness ---- */
|
||||
|
||||
static int failures = 0;
|
||||
static int passes = 0;
|
||||
|
||||
#define CHECK(cond, msg) do { \
|
||||
if (cond) { passes++; } \
|
||||
else { failures++; printf("FAIL: %s\n", msg); } \
|
||||
} while (0)
|
||||
|
||||
int main(void) {
|
||||
/* ---- role_path_matches_template ---- */
|
||||
|
||||
/* Fixed path: exact match */
|
||||
CHECK(role_path_matches_template("m/44'/1237'/0'/0/0", "m/44'/1237'/0'/0/0") == 1,
|
||||
"fixed path exact match");
|
||||
CHECK(role_path_matches_template("m/44'/1237'/0'/0/1", "m/44'/1237'/0'/0/0") == 0,
|
||||
"fixed path mismatch rejected");
|
||||
CHECK(role_path_matches_template("m/44'/1237'/0'/0/0/extra", "m/44'/1237'/0'/0/0") == 0,
|
||||
"path longer than template rejected");
|
||||
CHECK(role_path_matches_template("m/44'/1237'/0'/0", "m/44'/1237'/0'/0/0") == 0,
|
||||
"path shorter than template rejected");
|
||||
|
||||
/* Template with %d (hardened) */
|
||||
CHECK(role_path_matches_template("m/44'/1237'/5'/0/0", "m/44'/1237'/%d'/0/0") == 1,
|
||||
"template %d' matches index 5");
|
||||
CHECK(role_path_matches_template("m/44'/1237'/0'/0/0", "m/44'/1237'/%d'/0/0") == 1,
|
||||
"template %d' matches index 0");
|
||||
CHECK(role_path_matches_template("m/44'/1237'/100'/0/0", "m/44'/1237'/%d'/0/0") == 1,
|
||||
"template %d' matches index 100");
|
||||
/* NOTE: the hardened marker after %d in the template is OPTIONAL — the
|
||||
* matcher skips it in the template but does not require it in the path.
|
||||
* This matches the host's behavior (src/role_table.c). So %d' matches
|
||||
* both hardened (5') and unhardened (5) path segments structurally. The
|
||||
* range check then validates the numeric index. */
|
||||
CHECK(role_path_matches_template("m/44'/1237'/5/0/0", "m/44'/1237'/%d'/0/0") == 1,
|
||||
"template %d' matches unhardened path segment (host behavior)");
|
||||
CHECK(role_path_matches_template("m/44'/1237'/abc'/0/0", "m/44'/1237'/%d'/0/0") == 1,
|
||||
"template %d' structurally matches non-numeric (range check catches it)");
|
||||
|
||||
/* Template with %d (unhardened) */
|
||||
CHECK(role_path_matches_template("m/44'/1237'/5/0/0", "m/44'/1237'/%d/0/0") == 1,
|
||||
"template %d matches unhardened index 5");
|
||||
|
||||
/* NULL cases */
|
||||
CHECK(role_path_matches_template(NULL, "m/44'") == 0, "NULL path rejected");
|
||||
CHECK(role_path_matches_template("m/44'", NULL) == 0, "NULL template rejected");
|
||||
|
||||
/* ---- role_path_extract_index ---- */
|
||||
|
||||
CHECK(role_path_extract_index("m/44'/1237'/5'/0/0", "m/44'/1237'/%d'/0/0") == 5,
|
||||
"extract index 5 from hardened template");
|
||||
CHECK(role_path_extract_index("m/44'/1237'/0'/0/0", "m/44'/1237'/%d'/0/0") == 0,
|
||||
"extract index 0 from hardened template");
|
||||
CHECK(role_path_extract_index("m/44'/1237'/42/0/0", "m/44'/1237'/%d/0/0") == 42,
|
||||
"extract index 42 from unhardened template");
|
||||
CHECK(role_path_extract_index("m/44'/1237'/0'/0/0", "m/44'/1237'/0'/0/0") == -1,
|
||||
"extract index from fixed path returns -1");
|
||||
CHECK(role_path_extract_index("m/44'/1237'/abc'/0/0", "m/44'/1237'/%d'/0/0") == -1,
|
||||
"extract index from non-numeric returns -1");
|
||||
|
||||
/* ---- role_path_matches_with_range ---- */
|
||||
|
||||
/* Fixed path role */
|
||||
{
|
||||
role_entry_t r;
|
||||
memset(&r, 0, sizeof(r));
|
||||
strncpy(r.role_path, "m/44'/1237'/0'/0/0", sizeof(r.role_path) - 1);
|
||||
r.path_range_lo = -1;
|
||||
r.path_range_hi = -1;
|
||||
CHECK(role_path_matches_with_range("m/44'/1237'/0'/0/0", &r) == 1,
|
||||
"fixed path role matches its path");
|
||||
CHECK(role_path_matches_with_range("m/44'/1237'/0'/0/1", &r) == 0,
|
||||
"fixed path role rejects different path");
|
||||
}
|
||||
|
||||
/* Range role: m/44'/1237'/%d'/0/0, range 0-100 */
|
||||
{
|
||||
role_entry_t r;
|
||||
memset(&r, 0, sizeof(r));
|
||||
strncpy(r.role_path, "m/44'/1237'/%d'/0/0", sizeof(r.role_path) - 1);
|
||||
r.path_range_lo = 0;
|
||||
r.path_range_hi = 100;
|
||||
CHECK(role_path_matches_with_range("m/44'/1237'/0'/0/0", &r) == 1,
|
||||
"range role accepts index 0");
|
||||
CHECK(role_path_matches_with_range("m/44'/1237'/50'/0/0", &r) == 1,
|
||||
"range role accepts index 50");
|
||||
CHECK(role_path_matches_with_range("m/44'/1237'/100'/0/0", &r) == 1,
|
||||
"range role accepts index 100 (boundary)");
|
||||
CHECK(role_path_matches_with_range("m/44'/1237'/101'/0/0", &r) == 0,
|
||||
"range role rejects index 101 (out of bounds)");
|
||||
/* The hardened marker after %d is optional in the matcher, so an
|
||||
* unhardened segment that's in range is accepted. This matches the
|
||||
* host's behavior. (If hardened-only enforcement is ever needed, the
|
||||
* matcher would have to check the segment's trailing marker.) */
|
||||
CHECK(role_path_matches_with_range("m/44'/1237'/5/0/0", &r) == 1,
|
||||
"range role accepts unhardened segment in range (host behavior)");
|
||||
CHECK(role_path_matches_with_range("m/44'/1237'/abc'/0/0", &r) == 0,
|
||||
"range role rejects non-numeric segment");
|
||||
}
|
||||
|
||||
/* Range role with no range configured (lo/hi = -1) — fail-closed */
|
||||
{
|
||||
role_entry_t r;
|
||||
memset(&r, 0, sizeof(r));
|
||||
strncpy(r.role_path, "m/44'/1237'/%d'/0/0", sizeof(r.role_path) - 1);
|
||||
r.path_range_lo = -1;
|
||||
r.path_range_hi = -1;
|
||||
CHECK(role_path_matches_with_range("m/44'/1237'/0'/0/0", &r) == 0,
|
||||
"template role with no range denies (fail-closed)");
|
||||
}
|
||||
|
||||
printf("\n=== role_table host test: %d passed, %d failed ===\n",
|
||||
passes, failures);
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -121,23 +121,25 @@ def main():
|
||||
t("derive", lambda: call(ser, "derive", ["derive-test", {"algorithm": "secp256k1", "index": 1}]))
|
||||
# nostr
|
||||
npub = [None]
|
||||
# Role + role_path selector (replaces the deprecated nostr_index).
|
||||
main_role = {"role": "main", "role_path": "m/44'/1237'/0'/0/0"}
|
||||
def ngpk():
|
||||
r = call(ser, "nostr_get_public_key", [{"nostr_index": 0}])
|
||||
r = call(ser, "nostr_get_public_key", [main_role])
|
||||
npub[0] = r["result"]
|
||||
t("nostr_get_public_key", ngpk)
|
||||
def nse():
|
||||
ev = {"kind": 1, "created_at": int(time.time()), "tags": [], "content": "hello event"}
|
||||
call(ser, "nostr_sign_event", [ev, {"nostr_index": 0}])
|
||||
call(ser, "nostr_sign_event", [ev, main_role])
|
||||
t("nostr_sign_event", nse)
|
||||
# nip04
|
||||
def nip04():
|
||||
r = call(ser, "nostr_nip04_encrypt", [npub[0], "hello via nip04", {"nostr_index": 0}])
|
||||
call(ser, "nostr_nip04_decrypt", [npub[0], r["result"], {"nostr_index": 0}])
|
||||
r = call(ser, "nostr_nip04_encrypt", [npub[0], "hello via nip04", main_role])
|
||||
call(ser, "nostr_nip04_decrypt", [npub[0], r["result"], main_role])
|
||||
t("nip04 round-trip", nip04)
|
||||
# nip44
|
||||
def nip44():
|
||||
r = call(ser, "nostr_nip44_encrypt", [npub[0], "hello via nip44", {"nostr_index": 0}])
|
||||
call(ser, "nostr_nip44_decrypt", [npub[0], r["result"], {"nostr_index": 0}])
|
||||
r = call(ser, "nostr_nip44_encrypt", [npub[0], "hello via nip44", main_role])
|
||||
call(ser, "nostr_nip44_decrypt", [npub[0], r["result"], main_role])
|
||||
t("nip44 round-trip", nip44)
|
||||
except Exception as e:
|
||||
print(f"\n!! STOPPED: {e}", flush=True)
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
|
||||
Flow:
|
||||
1. get_info (sanity)
|
||||
2. nostr_get_public_key (nostr_index=0) -> our x-only secp256k1 pubkey (peer)
|
||||
3. nostr_nip04_encrypt [our_pub, "hello via nip04", {nostr_index:0}]
|
||||
2. nostr_get_public_key (role=main, role_path=m/44'1237'0'/0/0)
|
||||
-> our x-only secp256k1 pubkey (peer)
|
||||
3. nostr_nip04_encrypt [our_pub, "hello via nip04", {role:main, role_path:...}]
|
||||
-> ciphertext?iv=...
|
||||
4. nostr_nip04_decrypt [our_pub, ciphertext, {nostr_index:0}]
|
||||
4. nostr_nip04_decrypt [our_pub, ciphertext, {role:main, role_path:...}]
|
||||
-> should recover "hello via nip04"
|
||||
|
||||
Also tests NIP-44 the same way to verify the is_nip44 dispatch fix.
|
||||
@@ -90,8 +91,11 @@ def main():
|
||||
if "result" not in r:
|
||||
print("FAIL: get_info"); ok = False
|
||||
|
||||
# Role + role_path selector (replaces the deprecated nostr_index).
|
||||
main_role = {"role": "main", "role_path": "m/44'/1237'/0'/0/0"}
|
||||
|
||||
# 2. our nostr pubkey (x-only, 64 hex)
|
||||
r = call(ser, "nostr_get_public_key", [{"nostr_index": 0}])
|
||||
r = call(ser, "nostr_get_public_key", [main_role])
|
||||
if "result" not in r:
|
||||
print("FAIL: nostr_get_public_key"); ok = False; ser.close(); return 1
|
||||
our_pub = r["result"]
|
||||
@@ -101,14 +105,14 @@ def main():
|
||||
|
||||
# 3. NIP-04 encrypt to ourselves
|
||||
plaintext = "hello via nip04"
|
||||
r = call(ser, "nostr_nip04_encrypt", [our_pub, plaintext, {"nostr_index": 0}])
|
||||
r = call(ser, "nostr_nip04_encrypt", [our_pub, plaintext, main_role])
|
||||
if "result" not in r:
|
||||
print("FAIL: nostr_nip04_encrypt (this is the crash we are testing)"); ok = False
|
||||
else:
|
||||
cipher = r["result"]
|
||||
print(f" ciphertext = {cipher}")
|
||||
# 4. NIP-04 decrypt
|
||||
r = call(ser, "nostr_nip04_decrypt", [our_pub, cipher, {"nostr_index": 0}])
|
||||
r = call(ser, "nostr_nip04_decrypt", [our_pub, cipher, main_role])
|
||||
if "result" not in r:
|
||||
print("FAIL: nostr_nip04_decrypt"); ok = False
|
||||
else:
|
||||
@@ -122,13 +126,13 @@ def main():
|
||||
|
||||
# 5. NIP-44 encrypt to ourselves (verifies the is_nip44 dispatch fix)
|
||||
plaintext44 = "hello via nip44"
|
||||
r = call(ser, "nostr_nip44_encrypt", [our_pub, plaintext44, {"nostr_index": 0}])
|
||||
r = call(ser, "nostr_nip44_encrypt", [our_pub, plaintext44, main_role])
|
||||
if "result" not in r:
|
||||
print("FAIL: nostr_nip44_encrypt"); ok = False
|
||||
else:
|
||||
cipher44 = r["result"]
|
||||
print(f" nip44 ciphertext = {cipher44[:60]}...")
|
||||
r = call(ser, "nostr_nip44_decrypt", [our_pub, cipher44, {"nostr_index": 0}])
|
||||
r = call(ser, "nostr_nip44_decrypt", [our_pub, cipher44, main_role])
|
||||
if "result" not in r:
|
||||
print("FAIL: nostr_nip44_decrypt"); ok = False
|
||||
else:
|
||||
|
||||
@@ -229,8 +229,11 @@ def main():
|
||||
if r and "result" in r: passed += 1
|
||||
else: failed += 1
|
||||
|
||||
# Role + role_path selector (replaces the deprecated nostr_index).
|
||||
main_role = {"role": "main", "role_path": "m/44'/1237'/0'/0/0"}
|
||||
|
||||
# 8. nostr_get_public_key
|
||||
r = test_verb(ser, "nostr_get_public_key", [{"nostr_index": 0}])
|
||||
r = test_verb(ser, "nostr_get_public_key", [main_role])
|
||||
nostr_pub = None
|
||||
if r and "result" in r:
|
||||
passed += 1
|
||||
@@ -243,18 +246,18 @@ def main():
|
||||
# 9. nostr_sign_event
|
||||
if nostr_pub:
|
||||
event = {"kind": 1, "created_at": int(time.time()), "tags": [], "content": "hello from test_signer"}
|
||||
r = test_verb(ser, "nostr_sign_event", [event, {"nostr_index": 0}])
|
||||
r = test_verb(ser, "nostr_sign_event", [event, main_role])
|
||||
if r and "result" in r: passed += 1
|
||||
else: failed += 1
|
||||
|
||||
# 10. nostr_nip04_encrypt + decrypt (the bug we fixed)
|
||||
if nostr_pub:
|
||||
nip04_pt = "hello via nip04"
|
||||
r = test_verb(ser, "nostr_nip04_encrypt", [nostr_pub, nip04_pt, {"nostr_index": 0}])
|
||||
r = test_verb(ser, "nostr_nip04_encrypt", [nostr_pub, nip04_pt, main_role])
|
||||
if r and "result" in r:
|
||||
passed += 1
|
||||
cipher = r["result"]
|
||||
r = test_verb(ser, "nostr_nip04_decrypt", [nostr_pub, cipher, {"nostr_index": 0}])
|
||||
r = test_verb(ser, "nostr_nip04_decrypt", [nostr_pub, cipher, main_role])
|
||||
if r and "result" in r and r["result"] == nip04_pt:
|
||||
print(f" ✅ nip04 round-trip plaintext recovered")
|
||||
passed += 1
|
||||
@@ -267,11 +270,11 @@ def main():
|
||||
# 11. nostr_nip44_encrypt + decrypt (the is_nip44 dispatch bug we fixed)
|
||||
if nostr_pub:
|
||||
nip44_pt = "hello via nip44"
|
||||
r = test_verb(ser, "nostr_nip44_encrypt", [nostr_pub, nip44_pt, {"nostr_index": 0}])
|
||||
r = test_verb(ser, "nostr_nip44_encrypt", [nostr_pub, nip44_pt, main_role])
|
||||
if r and "result" in r:
|
||||
passed += 1
|
||||
cipher44 = r["result"]
|
||||
r = test_verb(ser, "nostr_nip44_decrypt", [nostr_pub, cipher44, {"nostr_index": 0}])
|
||||
r = test_verb(ser, "nostr_nip44_decrypt", [nostr_pub, cipher44, main_role])
|
||||
if r and "result" in r and r["result"] == nip44_pt:
|
||||
print(f" ✅ nip44 round-trip plaintext recovered")
|
||||
passed += 1
|
||||
@@ -281,6 +284,40 @@ def main():
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# ---- Role + path authorization error cases ----
|
||||
print("\n=== Role + path authorization error cases ===")
|
||||
|
||||
# 11a. nostr_index is deprecated → error 2006
|
||||
r = test_verb(ser, "nostr_get_public_key", [{"nostr_index": 0}])
|
||||
if r and "error" in r and r["error"].get("code") == 2006:
|
||||
print(f" ✅ nostr_index rejected with error 2006 (deprecated)")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" ❌ nostr_index not rejected as expected: {r}")
|
||||
failed += 1
|
||||
|
||||
# 11b. unknown role → error 1002
|
||||
r = test_verb(ser, "nostr_get_public_key",
|
||||
[{"role": "nonexistent", "role_path": "m/44'/1237'/0'/0/0"}])
|
||||
if r and "error" in r and r["error"].get("code") == 1002:
|
||||
print(f" ✅ unknown role rejected with error 1002")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" ❌ unknown role not rejected as expected: {r}")
|
||||
failed += 1
|
||||
|
||||
# 11c. path out of range → error 2003
|
||||
# (Requires a range role; the default "main" role is fixed-path, so this
|
||||
# tests a path that doesn't match the fixed template.)
|
||||
r = test_verb(ser, "nostr_get_public_key",
|
||||
[{"role": "main", "role_path": "m/44'/1237'/999'/0/0"}])
|
||||
if r and "error" in r and r["error"].get("code") == 2003:
|
||||
print(f" ✅ path mismatch rejected with error 2003")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" ❌ path mismatch not rejected as expected: {r}")
|
||||
failed += 1
|
||||
|
||||
# ---- PQ verbs (tested LAST: heap-heavy, may crash the device) ----
|
||||
print("\n=== PQ verbs (heap-heavy; tested last) ===")
|
||||
|
||||
|
||||
+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,179 @@
|
||||
# Audit: n_signer Breaking Changes vs Client Repos
|
||||
|
||||
## 1. The breaking changes made to n_signer
|
||||
|
||||
Three changes on the n_signer wire protocol are breaking for every existing
|
||||
client. All three are landed in `src/` and documented in `README.md` §4.
|
||||
|
||||
### 1.1 Verb renames (legacy names removed)
|
||||
|
||||
Source: [`plans/legacy_verb_aliases.md`](legacy_verb_aliases.md) — COMPLETED.
|
||||
|
||||
| Old wire verb | New wire verb |
|
||||
|--------------------|----------------------------|
|
||||
| `sign_event` | `nostr_sign_event` |
|
||||
| `mine_event` | `nostr_mine_event` |
|
||||
| `nip04_encrypt` | `nostr_nip04_encrypt` |
|
||||
| `nip04_decrypt` | `nostr_nip04_decrypt` |
|
||||
| `nip44_encrypt` | `nostr_nip44_encrypt` |
|
||||
| `nip44_decrypt` | `nostr_nip44_decrypt` |
|
||||
| `get_public_key` (role branch) | `nostr_get_public_key` |
|
||||
|
||||
The role-based `get_public_key` was split: algorithm-based stays
|
||||
`get_public_key`; Nostr-protocol key selection is now `nostr_get_public_key`.
|
||||
The old alias names are **gone** — no shim, no fallthrough.
|
||||
|
||||
### 1.2 Selector model rewrite (nostr_index / index removed for nostr verbs)
|
||||
|
||||
Source: [`plans/role_path_authorization.md`](role_path_authorization.md).
|
||||
|
||||
- `nostr_index` selector → **removed**, rejected with error `2006
|
||||
nostr_index_deprecated` (see [`src/dispatcher.c`](../src/dispatcher.c:1815)).
|
||||
- `index` on `nostr_*` verbs → **removed**, rejected with `2007
|
||||
index_deprecated`.
|
||||
- The **only** accepted selector for `nostr_*` verbs is now `{"role":"<name>",
|
||||
"role_path":"<full-path>"}` sent **together**. Either field alone is
|
||||
rejected: `2008 role_required` / `2009 path_required`
|
||||
([`README.md`](../README.md) §4.6).
|
||||
- No backward compatibility. `--nostr-index` / `--index` on the client CLI are
|
||||
removed; replaced by `--role` + `--path`.
|
||||
|
||||
### 1.3 OTP encoding values changed
|
||||
|
||||
`encrypt` / `decrypt` (algorithm `otp`) now take `encoding` =
|
||||
`"ascii"` (ASCII-armored, default) or `"binary"` (base64 raw `.otp` blob)
|
||||
([`src/dispatcher.c`](../src/dispatcher.c:1452), [`src/otp_pad.c`](../src/otp_pad.c:347)).
|
||||
|
||||
Note: [`client/n_signer_client.c`](../client/n_signer_client.c:51) help text
|
||||
still advertises `--encoding <base64|hex>` — that is a **stale doc string**
|
||||
inside n_signer's own client and should be fixed to `ascii|binary`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Are these reflected in the nostr_core_lib repo? — NO
|
||||
|
||||
`nostr_core_lib` is the shared client library that every C-based n_signer
|
||||
client links against. It is **out of date** and will fail against current
|
||||
n_signer. Specific gaps:
|
||||
|
||||
### 2.1 Still emits the removed `nostr_index` selector
|
||||
|
||||
[`nostr_core_lib/nostr_core/nostr_signer.c`](../../nostr_core_lib/nostr_core/nostr_signer.c:309)
|
||||
`signer_remote_params_with_selector()` emits `{"nostr_index":N}` when set
|
||||
(lines 320–327). n_signer now rejects this with `2006 nostr_index_deprecated`.
|
||||
|
||||
The public API
|
||||
[`nostr_signer_nsigner_set_nostr_index()`](../../nostr_core_lib/nostr_core/nostr_signer.c:858)
|
||||
still exists and is the documented way to select a key — it is now a dead end.
|
||||
|
||||
### 2.2 Sends `role` without `role_path`
|
||||
|
||||
When `nostr_index` is not set, the same helper emits only `{"role":"..."}`
|
||||
(line 341) with no `role_path`. n_signer now requires both and rejects
|
||||
role-only with `2009 path_required`.
|
||||
|
||||
The `nostr_signer_nsigner_*` factory constructors
|
||||
([`nostr_signer.h`](../../nostr_core_lib/nostr_core/nostr_signer.h:51)) take a
|
||||
single `const char* role` parameter — there is no way to pass a `role_path`
|
||||
through the high-level API at all.
|
||||
|
||||
### 2.3 `derive` (HMAC) path is half-broken
|
||||
|
||||
[`nostr_signer.c`](../../nostr_core_lib/nostr_core/nostr_signer.c:560) builds
|
||||
`{"algorithm":"secp256k1","index":N}` for the `derive` verb. The
|
||||
algorithm-based `derive` verb still accepts `index`, so the `nostr_index` branch
|
||||
works. But the `role`-only branch (line 563) sends `{"role":"..."}` with no
|
||||
`index` — `derive` requires `index` and will reject it.
|
||||
|
||||
### 2.4 Documentation is stale
|
||||
|
||||
[`NSIGNER_INTEGRATION.md`](../../nostr_core_lib/nostr_core/NSIGNER_INTEGRATION.md:123)
|
||||
still tells integrators to use `nostr_index` and `role`-only selectors, and
|
||||
[`plans/nostr_core_lib_client_updates.md`](../../nostr_core_lib/plans/nostr_core_lib_client_updates.md)
|
||||
proposes `nostr_index` support as the chosen design — both predate the
|
||||
selector rewrite.
|
||||
|
||||
### 2.5 What needs to change in nostr_core_lib
|
||||
|
||||
1. Replace the `role`-only + `nostr_index` selector model with a combined
|
||||
`role` + `role_path` selector. Concretely: change the `nostr_signer_nsigner_*`
|
||||
constructors (or add new ones / a selector struct) to accept both a role
|
||||
name and a full path.
|
||||
2. Remove `nostr_signer_nsigner_set_nostr_index` (or repurpose it to set
|
||||
`role` + `role_path` from an index by expanding the NIP-06 template
|
||||
`m/44'/1237'/N'/0/0` client-side).
|
||||
3. Update `signer_remote_params_with_selector` to always emit both `role` and
|
||||
`role_path`.
|
||||
4. Fix the `derive` remote path to always include `index`.
|
||||
5. Update `NSIGNER_INTEGRATION.md`, `nostr_core_lib_client_updates.md`, and
|
||||
`tests/nsigner_client_test.c` (which sends `nostr_get_public_key` with a
|
||||
`nostr_index` selector at line 297).
|
||||
|
||||
---
|
||||
|
||||
## 3. Repos in ~/lt/ that need client edits
|
||||
|
||||
### Tier 1 — Direct n_signer wire clients (BROKEN now)
|
||||
|
||||
These talk the n_signer JSON-RPC protocol directly and will fail against
|
||||
current n_signer:
|
||||
|
||||
| Repo | Files | Problem |
|
||||
|------|-------|---------|
|
||||
| **nostr_core_lib** | `nostr_core/nostr_signer.c`, `nostr_signer.h`, `nsigner_client.c`, `NSIGNER_INTEGRATION.md`, `tests/nsigner_client_test.c`, `examples/note_poster.c` | Emits removed `nostr_index`; sends `role` without `role_path`. Shared lib — fixing this fixes all C clients that link it. |
|
||||
| **nostr_terminal** | `src/nsigner_client.c`, `include/nsigner_client.h`, `src/signer.c`, `src/menu_login.c`, `src/menu_profile.c`, `plans/n_signer_integration.md` | Has its own hand-rolled `nsigner_client` that sends `{"nostr_index":N}` ([`nsigner_client.c`](../../nostr_terminal/src/nsigner_client.c:617)). Selector struct is `has_nostr_index`/`nostr_index`/`role` with no `role_path`. Login menu prompts for "index" only. |
|
||||
| **sovereign_browser** | `src/login_dialog.c`, `src/agent_login.c`, `src/key_store.c`, `src/key_store.h` | Uses `nostr_signer_nsigner_*` from nostr_core_lib + `nostr_signer_nsigner_set_nostr_index`. UI has a nostr_index spin button. Breaks via the lib, and the UI needs a role+path input. |
|
||||
| **laantungir_website** | `scripts/publish_nostr.js`, `scripts/get_nsigner_pubkey.js` | Raw JSON-RPC over qrexec sending `{"nostr_index": N}` ([`publish_nostr.js`](../../laantungir_website/scripts/publish_nostr.js:103)). Will get `2006`. |
|
||||
|
||||
### Tier 2 — Indirect (breaks once Tier 1 lib is fixed, or uses nostr_core_lib local signing only)
|
||||
|
||||
| Repo | Status | Action |
|
||||
|------|--------|--------|
|
||||
| **n_signer** (this repo) | `client/n_signer_client.c` help text says `--encoding <base64\|hex>` but server wants `ascii\|binary`; the client itself already uses `--role`+`--path` correctly per [`role_path_authorization.md`](role_path_authorization.md). | Fix the stale `--encoding` help string. |
|
||||
|
||||
### Not affected (use local nostr_core_lib signing, not n_signer remote)
|
||||
|
||||
These call `nostr_create_and_sign_event` / `nostr_signer_local` with a local
|
||||
private key — they do not speak the n_signer wire protocol and are unaffected:
|
||||
|
||||
- `open_wire` (local `sign_event` helper, not n_signer RPC)
|
||||
- `raspberry_pi_zero_nostr` (local `nostr_create_and_sign_event`)
|
||||
- `esp32_playground` (local `nostr_create_and_sign_event`)
|
||||
|
||||
### Not affected (NIP-46 to arbitrary remote signers, not n_signer)
|
||||
|
||||
These use NIP-46 method names (`sign_event`, `nip04_encrypt`, …) per the NIP-46
|
||||
spec, targeting generic remote signers / browser extensions — not n_signer's
|
||||
renamed verbs. No change needed unless they specifically add an n_signer
|
||||
backend:
|
||||
|
||||
- `primal-web-app` (`src/lib/nip46/nip46.ts`)
|
||||
- `super_ball` (`web/nostr.bundle.js`)
|
||||
- `nips` (spec docs)
|
||||
|
||||
---
|
||||
|
||||
## 4. Recommended remediation order
|
||||
|
||||
1. **nostr_core_lib** first — it is the shared dependency. Introduce a
|
||||
`role` + `role_path` selector (struct or new constructors), remove
|
||||
`nostr_index` emission, fix `derive`, update tests + integration doc.
|
||||
2. **sovereign_browser** — update login UI to collect role + path instead of
|
||||
index; switch to the new nostr_core_lib API.
|
||||
3. **nostr_terminal** — rewrite its hand-rolled `nsigner_client` selector to
|
||||
`role` + `role_path`; update login/profile menus and the integration plan.
|
||||
4. **laantungir_website** — switch the two JS scripts from `nostr_index` to
|
||||
`role` + `role_path`.
|
||||
5. **n_signer** — fix the stale `--encoding` help string in
|
||||
`client/n_signer_client.c`.
|
||||
|
||||
A Mermaid overview of the dependency order:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
NS[n_signer wire changes] --> NCL[nostr_core_lib]
|
||||
NCL --> SB[sovereign_browser]
|
||||
NCL --> NT[nostr_terminal]
|
||||
NS --> LW[laantungir_website]
|
||||
NS --> NSC[n_signer client help text]
|
||||
```
|
||||
+3
-178
@@ -1,179 +1,4 @@
|
||||
# Plan: `n_signer_client` — Linux CLI for n_signer
|
||||
# Moved
|
||||
|
||||
## Goal
|
||||
|
||||
A standalone Linux command-line client `n_signer_client` that connects to a
|
||||
running `n_signer` process over its abstract UNIX socket (and optionally the
|
||||
other framed transports) and exposes the full verb surface over stdin/stdout so
|
||||
that signed events can be piped directly into `nak publish`.
|
||||
|
||||
## Deliverable
|
||||
|
||||
- New file: [`client/n_signer_client.c`](../client/n_signer_client.c) — single-file C99 program.
|
||||
- New Makefile target producing `build/n_signer_client`.
|
||||
- Updated [`client/README.md`](../client/README.md) with usage and the pipe-to-nak recipe.
|
||||
|
||||
The binary links `nostr_core_lib` exactly like the existing examples
|
||||
[`examples/sign_event_client.c`](../examples/sign_event_client.c) and
|
||||
[`examples/get_public_key_client.c`](../examples/get_public_key_client.c). It
|
||||
uses:
|
||||
|
||||
- [`nsigner_transport_open_unix`](../resources/nostr_core_lib/nostr_core/nsigner_transport.h) (and optionally `_tcp`, `_serial`, `_qrexec`)
|
||||
- [`nsigner_client_new`](../resources/nostr_core_lib/nostr_core/nsigner_client.h) / [`nsigner_client_free`](../resources/nostr_core_lib/nostr_core/nsigner_client.h)
|
||||
- [`nsigner_client_call`](../resources/nostr_core_lib/nostr_core/nsigner_client.h) (takes ownership of `params`)
|
||||
- [`nsigner_client_set_auth`](../resources/nostr_core_lib/nostr_core/nsigner_client.h) for TCP mode
|
||||
|
||||
## CLI shape
|
||||
|
||||
```
|
||||
n_signer_client [global options] <verb> [verb args...]
|
||||
```
|
||||
|
||||
Global options:
|
||||
|
||||
| Flag | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `--socket-name`, `-n <name>` | auto-discover | Abstract socket name without `@` |
|
||||
| `--role <name>` | none | Selector `{"role":"<name>"}` (Nostr verbs) |
|
||||
| `--nostr-index <N>` | none | Selector `{"nostr_index":N}` (mutually exclusive with `--role`) |
|
||||
| `--algorithm <alg>` | none | Algorithm-based verbs: `secp256k1`/`ed25519`/`x25519`/`ml-dsa-65`/`slh-dsa-128s`/`ml-kem-768`/`otp` |
|
||||
| `--index <N>` | `0` | Algorithm key index |
|
||||
| `--scheme <schnorr\|ecdsa>` | `schnorr` | secp256k1 sign/verify scheme |
|
||||
| `--format <plain\|structured>` | `plain` | `get-public-key` output shape |
|
||||
| `--timeout <ms>` | `5000` | Transport timeout |
|
||||
| `--tcp <host:port>` | none | Use TCP transport (requires `--auth-privkey`) |
|
||||
| `--serial <device>` | none | Use USB CDC-ACM serial transport |
|
||||
| `--qrexec <qube:service>` | none | Use Qubes qrexec transport |
|
||||
| `--auth-privkey <32-byte hex>` | none | Auth envelope privkey for TCP |
|
||||
| `--auth-label <text>` | none | Auth envelope label |
|
||||
|
||||
Auto-discovery: when no `--socket-name` and no explicit transport is given,
|
||||
enumerate via `nsigner_transport_list_unix` and proceed only if exactly one
|
||||
`nsigner*` socket exists (mirror `discover_single_socket_name` in
|
||||
[`src/main.c`](../src/main.c)).
|
||||
|
||||
## Verb surface (full)
|
||||
|
||||
### Nostr verbs (role-based; selector from `--role` / `--nostr-index`)
|
||||
|
||||
| Verb | RPC method | stdin/argv | stdout |
|
||||
|---|---|---|---|
|
||||
| `get-public-key` | `nostr_get_public_key` | none | pubkey hex (or structured JSON with `--format structured`) |
|
||||
| `sign-event` | `nostr_sign_event` | event JSON from argv or one stdin line | signed event JSON, one line |
|
||||
| `mine-event` | `nostr_mine_event` | event JSON from argv or stdin; `--difficulty`, `--threads`, `--timeout-sec` | signed mined event JSON |
|
||||
| `nip04-encrypt <peer-pubkey>` | `nostr_nip04_encrypt` | plaintext from argv or stdin | ciphertext |
|
||||
| `nip04-decrypt <peer-pubkey>` | `nostr_nip04_decrypt` | ciphertext from argv or stdin | plaintext |
|
||||
| `nip44-encrypt <peer-pubkey>` | `nostr_nip44_encrypt` | plaintext from argv or stdin | ciphertext |
|
||||
| `nip44-decrypt <peer-pubkey>` | `nostr_nip44_decrypt` | ciphertext from argv or stdin | plaintext |
|
||||
|
||||
### Algorithm-based verbs (use `--algorithm` and `--index`)
|
||||
|
||||
| Verb | RPC method | argv | stdout |
|
||||
|---|---|---|---|
|
||||
| `get-public-key` | `get_public_key` | none | structured JSON `{"algorithm":...,"public_key":...,"key_id":...}` |
|
||||
| `sign <msg-hex>` | `sign` | hex bytes | signature hex |
|
||||
| `verify <msg-hex> <sig-hex>` | `verify` | hex bytes | `valid` / `invalid` (exit 0/1) |
|
||||
| `derive <data>` | `derive` | UTF-8 data (argv or stdin) | `{"algorithm":...,"key_id":...,"digest":...}` |
|
||||
| `encapsulate <peer-pubkey-hex>` | `encapsulate` | hex | `{"ciphertext":...,"shared_secret":...}` |
|
||||
| `decapsulate <ciphertext-hex>` | `decapsulate` | hex | `{"shared_secret":...}` |
|
||||
| `derive-shared-secret <peer-pubkey-hex>` | `derive_shared_secret` | hex | shared secret hex |
|
||||
|
||||
### Generic escape hatch
|
||||
|
||||
| Verb | RPC method | input | stdout |
|
||||
|---|---|---|---|
|
||||
| `call <method>` | `<method>` | JSON `params` array from stdin (one line) or argv | raw `result` JSON |
|
||||
|
||||
This keeps the client future-proof for any new server verb without a CLI rewrite.
|
||||
|
||||
## stdin/stdout contract (pipe-friendly)
|
||||
|
||||
- All payload output goes to stdout as a single line, newline-terminated.
|
||||
- All diagnostics go to stderr.
|
||||
- Exit code: `0` on success, non-zero on transport/RPC error (use
|
||||
`nsigner_client_last_error` for the message).
|
||||
- `sign-event` reads event JSON from argv if present, else reads exactly one
|
||||
line from stdin. This is the pipe-to-nak path:
|
||||
|
||||
```bash
|
||||
echo '{"kind":1,"content":"hello","tags":[],"created_at":1700000000}' \
|
||||
| n_signer_client sign-event \
|
||||
| nak publish
|
||||
```
|
||||
|
||||
- `nip04-encrypt` / `nip44-encrypt` read plaintext from argv or stdin.
|
||||
- `nip04-decrypt` / `nip44-decrypt` read ciphertext from argv or stdin.
|
||||
- `sign` / `verify` / `encapsulate` / `decapsulate` / `derive-shared-secret`
|
||||
take hex from argv (binary payloads, not pipe-friendly text).
|
||||
- `derive` takes UTF-8 data from argv or stdin.
|
||||
- `call` reads a JSON `params` array from stdin (one line) or argv.
|
||||
|
||||
## Selector handling
|
||||
|
||||
- `--role <name>` → `{"role":"<name>"}` in the options object.
|
||||
- `--nostr-index <N>` → `{"nostr_index":N}` (mutually exclusive with `--role`).
|
||||
- Default: no selector (server picks default role `main`).
|
||||
- For algorithm verbs, `--algorithm` and `--index` populate the options object
|
||||
instead; `--scheme` adds `"scheme"` for secp256k1 sign/verify.
|
||||
|
||||
## Transport
|
||||
|
||||
- Default: UNIX abstract socket via `nsigner_transport_open_unix(name, timeout_ms)`.
|
||||
- `--tcp host:port` → `nsigner_transport_open_tcp` (requires `--auth-privkey`
|
||||
32-byte hex; calls `nsigner_client_set_auth` with `--auth-label`).
|
||||
- `--serial /dev/ttyACM0` → `nsigner_transport_open_serial`.
|
||||
- `--qrexec qube:service` → `nsigner_transport_open_qrexec`.
|
||||
- The vtable is uniform so all four transports share the same call path after
|
||||
construction.
|
||||
|
||||
## Build
|
||||
|
||||
Add to [`Makefile`](../Makefile):
|
||||
|
||||
```make
|
||||
N_SIGNER_CLIENT_TARGET := $(BUILD_DIR)/n_signer_client
|
||||
|
||||
clients: $(N_SIGNER_CLIENT_TARGET)
|
||||
|
||||
$(N_SIGNER_CLIENT_TARGET): $(CLIENT_DIR)/n_signer_client.c
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(CC) $(CFLAGS) $(CLIENT_DIR)/n_signer_client.c -o $(N_SIGNER_CLIENT_TARGET) $(LDFLAGS)
|
||||
```
|
||||
|
||||
Add `clients` to the `all` aggregate and to the `test-client` target so it is
|
||||
built alongside the examples.
|
||||
|
||||
## Testing
|
||||
|
||||
1. Manual smoke test against a running `nsigner`:
|
||||
- `n_signer_client get-public-key` → 64-hex pubkey.
|
||||
- `echo '{"kind":1,"content":"hello","tags":[],"created_at":1}' | n_signer_client sign-event` → signed event with `id`, `pubkey`, `sig`.
|
||||
- Pipe to `nak event` / `nak publish` to verify the signed event is well-formed.
|
||||
- `n_signer_client --algorithm ed25519 sign 68656c6c6f` → 64-byte sig hex.
|
||||
2. Optional bash script `tests/test_n_signer_client.sh` that:
|
||||
- Spawns `nsigner --socket-name nsigner_test --listen unix --mnemonic-stdin` with a fixed test mnemonic.
|
||||
- Runs each verb and asserts on stdout shape.
|
||||
- Tears down the server.
|
||||
|
||||
## Mermaid flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[stdin or argv event JSON] --> B[n_signer_client sign-event]
|
||||
B --> C[nsigner_transport_open_unix]
|
||||
C --> D[nsigner_client_call nostr_sign_event]
|
||||
D --> E[nsigner @nsigner socket]
|
||||
E --> F[signed event JSON result]
|
||||
F --> G[stdout one line]
|
||||
G --> H[nak publish]
|
||||
```
|
||||
|
||||
## Out of scope
|
||||
|
||||
- No TUI, no approval UI — the human attendant lives in the running `nsigner`
|
||||
process; the client is just a thin wire caller.
|
||||
- No key storage, no mnemonic handling.
|
||||
- No HTTP listener client (the `http_listener` is server-side; the client uses
|
||||
the framed transports).
|
||||
- No NIP-46 bunker mode (covered separately by
|
||||
[`plans/nip46_bunker_mode.md`](nip46_bunker_mode.md)).
|
||||
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,197 @@
|
||||
# Analysis: Does nostr_core_lib Fully Cover the n_signer Client Verb Surface?
|
||||
|
||||
## Question
|
||||
|
||||
> When we wrote `n_signer_client` in this project, did we utilize
|
||||
> `nostr_core_lib` to the fullest? If a client wants to interface with
|
||||
> nsigner, they can use the CLI, or write C utilizing the functions in
|
||||
> `nostr_core_lib`. Did we fully put into nostr_core_lib the functionality
|
||||
> of our client? I have a suspicion we wrote the client and didn't add back
|
||||
> into nostr_core_lib.
|
||||
|
||||
## Answer: Your suspicion is correct — the library covers less than half the verb surface.
|
||||
|
||||
The CLI ([`client/n_signer_client.c`](../client/n_signer_client.c)) exposes
|
||||
**16 verbs**. The `nostr_core_lib` high-level `nostr_signer_t` API
|
||||
([`nostr_signer.h`](../../nostr_core_lib/nostr_core/nostr_signer.h)) exposes
|
||||
only **6** of them. The CLI hand-builds cJSON params and calls the low-level
|
||||
`nsigner_client_call()` for the other 10 verbs — none of which have a
|
||||
high-level library wrapper.
|
||||
|
||||
## Verb-by-verb coverage
|
||||
|
||||
| n_signer wire verb | CLI verb | `nostr_signer_t` high-level API | Status |
|
||||
|--------------------|----------|---------------------------------|--------|
|
||||
| `get_info` | `get-info` | — | **Missing** |
|
||||
| `get_public_key` (algorithm) | `get-public-key -a <alg>` | — | **Missing** |
|
||||
| `nostr_get_public_key` | `get-public-key --role --path` | `nostr_signer_get_public_key()` | Covered |
|
||||
| `nostr_sign_event` | `sign-event` | `nostr_signer_sign_event()` | Covered |
|
||||
| `nostr_mine_event` | `mine-event` | — | **Missing** |
|
||||
| `nostr_nip04_encrypt` | `nip04-encrypt` | `nostr_signer_nip04_encrypt()` | Covered |
|
||||
| `nostr_nip04_decrypt` | `nip04-decrypt` | `nostr_signer_nip04_decrypt()` | Covered |
|
||||
| `nostr_nip44_encrypt` | `nip44-encrypt` | `nostr_signer_nip44_encrypt()` | Covered |
|
||||
| `nostr_nip44_decrypt` | `nip44-decrypt` | `nostr_signer_nip44_decrypt()` | Covered |
|
||||
| `sign` | `sign` | — | **Missing** |
|
||||
| `verify` | `verify` | — | **Missing** |
|
||||
| `derive` | `derive` | `nostr_signer_derive_hmac()` | **Partial** (lib wraps it as HMAC-only, hardcodes `algorithm:"secp256k1"`; the raw `derive` verb is not exposed) |
|
||||
| `encapsulate` | `encapsulate` | — | **Missing** |
|
||||
| `decapsulate` | `decapsulate` | — | **Missing** |
|
||||
| `derive_shared_secret` | `derive-shared-secret` | — | **Missing** |
|
||||
| `encrypt` (OTP) | `encrypt` | — | **Missing** |
|
||||
| `decrypt` (OTP) | `decrypt` | — | **Missing** |
|
||||
| (raw passthrough) | `call <method>` | `nsigner_client_call()` (low-level) | Covered at low level |
|
||||
|
||||
**Score: 6 covered, 1 partial, 10 missing.**
|
||||
|
||||
## What the CLI does that the library doesn't
|
||||
|
||||
The CLI is essentially a thin argv-to-JSON-RPC mapper. For each verb it:
|
||||
1. Builds a `cJSON` params array with the positional args + options object.
|
||||
2. Calls `nsigner_client_call(client, method, params, &result)`.
|
||||
3. Prints the result.
|
||||
|
||||
This is exactly the kind of per-verb glue that belongs in the library, not
|
||||
duplicated in every client. Today a C client that wants to call `sign` with
|
||||
`ed25519` must either:
|
||||
- drop down to the low-level `nsigner_client_call` and hand-build cJSON (what
|
||||
the CLI does), or
|
||||
- not use the library for that verb at all.
|
||||
|
||||
## Two layers in nostr_core_lib today
|
||||
|
||||
The library has two layers, and the gap is in the **high-level** layer:
|
||||
|
||||
1. **Low-level** ([`nsigner_client.h`](../../nostr_core_lib/nostr_core/nsigner_client.h)):
|
||||
`nsigner_client_call(client, method, params, &result)` — generic
|
||||
JSON-RPC. This covers *everything* but forces the caller to build cJSON
|
||||
params by hand and parse cJSON results by hand. The CLI uses this layer
|
||||
exclusively.
|
||||
|
||||
2. **High-level** ([`nostr_signer.h`](../../nostr_core_lib/nostr_core/nostr_signer.h)):
|
||||
`nostr_signer_t` with typed verbs that take C strings/bytes and return
|
||||
C strings/bytes. This is the layer a C client *wants* to use. It only
|
||||
covers the 6 Nostr verbs + `derive_hmac`.
|
||||
|
||||
## What's missing and where it should go
|
||||
|
||||
The high-level `nostr_signer_t` API should gain typed wrappers for the
|
||||
algorithm-based verbs. Proposed additions (all on `nostr_signer_t`, remote
|
||||
backend routes to `nsigner_client_call` with the right method+params):
|
||||
|
||||
### Metadata
|
||||
```c
|
||||
int nostr_signer_get_info(nostr_signer_t* signer, cJSON** info_out);
|
||||
```
|
||||
|
||||
### Algorithm-based key/sign/verify (the `algorithm` + `index` selector)
|
||||
```c
|
||||
int nostr_signer_get_public_key_alg(nostr_signer_t* signer,
|
||||
const char* algorithm, int index,
|
||||
char** pubkey_hex_out);
|
||||
|
||||
int nostr_signer_sign(nostr_signer_t* signer,
|
||||
const char* algorithm, int index,
|
||||
const char* scheme, /* "schnorr"|"ecdsa"|NULL */
|
||||
const unsigned char* msg, size_t msg_len,
|
||||
char** sig_hex_out);
|
||||
|
||||
int nostr_signer_verify(nostr_signer_t* signer,
|
||||
const char* algorithm, int index,
|
||||
const char* scheme,
|
||||
const unsigned char* msg, size_t msg_len,
|
||||
const unsigned char* sig, size_t sig_len,
|
||||
int* valid_out);
|
||||
```
|
||||
|
||||
### Post-quantum KEM
|
||||
```c
|
||||
int nostr_signer_encapsulate(nostr_signer_t* signer,
|
||||
const char* peer_pubkey_hex,
|
||||
char** ciphertext_hex_out,
|
||||
char** shared_secret_hex_out);
|
||||
|
||||
int nostr_signer_decapsulate(nostr_signer_t* signer, int index,
|
||||
const char* ciphertext_hex,
|
||||
char** shared_secret_hex_out);
|
||||
```
|
||||
|
||||
### X25519 key agreement
|
||||
```c
|
||||
int nostr_signer_derive_shared_secret(nostr_signer_t* signer, int index,
|
||||
const char* peer_pubkey_hex,
|
||||
char** shared_secret_hex_out);
|
||||
```
|
||||
|
||||
### OTP one-time pad
|
||||
```c
|
||||
int nostr_signer_otp_encrypt(nostr_signer_t* signer,
|
||||
const char* plaintext_b64,
|
||||
const char* encoding, /* "ascii"|"binary"|NULL */
|
||||
char** ciphertext_out);
|
||||
|
||||
int nostr_signer_otp_decrypt(nostr_signer_t* signer,
|
||||
const char* ciphertext,
|
||||
const char* encoding,
|
||||
char** plaintext_out);
|
||||
```
|
||||
|
||||
### Nostr mine-event (POW)
|
||||
```c
|
||||
int nostr_signer_mine_event(nostr_signer_t* signer,
|
||||
const cJSON* unsigned_event,
|
||||
int difficulty, int timeout_sec, int threads,
|
||||
cJSON** signed_event_out);
|
||||
```
|
||||
|
||||
### Raw derive (the lib's `derive_hmac` is a specialization; expose the general verb)
|
||||
The existing `nostr_signer_derive_hmac` is fine as a convenience; no change
|
||||
needed, but the raw `derive` verb is already reachable through it.
|
||||
|
||||
## Impact on the CLI
|
||||
|
||||
If these wrappers are added to `nostr_core_lib`, the CLI
|
||||
([`client/n_signer_client.c`](../client/n_signer_client.c)) shrinks
|
||||
dramatically. Today it is ~945 lines, most of which is the per-verb
|
||||
`cJSON_CreateArray` / `cJSON_AddStringToObject` / `cJSON_AddNumberToObject`
|
||||
boilerplate. With the wrappers, each verb handler becomes a 3–5 line call to
|
||||
the library + `print_result`. The CLI becomes what you envisioned: mostly
|
||||
interface code (argv parsing + result printing) with the real logic in the
|
||||
library.
|
||||
|
||||
## Impact on other clients
|
||||
|
||||
Every C client that currently hand-builds JSON-RPC for the missing verbs
|
||||
(`nostr_terminal`'s `nsigner_client.c`, `sovereign_browser`, future embedded
|
||||
clients) would get typed wrappers for free and could stop hand-rolling cJSON.
|
||||
|
||||
## Recommendation
|
||||
|
||||
1. **Add the 10 missing high-level wrappers** to `nostr_signer.h` /
|
||||
`nostr_signer.c` in `nostr_core_lib` (remote backend only; the local
|
||||
backend can return `NOSTR_ERROR_NOT_SUPPORTED` for the algorithm-based
|
||||
verbs that are inherently signer-side).
|
||||
2. **Refactor `n_signer_client.c`** to call the wrappers instead of
|
||||
hand-building cJSON. This validates the API (the CLI becomes the first
|
||||
consumer) and shrinks the client to mostly argv parsing + printing.
|
||||
3. **Add tests** for the new wrappers in
|
||||
[`nostr_core_lib/tests/nsigner_client_test.c`](../../nostr_core_lib/tests/nsigner_client_test.c)
|
||||
using the mock-transport pattern already there.
|
||||
|
||||
A Mermaid view of the target architecture:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
CLI[n_signer_client CLI<br/>argv parse + print]
|
||||
LIB[nostr_core_lib<br/>nostr_signer_t high-level<br/>16 typed verbs]
|
||||
LOW[nostr_core_lib<br/>nsigner_client_call<br/>low-level JSON-RPC]
|
||||
NS[n_signer process<br/>wire protocol]
|
||||
|
||||
CLI --> LIB
|
||||
LIB --> LOW
|
||||
LOW -->|framed JSON-RPC| NS
|
||||
|
||||
OtherC[other C clients<br/>sovereign_browser<br/>nostr_terminal] --> LIB
|
||||
```
|
||||
|
||||
Today the `CLI --> LOW` arrow bypasses `LIB` for 10 of 16 verbs. The goal is
|
||||
to make `CLI --> LIB` the only path.
|
||||
@@ -0,0 +1,110 @@
|
||||
# Plan: Role-as-Password as the Default Authorization Model
|
||||
|
||||
## Status: Draft — ready for review
|
||||
|
||||
## Problem
|
||||
|
||||
The signer currently defaults to requiring interactive approval for every request, even for the default "main" role. This contradicts the intended design where **knowing the role name is sufficient authorization** (role-as-password). Users running v0.1.21 are prompted to approve requests when they should be authorized automatically.
|
||||
|
||||
## Root cause
|
||||
|
||||
Three places in the code set `requires_approval = 1` by default:
|
||||
|
||||
| Location | Context | Current value |
|
||||
|---|---|---|
|
||||
| [`src/main.c:3816`](../src/main.c:3816) | Default "main" role (non-interactive mode) | `requires_approval = 1` |
|
||||
| [`src/main.c:2374`](../src/main.c:2374) | Wizard role creation prompt | Default `1` (Y/n) |
|
||||
| [`src/main.c:2223`](../src/main.c:2223) | Wizard OTP role creation prompt | Default `1` (Y/n) |
|
||||
|
||||
## Changes required
|
||||
|
||||
### 1. Default "main" role — non-interactive mode
|
||||
|
||||
**File:** [`src/main.c:3816`](../src/main.c:3816)
|
||||
|
||||
Change:
|
||||
```c
|
||||
role.requires_approval = 1;
|
||||
```
|
||||
To:
|
||||
```c
|
||||
role.requires_approval = 0; /* role-as-password: knowing the role name is sufficient */
|
||||
```
|
||||
|
||||
This is the most critical fix — it affects every user running in non-interactive mode (e.g., `--listen unix`, `--listen tcp`, `--listen qrexec`).
|
||||
|
||||
### 2. Wizard role creation — remove approval prompt
|
||||
|
||||
**File:** [`src/main.c:2359-2377`](../src/main.c:2359)
|
||||
|
||||
Currently the wizard asks:
|
||||
```
|
||||
Require interactive approval for each request? [Y/n]:
|
||||
```
|
||||
|
||||
With role-as-password as the default, this prompt should be **removed entirely**. The role is created with `requires_approval = 0`. If a user wants approval, they can use `--preapprove` or manually edit the role after creation.
|
||||
|
||||
Remove the prompt block (lines 2359-2377) and set:
|
||||
```c
|
||||
int requires_approval = 0; /* role-as-password by default */
|
||||
```
|
||||
|
||||
### 3. Wizard OTP role creation — remove approval prompt
|
||||
|
||||
**File:** [`src/main.c:2208-2226`](../src/main.c:2208)
|
||||
|
||||
Same change as #2. Remove the approval prompt for OTP roles and default to `requires_approval = 0`.
|
||||
|
||||
### 4. Policy table default — no change needed
|
||||
|
||||
**File:** [`src/policy.c:1097-1113`](../src/policy.c:1097)
|
||||
|
||||
The current default policy is `* → PROMPT_EVERY_REQUEST`. This is correct because:
|
||||
|
||||
- For roles with `requires_approval = 0`, [`policy_check_with_role()`](../src/policy.c:1305) returns `POLICY_ALLOW` **before** consulting the policy table.
|
||||
- For roles with `requires_approval = 1`, the policy table prompt still fires as expected.
|
||||
|
||||
No change needed here.
|
||||
|
||||
### 5. `--allow-all` flag — retain as-is
|
||||
|
||||
**File:** [`src/main.c:3678`](../src/main.c:3678)
|
||||
|
||||
The `--allow-all` flag sets `g_prompt_always_allow`, which bypasses prompts for algorithm-based verbs (sign, verify, encapsulate, etc.) that don't go through the role system. This is still useful for testing and non-interactive scenarios. No change needed.
|
||||
|
||||
## Authorization flow after changes
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Client sends request with role name] --> B{Role found?}
|
||||
B -- No --> C[Reject: unknown_role]
|
||||
B -- Yes --> D{requires_approval?}
|
||||
D -- No --> E[Authorize immediately - role-as-password]
|
||||
D -- Yes --> F[Check policy table]
|
||||
F --> G{Policy match?}
|
||||
G -- Allow --> H[Authorize]
|
||||
G -- Prompt --> I[Show interactive prompt]
|
||||
G -- Deny --> J[Reject: policy_denied]
|
||||
I --> K{User choice}
|
||||
K -- y --> H
|
||||
K -- a/e --> L[Add session grant] --> H
|
||||
K -- n --> J
|
||||
```
|
||||
|
||||
## Test impact
|
||||
|
||||
- [`tests/test_n_signer_client.sh`](../tests/test_n_signer_client.sh) — may need updates if tests relied on the old approval-required default
|
||||
- [`tests/test_integration.c`](../tests/test_integration.c) — verify no tests break from the default change
|
||||
- The `NSIGNER_TEST_FORCE_PROMPT` env var ([`src/main.c:3063`](../src/main.c:3063)) can be used to force prompts in tests that need to exercise the approval path
|
||||
|
||||
## Files to modify
|
||||
|
||||
| File | Lines | Change |
|
||||
|---|---|---|
|
||||
| [`src/main.c`](../src/main.c) | 3816 | `requires_approval = 1` → `0` |
|
||||
| [`src/main.c`](../src/main.c) | 2359-2377 | Remove approval prompt, default to `0` |
|
||||
| [`src/main.c`](../src/main.c) | 2208-2226 | Remove approval prompt, default to `0` |
|
||||
|
||||
## Summary
|
||||
|
||||
Three one-line changes (plus removing two prompt blocks) to make role-as-password the default. The mechanism already exists in the code — it's just not the default.
|
||||
@@ -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,448 @@
|
||||
# Plan: Migrate Teensy 4.1 Signer to the Role + Path Authorization Model
|
||||
|
||||
## Status: Implemented (Phases 1-7) — pending hardware flash + verification
|
||||
|
||||
> All 7 phases are implemented. Host-side unit tests pass (79 assertions:
|
||||
> 53 for `parse_bip44_path`, 26 for `role_table` path matching). The
|
||||
> firmware code compiles pending an on-device build (`build_signer.sh`) and
|
||||
> the hardware test suite (`test_signer.py` etc.) needs a Teensy 4.1 flash
|
||||
> to verify end-to-end. The `ALLOW_DEPRECATED_NOSTR_INDEX` flag is set to 0
|
||||
> (nostr_index rejected with error 2006, matching the host).
|
||||
|
||||
## Problem
|
||||
|
||||
The host `n_signer` has migrated to a **role + path authorization model** (see
|
||||
[`plans/role_path_authorization.md`](role_path_authorization.md) and
|
||||
[`plans/role_as_password_default.md`](role_as_password_default.md)):
|
||||
|
||||
- `nostr_index` is **deprecated** — the host returns error `2006` with the
|
||||
message *"nostr_index is deprecated — use --role main --path
|
||||
m/44'/1237'/N'/0/0 instead"* ([`src/selector.c:759`](../src/selector.c:759)).
|
||||
- Clients must send `{"role":"<name>","role_path":"m/44'/1237'/0'/0/0"}` for
|
||||
all `nostr_*` verbs.
|
||||
- **Role-as-password**: roles default to `requires_approval = 0` — knowing the
|
||||
role name is sufficient authorization, no interactive prompt needed.
|
||||
- A **role table** with presets, path templates (`%d` placeholders), and
|
||||
range/set validation governs which paths are allowed.
|
||||
|
||||
The Teensy 4.1 firmware is **out of sync**. It still uses `nostr_index`
|
||||
exclusively:
|
||||
|
||||
- [`firmware/teensy41/signer/src/dispatch.cpp:868`](../firmware/teensy41/signer/src/dispatch.cpp:868)
|
||||
— `parse_nostr_index_from_params()` is the only selector parser.
|
||||
- [`firmware/teensy41/signer/src/dispatch.cpp:1958`](../firmware/teensy41/signer/src/dispatch.cpp:1958)
|
||||
— every `nostr_*` verb calls `derive_request_key(nostr_index, ...)`.
|
||||
- [`firmware/teensy41/signer/src/key_derivation.h:31`](../firmware/teensy41/signer/src/key_derivation.h:31)
|
||||
— only `derive_secp256k1_keys_index(nostr_index)` exists; there is no
|
||||
path-based derivation entry point.
|
||||
- There is **no role table**, no wizard, no path-template matching, and no
|
||||
`requires_approval` flag. Every `nostr_*` verb prompts for approval via
|
||||
`ui_approve()`.
|
||||
|
||||
The CYD firmware
|
||||
([`firmware/cyd_esp32_2432s028/main/main.c`](../firmware/cyd_esp32_2432s028/main/main.c))
|
||||
is in the same state — this plan focuses on the Teensy 4.1, but the CYD will
|
||||
need the same migration afterwards.
|
||||
|
||||
## Goal
|
||||
|
||||
Bring the Teensy 4.1 signer's `nostr_*` verb handling into parity with the
|
||||
host's role + path model:
|
||||
|
||||
1. Accept `{"role":"<name>","role_path":"<path>"}` and derive the key from the
|
||||
explicit BIP-44 path (not a `nostr_index` integer).
|
||||
2. Maintain a **role table** populated at boot via an LVGL role-preset wizard
|
||||
(the touch-screen equivalent of the host's terminal wizard).
|
||||
3. Implement **role-as-password**: roles with `requires_approval = 0` authorize
|
||||
immediately; only `requires_approval = 1` roles prompt via `ui_approve()`.
|
||||
4. Reject `nostr_index` with the same `2006` error the host returns.
|
||||
5. Keep the algorithm-based verbs (`sign`, `get_public_key`, `derive`, etc.)
|
||||
unchanged — they use `algorithm` + `index`, not roles.
|
||||
|
||||
## What already exists in the Teensy 4.1 firmware
|
||||
|
||||
The good news: the hard crypto plumbing is already there.
|
||||
|
||||
- **BIP-32 path derivation**: [`nostr_bip32_key_from_seed()`](../firmware/teensy41/signer/src/nostr_core/nostr_utils.c:1445)
|
||||
and [`nostr_bip32_derive_path()`](../firmware/teensy41/signer/src/nostr_core/nostr_utils.c:1565)
|
||||
are already compiled into the firmware (used by NIP-06). We just need a new
|
||||
entry point that takes a path string instead of a fixed `nostr_index`.
|
||||
- **Path parsing**: the host's [`parse_bip44_path()`](../src/key_store.c:685)
|
||||
is a ~75-line pure-C function that splits `m/44'/1237'/0'/0/0` into a
|
||||
`uint32_t[]` with hardened-bit handling. It ports directly (it uses only
|
||||
`strtol`, `strlen`, and the `'`/`h` markers).
|
||||
- **LVGL UI**: [`ui.h`](../firmware/teensy41/signer/src/ui.h) already has
|
||||
modal screen primitives (`ui_show_mnemonic`, `ui_enter_mnemonic`,
|
||||
`ui_approve`, `ui_pick_pad`) that pump LVGL while blocking. A role-wizard
|
||||
screen follows the same pattern.
|
||||
- **cJSON**: already vendored for request parsing.
|
||||
- **Memory**: the v0.1.6 `.rodata` → FLASH move
|
||||
([`plans/teensy41_memory_evaluation.md`](teensy41_memory_evaluation.md)
|
||||
Solution A) left **130.9 KB of free stack** — plenty of headroom for a role
|
||||
table and path strings.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Boot[signer.ino boot] --> Menu[startup menu<br/>generate or enter mnemonic]
|
||||
Menu --> Wizard[role_wizard UI<br/>LVGL preset menu]
|
||||
Wizard --> Table[role_table_t<br/>in-RAM, DMAMEM]
|
||||
Table --> Idle[ui_show_idle<br/>show npub + role count]
|
||||
Idle --> Frame[transport_read_frame]
|
||||
Frame --> Parse[dispatch.cpp<br/>parse JSON-RPC]
|
||||
Parse --> Sel{nostr_* verb?}
|
||||
Sel -- Yes --> Role[selector_resolve<br/>role + role_path]
|
||||
Role --> Match{role found + path matches?}
|
||||
Match -- No --> Err[error 2006 or 2003]
|
||||
Match -- Yes --> Approve{requires_approval?}
|
||||
Approve -- No --> Derive[derive_secp256k1_from_path]
|
||||
Approve -- Yes --> UI[ui_approve prompt]
|
||||
UI -- Approve --> Derive
|
||||
UI -- Deny --> Deny[deny JSON]
|
||||
Derive --> Exec[execute nostr verb]
|
||||
Exec --> Resp[structured JSON result]
|
||||
Resp --> Frame
|
||||
Sel -- No --> Alg[algorithm-based verbs<br/>unchanged]
|
||||
Alg --> Resp
|
||||
```
|
||||
|
||||
## File layout (new + modified)
|
||||
|
||||
```
|
||||
firmware/teensy41/signer/
|
||||
├── signer.ino (modified — boot flow calls role wizard)
|
||||
├── src/
|
||||
│ ├── role_table.h (NEW — role_entry_t, role_table_t, presets)
|
||||
│ ├── role_table.cpp (NEW — table ops, path matching, presets)
|
||||
│ ├── selector.h (NEW — selector_request_t, selector_resolve)
|
||||
│ ├── selector.cpp (NEW — parse role/role_path, reject nostr_index)
|
||||
│ ├── key_derivation.h (modified — add derive_secp256k1_from_path)
|
||||
│ ├── key_derivation.cpp (modified — add path-based derivation)
|
||||
│ ├── dispatch.cpp (modified — nostr_* verbs use selector + role)
|
||||
│ ├── dispatch.h (modified — extern role table, error codes)
|
||||
│ ├── ui.h (modified — add ui_role_wizard)
|
||||
│ └── ui.cpp (modified — implement role wizard screen)
|
||||
```
|
||||
|
||||
## Implementation phases
|
||||
|
||||
### Phase 1 — Port the path parser + path-based secp256k1 derivation
|
||||
|
||||
**Goal:** derive a secp256k1 keypair from an arbitrary BIP-44 path string,
|
||||
independent of `nostr_index`.
|
||||
|
||||
- [ ] Add `parse_bip44_path()` to
|
||||
[`key_derivation.cpp`](../firmware/teensy41/signer/src/key_derivation.cpp)
|
||||
— port from [`src/key_store.c:685`](../src/key_store.c:685). Pure C,
|
||||
~75 lines. Handles `m/`, `'`/`h`/`H` hardened markers, up to 16 segments.
|
||||
Mark it `__attribute__((section(".flashmem")))` to keep ITCM small.
|
||||
- [ ] Add `derive_secp256k1_from_path()` to
|
||||
[`key_derivation.cpp`](../firmware/teensy41/signer/src/key_derivation.cpp)
|
||||
— port from [`src/key_store.c:765`](../src/key_store.c:765). Calls
|
||||
`nostr_bip32_key_from_seed` + `parse_bip44_path` +
|
||||
`nostr_bip32_derive_path`, returns 32-byte privkey + 32-byte x-only
|
||||
pubkey. Mark `.flashmem`.
|
||||
- [ ] Declare both in [`key_derivation.h`](../firmware/teensy41/signer/src/key_derivation.h):
|
||||
```cpp
|
||||
int parse_bip44_path(const char *path_str, uint32_t *out, int max_segments);
|
||||
int derive_secp256k1_from_path(const uint8_t *seed, size_t seed_len,
|
||||
const char *path_str,
|
||||
uint8_t *privkey, uint8_t *pubkey);
|
||||
```
|
||||
- [ ] **Host-side unit test**: add a host-buildable test that links
|
||||
`key_derivation.cpp` (compiled with `HOST_TEST` against the nostr_core
|
||||
C files) and verifies `derive_secp256k1_from_path("m/44'/1237'/0'/0/0")`
|
||||
produces the same pubkey as
|
||||
`derive_secp256k1_keys_index(0)` (the existing NIP-06 path is
|
||||
`m/44'/1237'/0'/0/0` — they must match). Also test a hardened variant
|
||||
(`m/44'/1237'/0'/0'/0'`) produces a different key.
|
||||
|
||||
**Exit criterion:** path-based derivation produces byte-identical keys to the
|
||||
existing `nostr_index` path for the same BIP-44 path, and different keys for
|
||||
different paths.
|
||||
|
||||
### Phase 2 — Role table + path-template matching
|
||||
|
||||
**Goal:** an in-RAM role table with the same semantics as the host's
|
||||
[`src/role_table.c`](../src/role_table.c), sized for the Teensy's memory.
|
||||
|
||||
- [ ] Create [`role_table.h`](../firmware/teensy41/signer/src/role_table.h)
|
||||
with a **slimmed-down** `role_entry_t` (the host's struct has 256-entry
|
||||
arrays and 64-int allowed-indices sets — too big for the Teensy; cap at
|
||||
`ROLE_TABLE_MAX_ENTRIES 16` and `path_allowed_indices[16]`):
|
||||
```cpp
|
||||
typedef enum { PURPOSE_NOSTR, PURPOSE_SSH, PURPOSE_AGE,
|
||||
PURPOSE_PQ_SIG, PURPOSE_PQ_KEM } role_purpose_t;
|
||||
typedef enum { CURVE_SECP256K1, CURVE_ED25519, CURVE_X25519,
|
||||
CURVE_ML_DSA_65, CURVE_SLH_DSA_128S,
|
||||
CURVE_ML_KEM_768 } role_curve_t;
|
||||
|
||||
typedef struct {
|
||||
char name[32];
|
||||
char role_path[128]; /* template, may contain one "%d" */
|
||||
role_purpose_t purpose;
|
||||
role_curve_t curve;
|
||||
int path_range_lo; /* -1 = fixed path (no %d) */
|
||||
int path_range_hi;
|
||||
int path_default_index; /* -1 = require explicit */
|
||||
int requires_approval; /* 0 = role-as-password, 1 = prompt */
|
||||
int derived;
|
||||
char pubkey_hex[65]; /* filled after first derivation */
|
||||
} role_entry_t;
|
||||
|
||||
typedef struct {
|
||||
role_entry_t entries[16];
|
||||
int count;
|
||||
} role_table_t;
|
||||
```
|
||||
- [ ] Create [`role_table.cpp`](../firmware/teensy41/signer/src/role_table.cpp)
|
||||
with:
|
||||
- `role_table_init()`, `role_table_add()`, `role_table_find_by_name()`.
|
||||
- `role_path_matches_template()` — port from
|
||||
[`src/role_table.c:956`](../src/role_table.c:956). Handles one `%d`
|
||||
placeholder.
|
||||
- `role_path_extract_index()` — port from
|
||||
[`src/role_table.c:1007`](../src/role_table.c:1007).
|
||||
- `role_path_matches_with_range()` — port from
|
||||
[`src/role_table.c:1070`](../src/role_table.c:1070). Combines template
|
||||
match + range check.
|
||||
- `role_table_get_default()` — returns the role named `"main"`.
|
||||
- All marked `.flashmem` where reasonable.
|
||||
- [ ] **Host-side unit test**: link `role_table.cpp` with `HOST_TEST` and
|
||||
verify: fixed-path match, template match with `%d`, range rejection
|
||||
(index out of bounds), unknown role returns NULL.
|
||||
|
||||
**Exit criterion:** role table operations match the host's semantics for the
|
||||
subset of features we need (single `%d` placeholder, range bounds).
|
||||
|
||||
### Phase 3 — Selector: parse role + role_path, reject nostr_index
|
||||
|
||||
**Goal:** a `selector_resolve()` that mirrors the host's
|
||||
[`src/selector.c:745`](../src/selector.c:745) decision tree.
|
||||
|
||||
- [ ] Create [`selector.h`](../firmware/teensy41/signer/src/selector.h):
|
||||
```cpp
|
||||
typedef struct {
|
||||
int has_role; char role_name[32];
|
||||
int has_role_path; char role_path[128];
|
||||
int has_nostr_index; uint32_t nostr_index;
|
||||
int has_index; uint32_t index;
|
||||
} selector_request_t;
|
||||
|
||||
#define SELECTOR_OK 0
|
||||
#define SELECTOR_ERR_NOT_FOUND -1
|
||||
#define SELECTOR_ERR_NO_DEFAULT -3
|
||||
#define SELECTOR_ERR_PATH_MISMATCH -4
|
||||
#define SELECTOR_ERR_NOSTR_INDEX_DEPRECATED -5
|
||||
#define SELECTOR_ERR_PATH_REQUIRED -6
|
||||
#define SELECTOR_ERR_ROLE_REQUIRED -7
|
||||
```
|
||||
- [ ] Create [`selector.cpp`](../firmware/teensy41/signer/src/selector.cpp)
|
||||
with `selector_resolve()` — port the decision tree from
|
||||
[`src/selector.c:745`](../src/selector.c:745):
|
||||
- `has_nostr_index` → return `SELECTOR_ERR_NOSTR_INDEX_DEPRECATED`.
|
||||
- `has_role_path` without `has_role` → `SELECTOR_ERR_ROLE_REQUIRED`.
|
||||
- `has_role` + `has_role_path` → find role, verify path matches template
|
||||
+ range, return the role entry.
|
||||
- `has_role` only → if fixed path (no `%d`), use it; else
|
||||
`SELECTOR_ERR_PATH_REQUIRED`.
|
||||
- Neither → use default role (`"main"`), else `SELECTOR_ERR_NO_DEFAULT`.
|
||||
- [ ] Add a parser in `selector.cpp` that extracts `role` / `role_path` /
|
||||
`nostr_index` / `index` from the trailing cJSON options object of a
|
||||
`nostr_*` verb's params array (replacing
|
||||
[`parse_nostr_index_from_params()`](../firmware/teensy41/signer/src/dispatch.cpp:868)).
|
||||
|
||||
**Exit criterion:** selector returns the correct error code for each
|
||||
deprecated/missing/mismatched case, and the correct role entry for valid
|
||||
role+path combinations.
|
||||
|
||||
### Phase 4 — Wire selector + role table into dispatch
|
||||
|
||||
**Goal:** the `nostr_*` verbs in
|
||||
[`dispatch.cpp`](../firmware/teensy41/signer/src/dispatch.cpp) use the
|
||||
selector + role table instead of `nostr_index`.
|
||||
|
||||
- [ ] Add a global `role_table_t g_roles` (in `DMAMEM`) declared `extern` in
|
||||
[`dispatch.h`](../firmware/teensy41/signer/src/dispatch.h), populated by
|
||||
the boot flow (Phase 6).
|
||||
- [ ] Replace `parse_nostr_index_from_params()` + `derive_request_key()` in
|
||||
each `nostr_*` verb handler with:
|
||||
1. Parse the selector request from params.
|
||||
2. Call `selector_resolve(&req, &g_roles, &role)`.
|
||||
3. On `SELECTOR_ERR_NOSTR_INDEX_DEPRECATED` → return error `2006` with
|
||||
the host's exact message.
|
||||
4. On `SELECTOR_ERR_NOT_FOUND` → error `1002 unknown_role`.
|
||||
5. On `SELECTOR_ERR_PATH_MISMATCH` → error `2003 path_not_allowed`.
|
||||
6. On success → derive the key via
|
||||
`derive_secp256k1_from_path(g_seed, g_seed_len, req.role_path, ...)`.
|
||||
7. If `role->requires_approval == 1` → call `ui_approve()`; else skip
|
||||
the prompt (role-as-password).
|
||||
- [ ] The verbs to update (all in
|
||||
[`dispatch.cpp`](../firmware/teensy41/signer/src/dispatch.cpp)):
|
||||
- `nostr_get_public_key` (~line 1958)
|
||||
- `nostr_sign_event` (~line 2010)
|
||||
- `nostr_mine_event` (~line 2049)
|
||||
- `nostr_nip04_encrypt` / `nostr_nip04_decrypt` (~line 2246)
|
||||
- `nostr_nip44_encrypt` / `nostr_nip44_decrypt` (same handler area)
|
||||
- [ ] Update `get_info` to report the configured roles in the response (the
|
||||
host's `get_info` lists roles; the Teensy currently does not).
|
||||
- [ ] **Keep `nostr_index` working as a hidden fallback** behind a
|
||||
`#define ALLOW_DEPRECATED_NOSTR_INDEX 0` compile flag, default off, so
|
||||
the old `test_signer.py` can still run during migration by flipping the
|
||||
flag. Remove the flag entirely once tests are updated.
|
||||
|
||||
**Exit criterion:** a `nostr_get_public_key` request with
|
||||
`{"role":"main","role_path":"m/44'/1237'/0'/0/0"}` returns the same pubkey as
|
||||
the old `{"nostr_index":0}` request. A request with `{"nostr_index":0}`
|
||||
returns error `2006`.
|
||||
|
||||
### Phase 5 — Role-preset wizard UI (LVGL)
|
||||
|
||||
**Goal:** a touch-screen role wizard that runs at boot, mirroring the host's
|
||||
terminal preset menu ([`src/main.c:2050`](../src/main.c:2050)).
|
||||
|
||||
- [ ] Add `ui_role_wizard()` to [`ui.h`](../firmware/teensy41/signer/src/ui.h)
|
||||
/ [`ui.cpp`](../firmware/teensy41/signer/src/ui.cpp):
|
||||
```cpp
|
||||
/* Run the role-preset wizard. Fills `out_table` with at least one role.
|
||||
* Blocks (pumping LVGL) until the user defines at least one role and
|
||||
* taps "Done". Returns 0 on success, -1 if the user cancels (which
|
||||
* should abort the boot). */
|
||||
int ui_role_wizard(role_table_t *out_table);
|
||||
```
|
||||
- [ ] Implement the wizard screen as an LVGL list of preset buttons (matching
|
||||
the host's 10 presets, adapted for the 480×320 screen):
|
||||
1. Standard Nostr (secp256k1, `m/44'/1237'/0'/0/0`)
|
||||
2. Nostr range (secp256k1, `m/44'/1237'/*'/0/0`, range 0-100)
|
||||
3. Nostr agent (secp256k1, `m/44'/1237'/*'/1'/0'`, range 0-100)
|
||||
4. SSH (ed25519, `m/44'/102001'/0'/0'/0'`)
|
||||
5. Age (x25519, `m/44'/102002'/0'/0'/0'`)
|
||||
6. ML-DSA-65 (`m/44'/102003'/0'/0'/0'`)
|
||||
7. SLH-DSA-128s (`m/44'/102004'/0'/0'/0'`)
|
||||
8. ML-KEM-768 (`m/44'/102005'/0'/0'/0'`)
|
||||
9. OTP (no path — binds the SD pad instead)
|
||||
10. Custom (text entry for name + path)
|
||||
- [ ] After a preset is chosen, show a sub-screen to edit the role name
|
||||
(default from preset) and toggle `requires_approval` (default **off** =
|
||||
role-as-password, per
|
||||
[`plans/role_as_password_default.md`](role_as_password_default.md)).
|
||||
- [ ] Loop: "Add another role?" (Yes/No). At least one role is required; if
|
||||
the user taps "Done" with zero roles, show an error and re-loop.
|
||||
- [ ] Use the existing aesthetics (black bg, white text, red accent for the
|
||||
selected preset, grey for muted). Reuse the button + list primitives
|
||||
already in [`ui.cpp`](../firmware/teensy41/signer/src/ui.cpp).
|
||||
|
||||
**Exit criterion:** the user can define a "main" Standard Nostr role via touch
|
||||
and the role table is populated before the idle screen appears.
|
||||
|
||||
### Phase 6 — Boot flow integration
|
||||
|
||||
**Goal:** wire the role wizard into
|
||||
[`signer.ino`](../firmware/teensy41/signer/signer.ino) between mnemonic entry
|
||||
and the idle screen.
|
||||
|
||||
- [ ] In [`signer.ino`](../firmware/teensy41/signer/signer.ino) `setup()` /
|
||||
`apply_mnemonic()`, after the mnemonic is applied and the seed is
|
||||
derived:
|
||||
1. Call `role_table_init(&g_roles)`.
|
||||
2. If `DEBUG_AUTO_GENERATE == 1`: auto-populate `g_roles` with a single
|
||||
"main" role (`m/44'/1237'/0'/0/0`, `requires_approval = 0`) so
|
||||
headless tests work without the wizard. Log this over Serial.
|
||||
3. If `DEBUG_AUTO_GENERATE == 0`: call `ui_role_wizard(&g_roles)`. If it
|
||||
returns -1 (cancel), abort the boot (show an error screen and halt).
|
||||
4. Derive the default role's pubkey for the idle screen (show npub +
|
||||
role count, matching the host's status display).
|
||||
- [ ] Update [`ui_show_idle()`](../firmware/teensy41/signer/src/ui.h:59) to
|
||||
show the role count (e.g. "roles: 3") alongside the npub, mirroring the
|
||||
host's status line.
|
||||
|
||||
**Exit criterion:** the boot flow goes mnemonic → role wizard → idle screen,
|
||||
and `g_roles` is populated before any `nostr_*` verb can be dispatched.
|
||||
|
||||
### Phase 7 — Test updates + hardware verification
|
||||
|
||||
**Goal:** the test suite exercises the new role+path model and confirms
|
||||
parity with the host.
|
||||
|
||||
- [ ] Update [`firmware/teensy41/test_signer.py`](../firmware/teensy41/test_signer.py):
|
||||
- Replace all `{"nostr_index": N}` options with
|
||||
`{"role":"main","role_path":"m/44'/1237'/N'/0/0"}`.
|
||||
- Add a test that sends `{"nostr_index": 0}` and asserts the response is
|
||||
error `2006`.
|
||||
- Add a test that sends `{"role":"nonexistent","role_path":"..."}` and
|
||||
asserts error `1002`.
|
||||
- Add a test that sends `{"role":"main","role_path":"m/44'/1237'/999'/0/0"}`
|
||||
(out of range) and asserts error `2003`.
|
||||
- Add a test that verifies `requires_approval = 0` roles do NOT trigger
|
||||
`ui_approve` (the response comes back immediately, no 30s prompt).
|
||||
- [ ] Update [`firmware/teensy41/test_classical.py`](../firmware/teensy41/test_classical.py)
|
||||
and [`firmware/teensy41/test_nip04.py`](../firmware/teensy41/test_nip04.py)
|
||||
to use role+path selectors for the `nostr_*` verbs.
|
||||
- [ ] **Cross-board parity**: with the same mnemonic and a "main" role at
|
||||
`m/44'/1237'/0'/0/0`, verify the Teensy 4.1 and the host `n_signer`
|
||||
produce the same npub and the same `nostr_sign_event` signature.
|
||||
- [ ] Run the full suite:
|
||||
```bash
|
||||
bash firmware/teensy41/build_signer.sh --flash
|
||||
python3 firmware/teensy41/test_classical.py --port /dev/ttyACM0
|
||||
python3 firmware/teensy41/test_nip04.py --port /dev/ttyACM0
|
||||
python3 firmware/teensy41/test_signer.py --port /dev/ttyACM0
|
||||
```
|
||||
- [ ] Run [`check_stack.sh`](../firmware/teensy41/check_stack.sh) to confirm
|
||||
the new role table + wizard code did not push free stack below 16 KB.
|
||||
|
||||
**Exit criterion:** all tests pass with role+path selectors, `nostr_index` is
|
||||
rejected with error 2006, and the stack gauge reports ≥ 16 KB free.
|
||||
|
||||
## Memory considerations
|
||||
|
||||
- The role table is `16 × sizeof(role_entry_t)`. With `role_entry_t` at ~240
|
||||
bytes, that's ~3.8 KB. Place it in `DMAMEM` (RAM2) — there is 110 KB free
|
||||
heap and 413 KB of `.bss.dma` already; 3.8 KB is negligible.
|
||||
- The path parser and `derive_secp256k1_from_path` are pure code — mark them
|
||||
`.flashmem` so they live in FLASH (6.3 MB free) and don't steal ITCM banks.
|
||||
- The wizard UI adds LVGL widgets at runtime (heap-allocated by LVGL), freed
|
||||
when the wizard screen is destroyed. No persistent LVGL memory cost.
|
||||
- **Stack impact**: `derive_secp256k1_from_path` uses the same
|
||||
`nostr_hd_key_t` (1088 bytes each, two of them) as the existing NIP-06
|
||||
derivation — no new stack pressure. The 130.9 KB free stack is more than
|
||||
enough.
|
||||
|
||||
## Decisions to confirm
|
||||
|
||||
1. **Role table size**: 16 entries (vs the host's 256). Sufficient for a
|
||||
hardware signer? The host allows 256 for complex multi-agent setups; the
|
||||
Teensy is a single-user device. **Recommend 16.**
|
||||
2. **`requires_approval` default**: `0` (role-as-password), matching
|
||||
[`plans/role_as_password_default.md`](role_as_password_default.md). The
|
||||
wizard lets the user toggle it per role. **Confirm.**
|
||||
3. **`nostr_index` removal**: fully reject with error 2006 (no silent
|
||||
fallback), matching the host. A compile flag
|
||||
`ALLOW_DEPRECATED_NOSTR_INDEX` is provided **temporarily** for the
|
||||
migration period only. **Confirm.**
|
||||
4. **OTP role**: the host's OTP role (preset 9) has no derivation path. On the
|
||||
Teensy, OTP is already handled by the SD-pad bind flow
|
||||
([`plans/teensy41_otp_sd_pad.md`](teensy41_otp_sd_pad.md) Phase 6). The
|
||||
wizard's OTP preset should trigger `ui_pick_pad()` instead of path entry.
|
||||
**Confirm.**
|
||||
5. **Algorithm-based verbs**: `sign`, `get_public_key`, `derive`,
|
||||
`encapsulate`, `decapsulate`, `derive_shared_secret`, `encrypt`,
|
||||
`decrypt` are **unchanged** — they use `algorithm` + `index`, not roles.
|
||||
Only the `nostr_*` verbs migrate. **Confirm.**
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **CYD firmware migration**: the CYD
|
||||
([`firmware/cyd_esp32_2432s028/`](../firmware/cyd_esp32_2432s028/)) needs the
|
||||
same migration, but it is a separate task (different UI framework
|
||||
constraints, smaller screen). Tracked after the Teensy migration is
|
||||
verified.
|
||||
- **Policy table / `--preapprove`**: the host has a policy table for
|
||||
caller-based preapproval. The Teensy has no caller identity (USB CDC is a
|
||||
single trusted host), so the policy table is not needed — role-as-password
|
||||
is the only authorization mechanism.
|
||||
- **Path whitelist / `--allow-index`**: removed in the host's new model; not
|
||||
applicable to the Teensy.
|
||||
- **Multi-segment path templates** (e.g. `m/44'/1237'/%d/%d/%d`): the host
|
||||
supports only a single `%d` placeholder; we match that limitation.
|
||||
@@ -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).
|
||||
+98
-37
@@ -32,35 +32,83 @@ void auth_nonce_cache_init(auth_nonce_cache_t *cache) {
|
||||
memset(cache, 0, sizeof(*cache));
|
||||
}
|
||||
|
||||
static int auth_nonce_cache_contains(const auth_nonce_cache_t *cache, const uint8_t id[32]) {
|
||||
/*
|
||||
* Check and update replay protection using per-pubkey monotonic timestamps
|
||||
* plus event-ID tracking for same-second requests.
|
||||
*
|
||||
* Returns 0 if the (pubkey, created_at, event_id) tuple is acceptable.
|
||||
* Returns 1 if it is a replay.
|
||||
*
|
||||
* Hybrid approach:
|
||||
* - Track the highest created_at seen per pubkey.
|
||||
* - When created_at > max_seen: update max, clear event ID set, accept.
|
||||
* - When created_at == max_seen: check event ID set for duplicates.
|
||||
* - When created_at < max_seen: reject as replay.
|
||||
*
|
||||
* This allows multiple legitimate requests within the same second
|
||||
* (since created_at has 1-second granularity) while preventing replay
|
||||
* of any individual event ID.
|
||||
*/
|
||||
static int auth_nonce_cache_check_and_update(auth_nonce_cache_t *cache,
|
||||
const char *pubkey_hex,
|
||||
time_t created_at,
|
||||
const uint8_t event_id[32]) {
|
||||
int i;
|
||||
|
||||
if (cache == NULL || id == NULL) {
|
||||
return 0;
|
||||
if (cache == NULL || pubkey_hex == NULL || event_id == NULL) {
|
||||
return 1; /* treat as replay on invalid input */
|
||||
}
|
||||
|
||||
for (i = 0; i < cache->count; ++i) {
|
||||
if (memcmp(cache->ids[i], id, 32) == 0) {
|
||||
return 1;
|
||||
if (strcmp(cache->entries[i].pubkey_hex, pubkey_hex) == 0) {
|
||||
/* Found existing entry for this pubkey. */
|
||||
if (created_at > cache->entries[i].max_created_at) {
|
||||
/* Newer timestamp: update max, clear event ID set, accept. */
|
||||
cache->entries[i].max_created_at = created_at;
|
||||
cache->entries[i].event_id_count = 0;
|
||||
return 0;
|
||||
}
|
||||
if (created_at < cache->entries[i].max_created_at) {
|
||||
/* Older timestamp: reject as replay. */
|
||||
return 1;
|
||||
}
|
||||
/* Same timestamp: check event ID set. */
|
||||
int j;
|
||||
for (j = 0; j < cache->entries[i].event_id_count; ++j) {
|
||||
if (memcmp(cache->entries[i].event_ids[j], event_id, 32) == 0) {
|
||||
return 1; /* duplicate event ID */
|
||||
}
|
||||
}
|
||||
/* New event ID — add to set if space allows. */
|
||||
if (cache->entries[i].event_id_count < AUTH_MAX_EVENT_IDS_PER_SECOND) {
|
||||
memcpy(cache->entries[i].event_ids[cache->entries[i].event_id_count],
|
||||
event_id, 32);
|
||||
cache->entries[i].event_id_count++;
|
||||
}
|
||||
/* If event ID set is full, accept anyway (unlikely in practice). */
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void auth_nonce_cache_insert(auth_nonce_cache_t *cache, const uint8_t id[32]) {
|
||||
if (cache == NULL || id == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cache->count < AUTH_NONCE_CACHE_SIZE) {
|
||||
memcpy(cache->ids[cache->count], id, 32);
|
||||
/* New pubkey — add entry if space allows. */
|
||||
if (cache->count < AUTH_MAX_PUBKEYS) {
|
||||
strncpy(cache->entries[cache->count].pubkey_hex, pubkey_hex,
|
||||
sizeof(cache->entries[cache->count].pubkey_hex) - 1);
|
||||
cache->entries[cache->count].pubkey_hex[
|
||||
sizeof(cache->entries[cache->count].pubkey_hex) - 1] = '\0';
|
||||
cache->entries[cache->count].max_created_at = created_at;
|
||||
cache->entries[cache->count].event_id_count = 0;
|
||||
cache->count++;
|
||||
return;
|
||||
return 0;
|
||||
}
|
||||
|
||||
memcpy(cache->ids[cache->next], id, 32);
|
||||
cache->next = (cache->next + 1) % AUTH_NONCE_CACHE_SIZE;
|
||||
/*
|
||||
* Cache full — fall back to timestamp-only check.
|
||||
* In practice, AUTH_MAX_PUBKEYS=64 is sufficient for any realistic
|
||||
* session. This path exists only as a safety net.
|
||||
*/
|
||||
(void)i;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int json_item_to_compact_string(const cJSON *item, char *out, size_t out_sz) {
|
||||
@@ -243,7 +291,7 @@ int auth_envelope_verify_request(const char *request_json,
|
||||
cJSON *tag_body_hash;
|
||||
char request_id[128];
|
||||
char body_hash_hex[65];
|
||||
uint8_t nonce_bytes[32];
|
||||
uint8_t event_id[32];
|
||||
time_t now;
|
||||
long long created;
|
||||
|
||||
@@ -362,24 +410,6 @@ int auth_envelope_verify_request(const char *request_json,
|
||||
"auth_envelope_stale");
|
||||
}
|
||||
|
||||
id_hex_item = cJSON_GetObjectItemCaseSensitive(auth, "id");
|
||||
if (!cJSON_IsString(id_hex_item) || id_hex_item->valuestring == NULL ||
|
||||
strlen(id_hex_item->valuestring) != 64 ||
|
||||
nostr_hex_to_bytes(id_hex_item->valuestring, nonce_bytes, sizeof(nonce_bytes)) != 0) {
|
||||
cJSON_Delete(root);
|
||||
return set_error(out_error_code, out_error_message,
|
||||
AUTH_ERR_ENVELOPE_MALFORMED,
|
||||
"auth_envelope_malformed");
|
||||
}
|
||||
|
||||
if (auth_nonce_cache_contains(cache, nonce_bytes)) {
|
||||
cJSON_Delete(root);
|
||||
return set_error(out_error_code, out_error_message,
|
||||
AUTH_ERR_REPLAY_DETECTED,
|
||||
"auth_replay_detected");
|
||||
}
|
||||
auth_nonce_cache_insert(cache, nonce_bytes);
|
||||
|
||||
pubkey_item = cJSON_GetObjectItemCaseSensitive(auth, "pubkey");
|
||||
if (!cJSON_IsString(pubkey_item) || pubkey_item->valuestring == NULL ||
|
||||
strlen(pubkey_item->valuestring) != 64) {
|
||||
@@ -389,6 +419,37 @@ int auth_envelope_verify_request(const char *request_json,
|
||||
"auth_envelope_malformed");
|
||||
}
|
||||
|
||||
/*
|
||||
* Replay protection: hybrid monotonic-timestamp + event-ID tracking.
|
||||
*
|
||||
* Uses per-pubkey monotonic timestamps to reject replays, with an
|
||||
* event-ID set for the current max second to allow multiple legitimate
|
||||
* requests within the same wall-clock second (since created_at has
|
||||
* 1-second granularity).
|
||||
*
|
||||
* This replaces the old bounded-FIFO nonce cache which could wrap
|
||||
* and allow replay after 1024 entries.
|
||||
*/
|
||||
id_hex_item = cJSON_GetObjectItemCaseSensitive(auth, "id");
|
||||
if (!cJSON_IsString(id_hex_item) || id_hex_item->valuestring == NULL ||
|
||||
strlen(id_hex_item->valuestring) != 64 ||
|
||||
nostr_hex_to_bytes(id_hex_item->valuestring, event_id, sizeof(event_id)) != 0) {
|
||||
cJSON_Delete(root);
|
||||
return set_error(out_error_code, out_error_message,
|
||||
AUTH_ERR_ENVELOPE_MALFORMED,
|
||||
"auth_envelope_malformed");
|
||||
}
|
||||
|
||||
if (auth_nonce_cache_check_and_update(cache,
|
||||
pubkey_item->valuestring,
|
||||
created,
|
||||
event_id) != 0) {
|
||||
cJSON_Delete(root);
|
||||
return set_error(out_error_code, out_error_message,
|
||||
AUTH_ERR_REPLAY_DETECTED,
|
||||
"auth_replay_detected");
|
||||
}
|
||||
|
||||
strncpy(out_pubkey_hex, pubkey_item->valuestring, out_pubkey_hex_sz - 1);
|
||||
out_pubkey_hex[out_pubkey_hex_sz - 1] = '\0';
|
||||
|
||||
|
||||
+24
-3
@@ -7,7 +7,6 @@
|
||||
|
||||
#include <cJSON.h>
|
||||
|
||||
#define AUTH_NONCE_CACHE_SIZE 1024
|
||||
#define AUTH_DEFAULT_SKEW_SECONDS 30
|
||||
#define AUTH_EVENT_KIND 27235
|
||||
|
||||
@@ -20,10 +19,32 @@
|
||||
#define AUTH_ERR_ENVELOPE_STALE 2016
|
||||
#define AUTH_ERR_REPLAY_DETECTED 2017
|
||||
|
||||
/*
|
||||
* Replay-protection tracker: per-pubkey monotonic timestamp + event ID.
|
||||
*
|
||||
* Uses a hybrid approach:
|
||||
* 1. Track the highest created_at seen per pubkey (monotonic timestamp).
|
||||
* 2. For the current max second, also track event IDs to allow multiple
|
||||
* requests within the same second (since created_at has 1s granularity).
|
||||
* 3. When created_at > max_seen, clear the event ID set and update max.
|
||||
*
|
||||
* This is mathematically replay-proof: no bounded cache that can wrap,
|
||||
* and same-second requests with distinct event IDs are allowed.
|
||||
*/
|
||||
#define AUTH_MAX_PUBKEYS 64
|
||||
#define AUTH_MAX_EVENT_IDS_PER_SECOND 32
|
||||
|
||||
typedef struct {
|
||||
uint8_t ids[AUTH_NONCE_CACHE_SIZE][32];
|
||||
char pubkey_hex[65]; /* hex pubkey, NUL-terminated */
|
||||
time_t max_created_at; /* highest created_at seen for this pubkey */
|
||||
/* Event IDs seen at max_created_at (to allow same-second requests) */
|
||||
uint8_t event_ids[AUTH_MAX_EVENT_IDS_PER_SECOND][32];
|
||||
int event_id_count;
|
||||
} auth_pubkey_entry_t;
|
||||
|
||||
typedef struct {
|
||||
auth_pubkey_entry_t entries[AUTH_MAX_PUBKEYS];
|
||||
int count;
|
||||
int next;
|
||||
} auth_nonce_cache_t;
|
||||
|
||||
void auth_nonce_cache_init(auth_nonce_cache_t *cache);
|
||||
|
||||
@@ -123,6 +123,7 @@ typedef struct {
|
||||
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 */
|
||||
@@ -148,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);
|
||||
@@ -170,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 {
|
||||
@@ -325,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. */
|
||||
@@ -1794,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");
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ typedef struct {
|
||||
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 */
|
||||
|
||||
+21
-10
@@ -74,7 +74,8 @@ static int write_all(int fd, const char *buf, size_t len) {
|
||||
*/
|
||||
int http_recv_request(int fd, char **out_body, size_t max_body_size) {
|
||||
char line[2048];
|
||||
long content_length = -1;
|
||||
size_t content_length = 0;
|
||||
int has_content_length = 0;
|
||||
int is_post = 0;
|
||||
|
||||
if (!out_body) return -1;
|
||||
@@ -114,35 +115,45 @@ int http_recv_request(int fd, char **out_body, size_t max_body_size) {
|
||||
}
|
||||
if (len == 0) break; /* end of headers */
|
||||
|
||||
/* Parse Content-Length (case-insensitive). */
|
||||
/* Parse Content-Length (case-insensitive) using strtoull for
|
||||
* safe unsigned parsing with error detection. */
|
||||
if (strncasecmp(line, "Content-Length:", 15) == 0) {
|
||||
const char *p = line + 15;
|
||||
while (*p == ' ' || *p == '\t') p++;
|
||||
content_length = atol(p);
|
||||
char *endptr = NULL;
|
||||
unsigned long long cl = strtoull(p, &endptr, 10);
|
||||
/* Reject if: no digits parsed, trailing non-whitespace, or
|
||||
* value exceeds SIZE_MAX (can't allocate that much). */
|
||||
if (endptr == p || (*endptr != '\0' && *endptr != ' ' && *endptr != '\t' && *endptr != '\r') ||
|
||||
cl > (unsigned long long)SIZE_MAX) {
|
||||
return -3; /* invalid Content-Length */
|
||||
}
|
||||
content_length = (size_t)cl;
|
||||
has_content_length = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (content_length < 0) {
|
||||
if (!has_content_length) {
|
||||
return -3; /* missing Content-Length */
|
||||
}
|
||||
if ((size_t)content_length > max_body_size) {
|
||||
if (content_length > max_body_size) {
|
||||
/* Drain the body so the connection isn't left half-open. */
|
||||
char tmp[4096];
|
||||
long remaining = content_length;
|
||||
size_t remaining = content_length;
|
||||
while (remaining > 0) {
|
||||
size_t to_read = (size_t)remaining;
|
||||
size_t to_read = remaining;
|
||||
if (to_read > sizeof(tmp)) to_read = sizeof(tmp);
|
||||
ssize_t r = read(fd, tmp, to_read);
|
||||
if (r <= 0) break;
|
||||
remaining -= r;
|
||||
remaining -= (size_t)r;
|
||||
}
|
||||
return -4; /* body too large */
|
||||
}
|
||||
|
||||
/* Read the body. */
|
||||
char *body = (char *)malloc((size_t)content_length + 1);
|
||||
char *body = (char *)malloc(content_length + 1);
|
||||
if (!body) return -1;
|
||||
if (read_n_bytes(fd, body, (size_t)content_length) != 0) {
|
||||
if (read_n_bytes(fd, body, content_length) != 0) {
|
||||
free(body);
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ typedef struct {
|
||||
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 */
|
||||
@@ -1208,6 +1209,8 @@ 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';
|
||||
@@ -1217,7 +1220,37 @@ int crypto_derive_all(key_store_t *store, role_table_t *table, const mnemonic_st
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1225,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++;
|
||||
}
|
||||
|
||||
|
||||
+684
-300
File diff suppressed because it is too large
Load Diff
@@ -121,6 +121,7 @@ typedef struct {
|
||||
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 */
|
||||
|
||||
+5
-2
@@ -406,8 +406,11 @@ int otp_pad_encrypt(const unsigned char *plaintext, size_t pt_len,
|
||||
hdr.version = OTPPAD_FORMAT_VERSION;
|
||||
/* pad_chksum is binary 32 bytes — convert hex to bytes. */
|
||||
for (int i = 0; i < OTPPAD_CHKSUM_BIN_LEN; i++) {
|
||||
unsigned int byte;
|
||||
sscanf(g_otp_pad.chksum + i * 2, "%02x", &byte);
|
||||
unsigned int byte = 0;
|
||||
if (sscanf(g_otp_pad.chksum + i * 2, "%02x", &byte) != 1) {
|
||||
secure_memzero(g_otp_pad.scratch_data, g_otp_pad.scratch_size);
|
||||
return 11;
|
||||
}
|
||||
hdr.pad_chksum[i] = (unsigned char)byte;
|
||||
}
|
||||
hdr.pad_offset = offset;
|
||||
|
||||
@@ -121,6 +121,7 @@ typedef struct {
|
||||
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 */
|
||||
@@ -146,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);
|
||||
@@ -168,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 {
|
||||
@@ -328,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. */
|
||||
@@ -1267,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);
|
||||
}
|
||||
|
||||
+25
-6
@@ -131,6 +131,7 @@ typedef struct {
|
||||
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 */
|
||||
@@ -1452,14 +1453,32 @@ int crypto_slh_dsa_128s_sign(const unsigned char *priv, size_t priv_len,
|
||||
/* SLH-DSA-128s signing uses randombytes() for the opt_rand value.
|
||||
* With our deterministic DRBG (if seeded), signing is deterministic.
|
||||
* If the DRBG is not seeded, randombytes() will fail. We seed it
|
||||
* from the secret key's SK.prf to make signing deterministic. */
|
||||
* from a domain-separated derivation of SK.prf to make signing
|
||||
* deterministic while keeping the two uses of SK.prf independent.
|
||||
*
|
||||
* Per FIPS 205 Section 10.2, SK.prf is the key to PRF_msg() which
|
||||
* produces the randomization value R. We must not reuse SK.prf
|
||||
* directly as a DRBG seed, because if the DRBG output were ever
|
||||
* compromised, SK.prf would also be compromised, breaking the
|
||||
* PRF_msg security guarantee.
|
||||
*
|
||||
* Instead, we derive a separate DRBG seed:
|
||||
* drbg_seed = HMAC-SHA256(SK.prf, "slh-dsa-drbg-seed")
|
||||
* This ensures domain separation between the two uses of SK.prf. */
|
||||
{
|
||||
/* Seed the DRBG from SK.prf (bytes 16..31 of the secret key) to
|
||||
* make signing deterministic. This is not the standard approach
|
||||
* (which uses a separate RNG), but it ensures deterministic
|
||||
* signing which is what we need for mnemonic-recoverable keys. */
|
||||
const unsigned char *sk_prf = priv + SLH_DSA_128S_N;
|
||||
pq_drbg_init(sk_prf, SLH_DSA_128S_N);
|
||||
unsigned char drbg_seed[32];
|
||||
unsigned int hmac_len = 32;
|
||||
const unsigned char separator[] = "slh-dsa-drbg-seed";
|
||||
|
||||
if (HMAC(EVP_sha256(), sk_prf, SLH_DSA_128S_N,
|
||||
separator, sizeof(separator) - 1,
|
||||
drbg_seed, &hmac_len) == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
pq_drbg_init(drbg_seed, sizeof(drbg_seed));
|
||||
OPENSSL_cleanse(drbg_seed, sizeof(drbg_seed));
|
||||
}
|
||||
|
||||
if (slh_dsa_128s_crypto_sign(sig_out, &sig_len, msg, msg_len, priv) != 0) {
|
||||
|
||||
+18
-5
@@ -6,10 +6,19 @@
|
||||
*
|
||||
* The PRNG is simple: SHAKE-256(seed || counter) produces a stream of
|
||||
* pseudo-random bytes. The counter is a 64-bit little-endian integer that
|
||||
* increments each time we need more output. This is not a NIST SP 800-90A
|
||||
* compliant DRBG, but it is deterministic and sufficient for PQ keygen
|
||||
* (which only needs the output to be uniformly distributed, which SHAKE
|
||||
* provides).
|
||||
* increments each time we need more output. Domain separation between
|
||||
* different algorithm types (ML-DSA-65, SLH-DSA-128s, ML-KEM-768) is not
|
||||
* needed because the DRBG is initialized once per keygen operation with a
|
||||
* unique seed and zeroized immediately after — the streams never mix.
|
||||
*
|
||||
* This is NOT a NIST SP 800-90A compliant DRBG (it has no reseeding mechanism,
|
||||
* no prediction resistance, and uses a custom construction). However, it is
|
||||
* sufficient for this use case because:
|
||||
* 1. The DRBG is initialized once per keygen operation and zeroized after.
|
||||
* 2. The seed is derived from a BIP-39 mnemonic (256-bit entropy).
|
||||
* 3. SHAKE-256 is a NIST-standardized XOF with 256-bit preimage resistance.
|
||||
* 4. The counter domain-separates each output block (no two blocks overlap).
|
||||
* 5. The output is only used for key generation, never exposed directly.
|
||||
*
|
||||
* Security argument: SHAKE-256 is a XOF (extendable output function) based
|
||||
* on Keccak. Given a 256-bit seed, the output is computationally
|
||||
@@ -25,7 +34,11 @@
|
||||
static unsigned char g_seed[32];
|
||||
static int g_seed_len = 0;
|
||||
static uint64_t g_counter = 0;
|
||||
static unsigned char g_buffer[168]; /* SHAKE-256 rate = 136, but we use 168 for safety */
|
||||
/* SHAKE-256 rate = 136 bytes (1088 bits). We request 168 bytes per refill
|
||||
* because XOF output can be any length; 168 is a convenient buffer size
|
||||
* (matching SHAKE-128's rate of 1344 bits) and reduces the number of refills
|
||||
* needed for large keygen operations. */
|
||||
static unsigned char g_buffer[168];
|
||||
static size_t g_buffer_pos = sizeof(g_buffer);
|
||||
static int g_initialized = 0;
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ typedef struct {
|
||||
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 */
|
||||
@@ -147,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);
|
||||
|
||||
@@ -171,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 {
|
||||
@@ -328,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. */
|
||||
@@ -716,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) {
|
||||
@@ -907,6 +942,173 @@ int role_table_register_role_path(role_table_t *table, const char *name,
|
||||
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;
|
||||
|
||||
+29
-1
@@ -22,6 +22,9 @@ typedef struct {
|
||||
/* Allocate a secure buffer of `size` bytes. Returns 0 on success, -1 on failure. */
|
||||
int secure_buf_alloc(secure_buf_t *buf, size_t size);
|
||||
|
||||
/* Allow secure_buf_alloc to succeed even when mlock fails (development only). */
|
||||
void secure_buf_allow_unlocked(void);
|
||||
|
||||
/* Zeroize and free a secure buffer. Always succeeds (idempotent). */
|
||||
void secure_buf_free(secure_buf_t *buf);
|
||||
|
||||
@@ -123,6 +126,7 @@ typedef struct {
|
||||
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 */
|
||||
@@ -714,6 +718,7 @@ int socket_name_random(char *out, size_t out_len);
|
||||
|
||||
/* NSIGNER_HEADERLESS_DECLS_END */
|
||||
|
||||
#include <errno.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -743,7 +748,17 @@ void secure_memzero(void *ptr, size_t len) {
|
||||
/*
|
||||
* Allocate secure memory and attempt to lock it in RAM.
|
||||
* Returns 0 on success, -1 on allocation/argument failure.
|
||||
*
|
||||
* mlock failure is fatal by default — unlocked secrets may be paged to disk.
|
||||
* Call secure_buf_allow_unlocked() at startup to permit unlocked operation
|
||||
* (e.g. in containers or development environments).
|
||||
*/
|
||||
static int g_secure_buf_allow_unlocked = 0;
|
||||
|
||||
void secure_buf_allow_unlocked(void) {
|
||||
g_secure_buf_allow_unlocked = 1;
|
||||
}
|
||||
|
||||
int secure_buf_alloc(secure_buf_t *buf, size_t size) {
|
||||
if (buf == NULL || size == 0) {
|
||||
return -1;
|
||||
@@ -762,7 +777,20 @@ int secure_buf_alloc(secure_buf_t *buf, size_t size) {
|
||||
if (mlock(buf->data, buf->size) == 0) {
|
||||
buf->locked = 1;
|
||||
} else {
|
||||
fprintf(stderr, "warning: secure_buf_alloc: mlock failed; continuing unlocked\n");
|
||||
if (g_secure_buf_allow_unlocked) {
|
||||
fprintf(stderr, "warning: secure_buf_alloc: mlock failed (%s); "
|
||||
"continuing unlocked (--allow-unlocked-memory)\n",
|
||||
strerror(errno));
|
||||
} else {
|
||||
fprintf(stderr, "FATAL: secure_buf_alloc: mlock failed (%s).\n"
|
||||
" Secret material could be paged to disk.\n"
|
||||
" Use --allow-unlocked-memory to override (not recommended).\n",
|
||||
strerror(errno));
|
||||
free(buf->data);
|
||||
buf->data = NULL;
|
||||
buf->size = 0;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
memset(buf->data, 0, buf->size);
|
||||
|
||||
+74
-14
@@ -121,6 +121,7 @@ typedef struct {
|
||||
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 */
|
||||
@@ -147,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);
|
||||
|
||||
@@ -168,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 {
|
||||
@@ -325,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. */
|
||||
@@ -723,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) {
|
||||
@@ -734,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;
|
||||
@@ -778,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";
|
||||
}
|
||||
|
||||
+154
-96
@@ -124,6 +124,7 @@ typedef struct {
|
||||
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 */
|
||||
@@ -149,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);
|
||||
@@ -178,6 +193,11 @@ int role_table_register_role_path(role_table_t *table, const char *name, const 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 {
|
||||
@@ -343,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. */
|
||||
@@ -1970,7 +2000,7 @@ int server_start(server_ctx_t *ctx) {
|
||||
}
|
||||
(void)snprintf(ctx->last_error,
|
||||
sizeof(ctx->last_error),
|
||||
"bind(@%s) failed: %s (and failed to generate retry socket name)",
|
||||
"bind(%s) failed: %s (and failed to generate retry socket name)",
|
||||
ctx->socket_name,
|
||||
strerror(errno));
|
||||
close(fd);
|
||||
@@ -1980,13 +2010,13 @@ int server_start(server_ctx_t *ctx) {
|
||||
if (errno == EADDRINUSE && ctx->socket_name_explicit) {
|
||||
(void)snprintf(ctx->last_error,
|
||||
sizeof(ctx->last_error),
|
||||
"bind(@%s) failed: %s (explicit --socket-name is already in use)",
|
||||
"bind(%s) failed: %s (explicit --socket-name is already in use)",
|
||||
ctx->socket_name,
|
||||
strerror(errno));
|
||||
} else {
|
||||
(void)snprintf(ctx->last_error,
|
||||
sizeof(ctx->last_error),
|
||||
"bind(@%s) failed: %s",
|
||||
"bind(%s) failed: %s",
|
||||
ctx->socket_name,
|
||||
strerror(errno));
|
||||
}
|
||||
@@ -2386,19 +2416,13 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
|
||||
(void)snprintf(role_name, sizeof(role_name), "nostr_idx_%d", selector_req.nostr_index);
|
||||
}
|
||||
json_copy_string(purpose, sizeof(purpose), "nostr", "nostr");
|
||||
} else if (selector_rc == SELECTOR_ERR_NOT_FOUND && selector_req.has_role_path) {
|
||||
/* role_path not in table — check path whitelist for auto-registration */
|
||||
if (server_path_whitelist_allows(ctx, selector_req.role_path)) {
|
||||
pending_derivation = 1;
|
||||
(void)snprintf(role_name, sizeof(role_name), "path_%s", selector_req.role_path);
|
||||
json_copy_string(purpose, sizeof(purpose), "nostr", "nostr");
|
||||
} else {
|
||||
hard_selector_error = -200; /* path_not_allowed sentinel */
|
||||
}
|
||||
} else if (selector_rc == SELECTOR_OK && role != NULL &&
|
||||
role->selector_type == SELECTOR_ROLE_PATH &&
|
||||
strstr(role->role_path, "%d") == NULL) {
|
||||
/* Fixed-path named role — no index needed, derive if not yet done */
|
||||
/* 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) {
|
||||
@@ -2407,49 +2431,81 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
|
||||
} else if (selector_rc == SELECTOR_OK && role != NULL &&
|
||||
role->selector_type == SELECTOR_ROLE_PATH &&
|
||||
strstr(role->role_path, "%d") != NULL) {
|
||||
/* Named path-role with template — resolve the concrete path from index */
|
||||
/* 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");
|
||||
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;
|
||||
|
||||
if (selector_req.has_role_path) {
|
||||
/* Case (a): client supplied a concrete path that was already
|
||||
* validated by selector_resolve. Use it directly.
|
||||
*
|
||||
* Always re-derive for template roles: the client may request
|
||||
* a different concrete path (e.g. a different account index)
|
||||
* than the one previously derived and cached on the role.
|
||||
* The derivation block below swaps in the concrete path,
|
||||
* clears derived/pubkey, re-derives, and restores the
|
||||
* template — so forcing pending_derivation here is safe and
|
||||
* correct. Without this, a second request with a different
|
||||
* path would return the stale cached pubkey from the first. */
|
||||
snprintf(concrete_path, sizeof(concrete_path),
|
||||
"%s", selector_req.role_path);
|
||||
pending_derivation = 1;
|
||||
} 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;
|
||||
/* 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 {
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@@ -2482,6 +2538,12 @@ 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;
|
||||
@@ -2492,8 +2554,8 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
|
||||
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 */
|
||||
|
||||
@@ -2529,39 +2591,11 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
|
||||
ctx->dispatcher->key_store == NULL ||
|
||||
ctx->dispatcher->mnemonic == NULL) {
|
||||
derivation_error = 1;
|
||||
} else if (selector_req.has_role_path) {
|
||||
/* Auto-register a fixed-path role from the path whitelist */
|
||||
char path_role_name[ROLE_NAME_MAX];
|
||||
(void)snprintf(path_role_name, sizeof(path_role_name), "pathrole_%d",
|
||||
ctx->dispatcher->role_table->count);
|
||||
if (role_table_register_role_path(ctx->dispatcher->role_table,
|
||||
path_role_name,
|
||||
selector_req.role_path,
|
||||
PURPOSE_NOSTR, CURVE_SECP256K1,
|
||||
0, 0, -1, NULL, 0) != 0) {
|
||||
derivation_error = 1;
|
||||
} else {
|
||||
new_role = role_table_find_by_path(ctx->dispatcher->role_table,
|
||||
selector_req.role_path);
|
||||
if (new_role == NULL) {
|
||||
derivation_error = 1;
|
||||
} else {
|
||||
role_index = (int)(new_role - &ctx->dispatcher->role_table->entries[0]);
|
||||
if (role_index < 0 || role_index >= ctx->dispatcher->role_table->count ||
|
||||
crypto_derive_one(ctx->dispatcher->key_store,
|
||||
ctx->dispatcher->role_table,
|
||||
ctx->dispatcher->mnemonic,
|
||||
role_index) != 0) {
|
||||
derivation_error = 1;
|
||||
} else {
|
||||
json_copy_string(role_name, sizeof(role_name), new_role->name, role_name);
|
||||
json_copy_string(purpose, sizeof(purpose), role_purpose_to_str(new_role->purpose), "nostr");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (role != NULL && role->selector_type == SELECTOR_ROLE_PATH &&
|
||||
strstr(role->role_path, "%d") == NULL) {
|
||||
/* Fixed-path named role (found by role name) — derive directly */
|
||||
/* 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 ||
|
||||
@@ -2655,14 +2689,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);
|
||||
}
|
||||
@@ -2709,14 +2755,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);
|
||||
|
||||
@@ -123,6 +123,7 @@ typedef struct {
|
||||
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 */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+1118
File diff suppressed because it is too large
Load Diff
+39
-24
@@ -388,8 +388,10 @@
|
||||
<!-- Nostr Get Public Key -->
|
||||
<section class="divPostItem section">
|
||||
<h2>nostr_get_public_key</h2>
|
||||
<label for="ngpkIdx">nostr_index</label>
|
||||
<input id="ngpkIdx" type="number" value="0" min="0" class="inpStyle" />
|
||||
<label for="ngpkRole">role</label>
|
||||
<input id="ngpkRole" value="main" class="inpStyle" />
|
||||
<label for="ngpkPath">role_path</label>
|
||||
<input id="ngpkPath" value="m/44'/1237'/0'/0/0" class="inpStyle" />
|
||||
<label for="ngpkFmt">format</label>
|
||||
<select id="ngpkFmt" class="inpStyle"><option>bare</option><option>structured</option></select>
|
||||
<div class="row">
|
||||
@@ -465,8 +467,10 @@
|
||||
<h2>nostr_sign_event</h2>
|
||||
<label for="nseContent">content</label>
|
||||
<textarea id="nseContent" class="inpStyle">hello from usb test</textarea>
|
||||
<label for="nseIdx">nostr_index</label>
|
||||
<input id="nseIdx" type="number" value="0" min="0" class="inpStyle" />
|
||||
<label for="nseRole">role</label>
|
||||
<input id="nseRole" value="main" class="inpStyle" />
|
||||
<label for="nsePath">role_path</label>
|
||||
<input id="nsePath" value="m/44'/1237'/0'/0/0" class="inpStyle" />
|
||||
<div class="row">
|
||||
<button id="nseBtn" class="btn" disabled>nostr_sign_event</button>
|
||||
</div>
|
||||
@@ -479,8 +483,10 @@
|
||||
<p class="warn">Slow on ESP32 — uses single-threaded PoW. Keep difficulty low.</p>
|
||||
<label for="nmeContent">content</label>
|
||||
<textarea id="nmeContent" class="inpStyle">mined by usb test</textarea>
|
||||
<label for="nmeIdx">nostr_index</label>
|
||||
<input id="nmeIdx" type="number" value="0" min="0" class="inpStyle" />
|
||||
<label for="nmeRole">role</label>
|
||||
<input id="nmeRole" value="main" class="inpStyle" />
|
||||
<label for="nmePath">role_path</label>
|
||||
<input id="nmePath" value="m/44'/1237'/0'/0/0" class="inpStyle" />
|
||||
<label for="nmeDiff">difficulty (leading zero bits)</label>
|
||||
<input id="nmeDiff" type="number" value="4" min="1" max="16" class="inpStyle" />
|
||||
<label for="nmeTimeout">timeout (sec)</label>
|
||||
@@ -500,8 +506,10 @@
|
||||
<textarea id="nip04Msg" class="inpStyle">hello via nip04</textarea>
|
||||
<label for="nip04Cipher">ciphertext (for decrypt)</label>
|
||||
<textarea id="nip04Cipher" class="inpStyle" placeholder="ciphertext?iv=..."></textarea>
|
||||
<label for="nip04Idx">nostr_index</label>
|
||||
<input id="nip04Idx" type="number" value="0" min="0" class="inpStyle" />
|
||||
<label for="nip04Role">role</label>
|
||||
<input id="nip04Role" value="main" class="inpStyle" />
|
||||
<label for="nip04Path">role_path</label>
|
||||
<input id="nip04Path" value="m/44'/1237'/0'/0/0" class="inpStyle" />
|
||||
<div class="row">
|
||||
<button id="nip04EncBtn" class="btn" disabled>encrypt</button>
|
||||
<button id="nip04DecBtn" class="btn" disabled>decrypt</button>
|
||||
@@ -518,8 +526,10 @@
|
||||
<textarea id="nip44Msg" class="inpStyle">hello via nip44</textarea>
|
||||
<label for="nip44Cipher">ciphertext (for decrypt)</label>
|
||||
<textarea id="nip44Cipher" class="inpStyle" placeholder="base64 payload"></textarea>
|
||||
<label for="nip44Idx">nostr_index</label>
|
||||
<input id="nip44Idx" type="number" value="0" min="0" class="inpStyle" />
|
||||
<label for="nip44Role">role</label>
|
||||
<input id="nip44Role" value="main" class="inpStyle" />
|
||||
<label for="nip44Path">role_path</label>
|
||||
<input id="nip44Path" value="m/44'/1237'/0'/0/0" class="inpStyle" />
|
||||
<div class="row">
|
||||
<button id="nip44EncBtn" class="btn" disabled>encrypt</button>
|
||||
<button id="nip44DecBtn" class="btn" disabled>decrypt</button>
|
||||
@@ -788,8 +798,9 @@
|
||||
callVerb("get_public_key", [{ algorithm: alg, index: idx }], $("gpkOut"));
|
||||
});
|
||||
$("ngpkBtn").addEventListener("click", () => {
|
||||
const idx = Number($("ngpkIdx").value || 0), fmt = $("ngpkFmt").value;
|
||||
const opts = { nostr_index: idx };
|
||||
const role = $("ngpkRole").value.trim(), path = $("ngpkPath").value.trim();
|
||||
const fmt = $("ngpkFmt").value;
|
||||
const opts = { role, role_path: path };
|
||||
if (fmt === "structured") opts.format = "structured";
|
||||
callVerb("nostr_get_public_key", [opts], $("ngpkOut"));
|
||||
});
|
||||
@@ -846,24 +857,25 @@
|
||||
|
||||
$("nseBtn").addEventListener("click", () => {
|
||||
const content = $("nseContent").value;
|
||||
const idx = Number($("nseIdx").value || 0);
|
||||
const role = $("nseRole").value.trim(), path = $("nsePath").value.trim();
|
||||
const event = { kind: 1, created_at: Math.floor(Date.now()/1000), tags: [], content };
|
||||
callVerb("nostr_sign_event", [event, { nostr_index: idx }], $("nseOut"));
|
||||
callVerb("nostr_sign_event", [event, { role, role_path: path }], $("nseOut"));
|
||||
});
|
||||
|
||||
$("nmeBtn").addEventListener("click", () => {
|
||||
const content = $("nmeContent").value;
|
||||
const idx = Number($("nmeIdx").value || 0);
|
||||
const role = $("nmeRole").value.trim(), path = $("nmePath").value.trim();
|
||||
const diff = Number($("nmeDiff").value || 4);
|
||||
const timeout = Number($("nmeTimeout").value || 30);
|
||||
const event = { kind: 1, created_at: Math.floor(Date.now()/1000), tags: [], content };
|
||||
callVerb("nostr_mine_event", [event, { nostr_index: idx, difficulty: diff, timeout_sec: timeout }], $("nmeOut"));
|
||||
callVerb("nostr_mine_event", [event, { role, role_path: path, difficulty: diff, timeout_sec: timeout }], $("nmeOut"));
|
||||
});
|
||||
|
||||
const nip04Enc = async () => {
|
||||
const peer = $("nip04Peer").value.trim(), msg = $("nip04Msg").value, idx = Number($("nip04Idx").value || 0);
|
||||
const peer = $("nip04Peer").value.trim(), msg = $("nip04Msg").value;
|
||||
const role = $("nip04Role").value.trim(), path = $("nip04Path").value.trim();
|
||||
if (!peer) { $("nip04Out").textContent = "✗ enter peer pubkey"; return; }
|
||||
const params = [peer, msg, { nostr_index: idx }];
|
||||
const params = [peer, msg, { role, role_path: path }];
|
||||
$("nip04Out").textContent = "→ nostr_nip04_encrypt " + JSON.stringify(params);
|
||||
try {
|
||||
const auth = await buildAuth("nostr_nip04_encrypt", params);
|
||||
@@ -877,17 +889,19 @@
|
||||
}
|
||||
};
|
||||
const nip04Dec = () => {
|
||||
const peer = $("nip04Peer").value.trim(), ct = $("nip04Cipher").value, idx = Number($("nip04Idx").value || 0);
|
||||
const peer = $("nip04Peer").value.trim(), ct = $("nip04Cipher").value;
|
||||
const role = $("nip04Role").value.trim(), path = $("nip04Path").value.trim();
|
||||
if (!peer || !ct) { $("nip04Out").textContent = "✗ enter peer pubkey + ciphertext"; return; }
|
||||
callVerb("nostr_nip04_decrypt", [peer, ct, { nostr_index: idx }], $("nip04Out"));
|
||||
callVerb("nostr_nip04_decrypt", [peer, ct, { role, role_path: path }], $("nip04Out"));
|
||||
};
|
||||
$("nip04EncBtn").addEventListener("click", nip04Enc);
|
||||
$("nip04DecBtn").addEventListener("click", nip04Dec);
|
||||
|
||||
const nip44Enc = async () => {
|
||||
const peer = $("nip44Peer").value.trim(), msg = $("nip44Msg").value, idx = Number($("nip44Idx").value || 0);
|
||||
const peer = $("nip44Peer").value.trim(), msg = $("nip44Msg").value;
|
||||
const role = $("nip44Role").value.trim(), path = $("nip44Path").value.trim();
|
||||
if (!peer) { $("nip44Out").textContent = "✗ enter peer pubkey"; return; }
|
||||
const params = [peer, msg, { nostr_index: idx }];
|
||||
const params = [peer, msg, { role, role_path: path }];
|
||||
$("nip44Out").textContent = "→ nostr_nip44_encrypt " + JSON.stringify(params);
|
||||
try {
|
||||
const auth = await buildAuth("nostr_nip44_encrypt", params);
|
||||
@@ -901,9 +915,10 @@
|
||||
}
|
||||
};
|
||||
const nip44Dec = () => {
|
||||
const peer = $("nip44Peer").value.trim(), ct = $("nip44Cipher").value, idx = Number($("nip44Idx").value || 0);
|
||||
const peer = $("nip44Peer").value.trim(), ct = $("nip44Cipher").value;
|
||||
const role = $("nip44Role").value.trim(), path = $("nip44Path").value.trim();
|
||||
if (!peer || !ct) { $("nip44Out").textContent = "✗ enter peer pubkey + ciphertext"; return; }
|
||||
callVerb("nostr_nip44_decrypt", [peer, ct, { nostr_index: idx }], $("nip44Out"));
|
||||
callVerb("nostr_nip44_decrypt", [peer, ct, { role, role_path: path }], $("nip44Out"));
|
||||
};
|
||||
$("nip44EncBtn").addEventListener("click", nip44Enc);
|
||||
$("nip44DecBtn").addEventListener("click", nip44Dec);
|
||||
|
||||
Reference in New Issue
Block a user