Compare commits
23 Commits
v0.0.5
...
44372c0108
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44372c0108 | ||
|
|
5c214f3614 | ||
|
|
f4413b7969 | ||
|
|
cca1254740 | ||
|
|
5bb59c1422 | ||
|
|
cc5638b6e7 | ||
|
|
cc797a16df | ||
|
|
17a1b3f020 | ||
|
|
659aeb934a | ||
|
|
148259040c | ||
|
|
7ffba2b678 | ||
|
|
cfaa8ff637 | ||
|
|
1317de5cbe | ||
|
|
fac0ce6503 | ||
|
|
ae95ae6859 | ||
|
|
3efb0edb61 | ||
|
|
c5fdb1a207 | ||
|
|
28f50a750f | ||
|
|
1dd6630311 | ||
|
|
252d026991 | ||
|
|
f9d7b94962 | ||
|
|
b089bf36e3 | ||
|
|
3e86e539e0 |
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
description: "Brief description of what this command does"
|
description: "This command increments the version number and then adds, commits and pushes to the repo."
|
||||||
---
|
---
|
||||||
|
|
||||||
Run increment_and_push.sh, and supply a good git commit message. For example:
|
Run increment_and_push.sh, and supply a good git commit message. For example:
|
||||||
|
|||||||
7
.roo/commands/push_release.md
Normal file
7
.roo/commands/push_release.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
description: "This increments our git tag and version number, along with compiling and uploading a new release to our gitea page."
|
||||||
|
---
|
||||||
|
|
||||||
|
Run increment_and_push.sh -r -p, and supply a good git commit message. For example:
|
||||||
|
|
||||||
|
./increment_and_push.sh "Fixed the bug with nip05 implementation"
|
||||||
@@ -46,34 +46,50 @@ RUN cd /tmp && \
|
|||||||
|
|
||||||
# Copy and build nostr_core_lib from project resources
|
# Copy and build nostr_core_lib from project resources
|
||||||
COPY resources/nostr_core_lib /build/nostr_core_lib/
|
COPY resources/nostr_core_lib /build/nostr_core_lib/
|
||||||
RUN cd /build/nostr_core_lib && \
|
RUN if [ "$(uname -m)" = "aarch64" ] && ! command -v aarch64-linux-gnu-gcc >/dev/null 2>&1; then \
|
||||||
|
ln -s "$(command -v gcc)" /usr/local/bin/aarch64-linux-gnu-gcc; \
|
||||||
|
fi && \
|
||||||
|
cd /build/nostr_core_lib && \
|
||||||
chmod +x ./build.sh && \
|
chmod +x ./build.sh && \
|
||||||
./build.sh --nips=1,4,6,19,44,46
|
./build.sh --nips=1,4,6,19,44
|
||||||
|
|
||||||
# Copy source files
|
# Copy source files
|
||||||
COPY src/ /build/src/
|
COPY src/ /build/src/
|
||||||
|
COPY resources/tui_continuous/ /build/resources/tui_continuous/
|
||||||
|
|
||||||
# Build nsigner as a fully static binary
|
# Build nsigner as a fully static binary
|
||||||
RUN gcc -static -O2 -Wall -Wextra -std=c99 \
|
RUN ARCH="$(uname -m)"; \
|
||||||
-I/build/nostr_core_lib \
|
case "$ARCH" in \
|
||||||
-I/build/nostr_core_lib/nostr_core \
|
aarch64|arm64) NOSTR_LIB="/build/nostr_core_lib/libnostr_core_arm64.a" ;; \
|
||||||
-I/build/nostr_core_lib/cjson \
|
x86_64|amd64) NOSTR_LIB="/build/nostr_core_lib/libnostr_core_x64.a" ;; \
|
||||||
/build/src/main.c \
|
*) echo "Unsupported build arch: $ARCH"; exit 1 ;; \
|
||||||
/build/src/secure_mem.c \
|
esac; \
|
||||||
/build/src/mnemonic.c \
|
[ -f "$NOSTR_LIB" ] || { echo "Missing nostr core lib: $NOSTR_LIB"; ls -la /build/nostr_core_lib; exit 1; }; \
|
||||||
/build/src/role_table.c \
|
gcc -static -Os -ffunction-sections -fdata-sections -Wl,--gc-sections -s -Wall -Wextra -std=c99 \
|
||||||
/build/src/selector.c \
|
-I/build/nostr_core_lib \
|
||||||
/build/src/enforcement.c \
|
-I/build/nostr_core_lib/nostr_core \
|
||||||
/build/src/dispatcher.c \
|
-I/build/nostr_core_lib/cjson \
|
||||||
/build/src/policy.c \
|
-I/build/resources/tui_continuous \
|
||||||
/build/src/server.c \
|
/build/src/main.c \
|
||||||
/build/src/key_store.c \
|
/build/src/secure_mem.c \
|
||||||
/build/src/socket_name.c \
|
/build/src/mnemonic.c \
|
||||||
/build/nostr_core_lib/libnostr_core_x64.a \
|
/build/src/role_table.c \
|
||||||
-o /build/nsigner_static \
|
/build/src/selector.c \
|
||||||
$(pkg-config --static --libs libcurl openssl) \
|
/build/src/enforcement.c \
|
||||||
-lsecp256k1 -lsqlite3 -lz -lpthread -lm
|
/build/src/dispatcher.c \
|
||||||
|
/build/src/policy.c \
|
||||||
|
/build/src/server.c \
|
||||||
|
/build/src/transport_frame.c \
|
||||||
|
/build/src/key_store.c \
|
||||||
|
/build/src/socket_name.c \
|
||||||
|
/build/src/auth_envelope.c \
|
||||||
|
/build/resources/tui_continuous/tui_continuous.c \
|
||||||
|
"$NOSTR_LIB" \
|
||||||
|
-o /build/nsigner_static \
|
||||||
|
$(pkg-config --static --libs libcurl openssl) \
|
||||||
|
-lsecp256k1 -lsqlite3 -lz -lpthread -lm
|
||||||
|
|
||||||
|
RUN strip /build/nsigner_static || true
|
||||||
RUN file /build/nsigner_static && \
|
RUN file /build/nsigner_static && \
|
||||||
(ldd /build/nsigner_static 2>&1 || true)
|
(ldd /build/nsigner_static 2>&1 || true)
|
||||||
|
|
||||||
|
|||||||
59
Makefile
59
Makefile
@@ -1,10 +1,12 @@
|
|||||||
CC := gcc
|
CC := gcc
|
||||||
CFLAGS := -Wall -Wextra -std=c99 -O2 -Isrc -Iresources/nostr_core_lib -Iresources/nostr_core_lib/nostr_core -Iresources/nostr_core_lib/cjson
|
CFLAGS := -Wall -Wextra -std=c99 -Os -ffunction-sections -fdata-sections -Isrc -Iresources/nostr_core_lib -Iresources/nostr_core_lib/nostr_core -Iresources/nostr_core_lib/cjson -Iresources/tui_continuous
|
||||||
LDFLAGS := resources/nostr_core_lib/libnostr_core_x64.a -lz -ldl -lpthread -lm -lssl -lcrypto -lcurl -lsecp256k1
|
LDFLAGS := -Wl,--gc-sections resources/nostr_core_lib/libnostr_core_x64.a -lz -ldl -lpthread -lm -lssl -lcrypto -lcurl -lsecp256k1
|
||||||
|
|
||||||
SRC_DIR := src
|
SRC_DIR := src
|
||||||
BUILD_DIR := build
|
BUILD_DIR := build
|
||||||
TEST_DIR := tests
|
TEST_DIR := tests
|
||||||
|
CLIENT_DIR := client
|
||||||
|
EXAMPLES_DIR := examples
|
||||||
|
|
||||||
TARGET_DEV := $(BUILD_DIR)/nsigner
|
TARGET_DEV := $(BUILD_DIR)/nsigner
|
||||||
|
|
||||||
@@ -18,8 +20,11 @@ SOURCES := \
|
|||||||
$(SRC_DIR)/dispatcher.c \
|
$(SRC_DIR)/dispatcher.c \
|
||||||
$(SRC_DIR)/policy.c \
|
$(SRC_DIR)/policy.c \
|
||||||
$(SRC_DIR)/server.c \
|
$(SRC_DIR)/server.c \
|
||||||
|
$(SRC_DIR)/transport_frame.c \
|
||||||
$(SRC_DIR)/key_store.c \
|
$(SRC_DIR)/key_store.c \
|
||||||
$(SRC_DIR)/socket_name.c
|
$(SRC_DIR)/socket_name.c \
|
||||||
|
$(SRC_DIR)/auth_envelope.c \
|
||||||
|
resources/tui_continuous/tui_continuous.c
|
||||||
|
|
||||||
HEADERS :=
|
HEADERS :=
|
||||||
|
|
||||||
@@ -32,13 +37,18 @@ TEST_DISPATCHER_TARGET := $(BUILD_DIR)/test_dispatcher
|
|||||||
TEST_POLICY_TARGET := $(BUILD_DIR)/test_policy
|
TEST_POLICY_TARGET := $(BUILD_DIR)/test_policy
|
||||||
TEST_INTEGRATION_TARGET := $(BUILD_DIR)/test_integration
|
TEST_INTEGRATION_TARGET := $(BUILD_DIR)/test_integration
|
||||||
TEST_SOCKET_NAME_TARGET := $(BUILD_DIR)/test_socket_name
|
TEST_SOCKET_NAME_TARGET := $(BUILD_DIR)/test_socket_name
|
||||||
|
TEST_AUTH_ENVELOPE_TARGET := $(BUILD_DIR)/test_auth_envelope
|
||||||
|
TEST_QREXEC_AUTH_TARGET := $(BUILD_DIR)/test_qrexec_auth
|
||||||
|
TEST_MNEMONIC_INPUT_TARGET := $(BUILD_DIR)/test_mnemonic_input
|
||||||
|
EXAMPLE_GET_PUBLIC_KEY_TARGET := $(BUILD_DIR)/example_get_public_key_client
|
||||||
|
EXAMPLE_SIGN_EVENT_TARGET := $(BUILD_DIR)/example_sign_event_client
|
||||||
|
|
||||||
.PHONY: all lib dev static static-debug static-arm64 test test-integration test-mnemonic test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name clean
|
.PHONY: all lib dev static static-debug static-arm64 test test-integration test-mnemonic test-mnemonic-input test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth examples test-client clean
|
||||||
|
|
||||||
all: dev
|
all: dev
|
||||||
|
|
||||||
lib:
|
lib:
|
||||||
cd resources/nostr_core_lib && ./build.sh
|
cd resources/nostr_core_lib && ./build.sh --nips=1,4,6,19,44
|
||||||
|
|
||||||
dev: lib $(TARGET_DEV)
|
dev: lib $(TARGET_DEV)
|
||||||
|
|
||||||
@@ -58,7 +68,7 @@ static-arm64:
|
|||||||
chmod +x ./build_static.sh
|
chmod +x ./build_static.sh
|
||||||
./build_static.sh --arch arm64
|
./build_static.sh --arch arm64
|
||||||
|
|
||||||
test: lib test-mnemonic test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name
|
test: lib test-mnemonic test-mnemonic-input test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth test-client
|
||||||
|
|
||||||
test-integration: $(TEST_INTEGRATION_TARGET) $(TARGET_DEV)
|
test-integration: $(TEST_INTEGRATION_TARGET) $(TARGET_DEV)
|
||||||
./$(TEST_INTEGRATION_TARGET)
|
./$(TEST_INTEGRATION_TARGET)
|
||||||
@@ -66,6 +76,9 @@ test-integration: $(TEST_INTEGRATION_TARGET) $(TARGET_DEV)
|
|||||||
test-mnemonic: $(TEST_MNEMONIC_TARGET)
|
test-mnemonic: $(TEST_MNEMONIC_TARGET)
|
||||||
./$(TEST_MNEMONIC_TARGET)
|
./$(TEST_MNEMONIC_TARGET)
|
||||||
|
|
||||||
|
test-mnemonic-input: $(TEST_MNEMONIC_INPUT_TARGET)
|
||||||
|
./$(TEST_MNEMONIC_INPUT_TARGET)
|
||||||
|
|
||||||
test-role: $(TEST_ROLE_TARGET)
|
test-role: $(TEST_ROLE_TARGET)
|
||||||
./$(TEST_ROLE_TARGET)
|
./$(TEST_ROLE_TARGET)
|
||||||
|
|
||||||
@@ -84,10 +97,24 @@ test-policy: $(TEST_POLICY_TARGET)
|
|||||||
test-socket-name: $(TEST_SOCKET_NAME_TARGET)
|
test-socket-name: $(TEST_SOCKET_NAME_TARGET)
|
||||||
./$(TEST_SOCKET_NAME_TARGET)
|
./$(TEST_SOCKET_NAME_TARGET)
|
||||||
|
|
||||||
|
test-auth-envelope: $(TEST_AUTH_ENVELOPE_TARGET)
|
||||||
|
./$(TEST_AUTH_ENVELOPE_TARGET)
|
||||||
|
|
||||||
|
test-qrexec-auth: $(TEST_QREXEC_AUTH_TARGET) $(TARGET_DEV)
|
||||||
|
./$(TEST_QREXEC_AUTH_TARGET)
|
||||||
|
|
||||||
|
test-client: examples
|
||||||
|
|
||||||
|
examples: $(EXAMPLE_GET_PUBLIC_KEY_TARGET) $(EXAMPLE_SIGN_EVENT_TARGET)
|
||||||
|
|
||||||
$(TEST_MNEMONIC_TARGET): $(TEST_DIR)/test_mnemonic.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c
|
$(TEST_MNEMONIC_TARGET): $(TEST_DIR)/test_mnemonic.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
$(CC) $(CFLAGS) $(TEST_DIR)/test_mnemonic.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c -o $(TEST_MNEMONIC_TARGET) $(LDFLAGS)
|
$(CC) $(CFLAGS) $(TEST_DIR)/test_mnemonic.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c -o $(TEST_MNEMONIC_TARGET) $(LDFLAGS)
|
||||||
|
|
||||||
|
$(TEST_MNEMONIC_INPUT_TARGET): $(TEST_DIR)/test_mnemonic_input.c
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
$(CC) $(CFLAGS) -I$(CLIENT_DIR) $(TEST_DIR)/test_mnemonic_input.c -o $(TEST_MNEMONIC_INPUT_TARGET) $(LDFLAGS)
|
||||||
|
|
||||||
$(TEST_ROLE_TARGET): $(TEST_DIR)/test_role_table.c $(SRC_DIR)/role_table.c
|
$(TEST_ROLE_TARGET): $(TEST_DIR)/test_role_table.c $(SRC_DIR)/role_table.c
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
$(CC) $(CFLAGS) $(TEST_DIR)/test_role_table.c $(SRC_DIR)/role_table.c -o $(TEST_ROLE_TARGET) $(LDFLAGS)
|
$(CC) $(CFLAGS) $(TEST_DIR)/test_role_table.c $(SRC_DIR)/role_table.c -o $(TEST_ROLE_TARGET) $(LDFLAGS)
|
||||||
@@ -108,13 +135,29 @@ $(TEST_POLICY_TARGET): $(TEST_DIR)/test_policy.c $(SRC_DIR)/policy.c
|
|||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
$(CC) $(CFLAGS) $(TEST_DIR)/test_policy.c $(SRC_DIR)/policy.c -o $(TEST_POLICY_TARGET) $(LDFLAGS)
|
$(CC) $(CFLAGS) $(TEST_DIR)/test_policy.c $(SRC_DIR)/policy.c -o $(TEST_POLICY_TARGET) $(LDFLAGS)
|
||||||
|
|
||||||
$(TEST_INTEGRATION_TARGET): $(TEST_DIR)/test_integration.c
|
$(TEST_INTEGRATION_TARGET): $(TEST_DIR)/test_integration.c $(CLIENT_DIR)/nsigner_client.c $(SRC_DIR)/auth_envelope.c
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
$(CC) $(CFLAGS) $(TEST_DIR)/test_integration.c -o $(TEST_INTEGRATION_TARGET) $(LDFLAGS)
|
$(CC) $(CFLAGS) -I$(CLIENT_DIR) $(TEST_DIR)/test_integration.c $(CLIENT_DIR)/nsigner_client.c $(SRC_DIR)/auth_envelope.c -o $(TEST_INTEGRATION_TARGET) $(LDFLAGS)
|
||||||
|
|
||||||
$(TEST_SOCKET_NAME_TARGET): $(TEST_DIR)/test_socket_name.c $(SRC_DIR)/socket_name.c
|
$(TEST_SOCKET_NAME_TARGET): $(TEST_DIR)/test_socket_name.c $(SRC_DIR)/socket_name.c
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
$(CC) $(CFLAGS) $(TEST_DIR)/test_socket_name.c $(SRC_DIR)/socket_name.c -o $(TEST_SOCKET_NAME_TARGET) $(LDFLAGS)
|
$(CC) $(CFLAGS) $(TEST_DIR)/test_socket_name.c $(SRC_DIR)/socket_name.c -o $(TEST_SOCKET_NAME_TARGET) $(LDFLAGS)
|
||||||
|
|
||||||
|
$(TEST_AUTH_ENVELOPE_TARGET): $(TEST_DIR)/test_auth_envelope.c $(SRC_DIR)/auth_envelope.c
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
$(CC) $(CFLAGS) $(TEST_DIR)/test_auth_envelope.c $(SRC_DIR)/auth_envelope.c -o $(TEST_AUTH_ENVELOPE_TARGET) $(LDFLAGS)
|
||||||
|
|
||||||
|
$(TEST_QREXEC_AUTH_TARGET): $(TEST_DIR)/test_qrexec_auth.c $(SRC_DIR)/auth_envelope.c
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
$(CC) $(CFLAGS) $(TEST_DIR)/test_qrexec_auth.c $(SRC_DIR)/auth_envelope.c -o $(TEST_QREXEC_AUTH_TARGET) $(LDFLAGS)
|
||||||
|
|
||||||
|
$(EXAMPLE_GET_PUBLIC_KEY_TARGET): $(EXAMPLES_DIR)/get_public_key_client.c $(CLIENT_DIR)/nsigner_client.c $(SRC_DIR)/auth_envelope.c
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
$(CC) $(CFLAGS) -I$(CLIENT_DIR) $(EXAMPLES_DIR)/get_public_key_client.c $(CLIENT_DIR)/nsigner_client.c $(SRC_DIR)/auth_envelope.c -o $(EXAMPLE_GET_PUBLIC_KEY_TARGET) $(LDFLAGS)
|
||||||
|
|
||||||
|
$(EXAMPLE_SIGN_EVENT_TARGET): $(EXAMPLES_DIR)/sign_event_client.c $(CLIENT_DIR)/nsigner_client.c $(SRC_DIR)/auth_envelope.c
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
$(CC) $(CFLAGS) -I$(CLIENT_DIR) $(EXAMPLES_DIR)/sign_event_client.c $(CLIENT_DIR)/nsigner_client.c $(SRC_DIR)/auth_envelope.c -o $(EXAMPLE_SIGN_EVENT_TARGET) $(LDFLAGS)
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf $(BUILD_DIR)
|
rm -rf $(BUILD_DIR)
|
||||||
|
|||||||
63
README.md
63
README.md
@@ -74,12 +74,19 @@ When started, `n_signer` immediately enters terminal input mode:
|
|||||||
- On `E`: prompt for mnemonic with terminal echo disabled, then validate.
|
- 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.
|
- 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. Build in-memory role/selector state from the mnemonic.
|
||||||
3. Pick the abstract socket name (random BIP-39 pair, or `--socket-name` override).
|
3. Pick the abstract socket name (random BIP-39 pair, or `--socket-name` / `--name` / `-n` override).
|
||||||
4. Initialize transport endpoints and bind the socket.
|
4. Initialize transport endpoints and bind the socket.
|
||||||
5. Switch to running status display, with the signer name and socket address shown in the banner.
|
5. Switch to running status display, with the signer name and socket address shown in the banner.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
For parent-process launchers, startup can also be non-interactive:
|
||||||
|
|
||||||
|
- `--mnemonic-stdin`: read one mnemonic line from stdin at startup, then continue normally.
|
||||||
|
- `--mnemonic-fd N`: read one mnemonic line from inherited file descriptor `N` at startup.
|
||||||
|
|
||||||
|
These modes avoid putting mnemonic material in argv/environment and are designed for supervised spawners. See [`plans/mnemonic_startup_input.md`](plans/mnemonic_startup_input.md) for the full behavior contract.
|
||||||
|
|
||||||
### 3.2 Running phase (status display + signer)
|
### 3.2 Running phase (status display + signer)
|
||||||
|
|
||||||
After unlock, terminal becomes a live status and control console. Example layout:
|
After unlock, terminal becomes a live status and control console. Example layout:
|
||||||
@@ -108,9 +115,9 @@ Activity (latest first)
|
|||||||
|
|
||||||
Hotkeys
|
Hotkeys
|
||||||
-------
|
-------
|
||||||
a add role
|
a toggle auto-approve(prompt) for this session
|
||||||
r refresh
|
r refresh
|
||||||
l lock session
|
l lock/reunlock session
|
||||||
q quit
|
q quit
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -127,7 +134,7 @@ method: sign_event
|
|||||||
selector: role=ops
|
selector: role=ops
|
||||||
purpose/curve: nostr/secp256k1
|
purpose/curve: nostr/secp256k1
|
||||||
|
|
||||||
[y] allow once [n] deny [a] always allow for this session
|
[y] allow once [n] deny [a] always allow this session
|
||||||
```
|
```
|
||||||
|
|
||||||
No response is emitted to caller until the local user decides.
|
No response is emitted to caller until the local user decides.
|
||||||
@@ -173,6 +180,13 @@ Request shape is JSON-RPC with NIP-46-style methods and optional trailing select
|
|||||||
{ "id": "4", "method": "sign_event", "params": ["<event_json>", { "role_path": "m/84'/0'/0'/0/5", "purpose": "bitcoin", "curve": "secp256k1" }] }
|
{ "id": "4", "method": "sign_event", "params": ["<event_json>", { "role_path": "m/84'/0'/0'/0/5", "purpose": "bitcoin", "curve": "secp256k1" }] }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Implemented signer verbs in this build:
|
||||||
|
|
||||||
|
- `get_public_key`
|
||||||
|
- `sign_event`
|
||||||
|
- `nip04_encrypt` / `nip04_decrypt`
|
||||||
|
- `nip44_encrypt` / `nip44_decrypt`
|
||||||
|
|
||||||
Selector resolution order:
|
Selector resolution order:
|
||||||
|
|
||||||
1. `role`
|
1. `role`
|
||||||
@@ -224,12 +238,15 @@ Properties:
|
|||||||
Naming rules:
|
Naming rules:
|
||||||
|
|
||||||
- Default: random pick at startup, displayed in the TUI banner.
|
- Default: random pick at startup, displayed in the TUI banner.
|
||||||
- Override: `--socket-name <name>` forces a specific name (useful for scripts and tests).
|
- Override: `--socket-name <name>` (alias: `--name <name>` / `-n <name>`) forces a specific name (useful for scripts and tests).
|
||||||
- Collision: if the chosen random name is already bound by another process, `nsigner` retries with a fresh pair up to 8 times before erroring out with a hint to use `--socket-name`.
|
- Collision: if the chosen random name is already bound by another process, `nsigner` retries with a fresh pair up to 8 times before erroring out with a hint to use an explicit name override.
|
||||||
|
|
||||||
Discovery:
|
Discovery:
|
||||||
|
|
||||||
- `nsigner list` enumerates currently bound `nsigner_*` abstract sockets by reading `/proc/net/unix`.
|
- `nsigner list` enumerates currently bound `nsigner_*` abstract sockets by reading `/proc/net/unix`.
|
||||||
|
- `nsigner --listen stdio` runs one framed JSON-RPC request/response over stdin/stdout.
|
||||||
|
- `nsigner --listen qrexec` is the same stdio framing mode, but caller identity can be derived from `QREXEC_REMOTE_DOMAIN` (displayed as `qubes:<source-vm>`).
|
||||||
|
- `nsigner --listen tcp:IPv4:PORT` or `tcp:[IPv6]:PORT` enables TCP listening for non-AF_UNIX clients (for example `tcp:127.0.0.1:8080`, `tcp:[::]:8080`, or `tcp:[fd00::1234]:8080`).
|
||||||
|
|
||||||
### 7.2 ESP32 MCU: USB-CDC serial
|
### 7.2 ESP32 MCU: USB-CDC serial
|
||||||
|
|
||||||
@@ -278,7 +295,25 @@ Program starts in attached foreground mode and prompts for mnemonic. After mnemo
|
|||||||
To force a specific socket name (e.g. for scripted clients):
|
To force a specific socket name (e.g. for scripted clients):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nsigner --socket-name my_test_signer
|
nsigner --name my_test_signer
|
||||||
|
```
|
||||||
|
|
||||||
|
Qubes/qrexec service mode (single framed request over stdin/stdout):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nsigner --listen qrexec
|
||||||
|
```
|
||||||
|
|
||||||
|
Generic stdio transport mode (single framed request over stdin/stdout):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nsigner --listen stdio
|
||||||
|
```
|
||||||
|
|
||||||
|
TCP transport mode (no TUI; serves requests until terminated):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nsigner --listen tcp:[::]:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
### 9.2 Send a request (client mode)
|
### 9.2 Send a request (client mode)
|
||||||
@@ -286,7 +321,7 @@ nsigner --socket-name my_test_signer
|
|||||||
From another terminal, target the signer by its socket name:
|
From another terminal, target the signer by its socket name:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nsigner client --socket-name nsigner_hairy_dog '{"id":"1","method":"get_public_key","params":[]}'
|
nsigner --socket-name nsigner_hairy_dog client '{"id":"1","method":"get_public_key","params":[]}'
|
||||||
```
|
```
|
||||||
|
|
||||||
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 override and the client will use the default discovery rule.
|
||||||
@@ -294,7 +329,7 @@ If only one signer is running you can omit the override and the client will use
|
|||||||
Example signing request:
|
Example signing request:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nsigner client --socket-name nsigner_hairy_dog '{"id":"2","method":"sign_event","params":["<event_json>",{"role":"main"}]}'
|
nsigner -n nsigner_hairy_dog client '{"id":"2","method":"sign_event","params":["<event_json>",{"role":"main"}]}'
|
||||||
```
|
```
|
||||||
|
|
||||||
### 9.3 List running signers
|
### 9.3 List running signers
|
||||||
@@ -327,7 +362,7 @@ $ nsigner
|
|||||||
Terminal B:
|
Terminal B:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
$ nsigner client --socket-name nsigner_hairy_dog '{"id":"2","method":"sign_event","params":["<event_json>",{"role":"main"}]}'
|
$ nsigner --socket-name nsigner_hairy_dog client '{"id":"2","method":"sign_event","params":["<event_json>",{"role":"main"}]}'
|
||||||
{"id":"2","result":"<signed_event_json>"}
|
{"id":"2","result":"<signed_event_json>"}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -338,14 +373,15 @@ Build outputs a single executable artifact.
|
|||||||
- [`build_static.sh`](build_static.sh): musl-static build path
|
- [`build_static.sh`](build_static.sh): musl-static build path
|
||||||
- [`Makefile`](Makefile): local build targets
|
- [`Makefile`](Makefile): local build targets
|
||||||
- [`Dockerfile.alpine-musl`](Dockerfile.alpine-musl): reproducible Alpine musl toolchain
|
- [`Dockerfile.alpine-musl`](Dockerfile.alpine-musl): reproducible Alpine musl toolchain
|
||||||
|
- [`resources/tui_continuous/tui_continuous.h`](resources/tui_continuous/tui_continuous.h) + [`resources/tui_continuous/tui_continuous.c`](resources/tui_continuous/tui_continuous.c): vendored continuous-TUI component (from `~/lt/aesthetics`, API baseline `TUI_CONTINUOUS_VERSION 0.0.9`)
|
||||||
- [`increment_and_push.sh`](increment_and_push.sh): version/tag/release workflow
|
- [`increment_and_push.sh`](increment_and_push.sh): version/tag/release workflow
|
||||||
- [`src/main.h`](src/main.h): version macros
|
- [`src/main.c`](src/main.c): version macros (`NSIGNER_VERSION*`)
|
||||||
|
|
||||||
Local dev build:
|
Local dev build:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make dev
|
make dev
|
||||||
./build/nsigner_dev --version
|
./build/nsigner --version
|
||||||
```
|
```
|
||||||
|
|
||||||
Static build:
|
Static build:
|
||||||
@@ -358,6 +394,9 @@ Static build:
|
|||||||
## 11. Document map
|
## 11. Document map
|
||||||
|
|
||||||
- [`README.md`](README.md): authoritative behavior specification for the foreground single-program model
|
- [`README.md`](README.md): authoritative behavior specification for the foreground single-program model
|
||||||
|
- [`documents/CLIENT_IMPLEMENTATION.md`](documents/CLIENT_IMPLEMENTATION.md): client integration contract and framing behavior
|
||||||
|
- [`documents/QUBES_OS.md`](documents/QUBES_OS.md): Qubes OS deployment/integration checklist for dedicated signer qubes
|
||||||
|
- [`documents/FIPS_DEPLOYMENT.md`](documents/FIPS_DEPLOYMENT.md): Tier-1 FIPS deployment runbook using loopback TCP listener
|
||||||
- [`plans/nsigner.md`](plans/nsigner.md): implementation plan and sequencing
|
- [`plans/nsigner.md`](plans/nsigner.md): implementation plan and sequencing
|
||||||
- [`plans/seed_phrase_uses.md`](plans/seed_phrase_uses.md): seed phrase domain/use catalog and caveats
|
- [`plans/seed_phrase_uses.md`](plans/seed_phrase_uses.md): seed phrase domain/use catalog and caveats
|
||||||
- [`firmware/README.md`](firmware/README.md): firmware-side notes for MCU transport/UI integration
|
- [`firmware/README.md`](firmware/README.md): firmware-side notes for MCU transport/UI integration
|
||||||
|
|||||||
@@ -105,18 +105,36 @@ echo "Platform: $PLATFORM"
|
|||||||
echo "Output: $BUILD_DIR/$OUTPUT_NAME"
|
echo "Output: $BUILD_DIR/$OUTPUT_NAME"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
if [ "$ARCH" != "$HOST_ARCH" ]; then
|
||||||
|
echo "[0/3] Preparing buildx + QEMU for cross-architecture build"
|
||||||
|
if ! docker buildx inspect >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: docker buildx is not available"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
docker run --privileged --rm tonistiigi/binfmt --install all >/dev/null
|
||||||
|
|
||||||
|
if ! docker buildx inspect nsigner-builder >/dev/null 2>&1; then
|
||||||
|
docker buildx create --name nsigner-builder --driver docker-container --use >/dev/null
|
||||||
|
else
|
||||||
|
docker buildx use nsigner-builder >/dev/null
|
||||||
|
fi
|
||||||
|
docker buildx inspect --bootstrap >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
echo "[1/3] Building builder stage from project root context"
|
echo "[1/3] Building builder stage from project root context"
|
||||||
docker build \
|
docker buildx build \
|
||||||
--platform "$PLATFORM" \
|
--platform "$PLATFORM" \
|
||||||
--target builder \
|
--target builder \
|
||||||
-f "$DOCKERFILE" \
|
-f "$DOCKERFILE" \
|
||||||
-t "$IMAGE_TAG" \
|
-t "$IMAGE_TAG" \
|
||||||
|
--load \
|
||||||
"$SCRIPT_DIR"
|
"$SCRIPT_DIR"
|
||||||
|
|
||||||
echo "[2/3] Extracting static binary"
|
echo "[2/3] Extracting static binary"
|
||||||
CONTAINER_NAME="$(docker create "$IMAGE_TAG")"
|
CONTAINER_NAME="$(docker create "$IMAGE_TAG")"
|
||||||
docker cp "$CONTAINER_NAME:/build/nsigner_static" "$BUILD_DIR/$OUTPUT_NAME"
|
docker cp "$CONTAINER_NAME:/build/nsigner_static" "$BUILD_DIR/$OUTPUT_NAME"
|
||||||
chmod +x "$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"
|
echo "[3/3] Verifying static binary"
|
||||||
file "$BUILD_DIR/$OUTPUT_NAME"
|
file "$BUILD_DIR/$OUTPUT_NAME"
|
||||||
|
|||||||
47
client/README.md
Normal file
47
client/README.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# n_signer C Reference Client
|
||||||
|
|
||||||
|
This directory provides a minimal copy/paste-friendly C client for `n_signer`.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `nsigner_client.h` / `nsigner_client.c`:
|
||||||
|
- Unix abstract socket connect helper
|
||||||
|
- length-prefixed frame send/receive
|
||||||
|
- generic request API
|
||||||
|
- helpers for `get_public_key` and `sign_event`
|
||||||
|
- optional TCP auth envelope attachment via `auth_envelope_build_for_request()`
|
||||||
|
|
||||||
|
## API at a glance
|
||||||
|
|
||||||
|
```c
|
||||||
|
nsigner_client_t client;
|
||||||
|
nsigner_client_init(&client);
|
||||||
|
|
||||||
|
nsigner_client_connect_unix(&client, "nsigner", 5000);
|
||||||
|
|
||||||
|
char *resp = NULL;
|
||||||
|
nsigner_client_get_public_key(&client, "1", "", &resp);
|
||||||
|
free(resp);
|
||||||
|
|
||||||
|
nsigner_client_close(&client);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auth envelope usage
|
||||||
|
|
||||||
|
If transport requires auth envelopes (for TCP mode), set a private key once:
|
||||||
|
|
||||||
|
```c
|
||||||
|
unsigned char privkey[32] = { /* caller key */ };
|
||||||
|
nsigner_client_set_auth(&client, privkey, "example-client");
|
||||||
|
```
|
||||||
|
|
||||||
|
Subsequent requests automatically include an `auth` object.
|
||||||
|
|
||||||
|
## Ownership rules
|
||||||
|
|
||||||
|
- Any `out_response_json` returned by the API must be freed by caller using `free()`.
|
||||||
|
- `nsigner_client_close()` only closes the socket; it does not free the client struct itself.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Source files in this directory use SPDX `0BSD` headers for permissive reuse in external projects.
|
||||||
369
client/nsigner_client.c
Normal file
369
client/nsigner_client.c
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: 0BSD
|
||||||
|
*/
|
||||||
|
|
||||||
|
#define _GNU_SOURCE
|
||||||
|
|
||||||
|
#include "nsigner_client.h"
|
||||||
|
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <errno.h>
|
||||||
|
#include <stdarg.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <sys/un.h>
|
||||||
|
#include <time.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <cJSON.h>
|
||||||
|
|
||||||
|
#include "auth_envelope.h"
|
||||||
|
|
||||||
|
#define NSIGNER_CLIENT_MAX_FRAME (65536U)
|
||||||
|
|
||||||
|
static void client_set_error(nsigner_client_t *client, const char *fmt, ...) {
|
||||||
|
va_list ap;
|
||||||
|
|
||||||
|
if (client == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
va_start(ap, fmt);
|
||||||
|
vsnprintf(client->last_error, sizeof(client->last_error), fmt, ap);
|
||||||
|
va_end(ap);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int write_full(int fd, const void *buf, size_t len) {
|
||||||
|
const unsigned char *p = (const unsigned char *)buf;
|
||||||
|
size_t off = 0;
|
||||||
|
|
||||||
|
while (off < len) {
|
||||||
|
ssize_t n = write(fd, p + off, len - off);
|
||||||
|
if (n < 0) {
|
||||||
|
if (errno == EINTR) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
off += (size_t)n;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int read_full(int fd, void *buf, size_t len) {
|
||||||
|
unsigned char *p = (unsigned char *)buf;
|
||||||
|
size_t off = 0;
|
||||||
|
|
||||||
|
while (off < len) {
|
||||||
|
ssize_t n = read(fd, p + off, len - off);
|
||||||
|
if (n == 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (n < 0) {
|
||||||
|
if (errno == EINTR) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
off += (size_t)n;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int send_framed(int fd, const char *payload) {
|
||||||
|
uint32_t len;
|
||||||
|
uint32_t be_len;
|
||||||
|
|
||||||
|
if (payload == NULL) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
len = (uint32_t)strlen(payload);
|
||||||
|
be_len = htonl(len);
|
||||||
|
|
||||||
|
if (write_full(fd, &be_len, sizeof(be_len)) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (write_full(fd, payload, len) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int recv_framed(int fd, char **out_payload) {
|
||||||
|
uint32_t be_len;
|
||||||
|
uint32_t len;
|
||||||
|
char *payload;
|
||||||
|
|
||||||
|
if (out_payload == NULL) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
*out_payload = NULL;
|
||||||
|
|
||||||
|
if (read_full(fd, &be_len, sizeof(be_len)) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
len = ntohl(be_len);
|
||||||
|
if (len == 0 || len > NSIGNER_CLIENT_MAX_FRAME) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = (char *)malloc((size_t)len + 1U);
|
||||||
|
if (payload == NULL) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (read_full(fd, payload, len) != 0) {
|
||||||
|
free(payload);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
payload[len] = '\0';
|
||||||
|
*out_payload = payload;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int sleep_ms(int ms) {
|
||||||
|
struct timespec ts;
|
||||||
|
ts.tv_sec = ms / 1000;
|
||||||
|
ts.tv_nsec = (long)(ms % 1000) * 1000000L;
|
||||||
|
return nanosleep(&ts, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
void nsigner_client_init(nsigner_client_t *client) {
|
||||||
|
if (client == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
memset(client, 0, sizeof(*client));
|
||||||
|
client->fd = -1;
|
||||||
|
client->timeout_ms = 5000;
|
||||||
|
}
|
||||||
|
|
||||||
|
void nsigner_client_close(nsigner_client_t *client) {
|
||||||
|
if (client == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client->fd >= 0) {
|
||||||
|
close(client->fd);
|
||||||
|
client->fd = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int nsigner_client_connect_unix(nsigner_client_t *client,
|
||||||
|
const char *socket_name,
|
||||||
|
int timeout_ms) {
|
||||||
|
struct sockaddr_un addr;
|
||||||
|
socklen_t addr_len;
|
||||||
|
int elapsed = 0;
|
||||||
|
|
||||||
|
if (client == NULL || socket_name == NULL || socket_name[0] == '\0') {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout_ms = (timeout_ms > 0) ? timeout_ms : 5000;
|
||||||
|
nsigner_client_close(client);
|
||||||
|
|
||||||
|
memset(&addr, 0, sizeof(addr));
|
||||||
|
addr.sun_family = AF_UNIX;
|
||||||
|
addr.sun_path[0] = '\0';
|
||||||
|
strncpy(&addr.sun_path[1], socket_name, sizeof(addr.sun_path) - 2);
|
||||||
|
addr.sun_path[sizeof(addr.sun_path) - 1] = '\0';
|
||||||
|
addr_len = (socklen_t)(sizeof(sa_family_t) + 1 + strlen(socket_name));
|
||||||
|
|
||||||
|
while (elapsed < timeout_ms) {
|
||||||
|
int fd = socket(AF_UNIX, SOCK_STREAM, 0);
|
||||||
|
if (fd < 0) {
|
||||||
|
client_set_error(client, "socket failed: %s", strerror(errno));
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connect(fd, (struct sockaddr *)&addr, addr_len) == 0) {
|
||||||
|
client->fd = fd;
|
||||||
|
client->timeout_ms = timeout_ms;
|
||||||
|
client_set_error(client, "ok");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(fd);
|
||||||
|
sleep_ms(100);
|
||||||
|
elapsed += 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
client_set_error(client, "connect timeout: %s", strerror(errno));
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int nsigner_client_set_auth(nsigner_client_t *client,
|
||||||
|
const unsigned char privkey[32],
|
||||||
|
const char *label) {
|
||||||
|
if (client == NULL || privkey == NULL) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
memcpy(client->auth_privkey, privkey, 32);
|
||||||
|
client->auth_enabled = 1;
|
||||||
|
|
||||||
|
if (label == NULL) {
|
||||||
|
label = "";
|
||||||
|
}
|
||||||
|
strncpy(client->auth_label, label, sizeof(client->auth_label) - 1);
|
||||||
|
client->auth_label[sizeof(client->auth_label) - 1] = '\0';
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *nsigner_client_last_error(const nsigner_client_t *client) {
|
||||||
|
if (client == NULL) {
|
||||||
|
return "invalid_client";
|
||||||
|
}
|
||||||
|
return client->last_error;
|
||||||
|
}
|
||||||
|
|
||||||
|
int nsigner_client_request_raw(nsigner_client_t *client,
|
||||||
|
const char *request_json,
|
||||||
|
char **out_response_json) {
|
||||||
|
char *response_json = NULL;
|
||||||
|
|
||||||
|
if (client == NULL || request_json == NULL || out_response_json == NULL) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (client->fd < 0) {
|
||||||
|
client_set_error(client, "not connected");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
*out_response_json = NULL;
|
||||||
|
|
||||||
|
if (send_framed(client->fd, request_json) != 0) {
|
||||||
|
client_set_error(client, "send failed");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recv_framed(client->fd, &response_json) != 0) {
|
||||||
|
client_set_error(client, "receive failed");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
*out_response_json = response_json;
|
||||||
|
client_set_error(client, "ok");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int nsigner_client_request(nsigner_client_t *client,
|
||||||
|
const char *id,
|
||||||
|
const char *method,
|
||||||
|
const cJSON *params,
|
||||||
|
char **out_response_json) {
|
||||||
|
cJSON *root = NULL;
|
||||||
|
cJSON *params_copy = NULL;
|
||||||
|
cJSON *auth = NULL;
|
||||||
|
char *request_json = NULL;
|
||||||
|
int rc = -1;
|
||||||
|
|
||||||
|
if (client == NULL || id == NULL || method == NULL || out_response_json == NULL) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
root = cJSON_CreateObject();
|
||||||
|
if (root == NULL) {
|
||||||
|
client_set_error(client, "out of memory");
|
||||||
|
goto cleanup;
|
||||||
|
}
|
||||||
|
|
||||||
|
cJSON_AddStringToObject(root, "id", id);
|
||||||
|
cJSON_AddStringToObject(root, "method", method);
|
||||||
|
|
||||||
|
if (params != NULL) {
|
||||||
|
params_copy = cJSON_Duplicate((cJSON *)params, 1);
|
||||||
|
} else {
|
||||||
|
params_copy = cJSON_CreateArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params_copy == NULL) {
|
||||||
|
client_set_error(client, "failed to create params");
|
||||||
|
goto cleanup;
|
||||||
|
}
|
||||||
|
cJSON_AddItemToObject(root, "params", params_copy);
|
||||||
|
|
||||||
|
if (client->auth_enabled) {
|
||||||
|
if (auth_envelope_build_for_request(id,
|
||||||
|
method,
|
||||||
|
params_copy,
|
||||||
|
client->auth_privkey,
|
||||||
|
client->auth_label,
|
||||||
|
time(NULL),
|
||||||
|
&auth) != 0) {
|
||||||
|
client_set_error(client, "failed to build auth envelope");
|
||||||
|
goto cleanup;
|
||||||
|
}
|
||||||
|
cJSON_AddItemToObject(root, "auth", auth);
|
||||||
|
auth = NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
request_json = cJSON_PrintUnformatted(root);
|
||||||
|
if (request_json == NULL) {
|
||||||
|
client_set_error(client, "failed to serialize request");
|
||||||
|
goto cleanup;
|
||||||
|
}
|
||||||
|
|
||||||
|
rc = nsigner_client_request_raw(client, request_json, out_response_json);
|
||||||
|
|
||||||
|
cleanup:
|
||||||
|
cJSON_Delete(root);
|
||||||
|
cJSON_Delete(auth);
|
||||||
|
free(request_json);
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
int nsigner_client_get_public_key(nsigner_client_t *client,
|
||||||
|
const char *id,
|
||||||
|
const char *selector,
|
||||||
|
char **out_response_json) {
|
||||||
|
cJSON *params = cJSON_CreateArray();
|
||||||
|
int rc;
|
||||||
|
|
||||||
|
if (params == NULL) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
cJSON_AddItemToArray(params, cJSON_CreateString((selector != NULL) ? selector : ""));
|
||||||
|
rc = nsigner_client_request(client, id, "get_public_key", params, out_response_json);
|
||||||
|
cJSON_Delete(params);
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
int nsigner_client_sign_event(nsigner_client_t *client,
|
||||||
|
const char *id,
|
||||||
|
const char *event_json,
|
||||||
|
const char *role,
|
||||||
|
char **out_response_json) {
|
||||||
|
cJSON *params = cJSON_CreateArray();
|
||||||
|
cJSON *opts = cJSON_CreateObject();
|
||||||
|
int rc;
|
||||||
|
|
||||||
|
if (params == NULL || opts == NULL || event_json == NULL) {
|
||||||
|
cJSON_Delete(params);
|
||||||
|
cJSON_Delete(opts);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
cJSON_AddItemToArray(params, cJSON_CreateString(event_json));
|
||||||
|
if (role != NULL && role[0] != '\0') {
|
||||||
|
cJSON_AddStringToObject(opts, "role", role);
|
||||||
|
}
|
||||||
|
cJSON_AddItemToArray(params, opts);
|
||||||
|
|
||||||
|
rc = nsigner_client_request(client, id, "sign_event", params, out_response_json);
|
||||||
|
cJSON_Delete(params);
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
56
client/nsigner_client.h
Normal file
56
client/nsigner_client.h
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: 0BSD
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef NSIGNER_CLIENT_H
|
||||||
|
#define NSIGNER_CLIENT_H
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include <cJSON.h>
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
int fd;
|
||||||
|
int timeout_ms;
|
||||||
|
unsigned char auth_privkey[32];
|
||||||
|
int auth_enabled;
|
||||||
|
char auth_label[64];
|
||||||
|
char last_error[128];
|
||||||
|
} nsigner_client_t;
|
||||||
|
|
||||||
|
void nsigner_client_init(nsigner_client_t *client);
|
||||||
|
void nsigner_client_close(nsigner_client_t *client);
|
||||||
|
|
||||||
|
int nsigner_client_connect_unix(nsigner_client_t *client,
|
||||||
|
const char *socket_name,
|
||||||
|
int timeout_ms);
|
||||||
|
|
||||||
|
int nsigner_client_set_auth(nsigner_client_t *client,
|
||||||
|
const unsigned char privkey[32],
|
||||||
|
const char *label);
|
||||||
|
|
||||||
|
const char *nsigner_client_last_error(const nsigner_client_t *client);
|
||||||
|
|
||||||
|
int nsigner_client_request_raw(nsigner_client_t *client,
|
||||||
|
const char *request_json,
|
||||||
|
char **out_response_json);
|
||||||
|
|
||||||
|
int nsigner_client_request(nsigner_client_t *client,
|
||||||
|
const char *id,
|
||||||
|
const char *method,
|
||||||
|
const cJSON *params,
|
||||||
|
char **out_response_json);
|
||||||
|
|
||||||
|
int nsigner_client_get_public_key(nsigner_client_t *client,
|
||||||
|
const char *id,
|
||||||
|
const char *selector,
|
||||||
|
char **out_response_json);
|
||||||
|
|
||||||
|
int nsigner_client_sign_event(nsigner_client_t *client,
|
||||||
|
const char *id,
|
||||||
|
const char *event_json,
|
||||||
|
const char *role,
|
||||||
|
char **out_response_json);
|
||||||
|
|
||||||
|
#endif
|
||||||
492
documents/CLIENT_IMPLEMENTATION.md
Normal file
492
documents/CLIENT_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
# CLIENT_IMPLEMENTATION.md
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
This document is the client-integration spec for `nsigner`.
|
||||||
|
|
||||||
|
It is written for agent/tool authors implementing robust request flows against the local signer process.
|
||||||
|
|
||||||
|
Reference implementation in this repository:
|
||||||
|
|
||||||
|
- Reusable C client library: `client/nsigner_client.h` + `client/nsigner_client.c`
|
||||||
|
- Minimal usage examples: `examples/get_public_key_client.c` and `examples/sign_event_client.c`
|
||||||
|
- Auth envelope builder used by the client: `src/auth_envelope.h` (`auth_envelope_build_for_request`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Discovery and socket targeting
|
||||||
|
|
||||||
|
`nsigner` currently supports three transport families:
|
||||||
|
|
||||||
|
- Linux AF_UNIX **abstract namespace** sockets.
|
||||||
|
- Stdio framed mode (`--listen stdio` and `--listen qrexec`) for one request/response exchange.
|
||||||
|
- TCP framed mode (`--listen tcp:IPv4:PORT` or `--listen tcp:[IPv6]:PORT`).
|
||||||
|
|
||||||
|
For AF_UNIX:
|
||||||
|
|
||||||
|
- Socket names are exposed in `/proc/net/unix` with a leading `@`.
|
||||||
|
- Typical runtime names: `@nsigner_hairy_dog`, `@nsigner_brave_canyon`.
|
||||||
|
- Clients pass the socket name **without** `@` to CLI flags (example: `nsigner_hairy_dog`).
|
||||||
|
|
||||||
|
### 2.1 Discovery rules
|
||||||
|
|
||||||
|
Use one of these patterns:
|
||||||
|
|
||||||
|
1. Explicit target (recommended): pass `--socket-name` / `--name` / `-n`.
|
||||||
|
2. Enumerate with `nsigner list` and select one.
|
||||||
|
3. Auto-discovery only when exactly one signer is running.
|
||||||
|
|
||||||
|
### 2.2 Enumerating running signers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nsigner list
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output format (one per line):
|
||||||
|
|
||||||
|
```text
|
||||||
|
@nsigner
|
||||||
|
@nsigner_hairy_dog
|
||||||
|
@nsigner_brave_canyon
|
||||||
|
```
|
||||||
|
|
||||||
|
Clients should accept both `@nsigner` and `@nsigner_*` names.
|
||||||
|
|
||||||
|
### 2.3 Stdio / qrexec mode
|
||||||
|
|
||||||
|
In server mode:
|
||||||
|
|
||||||
|
- `nsigner --listen stdio`: reads exactly one framed request from stdin and writes one framed response to stdout.
|
||||||
|
- `nsigner --listen qrexec`: same behavior, but caller identity may be tagged from `QREXEC_REMOTE_DOMAIN` as `qubes:<vm-name>`.
|
||||||
|
|
||||||
|
This mode is server-side only in the current CLI (the `client` subcommand still targets AF_UNIX).
|
||||||
|
|
||||||
|
### 2.4 qrexec authentication posture (`--auth`)
|
||||||
|
|
||||||
|
qrexec now supports configurable auth-envelope handling:
|
||||||
|
|
||||||
|
- `--listen qrexec --auth off` (default): legacy behavior. Requests are authorized as `qubes:<vm-name>` only.
|
||||||
|
- `--listen qrexec --auth optional`: if the request includes an `auth` field, it is verified using the same auth envelope rules as TCP. On success, caller identity is upgraded to `qubes:<vm-name>+pubkey:<hex>`.
|
||||||
|
- `--listen qrexec --auth required`: every request must carry a valid `auth` envelope, and caller identity is `qubes:<vm-name>+pubkey:<hex>`.
|
||||||
|
|
||||||
|
When `--auth optional` is used, a malformed or invalid `auth` object is rejected with auth-layer errors (`2010..2017`) rather than silently falling back to `qubes:<vm-name>`.
|
||||||
|
|
||||||
|
### 2.5 TCP mode authentication (required)
|
||||||
|
|
||||||
|
For TCP transport, requests MUST include an `auth` object containing a signed Nostr-style event envelope.
|
||||||
|
|
||||||
|
- Missing `auth` returns `{"error":{"code":2014,"message":"auth_envelope_required"}}`.
|
||||||
|
- Signature verification, method/id/body binding, timestamp skew checks, and replay checks are enforced before policy lookup.
|
||||||
|
- On success, caller identity is normalized to `pubkey:<hex>` for policy checks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Transport framing
|
||||||
|
|
||||||
|
Signer requests/responses use a length-prefixed frame format over the socket:
|
||||||
|
|
||||||
|
- Prefix: 4-byte unsigned big-endian length `N`
|
||||||
|
- Payload: `N` bytes UTF-8 JSON text
|
||||||
|
- One JSON-RPC object per frame
|
||||||
|
|
||||||
|
### 3.1 Framing pseudocode
|
||||||
|
|
||||||
|
Write:
|
||||||
|
|
||||||
|
1. Serialize JSON to bytes
|
||||||
|
2. Compute `len(payload)`
|
||||||
|
3. Send `uint32_be(length)` then payload bytes
|
||||||
|
|
||||||
|
Read:
|
||||||
|
|
||||||
|
1. Read exactly 4 bytes
|
||||||
|
2. Parse big-endian payload length
|
||||||
|
3. Read exactly `length` bytes
|
||||||
|
4. Parse JSON
|
||||||
|
|
||||||
|
Do not assume line-delimited JSON.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. JSON-RPC contract
|
||||||
|
|
||||||
|
### 4.1 Request shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "id": "1", "method": "get_public_key", "params": [] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Methods are NIP-46 style verbs.
|
||||||
|
|
||||||
|
### 4.2 Implemented methods
|
||||||
|
|
||||||
|
- `get_public_key`
|
||||||
|
- `sign_event`
|
||||||
|
- `nip04_encrypt`
|
||||||
|
- `nip04_decrypt`
|
||||||
|
- `nip44_encrypt`
|
||||||
|
- `nip44_decrypt`
|
||||||
|
|
||||||
|
### 4.3 Selector options
|
||||||
|
|
||||||
|
The last param may include selector options:
|
||||||
|
|
||||||
|
- `role`
|
||||||
|
- `nostr_index`
|
||||||
|
- `role_path`
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
|
||||||
|
1. `role`
|
||||||
|
2. `nostr_index`
|
||||||
|
3. `role_path`
|
||||||
|
4. default role `main`
|
||||||
|
|
||||||
|
Conflicting selector fields must be rejected as `ambiguous_role_selector`.
|
||||||
|
|
||||||
|
### 4.4 Selector example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"method": "sign_event",
|
||||||
|
"params": [
|
||||||
|
"<event_json>",
|
||||||
|
{ "role": "main" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Error handling contract
|
||||||
|
|
||||||
|
Representative error names clients must handle:
|
||||||
|
|
||||||
|
- `invalid_request`
|
||||||
|
- `method_not_found`
|
||||||
|
- `ambiguous_role_selector`
|
||||||
|
- `unknown_role`
|
||||||
|
- `purpose_mismatch`
|
||||||
|
- `curve_mismatch`
|
||||||
|
- `unauthorized`
|
||||||
|
- `approval_denied`
|
||||||
|
- `internal_error`
|
||||||
|
- `auth_envelope_malformed` (2010)
|
||||||
|
- `auth_body_mismatch` (2011)
|
||||||
|
- `auth_signature_invalid` (2012)
|
||||||
|
- `auth_kind_invalid` (2013)
|
||||||
|
- `auth_envelope_required` (2014)
|
||||||
|
- `auth_envelope_mismatch` (2015)
|
||||||
|
- `auth_envelope_stale` (2016)
|
||||||
|
- `auth_replay_detected` (2017)
|
||||||
|
|
||||||
|
### 5.1 Recovery guidance
|
||||||
|
|
||||||
|
- `invalid_request`: client bug or malformed payload; fix request and retry.
|
||||||
|
- `method_not_found`: version mismatch; feature-detect and downgrade behavior.
|
||||||
|
- `ambiguous_role_selector`: send exactly one selector strategy.
|
||||||
|
- `unknown_role`: selector did not resolve; verify role inventory/config.
|
||||||
|
- `purpose_mismatch` / `curve_mismatch`: selected key is incompatible with method; pick compatible selector.
|
||||||
|
- `unauthorized`: caller identity disallowed by policy; do not blind-retry.
|
||||||
|
- `approval_denied`: user rejected prompt; treat as final unless user initiates retry.
|
||||||
|
- `internal_error`: bounded retry with backoff; surface diagnostics.
|
||||||
|
- `auth_envelope_*` / `auth_*` (2010-2017): fix request signing/auth envelope generation; do not blind-retry unchanged payloads.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Approval semantics
|
||||||
|
|
||||||
|
Approval has two layers:
|
||||||
|
|
||||||
|
1. Policy/identity checks (caller and method authorization)
|
||||||
|
2. User prompt (interactive allow/deny)
|
||||||
|
|
||||||
|
Important behavior:
|
||||||
|
|
||||||
|
- Passing identity checks does **not** bypass prompts.
|
||||||
|
- Non-interactive/no-TTY contexts can resolve to deny by policy/test configuration.
|
||||||
|
- Clients must treat `approval_denied` as a normal, expected outcome.
|
||||||
|
|
||||||
|
### 6.1 UX recommendation
|
||||||
|
|
||||||
|
If a signing call is denied, return control to the user and let them explicitly retry.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Multi-instance, concurrency, and timeouts
|
||||||
|
|
||||||
|
### 7.1 Multi-instance safety
|
||||||
|
|
||||||
|
- Multiple signers can coexist using different socket names.
|
||||||
|
- Always pin requests to a selected signer name once chosen.
|
||||||
|
- Avoid “discover per request” after initial bind in long-running clients.
|
||||||
|
|
||||||
|
### 7.2 Connection strategy
|
||||||
|
|
||||||
|
- Keep one connection per in-flight request path, or serialize requests if your client runtime is simple.
|
||||||
|
- Validate response `id` correlation before completing promise/future.
|
||||||
|
|
||||||
|
### 7.3 Timeout guidance
|
||||||
|
|
||||||
|
Use separate timeouts:
|
||||||
|
|
||||||
|
- connect timeout (short)
|
||||||
|
- write timeout (short)
|
||||||
|
- read timeout (longer, because human approval may be required)
|
||||||
|
|
||||||
|
For prompt-requiring methods, use a read timeout that accounts for user interaction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Security expectations for clients
|
||||||
|
|
||||||
|
- Treat socket access as sensitive local capability.
|
||||||
|
- Do not log plaintext secrets, private keys, or full decrypted payloads.
|
||||||
|
- Redact request params for encrypt/decrypt methods in normal logs.
|
||||||
|
- Validate method-level expectations before sending (selector + purpose compatibility).
|
||||||
|
- Use least-privilege execution context for any process that can reach the signer socket.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Reference code snippets
|
||||||
|
|
||||||
|
## 9.1 C (frame write/read skeleton)
|
||||||
|
|
||||||
|
```c
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
static int write_all(int fd, const void *buf, size_t len) {
|
||||||
|
const unsigned char *p = (const unsigned char *)buf;
|
||||||
|
while (len > 0) {
|
||||||
|
ssize_t n = write(fd, p, len);
|
||||||
|
if (n <= 0) return -1;
|
||||||
|
p += (size_t)n;
|
||||||
|
len -= (size_t)n;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int read_all(int fd, void *buf, size_t len) {
|
||||||
|
unsigned char *p = (unsigned char *)buf;
|
||||||
|
while (len > 0) {
|
||||||
|
ssize_t n = read(fd, p, len);
|
||||||
|
if (n <= 0) return -1;
|
||||||
|
p += (size_t)n;
|
||||||
|
len -= (size_t)n;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int send_json_frame(int fd, const char *json) {
|
||||||
|
uint32_t n = (uint32_t)strlen(json);
|
||||||
|
uint32_t be = htonl(n);
|
||||||
|
if (write_all(fd, &be, 4) != 0) return -1;
|
||||||
|
if (write_all(fd, json, n) != 0) return -1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9.2 Python (Unix abstract socket + frame)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
|
||||||
|
def send_rpc(socket_name: str, obj: dict) -> dict:
|
||||||
|
payload = json.dumps(obj, separators=(",", ":")).encode("utf-8")
|
||||||
|
frame = struct.pack(">I", len(payload)) + payload
|
||||||
|
|
||||||
|
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
try:
|
||||||
|
s.connect("\0" + socket_name) # abstract namespace
|
||||||
|
s.sendall(frame)
|
||||||
|
|
||||||
|
hdr = recv_exact(s, 4)
|
||||||
|
ln = struct.unpack(">I", hdr)[0]
|
||||||
|
body = recv_exact(s, ln)
|
||||||
|
return json.loads(body.decode("utf-8"))
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
def recv_exact(s: socket.socket, n: int) -> bytes:
|
||||||
|
out = bytearray()
|
||||||
|
while len(out) < n:
|
||||||
|
chunk = s.recv(n - len(out))
|
||||||
|
if not chunk:
|
||||||
|
raise ConnectionError("unexpected EOF")
|
||||||
|
out.extend(chunk)
|
||||||
|
return bytes(out)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9.3 TypeScript (Node.js net + frame)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import net from "node:net";
|
||||||
|
|
||||||
|
export async function sendRpc(socketName: string, request: unknown): Promise<any> {
|
||||||
|
const payload = Buffer.from(JSON.stringify(request), "utf8");
|
||||||
|
const header = Buffer.alloc(4);
|
||||||
|
header.writeUInt32BE(payload.length, 0);
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const socket = net.createConnection({ path: `\u0000${socketName}` });
|
||||||
|
|
||||||
|
let chunks: Buffer[] = [];
|
||||||
|
let needed = 4;
|
||||||
|
let mode: "header" | "body" = "header";
|
||||||
|
|
||||||
|
socket.on("connect", () => {
|
||||||
|
socket.write(Buffer.concat([header, payload]));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("data", (data) => {
|
||||||
|
chunks.push(data);
|
||||||
|
let buf = Buffer.concat(chunks);
|
||||||
|
|
||||||
|
while (buf.length >= needed) {
|
||||||
|
const part = buf.subarray(0, needed);
|
||||||
|
buf = buf.subarray(needed);
|
||||||
|
|
||||||
|
if (mode === "header") {
|
||||||
|
needed = part.readUInt32BE(0);
|
||||||
|
mode = "body";
|
||||||
|
} else {
|
||||||
|
socket.end();
|
||||||
|
resolve(JSON.parse(part.toString("utf8")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks = [buf];
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("error", reject);
|
||||||
|
socket.on("end", () => {
|
||||||
|
// no-op; resolution occurs when full body is parsed
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. End-to-end transcripts
|
||||||
|
|
||||||
|
## 10.1 Happy path: get public key
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "id": "1", "method": "get_public_key", "params": [] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "id": "1", "result": "<hex_pubkey>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10.2 Happy path: sign event with explicit role
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"method": "sign_event",
|
||||||
|
"params": ["<event_json>", { "role": "main" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "id": "2", "result": "<signed_event_json>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10.3 Error path: unknown role
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "3",
|
||||||
|
"method": "sign_event",
|
||||||
|
"params": ["<event_json>", { "role": "does_not_exist" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "id": "3", "error": { "code": 1002, "message": "unknown_role" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10.4 Error path: ambiguous selector
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "4",
|
||||||
|
"method": "sign_event",
|
||||||
|
"params": [
|
||||||
|
"<event_json>",
|
||||||
|
{ "role": "main", "nostr_index": 0 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "id": "4", "error": { "message": "ambiguous_role_selector" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10.5 Encrypt/decrypt round trip (NIP-44)
|
||||||
|
|
||||||
|
Encrypt request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "5",
|
||||||
|
"method": "nip44_encrypt",
|
||||||
|
"params": ["<peer_pubkey>", "hello", { "role": "main" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Encrypt response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "id": "5", "result": "<nip44_ciphertext>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Decrypt request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "6",
|
||||||
|
"method": "nip44_decrypt",
|
||||||
|
"params": ["<peer_pubkey>", "<nip44_ciphertext>", { "role": "main" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Decrypt response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "id": "6", "result": "hello" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Compatibility notes
|
||||||
|
|
||||||
|
- If you are writing an autonomous agent client, pin to explicit socket name and explicit role selector.
|
||||||
|
- Keep method support feature-detected (`method_not_found` fallback).
|
||||||
|
- Treat approval as asynchronous human gating even for local calls.
|
||||||
|
- Track and surface signer name, request id, and method for auditability.
|
||||||
177
documents/FIPS_DEPLOYMENT.md
Normal file
177
documents/FIPS_DEPLOYMENT.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
# FIPS_DEPLOYMENT.md
|
||||||
|
|
||||||
|
## 1. Scope
|
||||||
|
|
||||||
|
This runbook covers a practical Tier-1 deployment of `nsigner` over a TCP listener, with connectivity provided by FIPS as the network substrate.
|
||||||
|
|
||||||
|
Tier-1 objective:
|
||||||
|
|
||||||
|
- Keep `nsigner` transport simple (`--listen tcp:IPv4:PORT` or `--listen tcp:[IPv6]:PORT`).
|
||||||
|
- Use FIPS to carry traffic between peers.
|
||||||
|
- Do not add FIPS runtime dependencies into `nsigner`.
|
||||||
|
|
||||||
|
Out of scope in this document:
|
||||||
|
|
||||||
|
- Mandatory transport-level TLS/authentication hardening (still planned for a later phase).
|
||||||
|
- Automatic caller->npub enrichment from FIPS session metadata.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architecture
|
||||||
|
|
||||||
|
Two cooperating layers:
|
||||||
|
|
||||||
|
1. **Signer process layer** (`nsigner`)
|
||||||
|
- Listens on operator-selected TCP endpoint (IPv4 or IPv6).
|
||||||
|
- Uses existing 4-byte big-endian framed JSON-RPC protocol.
|
||||||
|
- Keeps existing policy/prompt behavior.
|
||||||
|
|
||||||
|
2. **Network substrate layer** (FIPS)
|
||||||
|
- Establishes peer connectivity between nodes/qubes.
|
||||||
|
- Carries application traffic to the configured signer TCP endpoint.
|
||||||
|
|
||||||
|
Conceptually:
|
||||||
|
|
||||||
|
`client app -> local FIPS endpoint -> FIPS mesh -> remote FIPS endpoint -> signer TCP endpoint -> nsigner`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Prerequisites
|
||||||
|
|
||||||
|
On signer host/qube:
|
||||||
|
|
||||||
|
- Built `nsigner` binary.
|
||||||
|
- FIPS installed and running.
|
||||||
|
- Local firewall policy that keeps signer listener local-only.
|
||||||
|
|
||||||
|
On caller host/qube:
|
||||||
|
|
||||||
|
- FIPS installed and peered with signer host/qube.
|
||||||
|
- A client implementation that speaks `nsigner` framed JSON-RPC (see `documents/CLIENT_IMPLEMENTATION.md`).
|
||||||
|
|
||||||
|
Operational assumptions:
|
||||||
|
|
||||||
|
- Operator controls both endpoints.
|
||||||
|
- Manual verification of peer identity is performed in FIPS tooling before enabling signer traffic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Start signer in Tier-1 TCP mode
|
||||||
|
|
||||||
|
Run `nsigner` in TCP listen mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build/nsigner --listen tcp:[::]:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Or bind to a specific FIPS ULA address:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build/nsigner --listen tcp:[fd00::1234]:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior notes:
|
||||||
|
|
||||||
|
- Bind target is operator-controlled; pick the narrowest reachable address that satisfies your topology.
|
||||||
|
- No TUI hotkey loop is required in TCP mode; process serves requests until terminated.
|
||||||
|
- Caller identity is shown as a TCP endpoint descriptor in activity/prompt context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. FIPS substrate wiring pattern
|
||||||
|
|
||||||
|
Because FIPS deployment topologies vary, use this generic pattern:
|
||||||
|
|
||||||
|
1. Bind `nsigner` on a deliberate signer endpoint (`[::]:PORT` for broad reach, or specific `fd..` for tighter scope).
|
||||||
|
2. Configure FIPS service/forwarding so remote authenticated peer traffic is delivered to that signer endpoint.
|
||||||
|
3. On caller side, direct client traffic to the local FIPS ingress endpoint for that remote service.
|
||||||
|
|
||||||
|
Validation checklist:
|
||||||
|
|
||||||
|
- FIPS session is established between caller and signer nodes.
|
||||||
|
- Transport path from caller -> configured signer endpoint succeeds.
|
||||||
|
- `nsigner` receives framed request and returns framed response.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Minimal validation flow
|
||||||
|
|
||||||
|
### 6.1 Liveness check
|
||||||
|
|
||||||
|
From caller side, send a framed `get_public_key` request through the FIPS-backed endpoint.
|
||||||
|
|
||||||
|
Request JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"id":"1","method":"get_public_key","params":[]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"id":"1","result":"<hex_pubkey>"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Signing check
|
||||||
|
|
||||||
|
Send `sign_event` with explicit role selector:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"method": "sign_event",
|
||||||
|
"params": ["<event_json>", {"role":"main"}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected result: signed event JSON in `result`.
|
||||||
|
|
||||||
|
### 6.3 Negative check (policy)
|
||||||
|
|
||||||
|
Trigger a request path that requires prompt/denial and confirm client handles policy denial as a normal result path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Security guardrails
|
||||||
|
|
||||||
|
- Prefer binding to a specific FIPS IPv6 ULA (`fd..`) rather than wildcard (`[::]`) when possible.
|
||||||
|
- Do not expose signer port directly on LAN/WAN.
|
||||||
|
- Keep FIPS peer allowlist tight; avoid broad trust domains.
|
||||||
|
- Treat FIPS connectivity as transport, not authorization bypass.
|
||||||
|
- Preserve interactive approval where required by policy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Troubleshooting
|
||||||
|
|
||||||
|
### 8.1 `invalid tcp listen target`
|
||||||
|
|
||||||
|
Cause:
|
||||||
|
- `--listen` argument does not match `tcp:HOST:PORT` or `tcp:[IPv6]:PORT`.
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
- Use valid numeric port and valid IPv4/IPv6 literal host.
|
||||||
|
|
||||||
|
### 8.2 Framing parse failures (`parse_error`)
|
||||||
|
|
||||||
|
Cause:
|
||||||
|
- Client sent line-delimited/raw JSON instead of framed JSON.
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
- Send 4-byte big-endian length prefix followed by exact UTF-8 JSON payload bytes.
|
||||||
|
|
||||||
|
### 8.3 FIPS path up, signer path down
|
||||||
|
|
||||||
|
Cause:
|
||||||
|
- FIPS session exists but forwarding/service mapping to signer loopback endpoint is missing.
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
- Verify substrate service routing config and local endpoint mapping.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Next hardening steps (post Tier-1)
|
||||||
|
|
||||||
|
- Add automated two-node validation script for operator smoke checks.
|
||||||
|
- Add optional identity enrichment from FIPS session metadata (`peer_npub`).
|
||||||
|
- Introduce remote TCP mode only with mandatory TLS + authenticated caller key flow.
|
||||||
56
documents/N_OS_TR_AGENT_CHANGES.md
Normal file
56
documents/N_OS_TR_AGENT_CHANGES.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# n_OS_tr Agent Guide: Handling n_signer v0.0.16 Changes
|
||||||
|
|
||||||
|
This is a short operational guide for the n_OS_tr agent after the approval-model update in `n_signer`.
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
- `n_signer` is now **deny-by-default**.
|
||||||
|
- First-time caller/index combinations require approval unless preapproved.
|
||||||
|
- `nostr_index` values can be auto-derived on approval.
|
||||||
|
- Policy decisions are visible in activity labels: `:preapprove`, `:session-grant`, `:prompt`, `:no-match`.
|
||||||
|
|
||||||
|
## What the agent should do
|
||||||
|
|
||||||
|
1. **Always send explicit selector options** in the last `params` item:
|
||||||
|
- Prefer `{"nostr_index": N}` for service identities.
|
||||||
|
2. **Expect `policy_denied` on first contact** if caller/index is not preapproved.
|
||||||
|
3. **Do not retry blindly** on `policy_denied`.
|
||||||
|
- Surface a clear status: "approval needed".
|
||||||
|
4. **Treat `unknown_role` as actionable**:
|
||||||
|
- If using `nostr_index`, request again only after human approval or valid preapprove config.
|
||||||
|
5. **Use stable caller identity** (service name / VM identity) so preapprove rules match reliably.
|
||||||
|
|
||||||
|
## Required startup pattern for n_OS_tr services
|
||||||
|
|
||||||
|
Launch `n_signer` with explicit `--preapprove` entries for each boot service that must sign without prompts.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
ExecStart=/usr/bin/n_signer \
|
||||||
|
--preapprove caller=qubes:nostr-relay,role=main \
|
||||||
|
--preapprove caller=qubes:dm-handler,nostr_index=1 \
|
||||||
|
--preapprove caller=qubes:contacts,nostr_index=2 \
|
||||||
|
--preapprove caller=qubes:zaps,nostr_index=3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent error-handling policy (recommended)
|
||||||
|
|
||||||
|
- `policy_denied` → stop, mark as "awaiting approval/preapprove".
|
||||||
|
- `unknown_role` with `nostr_index` → stop, mark as "identity not yet approved/created".
|
||||||
|
- `purpose_mismatch` / `curve_mismatch` → configuration bug; fail hard and alert.
|
||||||
|
- `ambiguous_role_selector` → client bug; send exactly one selector field.
|
||||||
|
|
||||||
|
## Observability checks
|
||||||
|
|
||||||
|
When troubleshooting, inspect n_signer activity lines:
|
||||||
|
|
||||||
|
- `ALLOWED:preapprove` → expected for boot services.
|
||||||
|
- `ALLOWED:session-grant` → interactive approval happened earlier this session.
|
||||||
|
- `ALLOWED:prompt` → just approved now.
|
||||||
|
- `DENIED:no-match` → no policy entry matched caller/index.
|
||||||
|
|
||||||
|
## Operational rule of thumb
|
||||||
|
|
||||||
|
For n_OS_tr system services, rely on `--preapprove` at startup.
|
||||||
|
For ad-hoc/manual clients, rely on interactive prompt approvals.
|
||||||
196
documents/QUBES_OS.md
Normal file
196
documents/QUBES_OS.md
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
# QUBES_OS.md
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Run `n_signer` inside a dedicated Qubes OS qube (for example `vault`-like behavior), and let caller qubes access signing via qrexec with explicit policy control.
|
||||||
|
|
||||||
|
This doc outlines what must be implemented/packaged for a reliable Qubes deployment path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Current status (where we are now)
|
||||||
|
|
||||||
|
Implemented in current codebase:
|
||||||
|
|
||||||
|
- `nsigner` supports `--listen qrexec` and `--listen stdio`.
|
||||||
|
- Framing is transport-agnostic and shared via length-prefixed JSON (`4-byte big-endian length + payload`).
|
||||||
|
- In qrexec/stdio mode, server handles one framed request-response exchange.
|
||||||
|
- Caller identity extraction supports `QREXEC_REMOTE_DOMAIN`, surfaced as `qubes:<source-vm>` when available.
|
||||||
|
|
||||||
|
Still missing for complete Qubes integration:
|
||||||
|
|
||||||
|
- qrexec service file + wrapper script artifacts.
|
||||||
|
- dom0 qrexec policy artifacts with sane defaults.
|
||||||
|
- install/uninstall guidance and verification flow for real Qubes deployment.
|
||||||
|
- packaging path (`packaging/qubes/`) and docs wired into README map.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architecture in Qubes
|
||||||
|
|
||||||
|
### 3.1 Components
|
||||||
|
|
||||||
|
- **Signer qube** (target): runs `nsigner` service entrypoint.
|
||||||
|
- **Caller qube(s)**: apps/tools invoking qrexec service.
|
||||||
|
- **dom0 policy**: controls which caller qubes may invoke signer service.
|
||||||
|
|
||||||
|
### 3.2 Request path
|
||||||
|
|
||||||
|
1. Caller qube invokes qrexec service (e.g. `qubes.NsignerRpc`).
|
||||||
|
2. qrexec starts service command inside signer qube.
|
||||||
|
3. Service command runs `nsigner --listen qrexec`.
|
||||||
|
4. Caller sends framed JSON-RPC request over qrexec stdio channel.
|
||||||
|
5. `nsigner` returns framed JSON-RPC response.
|
||||||
|
|
||||||
|
### 3.3 Trust and identity
|
||||||
|
|
||||||
|
- Source qube identity comes from `QREXEC_REMOTE_DOMAIN`.
|
||||||
|
- `n_signer` maps caller as `qubes:<source-vm>` where available.
|
||||||
|
- qrexec policy in dom0 remains first enforcement boundary.
|
||||||
|
- `n_signer` policy/approval remains second boundary.
|
||||||
|
|
||||||
|
### 3.4 Optional per-program identity inside one qube (`--auth optional`)
|
||||||
|
|
||||||
|
qrexec now supports an optional auth-envelope layer:
|
||||||
|
|
||||||
|
- `nsigner --listen qrexec --auth off` (default): identity remains `qubes:<source-vm>`.
|
||||||
|
- `nsigner --listen qrexec --auth optional`: if a caller includes a valid auth envelope, identity becomes `qubes:<source-vm>+pubkey:<hex>`.
|
||||||
|
- `nsigner --listen qrexec --auth required`: all requests must carry valid envelopes and identity is always `qubes:<source-vm>+pubkey:<hex>`.
|
||||||
|
|
||||||
|
This enables per-program approvals inside a single caller qube while preserving existing behavior for legacy callers that do not emit auth envelopes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Required implementation tasks
|
||||||
|
|
||||||
|
## 4.1 Service entrypoint artifacts ✅ Implemented
|
||||||
|
|
||||||
|
Implemented repo artifacts:
|
||||||
|
|
||||||
|
- `packaging/qubes/rpc/qubes.NsignerRpc`
|
||||||
|
- `packaging/qubes/install-service.sh`
|
||||||
|
|
||||||
|
`qubes.NsignerRpc` runs:
|
||||||
|
|
||||||
|
- `exec /usr/local/bin/nsigner --listen qrexec`
|
||||||
|
|
||||||
|
Install inside the signer qube:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo sh packaging/qubes/install-service.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This installs the qrexec service to `/etc/qubes-rpc/qubes.NsignerRpc` with executable permissions.
|
||||||
|
|
||||||
|
## 4.2 dom0 policy artifacts ✅ Implemented
|
||||||
|
|
||||||
|
Implemented repo artifacts:
|
||||||
|
|
||||||
|
- `packaging/qubes/policy.d/40-nsigner.policy`
|
||||||
|
- `packaging/qubes/install-policy.sh`
|
||||||
|
|
||||||
|
Policy defaults now use explicit `ask` plus deny catch-all:
|
||||||
|
|
||||||
|
- `qubes.NsignerRpc * @anyvm @tag:nsigner-signer ask default_target=nsigner-vault`
|
||||||
|
- `qubes.NsignerRpc * @anyvm @anyvm deny`
|
||||||
|
|
||||||
|
Install in dom0:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo sh packaging/qubes/install-policy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This installs `/etc/qubes/policy.d/40-nsigner.policy` and prints signer-tag guidance.
|
||||||
|
|
||||||
|
## 4.3 Policy model inside n_signer for qubes callers ✅ Implemented
|
||||||
|
|
||||||
|
Current code reads caller as `qubes:<vm>` and qrexec default behavior is hardened.
|
||||||
|
|
||||||
|
In qrexec mode, default prompt behavior is now:
|
||||||
|
|
||||||
|
- `PROMPT_EVERY_REQUEST`
|
||||||
|
|
||||||
|
This replaces the previous permissive `PROMPT_NEVER` temporary setting.
|
||||||
|
|
||||||
|
If qrexec auth envelopes are enabled (`--auth optional` or `--auth required`), approved callers can also be recorded as composite identities:
|
||||||
|
|
||||||
|
- `qubes:<vm>+pubkey:<hex>`
|
||||||
|
|
||||||
|
This gives independent approval rows for distinct programs running in the same source qube.
|
||||||
|
|
||||||
|
## 4.4 Client helper examples ✅ Implemented
|
||||||
|
|
||||||
|
Added:
|
||||||
|
|
||||||
|
- `documents/qubes_client_examples.md`
|
||||||
|
|
||||||
|
Includes:
|
||||||
|
|
||||||
|
- shell helper example invoking `qrexec-client-vm` with framed request/response handling
|
||||||
|
- Python helper example implementing frame encode/decode over qrexec stdio channel
|
||||||
|
- reference to `documents/CLIENT_IMPLEMENTATION.md` for full protocol details
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Operational runbook
|
||||||
|
|
||||||
|
## 5.1 Setup signer qube
|
||||||
|
|
||||||
|
- install `nsigner` binary at `/usr/local/bin/nsigner`
|
||||||
|
- run `sudo sh packaging/qubes/install-service.sh`
|
||||||
|
- verify `/etc/qubes-rpc/qubes.NsignerRpc` exists and is executable
|
||||||
|
|
||||||
|
## 5.2 Setup dom0 policy
|
||||||
|
|
||||||
|
- run `sudo sh packaging/qubes/install-policy.sh`
|
||||||
|
- tag signer qube (example): `qvm-tags nsigner-vault add nsigner-signer`
|
||||||
|
- reload qrexec policy per Qubes procedure/version
|
||||||
|
|
||||||
|
## 5.3 Verification
|
||||||
|
|
||||||
|
- from caller qube, invoke test request (`get_public_key`)
|
||||||
|
- confirm signer qube receives request
|
||||||
|
- confirm activity log displays `qubes:<source-vm>` caller prefix
|
||||||
|
- with `--auth optional`, confirm envelope-enabled clients show `qubes:<source-vm>+pubkey:<hex>`
|
||||||
|
- validate deny behavior from unauthorized qube
|
||||||
|
|
||||||
|
## 5.4 Failure checks
|
||||||
|
|
||||||
|
- malformed frame -> parse error response
|
||||||
|
- missing policy -> deny path
|
||||||
|
- missing `QREXEC_REMOTE_DOMAIN` -> fallback identity path
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Security requirements
|
||||||
|
|
||||||
|
- Never run signer service in disposable qube if mnemonic persistence is expected.
|
||||||
|
- Prefer dedicated minimal template for signer qube.
|
||||||
|
- Keep qrexec policy narrowly scoped (explicit source + target).
|
||||||
|
- Require user approval for sensitive methods unless explicitly intended otherwise.
|
||||||
|
- Log caller identity and method (without secret payload logging).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Documentation tasks
|
||||||
|
|
||||||
|
Update these after packaging lands:
|
||||||
|
|
||||||
|
- `README.md`
|
||||||
|
- add Qubes deployment subsection under transport/usage
|
||||||
|
- add `documents/QUBES_OS.md` and moved `documents/CLIENT_IMPLEMENTATION.md` in document map
|
||||||
|
- `plans/nsigner.md`
|
||||||
|
- mark T1 done with packaging status clearly separated
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Definition of done (Qubes)
|
||||||
|
|
||||||
|
Qubes integration is considered complete when:
|
||||||
|
|
||||||
|
1. qrexec service artifact exists and is installable.
|
||||||
|
2. dom0 policy artifact exists with secure default pattern.
|
||||||
|
3. End-to-end call from allowed caller qube succeeds.
|
||||||
|
4. Call from unauthorized qube is denied.
|
||||||
|
5. Caller displayed as `qubes:<vm>` in activity.
|
||||||
|
6. README + docs include full setup and troubleshooting.
|
||||||
520
documents/SECURITY.md
Normal file
520
documents/SECURITY.md
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
# SECURITY.md
|
||||||
|
|
||||||
|
This document explains how `n_signer` enforces security, written so someone new can understand the model end-to-end without reading the source.
|
||||||
|
|
||||||
|
If you only read one section, read [§3 The three concepts](#3-the-three-concepts-identity-index-approval) and [§4 The deny-by-default rule](#4-the-deny-by-default-rule).
|
||||||
|
|
||||||
|
> This document describes the security model as implemented in the current release. Design rationale is in [`plans/deny_by_default_approvals.md`](../plans/deny_by_default_approvals.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What `n_signer` is, in security terms
|
||||||
|
|
||||||
|
`n_signer` is a **single foreground program** that holds a BIP-39 mnemonic in locked memory and signs requests on behalf of local clients. It is not a daemon, not a service, and not a key-management database.
|
||||||
|
|
||||||
|
The security posture is intentionally minimalist:
|
||||||
|
|
||||||
|
- **One process. One terminal. One human.** The terminal is the trust anchor and the only out-of-band approval surface.
|
||||||
|
- **No persistence.** Nothing about the running session is written to disk: no config, no logs, no PID files, no socket pathnames, no state recovery.
|
||||||
|
- **Crash equals total wipe.** All sensitive state — mnemonic, derived keys, the policy table, activity buffer — exists only in process RAM (`mlock`'d where applicable) and is unrecoverable after the process ends.
|
||||||
|
- **Always-attended operation.** Every first request from a previously unknown caller requires an explicit human approval at the terminal, unless the caller was pre-approved at startup by the OS distribution. Human presence is part of the threat model, not an inconvenience.
|
||||||
|
|
||||||
|
Authoritative behavior reference: [`README.md`](../README.md). Implementation roadmap: [`plans/nsigner.md`](../plans/nsigner.md). Approval model plan: [`plans/deny_by_default_approvals.md`](../plans/deny_by_default_approvals.md). Wire contract for clients: [`documents/CLIENT_IMPLEMENTATION.md`](CLIENT_IMPLEMENTATION.md).
|
||||||
|
|
||||||
|
Interactive terminal presentation now follows the continuous-TUI conventions from `~/lt/aesthetics/TUI.md`, implemented via vendored [`resources/tui_continuous/tui_continuous.h`](../resources/tui_continuous/tui_continuous.h) / [`resources/tui_continuous/tui_continuous.c`](../resources/tui_continuous/tui_continuous.c) (baseline `TUI_CONTINUOUS_VERSION 0.0.9`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Threat model
|
||||||
|
|
||||||
|
### 2.1 What `n_signer` is designed to defend against
|
||||||
|
|
||||||
|
- **Untrusted local clients** asking the signer to perform operations they should not be allowed to perform.
|
||||||
|
- **Drive-by signing** — an unattended program quietly producing signatures the user did not consent to.
|
||||||
|
- **Accidental misuse** — a buggy client connecting to the wrong signer instance, or asking for an identity it should not be using.
|
||||||
|
- **Post-mortem key recovery** — someone obtaining the disk after a crash, kill, or shutdown and recovering signing keys.
|
||||||
|
|
||||||
|
### 2.2 What `n_signer` does **not** defend against
|
||||||
|
|
||||||
|
- A compromised kernel or hypervisor (`mlock` cannot save you).
|
||||||
|
- Physical access while the program is running and the terminal is unlocked.
|
||||||
|
- A malicious user who knows the mnemonic — `n_signer` is a runtime gatekeeper, not a vault.
|
||||||
|
- Side channels (timing, power, microarchitectural) that target the underlying crypto libraries.
|
||||||
|
- Network adversaries when transports without authentication are explicitly enabled (e.g. `tcp_local`).
|
||||||
|
- An OS distribution that ships a malicious `--preapprove` list in its system unit files. The OS is in scope of trust if it pre-approves anything; see [§7](#7-pre-approvals-the-os-distribution-pathway).
|
||||||
|
|
||||||
|
### 2.3 Trust anchors
|
||||||
|
|
||||||
|
| Anchor | Why it is trusted |
|
||||||
|
|---|---|
|
||||||
|
| The terminal `n_signer` is attached to | The user types the mnemonic into it and answers approval prompts on it. If the terminal is compromised, the session is compromised. |
|
||||||
|
| The kernel and `mlock` / `getrandom` syscalls | Used for in-memory protection and entropy. |
|
||||||
|
| The static binary itself | Built reproducibly via [`build_static.sh`](../build_static.sh) and shipped as one musl-static artifact. |
|
||||||
|
| The launcher that started `n_signer` | Whoever ran the process chose the `--preapprove` flags. In interactive use that's the human; in `n_OS_tr` boot it's the OS init system. The launcher's integrity is part of the trust chain. |
|
||||||
|
| Parent process (when using `--mnemonic-stdin` / `--mnemonic-fd`) | In non-interactive startup mode, the parent provides mnemonic bytes over stdin/inherited FD. That parent is now explicitly in the trust chain for mnemonic handling correctness and secrecy. |
|
||||||
|
|
||||||
|
Everything else — clients, other processes, other users, remote callers — is **untrusted by default** and must clear an approval check.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The three concepts: identity, index, approval
|
||||||
|
|
||||||
|
This is the entire user-facing security model. There are exactly three things to know.
|
||||||
|
|
||||||
|
```text
|
||||||
|
+----------------+ +-----------------------+ +---------------+
|
||||||
|
| IDENTITY | | INDEX (or PATH) | | APPROVAL |
|
||||||
|
| who is asking | +---- | which key to use | ----+ | is this OK? |
|
||||||
|
+----------------+ | +-----------------------+ | +---------------+
|
||||||
|
| |
|
||||||
|
| request resolves to |
|
||||||
|
+-------> (identity, index/path) -----+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+----------------------------+
|
||||||
|
| Approved entry exists? |
|
||||||
|
+----------------------------+
|
||||||
|
|yes |no
|
||||||
|
v v
|
||||||
|
+-----------------+ +----------------+
|
||||||
|
| Sign / encrypt | | Prompt human |
|
||||||
|
+-----------------+ +----------------+
|
||||||
|
|
|
||||||
|
[y]/[a] | [n]
|
||||||
|
allow now | deny
|
||||||
|
/save |
|
||||||
|
v v
|
||||||
|
+-----------------+ +----------------+
|
||||||
|
| Sign / encrypt | | policy_denied |
|
||||||
|
+-----------------+ +----------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
### Identity — *who is asking*
|
||||||
|
|
||||||
|
An identity is a tagged caller record built from the transport. Different transports produce different identity tags; the signer never invents identity, it always reads it from the kernel or framework.
|
||||||
|
|
||||||
|
| Kind | What it carries | Source of truth |
|
||||||
|
|---|---|---|
|
||||||
|
| `unix_peer` | `uid`, `pid` from `SO_PEERCRED` on an abstract Unix socket. | Kernel — the client cannot forge it. |
|
||||||
|
| `qubes` | source qube name from `QREXEC_REMOTE_DOMAIN`. | Qubes RPC framework. |
|
||||||
|
| `qubes_pubkey` | source qube + verified auth-envelope pubkey (`qubes:<vm>+pubkey:<hex>`). | Qubes RPC framework + application-layer signature verification. |
|
||||||
|
| `tcp_local` | Loopback address of caller. | TCP socket peer address — opt-in transport. |
|
||||||
|
| `tcp_remote` | Verified auth-envelope pubkey (`pubkey:<hex>`). | Application-layer authentication. |
|
||||||
|
| `fips` | Peer npub from a NIP-46 style flow (planned). | Application-layer authentication. |
|
||||||
|
| `usb_serial` | Device path plus an asserted caller (planned). | Trust-on-first-use. |
|
||||||
|
|
||||||
|
Identity quality varies. `unix_peer` and `qubes` are vouched for by the kernel or hypervisor — strong. `qubes_pubkey` is stronger still for multi-program qubes because it combines hypervisor-vouched source-qube identity with a per-program cryptographic identity. `tcp_local` and `tcp_remote` rely on transport/app-layer checks and are only as strong as deployment choices.
|
||||||
|
|
||||||
|
### Index (or path) — *which key*
|
||||||
|
|
||||||
|
The signer can derive many keys from one mnemonic. Clients select one in two equivalent ways:
|
||||||
|
|
||||||
|
- `nostr_index = N` — shorthand for the Nostr derivation `m/44'/1237'/N'/0/0` per NIP-06.
|
||||||
|
- `role_path = "<full BIP-32 path>"` — used for non-Nostr key trees (Bitcoin, SSH, etc.). Pre-registered only; see [§9.2](#92-pre-registration-of-role_path).
|
||||||
|
|
||||||
|
The three legacy selector words from the wire protocol — `role`, `nostr_index`, `role_path` — all resolve to a single internal record. From the user's perspective, what matters is "which Nostr identity (by index)" or "which advanced derivation (by path)."
|
||||||
|
|
||||||
|
|
||||||
|
### Approval — *is this OK?*
|
||||||
|
|
||||||
|
An approval is an in-memory entry that says: *"this identity may use this index/path."* Approvals come from exactly two sources:
|
||||||
|
|
||||||
|
1. **The human at the prompt.** When a request arrives that has no matching approval, the signer blocks the request and asks the user. On `[a]` ("allow this caller for this index/path for the rest of the session") the signer adds a session approval. On `[y]` it allows once without saving. On `[n]` it denies.
|
||||||
|
2. **Pre-approvals.** When the OS distribution starts `n_signer` with `--preapprove caller=...,nostr_index=...` flags, those become approvals that exist before any request arrives. They never trigger prompts. See [§7](#7-pre-approvals-the-os-distribution-pathway).
|
||||||
|
|
||||||
|
Approvals are stored in an in-memory table and vanish on process exit. There is no persistent approval database.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The deny-by-default rule
|
||||||
|
|
||||||
|
`n_signer` denies any request whose `(identity, index)` pair has no approval. Period.
|
||||||
|
|
||||||
|
There is no special case for "same-uid" callers. There is no implicit grant for the user running their own client. Every first request from every caller goes through an approval — either granted by the user at the prompt, or pre-approved by the OS distribution at startup.
|
||||||
|
|
||||||
|
This is the single most important property:
|
||||||
|
|
||||||
|
- It removes the assumption that "same-uid means trusted" — an assumption that was always more convenience than security.
|
||||||
|
- It collapses several mechanisms (policy, role pre-registration, the `[a]` flag) into one (the in-memory approval table).
|
||||||
|
- It makes the audit trail trivial: every signed operation maps to either a pre-approval declaration or a logged human approval.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. The two checks every request must pass
|
||||||
|
|
||||||
|
Every signing request runs through this short pipeline. Failure at any step returns an error response and is logged.
|
||||||
|
|
||||||
|
```text
|
||||||
|
+----------------------------------------------------------------+
|
||||||
|
| 1. CLIENT |
|
||||||
|
| sends length-prefixed JSON-RPC request |
|
||||||
|
+----------------------------------------------------------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+----------------------------------------------------------------+
|
||||||
|
| 2. TRANSPORT |
|
||||||
|
| - reads framed bytes off the socket |
|
||||||
|
| - builds caller_identity_t (uid / qubes vm / tcp peer / ...)|
|
||||||
|
+----------------------------------------------------------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+----------------------------------------------------------------+
|
||||||
|
| 3. RESOLVE INDEX |
|
||||||
|
| - parse selector options from request |
|
||||||
|
| - resolve to a specific (index or path) |
|
||||||
|
| - reject ambiguous selectors |
|
||||||
|
+----------------------------------------------------------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+----------------------------------------------------------------+
|
||||||
|
| 4. APPROVAL CHECK |
|
||||||
|
| - is there an approval for (this identity, this index)? |
|
||||||
|
| yes -> ALLOW |
|
||||||
|
| no -> PROMPT human (or DENY if non-interactive) |
|
||||||
|
| - on [a], append a session approval and continue |
|
||||||
|
| - on [y], continue this request only |
|
||||||
|
| - on [n] / EOF, deny |
|
||||||
|
+----------------------------------------------------------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+----------------------------------------------------------------+
|
||||||
|
| 5. ENFORCEMENT (verb x purpose x curve) |
|
||||||
|
| - is this verb compatible with the key's declared purpose? |
|
||||||
|
| - reject with purpose_mismatch / curve_mismatch otherwise |
|
||||||
|
+----------------------------------------------------------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+----------------------------------------------------------------+
|
||||||
|
| 6. KEY DERIVATION + SIGN |
|
||||||
|
| - derive key from mnemonic at the resolved path |
|
||||||
|
| - perform crypto operation |
|
||||||
|
| - return signed result |
|
||||||
|
+----------------------------------------------------------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+----------------------------------------------------------------+
|
||||||
|
| 7. RESPONSE FRAMED + SENT |
|
||||||
|
| - activity log line appended with the approval source label |
|
||||||
|
+----------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
There are two independent gates: the **approval check** (does this caller have permission to use this key?) and the **enforcement check** (is this operation compatible with this key?). Both must pass.
|
||||||
|
|
||||||
|
### 5.1 Approval check — *does this caller have permission?*
|
||||||
|
|
||||||
|
Implemented as a lookup in the in-memory approval table. First match wins; the catch-all is `*` → deny. No approval entry, no signature.
|
||||||
|
|
||||||
|
The approval check answers *who* and *which key*. It does not check what the request will do with the key — that is the enforcement check.
|
||||||
|
|
||||||
|
### 5.2 Enforcement check — *is this operation compatible with this key?*
|
||||||
|
|
||||||
|
The signer enforces a strict `(verb, purpose, curve)` matrix:
|
||||||
|
|
||||||
|
| Verb | Required purpose | Required curve |
|
||||||
|
|---|---|---|
|
||||||
|
| `sign_event` | `nostr` | `secp256k1` |
|
||||||
|
| `get_public_key` | `nostr` | `secp256k1` |
|
||||||
|
| `nip04_encrypt` / `nip04_decrypt` | `nostr` | `secp256k1` |
|
||||||
|
| `nip44_encrypt` / `nip44_decrypt` | `nostr` | `secp256k1` |
|
||||||
|
| Any other verb | rejected | rejected |
|
||||||
|
|
||||||
|
A pre-approval to use a Bitcoin-purposed key for `sign_event` does **not** override the enforcement matrix. The approval grants access to the key; enforcement still gates the verb. **Fail-closed**: unknown verbs are rejected, never passed through.
|
||||||
|
|
||||||
|
This is the layer that prevents (for example) a `bitcoin/secp256k1` key from being used to sign a Nostr event even if some pre-approval entry mistakenly named it. The key's *purpose* is part of its identity; you cannot reuse it across domains.
|
||||||
|
|
||||||
|
Test coverage: [`tests/test_enforcement.c`](../tests/test_enforcement.c).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. The interactive approval prompt
|
||||||
|
|
||||||
|
When a request has no matching approval and `n_signer` is running attached to a terminal, the signer blocks the request, prints a prompt, and waits for a local keystroke.
|
||||||
|
|
||||||
|
Target prompt format:
|
||||||
|
|
||||||
|
```
|
||||||
|
Approval required
|
||||||
|
caller: qubes:nostr-relay
|
||||||
|
verb: sign_event
|
||||||
|
index: nostr_index=0
|
||||||
|
purpose: nostr / secp256k1
|
||||||
|
|
||||||
|
[y] allow once
|
||||||
|
[a] allow this caller for index 0 for the rest of the session
|
||||||
|
[n] deny
|
||||||
|
```
|
||||||
|
|
||||||
|
The keys mean:
|
||||||
|
|
||||||
|
- `[y]` — Allow this single request. No approval is saved; the next request from the same caller will prompt again.
|
||||||
|
- `[a]` — Allow, and add a session approval so the same `(caller, index)` pair is silently allowed for the rest of the session.
|
||||||
|
- `[n]` — Deny this request. Returns `policy_denied` to the client.
|
||||||
|
- EOF / non-interactive — Treated as deny. There is no implicit "allow when nobody is at the terminal" mode. OS distributions that need silent operation use [`--preapprove`](#7-pre-approvals-the-os-distribution-pathway).
|
||||||
|
|
||||||
|
### 6.1 What `[a]` does and does not do
|
||||||
|
|
||||||
|
- It approves the **specific caller** (e.g. `qubes:nostr-relay`).
|
||||||
|
- It approves the **specific index/path** that triggered the prompt.
|
||||||
|
- It does **not** approve other callers.
|
||||||
|
- It does **not** approve other indices for the same caller — a different `nostr_index` from the same caller will prompt again.
|
||||||
|
- It does **not** persist past the process lifetime.
|
||||||
|
|
||||||
|
|
||||||
|
### 6.2 New-identity warning
|
||||||
|
|
||||||
|
When a request specifies an unregistered `nostr_index`, the prompt says so plainly:
|
||||||
|
|
||||||
|
```
|
||||||
|
Approval required
|
||||||
|
caller: qubes:nostr-relay
|
||||||
|
verb: get_public_key
|
||||||
|
index: nostr_index=7 (NEW IDENTITY — will be derived if approved)
|
||||||
|
|
||||||
|
[y] allow once [a] allow caller for this new identity [n] deny
|
||||||
|
```
|
||||||
|
|
||||||
|
A new identity is just a new derivation of the same mnemonic. It is not a separate key file or a separate seed; it is `m/44'/1237'/7'/0/0` of the existing mnemonic. The user should still see explicitly that they are *creating* a previously-unused identity.
|
||||||
|
|
||||||
|
### 6.3 Race-condition behavior
|
||||||
|
|
||||||
|
Requests are served one at a time. If a malicious client tries to flood requests during a prompt, only the request being prompted is held; subsequent requests queue or are dropped per the transport's framing. The prompt always names the request being approved. It is not possible for a queued request to "ride along" on an approval intended for an earlier request.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Pre-approvals: the OS distribution pathway
|
||||||
|
|
||||||
|
For deployments where multiple system services need keys at boot — `n_OS_tr` and similar — the OS distribution starts `n_signer` with one `--preapprove` flag per service:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/bin/n_signer \
|
||||||
|
--preapprove caller=qubes:nostr-relay,role=main \
|
||||||
|
--preapprove caller=qubes:dm-handler,nostr_index=1 \
|
||||||
|
--preapprove caller=qubes:contacts,nostr_index=2 \
|
||||||
|
--preapprove caller=qubes:zaps,nostr_index=3
|
||||||
|
```
|
||||||
|
|
||||||
|
At startup, `n_signer`:
|
||||||
|
|
||||||
|
1. Parses each flag into an approval entry with prompt mode `NEVER`.
|
||||||
|
2. Auto-derives any keys those entries depend on (so services don't see "ready" until keys exist).
|
||||||
|
3. Writes one log line per pre-approval to stderr so the OS unit's journal records what was authorized.
|
||||||
|
4. Begins listening normally. Pre-approved requests are served silently; everything else still hits the prompt or the catch-all deny.
|
||||||
|
|
||||||
|
### 7.1 What pre-approvals can do
|
||||||
|
|
||||||
|
- Allow a specific named caller to use a specific named key for any verb that key's purpose/curve permits.
|
||||||
|
- Auto-create a Nostr identity at a specified `nostr_index` if it does not yet exist.
|
||||||
|
- Be inspected: anyone reading the systemd unit (or `ps` output) can see exactly what was authorized.
|
||||||
|
|
||||||
|
### 7.2 What pre-approvals cannot do
|
||||||
|
|
||||||
|
- **Cannot deny.** Pre-approvals only widen authorization, never narrow it. The user at the terminal is always the source of denial. (The plan deliberately rejects "preapprove deny" entries; see [`plans/deny_by_default_approvals.md`](../plans/deny_by_default_approvals.md) §C.3.)
|
||||||
|
- **Cannot wildcard the caller.** Each entry must name exactly one caller. There is no `qubes:*` rule. The OS distribution must know who its services are.
|
||||||
|
- **Cannot bypass enforcement.** A pre-approval to use a Nostr key for a hypothetical Bitcoin verb still fails the enforcement matrix in [§5.2](#52-enforcement-check--is-this-operation-compatible-with-this-key).
|
||||||
|
- **Cannot pre-register `role_path` derivations.** Only `role=<name>` (an existing role) and `nostr_index=<n>` are allowed in pre-approval specs. Free-form `role_path` derivations remain pre-registration-only by code path. See [§9.2](#92-pre-registration-of-role_path).
|
||||||
|
|
||||||
|
### 7.3 Trust implications
|
||||||
|
|
||||||
|
Pre-approvals shift the trust boundary. Whoever wrote the systemd unit now decides what the signer will allow without asking. Concretely:
|
||||||
|
|
||||||
|
- The unit file's integrity matters. If an attacker can rewrite it, they can pre-approve themselves.
|
||||||
|
- The OS launcher's identity-stamping must be trustworthy. If something else can spoof a `qubes:nostr-relay` tag, it inherits that pre-approval.
|
||||||
|
- The user retains final authority at the running terminal. Anything *not* pre-approved still goes through the human-approved prompt.
|
||||||
|
|
||||||
|
In `n_OS_tr` these properties are part of the OS itself: the unit files are owned by root, the qrexec framework stamps caller identity at the hypervisor level, and the user's terminal is the ultimate arbiter. The trust chain is short and visible.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. The audit log
|
||||||
|
|
||||||
|
Every served (or denied) request appends a line to the in-memory activity buffer that the running TUI displays. Each line includes the **source label** that explains why the verdict was reached.
|
||||||
|
|
||||||
|
Target line format:
|
||||||
|
|
||||||
|
```
|
||||||
|
[2026-05-04 16:03:11] qubes:nostr-relay sign_event(nostr_index=0) ALLOWED:preapprove
|
||||||
|
[2026-05-04 16:03:14] uid:1000 sign_event(nostr_index=0) ALLOWED:session-grant
|
||||||
|
[2026-05-04 16:03:18] tcp:[::1]:54122 get_public_key(nostr_index=7) ALLOWED:prompt
|
||||||
|
[2026-05-04 16:03:21] uid:1001 sign_event(nostr_index=0) DENIED:no-match
|
||||||
|
```
|
||||||
|
|
||||||
|
Source labels:
|
||||||
|
|
||||||
|
| Label | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `:preapprove` | Approval came from a `--preapprove` startup flag. |
|
||||||
|
| `:session-grant` | Approval was added earlier this session by a prompt `[a]`. |
|
||||||
|
| `:prompt` | Approval was given just now by the user pressing `[y]` or `[a]` at the prompt. |
|
||||||
|
| `:no-match` | No approval existed and (in non-interactive mode) the request was denied. |
|
||||||
|
|
||||||
|
This makes forensic review fast: every signed operation maps to either a pre-approval (audit the unit file), a session grant (audit the session's earlier prompt activity), or a fresh prompt approval (audit human attention at that moment).
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. The role abstraction (internal plumbing)
|
||||||
|
|
||||||
|
Internally `n_signer` uses a structure called a **role** to bundle three things together:
|
||||||
|
|
||||||
|
- A derivation path (BIP-32).
|
||||||
|
- A purpose label (`nostr`, `bitcoin`, `ssh`, `age`).
|
||||||
|
- A curve label (`secp256k1`, `ed25519`, `x25519`).
|
||||||
|
|
||||||
|
Roles are the unit of *enforcement* (the matrix in [§5.2](#52-enforcement-check--is-this-operation-compatible-with-this-key) is keyed on role purpose and curve) and the unit of *audit* (every log line names the role used).
|
||||||
|
|
||||||
|
### 9.1 Why roles exist as plumbing rather than user-facing concept
|
||||||
|
|
||||||
|
- A future where one mnemonic produces Nostr, Bitcoin, SSH, and age keys requires *something* to track per-key purpose. Roles are that thing. Keeping them in the codebase preserves the option without forcing the user to think about them today.
|
||||||
|
- Two different identities can use the same role (the same key). The role is the audit unit shared between them, so the activity log is consistent regardless of caller.
|
||||||
|
- Enforcement against a role's declared purpose/curve is a defense-in-depth: a Nostr key cannot be coerced into producing a Bitcoin signature even if some approval entry mistakenly named it.
|
||||||
|
|
||||||
|
A user reading prompts and the activity log will see "index 0" and "index 7" most of the time, not "role main" or "role nostr_idx_7." Roles surface as labels only in advanced views and the source code.
|
||||||
|
|
||||||
|
### 9.2 Pre-registration of `role_path`
|
||||||
|
|
||||||
|
The `role_path` selector lets a client specify a BIP-32 path directly, including paths outside the Nostr derivation scheme. These remain **pre-registration-only**:
|
||||||
|
|
||||||
|
- The signer ships with an empty `role_path` table.
|
||||||
|
- Operators who want non-Nostr keys must register them at startup (mechanism TBD).
|
||||||
|
- The interactive prompt does **not** auto-derive `role_path` keys, even with `[a]`. The narrowing in [`plans/deny_by_default_approvals.md`](../plans/deny_by_default_approvals.md) §5 explicitly applies only to `nostr_index`.
|
||||||
|
|
||||||
|
The reasoning is that `role_path` is free-form. A malicious or buggy client could request paths that overlap with sensitive domains (bitcoin wallet roots, SSH host keys, age recipients). Auto-derivation in those domains would be a footgun. `nostr_index` is locked to a specific scheme with locked purpose and curve, so auto-derivation there is safe to gate on a single human keystroke.
|
||||||
|
|
||||||
|
### 9.3 Role table size limits
|
||||||
|
|
||||||
|
`ROLE_TABLE_MAX_ENTRIES` caps the number of distinct roles per session. The plan raises this to 256. Overflow is reported as a clear `role_table_full` error rather than silent truncation or memory unsafety.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Memory safety and zeroization
|
||||||
|
|
||||||
|
### 10.1 Locked memory
|
||||||
|
|
||||||
|
- The mnemonic and derived private keys live in `mlock`'d allocations managed by [`src/secure_mem.c`](../src/secure_mem.c).
|
||||||
|
- These pages are pinned in RAM (no swap to disk) for the process lifetime.
|
||||||
|
- On normal shutdown (`q`, `SIGINT`, `SIGTERM`), buffers are explicitly zeroized before unmap.
|
||||||
|
|
||||||
|
### 10.2 Crash semantics
|
||||||
|
|
||||||
|
If the process dies abnormally, the kernel reclaims its pages. Anything previously `mlock`'d is not flushed to disk because it never could be. There is no swap residue to scrape, no core dump containing keys (if your environment disables core dumps for setuid-equivalent processes — verify locally), and no recovery path.
|
||||||
|
|
||||||
|
This is the meaning of "crash equals total wipe is a security feature" in [`plans/nsigner.md:154`](../plans/nsigner.md:154).
|
||||||
|
|
||||||
|
### 10.3 No persistence by design
|
||||||
|
|
||||||
|
There is no file under `~/.nsigner`, no `/var/lib/nsigner`, no `~/.config/nsigner`, no approval database. The signer is a process whose authority begins when you type the mnemonic and ends when you quit.
|
||||||
|
|
||||||
|
If you want something durable: store the mnemonic on paper or hardware, not in `n_signer`.
|
||||||
|
|
||||||
|
The only file inputs are `--preapprove` flags from the launcher's command line, evaluated once at startup and never re-read. There is no live config reload.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Transports and their identity quality
|
||||||
|
|
||||||
|
Different transports give different identity quality. The wire format (4-byte length prefix + JSON-RPC) and the security checks ([§5](#5-the-two-checks-every-request-must-pass)) are identical across transports.
|
||||||
|
|
||||||
|
### 11.1 Abstract namespace Unix sockets (default)
|
||||||
|
|
||||||
|
- Address: `@nsigner_<word1>_<word2>` per [`plans/nsigner.md:96`](../plans/nsigner.md:96).
|
||||||
|
- Identity: kernel-vouched `uid`, `pid`. Cannot be forged by the client.
|
||||||
|
- Risk surface: any local process on the same host. Deny-by-default + per-(caller, index) approvals handle this.
|
||||||
|
|
||||||
|
### 11.2 Stdio / qrexec mode
|
||||||
|
|
||||||
|
- One framed request, one framed response, then exit.
|
||||||
|
- Identity defaults to `qubes:<vm-name>` from `QREXEC_REMOTE_DOMAIN`, vouched for by Qubes.
|
||||||
|
- With `--listen qrexec --auth optional|required`, requests carrying a valid auth envelope are upgraded to `qubes:<vm-name>+pubkey:<hex>`.
|
||||||
|
- Risk surface: only what Qubes RPC policy permits. See [`packaging/qubes/`](../packaging/qubes/).
|
||||||
|
|
||||||
|
### 11.3 TCP (advanced / opt-in)
|
||||||
|
|
||||||
|
- Loopback and remote TCP callers must present a valid signed auth envelope.
|
||||||
|
- Identity is normalized to caller `pubkey:<hex>` after signature verification; TCP peer address is context only.
|
||||||
|
- The auth gate runs before policy: missing/invalid envelopes are rejected with `2010..2017` auth errors.
|
||||||
|
- Prompt-driven approval still applies after auth; auth answers "who is calling", policy answers "is this caller allowed".
|
||||||
|
|
||||||
|
### 11.4 Future transports
|
||||||
|
|
||||||
|
The roadmap ([`plans/nsigner.md:178`](../plans/nsigner.md:178)) describes additional transports (USB serial, FIPS-style relay). The contract is:
|
||||||
|
|
||||||
|
- The transport produces a `caller_identity_t`.
|
||||||
|
- The same approval / enforcement / dispatch stack runs unchanged.
|
||||||
|
- New transports add new identity *kinds*, not new bypass paths.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Operational guidance
|
||||||
|
|
||||||
|
### 12.1 Running interactively (typical desktop use)
|
||||||
|
|
||||||
|
- Start `n_signer` only on a terminal you control. No `--preapprove` flags.
|
||||||
|
- Type or generate the mnemonic. Generated mnemonics are shown once with no confirmation step ([`plans/nsigner.md:84`](../plans/nsigner.md:84)) — write them down before pressing a key.
|
||||||
|
- Stay near the terminal for the first couple of minutes after launch. New clients will prompt for approval. Press `[a]` to grant for the session.
|
||||||
|
- Once your usual clients are approved, prompts stop appearing.
|
||||||
|
|
||||||
|
### 12.2 Running as a system signer (e.g. `n_OS_tr`)
|
||||||
|
|
||||||
|
- Configure pre-approvals in the systemd (or equivalent) unit file. One `--preapprove` flag per service.
|
||||||
|
- Verify each pre-approval's caller identity exactly matches what the launcher actually produces. Mismatches cause silent deny (`:no-match` in the log).
|
||||||
|
- Treat the unit file as security-sensitive. Restrict write access to root or its equivalent.
|
||||||
|
- Keep a terminal attached or piped to where prompts can still be seen. Anything not pre-approved still prompts.
|
||||||
|
|
||||||
|
### 12.3 Stopping
|
||||||
|
|
||||||
|
- Press `q` for clean shutdown.
|
||||||
|
- `Ctrl+C` and `SIGTERM` are equivalent — they zeroize and exit.
|
||||||
|
- Closing the terminal is also equivalent. There is no "detach" mode by design.
|
||||||
|
|
||||||
|
### 12.4 Auditing
|
||||||
|
|
||||||
|
- The activity buffer in the running TUI is the session log. It does not survive the process.
|
||||||
|
- For persistent audit, run `n_signer` under a logging supervisor that captures stderr/stdout, knowing the activity buffer reflects post-approval decisions only.
|
||||||
|
|
||||||
|
### 12.5 Rotating
|
||||||
|
|
||||||
|
- "Rotate" in `n_signer` means: quit, change mnemonic source, restart. There is no in-place rotation because there is no persistent state to migrate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Relationship to the current codebase
|
||||||
|
|
||||||
|
All stages from [`plans/deny_by_default_approvals.md`](../plans/deny_by_default_approvals.md) are implemented in the current codebase. This document therefore describes shipped behavior, not a future target state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Things that look like bugs but are security features
|
||||||
|
|
||||||
|
| Symptom | Reason |
|
||||||
|
|---|---|
|
||||||
|
| First request from a new client prompts even though it's the same uid | Deny-by-default. Same-uid trust is gone. Press `[a]` once and you won't see prompts from this client again this session. |
|
||||||
|
| Killing the process loses all approvals and key derivations | Crash-equals-wipe. Restart with the same mnemonic to rebuild deterministically; re-approve clients. |
|
||||||
|
| `purpose_mismatch` when calling `sign_event` against a non-Nostr key | Roles are scoped by purpose. Approvals do not bypass enforcement. |
|
||||||
|
| `unknown_role` for an unregistered `role_path` | `role_path` keys are pre-registration-only by design. Use `nostr_index` for ad-hoc Nostr identities. |
|
||||||
|
| Pre-approval entry doesn't match incoming requests | Caller identity must match exactly — no wildcards. Verify the launcher tags requests the way the unit file expects. |
|
||||||
|
| `role=unknown` in the activity log (current code) | Selector did not resolve. Future versions will prompt to create the identity; current code rejects with `unknown_role` after policy passes. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Quick reference — the security guarantee in one paragraph
|
||||||
|
|
||||||
|
`n_signer` will never sign with a key whose use has not been explicitly approved — either by a human keystroke at the running terminal or by a `--preapprove` flag declared at startup. It will never use a key outside its declared `(purpose, curve)` scope. It will never expose a private key to a client. It will never write key material to disk. Every request is checked twice: once for approval, once for enforcement. Default policy is deny. Approvals vanish on process exit.
|
||||||
|
|
||||||
|
If any of these statements becomes false in code, that is a security bug worth filing immediately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. References
|
||||||
|
|
||||||
|
- [`README.md`](../README.md) — authoritative behavior spec.
|
||||||
|
- [`plans/nsigner.md`](../plans/nsigner.md) — root design plan and decisions log.
|
||||||
|
- [`plans/deny_by_default_approvals.md`](../plans/deny_by_default_approvals.md) — the approval model plan this document tracks.
|
||||||
|
- [`documents/CLIENT_IMPLEMENTATION.md`](CLIENT_IMPLEMENTATION.md) — wire contract for clients.
|
||||||
|
- [`documents/QUBES_OS.md`](QUBES_OS.md) — Qubes RPC integration.
|
||||||
|
- [`documents/FIPS_DEPLOYMENT.md`](FIPS_DEPLOYMENT.md) — FIPS-mode deployment notes.
|
||||||
|
- [`plans/seed_phrase_uses.md`](../plans/seed_phrase_uses.md) — what one mnemonic can become.
|
||||||
|
- [`src/policy.c`](../src/policy.c), [`src/server.c`](../src/server.c), [`src/dispatcher.c`](../src/dispatcher.c), [`src/role_table.c`](../src/role_table.c), [`src/selector.c`](../src/selector.c), [`src/enforcement.c`](../src/enforcement.c) — the security-related code.
|
||||||
93
documents/qubes_client_examples.md
Normal file
93
documents/qubes_client_examples.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# qubes_client_examples.md
|
||||||
|
|
||||||
|
This document shows minimal caller-qube examples for invoking `nsigner --listen qrexec` through Qubes qrexec.
|
||||||
|
|
||||||
|
For complete protocol details (framing, JSON-RPC, error handling), see `documents/CLIENT_IMPLEMENTATION.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Shell example (`qrexec-client-vm` + framed JSON)
|
||||||
|
|
||||||
|
This sends one `get_public_key` request and decodes one framed response.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
TARGET_QUBE="nsigner-vault"
|
||||||
|
SERVICE="qubes.NsignerRpc"
|
||||||
|
REQ='{"id":"1","method":"get_public_key","params":[]}'
|
||||||
|
|
||||||
|
python3 - "$TARGET_QUBE" "$SERVICE" "$REQ" <<'PY'
|
||||||
|
import json
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
target, service, req_json = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
|
frame = struct.pack(">I", len(req_json.encode("utf-8"))) + req_json.encode("utf-8")
|
||||||
|
|
||||||
|
p = subprocess.Popen(
|
||||||
|
["qrexec-client-vm", target, service],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
out, _ = p.communicate(frame)
|
||||||
|
if p.returncode != 0:
|
||||||
|
raise SystemExit(f"qrexec-client-vm failed: {p.returncode}")
|
||||||
|
if len(out) < 4:
|
||||||
|
raise SystemExit("short response (missing frame header)")
|
||||||
|
|
||||||
|
n = struct.unpack(">I", out[:4])[0]
|
||||||
|
payload = out[4:4+n]
|
||||||
|
if len(payload) != n:
|
||||||
|
raise SystemExit("short response payload")
|
||||||
|
|
||||||
|
print(json.dumps(json.loads(payload.decode("utf-8")), indent=2))
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Python example (explicit frame helpers over qrexec stdio)
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import json
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
def frame_encode(obj: dict) -> bytes:
|
||||||
|
payload = json.dumps(obj, separators=(",", ":")).encode("utf-8")
|
||||||
|
return struct.pack(">I", len(payload)) + payload
|
||||||
|
|
||||||
|
|
||||||
|
def frame_decode(buf: bytes) -> dict:
|
||||||
|
if len(buf) < 4:
|
||||||
|
raise ValueError("missing frame header")
|
||||||
|
n = struct.unpack(">I", buf[:4])[0]
|
||||||
|
payload = buf[4:4 + n]
|
||||||
|
if len(payload) != n:
|
||||||
|
raise ValueError("short frame payload")
|
||||||
|
return json.loads(payload.decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def call_nsigner_qrexec(target_qube: str, request: dict) -> dict:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
["qrexec-client-vm", target_qube, "qubes.NsignerRpc"],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
out, err = proc.communicate(frame_encode(request))
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(f"qrexec failed ({proc.returncode}): {err.decode('utf-8', 'replace')}")
|
||||||
|
return frame_decode(out)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
req = {"id": "1", "method": "get_public_key", "params": []}
|
||||||
|
resp = call_nsigner_qrexec("nsigner-vault", req)
|
||||||
|
print(json.dumps(resp, indent=2))
|
||||||
|
```
|
||||||
32
examples/get_public_key_client.c
Normal file
32
examples/get_public_key_client.c
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
#include "../client/nsigner_client.h"
|
||||||
|
|
||||||
|
int main(int argc, char **argv) {
|
||||||
|
const char *socket_name = "nsigner";
|
||||||
|
nsigner_client_t client;
|
||||||
|
char *response = NULL;
|
||||||
|
|
||||||
|
if (argc > 1 && argv[1] != NULL && argv[1][0] != '\0') {
|
||||||
|
socket_name = argv[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
nsigner_client_init(&client);
|
||||||
|
|
||||||
|
if (nsigner_client_connect_unix(&client, socket_name, 5000) != 0) {
|
||||||
|
fprintf(stderr, "connect failed: %s\n", nsigner_client_last_error(&client));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nsigner_client_get_public_key(&client, "example-1", "", &response) != 0) {
|
||||||
|
fprintf(stderr, "request failed: %s\n", nsigner_client_last_error(&client));
|
||||||
|
nsigner_client_close(&client);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("%s\n", response);
|
||||||
|
free(response);
|
||||||
|
nsigner_client_close(&client);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
33
examples/sign_event_client.c
Normal file
33
examples/sign_event_client.c
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
#include "../client/nsigner_client.h"
|
||||||
|
|
||||||
|
int main(int argc, char **argv) {
|
||||||
|
const char *socket_name = "nsigner";
|
||||||
|
const char *event_json = "{\"kind\":1,\"content\":\"hello from client example\",\"tags\":[],\"created_at\":1700000000}";
|
||||||
|
nsigner_client_t client;
|
||||||
|
char *response = NULL;
|
||||||
|
|
||||||
|
if (argc > 1 && argv[1] != NULL && argv[1][0] != '\0') {
|
||||||
|
socket_name = argv[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
nsigner_client_init(&client);
|
||||||
|
|
||||||
|
if (nsigner_client_connect_unix(&client, socket_name, 5000) != 0) {
|
||||||
|
fprintf(stderr, "connect failed: %s\n", nsigner_client_last_error(&client));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nsigner_client_sign_event(&client, "example-2", event_json, "main", &response) != 0) {
|
||||||
|
fprintf(stderr, "request failed: %s\n", nsigner_client_last_error(&client));
|
||||||
|
nsigner_client_close(&client);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("%s\n", response);
|
||||||
|
free(response);
|
||||||
|
nsigner_client_close(&client);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -153,14 +153,42 @@ git_commit_and_push_no_tag() {
|
|||||||
git push origin "$NEW_VERSION" || git push --force origin "$NEW_VERSION"
|
git push origin "$NEW_VERSION" || git push --force origin "$NEW_VERSION"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
verify_binary_version() {
|
||||||
|
local bin_path="$1"
|
||||||
|
local expected="$2"
|
||||||
|
|
||||||
|
if [[ ! -x "$bin_path" ]]; then
|
||||||
|
print_error "Binary not found or not executable: $bin_path"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local got
|
||||||
|
got="$($bin_path --version 2>/dev/null | awk '{print $2}')"
|
||||||
|
if [[ "$got" != "$expected" ]]; then
|
||||||
|
print_error "Binary version mismatch for $(basename "$bin_path"): expected $expected, got ${got:-<unknown>}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
build_release_binary() {
|
build_release_binary() {
|
||||||
if [[ ! -f "build_static.sh" ]]; then
|
if [[ ! -f "build_static.sh" ]]; then
|
||||||
print_error "build_static.sh not found"
|
print_error "build_static.sh not found"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Prevent stale artifacts from previous builds being uploaded.
|
||||||
|
rm -f build/nsigner_static_x86_64 build/nsigner_static_arm64
|
||||||
|
|
||||||
./build_static.sh > /dev/null 2>&1 || return 1
|
./build_static.sh > /dev/null 2>&1 || return 1
|
||||||
|
verify_binary_version "build/nsigner_static_x86_64" "$NEW_VERSION" || return 1
|
||||||
|
|
||||||
./build_static.sh --arch arm64 > /dev/null 2>&1 || print_warning "ARM64 build failed (continuing)"
|
./build_static.sh --arch arm64 > /dev/null 2>&1 || print_warning "ARM64 build failed (continuing)"
|
||||||
|
if [[ -x "build/nsigner_static_arm64" ]]; then
|
||||||
|
verify_binary_version "build/nsigner_static_arm64" "$NEW_VERSION" || return 1
|
||||||
|
fi
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,13 +272,7 @@ main() {
|
|||||||
check_git_repo
|
check_git_repo
|
||||||
|
|
||||||
if [[ "$RELEASE_MODE" == true ]]; then
|
if [[ "$RELEASE_MODE" == true ]]; then
|
||||||
if [[ "$VERSION_INCREMENT_EXPLICIT" == true ]]; then
|
increment_version "$VERSION_INCREMENT_TYPE"
|
||||||
increment_version "$VERSION_INCREMENT_TYPE"
|
|
||||||
else
|
|
||||||
LATEST_TAG=$(git tag -l 'v*.*.*' | sort -V | tail -n 1 || echo "v0.0.1")
|
|
||||||
NEW_VERSION="$LATEST_TAG"
|
|
||||||
export NEW_VERSION
|
|
||||||
fi
|
|
||||||
|
|
||||||
if git tag "$NEW_VERSION" > /dev/null 2>&1; then
|
if git tag "$NEW_VERSION" > /dev/null 2>&1; then
|
||||||
print_success "Created tag: $NEW_VERSION"
|
print_success "Created tag: $NEW_VERSION"
|
||||||
@@ -259,9 +281,12 @@ main() {
|
|||||||
git tag "$NEW_VERSION" > /dev/null 2>&1
|
git tag "$NEW_VERSION" > /dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
git_commit_and_push_no_tag
|
if ! build_release_binary; then
|
||||||
|
print_error "x86_64 release build failed; aborting release before push/upload"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
build_release_binary || print_warning "x86_64 build failed"
|
git_commit_and_push_no_tag
|
||||||
|
|
||||||
local binary_path_x86="build/nsigner_static_x86_64"
|
local binary_path_x86="build/nsigner_static_x86_64"
|
||||||
local binary_path_arm64="build/nsigner_static_arm64"
|
local binary_path_arm64="build/nsigner_static_arm64"
|
||||||
|
|||||||
251
install_qube_fips_nsigner.sh
Executable file
251
install_qube_fips_nsigner.sh
Executable file
@@ -0,0 +1,251 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# User-only installer for Qubes AppVM persistence model.
|
||||||
|
# Nothing is written to /usr, /etc, or other root-owned paths.
|
||||||
|
#
|
||||||
|
# Installs into $HOME:
|
||||||
|
# - nsigner -> ~/.local/bin/nsigner
|
||||||
|
# - startup helper -> ~/start_nsigner.sh
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bash install_qube_fips_nsigner.sh
|
||||||
|
# bash install_qube_fips_nsigner.sh --help
|
||||||
|
#
|
||||||
|
# Optional env vars:
|
||||||
|
# NSIGNER_VERSION=vX.Y.Z # optional override; default is latest release tag
|
||||||
|
# NSIGNER_GITEA_TOKEN=<token> # if n_signer release assets are private
|
||||||
|
# NSIGNER_BINARY_URL=<direct url to nsigner_static_x86_64>
|
||||||
|
|
||||||
|
NSIGNER_VERSION="${NSIGNER_VERSION:-}"
|
||||||
|
|
||||||
|
PREFIX_BIN="${HOME}/.local/bin"
|
||||||
|
|
||||||
|
log() { printf "\033[1;34m[INFO]\033[0m %s\n" "$*"; }
|
||||||
|
warn() { printf "\033[1;33m[WARN]\033[0m %s\n" "$*"; }
|
||||||
|
err() { printf "\033[1;31m[ERR ]\033[0m %s\n" "$*"; }
|
||||||
|
|
||||||
|
show_help() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: bash install_qube_fips_nsigner.sh [options]
|
||||||
|
|
||||||
|
User-only install (Qubes AppVM friendly):
|
||||||
|
- n_signer ${NSIGNER_VERSION}
|
||||||
|
- signer startup helper script
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help Show this help and exit
|
||||||
|
|
||||||
|
Optional env vars:
|
||||||
|
NSIGNER_VERSION=vX.Y.Z # optional override; default is latest release tag
|
||||||
|
NSIGNER_GITEA_TOKEN=<token> # required if n_signer release assets are private
|
||||||
|
NSIGNER_BINARY_URL=<direct url to nsigner_static_x86_64>
|
||||||
|
|
||||||
|
Install paths:
|
||||||
|
~/.local/bin/nsigner
|
||||||
|
~/start_nsigner.sh
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || {
|
||||||
|
err "Missing command: $1"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
install_runtime_deps() {
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
log "Installing runtime dependencies via apt"
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y ca-certificates curl jq
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
log "Installing runtime dependencies via dnf"
|
||||||
|
sudo dnf install -y ca-certificates curl jq
|
||||||
|
else
|
||||||
|
err "Unsupported distro: need apt-get or dnf to install runtime dependencies"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_dirs() {
|
||||||
|
mkdir -p "${PREFIX_BIN}"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_nsigner_version() {
|
||||||
|
local headers=()
|
||||||
|
local latest_tag=""
|
||||||
|
|
||||||
|
if [[ -n "${NSIGNER_VERSION}" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${NSIGNER_GITEA_TOKEN:-}" ]]; then
|
||||||
|
headers=(-H "Authorization: token ${NSIGNER_GITEA_TOKEN}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
latest_tag="$(curl -fsSL "${headers[@]}" "https://git.laantungir.net/api/v1/repos/laantungir/n_signer/releases" \
|
||||||
|
| jq -r '.[0].tag_name // empty' || true)"
|
||||||
|
|
||||||
|
if [[ -z "${latest_tag}" ]]; then
|
||||||
|
err "Could not resolve latest n_signer release tag from API."
|
||||||
|
err "Set NSIGNER_VERSION explicitly (e.g. NSIGNER_VERSION=v0.0.11)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
NSIGNER_VERSION="${latest_tag}"
|
||||||
|
}
|
||||||
|
|
||||||
|
download_nsigner_asset_url() {
|
||||||
|
local headers=()
|
||||||
|
local api_tag_url="https://git.laantungir.net/api/v1/repos/laantungir/n_signer/releases/tags/${NSIGNER_VERSION}"
|
||||||
|
|
||||||
|
if [[ -n "${NSIGNER_GITEA_TOKEN:-}" ]]; then
|
||||||
|
headers=(-H "Authorization: token ${NSIGNER_GITEA_TOKEN}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -fsSL "${headers[@]}" "${api_tag_url}" \
|
||||||
|
| jq -r '.assets[]?.browser_download_url // empty' \
|
||||||
|
| grep -E 'nsigner_static_x86_64$' \
|
||||||
|
| head -n1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_installed_version() {
|
||||||
|
local expected="$1"
|
||||||
|
local got_line=""
|
||||||
|
local got_ver=""
|
||||||
|
|
||||||
|
if [[ ! -x "${PREFIX_BIN}/nsigner" ]]; then
|
||||||
|
err "Installed binary missing: ${PREFIX_BIN}/nsigner"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
got_line="$("${PREFIX_BIN}/nsigner" --version 2>/dev/null || true)"
|
||||||
|
got_ver="$(printf '%s\n' "${got_line}" | awk '{print $2}')"
|
||||||
|
|
||||||
|
if [[ -z "${got_ver}" ]]; then
|
||||||
|
err "Could not determine installed n_signer version from: ${got_line}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${got_ver}" != "${expected}" ]]; then
|
||||||
|
err "Downloaded binary version mismatch: expected ${expected}, got ${got_ver}"
|
||||||
|
err "Release asset appears stale or mislabeled."
|
||||||
|
err "Use NSIGNER_BINARY_URL to pin a known-good binary, or wait for a rebuilt release artifact."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
install_nsigner() {
|
||||||
|
local release_page=""
|
||||||
|
|
||||||
|
resolve_nsigner_version
|
||||||
|
release_page="https://git.laantungir.net/laantungir/n_signer/releases/tag/${NSIGNER_VERSION}"
|
||||||
|
|
||||||
|
log "Installing n_signer ${NSIGNER_VERSION}"
|
||||||
|
log "Release page: ${release_page}"
|
||||||
|
|
||||||
|
local asset_url="${NSIGNER_BINARY_URL:-}"
|
||||||
|
if [[ -z "${asset_url}" ]]; then
|
||||||
|
asset_url="$(download_nsigner_asset_url)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${asset_url}" ]]; then
|
||||||
|
err "Could not find downloadable n_signer x86_64 release binary for ${NSIGNER_VERSION}."
|
||||||
|
err "Provide NSIGNER_BINARY_URL or NSIGNER_GITEA_TOKEN so the release asset can be resolved."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Using n_signer binary URL: ${asset_url}"
|
||||||
|
if [[ -n "${NSIGNER_GITEA_TOKEN:-}" ]]; then
|
||||||
|
curl -fL -H "Authorization: token ${NSIGNER_GITEA_TOKEN}" -o "${PREFIX_BIN}/nsigner" "${asset_url}"
|
||||||
|
else
|
||||||
|
curl -fL -o "${PREFIX_BIN}/nsigner" "${asset_url}"
|
||||||
|
fi
|
||||||
|
chmod 0755 "${PREFIX_BIN}/nsigner"
|
||||||
|
|
||||||
|
verify_installed_version "${NSIGNER_VERSION}"
|
||||||
|
log "Installed ${PREFIX_BIN}/nsigner from release binary"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_signer_start_script() {
|
||||||
|
local script_path="${HOME}/start_nsigner.sh"
|
||||||
|
|
||||||
|
cat >"${script_path}" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
LISTEN_TARGET="${NSIGNER_LISTEN_TARGET:-tcp:[::]:8080}"
|
||||||
|
|
||||||
|
echo "=== n_signer startup ==="
|
||||||
|
echo "listen target: ${LISTEN_TARGET}"
|
||||||
|
|
||||||
|
# Optional: print current FIPS identity info if fipsctl is available.
|
||||||
|
if command -v fipsctl >/dev/null 2>&1; then
|
||||||
|
if fipsctl show status >/dev/null 2>&1; then
|
||||||
|
STATUS_JSON="$(fipsctl show status)"
|
||||||
|
elif sudo -n fipsctl show status >/dev/null 2>&1; then
|
||||||
|
STATUS_JSON="$(sudo -n fipsctl show status)"
|
||||||
|
else
|
||||||
|
STATUS_JSON=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${STATUS_JSON}" ]]; then
|
||||||
|
FIPS_IPV6="$(printf '%s\n' "${STATUS_JSON}" | sed -n 's/.*"ipv6_addr": "\([^"]*\)".*/\1/p')"
|
||||||
|
FIPS_NPUB="$(printf '%s\n' "${STATUS_JSON}" | sed -n 's/.*"npub": "\([^"]*\)".*/\1/p')"
|
||||||
|
LISTEN_PORT="$(printf '%s\n' "${LISTEN_TARGET}" | sed -n 's/.*:\([0-9][0-9]*\)$/\1/p')"
|
||||||
|
[[ -n "${FIPS_IPV6}" ]] && echo "fips ipv6: ${FIPS_IPV6}"
|
||||||
|
[[ -n "${FIPS_NPUB}" ]] && echo "fips npub: ${FIPS_NPUB}"
|
||||||
|
if [[ -n "${FIPS_NPUB}" && -n "${LISTEN_PORT}" ]]; then
|
||||||
|
echo "fips address: http://${FIPS_NPUB}.fips:${LISTEN_PORT}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "fips status: unavailable (run as user in fips group or with sudo)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Starting signer..."
|
||||||
|
echo "On first remote request, approve in prompt with [y] or [a]."
|
||||||
|
exec "$HOME/.local/bin/nsigner" --listen "${LISTEN_TARGET}"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod 0755 "${script_path}"
|
||||||
|
log "Wrote ${script_path}"
|
||||||
|
}
|
||||||
|
|
||||||
|
post_checks() {
|
||||||
|
export PATH="${PREFIX_BIN}:${PATH}"
|
||||||
|
|
||||||
|
log "Running post-install checks"
|
||||||
|
require_cmd nsigner
|
||||||
|
nsigner --version || true
|
||||||
|
|
||||||
|
log "User binaries installed in: ${PREFIX_BIN}"
|
||||||
|
log "If needed, add to shell PATH: export PATH=\"${PREFIX_BIN}:\$PATH\""
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||||
|
show_help
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $# -gt 0 ]]; then
|
||||||
|
err "Unknown option: $1"
|
||||||
|
show_help
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
install_runtime_deps
|
||||||
|
prepare_dirs
|
||||||
|
install_nsigner
|
||||||
|
write_signer_start_script
|
||||||
|
post_checks
|
||||||
|
|
||||||
|
log "Completed user-only install of n_signer"
|
||||||
|
log "Start signer with: ~/start_nsigner.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
17
packaging/qubes/install-policy.sh
Normal file
17
packaging/qubes/install-policy.sh
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
POLICY_SRC="packaging/qubes/policy.d/40-nsigner.policy"
|
||||||
|
POLICY_DST="/etc/qubes/policy.d/40-nsigner.policy"
|
||||||
|
|
||||||
|
if [ ! -f "$POLICY_SRC" ]; then
|
||||||
|
echo "Missing policy source: $POLICY_SRC" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -m 0644 "$POLICY_SRC" "$POLICY_DST"
|
||||||
|
|
||||||
|
echo "Installed qrexec policy to $POLICY_DST"
|
||||||
|
echo "Tag your signer qube in dom0, for example:"
|
||||||
|
echo " qvm-tags nsigner-vault add nsigner-signer"
|
||||||
|
echo "Then reload policy per your Qubes OS version procedures."
|
||||||
15
packaging/qubes/install-service.sh
Normal file
15
packaging/qubes/install-service.sh
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SERVICE_SRC="packaging/qubes/rpc/qubes.NsignerRpc"
|
||||||
|
SERVICE_DST="/etc/qubes-rpc/qubes.NsignerRpc"
|
||||||
|
|
||||||
|
if [ ! -f "$SERVICE_SRC" ]; then
|
||||||
|
echo "Missing service source: $SERVICE_SRC" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -m 0755 "$SERVICE_SRC" "$SERVICE_DST"
|
||||||
|
|
||||||
|
echo "Installed qrexec service to $SERVICE_DST"
|
||||||
|
echo "Executable bit set via install -m 0755."
|
||||||
5
packaging/qubes/policy.d/40-nsigner.policy
Normal file
5
packaging/qubes/policy.d/40-nsigner.policy
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Qubes OS qrexec policy for nsigner
|
||||||
|
# Syntax: service +argument source target action
|
||||||
|
# Allow specific qubes to reach the signer qube with user confirmation
|
||||||
|
qubes.NsignerRpc * @anyvm @tag:nsigner-signer ask default_target=nsigner-vault
|
||||||
|
qubes.NsignerRpc * @anyvm @anyvm deny
|
||||||
2
packaging/qubes/rpc/qubes.NsignerRpc
Normal file
2
packaging/qubes/rpc/qubes.NsignerRpc
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
exec /usr/local/bin/nsigner --listen qrexec
|
||||||
271
plans/auth_envelope_other_transports.md
Normal file
271
plans/auth_envelope_other_transports.md
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
# Plan: Where else does the Schnorr auth envelope earn its keep?
|
||||||
|
|
||||||
|
Status: **draft v1 — design exploration, for review**
|
||||||
|
|
||||||
|
> **Origin.** [`plans/caller_token_identity.md`](caller_token_identity.md) introduced a per-request, BIP-340-Schnorr-signed *auth envelope* (Nostr kind 27235) that gives the TCP transport an application-layer identity. The TCP plan was deliberate about scope: AF_UNIX and qrexec keep their kernel/hypervisor identities, stdio inherits them, and the envelope is gated to TCP only. This document asks the next obvious question — **for which other transports is the same envelope actually useful, and where is it noise or worse?** — and proposes a small, conservative extension.
|
||||||
|
|
||||||
|
The thesis up front: **the envelope is a tool for binding a stable program identity to a stream that the OS/hypervisor cannot itself attribute to a program**. Anywhere the OS already names the caller better than a pubkey could, the envelope adds attack surface for no gain. Anywhere the OS names a *machine* (uid, qube name) but cannot tell us *which program inside that machine*, the envelope earns its keep.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The framework: a 2x2 of what the substrate vouches for
|
||||||
|
|
||||||
|
Every transport `n_signer` supports today, or might support tomorrow, can be classified along two axes:
|
||||||
|
|
||||||
|
| | Substrate names a **principal** (uid, qube, account) | Substrate does **not** name a principal |
|
||||||
|
|---|---|---|
|
||||||
|
| **Substrate names a specific program** | A. Both. Strongest. | (impossible — naming a program implies naming its principal) |
|
||||||
|
| **Substrate names only a machine/account, not a program** | B. Principal-only. The "which program" gap. | C. Anonymous pipe. |
|
||||||
|
|
||||||
|
- **A** is hypothetical for our context. Not even AF_UNIX gives us this — `SO_PEERCRED.pid` is UX, not identity, because PIDs are reusable and the kernel does not bind PID to executable.
|
||||||
|
- **B** is where AF_UNIX, qrexec, and stdio-via-qrexec actually live today. We learn `uid:1000` or `qubes:foo`, but two different binaries running as the same uid in the same qube look identical.
|
||||||
|
- **C** is plain TCP, anonymous pipes, USB serial, Bluetooth — substrates that say only "some bytes arrived from somewhere."
|
||||||
|
|
||||||
|
**The auth envelope's job is to upgrade B → A and C → A** by letting the program-with-a-keypair speak for itself, on top of whatever the substrate already provides. It does *not* replace substrate identity; it composes with it.
|
||||||
|
|
||||||
|
This re-framing is the whole answer to the user's question. Every transport below is just a worked example of which cell it lives in.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Per-transport verdict
|
||||||
|
|
||||||
|
| Transport | Substrate identity | Cell | Envelope verdict |
|
||||||
|
|---|---|---|---|
|
||||||
|
| AF_UNIX (current) | `SO_PEERCRED.uid` (kernel-attested) | B | **Optional, off by default.** Adds program-level identity inside one uid; useful for multi-program desktops, redundant for single-user vault qubes. |
|
||||||
|
| qrexec (current) | `QREXEC_REMOTE_DOMAIN` (Xen-attested) | B | **Optional, off by default, but the most interesting case.** Solves the "many tools in one caller qube share one approval row" problem and the cross-qube state continuity question. See §3. |
|
||||||
|
| stdio (current, interactive) | inherited uid of launching shell | B | **Off.** The user is at the terminal; substrate identity is the human, and the envelope cannot improve on that. |
|
||||||
|
| stdio (current, qrexec-launched) | `QREXEC_REMOTE_DOMAIN` from env | B | **Same as qrexec row.** stdio over qrexec is qrexec by another name. |
|
||||||
|
| stdio (anonymous pipe, e.g. systemd socket-activated, or shelled out from a daemon) | none | C | **Required if used in production.** No principal at all otherwise. See §4. |
|
||||||
|
| TCP (implemented v2) | peer addr (forgeable beyond loopback) | C | **Required, on every request.** Already shipped; this plan does not modify it. |
|
||||||
|
| FIPS / serial-over-USB (planned) | device path; no principal | C | **Required.** Same reasoning as TCP — substrate provides confidentiality of the wire (in FIPS's case) but no caller identity. |
|
||||||
|
| Bluetooth / NFC (hypothetical) | MAC address (forgeable) | C | **Required.** |
|
||||||
|
|
||||||
|
The verdict column collapses to three modes — **off**, **optional** (accepted if present, ignored if absent), **required** (rejected if absent) — selectable per listener.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The qrexec story (the user's actual question)
|
||||||
|
|
||||||
|
Qubes is the case where the envelope buys the most, and where it most clearly solves a problem the user has named: *"keeping state between qubes is difficult."* Two distinct sub-problems hide under that phrase. The envelope addresses one of them cleanly and the other partially.
|
||||||
|
|
||||||
|
### 3.1 Sub-problem 1: many programs, one approval row
|
||||||
|
|
||||||
|
A typical caller qube — say `personal` — runs nostr_terminal, a browser extension, a CLI scratch tool, and a backup script. Today they all show up to the signer as `qubes:personal`. The user approves once, and from then on **every program in that qube is a signer for that nostr_index**. There is no way to express "the browser extension may sign kind-1 events but the CLI scratch tool may not." Approval granularity is one-per-qube, which is too coarse for any caller qube that holds more than one tool.
|
||||||
|
|
||||||
|
With the envelope optional-on for qrexec:
|
||||||
|
|
||||||
|
- nostr_terminal in `personal` signs envelopes with its caller pubkey `Pk_term`. Browser extension signs with `Pk_ext`. The signer records *two distinct caller_ids* — `qubes:personal+pubkey:<Pk_term>` and `qubes:personal+pubkey:<Pk_ext>` (composite form, see §6) — and prompts once per program.
|
||||||
|
- Programs that send no envelope keep the legacy `qubes:personal` identity and the legacy approval row. Backwards compatible.
|
||||||
|
- The user gets per-program revocation: removing the approval for `Pk_ext` does not affect nostr_terminal, even though both run inside `personal`.
|
||||||
|
|
||||||
|
This is the same connection-churn fix the TCP plan delivered, applied to a different ambiguity: not "same network address, different process" but "same qube, different program." The fix is identical because the underlying gap is identical — the substrate names something coarser than the unit we want to authorize.
|
||||||
|
|
||||||
|
### 3.2 Sub-problem 2: state continuity between qubes
|
||||||
|
|
||||||
|
In Qubes, programs that want shared identity across qubes — say a tool the user runs in both `personal` and `work`, or a tool installed in a disposable that is recreated each launch — cannot use any of the usual "save a token to disk" tricks because the qubes are separate state domains. The qrexec policy can route the call but cannot share secrets.
|
||||||
|
|
||||||
|
The envelope does not magically share state across qubes — but **it makes the question well-formed**. Two patterns become available:
|
||||||
|
|
||||||
|
- **Per-qube installation, distinct identity.** Tool `T` installed in `personal` generates its own keypair on first run, stored in that qube. Tool `T` in `work` generates a different keypair. The signer sees two callers; the user approves each. This is exactly what the security model wants — the qubes are *different trust domains*, so they should have different identities, full stop.
|
||||||
|
- **Per-tool identity, shared across qubes via deliberate seed copy.** A power user who genuinely wants "the same identity for nostr_terminal in `personal` and `work`" can copy nostr_terminal's seed (or derived caller key) between the two qubes themselves, using Qubes' usual file-copy primitives. The signer then sees one caller identity from two qubes. Whether that is a good idea is a user-policy decision; the protocol simply tolerates it.
|
||||||
|
|
||||||
|
The disposable-VM case is the most interesting. A disposable that re-rolls its caller keypair on every boot will re-prompt the signer every boot — which is correct behavior. *"This is a fresh untrusted environment"* is exactly what a disposable is. If the user wants disposable-but-recognized, they are asking for the per-tool seed pattern above and have to accept the tradeoff that the seed material outlives the disposable.
|
||||||
|
|
||||||
|
The envelope does not solve cross-qube state by itself. What it does is **give the user a stable, signer-visible name to make their state-management decisions against** — instead of forcing every cross-qube identity question through the qube-name primitive, which is too coarse to express the question.
|
||||||
|
|
||||||
|
### 3.3 Why "optional, off by default" rather than "required"
|
||||||
|
|
||||||
|
Required envelopes for qrexec would break every existing caller (shell scripts, the existing client examples in [`documents/qubes_client_examples.md`](../documents/qubes_client_examples.md)) and force every Qubes caller to grow Schnorr-signing capability before it can talk to the signer. That is too high a cost for a transport whose substrate identity is already strong.
|
||||||
|
|
||||||
|
Optional means:
|
||||||
|
|
||||||
|
- Old callers keep working unchanged. Their `caller_id` stays `qubes:<vm>`.
|
||||||
|
- New callers that *want* per-program approvals send envelopes and get `qubes:<vm>+pubkey:<hex>`.
|
||||||
|
- Operators who want to enforce envelope-on-qrexec for a hardened deployment can flip a flag (`--qrexec-require-auth`).
|
||||||
|
|
||||||
|
The default-off posture matches the TCP plan's principle: *give the weakest substrate the strongest application-layer identity.* qrexec is not the weakest; it is the second-strongest. It earns the upgrade only where the operator opts in.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The stdio story
|
||||||
|
|
||||||
|
stdio mode in `n_signer` has three real-world spawning patterns:
|
||||||
|
|
||||||
|
1. **Interactive: a user runs `nsigner --listen stdio` in a terminal and pipes a request in.** Substrate identity is the user's own uid. The user is physically present at the prompt anyway. **Envelope adds nothing.** Off.
|
||||||
|
|
||||||
|
2. **qrexec service: stdio with `QREXEC_REMOTE_DOMAIN` set.** Already covered as the qrexec row in §2. Same verdict as qrexec.
|
||||||
|
|
||||||
|
3. **Spawned by another program over an anonymous pipe.** Daemon `D` execs `nsigner --listen stdio` and pipes JSON-RPC over the inherited fds. The signer sees only `STDIN_FILENO`; there is no `SO_PEERCRED` (no socket), no `QREXEC_REMOTE_DOMAIN`, no peer address. Today the signer falls back to its own uid as caller_id, which is to say *the signer is its own caller* — meaningless as identity. **This is cell C.** If anyone is using this pattern, the envelope is the only available identity primitive.
|
||||||
|
|
||||||
|
The third pattern is rare but not hypothetical. systemd socket-activated services, container init wrappers, and "supervisor that spawns one signer process per request" deployments all fit. Today they are silently insecure (any program in the same uid can pipe a request in and be `caller_id = uid:1000`, indistinguishable from any other). With envelope-required for anonymous-pipe stdio, the daemon must hold a keypair and identify itself, and the signer can record per-daemon approvals.
|
||||||
|
|
||||||
|
The detection rule is mechanical: if the listen mode is stdio AND `QREXEC_REMOTE_DOMAIN` is unset AND `SO_PEERCRED` is unavailable on stdin (it is not a socket), classify the stream as anonymous-pipe stdio and require the envelope. Two existing pieces of code already participate in this branch: [`server_get_caller`](../src/server.c:1181) for the QREXEC env check, and the listen-mode plumbing in [`src/main.c:1043`](../src/main.c:1043).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. The AF_UNIX story (briefly)
|
||||||
|
|
||||||
|
AF_UNIX is the strongest substrate today and the one most likely to be misjudged. It is tempting to say "uid is enough." For single-user vault qubes it is. For shared workstations or for the per-program-revocation use case described in §3.1, it is not.
|
||||||
|
|
||||||
|
The right disposition for AF_UNIX is identical to qrexec: **optional envelope, off by default, on-flag to require**. Same composite caller_id form. The only difference is that the prompt UX should still surface `pid=N (exe=/path)` from `SO_PEERCRED` as decoration alongside the npub, because on a local machine the user can `ps -p N` to verify in a way they cannot for a remote pubkey.
|
||||||
|
|
||||||
|
This was [`plans/caller_token_identity.md`](caller_token_identity.md) §13 open question 5 ("should AF_UNIX accept an envelope as additional identity?") with the v2 default of "no." This document **revisits that default** and proposes "yes, but optional." See §6 and §8.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Composite caller_id form
|
||||||
|
|
||||||
|
When envelope is **optional and present** on a non-TCP transport, the caller_id needs to combine substrate identity and pubkey identity. The proposed form:
|
||||||
|
|
||||||
|
```
|
||||||
|
qubes:personal+pubkey:abc123…def
|
||||||
|
uid:1000+pubkey:abc123…def
|
||||||
|
anon-stdio+pubkey:abc123…def ; for case §4.3
|
||||||
|
```
|
||||||
|
|
||||||
|
Properties:
|
||||||
|
|
||||||
|
- **Lexicographic prefix matches substrate-only callers.** A policy entry for `qubes:personal` does **not** match `qubes:personal+pubkey:…` — they are distinct strings, distinct rows. This is intentional: opting into envelope auth means opting into a new, separate identity, not "same identity but with extra proof."
|
||||||
|
- **The plus character is illegal in every existing caller_id form** (uids are digits, pubkeys are hex, qube names are alphanumeric+hyphen). Using `+` as separator avoids ambiguity.
|
||||||
|
- **Width.** `caller_id` was widened to 80 bytes in v2 of the TCP plan ([`plans/caller_token_identity.md:181`](caller_token_identity.md:181)). The composite form needs `len("qubes:") + 64 + len("+pubkey:") + 64 + 1` ≈ 144 bytes. Bumps to 160 to round.
|
||||||
|
|
||||||
|
Approvals attach to the composite string. A user who approves the composite form is approving "this program *running in* this qube," not "this program anywhere" and not "this qube's anything." That is the correct semantics for the per-program-revocation use case.
|
||||||
|
|
||||||
|
For TCP — where substrate identity does not exist — the form remains the bare `pubkey:<hex>` from v2. No change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Wire-protocol changes (small)
|
||||||
|
|
||||||
|
The existing envelope already carries everything needed; only the verification gate changes per transport.
|
||||||
|
|
||||||
|
### 7.1 Per-listener auth posture
|
||||||
|
|
||||||
|
Three settings, one per listener:
|
||||||
|
|
||||||
|
| Posture | Behavior on receipt |
|
||||||
|
|---|---|
|
||||||
|
| `off` | `auth` field, if present, is **ignored**. caller_id is substrate-only. Default for AF_UNIX, qrexec, interactive stdio. |
|
||||||
|
| `optional` | If `auth` is present and verifies → composite caller_id. If `auth` is absent → substrate-only caller_id. If `auth` is present and fails verification → reject. Operator opt-in for AF_UNIX/qrexec/stdio. |
|
||||||
|
| `required` | `auth` must be present and verify. Default for TCP, FIPS-serial, anonymous-pipe stdio. |
|
||||||
|
|
||||||
|
CLI plumbing extends what the TCP plan already added:
|
||||||
|
|
||||||
|
```
|
||||||
|
--listen unix --auth off # default
|
||||||
|
--listen unix --auth optional
|
||||||
|
--listen qrexec --auth optional
|
||||||
|
--listen stdio --auth required # for anon-pipe deployments
|
||||||
|
--listen tcp:... --auth required # only legal value for tcp; kept explicit
|
||||||
|
```
|
||||||
|
|
||||||
|
The auth-skew-seconds and nonce cache are listener-context state; they already exist in `server_ctx_t` from v2 and are reused unmodified.
|
||||||
|
|
||||||
|
### 7.2 The envelope itself: zero changes
|
||||||
|
|
||||||
|
Same kind 27235, same three required tags, same JCS body hash, same Schnorr verification primitive [`nostr_verify_event_signature`](../resources/nostr_core_lib/nostr_core/nip001.h:20). A client that signs envelopes for TCP today can sign them for qrexec tomorrow with no code change. This is the single most important property of the proposal — it preserves the v2 envelope as a portable, transport-independent artifact.
|
||||||
|
|
||||||
|
### 7.3 Replay cache scoping
|
||||||
|
|
||||||
|
One subtle question: is the nonce cache per-listener or process-global? Process-global is simpler and strictly stricter (an envelope replayed across two listeners is also rejected). Per-listener is more permissive but has no real attack-model justification. **Default: process-global**, unchanged from v2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Decision matrix and recommended defaults
|
||||||
|
|
||||||
|
| Transport | Default posture | Operator can flip to | Rationale |
|
||||||
|
|---|---|---|---|
|
||||||
|
| AF_UNIX | off | optional, required | uid is strong; envelope is for power users who need per-program granularity. |
|
||||||
|
| qrexec | off | optional, required | qube is strong; envelope solves §3.1 for users who hit it. |
|
||||||
|
| stdio (interactive) | off | (locked off) | Envelope cannot improve on a human at the terminal. |
|
||||||
|
| stdio (qrexec-launched) | inherits qrexec posture | (inherits) | Same substrate. |
|
||||||
|
| stdio (anonymous pipe) | required | (locked required) | No substrate identity exists; refusing is the only safe default. |
|
||||||
|
| TCP | required | (locked required) | Already v2 default. Unchanged. |
|
||||||
|
| FIPS / USB-serial (planned) | required | (locked required) | Same as TCP — wire identity is fictional. |
|
||||||
|
|
||||||
|
"Locked" rows reflect transports where flipping the posture would re-introduce the gap the envelope was designed to close, and the CLI rejects the change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Implementation order
|
||||||
|
|
||||||
|
Each step is independently mergeable, gated behind the per-listener `--auth` flag default of `off` for the new transports so existing callers see no behavior change.
|
||||||
|
|
||||||
|
1. **Refactor: lift the v2 auth gate out of the TCP-specific branch in [`server_handle_one`](../src/server.c:1192) into a transport-agnostic helper** parameterized by posture. No semantic change yet; TCP listener still passes `required`.
|
||||||
|
2. **Add the `--auth {off|optional|required}` CLI flag** with per-listener storage on `server_ctx_t`. Default off for unix/qrexec, required for tcp, off-locked for interactive stdio, required-locked for anonymous-pipe stdio.
|
||||||
|
3. **Add anonymous-pipe-stdio detection.** In [`server_get_caller`](../src/server.c:1181), when listen mode is stdio and `QREXEC_REMOTE_DOMAIN` is unset, probe `SO_PEERCRED` on stdin; on failure mark the stream as anonymous-pipe and require auth.
|
||||||
|
4. **Composite caller_id format** in [`caller_identity_t`](../src/server.c:404). Widen to 160. Add a small helper `caller_id_compose(buf, sz, substrate_kind, substrate_value, pubkey_hex)`.
|
||||||
|
5. **`--preapprove` extension.** Accept the composite form (`caller=qubes:personal+pubkey:<hex>`, `caller=uid:1000+pubkey:<hex>`). Existing forms stay valid.
|
||||||
|
6. **Tests.**
|
||||||
|
- Optional-mode AF_UNIX accepting both with-envelope and without-envelope requests, producing two distinct caller_ids.
|
||||||
|
- Optional-mode qrexec with two distinct pubkeys from one source qube → two prompts, two approvals, independent revocation.
|
||||||
|
- Anonymous-pipe-stdio rejects no-envelope requests with code 2014.
|
||||||
|
- TCP behavior unchanged (regression check on v2).
|
||||||
|
7. **Documentation.**
|
||||||
|
- [`documents/SECURITY.md`](../documents/SECURITY.md) §3 identity table: add the composite kinds; clarify that envelope is now a portable identity layer, not a TCP-only feature.
|
||||||
|
- [`documents/QUBES_OS.md`](../documents/QUBES_OS.md): add a §3.4 on per-program identity within a qube and the disposable-VM tradeoff.
|
||||||
|
- [`documents/CLIENT_IMPLEMENTATION.md`](../documents/CLIENT_IMPLEMENTATION.md): note that any client may attach an envelope on any transport; the signer's posture decides whether it is honored or required.
|
||||||
|
|
||||||
|
## 10. What this plan deliberately does not do
|
||||||
|
|
||||||
|
- **Does not change TCP behavior.** The v2 contract on TCP — required envelope, every request — is preserved exactly.
|
||||||
|
- **Does not introduce a new envelope format, kind, or canonicalization.** Reuses kind 27235 and the JCS body-hash rule from v2 verbatim.
|
||||||
|
- **Does not propose persistent state** anywhere. Approvals stay in RAM; no caller registry on disk; the §1 "crash equals total wipe" property of [`documents/SECURITY.md:19`](../documents/SECURITY.md:19) is unchanged.
|
||||||
|
- **Does not invent an answer to cross-qube state continuity.** It articulates the question and gives the user a primitive (a stable pubkey-named caller) against which to make their own state decisions.
|
||||||
|
- **Does not add transport encryption** for any substrate. Confidentiality is the substrate's job (FIPS, qrexec, AF_UNIX with abstract namespace, loopback). The envelope is auth, not encryption.
|
||||||
|
|
||||||
|
## 11. Open questions for review
|
||||||
|
|
||||||
|
1. **Is the composite caller_id form (`substrate+pubkey:...`) the right disposition, or should the envelope, when present, *override* substrate identity entirely (replacing rather than composing)?** The override form is simpler — one identity, not two — but it discards information the user might want (the npub is verified; the qube name is hypervisor-attested; both data points are useful at the prompt). Default: compose.
|
||||||
|
2. **Should anonymous-pipe stdio be required or rejected outright?** A reasonable case can be made that `nsigner --listen stdio` over a non-tty, non-socket fd is just *misuse* and should refuse to start rather than silently demand envelopes. Default: require envelope; allow operator to disable the listen mode entirely if undesired.
|
||||||
|
3. **Should the `--auth optional` setting on AF_UNIX/qrexec be a per-listener flag (today's proposal) or per-method (e.g. envelope required for `sign_event` but not for `get_public_key`)?** Per-method adds policy surface for limited gain; substrate identity is already the per-method gate via the existing role table. Default: per-listener only.
|
||||||
|
4. **Does the v2 nonce cache size of 1024 still suffice when up to four listeners may share it?** Yes — the bound is "envelopes per skew window," not "listeners." Confirmed sufficient. No change.
|
||||||
|
5. **Should `qubes:<vm>+pubkey:<hex>` and `pubkey:<hex>` (TCP) for the same pubkey be considered the same caller for `--preapprove` convenience?** No. They are different identities by construction (different substrates, different threat models). An operator who wants both pre-approved writes both lines. Default: keep them distinct.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix A — Mermaid: revised per-transport decision tree
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[Request arrives on a listener] --> B{listener auth posture}
|
||||||
|
B -- off --> C[ignore any auth field]
|
||||||
|
C --> D[caller_id = substrate-only]
|
||||||
|
B -- optional --> E{auth field present?}
|
||||||
|
E -- no --> D
|
||||||
|
E -- yes --> F[verify envelope per v2 rules]
|
||||||
|
F -- fail --> G[reject 2010-2017]
|
||||||
|
F -- ok --> H[caller_id = substrate + pubkey:hex]
|
||||||
|
B -- required --> I{auth field present?}
|
||||||
|
I -- no --> J[reject 2014 auth_envelope_required]
|
||||||
|
I -- yes --> F
|
||||||
|
D --> K[normal policy_check]
|
||||||
|
H --> K
|
||||||
|
K --> L{verdict}
|
||||||
|
L -- ALLOW --> M[dispatch RPC]
|
||||||
|
L -- DENY --> N[2001 policy_denied]
|
||||||
|
L -- PROMPT --> O[approve / save / register caller_id]
|
||||||
|
O --> M
|
||||||
|
```
|
||||||
|
|
||||||
|
## Appendix B — Worked example: per-program identity inside a qube
|
||||||
|
|
||||||
|
Caller qube `personal` runs two tools:
|
||||||
|
|
||||||
|
- nostr_terminal, holding caller key `Pk_term`.
|
||||||
|
- A custom shell script, no keypair.
|
||||||
|
|
||||||
|
Operator starts the signer with:
|
||||||
|
|
||||||
|
```
|
||||||
|
nsigner --listen qrexec --auth optional \
|
||||||
|
--preapprove caller=qubes:personal+pubkey:Pk_term,role=main
|
||||||
|
```
|
||||||
|
|
||||||
|
First request from nostr_terminal carries an envelope signed by `Pk_term`. Signer verifies, builds caller_id `qubes:personal+pubkey:Pk_term`, hits the pre-approval, signs without prompting.
|
||||||
|
|
||||||
|
First request from the shell script carries no envelope. Signer skips verification, builds caller_id `qubes:personal`, finds no approval, prompts the user. User can approve `[a]` for the shell script independently of nostr_terminal.
|
||||||
|
|
||||||
|
Later, the user wants to revoke the shell script's access (a future TUI hotkey). They remove the `qubes:personal` row. nostr_terminal continues to work because its row `qubes:personal+pubkey:Pk_term` is untouched. **Per-program revocation, achieved without any change to qrexec policy or the qube boundary.**
|
||||||
|
|
||||||
|
This is the concrete answer to the user's "is this useful for other transports" question — yes, in qrexec specifically, where it converts qube-granularity approvals into program-granularity approvals while leaving the qube boundary itself unchanged.
|
||||||
483
plans/caller_token_identity.md
Normal file
483
plans/caller_token_identity.md
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
# Plan: Caller-pubkey identity with required Schnorr authentication for URL/TCP
|
||||||
|
|
||||||
|
Status: **draft v2 — for review**
|
||||||
|
|
||||||
|
> **History.** Draft v1 of this document proposed a random 32-byte bearer token persisted to a `0600` file on both ends. That draft was rejected on two grounds: (1) it violated [`documents/SECURITY.md:19`](../documents/SECURITY.md:19) — "Crash equals total wipe" — by writing tokens to disk, and (2) bearer credentials over a transport already chosen as the *least* trusted is the wrong security gradient. v2 replaces the bearer token with a Schnorr-authenticated public key, removes all disk persistence, and requires cryptographic proof-of-possession on every TCP/URL request from day one.
|
||||||
|
|
||||||
|
This plan addresses the same originating bug — every TCP reconnect re-fires the approval prompt because `caller_id` is built from the ephemeral `addr:port` pair — but does so by giving the weakest transport the strongest application-layer identity, not the weakest. AF_UNIX and qrexec retain their existing kernel- and hypervisor-vouched identities; TCP/URL gains BIP-340 Schnorr authentication using primitives the signer already links.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goals
|
||||||
|
|
||||||
|
1. **One approval per program-keypair, not per TCP connection.** Two requests carrying the same `caller_pubkey` resolve to the same policy entry regardless of source port, transport jitter, or reconnect.
|
||||||
|
2. **Cryptographic proof-of-possession on every TCP/URL request.** The wire identity is not just a name; it is backed by a signature that only the holder of the corresponding private key can produce.
|
||||||
|
3. **Replay-resistant within a bounded window.** A captured request cannot be replayed against the signer indefinitely.
|
||||||
|
4. **No disk persistence — anywhere.** No tokens on disk, no caller registry on disk, no policy on disk. "Crash equals total wipe" applies to this feature unmodified.
|
||||||
|
5. **Stronger transports keep their stronger primitives.** AF_UNIX still uses `SO_PEERCRED`; qrexec still uses `QREXEC_REMOTE_DOMAIN`. Neither acquires a new spoofable claim field, and neither is required to send signatures.
|
||||||
|
6. **Use only `nostr_core_lib` and existing dependencies.** No new crypto libraries. No new hash functions. The auth envelope is a Nostr event, verified by a primitive [`nostr_verify_event_signature`](../resources/nostr_core_lib/nostr_core/nip001.h:20) the signer already trusts.
|
||||||
|
|
||||||
|
## 2. Non-goals
|
||||||
|
|
||||||
|
- **No transport-level encryption / TLS.** FIPS still provides confidentiality of the substrate. Application-layer auth and transport encryption are separate concerns.
|
||||||
|
- **No mutual authentication of the signer to the client.** This plan authenticates the client to the signer. The reverse direction is future work and intentionally out of scope.
|
||||||
|
- **No persistence of any state.** Approvals stay in RAM. A signer restart re-prompts every caller exactly once. A client restart re-prompts itself once if it lost its in-memory keypair, or zero times if the client retains its own identity in its own memory.
|
||||||
|
- **No new transports.** AF_UNIX, qrexec, stdio, TCP unchanged at the socket layer.
|
||||||
|
- **No PID-as-identity.** Documented rejection in §10.
|
||||||
|
- **No bearer tokens.** Explicit reversal of v1.
|
||||||
|
- **No change to the deny-by-default rule.** A new pubkey shows up exactly once at the prompt; everything in [`plans/deny_by_default_approvals.md`](deny_by_default_approvals.md) applies unchanged.
|
||||||
|
|
||||||
|
## 3. The model in one paragraph
|
||||||
|
|
||||||
|
For TCP/URL transport, every JSON-RPC request must carry an **auth envelope** — a small Nostr-shaped event signed by the caller's private key. The signer verifies that signature with [`nostr_verify_event_signature`](../resources/nostr_core_lib/nostr_core/nip001.h:20) before any policy logic runs. The verified `pubkey` field becomes the `caller_id` for that request (`pubkey:<hex>` form), feeding straight into the existing policy table that already handles `uid:1000`, `qubes:foo`, `tcp:[addr]:port`. On first contact for a given pubkey, the existing deny-by-default prompt fires; the user sees the npub, an optional client-supplied label, and the verb/role; on `[a]` the pubkey is recorded as an approval entry the same way any other caller is. Subsequent requests from the same pubkey hit the approval and are served silently. AF_UNIX and qrexec are unaffected — they continue to use kernel/hypervisor identity and ignore any auth envelope they receive. Nothing is written to disk on either side at any point.
|
||||||
|
|
||||||
|
## 4. Concept additions to the existing model
|
||||||
|
|
||||||
|
[`documents/SECURITY.md:57`](../documents/SECURITY.md:57) defines three concepts: identity, index, approval. This plan adds nothing visible to the user beyond an alternate identity *kind*. There is no fourth concept. Specifically:
|
||||||
|
|
||||||
|
- A **pubkey-identified caller** is a new identity kind, alongside `unix_peer`, `qubes`, `tcp_local`, `tcp_remote`. Its `caller_id` form is `pubkey:<64 hex chars>`.
|
||||||
|
- An **auth envelope** is a wire-protocol artifact, not a user-facing concept. The user never sees the word "envelope"; they see "caller: npub1abc…xyz" at the prompt.
|
||||||
|
- An **approval** is unchanged: the same `(caller_id, role) → PROMPT_NEVER` row in the same in-memory policy table.
|
||||||
|
|
||||||
|
The only document-level change is that [`documents/SECURITY.md`](../documents/SECURITY.md) §11's "tcp_remote (planned)" and "fips (planned)" rows ([`documents/SECURITY.md:98-99`](../documents/SECURITY.md:98)) become "implemented; identity = caller pubkey verified by Schnorr signature."
|
||||||
|
|
||||||
|
## 5. Per-transport identity primitives — the unified picture
|
||||||
|
|
||||||
|
| Transport | Identity primitive | Validation | `caller_id` form | Auth envelope required? |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| AF_UNIX | `SO_PEERCRED.uid` (and `pid` for UX) | Kernel-attested. | `uid:<n>` | **No.** Kernel vouches. |
|
||||||
|
| qrexec | `QREXEC_REMOTE_DOMAIN` | Hypervisor-attested. | `qubes:<vm>` | **No.** Xen vouches. |
|
||||||
|
| stdio | inherited environment (qrexec or interactive) | As above. | `qubes:<vm>` or `uid:<n>` | **No.** |
|
||||||
|
| TCP / FIPS | `caller_pubkey` from auth envelope | BIP-340 Schnorr signature verified by `nostr_verify_event_signature`. | `pubkey:<hex>` | **Yes. Required on every request.** |
|
||||||
|
|
||||||
|
A TCP request that arrives **without** a valid auth envelope is rejected before any policy logic runs. There is no fallback to `tcp:[addr]:port` identity; v2 retires that form entirely for the TCP listener. Operators who relied on `--preapprove caller=tcp:...` must migrate to `--preapprove caller=pubkey:<hex>` (see §7.5).
|
||||||
|
|
||||||
|
## 6. Wire protocol: the auth envelope
|
||||||
|
|
||||||
|
### 6.1 Shape
|
||||||
|
|
||||||
|
The auth envelope is a single JSON object placed at the top level of the request, alongside (not inside) `method`/`params`. It is structured exactly like a NIP-01 Nostr event so that [`nostr_verify_event_signature`](../resources/nostr_core_lib/nostr_core/nip001.h:20) verifies it directly with no custom canonicalization code on the signer side:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "rpc-42",
|
||||||
|
"method": "sign_event",
|
||||||
|
"params": { ... },
|
||||||
|
"auth": {
|
||||||
|
"id": "<32-byte event id, hex>",
|
||||||
|
"pubkey": "<32-byte x-only secp256k1 pubkey, hex>",
|
||||||
|
"created_at": 1730000000,
|
||||||
|
"kind": 27235,
|
||||||
|
"tags": [
|
||||||
|
["nsigner_rpc", "rpc-42"],
|
||||||
|
["nsigner_method", "sign_event"],
|
||||||
|
["nsigner_body_hash", "<sha256(canonical body), hex>"]
|
||||||
|
],
|
||||||
|
"content": "<optional client label, e.g. \"nostr_terminal@laptop\">",
|
||||||
|
"sig": "<64-byte Schnorr signature, hex>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `kind` value `27235` is the same kind NIP-42 uses for client authentication to relays. We are explicitly piggybacking on that semantic: this is "client authenticates to a service it wants to use," and reusing the existing kind keeps us inside Nostr conventions rather than inventing a new event kind. The signer accepts kind 27235 events whose tag set marks them as nsigner-bound.
|
||||||
|
|
||||||
|
### 6.2 Required tags
|
||||||
|
|
||||||
|
Three tags are required on every envelope. The signer rejects envelopes missing any of them with `auth_envelope_malformed` (code 2010).
|
||||||
|
|
||||||
|
| Tag name | Value | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `nsigner_rpc` | The request's `id` field, copied verbatim. | Binds the signature to this specific request id; a captured envelope cannot be reused with a different request id. |
|
||||||
|
| `nsigner_method` | The request's `method` field, copied verbatim. | Binds the signature to the verb. A captured `get_public_key` envelope cannot be replayed as `sign_event`. |
|
||||||
|
| `nsigner_body_hash` | Hex SHA-256 of the canonicalized `params` object — see §6.3. | Binds the signature to the request payload. The signer recomputes this hash and rejects on mismatch with `auth_body_mismatch` (code 2011). |
|
||||||
|
|
||||||
|
### 6.3 Canonicalization of `params`
|
||||||
|
|
||||||
|
To make `nsigner_body_hash` deterministic across implementations, `params` must be hashed in a single canonical form. To avoid inventing a new canonicalizer, we adopt **JCS** (RFC 8785, JSON Canonicalization Scheme) — the same canonicalization Nostr already uses for event IDs in NIP-01. The body hash is `SHA-256(JCS(params))`, computed by both client and signer using [`nostr_sha256`](../resources/nostr_core_lib/nostr_core/utils.h:46).
|
||||||
|
|
||||||
|
If `params` is absent from the request, `nsigner_body_hash` is `SHA-256("null")` (the canonicalization of JSON `null`), which is a fixed 64-char constant the signer can use without per-request hashing for the no-params case.
|
||||||
|
|
||||||
|
### 6.4 Signature verification
|
||||||
|
|
||||||
|
On receipt of a TCP request, the signer:
|
||||||
|
|
||||||
|
1. Extracts the `auth` object. If absent, reject with `auth_envelope_required` (code 2014).
|
||||||
|
2. Validates the envelope is structurally a NIP-01 event (id is sha256 of canonical event header; `pubkey`, `sig`, `created_at`, `kind`, `tags`, `content` present and well-typed). Library function [`nostr_validate_event_structure`](../resources/nostr_core_lib/nostr_core/nip001.h:19) handles this.
|
||||||
|
3. Verifies the Schnorr signature: [`nostr_verify_event_signature(auth_event)`](../resources/nostr_core_lib/nostr_core/nip001.h:20). Reject with `auth_signature_invalid` (code 2012) on failure.
|
||||||
|
4. Checks `kind == 27235`. Reject with `auth_kind_invalid` (code 2013) otherwise.
|
||||||
|
5. Confirms `nsigner_rpc` tag matches request `id` and `nsigner_method` tag matches request `method`. Reject with `auth_envelope_mismatch` (code 2015) on either mismatch.
|
||||||
|
6. Computes `SHA-256(JCS(params))` and confirms it matches `nsigner_body_hash`. Reject with `auth_body_mismatch` (code 2011) on mismatch.
|
||||||
|
7. Applies replay protection — see §6.5.
|
||||||
|
8. On success, sets `caller.caller_id = "pubkey:" + hex(pubkey)`. Policy lookup proceeds against this caller_id.
|
||||||
|
|
||||||
|
### 6.5 Replay protection
|
||||||
|
|
||||||
|
Two layered defenses, neither requiring disk:
|
||||||
|
|
||||||
|
#### 6.5.1 Timestamp window
|
||||||
|
|
||||||
|
`auth.created_at` must satisfy `|now - created_at| <= AUTH_CLOCK_SKEW_SEC`. Default `AUTH_CLOCK_SKEW_SEC = 30`. Configurable via `--auth-skew-seconds` at startup. Reject with `auth_envelope_stale` (code 2016) if outside the window.
|
||||||
|
|
||||||
|
The 30-second window is small enough that a captured envelope is a poor reuse target and large enough to tolerate ordinary clock drift in the FIPS-mediated path. Operators with looser requirements can widen it; operators with NTP-locked deployments can narrow it.
|
||||||
|
|
||||||
|
#### 6.5.2 Nonce / id cache
|
||||||
|
|
||||||
|
The signer maintains an in-memory ring buffer of recently-seen `auth.id` values:
|
||||||
|
|
||||||
|
```c
|
||||||
|
#define AUTH_NONCE_CACHE_SIZE 1024
|
||||||
|
typedef struct {
|
||||||
|
uint8_t id[32];
|
||||||
|
uint64_t seen_at; /* monotonic seconds */
|
||||||
|
} auth_nonce_entry_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
auth_nonce_entry_t entries[AUTH_NONCE_CACHE_SIZE];
|
||||||
|
int head;
|
||||||
|
int count;
|
||||||
|
} auth_nonce_cache_t;
|
||||||
|
```
|
||||||
|
|
||||||
|
On verified envelope, the signer linearly scans the cache for `auth.id`. If found, reject with `auth_replay_detected` (code 2017). If not, insert at head, evicting the tail if full.
|
||||||
|
|
||||||
|
The cache size of 1024 paired with the 30-second window means at sustained 33+ requests/second from any caller would push older entries out before they expire — at which point the timestamp window catches the replay anyway. The two defenses overlap intentionally: the cache catches fast replays inside the window; the timestamp catches slow replays outside it.
|
||||||
|
|
||||||
|
The cache is purely in-memory. On signer restart it starts empty, which is safe because the timestamp window also resets the universe of acceptable envelopes — any envelope created before the restart is now outside the window and would be rejected on `created_at` grounds before reaching the nonce check.
|
||||||
|
|
||||||
|
### 6.6 Error codes added
|
||||||
|
|
||||||
|
| Code | Symbolic name | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| 2010 | `auth_envelope_malformed` | Required field or tag missing / wrong type. |
|
||||||
|
| 2011 | `auth_body_mismatch` | `nsigner_body_hash` did not match recomputed hash of `params`. |
|
||||||
|
| 2012 | `auth_signature_invalid` | Schnorr verification failed. |
|
||||||
|
| 2013 | `auth_kind_invalid` | Auth event kind ≠ 27235. |
|
||||||
|
| 2014 | `auth_envelope_required` | TCP request arrived with no `auth` object. |
|
||||||
|
| 2015 | `auth_envelope_mismatch` | `nsigner_rpc` or `nsigner_method` tag did not match request envelope. |
|
||||||
|
| 2016 | `auth_envelope_stale` | `created_at` outside skew window. |
|
||||||
|
| 2017 | `auth_replay_detected` | `auth.id` already seen in nonce cache. |
|
||||||
|
|
||||||
|
These are placed in their own contiguous range so client code can identify "auth-layer error → bug in my signing code" vs "policy-layer error → user denied."
|
||||||
|
|
||||||
|
## 7. Signer-side data model and code changes
|
||||||
|
|
||||||
|
### 7.1 Extension to `caller_identity_t`
|
||||||
|
|
||||||
|
[`src/server.c:404`](../src/server.c:404) currently has:
|
||||||
|
|
||||||
|
```c
|
||||||
|
typedef struct {
|
||||||
|
int kind;
|
||||||
|
char caller_id[64];
|
||||||
|
char source_qube[64];
|
||||||
|
} caller_identity_t;
|
||||||
|
```
|
||||||
|
|
||||||
|
Becomes:
|
||||||
|
|
||||||
|
```c
|
||||||
|
typedef struct {
|
||||||
|
int kind;
|
||||||
|
char caller_id[80]; /* widened: "pubkey:" + 64 hex + NUL fits */
|
||||||
|
char source_qube[64];
|
||||||
|
/* Filled in only when an auth envelope was verified on this request. */
|
||||||
|
int auth_present; /* 0 or 1 */
|
||||||
|
uint8_t auth_pubkey[32]; /* present iff auth_present */
|
||||||
|
char auth_label[64]; /* envelope `content` field, sanitized */
|
||||||
|
/* UX context, never used as policy key. */
|
||||||
|
pid_t peer_pid; /* SO_PEERCRED.pid for AF_UNIX, else 0 */
|
||||||
|
char peer_addr[64]; /* tcp:[...]:port, used in prompt context */
|
||||||
|
} caller_identity_t;
|
||||||
|
```
|
||||||
|
|
||||||
|
The widened `caller_id` ripples through every translation unit's headerless declaration block. Mechanical change.
|
||||||
|
|
||||||
|
### 7.2 New translation unit: `src/auth_envelope.c`
|
||||||
|
|
||||||
|
A new file owns auth-envelope parsing, verification, and the nonce cache. Public surface:
|
||||||
|
|
||||||
|
```c
|
||||||
|
typedef enum {
|
||||||
|
AUTH_OK = 0,
|
||||||
|
AUTH_ERR_MALFORMED,
|
||||||
|
AUTH_ERR_BODY_MISMATCH,
|
||||||
|
AUTH_ERR_SIG_INVALID,
|
||||||
|
AUTH_ERR_KIND_INVALID,
|
||||||
|
AUTH_ERR_REQUIRED,
|
||||||
|
AUTH_ERR_TAG_MISMATCH,
|
||||||
|
AUTH_ERR_STALE,
|
||||||
|
AUTH_ERR_REPLAY
|
||||||
|
} auth_result_t;
|
||||||
|
|
||||||
|
/* Verify the auth envelope embedded in `request_root` against the request's
|
||||||
|
* id, method, and params. On AUTH_OK, fills caller->auth_pubkey and
|
||||||
|
* caller->auth_label. Does not touch caller_id. */
|
||||||
|
auth_result_t auth_envelope_verify(cJSON *request_root,
|
||||||
|
auth_nonce_cache_t *nonce_cache,
|
||||||
|
int skew_seconds,
|
||||||
|
uint64_t now_unix,
|
||||||
|
caller_identity_t *caller);
|
||||||
|
|
||||||
|
void auth_nonce_cache_init(auth_nonce_cache_t *cache);
|
||||||
|
```
|
||||||
|
|
||||||
|
The implementation is thin: extract `auth` cJSON object, run library validations, run our tag/body/replay checks, return. All the heavy crypto lifting is `nostr_verify_event_signature`.
|
||||||
|
|
||||||
|
A corresponding [`tests/test_auth_envelope.c`](../tests/test_auth_envelope.c) covers the eight error paths with hand-crafted envelopes (using a fixed test keypair) and the happy path.
|
||||||
|
|
||||||
|
### 7.3 Ordering in `server_handle_one`
|
||||||
|
|
||||||
|
[`src/server.c:1192`](../src/server.c:1192) gains an auth gate between framed-receive and selector-resolve, but only on TCP. Pseudocode for the revised flow:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. accept(); server_get_caller(fd, &caller); /* unchanged: kind, peer_addr */
|
||||||
|
2. transport_recv_framed(fd, &request);
|
||||||
|
3. if (caller.kind == NSIGNER_LISTEN_TCP) {
|
||||||
|
parse request as cJSON;
|
||||||
|
auth_result = auth_envelope_verify(root, &ctx->nonce_cache,
|
||||||
|
ctx->auth_skew, now, &caller);
|
||||||
|
if (auth_result != AUTH_OK) { send error 2010-2017; close; return; }
|
||||||
|
snprintf(caller.caller_id, ..., "pubkey:%s", hex(caller.auth_pubkey));
|
||||||
|
}
|
||||||
|
/* AF_UNIX/qrexec: caller.caller_id already set; auth envelope, if any,
|
||||||
|
is silently ignored. */
|
||||||
|
4. extract method + selector; /* unchanged */
|
||||||
|
5. resolve role; /* unchanged */
|
||||||
|
6. policy_check(caller.caller_id, ...); /* unchanged */
|
||||||
|
7. prompt if PROMPT; /* unchanged */
|
||||||
|
8. dispatch; /* unchanged */
|
||||||
|
```
|
||||||
|
|
||||||
|
The auth gate is a single function call. Existing code paths are touched only at the point where `caller.caller_id` is overwritten on success.
|
||||||
|
|
||||||
|
### 7.4 Server context additions
|
||||||
|
|
||||||
|
[`server_ctx_t`](../src/server.c) gains:
|
||||||
|
|
||||||
|
```c
|
||||||
|
auth_nonce_cache_t nonce_cache; /* in-memory, zeroed at startup, never persisted */
|
||||||
|
int auth_skew_seconds; /* default 30, settable via --auth-skew-seconds */
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.5 `--preapprove` extension
|
||||||
|
|
||||||
|
[`parse_preapprove_spec`](../src/policy.c:553) gains a new caller form:
|
||||||
|
|
||||||
|
```
|
||||||
|
--preapprove caller=pubkey:<64 hex>,role=main
|
||||||
|
--preapprove caller=pubkey:<64 hex>,nostr_index=1
|
||||||
|
```
|
||||||
|
|
||||||
|
The pubkey is the **client's** caller pubkey — a 32-byte x-only secp256k1 hex value. No file reference; the operator has the pubkey directly. There is no `pubkey_file` form because the pubkey is not secret and embedding it in the unit file is fine.
|
||||||
|
|
||||||
|
The `tcp:` caller form is **deprecated** for the TCP listener (not removed; AF_UNIX and qrexec preapprovals are unaffected — they never used `tcp:`). On a `--preapprove caller=tcp:...` for a TCP listener, the signer prints a deprecation warning at startup explaining that TCP requests now require auth envelopes and the entry will never match. This is documented in [`documents/N_OS_TR_AGENT_CHANGES.md`](../documents/N_OS_TR_AGENT_CHANGES.md).
|
||||||
|
|
||||||
|
### 7.6 No persistence — confirmed property
|
||||||
|
|
||||||
|
This plan introduces:
|
||||||
|
- An in-memory `caller_identity_t` per request (already exists; widened).
|
||||||
|
- An in-memory `auth_nonce_cache_t` per server context (new, ring buffer, RAM only).
|
||||||
|
- New entries in the existing in-memory `policy_table_t` keyed on `pubkey:<hex>` (no schema change).
|
||||||
|
|
||||||
|
It does **not** introduce:
|
||||||
|
- Any file written by the signer.
|
||||||
|
- Any file required at startup by the signer (beyond the already-required mnemonic input).
|
||||||
|
- Any state that survives `server_stop`.
|
||||||
|
|
||||||
|
"Crash equals total wipe" remains literally true.
|
||||||
|
|
||||||
|
## 8. Client-side requirements (e.g. nostr_terminal)
|
||||||
|
|
||||||
|
### 8.1 Caller keypair
|
||||||
|
|
||||||
|
The client needs a secp256k1 keypair for the purpose of authenticating to n_signer. Three sourcing strategies are supported, and the choice is the client's:
|
||||||
|
|
||||||
|
1. **Reuse the program's existing Nostr identity.** A program like nostr_terminal that already has a published Nostr pubkey can use that same keypair as its caller identity. The user sees a familiar npub at the prompt and recognizes "yes, that's nostr_terminal's pubkey."
|
||||||
|
2. **Derive a dedicated caller identity from the program's seed.** A program with a stored seed can derive a separate `m/44'/1237'/<n>'/0/<m>` key as a stable "RPC client identity" distinct from its public posting key. The user sees a unique-to-the-program-installation npub.
|
||||||
|
3. **Generate ephemerally per session.** A program that has no persistent identity at all generates a fresh secp256k1 key in memory at startup. Each client restart yields a new key, and the user re-prompts once per restart. This is the slowest UX but the simplest implementation.
|
||||||
|
|
||||||
|
n_signer does not know or care which strategy a client uses. The pubkey on the wire is the only thing the signer sees.
|
||||||
|
|
||||||
|
### 8.2 Signing each request
|
||||||
|
|
||||||
|
For every TCP request, the client:
|
||||||
|
|
||||||
|
1. Builds the regular JSON-RPC envelope (`id`, `method`, `params`).
|
||||||
|
2. Computes `body_hash = SHA-256(JCS(params))`.
|
||||||
|
3. Constructs an auth event with kind 27235, the three required tags, optional `content` label, current `created_at`.
|
||||||
|
4. Signs the event with its private key (BIP-340 Schnorr — for nostr_terminal this is the same `nostr_schnorr_sign`-style call it already uses for kind-1 events, just with a different kind and tag set).
|
||||||
|
5. Embeds the signed event under an `auth` field in the request.
|
||||||
|
6. Sends the framed bytes.
|
||||||
|
|
||||||
|
The signing operation is bounded — one Schnorr sign per RPC. Modern hardware does this in microseconds. Implementations should not cache or skip signing; the per-request `created_at` is what makes the nonce defense meaningful.
|
||||||
|
|
||||||
|
### 8.3 Reconnect behavior — the bug being fixed
|
||||||
|
|
||||||
|
- TCP socket drops mid-session → client reconnects → next RPC carries a fresh auth envelope (different `id`, different `created_at`) signed by the same pubkey.
|
||||||
|
- Signer verifies envelope, computes the same `caller_id = "pubkey:<hex>"`, finds the existing approval entry, serves silently. **No prompt.**
|
||||||
|
- Signer process restart → policy table empties → next RPC's envelope verifies fine but the policy entry is gone → user re-prompts once per pubkey, picks `[a]`, future requests served silently. **One prompt per signer restart, exactly as for any other caller kind.**
|
||||||
|
- Client process restart → if the client persisted its keypair (its own choice), zero prompts. If the client used an ephemeral keypair, one prompt for the new pubkey.
|
||||||
|
|
||||||
|
### 8.4 Replay-window awareness
|
||||||
|
|
||||||
|
Clients must keep their clock synced within `AUTH_CLOCK_SKEW_SEC` (default 30s). This is normally trivial via NTP. The signer's response on stale-clock errors (`auth_envelope_stale`, code 2016) tells the client to fix its clock; clients should surface this clearly rather than retrying.
|
||||||
|
|
||||||
|
### 8.5 No on-disk requirement
|
||||||
|
|
||||||
|
The client is **not required** to store anything on disk. Whether it does is the client's policy. n_signer's plan imposes no client-side filesystem state.
|
||||||
|
|
||||||
|
## 9. Mermaid: TCP request decision tree
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[TCP request arrives] --> B[parse JSON, extract auth]
|
||||||
|
B --> C{auth present?}
|
||||||
|
C -- no --> D[reject 2014 auth_envelope_required]
|
||||||
|
C -- yes --> E[validate event structure]
|
||||||
|
E -- bad --> F[reject 2010 auth_envelope_malformed]
|
||||||
|
E -- ok --> G[verify Schnorr signature]
|
||||||
|
G -- bad --> H[reject 2012 auth_signature_invalid]
|
||||||
|
G -- ok --> I{kind == 27235?}
|
||||||
|
I -- no --> J[reject 2013 auth_kind_invalid]
|
||||||
|
I -- yes --> K{tags match request id and method?}
|
||||||
|
K -- no --> L[reject 2015 auth_envelope_mismatch]
|
||||||
|
K -- yes --> M{body hash matches?}
|
||||||
|
M -- no --> N[reject 2011 auth_body_mismatch]
|
||||||
|
M -- yes --> O{within skew window?}
|
||||||
|
O -- no --> P[reject 2016 auth_envelope_stale]
|
||||||
|
O -- yes --> Q{auth.id in nonce cache?}
|
||||||
|
Q -- yes --> R[reject 2017 auth_replay_detected]
|
||||||
|
Q -- no --> S[insert nonce, set caller_id pubkey:hex]
|
||||||
|
S --> T[normal policy_check pipeline]
|
||||||
|
T --> U{verdict}
|
||||||
|
U -- ALLOW --> V[dispatch RPC]
|
||||||
|
U -- DENY --> W[2001 policy_denied]
|
||||||
|
U -- PROMPT --> X[user approves with a, registers caller]
|
||||||
|
X --> V
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. Identity primitives compared
|
||||||
|
|
||||||
|
| Primitive | Stable across reconnects | Forgeable on TCP/FIPS | Survives client restart | Replay-resistant | n_signer dependency added |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `tcp:[addr]:port` (today) | **No** | n/a (it is the address) | n/a | n/a | none |
|
||||||
|
| PID claimed in JSON | Yes | **Yes** — any client claims any int | n/a | No | none |
|
||||||
|
| 32-byte random bearer token (v1 of this plan) | Yes | Yes if leaked from disk or wire | Only if disk-persisted (rejected) | No | none |
|
||||||
|
| **Caller pubkey + Schnorr signature (this plan)** | **Yes** | **No** — signature attests possession | Yes if client retains key (its choice) | Yes (nonce + skew window) | none — `nostr_core_lib` already linked |
|
||||||
|
|
||||||
|
The bottom row is the only one that satisfies all four columns and adds zero new transitive dependencies. PID and the v1 token are kept in the table as documentation of why we are not doing them.
|
||||||
|
|
||||||
|
PID retains a single legitimate use: **prompt UX context for AF_UNIX**. When the signer can read PID via `SO_PEERCRED`, it is displayed in the approval prompt as `pid=18437 (exe=/usr/local/bin/nostr_terminal)` so the user can `ps -p 18437` and verify. PID is never the policy key.
|
||||||
|
|
||||||
|
## 11. Threats addressed and not addressed
|
||||||
|
|
||||||
|
### Addressed
|
||||||
|
|
||||||
|
- **Connection-churn re-prompting.** The originating bug. One pubkey, one approval.
|
||||||
|
- **Same-IP, different-program ambiguity.** Two programs from the same FIPS endpoint hold different keypairs and produce distinct, verified `pubkey:` caller_ids.
|
||||||
|
- **Drive-by signing from a process that lacks the privkey.** A network adversary or local-but-unauthorized process cannot forge envelopes. Wire knowledge of the pubkey is non-sensitive; the privkey never leaves the legitimate client's memory.
|
||||||
|
- **Replay of captured envelopes.** Bounded by `AUTH_CLOCK_SKEW_SEC` and the in-memory nonce cache.
|
||||||
|
- **Tampered request bodies.** `nsigner_body_hash` covers `params`, so a man-in-the-middle who flips a kind-1 request to a kind-30000 request invalidates the signature and is rejected.
|
||||||
|
- **Tampered request methods or ids.** Covered by required tags `nsigner_method` and `nsigner_rpc`.
|
||||||
|
|
||||||
|
### Not addressed (explicit)
|
||||||
|
|
||||||
|
- **A local attacker who reads the client's privkey out of process memory.** They become the registered caller. This is the same threat as today's same-uid trust model and is handled by the OS, not by n_signer.
|
||||||
|
- **A network attacker who can both passively observe AND inject AND has compromised the FIPS substrate.** Without TLS at the n_signer layer, transport encryption is FIPS's job. If FIPS confidentiality is broken, the attacker can read envelopes — but they still cannot forge them (no privkey) and they cannot replay them outside the skew window. Confidentiality of `params` is a separate concern from auth.
|
||||||
|
- **Signer authenticating to the client.** This plan does the client → signer direction. Signer → client auth is future work; today the client trusts whichever endpoint the operator pointed it at.
|
||||||
|
- **Long-term key rotation policy.** Operators rotate by removing the old approval entry (TUI hotkey, future) and prompting the new pubkey. There is no automatic rotation.
|
||||||
|
|
||||||
|
## 12. Implementation order (suggested)
|
||||||
|
|
||||||
|
Each step is independently testable.
|
||||||
|
|
||||||
|
1. **`src/auth_envelope.c` and `tests/test_auth_envelope.c`.** Pure data-structure and crypto wrapper around `nostr_validate_event_structure` + `nostr_verify_event_signature` + tag/body/timestamp/nonce checks. No transport coupling. Test with hand-crafted JSON and fixed test keypairs.
|
||||||
|
2. **Nonce cache primitive.** Ring buffer; trivial unit tests for insert / lookup / eviction.
|
||||||
|
3. **`caller_identity_t` widening.** Mechanical change across all `NSIGNER_HEADERLESS_DECLS_BEGIN` blocks. Compile-only step; existing tests should still pass.
|
||||||
|
4. **Auth gate in `server_handle_one`.** Hook (1) into the request flow at TCP transport. Add the eight new error code responses. Integration test: fire a TCP request without `auth` and confirm rejection with code 2014; fire one with a valid envelope and confirm normal flow.
|
||||||
|
5. **`--preapprove caller=pubkey:<hex>` parsing.** Extension to [`parse_preapprove_spec`](../src/policy.c:553).
|
||||||
|
6. **`--auth-skew-seconds` flag.** Plumbing.
|
||||||
|
7. **Documentation.**
|
||||||
|
- [`documents/SECURITY.md`](../documents/SECURITY.md): replace `tcp_remote (planned)` and `fips (planned)` rows in §11 with the verified-pubkey description; add a section on the auth envelope.
|
||||||
|
- [`documents/CLIENT_IMPLEMENTATION.md`](../documents/CLIENT_IMPLEMENTATION.md): full chapter on the auth envelope, JCS canonicalization rule, the eight error codes, a concrete signing example.
|
||||||
|
- [`documents/FIPS_DEPLOYMENT.md`](../documents/FIPS_DEPLOYMENT.md): note that TCP requests now require auth envelopes; update the deployment checklist; remove the "remote TCP mode only with mandatory TLS + authenticated caller key flow" future-work note since this plan delivers the second half.
|
||||||
|
- [`documents/N_OS_TR_AGENT_CHANGES.md`](../documents/N_OS_TR_AGENT_CHANGES.md): example with `--preapprove caller=pubkey:<hex>`.
|
||||||
|
8. **Deprecation handling for `--preapprove caller=tcp:...` on TCP listeners.** Warning at startup; entries effectively dead.
|
||||||
|
9. **TUI revocation hotkey.** UX-side, can land later.
|
||||||
|
|
||||||
|
## 13. Open questions for review
|
||||||
|
|
||||||
|
These are the judgment calls left in v2. None of them block the v2 → implementation move; defaults are stated for each.
|
||||||
|
|
||||||
|
1. **Skew window default.** This plan says 30 seconds. Confirm. Tighter (5s) means fewer replay opportunities but more clock-drift errors; looser (120s) the reverse.
|
||||||
|
2. **Nonce cache size.** This plan says 1024. With a 30s window that handles ~33 RPS sustained per signer, far above realistic load. Confirm the size is fine.
|
||||||
|
3. **Should the auth envelope's `content` field carry a label?** This plan says yes, displayed at prompt as a hint, max 63 bytes UTF-8. Alternative: leave `content` empty and force the operator to recognize the npub. The label is purely UX; the npub is the truth. Default = label allowed, but ignored by policy.
|
||||||
|
4. **JCS as canonicalization vs. a simpler rule.** JCS is the right answer (RFC standard, used by NIP-01) but not all client languages have a JCS library. Alternative: define a small subset rule ("sort keys lexicographically, no whitespace, JSON-stringify each value"). Default = JCS; revisit if a target client language lacks support.
|
||||||
|
5. **Should AF_UNIX accept an `auth` envelope as an *additional* identity claim?** Today this plan says no — AF_UNIX uses `SO_PEERCRED` and ignores `auth`. Alternative: allow a client over AF_UNIX to ALSO send an envelope and have the signer record both `uid:1000` AND `pubkey:<hex>` as identities for the same caller. Adds complexity for a use case nobody has asked for. Default = AF_UNIX ignores `auth`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix A — Diff against existing plans and docs
|
||||||
|
|
||||||
|
- [`plans/deny_by_default_approvals.md`](deny_by_default_approvals.md): unchanged. The deny-by-default rule, prompt outcomes, on-demand role derivation, and `--preapprove` mechanics all continue. Only the *form* of `caller_id` for one transport changes.
|
||||||
|
- [`plans/nsigner.md`](nsigner.md): the future-work item "remote TCP mode only with mandatory TLS + authenticated caller key flow" ([`documents/FIPS_DEPLOYMENT.md:177`](../documents/FIPS_DEPLOYMENT.md:177)) is half answered: the auth side is done; TLS at the n_signer layer remains future work.
|
||||||
|
- [`documents/SECURITY.md`](../documents/SECURITY.md):
|
||||||
|
- §11 rows for `tcp_remote` and `fips` move from "planned" to "implemented; identity = caller pubkey verified by Schnorr signature."
|
||||||
|
- New subsection in §5 ("the two checks") inserts an auth check ahead of the approval check, applicable only to TCP transport.
|
||||||
|
- [`documents/CLIENT_IMPLEMENTATION.md`](../documents/CLIENT_IMPLEMENTATION.md): new chapter "Authenticating over TCP/URL transport" defining the auth envelope, JCS rule, and error codes 2010–2017.
|
||||||
|
- [`documents/N_OS_TR_AGENT_CHANGES.md`](../documents/N_OS_TR_AGENT_CHANGES.md): adds a `--preapprove caller=pubkey:<hex>` example for system services that have a stable identity keypair; notes that `--preapprove caller=tcp:...` is deprecated for TCP listeners.
|
||||||
|
|
||||||
|
## Appendix B — Why kind 27235 specifically
|
||||||
|
|
||||||
|
NIP-42 already defines kind 22242 for "client authentication to relays" with HTTP-Auth-style semantics. Reusing 22242 verbatim would be wrong because the tag schema (`relay`, `challenge`) is relay-specific. Choosing a fresh kind in the parameterized-replaceable range used for HTTP/RPC auth (27235 is used elsewhere for HTTP request auth in some Nostr ecosystems — verify before locking in) lets us define our own tag schema (`nsigner_rpc`, `nsigner_method`, `nsigner_body_hash`) without colliding with relay-auth conventions.
|
||||||
|
|
||||||
|
If 27235 turns out to be already claimed by a NIP we do not want to inherit semantics from, the plan can pick a different kind in the same range. The kind is a constant in [`src/auth_envelope.c`](../src/auth_envelope.c) and changing it is a one-line edit in both signer and client.
|
||||||
|
|
||||||
|
## Appendix C — Pseudocode: client signing one request
|
||||||
|
|
||||||
|
```c
|
||||||
|
/* Pseudocode, illustrative only. */
|
||||||
|
cJSON *params = build_params_for_method(method);
|
||||||
|
|
||||||
|
/* 1. Compute body hash. */
|
||||||
|
char *params_jcs = jcs_canonicalize(params);
|
||||||
|
unsigned char body_hash[32];
|
||||||
|
nostr_sha256((const unsigned char *)params_jcs, strlen(params_jcs), body_hash);
|
||||||
|
char body_hash_hex[65];
|
||||||
|
nostr_bytes_to_hex(body_hash, 32, body_hash_hex);
|
||||||
|
|
||||||
|
/* 2. Build the auth event JSON. */
|
||||||
|
cJSON *auth = cJSON_CreateObject();
|
||||||
|
cJSON_AddStringToObject(auth, "pubkey", caller_pubkey_hex);
|
||||||
|
cJSON_AddNumberToObject(auth, "created_at", (double)time(NULL));
|
||||||
|
cJSON_AddNumberToObject(auth, "kind", 27235);
|
||||||
|
|
||||||
|
cJSON *tags = cJSON_CreateArray();
|
||||||
|
add_tag(tags, "nsigner_rpc", request_id);
|
||||||
|
add_tag(tags, "nsigner_method", method);
|
||||||
|
add_tag(tags, "nsigner_body_hash", body_hash_hex);
|
||||||
|
cJSON_AddItemToObject(auth, "tags", tags);
|
||||||
|
|
||||||
|
cJSON_AddStringToObject(auth, "content", optional_label_or_empty);
|
||||||
|
|
||||||
|
/* 3. Compute event id (sha256 of canonical event header) and sign. */
|
||||||
|
unsigned char event_id[32], sig[64];
|
||||||
|
compute_nostr_event_id(auth, event_id); /* helper from nostr_core_lib usage */
|
||||||
|
nostr_schnorr_sign(caller_privkey, event_id, sig);
|
||||||
|
|
||||||
|
char id_hex[65], sig_hex[129];
|
||||||
|
nostr_bytes_to_hex(event_id, 32, id_hex);
|
||||||
|
nostr_bytes_to_hex(sig, 64, sig_hex);
|
||||||
|
cJSON_AddStringToObject(auth, "id", id_hex);
|
||||||
|
cJSON_AddStringToObject(auth, "sig", sig_hex);
|
||||||
|
|
||||||
|
/* 4. Embed in the request and send. */
|
||||||
|
cJSON *root = cJSON_CreateObject();
|
||||||
|
cJSON_AddStringToObject(root, "id", request_id);
|
||||||
|
cJSON_AddStringToObject(root, "method", method);
|
||||||
|
cJSON_AddItemToObject(root, "params", params);
|
||||||
|
cJSON_AddItemToObject(root, "auth", auth);
|
||||||
|
send_framed(cJSON_PrintUnformatted(root));
|
||||||
|
```
|
||||||
|
|
||||||
|
The signer side is the same flow in reverse, replacing `nostr_schnorr_sign` with `nostr_verify_event_signature` on the embedded `auth` event.
|
||||||
357
plans/deny_by_default_approvals.md
Normal file
357
plans/deny_by_default_approvals.md
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
# Plan: Deny-by-default approvals + on-demand role derivation
|
||||||
|
|
||||||
|
Status: **implemented**
|
||||||
|
|
||||||
|
This plan replaces several entangled mechanisms (same-uid auto-allow, role pre-registration as a hard rule, global `[a]` flag) with a single, coherent approval model. It also addresses the `n_OS_tr` system-services-at-boot scenario.
|
||||||
|
|
||||||
|
If implemented, this plan supersedes:
|
||||||
|
|
||||||
|
- The same-uid auto-allow rule in [`policy_init_default()`](../src/policy.c:551).
|
||||||
|
- The "role_path / nostr_index must be pre-registered" rule recorded in [`plans/nsigner.md:159`](nsigner.md:159) (the rule is *narrowed*, not removed — see §5).
|
||||||
|
- The global `g_prompt_always_allow` flag in [`src/server.c:469`](../src/server.c:469) as the meaning of prompt-`[a]`. The running-TUI `[a]` hotkey is unaffected (or removed; see §11).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goals
|
||||||
|
|
||||||
|
1. **Deny-by-default.** No client can sign or get a public key without an explicit human approval at the terminal **or** an explicit pre-approval declared at startup.
|
||||||
|
2. **One approval mechanism.** Both the runtime prompt (human approves a request) and the boot-time pre-approval (OS-level config) populate the **same** in-memory policy table. There is exactly one path through the dispatcher that determines whether to sign.
|
||||||
|
3. **On-demand role creation.** When a request specifies an unregistered `nostr_index`, the prompt path may auto-derive a fresh role at that index *only after* the human (or a startup pre-approval) consents. Pre-registration of `role_path` keys remains required.
|
||||||
|
4. **Boot-time ergonomics for `n_OS_tr`.** OS-level system services that need keys at boot can be pre-approved by the OS distribution via CLI flags, with no prompts and no on-disk runtime config files.
|
||||||
|
5. **Auditable approval source.** The activity log distinguishes between approvals granted at runtime by the user, approvals loaded from startup pre-approvals, and approvals already in place from prior session activity.
|
||||||
|
|
||||||
|
## 2. Non-goals
|
||||||
|
|
||||||
|
- This plan does **not** add per-verb scoping at the prompt. `[a]` means "this caller may use this role for all verbs the role's purpose/curve permits." Per-verb scoping is a future iteration.
|
||||||
|
- This plan does **not** add persistent (across-restart) policy storage. Approvals still vanish on process exit.
|
||||||
|
- This plan does **not** introduce capability tokens or any new authentication primitive. Identity remains transport-derived.
|
||||||
|
- This plan does **not** change the wire protocol. Clients see the same JSON-RPC request/response shape.
|
||||||
|
|
||||||
|
## 3. The new model in one paragraph
|
||||||
|
|
||||||
|
The signer maintains an in-memory policy table whose entries are tuples of `(caller_id, role, prompt_mode)`. At startup, the table is empty except for entries declared via `--preapprove` CLI flags (set by `n_OS_tr` system unit files for boot-required services). Every request without a matching policy entry triggers an interactive prompt at the terminal. If the user picks `[a]`, a new policy entry for `(this caller, this role)` is appended to the table and any future request matching that tuple is served silently. If the request specifies an unregistered `nostr_index`, the consent path also derives a fresh role at that index. The `*` catch-all is `PROMPT_DENY` for any caller that hasn't been explicitly added.
|
||||||
|
|
||||||
|
## 4. Concept renaming and positioning
|
||||||
|
|
||||||
|
The user-facing language collapses to three concepts:
|
||||||
|
|
||||||
|
| User-facing term | Internal mapping |
|
||||||
|
|---|---|
|
||||||
|
| **Identity** | `caller_identity_t` (uid, qubes vm name, tcp peer, etc.) |
|
||||||
|
| **Index / Path** | `nostr_index` shorthand or full `role_path` for advanced use |
|
||||||
|
| **Approval** | A policy table entry with `prompt_mode = PROMPT_NEVER` |
|
||||||
|
|
||||||
|
The word **role** is retained internally (the role table, the [`role_entry_t`](../src/role_table.c:103) struct, derivation paths) but de-emphasized in user-facing prompts and docs. A user reading a prompt sees:
|
||||||
|
|
||||||
|
```
|
||||||
|
Approval required
|
||||||
|
caller: qubes:nostr-relay
|
||||||
|
verb: sign_event
|
||||||
|
index: nostr_index=0 (resolves to the default identity)
|
||||||
|
```
|
||||||
|
|
||||||
|
instead of `role: main`. Internally the resolved role is still recorded for audit.
|
||||||
|
|
||||||
|
## 5. The narrowed pre-registration rule
|
||||||
|
|
||||||
|
The original rule was: **all** selectors must resolve to a pre-registered role.
|
||||||
|
|
||||||
|
The new rule is:
|
||||||
|
|
||||||
|
> Pre-registration is required for **`role_path`** selectors. For **`nostr_index`** selectors, an unregistered index is a valid prompt trigger, and the role for that index is derived on the fly **after** explicit consent (prompt or `--preapprove`).
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- `nostr_index` paths follow a fixed scheme (`m/44'/1237'/<n>'/0/0`) with locked purpose (`nostr`) and curve (`secp256k1`). Auto-derivation cannot accidentally produce a key that mixes purposes. The risk is solely "this is a fresh nostr identity"; the human at the prompt can answer that.
|
||||||
|
- `role_path` is free-form and can address any BIP-32 path, including paths that overlap with bitcoin/SSH/age domains. Auto-derivation here would let a malicious or buggy client coerce the signer into producing keys whose purpose the human can't easily verify. We keep that locked.
|
||||||
|
- This narrowing is the smallest crack we can open in the original rule that still solves the user's `nostr_index: 1` use case.
|
||||||
|
|
||||||
|
[`plans/nsigner.md:159`](nsigner.md:159) ("`role_path` must be pre-registered") remains true. The `nostr_index` clause becomes new and explicit.
|
||||||
|
|
||||||
|
## 6. The four pieces of work
|
||||||
|
|
||||||
|
### Piece A — Deny-by-default policy
|
||||||
|
|
||||||
|
#### A.1 Default policy table
|
||||||
|
|
||||||
|
[`policy_init_default()`](../src/policy.c:551) currently inserts two entries:
|
||||||
|
|
||||||
|
1. `caller = "uid:<owner>"` → `PROMPT_NEVER` (auto-allow same-uid)
|
||||||
|
2. `caller = "*"` → `PROMPT_DENY`
|
||||||
|
|
||||||
|
The new default inserts only:
|
||||||
|
|
||||||
|
1. `caller = "*"` → `PROMPT_PROMPT`
|
||||||
|
|
||||||
|
There is no same-uid special case. Even the user's own client triggers a prompt the first time it connects.
|
||||||
|
|
||||||
|
If `--preapprove` flags were given at startup, those entries are appended **before** the catch-all `*` so they win first-match. See §C.
|
||||||
|
|
||||||
|
#### A.2 Removal of `g_prompt_always_allow` as a *prompt outcome*
|
||||||
|
|
||||||
|
The flag is currently set to 1 by both:
|
||||||
|
|
||||||
|
- The interactive prompt's `[a]` outcome ([`src/server.c:684`](../src/server.c:684))
|
||||||
|
- The running-TUI `[a]` hotkey ([`src/main.c:974`](../src/main.c:974), [`src/main.c:1239`](../src/main.c:1239))
|
||||||
|
|
||||||
|
The prompt's `[a]` no longer flips the flag. Instead it appends a per-`(caller, role)` policy entry. See §B.
|
||||||
|
|
||||||
|
The running-TUI `[a]` hotkey can either be:
|
||||||
|
|
||||||
|
- Removed, since it is now redundant with explicit per-caller entries, **or**
|
||||||
|
- Retained as a debugging/panic mode under a non-default name, e.g. `[A]` (capital) for "auto-approve everything regardless of caller; for development only."
|
||||||
|
|
||||||
|
The plan recommends retaining it as a deliberately ugly, capital-letter hotkey so its destructive scope is visible. Default-off; activates only on explicit press.
|
||||||
|
|
||||||
|
#### A.3 Wire-level effect
|
||||||
|
|
||||||
|
A previously-unknown caller making a first request observes:
|
||||||
|
|
||||||
|
1. Length-prefixed request lands.
|
||||||
|
2. Selector resolves (or fails — see §B).
|
||||||
|
3. Policy lookup returns `POLICY_PROMPT` because no matching entry exists.
|
||||||
|
4. Server blocks the request, prints the prompt to the controlling terminal, waits for keystroke.
|
||||||
|
5. On `[a]`, server appends `(caller, role) → PROMPT_NEVER` and serves the request.
|
||||||
|
6. On `[y]`, server serves once without appending.
|
||||||
|
7. On `[n]`, server returns `policy_denied` without appending.
|
||||||
|
8. On EOF / non-interactive: server returns `policy_denied`. There is no implicit allow path for non-interactive sessions; OS distributions must use `--preapprove`.
|
||||||
|
|
||||||
|
### Piece B — On-demand role derivation at the prompt
|
||||||
|
|
||||||
|
#### B.1 Failure path today
|
||||||
|
|
||||||
|
When `selector_resolve()` returns `SELECTOR_ERR_NOT_FOUND` for an unregistered `nostr_index`, the request currently dies in the dispatcher with `unknown_role` (error 1002). The user observes nothing in the terminal.
|
||||||
|
|
||||||
|
#### B.2 New failure path
|
||||||
|
|
||||||
|
The server's request-handling loop checks selector resolution **before** invoking the dispatcher. The new control flow:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Receive request, parse method + selector.
|
||||||
|
2. Try selector_resolve():
|
||||||
|
a. SELECTOR_OK -> proceed to step 3 with the resolved role.
|
||||||
|
b. SELECTOR_ERR_NOT_FOUND on nostr_index -> proceed to step 3 with
|
||||||
|
a PENDING_DERIVATION marker holding the requested index.
|
||||||
|
c. SELECTOR_ERR_NOT_FOUND on role_path -> reject with unknown_role.
|
||||||
|
d. SELECTOR_ERR_AMBIGUOUS -> reject with ambiguous_role_selector.
|
||||||
|
e. SELECTOR_ERR_NO_DEFAULT -> reject with internal_error
|
||||||
|
(this should never happen if main role is always present).
|
||||||
|
3. Determine policy: ALLOW / DENY / PROMPT.
|
||||||
|
- For PENDING_DERIVATION cases, the prompt displays the requested
|
||||||
|
index and clearly states "this will create a new identity."
|
||||||
|
4. On PROMPT, ask human; on [a] or [y], proceed; on [n], reject.
|
||||||
|
5. On approval to proceed:
|
||||||
|
a. If PENDING_DERIVATION, register a role for that index in the
|
||||||
|
role_table now. Synthesize a name like nostr_idx_7. Trigger
|
||||||
|
key derivation for the new role.
|
||||||
|
b. If [a] (not [y]), append a session policy entry for
|
||||||
|
(caller, resolved-role-name) with PROMPT_NEVER.
|
||||||
|
6. Invoke the dispatcher with the resolved role.
|
||||||
|
7. Send response, append activity log line including approval source.
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B.3 Auto-registered role naming
|
||||||
|
|
||||||
|
A role auto-registered for `nostr_index = N` is named `nostr_idx_N` by the signer.
|
||||||
|
|
||||||
|
- Predictable, so humans reading the activity log can correlate.
|
||||||
|
- Will not collide with the built-in `main` (which is `nostr_idx_0` by index but `main` by name; the role table allows lookups by either).
|
||||||
|
- Does not require user input at the prompt. Naming UX is a future iteration.
|
||||||
|
|
||||||
|
#### B.4 Role-table sizing
|
||||||
|
|
||||||
|
[`ROLE_TABLE_MAX_ENTRIES`](../src/role_table.c) is currently small. With on-demand derivation a single session might accumulate dozens of `nostr_idx_*` entries. Bump the constant to an explicit budget (suggest 256). On overflow, return a clear error like `role_table_full` rather than crashing.
|
||||||
|
|
||||||
|
#### B.5 Persistence
|
||||||
|
|
||||||
|
Same as everything else in n_signer: derived roles vanish on process exit. The next session re-derives `nostr_idx_7` from the same mnemonic, getting the same key. This is normal and intentional.
|
||||||
|
|
||||||
|
### Piece C — `--preapprove` CLI flag
|
||||||
|
|
||||||
|
#### C.1 Flag syntax
|
||||||
|
|
||||||
|
```
|
||||||
|
--preapprove <SPEC>
|
||||||
|
```
|
||||||
|
|
||||||
|
`SPEC` is a comma-separated list of `key=value` pairs. Required keys:
|
||||||
|
|
||||||
|
- `caller=<id>` — exact caller identity to match. Format depends on transport:
|
||||||
|
- `uid:1000`
|
||||||
|
- `qubes:work-vm`
|
||||||
|
- `tcp:[fdb8::1]:0` (port 0 means "any port")
|
||||||
|
- `qubes:*` is **not** supported. Wildcards are intentionally restricted to the catch-all entry only.
|
||||||
|
- One of:
|
||||||
|
- `role=<name>` — match the role by name. Useful when the OS knows it wants `main`.
|
||||||
|
- `nostr_index=<n>` — match by index. Equivalent to `role=nostr_idx_<n>` after auto-derivation.
|
||||||
|
|
||||||
|
Optional keys (deferred for now, but reserved):
|
||||||
|
|
||||||
|
- `verbs=<list>` — comma-separated verb list. Default is `*`.
|
||||||
|
- `purposes=<list>` — comma-separated purpose list. Default is `*`.
|
||||||
|
|
||||||
|
Multiple `--preapprove` flags are allowed; each becomes a separate policy entry.
|
||||||
|
|
||||||
|
#### C.2 Example: `n_OS_tr` boot
|
||||||
|
|
||||||
|
`n_OS_tr`'s systemd unit file:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/bin/n_signer \
|
||||||
|
--preapprove caller=qubes:nostr-relay,role=main \
|
||||||
|
--preapprove caller=qubes:dm-handler,nostr_index=1 \
|
||||||
|
--preapprove caller=qubes:contacts,nostr_index=2 \
|
||||||
|
--preapprove caller=qubes:zaps,nostr_index=3
|
||||||
|
```
|
||||||
|
|
||||||
|
At startup, n_signer:
|
||||||
|
|
||||||
|
1. Parses each `--preapprove` flag into a `policy_entry_t` with `prompt = PROMPT_NEVER`.
|
||||||
|
2. For each entry that references an unregistered `nostr_index`, auto-derives the role *immediately at startup* (not lazily — we want all key derivation to happen before services start signaling readiness).
|
||||||
|
3. Inserts each policy entry into the table **before** the catch-all `*` deny.
|
||||||
|
4. Logs each pre-approval to stderr at boot so the systemd unit's journal records what was preapproved.
|
||||||
|
|
||||||
|
When `qubes:nostr-relay` connects and asks for `sign_event` against `role=main`, `policy_check()` matches the preapprove entry, returns `POLICY_ALLOW`, and the request is served with no prompt.
|
||||||
|
|
||||||
|
When some other caller (say, a misconfigured systemd service) connects, the catch-all matches and the request is denied (or prompts, in interactive mode — see §C.4).
|
||||||
|
|
||||||
|
#### C.3 What `--preapprove` cannot do
|
||||||
|
|
||||||
|
- **Cannot deny.** Deny entries can only come from defaults or the prompt's `[n]` (if we ever add caller-deny). Pre-approve only widens.
|
||||||
|
- **Cannot match wildcards on caller.** Each entry must specify exactly one caller_id. The OS distribution is responsible for knowing exactly who its services are.
|
||||||
|
- **Cannot bypass purpose/curve enforcement.** A pre-approval to use `role=main` for `verb=sign_btc_tx` (a hypothetical bitcoin verb) still fails enforcement because `main`'s purpose is `nostr`. The pre-approval grants access to the role; enforcement still gates the verb.
|
||||||
|
|
||||||
|
#### C.4 Interaction with interactive mode
|
||||||
|
|
||||||
|
The same binary supports both:
|
||||||
|
|
||||||
|
- `n_signer` with no `--preapprove` flags → pure deny-by-default, every first request prompts.
|
||||||
|
- `n_signer --preapprove ...` → starts with the listed entries already in the policy table.
|
||||||
|
|
||||||
|
Both modes use exactly the same code path. There is no separate "system mode" toggle.
|
||||||
|
|
||||||
|
#### C.5 Auditability
|
||||||
|
|
||||||
|
When a pre-approval matches, the activity log line includes the source:
|
||||||
|
|
||||||
|
```
|
||||||
|
qubes:nostr-relay sign_event(role=main) ALLOWED:preapprove
|
||||||
|
```
|
||||||
|
|
||||||
|
So an operator reviewing the log can tell which decisions came from boot config vs. runtime user approvals.
|
||||||
|
|
||||||
|
#### C.6 Validation
|
||||||
|
|
||||||
|
Bad `--preapprove` syntax fails fast at startup with a clear error message. n_signer refuses to start. This avoids the "I thought I configured this but typoed it" silent-degrade problem.
|
||||||
|
|
||||||
|
### Piece D — Audit-log labeling of approval source
|
||||||
|
|
||||||
|
#### D.1 The four label values
|
||||||
|
|
||||||
|
| Label | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `:preapprove` | Matched a policy entry installed via `--preapprove` at startup. |
|
||||||
|
| `:session-grant` | Matched a policy entry created earlier in this session by a prompt `[a]`. |
|
||||||
|
| `:prompt` | Just-now approved by the user pressing `[y]` or `[a]` at the prompt. |
|
||||||
|
| `:auto-allow` | Matched the running-TUI `[a]` panic mode (if retained). |
|
||||||
|
|
||||||
|
A line in the activity log under the new model:
|
||||||
|
|
||||||
|
```
|
||||||
|
[2026-05-04 16:03:11] qubes:nostr-relay sign_event(role=main) ALLOWED:preapprove
|
||||||
|
[2026-05-04 16:03:14] uid:1000 sign_event(role=main) ALLOWED:session-grant
|
||||||
|
[2026-05-04 16:03:18] tcp:[::1]:54122 get_public_key(role=nostr_idx_7) ALLOWED:prompt
|
||||||
|
[2026-05-04 16:03:21] uid:1001 sign_event(role=main) DENIED:no-match
|
||||||
|
```
|
||||||
|
|
||||||
|
#### D.2 Implementation
|
||||||
|
|
||||||
|
The label is a small enum threaded from `policy_check()` (which returns the matching entry's source tag) and from `prompt_for_policy_decision()` (which returns `:prompt`). `server_handle_one()` includes the label in the activity-log line.
|
||||||
|
|
||||||
|
#### D.3 Visibility
|
||||||
|
|
||||||
|
The label appears in:
|
||||||
|
|
||||||
|
- The TUI's running activity buffer.
|
||||||
|
- The structured activity event passed to the `server_activity_cb` callback (so external observers — e.g. a future syslog feeder — can also see the source).
|
||||||
|
|
||||||
|
#### D.4 No on-disk persistence
|
||||||
|
|
||||||
|
The activity buffer remains in-memory only. If `n_OS_tr` wants persistent audit, it forwards the activity callback to its own logging subsystem outside of n_signer's process.
|
||||||
|
|
||||||
|
## 7. Code change inventory
|
||||||
|
|
||||||
|
| Module | Change |
|
||||||
|
|---|---|
|
||||||
|
| [`src/policy.c`](../src/policy.c) | `policy_init_default()` collapses to one entry. `policy_check()` extends to return the matching entry's source label. New helper `policy_table_add_at_position()` to insert before the catch-all. |
|
||||||
|
| [`src/server.c`](../src/server.c) | `prompt_for_policy_decision()` rewritten: appends a policy entry on `[a]`, no longer flips the global flag. New pre-derivation control flow in `server_handle_one()`. New CLI parser for `--preapprove`. |
|
||||||
|
| [`src/role_table.c`](../src/role_table.c) | Bump `ROLE_TABLE_MAX_ENTRIES` to 256. New `role_table_register_nostr_index(int n)` helper that creates a `nostr_idx_<n>` entry and triggers derivation. |
|
||||||
|
| [`src/key_store.c`](../src/key_store.c) | Add a one-role derivation path so newly-registered roles can be derived without re-deriving the whole table. |
|
||||||
|
| [`src/dispatcher.c`](../src/dispatcher.c) | No semantic change — dispatcher continues to assume the role exists. The server makes that true before invoking. |
|
||||||
|
| [`src/main.c`](../src/main.c) | CLI parsing for `--preapprove`. Remove or restrict the running-TUI `[a]` hotkey. Log preapproves to stderr at boot. |
|
||||||
|
| [`tests/test_policy.c`](../tests/test_policy.c) | New tests: deny-by-default, prompt-`[a]` appends entry, preapprove flag parsing, source-label propagation. |
|
||||||
|
| [`tests/test_dispatcher.c`](../tests/test_dispatcher.c) | New tests: unregistered nostr_index path with consent-required gating. |
|
||||||
|
| [`tests/test_integration.c`](../tests/test_integration.c) | New scenario: `--preapprove` allows scripted client; without it, scripted client gets denied. |
|
||||||
|
| [`documents/SECURITY.md`](../documents/SECURITY.md) | Rewritten in identity / index / approval terms (separate task after this plan ships). |
|
||||||
|
| [`documents/CLIENT_IMPLEMENTATION.md`](../documents/CLIENT_IMPLEMENTATION.md) | Add a note about possible `policy_denied` on first contact in interactive mode; pointer to `--preapprove` for system services. |
|
||||||
|
| [`plans/nsigner.md`](nsigner.md) | Decisions log entry referencing this plan. The `role_path` pre-registration rule remains; the `nostr_index` clause becomes new. |
|
||||||
|
|
||||||
|
## 8. Test plan
|
||||||
|
|
||||||
|
Tests must cover at minimum:
|
||||||
|
|
||||||
|
1. **Default deny.** A startup with no `--preapprove` flags receives a request from any caller and (in non-interactive mode) returns `policy_denied`.
|
||||||
|
2. **Prompt `[a]` appends entry.** Inject a non-interactive prompt-default of `ALLOW`, observe a policy entry added, verify a second matching request is served without re-prompting.
|
||||||
|
3. **Prompt `[y]` does not append entry.** Same fixture, second request still triggers the prompt path.
|
||||||
|
4. **`--preapprove` with `role=main`.** Request from named caller succeeds without prompt; request from any other caller is denied.
|
||||||
|
5. **`--preapprove` with `nostr_index=N` for unregistered N.** Role is auto-derived at startup; request succeeds; activity log shows `:preapprove`.
|
||||||
|
6. **`--preapprove` cannot bypass enforcement.** Pre-approve `(caller=X, role=main)` and request a verb that violates purpose/curve. Response is `purpose_mismatch` or `curve_mismatch`.
|
||||||
|
7. **Unknown `role_path` still rejected.** Even with consent, an unregistered `role_path` returns `unknown_role`. (The narrowing is `nostr_index`-only.)
|
||||||
|
8. **Audit-log labels.** Each path produces the expected source tag.
|
||||||
|
9. **Multi-instance isolation.** Two n_signers with different `--preapprove` lists serve different requests; cross-instance requests are denied as expected.
|
||||||
|
10. **Role table overflow.** Force more than `ROLE_TABLE_MAX_ENTRIES` derivations and observe `role_table_full` instead of corruption.
|
||||||
|
|
||||||
|
## 9. Backwards-compatibility implications
|
||||||
|
|
||||||
|
- **Existing scripts that rely on same-uid auto-allow break.** They now hit the prompt (interactive) or get denied (non-interactive). Migration path: add `--preapprove caller=uid:1000,role=main` to the startup invocation. We document this prominently.
|
||||||
|
- **Interactive desktop users see a prompt the first time their client connects, where today they don't.** This is the intended behavior change. We document it in the README.
|
||||||
|
- **Wire protocol unchanged.** No client-side changes required.
|
||||||
|
- **Existing `[a]` running-TUI hotkey may change semantics or disappear.** This is the most disruptive UI change for someone who actually uses the panic-allow today. Document the change in the release notes.
|
||||||
|
|
||||||
|
## 10. Rollout staging
|
||||||
|
|
||||||
|
This is too much to land in one commit. Suggested order:
|
||||||
|
|
||||||
|
1. **Stage 1 — deny-by-default + prompt `[a]` appends entry.** Removes the same-uid auto-allow, makes the prompt the source of session-grants. No CLI flag yet. Headless scripts break; we accept that for now and document the workaround as "use stage 2."
|
||||||
|
2. **Stage 2 — `--preapprove` CLI flag.** Restores headless ergonomics for OS distributions like `n_OS_tr`.
|
||||||
|
3. **Stage 3 — on-demand role derivation at the prompt.** Closes the user-visible `(unknown)` failure mode.
|
||||||
|
4. **Stage 4 — audit-log labels and structured activity output.** Forensics + observability.
|
||||||
|
5. **Stage 5 — `documents/SECURITY.md` rewrite.** After the code is settled.
|
||||||
|
|
||||||
|
Each stage is a self-contained release.
|
||||||
|
|
||||||
|
## 11. Open decisions to confirm before coding
|
||||||
|
|
||||||
|
- **Q1.** Keep the running-TUI `[a]` hotkey as a panic-allow under a different visual treatment, or remove it entirely? Plan recommends keep, capitalized, default-off.
|
||||||
|
- **Q2.** Is `nostr_idx_<n>` an acceptable auto-naming convention, or do we want to ask the user for a name in the prompt? Plan recommends auto-name now, prompt-name later as iteration.
|
||||||
|
- **Q3.** Should the prompt's "this will create a new identity" warning differ visually from a normal approval prompt? Plan recommends a clearly distinct extra line in the prompt body.
|
||||||
|
- **Q4.** What is the ceiling for `ROLE_TABLE_MAX_ENTRIES`? Plan recommends 256 with a fail-loud overflow.
|
||||||
|
- **Q5.** Do we want a TUI panel showing the active session approvals (helpful for the user to see what they've granted)? Plan recommends yes, smallest possible: caller + role + source label. Could be deferred to a later stage.
|
||||||
|
|
||||||
|
## 12. After this plan lands: deferred work
|
||||||
|
|
||||||
|
- Per-verb scoping at the prompt (the user wants `[a]` to mean "all verbs for this role" but a future `[c]` to mean "let me pick"). Deferred per the user's instruction.
|
||||||
|
- Approval revocation hotkeys (`[k]` to clear all session-grants, `[K]` to clear one).
|
||||||
|
- Approvals visible-and-editable as a TUI panel.
|
||||||
|
- Persistent (cross-restart) approvals — explicitly out of scope; n_signer remains memory-only.
|
||||||
|
- `role_path` auto-derivation under prompt — explicitly out of scope; the security argument in §5 stands.
|
||||||
|
- Capability tokens for cross-machine transports (FIPS, USB serial) — separate plan, separate threat model.
|
||||||
|
|
||||||
|
## 13. References
|
||||||
|
|
||||||
|
- [`README.md`](../README.md) — authoritative behavior spec.
|
||||||
|
- [`plans/nsigner.md`](nsigner.md) — root design plan; pre-registration rule at line 159.
|
||||||
|
- [`documents/CLIENT_IMPLEMENTATION.md`](../documents/CLIENT_IMPLEMENTATION.md) — wire contract.
|
||||||
|
- [`documents/SECURITY.md`](../documents/SECURITY.md) — security model document; will be rewritten when this plan lands.
|
||||||
|
- [`src/policy.c`](../src/policy.c), [`src/server.c`](../src/server.c), [`src/dispatcher.c`](../src/dispatcher.c), [`src/role_table.c`](../src/role_table.c), [`src/selector.c`](../src/selector.c) — code that changes.
|
||||||
277
plans/docker_runtime_image.md
Normal file
277
plans/docker_runtime_image.md
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
# Plan: Runtime Docker image for n_signer with TCP + auth-envelope onboarding
|
||||||
|
|
||||||
|
Status: **draft v1 — for review**
|
||||||
|
|
||||||
|
This plan documents a path for new users to download and run [`n_signer`](../README.md) inside a Docker container with minimal friction, while preserving the project's existing security posture (zero filesystem footprint at runtime, mlock'd in-memory secrets, deny-by-default approvals, crash-equals-total-wipe). It complements — and does not replace — the native binary distribution.
|
||||||
|
|
||||||
|
The companion work breakdown is tracked as todos in this task; this document captures the rationale and architectural decisions behind those todos.
|
||||||
|
|
||||||
|
Related plans and references:
|
||||||
|
|
||||||
|
- [`plans/caller_token_identity.md`](caller_token_identity.md) — the auth envelope spec that makes TCP a first-class authenticated transport.
|
||||||
|
- [`plans/nip46_bunker_mode.md`](nip46_bunker_mode.md) — deferred relay-based transport that would offer a Docker-friendly future alternative.
|
||||||
|
- [`Dockerfile.alpine-musl`](../Dockerfile.alpine-musl) — current build-only Dockerfile that this plan extends.
|
||||||
|
- [`build_static.sh`](../build_static.sh) — host-side wrapper around the build image.
|
||||||
|
- [`increment_and_push.sh`](../increment_and_push.sh) — existing release flow that this plan extends.
|
||||||
|
- [`src/server.c`](../src/server.c) — current transport implementation (`unix`, `stdio`, `qrexec`, `tcp:HOST:PORT`).
|
||||||
|
- [`src/auth_envelope.c`](../src/auth_envelope.c) — verifier of NIP-01-shaped `kind:27235` auth envelopes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why this plan exists
|
||||||
|
|
||||||
|
Today a new user has three realistic on-ramps to `n_signer`:
|
||||||
|
|
||||||
|
1. Download the static binary from a release and run it natively.
|
||||||
|
2. Clone the repo and rebuild via [`build_static.sh`](../build_static.sh), which itself uses Docker as a build environment but produces a host binary.
|
||||||
|
3. Hand-roll a `Dockerfile` and `docker run` invocation around the static binary.
|
||||||
|
|
||||||
|
None of those produce a one-command `docker pull` + `docker run` experience. [`Dockerfile.alpine-musl`](../Dockerfile.alpine-musl) ends with `FROM scratch` and copies the binary out — it is a build artifact, not a runnable image. This plan closes that gap.
|
||||||
|
|
||||||
|
The Docker path also gives users defense-in-depth that the native path cannot offer: PID-namespace isolation prevents same-user processes on the host from `ptrace`'ing the signer, mount-namespace isolation hides the binary, default seccomp shrinks the kernel attack surface, and `--cap-drop ALL` plus `--security-opt no-new-privileges` constrain a hypothetical compromise of the signer itself. None of this defends against host root, kernel exploits, or side channels — that boundary requires a VM-class isolation like the Qubes path described in [`documents/QUBES_OS.md`](../documents/QUBES_OS.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Goals
|
||||||
|
|
||||||
|
1. **One-command run.** A new user with Docker installed should be able to `docker pull` and `docker run` to get a working interactive `n_signer` TUI, with no source checkout and no manual binary placement.
|
||||||
|
2. **Hardened by default.** The recommended invocation in the documentation must include `--read-only`, `--cap-drop ALL`, `--security-opt no-new-privileges`, resource limits, and `127.0.0.1`-only port publishing. Users who copy-paste the docs land on the safer configuration without having to think.
|
||||||
|
3. **TCP is the supported transport for the image.** Out of the four current transports, TCP is the only one that crosses the container/host network-namespace boundary cleanly today, and it is the only one with a working caller-authentication story per [`plans/caller_token_identity.md`](caller_token_identity.md). The image's `ENTRYPOINT` reflects this.
|
||||||
|
4. **No new transport code in v1.** This plan uses transports that already exist in [`src/server.c`](../src/server.c). Adding a `unix-path` transport variant for bind-mounted UNIX sockets is a separate plan that this one explicitly does not depend on.
|
||||||
|
5. **Preserve the runtime invariants.** Zero filesystem footprint at runtime, mlock'd secrets, crash-equals-total-wipe, and human-attended TUI all carry over. The image runs `--read-only`; no state files; SIGTERM from `docker stop` triggers the same wipe path as Ctrl-C in a terminal.
|
||||||
|
6. **Verifiable provenance.** Pulled images must be cryptographically verifiable to a release tag, with an SBOM and vulnerability scan attached as release assets so users can inspect what they are running.
|
||||||
|
7. **No regression for the native path.** The static-binary distribution and the `build_static.sh` build flow continue to work unchanged. The Docker image is an *additional* on-ramp, not a replacement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Non-goals
|
||||||
|
|
||||||
|
- **No multi-user, long-running daemon mode.** The TUI-attended, foreground-only design from [`README.md`](../README.md) is preserved. The image is run with `-it`, attached to a terminal. Background `docker run -d` is explicitly unsupported and will be documented as such.
|
||||||
|
- **No bundled clients.** The image contains only the signer binary. Clients (browser extension, native apps, examples) ship separately.
|
||||||
|
- **No persistent volume.** No mnemonic-on-disk, no policy-on-disk. The signer's "no runtime config or state files" rule from [`README.md`](../README.md) applies unchanged inside the container.
|
||||||
|
- **No network exposure beyond loopback by default.** The documented invocation publishes ports to `127.0.0.1` only. LAN/WAN exposure is out of scope and would require a separate threat model plus TLS termination, which is its own plan.
|
||||||
|
- **No NIP-46 relay transport.** That remains deferred per [`plans/nip46_bunker_mode.md`](nip46_bunker_mode.md).
|
||||||
|
- **No host-UNIX-socket bridging.** Bind-mounting a UNIX socket from the container to the host is appealing for UID-based identity but requires a `--listen unix-path:` mode that does not exist today. Tracked as a follow-up note (todo 20), out of scope here.
|
||||||
|
- **No image-internal privilege separation.** The signer runs as a single non-root user inside the container; further compartmentalization (separate uid for TUI vs network handling) is not in scope for v1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Threat model delta vs. the native binary
|
||||||
|
|
||||||
|
The Docker image inherits the same in-process threat model as the native binary (mlock'd secrets, no disk artifacts, deny-by-default approvals). It adds:
|
||||||
|
|
||||||
|
| Boundary | Native terminal | Docker image (this plan) |
|
||||||
|
|---|---|---|
|
||||||
|
| Same-UID host process reads signer memory | Vulnerable to `ptrace`, `/proc/<pid>/mem` | Blocked by PID namespace |
|
||||||
|
| Compromised signer escalates on host | Has full UID privileges on host | Constrained by `--cap-drop ALL`, `--security-opt no-new-privileges`, default seccomp |
|
||||||
|
| Compromised signer writes persistent files | Can write anywhere UID has access | `--read-only` rootfs blocks writes; no volumes mounted |
|
||||||
|
| Core dump leaks key material | Possible if `ulimit` or systemd allows | `--read-only` plus no writable paths means no core file path |
|
||||||
|
| Host root reads signer memory | Unconstrained | Still unconstrained — `nsenter`, `docker exec`, host `/proc` all work. **Documented as a non-defense.** |
|
||||||
|
| Kernel exploit | Full host compromise | Full host compromise — shared kernel. **Documented as a non-defense.** |
|
||||||
|
| Network reachability | Bound by socket file perms or abstract namespace | Bound by Docker port publishing; `127.0.0.1` only by docs |
|
||||||
|
| TCP caller identity | Auth envelope (per [`plans/caller_token_identity.md`](caller_token_identity.md)) | Same — unchanged inside the container |
|
||||||
|
|
||||||
|
The user-facing security message in [`documents/SECURITY.md`](../documents/SECURITY.md) gets a small subsection (todo 12) calling out exactly this — what Docker buys you and what it does not — so users do not over-trust container isolation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Image architecture
|
||||||
|
|
||||||
|
### 5.1 Multi-stage Dockerfile
|
||||||
|
|
||||||
|
Extend [`Dockerfile.alpine-musl`](../Dockerfile.alpine-musl) with a third stage that produces a runnable image. The existing two stages — `builder` and the `FROM scratch AS output` extraction stage — are preserved unchanged so [`build_static.sh`](../build_static.sh) keeps working.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
A[Stage: builder<br/>alpine:3.19 + toolchain] --> B[Stage: output<br/>FROM scratch<br/>used by build_static.sh]
|
||||||
|
A --> C[Stage: runtime<br/>FROM scratch<br/>ENTRYPOINT nsigner]
|
||||||
|
B -.-> D[host binary<br/>build/nsigner_static_*]
|
||||||
|
C -.-> E[OCI image<br/>nsigner:vX.Y.Z]
|
||||||
|
|
||||||
|
style C fill:#9f9
|
||||||
|
style E fill:#9f9
|
||||||
|
```
|
||||||
|
|
||||||
|
The runtime stage:
|
||||||
|
|
||||||
|
- Starts `FROM scratch` (no shell, no libc, no busybox — the binary is fully static MUSL).
|
||||||
|
- Copies in the stripped static binary as `/nsigner`.
|
||||||
|
- Sets `ENTRYPOINT ["/nsigner"]` and a default `CMD` of `["--listen", "tcp:0.0.0.0:7777"]`. Users can override CMD to pick another transport, but the ENTRYPOINT remains fixed at the binary.
|
||||||
|
- Declares OCI image labels (todo 3) — source URL, version, revision (git SHA), licenses, title, description — sourced from the existing `NSIGNER_VERSION` macro that [`increment_and_push.sh`](../increment_and_push.sh) already maintains.
|
||||||
|
- Runs as a non-root UID. Because `FROM scratch` has no `/etc/passwd`, this is done with a numeric UID via `USER 65532`. The static binary has no UID-name dependencies.
|
||||||
|
- `EXPOSE 7777` for documentation only (does not affect runtime).
|
||||||
|
|
||||||
|
### 5.2 Default `--listen` choice
|
||||||
|
|
||||||
|
The image defaults to `--listen tcp:0.0.0.0:7777`. Rationale (todo 15):
|
||||||
|
|
||||||
|
- It is the only existing transport that crosses the container netns boundary without `--network=host` (which would defeat the isolation we are running Docker for).
|
||||||
|
- It is the only transport with a working caller-authentication story today, via the auth envelope verified in [`src/auth_envelope.c`](../src/auth_envelope.c) and required for TCP per [`src/server.c`](../src/server.c) lines around 1313.
|
||||||
|
- `0.0.0.0` inside the container is safe **only** when the host publishes to `127.0.0.1`. The documentation makes this binding explicit and warns against `-p 7777:7777` (which publishes to all host interfaces). Todo 16 covers this in detail.
|
||||||
|
- Users who want stdio (one-shot) or qrexec (Qubes-specific) override `CMD` themselves; the default targets the dominant Docker-on-laptop case.
|
||||||
|
|
||||||
|
### 5.3 Image size and layers
|
||||||
|
|
||||||
|
Static MUSL binary, stripped, is roughly single-digit MB. The runtime image will be that plus the OCI metadata — well under 10 MB. `docker pull` is a few seconds on any reasonable connection. This is documented as a property, not just a coincidence, because it directly supports the "easy for new users" goal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Build, tag, and publish flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
Dev[Developer<br/>./increment_and_push.sh -r] --> Bump[Bump version macros]
|
||||||
|
Bump --> Tag[git tag vX.Y.Z]
|
||||||
|
Tag --> Build[Build static binaries<br/>x86_64 + arm64]
|
||||||
|
Build --> Test[Run make test inside builder]
|
||||||
|
Test --> Img[Build runtime image<br/>buildx multiarch]
|
||||||
|
Img --> Sbom[Generate SBOM + vuln scan]
|
||||||
|
Sbom --> Sign[cosign sign image]
|
||||||
|
Sign --> Push[Push image and assets<br/>to registry]
|
||||||
|
Push --> Release[Create Gitea release<br/>with binary, sbom, image digest, verify cmd]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.1 Registry choice
|
||||||
|
|
||||||
|
Decision pending (todo 4). Three viable destinations:
|
||||||
|
|
||||||
|
1. **The existing Gitea registry at `git.laantungir.net`.** Aligns with the existing release flow that already uses `~/.gitea_token`. Self-hosted, under project control. Downside: less familiar to new users; they may need to log in to pull.
|
||||||
|
2. **GitHub Container Registry (GHCR).** Free, public, well-known, integrates with cosign keyless signing using GitHub OIDC. Requires a GitHub mirror.
|
||||||
|
3. **Docker Hub.** Most familiar to new users. Rate limits on anonymous pulls. Requires a Docker Hub account and token.
|
||||||
|
|
||||||
|
The plan recommends GHCR as primary because keyless cosign signing dramatically simplifies the trust story for new users (`cosign verify ghcr.io/...` with no key management). The Gitea registry is a viable alternative if the project prefers to stay self-hosted; in that case, key-based cosign signing replaces keyless. Either way, the choice is documented up front so users know where to pull from.
|
||||||
|
|
||||||
|
### 6.2 Multi-arch
|
||||||
|
|
||||||
|
Built via `docker buildx build --platform linux/amd64,linux/arm64`. The existing builder stage already handles both architectures (it picks `libnostr_core_x64.a` vs `libnostr_core_arm64.a` based on `uname -m`). A `Makefile` target (todo 2) wraps this so the developer command is `make docker-image-multiarch`.
|
||||||
|
|
||||||
|
### 6.3 Tags
|
||||||
|
|
||||||
|
- `vX.Y.Z` — immutable, matches git tag and binary release.
|
||||||
|
- `latest` — moves to newest stable release.
|
||||||
|
- `vX` and `vX.Y` — moving tags for users who want major/minor pinning.
|
||||||
|
|
||||||
|
All tags are signed; `latest` is signed at each release.
|
||||||
|
|
||||||
|
### 6.4 Provenance
|
||||||
|
|
||||||
|
Every release attaches:
|
||||||
|
|
||||||
|
- Image digest (`sha256:...`) in release notes.
|
||||||
|
- `cosign verify` command line as a copy-pasteable block.
|
||||||
|
- SBOM in SPDX or CycloneDX format (syft) as a release asset and as an OCI attestation attached to the image.
|
||||||
|
- Vulnerability scan output (grype or trivy) as a release asset.
|
||||||
|
|
||||||
|
This makes "is the image I pulled the one the project published?" a one-command answer. Todos 6 and 7 cover this.
|
||||||
|
|
||||||
|
### 6.5 Hooks into the existing release script
|
||||||
|
|
||||||
|
[`increment_and_push.sh`](../increment_and_push.sh) currently:
|
||||||
|
|
||||||
|
1. Bumps the version macros in `src/main.h` or `src/main.c`.
|
||||||
|
2. Tags the commit.
|
||||||
|
3. Builds x86_64 and arm64 static binaries.
|
||||||
|
4. Verifies `--version` of each binary matches the new tag.
|
||||||
|
5. Creates a Gitea release and uploads the binaries and a source tarball.
|
||||||
|
|
||||||
|
The image flow plugs in between steps 4 and 5 (todo 5):
|
||||||
|
|
||||||
|
5a. Build the runtime image for both architectures via buildx.
|
||||||
|
5b. Generate SBOM and vuln scan.
|
||||||
|
5c. Sign with cosign.
|
||||||
|
5d. Push the image and signatures to the chosen registry.
|
||||||
|
5e. Capture the resulting digest for the release notes.
|
||||||
|
|
||||||
|
Each of these steps is gated on a credential being present, mirroring the existing `~/.gitea_token` check. A developer without registry credentials still gets a successful binary release; the image steps emit warnings and skip. This keeps the local release flow usable in offline or partial-credential setups.
|
||||||
|
|
||||||
|
### 6.6 CI integration
|
||||||
|
|
||||||
|
A CI smoke test (todo 13) boots the just-built runtime image with the default `--listen tcp:0.0.0.0:7777`, drives a single `get_public_key` request from a host client over the published port, and asserts a clean shutdown. This catches regressions where the binary builds but cannot actually serve TCP from inside a container — for example, a future code change that defaults to abstract sockets in a way that breaks netns separation.
|
||||||
|
|
||||||
|
The existing `make test` suite is also run inside the builder stage (todo 14) before the artifact is extracted, so we have a tested-then-shipped guarantee on the binary that ends up in the image.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. New-user runbook (target end-state)
|
||||||
|
|
||||||
|
This is the experience the plan is optimizing for. After the work is done, the [`documents/DOCKER.md`](../documents/DOCKER.md) (todo 8) opens with effectively this:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Verify the image you are about to run
|
||||||
|
cosign verify ghcr.io/laantungir/nsigner:v0.X.Y \
|
||||||
|
--certificate-identity-regexp 'https://github.com/laantungir/n_signer' \
|
||||||
|
--certificate-oidc-issuer https://token.actions.githubusercontent.com
|
||||||
|
|
||||||
|
# 2. Run with hardened defaults
|
||||||
|
docker run -it --rm \
|
||||||
|
--read-only \
|
||||||
|
--cap-drop ALL \
|
||||||
|
--security-opt no-new-privileges \
|
||||||
|
--pids-limit 64 \
|
||||||
|
--memory 128m \
|
||||||
|
-p 127.0.0.1:7777:7777 \
|
||||||
|
ghcr.io/laantungir/nsigner:v0.X.Y
|
||||||
|
```
|
||||||
|
|
||||||
|
The TUI then comes up exactly as it does for the native binary — mnemonic prompt, role table, status display, approval prompts. From the user's perspective, the only differences are the longer command line and the fact that pressing Ctrl-C now also stops the container.
|
||||||
|
|
||||||
|
A second documentation block (todo 9) walks through writing a TCP client that builds a `kind:27235` auth envelope per [`plans/caller_token_identity.md`](caller_token_identity.md), signs it with BIP-340 Schnorr, and gets approved exactly once at the TUI. A worked example client is added to [`examples/`](../examples) (todo 10) so users have a copy-pasteable starting point that they can run on the host against the containerized signer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Risks and decisions
|
||||||
|
|
||||||
|
### 8.1 TCP-only is a real limitation
|
||||||
|
|
||||||
|
UID-based identity is preserved on AF_UNIX abstract sockets, which is a strong primitive in the native deployment. In Docker, that primitive is gone — abstract sockets do not cross netns. This plan accepts that and shifts to pubkey-based identity via the auth envelope. The trade-off is documented; users who specifically want UID-based identity stay on the native binary, or wait for the future `unix-path` transport plan (todo 20) that would let them bind-mount a pathname socket and recover `SO_PEERCRED.uid`. UID identity inside the container is also affected by Docker user-namespace remapping, which is another reason this plan does not pretend AF_UNIX inside a container is equivalent to AF_UNIX on the host.
|
||||||
|
|
||||||
|
### 8.2 `0.0.0.0` inside the container is unforgiving of misconfiguration
|
||||||
|
|
||||||
|
If a user copies the image's default `CMD` but writes `-p 7777:7777` (forgetting the `127.0.0.1:` prefix), they expose the signer to any host on the network. This is documented prominently (todo 16) with the explicit warning. We considered defaulting the CMD to `tcp:127.0.0.1:7777`, but that breaks Docker's port-publishing model — the host-side `127.0.0.1:7777` proxy needs to talk to the container's network interface, which is not loopback. So the safer-looking default is actually broken. The chosen approach (default `0.0.0.0` inside, document loopback publishing on the host) is the only one that works with Docker's networking semantics, but it requires that warning.
|
||||||
|
|
||||||
|
### 8.3 TUI under `docker run -it` may have edge cases
|
||||||
|
|
||||||
|
The signer's TUI uses raw terminal input for keystroke approvals, ANSI status redraws, and signal-driven shutdown. Most of this works under `docker run -it` but the plan reserves a verification step (todo 19) to confirm:
|
||||||
|
|
||||||
|
- `a` / `y` / `n` / `q` / `l` keystrokes deliver the same response as on a native terminal.
|
||||||
|
- ANSI redraws do not get garbled by Docker's TTY layer.
|
||||||
|
- `docker stop` (which sends SIGTERM, then SIGKILL after grace) triggers the same total-state-wipe path as Ctrl-C, including mnemonic zeroization.
|
||||||
|
- `docker kill` (SIGKILL only) leaves no recoverable state — by the existing crash-equals-wipe property, this should already hold, but the plan explicitly tests it.
|
||||||
|
|
||||||
|
Any deviation discovered is documented as a caveat in [`documents/DOCKER.md`](../documents/DOCKER.md), not silently worked around.
|
||||||
|
|
||||||
|
### 8.4 Host root is not blocked
|
||||||
|
|
||||||
|
The plan documents this as a non-defense. Users whose threat model includes "another user on the same host has root" should not assume Docker isolates against that. They should run on a system where they trust root, or move to a Qubes-style VM compartment as documented in [`documents/QUBES_OS.md`](../documents/QUBES_OS.md). This is not a flaw in the plan; it is a property of containers that is easy to misunderstand and therefore worth stating up front.
|
||||||
|
|
||||||
|
### 8.5 Image sprawl across registries
|
||||||
|
|
||||||
|
If both Gitea and GHCR end up hosting the image, users may pull the wrong one or skip signature verification because "the other registry's image is the same anyway." The decision in §6.1 (one primary registry) is made specifically to avoid this. A secondary mirror is acceptable only if it carries the same digests and the same cosign signatures, and the documentation makes the mirror status explicit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Out of scope, explicitly
|
||||||
|
|
||||||
|
Listed so the plan's scope is clear:
|
||||||
|
|
||||||
|
- TLS termination for TCP (a reverse-proxy-in-container pattern).
|
||||||
|
- Remote signing (binding TCP to non-loopback). Both require a separate threat model and are deferred.
|
||||||
|
- A `--listen unix-path:` transport that would let Docker users bind-mount a pathname socket. Tracked as todo 20 for a follow-up plan.
|
||||||
|
- A NIP-46 relay transport. Deferred per [`plans/nip46_bunker_mode.md`](nip46_bunker_mode.md).
|
||||||
|
- Reproducible builds in the strict bit-for-bit sense. The static-binary build is already close to deterministic, but full reproducibility (matching digests across rebuilds at a given tag) is its own engineering problem and is not promised here.
|
||||||
|
- Distro packages (deb, rpm, Homebrew). The native binary remains a release asset; OS-native packaging is its own track.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Acceptance criteria
|
||||||
|
|
||||||
|
The plan is considered delivered when **all** of the following are true:
|
||||||
|
|
||||||
|
1. A new user with only Docker installed can pull and run the image with one `docker run` command from [`documents/DOCKER.md`](../documents/DOCKER.md) and see the same TUI they would see from the native binary.
|
||||||
|
2. `cosign verify` against the published image passes for the chosen registry, and the verification command is in the release notes for that tag.
|
||||||
|
3. SBOM and vulnerability scan output are attached to the release.
|
||||||
|
4. Pressing Ctrl-C in the attached terminal, and `docker stop` from another shell, both result in the signer process exiting cleanly with the existing zeroization path having run. No leftover state on the container's writable layer (which is `--read-only` anyway).
|
||||||
|
5. A TCP client running on the host, using the example added in todo 10, can complete one `get_public_key` round trip after a single `[a]` approval at the TUI, and a second request from the same client pubkey is served silently.
|
||||||
|
6. The CI smoke test (todo 13) is green on every PR that touches `Dockerfile.alpine-musl`, the runtime stage, or `src/server.c`.
|
||||||
|
7. [`README.md`](../README.md) links to [`documents/DOCKER.md`](../documents/DOCKER.md) from a "Run in Docker" quickstart section, and [`documents/SECURITY.md`](../documents/SECURITY.md) carries the Docker-specific subsection from todo 12.
|
||||||
|
8. The native binary distribution and `make static` flow produce identical artifacts to before this plan landed. No regression.
|
||||||
253
plans/embedded_nsigner_spawn.md
Normal file
253
plans/embedded_nsigner_spawn.md
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
# Plan (future): Host programs that embed and spawn `n_signer`
|
||||||
|
|
||||||
|
Status: design only. Do not implement until [`plans/mnemonic_startup_input.md`](mnemonic_startup_input.md) has landed and been used in at least one host program.
|
||||||
|
|
||||||
|
This plan describes how a host program (for example `~/lt/nostr_terminal`, future Bitcoin or SSH frontends) can embed the `nsigner` binary into itself and launch it in a new visible terminal window with the mnemonic pre-loaded and pre-approvals declared, without any new on-disk artifact for `nsigner`.
|
||||||
|
|
||||||
|
## 1. Why embed at all
|
||||||
|
|
||||||
|
Goals motivating this plan:
|
||||||
|
|
||||||
|
1. **Single-binary deployment.** A user installs `nostr_terminal` and gets `nsigner` for free; no separate package manager step, no `$PATH` confusion, no version skew.
|
||||||
|
2. **Version pinning.** The host always launches the exact `nsigner` it was tested against.
|
||||||
|
3. **Zero filesystem footprint preserved.** Spawning `nsigner` does not put a copy of the binary anywhere persistent; it runs out of an anonymous in-kernel memory object.
|
||||||
|
4. **Same security boundary.** `nsigner` continues to run as a separate process with its own address space, mlock'd pages, and approval prompt loop. The host does not become the signer; it merely launches it.
|
||||||
|
5. **Reusable across host programs.** Any program in the user's `~/lt/*` ecosystem can adopt the same helper.
|
||||||
|
|
||||||
|
Non-goals (explicit):
|
||||||
|
|
||||||
|
- Running `nsigner` *inside* the host process. That collapses the security boundary and is rejected. See [`documents/SECURITY.md`](../documents/SECURITY.md) "One process. One terminal. One human."
|
||||||
|
- Hiding the `nsigner` window. The TUI is the trust anchor; it must be visible and attended.
|
||||||
|
- Making `nsigner` a daemon. Same reason.
|
||||||
|
|
||||||
|
## 2. Architecture overview
|
||||||
|
|
||||||
|
```text
|
||||||
|
+----------------------------------------------------------------+
|
||||||
|
| host program (e.g. nostr_terminal) |
|
||||||
|
| |
|
||||||
|
| +-- embedded blob ---------------------------------+ |
|
||||||
|
| | _binary_nsigner_start ... _binary_nsigner_end | |
|
||||||
|
| | (the full statically-linked nsigner ELF) | |
|
||||||
|
| +--------------------------------------------------+ |
|
||||||
|
| |
|
||||||
|
| user types mnemonic into host's TUI |
|
||||||
|
| | |
|
||||||
|
| v |
|
||||||
|
| nsigner_spawn_embedded(...) |
|
||||||
|
| | |
|
||||||
|
| | 1. memfd_create(MFD_CLOEXEC) |
|
||||||
|
| | 2. write(memfd, blob, blob_len) |
|
||||||
|
| | 3. pipe2(stdin_pipe, O_CLOEXEC) |
|
||||||
|
| | 4. fork() terminal emulator wrapper |
|
||||||
|
| | child: dup2 stdin_pipe[0] -> 0 |
|
||||||
|
| | execvp("kitty","-e","/proc/self/fd/<memfd>"|
|
||||||
|
| | --listen unix --name <chosen> |
|
||||||
|
| | --mnemonic-stdin |
|
||||||
|
| | --preapprove ...) |
|
||||||
|
| | 5. parent: write(stdin_pipe[1], mnemonic); close + wipe |
|
||||||
|
| | 6. return chosen socket name to caller |
|
||||||
|
+----------------------------------------------------------------+
|
||||||
|
|
|
||||||
|
v (new graphical terminal window)
|
||||||
|
+----------------------------------------------------------------+
|
||||||
|
| nsigner (separate process) |
|
||||||
|
| - reads mnemonic from FD 0, closes FD 0 |
|
||||||
|
| - binds @nsigner_<chosen> abstract unix socket |
|
||||||
|
| - shows status TUI |
|
||||||
|
| - applies pre-approvals from --preapprove |
|
||||||
|
+----------------------------------------------------------------+
|
||||||
|
^
|
||||||
|
| (host can now connect)
|
||||||
|
+----------------------------------------------------------------+
|
||||||
|
| host re-uses client/nsigner_client.{c,h} to issue requests |
|
||||||
|
+----------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Build-time integration
|
||||||
|
|
||||||
|
### 3.1 Producing the blob
|
||||||
|
|
||||||
|
Add a Makefile rule (in `n_signer`'s Makefile) that exposes the binary as a relocatable object usable by other projects:
|
||||||
|
|
||||||
|
```make
|
||||||
|
nsigner_blob.o: nsigner
|
||||||
|
$(LD) -r -b binary -o $@ $<
|
||||||
|
# results in symbols:
|
||||||
|
# _binary_nsigner_start
|
||||||
|
# _binary_nsigner_end
|
||||||
|
# _binary_nsigner_size
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatives considered:
|
||||||
|
|
||||||
|
- `xxd -i nsigner > nsigner_blob.h` — produces a C header with a `unsigned char` array. Works, but adds compile time linear in blob size and blocks ccache usefulness.
|
||||||
|
- `objcopy -I binary -O elf64-x86-64 -B i386 nsigner nsigner_blob.o` — same effect as `ld -r -b binary` on most toolchains. Either is fine.
|
||||||
|
|
||||||
|
### 3.2 Embedding into the host
|
||||||
|
|
||||||
|
The host program adds these lines to its build:
|
||||||
|
|
||||||
|
```make
|
||||||
|
HOST_OBJS += nsigner_blob.o nsigner_spawn.o
|
||||||
|
```
|
||||||
|
|
||||||
|
and links them into its final executable. The blob lives in `.data` (read-only on most toolchains) and adds roughly the size of the `nsigner` binary to the host (currently ~1–3 MB for the musl static build).
|
||||||
|
|
||||||
|
### 3.3 Symbol declaration
|
||||||
|
|
||||||
|
```c
|
||||||
|
extern const unsigned char _binary_nsigner_start[];
|
||||||
|
extern const unsigned char _binary_nsigner_end[];
|
||||||
|
/* size = _binary_nsigner_end - _binary_nsigner_start */
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Runtime spawn helper API
|
||||||
|
|
||||||
|
New file pair: `client/nsigner_spawn.h` and `client/nsigner_spawn.c`.
|
||||||
|
|
||||||
|
### 4.1 Public API sketch
|
||||||
|
|
||||||
|
```c
|
||||||
|
typedef struct {
|
||||||
|
/* required */
|
||||||
|
const char *mnemonic; /* will be written to child stdin pipe and then wiped */
|
||||||
|
size_t mnemonic_len;
|
||||||
|
|
||||||
|
/* optional */
|
||||||
|
const char *const *preapprove_specs; /* array of "caller=...,nostr_index=N" strings */
|
||||||
|
size_t preapprove_count;
|
||||||
|
|
||||||
|
const char *socket_name; /* if NULL, helper picks a random BIP-39 pair */
|
||||||
|
const char *terminal_hint; /* "kitty"|"foot"|"alacritty"|"xterm"|... or NULL = autodetect */
|
||||||
|
|
||||||
|
/* outputs */
|
||||||
|
char *out_socket_name; /* caller-supplied buffer */
|
||||||
|
size_t out_socket_name_size;
|
||||||
|
|
||||||
|
pid_t *out_terminal_pid; /* may be NULL */
|
||||||
|
} nsigner_spawn_opts_t;
|
||||||
|
|
||||||
|
/* Returns 0 on success, negative on error. On success, the embedded nsigner
|
||||||
|
* binary has been written to a memfd, the terminal window has been launched,
|
||||||
|
* the mnemonic has been written into the child's stdin pipe, and the local
|
||||||
|
* `mnemonic` buffer has been zeroized.
|
||||||
|
*
|
||||||
|
* After return, the host can connect to the socket using
|
||||||
|
* client/nsigner_client.{c,h}
|
||||||
|
* with the chosen socket name in opts->out_socket_name.
|
||||||
|
*/
|
||||||
|
int nsigner_spawn_embedded(nsigner_spawn_opts_t *opts);
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional readiness primitive (post-MVP): a `nsigner_spawn_wait_ready()` call that polls the abstract socket until it accepts a connection or until a timeout, so the host doesn't race the child's bind step.
|
||||||
|
|
||||||
|
### 4.2 Internal pipeline
|
||||||
|
|
||||||
|
1. Validate inputs: non-empty mnemonic, sane buffer sizes, `out_socket_name` non-NULL.
|
||||||
|
2. Pick a socket name: random BIP-39 word pair if not provided. Reuses the same name-generation routine that `nsigner` itself uses; consider extracting it into a shared header `src/socket_name.h` already exists ([`src/socket_name.c`](../src/socket_name.c:1)) and exposing the generator publicly.
|
||||||
|
3. Create memfd: `memfd_create("nsigner", MFD_CLOEXEC)`. On older kernels (no `memfd_create`), return an error rather than falling back to disk — the whole point is no filesystem footprint. (Linux 3.17+ has memfd; the project's deployment targets all qualify.)
|
||||||
|
4. Write the embedded blob to the memfd. `lseek` back to zero is unnecessary because `fexecve`/`execveat` does not care about offset.
|
||||||
|
5. Create stdin pipe: `pipe2(stdin_pipe, O_CLOEXEC)`. Mark only the read end as inheritable in the child (clear `FD_CLOEXEC` on the read end after `fork`).
|
||||||
|
6. Pick a terminal emulator. Detection order: `$NSIGNER_TERMINAL` env override, then `terminal_hint`, then a built-in priority list:
|
||||||
|
|
||||||
|
| Emulator | Run-this flag | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `kitty` | `--detach -- <argv>` | preserves stdin |
|
||||||
|
| `foot` | `-- <argv>` | preserves stdin |
|
||||||
|
| `alacritty` | `-e <argv>` | preserves stdin |
|
||||||
|
| `wezterm` | `start -- <argv>` | preserves stdin |
|
||||||
|
| `xterm` | `-e <argv>` | preserves stdin (legacy reliable) |
|
||||||
|
| `gnome-terminal` | **avoided** | dbus-launches, drops stdin/FDs |
|
||||||
|
| `konsole` | `-e <argv>` | mostly preserves stdin |
|
||||||
|
|
||||||
|
If none found, return an error and let the host print a hint to install one.
|
||||||
|
7. Build child argv:
|
||||||
|
- `[0] = chosen_terminal`
|
||||||
|
- `[1..]` = the terminal's "exec" flag(s) and separator
|
||||||
|
- then `[N+0] = "/proc/self/fd/<memfd>"` (path the kernel exposes for the memfd, valid only inside the child's process tree — no on-disk file is created)
|
||||||
|
- then `--listen unix --name <chosen> --mnemonic-stdin`
|
||||||
|
- then each `--preapprove <spec>` pair
|
||||||
|
8. `fork()`. In the child:
|
||||||
|
- `dup2(stdin_pipe[0], 0)` (replace stdin with the read end)
|
||||||
|
- `close(stdin_pipe[1])`
|
||||||
|
- clear `FD_CLOEXEC` on the memfd so `/proc/self/fd/<memfd>` resolves after `execvp` runs the terminal — the terminal then runs `nsigner` via the inherited memfd path
|
||||||
|
- `execvp(chosen_terminal, child_argv)`
|
||||||
|
9. In the parent:
|
||||||
|
- `close(stdin_pipe[0])`
|
||||||
|
- `write(stdin_pipe[1], mnemonic, mnemonic_len)`
|
||||||
|
- `write(stdin_pipe[1], "\n", 1)`
|
||||||
|
- `close(stdin_pipe[1])`
|
||||||
|
- `secure_memzero` over the host's mnemonic buffer
|
||||||
|
- close the memfd in the parent (the child kernel reference keeps it alive)
|
||||||
|
- copy the chosen socket name into `out_socket_name`
|
||||||
|
- return 0
|
||||||
|
|
||||||
|
### 4.3 Failure modes and cleanup
|
||||||
|
|
||||||
|
Every failure path must:
|
||||||
|
|
||||||
|
- close any FDs already opened (memfd, both pipe ends)
|
||||||
|
- zeroize the mnemonic buffer if it was already copied locally
|
||||||
|
- not leave a child process behind: if `fork` succeeded but a later step failed, `waitpid` the child after `kill(SIGTERM)`
|
||||||
|
|
||||||
|
A short error-string accessor (`const char *nsigner_spawn_strerror(int)`) keeps callers from needing to know the internal codes.
|
||||||
|
|
||||||
|
## 5. Security review (to perform before implementation)
|
||||||
|
|
||||||
|
These items are flagged now so the implementation phase doesn't drift:
|
||||||
|
|
||||||
|
1. **Confirm memfd contents are not exposed to other processes.** `/proc/self/fd/<memfd>` is per-process; another process cannot dereference another's `/proc/self/fd/N`. Document that fact in the code comments and in [`documents/SECURITY.md`](../documents/SECURITY.md).
|
||||||
|
2. **Confirm `MFD_CLOEXEC` actually closes on the *outer* exec.** We need the memfd open across `fork`+`execvp(terminal)` but want it gone from the terminal emulator's children that aren't `nsigner`. Tested behavior: clear `FD_CLOEXEC` only on this one FD before `execvp` so the terminal inherits it; the terminal itself doesn't close arbitrary FDs in its child by default but a few do. Document the matrix.
|
||||||
|
3. **Sealing.** Optionally `fcntl(memfd, F_ADD_SEALS, F_SEAL_WRITE | F_SEAL_SHRINK | F_SEAL_GROW)` before exec, so even a compromised child cannot rewrite the binary it is about to execute.
|
||||||
|
4. **Pre-approval defaults.** The helper must NOT silently inject a default `--preapprove` for the host's uid. The host must declare every approval explicitly. This keeps the `documents/SECURITY.md` "deny by default" guarantee intact.
|
||||||
|
5. **Mnemonic in pipe buffer.** The kernel pipe buffer (4 KiB) holds the mnemonic for the moment between parent's `write` and child's `read`. This is in-kernel anonymous memory, comparable to a `mlock`'d page in lifetime, and is freed when both ends close. Acceptable.
|
||||||
|
6. **Mnemonic in environment of the host.** The helper does not put the mnemonic in `envp`. It only goes through the pipe.
|
||||||
|
7. **Host trust elevation.** When the host calls this helper, the host program joins the trust chain (it already had the mnemonic). Document that in [`documents/SECURITY.md`](../documents/SECURITY.md) §2.3 trust-anchors table.
|
||||||
|
8. **Terminal emulator trust.** The chosen terminal emulator is now part of the trust chain (it sees stdin and could capture it). Document; recommend `kitty`/`foot`/`alacritty`/`xterm` and warn against modifying via plugins/config that record stdin.
|
||||||
|
|
||||||
|
## 6. Documentation deliverables
|
||||||
|
|
||||||
|
- `documents/SPAWN.md`: how host programs embed `nsigner_blob.o` and use `nsigner_spawn.{c,h}`.
|
||||||
|
- Update [`documents/SECURITY.md`](../documents/SECURITY.md) §2.3 with the host-program and terminal-emulator trust-chain rows.
|
||||||
|
- Update [`README.md`](../README.md) §3.1 with a one-paragraph "Running embedded inside a host program" note pointing here.
|
||||||
|
- New worked example `examples/spawn_and_sign.c`: takes a mnemonic from its own argv (for the example only — clearly labeled "EXAMPLE: argv mnemonic, not for production"), spawns `nsigner` via the helper, then issues `get_public_key` over the resulting socket and prints the result. This demonstrates the full pattern in ~80 lines.
|
||||||
|
|
||||||
|
## 7. Testing strategy
|
||||||
|
|
||||||
|
Tests are inherently more involved because they require spawning a real terminal emulator. Recommended approach:
|
||||||
|
|
||||||
|
1. **Unit tests for the helper** (`tests/test_spawn_unit.c`):
|
||||||
|
- argv builder produces the expected list given a fixture options struct.
|
||||||
|
- terminal autodetect returns the correct emulator given a fake `$PATH`.
|
||||||
|
- the memfd write produces a payload whose first 4 bytes are `\x7fELF` (sanity).
|
||||||
|
2. **Integration test under a headless terminal** (`tests/test_spawn_integration.c`):
|
||||||
|
- use `xvfb-run` plus `xterm -e` (or `foot --headless`-ish modes if available) so CI can spawn a window without a display server.
|
||||||
|
- the test passes a known mnemonic, waits for the abstract socket to appear, calls `get_public_key`, asserts the returned pubkey matches the BIP-39 vector for that mnemonic.
|
||||||
|
- this test is gated on a build flag because not every CI environment has a usable headless emulator; `make test-spawn` rather than the default `make test`.
|
||||||
|
3. **Manual smoke checklist** in `documents/SPAWN.md` covering the four supported emulators.
|
||||||
|
|
||||||
|
## 8. Open questions to resolve before implementation
|
||||||
|
|
||||||
|
- Should the helper live in `client/` (next to `nsigner_client.{c,h}`) or in a new top-level `embed/` directory? Recommendation: `client/` keeps related host-side code together.
|
||||||
|
- Should the embedded blob be compressed (e.g. zstd-decompress on the fly into the memfd)? Probably not for the first version — extra dependency, marginal size benefit on already-small statically-linked `nsigner`.
|
||||||
|
- How does this interact with code signing on platforms that enforce it (Windows, macOS, signed Linux distros)? Linux generally allows `fexecve` of memfd content; macOS would require a different path. The plan is Linux-only for now; document that.
|
||||||
|
- Should `nsigner` itself learn a `--print-socket-name-on-startup` flag (one line to a designated FD) so the spawn helper doesn't have to choose the name? Useful future addition; keep it as a follow-up after the memfd path proves out.
|
||||||
|
|
||||||
|
## 9. Acceptance criteria (when the time comes to implement)
|
||||||
|
|
||||||
|
- [ ] `make nsigner_blob.o` produces a relocatable object usable by host projects.
|
||||||
|
- [ ] `client/nsigner_spawn.{c,h}` compiles cleanly and links into a host program with no extra dependencies.
|
||||||
|
- [ ] `examples/spawn_and_sign.c` runs end-to-end and prints a valid pubkey.
|
||||||
|
- [ ] No file is created on disk during a successful spawn (verified by `lsof` snapshot or `inotifywait` on `/tmp`, `/run/user/$UID`, `$HOME`).
|
||||||
|
- [ ] Host's mnemonic buffer is wiped on the path to `execvp`.
|
||||||
|
- [ ] Pre-approvals declared by the host appear in the spawned `nsigner`'s policy table without prompting.
|
||||||
|
- [ ] Documentation updates landed in `documents/SPAWN.md`, [`documents/SECURITY.md`](../documents/SECURITY.md), and [`README.md`](../README.md).
|
||||||
|
- [ ] Tests in `tests/test_spawn_unit.c` pass on plain CI; `tests/test_spawn_integration.c` passes under `xvfb-run` on the spawn-CI lane.
|
||||||
|
|
||||||
|
## 10. Relationship to other plans
|
||||||
|
|
||||||
|
- Builds on [`plans/mnemonic_startup_input.md`](mnemonic_startup_input.md) — the embedded helper consumes `--mnemonic-stdin`.
|
||||||
|
- Compatible with [`plans/deny_by_default_approvals.md`](deny_by_default_approvals.md) — every host-issued approval still goes through `--preapprove`.
|
||||||
|
- Compatible with [`plans/caller_token_identity.md`](caller_token_identity.md) — host identity is conveyed via `unix_peer` over the abstract socket like any other client.
|
||||||
|
- Companion to [`plans/auth_envelope_other_transports.md`](auth_envelope_other_transports.md): a host that spawns `nsigner` may also wish to forward authenticated requests on its behalf; out of scope here.
|
||||||
195
plans/mnemonic_startup_input.md
Normal file
195
plans/mnemonic_startup_input.md
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
# Plan: Non-interactive mnemonic input at startup (`--mnemonic-stdin`, `--mnemonic-fd`)
|
||||||
|
|
||||||
|
Status: ready to implement.
|
||||||
|
Related: [`documents/SECURITY.md`](../documents/SECURITY.md), [`src/main.c`](../src/main.c:1006), [`src/mnemonic.c`](../src/mnemonic.c:1).
|
||||||
|
Companion plan (future work): [`plans/embedded_nsigner_spawn.md`](embedded_nsigner_spawn.md).
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Allow `n_signer` to receive its mnemonic from a parent process at startup, without the human re-typing it, while preserving the project's existing security posture:
|
||||||
|
|
||||||
|
- nothing on the filesystem
|
||||||
|
- nothing in argv (no `--mnemonic <phrase>` flag)
|
||||||
|
- nothing in environment (no `NSIGNER_MNEMONIC` env var) for production use
|
||||||
|
- the mnemonic source is wiped from process memory immediately after a successful load
|
||||||
|
|
||||||
|
Two new CLI flags are added:
|
||||||
|
|
||||||
|
- `--mnemonic-stdin` — read one line from stdin during the unlock phase, treat that line as the mnemonic, then continue to the normal status display. Stdin is closed (or replaced with `/dev/null`) before the running phase begins so the listener loop and TUI hotkeys keep working.
|
||||||
|
- `--mnemonic-fd N` — read one line from file descriptor `N`, then close it. Intended to be wired by parent processes to a `pipe2(O_CLOEXEC)` write end or a `memfd_create(2)` region. **No filesystem path is involved** — `N` is an inherited FD number, equivalent to stdin in lifecycle but separable so it can coexist with `--listen stdio`.
|
||||||
|
|
||||||
|
The existing interactive TUI prompt remains the default when neither flag is given.
|
||||||
|
|
||||||
|
## 2. Why no `--mnemonic <phrase>` argv flag
|
||||||
|
|
||||||
|
Listed only to make the rejection explicit and discoverable in `git log`:
|
||||||
|
|
||||||
|
- visible to every same-uid process via `/proc/<pid>/cmdline`
|
||||||
|
- captured by `ps auxww`
|
||||||
|
- captured by shell history (`bash`/`zsh` `HISTFILE`)
|
||||||
|
- captured by audit subsystems (`auditd`, sysmon-like agents)
|
||||||
|
- captured by container/VM platform telemetry that records launch commands
|
||||||
|
|
||||||
|
Stdin and inherited FDs do not appear in any of those surfaces. They are the right primitive.
|
||||||
|
|
||||||
|
Likewise no `NSIGNER_MNEMONIC` environment variable, because envp is exposed via `/proc/<pid>/environ` to same-uid readers. Any future env-based override should be opt-in for testing only and named `NSIGNER_TEST_*` like the existing test hooks.
|
||||||
|
|
||||||
|
## 3. CLI surface changes
|
||||||
|
|
||||||
|
### 3.1 New flags (in [`src/main.c`](../src/main.c:1187))
|
||||||
|
|
||||||
|
| Flag | Argument | Behavior |
|
||||||
|
|---|---|---|
|
||||||
|
| `--mnemonic-stdin` | none | read mnemonic from FD 0, then close FD 0 and reopen as `/dev/null` |
|
||||||
|
| `--mnemonic-fd <N>` | int | read mnemonic from FD `N`, then close FD `N` |
|
||||||
|
|
||||||
|
Both flags are mutually exclusive with each other.
|
||||||
|
|
||||||
|
### 3.2 Compatibility with `--listen` modes
|
||||||
|
|
||||||
|
| Listen mode | `--mnemonic-stdin` | `--mnemonic-fd` |
|
||||||
|
|---|---|---|
|
||||||
|
| `unix` (default) | OK | OK |
|
||||||
|
| `tcp:HOST:PORT` | OK | OK |
|
||||||
|
| `stdio` | **rejected** (stdin is the request transport) | OK (FD must not be 0/1) |
|
||||||
|
| `qrexec` | **rejected** (stdio is the request transport) | OK (FD must not be 0/1) |
|
||||||
|
|
||||||
|
If both `--mnemonic-stdin` and `--listen stdio`/`qrexec` are passed, `n_signer` exits with a clear error before doing anything else.
|
||||||
|
|
||||||
|
`--mnemonic-fd` rejects `N == 0` if `--listen stdio`/`qrexec` is also passed, and rejects `N == 1` always (stdout is reserved for response framing in stdio/qrexec modes and for the TUI in unix/tcp modes).
|
||||||
|
|
||||||
|
### 3.3 Updated `print_usage()`
|
||||||
|
|
||||||
|
Two new lines added under the existing options section:
|
||||||
|
|
||||||
|
```
|
||||||
|
--mnemonic-stdin Read mnemonic from stdin (one line). Default unlock prompt is skipped.
|
||||||
|
--mnemonic-fd N Read mnemonic from inherited FD N (anonymous, no filesystem). Same-line semantics.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 `--help` discoverability
|
||||||
|
|
||||||
|
The current `print_usage()` block in [`src/main.c`](../src/main.c:615) already lists the other flags in tabular form; the two new lines slot in next to `--auth`. No structural change.
|
||||||
|
|
||||||
|
## 4. Behavioral spec
|
||||||
|
|
||||||
|
### 4.1 `prompt_load_mnemonic` becomes `load_mnemonic`
|
||||||
|
|
||||||
|
The existing function [`prompt_load_mnemonic()`](../src/main.c:1006) is renamed/refactored to `load_mnemonic(mnemonic_state_t*, mnemonic_source_t)` where:
|
||||||
|
|
||||||
|
```c
|
||||||
|
typedef enum {
|
||||||
|
MNEMONIC_SRC_TUI_PROMPT = 0, /* current default behavior */
|
||||||
|
MNEMONIC_SRC_STDIN, /* --mnemonic-stdin */
|
||||||
|
MNEMONIC_SRC_FD, /* --mnemonic-fd N */
|
||||||
|
} mnemonic_source_kind_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
mnemonic_source_kind_t kind;
|
||||||
|
int fd; /* used for SRC_STDIN (=0) and SRC_FD */
|
||||||
|
} mnemonic_source_t;
|
||||||
|
```
|
||||||
|
|
||||||
|
For `SRC_STDIN` and `SRC_FD`:
|
||||||
|
|
||||||
|
1. Read up to `MNEMONIC_MAX_LEN` bytes from the FD using a small bounded read loop (`read(2)`), stopping at the first `\n` or EOF.
|
||||||
|
2. Trim a single trailing `\n` and any single trailing `\r` (handle Windows-style line endings just in case).
|
||||||
|
3. Reject input containing any embedded `\0` (defensive; mnemonic is text).
|
||||||
|
4. Reject input that ends without seeing EOF or `\n` after `MNEMONIC_MAX_LEN` bytes (caller is misbehaving).
|
||||||
|
5. Pass the buffer to [`mnemonic_load()`](../src/mnemonic.c:1) which already validates word count.
|
||||||
|
6. Zeroize the local read buffer with `secure_memzero` regardless of success/failure.
|
||||||
|
7. On success, close the source FD. For `SRC_STDIN`, also `dup2()` `/dev/null` onto FD 0 so subsequent `read()`s on FD 0 don't accidentally consume bytes meant for nothing.
|
||||||
|
8. On failure (read error, validation error), exit non-zero. There is **no retry loop** in non-interactive mode — the parent should fix its input and re-spawn; silently retrying could mask a serious bug like leaked mnemonic-passing logic.
|
||||||
|
|
||||||
|
For `SRC_TUI_PROMPT`: unchanged, current behavior preserved verbatim, including the existing 10-attempt retry loop.
|
||||||
|
|
||||||
|
### 4.2 Lock/unlock keystroke (`l` hotkey)
|
||||||
|
|
||||||
|
Currently the `l` hotkey calls [`prompt_load_mnemonic()`](../src/main.c:1527) again interactively. After this change:
|
||||||
|
|
||||||
|
- If the original startup used the TUI prompt: `l` re-runs the TUI prompt (unchanged).
|
||||||
|
- If the original startup used `--mnemonic-stdin` or `--mnemonic-fd`: the source FD has already been closed and zeroized, so re-locking would have no source to re-read from. Two options:
|
||||||
|
- **Option A (chosen):** the `l` hotkey falls back to the TUI prompt on re-unlock, regardless of how the original unlock happened. The user is at the terminal, so the TUI prompt is always available.
|
||||||
|
- Option B: disable `l` entirely if non-interactive source was used. Rejected — too restrictive.
|
||||||
|
|
||||||
|
Implementation: track the original source kind in a `static` in main.c; when the user presses `l` after non-interactive startup, log a one-line notice ("re-unlock via terminal prompt") and call the TUI variant.
|
||||||
|
|
||||||
|
### 4.3 Trim/normalize rules
|
||||||
|
|
||||||
|
The mnemonic line is treated as ASCII text. Allowed transformations before validation:
|
||||||
|
|
||||||
|
- strip a single trailing `\r\n` or `\n`
|
||||||
|
- strip leading and trailing ASCII whitespace (space, tab)
|
||||||
|
- collapse internal runs of whitespace? **No** — `mnemonic_load()` already tokenizes on whitespace; passing the trimmed string through unchanged is simpler and matches typed-mnemonic behavior.
|
||||||
|
|
||||||
|
### 4.4 Error messages
|
||||||
|
|
||||||
|
All errors go to stderr (not the TUI), prefixed `nsigner:`, and exit with status 1:
|
||||||
|
|
||||||
|
- `nsigner: --mnemonic-stdin and --mnemonic-fd are mutually exclusive`
|
||||||
|
- `nsigner: --mnemonic-stdin requires --listen unix or --listen tcp:...`
|
||||||
|
- `nsigner: --mnemonic-fd N: invalid FD value`
|
||||||
|
- `nsigner: --mnemonic-fd N: read failed: <strerror>`
|
||||||
|
- `nsigner: mnemonic input exceeded maximum length`
|
||||||
|
- `nsigner: mnemonic validation failed`
|
||||||
|
|
||||||
|
The actual mnemonic content is **never** printed in any error message. The generic "validation failed" message is intentional.
|
||||||
|
|
||||||
|
## 5. Security review checklist
|
||||||
|
|
||||||
|
Items the implementer should verify before merging:
|
||||||
|
|
||||||
|
1. The stack buffer used for the read is wiped with `secure_memzero` on every exit path, including early-error returns.
|
||||||
|
2. If an `mlock`-backed temporary buffer is used instead of a stack buffer, it must be `secure_buf_alloc`'d and `secure_buf_free`'d.
|
||||||
|
3. The source FD is `close(2)`'d after read, before `mnemonic_load` returns control to `main()`. Close happens even on failure paths.
|
||||||
|
4. For `--mnemonic-stdin`, FD 0 is replaced with `/dev/null` (`open("/dev/null", O_RDONLY)` then `dup2`) so later code paths cannot read leftover bytes.
|
||||||
|
5. The new flags are documented in [`README.md`](../README.md) §3.1 (Startup phase) with an explicit note that they are intended for parent-process spawning and that the parent is responsible for FD hygiene.
|
||||||
|
6. The new flags are documented in [`documents/SECURITY.md`](../documents/SECURITY.md) under §2 ("trust anchors") — the parent process now joins the trust chain when these flags are used.
|
||||||
|
7. No new path is added that prints the mnemonic to logs, the activity buffer, or any debug output.
|
||||||
|
8. `man`-style banner / help output never echoes the mnemonic.
|
||||||
|
|
||||||
|
## 6. Testing plan
|
||||||
|
|
||||||
|
New tests:
|
||||||
|
|
||||||
|
- `tests/test_mnemonic_input.c` (new file) covering:
|
||||||
|
1. `--mnemonic-stdin` happy path: spawn `nsigner` with a pipe, write a valid 12-word mnemonic + `\n`, expect successful unlock.
|
||||||
|
2. `--mnemonic-stdin` invalid mnemonic: write garbage, expect non-zero exit.
|
||||||
|
3. `--mnemonic-stdin` no newline before EOF: write valid mnemonic without `\n`, close pipe, expect success.
|
||||||
|
4. `--mnemonic-stdin` line too long: write 1024 bytes, expect non-zero exit with the length error.
|
||||||
|
5. `--mnemonic-fd 3` happy path: parent opens an `O_CLOEXEC=0` pipe, dups its write end as FD 3 in the child via `posix_spawn_file_actions_*`, writes mnemonic, expects success.
|
||||||
|
6. Mutually-exclusive flag combinations: `--mnemonic-stdin --mnemonic-fd 3`, `--mnemonic-stdin --listen stdio`, `--mnemonic-fd 0 --listen stdio` — all expected to exit 1 with the matching error string.
|
||||||
|
7. Lock-then-reunlock after `--mnemonic-stdin` startup: verify the TUI prompt path still works (using `NSIGNER_TEST_HOTKEYS` and `NSIGNER_TEST_NONINTERACTIVE_PROMPT` test hooks).
|
||||||
|
|
||||||
|
Existing tests must continue to pass:
|
||||||
|
|
||||||
|
- [`tests/test_mnemonic.c`](../tests/test_mnemonic.c) (mnemonic library unit tests — unchanged surface)
|
||||||
|
- [`tests/test_integration.c`](../tests/test_integration.c) (default TUI-prompt path)
|
||||||
|
- [`tests/test.sh`](../tests/test.sh) wrapper.
|
||||||
|
|
||||||
|
Add a row to `Makefile` `test` target for the new test binary.
|
||||||
|
|
||||||
|
## 7. Documentation updates
|
||||||
|
|
||||||
|
- [`README.md`](../README.md) §3.1: add a paragraph at the end noting the two new flags and pointing to this plan.
|
||||||
|
- [`documents/SECURITY.md`](../documents/SECURITY.md) §2.3: add a row to the trust-anchors table for "the parent process when `--mnemonic-stdin`/`--mnemonic-fd` is used".
|
||||||
|
- [`documents/CLIENT_IMPLEMENTATION.md`](../documents/CLIENT_IMPLEMENTATION.md): no changes (client wire protocol unaffected).
|
||||||
|
- New section in [`README.md`](../README.md) under "Operating modes" pointing readers to the future spawning plan once that lands.
|
||||||
|
|
||||||
|
## 8. Out of scope (handled by companion plan)
|
||||||
|
|
||||||
|
- Embedding the `nsigner` binary into a host program via `objcopy` blob + `memfd_create` + `fexecve`. See [`plans/embedded_nsigner_spawn.md`](embedded_nsigner_spawn.md).
|
||||||
|
- Reusable C client helper that picks a terminal emulator and wires the pipe. Same companion plan.
|
||||||
|
- `examples/spawn_and_sign.c`. Same companion plan.
|
||||||
|
|
||||||
|
## 9. Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] `nsigner --mnemonic-stdin` reads one line, validates, and proceeds to status display without prompting.
|
||||||
|
- [ ] `nsigner --mnemonic-fd 3` (with FD 3 inherited from a parent test harness) does the same.
|
||||||
|
- [ ] Invalid mnemonics exit non-zero with the generic validation message; mnemonic content is never printed.
|
||||||
|
- [ ] Mutually exclusive flag combinations are rejected at argument-parse time.
|
||||||
|
- [ ] `--listen stdio` and `--mnemonic-stdin` are mutually exclusive.
|
||||||
|
- [ ] After non-interactive unlock, pressing `l` falls back to the TUI prompt for re-unlock and the running phase otherwise behaves identically.
|
||||||
|
- [ ] The mnemonic source FD is closed and the source buffer is zeroized on every path.
|
||||||
|
- [ ] All existing tests still pass; new tests in `tests/test_mnemonic_input.c` pass.
|
||||||
|
- [ ] [`README.md`](../README.md) and [`documents/SECURITY.md`](../documents/SECURITY.md) are updated.
|
||||||
98
plans/nip46_bunker_mode.md
Normal file
98
plans/nip46_bunker_mode.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Plan: Deferred NIP-46 bunker transport mode
|
||||||
|
|
||||||
|
Status: **deferred (design only, not in current implementation scope)**
|
||||||
|
|
||||||
|
This document records a future transport mode where `n_signer` can operate as a NIP-46-compatible bunker over relays, while keeping the current local/TCP framed JSON-RPC path intact.
|
||||||
|
|
||||||
|
Current implementation priority remains [`plans/caller_token_identity.md`](caller_token_identity.md): required per-request caller authentication for TCP/URL using signed auth envelopes and existing `n_signer` request flow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why this is deferred
|
||||||
|
|
||||||
|
NIP-46 adds more than caller authentication:
|
||||||
|
|
||||||
|
- Relay session lifecycle and routing
|
||||||
|
- NIP-44 encrypted request/response payloads
|
||||||
|
- `connect` ceremony and secret verification
|
||||||
|
- Signer service identity (`remote-signer-pubkey`) management
|
||||||
|
- Compatibility behavior for `nostrconnect://` and `bunker://`
|
||||||
|
|
||||||
|
That is a separate subsystem from the immediate bugfix (stable, authenticated caller identity over TCP reconnects). Combining both now would increase risk and delay rollout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Future objective
|
||||||
|
|
||||||
|
Add an optional mode (example: `--listen nip46`) that accepts NIP-46 `kind:24133` requests and returns NIP-46 `kind:24133` responses as defined in [`resources/nips/46.md`](../resources/nips/46.md).
|
||||||
|
|
||||||
|
This mode should:
|
||||||
|
|
||||||
|
1. Authenticate client requests using NIP-46 client keypairs and encrypted content.
|
||||||
|
2. Map NIP-46 caller identity to the same deny-by-default approval model already used by current transports.
|
||||||
|
3. Reuse existing dispatcher/enforcement/policy machinery after transport decoding.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Compatibility stance (future)
|
||||||
|
|
||||||
|
- Keep current framed JSON-RPC transports (`AF_UNIX`, `qrexec`, `tcp`) as first-class for local deployments.
|
||||||
|
- Add NIP-46 as **additional** transport, not replacement.
|
||||||
|
- Shared internal core: once a request is decoded into `(caller_id, method, params, selector)`, the same policy and enforcement pipeline runs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Planned phases
|
||||||
|
|
||||||
|
### Phase A — Transport shell
|
||||||
|
|
||||||
|
- Add `nip46` listen mode and relay I/O loop.
|
||||||
|
- Parse and validate incoming `kind:24133` envelope.
|
||||||
|
- Decrypt NIP-44 payload and extract JSON-RPC-like `method` + `params`.
|
||||||
|
|
||||||
|
### Phase B — Identity + approval binding
|
||||||
|
|
||||||
|
- Derive caller identity from NIP-46 `client-pubkey`.
|
||||||
|
- Represent as `caller_id = pubkey:<hex>` to align with the current TCP auth direction in [`plans/caller_token_identity.md`](caller_token_identity.md).
|
||||||
|
- Route to existing policy checks and prompt behavior.
|
||||||
|
|
||||||
|
### Phase C — Response path
|
||||||
|
|
||||||
|
- Convert dispatcher results into NIP-46 response payloads.
|
||||||
|
- Encrypt via NIP-44 and emit `kind:24133` response event.
|
||||||
|
- Preserve request `id` correlation and error mapping.
|
||||||
|
|
||||||
|
### Phase D — Connect / secret / perms polish
|
||||||
|
|
||||||
|
- Implement full `connect` handshake semantics from [`resources/nips/46.md`](../resources/nips/46.md:100).
|
||||||
|
- Validate optional secret behavior.
|
||||||
|
- Support requested permissions metadata and signer-side policy translation.
|
||||||
|
|
||||||
|
### Phase E — Operational hardening
|
||||||
|
|
||||||
|
- Relay switching behavior (`switch_relays`), lifecycle recovery.
|
||||||
|
- Rate limits and replay protections specific to relay transport.
|
||||||
|
- Audit/event logs for NIP-46 connections and requests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Open design questions (for later)
|
||||||
|
|
||||||
|
1. Should NIP-46 mode require a dedicated signer identity key, or may it reuse the user signing identity?
|
||||||
|
2. How should `connect` permissions map to `n_signer` role/selector approvals?
|
||||||
|
3. Should NIP-46 callers and TCP-auth callers share the same `pubkey:<hex>` approval namespace by default?
|
||||||
|
4. Do we support both relay-initiated and direct-client initiation flows from day one?
|
||||||
|
5. What minimum relay trust assumptions are required in `documents/SECURITY.md`?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Out of scope for current code work
|
||||||
|
|
||||||
|
The current implementation task does **not** include:
|
||||||
|
|
||||||
|
- Adding relay networking
|
||||||
|
- NIP-44 tunnel encryption for requests
|
||||||
|
- NIP-46 `connect` handshake
|
||||||
|
- `nostrconnect://` URI flow
|
||||||
|
|
||||||
|
Those remain deferred to this plan.
|
||||||
140
plans/nsigner.md
140
plans/nsigner.md
@@ -105,7 +105,7 @@ Steps:
|
|||||||
- `int socket_name_random(char *out, size_t out_len);`
|
- `int socket_name_random(char *out, size_t out_len);`
|
||||||
- Calls `getrandom(2)` for 3 bytes (24 bits), splits into two 11-bit indices, looks up words from the BIP-39 English wordlist, formats `nsigner_<w1>_<w2>`.
|
- Calls `getrandom(2)` for 3 bytes (24 bits), splits into two 11-bit indices, looks up words from the BIP-39 English wordlist, formats `nsigner_<w1>_<w2>`.
|
||||||
- BIP-39 English wordlist
|
- BIP-39 English wordlist
|
||||||
- Reuse the wordlist already linked via `nostr_core_lib` if exposed; otherwise vendor `src/bip39_english.c` as a 2048-entry `const char *[]`.
|
- Use the wordlist exposed by `nostr_core_lib` (no vendored `bip39_english.c` in this repo).
|
||||||
- `src/server.c` bind logic
|
- `src/server.c` bind logic
|
||||||
- Accept the resolved name from caller.
|
- Accept the resolved name from caller.
|
||||||
- Bind once. On `EADDRINUSE` and no `--socket-name` override, regenerate a fresh random name and retry up to 8 times. With override, fail immediately and report the override conflict.
|
- Bind once. On `EADDRINUSE` and no `--socket-name` override, regenerate a fresh random name and retry up to 8 times. With override, fail immediately and report the override conflict.
|
||||||
@@ -140,7 +140,7 @@ Privacy/UX notes:
|
|||||||
|
|
||||||
1. Multiple `nsigner` instances can run concurrently on one host.
|
1. Multiple `nsigner` instances can run concurrently on one host.
|
||||||
2. Default socket name is `@nsigner_<bip39_word1>_<bip39_word2>` chosen randomly at each launch.
|
2. Default socket name is `@nsigner_<bip39_word1>_<bip39_word2>` chosen randomly at each launch.
|
||||||
3. `--socket-name <name>` overrides the random pick for tests and scripted usage.
|
3. `--socket-name <name>` overrides the random pick for tests and scripted usage (aliases: `--name`, `-n`).
|
||||||
4. `nsigner list` enumerates running signers via `/proc/net/unix` filtered on `nsigner_` prefix.
|
4. `nsigner list` enumerates running signers via `/proc/net/unix` filtered on `nsigner_` prefix.
|
||||||
5. Random naming was chosen over deterministic-from-mnemonic for v1 to avoid leaking any seed-derived identifier in the abstract namespace; deterministic naming may be revisited later.
|
5. Random naming was chosen over deterministic-from-mnemonic for v1 to avoid leaking any seed-derived identifier in the abstract namespace; deterministic naming may be revisited later.
|
||||||
|
|
||||||
@@ -163,18 +163,130 @@ Privacy/UX notes:
|
|||||||
|
|
||||||
## 5. Open questions
|
## 5. Open questions
|
||||||
|
|
||||||
- Automated test strategy for interactive TUI (stdin pipe? expect-style? separate test mode flag?)
|
- ESP32 transport shim interface definition and frame format details.
|
||||||
- ESP32 transport shim interface definition
|
- NIP-46 relay transport integration timeline.
|
||||||
- NIP-46 relay transport integration timeline
|
- Role enrollment UX depth at startup vs. minimal defaults.
|
||||||
- Role enrollment UX: how many questions at startup vs. "just use main = index 0"?
|
- Optional policy granularity beyond same-uid + interactive prompt (e.g. per-verb/per-role session rules).
|
||||||
- Lock-without-exit: needed? Or is quit-and-relaunch sufficient?
|
|
||||||
|
|
||||||
## 6. Immediate next work
|
## 6. Immediate next work
|
||||||
|
|
||||||
- Execute Phase A (cleanup)
|
- Add regression tests for prompt-overlay behavior and running-phase hotkeys.
|
||||||
- Execute Phase B (server module)
|
- Expand integration coverage for NIP-04/NIP-44 edge cases and negative-path errors.
|
||||||
- Execute Phase C (unified main)
|
- Document and test static artifact size budgets across targets.
|
||||||
- Execute Phase D (policy simplification)
|
- Define MCU transport adapter contract to prepare desktop/firmware parity.
|
||||||
- Execute Phase E (integration test)
|
|
||||||
- Execute Phase G2 (mnemonic generation at startup)
|
## 7. Transport expansion roadmap
|
||||||
- Execute Phase H (multi-instance random naming + `list` subcommand)
|
|
||||||
|
Goal: keep one signer core, swap transports underneath without touching dispatcher, policy, or role layers. The wire contract in [`CLIENT_IMPLEMENTATION.md`](../CLIENT_IMPLEMENTATION.md) (4-byte length-prefixed JSON-RPC) stays identical across every transport; only listener and `caller_identity_t` change.
|
||||||
|
|
||||||
|
### 7.0 Prerequisite — transport abstraction (Phase T0)
|
||||||
|
|
||||||
|
Before adding any new transport, factor a small adapter contract out of [`src/server.c`](../src/server.c) and [`src/main.c`](../src/main.c).
|
||||||
|
|
||||||
|
- New header `src/transport.h` declaring an opaque `nsigner_transport_t` with:
|
||||||
|
- `accept(listener) -> connection`
|
||||||
|
- `recv_frame(connection) -> bytes`
|
||||||
|
- `send_frame(connection, bytes)`
|
||||||
|
- `peer_identity(connection) -> caller_identity_t`
|
||||||
|
- `close(connection)` / `shutdown(listener)`
|
||||||
|
- Generalize `caller_identity_t` to a tagged union of:
|
||||||
|
- `unix_peer { uid, pid, comm }` (current behavior)
|
||||||
|
- `qubes { source_qube_name }`
|
||||||
|
- `tcp_local { addr }`
|
||||||
|
- `tcp_remote { addr, authenticated_pubkey }`
|
||||||
|
- `fips { peer_npub }`
|
||||||
|
- `usb_serial { device_path, asserted_caller }`
|
||||||
|
- Move `recv_framed` / `send_framed` from `server.c` and `main.c` into a single shared `transport_frame.c` so client and server share one framing implementation.
|
||||||
|
- Server main loop becomes transport-agnostic (`while accept; recv; dispatch; send`).
|
||||||
|
- Tests: extend [`tests/test_integration.c`](../tests/test_integration.c) with a transport-loopback fake to validate the abstraction without binding any real socket.
|
||||||
|
|
||||||
|
This refactor is purely internal — no observable change.
|
||||||
|
|
||||||
|
### 7.1 Phase T1 — Qubes OS qrexec transport
|
||||||
|
|
||||||
|
Use Qubes' native inter-qube primitive instead of inventing one.
|
||||||
|
|
||||||
|
- Add a qrexec service script (e.g. `qubes.NsignerRpc`) that execs `nsigner` in a "stdio transport" mode where stdin/stdout carry the existing length-prefixed frame protocol.
|
||||||
|
- New CLI: `nsigner --listen stdio` (and `nsigner --listen qrexec`, behaving identically; `qrexec` value is for documentation/intent).
|
||||||
|
- Caller identity comes from qrexec environment (`QREXEC_REMOTE_DOMAIN`) and is mapped to `caller_identity_t.kind=qubes`.
|
||||||
|
- Reference policy file under `packaging/qubes/policy.d/40-nsigner.policy` showing `ask` / `allow` per source qube.
|
||||||
|
- No new attack surface inside nsigner: dom0 enforces who can even invoke the service.
|
||||||
|
- Tests: a unit test that injects fake qrexec env vars and a stdio framing harness; an integration script that documents end-to-end install in a Qubes VM (manual, not in CI).
|
||||||
|
- Docs: add a "Qubes deployment" section to [`README.md`](../README.md) and to [`CLIENT_IMPLEMENTATION.md`](../CLIENT_IMPLEMENTATION.md).
|
||||||
|
|
||||||
|
### 7.2 Phase T2 — TCP transport
|
||||||
|
|
||||||
|
Smallest IP-based step; on-ramp for non-Linux clients and for FIPS later.
|
||||||
|
|
||||||
|
- New CLI: `nsigner --listen tcp:HOST:PORT` (IPv4 literal) or `nsigner --listen tcp:[IPv6]:PORT`.
|
||||||
|
- Current behavior: accepts operator-selected local/remote bind addresses (including `[::]` and `fd..`), pending later transport hardening.
|
||||||
|
- Caller identity for TCP: endpoint address/port in caller descriptor. Approval prompt still mandatory.
|
||||||
|
- `nsigner list` extended to enumerate active TCP listeners (from internal registry; not from `/proc/net/tcp`).
|
||||||
|
- Same framing as AF_UNIX path; no protocol changes.
|
||||||
|
- Tests: integration coverage that spawns a child signer with `--listen tcp:127.0.0.1:0` (and one IPv6 case), captures the bound port, runs the same NIP-04/NIP-44/sign_event matrix as AF_UNIX.
|
||||||
|
- Docs: extend [`documents/CLIENT_IMPLEMENTATION.md`](../documents/CLIENT_IMPLEMENTATION.md) section 2 with `tcp:` discovery rules and section 3 confirming framing parity.
|
||||||
|
|
||||||
|
Implementation checklist (Tier-1 delivery):
|
||||||
|
|
||||||
|
- [x] Parse `--listen tcp:HOST:PORT` in [`src/main.c`](../src/main.c).
|
||||||
|
- [x] Parse and validate literal IPv4/IPv6 listen targets in [`src/server.c`](../src/server.c).
|
||||||
|
- [x] Bind/listen non-blocking TCP sockets and run server loop without TUI dependence.
|
||||||
|
- [x] Keep existing framed JSON-RPC protocol unchanged via shared [`src/transport_frame.c`](../src/transport_frame.c).
|
||||||
|
- [ ] Add integration test coverage for `tcp:127.0.0.1:PORT` request flow.
|
||||||
|
|
||||||
|
### 7.3 Phase T3 — TCP remote with TLS + caller-pubkey auth
|
||||||
|
|
||||||
|
Only after T2 is solid.
|
||||||
|
|
||||||
|
- New CLI: `nsigner --listen tcp:0.0.0.0:PORT --allow-remote --tls-cert <pem> --tls-key <pem>`.
|
||||||
|
- Mandatory: TLS for any non-loopback bind. Refuse to start otherwise.
|
||||||
|
- Caller authentication: client must sign a per-connection challenge with its declared npub (Schnorr/secp256k1) before any signer verb is dispatched. Identity becomes `tcp_remote { addr, authenticated_pubkey }`.
|
||||||
|
- Failure modes: `transport_tls_required`, `caller_auth_failed`, `caller_auth_timeout` — all surfaced with new error names in dispatcher and documented in [`CLIENT_IMPLEMENTATION.md`](../CLIENT_IMPLEMENTATION.md).
|
||||||
|
- Approval prompt now displays `caller=npub:abcd…wxyz` instead of `uid:1000`.
|
||||||
|
- Tests: integration test that exercises happy path, wrong-pubkey, replayed-challenge, expired-challenge.
|
||||||
|
- Docs: dedicated "Remote TCP deployment" section in `README.md` with strong "do not expose to the public internet without firewalling" warning.
|
||||||
|
|
||||||
|
### 7.4 Phase T4 — FIPS substrate integration
|
||||||
|
|
||||||
|
FIPS is a *substrate* for an existing TCP listener, not a new transport in nsigner code.
|
||||||
|
|
||||||
|
- Deployment topology: nsigner binds a chosen TCP endpoint inside the FIPS network namespace (or on a host where `fips0` is up); peers reach it via `fd00::/8` IPv6 derived from the signer's npub.
|
||||||
|
- Optional `caller_kind=fips` enrichment: a small sidecar query (`fipsctl show sessions` style) maps the connecting IPv6 address to a peer npub and feeds it into `caller_identity_t.fips { peer_npub }`. If unavailable, fall back to `tcp_remote` identity.
|
||||||
|
- nsigner does not embed FIPS, does not depend on libfips, and does not require Rust.
|
||||||
|
- New optional flag: `--peer-id-source fips:/var/run/fips/fips.sock` (path/method TBD per FIPS API).
|
||||||
|
- Tests: a Docker-compose fixture borrowed from `resources/fips/testing/` that boots two FIPS nodes, runs nsigner on one, runs a Python client (per snippet in [`documents/CLIENT_IMPLEMENTATION.md`](../documents/CLIENT_IMPLEMENTATION.md)) on the other, and exercises the same verb matrix.
|
||||||
|
- Docs: new [`documents/FIPS_DEPLOYMENT.md`](../documents/FIPS_DEPLOYMENT.md) deep-dive describing identity mapping, npub-as-caller, and operator setup. Cross-link from [`README.md`](../README.md) section 7 (Transport).
|
||||||
|
|
||||||
|
Execution tasks for initial FIPS trial:
|
||||||
|
|
||||||
|
- [x] Deliver T2 TCP listener as FIPS substrate prerequisite.
|
||||||
|
- [x] Document signer/caller qube deployment flow in [`documents/FIPS_DEPLOYMENT.md`](../documents/FIPS_DEPLOYMENT.md).
|
||||||
|
- [ ] Add two-node operator validation script (manual) using `fipsctl` + framed JSON-RPC client.
|
||||||
|
- [ ] Evaluate optional caller identity enrichment from FIPS session metadata.
|
||||||
|
|
||||||
|
### 7.5 Phase T5 — USB / serial transport
|
||||||
|
|
||||||
|
Two distinct sub-tracks; do not conflate.
|
||||||
|
|
||||||
|
- T5a (firmware-side, MCU): ESP32/USB-CDC. Already in the [`firmware/`](../firmware/) track. Same dispatcher; transport adapter is UART read/write loop. `caller_identity_t.kind=usb_serial` with `asserted_caller` because the host claims the identity.
|
||||||
|
- T5b (host-side optional): `nsigner --listen serial:/dev/ttyACM0,baud=115200`. Useful for desktop signer reachable by a USB-tethered client. Same frame protocol over the serial line. Marks identity as asserted (low trust) and forces approval prompt.
|
||||||
|
- USB-as-Ethernet (gadget mode, RNDIS/ECM) is **not** a separate transport — it reduces to T2/T3.
|
||||||
|
- Tests: loopback pty pair (`openpty`) for T5b unit/integration coverage; firmware-side covered in firmware track.
|
||||||
|
|
||||||
|
### 7.6 Cross-cutting concerns
|
||||||
|
|
||||||
|
Apply once per phase as needed:
|
||||||
|
|
||||||
|
- Transport-aware approval prompt: clear visual indication of transport kind and identity (uid vs qube vs npub vs serial-asserted). No silent identity-source confusion.
|
||||||
|
- Per-transport policy gates: deny-by-default for new identity kinds until operator explicitly enables them in policy.
|
||||||
|
- Discovery (`nsigner list`) becomes per-transport pluggable (proc/net/unix today, internal registry for tcp, qrexec service announce for qubes, fips peer table for fips).
|
||||||
|
- Audit logging: include transport kind and identity descriptor in every approval/decision record.
|
||||||
|
- Error name parity: every new transport introduces only well-named errors (extend the table in [`CLIENT_IMPLEMENTATION.md`](../CLIENT_IMPLEMENTATION.md) section 5).
|
||||||
|
|
||||||
|
### 7.7 Decision points (open)
|
||||||
|
|
||||||
|
- D1: Land T0 (refactor) before any transport, or in parallel with T1?
|
||||||
|
- D2: Bundle T2 and T3 as one phase, or hard split (loopback-only first, then remote-with-TLS later)?
|
||||||
|
- D3: T4 FIPS — embed an explicit `caller_kind=fips` path in nsigner now, or treat FIPS as plain TCP and revisit identity enrichment after a working deployment?
|
||||||
|
- D4: T5b host-side serial — in scope for desktop nsigner, or strictly firmware track?
|
||||||
|
- D5: Qubes packaging — ship `packaging/qubes/` artifacts in this repo, or document only and let operators wire it up?
|
||||||
|
|||||||
101
plans/tui_continuous_integration.md
Normal file
101
plans/tui_continuous_integration.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# tui_continuous integration plan for n_signer
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Adopt the `tui_continuous` style from `/home/user/lt/aesthetics` with minimal-risk restructuring:
|
||||||
|
|
||||||
|
- keep caller-owned poll loop and line-input model
|
||||||
|
- avoid ncurses/event-loop inversion
|
||||||
|
- preserve non-interactive behavior and test compatibility
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### 1) Vendor and wire library
|
||||||
|
|
||||||
|
- Vendor `tui_continuous.c/.h` into this repository under a stable path (planned: `resources/tui_continuous/`).
|
||||||
|
- Add include path and source wiring to:
|
||||||
|
- `Makefile` dev build
|
||||||
|
- targets that link `src/main.c` (notably integration path)
|
||||||
|
- static build flow (`build_static.sh`, Alpine musl path)
|
||||||
|
|
||||||
|
### 2) Main-screen TUI adapter in `src/main.c`
|
||||||
|
|
||||||
|
- Introduce a small adapter layer that owns:
|
||||||
|
- persistent `TuiFrame` (`app_name=nsigner`, `app_version=NSIGNER_VERSION`, breadcrumb)
|
||||||
|
- hotkey menu definitions matching existing behavior (`q/x`, `l`, `r`, `A`)
|
||||||
|
- status text composition (`locked/unlocked`, words, socket, derived, auto-approve)
|
||||||
|
|
||||||
|
### 3) Replace legacy `render_status()` with `tui_continuous`
|
||||||
|
|
||||||
|
- Use `tui_render_screen()` for top framing/menu/status strip.
|
||||||
|
- Render roles with `tui_render_table()` using columns:
|
||||||
|
- role name
|
||||||
|
- purpose
|
||||||
|
- curve
|
||||||
|
- selector
|
||||||
|
- Render activity log (latest first) via `tui_print()`.
|
||||||
|
- Keep prompt anchoring stable with `tui_anchor_prompt()`.
|
||||||
|
|
||||||
|
### 4) Resize behavior
|
||||||
|
|
||||||
|
- Install SIGWINCH handler with `tui_install_resize_handler()` before unix interactive loop.
|
||||||
|
- On each poll tick, if `tui_resize_pending()` is set, repaint current view.
|
||||||
|
|
||||||
|
### 5) Approval flow refactor (`src/server.c` + `src/main.c`)
|
||||||
|
|
||||||
|
- Add server approval callback API in `server.c`:
|
||||||
|
- callback typedef
|
||||||
|
- registration function (`server_set_approval_cb(...)`)
|
||||||
|
- compact request payload struct (caller/method/role/purpose/derivation + optional peer display)
|
||||||
|
- Keep default callback behavior equivalent to existing stdio prompt output for non-TUI and tests.
|
||||||
|
- In `main.c` unix interactive mode, register a TUI-aware callback that:
|
||||||
|
- paints an approval content screen
|
||||||
|
- shows `[y]/[n]/[e]/[a]` options
|
||||||
|
- reads line input (fgets-compatible behavior)
|
||||||
|
- returns policy decision enums
|
||||||
|
- repaints main status screen after decision
|
||||||
|
|
||||||
|
### 6) Mnemonic prompt styling
|
||||||
|
|
||||||
|
- Keep `fgets` input path intact for paste reliability.
|
||||||
|
- Re-style startup and lock/reunlock prompts using `tui_render_content_screen()` + `tui_print()`.
|
||||||
|
|
||||||
|
### 7) Output safety boundaries
|
||||||
|
|
||||||
|
- Keep machine-readable output untouched (client JSON-RPC responses remain raw).
|
||||||
|
- Restrict style updates for non-interactive surfaces to human-facing usage/banner text only.
|
||||||
|
|
||||||
|
### 8) Validation
|
||||||
|
|
||||||
|
- Run/build validation for:
|
||||||
|
- normal dev build
|
||||||
|
- static build flow
|
||||||
|
- existing prompt-related test paths (`NSIGNER_TEST_FORCE_PROMPT`, `NSIGNER_TEST_NONINTERACTIVE_PROMPT`)
|
||||||
|
- Manual visual checks:
|
||||||
|
- narrow/normal/wide terminal widths
|
||||||
|
- live resize behavior
|
||||||
|
- approval prompt arriving during idle and proper redraw afterwards
|
||||||
|
|
||||||
|
### 9) Documentation updates
|
||||||
|
|
||||||
|
- Update `README.md` and security docs where relevant to mention:
|
||||||
|
- adopted TUI style source (`aesthetics/TUI.md`)
|
||||||
|
- vendored `tui_continuous` component/version
|
||||||
|
|
||||||
|
## Execution order
|
||||||
|
|
||||||
|
1. Vendor library and wire build
|
||||||
|
2. Implement main screen adapter + rendering
|
||||||
|
3. Add resize repaint
|
||||||
|
4. Refactor approval callback plumbing
|
||||||
|
5. Add TUI approval view in main
|
||||||
|
6. Re-style mnemonic prompts
|
||||||
|
7. Validate tests/builds + manual UX checks
|
||||||
|
8. Update docs
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No ncurses integration
|
||||||
|
- No event loop ownership changes
|
||||||
|
- No changes to transport semantics (`unix`, `stdio`, `qrexec`, `tcp`)
|
||||||
|
- No machine-readable output format changes
|
||||||
33
run.sh
Executable file
33
run.sh
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
BUILD_SCRIPT="$SCRIPT_DIR/build_static.sh"
|
||||||
|
BUILD_DIR="$SCRIPT_DIR/build"
|
||||||
|
|
||||||
|
HOST_UNAME="$(uname -m)"
|
||||||
|
case "$HOST_UNAME" in
|
||||||
|
x86_64)
|
||||||
|
OUTPUT_NAME="nsigner_static_x86_64"
|
||||||
|
;;
|
||||||
|
aarch64|arm64)
|
||||||
|
OUTPUT_NAME="nsigner_static_arm64"
|
||||||
|
;;
|
||||||
|
armv7l|armv7)
|
||||||
|
OUTPUT_NAME="nsigner_static_armv7"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "ERROR: Unsupported host architecture '$HOST_UNAME'"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
"$BUILD_SCRIPT"
|
||||||
|
|
||||||
|
BINARY_PATH="$BUILD_DIR/$OUTPUT_NAME"
|
||||||
|
if [[ ! -x "$BINARY_PATH" ]]; then
|
||||||
|
echo "ERROR: Compiled binary not found or not executable: $BINARY_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$BINARY_PATH" "$@"
|
||||||
409
src/auth_envelope.c
Normal file
409
src/auth_envelope.c
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
#define _GNU_SOURCE
|
||||||
|
|
||||||
|
#include "auth_envelope.h"
|
||||||
|
|
||||||
|
#include <ctype.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
|
#include <cJSON.h>
|
||||||
|
#include <nostr_core/nip001.h>
|
||||||
|
#include <nostr_core/utils.h>
|
||||||
|
|
||||||
|
static int set_error(int *out_code,
|
||||||
|
const char **out_message,
|
||||||
|
int code,
|
||||||
|
const char *message) {
|
||||||
|
if (out_code != NULL) {
|
||||||
|
*out_code = code;
|
||||||
|
}
|
||||||
|
if (out_message != NULL) {
|
||||||
|
*out_message = message;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void auth_nonce_cache_init(auth_nonce_cache_t *cache) {
|
||||||
|
if (cache == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
memset(cache, 0, sizeof(*cache));
|
||||||
|
}
|
||||||
|
|
||||||
|
static int auth_nonce_cache_contains(const auth_nonce_cache_t *cache, const uint8_t id[32]) {
|
||||||
|
int i;
|
||||||
|
|
||||||
|
if (cache == NULL || id == NULL) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i = 0; i < cache->count; ++i) {
|
||||||
|
if (memcmp(cache->ids[i], id, 32) == 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void auth_nonce_cache_insert(auth_nonce_cache_t *cache, const uint8_t id[32]) {
|
||||||
|
if (cache == NULL || id == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cache->count < AUTH_NONCE_CACHE_SIZE) {
|
||||||
|
memcpy(cache->ids[cache->count], id, 32);
|
||||||
|
cache->count++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
memcpy(cache->ids[cache->next], id, 32);
|
||||||
|
cache->next = (cache->next + 1) % AUTH_NONCE_CACHE_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int json_item_to_compact_string(const cJSON *item, char *out, size_t out_sz) {
|
||||||
|
char *printed;
|
||||||
|
|
||||||
|
if (out == NULL || out_sz == 0 || item == NULL) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cJSON_IsString(item) && item->valuestring != NULL) {
|
||||||
|
strncpy(out, item->valuestring, out_sz - 1);
|
||||||
|
out[out_sz - 1] = '\0';
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
printed = cJSON_PrintUnformatted((cJSON *)item);
|
||||||
|
if (printed == NULL) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
strncpy(out, printed, out_sz - 1);
|
||||||
|
out[out_sz - 1] = '\0';
|
||||||
|
free(printed);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int compute_hash_hex_for_json_item(const cJSON *item, char *out_hex, size_t out_hex_sz) {
|
||||||
|
unsigned char hash[32];
|
||||||
|
char *compact = NULL;
|
||||||
|
|
||||||
|
if (out_hex == NULL || out_hex_sz < 65) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item == NULL) {
|
||||||
|
compact = strdup("null");
|
||||||
|
} else {
|
||||||
|
compact = cJSON_PrintUnformatted((cJSON *)item);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compact == NULL) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nostr_sha256((const unsigned char *)compact, strlen(compact), hash) != 0) {
|
||||||
|
free(compact);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
nostr_bytes_to_hex(hash, sizeof(hash), out_hex);
|
||||||
|
out_hex[64] = '\0';
|
||||||
|
free(compact);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int compute_params_hash_hex(cJSON *root, char *out_hex, size_t out_hex_sz) {
|
||||||
|
cJSON *params = cJSON_GetObjectItemCaseSensitive(root, "params");
|
||||||
|
return compute_hash_hex_for_json_item(params, out_hex, out_hex_sz);
|
||||||
|
}
|
||||||
|
|
||||||
|
static cJSON *find_tag_value(cJSON *tags, const char *name) {
|
||||||
|
int i;
|
||||||
|
|
||||||
|
if (!cJSON_IsArray(tags) || name == NULL) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i = 0; i < cJSON_GetArraySize(tags); ++i) {
|
||||||
|
cJSON *tag = cJSON_GetArrayItem(tags, i);
|
||||||
|
cJSON *tag_name;
|
||||||
|
cJSON *tag_value;
|
||||||
|
|
||||||
|
if (!cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
tag_name = cJSON_GetArrayItem(tag, 0);
|
||||||
|
tag_value = cJSON_GetArrayItem(tag, 1);
|
||||||
|
if (!cJSON_IsString(tag_name) || tag_name->valuestring == NULL) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (strcmp(tag_name->valuestring, name) != 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tag_value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
static cJSON *build_tag_pair(const char *name, const char *value) {
|
||||||
|
cJSON *pair;
|
||||||
|
|
||||||
|
if (name == NULL || value == NULL) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
pair = cJSON_CreateArray();
|
||||||
|
if (pair == NULL) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
cJSON_AddItemToArray(pair, cJSON_CreateString(name));
|
||||||
|
cJSON_AddItemToArray(pair, cJSON_CreateString(value));
|
||||||
|
return pair;
|
||||||
|
}
|
||||||
|
|
||||||
|
int auth_envelope_build_for_request(const char *request_id,
|
||||||
|
const char *method,
|
||||||
|
const cJSON *params,
|
||||||
|
const unsigned char privkey[32],
|
||||||
|
const char *label,
|
||||||
|
time_t created_at,
|
||||||
|
cJSON **out_auth) {
|
||||||
|
cJSON *tags = NULL;
|
||||||
|
cJSON *auth = NULL;
|
||||||
|
char body_hash_hex[65];
|
||||||
|
|
||||||
|
if (out_auth == NULL || request_id == NULL || method == NULL ||
|
||||||
|
privkey == NULL || request_id[0] == '\0' || method[0] == '\0') {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
*out_auth = NULL;
|
||||||
|
|
||||||
|
if (compute_hash_hex_for_json_item(params, body_hash_hex, sizeof(body_hash_hex)) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
tags = cJSON_CreateArray();
|
||||||
|
if (tags == NULL) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
cJSON_AddItemToArray(tags, build_tag_pair("nsigner_rpc", request_id));
|
||||||
|
cJSON_AddItemToArray(tags, build_tag_pair("nsigner_method", method));
|
||||||
|
cJSON_AddItemToArray(tags, build_tag_pair("nsigner_body_hash", body_hash_hex));
|
||||||
|
|
||||||
|
if (created_at <= 0) {
|
||||||
|
created_at = time(NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
auth = nostr_create_and_sign_event(AUTH_EVENT_KIND,
|
||||||
|
(label != NULL) ? label : "",
|
||||||
|
tags,
|
||||||
|
privkey,
|
||||||
|
created_at);
|
||||||
|
|
||||||
|
if (auth == NULL) {
|
||||||
|
cJSON_Delete(tags);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
*out_auth = auth;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int auth_envelope_verify_request(const char *request_json,
|
||||||
|
auth_nonce_cache_t *cache,
|
||||||
|
int skew_seconds,
|
||||||
|
char *out_pubkey_hex,
|
||||||
|
size_t out_pubkey_hex_sz,
|
||||||
|
char *out_label,
|
||||||
|
size_t out_label_sz,
|
||||||
|
int *out_error_code,
|
||||||
|
const char **out_error_message) {
|
||||||
|
cJSON *root = NULL;
|
||||||
|
cJSON *auth = NULL;
|
||||||
|
cJSON *method_item;
|
||||||
|
cJSON *id_item;
|
||||||
|
cJSON *kind_item;
|
||||||
|
cJSON *created_item;
|
||||||
|
cJSON *pubkey_item;
|
||||||
|
cJSON *content_item;
|
||||||
|
cJSON *id_hex_item;
|
||||||
|
cJSON *tags_item;
|
||||||
|
cJSON *tag_rpc;
|
||||||
|
cJSON *tag_method;
|
||||||
|
cJSON *tag_body_hash;
|
||||||
|
char request_id[128];
|
||||||
|
char body_hash_hex[65];
|
||||||
|
uint8_t nonce_bytes[32];
|
||||||
|
time_t now;
|
||||||
|
long long created;
|
||||||
|
|
||||||
|
if (out_error_code != NULL) {
|
||||||
|
*out_error_code = 0;
|
||||||
|
}
|
||||||
|
if (out_error_message != NULL) {
|
||||||
|
*out_error_message = NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (out_pubkey_hex == NULL || out_pubkey_hex_sz < 65 ||
|
||||||
|
out_label == NULL || out_label_sz == 0 ||
|
||||||
|
request_json == NULL || cache == NULL) {
|
||||||
|
return set_error(out_error_code, out_error_message,
|
||||||
|
AUTH_ERR_ENVELOPE_MALFORMED,
|
||||||
|
"auth_envelope_malformed");
|
||||||
|
}
|
||||||
|
|
||||||
|
out_pubkey_hex[0] = '\0';
|
||||||
|
out_label[0] = '\0';
|
||||||
|
|
||||||
|
root = cJSON_Parse(request_json);
|
||||||
|
if (root == NULL) {
|
||||||
|
return set_error(out_error_code, out_error_message,
|
||||||
|
AUTH_ERR_ENVELOPE_MALFORMED,
|
||||||
|
"auth_envelope_malformed");
|
||||||
|
}
|
||||||
|
|
||||||
|
method_item = cJSON_GetObjectItemCaseSensitive(root, "method");
|
||||||
|
id_item = cJSON_GetObjectItemCaseSensitive(root, "id");
|
||||||
|
if (!cJSON_IsString(method_item) || method_item->valuestring == NULL ||
|
||||||
|
json_item_to_compact_string(id_item, request_id, sizeof(request_id)) != 0) {
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return set_error(out_error_code, out_error_message,
|
||||||
|
AUTH_ERR_ENVELOPE_MALFORMED,
|
||||||
|
"auth_envelope_malformed");
|
||||||
|
}
|
||||||
|
|
||||||
|
auth = cJSON_GetObjectItemCaseSensitive(root, "auth");
|
||||||
|
if (!cJSON_IsObject(auth)) {
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return set_error(out_error_code, out_error_message,
|
||||||
|
AUTH_ERR_ENVELOPE_REQUIRED,
|
||||||
|
"auth_envelope_required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nostr_validate_event_structure(auth) != 0) {
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return set_error(out_error_code, out_error_message,
|
||||||
|
AUTH_ERR_ENVELOPE_MALFORMED,
|
||||||
|
"auth_envelope_malformed");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nostr_verify_event_signature(auth) != 0) {
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return set_error(out_error_code, out_error_message,
|
||||||
|
AUTH_ERR_SIGNATURE_INVALID,
|
||||||
|
"auth_signature_invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
kind_item = cJSON_GetObjectItemCaseSensitive(auth, "kind");
|
||||||
|
if (!cJSON_IsNumber(kind_item) || kind_item->valueint != AUTH_EVENT_KIND) {
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return set_error(out_error_code, out_error_message,
|
||||||
|
AUTH_ERR_KIND_INVALID,
|
||||||
|
"auth_kind_invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
tags_item = cJSON_GetObjectItemCaseSensitive(auth, "tags");
|
||||||
|
tag_rpc = find_tag_value(tags_item, "nsigner_rpc");
|
||||||
|
tag_method = find_tag_value(tags_item, "nsigner_method");
|
||||||
|
tag_body_hash = find_tag_value(tags_item, "nsigner_body_hash");
|
||||||
|
if (!cJSON_IsString(tag_rpc) || tag_rpc->valuestring == NULL ||
|
||||||
|
!cJSON_IsString(tag_method) || tag_method->valuestring == NULL ||
|
||||||
|
!cJSON_IsString(tag_body_hash) || tag_body_hash->valuestring == NULL) {
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return set_error(out_error_code, out_error_message,
|
||||||
|
AUTH_ERR_ENVELOPE_MALFORMED,
|
||||||
|
"auth_envelope_malformed");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcmp(tag_rpc->valuestring, request_id) != 0 ||
|
||||||
|
strcmp(tag_method->valuestring, method_item->valuestring) != 0) {
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return set_error(out_error_code, out_error_message,
|
||||||
|
AUTH_ERR_ENVELOPE_MISMATCH,
|
||||||
|
"auth_envelope_mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compute_params_hash_hex(root, body_hash_hex, sizeof(body_hash_hex)) != 0 ||
|
||||||
|
strcasecmp(tag_body_hash->valuestring, body_hash_hex) != 0) {
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return set_error(out_error_code, out_error_message,
|
||||||
|
AUTH_ERR_BODY_MISMATCH,
|
||||||
|
"auth_body_mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
created_item = cJSON_GetObjectItemCaseSensitive(auth, "created_at");
|
||||||
|
if (!cJSON_IsNumber(created_item)) {
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return set_error(out_error_code, out_error_message,
|
||||||
|
AUTH_ERR_ENVELOPE_MALFORMED,
|
||||||
|
"auth_envelope_malformed");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skew_seconds <= 0) {
|
||||||
|
skew_seconds = AUTH_DEFAULT_SKEW_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
now = time(NULL);
|
||||||
|
created = (long long)created_item->valuedouble;
|
||||||
|
if (llabs((long long)now - created) > (long long)skew_seconds) {
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return set_error(out_error_code, out_error_message,
|
||||||
|
AUTH_ERR_ENVELOPE_STALE,
|
||||||
|
"auth_envelope_stale");
|
||||||
|
}
|
||||||
|
|
||||||
|
id_hex_item = cJSON_GetObjectItemCaseSensitive(auth, "id");
|
||||||
|
if (!cJSON_IsString(id_hex_item) || id_hex_item->valuestring == NULL ||
|
||||||
|
strlen(id_hex_item->valuestring) != 64 ||
|
||||||
|
nostr_hex_to_bytes(id_hex_item->valuestring, nonce_bytes, sizeof(nonce_bytes)) != 0) {
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return set_error(out_error_code, out_error_message,
|
||||||
|
AUTH_ERR_ENVELOPE_MALFORMED,
|
||||||
|
"auth_envelope_malformed");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth_nonce_cache_contains(cache, nonce_bytes)) {
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return set_error(out_error_code, out_error_message,
|
||||||
|
AUTH_ERR_REPLAY_DETECTED,
|
||||||
|
"auth_replay_detected");
|
||||||
|
}
|
||||||
|
auth_nonce_cache_insert(cache, nonce_bytes);
|
||||||
|
|
||||||
|
pubkey_item = cJSON_GetObjectItemCaseSensitive(auth, "pubkey");
|
||||||
|
if (!cJSON_IsString(pubkey_item) || pubkey_item->valuestring == NULL ||
|
||||||
|
strlen(pubkey_item->valuestring) != 64) {
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return set_error(out_error_code, out_error_message,
|
||||||
|
AUTH_ERR_ENVELOPE_MALFORMED,
|
||||||
|
"auth_envelope_malformed");
|
||||||
|
}
|
||||||
|
|
||||||
|
strncpy(out_pubkey_hex, pubkey_item->valuestring, out_pubkey_hex_sz - 1);
|
||||||
|
out_pubkey_hex[out_pubkey_hex_sz - 1] = '\0';
|
||||||
|
|
||||||
|
content_item = cJSON_GetObjectItemCaseSensitive(auth, "content");
|
||||||
|
if (cJSON_IsString(content_item) && content_item->valuestring != NULL) {
|
||||||
|
size_t i;
|
||||||
|
for (i = 0; content_item->valuestring[i] != '\0' && i + 1 < out_label_sz; ++i) {
|
||||||
|
unsigned char ch = (unsigned char)content_item->valuestring[i];
|
||||||
|
out_label[i] = (char)((isprint(ch) && ch != '\n' && ch != '\r' && ch != '\t') ? ch : ' ');
|
||||||
|
}
|
||||||
|
out_label[i] = '\0';
|
||||||
|
} else {
|
||||||
|
out_label[0] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
49
src/auth_envelope.h
Normal file
49
src/auth_envelope.h
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
#ifndef NSIGNER_AUTH_ENVELOPE_H
|
||||||
|
#define NSIGNER_AUTH_ENVELOPE_H
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
|
#include <cJSON.h>
|
||||||
|
|
||||||
|
#define AUTH_NONCE_CACHE_SIZE 1024
|
||||||
|
#define AUTH_DEFAULT_SKEW_SECONDS 30
|
||||||
|
#define AUTH_EVENT_KIND 27235
|
||||||
|
|
||||||
|
#define AUTH_ERR_ENVELOPE_MALFORMED 2010
|
||||||
|
#define AUTH_ERR_BODY_MISMATCH 2011
|
||||||
|
#define AUTH_ERR_SIGNATURE_INVALID 2012
|
||||||
|
#define AUTH_ERR_KIND_INVALID 2013
|
||||||
|
#define AUTH_ERR_ENVELOPE_REQUIRED 2014
|
||||||
|
#define AUTH_ERR_ENVELOPE_MISMATCH 2015
|
||||||
|
#define AUTH_ERR_ENVELOPE_STALE 2016
|
||||||
|
#define AUTH_ERR_REPLAY_DETECTED 2017
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint8_t ids[AUTH_NONCE_CACHE_SIZE][32];
|
||||||
|
int count;
|
||||||
|
int next;
|
||||||
|
} auth_nonce_cache_t;
|
||||||
|
|
||||||
|
void auth_nonce_cache_init(auth_nonce_cache_t *cache);
|
||||||
|
|
||||||
|
int auth_envelope_verify_request(const char *request_json,
|
||||||
|
auth_nonce_cache_t *cache,
|
||||||
|
int skew_seconds,
|
||||||
|
char *out_pubkey_hex,
|
||||||
|
size_t out_pubkey_hex_sz,
|
||||||
|
char *out_label,
|
||||||
|
size_t out_label_sz,
|
||||||
|
int *out_error_code,
|
||||||
|
const char **out_error_message);
|
||||||
|
|
||||||
|
int auth_envelope_build_for_request(const char *request_id,
|
||||||
|
const char *method,
|
||||||
|
const cJSON *params,
|
||||||
|
const unsigned char privkey[32],
|
||||||
|
const char *label,
|
||||||
|
time_t created_at,
|
||||||
|
cJSON **out_auth);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -73,7 +73,7 @@ int mnemonic_generate(int word_count, char *out, size_t out_len);
|
|||||||
#define ROLE_PURPOSE_MAX 32
|
#define ROLE_PURPOSE_MAX 32
|
||||||
#define ROLE_CURVE_MAX 16
|
#define ROLE_CURVE_MAX 16
|
||||||
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||||
#define ROLE_TABLE_MAX_ENTRIES 64
|
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||||
|
|
||||||
/* Purpose enum for fast comparison (string form kept for config/display) */
|
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -229,7 +229,7 @@ const char *enforce_strerror(int err);
|
|||||||
#define POLICY_MAX_ROLES 16
|
#define POLICY_MAX_ROLES 16
|
||||||
#define POLICY_MAX_PURPOSES 8
|
#define POLICY_MAX_PURPOSES 8
|
||||||
#define POLICY_VERB_MAX_LEN 32
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
#define POLICY_CALLER_MAX_LEN 64
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
/* Prompt behavior */
|
/* Prompt behavior */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -239,6 +239,12 @@ typedef enum {
|
|||||||
PROMPT_DENY
|
PROMPT_DENY
|
||||||
} prompt_mode_t;
|
} prompt_mode_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||||
|
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||||
|
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||||
|
} policy_source_t;
|
||||||
|
|
||||||
/* A single policy entry */
|
/* A single policy entry */
|
||||||
typedef struct {
|
typedef struct {
|
||||||
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||||
@@ -249,6 +255,7 @@ typedef struct {
|
|||||||
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||||
int purpose_count;
|
int purpose_count;
|
||||||
prompt_mode_t prompt;
|
prompt_mode_t prompt;
|
||||||
|
policy_source_t source;
|
||||||
} policy_entry_t;
|
} policy_entry_t;
|
||||||
|
|
||||||
/* Policy table */
|
/* Policy table */
|
||||||
@@ -277,7 +284,8 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
|||||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||||
*/
|
*/
|
||||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||||
const char *verb, const char *role_name, const char *purpose);
|
const char *verb, const char *role_name, const char *purpose,
|
||||||
|
policy_source_t *out_source);
|
||||||
|
|
||||||
/* Parse prompt mode from string */
|
/* Parse prompt mode from string */
|
||||||
prompt_mode_t prompt_mode_from_str(const char *s);
|
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||||
@@ -321,6 +329,18 @@ const char *crypto_get_pubkey_hex(const key_store_t *store, int role_index);
|
|||||||
* Caller must free() the returned string. */
|
* Caller must free() the returned string. */
|
||||||
char *crypto_sign_event(const key_store_t *store, int role_index, const char *event_json);
|
char *crypto_sign_event(const key_store_t *store, int role_index, const char *event_json);
|
||||||
|
|
||||||
|
/* NIP-44 encrypt/decrypt */
|
||||||
|
char *crypto_nip44_encrypt(const key_store_t *store, int role_index,
|
||||||
|
const char *recipient_pubkey_hex, const char *plaintext);
|
||||||
|
char *crypto_nip44_decrypt(const key_store_t *store, int role_index,
|
||||||
|
const char *sender_pubkey_hex, const char *ciphertext);
|
||||||
|
|
||||||
|
/* NIP-04 encrypt/decrypt */
|
||||||
|
char *crypto_nip04_encrypt(const key_store_t *store, int role_index,
|
||||||
|
const char *recipient_pubkey_hex, const char *plaintext);
|
||||||
|
char *crypto_nip04_decrypt(const key_store_t *store, int role_index,
|
||||||
|
const char *sender_pubkey_hex, const char *ciphertext);
|
||||||
|
|
||||||
/* Zeroize all derived keys in the store. */
|
/* Zeroize all derived keys in the store. */
|
||||||
void crypto_wipe(key_store_t *store);
|
void crypto_wipe(key_store_t *store);
|
||||||
|
|
||||||
@@ -356,7 +376,7 @@ void dispatcher_init(dispatcher_ctx_t *ctx, role_table_t *table, mnemonic_state_
|
|||||||
* -32601 Method not found (unknown verb after enforcement)
|
* -32601 Method not found (unknown verb after enforcement)
|
||||||
* -32602 Invalid params
|
* -32602 Invalid params
|
||||||
* 1001 ambiguous_role_selector
|
* 1001 ambiguous_role_selector
|
||||||
* 1002 role_not_found
|
* 1002 unknown_role
|
||||||
* 1003 no_default_role
|
* 1003 no_default_role
|
||||||
* 1004 purpose_mismatch
|
* 1004 purpose_mismatch
|
||||||
* 1005 curve_mismatch
|
* 1005 curve_mismatch
|
||||||
@@ -376,7 +396,7 @@ typedef struct {
|
|||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
pid_t pid;
|
pid_t pid;
|
||||||
char caller_id[64]; /* "uid:<n>" */
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
} caller_identity_t;
|
} caller_identity_t;
|
||||||
|
|
||||||
/* Server context */
|
/* Server context */
|
||||||
@@ -572,7 +592,10 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
|
|||||||
|
|
||||||
selector_request_init(&selector_req);
|
selector_request_init(&selector_req);
|
||||||
|
|
||||||
options_item = cJSON_GetArrayItem(params_item, 1);
|
{
|
||||||
|
int params_count = cJSON_GetArraySize(params_item);
|
||||||
|
options_item = (params_count > 0) ? cJSON_GetArrayItem(params_item, params_count - 1) : NULL;
|
||||||
|
}
|
||||||
if (cJSON_IsObject(options_item)) {
|
if (cJSON_IsObject(options_item)) {
|
||||||
tmp = cJSON_GetObjectItemCaseSensitive(options_item, "role");
|
tmp = cJSON_GetObjectItemCaseSensitive(options_item, "role");
|
||||||
if (tmp != NULL) {
|
if (tmp != NULL) {
|
||||||
@@ -614,7 +637,7 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
|
|||||||
return make_error_response(id_str, 1001, "ambiguous_role_selector");
|
return make_error_response(id_str, 1001, "ambiguous_role_selector");
|
||||||
}
|
}
|
||||||
if (rc == SELECTOR_ERR_NOT_FOUND) {
|
if (rc == SELECTOR_ERR_NOT_FOUND) {
|
||||||
return make_error_response(id_str, 1002, "role_not_found");
|
return make_error_response(id_str, 1002, "unknown_role");
|
||||||
}
|
}
|
||||||
if (rc == SELECTOR_ERR_NO_DEFAULT) {
|
if (rc == SELECTOR_ERR_NO_DEFAULT) {
|
||||||
return make_error_response(id_str, 1003, "no_default_role");
|
return make_error_response(id_str, 1003, "no_default_role");
|
||||||
@@ -665,13 +688,61 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
|
|||||||
}
|
}
|
||||||
result = owned_result;
|
result = owned_result;
|
||||||
} else if (strcmp(method, VERB_NIP44_ENCRYPT) == 0) {
|
} else if (strcmp(method, VERB_NIP44_ENCRYPT) == 0) {
|
||||||
result = "stub:nip44_encrypt_ok";
|
cJSON *peer_item = cJSON_GetArrayItem(params_item, 0);
|
||||||
|
cJSON *msg_item = cJSON_GetArrayItem(params_item, 1);
|
||||||
|
if (!cJSON_IsString(peer_item) || peer_item->valuestring == NULL ||
|
||||||
|
!cJSON_IsString(msg_item) || msg_item->valuestring == NULL) {
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return make_error_response(id_str, -32602, "invalid_params");
|
||||||
|
}
|
||||||
|
owned_result = crypto_nip44_encrypt(ctx->key_store, role_index, peer_item->valuestring, msg_item->valuestring);
|
||||||
|
if (owned_result == NULL) {
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return make_error_response(id_str, -32602, "invalid_params");
|
||||||
|
}
|
||||||
|
result = owned_result;
|
||||||
} else if (strcmp(method, VERB_NIP44_DECRYPT) == 0) {
|
} else if (strcmp(method, VERB_NIP44_DECRYPT) == 0) {
|
||||||
result = "stub:nip44_decrypt_ok";
|
cJSON *peer_item = cJSON_GetArrayItem(params_item, 0);
|
||||||
|
cJSON *msg_item = cJSON_GetArrayItem(params_item, 1);
|
||||||
|
if (!cJSON_IsString(peer_item) || peer_item->valuestring == NULL ||
|
||||||
|
!cJSON_IsString(msg_item) || msg_item->valuestring == NULL) {
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return make_error_response(id_str, -32602, "invalid_params");
|
||||||
|
}
|
||||||
|
owned_result = crypto_nip44_decrypt(ctx->key_store, role_index, peer_item->valuestring, msg_item->valuestring);
|
||||||
|
if (owned_result == NULL) {
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return make_error_response(id_str, -32602, "invalid_params");
|
||||||
|
}
|
||||||
|
result = owned_result;
|
||||||
} else if (strcmp(method, VERB_NIP04_ENCRYPT) == 0) {
|
} else if (strcmp(method, VERB_NIP04_ENCRYPT) == 0) {
|
||||||
result = "stub:nip04_encrypt_ok";
|
cJSON *peer_item = cJSON_GetArrayItem(params_item, 0);
|
||||||
|
cJSON *msg_item = cJSON_GetArrayItem(params_item, 1);
|
||||||
|
if (!cJSON_IsString(peer_item) || peer_item->valuestring == NULL ||
|
||||||
|
!cJSON_IsString(msg_item) || msg_item->valuestring == NULL) {
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return make_error_response(id_str, -32602, "invalid_params");
|
||||||
|
}
|
||||||
|
owned_result = crypto_nip04_encrypt(ctx->key_store, role_index, peer_item->valuestring, msg_item->valuestring);
|
||||||
|
if (owned_result == NULL) {
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return make_error_response(id_str, -32602, "invalid_params");
|
||||||
|
}
|
||||||
|
result = owned_result;
|
||||||
} else if (strcmp(method, VERB_NIP04_DECRYPT) == 0) {
|
} else if (strcmp(method, VERB_NIP04_DECRYPT) == 0) {
|
||||||
result = "stub:nip04_decrypt_ok";
|
cJSON *peer_item = cJSON_GetArrayItem(params_item, 0);
|
||||||
|
cJSON *msg_item = cJSON_GetArrayItem(params_item, 1);
|
||||||
|
if (!cJSON_IsString(peer_item) || peer_item->valuestring == NULL ||
|
||||||
|
!cJSON_IsString(msg_item) || msg_item->valuestring == NULL) {
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return make_error_response(id_str, -32602, "invalid_params");
|
||||||
|
}
|
||||||
|
owned_result = crypto_nip04_decrypt(ctx->key_store, role_index, peer_item->valuestring, msg_item->valuestring);
|
||||||
|
if (owned_result == NULL) {
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return make_error_response(id_str, -32602, "invalid_params");
|
||||||
|
}
|
||||||
|
result = owned_result;
|
||||||
} else {
|
} else {
|
||||||
cJSON_Delete(root);
|
cJSON_Delete(root);
|
||||||
return make_error_response(id_str, -32601, "method_not_found");
|
return make_error_response(id_str, -32601, "method_not_found");
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ int mnemonic_generate(int word_count, char *out, size_t out_len);
|
|||||||
#define ROLE_PURPOSE_MAX 32
|
#define ROLE_PURPOSE_MAX 32
|
||||||
#define ROLE_CURVE_MAX 16
|
#define ROLE_CURVE_MAX 16
|
||||||
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||||
#define ROLE_TABLE_MAX_ENTRIES 64
|
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||||
|
|
||||||
/* Purpose enum for fast comparison (string form kept for config/display) */
|
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -229,7 +229,7 @@ const char *enforce_strerror(int err);
|
|||||||
#define POLICY_MAX_ROLES 16
|
#define POLICY_MAX_ROLES 16
|
||||||
#define POLICY_MAX_PURPOSES 8
|
#define POLICY_MAX_PURPOSES 8
|
||||||
#define POLICY_VERB_MAX_LEN 32
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
#define POLICY_CALLER_MAX_LEN 64
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
/* Prompt behavior */
|
/* Prompt behavior */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -239,6 +239,12 @@ typedef enum {
|
|||||||
PROMPT_DENY
|
PROMPT_DENY
|
||||||
} prompt_mode_t;
|
} prompt_mode_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||||
|
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||||
|
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||||
|
} policy_source_t;
|
||||||
|
|
||||||
/* A single policy entry */
|
/* A single policy entry */
|
||||||
typedef struct {
|
typedef struct {
|
||||||
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||||
@@ -249,6 +255,7 @@ typedef struct {
|
|||||||
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||||
int purpose_count;
|
int purpose_count;
|
||||||
prompt_mode_t prompt;
|
prompt_mode_t prompt;
|
||||||
|
policy_source_t source;
|
||||||
} policy_entry_t;
|
} policy_entry_t;
|
||||||
|
|
||||||
/* Policy table */
|
/* Policy table */
|
||||||
@@ -277,7 +284,8 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
|||||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||||
*/
|
*/
|
||||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||||
const char *verb, const char *role_name, const char *purpose);
|
const char *verb, const char *role_name, const char *purpose,
|
||||||
|
policy_source_t *out_source);
|
||||||
|
|
||||||
/* Parse prompt mode from string */
|
/* Parse prompt mode from string */
|
||||||
prompt_mode_t prompt_mode_from_str(const char *s);
|
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||||
@@ -376,7 +384,7 @@ typedef struct {
|
|||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
pid_t pid;
|
pid_t pid;
|
||||||
char caller_id[64]; /* "uid:<n>" */
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
} caller_identity_t;
|
} caller_identity_t;
|
||||||
|
|
||||||
/* Server context */
|
/* Server context */
|
||||||
|
|||||||
215
src/key_store.c
215
src/key_store.c
@@ -75,7 +75,7 @@ int mnemonic_generate(int word_count, char *out, size_t out_len);
|
|||||||
#define ROLE_PURPOSE_MAX 32
|
#define ROLE_PURPOSE_MAX 32
|
||||||
#define ROLE_CURVE_MAX 16
|
#define ROLE_CURVE_MAX 16
|
||||||
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||||
#define ROLE_TABLE_MAX_ENTRIES 64
|
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||||
|
|
||||||
/* Purpose enum for fast comparison (string form kept for config/display) */
|
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -231,7 +231,7 @@ const char *enforce_strerror(int err);
|
|||||||
#define POLICY_MAX_ROLES 16
|
#define POLICY_MAX_ROLES 16
|
||||||
#define POLICY_MAX_PURPOSES 8
|
#define POLICY_MAX_PURPOSES 8
|
||||||
#define POLICY_VERB_MAX_LEN 32
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
#define POLICY_CALLER_MAX_LEN 64
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
/* Prompt behavior */
|
/* Prompt behavior */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -241,6 +241,12 @@ typedef enum {
|
|||||||
PROMPT_DENY
|
PROMPT_DENY
|
||||||
} prompt_mode_t;
|
} prompt_mode_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||||
|
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||||
|
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||||
|
} policy_source_t;
|
||||||
|
|
||||||
/* A single policy entry */
|
/* A single policy entry */
|
||||||
typedef struct {
|
typedef struct {
|
||||||
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||||
@@ -251,6 +257,7 @@ typedef struct {
|
|||||||
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||||
int purpose_count;
|
int purpose_count;
|
||||||
prompt_mode_t prompt;
|
prompt_mode_t prompt;
|
||||||
|
policy_source_t source;
|
||||||
} policy_entry_t;
|
} policy_entry_t;
|
||||||
|
|
||||||
/* Policy table */
|
/* Policy table */
|
||||||
@@ -279,7 +286,8 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
|||||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||||
*/
|
*/
|
||||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||||
const char *verb, const char *role_name, const char *purpose);
|
const char *verb, const char *role_name, const char *purpose,
|
||||||
|
policy_source_t *out_source);
|
||||||
|
|
||||||
/* Parse prompt mode from string */
|
/* Parse prompt mode from string */
|
||||||
prompt_mode_t prompt_mode_from_str(const char *s);
|
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||||
@@ -312,6 +320,9 @@ typedef struct {
|
|||||||
* Returns number of keys derived, or -1 on error. */
|
* Returns number of keys derived, or -1 on error. */
|
||||||
int crypto_derive_all(key_store_t *store, role_table_t *table, const mnemonic_state_t *mnemonic);
|
int crypto_derive_all(key_store_t *store, role_table_t *table, const mnemonic_state_t *mnemonic);
|
||||||
|
|
||||||
|
/* Derive key for exactly one role index. Returns 0 on success, -1 on error. */
|
||||||
|
int crypto_derive_one(key_store_t *store, role_table_t *table, const mnemonic_state_t *mnemonic, int role_index);
|
||||||
|
|
||||||
/* Get the derived private key for a role (by table index). Returns NULL if not derived. */
|
/* Get the derived private key for a role (by table index). Returns NULL if not derived. */
|
||||||
const unsigned char *crypto_get_private_key(const key_store_t *store, int role_index);
|
const unsigned char *crypto_get_private_key(const key_store_t *store, int role_index);
|
||||||
|
|
||||||
@@ -378,7 +389,7 @@ typedef struct {
|
|||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
pid_t pid;
|
pid_t pid;
|
||||||
char caller_id[64]; /* "uid:<n>" */
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
} caller_identity_t;
|
} caller_identity_t;
|
||||||
|
|
||||||
/* Server context */
|
/* Server context */
|
||||||
@@ -444,8 +455,10 @@ int socket_name_random(char *out, size_t out_len);
|
|||||||
|
|
||||||
#include <nostr_core/nostr_common.h>
|
#include <nostr_core/nostr_common.h>
|
||||||
#include <nostr_core/nip001.h>
|
#include <nostr_core/nip001.h>
|
||||||
|
#include <nostr_core/nip004.h>
|
||||||
#include <nostr_core/nip006.h>
|
#include <nostr_core/nip006.h>
|
||||||
#include <nostr_core/nip019.h>
|
#include <nostr_core/nip019.h>
|
||||||
|
#include <nostr_core/nip044.h>
|
||||||
#include <nostr_core/utils.h>
|
#include <nostr_core/utils.h>
|
||||||
|
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
@@ -518,6 +531,74 @@ int crypto_derive_all(key_store_t *store, role_table_t *table, const mnemonic_st
|
|||||||
return derived_count;
|
return derived_count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int crypto_derive_one(key_store_t *store, role_table_t *table, const mnemonic_state_t *mnemonic, int role_index) {
|
||||||
|
role_entry_t *role;
|
||||||
|
derived_key_t *dst;
|
||||||
|
unsigned char priv[32];
|
||||||
|
unsigned char pub[32];
|
||||||
|
|
||||||
|
if (store == NULL || table == NULL || mnemonic == NULL) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mnemonic_is_loaded(mnemonic)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role_index < 0 || role_index >= table->count || role_index >= ROLE_TABLE_MAX_ENTRIES) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
role = &table->entries[role_index];
|
||||||
|
dst = &store->keys[role_index];
|
||||||
|
|
||||||
|
role->derived = 0;
|
||||||
|
role->pubkey_hex[0] = '\0';
|
||||||
|
|
||||||
|
secure_buf_free(&dst->private_key);
|
||||||
|
secure_memzero(dst->public_key, sizeof(dst->public_key));
|
||||||
|
secure_memzero(dst->pubkey_hex, sizeof(dst->pubkey_hex));
|
||||||
|
secure_memzero(dst->npub, sizeof(dst->npub));
|
||||||
|
dst->valid = 0;
|
||||||
|
|
||||||
|
if (role->purpose != PURPOSE_NOSTR ||
|
||||||
|
role->curve != CURVE_SECP256K1 ||
|
||||||
|
role->selector_type != SELECTOR_NOSTR_INDEX) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secure_buf_alloc(&dst->private_key, 32) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nostr_derive_keys_from_mnemonic(mnemonic_get_phrase(mnemonic), role->nostr_index, priv, pub) != 0) {
|
||||||
|
secure_memzero(priv, sizeof(priv));
|
||||||
|
secure_memzero(pub, sizeof(pub));
|
||||||
|
secure_buf_free(&dst->private_key);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
memcpy(dst->private_key.data, priv, 32);
|
||||||
|
memcpy(dst->public_key, pub, 32);
|
||||||
|
|
||||||
|
nostr_bytes_to_hex(pub, 32, dst->pubkey_hex);
|
||||||
|
dst->npub[0] = '\0';
|
||||||
|
(void)nostr_key_to_bech32(pub, "npub", dst->npub);
|
||||||
|
|
||||||
|
strncpy(role->pubkey_hex, dst->pubkey_hex, sizeof(role->pubkey_hex) - 1);
|
||||||
|
role->pubkey_hex[sizeof(role->pubkey_hex) - 1] = '\0';
|
||||||
|
role->derived = 1;
|
||||||
|
|
||||||
|
dst->valid = 1;
|
||||||
|
if (store->count < table->count) {
|
||||||
|
store->count = table->count;
|
||||||
|
}
|
||||||
|
|
||||||
|
secure_memzero(priv, sizeof(priv));
|
||||||
|
secure_memzero(pub, sizeof(pub));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
const unsigned char *crypto_get_private_key(const key_store_t *store, int role_index) {
|
const unsigned char *crypto_get_private_key(const key_store_t *store, int role_index) {
|
||||||
if (store == NULL || role_index < 0 || role_index >= ROLE_TABLE_MAX_ENTRIES) {
|
if (store == NULL || role_index < 0 || role_index >= ROLE_TABLE_MAX_ENTRIES) {
|
||||||
return NULL;
|
return NULL;
|
||||||
@@ -609,6 +690,132 @@ char *crypto_sign_event(const key_store_t *store, int role_index, const char *ev
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int parse_hex_pubkey32(const char *hex, unsigned char out[32]) {
|
||||||
|
if (hex == NULL || out == NULL || strlen(hex) != 64) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (nostr_hex_to_bytes(hex, out, 32) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
char *crypto_nip44_encrypt(const key_store_t *store, int role_index,
|
||||||
|
const char *recipient_pubkey_hex, const char *plaintext) {
|
||||||
|
const unsigned char *private_key;
|
||||||
|
unsigned char recipient_pub[32];
|
||||||
|
char *out;
|
||||||
|
|
||||||
|
if (plaintext == NULL || recipient_pubkey_hex == NULL) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
private_key = crypto_get_private_key(store, role_index);
|
||||||
|
if (private_key == NULL || parse_hex_pubkey32(recipient_pubkey_hex, recipient_pub) != 0) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
out = (char *)malloc(NSIGNER_ENCRYPT_OUTPUT_MAX);
|
||||||
|
if (out == NULL) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
out[0] = '\0';
|
||||||
|
|
||||||
|
if (nostr_nip44_encrypt(private_key, recipient_pub, plaintext, out, NSIGNER_ENCRYPT_OUTPUT_MAX) != 0) {
|
||||||
|
free(out);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
char *crypto_nip44_decrypt(const key_store_t *store, int role_index,
|
||||||
|
const char *sender_pubkey_hex, const char *ciphertext) {
|
||||||
|
const unsigned char *private_key;
|
||||||
|
unsigned char sender_pub[32];
|
||||||
|
char *out;
|
||||||
|
|
||||||
|
if (ciphertext == NULL || sender_pubkey_hex == NULL) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
private_key = crypto_get_private_key(store, role_index);
|
||||||
|
if (private_key == NULL || parse_hex_pubkey32(sender_pubkey_hex, sender_pub) != 0) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
out = (char *)malloc(NSIGNER_ENCRYPT_OUTPUT_MAX);
|
||||||
|
if (out == NULL) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
out[0] = '\0';
|
||||||
|
|
||||||
|
if (nostr_nip44_decrypt(private_key, sender_pub, ciphertext, out, NSIGNER_ENCRYPT_OUTPUT_MAX) != 0) {
|
||||||
|
free(out);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
char *crypto_nip04_encrypt(const key_store_t *store, int role_index,
|
||||||
|
const char *recipient_pubkey_hex, const char *plaintext) {
|
||||||
|
const unsigned char *private_key;
|
||||||
|
unsigned char recipient_pub[32];
|
||||||
|
char *out;
|
||||||
|
|
||||||
|
if (plaintext == NULL || recipient_pubkey_hex == NULL) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
private_key = crypto_get_private_key(store, role_index);
|
||||||
|
if (private_key == NULL || parse_hex_pubkey32(recipient_pubkey_hex, recipient_pub) != 0) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
out = (char *)malloc(NSIGNER_ENCRYPT_OUTPUT_MAX);
|
||||||
|
if (out == NULL) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
out[0] = '\0';
|
||||||
|
|
||||||
|
if (nostr_nip04_encrypt(private_key, recipient_pub, plaintext, out, NSIGNER_ENCRYPT_OUTPUT_MAX) != 0) {
|
||||||
|
free(out);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
char *crypto_nip04_decrypt(const key_store_t *store, int role_index,
|
||||||
|
const char *sender_pubkey_hex, const char *ciphertext) {
|
||||||
|
const unsigned char *private_key;
|
||||||
|
unsigned char sender_pub[32];
|
||||||
|
char *out;
|
||||||
|
|
||||||
|
if (ciphertext == NULL || sender_pubkey_hex == NULL) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
private_key = crypto_get_private_key(store, role_index);
|
||||||
|
if (private_key == NULL || parse_hex_pubkey32(sender_pubkey_hex, sender_pub) != 0) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
out = (char *)malloc(NSIGNER_ENCRYPT_OUTPUT_MAX);
|
||||||
|
if (out == NULL) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
out[0] = '\0';
|
||||||
|
|
||||||
|
if (nostr_nip04_decrypt(private_key, sender_pub, ciphertext, out, NSIGNER_ENCRYPT_OUTPUT_MAX) != 0) {
|
||||||
|
free(out);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
void crypto_wipe(key_store_t *store) {
|
void crypto_wipe(key_store_t *store) {
|
||||||
int i;
|
int i;
|
||||||
|
|
||||||
|
|||||||
1368
src/main.c
1368
src/main.c
File diff suppressed because it is too large
Load Diff
@@ -73,7 +73,7 @@ int mnemonic_generate(int word_count, char *out, size_t out_len);
|
|||||||
#define ROLE_PURPOSE_MAX 32
|
#define ROLE_PURPOSE_MAX 32
|
||||||
#define ROLE_CURVE_MAX 16
|
#define ROLE_CURVE_MAX 16
|
||||||
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||||
#define ROLE_TABLE_MAX_ENTRIES 64
|
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||||
|
|
||||||
/* Purpose enum for fast comparison (string form kept for config/display) */
|
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -229,7 +229,7 @@ const char *enforce_strerror(int err);
|
|||||||
#define POLICY_MAX_ROLES 16
|
#define POLICY_MAX_ROLES 16
|
||||||
#define POLICY_MAX_PURPOSES 8
|
#define POLICY_MAX_PURPOSES 8
|
||||||
#define POLICY_VERB_MAX_LEN 32
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
#define POLICY_CALLER_MAX_LEN 64
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
/* Prompt behavior */
|
/* Prompt behavior */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -239,6 +239,12 @@ typedef enum {
|
|||||||
PROMPT_DENY
|
PROMPT_DENY
|
||||||
} prompt_mode_t;
|
} prompt_mode_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||||
|
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||||
|
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||||
|
} policy_source_t;
|
||||||
|
|
||||||
/* A single policy entry */
|
/* A single policy entry */
|
||||||
typedef struct {
|
typedef struct {
|
||||||
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||||
@@ -249,6 +255,7 @@ typedef struct {
|
|||||||
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||||
int purpose_count;
|
int purpose_count;
|
||||||
prompt_mode_t prompt;
|
prompt_mode_t prompt;
|
||||||
|
policy_source_t source;
|
||||||
} policy_entry_t;
|
} policy_entry_t;
|
||||||
|
|
||||||
/* Policy table */
|
/* Policy table */
|
||||||
@@ -277,7 +284,8 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
|||||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||||
*/
|
*/
|
||||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||||
const char *verb, const char *role_name, const char *purpose);
|
const char *verb, const char *role_name, const char *purpose,
|
||||||
|
policy_source_t *out_source);
|
||||||
|
|
||||||
/* Parse prompt mode from string */
|
/* Parse prompt mode from string */
|
||||||
prompt_mode_t prompt_mode_from_str(const char *s);
|
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||||
@@ -376,7 +384,7 @@ typedef struct {
|
|||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
pid_t pid;
|
pid_t pid;
|
||||||
char caller_id[64]; /* "uid:<n>" */
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
} caller_identity_t;
|
} caller_identity_t;
|
||||||
|
|
||||||
/* Server context */
|
/* Server context */
|
||||||
|
|||||||
177
src/policy.c
177
src/policy.c
@@ -73,7 +73,7 @@ int mnemonic_generate(int word_count, char *out, size_t out_len);
|
|||||||
#define ROLE_PURPOSE_MAX 32
|
#define ROLE_PURPOSE_MAX 32
|
||||||
#define ROLE_CURVE_MAX 16
|
#define ROLE_CURVE_MAX 16
|
||||||
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||||
#define ROLE_TABLE_MAX_ENTRIES 64
|
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||||
|
|
||||||
/* Purpose enum for fast comparison (string form kept for config/display) */
|
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -229,7 +229,7 @@ const char *enforce_strerror(int err);
|
|||||||
#define POLICY_MAX_ROLES 16
|
#define POLICY_MAX_ROLES 16
|
||||||
#define POLICY_MAX_PURPOSES 8
|
#define POLICY_MAX_PURPOSES 8
|
||||||
#define POLICY_VERB_MAX_LEN 32
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
#define POLICY_CALLER_MAX_LEN 64
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
/* Prompt behavior */
|
/* Prompt behavior */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -239,6 +239,12 @@ typedef enum {
|
|||||||
PROMPT_DENY
|
PROMPT_DENY
|
||||||
} prompt_mode_t;
|
} prompt_mode_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||||
|
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||||
|
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||||
|
} policy_source_t;
|
||||||
|
|
||||||
/* A single policy entry */
|
/* A single policy entry */
|
||||||
typedef struct {
|
typedef struct {
|
||||||
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||||
@@ -249,6 +255,7 @@ typedef struct {
|
|||||||
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||||
int purpose_count;
|
int purpose_count;
|
||||||
prompt_mode_t prompt;
|
prompt_mode_t prompt;
|
||||||
|
policy_source_t source;
|
||||||
} policy_entry_t;
|
} policy_entry_t;
|
||||||
|
|
||||||
/* Policy table */
|
/* Policy table */
|
||||||
@@ -277,7 +284,8 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
|||||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||||
*/
|
*/
|
||||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||||
const char *verb, const char *role_name, const char *purpose);
|
const char *verb, const char *role_name, const char *purpose,
|
||||||
|
policy_source_t *out_source);
|
||||||
|
|
||||||
/* Parse prompt mode from string */
|
/* Parse prompt mode from string */
|
||||||
prompt_mode_t prompt_mode_from_str(const char *s);
|
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||||
@@ -376,7 +384,7 @@ typedef struct {
|
|||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
pid_t pid;
|
pid_t pid;
|
||||||
char caller_id[64]; /* "uid:<n>" */
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
} caller_identity_t;
|
} caller_identity_t;
|
||||||
|
|
||||||
/* Server context */
|
/* Server context */
|
||||||
@@ -440,7 +448,9 @@ int socket_name_random(char *out, size_t out_len);
|
|||||||
|
|
||||||
/* NSIGNER_HEADERLESS_DECLS_END */
|
/* NSIGNER_HEADERLESS_DECLS_END */
|
||||||
|
|
||||||
|
#include <errno.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
static void copy_str(char *dst, size_t dst_sz, const char *src) {
|
static void copy_str(char *dst, size_t dst_sz, const char *src) {
|
||||||
@@ -540,6 +550,112 @@ const char *prompt_mode_to_str(prompt_mode_t m) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int parse_preapprove_spec(const char *spec, policy_entry_t *out_entry, int *out_nostr_index) {
|
||||||
|
char *spec_copy;
|
||||||
|
char *token;
|
||||||
|
const char *caller = NULL;
|
||||||
|
const char *role = NULL;
|
||||||
|
int nostr_index = -1;
|
||||||
|
int has_role = 0;
|
||||||
|
int has_nostr_index = 0;
|
||||||
|
|
||||||
|
if (out_nostr_index != NULL) {
|
||||||
|
*out_nostr_index = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spec == NULL || out_entry == NULL) {
|
||||||
|
fprintf(stderr, "ERROR: invalid --preapprove arguments\n");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
spec_copy = (char *)malloc(strlen(spec) + 1);
|
||||||
|
if (spec_copy == NULL) {
|
||||||
|
fprintf(stderr, "ERROR: out of memory parsing --preapprove spec: %s\n", spec);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
memcpy(spec_copy, spec, strlen(spec) + 1);
|
||||||
|
|
||||||
|
token = strtok(spec_copy, ",");
|
||||||
|
while (token != NULL) {
|
||||||
|
char *eq = strchr(token, '=');
|
||||||
|
if (eq == NULL || eq == token || eq[1] == '\0') {
|
||||||
|
fprintf(stderr, "ERROR: invalid --preapprove key=value field: %s\n", spec);
|
||||||
|
free(spec_copy);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
*eq = '\0';
|
||||||
|
if (strcmp(token, "caller") == 0) {
|
||||||
|
caller = eq + 1;
|
||||||
|
} else if (strcmp(token, "role") == 0) {
|
||||||
|
role = eq + 1;
|
||||||
|
has_role = 1;
|
||||||
|
} else if (strcmp(token, "nostr_index") == 0) {
|
||||||
|
char *endptr = NULL;
|
||||||
|
long val;
|
||||||
|
|
||||||
|
errno = 0;
|
||||||
|
val = strtol(eq + 1, &endptr, 10);
|
||||||
|
if (errno != 0 || endptr == (eq + 1) || *endptr != '\0' || val < 0) {
|
||||||
|
fprintf(stderr, "ERROR: --preapprove spec has invalid nostr_index= value: %s\n", spec);
|
||||||
|
free(spec_copy);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
nostr_index = (int)val;
|
||||||
|
has_nostr_index = 1;
|
||||||
|
} else {
|
||||||
|
fprintf(stderr, "ERROR: unknown --preapprove field '%s' in spec: %s\n", token, spec);
|
||||||
|
free(spec_copy);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
token = strtok(NULL, ",");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (caller == NULL) {
|
||||||
|
fprintf(stderr, "ERROR: --preapprove spec missing 'caller=' field: %s\n", spec);
|
||||||
|
free(spec_copy);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!has_role && !has_nostr_index) {
|
||||||
|
fprintf(stderr, "ERROR: --preapprove spec must include 'role=' or 'nostr_index=': %s\n", spec);
|
||||||
|
free(spec_copy);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (has_role && has_nostr_index) {
|
||||||
|
fprintf(stderr, "ERROR: --preapprove spec has both 'role=' and 'nostr_index=' (use one): %s\n", spec);
|
||||||
|
free(spec_copy);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
memset(out_entry, 0, sizeof(*out_entry));
|
||||||
|
strncpy(out_entry->caller, caller, sizeof(out_entry->caller) - 1);
|
||||||
|
out_entry->caller[sizeof(out_entry->caller) - 1] = '\0';
|
||||||
|
|
||||||
|
if (has_role) {
|
||||||
|
strncpy(out_entry->roles[0], role, sizeof(out_entry->roles[0]) - 1);
|
||||||
|
out_entry->roles[0][sizeof(out_entry->roles[0]) - 1] = '\0';
|
||||||
|
} else {
|
||||||
|
if (nostr_index == 0) {
|
||||||
|
strncpy(out_entry->roles[0], "main", sizeof(out_entry->roles[0]) - 1);
|
||||||
|
out_entry->roles[0][sizeof(out_entry->roles[0]) - 1] = '\0';
|
||||||
|
} else {
|
||||||
|
(void)snprintf(out_entry->roles[0], sizeof(out_entry->roles[0]), "nostr_idx_%d", nostr_index);
|
||||||
|
}
|
||||||
|
if (out_nostr_index != NULL) {
|
||||||
|
*out_nostr_index = nostr_index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out_entry->role_count = 1;
|
||||||
|
out_entry->prompt = PROMPT_NEVER;
|
||||||
|
|
||||||
|
free(spec_copy);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
void policy_table_init(policy_table_t *table) {
|
void policy_table_init(policy_table_t *table) {
|
||||||
if (table == NULL) {
|
if (table == NULL) {
|
||||||
return;
|
return;
|
||||||
@@ -549,8 +665,9 @@ void policy_table_init(policy_table_t *table) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void policy_init_default(policy_table_t *table, uid_t owner_uid) {
|
void policy_init_default(policy_table_t *table, uid_t owner_uid) {
|
||||||
policy_entry_t allow_owner;
|
policy_entry_t prompt_all;
|
||||||
policy_entry_t deny_all;
|
|
||||||
|
(void)owner_uid;
|
||||||
|
|
||||||
if (table == NULL) {
|
if (table == NULL) {
|
||||||
return;
|
return;
|
||||||
@@ -558,15 +675,11 @@ void policy_init_default(policy_table_t *table, uid_t owner_uid) {
|
|||||||
|
|
||||||
policy_table_init(table);
|
policy_table_init(table);
|
||||||
|
|
||||||
memset(&allow_owner, 0, sizeof(allow_owner));
|
memset(&prompt_all, 0, sizeof(prompt_all));
|
||||||
(void)snprintf(allow_owner.caller, sizeof(allow_owner.caller), "uid:%u", (unsigned int)owner_uid);
|
copy_str(prompt_all.caller, sizeof(prompt_all.caller), "*");
|
||||||
allow_owner.prompt = PROMPT_NEVER;
|
prompt_all.prompt = PROMPT_EVERY_REQUEST;
|
||||||
(void)policy_table_add(table, &allow_owner);
|
prompt_all.source = POLICY_SOURCE_DEFAULT;
|
||||||
|
(void)policy_table_add(table, &prompt_all);
|
||||||
memset(&deny_all, 0, sizeof(deny_all));
|
|
||||||
copy_str(deny_all.caller, sizeof(deny_all.caller), "*");
|
|
||||||
deny_all.prompt = PROMPT_DENY;
|
|
||||||
(void)policy_table_add(table, &deny_all);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
int policy_table_add(policy_table_t *table, const policy_entry_t *entry) {
|
int policy_table_add(policy_table_t *table, const policy_entry_t *entry) {
|
||||||
@@ -583,10 +696,34 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int policy_table_insert_before_last(policy_table_t *table, const policy_entry_t *entry) {
|
||||||
|
if (table == NULL || entry == NULL) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (table->count >= POLICY_MAX_ENTRIES) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (table->count == 0) {
|
||||||
|
return policy_table_add(table, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
table->entries[table->count] = table->entries[table->count - 1];
|
||||||
|
table->entries[table->count - 1] = *entry;
|
||||||
|
table->count++;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||||
const char *verb, const char *role_name, const char *purpose) {
|
const char *verb, const char *role_name, const char *purpose,
|
||||||
|
policy_source_t *out_source) {
|
||||||
int i;
|
int i;
|
||||||
|
|
||||||
|
if (out_source != NULL) {
|
||||||
|
*out_source = POLICY_SOURCE_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
if (table == NULL || caller_id == NULL || verb == NULL || role_name == NULL || purpose == NULL) {
|
if (table == NULL || caller_id == NULL || verb == NULL || role_name == NULL || purpose == NULL) {
|
||||||
return POLICY_NO_MATCH;
|
return POLICY_NO_MATCH;
|
||||||
}
|
}
|
||||||
@@ -618,12 +755,20 @@ int policy_check(const policy_table_t *table, const char *caller_id,
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (out_source != NULL) {
|
||||||
|
*out_source = entry->source;
|
||||||
|
}
|
||||||
|
|
||||||
if (entry->prompt == PROMPT_DENY) {
|
if (entry->prompt == PROMPT_DENY) {
|
||||||
return POLICY_DENY;
|
return POLICY_DENY;
|
||||||
}
|
}
|
||||||
if (entry->prompt == PROMPT_NEVER) {
|
if (entry->prompt == PROMPT_NEVER) {
|
||||||
return POLICY_ALLOW;
|
return POLICY_ALLOW;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (out_source != NULL) {
|
||||||
|
*out_source = POLICY_SOURCE_DEFAULT;
|
||||||
|
}
|
||||||
return POLICY_PROMPT;
|
return POLICY_PROMPT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ int mnemonic_generate(int word_count, char *out, size_t out_len);
|
|||||||
#define ROLE_PURPOSE_MAX 32
|
#define ROLE_PURPOSE_MAX 32
|
||||||
#define ROLE_CURVE_MAX 16
|
#define ROLE_CURVE_MAX 16
|
||||||
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||||
#define ROLE_TABLE_MAX_ENTRIES 64
|
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||||
|
|
||||||
/* Purpose enum for fast comparison (string form kept for config/display) */
|
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -149,6 +149,9 @@ const char *role_purpose_to_str(role_purpose_t p);
|
|||||||
/* Curve enum to string */
|
/* Curve enum to string */
|
||||||
const char *role_curve_to_str(role_curve_t c);
|
const char *role_curve_to_str(role_curve_t c);
|
||||||
|
|
||||||
|
/* Register a nostr-index role if missing. Returns 0 on success, -1 on error. */
|
||||||
|
int role_table_register_nostr_index(role_table_t *table, int nostr_index);
|
||||||
|
|
||||||
|
|
||||||
/* from selector.h */
|
/* from selector.h */
|
||||||
|
|
||||||
@@ -229,7 +232,7 @@ const char *enforce_strerror(int err);
|
|||||||
#define POLICY_MAX_ROLES 16
|
#define POLICY_MAX_ROLES 16
|
||||||
#define POLICY_MAX_PURPOSES 8
|
#define POLICY_MAX_PURPOSES 8
|
||||||
#define POLICY_VERB_MAX_LEN 32
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
#define POLICY_CALLER_MAX_LEN 64
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
/* Prompt behavior */
|
/* Prompt behavior */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -239,6 +242,12 @@ typedef enum {
|
|||||||
PROMPT_DENY
|
PROMPT_DENY
|
||||||
} prompt_mode_t;
|
} prompt_mode_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||||
|
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||||
|
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||||
|
} policy_source_t;
|
||||||
|
|
||||||
/* A single policy entry */
|
/* A single policy entry */
|
||||||
typedef struct {
|
typedef struct {
|
||||||
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||||
@@ -249,6 +258,7 @@ typedef struct {
|
|||||||
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||||
int purpose_count;
|
int purpose_count;
|
||||||
prompt_mode_t prompt;
|
prompt_mode_t prompt;
|
||||||
|
policy_source_t source;
|
||||||
} policy_entry_t;
|
} policy_entry_t;
|
||||||
|
|
||||||
/* Policy table */
|
/* Policy table */
|
||||||
@@ -277,7 +287,8 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
|||||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||||
*/
|
*/
|
||||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||||
const char *verb, const char *role_name, const char *purpose);
|
const char *verb, const char *role_name, const char *purpose,
|
||||||
|
policy_source_t *out_source);
|
||||||
|
|
||||||
/* Parse prompt mode from string */
|
/* Parse prompt mode from string */
|
||||||
prompt_mode_t prompt_mode_from_str(const char *s);
|
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||||
@@ -376,7 +387,7 @@ typedef struct {
|
|||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
pid_t pid;
|
pid_t pid;
|
||||||
char caller_id[64]; /* "uid:<n>" */
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
} caller_identity_t;
|
} caller_identity_t;
|
||||||
|
|
||||||
/* Server context */
|
/* Server context */
|
||||||
@@ -440,6 +451,7 @@ int socket_name_random(char *out, size_t out_len);
|
|||||||
|
|
||||||
/* NSIGNER_HEADERLESS_DECLS_END */
|
/* NSIGNER_HEADERLESS_DECLS_END */
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
static int str_eq(const char *a, const char *b) {
|
static int str_eq(const char *a, const char *b) {
|
||||||
@@ -533,6 +545,36 @@ role_entry_t *role_table_get_default(role_table_t *table) {
|
|||||||
return role_table_find_by_name(table, "main");
|
return role_table_find_by_name(table, "main");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int role_table_register_nostr_index(role_table_t *table, int nostr_index) {
|
||||||
|
role_entry_t role;
|
||||||
|
|
||||||
|
if (table == NULL || nostr_index < 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role_table_find_by_nostr_index(table, nostr_index) != NULL) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
memset(&role, 0, sizeof(role));
|
||||||
|
|
||||||
|
if (nostr_index == 0) {
|
||||||
|
strncpy(role.name, "main", sizeof(role.name) - 1);
|
||||||
|
} else {
|
||||||
|
(void)snprintf(role.name, sizeof(role.name), "nostr_idx_%d", nostr_index);
|
||||||
|
}
|
||||||
|
|
||||||
|
strncpy(role.purpose_str, "nostr", sizeof(role.purpose_str) - 1);
|
||||||
|
strncpy(role.curve_str, "secp256k1", sizeof(role.curve_str) - 1);
|
||||||
|
role.purpose = role_purpose_from_str(role.purpose_str);
|
||||||
|
role.curve = role_curve_from_str(role.curve_str);
|
||||||
|
role.selector_type = SELECTOR_NOSTR_INDEX;
|
||||||
|
role.nostr_index = nostr_index;
|
||||||
|
role.derived = 0;
|
||||||
|
|
||||||
|
return role_table_add(table, &role);
|
||||||
|
}
|
||||||
|
|
||||||
role_purpose_t role_purpose_from_str(const char *s) {
|
role_purpose_t role_purpose_from_str(const char *s) {
|
||||||
if (str_eq(s, "nostr")) {
|
if (str_eq(s, "nostr")) {
|
||||||
return PURPOSE_NOSTR;
|
return PURPOSE_NOSTR;
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ int mnemonic_generate(int word_count, char *out, size_t out_len);
|
|||||||
#define ROLE_PURPOSE_MAX 32
|
#define ROLE_PURPOSE_MAX 32
|
||||||
#define ROLE_CURVE_MAX 16
|
#define ROLE_CURVE_MAX 16
|
||||||
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||||
#define ROLE_TABLE_MAX_ENTRIES 64
|
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||||
|
|
||||||
/* Purpose enum for fast comparison (string form kept for config/display) */
|
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -231,7 +231,7 @@ const char *enforce_strerror(int err);
|
|||||||
#define POLICY_MAX_ROLES 16
|
#define POLICY_MAX_ROLES 16
|
||||||
#define POLICY_MAX_PURPOSES 8
|
#define POLICY_MAX_PURPOSES 8
|
||||||
#define POLICY_VERB_MAX_LEN 32
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
#define POLICY_CALLER_MAX_LEN 64
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
/* Prompt behavior */
|
/* Prompt behavior */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -241,6 +241,12 @@ typedef enum {
|
|||||||
PROMPT_DENY
|
PROMPT_DENY
|
||||||
} prompt_mode_t;
|
} prompt_mode_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||||
|
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||||
|
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||||
|
} policy_source_t;
|
||||||
|
|
||||||
/* A single policy entry */
|
/* A single policy entry */
|
||||||
typedef struct {
|
typedef struct {
|
||||||
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||||
@@ -251,6 +257,7 @@ typedef struct {
|
|||||||
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||||
int purpose_count;
|
int purpose_count;
|
||||||
prompt_mode_t prompt;
|
prompt_mode_t prompt;
|
||||||
|
policy_source_t source;
|
||||||
} policy_entry_t;
|
} policy_entry_t;
|
||||||
|
|
||||||
/* Policy table */
|
/* Policy table */
|
||||||
@@ -279,7 +286,8 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
|||||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||||
*/
|
*/
|
||||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||||
const char *verb, const char *role_name, const char *purpose);
|
const char *verb, const char *role_name, const char *purpose,
|
||||||
|
policy_source_t *out_source);
|
||||||
|
|
||||||
/* Parse prompt mode from string */
|
/* Parse prompt mode from string */
|
||||||
prompt_mode_t prompt_mode_from_str(const char *s);
|
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||||
@@ -378,7 +386,7 @@ typedef struct {
|
|||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
pid_t pid;
|
pid_t pid;
|
||||||
char caller_id[64]; /* "uid:<n>" */
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
} caller_identity_t;
|
} caller_identity_t;
|
||||||
|
|
||||||
/* Server context */
|
/* Server context */
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ int mnemonic_generate(int word_count, char *out, size_t out_len);
|
|||||||
#define ROLE_PURPOSE_MAX 32
|
#define ROLE_PURPOSE_MAX 32
|
||||||
#define ROLE_CURVE_MAX 16
|
#define ROLE_CURVE_MAX 16
|
||||||
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||||
#define ROLE_TABLE_MAX_ENTRIES 64
|
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||||
|
|
||||||
/* Purpose enum for fast comparison (string form kept for config/display) */
|
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -229,7 +229,7 @@ const char *enforce_strerror(int err);
|
|||||||
#define POLICY_MAX_ROLES 16
|
#define POLICY_MAX_ROLES 16
|
||||||
#define POLICY_MAX_PURPOSES 8
|
#define POLICY_MAX_PURPOSES 8
|
||||||
#define POLICY_VERB_MAX_LEN 32
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
#define POLICY_CALLER_MAX_LEN 64
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
/* Prompt behavior */
|
/* Prompt behavior */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -239,6 +239,12 @@ typedef enum {
|
|||||||
PROMPT_DENY
|
PROMPT_DENY
|
||||||
} prompt_mode_t;
|
} prompt_mode_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||||
|
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||||
|
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||||
|
} policy_source_t;
|
||||||
|
|
||||||
/* A single policy entry */
|
/* A single policy entry */
|
||||||
typedef struct {
|
typedef struct {
|
||||||
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||||
@@ -249,6 +255,7 @@ typedef struct {
|
|||||||
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||||
int purpose_count;
|
int purpose_count;
|
||||||
prompt_mode_t prompt;
|
prompt_mode_t prompt;
|
||||||
|
policy_source_t source;
|
||||||
} policy_entry_t;
|
} policy_entry_t;
|
||||||
|
|
||||||
/* Policy table */
|
/* Policy table */
|
||||||
@@ -277,7 +284,8 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
|||||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||||
*/
|
*/
|
||||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||||
const char *verb, const char *role_name, const char *purpose);
|
const char *verb, const char *role_name, const char *purpose,
|
||||||
|
policy_source_t *out_source);
|
||||||
|
|
||||||
/* Parse prompt mode from string */
|
/* Parse prompt mode from string */
|
||||||
prompt_mode_t prompt_mode_from_str(const char *s);
|
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||||
@@ -376,7 +384,7 @@ typedef struct {
|
|||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
pid_t pid;
|
pid_t pid;
|
||||||
char caller_id[64]; /* "uid:<n>" */
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
} caller_identity_t;
|
} caller_identity_t;
|
||||||
|
|
||||||
/* Server context */
|
/* Server context */
|
||||||
|
|||||||
969
src/server.c
969
src/server.c
File diff suppressed because it is too large
Load Diff
@@ -75,7 +75,7 @@ int mnemonic_generate(int word_count, char *out, size_t out_len);
|
|||||||
#define ROLE_PURPOSE_MAX 32
|
#define ROLE_PURPOSE_MAX 32
|
||||||
#define ROLE_CURVE_MAX 16
|
#define ROLE_CURVE_MAX 16
|
||||||
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||||
#define ROLE_TABLE_MAX_ENTRIES 64
|
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||||
|
|
||||||
/* Purpose enum for fast comparison (string form kept for config/display) */
|
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -231,7 +231,7 @@ const char *enforce_strerror(int err);
|
|||||||
#define POLICY_MAX_ROLES 16
|
#define POLICY_MAX_ROLES 16
|
||||||
#define POLICY_MAX_PURPOSES 8
|
#define POLICY_MAX_PURPOSES 8
|
||||||
#define POLICY_VERB_MAX_LEN 32
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
#define POLICY_CALLER_MAX_LEN 64
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
/* Prompt behavior */
|
/* Prompt behavior */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -241,6 +241,12 @@ typedef enum {
|
|||||||
PROMPT_DENY
|
PROMPT_DENY
|
||||||
} prompt_mode_t;
|
} prompt_mode_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||||
|
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||||
|
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||||
|
} policy_source_t;
|
||||||
|
|
||||||
/* A single policy entry */
|
/* A single policy entry */
|
||||||
typedef struct {
|
typedef struct {
|
||||||
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||||
@@ -251,6 +257,7 @@ typedef struct {
|
|||||||
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||||
int purpose_count;
|
int purpose_count;
|
||||||
prompt_mode_t prompt;
|
prompt_mode_t prompt;
|
||||||
|
policy_source_t source;
|
||||||
} policy_entry_t;
|
} policy_entry_t;
|
||||||
|
|
||||||
/* Policy table */
|
/* Policy table */
|
||||||
@@ -279,7 +286,8 @@ int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
|||||||
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
* Returns POLICY_ALLOW, POLICY_DENY, POLICY_PROMPT, or POLICY_NO_MATCH.
|
||||||
*/
|
*/
|
||||||
int policy_check(const policy_table_t *table, const char *caller_id,
|
int policy_check(const policy_table_t *table, const char *caller_id,
|
||||||
const char *verb, const char *role_name, const char *purpose);
|
const char *verb, const char *role_name, const char *purpose,
|
||||||
|
policy_source_t *out_source);
|
||||||
|
|
||||||
/* Parse prompt mode from string */
|
/* Parse prompt mode from string */
|
||||||
prompt_mode_t prompt_mode_from_str(const char *s);
|
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||||
@@ -378,7 +386,7 @@ typedef struct {
|
|||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
pid_t pid;
|
pid_t pid;
|
||||||
char caller_id[64]; /* "uid:<n>" */
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
} caller_identity_t;
|
} caller_identity_t;
|
||||||
|
|
||||||
/* Server context */
|
/* Server context */
|
||||||
|
|||||||
104
src/transport_frame.c
Normal file
104
src/transport_frame.c
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
#define _GNU_SOURCE
|
||||||
|
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <errno.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
static int transport_read_full(int fd, void *buf, size_t len) {
|
||||||
|
unsigned char *p = (unsigned char *)buf;
|
||||||
|
size_t off = 0;
|
||||||
|
|
||||||
|
while (off < len) {
|
||||||
|
ssize_t n = read(fd, p + off, len - off);
|
||||||
|
if (n == 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (n < 0) {
|
||||||
|
if (errno == EINTR) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
off += (size_t)n;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int transport_write_full(int fd, const void *buf, size_t len) {
|
||||||
|
const unsigned char *p = (const unsigned char *)buf;
|
||||||
|
size_t off = 0;
|
||||||
|
|
||||||
|
while (off < len) {
|
||||||
|
ssize_t n = write(fd, p + off, len - off);
|
||||||
|
if (n < 0) {
|
||||||
|
if (errno == EINTR) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
off += (size_t)n;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int transport_send_framed(int fd, const char *payload) {
|
||||||
|
uint32_t len;
|
||||||
|
uint32_t be_len;
|
||||||
|
|
||||||
|
if (payload == NULL) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
len = (uint32_t)strlen(payload);
|
||||||
|
be_len = htonl(len);
|
||||||
|
|
||||||
|
if (transport_write_full(fd, &be_len, sizeof(be_len)) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (transport_write_full(fd, payload, len) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int transport_recv_framed(int fd, char **out_payload, size_t max_size) {
|
||||||
|
uint32_t be_len;
|
||||||
|
uint32_t len;
|
||||||
|
char *payload;
|
||||||
|
|
||||||
|
if (out_payload == NULL || max_size == 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
*out_payload = NULL;
|
||||||
|
|
||||||
|
if (transport_read_full(fd, &be_len, sizeof(be_len)) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
len = ntohl(be_len);
|
||||||
|
if (len == 0 || len > max_size) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = (char *)malloc((size_t)len + 1U);
|
||||||
|
if (payload == NULL) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transport_read_full(fd, payload, len) != 0) {
|
||||||
|
free(payload);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
payload[len] = '\0';
|
||||||
|
*out_payload = payload;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
129
tests/test.sh
129
tests/test.sh
@@ -2,6 +2,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
NSIGNER_BIN="${NSIGNER_BIN:-./build/nsigner}"
|
NSIGNER_BIN="${NSIGNER_BIN:-./build/nsigner}"
|
||||||
|
socket_name=""
|
||||||
|
|
||||||
if [[ ! -x "$NSIGNER_BIN" ]]; then
|
if [[ ! -x "$NSIGNER_BIN" ]]; then
|
||||||
echo "[error] nsigner binary not found or not executable at: $NSIGNER_BIN"
|
echo "[error] nsigner binary not found or not executable at: $NSIGNER_BIN"
|
||||||
@@ -9,20 +10,44 @@ if [[ ! -x "$NSIGNER_BIN" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-n|--name|--socket-name)
|
||||||
|
if [[ $# -lt 2 ]]; then
|
||||||
|
echo "[error] missing value for $1"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
socket_name="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
echo "Usage: $0 [-n|--name|--socket-name <socket_name>]"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "[error] unknown argument: $1"
|
||||||
|
echo "Usage: $0 [-n|--name|--socket-name <socket_name>]"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
echo "n_signer interactive smoke test"
|
echo "n_signer interactive smoke test"
|
||||||
echo "Using binary: $NSIGNER_BIN"
|
echo "Using binary: $NSIGNER_BIN"
|
||||||
echo
|
echo
|
||||||
|
|
||||||
default_socket=""
|
if [[ -z "$socket_name" ]]; then
|
||||||
if [[ -r /proc/net/unix ]]; then
|
default_socket=""
|
||||||
default_socket="$(awk '/@nsigner_/ {sub(/^@/, "", $8); print $8; exit}' /proc/net/unix || true)"
|
if [[ -r /proc/net/unix ]]; then
|
||||||
fi
|
default_socket="$(awk '/@nsigner_/ {sub(/^@/, "", $8); print $8; exit}' /proc/net/unix || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -n "$default_socket" ]]; then
|
if [[ -n "$default_socket" ]]; then
|
||||||
read -r -p "Signer socket name (without @) [${default_socket}]: " socket_name
|
read -r -p "Signer socket name (without @) [${default_socket}]: " socket_name
|
||||||
socket_name="${socket_name:-$default_socket}"
|
socket_name="${socket_name:-$default_socket}"
|
||||||
else
|
else
|
||||||
read -r -p "Signer socket name (without @, e.g. nsigner_hairy_dog): " socket_name
|
read -r -p "Signer socket name (without @, e.g. nsigner_hairy_dog): " socket_name
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "${socket_name}" ]]; then
|
if [[ -z "${socket_name}" ]]; then
|
||||||
@@ -35,9 +60,13 @@ run_request() {
|
|||||||
"$NSIGNER_BIN" --socket-name "$socket_name" client "$req" || true
|
"$NSIGNER_BIN" --socket-name "$socket_name" client "$req" || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extract_result_string() {
|
||||||
|
sed -n 's/.*"result":"\([^"]*\)".*/\1/p'
|
||||||
|
}
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "[1/5] get_public_key"
|
echo "[1/7] get_public_key"
|
||||||
req_get='{"id":"1","method":"get_public_key","params":[""]}'
|
req_get='{"id":"1","method":"get_public_key","params":[]}'
|
||||||
resp_get="$(run_request "$req_get")"
|
resp_get="$(run_request "$req_get")"
|
||||||
echo "request : $req_get"
|
echo "request : $req_get"
|
||||||
echo "response: $resp_get"
|
echo "response: $resp_get"
|
||||||
@@ -49,7 +78,7 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "[2/5] sign_event"
|
echo "[2/7] sign_event"
|
||||||
now_ts="$(date +%s)"
|
now_ts="$(date +%s)"
|
||||||
event_json='{"kind":1,"content":"hello from tests/test.sh","tags":[],"created_at":__TS__}'
|
event_json='{"kind":1,"content":"hello from tests/test.sh","tags":[],"created_at":__TS__}'
|
||||||
event_json="${event_json/__TS__/${now_ts}}"
|
event_json="${event_json/__TS__/${now_ts}}"
|
||||||
@@ -65,23 +94,91 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "[3/5] sign_event with nostr_index=0"
|
echo "[3/7] sign_event with nostr_index=0"
|
||||||
req_sign_idx="{\"id\":\"3\",\"method\":\"sign_event\",\"params\":[\"${escaped_event_json}\",{\"nostr_index\":0}]}"
|
req_sign_idx="{\"id\":\"3\",\"method\":\"sign_event\",\"params\":[\"${escaped_event_json}\",{\"nostr_index\":0}]}"
|
||||||
resp_sign_idx="$(run_request "$req_sign_idx")"
|
resp_sign_idx="$(run_request "$req_sign_idx")"
|
||||||
echo "request : $req_sign_idx"
|
echo "request : $req_sign_idx"
|
||||||
echo "response: $resp_sign_idx"
|
echo "response: $resp_sign_idx"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "[4/5] role selector error case (unknown role)"
|
echo "[4/7] role selector error case (unknown role)"
|
||||||
req_bad_role='{"id":"4","method":"sign_event","params":["{}",{"role":"does_not_exist"}]}'
|
req_bad_role='{"id":"4","method":"sign_event","params":["{}",{"role":"does_not_exist"}]}'
|
||||||
resp_bad_role="$(run_request "$req_bad_role")"
|
resp_bad_role="$(run_request "$req_bad_role")"
|
||||||
echo "request : $req_bad_role"
|
echo "request : $req_bad_role"
|
||||||
echo "response: $resp_bad_role"
|
echo "response: $resp_bad_role"
|
||||||
|
if printf '%s' "$resp_bad_role" | grep -q '"message":"unknown_role"'; then
|
||||||
|
echo "[ok] unknown role error name is unknown_role"
|
||||||
|
else
|
||||||
|
echo "[warn] unknown role error name mismatch"
|
||||||
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "[5/5] list currently running signers"
|
echo "[5/7] NIP-04 self round-trip"
|
||||||
list_resp="$($NSIGNER_BIN list || true)"
|
if [[ -n "$pubkey" ]]; then
|
||||||
|
req_nip04_enc="{\"id\":\"5\",\"method\":\"nip04_encrypt\",\"params\":[\"${pubkey}\",\"hello_nip04_from_test_sh\"]}"
|
||||||
|
resp_nip04_enc="$(run_request "$req_nip04_enc")"
|
||||||
|
echo "request : $req_nip04_enc"
|
||||||
|
echo "response: $resp_nip04_enc"
|
||||||
|
cipher_nip04="$(printf '%s' "$resp_nip04_enc" | extract_result_string)"
|
||||||
|
if [[ -n "$cipher_nip04" ]]; then
|
||||||
|
req_nip04_dec="{\"id\":\"6\",\"method\":\"nip04_decrypt\",\"params\":[\"${pubkey}\",\"${cipher_nip04}\"]}"
|
||||||
|
resp_nip04_dec="$(run_request "$req_nip04_dec")"
|
||||||
|
echo "request : $req_nip04_dec"
|
||||||
|
echo "response: $resp_nip04_dec"
|
||||||
|
if printf '%s' "$resp_nip04_dec" | grep -q '"result":"hello_nip04_from_test_sh"'; then
|
||||||
|
echo "[ok] nip04 decrypt recovered plaintext"
|
||||||
|
else
|
||||||
|
echo "[warn] nip04 decrypt did not recover expected plaintext"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[warn] could not parse nip04 ciphertext"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[warn] skipping nip04 test (missing pubkey)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "[6/7] NIP-44 self round-trip"
|
||||||
|
if [[ -n "$pubkey" ]]; then
|
||||||
|
req_nip44_enc="{\"id\":\"7\",\"method\":\"nip44_encrypt\",\"params\":[\"${pubkey}\",\"hello_nip44_from_test_sh\"]}"
|
||||||
|
resp_nip44_enc="$(run_request "$req_nip44_enc")"
|
||||||
|
echo "request : $req_nip44_enc"
|
||||||
|
echo "response: $resp_nip44_enc"
|
||||||
|
cipher_nip44="$(printf '%s' "$resp_nip44_enc" | extract_result_string)"
|
||||||
|
if [[ -n "$cipher_nip44" ]]; then
|
||||||
|
req_nip44_dec="{\"id\":\"8\",\"method\":\"nip44_decrypt\",\"params\":[\"${pubkey}\",\"${cipher_nip44}\"]}"
|
||||||
|
resp_nip44_dec="$(run_request "$req_nip44_dec")"
|
||||||
|
echo "request : $req_nip44_dec"
|
||||||
|
echo "response: $resp_nip44_dec"
|
||||||
|
if printf '%s' "$resp_nip44_dec" | grep -q '"result":"hello_nip44_from_test_sh"'; then
|
||||||
|
echo "[ok] nip44 decrypt recovered plaintext"
|
||||||
|
else
|
||||||
|
echo "[warn] nip44 decrypt did not recover expected plaintext"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[warn] could not parse nip44 ciphertext"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[warn] skipping nip44 test (missing pubkey)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "[7/7] list currently running signers"
|
||||||
|
list_resp="$($NSIGNER_BIN list 2>&1 || true)"
|
||||||
echo "$list_resp"
|
echo "$list_resp"
|
||||||
|
if printf '%s' "$list_resp" | grep -q '^@nsigner'; then
|
||||||
|
echo "[ok] signer list contains nsigner entries"
|
||||||
|
else
|
||||||
|
echo "[warn] no @nsigner entries found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "[extra] mnemonic startup input tests"
|
||||||
|
if [[ -x ./build/test_mnemonic_input ]]; then
|
||||||
|
./build/test_mnemonic_input || true
|
||||||
|
else
|
||||||
|
echo "[warn] build/test_mnemonic_input not found (run: make test-mnemonic-input)"
|
||||||
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Smoke test complete."
|
echo "Smoke test complete."
|
||||||
227
tests/test_auth_envelope.c
Normal file
227
tests/test_auth_envelope.c
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
|
#include <cJSON.h>
|
||||||
|
#include <nostr_core/nip001.h>
|
||||||
|
#include <nostr_core/utils.h>
|
||||||
|
|
||||||
|
#include "../src/auth_envelope.h"
|
||||||
|
|
||||||
|
static int g_failures = 0;
|
||||||
|
|
||||||
|
static void check_condition(const char *name, int ok) {
|
||||||
|
if (ok) {
|
||||||
|
printf("[PASS] %s\n", name);
|
||||||
|
} else {
|
||||||
|
printf("[FAIL] %s\n", name);
|
||||||
|
g_failures++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int compute_params_hash_hex(cJSON *params, char out_hex[65]) {
|
||||||
|
char *compact;
|
||||||
|
unsigned char hash[32];
|
||||||
|
|
||||||
|
if (params == NULL || out_hex == NULL) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
compact = cJSON_PrintUnformatted(params);
|
||||||
|
if (compact == NULL) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nostr_sha256((const unsigned char *)compact, strlen(compact), hash) != 0) {
|
||||||
|
free(compact);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
nostr_bytes_to_hex(hash, 32, out_hex);
|
||||||
|
out_hex[64] = '\0';
|
||||||
|
free(compact);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static cJSON *tag_pair(const char *k, const char *v) {
|
||||||
|
cJSON *arr = cJSON_CreateArray();
|
||||||
|
cJSON_AddItemToArray(arr, cJSON_CreateString(k));
|
||||||
|
cJSON_AddItemToArray(arr, cJSON_CreateString(v));
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
static char *make_request_json(const unsigned char privkey[32],
|
||||||
|
const char *req_id,
|
||||||
|
const char *method,
|
||||||
|
int created_at) {
|
||||||
|
cJSON *params = cJSON_CreateArray();
|
||||||
|
cJSON *options = cJSON_CreateObject();
|
||||||
|
cJSON *tags = cJSON_CreateArray();
|
||||||
|
cJSON *auth;
|
||||||
|
cJSON *root;
|
||||||
|
char body_hash[65];
|
||||||
|
char *out;
|
||||||
|
|
||||||
|
cJSON_AddItemToArray(params, cJSON_CreateString("{\"kind\":1,\"content\":\"x\",\"tags\":[],\"created_at\":1700000000}"));
|
||||||
|
cJSON_AddStringToObject(options, "role", "main");
|
||||||
|
cJSON_AddItemToArray(params, options);
|
||||||
|
|
||||||
|
if (compute_params_hash_hex(params, body_hash) != 0) {
|
||||||
|
cJSON_Delete(params);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
cJSON_AddItemToArray(tags, tag_pair("nsigner_rpc", req_id));
|
||||||
|
cJSON_AddItemToArray(tags, tag_pair("nsigner_method", method));
|
||||||
|
cJSON_AddItemToArray(tags, tag_pair("nsigner_body_hash", body_hash));
|
||||||
|
|
||||||
|
auth = nostr_create_and_sign_event(AUTH_EVENT_KIND, "test-client", tags, privkey, (time_t)created_at);
|
||||||
|
if (auth == NULL) {
|
||||||
|
cJSON_Delete(params);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
root = cJSON_CreateObject();
|
||||||
|
cJSON_AddStringToObject(root, "id", req_id);
|
||||||
|
cJSON_AddStringToObject(root, "method", method);
|
||||||
|
cJSON_AddItemToObject(root, "params", params);
|
||||||
|
cJSON_AddItemToObject(root, "auth", auth);
|
||||||
|
|
||||||
|
out = cJSON_PrintUnformatted(root);
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
auth_nonce_cache_t cache;
|
||||||
|
unsigned char privkey[32] = {
|
||||||
|
0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
|
||||||
|
};
|
||||||
|
char pubkey_hex[65];
|
||||||
|
char label[64];
|
||||||
|
const char *err_msg = NULL;
|
||||||
|
int err_code = 0;
|
||||||
|
char *req;
|
||||||
|
|
||||||
|
check_condition("nostr_crypto_init", nostr_crypto_init() == 0);
|
||||||
|
auth_nonce_cache_init(&cache);
|
||||||
|
|
||||||
|
req = make_request_json(privkey, "req-1", "sign_event", (int)time(NULL));
|
||||||
|
check_condition("build valid request", req != NULL);
|
||||||
|
if (req != NULL) {
|
||||||
|
check_condition("valid auth envelope accepted",
|
||||||
|
auth_envelope_verify_request(req,
|
||||||
|
&cache,
|
||||||
|
AUTH_DEFAULT_SKEW_SECONDS,
|
||||||
|
pubkey_hex,
|
||||||
|
sizeof(pubkey_hex),
|
||||||
|
label,
|
||||||
|
sizeof(label),
|
||||||
|
&err_code,
|
||||||
|
&err_msg) == 0);
|
||||||
|
|
||||||
|
check_condition("pubkey output length 64", strlen(pubkey_hex) == 64);
|
||||||
|
free(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
check_condition("missing auth rejected with required code",
|
||||||
|
auth_envelope_verify_request("{\"id\":\"x\",\"method\":\"get_public_key\",\"params\":[]}",
|
||||||
|
&cache,
|
||||||
|
AUTH_DEFAULT_SKEW_SECONDS,
|
||||||
|
pubkey_hex,
|
||||||
|
sizeof(pubkey_hex),
|
||||||
|
label,
|
||||||
|
sizeof(label),
|
||||||
|
&err_code,
|
||||||
|
&err_msg) != 0 &&
|
||||||
|
err_code == AUTH_ERR_ENVELOPE_REQUIRED);
|
||||||
|
|
||||||
|
req = make_request_json(privkey, "req-2", "get_public_key", (int)time(NULL));
|
||||||
|
check_condition("build second request", req != NULL);
|
||||||
|
if (req != NULL) {
|
||||||
|
cJSON *root = cJSON_Parse(req);
|
||||||
|
cJSON *method_item;
|
||||||
|
char *tampered;
|
||||||
|
|
||||||
|
check_condition("parse second request", root != NULL);
|
||||||
|
method_item = (root != NULL) ? cJSON_GetObjectItemCaseSensitive(root, "method") : NULL;
|
||||||
|
if (method_item != NULL) {
|
||||||
|
cJSON_SetValuestring(method_item, "sign_event");
|
||||||
|
}
|
||||||
|
tampered = (root != NULL) ? cJSON_PrintUnformatted(root) : NULL;
|
||||||
|
cJSON_Delete(root);
|
||||||
|
|
||||||
|
check_condition("tamper method mismatch rejected",
|
||||||
|
tampered != NULL &&
|
||||||
|
auth_envelope_verify_request(tampered,
|
||||||
|
&cache,
|
||||||
|
AUTH_DEFAULT_SKEW_SECONDS,
|
||||||
|
pubkey_hex,
|
||||||
|
sizeof(pubkey_hex),
|
||||||
|
label,
|
||||||
|
sizeof(label),
|
||||||
|
&err_code,
|
||||||
|
&err_msg) != 0 &&
|
||||||
|
err_code == AUTH_ERR_ENVELOPE_MISMATCH);
|
||||||
|
|
||||||
|
free(tampered);
|
||||||
|
free(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
req = make_request_json(privkey, "req-3", "sign_event", (int)time(NULL));
|
||||||
|
check_condition("build replay request", req != NULL);
|
||||||
|
if (req != NULL) {
|
||||||
|
int first_ok = auth_envelope_verify_request(req,
|
||||||
|
&cache,
|
||||||
|
AUTH_DEFAULT_SKEW_SECONDS,
|
||||||
|
pubkey_hex,
|
||||||
|
sizeof(pubkey_hex),
|
||||||
|
label,
|
||||||
|
sizeof(label),
|
||||||
|
&err_code,
|
||||||
|
&err_msg);
|
||||||
|
int second_ok = auth_envelope_verify_request(req,
|
||||||
|
&cache,
|
||||||
|
AUTH_DEFAULT_SKEW_SECONDS,
|
||||||
|
pubkey_hex,
|
||||||
|
sizeof(pubkey_hex),
|
||||||
|
label,
|
||||||
|
sizeof(label),
|
||||||
|
&err_code,
|
||||||
|
&err_msg);
|
||||||
|
check_condition("first replay request accepted", first_ok == 0);
|
||||||
|
check_condition("second replay request rejected", second_ok != 0 && err_code == AUTH_ERR_REPLAY_DETECTED);
|
||||||
|
free(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
req = make_request_json(privkey, "req-4", "sign_event", (int)time(NULL) - 1000);
|
||||||
|
check_condition("build stale request", req != NULL);
|
||||||
|
if (req != NULL) {
|
||||||
|
check_condition("stale request rejected",
|
||||||
|
auth_envelope_verify_request(req,
|
||||||
|
&cache,
|
||||||
|
AUTH_DEFAULT_SKEW_SECONDS,
|
||||||
|
pubkey_hex,
|
||||||
|
sizeof(pubkey_hex),
|
||||||
|
label,
|
||||||
|
sizeof(label),
|
||||||
|
&err_code,
|
||||||
|
&err_msg) != 0 &&
|
||||||
|
err_code == AUTH_ERR_ENVELOPE_STALE);
|
||||||
|
free(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
nostr_crypto_cleanup();
|
||||||
|
|
||||||
|
if (g_failures == 0) {
|
||||||
|
printf("ALL TESTS PASSED\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("TESTS FAILED: %d\n", g_failures);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
639
tests/test_dispatcher.c
Normal file
639
tests/test_dispatcher.c
Normal file
@@ -0,0 +1,639 @@
|
|||||||
|
/* NSIGNER_HEADERLESS_DECLS_BEGIN */
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <cJSON.h>
|
||||||
|
|
||||||
|
/* from secure_mem.h */
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Secure memory buffer — mlock'd, zeroized on free.
|
||||||
|
* Used for mnemonic phrases, private keys, and any sensitive material.
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
void *data; /* pointer to locked allocation */
|
||||||
|
size_t size; /* usable size in bytes */
|
||||||
|
int locked; /* 1 if mlock succeeded */
|
||||||
|
} secure_buf_t;
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
|
||||||
|
/* Zeroize and free a secure buffer. Always succeeds (idempotent). */
|
||||||
|
void secure_buf_free(secure_buf_t *buf);
|
||||||
|
|
||||||
|
/* Zeroize `len` bytes at `ptr` in a way the compiler cannot optimize away. */
|
||||||
|
void secure_memzero(void *ptr, size_t len);
|
||||||
|
|
||||||
|
|
||||||
|
/* from mnemonic.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Maximum mnemonic length: 24 words * 10 chars avg + spaces + null = 256 is safe */
|
||||||
|
#define MNEMONIC_MAX_LEN 256
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mnemonic state — holds the loaded mnemonic in secure memory.
|
||||||
|
* Only one mnemonic is active at a time per process.
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
secure_buf_t buf; /* secure storage for the mnemonic string */
|
||||||
|
int loaded; /* 1 if a mnemonic is currently loaded */
|
||||||
|
int word_count; /* 12, 15, 18, 21, or 24 */
|
||||||
|
} mnemonic_state_t;
|
||||||
|
|
||||||
|
/* Initialize mnemonic state (must be called before use). */
|
||||||
|
void mnemonic_init(mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Load a mnemonic string into secure memory. Validates word count (12/15/18/21/24).
|
||||||
|
* Returns 0 on success, -1 on invalid input, -2 on memory error. */
|
||||||
|
int mnemonic_load(mnemonic_state_t *state, const char *phrase);
|
||||||
|
|
||||||
|
/* Zeroize and unload the mnemonic. Idempotent. */
|
||||||
|
void mnemonic_unload(mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Check if a mnemonic is currently loaded. */
|
||||||
|
int mnemonic_is_loaded(const mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Get the mnemonic string (only valid while loaded). Returns NULL if not loaded. */
|
||||||
|
const char *mnemonic_get_phrase(const mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Generate a new BIP-39 mnemonic phrase (12/15/18/21/24 words) into out.
|
||||||
|
* Returns 0 on success, -1 on invalid arguments or generation failure. */
|
||||||
|
int mnemonic_generate(int word_count, char *out, size_t out_len);
|
||||||
|
|
||||||
|
|
||||||
|
/* from role_table.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Maximum limits */
|
||||||
|
#define ROLE_NAME_MAX 64
|
||||||
|
#define ROLE_PATH_MAX 128
|
||||||
|
#define ROLE_PURPOSE_MAX 32
|
||||||
|
#define ROLE_CURVE_MAX 16
|
||||||
|
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||||
|
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||||
|
|
||||||
|
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||||
|
typedef enum {
|
||||||
|
PURPOSE_NOSTR = 0,
|
||||||
|
PURPOSE_BITCOIN,
|
||||||
|
PURPOSE_SSH,
|
||||||
|
PURPOSE_AGE,
|
||||||
|
PURPOSE_FIPS,
|
||||||
|
PURPOSE_UNKNOWN
|
||||||
|
} role_purpose_t;
|
||||||
|
|
||||||
|
/* Curve enum */
|
||||||
|
typedef enum {
|
||||||
|
CURVE_SECP256K1 = 0,
|
||||||
|
CURVE_ED25519,
|
||||||
|
CURVE_X25519,
|
||||||
|
CURVE_UNKNOWN
|
||||||
|
} role_curve_t;
|
||||||
|
|
||||||
|
/* Selector type — how this role's key is addressed */
|
||||||
|
typedef enum {
|
||||||
|
SELECTOR_NOSTR_INDEX, /* uses nostr_index shorthand */
|
||||||
|
SELECTOR_ROLE_PATH /* uses explicit full path */
|
||||||
|
} role_selector_type_t;
|
||||||
|
|
||||||
|
/* A single role entry */
|
||||||
|
typedef struct {
|
||||||
|
char name[ROLE_NAME_MAX];
|
||||||
|
char purpose_str[ROLE_PURPOSE_MAX];
|
||||||
|
char curve_str[ROLE_CURVE_MAX];
|
||||||
|
role_purpose_t purpose;
|
||||||
|
role_curve_t curve;
|
||||||
|
role_selector_type_t selector_type;
|
||||||
|
int nostr_index; /* valid if selector_type == SELECTOR_NOSTR_INDEX */
|
||||||
|
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
|
||||||
|
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
|
||||||
|
int derived; /* 1 if pubkey_hex has been populated */
|
||||||
|
} role_entry_t;
|
||||||
|
|
||||||
|
/* The role table */
|
||||||
|
typedef struct {
|
||||||
|
role_entry_t entries[ROLE_TABLE_MAX_ENTRIES];
|
||||||
|
int count;
|
||||||
|
} role_table_t;
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
|
||||||
|
/* Find a role by nostr_index. Returns pointer or NULL. */
|
||||||
|
role_entry_t *role_table_find_by_nostr_index(role_table_t *table, int index);
|
||||||
|
|
||||||
|
/* Find a role by role_path. Returns pointer or NULL. */
|
||||||
|
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);
|
||||||
|
|
||||||
|
/* Parse purpose string to enum */
|
||||||
|
role_purpose_t role_purpose_from_str(const char *s);
|
||||||
|
|
||||||
|
/* Parse curve string to enum */
|
||||||
|
role_curve_t role_curve_from_str(const char *s);
|
||||||
|
|
||||||
|
/* Purpose enum to string */
|
||||||
|
const char *role_purpose_to_str(role_purpose_t p);
|
||||||
|
|
||||||
|
/* Curve enum to string */
|
||||||
|
const char *role_curve_to_str(role_curve_t c);
|
||||||
|
|
||||||
|
/* Register a nostr-index role if missing. Returns 0 on success, -1 on error. */
|
||||||
|
int role_table_register_nostr_index(role_table_t *table, int nostr_index);
|
||||||
|
|
||||||
|
|
||||||
|
/* from selector.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Error codes for selector resolution */
|
||||||
|
#define SELECTOR_OK 0
|
||||||
|
#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 */
|
||||||
|
|
||||||
|
/* Parsed selector from a request's options object */
|
||||||
|
typedef struct {
|
||||||
|
int has_role; /* 1 if "role" field was present */
|
||||||
|
char role_name[ROLE_NAME_MAX];
|
||||||
|
|
||||||
|
int has_nostr_index; /* 1 if "nostr_index" field was present */
|
||||||
|
int nostr_index;
|
||||||
|
|
||||||
|
int has_role_path; /* 1 if "role_path" field was present */
|
||||||
|
char role_path[ROLE_PATH_MAX];
|
||||||
|
} selector_request_t;
|
||||||
|
|
||||||
|
/* Initialize a selector request (all fields zeroed/unset) */
|
||||||
|
void selector_request_init(selector_request_t *req);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Resolve a selector request against the role table.
|
||||||
|
* On success (returns SELECTOR_OK), *out points to the matched role_entry_t.
|
||||||
|
* On failure, returns one of the SELECTOR_ERR_* codes and *out is NULL.
|
||||||
|
*/
|
||||||
|
int selector_resolve(const selector_request_t *req, role_table_t *table, role_entry_t **out);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return a human-readable error string for a selector error code.
|
||||||
|
*/
|
||||||
|
const char *selector_strerror(int err);
|
||||||
|
|
||||||
|
|
||||||
|
/* from enforcement.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Error codes */
|
||||||
|
#define ENFORCE_OK 0
|
||||||
|
#define ENFORCE_ERR_PURPOSE -1 /* purpose mismatch */
|
||||||
|
#define ENFORCE_ERR_CURVE -2 /* curve mismatch */
|
||||||
|
#define ENFORCE_ERR_UNKNOWN_VERB -3 /* verb not recognized */
|
||||||
|
|
||||||
|
/* Known verbs */
|
||||||
|
#define VERB_SIGN_EVENT "sign_event"
|
||||||
|
#define VERB_GET_PUBLIC_KEY "get_public_key"
|
||||||
|
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
|
||||||
|
#define VERB_NIP44_DECRYPT "nip44_decrypt"
|
||||||
|
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
|
||||||
|
#define VERB_NIP04_DECRYPT "nip04_decrypt"
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check whether `verb` is allowed to execute against `role`.
|
||||||
|
* Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code.
|
||||||
|
*
|
||||||
|
* The enforcement rules are:
|
||||||
|
* - All nostr verbs (sign_event, get_public_key, nip44_*, nip04_*) require:
|
||||||
|
* purpose == PURPOSE_NOSTR and curve == CURVE_SECP256K1
|
||||||
|
* - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed).
|
||||||
|
*/
|
||||||
|
int enforce_verb_role(const char *verb, const role_entry_t *role);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return a human-readable error string for an enforcement error code.
|
||||||
|
*/
|
||||||
|
const char *enforce_strerror(int err);
|
||||||
|
|
||||||
|
|
||||||
|
/* from policy.h */
|
||||||
|
|
||||||
|
|
||||||
|
#define POLICY_MAX_ENTRIES 32
|
||||||
|
#define POLICY_MAX_VERBS 16
|
||||||
|
#define POLICY_MAX_ROLES 16
|
||||||
|
#define POLICY_MAX_PURPOSES 8
|
||||||
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
|
/* Prompt behavior */
|
||||||
|
typedef enum {
|
||||||
|
PROMPT_NEVER = 0,
|
||||||
|
PROMPT_FIRST_PER_BOOT,
|
||||||
|
PROMPT_EVERY_REQUEST,
|
||||||
|
PROMPT_DENY
|
||||||
|
} prompt_mode_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||||
|
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||||
|
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||||
|
} policy_source_t;
|
||||||
|
|
||||||
|
/* A single policy entry */
|
||||||
|
typedef struct {
|
||||||
|
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||||
|
char verbs[POLICY_MAX_VERBS][POLICY_VERB_MAX_LEN];
|
||||||
|
int verb_count;
|
||||||
|
char roles[POLICY_MAX_ROLES][ROLE_NAME_MAX];
|
||||||
|
int role_count;
|
||||||
|
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||||
|
int purpose_count;
|
||||||
|
prompt_mode_t prompt;
|
||||||
|
policy_source_t source;
|
||||||
|
} policy_entry_t;
|
||||||
|
|
||||||
|
/* Policy table */
|
||||||
|
typedef struct {
|
||||||
|
policy_entry_t entries[POLICY_MAX_ENTRIES];
|
||||||
|
int count;
|
||||||
|
} policy_table_t;
|
||||||
|
|
||||||
|
/* Policy check result */
|
||||||
|
#define POLICY_ALLOW 0
|
||||||
|
#define POLICY_DENY -1
|
||||||
|
#define POLICY_PROMPT -2 /* would need user confirmation */
|
||||||
|
#define POLICY_NO_MATCH -3 /* no policy entry matched (fail-closed = deny) */
|
||||||
|
|
||||||
|
/* Initialize policy table */
|
||||||
|
void policy_table_init(policy_table_t *table);
|
||||||
|
|
||||||
|
/* Initialize default policy: allow same-uid, deny others */
|
||||||
|
void policy_init_default(policy_table_t *table, uid_t owner_uid);
|
||||||
|
|
||||||
|
/* Add a policy entry. Returns 0 on success, -1 if full. */
|
||||||
|
int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check whether caller_id is allowed to invoke `verb` on `role_name` with given `purpose`.
|
||||||
|
* 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);
|
||||||
|
|
||||||
|
/* Parse prompt mode from string */
|
||||||
|
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||||
|
|
||||||
|
/* Prompt mode to string */
|
||||||
|
const char *prompt_mode_to_str(prompt_mode_t m);
|
||||||
|
|
||||||
|
|
||||||
|
/* from crypto.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Per-role derived key material (stored in secure memory) */
|
||||||
|
typedef struct {
|
||||||
|
secure_buf_t private_key; /* 32 bytes, mlock'd */
|
||||||
|
unsigned char public_key[32];
|
||||||
|
char pubkey_hex[65]; /* 64 hex chars + null */
|
||||||
|
char npub[128]; /* bech32 npub */
|
||||||
|
int valid;
|
||||||
|
} derived_key_t;
|
||||||
|
|
||||||
|
/* Key store — holds derived keys for all roles */
|
||||||
|
typedef struct {
|
||||||
|
derived_key_t keys[ROLE_TABLE_MAX_ENTRIES];
|
||||||
|
int count;
|
||||||
|
} key_store_t;
|
||||||
|
|
||||||
|
/* Derive keys for all roles in the table using the loaded mnemonic.
|
||||||
|
* Populates key_store and sets role->pubkey_hex and role->derived for each role.
|
||||||
|
* Only derives for roles with purpose=nostr and curve=secp256k1 (for now).
|
||||||
|
* Returns number of keys derived, or -1 on error. */
|
||||||
|
int crypto_derive_all(key_store_t *store, role_table_t *table, const mnemonic_state_t *mnemonic);
|
||||||
|
|
||||||
|
/* Derive key for exactly one role index. Returns 0 on success, -1 on error. */
|
||||||
|
int crypto_derive_one(key_store_t *store, role_table_t *table, const mnemonic_state_t *mnemonic, int role_index);
|
||||||
|
|
||||||
|
/* Get the derived private key for a role (by table index). Returns NULL if not derived. */
|
||||||
|
const unsigned char *crypto_get_private_key(const key_store_t *store, int role_index);
|
||||||
|
|
||||||
|
/* Get the derived public key hex for a role. Returns NULL if not derived. */
|
||||||
|
const char *crypto_get_pubkey_hex(const key_store_t *store, int role_index);
|
||||||
|
|
||||||
|
/* Sign a Nostr event. event_json is the unsigned event JSON string.
|
||||||
|
* Returns a newly-allocated string containing the signed event JSON, or NULL on error.
|
||||||
|
* Caller must free() the returned string. */
|
||||||
|
char *crypto_sign_event(const key_store_t *store, int role_index, const char *event_json);
|
||||||
|
|
||||||
|
/* Zeroize all derived keys in the store. */
|
||||||
|
void crypto_wipe(key_store_t *store);
|
||||||
|
|
||||||
|
|
||||||
|
/* from dispatcher.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Dispatcher context — holds references to shared state */
|
||||||
|
typedef struct {
|
||||||
|
role_table_t *role_table;
|
||||||
|
mnemonic_state_t *mnemonic;
|
||||||
|
key_store_t *key_store;
|
||||||
|
} dispatcher_ctx_t;
|
||||||
|
|
||||||
|
/* Initialize dispatcher context */
|
||||||
|
void dispatcher_init(dispatcher_ctx_t *ctx, role_table_t *table, mnemonic_state_t *mnemonic, key_store_t *key_store);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Process a JSON-RPC request string and produce a JSON-RPC response string.
|
||||||
|
*
|
||||||
|
* The caller owns the returned string and must free() it.
|
||||||
|
* Returns NULL only on catastrophic allocation failure.
|
||||||
|
*
|
||||||
|
* Response format on success:
|
||||||
|
* { "id": "...", "result": "..." }
|
||||||
|
*
|
||||||
|
* Response format on error:
|
||||||
|
* { "id": "...", "error": { "code": <int>, "message": "..." } }
|
||||||
|
*
|
||||||
|
* Error codes:
|
||||||
|
* -32700 Parse error (invalid JSON)
|
||||||
|
* -32600 Invalid request (missing id/method/params)
|
||||||
|
* -32601 Method not found (unknown verb after enforcement)
|
||||||
|
* -32602 Invalid params
|
||||||
|
* 1001 ambiguous_role_selector
|
||||||
|
* 1002 role_not_found
|
||||||
|
* 1003 no_default_role
|
||||||
|
* 1004 purpose_mismatch
|
||||||
|
* 1005 curve_mismatch
|
||||||
|
* 1006 mnemonic_not_loaded
|
||||||
|
*/
|
||||||
|
char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request);
|
||||||
|
|
||||||
|
|
||||||
|
/* from server.h */
|
||||||
|
|
||||||
|
|
||||||
|
#define SERVER_SOCKET_NAME_MAX 108
|
||||||
|
#define SERVER_MAX_MSG_SIZE 65536
|
||||||
|
|
||||||
|
/* Caller identity */
|
||||||
|
typedef struct {
|
||||||
|
uid_t uid;
|
||||||
|
gid_t gid;
|
||||||
|
pid_t pid;
|
||||||
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
|
} caller_identity_t;
|
||||||
|
|
||||||
|
/* Server context */
|
||||||
|
typedef struct {
|
||||||
|
char socket_name[SERVER_SOCKET_NAME_MAX]; /* abstract namespace name (without \0 prefix) */
|
||||||
|
char last_error[256];
|
||||||
|
int listen_fd;
|
||||||
|
int running;
|
||||||
|
dispatcher_ctx_t *dispatcher;
|
||||||
|
policy_table_t *policy;
|
||||||
|
int socket_name_explicit;
|
||||||
|
} server_ctx_t;
|
||||||
|
|
||||||
|
/* Initialize server context. socket_name is the abstract namespace name (e.g. "nsigner").
|
||||||
|
* socket_name_explicit should be non-zero when provided via --socket-name override. */
|
||||||
|
void server_init(server_ctx_t *ctx, const char *socket_name, int socket_name_explicit,
|
||||||
|
dispatcher_ctx_t *dispatcher, policy_table_t *policy);
|
||||||
|
|
||||||
|
/* Start listening. Returns 0 on success, -1 on error. */
|
||||||
|
int server_start(server_ctx_t *ctx);
|
||||||
|
|
||||||
|
/* Get human-readable description of last server error. */
|
||||||
|
const char *server_last_error(const server_ctx_t *ctx);
|
||||||
|
|
||||||
|
/* Handle one pending connection (non-blocking). Returns 1 if handled, 0 if nothing pending, -1 on error.
|
||||||
|
* activity_cb is called with a description string for the TUI activity log. */
|
||||||
|
typedef void (*server_activity_cb)(const char *message, void *user_data);
|
||||||
|
int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data);
|
||||||
|
|
||||||
|
/* Stop server and close socket */
|
||||||
|
void server_stop(server_ctx_t *ctx);
|
||||||
|
|
||||||
|
/* Extract caller identity from connected fd */
|
||||||
|
int server_get_caller(int fd, caller_identity_t *out);
|
||||||
|
|
||||||
|
|
||||||
|
/* from socket_name.h */
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Generate random socket name in format: nsigner_<word1>_<word2>
|
||||||
|
* Returns 0 on success, -1 on error.
|
||||||
|
*/
|
||||||
|
int socket_name_random(char *out, size_t out_len);
|
||||||
|
|
||||||
|
|
||||||
|
/* from main.h */
|
||||||
|
/*
|
||||||
|
* nsigner main header - version information
|
||||||
|
*
|
||||||
|
* Version macros are auto-updated by increment_and_push.sh.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
/* Version information (auto-updated by build/version tooling) */
|
||||||
|
#define NSIGNER_VERSION_MAJOR 0
|
||||||
|
#define NSIGNER_VERSION_MINOR 0
|
||||||
|
#define NSIGNER_VERSION_PATCH 2
|
||||||
|
#define NSIGNER_VERSION "v0.0.2"
|
||||||
|
|
||||||
|
|
||||||
|
/* NSIGNER_HEADERLESS_DECLS_END */
|
||||||
|
|
||||||
|
#include <nostr_core/nostr_common.h>
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
static int g_passes = 0;
|
||||||
|
static int g_total = 0;
|
||||||
|
|
||||||
|
static void check_condition(const char *name, int condition) {
|
||||||
|
g_total++;
|
||||||
|
if (condition) {
|
||||||
|
printf("PASS: %s\n", name);
|
||||||
|
g_passes++;
|
||||||
|
} else {
|
||||||
|
printf("FAIL: %s\n", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int response_has(const char *response, const char *needle) {
|
||||||
|
return (response != NULL && needle != NULL && strstr(response, needle) != NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
static role_entry_t make_nostr_entry(const char *name, int idx) {
|
||||||
|
role_entry_t e;
|
||||||
|
|
||||||
|
memset(&e, 0, sizeof(e));
|
||||||
|
strncpy(e.name, name, sizeof(e.name) - 1);
|
||||||
|
strncpy(e.purpose_str, "nostr", sizeof(e.purpose_str) - 1);
|
||||||
|
strncpy(e.curve_str, "secp256k1", sizeof(e.curve_str) - 1);
|
||||||
|
e.purpose = role_purpose_from_str(e.purpose_str);
|
||||||
|
e.curve = role_curve_from_str(e.curve_str);
|
||||||
|
e.selector_type = SELECTOR_NOSTR_INDEX;
|
||||||
|
e.nostr_index = idx;
|
||||||
|
e.derived = 0;
|
||||||
|
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
static role_entry_t make_path_entry(const char *name, const char *purpose, const char *curve, const char *path) {
|
||||||
|
role_entry_t e;
|
||||||
|
|
||||||
|
memset(&e, 0, sizeof(e));
|
||||||
|
strncpy(e.name, name, sizeof(e.name) - 1);
|
||||||
|
strncpy(e.purpose_str, purpose, sizeof(e.purpose_str) - 1);
|
||||||
|
strncpy(e.curve_str, curve, sizeof(e.curve_str) - 1);
|
||||||
|
strncpy(e.role_path, path, sizeof(e.role_path) - 1);
|
||||||
|
e.purpose = role_purpose_from_str(e.purpose_str);
|
||||||
|
e.curve = role_curve_from_str(e.curve_str);
|
||||||
|
e.selector_type = SELECTOR_ROLE_PATH;
|
||||||
|
e.nostr_index = -1;
|
||||||
|
e.derived = 0;
|
||||||
|
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
role_table_t table;
|
||||||
|
role_entry_t main_role;
|
||||||
|
role_entry_t ssh_role;
|
||||||
|
mnemonic_state_t mnemonic;
|
||||||
|
dispatcher_ctx_t dispatcher;
|
||||||
|
key_store_t key_store;
|
||||||
|
const char *valid_12 = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
|
||||||
|
char *resp;
|
||||||
|
int derived;
|
||||||
|
|
||||||
|
role_table_init(&table);
|
||||||
|
|
||||||
|
main_role = make_nostr_entry("main", 0);
|
||||||
|
ssh_role = make_path_entry("ssh_key", "ssh", "ed25519", "m/44'/822'/0'/0/0");
|
||||||
|
|
||||||
|
role_table_add(&table, &main_role);
|
||||||
|
role_table_add(&table, &ssh_role);
|
||||||
|
|
||||||
|
check_condition("role_table_register_nostr_index adds index 9",
|
||||||
|
role_table_register_nostr_index(&table, 9) == 0 && role_table_find_by_nostr_index(&table, 9) != NULL);
|
||||||
|
|
||||||
|
mnemonic_init(&mnemonic);
|
||||||
|
mnemonic_load(&mnemonic, valid_12);
|
||||||
|
|
||||||
|
memset(&key_store, 0, sizeof(key_store));
|
||||||
|
dispatcher_init(&dispatcher, &table, &mnemonic, &key_store);
|
||||||
|
|
||||||
|
derived = nostr_init();
|
||||||
|
check_condition("nostr_init succeeds", derived == 0);
|
||||||
|
|
||||||
|
derived = crypto_derive_all(&key_store, &table, &mnemonic);
|
||||||
|
check_condition("crypto_derive_all derives at least one key", derived >= 1);
|
||||||
|
|
||||||
|
{
|
||||||
|
role_entry_t *r9 = role_table_find_by_nostr_index(&table, 9);
|
||||||
|
int idx9 = (r9 != NULL) ? (int)(r9 - &table.entries[0]) : -1;
|
||||||
|
|
||||||
|
check_condition("new role idx9 exists before derive_one", idx9 >= 0);
|
||||||
|
if (idx9 >= 0) {
|
||||||
|
check_condition("crypto_derive_one derives newly registered role",
|
||||||
|
crypto_derive_one(&key_store, &table, &mnemonic, idx9) == 0 &&
|
||||||
|
table.entries[idx9].derived == 1 &&
|
||||||
|
crypto_get_pubkey_hex(&key_store, idx9) != NULL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 1. Valid get_public_key no selector -> default main, real pubkey */
|
||||||
|
resp = dispatcher_handle_request(&dispatcher,
|
||||||
|
"{\"id\":\"1\",\"method\":\"get_public_key\",\"params\":[\"\"]}");
|
||||||
|
check_condition("get_public_key default role returns derived hex",
|
||||||
|
response_has(resp, "\"id\":\"1\"") && !response_has(resp, "not_yet_derived") && response_has(resp, "\"result\":\""));
|
||||||
|
free(resp);
|
||||||
|
|
||||||
|
/* 2. sign_event with role main */
|
||||||
|
resp = dispatcher_handle_request(&dispatcher,
|
||||||
|
"{\"id\":\"2\",\"method\":\"sign_event\",\"params\":[\"{\\\"kind\\\":1,\\\"content\\\":\\\"hello\\\",\\\"tags\\\":[]}\",{\"role\":\"main\"}]}");
|
||||||
|
check_condition("sign_event with role=main returns signed event",
|
||||||
|
response_has(resp, "\"id\":\"2\"") && response_has(resp, "pubkey") && response_has(resp, "sig") && response_has(resp, "created_at"));
|
||||||
|
free(resp);
|
||||||
|
|
||||||
|
/* 3. sign_event with nostr_index 0 */
|
||||||
|
resp = dispatcher_handle_request(&dispatcher,
|
||||||
|
"{\"id\":\"3\",\"method\":\"sign_event\",\"params\":[\"{\\\"kind\\\":1,\\\"content\\\":\\\"hello2\\\",\\\"tags\\\":[]}\",{\"nostr_index\":0}]}");
|
||||||
|
check_condition("sign_event with nostr_index=0 returns signed event",
|
||||||
|
response_has(resp, "\"id\":\"3\"") && response_has(resp, "pubkey") && response_has(resp, "sig") && response_has(resp, "created_at"));
|
||||||
|
free(resp);
|
||||||
|
|
||||||
|
/* 4. ambiguous selector role + nostr_index */
|
||||||
|
resp = dispatcher_handle_request(&dispatcher,
|
||||||
|
"{\"id\":\"4\",\"method\":\"sign_event\",\"params\":[\"{}\",{\"role\":\"main\",\"nostr_index\":0}]}");
|
||||||
|
check_condition("ambiguous selector returns 1001",
|
||||||
|
response_has(resp, "\"id\":\"4\"") && response_has(resp, "\"code\":1001"));
|
||||||
|
free(resp);
|
||||||
|
|
||||||
|
/* 5. unknown role */
|
||||||
|
resp = dispatcher_handle_request(&dispatcher,
|
||||||
|
"{\"id\":\"5\",\"method\":\"sign_event\",\"params\":[\"{}\",{\"role\":\"nonexistent\"}]}");
|
||||||
|
check_condition("unknown role returns 1002 with unknown_role",
|
||||||
|
response_has(resp, "\"id\":\"5\"") && response_has(resp, "\"code\":1002") && response_has(resp, "unknown_role"));
|
||||||
|
free(resp);
|
||||||
|
|
||||||
|
/* 6. purpose mismatch: sign_event against ssh role */
|
||||||
|
resp = dispatcher_handle_request(&dispatcher,
|
||||||
|
"{\"id\":\"6\",\"method\":\"sign_event\",\"params\":[\"{}\",{\"role\":\"ssh_key\"}]}");
|
||||||
|
check_condition("purpose mismatch returns 1004",
|
||||||
|
response_has(resp, "\"id\":\"6\"") && response_has(resp, "\"code\":1004"));
|
||||||
|
free(resp);
|
||||||
|
|
||||||
|
/* 7. invalid JSON */
|
||||||
|
resp = dispatcher_handle_request(&dispatcher,
|
||||||
|
"{\"id\":\"7\",\"method\":\"sign_event\",\"params\":[\"{}\"]");
|
||||||
|
check_condition("invalid JSON returns -32700",
|
||||||
|
response_has(resp, "\"code\":-32700"));
|
||||||
|
free(resp);
|
||||||
|
|
||||||
|
/* 8. missing method */
|
||||||
|
resp = dispatcher_handle_request(&dispatcher,
|
||||||
|
"{\"id\":\"8\",\"params\":[\"{}\"]}");
|
||||||
|
check_condition("missing method returns -32600",
|
||||||
|
response_has(resp, "\"id\":\"8\"") && response_has(resp, "\"code\":-32600"));
|
||||||
|
free(resp);
|
||||||
|
|
||||||
|
/* 9. mnemonic not loaded */
|
||||||
|
mnemonic_unload(&mnemonic);
|
||||||
|
resp = dispatcher_handle_request(&dispatcher,
|
||||||
|
"{\"id\":\"9\",\"method\":\"get_public_key\",\"params\":[\"\"]}");
|
||||||
|
check_condition("mnemonic not loaded returns 1006",
|
||||||
|
response_has(resp, "\"id\":\"9\"") && response_has(resp, "\"code\":1006"));
|
||||||
|
free(resp);
|
||||||
|
|
||||||
|
mnemonic_load(&mnemonic, valid_12);
|
||||||
|
|
||||||
|
/* 10. unknown verb via enforcement */
|
||||||
|
resp = dispatcher_handle_request(&dispatcher,
|
||||||
|
"{\"id\":\"10\",\"method\":\"foo_bar\",\"params\":[\"\"]}");
|
||||||
|
check_condition("unknown verb returns -32601",
|
||||||
|
response_has(resp, "\"id\":\"10\"") && response_has(resp, "\"code\":-32601"));
|
||||||
|
free(resp);
|
||||||
|
|
||||||
|
crypto_wipe(&key_store);
|
||||||
|
nostr_cleanup();
|
||||||
|
|
||||||
|
printf("%d/%d tests passed\n", g_passes, g_total);
|
||||||
|
|
||||||
|
return (g_passes == g_total) ? 0 : 1;
|
||||||
|
}
|
||||||
540
tests/test_enforcement.c
Normal file
540
tests/test_enforcement.c
Normal file
@@ -0,0 +1,540 @@
|
|||||||
|
/* NSIGNER_HEADERLESS_DECLS_BEGIN */
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <cJSON.h>
|
||||||
|
|
||||||
|
/* from secure_mem.h */
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Secure memory buffer — mlock'd, zeroized on free.
|
||||||
|
* Used for mnemonic phrases, private keys, and any sensitive material.
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
void *data; /* pointer to locked allocation */
|
||||||
|
size_t size; /* usable size in bytes */
|
||||||
|
int locked; /* 1 if mlock succeeded */
|
||||||
|
} secure_buf_t;
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
|
||||||
|
/* Zeroize and free a secure buffer. Always succeeds (idempotent). */
|
||||||
|
void secure_buf_free(secure_buf_t *buf);
|
||||||
|
|
||||||
|
/* Zeroize `len` bytes at `ptr` in a way the compiler cannot optimize away. */
|
||||||
|
void secure_memzero(void *ptr, size_t len);
|
||||||
|
|
||||||
|
|
||||||
|
/* from mnemonic.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Maximum mnemonic length: 24 words * 10 chars avg + spaces + null = 256 is safe */
|
||||||
|
#define MNEMONIC_MAX_LEN 256
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mnemonic state — holds the loaded mnemonic in secure memory.
|
||||||
|
* Only one mnemonic is active at a time per process.
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
secure_buf_t buf; /* secure storage for the mnemonic string */
|
||||||
|
int loaded; /* 1 if a mnemonic is currently loaded */
|
||||||
|
int word_count; /* 12, 15, 18, 21, or 24 */
|
||||||
|
} mnemonic_state_t;
|
||||||
|
|
||||||
|
/* Initialize mnemonic state (must be called before use). */
|
||||||
|
void mnemonic_init(mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Load a mnemonic string into secure memory. Validates word count (12/15/18/21/24).
|
||||||
|
* Returns 0 on success, -1 on invalid input, -2 on memory error. */
|
||||||
|
int mnemonic_load(mnemonic_state_t *state, const char *phrase);
|
||||||
|
|
||||||
|
/* Zeroize and unload the mnemonic. Idempotent. */
|
||||||
|
void mnemonic_unload(mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Check if a mnemonic is currently loaded. */
|
||||||
|
int mnemonic_is_loaded(const mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Get the mnemonic string (only valid while loaded). Returns NULL if not loaded. */
|
||||||
|
const char *mnemonic_get_phrase(const mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Generate a new BIP-39 mnemonic phrase (12/15/18/21/24 words) into out.
|
||||||
|
* Returns 0 on success, -1 on invalid arguments or generation failure. */
|
||||||
|
int mnemonic_generate(int word_count, char *out, size_t out_len);
|
||||||
|
|
||||||
|
|
||||||
|
/* from role_table.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Maximum limits */
|
||||||
|
#define ROLE_NAME_MAX 64
|
||||||
|
#define ROLE_PATH_MAX 128
|
||||||
|
#define ROLE_PURPOSE_MAX 32
|
||||||
|
#define ROLE_CURVE_MAX 16
|
||||||
|
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||||
|
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||||
|
|
||||||
|
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||||
|
typedef enum {
|
||||||
|
PURPOSE_NOSTR = 0,
|
||||||
|
PURPOSE_BITCOIN,
|
||||||
|
PURPOSE_SSH,
|
||||||
|
PURPOSE_AGE,
|
||||||
|
PURPOSE_FIPS,
|
||||||
|
PURPOSE_UNKNOWN
|
||||||
|
} role_purpose_t;
|
||||||
|
|
||||||
|
/* Curve enum */
|
||||||
|
typedef enum {
|
||||||
|
CURVE_SECP256K1 = 0,
|
||||||
|
CURVE_ED25519,
|
||||||
|
CURVE_X25519,
|
||||||
|
CURVE_UNKNOWN
|
||||||
|
} role_curve_t;
|
||||||
|
|
||||||
|
/* Selector type — how this role's key is addressed */
|
||||||
|
typedef enum {
|
||||||
|
SELECTOR_NOSTR_INDEX, /* uses nostr_index shorthand */
|
||||||
|
SELECTOR_ROLE_PATH /* uses explicit full path */
|
||||||
|
} role_selector_type_t;
|
||||||
|
|
||||||
|
/* A single role entry */
|
||||||
|
typedef struct {
|
||||||
|
char name[ROLE_NAME_MAX];
|
||||||
|
char purpose_str[ROLE_PURPOSE_MAX];
|
||||||
|
char curve_str[ROLE_CURVE_MAX];
|
||||||
|
role_purpose_t purpose;
|
||||||
|
role_curve_t curve;
|
||||||
|
role_selector_type_t selector_type;
|
||||||
|
int nostr_index; /* valid if selector_type == SELECTOR_NOSTR_INDEX */
|
||||||
|
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
|
||||||
|
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
|
||||||
|
int derived; /* 1 if pubkey_hex has been populated */
|
||||||
|
} role_entry_t;
|
||||||
|
|
||||||
|
/* The role table */
|
||||||
|
typedef struct {
|
||||||
|
role_entry_t entries[ROLE_TABLE_MAX_ENTRIES];
|
||||||
|
int count;
|
||||||
|
} role_table_t;
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
|
||||||
|
/* Find a role by nostr_index. Returns pointer or NULL. */
|
||||||
|
role_entry_t *role_table_find_by_nostr_index(role_table_t *table, int index);
|
||||||
|
|
||||||
|
/* Find a role by role_path. Returns pointer or NULL. */
|
||||||
|
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);
|
||||||
|
|
||||||
|
/* Parse purpose string to enum */
|
||||||
|
role_purpose_t role_purpose_from_str(const char *s);
|
||||||
|
|
||||||
|
/* Parse curve string to enum */
|
||||||
|
role_curve_t role_curve_from_str(const char *s);
|
||||||
|
|
||||||
|
/* Purpose enum to string */
|
||||||
|
const char *role_purpose_to_str(role_purpose_t p);
|
||||||
|
|
||||||
|
/* Curve enum to string */
|
||||||
|
const char *role_curve_to_str(role_curve_t c);
|
||||||
|
|
||||||
|
|
||||||
|
/* from selector.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Error codes for selector resolution */
|
||||||
|
#define SELECTOR_OK 0
|
||||||
|
#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 */
|
||||||
|
|
||||||
|
/* Parsed selector from a request's options object */
|
||||||
|
typedef struct {
|
||||||
|
int has_role; /* 1 if "role" field was present */
|
||||||
|
char role_name[ROLE_NAME_MAX];
|
||||||
|
|
||||||
|
int has_nostr_index; /* 1 if "nostr_index" field was present */
|
||||||
|
int nostr_index;
|
||||||
|
|
||||||
|
int has_role_path; /* 1 if "role_path" field was present */
|
||||||
|
char role_path[ROLE_PATH_MAX];
|
||||||
|
} selector_request_t;
|
||||||
|
|
||||||
|
/* Initialize a selector request (all fields zeroed/unset) */
|
||||||
|
void selector_request_init(selector_request_t *req);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Resolve a selector request against the role table.
|
||||||
|
* On success (returns SELECTOR_OK), *out points to the matched role_entry_t.
|
||||||
|
* On failure, returns one of the SELECTOR_ERR_* codes and *out is NULL.
|
||||||
|
*/
|
||||||
|
int selector_resolve(const selector_request_t *req, role_table_t *table, role_entry_t **out);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return a human-readable error string for a selector error code.
|
||||||
|
*/
|
||||||
|
const char *selector_strerror(int err);
|
||||||
|
|
||||||
|
|
||||||
|
/* from enforcement.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Error codes */
|
||||||
|
#define ENFORCE_OK 0
|
||||||
|
#define ENFORCE_ERR_PURPOSE -1 /* purpose mismatch */
|
||||||
|
#define ENFORCE_ERR_CURVE -2 /* curve mismatch */
|
||||||
|
#define ENFORCE_ERR_UNKNOWN_VERB -3 /* verb not recognized */
|
||||||
|
|
||||||
|
/* Known verbs */
|
||||||
|
#define VERB_SIGN_EVENT "sign_event"
|
||||||
|
#define VERB_GET_PUBLIC_KEY "get_public_key"
|
||||||
|
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
|
||||||
|
#define VERB_NIP44_DECRYPT "nip44_decrypt"
|
||||||
|
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
|
||||||
|
#define VERB_NIP04_DECRYPT "nip04_decrypt"
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check whether `verb` is allowed to execute against `role`.
|
||||||
|
* Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code.
|
||||||
|
*
|
||||||
|
* The enforcement rules are:
|
||||||
|
* - All nostr verbs (sign_event, get_public_key, nip44_*, nip04_*) require:
|
||||||
|
* purpose == PURPOSE_NOSTR and curve == CURVE_SECP256K1
|
||||||
|
* - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed).
|
||||||
|
*/
|
||||||
|
int enforce_verb_role(const char *verb, const role_entry_t *role);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return a human-readable error string for an enforcement error code.
|
||||||
|
*/
|
||||||
|
const char *enforce_strerror(int err);
|
||||||
|
|
||||||
|
|
||||||
|
/* from policy.h */
|
||||||
|
|
||||||
|
|
||||||
|
#define POLICY_MAX_ENTRIES 32
|
||||||
|
#define POLICY_MAX_VERBS 16
|
||||||
|
#define POLICY_MAX_ROLES 16
|
||||||
|
#define POLICY_MAX_PURPOSES 8
|
||||||
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
|
/* Prompt behavior */
|
||||||
|
typedef enum {
|
||||||
|
PROMPT_NEVER = 0,
|
||||||
|
PROMPT_FIRST_PER_BOOT,
|
||||||
|
PROMPT_EVERY_REQUEST,
|
||||||
|
PROMPT_DENY
|
||||||
|
} prompt_mode_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||||
|
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||||
|
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||||
|
} policy_source_t;
|
||||||
|
|
||||||
|
/* A single policy entry */
|
||||||
|
typedef struct {
|
||||||
|
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||||
|
char verbs[POLICY_MAX_VERBS][POLICY_VERB_MAX_LEN];
|
||||||
|
int verb_count;
|
||||||
|
char roles[POLICY_MAX_ROLES][ROLE_NAME_MAX];
|
||||||
|
int role_count;
|
||||||
|
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||||
|
int purpose_count;
|
||||||
|
prompt_mode_t prompt;
|
||||||
|
policy_source_t source;
|
||||||
|
} policy_entry_t;
|
||||||
|
|
||||||
|
/* Policy table */
|
||||||
|
typedef struct {
|
||||||
|
policy_entry_t entries[POLICY_MAX_ENTRIES];
|
||||||
|
int count;
|
||||||
|
} policy_table_t;
|
||||||
|
|
||||||
|
/* Policy check result */
|
||||||
|
#define POLICY_ALLOW 0
|
||||||
|
#define POLICY_DENY -1
|
||||||
|
#define POLICY_PROMPT -2 /* would need user confirmation */
|
||||||
|
#define POLICY_NO_MATCH -3 /* no policy entry matched (fail-closed = deny) */
|
||||||
|
|
||||||
|
/* Initialize policy table */
|
||||||
|
void policy_table_init(policy_table_t *table);
|
||||||
|
|
||||||
|
/* Initialize default policy: allow same-uid, deny others */
|
||||||
|
void policy_init_default(policy_table_t *table, uid_t owner_uid);
|
||||||
|
|
||||||
|
/* Add a policy entry. Returns 0 on success, -1 if full. */
|
||||||
|
int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check whether caller_id is allowed to invoke `verb` on `role_name` with given `purpose`.
|
||||||
|
* 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);
|
||||||
|
|
||||||
|
/* Parse prompt mode from string */
|
||||||
|
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||||
|
|
||||||
|
/* Prompt mode to string */
|
||||||
|
const char *prompt_mode_to_str(prompt_mode_t m);
|
||||||
|
|
||||||
|
|
||||||
|
/* from crypto.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Per-role derived key material (stored in secure memory) */
|
||||||
|
typedef struct {
|
||||||
|
secure_buf_t private_key; /* 32 bytes, mlock'd */
|
||||||
|
unsigned char public_key[32];
|
||||||
|
char pubkey_hex[65]; /* 64 hex chars + null */
|
||||||
|
char npub[128]; /* bech32 npub */
|
||||||
|
int valid;
|
||||||
|
} derived_key_t;
|
||||||
|
|
||||||
|
/* Key store — holds derived keys for all roles */
|
||||||
|
typedef struct {
|
||||||
|
derived_key_t keys[ROLE_TABLE_MAX_ENTRIES];
|
||||||
|
int count;
|
||||||
|
} key_store_t;
|
||||||
|
|
||||||
|
/* Derive keys for all roles in the table using the loaded mnemonic.
|
||||||
|
* Populates key_store and sets role->pubkey_hex and role->derived for each role.
|
||||||
|
* Only derives for roles with purpose=nostr and curve=secp256k1 (for now).
|
||||||
|
* Returns number of keys derived, or -1 on error. */
|
||||||
|
int crypto_derive_all(key_store_t *store, role_table_t *table, const mnemonic_state_t *mnemonic);
|
||||||
|
|
||||||
|
/* Get the derived private key for a role (by table index). Returns NULL if not derived. */
|
||||||
|
const unsigned char *crypto_get_private_key(const key_store_t *store, int role_index);
|
||||||
|
|
||||||
|
/* Get the derived public key hex for a role. Returns NULL if not derived. */
|
||||||
|
const char *crypto_get_pubkey_hex(const key_store_t *store, int role_index);
|
||||||
|
|
||||||
|
/* Sign a Nostr event. event_json is the unsigned event JSON string.
|
||||||
|
* Returns a newly-allocated string containing the signed event JSON, or NULL on error.
|
||||||
|
* Caller must free() the returned string. */
|
||||||
|
char *crypto_sign_event(const key_store_t *store, int role_index, const char *event_json);
|
||||||
|
|
||||||
|
/* Zeroize all derived keys in the store. */
|
||||||
|
void crypto_wipe(key_store_t *store);
|
||||||
|
|
||||||
|
|
||||||
|
/* from dispatcher.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Dispatcher context — holds references to shared state */
|
||||||
|
typedef struct {
|
||||||
|
role_table_t *role_table;
|
||||||
|
mnemonic_state_t *mnemonic;
|
||||||
|
key_store_t *key_store;
|
||||||
|
} dispatcher_ctx_t;
|
||||||
|
|
||||||
|
/* Initialize dispatcher context */
|
||||||
|
void dispatcher_init(dispatcher_ctx_t *ctx, role_table_t *table, mnemonic_state_t *mnemonic, key_store_t *key_store);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Process a JSON-RPC request string and produce a JSON-RPC response string.
|
||||||
|
*
|
||||||
|
* The caller owns the returned string and must free() it.
|
||||||
|
* Returns NULL only on catastrophic allocation failure.
|
||||||
|
*
|
||||||
|
* Response format on success:
|
||||||
|
* { "id": "...", "result": "..." }
|
||||||
|
*
|
||||||
|
* Response format on error:
|
||||||
|
* { "id": "...", "error": { "code": <int>, "message": "..." } }
|
||||||
|
*
|
||||||
|
* Error codes:
|
||||||
|
* -32700 Parse error (invalid JSON)
|
||||||
|
* -32600 Invalid request (missing id/method/params)
|
||||||
|
* -32601 Method not found (unknown verb after enforcement)
|
||||||
|
* -32602 Invalid params
|
||||||
|
* 1001 ambiguous_role_selector
|
||||||
|
* 1002 role_not_found
|
||||||
|
* 1003 no_default_role
|
||||||
|
* 1004 purpose_mismatch
|
||||||
|
* 1005 curve_mismatch
|
||||||
|
* 1006 mnemonic_not_loaded
|
||||||
|
*/
|
||||||
|
char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request);
|
||||||
|
|
||||||
|
|
||||||
|
/* from server.h */
|
||||||
|
|
||||||
|
|
||||||
|
#define SERVER_SOCKET_NAME_MAX 108
|
||||||
|
#define SERVER_MAX_MSG_SIZE 65536
|
||||||
|
|
||||||
|
/* Caller identity */
|
||||||
|
typedef struct {
|
||||||
|
uid_t uid;
|
||||||
|
gid_t gid;
|
||||||
|
pid_t pid;
|
||||||
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
|
} caller_identity_t;
|
||||||
|
|
||||||
|
/* Server context */
|
||||||
|
typedef struct {
|
||||||
|
char socket_name[SERVER_SOCKET_NAME_MAX]; /* abstract namespace name (without \0 prefix) */
|
||||||
|
char last_error[256];
|
||||||
|
int listen_fd;
|
||||||
|
int running;
|
||||||
|
dispatcher_ctx_t *dispatcher;
|
||||||
|
policy_table_t *policy;
|
||||||
|
int socket_name_explicit;
|
||||||
|
} server_ctx_t;
|
||||||
|
|
||||||
|
/* Initialize server context. socket_name is the abstract namespace name (e.g. "nsigner").
|
||||||
|
* socket_name_explicit should be non-zero when provided via --socket-name override. */
|
||||||
|
void server_init(server_ctx_t *ctx, const char *socket_name, int socket_name_explicit,
|
||||||
|
dispatcher_ctx_t *dispatcher, policy_table_t *policy);
|
||||||
|
|
||||||
|
/* Start listening. Returns 0 on success, -1 on error. */
|
||||||
|
int server_start(server_ctx_t *ctx);
|
||||||
|
|
||||||
|
/* Get human-readable description of last server error. */
|
||||||
|
const char *server_last_error(const server_ctx_t *ctx);
|
||||||
|
|
||||||
|
/* Handle one pending connection (non-blocking). Returns 1 if handled, 0 if nothing pending, -1 on error.
|
||||||
|
* activity_cb is called with a description string for the TUI activity log. */
|
||||||
|
typedef void (*server_activity_cb)(const char *message, void *user_data);
|
||||||
|
int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data);
|
||||||
|
|
||||||
|
/* Stop server and close socket */
|
||||||
|
void server_stop(server_ctx_t *ctx);
|
||||||
|
|
||||||
|
/* Extract caller identity from connected fd */
|
||||||
|
int server_get_caller(int fd, caller_identity_t *out);
|
||||||
|
|
||||||
|
|
||||||
|
/* from socket_name.h */
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Generate random socket name in format: nsigner_<word1>_<word2>
|
||||||
|
* Returns 0 on success, -1 on error.
|
||||||
|
*/
|
||||||
|
int socket_name_random(char *out, size_t out_len);
|
||||||
|
|
||||||
|
|
||||||
|
/* from main.h */
|
||||||
|
/*
|
||||||
|
* nsigner main header - version information
|
||||||
|
*
|
||||||
|
* Version macros are auto-updated by increment_and_push.sh.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
/* Version information (auto-updated by build/version tooling) */
|
||||||
|
#define NSIGNER_VERSION_MAJOR 0
|
||||||
|
#define NSIGNER_VERSION_MINOR 0
|
||||||
|
#define NSIGNER_VERSION_PATCH 2
|
||||||
|
#define NSIGNER_VERSION "v0.0.2"
|
||||||
|
|
||||||
|
|
||||||
|
/* NSIGNER_HEADERLESS_DECLS_END */
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
static int g_passes = 0;
|
||||||
|
static int g_total = 0;
|
||||||
|
|
||||||
|
static void check_condition(const char *name, int condition) {
|
||||||
|
g_total++;
|
||||||
|
if (condition) {
|
||||||
|
printf("PASS: %s\n", name);
|
||||||
|
g_passes++;
|
||||||
|
} else {
|
||||||
|
printf("FAIL: %s\n", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static role_entry_t make_role(const char *purpose, const char *curve) {
|
||||||
|
role_entry_t role;
|
||||||
|
|
||||||
|
memset(&role, 0, sizeof(role));
|
||||||
|
strncpy(role.name, "test_role", sizeof(role.name) - 1);
|
||||||
|
strncpy(role.purpose_str, purpose, sizeof(role.purpose_str) - 1);
|
||||||
|
strncpy(role.curve_str, curve, sizeof(role.curve_str) - 1);
|
||||||
|
role.purpose = role_purpose_from_str(role.purpose_str);
|
||||||
|
role.curve = role_curve_from_str(role.curve_str);
|
||||||
|
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
role_entry_t nostr_secp = make_role("nostr", "secp256k1");
|
||||||
|
role_entry_t bitcoin_secp = make_role("bitcoin", "secp256k1");
|
||||||
|
role_entry_t nostr_ed = make_role("nostr", "ed25519");
|
||||||
|
role_entry_t ssh_ed = make_role("ssh", "ed25519");
|
||||||
|
role_entry_t age_x = make_role("age", "x25519");
|
||||||
|
|
||||||
|
check_condition(
|
||||||
|
"sign_event + nostr/secp256k1 -> ENFORCE_OK",
|
||||||
|
enforce_verb_role(VERB_SIGN_EVENT, &nostr_secp) == ENFORCE_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
check_condition(
|
||||||
|
"get_public_key + nostr/secp256k1 -> ENFORCE_OK",
|
||||||
|
enforce_verb_role(VERB_GET_PUBLIC_KEY, &nostr_secp) == ENFORCE_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
check_condition(
|
||||||
|
"nip44_encrypt + nostr/secp256k1 -> ENFORCE_OK",
|
||||||
|
enforce_verb_role(VERB_NIP44_ENCRYPT, &nostr_secp) == ENFORCE_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
check_condition(
|
||||||
|
"nip44_decrypt + nostr/secp256k1 -> ENFORCE_OK",
|
||||||
|
enforce_verb_role(VERB_NIP44_DECRYPT, &nostr_secp) == ENFORCE_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
check_condition(
|
||||||
|
"nip04_encrypt + nostr/secp256k1 -> ENFORCE_OK",
|
||||||
|
enforce_verb_role(VERB_NIP04_ENCRYPT, &nostr_secp) == ENFORCE_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
check_condition(
|
||||||
|
"sign_event + bitcoin/secp256k1 -> ENFORCE_ERR_PURPOSE",
|
||||||
|
enforce_verb_role(VERB_SIGN_EVENT, &bitcoin_secp) == ENFORCE_ERR_PURPOSE
|
||||||
|
);
|
||||||
|
|
||||||
|
check_condition(
|
||||||
|
"sign_event + nostr/ed25519 -> ENFORCE_ERR_CURVE",
|
||||||
|
enforce_verb_role(VERB_SIGN_EVENT, &nostr_ed) == ENFORCE_ERR_CURVE
|
||||||
|
);
|
||||||
|
|
||||||
|
check_condition(
|
||||||
|
"sign_event + ssh/ed25519 -> ENFORCE_ERR_PURPOSE",
|
||||||
|
enforce_verb_role(VERB_SIGN_EVENT, &ssh_ed) == ENFORCE_ERR_PURPOSE
|
||||||
|
);
|
||||||
|
|
||||||
|
check_condition(
|
||||||
|
"unknown_verb + nostr/secp256k1 -> ENFORCE_ERR_UNKNOWN_VERB",
|
||||||
|
enforce_verb_role("unknown_verb", &nostr_secp) == ENFORCE_ERR_UNKNOWN_VERB
|
||||||
|
);
|
||||||
|
|
||||||
|
check_condition(
|
||||||
|
"nip44_encrypt + age/x25519 -> ENFORCE_ERR_PURPOSE",
|
||||||
|
enforce_verb_role(VERB_NIP44_ENCRYPT, &age_x) == ENFORCE_ERR_PURPOSE
|
||||||
|
);
|
||||||
|
|
||||||
|
printf("%d/10 tests passed\n", g_passes);
|
||||||
|
|
||||||
|
return (g_passes == 10 && g_total == 10) ? 0 : 1;
|
||||||
|
}
|
||||||
831
tests/test_integration.c
Normal file
831
tests/test_integration.c
Normal file
@@ -0,0 +1,831 @@
|
|||||||
|
#define _GNU_SOURCE
|
||||||
|
|
||||||
|
/* NSIGNER_HEADERLESS_DECLS_BEGIN */
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <cJSON.h>
|
||||||
|
|
||||||
|
/* from secure_mem.h */
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Secure memory buffer — mlock'd, zeroized on free.
|
||||||
|
* Used for mnemonic phrases, private keys, and any sensitive material.
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
void *data; /* pointer to locked allocation */
|
||||||
|
size_t size; /* usable size in bytes */
|
||||||
|
int locked; /* 1 if mlock succeeded */
|
||||||
|
} secure_buf_t;
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
|
||||||
|
/* Zeroize and free a secure buffer. Always succeeds (idempotent). */
|
||||||
|
void secure_buf_free(secure_buf_t *buf);
|
||||||
|
|
||||||
|
/* Zeroize `len` bytes at `ptr` in a way the compiler cannot optimize away. */
|
||||||
|
void secure_memzero(void *ptr, size_t len);
|
||||||
|
|
||||||
|
|
||||||
|
/* from mnemonic.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Maximum mnemonic length: 24 words * 10 chars avg + spaces + null = 256 is safe */
|
||||||
|
#define MNEMONIC_MAX_LEN 256
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mnemonic state — holds the loaded mnemonic in secure memory.
|
||||||
|
* Only one mnemonic is active at a time per process.
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
secure_buf_t buf; /* secure storage for the mnemonic string */
|
||||||
|
int loaded; /* 1 if a mnemonic is currently loaded */
|
||||||
|
int word_count; /* 12, 15, 18, 21, or 24 */
|
||||||
|
} mnemonic_state_t;
|
||||||
|
|
||||||
|
/* Initialize mnemonic state (must be called before use). */
|
||||||
|
void mnemonic_init(mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Load a mnemonic string into secure memory. Validates word count (12/15/18/21/24).
|
||||||
|
* Returns 0 on success, -1 on invalid input, -2 on memory error. */
|
||||||
|
int mnemonic_load(mnemonic_state_t *state, const char *phrase);
|
||||||
|
|
||||||
|
/* Zeroize and unload the mnemonic. Idempotent. */
|
||||||
|
void mnemonic_unload(mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Check if a mnemonic is currently loaded. */
|
||||||
|
int mnemonic_is_loaded(const mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Get the mnemonic string (only valid while loaded). Returns NULL if not loaded. */
|
||||||
|
const char *mnemonic_get_phrase(const mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Generate a new BIP-39 mnemonic phrase (12/15/18/21/24 words) into out.
|
||||||
|
* Returns 0 on success, -1 on invalid arguments or generation failure. */
|
||||||
|
int mnemonic_generate(int word_count, char *out, size_t out_len);
|
||||||
|
|
||||||
|
|
||||||
|
/* from role_table.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Maximum limits */
|
||||||
|
#define ROLE_NAME_MAX 64
|
||||||
|
#define ROLE_PATH_MAX 128
|
||||||
|
#define ROLE_PURPOSE_MAX 32
|
||||||
|
#define ROLE_CURVE_MAX 16
|
||||||
|
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||||
|
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||||
|
|
||||||
|
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||||
|
typedef enum {
|
||||||
|
PURPOSE_NOSTR = 0,
|
||||||
|
PURPOSE_BITCOIN,
|
||||||
|
PURPOSE_SSH,
|
||||||
|
PURPOSE_AGE,
|
||||||
|
PURPOSE_FIPS,
|
||||||
|
PURPOSE_UNKNOWN
|
||||||
|
} role_purpose_t;
|
||||||
|
|
||||||
|
/* Curve enum */
|
||||||
|
typedef enum {
|
||||||
|
CURVE_SECP256K1 = 0,
|
||||||
|
CURVE_ED25519,
|
||||||
|
CURVE_X25519,
|
||||||
|
CURVE_UNKNOWN
|
||||||
|
} role_curve_t;
|
||||||
|
|
||||||
|
/* Selector type — how this role's key is addressed */
|
||||||
|
typedef enum {
|
||||||
|
SELECTOR_NOSTR_INDEX, /* uses nostr_index shorthand */
|
||||||
|
SELECTOR_ROLE_PATH /* uses explicit full path */
|
||||||
|
} role_selector_type_t;
|
||||||
|
|
||||||
|
/* A single role entry */
|
||||||
|
typedef struct {
|
||||||
|
char name[ROLE_NAME_MAX];
|
||||||
|
char purpose_str[ROLE_PURPOSE_MAX];
|
||||||
|
char curve_str[ROLE_CURVE_MAX];
|
||||||
|
role_purpose_t purpose;
|
||||||
|
role_curve_t curve;
|
||||||
|
role_selector_type_t selector_type;
|
||||||
|
int nostr_index; /* valid if selector_type == SELECTOR_NOSTR_INDEX */
|
||||||
|
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
|
||||||
|
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
|
||||||
|
int derived; /* 1 if pubkey_hex has been populated */
|
||||||
|
} role_entry_t;
|
||||||
|
|
||||||
|
/* The role table */
|
||||||
|
typedef struct {
|
||||||
|
role_entry_t entries[ROLE_TABLE_MAX_ENTRIES];
|
||||||
|
int count;
|
||||||
|
} role_table_t;
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
|
||||||
|
/* Find a role by nostr_index. Returns pointer or NULL. */
|
||||||
|
role_entry_t *role_table_find_by_nostr_index(role_table_t *table, int index);
|
||||||
|
|
||||||
|
/* Find a role by role_path. Returns pointer or NULL. */
|
||||||
|
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);
|
||||||
|
|
||||||
|
/* Parse purpose string to enum */
|
||||||
|
role_purpose_t role_purpose_from_str(const char *s);
|
||||||
|
|
||||||
|
/* Parse curve string to enum */
|
||||||
|
role_curve_t role_curve_from_str(const char *s);
|
||||||
|
|
||||||
|
/* Purpose enum to string */
|
||||||
|
const char *role_purpose_to_str(role_purpose_t p);
|
||||||
|
|
||||||
|
/* Curve enum to string */
|
||||||
|
const char *role_curve_to_str(role_curve_t c);
|
||||||
|
|
||||||
|
|
||||||
|
/* from selector.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Error codes for selector resolution */
|
||||||
|
#define SELECTOR_OK 0
|
||||||
|
#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 */
|
||||||
|
|
||||||
|
/* Parsed selector from a request's options object */
|
||||||
|
typedef struct {
|
||||||
|
int has_role; /* 1 if "role" field was present */
|
||||||
|
char role_name[ROLE_NAME_MAX];
|
||||||
|
|
||||||
|
int has_nostr_index; /* 1 if "nostr_index" field was present */
|
||||||
|
int nostr_index;
|
||||||
|
|
||||||
|
int has_role_path; /* 1 if "role_path" field was present */
|
||||||
|
char role_path[ROLE_PATH_MAX];
|
||||||
|
} selector_request_t;
|
||||||
|
|
||||||
|
/* Initialize a selector request (all fields zeroed/unset) */
|
||||||
|
void selector_request_init(selector_request_t *req);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Resolve a selector request against the role table.
|
||||||
|
* On success (returns SELECTOR_OK), *out points to the matched role_entry_t.
|
||||||
|
* On failure, returns one of the SELECTOR_ERR_* codes and *out is NULL.
|
||||||
|
*/
|
||||||
|
int selector_resolve(const selector_request_t *req, role_table_t *table, role_entry_t **out);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return a human-readable error string for a selector error code.
|
||||||
|
*/
|
||||||
|
const char *selector_strerror(int err);
|
||||||
|
|
||||||
|
|
||||||
|
/* from enforcement.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Error codes */
|
||||||
|
#define ENFORCE_OK 0
|
||||||
|
#define ENFORCE_ERR_PURPOSE -1 /* purpose mismatch */
|
||||||
|
#define ENFORCE_ERR_CURVE -2 /* curve mismatch */
|
||||||
|
#define ENFORCE_ERR_UNKNOWN_VERB -3 /* verb not recognized */
|
||||||
|
|
||||||
|
/* Known verbs */
|
||||||
|
#define VERB_SIGN_EVENT "sign_event"
|
||||||
|
#define VERB_GET_PUBLIC_KEY "get_public_key"
|
||||||
|
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
|
||||||
|
#define VERB_NIP44_DECRYPT "nip44_decrypt"
|
||||||
|
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
|
||||||
|
#define VERB_NIP04_DECRYPT "nip04_decrypt"
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check whether `verb` is allowed to execute against `role`.
|
||||||
|
* Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code.
|
||||||
|
*
|
||||||
|
* The enforcement rules are:
|
||||||
|
* - All nostr verbs (sign_event, get_public_key, nip44_*, nip04_*) require:
|
||||||
|
* purpose == PURPOSE_NOSTR and curve == CURVE_SECP256K1
|
||||||
|
* - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed).
|
||||||
|
*/
|
||||||
|
int enforce_verb_role(const char *verb, const role_entry_t *role);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return a human-readable error string for an enforcement error code.
|
||||||
|
*/
|
||||||
|
const char *enforce_strerror(int err);
|
||||||
|
|
||||||
|
|
||||||
|
/* from policy.h */
|
||||||
|
|
||||||
|
|
||||||
|
#define POLICY_MAX_ENTRIES 32
|
||||||
|
#define POLICY_MAX_VERBS 16
|
||||||
|
#define POLICY_MAX_ROLES 16
|
||||||
|
#define POLICY_MAX_PURPOSES 8
|
||||||
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
|
/* Prompt behavior */
|
||||||
|
typedef enum {
|
||||||
|
PROMPT_NEVER = 0,
|
||||||
|
PROMPT_FIRST_PER_BOOT,
|
||||||
|
PROMPT_EVERY_REQUEST,
|
||||||
|
PROMPT_DENY
|
||||||
|
} prompt_mode_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||||
|
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||||
|
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||||
|
} policy_source_t;
|
||||||
|
|
||||||
|
/* A single policy entry */
|
||||||
|
typedef struct {
|
||||||
|
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||||
|
char verbs[POLICY_MAX_VERBS][POLICY_VERB_MAX_LEN];
|
||||||
|
int verb_count;
|
||||||
|
char roles[POLICY_MAX_ROLES][ROLE_NAME_MAX];
|
||||||
|
int role_count;
|
||||||
|
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||||
|
int purpose_count;
|
||||||
|
prompt_mode_t prompt;
|
||||||
|
policy_source_t source;
|
||||||
|
} policy_entry_t;
|
||||||
|
|
||||||
|
/* Policy table */
|
||||||
|
typedef struct {
|
||||||
|
policy_entry_t entries[POLICY_MAX_ENTRIES];
|
||||||
|
int count;
|
||||||
|
} policy_table_t;
|
||||||
|
|
||||||
|
/* Policy check result */
|
||||||
|
#define POLICY_ALLOW 0
|
||||||
|
#define POLICY_DENY -1
|
||||||
|
#define POLICY_PROMPT -2 /* would need user confirmation */
|
||||||
|
#define POLICY_NO_MATCH -3 /* no policy entry matched (fail-closed = deny) */
|
||||||
|
|
||||||
|
/* Initialize policy table */
|
||||||
|
void policy_table_init(policy_table_t *table);
|
||||||
|
|
||||||
|
/* Initialize default policy: allow same-uid, deny others */
|
||||||
|
void policy_init_default(policy_table_t *table, uid_t owner_uid);
|
||||||
|
|
||||||
|
/* Add a policy entry. Returns 0 on success, -1 if full. */
|
||||||
|
int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check whether caller_id is allowed to invoke `verb` on `role_name` with given `purpose`.
|
||||||
|
* 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);
|
||||||
|
|
||||||
|
/* Parse prompt mode from string */
|
||||||
|
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||||
|
|
||||||
|
/* Prompt mode to string */
|
||||||
|
const char *prompt_mode_to_str(prompt_mode_t m);
|
||||||
|
|
||||||
|
|
||||||
|
/* from crypto.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Per-role derived key material (stored in secure memory) */
|
||||||
|
typedef struct {
|
||||||
|
secure_buf_t private_key; /* 32 bytes, mlock'd */
|
||||||
|
unsigned char public_key[32];
|
||||||
|
char pubkey_hex[65]; /* 64 hex chars + null */
|
||||||
|
char npub[128]; /* bech32 npub */
|
||||||
|
int valid;
|
||||||
|
} derived_key_t;
|
||||||
|
|
||||||
|
/* Key store — holds derived keys for all roles */
|
||||||
|
typedef struct {
|
||||||
|
derived_key_t keys[ROLE_TABLE_MAX_ENTRIES];
|
||||||
|
int count;
|
||||||
|
} key_store_t;
|
||||||
|
|
||||||
|
/* Derive keys for all roles in the table using the loaded mnemonic.
|
||||||
|
* Populates key_store and sets role->pubkey_hex and role->derived for each role.
|
||||||
|
* Only derives for roles with purpose=nostr and curve=secp256k1 (for now).
|
||||||
|
* Returns number of keys derived, or -1 on error. */
|
||||||
|
int crypto_derive_all(key_store_t *store, role_table_t *table, const mnemonic_state_t *mnemonic);
|
||||||
|
|
||||||
|
/* Get the derived private key for a role (by table index). Returns NULL if not derived. */
|
||||||
|
const unsigned char *crypto_get_private_key(const key_store_t *store, int role_index);
|
||||||
|
|
||||||
|
/* Get the derived public key hex for a role. Returns NULL if not derived. */
|
||||||
|
const char *crypto_get_pubkey_hex(const key_store_t *store, int role_index);
|
||||||
|
|
||||||
|
/* Sign a Nostr event. event_json is the unsigned event JSON string.
|
||||||
|
* Returns a newly-allocated string containing the signed event JSON, or NULL on error.
|
||||||
|
* Caller must free() the returned string. */
|
||||||
|
char *crypto_sign_event(const key_store_t *store, int role_index, const char *event_json);
|
||||||
|
|
||||||
|
/* Zeroize all derived keys in the store. */
|
||||||
|
void crypto_wipe(key_store_t *store);
|
||||||
|
|
||||||
|
|
||||||
|
/* from dispatcher.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Dispatcher context — holds references to shared state */
|
||||||
|
typedef struct {
|
||||||
|
role_table_t *role_table;
|
||||||
|
mnemonic_state_t *mnemonic;
|
||||||
|
key_store_t *key_store;
|
||||||
|
} dispatcher_ctx_t;
|
||||||
|
|
||||||
|
/* Initialize dispatcher context */
|
||||||
|
void dispatcher_init(dispatcher_ctx_t *ctx, role_table_t *table, mnemonic_state_t *mnemonic, key_store_t *key_store);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Process a JSON-RPC request string and produce a JSON-RPC response string.
|
||||||
|
*
|
||||||
|
* The caller owns the returned string and must free() it.
|
||||||
|
* Returns NULL only on catastrophic allocation failure.
|
||||||
|
*
|
||||||
|
* Response format on success:
|
||||||
|
* { "id": "...", "result": "..." }
|
||||||
|
*
|
||||||
|
* Response format on error:
|
||||||
|
* { "id": "...", "error": { "code": <int>, "message": "..." } }
|
||||||
|
*
|
||||||
|
* Error codes:
|
||||||
|
* -32700 Parse error (invalid JSON)
|
||||||
|
* -32600 Invalid request (missing id/method/params)
|
||||||
|
* -32601 Method not found (unknown verb after enforcement)
|
||||||
|
* -32602 Invalid params
|
||||||
|
* 1001 ambiguous_role_selector
|
||||||
|
* 1002 role_not_found
|
||||||
|
* 1003 no_default_role
|
||||||
|
* 1004 purpose_mismatch
|
||||||
|
* 1005 curve_mismatch
|
||||||
|
* 1006 mnemonic_not_loaded
|
||||||
|
*/
|
||||||
|
char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request);
|
||||||
|
|
||||||
|
|
||||||
|
/* from server.h */
|
||||||
|
|
||||||
|
|
||||||
|
#define SERVER_SOCKET_NAME_MAX 108
|
||||||
|
#define SERVER_MAX_MSG_SIZE 65536
|
||||||
|
|
||||||
|
/* Caller identity */
|
||||||
|
typedef struct {
|
||||||
|
uid_t uid;
|
||||||
|
gid_t gid;
|
||||||
|
pid_t pid;
|
||||||
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
|
} caller_identity_t;
|
||||||
|
|
||||||
|
/* Server context */
|
||||||
|
typedef struct {
|
||||||
|
char socket_name[SERVER_SOCKET_NAME_MAX]; /* abstract namespace name (without \0 prefix) */
|
||||||
|
char last_error[256];
|
||||||
|
int listen_fd;
|
||||||
|
int running;
|
||||||
|
dispatcher_ctx_t *dispatcher;
|
||||||
|
policy_table_t *policy;
|
||||||
|
int socket_name_explicit;
|
||||||
|
} server_ctx_t;
|
||||||
|
|
||||||
|
/* Initialize server context. socket_name is the abstract namespace name (e.g. "nsigner").
|
||||||
|
* socket_name_explicit should be non-zero when provided via --socket-name override. */
|
||||||
|
void server_init(server_ctx_t *ctx, const char *socket_name, int socket_name_explicit,
|
||||||
|
dispatcher_ctx_t *dispatcher, policy_table_t *policy);
|
||||||
|
|
||||||
|
/* Start listening. Returns 0 on success, -1 on error. */
|
||||||
|
int server_start(server_ctx_t *ctx);
|
||||||
|
|
||||||
|
/* Get human-readable description of last server error. */
|
||||||
|
const char *server_last_error(const server_ctx_t *ctx);
|
||||||
|
|
||||||
|
/* Handle one pending connection (non-blocking). Returns 1 if handled, 0 if nothing pending, -1 on error.
|
||||||
|
* activity_cb is called with a description string for the TUI activity log. */
|
||||||
|
typedef void (*server_activity_cb)(const char *message, void *user_data);
|
||||||
|
int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data);
|
||||||
|
|
||||||
|
/* Stop server and close socket */
|
||||||
|
void server_stop(server_ctx_t *ctx);
|
||||||
|
|
||||||
|
/* Extract caller identity from connected fd */
|
||||||
|
int server_get_caller(int fd, caller_identity_t *out);
|
||||||
|
|
||||||
|
|
||||||
|
/* from socket_name.h */
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Generate random socket name in format: nsigner_<word1>_<word2>
|
||||||
|
* Returns 0 on success, -1 on error.
|
||||||
|
*/
|
||||||
|
int socket_name_random(char *out, size_t out_len);
|
||||||
|
|
||||||
|
|
||||||
|
/* from main.h */
|
||||||
|
/*
|
||||||
|
* nsigner main header - version information
|
||||||
|
*
|
||||||
|
* Version macros are auto-updated by increment_and_push.sh.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
/* Version information (auto-updated by build/version tooling) */
|
||||||
|
#define NSIGNER_VERSION_MAJOR 0
|
||||||
|
#define NSIGNER_VERSION_MINOR 0
|
||||||
|
#define NSIGNER_VERSION_PATCH 2
|
||||||
|
#define NSIGNER_VERSION "v0.0.2"
|
||||||
|
|
||||||
|
|
||||||
|
/* NSIGNER_HEADERLESS_DECLS_END */
|
||||||
|
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <errno.h>
|
||||||
|
#include <signal.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <sys/un.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
#include <time.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include "nsigner_client.h"
|
||||||
|
|
||||||
|
#define SOCKET_NAME_A "nsigner_test_run_a"
|
||||||
|
#define SOCKET_NAME_B "nsigner_test_run_b"
|
||||||
|
#define MAX_MSG_SIZE 65536
|
||||||
|
|
||||||
|
static int g_failures = 0;
|
||||||
|
|
||||||
|
static void check_condition(const char *name, int condition) {
|
||||||
|
if (condition) {
|
||||||
|
printf("PASS: %s\n", name);
|
||||||
|
} else {
|
||||||
|
printf("FAIL: %s\n", name);
|
||||||
|
g_failures++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int sleep_ms(int ms) {
|
||||||
|
struct timespec ts;
|
||||||
|
ts.tv_sec = ms / 1000;
|
||||||
|
ts.tv_nsec = (long)(ms % 1000) * 1000000L;
|
||||||
|
return nanosleep(&ts, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int read_full(int fd, void *buf, size_t len) {
|
||||||
|
unsigned char *p = (unsigned char *)buf;
|
||||||
|
size_t off = 0;
|
||||||
|
|
||||||
|
while (off < len) {
|
||||||
|
ssize_t n = read(fd, p + off, len - off);
|
||||||
|
if (n == 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (n < 0) {
|
||||||
|
if (errno == EINTR) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
off += (size_t)n;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int write_full(int fd, const void *buf, size_t len) {
|
||||||
|
const unsigned char *p = (const unsigned char *)buf;
|
||||||
|
size_t off = 0;
|
||||||
|
|
||||||
|
while (off < len) {
|
||||||
|
ssize_t n = write(fd, p + off, len - off);
|
||||||
|
if (n < 0) {
|
||||||
|
if (errno == EINTR) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
off += (size_t)n;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int connect_socket_retry(const char *name, int timeout_ms) {
|
||||||
|
int elapsed = 0;
|
||||||
|
|
||||||
|
while (elapsed < timeout_ms) {
|
||||||
|
int fd;
|
||||||
|
struct sockaddr_un addr;
|
||||||
|
socklen_t addr_len;
|
||||||
|
|
||||||
|
fd = socket(AF_UNIX, SOCK_STREAM, 0);
|
||||||
|
if (fd < 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
memset(&addr, 0, sizeof(addr));
|
||||||
|
addr.sun_family = AF_UNIX;
|
||||||
|
addr.sun_path[0] = '\0';
|
||||||
|
strncpy(&addr.sun_path[1], name, sizeof(addr.sun_path) - 2);
|
||||||
|
addr.sun_path[sizeof(addr.sun_path) - 1] = '\0';
|
||||||
|
addr_len = (socklen_t)(sizeof(sa_family_t) + 1 + strlen(name));
|
||||||
|
|
||||||
|
if (connect(fd, (struct sockaddr *)&addr, addr_len) == 0) {
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(fd);
|
||||||
|
sleep_ms(100);
|
||||||
|
elapsed += 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int send_framed(int fd, const char *payload) {
|
||||||
|
uint32_t len = (uint32_t)strlen(payload);
|
||||||
|
uint32_t be_len = htonl(len);
|
||||||
|
|
||||||
|
if (write_full(fd, &be_len, sizeof(be_len)) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (write_full(fd, payload, len) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int recv_framed(int fd, char **out_payload) {
|
||||||
|
uint32_t be_len;
|
||||||
|
uint32_t len;
|
||||||
|
char *payload;
|
||||||
|
|
||||||
|
if (out_payload == NULL) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
*out_payload = NULL;
|
||||||
|
|
||||||
|
if (read_full(fd, &be_len, sizeof(be_len)) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
len = ntohl(be_len);
|
||||||
|
if (len == 0 || len > MAX_MSG_SIZE) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = (char *)malloc((size_t)len + 1U);
|
||||||
|
if (payload == NULL) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (read_full(fd, payload, len) != 0) {
|
||||||
|
free(payload);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
payload[len] = '\0';
|
||||||
|
*out_payload = payload;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int request_roundtrip_to(const char *socket_name, const char *req, char **resp) {
|
||||||
|
int fd = connect_socket_retry(socket_name, 5000);
|
||||||
|
int rc;
|
||||||
|
|
||||||
|
if (fd < 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
rc = send_framed(fd, req);
|
||||||
|
if (rc == 0) {
|
||||||
|
rc = recv_framed(fd, resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
close(fd);
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
int stdin_pipe[2];
|
||||||
|
pid_t child;
|
||||||
|
pid_t child2;
|
||||||
|
const char *mnemonic = "\nabandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about\n";
|
||||||
|
char *resp = NULL;
|
||||||
|
int status;
|
||||||
|
|
||||||
|
(void)signal(SIGPIPE, SIG_IGN);
|
||||||
|
|
||||||
|
if (pipe(stdin_pipe) != 0) {
|
||||||
|
perror("pipe");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
child = fork();
|
||||||
|
if (child < 0) {
|
||||||
|
perror("fork");
|
||||||
|
close(stdin_pipe[0]);
|
||||||
|
close(stdin_pipe[1]);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child == 0) {
|
||||||
|
int null_fd = open("/dev/null", O_WRONLY);
|
||||||
|
if (null_fd >= 0) {
|
||||||
|
dup2(null_fd, STDOUT_FILENO);
|
||||||
|
dup2(null_fd, STDERR_FILENO);
|
||||||
|
close(null_fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
(void)setenv("NSIGNER_TEST_FORCE_PROMPT", "1", 1);
|
||||||
|
(void)setenv("NSIGNER_TEST_NONINTERACTIVE_PROMPT", "allow", 1);
|
||||||
|
|
||||||
|
dup2(stdin_pipe[0], STDIN_FILENO);
|
||||||
|
close(stdin_pipe[0]);
|
||||||
|
close(stdin_pipe[1]);
|
||||||
|
|
||||||
|
execl("./build/nsigner", "./build/nsigner", "--socket-name", SOCKET_NAME_A, (char *)NULL);
|
||||||
|
_exit(127);
|
||||||
|
}
|
||||||
|
|
||||||
|
close(stdin_pipe[0]);
|
||||||
|
if (write_full(stdin_pipe[1], mnemonic, strlen(mnemonic)) == 0) {
|
||||||
|
|
||||||
|
check_condition("feed mnemonic to child stdin", 1);
|
||||||
|
} else {
|
||||||
|
check_condition("feed mnemonic to child stdin", 0);
|
||||||
|
}
|
||||||
|
close(stdin_pipe[1]);
|
||||||
|
|
||||||
|
{
|
||||||
|
int stdin_pipe2[2];
|
||||||
|
if (pipe(stdin_pipe2) != 0) {
|
||||||
|
perror("pipe2");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
child2 = fork();
|
||||||
|
if (child2 < 0) {
|
||||||
|
perror("fork2");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (child2 == 0) {
|
||||||
|
int null_fd = open("/dev/null", O_WRONLY);
|
||||||
|
if (null_fd >= 0) {
|
||||||
|
dup2(null_fd, STDOUT_FILENO);
|
||||||
|
dup2(null_fd, STDERR_FILENO);
|
||||||
|
close(null_fd);
|
||||||
|
}
|
||||||
|
(void)setenv("NSIGNER_TEST_FORCE_PROMPT", "1", 1);
|
||||||
|
(void)setenv("NSIGNER_TEST_HOTKEYS", "a", 1);
|
||||||
|
dup2(stdin_pipe2[0], STDIN_FILENO);
|
||||||
|
close(stdin_pipe2[0]);
|
||||||
|
close(stdin_pipe2[1]);
|
||||||
|
execl("./build/nsigner", "./build/nsigner", "--socket-name", SOCKET_NAME_B, (char *)NULL);
|
||||||
|
_exit(127);
|
||||||
|
}
|
||||||
|
close(stdin_pipe2[0]);
|
||||||
|
(void)write_full(stdin_pipe2[1], mnemonic, strlen(mnemonic));
|
||||||
|
close(stdin_pipe2[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep_ms(1000);
|
||||||
|
|
||||||
|
{
|
||||||
|
nsigner_client_t client;
|
||||||
|
nsigner_client_init(&client);
|
||||||
|
if (nsigner_client_connect_unix(&client, SOCKET_NAME_A, 5000) == 0) {
|
||||||
|
if (nsigner_client_get_public_key(&client, "1", "", &resp) == 0) {
|
||||||
|
check_condition("get_public_key response id", strstr(resp, "\"id\":\"1\"") != NULL);
|
||||||
|
check_condition("get_public_key has result", strstr(resp, "\"result\":") != NULL);
|
||||||
|
} else {
|
||||||
|
check_condition("get_public_key request roundtrip", 0);
|
||||||
|
}
|
||||||
|
free(resp);
|
||||||
|
resp = NULL;
|
||||||
|
nsigner_client_close(&client);
|
||||||
|
} else {
|
||||||
|
check_condition("connect signer socket for client library", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
nsigner_client_init(&client);
|
||||||
|
if (nsigner_client_connect_unix(&client, SOCKET_NAME_A, 5000) == 0) {
|
||||||
|
if (nsigner_client_sign_event(&client,
|
||||||
|
"2",
|
||||||
|
"{\"kind\":1,\"content\":\"hello\",\"tags\":[],\"created_at\":1700000000}",
|
||||||
|
"main",
|
||||||
|
&resp) == 0) {
|
||||||
|
check_condition("sign_event response id", strstr(resp, "\"id\":\"2\"") != NULL);
|
||||||
|
check_condition("sign_event has result", strstr(resp, "\"result\":") != NULL);
|
||||||
|
} else {
|
||||||
|
check_condition("sign_event request roundtrip", 0);
|
||||||
|
}
|
||||||
|
free(resp);
|
||||||
|
resp = NULL;
|
||||||
|
} else {
|
||||||
|
check_condition("connect signer socket for sign_event", 0);
|
||||||
|
}
|
||||||
|
nsigner_client_close(&client);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
{
|
||||||
|
char *resp_a = NULL;
|
||||||
|
char pub_a[65] = {0};
|
||||||
|
char cipher[2048] = {0};
|
||||||
|
char req[4096];
|
||||||
|
const char *p;
|
||||||
|
|
||||||
|
if (request_roundtrip_to(SOCKET_NAME_A, "{\"id\":\"3\",\"method\":\"get_public_key\",\"params\":[\"\"]}", &resp_a) == 0) {
|
||||||
|
p = strstr(resp_a, "\"result\":\"");
|
||||||
|
if (p) { p += 10; strncpy(pub_a, p, 64); pub_a[64]='\0'; }
|
||||||
|
check_condition("single-instance public key request", pub_a[0] != '\0');
|
||||||
|
|
||||||
|
snprintf(req, sizeof(req), "{\"id\":\"5\",\"method\":\"nip04_encrypt\",\"params\":[\"%s\",\"hello_nip04\"]}", pub_a);
|
||||||
|
free(resp_a); resp_a = NULL;
|
||||||
|
if (request_roundtrip_to(SOCKET_NAME_A, req, &resp_a) == 0) {
|
||||||
|
p = strstr(resp_a, "\"result\":\"");
|
||||||
|
if (p) {
|
||||||
|
size_t i = 0;
|
||||||
|
p += 10;
|
||||||
|
while (p[i] && p[i] != '\"' && i < sizeof(cipher)-1) { cipher[i]=p[i]; i++; }
|
||||||
|
cipher[i]='\0';
|
||||||
|
}
|
||||||
|
snprintf(req, sizeof(req), "{\"id\":\"6\",\"method\":\"nip04_decrypt\",\"params\":[\"%s\",\"%s\"]}", pub_a, cipher);
|
||||||
|
free(resp_a); resp_a = NULL;
|
||||||
|
if (request_roundtrip_to(SOCKET_NAME_A, req, &resp_a) == 0) {
|
||||||
|
check_condition("nip04 round-trip plaintext recovered", strstr(resp_a, "hello_nip04") != NULL);
|
||||||
|
} else {
|
||||||
|
check_condition("nip04 decrypt request roundtrip", 0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
check_condition("nip04 encrypt request roundtrip", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
memset(cipher, 0, sizeof(cipher));
|
||||||
|
snprintf(req, sizeof(req), "{\"id\":\"7\",\"method\":\"nip44_encrypt\",\"params\":[\"%s\",\"hello_nip44\"]}", pub_a);
|
||||||
|
free(resp_a); resp_a = NULL;
|
||||||
|
if (request_roundtrip_to(SOCKET_NAME_A, req, &resp_a) == 0) {
|
||||||
|
p = strstr(resp_a, "\"result\":\"");
|
||||||
|
if (p) {
|
||||||
|
size_t i = 0;
|
||||||
|
p += 10;
|
||||||
|
while (p[i] && p[i] != '\"' && i < sizeof(cipher)-1) { cipher[i]=p[i]; i++; }
|
||||||
|
cipher[i]='\0';
|
||||||
|
}
|
||||||
|
snprintf(req, sizeof(req), "{\"id\":\"8\",\"method\":\"nip44_decrypt\",\"params\":[\"%s\",\"%s\"]}", pub_a, cipher);
|
||||||
|
free(resp_a); resp_a = NULL;
|
||||||
|
if (request_roundtrip_to(SOCKET_NAME_A, req, &resp_a) == 0) {
|
||||||
|
check_condition("nip44 round-trip plaintext recovered", strstr(resp_a, "hello_nip44") != NULL);
|
||||||
|
} else {
|
||||||
|
check_condition("nip44 decrypt request roundtrip", 0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
check_condition("nip44 encrypt request roundtrip", 0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
check_condition("single-instance public key request", 0);
|
||||||
|
}
|
||||||
|
free(resp_a);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kill(child, SIGTERM) == 0) {
|
||||||
|
check_condition("send SIGTERM to child", 1);
|
||||||
|
} else {
|
||||||
|
check_condition("send SIGTERM to child", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (waitpid(child, &status, 0) > 0) {
|
||||||
|
check_condition("child exited", WIFEXITED(status) || WIFSIGNALED(status));
|
||||||
|
} else {
|
||||||
|
check_condition("child exited", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
(void)kill(child2, SIGTERM);
|
||||||
|
(void)waitpid(child2, &status, 0);
|
||||||
|
|
||||||
|
if (g_failures == 0) {
|
||||||
|
printf("ALL TESTS PASSED\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("TESTS FAILED: %d\n", g_failures);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
549
tests/test_mnemonic.c
Normal file
549
tests/test_mnemonic.c
Normal file
@@ -0,0 +1,549 @@
|
|||||||
|
/* NSIGNER_HEADERLESS_DECLS_BEGIN */
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <cJSON.h>
|
||||||
|
|
||||||
|
/* from secure_mem.h */
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Secure memory buffer — mlock'd, zeroized on free.
|
||||||
|
* Used for mnemonic phrases, private keys, and any sensitive material.
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
void *data; /* pointer to locked allocation */
|
||||||
|
size_t size; /* usable size in bytes */
|
||||||
|
int locked; /* 1 if mlock succeeded */
|
||||||
|
} secure_buf_t;
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
|
||||||
|
/* Zeroize and free a secure buffer. Always succeeds (idempotent). */
|
||||||
|
void secure_buf_free(secure_buf_t *buf);
|
||||||
|
|
||||||
|
/* Zeroize `len` bytes at `ptr` in a way the compiler cannot optimize away. */
|
||||||
|
void secure_memzero(void *ptr, size_t len);
|
||||||
|
|
||||||
|
|
||||||
|
/* from mnemonic.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Maximum mnemonic length: 24 words * 10 chars avg + spaces + null = 256 is safe */
|
||||||
|
#define MNEMONIC_MAX_LEN 256
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mnemonic state — holds the loaded mnemonic in secure memory.
|
||||||
|
* Only one mnemonic is active at a time per process.
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
secure_buf_t buf; /* secure storage for the mnemonic string */
|
||||||
|
int loaded; /* 1 if a mnemonic is currently loaded */
|
||||||
|
int word_count; /* 12, 15, 18, 21, or 24 */
|
||||||
|
} mnemonic_state_t;
|
||||||
|
|
||||||
|
/* Initialize mnemonic state (must be called before use). */
|
||||||
|
void mnemonic_init(mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Load a mnemonic string into secure memory. Validates word count (12/15/18/21/24).
|
||||||
|
* Returns 0 on success, -1 on invalid input, -2 on memory error. */
|
||||||
|
int mnemonic_load(mnemonic_state_t *state, const char *phrase);
|
||||||
|
|
||||||
|
/* Zeroize and unload the mnemonic. Idempotent. */
|
||||||
|
void mnemonic_unload(mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Check if a mnemonic is currently loaded. */
|
||||||
|
int mnemonic_is_loaded(const mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Get the mnemonic string (only valid while loaded). Returns NULL if not loaded. */
|
||||||
|
const char *mnemonic_get_phrase(const mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Generate a new BIP-39 mnemonic phrase (12/15/18/21/24 words) into out.
|
||||||
|
* Returns 0 on success, -1 on invalid arguments or generation failure. */
|
||||||
|
int mnemonic_generate(int word_count, char *out, size_t out_len);
|
||||||
|
|
||||||
|
|
||||||
|
/* from role_table.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Maximum limits */
|
||||||
|
#define ROLE_NAME_MAX 64
|
||||||
|
#define ROLE_PATH_MAX 128
|
||||||
|
#define ROLE_PURPOSE_MAX 32
|
||||||
|
#define ROLE_CURVE_MAX 16
|
||||||
|
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||||
|
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||||
|
|
||||||
|
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||||
|
typedef enum {
|
||||||
|
PURPOSE_NOSTR = 0,
|
||||||
|
PURPOSE_BITCOIN,
|
||||||
|
PURPOSE_SSH,
|
||||||
|
PURPOSE_AGE,
|
||||||
|
PURPOSE_FIPS,
|
||||||
|
PURPOSE_UNKNOWN
|
||||||
|
} role_purpose_t;
|
||||||
|
|
||||||
|
/* Curve enum */
|
||||||
|
typedef enum {
|
||||||
|
CURVE_SECP256K1 = 0,
|
||||||
|
CURVE_ED25519,
|
||||||
|
CURVE_X25519,
|
||||||
|
CURVE_UNKNOWN
|
||||||
|
} role_curve_t;
|
||||||
|
|
||||||
|
/* Selector type — how this role's key is addressed */
|
||||||
|
typedef enum {
|
||||||
|
SELECTOR_NOSTR_INDEX, /* uses nostr_index shorthand */
|
||||||
|
SELECTOR_ROLE_PATH /* uses explicit full path */
|
||||||
|
} role_selector_type_t;
|
||||||
|
|
||||||
|
/* A single role entry */
|
||||||
|
typedef struct {
|
||||||
|
char name[ROLE_NAME_MAX];
|
||||||
|
char purpose_str[ROLE_PURPOSE_MAX];
|
||||||
|
char curve_str[ROLE_CURVE_MAX];
|
||||||
|
role_purpose_t purpose;
|
||||||
|
role_curve_t curve;
|
||||||
|
role_selector_type_t selector_type;
|
||||||
|
int nostr_index; /* valid if selector_type == SELECTOR_NOSTR_INDEX */
|
||||||
|
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
|
||||||
|
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
|
||||||
|
int derived; /* 1 if pubkey_hex has been populated */
|
||||||
|
} role_entry_t;
|
||||||
|
|
||||||
|
/* The role table */
|
||||||
|
typedef struct {
|
||||||
|
role_entry_t entries[ROLE_TABLE_MAX_ENTRIES];
|
||||||
|
int count;
|
||||||
|
} role_table_t;
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
|
||||||
|
/* Find a role by nostr_index. Returns pointer or NULL. */
|
||||||
|
role_entry_t *role_table_find_by_nostr_index(role_table_t *table, int index);
|
||||||
|
|
||||||
|
/* Find a role by role_path. Returns pointer or NULL. */
|
||||||
|
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);
|
||||||
|
|
||||||
|
/* Parse purpose string to enum */
|
||||||
|
role_purpose_t role_purpose_from_str(const char *s);
|
||||||
|
|
||||||
|
/* Parse curve string to enum */
|
||||||
|
role_curve_t role_curve_from_str(const char *s);
|
||||||
|
|
||||||
|
/* Purpose enum to string */
|
||||||
|
const char *role_purpose_to_str(role_purpose_t p);
|
||||||
|
|
||||||
|
/* Curve enum to string */
|
||||||
|
const char *role_curve_to_str(role_curve_t c);
|
||||||
|
|
||||||
|
|
||||||
|
/* from selector.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Error codes for selector resolution */
|
||||||
|
#define SELECTOR_OK 0
|
||||||
|
#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 */
|
||||||
|
|
||||||
|
/* Parsed selector from a request's options object */
|
||||||
|
typedef struct {
|
||||||
|
int has_role; /* 1 if "role" field was present */
|
||||||
|
char role_name[ROLE_NAME_MAX];
|
||||||
|
|
||||||
|
int has_nostr_index; /* 1 if "nostr_index" field was present */
|
||||||
|
int nostr_index;
|
||||||
|
|
||||||
|
int has_role_path; /* 1 if "role_path" field was present */
|
||||||
|
char role_path[ROLE_PATH_MAX];
|
||||||
|
} selector_request_t;
|
||||||
|
|
||||||
|
/* Initialize a selector request (all fields zeroed/unset) */
|
||||||
|
void selector_request_init(selector_request_t *req);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Resolve a selector request against the role table.
|
||||||
|
* On success (returns SELECTOR_OK), *out points to the matched role_entry_t.
|
||||||
|
* On failure, returns one of the SELECTOR_ERR_* codes and *out is NULL.
|
||||||
|
*/
|
||||||
|
int selector_resolve(const selector_request_t *req, role_table_t *table, role_entry_t **out);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return a human-readable error string for a selector error code.
|
||||||
|
*/
|
||||||
|
const char *selector_strerror(int err);
|
||||||
|
|
||||||
|
|
||||||
|
/* from enforcement.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Error codes */
|
||||||
|
#define ENFORCE_OK 0
|
||||||
|
#define ENFORCE_ERR_PURPOSE -1 /* purpose mismatch */
|
||||||
|
#define ENFORCE_ERR_CURVE -2 /* curve mismatch */
|
||||||
|
#define ENFORCE_ERR_UNKNOWN_VERB -3 /* verb not recognized */
|
||||||
|
|
||||||
|
/* Known verbs */
|
||||||
|
#define VERB_SIGN_EVENT "sign_event"
|
||||||
|
#define VERB_GET_PUBLIC_KEY "get_public_key"
|
||||||
|
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
|
||||||
|
#define VERB_NIP44_DECRYPT "nip44_decrypt"
|
||||||
|
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
|
||||||
|
#define VERB_NIP04_DECRYPT "nip04_decrypt"
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check whether `verb` is allowed to execute against `role`.
|
||||||
|
* Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code.
|
||||||
|
*
|
||||||
|
* The enforcement rules are:
|
||||||
|
* - All nostr verbs (sign_event, get_public_key, nip44_*, nip04_*) require:
|
||||||
|
* purpose == PURPOSE_NOSTR and curve == CURVE_SECP256K1
|
||||||
|
* - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed).
|
||||||
|
*/
|
||||||
|
int enforce_verb_role(const char *verb, const role_entry_t *role);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return a human-readable error string for an enforcement error code.
|
||||||
|
*/
|
||||||
|
const char *enforce_strerror(int err);
|
||||||
|
|
||||||
|
|
||||||
|
/* from policy.h */
|
||||||
|
|
||||||
|
|
||||||
|
#define POLICY_MAX_ENTRIES 32
|
||||||
|
#define POLICY_MAX_VERBS 16
|
||||||
|
#define POLICY_MAX_ROLES 16
|
||||||
|
#define POLICY_MAX_PURPOSES 8
|
||||||
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
|
/* Prompt behavior */
|
||||||
|
typedef enum {
|
||||||
|
PROMPT_NEVER = 0,
|
||||||
|
PROMPT_FIRST_PER_BOOT,
|
||||||
|
PROMPT_EVERY_REQUEST,
|
||||||
|
PROMPT_DENY
|
||||||
|
} prompt_mode_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||||
|
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||||
|
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||||
|
} policy_source_t;
|
||||||
|
|
||||||
|
/* A single policy entry */
|
||||||
|
typedef struct {
|
||||||
|
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||||
|
char verbs[POLICY_MAX_VERBS][POLICY_VERB_MAX_LEN];
|
||||||
|
int verb_count;
|
||||||
|
char roles[POLICY_MAX_ROLES][ROLE_NAME_MAX];
|
||||||
|
int role_count;
|
||||||
|
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||||
|
int purpose_count;
|
||||||
|
prompt_mode_t prompt;
|
||||||
|
policy_source_t source;
|
||||||
|
} policy_entry_t;
|
||||||
|
|
||||||
|
/* Policy table */
|
||||||
|
typedef struct {
|
||||||
|
policy_entry_t entries[POLICY_MAX_ENTRIES];
|
||||||
|
int count;
|
||||||
|
} policy_table_t;
|
||||||
|
|
||||||
|
/* Policy check result */
|
||||||
|
#define POLICY_ALLOW 0
|
||||||
|
#define POLICY_DENY -1
|
||||||
|
#define POLICY_PROMPT -2 /* would need user confirmation */
|
||||||
|
#define POLICY_NO_MATCH -3 /* no policy entry matched (fail-closed = deny) */
|
||||||
|
|
||||||
|
/* Initialize policy table */
|
||||||
|
void policy_table_init(policy_table_t *table);
|
||||||
|
|
||||||
|
/* Initialize default policy: allow same-uid, deny others */
|
||||||
|
void policy_init_default(policy_table_t *table, uid_t owner_uid);
|
||||||
|
|
||||||
|
/* Add a policy entry. Returns 0 on success, -1 if full. */
|
||||||
|
int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check whether caller_id is allowed to invoke `verb` on `role_name` with given `purpose`.
|
||||||
|
* 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);
|
||||||
|
|
||||||
|
/* Parse prompt mode from string */
|
||||||
|
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||||
|
|
||||||
|
/* Prompt mode to string */
|
||||||
|
const char *prompt_mode_to_str(prompt_mode_t m);
|
||||||
|
|
||||||
|
|
||||||
|
/* from crypto.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Per-role derived key material (stored in secure memory) */
|
||||||
|
typedef struct {
|
||||||
|
secure_buf_t private_key; /* 32 bytes, mlock'd */
|
||||||
|
unsigned char public_key[32];
|
||||||
|
char pubkey_hex[65]; /* 64 hex chars + null */
|
||||||
|
char npub[128]; /* bech32 npub */
|
||||||
|
int valid;
|
||||||
|
} derived_key_t;
|
||||||
|
|
||||||
|
/* Key store — holds derived keys for all roles */
|
||||||
|
typedef struct {
|
||||||
|
derived_key_t keys[ROLE_TABLE_MAX_ENTRIES];
|
||||||
|
int count;
|
||||||
|
} key_store_t;
|
||||||
|
|
||||||
|
/* Derive keys for all roles in the table using the loaded mnemonic.
|
||||||
|
* Populates key_store and sets role->pubkey_hex and role->derived for each role.
|
||||||
|
* Only derives for roles with purpose=nostr and curve=secp256k1 (for now).
|
||||||
|
* Returns number of keys derived, or -1 on error. */
|
||||||
|
int crypto_derive_all(key_store_t *store, role_table_t *table, const mnemonic_state_t *mnemonic);
|
||||||
|
|
||||||
|
/* Get the derived private key for a role (by table index). Returns NULL if not derived. */
|
||||||
|
const unsigned char *crypto_get_private_key(const key_store_t *store, int role_index);
|
||||||
|
|
||||||
|
/* Get the derived public key hex for a role. Returns NULL if not derived. */
|
||||||
|
const char *crypto_get_pubkey_hex(const key_store_t *store, int role_index);
|
||||||
|
|
||||||
|
/* Sign a Nostr event. event_json is the unsigned event JSON string.
|
||||||
|
* Returns a newly-allocated string containing the signed event JSON, or NULL on error.
|
||||||
|
* Caller must free() the returned string. */
|
||||||
|
char *crypto_sign_event(const key_store_t *store, int role_index, const char *event_json);
|
||||||
|
|
||||||
|
/* Zeroize all derived keys in the store. */
|
||||||
|
void crypto_wipe(key_store_t *store);
|
||||||
|
|
||||||
|
|
||||||
|
/* from dispatcher.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Dispatcher context — holds references to shared state */
|
||||||
|
typedef struct {
|
||||||
|
role_table_t *role_table;
|
||||||
|
mnemonic_state_t *mnemonic;
|
||||||
|
key_store_t *key_store;
|
||||||
|
} dispatcher_ctx_t;
|
||||||
|
|
||||||
|
/* Initialize dispatcher context */
|
||||||
|
void dispatcher_init(dispatcher_ctx_t *ctx, role_table_t *table, mnemonic_state_t *mnemonic, key_store_t *key_store);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Process a JSON-RPC request string and produce a JSON-RPC response string.
|
||||||
|
*
|
||||||
|
* The caller owns the returned string and must free() it.
|
||||||
|
* Returns NULL only on catastrophic allocation failure.
|
||||||
|
*
|
||||||
|
* Response format on success:
|
||||||
|
* { "id": "...", "result": "..." }
|
||||||
|
*
|
||||||
|
* Response format on error:
|
||||||
|
* { "id": "...", "error": { "code": <int>, "message": "..." } }
|
||||||
|
*
|
||||||
|
* Error codes:
|
||||||
|
* -32700 Parse error (invalid JSON)
|
||||||
|
* -32600 Invalid request (missing id/method/params)
|
||||||
|
* -32601 Method not found (unknown verb after enforcement)
|
||||||
|
* -32602 Invalid params
|
||||||
|
* 1001 ambiguous_role_selector
|
||||||
|
* 1002 role_not_found
|
||||||
|
* 1003 no_default_role
|
||||||
|
* 1004 purpose_mismatch
|
||||||
|
* 1005 curve_mismatch
|
||||||
|
* 1006 mnemonic_not_loaded
|
||||||
|
*/
|
||||||
|
char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request);
|
||||||
|
|
||||||
|
|
||||||
|
/* from server.h */
|
||||||
|
|
||||||
|
|
||||||
|
#define SERVER_SOCKET_NAME_MAX 108
|
||||||
|
#define SERVER_MAX_MSG_SIZE 65536
|
||||||
|
|
||||||
|
/* Caller identity */
|
||||||
|
typedef struct {
|
||||||
|
uid_t uid;
|
||||||
|
gid_t gid;
|
||||||
|
pid_t pid;
|
||||||
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
|
} caller_identity_t;
|
||||||
|
|
||||||
|
/* Server context */
|
||||||
|
typedef struct {
|
||||||
|
char socket_name[SERVER_SOCKET_NAME_MAX]; /* abstract namespace name (without \0 prefix) */
|
||||||
|
char last_error[256];
|
||||||
|
int listen_fd;
|
||||||
|
int running;
|
||||||
|
dispatcher_ctx_t *dispatcher;
|
||||||
|
policy_table_t *policy;
|
||||||
|
int socket_name_explicit;
|
||||||
|
} server_ctx_t;
|
||||||
|
|
||||||
|
/* Initialize server context. socket_name is the abstract namespace name (e.g. "nsigner").
|
||||||
|
* socket_name_explicit should be non-zero when provided via --socket-name override. */
|
||||||
|
void server_init(server_ctx_t *ctx, const char *socket_name, int socket_name_explicit,
|
||||||
|
dispatcher_ctx_t *dispatcher, policy_table_t *policy);
|
||||||
|
|
||||||
|
/* Start listening. Returns 0 on success, -1 on error. */
|
||||||
|
int server_start(server_ctx_t *ctx);
|
||||||
|
|
||||||
|
/* Get human-readable description of last server error. */
|
||||||
|
const char *server_last_error(const server_ctx_t *ctx);
|
||||||
|
|
||||||
|
/* Handle one pending connection (non-blocking). Returns 1 if handled, 0 if nothing pending, -1 on error.
|
||||||
|
* activity_cb is called with a description string for the TUI activity log. */
|
||||||
|
typedef void (*server_activity_cb)(const char *message, void *user_data);
|
||||||
|
int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data);
|
||||||
|
|
||||||
|
/* Stop server and close socket */
|
||||||
|
void server_stop(server_ctx_t *ctx);
|
||||||
|
|
||||||
|
/* Extract caller identity from connected fd */
|
||||||
|
int server_get_caller(int fd, caller_identity_t *out);
|
||||||
|
|
||||||
|
|
||||||
|
/* from socket_name.h */
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Generate random socket name in format: nsigner_<word1>_<word2>
|
||||||
|
* Returns 0 on success, -1 on error.
|
||||||
|
*/
|
||||||
|
int socket_name_random(char *out, size_t out_len);
|
||||||
|
|
||||||
|
|
||||||
|
/* from main.h */
|
||||||
|
/*
|
||||||
|
* nsigner main header - version information
|
||||||
|
*
|
||||||
|
* Version macros are auto-updated by increment_and_push.sh.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
/* Version information (auto-updated by build/version tooling) */
|
||||||
|
#define NSIGNER_VERSION_MAJOR 0
|
||||||
|
#define NSIGNER_VERSION_MINOR 0
|
||||||
|
#define NSIGNER_VERSION_PATCH 2
|
||||||
|
#define NSIGNER_VERSION "v0.0.2"
|
||||||
|
|
||||||
|
|
||||||
|
/* NSIGNER_HEADERLESS_DECLS_END */
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
static int g_failures = 0;
|
||||||
|
|
||||||
|
/* Print PASS/FAIL and track failures. */
|
||||||
|
static void check_condition(const char *name, int condition) {
|
||||||
|
if (condition) {
|
||||||
|
printf("PASS: %s\n", name);
|
||||||
|
} else {
|
||||||
|
printf("FAIL: %s\n", name);
|
||||||
|
g_failures++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Build a deterministic 25-word invalid mnemonic in `out`. */
|
||||||
|
static void build_25_word_phrase(char *out, size_t out_size) {
|
||||||
|
size_t used = 0;
|
||||||
|
int i;
|
||||||
|
|
||||||
|
if (out == NULL || out_size == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
out[0] = '\0';
|
||||||
|
for (i = 0; i < 25; ++i) {
|
||||||
|
const char *word = "abandon";
|
||||||
|
int wrote;
|
||||||
|
|
||||||
|
wrote = snprintf(out + used, out_size - used, "%s%s", (i == 0) ? "" : " ", word);
|
||||||
|
if (wrote < 0 || (size_t)wrote >= out_size - used) {
|
||||||
|
/* Truncate safely and stop if buffer is insufficient. */
|
||||||
|
out[out_size - 1] = '\0';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
used += (size_t)wrote;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
mnemonic_state_t state;
|
||||||
|
const char *valid_12 = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
|
||||||
|
const char *invalid_11 = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
|
||||||
|
char invalid_25[MNEMONIC_MAX_LEN];
|
||||||
|
const char *loaded_phrase;
|
||||||
|
char generated_12[MNEMONIC_MAX_LEN];
|
||||||
|
char generated_24[MNEMONIC_MAX_LEN];
|
||||||
|
int rc;
|
||||||
|
|
||||||
|
mnemonic_init(&state);
|
||||||
|
check_condition("state initially unloaded", mnemonic_is_loaded(&state) == 0);
|
||||||
|
|
||||||
|
rc = mnemonic_load(&state, valid_12);
|
||||||
|
check_condition("load valid 12-word mnemonic returns 0", rc == 0);
|
||||||
|
check_condition("state loaded after valid load", mnemonic_is_loaded(&state) == 1);
|
||||||
|
check_condition("word_count == 12", state.word_count == 12);
|
||||||
|
|
||||||
|
loaded_phrase = mnemonic_get_phrase(&state);
|
||||||
|
check_condition("mnemonic_get_phrase non-NULL when loaded", loaded_phrase != NULL);
|
||||||
|
check_condition("mnemonic_get_phrase matches input", loaded_phrase != NULL && strcmp(loaded_phrase, valid_12) == 0);
|
||||||
|
|
||||||
|
mnemonic_unload(&state);
|
||||||
|
check_condition("state unloaded after mnemonic_unload", mnemonic_is_loaded(&state) == 0);
|
||||||
|
check_condition("mnemonic_get_phrase NULL after unload", mnemonic_get_phrase(&state) == NULL);
|
||||||
|
|
||||||
|
rc = mnemonic_load(&state, invalid_11);
|
||||||
|
check_condition("reject invalid 11-word mnemonic", rc == -1);
|
||||||
|
check_condition("state remains unloaded after invalid 11-word load", mnemonic_is_loaded(&state) == 0);
|
||||||
|
|
||||||
|
build_25_word_phrase(invalid_25, sizeof(invalid_25));
|
||||||
|
rc = mnemonic_load(&state, invalid_25);
|
||||||
|
check_condition("reject invalid 25-word mnemonic", rc == -1);
|
||||||
|
check_condition("state remains unloaded after invalid 25-word load", mnemonic_is_loaded(&state) == 0);
|
||||||
|
|
||||||
|
rc = mnemonic_generate(12, generated_12, sizeof(generated_12));
|
||||||
|
check_condition("mnemonic_generate(12) returns 0", rc == 0);
|
||||||
|
check_condition("mnemonic_generate(12) output non-empty", rc == 0 && generated_12[0] != '\0');
|
||||||
|
check_condition("load generated 12-word mnemonic", rc == 0 && mnemonic_load(&state, generated_12) == 0);
|
||||||
|
check_condition("generated 12 word_count == 12", mnemonic_is_loaded(&state) && state.word_count == 12);
|
||||||
|
mnemonic_unload(&state);
|
||||||
|
|
||||||
|
rc = mnemonic_generate(24, generated_24, sizeof(generated_24));
|
||||||
|
check_condition("mnemonic_generate(24) returns 0", rc == 0);
|
||||||
|
check_condition("mnemonic_generate(24) output non-empty", rc == 0 && generated_24[0] != '\0');
|
||||||
|
check_condition("load generated 24-word mnemonic", rc == 0 && mnemonic_load(&state, generated_24) == 0);
|
||||||
|
check_condition("generated 24 word_count == 24", mnemonic_is_loaded(&state) && state.word_count == 24);
|
||||||
|
mnemonic_unload(&state);
|
||||||
|
|
||||||
|
check_condition("two generated mnemonics differ", strcmp(generated_12, generated_24) != 0);
|
||||||
|
|
||||||
|
if (g_failures == 0) {
|
||||||
|
printf("ALL TESTS PASSED\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("TESTS FAILED: %d\n", g_failures);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
532
tests/test_mnemonic_input.c
Normal file
532
tests/test_mnemonic_input.c
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
#define _GNU_SOURCE
|
||||||
|
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <errno.h>
|
||||||
|
#include <signal.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <sys/un.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
#include <time.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#define VALID_MNEMONIC "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||||
|
#define LONG_INPUT_LEN 1024
|
||||||
|
|
||||||
|
static int g_failures = 0;
|
||||||
|
|
||||||
|
static void check_condition(const char *name, int condition) {
|
||||||
|
if (condition) {
|
||||||
|
printf("PASS: %s\n", name);
|
||||||
|
} else {
|
||||||
|
printf("FAIL: %s\n", name);
|
||||||
|
g_failures++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int sleep_ms(int ms) {
|
||||||
|
struct timespec ts;
|
||||||
|
ts.tv_sec = ms / 1000;
|
||||||
|
ts.tv_nsec = (long)(ms % 1000) * 1000000L;
|
||||||
|
return nanosleep(&ts, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int write_full(int fd, const void *buf, size_t len) {
|
||||||
|
const unsigned char *p = (const unsigned char *)buf;
|
||||||
|
size_t off = 0;
|
||||||
|
|
||||||
|
while (off < len) {
|
||||||
|
ssize_t n = write(fd, p + off, len - off);
|
||||||
|
if (n < 0) {
|
||||||
|
if (errno == EINTR) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
off += (size_t)n;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int connect_socket_retry(const char *name, int timeout_ms) {
|
||||||
|
int elapsed = 0;
|
||||||
|
|
||||||
|
while (elapsed < timeout_ms) {
|
||||||
|
int fd;
|
||||||
|
struct sockaddr_un addr;
|
||||||
|
socklen_t addr_len;
|
||||||
|
|
||||||
|
fd = socket(AF_UNIX, SOCK_STREAM, 0);
|
||||||
|
if (fd < 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
memset(&addr, 0, sizeof(addr));
|
||||||
|
addr.sun_family = AF_UNIX;
|
||||||
|
addr.sun_path[0] = '\0';
|
||||||
|
strncpy(&addr.sun_path[1], name, sizeof(addr.sun_path) - 2);
|
||||||
|
addr.sun_path[sizeof(addr.sun_path) - 1] = '\0';
|
||||||
|
addr_len = (socklen_t)(sizeof(sa_family_t) + 1 + strlen(name));
|
||||||
|
|
||||||
|
if (connect(fd, (struct sockaddr *)&addr, addr_len) == 0) {
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(fd);
|
||||||
|
sleep_ms(100);
|
||||||
|
elapsed += 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static pid_t spawn_with_stdin_and_fd(const char *const argv[],
|
||||||
|
const char *stdin_data,
|
||||||
|
int close_stdin_after_write,
|
||||||
|
const char *fd_data,
|
||||||
|
int fd_num,
|
||||||
|
int close_fd_after_write,
|
||||||
|
int *stdin_write_end,
|
||||||
|
int *fd_write_end,
|
||||||
|
int *capture_read_end) {
|
||||||
|
int stdin_pipe[2] = {-1, -1};
|
||||||
|
int fd_pipe[2] = {-1, -1};
|
||||||
|
int capture_pipe[2] = {-1, -1};
|
||||||
|
pid_t child;
|
||||||
|
|
||||||
|
if (pipe(stdin_pipe) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (pipe(capture_pipe) != 0) {
|
||||||
|
close(stdin_pipe[0]);
|
||||||
|
close(stdin_pipe[1]);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (fd_data != NULL) {
|
||||||
|
if (pipe(fd_pipe) != 0) {
|
||||||
|
close(stdin_pipe[0]);
|
||||||
|
close(stdin_pipe[1]);
|
||||||
|
close(capture_pipe[0]);
|
||||||
|
close(capture_pipe[1]);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
child = fork();
|
||||||
|
if (child < 0) {
|
||||||
|
close(stdin_pipe[0]);
|
||||||
|
close(stdin_pipe[1]);
|
||||||
|
close(capture_pipe[0]);
|
||||||
|
close(capture_pipe[1]);
|
||||||
|
if (fd_pipe[0] >= 0) close(fd_pipe[0]);
|
||||||
|
if (fd_pipe[1] >= 0) close(fd_pipe[1]);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child == 0) {
|
||||||
|
dup2(stdin_pipe[0], STDIN_FILENO);
|
||||||
|
dup2(capture_pipe[1], STDOUT_FILENO);
|
||||||
|
dup2(capture_pipe[1], STDERR_FILENO);
|
||||||
|
|
||||||
|
close(stdin_pipe[0]);
|
||||||
|
close(stdin_pipe[1]);
|
||||||
|
close(capture_pipe[0]);
|
||||||
|
close(capture_pipe[1]);
|
||||||
|
|
||||||
|
if (fd_data != NULL) {
|
||||||
|
dup2(fd_pipe[0], fd_num);
|
||||||
|
close(fd_pipe[0]);
|
||||||
|
close(fd_pipe[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
execv("./build/nsigner", (char *const *)argv);
|
||||||
|
_exit(127);
|
||||||
|
}
|
||||||
|
|
||||||
|
close(stdin_pipe[0]);
|
||||||
|
close(capture_pipe[1]);
|
||||||
|
|
||||||
|
if (fd_data != NULL) {
|
||||||
|
close(fd_pipe[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stdin_data != NULL) {
|
||||||
|
(void)write_full(stdin_pipe[1], stdin_data, strlen(stdin_data));
|
||||||
|
if (close_stdin_after_write) {
|
||||||
|
close(stdin_pipe[1]);
|
||||||
|
stdin_pipe[1] = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fd_data != NULL) {
|
||||||
|
(void)write_full(fd_pipe[1], fd_data, strlen(fd_data));
|
||||||
|
if (close_fd_after_write) {
|
||||||
|
close(fd_pipe[1]);
|
||||||
|
fd_pipe[1] = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stdin_write_end != NULL) {
|
||||||
|
*stdin_write_end = stdin_pipe[1];
|
||||||
|
} else if (stdin_pipe[1] >= 0) {
|
||||||
|
close(stdin_pipe[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fd_write_end != NULL) {
|
||||||
|
*fd_write_end = fd_pipe[1];
|
||||||
|
} else if (fd_pipe[1] >= 0) {
|
||||||
|
close(fd_pipe[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (capture_read_end != NULL) {
|
||||||
|
*capture_read_end = capture_pipe[0];
|
||||||
|
} else {
|
||||||
|
close(capture_pipe[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int read_capture_nonblocking(int fd, char *buf, size_t buf_sz) {
|
||||||
|
ssize_t n;
|
||||||
|
if (buf == NULL || buf_sz == 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
n = read(fd, buf, buf_sz - 1);
|
||||||
|
if (n < 0) {
|
||||||
|
if (errno == EAGAIN || errno == EWOULDBLOCK) {
|
||||||
|
buf[0] = '\0';
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
buf[n] = '\0';
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int wait_exit_timeout(pid_t pid, int timeout_ms, int *status) {
|
||||||
|
int elapsed = 0;
|
||||||
|
while (elapsed < timeout_ms) {
|
||||||
|
pid_t rc = waitpid(pid, status, WNOHANG);
|
||||||
|
if (rc == pid) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (rc < 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
sleep_ms(50);
|
||||||
|
elapsed += 50;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_stdin_happy_path(void) {
|
||||||
|
const char *const argv[] = {
|
||||||
|
"./build/nsigner", "--socket-name", "nsigner_test_mi_stdin_ok", "--mnemonic-stdin", NULL
|
||||||
|
};
|
||||||
|
int cap_fd = -1;
|
||||||
|
int status = 0;
|
||||||
|
int fd;
|
||||||
|
pid_t child = spawn_with_stdin_and_fd(argv,
|
||||||
|
VALID_MNEMONIC "\n",
|
||||||
|
1,
|
||||||
|
NULL,
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
&cap_fd);
|
||||||
|
|
||||||
|
check_condition("spawn --mnemonic-stdin happy-path child", child > 0);
|
||||||
|
if (child <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fd = connect_socket_retry("nsigner_test_mi_stdin_ok", 5000);
|
||||||
|
check_condition("--mnemonic-stdin reaches running server", fd >= 0);
|
||||||
|
if (fd >= 0) {
|
||||||
|
close(fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
(void)kill(child, SIGTERM);
|
||||||
|
(void)waitpid(child, &status, 0);
|
||||||
|
if (cap_fd >= 0) {
|
||||||
|
close(cap_fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_stdin_eof_without_newline(void) {
|
||||||
|
const char *const argv[] = {
|
||||||
|
"./build/nsigner", "--socket-name", "nsigner_test_mi_stdin_eof", "--mnemonic-stdin", NULL
|
||||||
|
};
|
||||||
|
int cap_fd = -1;
|
||||||
|
int status = 0;
|
||||||
|
int fd;
|
||||||
|
pid_t child = spawn_with_stdin_and_fd(argv,
|
||||||
|
VALID_MNEMONIC,
|
||||||
|
1,
|
||||||
|
NULL,
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
&cap_fd);
|
||||||
|
|
||||||
|
check_condition("spawn --mnemonic-stdin EOF-without-newline child", child > 0);
|
||||||
|
if (child <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fd = connect_socket_retry("nsigner_test_mi_stdin_eof", 5000);
|
||||||
|
check_condition("--mnemonic-stdin EOF without newline accepted", fd >= 0);
|
||||||
|
if (fd >= 0) {
|
||||||
|
close(fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
(void)kill(child, SIGTERM);
|
||||||
|
(void)waitpid(child, &status, 0);
|
||||||
|
if (cap_fd >= 0) {
|
||||||
|
close(cap_fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_stdin_invalid_mnemonic(void) {
|
||||||
|
const char *const argv[] = {
|
||||||
|
"./build/nsigner", "--mnemonic-stdin", NULL
|
||||||
|
};
|
||||||
|
int cap_fd = -1;
|
||||||
|
int status = 0;
|
||||||
|
char cap[512];
|
||||||
|
pid_t child = spawn_with_stdin_and_fd(argv,
|
||||||
|
"not a valid mnemonic\n",
|
||||||
|
1,
|
||||||
|
NULL,
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
&cap_fd);
|
||||||
|
|
||||||
|
check_condition("spawn --mnemonic-stdin invalid child", child > 0);
|
||||||
|
if (child <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
check_condition("--mnemonic-stdin invalid exits", wait_exit_timeout(child, 2000, &status) == 0);
|
||||||
|
check_condition("--mnemonic-stdin invalid exits non-zero", WIFEXITED(status) && WEXITSTATUS(status) != 0);
|
||||||
|
|
||||||
|
if (cap_fd >= 0) {
|
||||||
|
(void)read_capture_nonblocking(cap_fd, cap, sizeof(cap));
|
||||||
|
check_condition("--mnemonic-stdin invalid emits validation failure", strstr(cap, "mnemonic validation failed") != NULL);
|
||||||
|
close(cap_fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_stdin_overflow(void) {
|
||||||
|
const char *const argv[] = {
|
||||||
|
"./build/nsigner", "--mnemonic-stdin", NULL
|
||||||
|
};
|
||||||
|
char long_input[LONG_INPUT_LEN + 2];
|
||||||
|
int i;
|
||||||
|
int cap_fd = -1;
|
||||||
|
int status = 0;
|
||||||
|
char cap[512];
|
||||||
|
pid_t child;
|
||||||
|
|
||||||
|
for (i = 0; i < LONG_INPUT_LEN; ++i) {
|
||||||
|
long_input[i] = 'a';
|
||||||
|
}
|
||||||
|
long_input[LONG_INPUT_LEN] = '\n';
|
||||||
|
long_input[LONG_INPUT_LEN + 1] = '\0';
|
||||||
|
|
||||||
|
child = spawn_with_stdin_and_fd(argv,
|
||||||
|
long_input,
|
||||||
|
1,
|
||||||
|
NULL,
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
&cap_fd);
|
||||||
|
|
||||||
|
check_condition("spawn --mnemonic-stdin overflow child", child > 0);
|
||||||
|
if (child <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
check_condition("--mnemonic-stdin overflow exits", wait_exit_timeout(child, 2000, &status) == 0);
|
||||||
|
check_condition("--mnemonic-stdin overflow exits non-zero", WIFEXITED(status) && WEXITSTATUS(status) != 0);
|
||||||
|
|
||||||
|
if (cap_fd >= 0) {
|
||||||
|
(void)read_capture_nonblocking(cap_fd, cap, sizeof(cap));
|
||||||
|
check_condition("--mnemonic-stdin overflow emits length error", strstr(cap, "exceeded maximum length") != NULL);
|
||||||
|
close(cap_fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_fd_happy_path(void) {
|
||||||
|
const char *const argv[] = {
|
||||||
|
"./build/nsigner", "--socket-name", "nsigner_test_mi_fd_ok", "--mnemonic-fd", "3", NULL
|
||||||
|
};
|
||||||
|
int cap_fd = -1;
|
||||||
|
int status = 0;
|
||||||
|
int fd;
|
||||||
|
pid_t child = spawn_with_stdin_and_fd(argv,
|
||||||
|
"",
|
||||||
|
0,
|
||||||
|
VALID_MNEMONIC "\n",
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
&cap_fd);
|
||||||
|
|
||||||
|
check_condition("spawn --mnemonic-fd happy-path child", child > 0);
|
||||||
|
if (child <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fd = connect_socket_retry("nsigner_test_mi_fd_ok", 5000);
|
||||||
|
check_condition("--mnemonic-fd reaches running server", fd >= 0);
|
||||||
|
if (fd >= 0) {
|
||||||
|
close(fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
(void)kill(child, SIGTERM);
|
||||||
|
(void)waitpid(child, &status, 0);
|
||||||
|
if (cap_fd >= 0) {
|
||||||
|
close(cap_fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_mutual_exclusion_and_validation_errors(void) {
|
||||||
|
struct {
|
||||||
|
const char *name;
|
||||||
|
const char *const *argv;
|
||||||
|
const char *expect_substr;
|
||||||
|
} cases[] = {
|
||||||
|
{
|
||||||
|
"mutual exclusion stdin+fd",
|
||||||
|
(const char *const[]){"./build/nsigner", "--mnemonic-stdin", "--mnemonic-fd", "3", NULL},
|
||||||
|
"mutually exclusive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stdin with stdio listener",
|
||||||
|
(const char *const[]){"./build/nsigner", "--listen", "stdio", "--mnemonic-stdin", NULL},
|
||||||
|
"requires --listen unix or --listen tcp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mnemonic-fd 1 rejected",
|
||||||
|
(const char *const[]){"./build/nsigner", "--mnemonic-fd", "1", NULL},
|
||||||
|
"--mnemonic-fd 1 is not allowed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mnemonic-fd 0 rejected with stdio",
|
||||||
|
(const char *const[]){"./build/nsigner", "--listen", "stdio", "--mnemonic-fd", "0", NULL},
|
||||||
|
"cannot be used with --listen stdio"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
size_t i;
|
||||||
|
for (i = 0; i < sizeof(cases) / sizeof(cases[0]); ++i) {
|
||||||
|
int cap_fd = -1;
|
||||||
|
int status = 0;
|
||||||
|
char cap[512];
|
||||||
|
pid_t child = spawn_with_stdin_and_fd(cases[i].argv,
|
||||||
|
"",
|
||||||
|
1,
|
||||||
|
NULL,
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
&cap_fd);
|
||||||
|
|
||||||
|
check_condition(cases[i].name, child > 0);
|
||||||
|
if (child <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
check_condition("expected parse-time failure exits", wait_exit_timeout(child, 2000, &status) == 0);
|
||||||
|
check_condition("expected parse-time failure non-zero", WIFEXITED(status) && WEXITSTATUS(status) != 0);
|
||||||
|
|
||||||
|
if (cap_fd >= 0) {
|
||||||
|
(void)read_capture_nonblocking(cap_fd, cap, sizeof(cap));
|
||||||
|
check_condition("expected parse-time error message", strstr(cap, cases[i].expect_substr) != NULL);
|
||||||
|
close(cap_fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_lock_reunlock_falls_back_to_tui_after_fd_startup(void) {
|
||||||
|
const char *const argv[] = {
|
||||||
|
"./build/nsigner", "--socket-name", "nsigner_test_mi_lock", "--mnemonic-fd", "3", NULL
|
||||||
|
};
|
||||||
|
int stdin_write = -1;
|
||||||
|
int cap_fd = -1;
|
||||||
|
int status = 0;
|
||||||
|
int fd;
|
||||||
|
pid_t child = spawn_with_stdin_and_fd(argv,
|
||||||
|
"",
|
||||||
|
0,
|
||||||
|
VALID_MNEMONIC "\n",
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
&stdin_write,
|
||||||
|
NULL,
|
||||||
|
&cap_fd);
|
||||||
|
|
||||||
|
check_condition("spawn --mnemonic-fd lock test child", child > 0);
|
||||||
|
if (child <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fd = connect_socket_retry("nsigner_test_mi_lock", 5000);
|
||||||
|
check_condition("--mnemonic-fd lock test initial running server", fd >= 0);
|
||||||
|
if (fd >= 0) {
|
||||||
|
close(fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
(void)write_full(stdin_write, "l\n" VALID_MNEMONIC "\n", strlen("l\n" VALID_MNEMONIC "\n"));
|
||||||
|
sleep_ms(800);
|
||||||
|
|
||||||
|
fd = connect_socket_retry("nsigner_test_mi_lock", 2000);
|
||||||
|
check_condition("lock+reunlock via TUI keeps server alive", fd >= 0);
|
||||||
|
if (fd >= 0) {
|
||||||
|
close(fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stdin_write >= 0) {
|
||||||
|
close(stdin_write);
|
||||||
|
}
|
||||||
|
|
||||||
|
(void)kill(child, SIGTERM);
|
||||||
|
(void)waitpid(child, &status, 0);
|
||||||
|
if (cap_fd >= 0) {
|
||||||
|
close(cap_fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
(void)signal(SIGPIPE, SIG_IGN);
|
||||||
|
|
||||||
|
test_stdin_happy_path();
|
||||||
|
test_stdin_eof_without_newline();
|
||||||
|
test_stdin_invalid_mnemonic();
|
||||||
|
test_stdin_overflow();
|
||||||
|
test_fd_happy_path();
|
||||||
|
test_mutual_exclusion_and_validation_errors();
|
||||||
|
test_lock_reunlock_falls_back_to_tui_after_fd_startup();
|
||||||
|
|
||||||
|
if (g_failures == 0) {
|
||||||
|
printf("ALL TESTS PASSED\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("TESTS FAILED: %d\n", g_failures);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
694
tests/test_policy.c
Normal file
694
tests/test_policy.c
Normal file
@@ -0,0 +1,694 @@
|
|||||||
|
/* NSIGNER_HEADERLESS_DECLS_BEGIN */
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <cJSON.h>
|
||||||
|
|
||||||
|
/* from secure_mem.h */
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Secure memory buffer — mlock'd, zeroized on free.
|
||||||
|
* Used for mnemonic phrases, private keys, and any sensitive material.
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
void *data; /* pointer to locked allocation */
|
||||||
|
size_t size; /* usable size in bytes */
|
||||||
|
int locked; /* 1 if mlock succeeded */
|
||||||
|
} secure_buf_t;
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
|
||||||
|
/* Zeroize and free a secure buffer. Always succeeds (idempotent). */
|
||||||
|
void secure_buf_free(secure_buf_t *buf);
|
||||||
|
|
||||||
|
/* Zeroize `len` bytes at `ptr` in a way the compiler cannot optimize away. */
|
||||||
|
void secure_memzero(void *ptr, size_t len);
|
||||||
|
|
||||||
|
|
||||||
|
/* from mnemonic.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Maximum mnemonic length: 24 words * 10 chars avg + spaces + null = 256 is safe */
|
||||||
|
#define MNEMONIC_MAX_LEN 256
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mnemonic state — holds the loaded mnemonic in secure memory.
|
||||||
|
* Only one mnemonic is active at a time per process.
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
secure_buf_t buf; /* secure storage for the mnemonic string */
|
||||||
|
int loaded; /* 1 if a mnemonic is currently loaded */
|
||||||
|
int word_count; /* 12, 15, 18, 21, or 24 */
|
||||||
|
} mnemonic_state_t;
|
||||||
|
|
||||||
|
/* Initialize mnemonic state (must be called before use). */
|
||||||
|
void mnemonic_init(mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Load a mnemonic string into secure memory. Validates word count (12/15/18/21/24).
|
||||||
|
* Returns 0 on success, -1 on invalid input, -2 on memory error. */
|
||||||
|
int mnemonic_load(mnemonic_state_t *state, const char *phrase);
|
||||||
|
|
||||||
|
/* Zeroize and unload the mnemonic. Idempotent. */
|
||||||
|
void mnemonic_unload(mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Check if a mnemonic is currently loaded. */
|
||||||
|
int mnemonic_is_loaded(const mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Get the mnemonic string (only valid while loaded). Returns NULL if not loaded. */
|
||||||
|
const char *mnemonic_get_phrase(const mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Generate a new BIP-39 mnemonic phrase (12/15/18/21/24 words) into out.
|
||||||
|
* Returns 0 on success, -1 on invalid arguments or generation failure. */
|
||||||
|
int mnemonic_generate(int word_count, char *out, size_t out_len);
|
||||||
|
|
||||||
|
|
||||||
|
/* from role_table.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Maximum limits */
|
||||||
|
#define ROLE_NAME_MAX 64
|
||||||
|
#define ROLE_PATH_MAX 128
|
||||||
|
#define ROLE_PURPOSE_MAX 32
|
||||||
|
#define ROLE_CURVE_MAX 16
|
||||||
|
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||||
|
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||||
|
|
||||||
|
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||||
|
typedef enum {
|
||||||
|
PURPOSE_NOSTR = 0,
|
||||||
|
PURPOSE_BITCOIN,
|
||||||
|
PURPOSE_SSH,
|
||||||
|
PURPOSE_AGE,
|
||||||
|
PURPOSE_FIPS,
|
||||||
|
PURPOSE_UNKNOWN
|
||||||
|
} role_purpose_t;
|
||||||
|
|
||||||
|
/* Curve enum */
|
||||||
|
typedef enum {
|
||||||
|
CURVE_SECP256K1 = 0,
|
||||||
|
CURVE_ED25519,
|
||||||
|
CURVE_X25519,
|
||||||
|
CURVE_UNKNOWN
|
||||||
|
} role_curve_t;
|
||||||
|
|
||||||
|
/* Selector type — how this role's key is addressed */
|
||||||
|
typedef enum {
|
||||||
|
SELECTOR_NOSTR_INDEX, /* uses nostr_index shorthand */
|
||||||
|
SELECTOR_ROLE_PATH /* uses explicit full path */
|
||||||
|
} role_selector_type_t;
|
||||||
|
|
||||||
|
/* A single role entry */
|
||||||
|
typedef struct {
|
||||||
|
char name[ROLE_NAME_MAX];
|
||||||
|
char purpose_str[ROLE_PURPOSE_MAX];
|
||||||
|
char curve_str[ROLE_CURVE_MAX];
|
||||||
|
role_purpose_t purpose;
|
||||||
|
role_curve_t curve;
|
||||||
|
role_selector_type_t selector_type;
|
||||||
|
int nostr_index; /* valid if selector_type == SELECTOR_NOSTR_INDEX */
|
||||||
|
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
|
||||||
|
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
|
||||||
|
int derived; /* 1 if pubkey_hex has been populated */
|
||||||
|
} role_entry_t;
|
||||||
|
|
||||||
|
/* The role table */
|
||||||
|
typedef struct {
|
||||||
|
role_entry_t entries[ROLE_TABLE_MAX_ENTRIES];
|
||||||
|
int count;
|
||||||
|
} role_table_t;
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
|
||||||
|
/* Find a role by nostr_index. Returns pointer or NULL. */
|
||||||
|
role_entry_t *role_table_find_by_nostr_index(role_table_t *table, int index);
|
||||||
|
|
||||||
|
/* Find a role by role_path. Returns pointer or NULL. */
|
||||||
|
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);
|
||||||
|
|
||||||
|
/* Parse purpose string to enum */
|
||||||
|
role_purpose_t role_purpose_from_str(const char *s);
|
||||||
|
|
||||||
|
/* Parse curve string to enum */
|
||||||
|
role_curve_t role_curve_from_str(const char *s);
|
||||||
|
|
||||||
|
/* Purpose enum to string */
|
||||||
|
const char *role_purpose_to_str(role_purpose_t p);
|
||||||
|
|
||||||
|
/* Curve enum to string */
|
||||||
|
const char *role_curve_to_str(role_curve_t c);
|
||||||
|
|
||||||
|
|
||||||
|
/* from selector.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Error codes for selector resolution */
|
||||||
|
#define SELECTOR_OK 0
|
||||||
|
#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 */
|
||||||
|
|
||||||
|
/* Parsed selector from a request's options object */
|
||||||
|
typedef struct {
|
||||||
|
int has_role; /* 1 if "role" field was present */
|
||||||
|
char role_name[ROLE_NAME_MAX];
|
||||||
|
|
||||||
|
int has_nostr_index; /* 1 if "nostr_index" field was present */
|
||||||
|
int nostr_index;
|
||||||
|
|
||||||
|
int has_role_path; /* 1 if "role_path" field was present */
|
||||||
|
char role_path[ROLE_PATH_MAX];
|
||||||
|
} selector_request_t;
|
||||||
|
|
||||||
|
/* Initialize a selector request (all fields zeroed/unset) */
|
||||||
|
void selector_request_init(selector_request_t *req);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Resolve a selector request against the role table.
|
||||||
|
* On success (returns SELECTOR_OK), *out points to the matched role_entry_t.
|
||||||
|
* On failure, returns one of the SELECTOR_ERR_* codes and *out is NULL.
|
||||||
|
*/
|
||||||
|
int selector_resolve(const selector_request_t *req, role_table_t *table, role_entry_t **out);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return a human-readable error string for a selector error code.
|
||||||
|
*/
|
||||||
|
const char *selector_strerror(int err);
|
||||||
|
|
||||||
|
|
||||||
|
/* from enforcement.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Error codes */
|
||||||
|
#define ENFORCE_OK 0
|
||||||
|
#define ENFORCE_ERR_PURPOSE -1 /* purpose mismatch */
|
||||||
|
#define ENFORCE_ERR_CURVE -2 /* curve mismatch */
|
||||||
|
#define ENFORCE_ERR_UNKNOWN_VERB -3 /* verb not recognized */
|
||||||
|
|
||||||
|
/* Known verbs */
|
||||||
|
#define VERB_SIGN_EVENT "sign_event"
|
||||||
|
#define VERB_GET_PUBLIC_KEY "get_public_key"
|
||||||
|
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
|
||||||
|
#define VERB_NIP44_DECRYPT "nip44_decrypt"
|
||||||
|
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
|
||||||
|
#define VERB_NIP04_DECRYPT "nip04_decrypt"
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check whether `verb` is allowed to execute against `role`.
|
||||||
|
* Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code.
|
||||||
|
*
|
||||||
|
* The enforcement rules are:
|
||||||
|
* - All nostr verbs (sign_event, get_public_key, nip44_*, nip04_*) require:
|
||||||
|
* purpose == PURPOSE_NOSTR and curve == CURVE_SECP256K1
|
||||||
|
* - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed).
|
||||||
|
*/
|
||||||
|
int enforce_verb_role(const char *verb, const role_entry_t *role);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return a human-readable error string for an enforcement error code.
|
||||||
|
*/
|
||||||
|
const char *enforce_strerror(int err);
|
||||||
|
|
||||||
|
|
||||||
|
/* from policy.h */
|
||||||
|
|
||||||
|
|
||||||
|
#define POLICY_MAX_ENTRIES 32
|
||||||
|
#define POLICY_MAX_VERBS 16
|
||||||
|
#define POLICY_MAX_ROLES 16
|
||||||
|
#define POLICY_MAX_PURPOSES 8
|
||||||
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
|
/* Prompt behavior */
|
||||||
|
typedef enum {
|
||||||
|
PROMPT_NEVER = 0,
|
||||||
|
PROMPT_FIRST_PER_BOOT,
|
||||||
|
PROMPT_EVERY_REQUEST,
|
||||||
|
PROMPT_DENY
|
||||||
|
} prompt_mode_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||||
|
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||||
|
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||||
|
} policy_source_t;
|
||||||
|
|
||||||
|
/* A single policy entry */
|
||||||
|
typedef struct {
|
||||||
|
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||||
|
char verbs[POLICY_MAX_VERBS][POLICY_VERB_MAX_LEN];
|
||||||
|
int verb_count;
|
||||||
|
char roles[POLICY_MAX_ROLES][ROLE_NAME_MAX];
|
||||||
|
int role_count;
|
||||||
|
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||||
|
int purpose_count;
|
||||||
|
prompt_mode_t prompt;
|
||||||
|
policy_source_t source;
|
||||||
|
} policy_entry_t;
|
||||||
|
|
||||||
|
/* Policy table */
|
||||||
|
typedef struct {
|
||||||
|
policy_entry_t entries[POLICY_MAX_ENTRIES];
|
||||||
|
int count;
|
||||||
|
} policy_table_t;
|
||||||
|
|
||||||
|
/* Policy check result */
|
||||||
|
#define POLICY_ALLOW 0
|
||||||
|
#define POLICY_DENY -1
|
||||||
|
#define POLICY_PROMPT -2 /* would need user confirmation */
|
||||||
|
#define POLICY_NO_MATCH -3 /* no policy entry matched (fail-closed = deny) */
|
||||||
|
|
||||||
|
/* Initialize policy table */
|
||||||
|
void policy_table_init(policy_table_t *table);
|
||||||
|
|
||||||
|
/* Initialize default policy table entries. */
|
||||||
|
void policy_init_default(policy_table_t *table, uid_t owner_uid);
|
||||||
|
|
||||||
|
/* Add a policy entry. Returns 0 on success, -1 if full. */
|
||||||
|
int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||||
|
|
||||||
|
/* Insert an entry before the final catch-all rule. */
|
||||||
|
int policy_table_insert_before_last(policy_table_t *table, const policy_entry_t *entry);
|
||||||
|
|
||||||
|
/* Parse a --preapprove spec into a policy entry. */
|
||||||
|
int parse_preapprove_spec(const char *spec, policy_entry_t *out_entry, int *out_nostr_index);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check whether caller_id is allowed to invoke `verb` on `role_name` with given `purpose`.
|
||||||
|
* 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);
|
||||||
|
|
||||||
|
/* Parse prompt mode from string */
|
||||||
|
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||||
|
|
||||||
|
/* Prompt mode to string */
|
||||||
|
const char *prompt_mode_to_str(prompt_mode_t m);
|
||||||
|
|
||||||
|
|
||||||
|
/* from crypto.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Per-role derived key material (stored in secure memory) */
|
||||||
|
typedef struct {
|
||||||
|
secure_buf_t private_key; /* 32 bytes, mlock'd */
|
||||||
|
unsigned char public_key[32];
|
||||||
|
char pubkey_hex[65]; /* 64 hex chars + null */
|
||||||
|
char npub[128]; /* bech32 npub */
|
||||||
|
int valid;
|
||||||
|
} derived_key_t;
|
||||||
|
|
||||||
|
/* Key store — holds derived keys for all roles */
|
||||||
|
typedef struct {
|
||||||
|
derived_key_t keys[ROLE_TABLE_MAX_ENTRIES];
|
||||||
|
int count;
|
||||||
|
} key_store_t;
|
||||||
|
|
||||||
|
/* Derive keys for all roles in the table using the loaded mnemonic.
|
||||||
|
* Populates key_store and sets role->pubkey_hex and role->derived for each role.
|
||||||
|
* Only derives for roles with purpose=nostr and curve=secp256k1 (for now).
|
||||||
|
* Returns number of keys derived, or -1 on error. */
|
||||||
|
int crypto_derive_all(key_store_t *store, role_table_t *table, const mnemonic_state_t *mnemonic);
|
||||||
|
|
||||||
|
/* Get the derived private key for a role (by table index). Returns NULL if not derived. */
|
||||||
|
const unsigned char *crypto_get_private_key(const key_store_t *store, int role_index);
|
||||||
|
|
||||||
|
/* Get the derived public key hex for a role. Returns NULL if not derived. */
|
||||||
|
const char *crypto_get_pubkey_hex(const key_store_t *store, int role_index);
|
||||||
|
|
||||||
|
/* Sign a Nostr event. event_json is the unsigned event JSON string.
|
||||||
|
* Returns a newly-allocated string containing the signed event JSON, or NULL on error.
|
||||||
|
* Caller must free() the returned string. */
|
||||||
|
char *crypto_sign_event(const key_store_t *store, int role_index, const char *event_json);
|
||||||
|
|
||||||
|
/* Zeroize all derived keys in the store. */
|
||||||
|
void crypto_wipe(key_store_t *store);
|
||||||
|
|
||||||
|
|
||||||
|
/* from dispatcher.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Dispatcher context — holds references to shared state */
|
||||||
|
typedef struct {
|
||||||
|
role_table_t *role_table;
|
||||||
|
mnemonic_state_t *mnemonic;
|
||||||
|
key_store_t *key_store;
|
||||||
|
} dispatcher_ctx_t;
|
||||||
|
|
||||||
|
/* Initialize dispatcher context */
|
||||||
|
void dispatcher_init(dispatcher_ctx_t *ctx, role_table_t *table, mnemonic_state_t *mnemonic, key_store_t *key_store);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Process a JSON-RPC request string and produce a JSON-RPC response string.
|
||||||
|
*
|
||||||
|
* The caller owns the returned string and must free() it.
|
||||||
|
* Returns NULL only on catastrophic allocation failure.
|
||||||
|
*
|
||||||
|
* Response format on success:
|
||||||
|
* { "id": "...", "result": "..." }
|
||||||
|
*
|
||||||
|
* Response format on error:
|
||||||
|
* { "id": "...", "error": { "code": <int>, "message": "..." } }
|
||||||
|
*
|
||||||
|
* Error codes:
|
||||||
|
* -32700 Parse error (invalid JSON)
|
||||||
|
* -32600 Invalid request (missing id/method/params)
|
||||||
|
* -32601 Method not found (unknown verb after enforcement)
|
||||||
|
* -32602 Invalid params
|
||||||
|
* 1001 ambiguous_role_selector
|
||||||
|
* 1002 role_not_found
|
||||||
|
* 1003 no_default_role
|
||||||
|
* 1004 purpose_mismatch
|
||||||
|
* 1005 curve_mismatch
|
||||||
|
* 1006 mnemonic_not_loaded
|
||||||
|
*/
|
||||||
|
char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request);
|
||||||
|
|
||||||
|
|
||||||
|
/* from server.h */
|
||||||
|
|
||||||
|
|
||||||
|
#define SERVER_SOCKET_NAME_MAX 108
|
||||||
|
#define SERVER_MAX_MSG_SIZE 65536
|
||||||
|
|
||||||
|
/* Caller identity */
|
||||||
|
typedef struct {
|
||||||
|
uid_t uid;
|
||||||
|
gid_t gid;
|
||||||
|
pid_t pid;
|
||||||
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
|
} caller_identity_t;
|
||||||
|
|
||||||
|
/* Server context */
|
||||||
|
typedef struct {
|
||||||
|
char socket_name[SERVER_SOCKET_NAME_MAX]; /* abstract namespace name (without \0 prefix) */
|
||||||
|
char last_error[256];
|
||||||
|
int listen_fd;
|
||||||
|
int running;
|
||||||
|
dispatcher_ctx_t *dispatcher;
|
||||||
|
policy_table_t *policy;
|
||||||
|
int socket_name_explicit;
|
||||||
|
} server_ctx_t;
|
||||||
|
|
||||||
|
/* Initialize server context. socket_name is the abstract namespace name (e.g. "nsigner").
|
||||||
|
* socket_name_explicit should be non-zero when provided via --socket-name override. */
|
||||||
|
void server_init(server_ctx_t *ctx, const char *socket_name, int socket_name_explicit,
|
||||||
|
dispatcher_ctx_t *dispatcher, policy_table_t *policy);
|
||||||
|
|
||||||
|
/* Start listening. Returns 0 on success, -1 on error. */
|
||||||
|
int server_start(server_ctx_t *ctx);
|
||||||
|
|
||||||
|
/* Get human-readable description of last server error. */
|
||||||
|
const char *server_last_error(const server_ctx_t *ctx);
|
||||||
|
|
||||||
|
/* Handle one pending connection (non-blocking). Returns 1 if handled, 0 if nothing pending, -1 on error.
|
||||||
|
* activity_cb is called with a description string for the TUI activity log. */
|
||||||
|
typedef void (*server_activity_cb)(const char *message, void *user_data);
|
||||||
|
int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data);
|
||||||
|
|
||||||
|
/* Stop server and close socket */
|
||||||
|
void server_stop(server_ctx_t *ctx);
|
||||||
|
|
||||||
|
/* Extract caller identity from connected fd */
|
||||||
|
int server_get_caller(int fd, caller_identity_t *out);
|
||||||
|
|
||||||
|
|
||||||
|
/* from socket_name.h */
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Generate random socket name in format: nsigner_<word1>_<word2>
|
||||||
|
* Returns 0 on success, -1 on error.
|
||||||
|
*/
|
||||||
|
int socket_name_random(char *out, size_t out_len);
|
||||||
|
|
||||||
|
|
||||||
|
/* from main.h */
|
||||||
|
/*
|
||||||
|
* nsigner main header - version information
|
||||||
|
*
|
||||||
|
* Version macros are auto-updated by increment_and_push.sh.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
/* Version information (auto-updated by build/version tooling) */
|
||||||
|
#define NSIGNER_VERSION_MAJOR 0
|
||||||
|
#define NSIGNER_VERSION_MINOR 0
|
||||||
|
#define NSIGNER_VERSION_PATCH 2
|
||||||
|
#define NSIGNER_VERSION "v0.0.2"
|
||||||
|
|
||||||
|
|
||||||
|
/* NSIGNER_HEADERLESS_DECLS_END */
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
static int g_passes = 0;
|
||||||
|
static int g_total = 0;
|
||||||
|
|
||||||
|
static void check_condition(const char *name, int condition) {
|
||||||
|
g_total++;
|
||||||
|
if (condition) {
|
||||||
|
printf("PASS: %s\n", name);
|
||||||
|
g_passes++;
|
||||||
|
} else {
|
||||||
|
printf("FAIL: %s\n", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void add_single_entry(policy_table_t *table,
|
||||||
|
const char *caller,
|
||||||
|
const char *verb,
|
||||||
|
const char *role,
|
||||||
|
const char *purpose,
|
||||||
|
prompt_mode_t prompt) {
|
||||||
|
policy_entry_t e;
|
||||||
|
|
||||||
|
memset(&e, 0, sizeof(e));
|
||||||
|
strncpy(e.caller, caller, sizeof(e.caller) - 1);
|
||||||
|
if (verb != NULL) {
|
||||||
|
strncpy(e.verbs[0], verb, sizeof(e.verbs[0]) - 1);
|
||||||
|
e.verb_count = 1;
|
||||||
|
}
|
||||||
|
if (role != NULL) {
|
||||||
|
strncpy(e.roles[0], role, sizeof(e.roles[0]) - 1);
|
||||||
|
e.role_count = 1;
|
||||||
|
}
|
||||||
|
if (purpose != NULL) {
|
||||||
|
strncpy(e.purposes[0], purpose, sizeof(e.purposes[0]) - 1);
|
||||||
|
e.purpose_count = 1;
|
||||||
|
}
|
||||||
|
e.prompt = prompt;
|
||||||
|
|
||||||
|
policy_table_add(table, &e);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
policy_table_t table;
|
||||||
|
policy_entry_t parsed;
|
||||||
|
int rc;
|
||||||
|
int parsed_nostr_index;
|
||||||
|
uid_t uid = getuid();
|
||||||
|
char same_uid[64];
|
||||||
|
char other_uid[64];
|
||||||
|
|
||||||
|
(void)snprintf(same_uid, sizeof(same_uid), "uid:%u", (unsigned int)uid);
|
||||||
|
(void)snprintf(other_uid, sizeof(other_uid), "uid:%u", (unsigned int)(uid + 1U));
|
||||||
|
|
||||||
|
rc = parse_preapprove_spec("caller=uid:1000,role=main", &parsed, &parsed_nostr_index);
|
||||||
|
check_condition("parse_preapprove_spec accepts caller+role", rc == 0);
|
||||||
|
check_condition("parse_preapprove_spec caller set", strcmp(parsed.caller, "uid:1000") == 0);
|
||||||
|
check_condition("parse_preapprove_spec role set", strcmp(parsed.roles[0], "main") == 0);
|
||||||
|
check_condition("parse_preapprove_spec role_count=1", parsed.role_count == 1);
|
||||||
|
check_condition("parse_preapprove_spec prompt=never", parsed.prompt == PROMPT_NEVER);
|
||||||
|
check_condition("parse_preapprove_spec nostr_index unchanged for role", parsed_nostr_index == -1);
|
||||||
|
|
||||||
|
rc = parse_preapprove_spec("caller=uid:1000,nostr_index=0", &parsed, &parsed_nostr_index);
|
||||||
|
check_condition("parse_preapprove_spec accepts caller+nostr_index", rc == 0);
|
||||||
|
check_condition("parse_preapprove_spec nostr_index=0 maps to main", strcmp(parsed.roles[0], "main") == 0);
|
||||||
|
check_condition("parse_preapprove_spec returns parsed nostr_index", parsed_nostr_index == 0);
|
||||||
|
|
||||||
|
rc = parse_preapprove_spec("caller=uid:1000,nostr_index=2", &parsed, &parsed_nostr_index);
|
||||||
|
check_condition("parse_preapprove_spec maps nonzero nostr_index role", strcmp(parsed.roles[0], "nostr_idx_2") == 0);
|
||||||
|
check_condition("parse_preapprove_spec stores nonzero nostr_index", parsed_nostr_index == 2);
|
||||||
|
|
||||||
|
rc = parse_preapprove_spec("caller=qubes:personal+pubkey:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef,role=main",
|
||||||
|
&parsed,
|
||||||
|
&parsed_nostr_index);
|
||||||
|
check_condition("parse_preapprove_spec accepts composite qubes+pubkey caller", rc == 0);
|
||||||
|
check_condition("parse_preapprove_spec stores composite caller",
|
||||||
|
strcmp(parsed.caller,
|
||||||
|
"qubes:personal+pubkey:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") == 0);
|
||||||
|
|
||||||
|
rc = parse_preapprove_spec("role=main", &parsed, &parsed_nostr_index);
|
||||||
|
check_condition("parse_preapprove_spec rejects missing caller", rc == -1);
|
||||||
|
rc = parse_preapprove_spec("caller=uid:1000", &parsed, &parsed_nostr_index);
|
||||||
|
check_condition("parse_preapprove_spec rejects missing role and nostr_index", rc == -1);
|
||||||
|
rc = parse_preapprove_spec("caller=uid:1000,role=main,nostr_index=0", &parsed, &parsed_nostr_index);
|
||||||
|
check_condition("parse_preapprove_spec rejects both role and nostr_index", rc == -1);
|
||||||
|
rc = parse_preapprove_spec("caller=uid:1000,nostr_index=-1", &parsed, &parsed_nostr_index);
|
||||||
|
check_condition("parse_preapprove_spec rejects negative nostr_index", rc == -1);
|
||||||
|
rc = parse_preapprove_spec("caller=uid:1000,nostr_index=abc", &parsed, &parsed_nostr_index);
|
||||||
|
check_condition("parse_preapprove_spec rejects non-numeric nostr_index", rc == -1);
|
||||||
|
|
||||||
|
policy_init_default(&table, uid);
|
||||||
|
rc = policy_check(&table, same_uid, "sign_event", "main", "nostr", NULL);
|
||||||
|
check_condition("policy_init_default prompts same uid", rc == POLICY_PROMPT);
|
||||||
|
|
||||||
|
rc = policy_check(&table, other_uid, "sign_event", "main", "nostr", NULL);
|
||||||
|
check_condition("policy_init_default prompts different uid", rc == POLICY_PROMPT);
|
||||||
|
|
||||||
|
/* Insert before catch-all: matching caller allowed, non-matching still prompted */
|
||||||
|
policy_table_init(&table);
|
||||||
|
add_single_entry(&table, "*", NULL, NULL, NULL, PROMPT_EVERY_REQUEST);
|
||||||
|
{
|
||||||
|
policy_entry_t grant;
|
||||||
|
memset(&grant, 0, sizeof(grant));
|
||||||
|
strncpy(grant.caller, "uid:1000", sizeof(grant.caller) - 1);
|
||||||
|
strncpy(grant.roles[0], "main", sizeof(grant.roles[0]) - 1);
|
||||||
|
grant.role_count = 1;
|
||||||
|
grant.prompt = PROMPT_NEVER;
|
||||||
|
rc = policy_table_insert_before_last(&table, &grant);
|
||||||
|
check_condition("policy_table_insert_before_last succeeds", rc == 0);
|
||||||
|
}
|
||||||
|
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", NULL);
|
||||||
|
check_condition("insert_before_last matching caller returns POLICY_ALLOW", rc == POLICY_ALLOW);
|
||||||
|
rc = policy_check(&table, "uid:2000", "sign_event", "main", "nostr", NULL);
|
||||||
|
check_condition("insert_before_last non-matching caller returns POLICY_PROMPT", rc == POLICY_PROMPT);
|
||||||
|
|
||||||
|
/* Preapprove parse + insert: matching caller allowed, non-matching prompted */
|
||||||
|
policy_init_default(&table, uid);
|
||||||
|
rc = parse_preapprove_spec("caller=uid:1000,role=main", &parsed, &parsed_nostr_index);
|
||||||
|
check_condition("preapprove parse for policy insert succeeds", rc == 0);
|
||||||
|
rc = policy_table_insert_before_last(&table, &parsed);
|
||||||
|
check_condition("preapprove insert_before_last succeeds", rc == 0);
|
||||||
|
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", NULL);
|
||||||
|
check_condition("preapprove allows matching caller+role", rc == POLICY_ALLOW);
|
||||||
|
rc = policy_check(&table, "uid:2000", "sign_event", "main", "nostr", NULL);
|
||||||
|
check_condition("preapprove leaves non-matching caller at POLICY_PROMPT", rc == POLICY_PROMPT);
|
||||||
|
|
||||||
|
/* Exact caller, verb, role, purpose + never => allow */
|
||||||
|
policy_table_init(&table);
|
||||||
|
add_single_entry(&table, "uid:1000", "sign_event", "main", "nostr", PROMPT_NEVER);
|
||||||
|
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", NULL);
|
||||||
|
check_condition("exact match returns POLICY_ALLOW", rc == POLICY_ALLOW);
|
||||||
|
|
||||||
|
/* Wildcard caller + deny => deny */
|
||||||
|
policy_table_init(&table);
|
||||||
|
add_single_entry(&table, "*", "sign_event", "main", "nostr", PROMPT_DENY);
|
||||||
|
rc = policy_check(&table, "uid:2000", "sign_event", "main", "nostr", NULL);
|
||||||
|
check_condition("wildcard caller with deny returns POLICY_DENY", rc == POLICY_DENY);
|
||||||
|
|
||||||
|
/* Caller no match => POLICY_NO_MATCH */
|
||||||
|
policy_table_init(&table);
|
||||||
|
add_single_entry(&table, "uid:1000", "sign_event", "main", "nostr", PROMPT_NEVER);
|
||||||
|
rc = policy_check(&table, "uid:9999", "sign_event", "main", "nostr", NULL);
|
||||||
|
check_condition("caller mismatch returns POLICY_NO_MATCH", rc == POLICY_NO_MATCH);
|
||||||
|
|
||||||
|
/* Verb not in list => no match */
|
||||||
|
policy_table_init(&table);
|
||||||
|
add_single_entry(&table, "uid:1000", "get_public_key", "main", "nostr", PROMPT_NEVER);
|
||||||
|
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", NULL);
|
||||||
|
check_condition("verb mismatch returns POLICY_NO_MATCH", rc == POLICY_NO_MATCH);
|
||||||
|
|
||||||
|
/* Role not in list => no match */
|
||||||
|
policy_table_init(&table);
|
||||||
|
add_single_entry(&table, "uid:1000", "sign_event", "throwaway", "nostr", PROMPT_NEVER);
|
||||||
|
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", NULL);
|
||||||
|
check_condition("role mismatch returns POLICY_NO_MATCH", rc == POLICY_NO_MATCH);
|
||||||
|
|
||||||
|
/* Purpose not in list => no match */
|
||||||
|
policy_table_init(&table);
|
||||||
|
add_single_entry(&table, "uid:1000", "sign_event", "main", "bitcoin", PROMPT_NEVER);
|
||||||
|
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", NULL);
|
||||||
|
check_condition("purpose mismatch returns POLICY_NO_MATCH", rc == POLICY_NO_MATCH);
|
||||||
|
|
||||||
|
/* Empty verbs list means all verbs */
|
||||||
|
policy_table_init(&table);
|
||||||
|
add_single_entry(&table, "uid:1000", NULL, "main", "nostr", PROMPT_NEVER);
|
||||||
|
rc = policy_check(&table, "uid:1000", "nip44_encrypt", "main", "nostr", NULL);
|
||||||
|
check_condition("empty verbs list matches any verb", rc == POLICY_ALLOW);
|
||||||
|
|
||||||
|
/* prompt=first_per_boot => POLICY_PROMPT */
|
||||||
|
policy_table_init(&table);
|
||||||
|
add_single_entry(&table, "uid:1000", "sign_event", "main", "nostr", PROMPT_FIRST_PER_BOOT);
|
||||||
|
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", NULL);
|
||||||
|
check_condition("first_per_boot returns POLICY_PROMPT", rc == POLICY_PROMPT);
|
||||||
|
|
||||||
|
/* prompt=every_request => POLICY_PROMPT */
|
||||||
|
policy_table_init(&table);
|
||||||
|
add_single_entry(&table, "uid:1000", "sign_event", "main", "nostr", PROMPT_EVERY_REQUEST);
|
||||||
|
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", NULL);
|
||||||
|
check_condition("every_request returns POLICY_PROMPT", rc == POLICY_PROMPT);
|
||||||
|
|
||||||
|
/* First matching entry wins */
|
||||||
|
policy_table_init(&table);
|
||||||
|
add_single_entry(&table, "uid:1000", "sign_event", "main", "nostr", PROMPT_DENY);
|
||||||
|
add_single_entry(&table, "uid:1000", "sign_event", "main", "nostr", PROMPT_NEVER);
|
||||||
|
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", NULL);
|
||||||
|
check_condition("first matching entry wins", rc == POLICY_DENY);
|
||||||
|
|
||||||
|
/* Verify out_source for preapprove, session-grant, and default catch-all */
|
||||||
|
policy_table_init(&table);
|
||||||
|
{
|
||||||
|
policy_entry_t pre;
|
||||||
|
policy_entry_t sess;
|
||||||
|
policy_entry_t prompt_all;
|
||||||
|
policy_source_t src = POLICY_SOURCE_DEFAULT;
|
||||||
|
|
||||||
|
memset(&pre, 0, sizeof(pre));
|
||||||
|
strncpy(pre.caller, "uid:1000", sizeof(pre.caller) - 1);
|
||||||
|
strncpy(pre.roles[0], "main", sizeof(pre.roles[0]) - 1);
|
||||||
|
pre.role_count = 1;
|
||||||
|
pre.prompt = PROMPT_NEVER;
|
||||||
|
pre.source = POLICY_SOURCE_PREAPPROVE;
|
||||||
|
rc = policy_table_add(&table, &pre);
|
||||||
|
check_condition("source test preapprove entry add", rc == 0);
|
||||||
|
|
||||||
|
memset(&sess, 0, sizeof(sess));
|
||||||
|
strncpy(sess.caller, "uid:1001", sizeof(sess.caller) - 1);
|
||||||
|
strncpy(sess.roles[0], "main", sizeof(sess.roles[0]) - 1);
|
||||||
|
sess.role_count = 1;
|
||||||
|
sess.prompt = PROMPT_NEVER;
|
||||||
|
sess.source = POLICY_SOURCE_SESSION_GRANT;
|
||||||
|
rc = policy_table_add(&table, &sess);
|
||||||
|
check_condition("source test session entry add", rc == 0);
|
||||||
|
|
||||||
|
memset(&prompt_all, 0, sizeof(prompt_all));
|
||||||
|
strncpy(prompt_all.caller, "*", sizeof(prompt_all.caller) - 1);
|
||||||
|
prompt_all.prompt = PROMPT_EVERY_REQUEST;
|
||||||
|
prompt_all.source = POLICY_SOURCE_DEFAULT;
|
||||||
|
rc = policy_table_add(&table, &prompt_all);
|
||||||
|
check_condition("source test default catch-all add", rc == 0);
|
||||||
|
|
||||||
|
src = POLICY_SOURCE_DEFAULT;
|
||||||
|
rc = policy_check(&table, "uid:1000", "sign_event", "main", "nostr", &src);
|
||||||
|
check_condition("policy_check reports preapprove source", rc == POLICY_ALLOW && src == POLICY_SOURCE_PREAPPROVE);
|
||||||
|
|
||||||
|
src = POLICY_SOURCE_DEFAULT;
|
||||||
|
rc = policy_check(&table, "uid:1001", "sign_event", "main", "nostr", &src);
|
||||||
|
check_condition("policy_check reports session-grant source", rc == POLICY_ALLOW && src == POLICY_SOURCE_SESSION_GRANT);
|
||||||
|
|
||||||
|
src = POLICY_SOURCE_PREAPPROVE;
|
||||||
|
rc = policy_check(&table, "uid:9999", "sign_event", "main", "nostr", &src);
|
||||||
|
check_condition("policy_check resets source for prompt/default", rc == POLICY_PROMPT && src == POLICY_SOURCE_DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("%d/%d tests passed\n", g_passes, g_total);
|
||||||
|
return (g_passes == g_total) ? 0 : 1;
|
||||||
|
}
|
||||||
369
tests/test_qrexec_auth.c
Normal file
369
tests/test_qrexec_auth.c
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
#define _GNU_SOURCE
|
||||||
|
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <errno.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <signal.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
#include <time.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <cJSON.h>
|
||||||
|
#include <nostr_core/nostr_common.h>
|
||||||
|
|
||||||
|
#include "../src/auth_envelope.h"
|
||||||
|
|
||||||
|
#define MAX_MSG_SIZE 65536
|
||||||
|
|
||||||
|
static int g_failures = 0;
|
||||||
|
|
||||||
|
static void check_condition(const char *name, int condition) {
|
||||||
|
if (condition) {
|
||||||
|
printf("PASS: %s\n", name);
|
||||||
|
} else {
|
||||||
|
printf("FAIL: %s\n", name);
|
||||||
|
g_failures++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int write_full(int fd, const void *buf, size_t len) {
|
||||||
|
const unsigned char *p = (const unsigned char *)buf;
|
||||||
|
size_t off = 0;
|
||||||
|
|
||||||
|
while (off < len) {
|
||||||
|
ssize_t n = write(fd, p + off, len - off);
|
||||||
|
if (n < 0) {
|
||||||
|
if (errno == EINTR) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
off += (size_t)n;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int send_framed(int fd, const char *payload) {
|
||||||
|
uint32_t len = (uint32_t)strlen(payload);
|
||||||
|
uint32_t be_len = htonl(len);
|
||||||
|
|
||||||
|
if (write_full(fd, &be_len, sizeof(be_len)) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (write_full(fd, payload, len) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int recv_framed(int fd, char **out_payload) {
|
||||||
|
unsigned char *buf = NULL;
|
||||||
|
size_t used = 0;
|
||||||
|
size_t cap = 0;
|
||||||
|
|
||||||
|
if (out_payload == NULL) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
*out_payload = NULL;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
unsigned char chunk[1024];
|
||||||
|
ssize_t n = read(fd, chunk, sizeof(chunk));
|
||||||
|
if (n == 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (n < 0) {
|
||||||
|
if (errno == EINTR) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
free(buf);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (used + (size_t)n > cap) {
|
||||||
|
size_t new_cap = (cap == 0) ? 2048 : cap * 2;
|
||||||
|
while (new_cap < used + (size_t)n) {
|
||||||
|
new_cap *= 2;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
unsigned char *tmp = (unsigned char *)realloc(buf, new_cap);
|
||||||
|
if (tmp == NULL) {
|
||||||
|
free(buf);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
buf = tmp;
|
||||||
|
cap = new_cap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
memcpy(buf + used, chunk, (size_t)n);
|
||||||
|
used += (size_t)n;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (used < 4) {
|
||||||
|
free(buf);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
size_t off;
|
||||||
|
for (off = 0; off + 4 <= used; ++off) {
|
||||||
|
uint32_t be_len;
|
||||||
|
uint32_t len;
|
||||||
|
|
||||||
|
memcpy(&be_len, buf + off, sizeof(be_len));
|
||||||
|
len = ntohl(be_len);
|
||||||
|
if (len == 0 || len > MAX_MSG_SIZE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (off + 4U + (size_t)len > used) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (buf[off + 4] != '{') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
char *payload = (char *)malloc((size_t)len + 1U);
|
||||||
|
if (payload == NULL) {
|
||||||
|
free(buf);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
memcpy(payload, buf + off + 4, (size_t)len);
|
||||||
|
payload[len] = '\0';
|
||||||
|
*out_payload = payload;
|
||||||
|
free(buf);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
free(buf);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static char *build_auth_request(const unsigned char privkey[32],
|
||||||
|
const char *req_id,
|
||||||
|
const char *method) {
|
||||||
|
cJSON *root = NULL;
|
||||||
|
cJSON *params = NULL;
|
||||||
|
cJSON *auth = NULL;
|
||||||
|
char *printed = NULL;
|
||||||
|
|
||||||
|
params = cJSON_CreateArray();
|
||||||
|
if (params == NULL) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
cJSON_AddItemToArray(params, cJSON_CreateString(""));
|
||||||
|
|
||||||
|
if (auth_envelope_build_for_request(req_id,
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
privkey,
|
||||||
|
"qrexec-test",
|
||||||
|
time(NULL),
|
||||||
|
&auth) != 0) {
|
||||||
|
cJSON_Delete(params);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
root = cJSON_CreateObject();
|
||||||
|
if (root == NULL) {
|
||||||
|
cJSON_Delete(params);
|
||||||
|
cJSON_Delete(auth);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
cJSON_AddStringToObject(root, "id", req_id);
|
||||||
|
cJSON_AddStringToObject(root, "method", method);
|
||||||
|
cJSON_AddItemToObject(root, "params", params);
|
||||||
|
cJSON_AddItemToObject(root, "auth", auth);
|
||||||
|
|
||||||
|
printed = cJSON_PrintUnformatted(root);
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return printed;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int run_qrexec_once(const char *request_json, char **out_response) {
|
||||||
|
int in_pipe[2];
|
||||||
|
int out_pipe[2];
|
||||||
|
pid_t child;
|
||||||
|
int status;
|
||||||
|
const char *mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about\n";
|
||||||
|
|
||||||
|
if (out_response == NULL || request_json == NULL) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
*out_response = NULL;
|
||||||
|
|
||||||
|
if (pipe(in_pipe) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (pipe(out_pipe) != 0) {
|
||||||
|
close(in_pipe[0]);
|
||||||
|
close(in_pipe[1]);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
child = fork();
|
||||||
|
if (child < 0) {
|
||||||
|
close(in_pipe[0]);
|
||||||
|
close(in_pipe[1]);
|
||||||
|
close(out_pipe[0]);
|
||||||
|
close(out_pipe[1]);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child == 0) {
|
||||||
|
int null_fd = open("/dev/null", O_WRONLY);
|
||||||
|
dup2(in_pipe[0], STDIN_FILENO);
|
||||||
|
dup2(out_pipe[1], STDOUT_FILENO);
|
||||||
|
if (null_fd >= 0) {
|
||||||
|
dup2(null_fd, STDERR_FILENO);
|
||||||
|
close(null_fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
close(in_pipe[0]);
|
||||||
|
close(in_pipe[1]);
|
||||||
|
close(out_pipe[0]);
|
||||||
|
close(out_pipe[1]);
|
||||||
|
|
||||||
|
(void)setenv("QREXEC_REMOTE_DOMAIN", "personal", 1);
|
||||||
|
|
||||||
|
execl("./build/nsigner",
|
||||||
|
"./build/nsigner",
|
||||||
|
"--listen", "qrexec",
|
||||||
|
"--auth", "optional",
|
||||||
|
"--preapprove", "caller=qubes:personal,role=main",
|
||||||
|
(char *)NULL);
|
||||||
|
_exit(127);
|
||||||
|
}
|
||||||
|
|
||||||
|
close(in_pipe[0]);
|
||||||
|
close(out_pipe[1]);
|
||||||
|
|
||||||
|
if (write_full(in_pipe[1], mnemonic, strlen(mnemonic)) != 0) {
|
||||||
|
close(in_pipe[1]);
|
||||||
|
close(out_pipe[0]);
|
||||||
|
(void)kill(child, SIGKILL);
|
||||||
|
(void)waitpid(child, &status, 0);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Give prompt_load_mnemonic()/fgets() time to consume only the mnemonic line
|
||||||
|
* before we write framed request bytes to the same stdin stream. */
|
||||||
|
{
|
||||||
|
struct timespec ts;
|
||||||
|
ts.tv_sec = 0;
|
||||||
|
ts.tv_nsec = 250 * 1000 * 1000;
|
||||||
|
(void)nanosleep(&ts, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (send_framed(in_pipe[1], request_json) != 0) {
|
||||||
|
close(in_pipe[1]);
|
||||||
|
close(out_pipe[0]);
|
||||||
|
(void)kill(child, SIGKILL);
|
||||||
|
(void)waitpid(child, &status, 0);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
close(in_pipe[1]);
|
||||||
|
|
||||||
|
if (recv_framed(out_pipe[0], out_response) != 0) {
|
||||||
|
close(out_pipe[0]);
|
||||||
|
(void)kill(child, SIGKILL);
|
||||||
|
(void)waitpid(child, &status, 0);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(out_pipe[0]);
|
||||||
|
if (waitpid(child, &status, 0) <= 0) {
|
||||||
|
free(*out_response);
|
||||||
|
*out_response = NULL;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
char *resp = NULL;
|
||||||
|
char *auth_req = NULL;
|
||||||
|
unsigned char privkey[32] = {
|
||||||
|
0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
|
||||||
|
};
|
||||||
|
|
||||||
|
check_condition("nostr_init for auth signing", nostr_init() == 0);
|
||||||
|
|
||||||
|
/* no auth => legacy qubes:personal preapprove should match */
|
||||||
|
if (run_qrexec_once("{\"id\":\"1\",\"method\":\"get_public_key\",\"params\":[\"\"]}", &resp) == 0) {
|
||||||
|
check_condition("qrexec optional/no-auth still allows legacy qubes caller preapprove",
|
||||||
|
strstr(resp, "\"result\":") != NULL);
|
||||||
|
free(resp);
|
||||||
|
resp = NULL;
|
||||||
|
} else {
|
||||||
|
check_condition("qrexec optional/no-auth request roundtrip", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
auth_req = build_auth_request(privkey, "2", "get_public_key");
|
||||||
|
check_condition("build qrexec auth request", auth_req != NULL);
|
||||||
|
if (auth_req != NULL) {
|
||||||
|
if (run_qrexec_once(auth_req, &resp) == 0) {
|
||||||
|
check_condition("qrexec optional/auth request uses composite identity and misses legacy preapprove",
|
||||||
|
strstr(resp, "\"code\":2001") != NULL && strstr(resp, "policy_denied") != NULL);
|
||||||
|
free(resp);
|
||||||
|
resp = NULL;
|
||||||
|
} else {
|
||||||
|
check_condition("qrexec optional/auth request roundtrip", 0);
|
||||||
|
}
|
||||||
|
free(auth_req);
|
||||||
|
auth_req = NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
auth_req = build_auth_request(privkey, "3", "get_public_key");
|
||||||
|
check_condition("build second qrexec auth request", auth_req != NULL);
|
||||||
|
if (auth_req != NULL) {
|
||||||
|
cJSON *root = cJSON_Parse(auth_req);
|
||||||
|
char *tampered = NULL;
|
||||||
|
cJSON *method_item;
|
||||||
|
|
||||||
|
if (root != NULL) {
|
||||||
|
method_item = cJSON_GetObjectItemCaseSensitive(root, "method");
|
||||||
|
if (cJSON_IsString(method_item)) {
|
||||||
|
cJSON_SetValuestring(method_item, "sign_event");
|
||||||
|
}
|
||||||
|
tampered = cJSON_PrintUnformatted(root);
|
||||||
|
cJSON_Delete(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tampered != NULL && run_qrexec_once(tampered, &resp) == 0) {
|
||||||
|
check_condition("qrexec optional/malformed-auth rejects with auth-layer code",
|
||||||
|
strstr(resp, "\"code\":2015") != NULL && strstr(resp, "auth_envelope_mismatch") != NULL);
|
||||||
|
free(resp);
|
||||||
|
resp = NULL;
|
||||||
|
} else {
|
||||||
|
check_condition("qrexec optional/malformed-auth request roundtrip", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
free(tampered);
|
||||||
|
free(auth_req);
|
||||||
|
}
|
||||||
|
|
||||||
|
nostr_cleanup();
|
||||||
|
|
||||||
|
if (g_failures == 0) {
|
||||||
|
printf("ALL TESTS PASSED\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("TESTS FAILED: %d\n", g_failures);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
574
tests/test_role_table.c
Normal file
574
tests/test_role_table.c
Normal file
@@ -0,0 +1,574 @@
|
|||||||
|
/* NSIGNER_HEADERLESS_DECLS_BEGIN */
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <cJSON.h>
|
||||||
|
|
||||||
|
/* from secure_mem.h */
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Secure memory buffer — mlock'd, zeroized on free.
|
||||||
|
* Used for mnemonic phrases, private keys, and any sensitive material.
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
void *data; /* pointer to locked allocation */
|
||||||
|
size_t size; /* usable size in bytes */
|
||||||
|
int locked; /* 1 if mlock succeeded */
|
||||||
|
} secure_buf_t;
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
|
||||||
|
/* Zeroize and free a secure buffer. Always succeeds (idempotent). */
|
||||||
|
void secure_buf_free(secure_buf_t *buf);
|
||||||
|
|
||||||
|
/* Zeroize `len` bytes at `ptr` in a way the compiler cannot optimize away. */
|
||||||
|
void secure_memzero(void *ptr, size_t len);
|
||||||
|
|
||||||
|
|
||||||
|
/* from mnemonic.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Maximum mnemonic length: 24 words * 10 chars avg + spaces + null = 256 is safe */
|
||||||
|
#define MNEMONIC_MAX_LEN 256
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mnemonic state — holds the loaded mnemonic in secure memory.
|
||||||
|
* Only one mnemonic is active at a time per process.
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
secure_buf_t buf; /* secure storage for the mnemonic string */
|
||||||
|
int loaded; /* 1 if a mnemonic is currently loaded */
|
||||||
|
int word_count; /* 12, 15, 18, 21, or 24 */
|
||||||
|
} mnemonic_state_t;
|
||||||
|
|
||||||
|
/* Initialize mnemonic state (must be called before use). */
|
||||||
|
void mnemonic_init(mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Load a mnemonic string into secure memory. Validates word count (12/15/18/21/24).
|
||||||
|
* Returns 0 on success, -1 on invalid input, -2 on memory error. */
|
||||||
|
int mnemonic_load(mnemonic_state_t *state, const char *phrase);
|
||||||
|
|
||||||
|
/* Zeroize and unload the mnemonic. Idempotent. */
|
||||||
|
void mnemonic_unload(mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Check if a mnemonic is currently loaded. */
|
||||||
|
int mnemonic_is_loaded(const mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Get the mnemonic string (only valid while loaded). Returns NULL if not loaded. */
|
||||||
|
const char *mnemonic_get_phrase(const mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Generate a new BIP-39 mnemonic phrase (12/15/18/21/24 words) into out.
|
||||||
|
* Returns 0 on success, -1 on invalid arguments or generation failure. */
|
||||||
|
int mnemonic_generate(int word_count, char *out, size_t out_len);
|
||||||
|
|
||||||
|
|
||||||
|
/* from role_table.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Maximum limits */
|
||||||
|
#define ROLE_NAME_MAX 64
|
||||||
|
#define ROLE_PATH_MAX 128
|
||||||
|
#define ROLE_PURPOSE_MAX 32
|
||||||
|
#define ROLE_CURVE_MAX 16
|
||||||
|
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||||
|
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||||
|
|
||||||
|
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||||
|
typedef enum {
|
||||||
|
PURPOSE_NOSTR = 0,
|
||||||
|
PURPOSE_BITCOIN,
|
||||||
|
PURPOSE_SSH,
|
||||||
|
PURPOSE_AGE,
|
||||||
|
PURPOSE_FIPS,
|
||||||
|
PURPOSE_UNKNOWN
|
||||||
|
} role_purpose_t;
|
||||||
|
|
||||||
|
/* Curve enum */
|
||||||
|
typedef enum {
|
||||||
|
CURVE_SECP256K1 = 0,
|
||||||
|
CURVE_ED25519,
|
||||||
|
CURVE_X25519,
|
||||||
|
CURVE_UNKNOWN
|
||||||
|
} role_curve_t;
|
||||||
|
|
||||||
|
/* Selector type — how this role's key is addressed */
|
||||||
|
typedef enum {
|
||||||
|
SELECTOR_NOSTR_INDEX, /* uses nostr_index shorthand */
|
||||||
|
SELECTOR_ROLE_PATH /* uses explicit full path */
|
||||||
|
} role_selector_type_t;
|
||||||
|
|
||||||
|
/* A single role entry */
|
||||||
|
typedef struct {
|
||||||
|
char name[ROLE_NAME_MAX];
|
||||||
|
char purpose_str[ROLE_PURPOSE_MAX];
|
||||||
|
char curve_str[ROLE_CURVE_MAX];
|
||||||
|
role_purpose_t purpose;
|
||||||
|
role_curve_t curve;
|
||||||
|
role_selector_type_t selector_type;
|
||||||
|
int nostr_index; /* valid if selector_type == SELECTOR_NOSTR_INDEX */
|
||||||
|
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
|
||||||
|
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
|
||||||
|
int derived; /* 1 if pubkey_hex has been populated */
|
||||||
|
} role_entry_t;
|
||||||
|
|
||||||
|
/* The role table */
|
||||||
|
typedef struct {
|
||||||
|
role_entry_t entries[ROLE_TABLE_MAX_ENTRIES];
|
||||||
|
int count;
|
||||||
|
} role_table_t;
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
|
||||||
|
/* Find a role by nostr_index. Returns pointer or NULL. */
|
||||||
|
role_entry_t *role_table_find_by_nostr_index(role_table_t *table, int index);
|
||||||
|
|
||||||
|
/* Find a role by role_path. Returns pointer or NULL. */
|
||||||
|
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);
|
||||||
|
|
||||||
|
/* Parse purpose string to enum */
|
||||||
|
role_purpose_t role_purpose_from_str(const char *s);
|
||||||
|
|
||||||
|
/* Parse curve string to enum */
|
||||||
|
role_curve_t role_curve_from_str(const char *s);
|
||||||
|
|
||||||
|
/* Purpose enum to string */
|
||||||
|
const char *role_purpose_to_str(role_purpose_t p);
|
||||||
|
|
||||||
|
/* Curve enum to string */
|
||||||
|
const char *role_curve_to_str(role_curve_t c);
|
||||||
|
|
||||||
|
/* Register a nostr-index role if missing. Returns 0 on success, -1 on error. */
|
||||||
|
int role_table_register_nostr_index(role_table_t *table, int nostr_index);
|
||||||
|
|
||||||
|
|
||||||
|
/* from selector.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Error codes for selector resolution */
|
||||||
|
#define SELECTOR_OK 0
|
||||||
|
#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 */
|
||||||
|
|
||||||
|
/* Parsed selector from a request's options object */
|
||||||
|
typedef struct {
|
||||||
|
int has_role; /* 1 if "role" field was present */
|
||||||
|
char role_name[ROLE_NAME_MAX];
|
||||||
|
|
||||||
|
int has_nostr_index; /* 1 if "nostr_index" field was present */
|
||||||
|
int nostr_index;
|
||||||
|
|
||||||
|
int has_role_path; /* 1 if "role_path" field was present */
|
||||||
|
char role_path[ROLE_PATH_MAX];
|
||||||
|
} selector_request_t;
|
||||||
|
|
||||||
|
/* Initialize a selector request (all fields zeroed/unset) */
|
||||||
|
void selector_request_init(selector_request_t *req);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Resolve a selector request against the role table.
|
||||||
|
* On success (returns SELECTOR_OK), *out points to the matched role_entry_t.
|
||||||
|
* On failure, returns one of the SELECTOR_ERR_* codes and *out is NULL.
|
||||||
|
*/
|
||||||
|
int selector_resolve(const selector_request_t *req, role_table_t *table, role_entry_t **out);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return a human-readable error string for a selector error code.
|
||||||
|
*/
|
||||||
|
const char *selector_strerror(int err);
|
||||||
|
|
||||||
|
|
||||||
|
/* from enforcement.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Error codes */
|
||||||
|
#define ENFORCE_OK 0
|
||||||
|
#define ENFORCE_ERR_PURPOSE -1 /* purpose mismatch */
|
||||||
|
#define ENFORCE_ERR_CURVE -2 /* curve mismatch */
|
||||||
|
#define ENFORCE_ERR_UNKNOWN_VERB -3 /* verb not recognized */
|
||||||
|
|
||||||
|
/* Known verbs */
|
||||||
|
#define VERB_SIGN_EVENT "sign_event"
|
||||||
|
#define VERB_GET_PUBLIC_KEY "get_public_key"
|
||||||
|
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
|
||||||
|
#define VERB_NIP44_DECRYPT "nip44_decrypt"
|
||||||
|
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
|
||||||
|
#define VERB_NIP04_DECRYPT "nip04_decrypt"
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check whether `verb` is allowed to execute against `role`.
|
||||||
|
* Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code.
|
||||||
|
*
|
||||||
|
* The enforcement rules are:
|
||||||
|
* - All nostr verbs (sign_event, get_public_key, nip44_*, nip04_*) require:
|
||||||
|
* purpose == PURPOSE_NOSTR and curve == CURVE_SECP256K1
|
||||||
|
* - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed).
|
||||||
|
*/
|
||||||
|
int enforce_verb_role(const char *verb, const role_entry_t *role);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return a human-readable error string for an enforcement error code.
|
||||||
|
*/
|
||||||
|
const char *enforce_strerror(int err);
|
||||||
|
|
||||||
|
|
||||||
|
/* from policy.h */
|
||||||
|
|
||||||
|
|
||||||
|
#define POLICY_MAX_ENTRIES 32
|
||||||
|
#define POLICY_MAX_VERBS 16
|
||||||
|
#define POLICY_MAX_ROLES 16
|
||||||
|
#define POLICY_MAX_PURPOSES 8
|
||||||
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
|
/* Prompt behavior */
|
||||||
|
typedef enum {
|
||||||
|
PROMPT_NEVER = 0,
|
||||||
|
PROMPT_FIRST_PER_BOOT,
|
||||||
|
PROMPT_EVERY_REQUEST,
|
||||||
|
PROMPT_DENY
|
||||||
|
} prompt_mode_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||||
|
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||||
|
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||||
|
} policy_source_t;
|
||||||
|
|
||||||
|
/* A single policy entry */
|
||||||
|
typedef struct {
|
||||||
|
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||||
|
char verbs[POLICY_MAX_VERBS][POLICY_VERB_MAX_LEN];
|
||||||
|
int verb_count;
|
||||||
|
char roles[POLICY_MAX_ROLES][ROLE_NAME_MAX];
|
||||||
|
int role_count;
|
||||||
|
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||||
|
int purpose_count;
|
||||||
|
prompt_mode_t prompt;
|
||||||
|
policy_source_t source;
|
||||||
|
} policy_entry_t;
|
||||||
|
|
||||||
|
/* Policy table */
|
||||||
|
typedef struct {
|
||||||
|
policy_entry_t entries[POLICY_MAX_ENTRIES];
|
||||||
|
int count;
|
||||||
|
} policy_table_t;
|
||||||
|
|
||||||
|
/* Policy check result */
|
||||||
|
#define POLICY_ALLOW 0
|
||||||
|
#define POLICY_DENY -1
|
||||||
|
#define POLICY_PROMPT -2 /* would need user confirmation */
|
||||||
|
#define POLICY_NO_MATCH -3 /* no policy entry matched (fail-closed = deny) */
|
||||||
|
|
||||||
|
/* Initialize policy table */
|
||||||
|
void policy_table_init(policy_table_t *table);
|
||||||
|
|
||||||
|
/* Initialize default policy: allow same-uid, deny others */
|
||||||
|
void policy_init_default(policy_table_t *table, uid_t owner_uid);
|
||||||
|
|
||||||
|
/* Add a policy entry. Returns 0 on success, -1 if full. */
|
||||||
|
int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check whether caller_id is allowed to invoke `verb` on `role_name` with given `purpose`.
|
||||||
|
* 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);
|
||||||
|
|
||||||
|
/* Parse prompt mode from string */
|
||||||
|
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||||
|
|
||||||
|
/* Prompt mode to string */
|
||||||
|
const char *prompt_mode_to_str(prompt_mode_t m);
|
||||||
|
|
||||||
|
|
||||||
|
/* from crypto.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Per-role derived key material (stored in secure memory) */
|
||||||
|
typedef struct {
|
||||||
|
secure_buf_t private_key; /* 32 bytes, mlock'd */
|
||||||
|
unsigned char public_key[32];
|
||||||
|
char pubkey_hex[65]; /* 64 hex chars + null */
|
||||||
|
char npub[128]; /* bech32 npub */
|
||||||
|
int valid;
|
||||||
|
} derived_key_t;
|
||||||
|
|
||||||
|
/* Key store — holds derived keys for all roles */
|
||||||
|
typedef struct {
|
||||||
|
derived_key_t keys[ROLE_TABLE_MAX_ENTRIES];
|
||||||
|
int count;
|
||||||
|
} key_store_t;
|
||||||
|
|
||||||
|
/* Derive keys for all roles in the table using the loaded mnemonic.
|
||||||
|
* Populates key_store and sets role->pubkey_hex and role->derived for each role.
|
||||||
|
* Only derives for roles with purpose=nostr and curve=secp256k1 (for now).
|
||||||
|
* Returns number of keys derived, or -1 on error. */
|
||||||
|
int crypto_derive_all(key_store_t *store, role_table_t *table, const mnemonic_state_t *mnemonic);
|
||||||
|
|
||||||
|
/* Get the derived private key for a role (by table index). Returns NULL if not derived. */
|
||||||
|
const unsigned char *crypto_get_private_key(const key_store_t *store, int role_index);
|
||||||
|
|
||||||
|
/* Get the derived public key hex for a role. Returns NULL if not derived. */
|
||||||
|
const char *crypto_get_pubkey_hex(const key_store_t *store, int role_index);
|
||||||
|
|
||||||
|
/* Sign a Nostr event. event_json is the unsigned event JSON string.
|
||||||
|
* Returns a newly-allocated string containing the signed event JSON, or NULL on error.
|
||||||
|
* Caller must free() the returned string. */
|
||||||
|
char *crypto_sign_event(const key_store_t *store, int role_index, const char *event_json);
|
||||||
|
|
||||||
|
/* Zeroize all derived keys in the store. */
|
||||||
|
void crypto_wipe(key_store_t *store);
|
||||||
|
|
||||||
|
|
||||||
|
/* from dispatcher.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Dispatcher context — holds references to shared state */
|
||||||
|
typedef struct {
|
||||||
|
role_table_t *role_table;
|
||||||
|
mnemonic_state_t *mnemonic;
|
||||||
|
key_store_t *key_store;
|
||||||
|
} dispatcher_ctx_t;
|
||||||
|
|
||||||
|
/* Initialize dispatcher context */
|
||||||
|
void dispatcher_init(dispatcher_ctx_t *ctx, role_table_t *table, mnemonic_state_t *mnemonic, key_store_t *key_store);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Process a JSON-RPC request string and produce a JSON-RPC response string.
|
||||||
|
*
|
||||||
|
* The caller owns the returned string and must free() it.
|
||||||
|
* Returns NULL only on catastrophic allocation failure.
|
||||||
|
*
|
||||||
|
* Response format on success:
|
||||||
|
* { "id": "...", "result": "..." }
|
||||||
|
*
|
||||||
|
* Response format on error:
|
||||||
|
* { "id": "...", "error": { "code": <int>, "message": "..." } }
|
||||||
|
*
|
||||||
|
* Error codes:
|
||||||
|
* -32700 Parse error (invalid JSON)
|
||||||
|
* -32600 Invalid request (missing id/method/params)
|
||||||
|
* -32601 Method not found (unknown verb after enforcement)
|
||||||
|
* -32602 Invalid params
|
||||||
|
* 1001 ambiguous_role_selector
|
||||||
|
* 1002 role_not_found
|
||||||
|
* 1003 no_default_role
|
||||||
|
* 1004 purpose_mismatch
|
||||||
|
* 1005 curve_mismatch
|
||||||
|
* 1006 mnemonic_not_loaded
|
||||||
|
*/
|
||||||
|
char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request);
|
||||||
|
|
||||||
|
|
||||||
|
/* from server.h */
|
||||||
|
|
||||||
|
|
||||||
|
#define SERVER_SOCKET_NAME_MAX 108
|
||||||
|
#define SERVER_MAX_MSG_SIZE 65536
|
||||||
|
|
||||||
|
/* Caller identity */
|
||||||
|
typedef struct {
|
||||||
|
uid_t uid;
|
||||||
|
gid_t gid;
|
||||||
|
pid_t pid;
|
||||||
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
|
} caller_identity_t;
|
||||||
|
|
||||||
|
/* Server context */
|
||||||
|
typedef struct {
|
||||||
|
char socket_name[SERVER_SOCKET_NAME_MAX]; /* abstract namespace name (without \0 prefix) */
|
||||||
|
char last_error[256];
|
||||||
|
int listen_fd;
|
||||||
|
int running;
|
||||||
|
dispatcher_ctx_t *dispatcher;
|
||||||
|
policy_table_t *policy;
|
||||||
|
int socket_name_explicit;
|
||||||
|
} server_ctx_t;
|
||||||
|
|
||||||
|
/* Initialize server context. socket_name is the abstract namespace name (e.g. "nsigner").
|
||||||
|
* socket_name_explicit should be non-zero when provided via --socket-name override. */
|
||||||
|
void server_init(server_ctx_t *ctx, const char *socket_name, int socket_name_explicit,
|
||||||
|
dispatcher_ctx_t *dispatcher, policy_table_t *policy);
|
||||||
|
|
||||||
|
/* Start listening. Returns 0 on success, -1 on error. */
|
||||||
|
int server_start(server_ctx_t *ctx);
|
||||||
|
|
||||||
|
/* Get human-readable description of last server error. */
|
||||||
|
const char *server_last_error(const server_ctx_t *ctx);
|
||||||
|
|
||||||
|
/* Handle one pending connection (non-blocking). Returns 1 if handled, 0 if nothing pending, -1 on error.
|
||||||
|
* activity_cb is called with a description string for the TUI activity log. */
|
||||||
|
typedef void (*server_activity_cb)(const char *message, void *user_data);
|
||||||
|
int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data);
|
||||||
|
|
||||||
|
/* Stop server and close socket */
|
||||||
|
void server_stop(server_ctx_t *ctx);
|
||||||
|
|
||||||
|
/* Extract caller identity from connected fd */
|
||||||
|
int server_get_caller(int fd, caller_identity_t *out);
|
||||||
|
|
||||||
|
|
||||||
|
/* from socket_name.h */
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Generate random socket name in format: nsigner_<word1>_<word2>
|
||||||
|
* Returns 0 on success, -1 on error.
|
||||||
|
*/
|
||||||
|
int socket_name_random(char *out, size_t out_len);
|
||||||
|
|
||||||
|
|
||||||
|
/* from main.h */
|
||||||
|
/*
|
||||||
|
* nsigner main header - version information
|
||||||
|
*
|
||||||
|
* Version macros are auto-updated by increment_and_push.sh.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
/* Version information (auto-updated by build/version tooling) */
|
||||||
|
#define NSIGNER_VERSION_MAJOR 0
|
||||||
|
#define NSIGNER_VERSION_MINOR 0
|
||||||
|
#define NSIGNER_VERSION_PATCH 2
|
||||||
|
#define NSIGNER_VERSION "v0.0.2"
|
||||||
|
|
||||||
|
|
||||||
|
/* NSIGNER_HEADERLESS_DECLS_END */
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
static int g_failures = 0;
|
||||||
|
|
||||||
|
static void check_condition(const char *name, int condition) {
|
||||||
|
if (condition) {
|
||||||
|
printf("PASS: %s\n", name);
|
||||||
|
} else {
|
||||||
|
printf("FAIL: %s\n", name);
|
||||||
|
g_failures++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static role_entry_t make_nostr_entry(const char *name, const char *purpose, const char *curve, int idx) {
|
||||||
|
role_entry_t e;
|
||||||
|
|
||||||
|
memset(&e, 0, sizeof(e));
|
||||||
|
strncpy(e.name, name, sizeof(e.name) - 1);
|
||||||
|
strncpy(e.purpose_str, purpose, sizeof(e.purpose_str) - 1);
|
||||||
|
strncpy(e.curve_str, curve, sizeof(e.curve_str) - 1);
|
||||||
|
e.purpose = role_purpose_from_str(e.purpose_str);
|
||||||
|
e.curve = role_curve_from_str(e.curve_str);
|
||||||
|
e.selector_type = SELECTOR_NOSTR_INDEX;
|
||||||
|
e.nostr_index = idx;
|
||||||
|
e.derived = 0;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
static role_entry_t make_path_entry(const char *name, const char *purpose, const char *curve, const char *path) {
|
||||||
|
role_entry_t e;
|
||||||
|
|
||||||
|
memset(&e, 0, sizeof(e));
|
||||||
|
strncpy(e.name, name, sizeof(e.name) - 1);
|
||||||
|
strncpy(e.purpose_str, purpose, sizeof(e.purpose_str) - 1);
|
||||||
|
strncpy(e.curve_str, curve, sizeof(e.curve_str) - 1);
|
||||||
|
strncpy(e.role_path, path, sizeof(e.role_path) - 1);
|
||||||
|
e.purpose = role_purpose_from_str(e.purpose_str);
|
||||||
|
e.curve = role_curve_from_str(e.curve_str);
|
||||||
|
e.selector_type = SELECTOR_ROLE_PATH;
|
||||||
|
e.nostr_index = -1;
|
||||||
|
e.derived = 0;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
role_table_t table;
|
||||||
|
role_entry_t e_main;
|
||||||
|
role_entry_t e_throwaway;
|
||||||
|
role_entry_t e_btc;
|
||||||
|
role_entry_t e_ssh;
|
||||||
|
role_entry_t *found;
|
||||||
|
int rc;
|
||||||
|
int i;
|
||||||
|
|
||||||
|
role_table_init(&table);
|
||||||
|
check_condition("init count==0", table.count == 0);
|
||||||
|
|
||||||
|
e_main = make_nostr_entry("main", "nostr", "secp256k1", 0);
|
||||||
|
rc = role_table_add(&table, &e_main);
|
||||||
|
check_condition("add main returns 0", rc == 0);
|
||||||
|
|
||||||
|
e_throwaway = make_nostr_entry("throwaway", "nostr", "secp256k1", 1);
|
||||||
|
rc = role_table_add(&table, &e_throwaway);
|
||||||
|
check_condition("add throwaway returns 0", rc == 0);
|
||||||
|
|
||||||
|
e_btc = make_path_entry("btc_savings", "bitcoin", "secp256k1", "m/84'/0'/0'/0/0");
|
||||||
|
rc = role_table_add(&table, &e_btc);
|
||||||
|
check_condition("add btc_savings returns 0", rc == 0);
|
||||||
|
|
||||||
|
e_ssh = make_path_entry("ssh_key", "ssh", "ed25519", "m/44'/822'/0'/0/0");
|
||||||
|
rc = role_table_add(&table, &e_ssh);
|
||||||
|
check_condition("add ssh_key returns 0", rc == 0);
|
||||||
|
|
||||||
|
found = role_table_find_by_name(&table, "main");
|
||||||
|
check_condition("find_by_name(main) not NULL", found != NULL);
|
||||||
|
check_condition("find_by_name(main) purpose nostr", found != NULL && found->purpose == PURPOSE_NOSTR);
|
||||||
|
|
||||||
|
found = role_table_find_by_nostr_index(&table, 1);
|
||||||
|
check_condition("find_by_nostr_index(1) is throwaway", found != NULL && strcmp(found->name, "throwaway") == 0);
|
||||||
|
|
||||||
|
found = role_table_find_by_path(&table, "m/84'/0'/0'/0/0");
|
||||||
|
check_condition("find_by_path btc path is btc_savings", found != NULL && strcmp(found->name, "btc_savings") == 0);
|
||||||
|
|
||||||
|
found = role_table_get_default(&table);
|
||||||
|
check_condition("get_default() is main", found != NULL && strcmp(found->name, "main") == 0);
|
||||||
|
|
||||||
|
rc = role_table_add(&table, &e_main);
|
||||||
|
check_condition("duplicate name rejection returns -2", rc == -2);
|
||||||
|
|
||||||
|
role_table_init(&table);
|
||||||
|
rc = role_table_register_nostr_index(&table, 7);
|
||||||
|
check_condition("register nostr_index=7 returns 0", rc == 0);
|
||||||
|
found = role_table_find_by_nostr_index(&table, 7);
|
||||||
|
check_condition("register nostr_index=7 creates role", found != NULL);
|
||||||
|
check_condition("registered role name is nostr_idx_7", found != NULL && strcmp(found->name, "nostr_idx_7") == 0);
|
||||||
|
check_condition("registered role purpose nostr", found != NULL && found->purpose == PURPOSE_NOSTR);
|
||||||
|
check_condition("registered role curve secp256k1", found != NULL && found->curve == CURVE_SECP256K1);
|
||||||
|
check_condition("registered role selector type nostr_index", found != NULL && found->selector_type == SELECTOR_NOSTR_INDEX);
|
||||||
|
|
||||||
|
rc = role_table_register_nostr_index(&table, 7);
|
||||||
|
check_condition("register nostr_index=7 idempotent second call", rc == 0);
|
||||||
|
check_condition("register nostr_index=7 idempotent no duplicate", table.count == 1);
|
||||||
|
|
||||||
|
role_table_init(&table);
|
||||||
|
for (i = 0; i < ROLE_TABLE_MAX_ENTRIES; ++i) {
|
||||||
|
rc = role_table_register_nostr_index(&table, i);
|
||||||
|
check_condition("fill table via register_nostr_index returns 0", rc == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
rc = role_table_register_nostr_index(&table, ROLE_TABLE_MAX_ENTRIES + 7);
|
||||||
|
check_condition("register_nostr_index table full returns -1", rc == -1);
|
||||||
|
|
||||||
|
if (g_failures == 0) {
|
||||||
|
printf("ALL TESTS PASSED\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("TESTS FAILED: %d\n", g_failures);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
618
tests/test_selector.c
Normal file
618
tests/test_selector.c
Normal file
@@ -0,0 +1,618 @@
|
|||||||
|
/* NSIGNER_HEADERLESS_DECLS_BEGIN */
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <cJSON.h>
|
||||||
|
|
||||||
|
/* from secure_mem.h */
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Secure memory buffer — mlock'd, zeroized on free.
|
||||||
|
* Used for mnemonic phrases, private keys, and any sensitive material.
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
void *data; /* pointer to locked allocation */
|
||||||
|
size_t size; /* usable size in bytes */
|
||||||
|
int locked; /* 1 if mlock succeeded */
|
||||||
|
} secure_buf_t;
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
|
||||||
|
/* Zeroize and free a secure buffer. Always succeeds (idempotent). */
|
||||||
|
void secure_buf_free(secure_buf_t *buf);
|
||||||
|
|
||||||
|
/* Zeroize `len` bytes at `ptr` in a way the compiler cannot optimize away. */
|
||||||
|
void secure_memzero(void *ptr, size_t len);
|
||||||
|
|
||||||
|
|
||||||
|
/* from mnemonic.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Maximum mnemonic length: 24 words * 10 chars avg + spaces + null = 256 is safe */
|
||||||
|
#define MNEMONIC_MAX_LEN 256
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mnemonic state — holds the loaded mnemonic in secure memory.
|
||||||
|
* Only one mnemonic is active at a time per process.
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
secure_buf_t buf; /* secure storage for the mnemonic string */
|
||||||
|
int loaded; /* 1 if a mnemonic is currently loaded */
|
||||||
|
int word_count; /* 12, 15, 18, 21, or 24 */
|
||||||
|
} mnemonic_state_t;
|
||||||
|
|
||||||
|
/* Initialize mnemonic state (must be called before use). */
|
||||||
|
void mnemonic_init(mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Load a mnemonic string into secure memory. Validates word count (12/15/18/21/24).
|
||||||
|
* Returns 0 on success, -1 on invalid input, -2 on memory error. */
|
||||||
|
int mnemonic_load(mnemonic_state_t *state, const char *phrase);
|
||||||
|
|
||||||
|
/* Zeroize and unload the mnemonic. Idempotent. */
|
||||||
|
void mnemonic_unload(mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Check if a mnemonic is currently loaded. */
|
||||||
|
int mnemonic_is_loaded(const mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Get the mnemonic string (only valid while loaded). Returns NULL if not loaded. */
|
||||||
|
const char *mnemonic_get_phrase(const mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Generate a new BIP-39 mnemonic phrase (12/15/18/21/24 words) into out.
|
||||||
|
* Returns 0 on success, -1 on invalid arguments or generation failure. */
|
||||||
|
int mnemonic_generate(int word_count, char *out, size_t out_len);
|
||||||
|
|
||||||
|
|
||||||
|
/* from role_table.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Maximum limits */
|
||||||
|
#define ROLE_NAME_MAX 64
|
||||||
|
#define ROLE_PATH_MAX 128
|
||||||
|
#define ROLE_PURPOSE_MAX 32
|
||||||
|
#define ROLE_CURVE_MAX 16
|
||||||
|
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||||
|
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||||
|
|
||||||
|
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||||
|
typedef enum {
|
||||||
|
PURPOSE_NOSTR = 0,
|
||||||
|
PURPOSE_BITCOIN,
|
||||||
|
PURPOSE_SSH,
|
||||||
|
PURPOSE_AGE,
|
||||||
|
PURPOSE_FIPS,
|
||||||
|
PURPOSE_UNKNOWN
|
||||||
|
} role_purpose_t;
|
||||||
|
|
||||||
|
/* Curve enum */
|
||||||
|
typedef enum {
|
||||||
|
CURVE_SECP256K1 = 0,
|
||||||
|
CURVE_ED25519,
|
||||||
|
CURVE_X25519,
|
||||||
|
CURVE_UNKNOWN
|
||||||
|
} role_curve_t;
|
||||||
|
|
||||||
|
/* Selector type — how this role's key is addressed */
|
||||||
|
typedef enum {
|
||||||
|
SELECTOR_NOSTR_INDEX, /* uses nostr_index shorthand */
|
||||||
|
SELECTOR_ROLE_PATH /* uses explicit full path */
|
||||||
|
} role_selector_type_t;
|
||||||
|
|
||||||
|
/* A single role entry */
|
||||||
|
typedef struct {
|
||||||
|
char name[ROLE_NAME_MAX];
|
||||||
|
char purpose_str[ROLE_PURPOSE_MAX];
|
||||||
|
char curve_str[ROLE_CURVE_MAX];
|
||||||
|
role_purpose_t purpose;
|
||||||
|
role_curve_t curve;
|
||||||
|
role_selector_type_t selector_type;
|
||||||
|
int nostr_index; /* valid if selector_type == SELECTOR_NOSTR_INDEX */
|
||||||
|
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
|
||||||
|
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
|
||||||
|
int derived; /* 1 if pubkey_hex has been populated */
|
||||||
|
} role_entry_t;
|
||||||
|
|
||||||
|
/* The role table */
|
||||||
|
typedef struct {
|
||||||
|
role_entry_t entries[ROLE_TABLE_MAX_ENTRIES];
|
||||||
|
int count;
|
||||||
|
} role_table_t;
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
|
||||||
|
/* Find a role by nostr_index. Returns pointer or NULL. */
|
||||||
|
role_entry_t *role_table_find_by_nostr_index(role_table_t *table, int index);
|
||||||
|
|
||||||
|
/* Find a role by role_path. Returns pointer or NULL. */
|
||||||
|
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);
|
||||||
|
|
||||||
|
/* Parse purpose string to enum */
|
||||||
|
role_purpose_t role_purpose_from_str(const char *s);
|
||||||
|
|
||||||
|
/* Parse curve string to enum */
|
||||||
|
role_curve_t role_curve_from_str(const char *s);
|
||||||
|
|
||||||
|
/* Purpose enum to string */
|
||||||
|
const char *role_purpose_to_str(role_purpose_t p);
|
||||||
|
|
||||||
|
/* Curve enum to string */
|
||||||
|
const char *role_curve_to_str(role_curve_t c);
|
||||||
|
|
||||||
|
|
||||||
|
/* from selector.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Error codes for selector resolution */
|
||||||
|
#define SELECTOR_OK 0
|
||||||
|
#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 */
|
||||||
|
|
||||||
|
/* Parsed selector from a request's options object */
|
||||||
|
typedef struct {
|
||||||
|
int has_role; /* 1 if "role" field was present */
|
||||||
|
char role_name[ROLE_NAME_MAX];
|
||||||
|
|
||||||
|
int has_nostr_index; /* 1 if "nostr_index" field was present */
|
||||||
|
int nostr_index;
|
||||||
|
|
||||||
|
int has_role_path; /* 1 if "role_path" field was present */
|
||||||
|
char role_path[ROLE_PATH_MAX];
|
||||||
|
} selector_request_t;
|
||||||
|
|
||||||
|
/* Initialize a selector request (all fields zeroed/unset) */
|
||||||
|
void selector_request_init(selector_request_t *req);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Resolve a selector request against the role table.
|
||||||
|
* On success (returns SELECTOR_OK), *out points to the matched role_entry_t.
|
||||||
|
* On failure, returns one of the SELECTOR_ERR_* codes and *out is NULL.
|
||||||
|
*/
|
||||||
|
int selector_resolve(const selector_request_t *req, role_table_t *table, role_entry_t **out);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return a human-readable error string for a selector error code.
|
||||||
|
*/
|
||||||
|
const char *selector_strerror(int err);
|
||||||
|
|
||||||
|
|
||||||
|
/* from enforcement.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Error codes */
|
||||||
|
#define ENFORCE_OK 0
|
||||||
|
#define ENFORCE_ERR_PURPOSE -1 /* purpose mismatch */
|
||||||
|
#define ENFORCE_ERR_CURVE -2 /* curve mismatch */
|
||||||
|
#define ENFORCE_ERR_UNKNOWN_VERB -3 /* verb not recognized */
|
||||||
|
|
||||||
|
/* Known verbs */
|
||||||
|
#define VERB_SIGN_EVENT "sign_event"
|
||||||
|
#define VERB_GET_PUBLIC_KEY "get_public_key"
|
||||||
|
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
|
||||||
|
#define VERB_NIP44_DECRYPT "nip44_decrypt"
|
||||||
|
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
|
||||||
|
#define VERB_NIP04_DECRYPT "nip04_decrypt"
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check whether `verb` is allowed to execute against `role`.
|
||||||
|
* Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code.
|
||||||
|
*
|
||||||
|
* The enforcement rules are:
|
||||||
|
* - All nostr verbs (sign_event, get_public_key, nip44_*, nip04_*) require:
|
||||||
|
* purpose == PURPOSE_NOSTR and curve == CURVE_SECP256K1
|
||||||
|
* - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed).
|
||||||
|
*/
|
||||||
|
int enforce_verb_role(const char *verb, const role_entry_t *role);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return a human-readable error string for an enforcement error code.
|
||||||
|
*/
|
||||||
|
const char *enforce_strerror(int err);
|
||||||
|
|
||||||
|
|
||||||
|
/* from policy.h */
|
||||||
|
|
||||||
|
|
||||||
|
#define POLICY_MAX_ENTRIES 32
|
||||||
|
#define POLICY_MAX_VERBS 16
|
||||||
|
#define POLICY_MAX_ROLES 16
|
||||||
|
#define POLICY_MAX_PURPOSES 8
|
||||||
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
|
/* Prompt behavior */
|
||||||
|
typedef enum {
|
||||||
|
PROMPT_NEVER = 0,
|
||||||
|
PROMPT_FIRST_PER_BOOT,
|
||||||
|
PROMPT_EVERY_REQUEST,
|
||||||
|
PROMPT_DENY
|
||||||
|
} prompt_mode_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||||
|
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||||
|
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||||
|
} policy_source_t;
|
||||||
|
|
||||||
|
/* A single policy entry */
|
||||||
|
typedef struct {
|
||||||
|
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||||
|
char verbs[POLICY_MAX_VERBS][POLICY_VERB_MAX_LEN];
|
||||||
|
int verb_count;
|
||||||
|
char roles[POLICY_MAX_ROLES][ROLE_NAME_MAX];
|
||||||
|
int role_count;
|
||||||
|
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||||
|
int purpose_count;
|
||||||
|
prompt_mode_t prompt;
|
||||||
|
policy_source_t source;
|
||||||
|
} policy_entry_t;
|
||||||
|
|
||||||
|
/* Policy table */
|
||||||
|
typedef struct {
|
||||||
|
policy_entry_t entries[POLICY_MAX_ENTRIES];
|
||||||
|
int count;
|
||||||
|
} policy_table_t;
|
||||||
|
|
||||||
|
/* Policy check result */
|
||||||
|
#define POLICY_ALLOW 0
|
||||||
|
#define POLICY_DENY -1
|
||||||
|
#define POLICY_PROMPT -2 /* would need user confirmation */
|
||||||
|
#define POLICY_NO_MATCH -3 /* no policy entry matched (fail-closed = deny) */
|
||||||
|
|
||||||
|
/* Initialize policy table */
|
||||||
|
void policy_table_init(policy_table_t *table);
|
||||||
|
|
||||||
|
/* Initialize default policy: allow same-uid, deny others */
|
||||||
|
void policy_init_default(policy_table_t *table, uid_t owner_uid);
|
||||||
|
|
||||||
|
/* Add a policy entry. Returns 0 on success, -1 if full. */
|
||||||
|
int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check whether caller_id is allowed to invoke `verb` on `role_name` with given `purpose`.
|
||||||
|
* 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);
|
||||||
|
|
||||||
|
/* Parse prompt mode from string */
|
||||||
|
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||||
|
|
||||||
|
/* Prompt mode to string */
|
||||||
|
const char *prompt_mode_to_str(prompt_mode_t m);
|
||||||
|
|
||||||
|
|
||||||
|
/* from crypto.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Per-role derived key material (stored in secure memory) */
|
||||||
|
typedef struct {
|
||||||
|
secure_buf_t private_key; /* 32 bytes, mlock'd */
|
||||||
|
unsigned char public_key[32];
|
||||||
|
char pubkey_hex[65]; /* 64 hex chars + null */
|
||||||
|
char npub[128]; /* bech32 npub */
|
||||||
|
int valid;
|
||||||
|
} derived_key_t;
|
||||||
|
|
||||||
|
/* Key store — holds derived keys for all roles */
|
||||||
|
typedef struct {
|
||||||
|
derived_key_t keys[ROLE_TABLE_MAX_ENTRIES];
|
||||||
|
int count;
|
||||||
|
} key_store_t;
|
||||||
|
|
||||||
|
/* Derive keys for all roles in the table using the loaded mnemonic.
|
||||||
|
* Populates key_store and sets role->pubkey_hex and role->derived for each role.
|
||||||
|
* Only derives for roles with purpose=nostr and curve=secp256k1 (for now).
|
||||||
|
* Returns number of keys derived, or -1 on error. */
|
||||||
|
int crypto_derive_all(key_store_t *store, role_table_t *table, const mnemonic_state_t *mnemonic);
|
||||||
|
|
||||||
|
/* Get the derived private key for a role (by table index). Returns NULL if not derived. */
|
||||||
|
const unsigned char *crypto_get_private_key(const key_store_t *store, int role_index);
|
||||||
|
|
||||||
|
/* Get the derived public key hex for a role. Returns NULL if not derived. */
|
||||||
|
const char *crypto_get_pubkey_hex(const key_store_t *store, int role_index);
|
||||||
|
|
||||||
|
/* Sign a Nostr event. event_json is the unsigned event JSON string.
|
||||||
|
* Returns a newly-allocated string containing the signed event JSON, or NULL on error.
|
||||||
|
* Caller must free() the returned string. */
|
||||||
|
char *crypto_sign_event(const key_store_t *store, int role_index, const char *event_json);
|
||||||
|
|
||||||
|
/* Zeroize all derived keys in the store. */
|
||||||
|
void crypto_wipe(key_store_t *store);
|
||||||
|
|
||||||
|
|
||||||
|
/* from dispatcher.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Dispatcher context — holds references to shared state */
|
||||||
|
typedef struct {
|
||||||
|
role_table_t *role_table;
|
||||||
|
mnemonic_state_t *mnemonic;
|
||||||
|
key_store_t *key_store;
|
||||||
|
} dispatcher_ctx_t;
|
||||||
|
|
||||||
|
/* Initialize dispatcher context */
|
||||||
|
void dispatcher_init(dispatcher_ctx_t *ctx, role_table_t *table, mnemonic_state_t *mnemonic, key_store_t *key_store);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Process a JSON-RPC request string and produce a JSON-RPC response string.
|
||||||
|
*
|
||||||
|
* The caller owns the returned string and must free() it.
|
||||||
|
* Returns NULL only on catastrophic allocation failure.
|
||||||
|
*
|
||||||
|
* Response format on success:
|
||||||
|
* { "id": "...", "result": "..." }
|
||||||
|
*
|
||||||
|
* Response format on error:
|
||||||
|
* { "id": "...", "error": { "code": <int>, "message": "..." } }
|
||||||
|
*
|
||||||
|
* Error codes:
|
||||||
|
* -32700 Parse error (invalid JSON)
|
||||||
|
* -32600 Invalid request (missing id/method/params)
|
||||||
|
* -32601 Method not found (unknown verb after enforcement)
|
||||||
|
* -32602 Invalid params
|
||||||
|
* 1001 ambiguous_role_selector
|
||||||
|
* 1002 role_not_found
|
||||||
|
* 1003 no_default_role
|
||||||
|
* 1004 purpose_mismatch
|
||||||
|
* 1005 curve_mismatch
|
||||||
|
* 1006 mnemonic_not_loaded
|
||||||
|
*/
|
||||||
|
char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request);
|
||||||
|
|
||||||
|
|
||||||
|
/* from server.h */
|
||||||
|
|
||||||
|
|
||||||
|
#define SERVER_SOCKET_NAME_MAX 108
|
||||||
|
#define SERVER_MAX_MSG_SIZE 65536
|
||||||
|
|
||||||
|
/* Caller identity */
|
||||||
|
typedef struct {
|
||||||
|
uid_t uid;
|
||||||
|
gid_t gid;
|
||||||
|
pid_t pid;
|
||||||
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
|
} caller_identity_t;
|
||||||
|
|
||||||
|
/* Server context */
|
||||||
|
typedef struct {
|
||||||
|
char socket_name[SERVER_SOCKET_NAME_MAX]; /* abstract namespace name (without \0 prefix) */
|
||||||
|
char last_error[256];
|
||||||
|
int listen_fd;
|
||||||
|
int running;
|
||||||
|
dispatcher_ctx_t *dispatcher;
|
||||||
|
policy_table_t *policy;
|
||||||
|
int socket_name_explicit;
|
||||||
|
} server_ctx_t;
|
||||||
|
|
||||||
|
/* Initialize server context. socket_name is the abstract namespace name (e.g. "nsigner").
|
||||||
|
* socket_name_explicit should be non-zero when provided via --socket-name override. */
|
||||||
|
void server_init(server_ctx_t *ctx, const char *socket_name, int socket_name_explicit,
|
||||||
|
dispatcher_ctx_t *dispatcher, policy_table_t *policy);
|
||||||
|
|
||||||
|
/* Start listening. Returns 0 on success, -1 on error. */
|
||||||
|
int server_start(server_ctx_t *ctx);
|
||||||
|
|
||||||
|
/* Get human-readable description of last server error. */
|
||||||
|
const char *server_last_error(const server_ctx_t *ctx);
|
||||||
|
|
||||||
|
/* Handle one pending connection (non-blocking). Returns 1 if handled, 0 if nothing pending, -1 on error.
|
||||||
|
* activity_cb is called with a description string for the TUI activity log. */
|
||||||
|
typedef void (*server_activity_cb)(const char *message, void *user_data);
|
||||||
|
int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data);
|
||||||
|
|
||||||
|
/* Stop server and close socket */
|
||||||
|
void server_stop(server_ctx_t *ctx);
|
||||||
|
|
||||||
|
/* Extract caller identity from connected fd */
|
||||||
|
int server_get_caller(int fd, caller_identity_t *out);
|
||||||
|
|
||||||
|
|
||||||
|
/* from socket_name.h */
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Generate random socket name in format: nsigner_<word1>_<word2>
|
||||||
|
* Returns 0 on success, -1 on error.
|
||||||
|
*/
|
||||||
|
int socket_name_random(char *out, size_t out_len);
|
||||||
|
|
||||||
|
|
||||||
|
/* from main.h */
|
||||||
|
/*
|
||||||
|
* nsigner main header - version information
|
||||||
|
*
|
||||||
|
* Version macros are auto-updated by increment_and_push.sh.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
/* Version information (auto-updated by build/version tooling) */
|
||||||
|
#define NSIGNER_VERSION_MAJOR 0
|
||||||
|
#define NSIGNER_VERSION_MINOR 0
|
||||||
|
#define NSIGNER_VERSION_PATCH 2
|
||||||
|
#define NSIGNER_VERSION "v0.0.2"
|
||||||
|
|
||||||
|
|
||||||
|
/* NSIGNER_HEADERLESS_DECLS_END */
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
static int g_passes = 0;
|
||||||
|
static int g_total = 0;
|
||||||
|
|
||||||
|
static void check_condition(const char *name, int condition) {
|
||||||
|
g_total++;
|
||||||
|
if (condition) {
|
||||||
|
printf("PASS: %s\n", name);
|
||||||
|
g_passes++;
|
||||||
|
} else {
|
||||||
|
printf("FAIL: %s\n", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static role_entry_t make_nostr_entry(const char *name, const char *purpose, const char *curve, int idx) {
|
||||||
|
role_entry_t e;
|
||||||
|
|
||||||
|
memset(&e, 0, sizeof(e));
|
||||||
|
strncpy(e.name, name, sizeof(e.name) - 1);
|
||||||
|
strncpy(e.purpose_str, purpose, sizeof(e.purpose_str) - 1);
|
||||||
|
strncpy(e.curve_str, curve, sizeof(e.curve_str) - 1);
|
||||||
|
e.purpose = role_purpose_from_str(e.purpose_str);
|
||||||
|
e.curve = role_curve_from_str(e.curve_str);
|
||||||
|
e.selector_type = SELECTOR_NOSTR_INDEX;
|
||||||
|
e.nostr_index = idx;
|
||||||
|
e.derived = 0;
|
||||||
|
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
static role_entry_t make_path_entry(const char *name, const char *purpose, const char *curve, const char *path) {
|
||||||
|
role_entry_t e;
|
||||||
|
|
||||||
|
memset(&e, 0, sizeof(e));
|
||||||
|
strncpy(e.name, name, sizeof(e.name) - 1);
|
||||||
|
strncpy(e.purpose_str, purpose, sizeof(e.purpose_str) - 1);
|
||||||
|
strncpy(e.curve_str, curve, sizeof(e.curve_str) - 1);
|
||||||
|
strncpy(e.role_path, path, sizeof(e.role_path) - 1);
|
||||||
|
e.purpose = role_purpose_from_str(e.purpose_str);
|
||||||
|
e.curve = role_curve_from_str(e.curve_str);
|
||||||
|
e.selector_type = SELECTOR_ROLE_PATH;
|
||||||
|
e.nostr_index = -1;
|
||||||
|
e.derived = 0;
|
||||||
|
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void build_standard_table(role_table_t *table) {
|
||||||
|
role_entry_t e_main;
|
||||||
|
role_entry_t e_throwaway;
|
||||||
|
role_entry_t e_btc;
|
||||||
|
|
||||||
|
role_table_init(table);
|
||||||
|
|
||||||
|
e_main = make_nostr_entry("main", "nostr", "secp256k1", 0);
|
||||||
|
e_throwaway = make_nostr_entry("throwaway", "nostr", "secp256k1", 1);
|
||||||
|
e_btc = make_path_entry("btc_savings", "bitcoin", "secp256k1", "m/84'/0'/0'/0/0");
|
||||||
|
|
||||||
|
role_table_add(table, &e_main);
|
||||||
|
role_table_add(table, &e_throwaway);
|
||||||
|
role_table_add(table, &e_btc);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
role_table_t table;
|
||||||
|
role_table_t no_main_table;
|
||||||
|
selector_request_t req;
|
||||||
|
role_entry_t *out;
|
||||||
|
int rc;
|
||||||
|
|
||||||
|
build_standard_table(&table);
|
||||||
|
|
||||||
|
role_table_init(&no_main_table);
|
||||||
|
{
|
||||||
|
role_entry_t e_throwaway = make_nostr_entry("throwaway", "nostr", "secp256k1", 1);
|
||||||
|
role_entry_t e_btc = make_path_entry("btc_savings", "bitcoin", "secp256k1", "m/84'/0'/0'/0/0");
|
||||||
|
role_table_add(&no_main_table, &e_throwaway);
|
||||||
|
role_table_add(&no_main_table, &e_btc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 1. No selector given, "main" exists -> resolves to "main" */
|
||||||
|
selector_request_init(&req);
|
||||||
|
out = NULL;
|
||||||
|
rc = selector_resolve(&req, &table, &out);
|
||||||
|
check_condition("default resolves to main", rc == SELECTOR_OK && out != NULL && strcmp(out->name, "main") == 0);
|
||||||
|
|
||||||
|
/* 2. No selector given, no "main" role -> SELECTOR_ERR_NO_DEFAULT */
|
||||||
|
selector_request_init(&req);
|
||||||
|
out = (role_entry_t *)0x1;
|
||||||
|
rc = selector_resolve(&req, &no_main_table, &out);
|
||||||
|
check_condition("no default role returns SELECTOR_ERR_NO_DEFAULT", rc == SELECTOR_ERR_NO_DEFAULT && out == NULL);
|
||||||
|
|
||||||
|
/* 3. role = "throwaway" -> resolves to "throwaway" */
|
||||||
|
selector_request_init(&req);
|
||||||
|
req.has_role = 1;
|
||||||
|
strncpy(req.role_name, "throwaway", sizeof(req.role_name) - 1);
|
||||||
|
out = NULL;
|
||||||
|
rc = selector_resolve(&req, &table, &out);
|
||||||
|
check_condition("role selector resolves throwaway", rc == SELECTOR_OK && out != NULL && strcmp(out->name, "throwaway") == 0);
|
||||||
|
|
||||||
|
/* 4. nostr_index = 1 -> resolves to nostr_index == 1 */
|
||||||
|
selector_request_init(&req);
|
||||||
|
req.has_nostr_index = 1;
|
||||||
|
req.nostr_index = 1;
|
||||||
|
out = NULL;
|
||||||
|
rc = selector_resolve(&req, &table, &out);
|
||||||
|
check_condition("nostr_index selector resolves index 1", rc == SELECTOR_OK && out != NULL && out->nostr_index == 1);
|
||||||
|
|
||||||
|
/* 5. role_path = m/84'/0'/0'/0/0 -> resolves to btc_savings */
|
||||||
|
selector_request_init(&req);
|
||||||
|
req.has_role_path = 1;
|
||||||
|
strncpy(req.role_path, "m/84'/0'/0'/0/0", sizeof(req.role_path) - 1);
|
||||||
|
out = NULL;
|
||||||
|
rc = selector_resolve(&req, &table, &out);
|
||||||
|
check_condition("role_path selector resolves btc_savings", rc == SELECTOR_OK && out != NULL && strcmp(out->name, "btc_savings") == 0);
|
||||||
|
|
||||||
|
/* 6. role + nostr_index both set -> SELECTOR_ERR_AMBIGUOUS */
|
||||||
|
selector_request_init(&req);
|
||||||
|
req.has_role = 1;
|
||||||
|
strncpy(req.role_name, "main", sizeof(req.role_name) - 1);
|
||||||
|
req.has_nostr_index = 1;
|
||||||
|
req.nostr_index = 0;
|
||||||
|
out = (role_entry_t *)0x1;
|
||||||
|
rc = selector_resolve(&req, &table, &out);
|
||||||
|
check_condition("role and nostr_index set is ambiguous", rc == SELECTOR_ERR_AMBIGUOUS && out == NULL);
|
||||||
|
|
||||||
|
/* 7. role = nonexistent -> SELECTOR_ERR_NOT_FOUND */
|
||||||
|
selector_request_init(&req);
|
||||||
|
req.has_role = 1;
|
||||||
|
strncpy(req.role_name, "nonexistent", sizeof(req.role_name) - 1);
|
||||||
|
out = (role_entry_t *)0x1;
|
||||||
|
rc = selector_resolve(&req, &table, &out);
|
||||||
|
check_condition("unknown role returns SELECTOR_ERR_NOT_FOUND", rc == SELECTOR_ERR_NOT_FOUND && out == NULL);
|
||||||
|
|
||||||
|
/* 8. nostr_index = 99 -> SELECTOR_ERR_NOT_FOUND */
|
||||||
|
selector_request_init(&req);
|
||||||
|
req.has_nostr_index = 1;
|
||||||
|
req.nostr_index = 99;
|
||||||
|
out = (role_entry_t *)0x1;
|
||||||
|
rc = selector_resolve(&req, &table, &out);
|
||||||
|
check_condition("unknown nostr_index returns SELECTOR_ERR_NOT_FOUND", rc == SELECTOR_ERR_NOT_FOUND && out == NULL);
|
||||||
|
|
||||||
|
/* 9. role_path = m/99'/0'/0' -> SELECTOR_ERR_NOT_FOUND */
|
||||||
|
selector_request_init(&req);
|
||||||
|
req.has_role_path = 1;
|
||||||
|
strncpy(req.role_path, "m/99'/0'/0'", sizeof(req.role_path) - 1);
|
||||||
|
out = (role_entry_t *)0x1;
|
||||||
|
rc = selector_resolve(&req, &table, &out);
|
||||||
|
check_condition("unknown role_path returns SELECTOR_ERR_NOT_FOUND", rc == SELECTOR_ERR_NOT_FOUND && out == NULL);
|
||||||
|
|
||||||
|
/* 10. all three selectors set -> SELECTOR_ERR_AMBIGUOUS */
|
||||||
|
selector_request_init(&req);
|
||||||
|
req.has_role = 1;
|
||||||
|
strncpy(req.role_name, "main", sizeof(req.role_name) - 1);
|
||||||
|
req.has_nostr_index = 1;
|
||||||
|
req.nostr_index = 0;
|
||||||
|
req.has_role_path = 1;
|
||||||
|
strncpy(req.role_path, "m/84'/0'/0'/0/0", sizeof(req.role_path) - 1);
|
||||||
|
out = (role_entry_t *)0x1;
|
||||||
|
rc = selector_resolve(&req, &table, &out);
|
||||||
|
check_condition("all selectors set is ambiguous", rc == SELECTOR_ERR_AMBIGUOUS && out == NULL);
|
||||||
|
|
||||||
|
printf("%d/10 tests passed\n", g_passes);
|
||||||
|
|
||||||
|
return (g_passes == 10 && g_total == 10) ? 0 : 1;
|
||||||
|
}
|
||||||
499
tests/test_socket_name.c
Normal file
499
tests/test_socket_name.c
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
/* NSIGNER_HEADERLESS_DECLS_BEGIN */
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <cJSON.h>
|
||||||
|
|
||||||
|
/* from secure_mem.h */
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Secure memory buffer — mlock'd, zeroized on free.
|
||||||
|
* Used for mnemonic phrases, private keys, and any sensitive material.
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
void *data; /* pointer to locked allocation */
|
||||||
|
size_t size; /* usable size in bytes */
|
||||||
|
int locked; /* 1 if mlock succeeded */
|
||||||
|
} secure_buf_t;
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
|
||||||
|
/* Zeroize and free a secure buffer. Always succeeds (idempotent). */
|
||||||
|
void secure_buf_free(secure_buf_t *buf);
|
||||||
|
|
||||||
|
/* Zeroize `len` bytes at `ptr` in a way the compiler cannot optimize away. */
|
||||||
|
void secure_memzero(void *ptr, size_t len);
|
||||||
|
|
||||||
|
|
||||||
|
/* from mnemonic.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Maximum mnemonic length: 24 words * 10 chars avg + spaces + null = 256 is safe */
|
||||||
|
#define MNEMONIC_MAX_LEN 256
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mnemonic state — holds the loaded mnemonic in secure memory.
|
||||||
|
* Only one mnemonic is active at a time per process.
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
secure_buf_t buf; /* secure storage for the mnemonic string */
|
||||||
|
int loaded; /* 1 if a mnemonic is currently loaded */
|
||||||
|
int word_count; /* 12, 15, 18, 21, or 24 */
|
||||||
|
} mnemonic_state_t;
|
||||||
|
|
||||||
|
/* Initialize mnemonic state (must be called before use). */
|
||||||
|
void mnemonic_init(mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Load a mnemonic string into secure memory. Validates word count (12/15/18/21/24).
|
||||||
|
* Returns 0 on success, -1 on invalid input, -2 on memory error. */
|
||||||
|
int mnemonic_load(mnemonic_state_t *state, const char *phrase);
|
||||||
|
|
||||||
|
/* Zeroize and unload the mnemonic. Idempotent. */
|
||||||
|
void mnemonic_unload(mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Check if a mnemonic is currently loaded. */
|
||||||
|
int mnemonic_is_loaded(const mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Get the mnemonic string (only valid while loaded). Returns NULL if not loaded. */
|
||||||
|
const char *mnemonic_get_phrase(const mnemonic_state_t *state);
|
||||||
|
|
||||||
|
/* Generate a new BIP-39 mnemonic phrase (12/15/18/21/24 words) into out.
|
||||||
|
* Returns 0 on success, -1 on invalid arguments or generation failure. */
|
||||||
|
int mnemonic_generate(int word_count, char *out, size_t out_len);
|
||||||
|
|
||||||
|
|
||||||
|
/* from role_table.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Maximum limits */
|
||||||
|
#define ROLE_NAME_MAX 64
|
||||||
|
#define ROLE_PATH_MAX 128
|
||||||
|
#define ROLE_PURPOSE_MAX 32
|
||||||
|
#define ROLE_CURVE_MAX 16
|
||||||
|
#define ROLE_PUBKEY_HEX_MAX 66 /* 64 hex chars + null + pad */
|
||||||
|
#define ROLE_TABLE_MAX_ENTRIES 256
|
||||||
|
|
||||||
|
/* Purpose enum for fast comparison (string form kept for config/display) */
|
||||||
|
typedef enum {
|
||||||
|
PURPOSE_NOSTR = 0,
|
||||||
|
PURPOSE_BITCOIN,
|
||||||
|
PURPOSE_SSH,
|
||||||
|
PURPOSE_AGE,
|
||||||
|
PURPOSE_FIPS,
|
||||||
|
PURPOSE_UNKNOWN
|
||||||
|
} role_purpose_t;
|
||||||
|
|
||||||
|
/* Curve enum */
|
||||||
|
typedef enum {
|
||||||
|
CURVE_SECP256K1 = 0,
|
||||||
|
CURVE_ED25519,
|
||||||
|
CURVE_X25519,
|
||||||
|
CURVE_UNKNOWN
|
||||||
|
} role_curve_t;
|
||||||
|
|
||||||
|
/* Selector type — how this role's key is addressed */
|
||||||
|
typedef enum {
|
||||||
|
SELECTOR_NOSTR_INDEX, /* uses nostr_index shorthand */
|
||||||
|
SELECTOR_ROLE_PATH /* uses explicit full path */
|
||||||
|
} role_selector_type_t;
|
||||||
|
|
||||||
|
/* A single role entry */
|
||||||
|
typedef struct {
|
||||||
|
char name[ROLE_NAME_MAX];
|
||||||
|
char purpose_str[ROLE_PURPOSE_MAX];
|
||||||
|
char curve_str[ROLE_CURVE_MAX];
|
||||||
|
role_purpose_t purpose;
|
||||||
|
role_curve_t curve;
|
||||||
|
role_selector_type_t selector_type;
|
||||||
|
int nostr_index; /* valid if selector_type == SELECTOR_NOSTR_INDEX */
|
||||||
|
char role_path[ROLE_PATH_MAX]; /* valid if selector_type == SELECTOR_ROLE_PATH */
|
||||||
|
char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; /* filled after derivation, empty until then */
|
||||||
|
int derived; /* 1 if pubkey_hex has been populated */
|
||||||
|
} role_entry_t;
|
||||||
|
|
||||||
|
/* The role table */
|
||||||
|
typedef struct {
|
||||||
|
role_entry_t entries[ROLE_TABLE_MAX_ENTRIES];
|
||||||
|
int count;
|
||||||
|
} role_table_t;
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
|
||||||
|
/* Find a role by nostr_index. Returns pointer or NULL. */
|
||||||
|
role_entry_t *role_table_find_by_nostr_index(role_table_t *table, int index);
|
||||||
|
|
||||||
|
/* Find a role by role_path. Returns pointer or NULL. */
|
||||||
|
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);
|
||||||
|
|
||||||
|
/* Parse purpose string to enum */
|
||||||
|
role_purpose_t role_purpose_from_str(const char *s);
|
||||||
|
|
||||||
|
/* Parse curve string to enum */
|
||||||
|
role_curve_t role_curve_from_str(const char *s);
|
||||||
|
|
||||||
|
/* Purpose enum to string */
|
||||||
|
const char *role_purpose_to_str(role_purpose_t p);
|
||||||
|
|
||||||
|
/* Curve enum to string */
|
||||||
|
const char *role_curve_to_str(role_curve_t c);
|
||||||
|
|
||||||
|
|
||||||
|
/* from selector.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Error codes for selector resolution */
|
||||||
|
#define SELECTOR_OK 0
|
||||||
|
#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 */
|
||||||
|
|
||||||
|
/* Parsed selector from a request's options object */
|
||||||
|
typedef struct {
|
||||||
|
int has_role; /* 1 if "role" field was present */
|
||||||
|
char role_name[ROLE_NAME_MAX];
|
||||||
|
|
||||||
|
int has_nostr_index; /* 1 if "nostr_index" field was present */
|
||||||
|
int nostr_index;
|
||||||
|
|
||||||
|
int has_role_path; /* 1 if "role_path" field was present */
|
||||||
|
char role_path[ROLE_PATH_MAX];
|
||||||
|
} selector_request_t;
|
||||||
|
|
||||||
|
/* Initialize a selector request (all fields zeroed/unset) */
|
||||||
|
void selector_request_init(selector_request_t *req);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Resolve a selector request against the role table.
|
||||||
|
* On success (returns SELECTOR_OK), *out points to the matched role_entry_t.
|
||||||
|
* On failure, returns one of the SELECTOR_ERR_* codes and *out is NULL.
|
||||||
|
*/
|
||||||
|
int selector_resolve(const selector_request_t *req, role_table_t *table, role_entry_t **out);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return a human-readable error string for a selector error code.
|
||||||
|
*/
|
||||||
|
const char *selector_strerror(int err);
|
||||||
|
|
||||||
|
|
||||||
|
/* from enforcement.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Error codes */
|
||||||
|
#define ENFORCE_OK 0
|
||||||
|
#define ENFORCE_ERR_PURPOSE -1 /* purpose mismatch */
|
||||||
|
#define ENFORCE_ERR_CURVE -2 /* curve mismatch */
|
||||||
|
#define ENFORCE_ERR_UNKNOWN_VERB -3 /* verb not recognized */
|
||||||
|
|
||||||
|
/* Known verbs */
|
||||||
|
#define VERB_SIGN_EVENT "sign_event"
|
||||||
|
#define VERB_GET_PUBLIC_KEY "get_public_key"
|
||||||
|
#define VERB_NIP44_ENCRYPT "nip44_encrypt"
|
||||||
|
#define VERB_NIP44_DECRYPT "nip44_decrypt"
|
||||||
|
#define VERB_NIP04_ENCRYPT "nip04_encrypt"
|
||||||
|
#define VERB_NIP04_DECRYPT "nip04_decrypt"
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check whether `verb` is allowed to execute against `role`.
|
||||||
|
* Returns ENFORCE_OK if allowed, or an ENFORCE_ERR_* code.
|
||||||
|
*
|
||||||
|
* The enforcement rules are:
|
||||||
|
* - All nostr verbs (sign_event, get_public_key, nip44_*, nip04_*) require:
|
||||||
|
* purpose == PURPOSE_NOSTR and curve == CURVE_SECP256K1
|
||||||
|
* - Unknown verbs return ENFORCE_ERR_UNKNOWN_VERB (fail-closed).
|
||||||
|
*/
|
||||||
|
int enforce_verb_role(const char *verb, const role_entry_t *role);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return a human-readable error string for an enforcement error code.
|
||||||
|
*/
|
||||||
|
const char *enforce_strerror(int err);
|
||||||
|
|
||||||
|
|
||||||
|
/* from policy.h */
|
||||||
|
|
||||||
|
|
||||||
|
#define POLICY_MAX_ENTRIES 32
|
||||||
|
#define POLICY_MAX_VERBS 16
|
||||||
|
#define POLICY_MAX_ROLES 16
|
||||||
|
#define POLICY_MAX_PURPOSES 8
|
||||||
|
#define POLICY_VERB_MAX_LEN 32
|
||||||
|
#define POLICY_CALLER_MAX_LEN 160
|
||||||
|
|
||||||
|
/* Prompt behavior */
|
||||||
|
typedef enum {
|
||||||
|
PROMPT_NEVER = 0,
|
||||||
|
PROMPT_FIRST_PER_BOOT,
|
||||||
|
PROMPT_EVERY_REQUEST,
|
||||||
|
PROMPT_DENY
|
||||||
|
} prompt_mode_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
POLICY_SOURCE_DEFAULT = 0, /* the catch-all entry */
|
||||||
|
POLICY_SOURCE_PREAPPROVE, /* from --preapprove CLI flag */
|
||||||
|
POLICY_SOURCE_SESSION_GRANT /* from prompt [a] during session */
|
||||||
|
} policy_source_t;
|
||||||
|
|
||||||
|
/* A single policy entry */
|
||||||
|
typedef struct {
|
||||||
|
char caller[POLICY_CALLER_MAX_LEN]; /* e.g. "uid:1000" or "*" for any */
|
||||||
|
char verbs[POLICY_MAX_VERBS][POLICY_VERB_MAX_LEN];
|
||||||
|
int verb_count;
|
||||||
|
char roles[POLICY_MAX_ROLES][ROLE_NAME_MAX];
|
||||||
|
int role_count;
|
||||||
|
char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX];
|
||||||
|
int purpose_count;
|
||||||
|
prompt_mode_t prompt;
|
||||||
|
policy_source_t source;
|
||||||
|
} policy_entry_t;
|
||||||
|
|
||||||
|
/* Policy table */
|
||||||
|
typedef struct {
|
||||||
|
policy_entry_t entries[POLICY_MAX_ENTRIES];
|
||||||
|
int count;
|
||||||
|
} policy_table_t;
|
||||||
|
|
||||||
|
/* Policy check result */
|
||||||
|
#define POLICY_ALLOW 0
|
||||||
|
#define POLICY_DENY -1
|
||||||
|
#define POLICY_PROMPT -2 /* would need user confirmation */
|
||||||
|
#define POLICY_NO_MATCH -3 /* no policy entry matched (fail-closed = deny) */
|
||||||
|
|
||||||
|
/* Initialize policy table */
|
||||||
|
void policy_table_init(policy_table_t *table);
|
||||||
|
|
||||||
|
/* Initialize default policy: allow same-uid, deny others */
|
||||||
|
void policy_init_default(policy_table_t *table, uid_t owner_uid);
|
||||||
|
|
||||||
|
/* Add a policy entry. Returns 0 on success, -1 if full. */
|
||||||
|
int policy_table_add(policy_table_t *table, const policy_entry_t *entry);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check whether caller_id is allowed to invoke `verb` on `role_name` with given `purpose`.
|
||||||
|
* 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);
|
||||||
|
|
||||||
|
/* Parse prompt mode from string */
|
||||||
|
prompt_mode_t prompt_mode_from_str(const char *s);
|
||||||
|
|
||||||
|
/* Prompt mode to string */
|
||||||
|
const char *prompt_mode_to_str(prompt_mode_t m);
|
||||||
|
|
||||||
|
|
||||||
|
/* from crypto.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Per-role derived key material (stored in secure memory) */
|
||||||
|
typedef struct {
|
||||||
|
secure_buf_t private_key; /* 32 bytes, mlock'd */
|
||||||
|
unsigned char public_key[32];
|
||||||
|
char pubkey_hex[65]; /* 64 hex chars + null */
|
||||||
|
char npub[128]; /* bech32 npub */
|
||||||
|
int valid;
|
||||||
|
} derived_key_t;
|
||||||
|
|
||||||
|
/* Key store — holds derived keys for all roles */
|
||||||
|
typedef struct {
|
||||||
|
derived_key_t keys[ROLE_TABLE_MAX_ENTRIES];
|
||||||
|
int count;
|
||||||
|
} key_store_t;
|
||||||
|
|
||||||
|
/* Derive keys for all roles in the table using the loaded mnemonic.
|
||||||
|
* Populates key_store and sets role->pubkey_hex and role->derived for each role.
|
||||||
|
* Only derives for roles with purpose=nostr and curve=secp256k1 (for now).
|
||||||
|
* Returns number of keys derived, or -1 on error. */
|
||||||
|
int crypto_derive_all(key_store_t *store, role_table_t *table, const mnemonic_state_t *mnemonic);
|
||||||
|
|
||||||
|
/* Get the derived private key for a role (by table index). Returns NULL if not derived. */
|
||||||
|
const unsigned char *crypto_get_private_key(const key_store_t *store, int role_index);
|
||||||
|
|
||||||
|
/* Get the derived public key hex for a role. Returns NULL if not derived. */
|
||||||
|
const char *crypto_get_pubkey_hex(const key_store_t *store, int role_index);
|
||||||
|
|
||||||
|
/* Sign a Nostr event. event_json is the unsigned event JSON string.
|
||||||
|
* Returns a newly-allocated string containing the signed event JSON, or NULL on error.
|
||||||
|
* Caller must free() the returned string. */
|
||||||
|
char *crypto_sign_event(const key_store_t *store, int role_index, const char *event_json);
|
||||||
|
|
||||||
|
/* Zeroize all derived keys in the store. */
|
||||||
|
void crypto_wipe(key_store_t *store);
|
||||||
|
|
||||||
|
|
||||||
|
/* from dispatcher.h */
|
||||||
|
|
||||||
|
|
||||||
|
/* Dispatcher context — holds references to shared state */
|
||||||
|
typedef struct {
|
||||||
|
role_table_t *role_table;
|
||||||
|
mnemonic_state_t *mnemonic;
|
||||||
|
key_store_t *key_store;
|
||||||
|
} dispatcher_ctx_t;
|
||||||
|
|
||||||
|
/* Initialize dispatcher context */
|
||||||
|
void dispatcher_init(dispatcher_ctx_t *ctx, role_table_t *table, mnemonic_state_t *mnemonic, key_store_t *key_store);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Process a JSON-RPC request string and produce a JSON-RPC response string.
|
||||||
|
*
|
||||||
|
* The caller owns the returned string and must free() it.
|
||||||
|
* Returns NULL only on catastrophic allocation failure.
|
||||||
|
*
|
||||||
|
* Response format on success:
|
||||||
|
* { "id": "...", "result": "..." }
|
||||||
|
*
|
||||||
|
* Response format on error:
|
||||||
|
* { "id": "...", "error": { "code": <int>, "message": "..." } }
|
||||||
|
*
|
||||||
|
* Error codes:
|
||||||
|
* -32700 Parse error (invalid JSON)
|
||||||
|
* -32600 Invalid request (missing id/method/params)
|
||||||
|
* -32601 Method not found (unknown verb after enforcement)
|
||||||
|
* -32602 Invalid params
|
||||||
|
* 1001 ambiguous_role_selector
|
||||||
|
* 1002 role_not_found
|
||||||
|
* 1003 no_default_role
|
||||||
|
* 1004 purpose_mismatch
|
||||||
|
* 1005 curve_mismatch
|
||||||
|
* 1006 mnemonic_not_loaded
|
||||||
|
*/
|
||||||
|
char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request);
|
||||||
|
|
||||||
|
|
||||||
|
/* from server.h */
|
||||||
|
|
||||||
|
|
||||||
|
#define SERVER_SOCKET_NAME_MAX 108
|
||||||
|
#define SERVER_MAX_MSG_SIZE 65536
|
||||||
|
|
||||||
|
/* Caller identity */
|
||||||
|
typedef struct {
|
||||||
|
uid_t uid;
|
||||||
|
gid_t gid;
|
||||||
|
pid_t pid;
|
||||||
|
char caller_id[POLICY_CALLER_MAX_LEN]; /* "uid:<n>" */
|
||||||
|
} caller_identity_t;
|
||||||
|
|
||||||
|
/* Server context */
|
||||||
|
typedef struct {
|
||||||
|
char socket_name[SERVER_SOCKET_NAME_MAX]; /* abstract namespace name (without \0 prefix) */
|
||||||
|
char last_error[256];
|
||||||
|
int listen_fd;
|
||||||
|
int running;
|
||||||
|
dispatcher_ctx_t *dispatcher;
|
||||||
|
policy_table_t *policy;
|
||||||
|
int socket_name_explicit;
|
||||||
|
} server_ctx_t;
|
||||||
|
|
||||||
|
/* Initialize server context. socket_name is the abstract namespace name (e.g. "nsigner").
|
||||||
|
* socket_name_explicit should be non-zero when provided via --socket-name override. */
|
||||||
|
void server_init(server_ctx_t *ctx, const char *socket_name, int socket_name_explicit,
|
||||||
|
dispatcher_ctx_t *dispatcher, policy_table_t *policy);
|
||||||
|
|
||||||
|
/* Start listening. Returns 0 on success, -1 on error. */
|
||||||
|
int server_start(server_ctx_t *ctx);
|
||||||
|
|
||||||
|
/* Get human-readable description of last server error. */
|
||||||
|
const char *server_last_error(const server_ctx_t *ctx);
|
||||||
|
|
||||||
|
/* Handle one pending connection (non-blocking). Returns 1 if handled, 0 if nothing pending, -1 on error.
|
||||||
|
* activity_cb is called with a description string for the TUI activity log. */
|
||||||
|
typedef void (*server_activity_cb)(const char *message, void *user_data);
|
||||||
|
int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data);
|
||||||
|
|
||||||
|
/* Stop server and close socket */
|
||||||
|
void server_stop(server_ctx_t *ctx);
|
||||||
|
|
||||||
|
/* Extract caller identity from connected fd */
|
||||||
|
int server_get_caller(int fd, caller_identity_t *out);
|
||||||
|
|
||||||
|
|
||||||
|
/* from socket_name.h */
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Generate random socket name in format: nsigner_<word1>_<word2>
|
||||||
|
* Returns 0 on success, -1 on error.
|
||||||
|
*/
|
||||||
|
int socket_name_random(char *out, size_t out_len);
|
||||||
|
|
||||||
|
|
||||||
|
/* from main.h */
|
||||||
|
/*
|
||||||
|
* nsigner main header - version information
|
||||||
|
*
|
||||||
|
* Version macros are auto-updated by increment_and_push.sh.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
/* Version information (auto-updated by build/version tooling) */
|
||||||
|
#define NSIGNER_VERSION_MAJOR 0
|
||||||
|
#define NSIGNER_VERSION_MINOR 0
|
||||||
|
#define NSIGNER_VERSION_PATCH 2
|
||||||
|
#define NSIGNER_VERSION "v0.0.2"
|
||||||
|
|
||||||
|
|
||||||
|
/* NSIGNER_HEADERLESS_DECLS_END */
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
static int g_failures = 0;
|
||||||
|
|
||||||
|
static void check_condition(const char *name, int condition) {
|
||||||
|
if (condition) {
|
||||||
|
printf("PASS: %s\n", name);
|
||||||
|
} else {
|
||||||
|
printf("FAIL: %s\n", name);
|
||||||
|
g_failures++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
char a[128];
|
||||||
|
char b[128];
|
||||||
|
|
||||||
|
memset(a, 0, sizeof(a));
|
||||||
|
memset(b, 0, sizeof(b));
|
||||||
|
|
||||||
|
check_condition("socket_name_random(a) succeeds", socket_name_random(a, sizeof(a)) == 0);
|
||||||
|
check_condition("socket_name_random(b) succeeds", socket_name_random(b, sizeof(b)) == 0);
|
||||||
|
|
||||||
|
check_condition("name a starts with nsigner_", strncmp(a, "nsigner_", 8) == 0);
|
||||||
|
check_condition("name b starts with nsigner_", strncmp(b, "nsigner_", 8) == 0);
|
||||||
|
|
||||||
|
{
|
||||||
|
const char *suffix = a + 8;
|
||||||
|
const char *underscore = strchr(suffix, '_');
|
||||||
|
check_condition("name a contains second underscore", underscore != NULL && underscore > suffix && underscore[1] != '\0');
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const char *suffix = b + 8;
|
||||||
|
const char *underscore = strchr(suffix, '_');
|
||||||
|
check_condition("name b contains second underscore", underscore != NULL && underscore > suffix && underscore[1] != '\0');
|
||||||
|
}
|
||||||
|
|
||||||
|
check_condition("two generated names are typically different", strcmp(a, b) != 0);
|
||||||
|
|
||||||
|
if (g_failures == 0) {
|
||||||
|
printf("ALL TESTS PASSED\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("TESTS FAILED: %d\n", g_failures);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user