Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5fdb1a207 | ||
|
|
28f50a750f | ||
|
|
1dd6630311 | ||
|
|
252d026991 | ||
|
|
f9d7b94962 | ||
|
|
b089bf36e3 | ||
|
|
3e86e539e0 |
@@ -46,34 +46,46 @@ RUN cd /tmp && \
|
||||
|
||||
# Copy and build nostr_core_lib from project resources
|
||||
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 && \
|
||||
./build.sh --nips=1,4,6,19,44,46
|
||||
./build.sh --nips=1,4,6,19,44
|
||||
|
||||
# Copy source files
|
||||
COPY src/ /build/src/
|
||||
|
||||
# Build nsigner as a fully static binary
|
||||
RUN gcc -static -O2 -Wall -Wextra -std=c99 \
|
||||
-I/build/nostr_core_lib \
|
||||
-I/build/nostr_core_lib/nostr_core \
|
||||
-I/build/nostr_core_lib/cjson \
|
||||
/build/src/main.c \
|
||||
/build/src/secure_mem.c \
|
||||
/build/src/mnemonic.c \
|
||||
/build/src/role_table.c \
|
||||
/build/src/selector.c \
|
||||
/build/src/enforcement.c \
|
||||
/build/src/dispatcher.c \
|
||||
/build/src/policy.c \
|
||||
/build/src/server.c \
|
||||
/build/src/key_store.c \
|
||||
/build/src/socket_name.c \
|
||||
/build/nostr_core_lib/libnostr_core_x64.a \
|
||||
-o /build/nsigner_static \
|
||||
$(pkg-config --static --libs libcurl openssl) \
|
||||
-lsecp256k1 -lsqlite3 -lz -lpthread -lm
|
||||
RUN ARCH="$(uname -m)"; \
|
||||
case "$ARCH" in \
|
||||
aarch64|arm64) NOSTR_LIB="/build/nostr_core_lib/libnostr_core_arm64.a" ;; \
|
||||
x86_64|amd64) NOSTR_LIB="/build/nostr_core_lib/libnostr_core_x64.a" ;; \
|
||||
*) echo "Unsupported build arch: $ARCH"; exit 1 ;; \
|
||||
esac; \
|
||||
[ -f "$NOSTR_LIB" ] || { echo "Missing nostr core lib: $NOSTR_LIB"; ls -la /build/nostr_core_lib; exit 1; }; \
|
||||
gcc -static -Os -ffunction-sections -fdata-sections -Wl,--gc-sections -s -Wall -Wextra -std=c99 \
|
||||
-I/build/nostr_core_lib \
|
||||
-I/build/nostr_core_lib/nostr_core \
|
||||
-I/build/nostr_core_lib/cjson \
|
||||
/build/src/main.c \
|
||||
/build/src/secure_mem.c \
|
||||
/build/src/mnemonic.c \
|
||||
/build/src/role_table.c \
|
||||
/build/src/selector.c \
|
||||
/build/src/enforcement.c \
|
||||
/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 \
|
||||
"$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 && \
|
||||
(ldd /build/nsigner_static 2>&1 || true)
|
||||
|
||||
|
||||
7
Makefile
7
Makefile
@@ -1,6 +1,6 @@
|
||||
CC := gcc
|
||||
CFLAGS := -Wall -Wextra -std=c99 -O2 -Isrc -Iresources/nostr_core_lib -Iresources/nostr_core_lib/nostr_core -Iresources/nostr_core_lib/cjson
|
||||
LDFLAGS := resources/nostr_core_lib/libnostr_core_x64.a -lz -ldl -lpthread -lm -lssl -lcrypto -lcurl -lsecp256k1
|
||||
CFLAGS := -Wall -Wextra -std=c99 -Os -ffunction-sections -fdata-sections -Isrc -Iresources/nostr_core_lib -Iresources/nostr_core_lib/nostr_core -Iresources/nostr_core_lib/cjson
|
||||
LDFLAGS := -Wl,--gc-sections resources/nostr_core_lib/libnostr_core_x64.a -lz -ldl -lpthread -lm -lssl -lcrypto -lcurl -lsecp256k1
|
||||
|
||||
SRC_DIR := src
|
||||
BUILD_DIR := build
|
||||
@@ -18,6 +18,7 @@ SOURCES := \
|
||||
$(SRC_DIR)/dispatcher.c \
|
||||
$(SRC_DIR)/policy.c \
|
||||
$(SRC_DIR)/server.c \
|
||||
$(SRC_DIR)/transport_frame.c \
|
||||
$(SRC_DIR)/key_store.c \
|
||||
$(SRC_DIR)/socket_name.c
|
||||
|
||||
@@ -38,7 +39,7 @@ TEST_SOCKET_NAME_TARGET := $(BUILD_DIR)/test_socket_name
|
||||
all: dev
|
||||
|
||||
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)
|
||||
|
||||
|
||||
55
README.md
55
README.md
@@ -74,7 +74,7 @@ When started, `n_signer` immediately enters terminal input mode:
|
||||
- On `E`: prompt for mnemonic with terminal echo disabled, then validate.
|
||||
- On `G`: generate a fresh 12-word BIP-39 mnemonic from `getrandom(2)`, display it numbered with a "WRITE THIS DOWN — IT WILL NOT BE SHOWN AGAIN" warning, then continue. There is no confirmation step.
|
||||
2. Build in-memory role/selector state from the mnemonic.
|
||||
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.
|
||||
5. Switch to running status display, with the signer name and socket address shown in the banner.
|
||||
|
||||
@@ -108,9 +108,9 @@ Activity (latest first)
|
||||
|
||||
Hotkeys
|
||||
-------
|
||||
a add role
|
||||
a toggle auto-approve(prompt) for this session
|
||||
r refresh
|
||||
l lock session
|
||||
l lock/reunlock session
|
||||
q quit
|
||||
```
|
||||
|
||||
@@ -127,7 +127,7 @@ method: sign_event
|
||||
selector: role=ops
|
||||
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.
|
||||
@@ -173,6 +173,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" }] }
|
||||
```
|
||||
|
||||
Implemented signer verbs in this build:
|
||||
|
||||
- `get_public_key`
|
||||
- `sign_event`
|
||||
- `nip04_encrypt` / `nip04_decrypt`
|
||||
- `nip44_encrypt` / `nip44_decrypt`
|
||||
|
||||
Selector resolution order:
|
||||
|
||||
1. `role`
|
||||
@@ -224,12 +231,15 @@ Properties:
|
||||
Naming rules:
|
||||
|
||||
- Default: random pick at startup, displayed in the TUI banner.
|
||||
- Override: `--socket-name <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`.
|
||||
- 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 an explicit name override.
|
||||
|
||||
Discovery:
|
||||
|
||||
- `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
|
||||
|
||||
@@ -278,7 +288,25 @@ Program starts in attached foreground mode and prompts for mnemonic. After mnemo
|
||||
To force a specific socket name (e.g. for scripted clients):
|
||||
|
||||
```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)
|
||||
@@ -286,7 +314,7 @@ nsigner --socket-name my_test_signer
|
||||
From another terminal, target the signer by its socket name:
|
||||
|
||||
```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.
|
||||
@@ -294,7 +322,7 @@ If only one signer is running you can omit the override and the client will use
|
||||
Example signing request:
|
||||
|
||||
```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
|
||||
@@ -327,7 +355,7 @@ $ nsigner
|
||||
Terminal B:
|
||||
|
||||
```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>"}
|
||||
```
|
||||
|
||||
@@ -339,13 +367,13 @@ Build outputs a single executable artifact.
|
||||
- [`Makefile`](Makefile): local build targets
|
||||
- [`Dockerfile.alpine-musl`](Dockerfile.alpine-musl): reproducible Alpine musl toolchain
|
||||
- [`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:
|
||||
|
||||
```bash
|
||||
make dev
|
||||
./build/nsigner_dev --version
|
||||
./build/nsigner --version
|
||||
```
|
||||
|
||||
Static build:
|
||||
@@ -358,6 +386,9 @@ Static build:
|
||||
## 11. Document map
|
||||
|
||||
- [`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/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
|
||||
|
||||
@@ -105,18 +105,36 @@ echo "Platform: $PLATFORM"
|
||||
echo "Output: $BUILD_DIR/$OUTPUT_NAME"
|
||||
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"
|
||||
docker build \
|
||||
docker buildx build \
|
||||
--platform "$PLATFORM" \
|
||||
--target builder \
|
||||
-f "$DOCKERFILE" \
|
||||
-t "$IMAGE_TAG" \
|
||||
--load \
|
||||
"$SCRIPT_DIR"
|
||||
|
||||
echo "[2/3] Extracting static binary"
|
||||
CONTAINER_NAME="$(docker create "$IMAGE_TAG")"
|
||||
docker cp "$CONTAINER_NAME:/build/nsigner_static" "$BUILD_DIR/$OUTPUT_NAME"
|
||||
chmod +x "$BUILD_DIR/$OUTPUT_NAME"
|
||||
strip "$BUILD_DIR/$OUTPUT_NAME" >/dev/null 2>&1 || true
|
||||
|
||||
echo "[3/3] Verifying static binary"
|
||||
file "$BUILD_DIR/$OUTPUT_NAME"
|
||||
|
||||
458
documents/CLIENT_IMPLEMENTATION.md
Normal file
458
documents/CLIENT_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,458 @@
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
||||
## 2. Discovery and socket targeting
|
||||
|
||||
`nsigner` currently supports two transport families:
|
||||
|
||||
- Linux AF_UNIX **abstract namespace** sockets.
|
||||
- Stdio framed mode (`--listen stdio` and `--listen qrexec`) for one request/response exchange.
|
||||
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
## 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`
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
179
documents/QUBES_OS.md
Normal file
179
documents/QUBES_OS.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
## 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
|
||||
- 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.
|
||||
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))
|
||||
```
|
||||
195
install_qube_fips_nsigner.sh
Executable file
195
install_qube_fips_nsigner.sh
Executable file
@@ -0,0 +1,195 @@
|
||||
#!/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=v0.0.7
|
||||
# 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:-v0.0.7}"
|
||||
|
||||
NSIGNER_RELEASE_PAGE="https://git.laantungir.net/laantungir/n_signer/releases/tag/${NSIGNER_VERSION}"
|
||||
NSIGNER_API_TAG_URL="https://git.laantungir.net/api/v1/repos/laantungir/n_signer/releases/tags/${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=v0.0.7
|
||||
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}"
|
||||
}
|
||||
|
||||
download_nsigner_asset_url() {
|
||||
local headers=()
|
||||
if [[ -n "${NSIGNER_GITEA_TOKEN:-}" ]]; then
|
||||
headers=(-H "Authorization: token ${NSIGNER_GITEA_TOKEN}")
|
||||
fi
|
||||
|
||||
curl -fsSL "${headers[@]}" "${NSIGNER_API_TAG_URL}" \
|
||||
| jq -r '.assets[]?.browser_download_url // empty' \
|
||||
| grep -E 'nsigner_static_x86_64$' \
|
||||
| head -n1 || true
|
||||
}
|
||||
|
||||
install_nsigner() {
|
||||
log "Installing n_signer ${NSIGNER_VERSION}"
|
||||
log "Release page: ${NSIGNER_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"
|
||||
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
|
||||
140
plans/nsigner.md
140
plans/nsigner.md
@@ -105,7 +105,7 @@ Steps:
|
||||
- `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>`.
|
||||
- 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
|
||||
- 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.
|
||||
@@ -140,7 +140,7 @@ Privacy/UX notes:
|
||||
|
||||
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.
|
||||
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.
|
||||
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
|
||||
|
||||
- Automated test strategy for interactive TUI (stdin pipe? expect-style? separate test mode flag?)
|
||||
- ESP32 transport shim interface definition
|
||||
- NIP-46 relay transport integration timeline
|
||||
- Role enrollment UX: how many questions at startup vs. "just use main = index 0"?
|
||||
- Lock-without-exit: needed? Or is quit-and-relaunch sufficient?
|
||||
- ESP32 transport shim interface definition and frame format details.
|
||||
- NIP-46 relay transport integration timeline.
|
||||
- Role enrollment UX depth at startup vs. minimal defaults.
|
||||
- Optional policy granularity beyond same-uid + interactive prompt (e.g. per-verb/per-role session rules).
|
||||
|
||||
## 6. Immediate next work
|
||||
|
||||
- Execute Phase A (cleanup)
|
||||
- Execute Phase B (server module)
|
||||
- Execute Phase C (unified main)
|
||||
- Execute Phase D (policy simplification)
|
||||
- Execute Phase E (integration test)
|
||||
- Execute Phase G2 (mnemonic generation at startup)
|
||||
- Execute Phase H (multi-instance random naming + `list` subcommand)
|
||||
- Add regression tests for prompt-overlay behavior and running-phase hotkeys.
|
||||
- Expand integration coverage for NIP-04/NIP-44 edge cases and negative-path errors.
|
||||
- Document and test static artifact size budgets across targets.
|
||||
- Define MCU transport adapter contract to prepare desktop/firmware parity.
|
||||
|
||||
## 7. Transport expansion roadmap
|
||||
|
||||
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?
|
||||
|
||||
@@ -321,6 +321,18 @@ const char *crypto_get_pubkey_hex(const key_store_t *store, int role_index);
|
||||
* Caller must free() the returned string. */
|
||||
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. */
|
||||
void crypto_wipe(key_store_t *store);
|
||||
|
||||
@@ -356,7 +368,7 @@ void dispatcher_init(dispatcher_ctx_t *ctx, role_table_t *table, mnemonic_state_
|
||||
* -32601 Method not found (unknown verb after enforcement)
|
||||
* -32602 Invalid params
|
||||
* 1001 ambiguous_role_selector
|
||||
* 1002 role_not_found
|
||||
* 1002 unknown_role
|
||||
* 1003 no_default_role
|
||||
* 1004 purpose_mismatch
|
||||
* 1005 curve_mismatch
|
||||
@@ -614,7 +626,7 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
|
||||
return make_error_response(id_str, 1001, "ambiguous_role_selector");
|
||||
}
|
||||
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) {
|
||||
return make_error_response(id_str, 1003, "no_default_role");
|
||||
@@ -665,13 +677,61 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
|
||||
}
|
||||
result = owned_result;
|
||||
} 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) {
|
||||
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) {
|
||||
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) {
|
||||
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 {
|
||||
cJSON_Delete(root);
|
||||
return make_error_response(id_str, -32601, "method_not_found");
|
||||
|
||||
128
src/key_store.c
128
src/key_store.c
@@ -444,8 +444,10 @@ int socket_name_random(char *out, size_t out_len);
|
||||
|
||||
#include <nostr_core/nostr_common.h>
|
||||
#include <nostr_core/nip001.h>
|
||||
#include <nostr_core/nip004.h>
|
||||
#include <nostr_core/nip006.h>
|
||||
#include <nostr_core/nip019.h>
|
||||
#include <nostr_core/nip044.h>
|
||||
#include <nostr_core/utils.h>
|
||||
|
||||
#include <stdlib.h>
|
||||
@@ -609,6 +611,132 @@ char *crypto_sign_event(const key_store_t *store, int role_index, const char *ev
|
||||
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) {
|
||||
int i;
|
||||
|
||||
|
||||
663
src/main.c
663
src/main.c
@@ -373,12 +373,19 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
|
||||
#define SERVER_SOCKET_NAME_MAX 108
|
||||
#define SERVER_MAX_MSG_SIZE 65536
|
||||
|
||||
#define NSIGNER_LISTEN_UNIX 0
|
||||
#define NSIGNER_LISTEN_STDIO 1
|
||||
#define NSIGNER_LISTEN_QREXEC 2
|
||||
#define NSIGNER_LISTEN_TCP 3
|
||||
|
||||
/* Caller identity */
|
||||
typedef struct {
|
||||
uid_t uid;
|
||||
gid_t gid;
|
||||
pid_t pid;
|
||||
char caller_id[64]; /* "uid:<n>" */
|
||||
int kind;
|
||||
char caller_id[64]; /* "uid:<n>" or "qubes:<vm>" */
|
||||
char source_qube[64];
|
||||
} caller_identity_t;
|
||||
|
||||
/* Server context */
|
||||
@@ -387,6 +394,8 @@ typedef struct {
|
||||
char last_error[256];
|
||||
int listen_fd;
|
||||
int running;
|
||||
int listen_mode;
|
||||
int stdio_handled;
|
||||
dispatcher_ctx_t *dispatcher;
|
||||
policy_table_t *policy;
|
||||
int socket_name_explicit;
|
||||
@@ -395,6 +404,7 @@ typedef struct {
|
||||
/* 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,
|
||||
int listen_mode,
|
||||
dispatcher_ctx_t *dispatcher, policy_table_t *policy);
|
||||
|
||||
/* Start listening. Returns 0 on success, -1 on error. */
|
||||
@@ -414,6 +424,11 @@ void server_stop(server_ctx_t *ctx);
|
||||
/* Extract caller identity from connected fd */
|
||||
int server_get_caller(int fd, caller_identity_t *out);
|
||||
|
||||
/* Configure interactive policy-prompt behavior for current session */
|
||||
void server_set_prompt_always_allow(int enabled);
|
||||
|
||||
/* Configure non-interactive prompt fallback: -1 disabled, POLICY_ALLOW, or POLICY_DENY */
|
||||
void server_set_noninteractive_prompt_default(int decision);
|
||||
|
||||
/* from socket_name.h */
|
||||
|
||||
@@ -436,29 +451,44 @@ int socket_name_random(char *out, size_t out_len);
|
||||
/* Version information (auto-updated by build/version tooling) */
|
||||
#define NSIGNER_VERSION_MAJOR 0
|
||||
#define NSIGNER_VERSION_MINOR 0
|
||||
#define NSIGNER_VERSION_PATCH 4
|
||||
#define NSIGNER_VERSION "v0.0.4"
|
||||
#define NSIGNER_VERSION_PATCH 11
|
||||
#define NSIGNER_VERSION "v0.0.11"
|
||||
|
||||
|
||||
/* NSIGNER_HEADERLESS_DECLS_END */
|
||||
|
||||
int transport_send_framed(int fd, const char *payload);
|
||||
int transport_recv_framed(int fd, char **out_payload, size_t max_size);
|
||||
|
||||
#include <nostr_core/nostr_common.h>
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <ctype.h>
|
||||
#include <errno.h>
|
||||
#include <poll.h>
|
||||
#include <signal.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/un.h>
|
||||
#include <termios.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define NSIGNER_DEFAULT_SOCKET_NAME "nsigner"
|
||||
#define ACTIVITY_LOG_CAP 16
|
||||
|
||||
typedef struct {
|
||||
char lines[ACTIVITY_LOG_CAP][256];
|
||||
int count;
|
||||
} activity_log_t;
|
||||
|
||||
static volatile sig_atomic_t g_running = 1;
|
||||
static activity_log_t g_activity_log;
|
||||
static int g_auto_approve = 0;
|
||||
|
||||
static void handle_signal(int sig) {
|
||||
(void)sig;
|
||||
@@ -485,100 +515,6 @@ static int read_line_stdin(char *buf, size_t buf_sz) {
|
||||
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 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 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 > SERVER_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 connect_abstract_socket(const char *name) {
|
||||
int fd;
|
||||
@@ -612,14 +548,62 @@ static int connect_abstract_socket(const char *name) {
|
||||
static void print_usage(const char *program_name) {
|
||||
printf("nsigner - single-binary signer program\n");
|
||||
printf("Usage:\n");
|
||||
printf(" %s [--socket-name <name>] Run signer server + built-in TUI\n", program_name);
|
||||
printf(" %s [--socket-name <name>] client '<json>' Send JSON-RPC request\n", program_name);
|
||||
printf(" %s [--socket-name <name>] client - Read JSON-RPC request from stdin\n", program_name);
|
||||
printf(" %s [--socket-name|--name|-n <name>] [--listen <unix|stdio|qrexec|tcp:HOST:PORT>]\n", program_name);
|
||||
printf(" Run signer server (unix mode has TUI)\n");
|
||||
printf(" %s [--socket-name|--name|-n <name>] client '<json>' Send JSON-RPC request\n", program_name);
|
||||
printf(" %s [--socket-name|--name|-n <name>] client - Read JSON-RPC request from stdin\n", program_name);
|
||||
printf(" %s list List running nsigner abstract sockets\n", program_name);
|
||||
printf(" %s --help\n", program_name);
|
||||
printf(" %s --version\n", program_name);
|
||||
}
|
||||
|
||||
static int extract_nsigner_socket_from_proc_line(const char *line,
|
||||
char *with_at,
|
||||
size_t with_at_sz,
|
||||
char *without_at,
|
||||
size_t without_at_sz) {
|
||||
const char *p;
|
||||
const char *end;
|
||||
size_t len_with_at;
|
||||
|
||||
if (line == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
p = strstr(line, "@nsigner");
|
||||
if (p == NULL) {
|
||||
return -1;
|
||||
}
|
||||
if (p[8] != '\0' && p[8] != '\n' && p[8] != ' ' && p[8] != '\t' && p[8] != '_') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
end = p;
|
||||
while (*end != '\0' && *end != '\n' && *end != ' ' && *end != '\t') {
|
||||
end++;
|
||||
}
|
||||
|
||||
len_with_at = (size_t)(end - p);
|
||||
if (len_with_at <= 1 || len_with_at > SERVER_SOCKET_NAME_MAX) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (with_at != NULL && with_at_sz > 0) {
|
||||
size_t copy_len = (len_with_at < (with_at_sz - 1)) ? len_with_at : (with_at_sz - 1);
|
||||
memcpy(with_at, p, copy_len);
|
||||
with_at[copy_len] = '\0';
|
||||
}
|
||||
|
||||
if (without_at != NULL && without_at_sz > 0) {
|
||||
size_t len_without_at = len_with_at - 1;
|
||||
size_t copy_len = (len_without_at < (without_at_sz - 1)) ? len_without_at : (without_at_sz - 1);
|
||||
memcpy(without_at, p + 1, copy_len);
|
||||
without_at[copy_len] = '\0';
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int list_sockets_main(void) {
|
||||
FILE *fp;
|
||||
char line[512];
|
||||
@@ -632,14 +616,9 @@ static int list_sockets_main(void) {
|
||||
}
|
||||
|
||||
while (fgets(line, sizeof(line), fp) != NULL) {
|
||||
char *p = strstr(line, "@nsigner_");
|
||||
if (p != NULL) {
|
||||
char *end = p;
|
||||
while (*end != '\0' && *end != '\n' && *end != ' ' && *end != '\t') {
|
||||
end++;
|
||||
}
|
||||
*end = '\0';
|
||||
printf("%s\n", p);
|
||||
char name_with_at[SERVER_SOCKET_NAME_MAX + 1];
|
||||
if (extract_nsigner_socket_from_proc_line(line, name_with_at, sizeof(name_with_at), NULL, 0) == 0) {
|
||||
printf("%s\n", name_with_at);
|
||||
found = 1;
|
||||
}
|
||||
}
|
||||
@@ -669,32 +648,15 @@ static int discover_single_socket_name(char *out, size_t out_len) {
|
||||
}
|
||||
|
||||
while (fgets(line, sizeof(line), fp) != NULL) {
|
||||
char *p = strstr(line, "@nsigner_");
|
||||
if (p == NULL) {
|
||||
char name_no_at[SERVER_SOCKET_NAME_MAX + 1];
|
||||
if (extract_nsigner_socket_from_proc_line(line, NULL, 0, name_no_at, sizeof(name_no_at)) != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
{
|
||||
char *end = p;
|
||||
char name_buf[SERVER_SOCKET_NAME_MAX + 1];
|
||||
size_t len;
|
||||
|
||||
while (*end != '\0' && *end != '\n' && *end != ' ' && *end != '\t') {
|
||||
end++;
|
||||
}
|
||||
len = (size_t)(end - p);
|
||||
if (len <= 1 || len > SERVER_SOCKET_NAME_MAX) {
|
||||
continue;
|
||||
}
|
||||
|
||||
memcpy(name_buf, p + 1, len - 1);
|
||||
name_buf[len - 1] = '\0';
|
||||
|
||||
count++;
|
||||
if (count == 1) {
|
||||
strncpy(out, name_buf, out_len - 1);
|
||||
out[out_len - 1] = '\0';
|
||||
}
|
||||
count++;
|
||||
if (count == 1) {
|
||||
strncpy(out, name_no_at, out_len - 1);
|
||||
out[out_len - 1] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,7 +672,7 @@ static int client_main(int argc, char *argv[], const char *socket_name, int sock
|
||||
char stdin_buf[SERVER_MAX_MSG_SIZE + 1];
|
||||
|
||||
if (argc < 1) {
|
||||
fprintf(stderr, "Usage: nsigner [--socket-name <name>] client '<json-rpc-request>' | -\n");
|
||||
fprintf(stderr, "Usage: nsigner [--socket-name|--name|-n <name>] client '<json-rpc-request>' | -\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -737,13 +699,13 @@ static int client_main(int argc, char *argv[], const char *socket_name, int sock
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (send_framed(fd, request) != 0) {
|
||||
if (transport_send_framed(fd, request) != 0) {
|
||||
perror("send");
|
||||
close(fd);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (recv_framed(fd, &response) != 0) {
|
||||
if (transport_recv_framed(fd, &response, SERVER_MAX_MSG_SIZE) != 0) {
|
||||
perror("recv");
|
||||
close(fd);
|
||||
return 1;
|
||||
@@ -757,11 +719,97 @@ static int client_main(int argc, char *argv[], const char *socket_name, int sock
|
||||
}
|
||||
|
||||
static void activity_log_cb(const char *message, void *user_data) {
|
||||
int idx;
|
||||
time_t now;
|
||||
struct tm tm_now;
|
||||
char ts[16];
|
||||
|
||||
(void)user_data;
|
||||
if (message != NULL) {
|
||||
printf("%s\n", message);
|
||||
fflush(stdout);
|
||||
if (message == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
now = time(NULL);
|
||||
if (localtime_r(&now, &tm_now) != NULL) {
|
||||
(void)strftime(ts, sizeof(ts), "%m%d-%H%M%S", &tm_now);
|
||||
} else {
|
||||
strncpy(ts, "0000-000000", sizeof(ts) - 1);
|
||||
ts[sizeof(ts) - 1] = '\0';
|
||||
}
|
||||
|
||||
idx = g_activity_log.count % ACTIVITY_LOG_CAP;
|
||||
(void)snprintf(g_activity_log.lines[idx],
|
||||
sizeof(g_activity_log.lines[idx]),
|
||||
"%s %s",
|
||||
ts,
|
||||
message);
|
||||
g_activity_log.count++;
|
||||
}
|
||||
|
||||
static void tcp_activity_stdout_cb(const char *message, void *user_data) {
|
||||
time_t now;
|
||||
struct tm tm_now;
|
||||
char ts[32];
|
||||
|
||||
(void)user_data;
|
||||
if (message == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
now = time(NULL);
|
||||
if (localtime_r(&now, &tm_now) != NULL) {
|
||||
(void)strftime(ts, sizeof(ts), "%Y-%m-%d %H:%M:%S", &tm_now);
|
||||
} else {
|
||||
strncpy(ts, "0000-00-00 00:00:00", sizeof(ts) - 1);
|
||||
ts[sizeof(ts) - 1] = '\0';
|
||||
}
|
||||
|
||||
printf("[%s] %s\n", ts, message);
|
||||
fflush(stdout);
|
||||
}
|
||||
|
||||
static void render_status(const role_table_t *role_table,
|
||||
const mnemonic_state_t *mnemonic,
|
||||
int derived_count,
|
||||
const char *socket_name) {
|
||||
int i;
|
||||
int shown;
|
||||
|
||||
printf("\n=== n_signer %s ===\n", NSIGNER_VERSION);
|
||||
printf("session: %s (%d words)\n",
|
||||
mnemonic_is_loaded(mnemonic) ? "unlocked" : "locked",
|
||||
(mnemonic != NULL) ? mnemonic->word_count : 0);
|
||||
printf("signer : %s\n", (socket_name != NULL) ? socket_name : "(none)");
|
||||
printf("derived: %d\n", derived_count);
|
||||
|
||||
printf("\nRoles\n-----\n");
|
||||
if (role_table == NULL || role_table->count == 0) {
|
||||
printf("(none)\n");
|
||||
} else {
|
||||
for (i = 0; i < role_table->count; ++i) {
|
||||
const role_entry_t *r = &role_table->entries[i];
|
||||
printf("%s purpose=%s curve=%s selector=%s\n",
|
||||
r->name,
|
||||
role_purpose_to_str(r->purpose),
|
||||
role_curve_to_str(r->curve),
|
||||
(r->selector_type == SELECTOR_NOSTR_INDEX) ? "nostr_index" : "role_path");
|
||||
}
|
||||
}
|
||||
|
||||
printf("\nActivity (latest first)\n-----------------------\n");
|
||||
if (g_activity_log.count == 0) {
|
||||
printf("(none)\n");
|
||||
} else {
|
||||
shown = 0;
|
||||
for (i = g_activity_log.count - 1; i >= 0 && shown < ACTIVITY_LOG_CAP; --i, ++shown) {
|
||||
printf("%s\n", g_activity_log.lines[i % ACTIVITY_LOG_CAP]);
|
||||
}
|
||||
}
|
||||
|
||||
printf("\nHotkeys\n-------\n");
|
||||
printf("q/x quit l lock/reunlock r refresh a toggle auto-approve(prompt): %s\n",
|
||||
g_auto_approve ? "ON" : "OFF");
|
||||
fflush(stdout);
|
||||
}
|
||||
|
||||
static int setup_default_role(role_table_t *role_table) {
|
||||
@@ -787,87 +835,145 @@ static int setup_default_role(role_table_t *role_table) {
|
||||
static int prompt_load_mnemonic(mnemonic_state_t *mnemonic) {
|
||||
char phrase[MNEMONIC_MAX_LEN];
|
||||
char phrase_copy[MNEMONIC_MAX_LEN];
|
||||
char mode[16];
|
||||
struct termios old_term;
|
||||
struct termios new_term;
|
||||
int have_term = 0;
|
||||
char mode[MNEMONIC_MAX_LEN];
|
||||
int invalid_attempts = 0;
|
||||
const int max_invalid_attempts = 10;
|
||||
|
||||
if (mnemonic == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
printf("Mnemonic source: [E]nter existing or [G]enerate new (default E): ");
|
||||
fflush(stdout);
|
||||
if (read_line_stdin(mode, sizeof(mode)) != 0) {
|
||||
fprintf(stderr, "Failed to read mnemonic source choice\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (mode[0] == 'g' || mode[0] == 'G') {
|
||||
int idx = 1;
|
||||
char *ctx = NULL;
|
||||
char *word;
|
||||
|
||||
if (mnemonic_generate(12, phrase, sizeof(phrase)) != 0) {
|
||||
fprintf(stderr, "Failed to generate mnemonic\n");
|
||||
while (invalid_attempts < max_invalid_attempts) {
|
||||
printf("Mnemonic source: [E]nter existing or [G]enerate new (default E; you can also paste mnemonic here): ");
|
||||
fflush(stdout);
|
||||
if (read_line_stdin(mode, sizeof(mode)) != 0) {
|
||||
fprintf(stderr, "Failed to read mnemonic source choice\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
strncpy(phrase_copy, phrase, sizeof(phrase_copy) - 1);
|
||||
phrase_copy[sizeof(phrase_copy) - 1] = '\0';
|
||||
|
||||
printf("\nGenerated mnemonic (WRITE THIS DOWN - it will not be shown again):\n");
|
||||
word = strtok_r(phrase_copy, " ", &ctx);
|
||||
while (word != NULL) {
|
||||
printf("%2d. %s\n", idx, word);
|
||||
idx++;
|
||||
word = strtok_r(NULL, " ", &ctx);
|
||||
if ((mode[0] == 'q' || mode[0] == 'Q' || mode[0] == 'x' || mode[0] == 'X') && mode[1] == '\0') {
|
||||
fprintf(stderr, "User requested exit.\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (mnemonic_load(mnemonic, phrase) != 0) {
|
||||
if (strchr(mode, ' ') != NULL && mode[0] != 'g' && mode[0] != 'G') {
|
||||
if (mnemonic_load(mnemonic, mode) == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
invalid_attempts++;
|
||||
fprintf(stderr,
|
||||
"Invalid mnemonic (must be 12/15/18/21/24 words). Attempts: %d/%d\n",
|
||||
invalid_attempts,
|
||||
max_invalid_attempts);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mode[0] == 'g' || mode[0] == 'G') {
|
||||
int idx = 1;
|
||||
char *ctx = NULL;
|
||||
char *word;
|
||||
|
||||
if (mnemonic_generate(12, phrase, sizeof(phrase)) != 0) {
|
||||
fprintf(stderr, "Failed to generate mnemonic\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
strncpy(phrase_copy, phrase, sizeof(phrase_copy) - 1);
|
||||
phrase_copy[sizeof(phrase_copy) - 1] = '\0';
|
||||
|
||||
printf("\nGenerated mnemonic (WRITE THIS DOWN - it will not be shown again):\n");
|
||||
word = strtok_r(phrase_copy, " ", &ctx);
|
||||
while (word != NULL) {
|
||||
printf("%2d. %s\n", idx, word);
|
||||
idx++;
|
||||
word = strtok_r(NULL, " ", &ctx);
|
||||
}
|
||||
|
||||
if (mnemonic_load(mnemonic, phrase) != 0) {
|
||||
memset(phrase, 0, sizeof(phrase));
|
||||
memset(phrase_copy, 0, sizeof(phrase_copy));
|
||||
fprintf(stderr, "Failed to load generated mnemonic\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
memset(phrase, 0, sizeof(phrase));
|
||||
memset(phrase_copy, 0, sizeof(phrase_copy));
|
||||
fprintf(stderr, "Failed to load generated mnemonic\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
printf("Enter mnemonic (12/15/18/21/24 words): ");
|
||||
fflush(stdout);
|
||||
|
||||
if (read_line_stdin(phrase, sizeof(phrase)) != 0) {
|
||||
fprintf(stderr, "Failed to read mnemonic\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if ((phrase[0] == 'q' || phrase[0] == 'Q' || phrase[0] == 'x' || phrase[0] == 'X') && phrase[1] == '\0') {
|
||||
memset(phrase, 0, sizeof(phrase));
|
||||
fprintf(stderr, "User requested exit.\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (mnemonic_load(mnemonic, phrase) == 0) {
|
||||
memset(phrase, 0, sizeof(phrase));
|
||||
return 0;
|
||||
}
|
||||
|
||||
memset(phrase, 0, sizeof(phrase));
|
||||
memset(phrase_copy, 0, sizeof(phrase_copy));
|
||||
return 0;
|
||||
invalid_attempts++;
|
||||
fprintf(stderr,
|
||||
"Invalid mnemonic (must be 12/15/18/21/24 words). Attempts: %d/%d\n",
|
||||
invalid_attempts,
|
||||
max_invalid_attempts);
|
||||
}
|
||||
|
||||
printf("Enter mnemonic (12/15/18/21/24 words): ");
|
||||
fflush(stdout);
|
||||
fprintf(stderr, "Too many invalid mnemonic attempts (%d). Exiting.\n", max_invalid_attempts);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (isatty(STDIN_FILENO) && tcgetattr(STDIN_FILENO, &old_term) == 0) {
|
||||
new_term = old_term;
|
||||
new_term.c_lflag &= (tcflag_t)~ECHO;
|
||||
if (tcsetattr(STDIN_FILENO, TCSANOW, &new_term) == 0) {
|
||||
have_term = 1;
|
||||
static void apply_test_overrides(policy_table_t *policy) {
|
||||
const char *force_prompt;
|
||||
const char *noninteractive_prompt;
|
||||
const char *hotkeys;
|
||||
|
||||
if (policy == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
force_prompt = getenv("NSIGNER_TEST_FORCE_PROMPT");
|
||||
if (force_prompt != NULL && strcmp(force_prompt, "1") == 0) {
|
||||
policy_entry_t e;
|
||||
uid_t uid = getuid();
|
||||
|
||||
memset(&e, 0, sizeof(e));
|
||||
(void)snprintf(e.caller, sizeof(e.caller), "uid:%u", (unsigned int)uid);
|
||||
e.prompt = PROMPT_EVERY_REQUEST;
|
||||
(void)policy_table_add(policy, &e);
|
||||
}
|
||||
|
||||
noninteractive_prompt = getenv("NSIGNER_TEST_NONINTERACTIVE_PROMPT");
|
||||
if (noninteractive_prompt != NULL) {
|
||||
if (strcasecmp(noninteractive_prompt, "allow") == 0) {
|
||||
server_set_noninteractive_prompt_default(POLICY_ALLOW);
|
||||
} else if (strcasecmp(noninteractive_prompt, "deny") == 0) {
|
||||
server_set_noninteractive_prompt_default(POLICY_DENY);
|
||||
}
|
||||
}
|
||||
|
||||
if (read_line_stdin(phrase, sizeof(phrase)) != 0) {
|
||||
if (have_term) {
|
||||
(void)tcsetattr(STDIN_FILENO, TCSANOW, &old_term);
|
||||
hotkeys = getenv("NSIGNER_TEST_HOTKEYS");
|
||||
if (hotkeys != NULL) {
|
||||
const char *p = hotkeys;
|
||||
while (*p != '\0') {
|
||||
char ch = (char)tolower((unsigned char)*p);
|
||||
if (ch == 'a') {
|
||||
g_auto_approve = g_auto_approve ? 0 : 1;
|
||||
server_set_prompt_always_allow(g_auto_approve);
|
||||
}
|
||||
p++;
|
||||
}
|
||||
fprintf(stderr, "Failed to read mnemonic\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (have_term) {
|
||||
(void)tcsetattr(STDIN_FILENO, TCSANOW, &old_term);
|
||||
printf("\n");
|
||||
}
|
||||
|
||||
if (mnemonic_load(mnemonic, phrase) != 0) {
|
||||
memset(phrase, 0, sizeof(phrase));
|
||||
fprintf(stderr, "Invalid mnemonic (must be 12/15/18/21/24 words)\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
memset(phrase, 0, sizeof(phrase));
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
@@ -877,18 +983,22 @@ int main(int argc, char *argv[]) {
|
||||
policy_table_t policy;
|
||||
server_ctx_t server;
|
||||
key_store_t key_store;
|
||||
struct pollfd pfd;
|
||||
struct pollfd pfds[2];
|
||||
uid_t owner_uid;
|
||||
int derived_count;
|
||||
const char *socket_name = NSIGNER_DEFAULT_SOCKET_NAME;
|
||||
char generated_socket_name[SERVER_SOCKET_NAME_MAX];
|
||||
int socket_name_explicit = 0;
|
||||
int listen_mode = NSIGNER_LISTEN_UNIX;
|
||||
const char *listen_target = NSIGNER_DEFAULT_SOCKET_NAME;
|
||||
int argi = 1;
|
||||
|
||||
while (argi < argc) {
|
||||
if (strcmp(argv[argi], "--socket-name") == 0) {
|
||||
if (strcmp(argv[argi], "--socket-name") == 0 ||
|
||||
strcmp(argv[argi], "--name") == 0 ||
|
||||
strcmp(argv[argi], "-n") == 0) {
|
||||
if (argi + 1 >= argc) {
|
||||
fprintf(stderr, "Missing value for --socket-name\n");
|
||||
fprintf(stderr, "Missing value for %s\n", argv[argi]);
|
||||
return 1;
|
||||
}
|
||||
socket_name = argv[argi + 1];
|
||||
@@ -896,14 +1006,43 @@ int main(int argc, char *argv[]) {
|
||||
argi += 2;
|
||||
continue;
|
||||
}
|
||||
if (strcmp(argv[argi], "--listen") == 0) {
|
||||
if (argi + 1 >= argc) {
|
||||
fprintf(stderr, "Missing value for %s\n", argv[argi]);
|
||||
return 1;
|
||||
}
|
||||
if (strcmp(argv[argi + 1], "unix") == 0) {
|
||||
listen_mode = NSIGNER_LISTEN_UNIX;
|
||||
} else if (strcmp(argv[argi + 1], "stdio") == 0) {
|
||||
listen_mode = NSIGNER_LISTEN_STDIO;
|
||||
} else if (strcmp(argv[argi + 1], "qrexec") == 0) {
|
||||
listen_mode = NSIGNER_LISTEN_QREXEC;
|
||||
} else if (strncmp(argv[argi + 1], "tcp:", 4) == 0) {
|
||||
listen_mode = NSIGNER_LISTEN_TCP;
|
||||
listen_target = argv[argi + 1];
|
||||
} else {
|
||||
fprintf(stderr, "Invalid --listen mode: %s (expected unix|stdio|qrexec|tcp:HOST:PORT)\n", argv[argi + 1]);
|
||||
return 1;
|
||||
}
|
||||
argi += 2;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (argi < argc && strcmp(argv[argi], "client") == 0) {
|
||||
if (listen_mode != NSIGNER_LISTEN_UNIX) {
|
||||
fprintf(stderr, "--listen is server-only; client mode uses unix abstract sockets\n");
|
||||
return 1;
|
||||
}
|
||||
return client_main(argc - argi - 1, argv + argi + 1, socket_name, socket_name_explicit);
|
||||
}
|
||||
|
||||
if (argi < argc && strcmp(argv[argi], "list") == 0) {
|
||||
if (listen_mode != NSIGNER_LISTEN_UNIX) {
|
||||
fprintf(stderr, "--listen is server-only; list inspects unix abstract sockets\n");
|
||||
return 1;
|
||||
}
|
||||
return list_sockets_main();
|
||||
}
|
||||
|
||||
@@ -923,6 +1062,9 @@ int main(int argc, char *argv[]) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
printf("nsigner %s\n", NSIGNER_VERSION);
|
||||
fflush(stdout);
|
||||
|
||||
mnemonic_init(&mnemonic);
|
||||
if (prompt_load_mnemonic(&mnemonic) != 0) {
|
||||
mnemonic_unload(&mnemonic);
|
||||
@@ -955,9 +1097,20 @@ int main(int argc, char *argv[]) {
|
||||
dispatcher_init(&dispatcher, &role_table, &mnemonic, &key_store);
|
||||
|
||||
owner_uid = getuid();
|
||||
policy_init_default(&policy, owner_uid);
|
||||
if (listen_mode == NSIGNER_LISTEN_QREXEC || listen_mode == NSIGNER_LISTEN_TCP) {
|
||||
policy_entry_t e;
|
||||
policy_table_init(&policy);
|
||||
memset(&e, 0, sizeof(e));
|
||||
strncpy(e.caller, "*", sizeof(e.caller) - 1);
|
||||
e.prompt = PROMPT_EVERY_REQUEST;
|
||||
(void)policy_table_add(&policy, &e);
|
||||
} else {
|
||||
policy_init_default(&policy, owner_uid);
|
||||
}
|
||||
|
||||
if (!socket_name_explicit) {
|
||||
apply_test_overrides(&policy);
|
||||
|
||||
if (listen_mode == NSIGNER_LISTEN_UNIX && !socket_name_explicit) {
|
||||
if (socket_name_random(generated_socket_name, sizeof(generated_socket_name)) != 0) {
|
||||
fprintf(stderr, "Failed to generate random socket name\n");
|
||||
crypto_wipe(&key_store);
|
||||
@@ -968,9 +1121,27 @@ int main(int argc, char *argv[]) {
|
||||
socket_name = generated_socket_name;
|
||||
}
|
||||
|
||||
server_init(&server, socket_name, socket_name_explicit, &dispatcher, &policy);
|
||||
if (listen_mode == NSIGNER_LISTEN_UNIX) {
|
||||
listen_target = socket_name;
|
||||
} else if (listen_mode == NSIGNER_LISTEN_TCP && socket_name_explicit) {
|
||||
fprintf(stderr, "--socket-name is only valid with unix listen mode\n");
|
||||
crypto_wipe(&key_store);
|
||||
nostr_cleanup();
|
||||
mnemonic_unload(&mnemonic);
|
||||
return 1;
|
||||
}
|
||||
|
||||
server_init(&server, listen_target, socket_name_explicit, listen_mode, &dispatcher, &policy);
|
||||
if (server_start(&server) != 0) {
|
||||
fprintf(stderr, "Failed to start server on @%s: %s\n", socket_name, server_last_error(&server));
|
||||
if (listen_mode == NSIGNER_LISTEN_UNIX) {
|
||||
fprintf(stderr, "Failed to start server on @%s: %s\n", socket_name, server_last_error(&server));
|
||||
} else if (listen_mode == NSIGNER_LISTEN_TCP) {
|
||||
fprintf(stderr, "Failed to start server on %s: %s\n", listen_target, server_last_error(&server));
|
||||
} else {
|
||||
fprintf(stderr, "Failed to start server (%s): %s\n",
|
||||
(listen_mode == NSIGNER_LISTEN_QREXEC) ? "qrexec" : "stdio",
|
||||
server_last_error(&server));
|
||||
}
|
||||
crypto_wipe(&key_store);
|
||||
nostr_cleanup();
|
||||
mnemonic_unload(&mnemonic);
|
||||
@@ -980,24 +1151,53 @@ int main(int argc, char *argv[]) {
|
||||
(void)signal(SIGINT, handle_signal);
|
||||
(void)signal(SIGTERM, handle_signal);
|
||||
|
||||
printf("n_signer %s \\u2014 running\n", NSIGNER_VERSION);
|
||||
printf("Mnemonic: loaded (%d words)\n", mnemonic.word_count);
|
||||
printf("Roles: main (nostr, secp256k1, index=0)\n");
|
||||
printf("Derived keys: %d\n", derived_count);
|
||||
if (role_table.count > 0 && role_table.entries[0].derived) {
|
||||
printf("main pubkey(hex): %s\n", role_table.entries[0].pubkey_hex);
|
||||
if (listen_mode == NSIGNER_LISTEN_STDIO || listen_mode == NSIGNER_LISTEN_QREXEC) {
|
||||
int hrc = server_handle_one(&server, NULL, NULL);
|
||||
server_stop(&server);
|
||||
crypto_wipe(&key_store);
|
||||
nostr_cleanup();
|
||||
mnemonic_unload(&mnemonic);
|
||||
return (hrc < 0) ? 1 : 0;
|
||||
}
|
||||
printf("Signer name: %s\n", socket_name);
|
||||
printf("Listening: '@%s'\n", server.socket_name);
|
||||
printf("\n");
|
||||
printf("Activity:\n");
|
||||
fflush(stdout);
|
||||
|
||||
pfd.fd = server.listen_fd;
|
||||
pfd.events = POLLIN;
|
||||
if (listen_mode == NSIGNER_LISTEN_TCP) {
|
||||
pfds[0].fd = server.listen_fd;
|
||||
pfds[0].events = POLLIN;
|
||||
|
||||
while (g_running && server.running) {
|
||||
int prc = poll(pfds, 1, 200);
|
||||
if (prc < 0) {
|
||||
if (errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (prc > 0 && (pfds[0].revents & POLLIN)) {
|
||||
if (server_handle_one(&server, tcp_activity_stdout_cb, NULL) < 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
server_stop(&server);
|
||||
crypto_wipe(&key_store);
|
||||
nostr_cleanup();
|
||||
mnemonic_unload(&mnemonic);
|
||||
return 0;
|
||||
}
|
||||
|
||||
memset(&g_activity_log, 0, sizeof(g_activity_log));
|
||||
g_auto_approve = 0;
|
||||
server_set_prompt_always_allow(0);
|
||||
render_status(&role_table, &mnemonic, derived_count, socket_name);
|
||||
|
||||
pfds[0].fd = server.listen_fd;
|
||||
pfds[0].events = POLLIN;
|
||||
pfds[1].fd = STDIN_FILENO;
|
||||
pfds[1].events = POLLIN;
|
||||
|
||||
while (g_running && server.running) {
|
||||
int prc = poll(&pfd, 1, 200);
|
||||
int prc = poll(pfds, 2, 200);
|
||||
if (prc < 0) {
|
||||
if (errno == EINTR) {
|
||||
continue;
|
||||
@@ -1005,11 +1205,42 @@ int main(int argc, char *argv[]) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (prc > 0 && (pfd.revents & POLLIN)) {
|
||||
if (prc > 0 && (pfds[0].revents & POLLIN)) {
|
||||
int hrc = server_handle_one(&server, activity_log_cb, NULL);
|
||||
if (hrc < 0) {
|
||||
break;
|
||||
}
|
||||
render_status(&role_table, &mnemonic, derived_count, socket_name);
|
||||
}
|
||||
|
||||
if (prc > 0 && (pfds[1].revents & POLLIN)) {
|
||||
char ch = '\0';
|
||||
if (read(STDIN_FILENO, &ch, 1) > 0) {
|
||||
ch = (char)tolower((unsigned char)ch);
|
||||
if (ch == 'q' || ch == 'x') {
|
||||
g_running = 0;
|
||||
} else if (ch == 'r') {
|
||||
render_status(&role_table, &mnemonic, derived_count, socket_name);
|
||||
} else if (ch == 'a') {
|
||||
g_auto_approve = g_auto_approve ? 0 : 1;
|
||||
server_set_prompt_always_allow(g_auto_approve);
|
||||
render_status(&role_table, &mnemonic, derived_count, socket_name);
|
||||
} else if (ch == 'l') {
|
||||
printf("\n[lock] Session locked. Re-enter mnemonic to unlock.\n");
|
||||
fflush(stdout);
|
||||
crypto_wipe(&key_store);
|
||||
mnemonic_unload(&mnemonic);
|
||||
if (prompt_load_mnemonic(&mnemonic) != 0) {
|
||||
g_running = 0;
|
||||
} else {
|
||||
derived_count = crypto_derive_all(&key_store, &role_table, &mnemonic);
|
||||
if (derived_count < 0) {
|
||||
g_running = 0;
|
||||
}
|
||||
}
|
||||
render_status(&role_table, &mnemonic, derived_count, socket_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
583
src/server.c
583
src/server.c
@@ -373,12 +373,19 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
|
||||
#define SERVER_SOCKET_NAME_MAX 108
|
||||
#define SERVER_MAX_MSG_SIZE 65536
|
||||
|
||||
#define NSIGNER_LISTEN_UNIX 0
|
||||
#define NSIGNER_LISTEN_STDIO 1
|
||||
#define NSIGNER_LISTEN_QREXEC 2
|
||||
#define NSIGNER_LISTEN_TCP 3
|
||||
|
||||
/* Caller identity */
|
||||
typedef struct {
|
||||
uid_t uid;
|
||||
gid_t gid;
|
||||
pid_t pid;
|
||||
char caller_id[64]; /* "uid:<n>" */
|
||||
int kind;
|
||||
char caller_id[64]; /* "uid:<n>" or "qubes:<vm>" */
|
||||
char source_qube[64];
|
||||
} caller_identity_t;
|
||||
|
||||
/* Server context */
|
||||
@@ -387,6 +394,8 @@ typedef struct {
|
||||
char last_error[256];
|
||||
int listen_fd;
|
||||
int running;
|
||||
int listen_mode;
|
||||
int stdio_handled;
|
||||
dispatcher_ctx_t *dispatcher;
|
||||
policy_table_t *policy;
|
||||
int socket_name_explicit;
|
||||
@@ -395,6 +404,7 @@ typedef struct {
|
||||
/* 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,
|
||||
int listen_mode,
|
||||
dispatcher_ctx_t *dispatcher, policy_table_t *policy);
|
||||
|
||||
/* Start listening. Returns 0 on success, -1 on error. */
|
||||
@@ -442,7 +452,11 @@ int socket_name_random(char *out, size_t out_len);
|
||||
|
||||
/* NSIGNER_HEADERLESS_DECLS_END */
|
||||
|
||||
int transport_send_framed(int fd, const char *payload);
|
||||
int transport_recv_framed(int fd, char **out_payload, size_t max_size);
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <ctype.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdio.h>
|
||||
@@ -452,6 +466,231 @@ int socket_name_random(char *out, size_t out_len);
|
||||
#include <sys/un.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static int g_prompt_always_allow = 0;
|
||||
static int g_noninteractive_prompt_default = -1;
|
||||
|
||||
static int caller_id_extract_ipv6(const char *caller_id, char *out_ipv6, size_t out_sz) {
|
||||
const char *start;
|
||||
const char *end;
|
||||
size_t len;
|
||||
|
||||
if (caller_id == NULL || out_ipv6 == NULL || out_sz == 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (strncmp(caller_id, "tcp:[", 5) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
start = caller_id + 5;
|
||||
end = strchr(start, ']');
|
||||
if (end == NULL || end[1] != ':') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
len = (size_t)(end - start);
|
||||
if (len == 0 || len >= out_sz) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
memcpy(out_ipv6, start, len);
|
||||
out_ipv6[len] = '\0';
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int read_cmd_output(const char *cmd, char **out_buf) {
|
||||
FILE *fp;
|
||||
char chunk[512];
|
||||
char *buf = NULL;
|
||||
size_t used = 0;
|
||||
size_t cap = 0;
|
||||
|
||||
if (cmd == NULL || out_buf == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
*out_buf = NULL;
|
||||
fp = popen(cmd, "r");
|
||||
if (fp == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
while (fgets(chunk, sizeof(chunk), fp) != NULL) {
|
||||
size_t n = strlen(chunk);
|
||||
if (used + n + 1 > cap) {
|
||||
size_t new_cap = (cap == 0) ? 2048 : cap * 2;
|
||||
while (new_cap < used + n + 1) {
|
||||
new_cap *= 2;
|
||||
}
|
||||
{
|
||||
char *tmp = (char *)realloc(buf, new_cap);
|
||||
if (tmp == NULL) {
|
||||
free(buf);
|
||||
(void)pclose(fp);
|
||||
return -1;
|
||||
}
|
||||
buf = tmp;
|
||||
cap = new_cap;
|
||||
}
|
||||
}
|
||||
memcpy(buf + used, chunk, n);
|
||||
used += n;
|
||||
}
|
||||
|
||||
(void)pclose(fp);
|
||||
|
||||
if (buf == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
buf[used] = '\0';
|
||||
*out_buf = buf;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int lookup_fips_peer_for_ipv6(const char *ipv6,
|
||||
char *out_npub,
|
||||
size_t out_npub_sz,
|
||||
char *out_name,
|
||||
size_t out_name_sz) {
|
||||
char *json = NULL;
|
||||
cJSON *root = NULL;
|
||||
cJSON *peers = NULL;
|
||||
int i;
|
||||
|
||||
if (ipv6 == NULL || out_npub == NULL || out_npub_sz == 0 || out_name == NULL || out_name_sz == 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
out_npub[0] = '\0';
|
||||
out_name[0] = '\0';
|
||||
|
||||
if (read_cmd_output("fipsctl show peers 2>/dev/null", &json) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
root = cJSON_Parse(json);
|
||||
free(json);
|
||||
if (root == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
peers = cJSON_GetObjectItemCaseSensitive(root, "peers");
|
||||
if (!cJSON_IsArray(peers)) {
|
||||
cJSON_Delete(root);
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (i = 0; i < cJSON_GetArraySize(peers); ++i) {
|
||||
cJSON *peer = cJSON_GetArrayItem(peers, i);
|
||||
cJSON *peer_ip;
|
||||
cJSON *peer_npub;
|
||||
cJSON *peer_name;
|
||||
|
||||
if (!cJSON_IsObject(peer)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
peer_ip = cJSON_GetObjectItemCaseSensitive(peer, "ipv6_addr");
|
||||
if (!cJSON_IsString(peer_ip) || peer_ip->valuestring == NULL) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strcmp(peer_ip->valuestring, ipv6) != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
peer_npub = cJSON_GetObjectItemCaseSensitive(peer, "npub");
|
||||
peer_name = cJSON_GetObjectItemCaseSensitive(peer, "display_name");
|
||||
|
||||
if (cJSON_IsString(peer_npub) && peer_npub->valuestring != NULL) {
|
||||
strncpy(out_npub, peer_npub->valuestring, out_npub_sz - 1);
|
||||
out_npub[out_npub_sz - 1] = '\0';
|
||||
}
|
||||
if (cJSON_IsString(peer_name) && peer_name->valuestring != NULL) {
|
||||
strncpy(out_name, peer_name->valuestring, out_name_sz - 1);
|
||||
out_name[out_name_sz - 1] = '\0';
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
return (out_npub[0] != '\0') ? 0 : -1;
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
return -1;
|
||||
}
|
||||
|
||||
void server_set_prompt_always_allow(int enabled) {
|
||||
g_prompt_always_allow = enabled ? 1 : 0;
|
||||
}
|
||||
|
||||
void server_set_noninteractive_prompt_default(int decision) {
|
||||
if (decision == POLICY_ALLOW || decision == POLICY_DENY) {
|
||||
g_noninteractive_prompt_default = decision;
|
||||
} else {
|
||||
g_noninteractive_prompt_default = -1;
|
||||
}
|
||||
}
|
||||
|
||||
static int prompt_for_policy_decision(const caller_identity_t *caller,
|
||||
const char *method,
|
||||
const char *role_name,
|
||||
const char *purpose) {
|
||||
int ch;
|
||||
|
||||
if (g_prompt_always_allow) {
|
||||
return POLICY_ALLOW;
|
||||
}
|
||||
|
||||
if (!isatty(STDIN_FILENO)) {
|
||||
if (g_noninteractive_prompt_default == POLICY_ALLOW ||
|
||||
g_noninteractive_prompt_default == POLICY_DENY) {
|
||||
return g_noninteractive_prompt_default;
|
||||
}
|
||||
return POLICY_DENY;
|
||||
}
|
||||
|
||||
printf("\nApproval required\n");
|
||||
printf("caller: %s\n", (caller != NULL) ? caller->caller_id : "unknown");
|
||||
if (caller != NULL && caller->kind == NSIGNER_LISTEN_TCP) {
|
||||
char ipv6[INET6_ADDRSTRLEN];
|
||||
char npub[128];
|
||||
char display_name[128];
|
||||
|
||||
if (caller_id_extract_ipv6(caller->caller_id, ipv6, sizeof(ipv6)) == 0 &&
|
||||
lookup_fips_peer_for_ipv6(ipv6, npub, sizeof(npub), display_name, sizeof(display_name)) == 0) {
|
||||
if (display_name[0] != '\0') {
|
||||
printf("fips peer: %s (%s)\n", npub, display_name);
|
||||
} else {
|
||||
printf("fips peer: %s\n", npub);
|
||||
}
|
||||
}
|
||||
}
|
||||
printf("method: %s\n", (method != NULL) ? method : "unknown");
|
||||
printf("role: %s\n", (role_name != NULL) ? role_name : "unknown");
|
||||
printf("purpose: %s\n", (purpose != NULL) ? purpose : "unknown");
|
||||
printf("[y] allow once [n] deny [a] always allow this session\n> ");
|
||||
fflush(stdout);
|
||||
|
||||
ch = getchar();
|
||||
while (ch != EOF && ch != '\n') {
|
||||
int next = getchar();
|
||||
if (next == EOF || next == '\n') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ch = tolower(ch);
|
||||
if (ch == 'a') {
|
||||
g_prompt_always_allow = 1;
|
||||
return POLICY_ALLOW;
|
||||
}
|
||||
if (ch == 'y') {
|
||||
return POLICY_ALLOW;
|
||||
}
|
||||
return POLICY_DENY;
|
||||
}
|
||||
|
||||
static void server_set_error(server_ctx_t *ctx, const char *msg) {
|
||||
if (ctx == NULL) {
|
||||
return;
|
||||
@@ -466,98 +705,81 @@ static void server_set_error(server_ctx_t *ctx, const char *msg) {
|
||||
ctx->last_error[sizeof(ctx->last_error) - 1] = '\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) {
|
||||
static int parse_tcp_target(const char *target,
|
||||
int *out_family,
|
||||
char *out_host,
|
||||
size_t out_host_sz,
|
||||
uint16_t *out_port) {
|
||||
const char *p;
|
||||
const char *host_start;
|
||||
const char *host_end;
|
||||
const char *port_start;
|
||||
char port_buf[16];
|
||||
size_t host_len;
|
||||
size_t port_len;
|
||||
char *endptr = NULL;
|
||||
long port_long;
|
||||
|
||||
if (target == NULL || out_family == NULL || out_host == NULL || out_port == NULL ||
|
||||
out_host_sz == 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (strncmp(target, "tcp:", 4) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
p = target + 4;
|
||||
if (*p == '[') {
|
||||
host_start = p + 1;
|
||||
host_end = strchr(host_start, ']');
|
||||
if (host_end == NULL || host_end[1] != ':') {
|
||||
return -1;
|
||||
}
|
||||
if (n < 0) {
|
||||
if (errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
port_start = host_end + 2;
|
||||
} else {
|
||||
host_start = p;
|
||||
host_end = strrchr(p, ':');
|
||||
if (host_end == NULL || host_end == host_start) {
|
||||
return -1;
|
||||
}
|
||||
off += (size_t)n;
|
||||
port_start = host_end + 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
host_len = (size_t)(host_end - host_start);
|
||||
if (host_len == 0 || host_len >= out_host_sz) {
|
||||
return -1;
|
||||
}
|
||||
memcpy(out_host, host_start, host_len);
|
||||
out_host[host_len] = '\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;
|
||||
port_len = strlen(port_start);
|
||||
if (port_len == 0 || port_len >= sizeof(port_buf)) {
|
||||
return -1;
|
||||
}
|
||||
memcpy(port_buf, port_start, port_len + 1);
|
||||
|
||||
while (off < len) {
|
||||
ssize_t n = write(fd, p + off, len - off);
|
||||
if (n < 0) {
|
||||
if (errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
errno = 0;
|
||||
port_long = strtol(port_buf, &endptr, 10);
|
||||
if (errno != 0 || endptr == port_buf || *endptr != '\0' || port_long < 1 || port_long > 65535) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
{
|
||||
struct in6_addr addr6;
|
||||
struct in_addr addr4;
|
||||
|
||||
if (inet_pton(AF_INET6, out_host, &addr6) == 1) {
|
||||
*out_family = AF_INET6;
|
||||
} else if (inet_pton(AF_INET, out_host, &addr4) == 1) {
|
||||
*out_family = AF_INET;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
off += (size_t)n;
|
||||
}
|
||||
|
||||
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 > SERVER_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 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;
|
||||
}
|
||||
|
||||
*out_port = (uint16_t)port_long;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -636,6 +858,7 @@ static int extract_method_and_selector(const char *json,
|
||||
}
|
||||
|
||||
void server_init(server_ctx_t *ctx, const char *socket_name, int socket_name_explicit,
|
||||
int listen_mode,
|
||||
dispatcher_ctx_t *dispatcher, policy_table_t *policy) {
|
||||
if (ctx == NULL) {
|
||||
return;
|
||||
@@ -648,6 +871,8 @@ void server_init(server_ctx_t *ctx, const char *socket_name, int socket_name_exp
|
||||
ctx->socket_name[sizeof(ctx->socket_name) - 1] = '\0';
|
||||
}
|
||||
ctx->listen_fd = -1;
|
||||
ctx->listen_mode = listen_mode;
|
||||
ctx->stdio_handled = 0;
|
||||
ctx->dispatcher = dispatcher;
|
||||
ctx->policy = policy;
|
||||
ctx->socket_name_explicit = socket_name_explicit ? 1 : 0;
|
||||
@@ -671,6 +896,105 @@ int server_start(server_ctx_t *ctx) {
|
||||
|
||||
server_set_error(ctx, NULL);
|
||||
|
||||
if (ctx->listen_mode == NSIGNER_LISTEN_STDIO || ctx->listen_mode == NSIGNER_LISTEN_QREXEC) {
|
||||
ctx->listen_fd = STDIN_FILENO;
|
||||
ctx->running = 1;
|
||||
ctx->stdio_handled = 0;
|
||||
server_set_error(ctx, NULL);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (ctx->listen_mode == NSIGNER_LISTEN_TCP) {
|
||||
int family;
|
||||
uint16_t port;
|
||||
char host[64];
|
||||
int one = 1;
|
||||
|
||||
int prc = parse_tcp_target(ctx->socket_name, &family, host, sizeof(host), &port);
|
||||
if (prc != 0) {
|
||||
(void)snprintf(ctx->last_error,
|
||||
sizeof(ctx->last_error),
|
||||
"invalid tcp listen target: %s (expected tcp:IPv4:PORT or tcp:[IPv6]:PORT)",
|
||||
ctx->socket_name);
|
||||
return -1;
|
||||
}
|
||||
|
||||
fd = socket(family, SOCK_STREAM, 0);
|
||||
if (fd < 0) {
|
||||
(void)snprintf(ctx->last_error,
|
||||
sizeof(ctx->last_error),
|
||||
"socket(tcp) failed: %s",
|
||||
strerror(errno));
|
||||
return -1;
|
||||
}
|
||||
|
||||
(void)setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
|
||||
|
||||
if (family == AF_INET) {
|
||||
struct sockaddr_in addr4;
|
||||
memset(&addr4, 0, sizeof(addr4));
|
||||
addr4.sin_family = AF_INET;
|
||||
addr4.sin_port = htons(port);
|
||||
if (inet_pton(AF_INET, host, &addr4.sin_addr) != 1) {
|
||||
close(fd);
|
||||
server_set_error(ctx, "inet_pton(AF_INET) failed for listen target");
|
||||
return -1;
|
||||
}
|
||||
if (bind(fd, (struct sockaddr *)&addr4, sizeof(addr4)) != 0) {
|
||||
(void)snprintf(ctx->last_error,
|
||||
sizeof(ctx->last_error),
|
||||
"bind(%s) failed: %s",
|
||||
ctx->socket_name,
|
||||
strerror(errno));
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
} else {
|
||||
struct sockaddr_in6 addr6;
|
||||
memset(&addr6, 0, sizeof(addr6));
|
||||
addr6.sin6_family = AF_INET6;
|
||||
addr6.sin6_port = htons(port);
|
||||
if (inet_pton(AF_INET6, host, &addr6.sin6_addr) != 1) {
|
||||
close(fd);
|
||||
server_set_error(ctx, "inet_pton(AF_INET6) failed for listen target");
|
||||
return -1;
|
||||
}
|
||||
if (bind(fd, (struct sockaddr *)&addr6, sizeof(addr6)) != 0) {
|
||||
(void)snprintf(ctx->last_error,
|
||||
sizeof(ctx->last_error),
|
||||
"bind(%s) failed: %s",
|
||||
ctx->socket_name,
|
||||
strerror(errno));
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (listen(fd, 16) != 0) {
|
||||
(void)snprintf(ctx->last_error,
|
||||
sizeof(ctx->last_error),
|
||||
"listen() failed: %s",
|
||||
strerror(errno));
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
|
||||
flags = fcntl(fd, F_GETFL, 0);
|
||||
if (flags < 0 || fcntl(fd, F_SETFL, flags | O_NONBLOCK) != 0) {
|
||||
(void)snprintf(ctx->last_error,
|
||||
sizeof(ctx->last_error),
|
||||
"fcntl(O_NONBLOCK) failed: %s",
|
||||
strerror(errno));
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
|
||||
ctx->listen_fd = fd;
|
||||
ctx->running = 1;
|
||||
server_set_error(ctx, NULL);
|
||||
return 0;
|
||||
}
|
||||
|
||||
fd = socket(AF_UNIX, SOCK_STREAM, 0);
|
||||
if (fd < 0) {
|
||||
(void)snprintf(ctx->last_error,
|
||||
@@ -774,6 +1098,56 @@ int server_get_caller(int fd, caller_identity_t *out) {
|
||||
|
||||
memset(out, 0, sizeof(*out));
|
||||
|
||||
if (fd == STDIN_FILENO) {
|
||||
const char *src = getenv("QREXEC_REMOTE_DOMAIN");
|
||||
out->kind = NSIGNER_LISTEN_STDIO;
|
||||
if (src != NULL && src[0] != '\0') {
|
||||
out->kind = NSIGNER_LISTEN_QREXEC;
|
||||
strncpy(out->source_qube, src, sizeof(out->source_qube) - 1);
|
||||
out->source_qube[sizeof(out->source_qube) - 1] = '\0';
|
||||
(void)snprintf(out->caller_id, sizeof(out->caller_id), "qubes:%.57s", out->source_qube);
|
||||
return 0;
|
||||
}
|
||||
|
||||
out->uid = getuid();
|
||||
out->gid = getgid();
|
||||
out->pid = getpid();
|
||||
(void)snprintf(out->caller_id, sizeof(out->caller_id), "uid:%u", (unsigned int)out->uid);
|
||||
return 0;
|
||||
}
|
||||
|
||||
{
|
||||
struct sockaddr_storage peer;
|
||||
socklen_t peer_len = sizeof(peer);
|
||||
if (getpeername(fd, (struct sockaddr *)&peer, &peer_len) == 0) {
|
||||
if (peer.ss_family == AF_INET) {
|
||||
const struct sockaddr_in *in4 = (const struct sockaddr_in *)&peer;
|
||||
char ip[INET_ADDRSTRLEN];
|
||||
if (inet_ntop(AF_INET, &in4->sin_addr, ip, sizeof(ip)) != NULL) {
|
||||
out->kind = NSIGNER_LISTEN_TCP;
|
||||
(void)snprintf(out->caller_id,
|
||||
sizeof(out->caller_id),
|
||||
"tcp:%s:%u",
|
||||
ip,
|
||||
(unsigned int)ntohs(in4->sin_port));
|
||||
return 0;
|
||||
}
|
||||
} else if (peer.ss_family == AF_INET6) {
|
||||
const struct sockaddr_in6 *in6 = (const struct sockaddr_in6 *)&peer;
|
||||
char ip6[INET6_ADDRSTRLEN];
|
||||
if (inet_ntop(AF_INET6, &in6->sin6_addr, ip6, sizeof(ip6)) != NULL) {
|
||||
out->kind = NSIGNER_LISTEN_TCP;
|
||||
(void)snprintf(out->caller_id,
|
||||
sizeof(out->caller_id),
|
||||
"tcp:[%s]:%u",
|
||||
ip6,
|
||||
(unsigned int)ntohs(in6->sin6_port));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &cred, &len) != 0) {
|
||||
return -1;
|
||||
}
|
||||
@@ -781,6 +1155,7 @@ int server_get_caller(int fd, caller_identity_t *out) {
|
||||
out->uid = cred.uid;
|
||||
out->gid = cred.gid;
|
||||
out->pid = cred.pid;
|
||||
out->kind = NSIGNER_LISTEN_UNIX;
|
||||
(void)snprintf(out->caller_id, sizeof(out->caller_id), "uid:%u", (unsigned int)out->uid);
|
||||
return 0;
|
||||
}
|
||||
@@ -803,26 +1178,38 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
client_fd = accept(ctx->listen_fd, NULL, NULL);
|
||||
if (client_fd < 0) {
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK) {
|
||||
if (ctx->listen_mode == NSIGNER_LISTEN_STDIO || ctx->listen_mode == NSIGNER_LISTEN_QREXEC) {
|
||||
if (ctx->stdio_handled) {
|
||||
return 0;
|
||||
}
|
||||
return -1;
|
||||
client_fd = STDIN_FILENO;
|
||||
ctx->stdio_handled = 1;
|
||||
} else {
|
||||
client_fd = accept(ctx->listen_fd, NULL, NULL);
|
||||
if (client_fd < 0) {
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK) {
|
||||
return 0;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (server_get_caller(client_fd, &caller) != 0) {
|
||||
close(client_fd);
|
||||
if (client_fd != STDIN_FILENO) {
|
||||
close(client_fd);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (recv_framed(client_fd, &request) != 0) {
|
||||
if (transport_recv_framed(client_fd, &request, SERVER_MAX_MSG_SIZE) != 0) {
|
||||
response = strdup("{\"id\":\"null\",\"error\":{\"code\":-32700,\"message\":\"parse_error\"}}");
|
||||
if (response != NULL) {
|
||||
(void)send_framed(client_fd, response);
|
||||
(void)transport_send_framed((client_fd == STDIN_FILENO) ? STDOUT_FILENO : client_fd, response);
|
||||
free(response);
|
||||
}
|
||||
close(client_fd);
|
||||
if (client_fd != STDIN_FILENO) {
|
||||
close(client_fd);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -840,6 +1227,10 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
|
||||
}
|
||||
|
||||
pchk = policy_check(ctx->policy, caller.caller_id, method, role_name, purpose);
|
||||
if (pchk == POLICY_PROMPT) {
|
||||
pchk = prompt_for_policy_decision(&caller, method, role_name, purpose);
|
||||
}
|
||||
|
||||
if (pchk == POLICY_ALLOW) {
|
||||
verdict = "ALLOWED";
|
||||
response = dispatcher_handle_request(ctx->dispatcher, request);
|
||||
@@ -847,18 +1238,18 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
|
||||
response = strdup("{\"id\":\"null\",\"error\":{\"code\":-32603,\"message\":\"internal_error\"}}");
|
||||
}
|
||||
} else {
|
||||
verdict = "DENIED";
|
||||
response = strdup("{\"id\":\"null\",\"error\":{\"code\":2001,\"message\":\"policy_denied\"}}");
|
||||
}
|
||||
|
||||
if (response != NULL) {
|
||||
(void)send_framed(client_fd, response);
|
||||
(void)transport_send_framed((client_fd == STDIN_FILENO) ? STDOUT_FILENO : client_fd, response);
|
||||
}
|
||||
|
||||
(void)snprintf(activity,
|
||||
sizeof(activity),
|
||||
"uid=%u pid=%d %s(%s) %s",
|
||||
(unsigned int)caller.uid,
|
||||
(int)caller.pid,
|
||||
"%s %s(%s) %s",
|
||||
caller.caller_id,
|
||||
method,
|
||||
role_name,
|
||||
verdict);
|
||||
@@ -869,7 +1260,9 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
|
||||
|
||||
free(request);
|
||||
free(response);
|
||||
close(client_fd);
|
||||
if (client_fd != STDIN_FILENO) {
|
||||
close(client_fd);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -879,7 +1272,9 @@ void server_stop(server_ctx_t *ctx) {
|
||||
}
|
||||
|
||||
if (ctx->listen_fd >= 0) {
|
||||
close(ctx->listen_fd);
|
||||
if (ctx->listen_mode == NSIGNER_LISTEN_UNIX || ctx->listen_mode == NSIGNER_LISTEN_TCP) {
|
||||
close(ctx->listen_fd);
|
||||
}
|
||||
ctx->listen_fd = -1;
|
||||
}
|
||||
ctx->running = 0;
|
||||
|
||||
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;
|
||||
}
|
||||
121
tests/test.sh
121
tests/test.sh
@@ -2,6 +2,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
NSIGNER_BIN="${NSIGNER_BIN:-./build/nsigner}"
|
||||
socket_name=""
|
||||
|
||||
if [[ ! -x "$NSIGNER_BIN" ]]; then
|
||||
echo "[error] nsigner binary not found or not executable at: $NSIGNER_BIN"
|
||||
@@ -9,20 +10,44 @@ if [[ ! -x "$NSIGNER_BIN" ]]; then
|
||||
exit 1
|
||||
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 "Using binary: $NSIGNER_BIN"
|
||||
echo
|
||||
|
||||
default_socket=""
|
||||
if [[ -r /proc/net/unix ]]; then
|
||||
default_socket="$(awk '/@nsigner_/ {sub(/^@/, "", $8); print $8; exit}' /proc/net/unix || true)"
|
||||
fi
|
||||
if [[ -z "$socket_name" ]]; then
|
||||
default_socket=""
|
||||
if [[ -r /proc/net/unix ]]; then
|
||||
default_socket="$(awk '/@nsigner_/ {sub(/^@/, "", $8); print $8; exit}' /proc/net/unix || true)"
|
||||
fi
|
||||
|
||||
if [[ -n "$default_socket" ]]; then
|
||||
read -r -p "Signer socket name (without @) [${default_socket}]: " socket_name
|
||||
socket_name="${socket_name:-$default_socket}"
|
||||
else
|
||||
read -r -p "Signer socket name (without @, e.g. nsigner_hairy_dog): " socket_name
|
||||
if [[ -n "$default_socket" ]]; then
|
||||
read -r -p "Signer socket name (without @) [${default_socket}]: " socket_name
|
||||
socket_name="${socket_name:-$default_socket}"
|
||||
else
|
||||
read -r -p "Signer socket name (without @, e.g. nsigner_hairy_dog): " socket_name
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "${socket_name}" ]]; then
|
||||
@@ -35,9 +60,13 @@ run_request() {
|
||||
"$NSIGNER_BIN" --socket-name "$socket_name" client "$req" || true
|
||||
}
|
||||
|
||||
extract_result_string() {
|
||||
sed -n 's/.*"result":"\([^"]*\)".*/\1/p'
|
||||
}
|
||||
|
||||
echo
|
||||
echo "[1/5] get_public_key"
|
||||
req_get='{"id":"1","method":"get_public_key","params":[""]}'
|
||||
echo "[1/7] get_public_key"
|
||||
req_get='{"id":"1","method":"get_public_key","params":[]}'
|
||||
resp_get="$(run_request "$req_get")"
|
||||
echo "request : $req_get"
|
||||
echo "response: $resp_get"
|
||||
@@ -49,7 +78,7 @@ else
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "[2/5] sign_event"
|
||||
echo "[2/7] sign_event"
|
||||
now_ts="$(date +%s)"
|
||||
event_json='{"kind":1,"content":"hello from tests/test.sh","tags":[],"created_at":__TS__}'
|
||||
event_json="${event_json/__TS__/${now_ts}}"
|
||||
@@ -65,23 +94,83 @@ else
|
||||
fi
|
||||
|
||||
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}]}"
|
||||
resp_sign_idx="$(run_request "$req_sign_idx")"
|
||||
echo "request : $req_sign_idx"
|
||||
echo "response: $resp_sign_idx"
|
||||
|
||||
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"}]}'
|
||||
resp_bad_role="$(run_request "$req_bad_role")"
|
||||
echo "request : $req_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 "[5/5] list currently running signers"
|
||||
list_resp="$($NSIGNER_BIN list || true)"
|
||||
echo "[5/7] NIP-04 self round-trip"
|
||||
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"
|
||||
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 "Smoke test complete."
|
||||
609
tests/test_dispatcher.c
Normal file
609
tests/test_dispatcher.c
Normal file
@@ -0,0 +1,609 @@
|
||||
/* 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 64
|
||||
|
||||
/* 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 64
|
||||
|
||||
/* Prompt behavior */
|
||||
typedef enum {
|
||||
PROMPT_NEVER = 0,
|
||||
PROMPT_FIRST_PER_BOOT,
|
||||
PROMPT_EVERY_REQUEST,
|
||||
PROMPT_DENY
|
||||
} prompt_mode_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_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);
|
||||
|
||||
/* 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[64]; /* "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);
|
||||
|
||||
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);
|
||||
|
||||
/* 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/12 tests passed\n", g_passes);
|
||||
|
||||
return (g_passes == 12 && g_total == 12) ? 0 : 1;
|
||||
}
|
||||
532
tests/test_enforcement.c
Normal file
532
tests/test_enforcement.c
Normal file
@@ -0,0 +1,532 @@
|
||||
/* 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 64
|
||||
|
||||
/* 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 64
|
||||
|
||||
/* Prompt behavior */
|
||||
typedef enum {
|
||||
PROMPT_NEVER = 0,
|
||||
PROMPT_FIRST_PER_BOOT,
|
||||
PROMPT_EVERY_REQUEST,
|
||||
PROMPT_DENY
|
||||
} prompt_mode_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_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);
|
||||
|
||||
/* 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[64]; /* "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;
|
||||
}
|
||||
805
tests/test_integration.c
Normal file
805
tests/test_integration.c
Normal file
@@ -0,0 +1,805 @@
|
||||
#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 64
|
||||
|
||||
/* 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 64
|
||||
|
||||
/* Prompt behavior */
|
||||
typedef enum {
|
||||
PROMPT_NEVER = 0,
|
||||
PROMPT_FIRST_PER_BOOT,
|
||||
PROMPT_EVERY_REQUEST,
|
||||
PROMPT_DENY
|
||||
} prompt_mode_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_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);
|
||||
|
||||
/* 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[64]; /* "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>
|
||||
|
||||
#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;
|
||||
|
||||
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);
|
||||
|
||||
if (request_roundtrip_to(SOCKET_NAME_A, "{\"id\":\"1\",\"method\":\"get_public_key\",\"params\":[\"\"]}", &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;
|
||||
|
||||
if (request_roundtrip_to(SOCKET_NAME_A, "{\"id\":\"2\",\"method\":\"sign_event\",\"params\":[\"{\\\"kind\\\":1,\\\"content\\\":\\\"hello\\\",\\\"tags\\\":[],\\\"created_at\\\":1700000000}\",{\"role\":\"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);
|
||||
|
||||
|
||||
{
|
||||
char *resp_a = NULL;
|
||||
char *resp_b = NULL;
|
||||
char pub_a[65] = {0};
|
||||
char pub_b[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 &&
|
||||
request_roundtrip_to(SOCKET_NAME_B, "{\"id\":\"4\",\"method\":\"get_public_key\",\"params\":[\"\"]}", &resp_b) == 0) {
|
||||
p = strstr(resp_a, "\"result\":\"");
|
||||
if (p) { p += 10; strncpy(pub_a, p, 64); pub_a[64]='\0'; }
|
||||
p = strstr(resp_b, "\"result\":\"");
|
||||
if (p) { p += 10; strncpy(pub_b, p, 64); pub_b[64]='\0'; }
|
||||
check_condition("multi-instance distinct sockets respond", pub_a[0] && pub_b[0]);
|
||||
|
||||
snprintf(req, sizeof(req), "{\"id\":\"5\",\"method\":\"nip04_encrypt\",\"params\":[\"%s\",\"hello_nip04\"]}", pub_b);
|
||||
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_b); resp_b = NULL;
|
||||
if (request_roundtrip_to(SOCKET_NAME_B, req, &resp_b) == 0) {
|
||||
check_condition("nip04 round-trip plaintext recovered", strstr(resp_b, "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_b);
|
||||
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_b); resp_b = NULL;
|
||||
if (request_roundtrip_to(SOCKET_NAME_B, req, &resp_b) == 0) {
|
||||
check_condition("nip44 round-trip plaintext recovered", strstr(resp_b, "hello_nip44") != NULL);
|
||||
} else {
|
||||
check_condition("nip44 decrypt request roundtrip", 0);
|
||||
}
|
||||
} else {
|
||||
check_condition("nip44 encrypt request roundtrip", 0);
|
||||
}
|
||||
} else {
|
||||
check_condition("multi-instance public key requests", 0);
|
||||
}
|
||||
free(resp_a);
|
||||
free(resp_b);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
541
tests/test_mnemonic.c
Normal file
541
tests/test_mnemonic.c
Normal file
@@ -0,0 +1,541 @@
|
||||
/* 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 64
|
||||
|
||||
/* 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 64
|
||||
|
||||
/* Prompt behavior */
|
||||
typedef enum {
|
||||
PROMPT_NEVER = 0,
|
||||
PROMPT_FIRST_PER_BOOT,
|
||||
PROMPT_EVERY_REQUEST,
|
||||
PROMPT_DENY
|
||||
} prompt_mode_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_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);
|
||||
|
||||
/* 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[64]; /* "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;
|
||||
}
|
||||
567
tests/test_policy.c
Normal file
567
tests/test_policy.c
Normal file
@@ -0,0 +1,567 @@
|
||||
/* 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 64
|
||||
|
||||
/* 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 64
|
||||
|
||||
/* Prompt behavior */
|
||||
typedef enum {
|
||||
PROMPT_NEVER = 0,
|
||||
PROMPT_FIRST_PER_BOOT,
|
||||
PROMPT_EVERY_REQUEST,
|
||||
PROMPT_DENY
|
||||
} prompt_mode_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_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);
|
||||
|
||||
/* 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[64]; /* "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;
|
||||
int rc;
|
||||
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));
|
||||
|
||||
policy_init_default(&table, uid);
|
||||
rc = policy_check(&table, same_uid, "sign_event", "main", "nostr");
|
||||
check_condition("policy_init_default allows same uid", rc == POLICY_ALLOW);
|
||||
|
||||
rc = policy_check(&table, other_uid, "sign_event", "main", "nostr");
|
||||
check_condition("policy_init_default denies different uid", rc == POLICY_DENY);
|
||||
|
||||
/* 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");
|
||||
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");
|
||||
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");
|
||||
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");
|
||||
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");
|
||||
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");
|
||||
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");
|
||||
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");
|
||||
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");
|
||||
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");
|
||||
check_condition("first matching entry wins", rc == POLICY_DENY);
|
||||
|
||||
printf("%d/%d tests passed\n", g_passes, g_total);
|
||||
return (g_passes == g_total) ? 0 : 1;
|
||||
}
|
||||
575
tests/test_role_table.c
Normal file
575
tests/test_role_table.c
Normal file
@@ -0,0 +1,575 @@
|
||||
/* 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 64
|
||||
|
||||
/* 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 64
|
||||
|
||||
/* Prompt behavior */
|
||||
typedef enum {
|
||||
PROMPT_NEVER = 0,
|
||||
PROMPT_FIRST_PER_BOOT,
|
||||
PROMPT_EVERY_REQUEST,
|
||||
PROMPT_DENY
|
||||
} prompt_mode_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_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);
|
||||
|
||||
/* 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[64]; /* "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);
|
||||
for (i = 0; i < ROLE_TABLE_MAX_ENTRIES; ++i) {
|
||||
role_entry_t e;
|
||||
char name_buf[ROLE_NAME_MAX];
|
||||
|
||||
memset(&e, 0, sizeof(e));
|
||||
snprintf(name_buf, sizeof(name_buf), "role_%d", i);
|
||||
strncpy(e.name, name_buf, 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 = PURPOSE_NOSTR;
|
||||
e.curve = CURVE_SECP256K1;
|
||||
e.selector_type = SELECTOR_NOSTR_INDEX;
|
||||
e.nostr_index = i;
|
||||
|
||||
rc = role_table_add(&table, &e);
|
||||
check_condition("fill table role add returns 0", rc == 0);
|
||||
}
|
||||
|
||||
{
|
||||
role_entry_t overflow;
|
||||
|
||||
memset(&overflow, 0, sizeof(overflow));
|
||||
strncpy(overflow.name, "overflow", sizeof(overflow.name) - 1);
|
||||
strncpy(overflow.purpose_str, "nostr", sizeof(overflow.purpose_str) - 1);
|
||||
strncpy(overflow.curve_str, "secp256k1", sizeof(overflow.curve_str) - 1);
|
||||
overflow.purpose = PURPOSE_NOSTR;
|
||||
overflow.curve = CURVE_SECP256K1;
|
||||
overflow.selector_type = SELECTOR_NOSTR_INDEX;
|
||||
overflow.nostr_index = 999;
|
||||
|
||||
rc = role_table_add(&table, &overflow);
|
||||
check_condition("table full rejection returns -1", rc == -1);
|
||||
}
|
||||
|
||||
if (g_failures == 0) {
|
||||
printf("ALL TESTS PASSED\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
printf("TESTS FAILED: %d\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
610
tests/test_selector.c
Normal file
610
tests/test_selector.c
Normal file
@@ -0,0 +1,610 @@
|
||||
/* 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 64
|
||||
|
||||
/* 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 64
|
||||
|
||||
/* Prompt behavior */
|
||||
typedef enum {
|
||||
PROMPT_NEVER = 0,
|
||||
PROMPT_FIRST_PER_BOOT,
|
||||
PROMPT_EVERY_REQUEST,
|
||||
PROMPT_DENY
|
||||
} prompt_mode_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_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);
|
||||
|
||||
/* 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[64]; /* "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;
|
||||
}
|
||||
491
tests/test_socket_name.c
Normal file
491
tests/test_socket_name.c
Normal file
@@ -0,0 +1,491 @@
|
||||
/* 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 64
|
||||
|
||||
/* 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 64
|
||||
|
||||
/* Prompt behavior */
|
||||
typedef enum {
|
||||
PROMPT_NEVER = 0,
|
||||
PROMPT_FIRST_PER_BOOT,
|
||||
PROMPT_EVERY_REQUEST,
|
||||
PROMPT_DENY
|
||||
} prompt_mode_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_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);
|
||||
|
||||
/* 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[64]; /* "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