This commit is contained in:
Laan Tungir
2026-05-02 12:31:26 -04:00
commit 268b33b6d3
37 changed files with 7947 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
build/
*.o
*.a
*.tar.gz
.gitea_token
resources/

77
Dockerfile.alpine-musl Normal file
View File

@@ -0,0 +1,77 @@
# Alpine-based MUSL static binary builder for nsigner
# Produces portable binaries with zero runtime dependencies
ARG DEBUG_BUILD=false
FROM alpine:3.19 AS builder
ARG DEBUG_BUILD=false
RUN apk add --no-cache \
build-base \
musl-dev \
git \
cmake \
pkgconfig \
autoconf \
automake \
libtool \
openssl-dev \
openssl-libs-static \
zlib-dev \
zlib-static \
curl-dev \
curl-static \
linux-headers \
wget \
bash
WORKDIR /build
# Build libsecp256k1 static for nostr_core_lib
RUN cd /tmp && \
git clone https://github.com/bitcoin-core/secp256k1.git && \
cd secp256k1 && \
./autogen.sh && \
./configure --enable-static --disable-shared --prefix=/usr CFLAGS="-fPIC" && \
make -j$(nproc) && \
make install && \
rm -rf /tmp/secp256k1
# Copy and build nostr_core_lib with signer-focused NIPs
COPY nostr_core_lib /build/nostr_core_lib/
RUN cd nostr_core_lib && \
chmod +x build.sh && \
sed -i 's/CFLAGS="-Wall -Wextra -std=c99 -fPIC -O2"/CFLAGS="-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 -Wall -Wextra -std=c99 -fPIC -O2"/' build.sh && \
rm -f *.o *.a 2>/dev/null || true && \
./build.sh --nips=1,6,13,17,19,44,46,59
# Copy project source
COPY src/ /build/src/
# Build nsigner static binary
RUN if [ "$DEBUG_BUILD" = "true" ]; then \
CFLAGS="-g -O2 -DDEBUG -fno-omit-frame-pointer"; \
STRIP_CMD="echo 'Keeping debug symbols'"; \
echo "Building debug binary"; \
else \
CFLAGS="-O2"; \
STRIP_CMD="strip /build/nsigner_static"; \
echo "Building production binary"; \
fi && \
gcc -static $CFLAGS -Wall -Wextra -std=c99 \
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \
-Isrc \
src/main.c \
-o /build/nsigner_static && \
eval "$STRIP_CMD"
RUN echo "=== Binary Information ===" && \
file /build/nsigner_static && \
ls -lh /build/nsigner_static && \
echo "=== Checking for dynamic dependencies ===" && \
(ldd /build/nsigner_static 2>&1 || echo "Binary is static") && \
echo "=== Build complete ==="
FROM scratch AS output
COPY --from=builder /build/nsigner_static /nsigner_static

117
Makefile Normal file
View File

@@ -0,0 +1,117 @@
CC := gcc
CFLAGS := -Wall -Wextra -std=c99 -O2 -Isrc -Isrc/cjson
LDFLAGS :=
SRC_DIR := src
BUILD_DIR := build
TEST_DIR := tests
TARGET_DEV := $(BUILD_DIR)/nsigner
SOURCES := \
$(SRC_DIR)/main.c \
$(SRC_DIR)/secure_mem.c \
$(SRC_DIR)/mnemonic.c \
$(SRC_DIR)/role_table.c \
$(SRC_DIR)/selector.c \
$(SRC_DIR)/enforcement.c \
$(SRC_DIR)/dispatcher.c \
$(SRC_DIR)/policy.c \
$(SRC_DIR)/server.c \
$(SRC_DIR)/cjson/cJSON.c
HEADERS := \
$(SRC_DIR)/main.h \
$(SRC_DIR)/secure_mem.h \
$(SRC_DIR)/mnemonic.h \
$(SRC_DIR)/role_table.h \
$(SRC_DIR)/selector.h \
$(SRC_DIR)/enforcement.h \
$(SRC_DIR)/dispatcher.h \
$(SRC_DIR)/policy.h \
$(SRC_DIR)/server.h \
$(SRC_DIR)/cjson/cJSON.h
TEST_MNEMONIC_TARGET := $(BUILD_DIR)/test_mnemonic
TEST_ROLE_TARGET := $(BUILD_DIR)/test_role_table
TEST_SELECTOR_TARGET := $(BUILD_DIR)/test_selector
TEST_ENFORCEMENT_TARGET := $(BUILD_DIR)/test_enforcement
TEST_DISPATCHER_TARGET := $(BUILD_DIR)/test_dispatcher
TEST_POLICY_TARGET := $(BUILD_DIR)/test_policy
TEST_INTEGRATION_TARGET := $(BUILD_DIR)/test_integration
.PHONY: all dev static static-debug static-arm64 test test-integration test-mnemonic test-role test-selector test-enforcement test-dispatcher test-policy clean
all: dev
dev: $(TARGET_DEV)
$(TARGET_DEV): $(SOURCES) $(HEADERS)
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(SOURCES) -o $(TARGET_DEV) $(LDFLAGS)
static:
chmod +x ./build_static.sh
./build_static.sh
static-debug:
chmod +x ./build_static.sh
./build_static.sh --debug
static-arm64:
chmod +x ./build_static.sh
./build_static.sh --arch arm64
test: test-mnemonic test-role test-selector test-enforcement test-dispatcher test-policy
test-integration: $(TEST_INTEGRATION_TARGET) $(TARGET_DEV)
./$(TEST_INTEGRATION_TARGET)
test-mnemonic: $(TEST_MNEMONIC_TARGET)
./$(TEST_MNEMONIC_TARGET)
test-role: $(TEST_ROLE_TARGET)
./$(TEST_ROLE_TARGET)
test-selector: $(TEST_SELECTOR_TARGET)
./$(TEST_SELECTOR_TARGET)
test-enforcement: $(TEST_ENFORCEMENT_TARGET)
./$(TEST_ENFORCEMENT_TARGET)
test-dispatcher: $(TEST_DISPATCHER_TARGET)
./$(TEST_DISPATCHER_TARGET)
test-policy: $(TEST_POLICY_TARGET)
./$(TEST_POLICY_TARGET)
$(TEST_MNEMONIC_TARGET): $(TEST_DIR)/test_mnemonic.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c $(SRC_DIR)/mnemonic.h $(SRC_DIR)/secure_mem.h
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(TEST_DIR)/test_mnemonic.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c -o $(TEST_MNEMONIC_TARGET) $(LDFLAGS)
$(TEST_ROLE_TARGET): $(TEST_DIR)/test_role_table.c $(SRC_DIR)/role_table.c $(SRC_DIR)/role_table.h
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(TEST_DIR)/test_role_table.c $(SRC_DIR)/role_table.c -o $(TEST_ROLE_TARGET) $(LDFLAGS)
$(TEST_SELECTOR_TARGET): $(TEST_DIR)/test_selector.c $(SRC_DIR)/selector.c $(SRC_DIR)/role_table.c $(SRC_DIR)/selector.h $(SRC_DIR)/role_table.h
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(TEST_DIR)/test_selector.c $(SRC_DIR)/selector.c $(SRC_DIR)/role_table.c -o $(TEST_SELECTOR_TARGET) $(LDFLAGS)
$(TEST_ENFORCEMENT_TARGET): $(TEST_DIR)/test_enforcement.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c $(SRC_DIR)/enforcement.h $(SRC_DIR)/role_table.h
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(TEST_DIR)/test_enforcement.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c -o $(TEST_ENFORCEMENT_TARGET) $(LDFLAGS)
$(TEST_DISPATCHER_TARGET): $(TEST_DIR)/test_dispatcher.c $(SRC_DIR)/dispatcher.c $(SRC_DIR)/selector.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c $(SRC_DIR)/cjson/cJSON.c $(SRC_DIR)/dispatcher.h $(SRC_DIR)/selector.h $(SRC_DIR)/enforcement.h $(SRC_DIR)/role_table.h $(SRC_DIR)/mnemonic.h $(SRC_DIR)/secure_mem.h $(SRC_DIR)/cjson/cJSON.h
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(TEST_DIR)/test_dispatcher.c $(SRC_DIR)/dispatcher.c $(SRC_DIR)/selector.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c $(SRC_DIR)/cjson/cJSON.c -o $(TEST_DISPATCHER_TARGET) $(LDFLAGS)
$(TEST_POLICY_TARGET): $(TEST_DIR)/test_policy.c $(SRC_DIR)/policy.c $(SRC_DIR)/policy.h $(SRC_DIR)/role_table.h
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(TEST_DIR)/test_policy.c $(SRC_DIR)/policy.c -o $(TEST_POLICY_TARGET) $(LDFLAGS)
$(TEST_INTEGRATION_TARGET): $(TEST_DIR)/test_integration.c
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(TEST_DIR)/test_integration.c -o $(TEST_INTEGRATION_TARGET) $(LDFLAGS)
clean:
rm -rf $(BUILD_DIR)

325
README.md Normal file
View File

@@ -0,0 +1,325 @@
# n_signer
`n_signer` is a single statically-linked program that holds signing key material in locked memory and signs on request.
It runs in the foreground, attached to your terminal. The terminal is the trust anchor and control surface. Nothing touches disk at runtime. If the process crashes or exits, all in-memory state is gone.
This is a **program, not a daemon**:
- no hidden background process
- no detached service lifecycle
- no runtime config or state files
- no persistence to recover after compromise
## 1. What it is
`n_signer` is one musl-static binary that combines:
- mnemonic handling
- role selection and derivation
- purpose/curve enforcement
- request dispatch
- interactive terminal UI
- transport adapter(s)
You run it when you need signing. You stop it when you are done. Closing the terminal or quitting the program ends the trust session and destroys state.
## 2. Security model
### 2.1 Zero filesystem footprint
At runtime, `n_signer` writes nothing to disk:
- no config files
- no logs
- no PID files
- no lock files
- no socket pathname artifacts
On Linux desktop, local IPC uses abstract namespace Unix sockets (`@name` semantics) that exist only in kernel memory and disappear with process/kernel namespace lifetime.
### 2.2 Crash = total wipe
All sensitive and operational state exists only in-process RAM (mlock'd where applicable):
- mnemonic-derived key material
- role table
- policy/approval decisions for the live session
- activity display buffer
If the process dies (fault, kill, exploit, power loss), state is unrecoverable by design. There is no persistence layer to scrape post-crash.
### 2.3 Single binary, no external dependencies
Runtime target is one statically-linked musl executable:
- no shared libraries required at runtime
- no interpreter/runtime VM dependency
- no sidecar services
- no helper daemon binaries
This reduces deployment variability and shrinks the runtime trust surface.
### 2.4 Always-attended operation
`n_signer` is intentionally human-attended. It stays attached to a terminal and can require explicit keystroke approval for unknown callers or sensitive actions. Human presence is part of the security model.
## 3. How it works
### 3.1 Startup phase (TUI input mode)
When started, `n_signer` immediately enters terminal input mode:
1. Prompt for mnemonic (terminal echo disabled).
2. Parse and validate mnemonic.
3. Build in-memory role/selector state.
4. Initialize transport endpoints.
5. Switch to running status display.
No startup files are read or written.
### 3.2 Running phase (status display + signer)
After unlock, terminal becomes a live status and control console. Example layout:
```text
n_signer v0.x | foreground session active
transport: unix-abstract:@nsigner
session: unlocked (RAM-only)
Roles
-----
main purpose=nostr curve=secp256k1 selector=role:main
ops purpose=nostr curve=secp256k1 selector=nostr_index:7
backup purpose=bitcoin curve=secp256k1 selector=role_path:m/84'/0'/0'/0/5
Pending approvals
-----------------
(none)
Activity (latest first)
-----------------------
16:03:11 allow caller=uid:1000 method=get_public_key role=main
16:02:44 prompt caller=uid:1000 method=sign_event role=ops
16:02:46 allow caller=uid:1000 method=sign_event role=ops
15:59:10 deny caller=uid:1001 method=sign_event error=unauthorized
Hotkeys
-------
a add role
r refresh
l lock session
q quit
```
### 3.3 Approval prompts
When a request needs confirmation, `n_signer` interrupts the status view with a prompt and waits for a local keystroke.
Example:
```text
Approval required
caller: uid:1000
method: sign_event
selector: role=ops
purpose/curve: nostr/secp256k1
[y] allow once [n] deny [a] always allow for this session
```
No response is emitted to caller until the local user decides.
### 3.4 Shutdown / lock
- `q` quits the process: all session state is destroyed.
- `l` locks the session in-place: signing stops until mnemonic is re-entered.
- terminal close or process termination has the same effect as quit: total state wipe.
## 4. Mnemonic-rooted role model
`n_signer` derives many role-scoped keys from one mnemonic root. Requests select a role using explicit selectors, then enforcement checks operation compatibility.
### 4.1 Nostr shorthand: nostr_index
Nostr shorthand keeps the explicit index selector:
`m/44'/1237'/<nostr_index>'/0/0`
Use `nostr_index` only for Nostr-indexed roles.
### 4.2 Full derivation path: role_path
For non-Nostr or advanced layouts, caller may use full `role_path` selector.
Security rule: `role_path` must match a pre-registered role entry. Unregistered ad-hoc derivation requests are rejected.
### 4.3 What memorizing your seed phrase gets you
A single memorized mnemonic can deterministically recover multiple key domains through role definitions, not just one identity.
Examples include Nostr roles, Bitcoin branches, and future application-specific paths. See [`plans/seed_phrase_uses.md`](plans/seed_phrase_uses.md) for the maintained use-case catalog and caveats.
## 5. Wire contract (JSON-RPC)
Request shape is JSON-RPC with NIP-46-style methods and optional trailing selector options.
```jsonc
{ "id": "1", "method": "get_public_key", "params": [] }
{ "id": "2", "method": "sign_event", "params": ["<event_json>", { "role": "main" }] }
{ "id": "3", "method": "sign_event", "params": ["<event_json>", { "nostr_index": 7 }] }
{ "id": "4", "method": "sign_event", "params": ["<event_json>", { "role_path": "m/84'/0'/0'/0/5", "purpose": "bitcoin", "curve": "secp256k1" }] }
```
Selector resolution order:
1. `role`
2. `nostr_index`
3. `role_path`
4. default role `main`
Conflicting selectors are rejected (`ambiguous_role_selector`).
Representative error codes:
- `invalid_request`
- `method_not_found`
- `ambiguous_role_selector`
- `unknown_role`
- `purpose_mismatch`
- `curve_mismatch`
- `unauthorized`
- `approval_denied`
- `internal_error`
## 6. Purpose and curve enforcement
Selector resolution chooses *which* role. Enforcement decides *whether the requested method is valid* for that role.
Example:
- `sign_event` requires `purpose="nostr"` and `curve="secp256k1"`.
- If caller selects a Bitcoin-role key for `sign_event`, request fails with `purpose_mismatch`.
This prevents cross-protocol misuse inside one mnemonic-rooted signer process.
## 7. Transport
### 7.1 Linux desktop: abstract namespace Unix socket
Primary local transport is AF_UNIX abstract namespace (for example `@nsigner`).
Properties:
- no pathname in filesystem
- endpoint lifetime bound to process/kernel namespace
- no stale socket files
- caller identity via peer credentials (`SO_PEERCRED`)
### 7.2 ESP32 MCU: USB-CDC serial
On MCU targets, the same core modules run with a different transport adapter: USB-CDC serial framing instead of AF_UNIX.
Core logic stays the same; only transport/UI bindings change.
### 7.3 NIP-46 relay flow (both platforms)
NIP-46 request/response flow can be bridged on both desktop and MCU transports. The JSON-RPC signer contract remains consistent while the outer carrier differs.
### 7.4 Caller verification
Every transport must provide concrete caller identity before policy evaluation.
- Linux AF_UNIX: map peer credentials to caller identity.
- ESP32 USB bridge: map authenticated host bridge identity to caller identity.
- Relay session: bind remote peer/session identity before allowing signer verbs.
Identity verification and interactive approval are separate layers. Passing identity checks does not bypass prompt requirements.
## 8. Platform targets
### 8.1 Linux desktop (primary)
Primary deployment is a local, foreground terminal program with abstract namespace socket transport.
### 8.2 ESP32 / MCU
MCU target reuses mnemonic/role/selector/enforcement/dispatcher core and swaps transport/UI for constrained hardware (for example UART/OLED/buttons or USB-CDC host interaction).
### 8.3 Qubes OS qube
Qubes deployment runs `n_signer` in a dedicated signer qube as a foreground process under explicit user session control. Transport binding is platform-specific, but lifecycle and memory-only state model are unchanged.
## 9. Usage
### 9.1 Run the program
```bash
nsigner
```
Program starts in attached foreground mode and prompts for mnemonic.
### 9.2 Send a request (client mode)
From another terminal:
```bash
nsigner client '{"id":"1","method":"get_public_key","params":[]}'
```
Example signing request:
```bash
nsigner client '{"id":"2","method":"sign_event","params":["<event_json>",{"role":"main"}]}'
```
### 9.3 Example session
Terminal A:
```text
$ nsigner
[unlock] enter mnemonic:
[ok] session unlocked
[listen] unix-abstract:@nsigner
[prompt] caller=uid:1000 method=sign_event role=main -> allow? (y/n)
```
Terminal B:
```text
$ nsigner client '{"id":"2","method":"sign_event","params":["<event_json>",{"role":"main"}]}'
{"id":"2","result":"<signed_event_json>"}
```
## 10. Build and versioning
Build outputs a single executable artifact.
- [`build_static.sh`](build_static.sh): musl-static build path
- [`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
Local dev build:
```bash
make dev
./build/nsigner_dev --version
```
Static build:
```bash
./build_static.sh
./build/nsigner_static_x86_64 --version
```
## 11. Document map
- [`README.md`](README.md): authoritative behavior specification for the foreground single-program model
- [`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

235
build_static.sh Executable file
View File

@@ -0,0 +1,235 @@
#!/bin/bash
# Build fully static MUSL binaries for nsigner using Alpine Docker
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUILD_DIR="$SCRIPT_DIR/build"
DOCKERFILE="$SCRIPT_DIR/Dockerfile.alpine-musl"
DEBUG_BUILD=false
TARGET_ARCH=""
while [[ $# -gt 0 ]]; do
case "$1" in
--debug)
DEBUG_BUILD=true
shift
;;
--arch)
if [[ -z "$2" ]]; then
echo "ERROR: --arch requires a value"
echo "Usage: $0 [--debug] [--arch <arm64|armv7|x86_64>]"
exit 1
fi
case "$2" in
arm64|armv7|x86_64)
TARGET_ARCH="$2"
;;
*)
echo "ERROR: Unsupported architecture '$2'"
echo "Supported values: arm64, armv7, x86_64"
exit 1
;;
esac
shift 2
;;
*)
echo "ERROR: Unknown argument '$1'"
echo "Usage: $0 [--debug] [--arch <arm64|armv7|x86_64>]"
exit 1
;;
esac
done
if [ "$DEBUG_BUILD" = true ]; then
echo "=========================================="
echo "nsigner MUSL Static Binary Builder (DEBUG MODE)"
echo "=========================================="
else
echo "=========================================="
echo "nsigner MUSL Static Binary Builder (PRODUCTION MODE)"
echo "=========================================="
fi
echo "Project directory: $SCRIPT_DIR"
echo "Build directory: $BUILD_DIR"
echo "Debug build: $DEBUG_BUILD"
if [[ -n "$TARGET_ARCH" ]]; then
echo "Requested target architecture: $TARGET_ARCH"
fi
echo ""
mkdir -p "$BUILD_DIR"
if ! command -v docker &> /dev/null; then
echo "ERROR: Docker is not installed or not in PATH"
exit 1
fi
if ! docker info &> /dev/null; then
echo "ERROR: Docker daemon is not running or user not in docker group"
exit 1
fi
DOCKER_CMD="docker"
echo "✓ Docker is available and running"
echo ""
ARCH=$(uname -m)
case "$ARCH" in
x86_64)
HOST_ARCH="x86_64"
;;
aarch64|arm64)
HOST_ARCH="arm64"
;;
armv7l|armv7)
HOST_ARCH="armv7"
;;
*)
HOST_ARCH="unknown"
;;
esac
if [[ -n "$TARGET_ARCH" ]]; then
ARCH="$TARGET_ARCH"
fi
case "$ARCH" in
x86_64)
PLATFORM="linux/amd64"
OUTPUT_NAME="nsigner_static_x86_64"
;;
aarch64|arm64)
ARCH="arm64"
PLATFORM="linux/arm64"
OUTPUT_NAME="nsigner_static_arm64"
;;
armv7l|armv7)
ARCH="armv7"
PLATFORM="linux/arm/v7"
OUTPUT_NAME="nsigner_static_armv7"
;;
*)
echo "WARNING: Unknown architecture: $ARCH"
echo "Defaulting to linux/amd64"
PLATFORM="linux/amd64"
OUTPUT_NAME="nsigner_static_${ARCH}"
;;
esac
if [[ "$HOST_ARCH" != "unknown" && "$ARCH" != "$HOST_ARCH" ]]; then
echo "NOTE: Cross-building from host '$HOST_ARCH' to target '$ARCH'."
if ! docker buildx version >/dev/null 2>&1; then
echo "ERROR: docker buildx is required for cross-architecture builds but is not available."
exit 1
fi
echo "✓ docker buildx is available for cross-architecture build"
echo ""
fi
if [ "$DEBUG_BUILD" = true ]; then
OUTPUT_NAME="${OUTPUT_NAME}_debug"
fi
echo "Building for platform: $PLATFORM"
echo "Output binary: $OUTPUT_NAME"
echo ""
echo "=========================================="
echo "Step 1: Building Alpine Docker image"
echo "=========================================="
$DOCKER_CMD build \
--platform "$PLATFORM" \
--build-arg DEBUG_BUILD=$DEBUG_BUILD \
-f "$DOCKERFILE" \
-t nsigner-musl-builder:latest \
--progress=plain \
.
echo ""
echo "✓ Docker image built successfully"
echo ""
echo "=========================================="
echo "Step 2: Extracting static binary"
echo "=========================================="
$DOCKER_CMD build \
--platform "$PLATFORM" \
--build-arg DEBUG_BUILD=$DEBUG_BUILD \
--target builder \
-f "$DOCKERFILE" \
-t nsigner-static-builder-stage:latest \
. > /dev/null 2>&1
CONTAINER_ID=$($DOCKER_CMD create nsigner-static-builder-stage:latest)
$DOCKER_CMD cp "$CONTAINER_ID:/build/nsigner_static" "$BUILD_DIR/$OUTPUT_NAME"
$DOCKER_CMD rm "$CONTAINER_ID" > /dev/null
chmod +x "$BUILD_DIR/$OUTPUT_NAME"
echo "✓ Binary extracted to: $BUILD_DIR/$OUTPUT_NAME"
echo ""
echo "=========================================="
echo "Step 3: Verifying static binary"
echo "=========================================="
echo ""
if LDD_OUTPUT=$(timeout 5 ldd "$BUILD_DIR/$OUTPUT_NAME" 2>&1); then
if echo "$LDD_OUTPUT" | grep -q "not a dynamic executable"; then
echo "✓ Binary is fully static"
TRULY_STATIC=true
elif echo "$LDD_OUTPUT" | grep -q "statically linked"; then
echo "✓ Binary is statically linked"
TRULY_STATIC=true
else
echo "⚠ WARNING: Binary may have dynamic dependencies:"
echo "$LDD_OUTPUT"
TRULY_STATIC=false
fi
else
if file "$BUILD_DIR/$OUTPUT_NAME" | grep -q "statically linked"; then
echo "✓ Binary is statically linked (verified with file command)"
TRULY_STATIC=true
else
echo "⚠ Could not verify static linking (ldd check failed)"
TRULY_STATIC=false
fi
fi
echo ""
echo "File size: $(ls -lh "$BUILD_DIR/$OUTPUT_NAME" | awk '{print $5}')"
echo ""
echo "Testing binary execution:"
if "$BUILD_DIR/$OUTPUT_NAME" --version 2>&1 | head -5; then
echo "✓ Binary executes successfully"
else
echo "⚠ Binary execution test failed"
fi
echo ""
echo "=========================================="
echo "Build Summary"
echo "=========================================="
echo "Binary: $BUILD_DIR/$OUTPUT_NAME"
echo "Size: $(du -h "$BUILD_DIR/$OUTPUT_NAME" | cut -f1)"
echo "Platform: $PLATFORM"
if [ "$DEBUG_BUILD" = true ]; then
echo "Build Type: DEBUG"
else
echo "Build Type: PRODUCTION"
fi
if [ "$TRULY_STATIC" = true ]; then
echo "Linkage: Fully static binary"
else
echo "Linkage: Static binary (verification inconclusive)"
fi
echo ""
echo "✓ Build complete!"

22
firmware/README.md Normal file
View File

@@ -0,0 +1,22 @@
# Firmware Scaffolding
This directory is the placeholder for the future microcontroller variant of `nsigner`.
## Current direction
Initial MCU work should reuse platform abstractions that already exist in [`nostr_core_lib`](../nostr_core_lib/README.md), especially the ESP32 platform files under [`nostr_core_lib/platform/esp32/`](../nostr_core_lib/platform/esp32):
- [`nostr_platform_esp32.c`](../nostr_core_lib/platform/esp32/nostr_platform_esp32.c)
- [`nostr_http_esp32.c`](../nostr_core_lib/platform/esp32/nostr_http_esp32.c)
- [`nostr_websocket_esp32.c`](../nostr_core_lib/platform/esp32/nostr_websocket_esp32.c)
## Intended purpose
This firmware target is for a USB signer and constrained-device signer profile, with `nsigner` protocol compatibility and transport adaptation (USB-CDC first, then other embedded transport options as needed).
## Next milestones
1. Add MCU-specific build system (ESP-IDF/CMake or equivalent).
2. Add a minimal signer main loop with version reporting compatible with desktop `nsigner` semantics.
3. Implement transport bridge framing compatible with host-side `nsigner-bridge` plans.
4. Add hardware confirmation UX hooks (button + tiny display flow).

289
increment_and_push.sh Executable file
View File

@@ -0,0 +1,289 @@
#!/bin/bash
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
print_status() { echo -e "${BLUE}[INFO]${NC} $1" >&2; }
print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1" >&2; }
print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1" >&2; }
print_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
COMMIT_MESSAGE=""
RELEASE_MODE=false
VERSION_INCREMENT_TYPE="patch"
VERSION_INCREMENT_EXPLICIT=false
show_usage() {
echo "nsigner Increment and Push Script"
echo ""
echo "USAGE:"
echo " $0 [OPTIONS] \"commit message\""
echo ""
echo "OPTIONS:"
echo " -p, --patch Increment patch version (default)"
echo " -m, --minor Increment minor version"
echo " -M, --major Increment major version"
echo " -r, --release Create release with assets"
echo " -h, --help Show this help message"
}
while [[ $# -gt 0 ]]; do
case $1 in
-r|--release)
RELEASE_MODE=true
shift
;;
-p|--patch)
VERSION_INCREMENT_TYPE="patch"
VERSION_INCREMENT_EXPLICIT=true
shift
;;
-m|--minor)
VERSION_INCREMENT_TYPE="minor"
VERSION_INCREMENT_EXPLICIT=true
shift
;;
-M|--major)
VERSION_INCREMENT_TYPE="major"
VERSION_INCREMENT_EXPLICIT=true
shift
;;
-h|--help)
show_usage
exit 0
;;
*)
if [[ -z "$COMMIT_MESSAGE" ]]; then
COMMIT_MESSAGE="$1"
fi
shift
;;
esac
done
if [[ -z "$COMMIT_MESSAGE" ]]; then
print_error "Commit message is required"
show_usage
exit 1
fi
check_git_repo() {
if ! git rev-parse --git-dir > /dev/null 2>&1; then
print_error "Not in a git repository"
exit 1
fi
}
update_version_in_header() {
local new_version="$1"
local major="$2"
local minor="$3"
local patch="$4"
if [[ ! -f "src/main.h" ]]; then
print_error "src/main.h not found"
exit 1
fi
sed -i "s/#define NSIGNER_VERSION \".*\"/#define NSIGNER_VERSION \"$new_version\"/" src/main.h
sed -i "s/#define NSIGNER_VERSION_MAJOR [0-9]\+/#define NSIGNER_VERSION_MAJOR $major/" src/main.h
sed -i "s/#define NSIGNER_VERSION_MINOR [0-9]\+/#define NSIGNER_VERSION_MINOR $minor/" src/main.h
sed -i "s/#define NSIGNER_VERSION_PATCH [0-9]\+/#define NSIGNER_VERSION_PATCH $patch/" src/main.h
print_success "Updated src/main.h to $new_version"
}
increment_version() {
local increment_type="$1"
LATEST_TAG=$(git tag -l 'v*.*.*' | sort -V | tail -n 1 || echo "")
if [[ -z "$LATEST_TAG" ]]; then
LATEST_TAG="v0.0.0"
print_warning "No version tags found, starting from $LATEST_TAG"
fi
VERSION=${LATEST_TAG#v}
if [[ $VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
MAJOR=${BASH_REMATCH[1]}
MINOR=${BASH_REMATCH[2]}
PATCH=${BASH_REMATCH[3]}
else
print_error "Invalid version format in tag: $LATEST_TAG"
exit 1
fi
if [[ "$increment_type" == "major" ]]; then
NEW_VERSION="v$((MAJOR + 1)).0.0"
elif [[ "$increment_type" == "minor" ]]; then
NEW_VERSION="v${MAJOR}.$((MINOR + 1)).0"
else
NEW_VERSION="v${MAJOR}.${MINOR}.$((PATCH + 1))"
fi
local new_no_v=${NEW_VERSION#v}
local new_major=${new_no_v%%.*}
local rest=${new_no_v#*.}
local new_minor=${rest%%.*}
local new_patch=${rest##*.}
update_version_in_header "$NEW_VERSION" "$new_major" "$new_minor" "$new_patch"
export NEW_VERSION
}
git_commit_and_push_no_tag() {
git add .
if ! git diff --staged --quiet; then
git commit -m "$NEW_VERSION - $COMMIT_MESSAGE"
else
print_warning "No changes to commit"
fi
git push
git push origin "$NEW_VERSION" || git push --force origin "$NEW_VERSION"
}
build_release_binary() {
if [[ ! -f "build_static.sh" ]]; then
print_error "build_static.sh not found"
return 1
fi
./build_static.sh > /dev/null 2>&1 || return 1
./build_static.sh --arch arm64 > /dev/null 2>&1 || print_warning "ARM64 build failed (continuing)"
return 0
}
create_source_tarball() {
local tarball_name="nsigner-${NEW_VERSION#v}.tar.gz"
if tar -czf "$tarball_name" \
--exclude='build/*' \
--exclude='.git*' \
--exclude='*.log' \
--exclude='*.tar.gz' \
. > /dev/null 2>&1; then
echo "$tarball_name"
else
return 1
fi
}
create_gitea_release() {
if [[ ! -f "$HOME/.gitea_token" ]]; then
print_warning "No ~/.gitea_token found. Skipping release creation."
return 0
fi
local token
token=$(cat "$HOME/.gitea_token" | tr -d '\n\r')
local api_url="https://git.laantungir.net/api/v1/repos/laantungir/n_signer"
local response
response=$(curl -s -X POST "$api_url/releases" \
-H "Authorization: token $token" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"$NEW_VERSION\", \"name\": \"$NEW_VERSION\", \"body\": \"$COMMIT_MESSAGE\"}")
if echo "$response" | grep -q '"id"'; then
echo "$response" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2
else
return 1
fi
}
upload_release_assets() {
local release_id="$1"
local binary_path_x86="$2"
local tarball_path="$3"
local binary_path_arm64="$4"
if [[ ! -f "$HOME/.gitea_token" ]]; then
print_warning "No ~/.gitea_token found. Skipping asset uploads."
return 0
fi
local token
token=$(cat "$HOME/.gitea_token" | tr -d '\n\r')
local api_url="https://git.laantungir.net/api/v1/repos/laantungir/n_signer"
local assets_url="$api_url/releases/$release_id/assets"
if [[ -f "$binary_path_x86" ]]; then
curl -s -X POST "$assets_url" \
-H "Authorization: token $token" \
-F "attachment=@$binary_path_x86;filename=$(basename "$binary_path_x86")" \
-F "name=$(basename "$binary_path_x86")" > /dev/null
fi
if [[ -f "$binary_path_arm64" ]]; then
curl -s -X POST "$assets_url" \
-H "Authorization: token $token" \
-F "attachment=@$binary_path_arm64;filename=$(basename "$binary_path_arm64")" \
-F "name=$(basename "$binary_path_arm64")" > /dev/null
fi
if [[ -f "$tarball_path" ]]; then
curl -s -X POST "$assets_url" \
-H "Authorization: token $token" \
-F "attachment=@$tarball_path;filename=$(basename "$tarball_path")" \
-F "name=$(basename "$tarball_path")" > /dev/null
fi
}
main() {
check_git_repo
if [[ "$RELEASE_MODE" == true ]]; then
if [[ "$VERSION_INCREMENT_EXPLICIT" == true ]]; then
increment_version "$VERSION_INCREMENT_TYPE"
else
LATEST_TAG=$(git tag -l 'v*.*.*' | sort -V | tail -n 1 || echo "v0.0.1")
NEW_VERSION="$LATEST_TAG"
export NEW_VERSION
fi
if git tag "$NEW_VERSION" > /dev/null 2>&1; then
print_success "Created tag: $NEW_VERSION"
else
git tag -d "$NEW_VERSION" > /dev/null 2>&1 || true
git tag "$NEW_VERSION" > /dev/null 2>&1
fi
git_commit_and_push_no_tag
build_release_binary || print_warning "x86_64 build failed"
local binary_path_x86="build/nsigner_static_x86_64"
local binary_path_arm64="build/nsigner_static_arm64"
local tarball_path=""
tarball_path=$(create_source_tarball || true)
local release_id=""
release_id=$(create_gitea_release || true)
if [[ -n "$release_id" ]]; then
upload_release_assets "$release_id" "$binary_path_x86" "$tarball_path" "$binary_path_arm64"
fi
print_success "Release flow completed"
else
increment_version "$VERSION_INCREMENT_TYPE"
if git tag "$NEW_VERSION" > /dev/null 2>&1; then
print_success "Created tag: $NEW_VERSION"
else
git tag -d "$NEW_VERSION" > /dev/null 2>&1 || true
git tag "$NEW_VERSION" > /dev/null 2>&1
fi
git_commit_and_push_no_tag
print_success "Increment and push completed"
fi
}
main

104
plans/nsigner.md Normal file
View File

@@ -0,0 +1,104 @@
# nsigner Implementation Plan
This document is the implementation roadmap for `n_signer`.
Authoritative behavior documentation: [README.md](../README.md).
## 1. Architecture pivot: program not daemon
This plan adopts a single foreground program model instead of a daemon model to reduce operational complexity and eliminate persistent runtime artifacts.
- Single foreground process, not a background daemon
- No config files — all state entered at runtime via TUI
- No control socket — TUI is built into the program
- No separate binaries — one binary with subcommands
- Abstract namespace sockets — no filesystem artifacts
## 2. Module inventory (what exists, what stays, what goes)
| Module | Fate |
|---|---|
| `src/secure_mem.{h,c}` | **KEEP** unchanged |
| `src/mnemonic.{h,c}` | **KEEP** unchanged |
| `src/role_table.{h,c}` | **KEEP** data structures, **REMOVE** file-loading code |
| `src/selector.{h,c}` | **KEEP** unchanged |
| `src/enforcement.{h,c}` | **KEEP** unchanged |
| `src/dispatcher.{h,c}` | **KEEP** unchanged |
| `src/cjson/` | **KEEP** unchanged |
| `src/policy.{h,c}` | **REFACTOR**: remove file loading, add interactive approval, hardcode same-uid default |
| `src/daemon.{h,c}` | **REPLACE** with `src/server.{h,c}`: abstract namespace socket, poll-based, integrated with TUI |
| `src/control.{h,c}` | **DELETE** (control socket no longer needed) |
| `src/tui.c` | **DELETE** as separate binary (functionality moves into `src/main.c`) |
| `src/client.c` | **DELETE** as separate binary (becomes subcommand in `src/main.c`) |
| `src/main.c` | **REWRITE**: startup TUI + server loop + status display + client subcommand |
| `config/` | **DELETE** entirely |
| `tests/test_daemon_integration.c` | **DELETE** or rewrite |
| `tests/test_full_flow.c` | **REWRITE** for new architecture |
| `tests/test_policy.c` | **UPDATE** for new policy API |
## 3. Implementation phases (new)
### Phase A — Cleanup and removal
- Delete `config/`, `src/control.{h,c}`, `src/tui.c`, `src/client.c`
- Remove file-loading functions from `src/role_table.c` and `src/policy.c`
- Remove old daemon integration tests
### Phase B — Server module (replaces daemon)
- Add `src/server.{h,c}`: abstract namespace socket, poll-based accept loop
- Keep peer-cred extraction behavior on abstract socket
- Add integration point for TUI approval callbacks
### Phase C — Unified main with built-in TUI
- Startup phase: mnemonic prompt (echo off), word count confirm, optional role enrollment
- Transition to running phase: status display, activity log, hotkey handling
- Approval prompt overlay when policy requires it
- Client subcommand: `nsigner client '<json>'` connects to abstract socket, sends request, prints response
- Signal handling: `SIGINT`/`SIGTERM` → zeroize and exit
### Phase D — Policy simplification
- Default policy: same-uid as process = allow all verbs on all roles, no prompt
- Unknown caller: display approval prompt in TUI, user decides per-request or per-session
- No file-based policy at all
### Phase E — Integration test
- Single test that launches the program (with mnemonic piped via stdin for automation), sends requests via client subcommand, verifies responses
## 4. Decisions log
### 2026-05-02 — Program not daemon pivot
1. Single foreground process replaces daemon + TUI + client multi-binary model
2. No config files — all state entered at runtime, lives in RAM only
3. Abstract namespace sockets replace filesystem sockets
4. Control socket eliminated — TUI is in-process
5. Policy simplified to same-uid default + interactive approval
6. Crash = total wipe is a security feature, not a limitation
7. Same core modules target both Linux desktop and ESP32 MCU
### 2026-05-02 — Earlier decisions (retained)
- `role_path` must be pre-registered (no ad-hoc derivation)
- `nostr_index` naming (not `role_index`)
- Multi-curve support from start (`secp256k1`, `ed25519`, `x25519`)
- [README.md](../README.md) is authoritative behavior spec
## 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?
## 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)

View File

@@ -0,0 +1,87 @@
# nsigner Browser Extension — NIP-07 frontend over nsigner
> **Project:** subtree of `n_signer` at `browser-extension/` (may spin out later).
>
> **Status:** Planning. This document describes a WebExtension that exposes [NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md) `window.nostr` and forwards calls to a running `nsigner` instance.
>
> **Authoritative signer behavior:** [`../README.md`](../README.md)
## 1. What this is
A Chrome/Firefox/Brave WebExtension (Manifest V3) that:
1. injects a standard NIP-07-compatible `window.nostr` surface
2. forwards each call to `nsigner` over native-messaging, TLS, or relay fallback transport
3. applies per-origin permission policy and per-origin role pinning
The extension is not a wallet and holds no private keys.
## 2. Important compatibility note with multi-purpose roles
`nsigner` role entries now carry explicit purpose/curve metadata (see [`../README.md`](../README.md)).
For browser NIP-07 operations, the extension must request only roles whose purpose resolves to `nostr`.
If a role is configured for another purpose (e.g. bitcoin/ssh/age), extension UI must hide or reject it for NIP-07 signing/encryption calls instead of forwarding an invalid request.
## 3. Architecture (unchanged high level)
- page -> content script -> service worker
- service worker -> chosen transport -> `nsigner`
- policy and user prompts in extension UI
## 4. NIP-07 surface mapping
Standard NIP-07 calls map to `nsigner` request verbs:
- `getPublicKey`
- `signEvent`
- `nip04.encrypt` / `nip04.decrypt`
- `nip44.encrypt` / `nip44.decrypt`
The extension attaches role selector options when needed, but role choices must remain purpose-compatible with Nostr operations.
## 5. Transport options
1. Native messaging bridge (primary)
2. TLS to local/nearby `nsigner`
3. Relay-based fallback
## 6. Permission model
Per-origin policy remains the control point:
- allow/deny by verb/kind/origin
- origin-level role pinning
- explicit prompts for sensitive operations
## 7. Security constraints
- no private key/mnemonic persistence in extension storage
- no plaintext decrypt cache
- strong defaults for decrypt operations
- role purpose compatibility checks before forwarding signer requests
## 8. Implementation phases (extension-specific)
1. native helper
2. MVP extension with native path
3. role-aware UI
4. TLS transport
5. management/audit UX
6. relay fallback transport
7. browser polish and release
## 9. Repo location
Planned extension subtree remains:
- `browser-extension/extension/`
- `browser-extension/native-bridge/`
- `browser-extension/install/`
- `browser-extension/tests/`
## 10. Open questions
- extension coexistence with other NIP-07 providers
- multi-profile signer selection UX
- mobile constraints without native messaging
- hardware-token pending-confirmation UX in popup

135
plans/seed_phrase_uses.md Normal file
View File

@@ -0,0 +1,135 @@
# Seed Phrase Use Catalog for n_signer
This document catalogs potential uses of a single memorized BIP-39 seed phrase when mediated through `n_signer` role/purpose/curve controls.
It is intentionally expansive: some entries are immediate, others are future-facing.
## 1. Core idea
A single mnemonic can deterministically derive many key families. `n_signer` treats those as distinct role entries with explicit metadata:
- purpose
- curve
- derivation selector/path
- policy scope
This prevents "same seed means same trust domain" mistakes by separating identities at the policy layer.
## 2. Immediate and near-term domains
### 2.1 Nostr identities (`purpose="nostr"`)
- Curve: `secp256k1`
- Typical shorthand: `nostr_index` -> `m/44'/1237'/<index>'/0/0`
- Uses:
- main pub identity
- throwaway anti-correlation identity
- per-app/per-origin identities
- delegated service identities for local systems
### 2.2 Bitcoin keys (`purpose="bitcoin"`)
- Curve: `secp256k1`
- Typical path families (examples):
- BIP44 legacy account trees
- BIP49 wrapped-segwit
- BIP84 native segwit
- BIP86 taproot keypath flows
- Uses:
- deterministic receive/change branches
- per-context account segregation
- deterministic recovery for wallet setups
### 2.3 Lightning-related identity material
- Curve: usually `secp256k1`
- Uses vary by node software and key hierarchy model.
- Practical use with `n_signer` should be constrained to explicit purpose-bound role entries, not shared with general signing identities.
### 2.4 Mesh/service identities (`purpose="fips"` and similar)
- Curve: primarily `secp256k1` in current planning
- Uses:
- deterministic node identity roots
- stable key-derived addressing inputs
- service bootstrapping in reproducible deployments
## 3. Additional potential domains
### 3.1 SSH identity material (`purpose="ssh"`)
- Candidate curves: `ed25519` (primary), potentially others
- Uses:
- deterministic host/user key regeneration
- environment-specific SSH key derivation without storing private keys on disk
### 3.2 age-style encryption identities (`purpose="age"`)
- Candidate curves: `x25519` / age-native identity mapping
- Uses:
- deterministic file encryption identity recovery
- reproducible offline decryption setups
### 3.3 WireGuard-like transport identities
- Candidate curves: curve25519 family
- Uses:
- deterministic tunnel identity generation
- ephemeral/per-peer derivation schemes (policy-gated)
### 3.4 PGP/OpenPGP-like profiles (future)
- Possible but operationally complex due to multipurpose key lifecycles and tooling expectations.
- Better treated as explicit, isolated purpose entries if ever added.
### 3.5 Deterministic password or secret derivation (future)
- Could derive app/site secrets from role-scoped KDF outputs.
- Must never blur boundaries with signing keys; separate purpose namespace and strict export policy required.
### 3.6 API/service auth tokens (future)
- Per-service deterministic auth keys (HMAC or asymmetric challenge keys depending on service model).
- Strongly policy-scoped to avoid accidental cross-service linkage.
## 4. Curve and derivation notes
`n_signer` currently models curve metadata from day one:
- `secp256k1`
- `ed25519`
- `x25519`
Not every purpose should be valid on every curve. Enforcement should be explicit in policy/runtime validation.
## 5. Operational caveats
1. **One seed phrase is one root compromise domain**
- If the mnemonic is exposed, every derivable domain is exposed.
2. **Purpose isolation is mandatory**
- Reusing one role across unrelated domains collapses privacy and security boundaries.
3. **Pre-registration requirement matters**
- Arbitrary ad-hoc `role_path` requests are too permissive; only pre-registered paths should be derivable by clients.
4. **Policy must constrain verbs + purposes + roles**
- A caller authorized for Nostr signing should not implicitly gain rights for Bitcoin or SSH derivations.
5. **Auditability and naming discipline are crucial**
- Human-readable role names should clearly encode domain intent to avoid operator mistakes.
## 6. Practical guidance
Recommended baseline for early deployments:
- keep `purpose="nostr"` roles as primary production scope
- add non-Nostr purposes only when a concrete consumer exists
- require explicit approval to register new purpose+path combinations
- test recovery procedures per purpose before relying on them operationally
## 7. Relationship to main docs
- Product behavior contract: [`README.md`](../README.md)
- Implementation sequencing: [`plans/nsigner.md`](nsigner.md)
- Browser integration planning: [`plans/nsigner_browser_extension.md`](nsigner_browser_extension.md)

3191
src/cjson/cJSON.c Normal file

File diff suppressed because it is too large Load Diff

306
src/cjson/cJSON.h Normal file
View File

@@ -0,0 +1,306 @@
/*
Copyright (c) 2009-2017 Dave Gamble and cJSON contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
#ifndef cJSON__h
#define cJSON__h
#ifdef __cplusplus
extern "C"
{
#endif
#if !defined(__WINDOWS__) && (defined(WIN32) || defined(WIN64) || defined(_MSC_VER) || defined(_WIN32))
#define __WINDOWS__
#endif
#ifdef __WINDOWS__
/* When compiling for windows, we specify a specific calling convention to avoid issues where we are being called from a project with a different default calling convention. For windows you have 3 define options:
CJSON_HIDE_SYMBOLS - Define this in the case where you don't want to ever dllexport symbols
CJSON_EXPORT_SYMBOLS - Define this on library build when you want to dllexport symbols (default)
CJSON_IMPORT_SYMBOLS - Define this if you want to dllimport symbol
For *nix builds that support visibility attribute, you can define similar behavior by
setting default visibility to hidden by adding
-fvisibility=hidden (for gcc)
or
-xldscope=hidden (for sun cc)
to CFLAGS
then using the CJSON_API_VISIBILITY flag to "export" the same symbols the way CJSON_EXPORT_SYMBOLS does
*/
#define CJSON_CDECL __cdecl
#define CJSON_STDCALL __stdcall
/* export symbols by default, this is necessary for copy pasting the C and header file */
#if !defined(CJSON_HIDE_SYMBOLS) && !defined(CJSON_IMPORT_SYMBOLS) && !defined(CJSON_EXPORT_SYMBOLS)
#define CJSON_EXPORT_SYMBOLS
#endif
#if defined(CJSON_HIDE_SYMBOLS)
#define CJSON_PUBLIC(type) type CJSON_STDCALL
#elif defined(CJSON_EXPORT_SYMBOLS)
#define CJSON_PUBLIC(type) __declspec(dllexport) type CJSON_STDCALL
#elif defined(CJSON_IMPORT_SYMBOLS)
#define CJSON_PUBLIC(type) __declspec(dllimport) type CJSON_STDCALL
#endif
#else /* !__WINDOWS__ */
#define CJSON_CDECL
#define CJSON_STDCALL
#if (defined(__GNUC__) || defined(__SUNPRO_CC) || defined (__SUNPRO_C)) && defined(CJSON_API_VISIBILITY)
#define CJSON_PUBLIC(type) __attribute__((visibility("default"))) type
#else
#define CJSON_PUBLIC(type) type
#endif
#endif
/* project version */
#define CJSON_VERSION_MAJOR 1
#define CJSON_VERSION_MINOR 7
#define CJSON_VERSION_PATCH 18
#include <stddef.h>
/* cJSON Types: */
#define cJSON_Invalid (0)
#define cJSON_False (1 << 0)
#define cJSON_True (1 << 1)
#define cJSON_NULL (1 << 2)
#define cJSON_Number (1 << 3)
#define cJSON_String (1 << 4)
#define cJSON_Array (1 << 5)
#define cJSON_Object (1 << 6)
#define cJSON_Raw (1 << 7) /* raw json */
#define cJSON_IsReference 256
#define cJSON_StringIsConst 512
/* The cJSON structure: */
typedef struct cJSON
{
/* next/prev allow you to walk array/object chains. Alternatively, use GetArraySize/GetArrayItem/GetObjectItem */
struct cJSON *next;
struct cJSON *prev;
/* An array or object item will have a child pointer pointing to a chain of the items in the array/object. */
struct cJSON *child;
/* The type of the item, as above. */
int type;
/* The item's string, if type==cJSON_String and type == cJSON_Raw */
char *valuestring;
/* writing to valueint is DEPRECATED, use cJSON_SetNumberValue instead */
int valueint;
/* The item's number, if type==cJSON_Number */
double valuedouble;
/* The item's name string, if this item is the child of, or is in the list of subitems of an object. */
char *string;
} cJSON;
typedef struct cJSON_Hooks
{
/* malloc/free are CDECL on Windows regardless of the default calling convention of the compiler, so ensure the hooks allow passing those functions directly. */
void *(CJSON_CDECL *malloc_fn)(size_t sz);
void (CJSON_CDECL *free_fn)(void *ptr);
} cJSON_Hooks;
typedef int cJSON_bool;
/* Limits how deeply nested arrays/objects can be before cJSON rejects to parse them.
* This is to prevent stack overflows. */
#ifndef CJSON_NESTING_LIMIT
#define CJSON_NESTING_LIMIT 1000
#endif
/* Limits the length of circular references can be before cJSON rejects to parse them.
* This is to prevent stack overflows. */
#ifndef CJSON_CIRCULAR_LIMIT
#define CJSON_CIRCULAR_LIMIT 10000
#endif
/* returns the version of cJSON as a string */
CJSON_PUBLIC(const char*) cJSON_Version(void);
/* Supply malloc, realloc and free functions to cJSON */
CJSON_PUBLIC(void) cJSON_InitHooks(cJSON_Hooks* hooks);
/* Memory Management: the caller is always responsible to free the results from all variants of cJSON_Parse (with cJSON_Delete) and cJSON_Print (with stdlib free, cJSON_Hooks.free_fn, or cJSON_free as appropriate). The exception is cJSON_PrintPreallocated, where the caller has full responsibility of the buffer. */
/* Supply a block of JSON, and this returns a cJSON object you can interrogate. */
CJSON_PUBLIC(cJSON *) cJSON_Parse(const char *value);
CJSON_PUBLIC(cJSON *) cJSON_ParseWithLength(const char *value, size_t buffer_length);
/* ParseWithOpts allows you to require (and check) that the JSON is null terminated, and to retrieve the pointer to the final byte parsed. */
/* If you supply a ptr in return_parse_end and parsing fails, then return_parse_end will contain a pointer to the error so will match cJSON_GetErrorPtr(). */
CJSON_PUBLIC(cJSON *) cJSON_ParseWithOpts(const char *value, const char **return_parse_end, cJSON_bool require_null_terminated);
CJSON_PUBLIC(cJSON *) cJSON_ParseWithLengthOpts(const char *value, size_t buffer_length, const char **return_parse_end, cJSON_bool require_null_terminated);
/* Render a cJSON entity to text for transfer/storage. */
CJSON_PUBLIC(char *) cJSON_Print(const cJSON *item);
/* Render a cJSON entity to text for transfer/storage without any formatting. */
CJSON_PUBLIC(char *) cJSON_PrintUnformatted(const cJSON *item);
/* Render a cJSON entity to text using a buffered strategy. prebuffer is a guess at the final size. guessing well reduces reallocation. fmt=0 gives unformatted, =1 gives formatted */
CJSON_PUBLIC(char *) cJSON_PrintBuffered(const cJSON *item, int prebuffer, cJSON_bool fmt);
/* Render a cJSON entity to text using a buffer already allocated in memory with given length. Returns 1 on success and 0 on failure. */
/* NOTE: cJSON is not always 100% accurate in estimating how much memory it will use, so to be safe allocate 5 bytes more than you actually need */
CJSON_PUBLIC(cJSON_bool) cJSON_PrintPreallocated(cJSON *item, char *buffer, const int length, const cJSON_bool format);
/* Delete a cJSON entity and all subentities. */
CJSON_PUBLIC(void) cJSON_Delete(cJSON *item);
/* Returns the number of items in an array (or object). */
CJSON_PUBLIC(int) cJSON_GetArraySize(const cJSON *array);
/* Retrieve item number "index" from array "array". Returns NULL if unsuccessful. */
CJSON_PUBLIC(cJSON *) cJSON_GetArrayItem(const cJSON *array, int index);
/* Get item "string" from object. Case insensitive. */
CJSON_PUBLIC(cJSON *) cJSON_GetObjectItem(const cJSON * const object, const char * const string);
CJSON_PUBLIC(cJSON *) cJSON_GetObjectItemCaseSensitive(const cJSON * const object, const char * const string);
CJSON_PUBLIC(cJSON_bool) cJSON_HasObjectItem(const cJSON *object, const char *string);
/* For analysing failed parses. This returns a pointer to the parse error. You'll probably need to look a few chars back to make sense of it. Defined when cJSON_Parse() returns 0. 0 when cJSON_Parse() succeeds. */
CJSON_PUBLIC(const char *) cJSON_GetErrorPtr(void);
/* Check item type and return its value */
CJSON_PUBLIC(char *) cJSON_GetStringValue(const cJSON * const item);
CJSON_PUBLIC(double) cJSON_GetNumberValue(const cJSON * const item);
/* These functions check the type of an item */
CJSON_PUBLIC(cJSON_bool) cJSON_IsInvalid(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsFalse(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsTrue(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsBool(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsNull(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsNumber(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsString(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsArray(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsObject(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsRaw(const cJSON * const item);
/* These calls create a cJSON item of the appropriate type. */
CJSON_PUBLIC(cJSON *) cJSON_CreateNull(void);
CJSON_PUBLIC(cJSON *) cJSON_CreateTrue(void);
CJSON_PUBLIC(cJSON *) cJSON_CreateFalse(void);
CJSON_PUBLIC(cJSON *) cJSON_CreateBool(cJSON_bool boolean);
CJSON_PUBLIC(cJSON *) cJSON_CreateNumber(double num);
CJSON_PUBLIC(cJSON *) cJSON_CreateString(const char *string);
/* raw json */
CJSON_PUBLIC(cJSON *) cJSON_CreateRaw(const char *raw);
CJSON_PUBLIC(cJSON *) cJSON_CreateArray(void);
CJSON_PUBLIC(cJSON *) cJSON_CreateObject(void);
/* Create a string where valuestring references a string so
* it will not be freed by cJSON_Delete */
CJSON_PUBLIC(cJSON *) cJSON_CreateStringReference(const char *string);
/* Create an object/array that only references it's elements so
* they will not be freed by cJSON_Delete */
CJSON_PUBLIC(cJSON *) cJSON_CreateObjectReference(const cJSON *child);
CJSON_PUBLIC(cJSON *) cJSON_CreateArrayReference(const cJSON *child);
/* These utilities create an Array of count items.
* The parameter count cannot be greater than the number of elements in the number array, otherwise array access will be out of bounds.*/
CJSON_PUBLIC(cJSON *) cJSON_CreateIntArray(const int *numbers, int count);
CJSON_PUBLIC(cJSON *) cJSON_CreateFloatArray(const float *numbers, int count);
CJSON_PUBLIC(cJSON *) cJSON_CreateDoubleArray(const double *numbers, int count);
CJSON_PUBLIC(cJSON *) cJSON_CreateStringArray(const char *const *strings, int count);
/* Append item to the specified array/object. */
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToArray(cJSON *array, cJSON *item);
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToObject(cJSON *object, const char *string, cJSON *item);
/* Use this when string is definitely const (i.e. a literal, or as good as), and will definitely survive the cJSON object.
* WARNING: When this function was used, make sure to always check that (item->type & cJSON_StringIsConst) is zero before
* writing to `item->string` */
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToObjectCS(cJSON *object, const char *string, cJSON *item);
/* Append reference to item to the specified array/object. Use this when you want to add an existing cJSON to a new cJSON, but don't want to corrupt your existing cJSON. */
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemReferenceToArray(cJSON *array, cJSON *item);
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemReferenceToObject(cJSON *object, const char *string, cJSON *item);
/* Remove/Detach items from Arrays/Objects. */
CJSON_PUBLIC(cJSON *) cJSON_DetachItemViaPointer(cJSON *parent, cJSON * const item);
CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromArray(cJSON *array, int which);
CJSON_PUBLIC(void) cJSON_DeleteItemFromArray(cJSON *array, int which);
CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromObject(cJSON *object, const char *string);
CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromObjectCaseSensitive(cJSON *object, const char *string);
CJSON_PUBLIC(void) cJSON_DeleteItemFromObject(cJSON *object, const char *string);
CJSON_PUBLIC(void) cJSON_DeleteItemFromObjectCaseSensitive(cJSON *object, const char *string);
/* Update array items. */
CJSON_PUBLIC(cJSON_bool) cJSON_InsertItemInArray(cJSON *array, int which, cJSON *newitem); /* Shifts pre-existing items to the right. */
CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemViaPointer(cJSON * const parent, cJSON * const item, cJSON * replacement);
CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInArray(cJSON *array, int which, cJSON *newitem);
CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInObject(cJSON *object,const char *string,cJSON *newitem);
CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInObjectCaseSensitive(cJSON *object,const char *string,cJSON *newitem);
/* Duplicate a cJSON item */
CJSON_PUBLIC(cJSON *) cJSON_Duplicate(const cJSON *item, cJSON_bool recurse);
/* Duplicate will create a new, identical cJSON item to the one you pass, in new memory that will
* need to be released. With recurse!=0, it will duplicate any children connected to the item.
* The item->next and ->prev pointers are always zero on return from Duplicate. */
/* Recursively compare two cJSON items for equality. If either a or b is NULL or invalid, they will be considered unequal.
* case_sensitive determines if object keys are treated case sensitive (1) or case insensitive (0) */
CJSON_PUBLIC(cJSON_bool) cJSON_Compare(const cJSON * const a, const cJSON * const b, const cJSON_bool case_sensitive);
/* Minify a strings, remove blank characters(such as ' ', '\t', '\r', '\n') from strings.
* The input pointer json cannot point to a read-only address area, such as a string constant,
* but should point to a readable and writable address area. */
CJSON_PUBLIC(void) cJSON_Minify(char *json);
/* Helper functions for creating and adding items to an object at the same time.
* They return the added item or NULL on failure. */
CJSON_PUBLIC(cJSON*) cJSON_AddNullToObject(cJSON * const object, const char * const name);
CJSON_PUBLIC(cJSON*) cJSON_AddTrueToObject(cJSON * const object, const char * const name);
CJSON_PUBLIC(cJSON*) cJSON_AddFalseToObject(cJSON * const object, const char * const name);
CJSON_PUBLIC(cJSON*) cJSON_AddBoolToObject(cJSON * const object, const char * const name, const cJSON_bool boolean);
CJSON_PUBLIC(cJSON*) cJSON_AddNumberToObject(cJSON * const object, const char * const name, const double number);
CJSON_PUBLIC(cJSON*) cJSON_AddStringToObject(cJSON * const object, const char * const name, const char * const string);
CJSON_PUBLIC(cJSON*) cJSON_AddRawToObject(cJSON * const object, const char * const name, const char * const raw);
CJSON_PUBLIC(cJSON*) cJSON_AddObjectToObject(cJSON * const object, const char * const name);
CJSON_PUBLIC(cJSON*) cJSON_AddArrayToObject(cJSON * const object, const char * const name);
/* When assigning an integer value, it needs to be propagated to valuedouble too. */
#define cJSON_SetIntValue(object, number) ((object) ? (object)->valueint = (object)->valuedouble = (number) : (number))
/* helper for the cJSON_SetNumberValue macro */
CJSON_PUBLIC(double) cJSON_SetNumberHelper(cJSON *object, double number);
#define cJSON_SetNumberValue(object, number) ((object != NULL) ? cJSON_SetNumberHelper(object, (double)number) : (number))
/* Change the valuestring of a cJSON_String object, only takes effect when type of object is cJSON_String */
CJSON_PUBLIC(char*) cJSON_SetValuestring(cJSON *object, const char *valuestring);
/* If the object is not a boolean type this does nothing and returns cJSON_Invalid else it returns the new type*/
#define cJSON_SetBoolValue(object, boolValue) ( \
(object != NULL && ((object)->type & (cJSON_False|cJSON_True))) ? \
(object)->type=((object)->type &(~(cJSON_False|cJSON_True)))|((boolValue)?cJSON_True:cJSON_False) : \
cJSON_Invalid\
)
/* Macro for iterating over an array or object */
#define cJSON_ArrayForEach(element, array) for(element = (array != NULL) ? (array)->child : NULL; element != NULL; element = element->next)
/* malloc/free objects using the malloc/free functions that have been set with cJSON_InitHooks */
CJSON_PUBLIC(void *) cJSON_malloc(size_t size);
CJSON_PUBLIC(void) cJSON_free(void *object);
#ifdef __cplusplus
}
#endif
#endif

223
src/dispatcher.c Normal file
View File

@@ -0,0 +1,223 @@
#include "dispatcher.h"
#include "enforcement.h"
#include "selector.h"
#include "cjson/cJSON.h"
#include <stdlib.h>
#include <string.h>
static char *make_error_response(const char *id, int code, const char *message) {
cJSON *root = NULL;
cJSON *err = NULL;
cJSON *id_item = NULL;
char *out = NULL;
root = cJSON_CreateObject();
if (root == NULL) {
return NULL;
}
if (id == NULL) {
id = "null";
}
id_item = cJSON_CreateString(id);
if (id_item == NULL) {
cJSON_Delete(root);
return NULL;
}
cJSON_AddItemToObject(root, "id", id_item);
err = cJSON_CreateObject();
if (err == NULL) {
cJSON_Delete(root);
return NULL;
}
cJSON_AddNumberToObject(err, "code", code);
cJSON_AddStringToObject(err, "message", (message != NULL) ? message : "error");
cJSON_AddItemToObject(root, "error", err);
out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return out;
}
static char *make_success_response(const char *id, const char *result) {
cJSON *root = NULL;
cJSON *id_item = NULL;
char *out = NULL;
root = cJSON_CreateObject();
if (root == NULL) {
return NULL;
}
if (id == NULL) {
id = "null";
}
id_item = cJSON_CreateString(id);
if (id_item == NULL) {
cJSON_Delete(root);
return NULL;
}
cJSON_AddItemToObject(root, "id", id_item);
if (!cJSON_AddStringToObject(root, "result", (result != NULL) ? result : "")) {
cJSON_Delete(root);
return NULL;
}
out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return out;
}
void dispatcher_init(dispatcher_ctx_t *ctx, role_table_t *table, mnemonic_state_t *mnemonic) {
if (ctx == NULL) {
return;
}
ctx->role_table = table;
ctx->mnemonic = mnemonic;
}
char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request) {
cJSON *root = NULL;
cJSON *method_item;
cJSON *params_item;
cJSON *id_item;
cJSON *options_item;
cJSON *tmp;
const char *id_str = "null";
char id_copy[256];
const char *method;
selector_request_t selector_req;
role_entry_t *role = NULL;
int rc;
const char *result = NULL;
if (ctx == NULL || ctx->role_table == NULL || ctx->mnemonic == NULL || json_request == NULL) {
return make_error_response("null", -32600, "invalid_request");
}
root = cJSON_Parse(json_request);
if (root == NULL) {
return make_error_response("null", -32700, "parse_error");
}
id_copy[0] = '\0';
id_item = cJSON_GetObjectItemCaseSensitive(root, "id");
if (cJSON_IsString(id_item) && id_item->valuestring != NULL) {
strncpy(id_copy, id_item->valuestring, sizeof(id_copy) - 1);
id_copy[sizeof(id_copy) - 1] = '\0';
id_str = id_copy;
}
method_item = cJSON_GetObjectItemCaseSensitive(root, "method");
if (!cJSON_IsString(method_item) || method_item->valuestring == NULL) {
cJSON_Delete(root);
return make_error_response(id_str, -32600, "invalid_request");
}
method = method_item->valuestring;
params_item = cJSON_GetObjectItemCaseSensitive(root, "params");
if (!cJSON_IsArray(params_item)) {
cJSON_Delete(root);
return make_error_response(id_str, -32600, "invalid_request");
}
selector_request_init(&selector_req);
options_item = cJSON_GetArrayItem(params_item, 1);
if (cJSON_IsObject(options_item)) {
tmp = cJSON_GetObjectItemCaseSensitive(options_item, "role");
if (tmp != NULL) {
if (!cJSON_IsString(tmp) || tmp->valuestring == NULL) {
cJSON_Delete(root);
return make_error_response(id_str, -32602, "invalid_params");
}
selector_req.has_role = 1;
strncpy(selector_req.role_name, tmp->valuestring, sizeof(selector_req.role_name) - 1);
selector_req.role_name[sizeof(selector_req.role_name) - 1] = '\0';
}
tmp = cJSON_GetObjectItemCaseSensitive(options_item, "nostr_index");
if (tmp != NULL) {
if (!cJSON_IsNumber(tmp)) {
cJSON_Delete(root);
return make_error_response(id_str, -32602, "invalid_params");
}
selector_req.has_nostr_index = 1;
selector_req.nostr_index = tmp->valueint;
}
tmp = cJSON_GetObjectItemCaseSensitive(options_item, "role_path");
if (tmp != NULL) {
if (!cJSON_IsString(tmp) || tmp->valuestring == NULL) {
cJSON_Delete(root);
return make_error_response(id_str, -32602, "invalid_params");
}
selector_req.has_role_path = 1;
strncpy(selector_req.role_path, tmp->valuestring, sizeof(selector_req.role_path) - 1);
selector_req.role_path[sizeof(selector_req.role_path) - 1] = '\0';
}
}
rc = selector_resolve(&selector_req, ctx->role_table, &role);
if (rc != SELECTOR_OK) {
cJSON_Delete(root);
if (rc == SELECTOR_ERR_AMBIGUOUS) {
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");
}
if (rc == SELECTOR_ERR_NO_DEFAULT) {
return make_error_response(id_str, 1003, "no_default_role");
}
return make_error_response(id_str, -32602, "invalid_params");
}
rc = enforce_verb_role(method, role);
if (rc != ENFORCE_OK) {
cJSON_Delete(root);
if (rc == ENFORCE_ERR_PURPOSE) {
return make_error_response(id_str, 1004, "purpose_mismatch");
}
if (rc == ENFORCE_ERR_CURVE) {
return make_error_response(id_str, 1005, "curve_mismatch");
}
if (rc == ENFORCE_ERR_UNKNOWN_VERB) {
return make_error_response(id_str, -32601, "method_not_found");
}
return make_error_response(id_str, -32601, "method_not_found");
}
if (!mnemonic_is_loaded(ctx->mnemonic)) {
cJSON_Delete(root);
return make_error_response(id_str, 1006, "mnemonic_not_loaded");
}
if (strcmp(method, VERB_GET_PUBLIC_KEY) == 0) {
result = role->derived ? role->pubkey_hex : "not_yet_derived";
} else if (strcmp(method, VERB_SIGN_EVENT) == 0) {
result = "stub:sign_event_ok";
} else if (strcmp(method, VERB_NIP44_ENCRYPT) == 0) {
result = "stub:nip44_encrypt_ok";
} else if (strcmp(method, VERB_NIP44_DECRYPT) == 0) {
result = "stub:nip44_decrypt_ok";
} else if (strcmp(method, VERB_NIP04_ENCRYPT) == 0) {
result = "stub:nip04_encrypt_ok";
} else if (strcmp(method, VERB_NIP04_DECRYPT) == 0) {
result = "stub:nip04_decrypt_ok";
} else {
cJSON_Delete(root);
return make_error_response(id_str, -32601, "method_not_found");
}
cJSON_Delete(root);
return make_success_response(id_str, result);
}

42
src/dispatcher.h Normal file
View File

@@ -0,0 +1,42 @@
#ifndef NSIGNER_DISPATCHER_H
#define NSIGNER_DISPATCHER_H
#include "role_table.h"
#include "mnemonic.h"
/* Dispatcher context — holds references to shared state */
typedef struct {
role_table_t *role_table;
mnemonic_state_t *mnemonic;
} dispatcher_ctx_t;
/* Initialize dispatcher context */
void dispatcher_init(dispatcher_ctx_t *ctx, role_table_t *table, mnemonic_state_t *mnemonic);
/*
* 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);
#endif /* NSIGNER_DISPATCHER_H */

47
src/enforcement.c Normal file
View File

@@ -0,0 +1,47 @@
#include "enforcement.h"
#include <string.h>
static int is_nostr_verb(const char *verb) {
if (verb == NULL) {
return 0;
}
return (strcmp(verb, VERB_SIGN_EVENT) == 0) ||
(strcmp(verb, VERB_GET_PUBLIC_KEY) == 0) ||
(strcmp(verb, VERB_NIP44_ENCRYPT) == 0) ||
(strcmp(verb, VERB_NIP44_DECRYPT) == 0) ||
(strcmp(verb, VERB_NIP04_ENCRYPT) == 0) ||
(strcmp(verb, VERB_NIP04_DECRYPT) == 0);
}
int enforce_verb_role(const char *verb, const role_entry_t *role) {
if (!is_nostr_verb(verb)) {
return ENFORCE_ERR_UNKNOWN_VERB;
}
if (role->purpose != PURPOSE_NOSTR) {
return ENFORCE_ERR_PURPOSE;
}
if (role->curve != CURVE_SECP256K1) {
return ENFORCE_ERR_CURVE;
}
return ENFORCE_OK;
}
const char *enforce_strerror(int err) {
switch (err) {
case ENFORCE_OK:
return "ok";
case ENFORCE_ERR_PURPOSE:
return "purpose_mismatch";
case ENFORCE_ERR_CURVE:
return "curve_mismatch";
case ENFORCE_ERR_UNKNOWN_VERB:
return "unknown_verb";
default:
return "unknown_error";
}
}

36
src/enforcement.h Normal file
View File

@@ -0,0 +1,36 @@
#ifndef NSIGNER_ENFORCEMENT_H
#define NSIGNER_ENFORCEMENT_H
#include "role_table.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);
#endif /* NSIGNER_ENFORCEMENT_H */

394
src/main.c Normal file
View File

@@ -0,0 +1,394 @@
#define _GNU_SOURCE
#include "main.h"
#include "dispatcher.h"
#include "mnemonic.h"
#include "policy.h"
#include "role_table.h"
#include "server.h"
#include <arpa/inet.h>
#include <errno.h>
#include <poll.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/un.h>
#include <termios.h>
#include <unistd.h>
#define NSIGNER_SOCKET_NAME "nsigner"
static volatile sig_atomic_t g_running = 1;
static void handle_signal(int sig) {
(void)sig;
g_running = 0;
}
static int read_line_stdin(char *buf, size_t buf_sz) {
size_t len;
if (buf == NULL || buf_sz == 0) {
return -1;
}
if (fgets(buf, (int)buf_sz, stdin) == NULL) {
return -1;
}
len = strlen(buf);
while (len > 0 && (buf[len - 1] == '\n' || buf[len - 1] == '\r')) {
buf[len - 1] = '\0';
len--;
}
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;
struct sockaddr_un addr;
socklen_t addr_len;
if (name == NULL || name[0] == '\0') {
return -1;
}
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) {
close(fd);
return -1;
}
return fd;
}
static void print_usage(const char *program_name) {
printf("nsigner - single-binary signer program\n");
printf("Usage:\n");
printf(" %s Run signer server + built-in TUI\n", program_name);
printf(" %s client '<json>' Send JSON-RPC request\n", program_name);
printf(" %s client - Read JSON-RPC request from stdin\n", program_name);
printf(" %s --help\n", program_name);
printf(" %s --version\n", program_name);
}
static int client_main(int argc, char *argv[]) {
int fd;
char *request = NULL;
char *response = NULL;
char stdin_buf[SERVER_MAX_MSG_SIZE + 1];
if (argc < 2) {
fprintf(stderr, "Usage: nsigner client '<json-rpc-request>' | -\n");
return 1;
}
if (strcmp(argv[1], "-") == 0) {
if (read_line_stdin(stdin_buf, sizeof(stdin_buf)) != 0) {
fprintf(stderr, "Failed to read request from stdin\n");
return 1;
}
request = stdin_buf;
} else {
request = argv[1];
}
fd = connect_abstract_socket(NSIGNER_SOCKET_NAME);
if (fd < 0) {
perror("connect");
return 1;
}
if (send_framed(fd, request) != 0) {
perror("send");
close(fd);
return 1;
}
if (recv_framed(fd, &response) != 0) {
perror("recv");
close(fd);
return 1;
}
printf("%s\n", response);
free(response);
close(fd);
return 0;
}
static void activity_log_cb(const char *message, void *user_data) {
(void)user_data;
if (message != NULL) {
printf("%s\n", message);
fflush(stdout);
}
}
static int setup_default_role(role_table_t *role_table) {
role_entry_t role;
if (role_table == NULL) {
return -1;
}
memset(&role, 0, sizeof(role));
strncpy(role.name, "main", sizeof(role.name) - 1);
strncpy(role.purpose_str, "nostr", sizeof(role.purpose_str) - 1);
strncpy(role.curve_str, "secp256k1", sizeof(role.curve_str) - 1);
role.purpose = role_purpose_from_str(role.purpose_str);
role.curve = role_curve_from_str(role.curve_str);
role.selector_type = SELECTOR_NOSTR_INDEX;
role.nostr_index = 0;
role.derived = 0;
return role_table_add(role_table, &role);
}
static int prompt_load_mnemonic(mnemonic_state_t *mnemonic) {
char phrase[MNEMONIC_MAX_LEN];
struct termios old_term;
struct termios new_term;
int have_term = 0;
if (mnemonic == NULL) {
return -1;
}
printf("Enter mnemonic (12/15/18/21/24 words): ");
fflush(stdout);
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;
}
}
if (read_line_stdin(phrase, sizeof(phrase)) != 0) {
if (have_term) {
(void)tcsetattr(STDIN_FILENO, TCSANOW, &old_term);
}
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;
}
printf("%d words loaded. Press Enter to continue.", mnemonic->word_count);
fflush(stdout);
if (read_line_stdin(phrase, sizeof(phrase)) != 0) {
memset(phrase, 0, sizeof(phrase));
return -1;
}
memset(phrase, 0, sizeof(phrase));
return 0;
}
int main(int argc, char *argv[]) {
mnemonic_state_t mnemonic;
role_table_t role_table;
dispatcher_ctx_t dispatcher;
policy_table_t policy;
server_ctx_t server;
struct pollfd pfd;
uid_t owner_uid;
if (argc >= 2 && strcmp(argv[1], "client") == 0) {
return client_main(argc - 1, argv + 1);
}
if (argc >= 2 && (strcmp(argv[1], "--version") == 0 || strcmp(argv[1], "-v") == 0)) {
printf("nsigner %s\n", NSIGNER_VERSION);
return 0;
}
if (argc >= 2 && (strcmp(argv[1], "--help") == 0 || strcmp(argv[1], "-h") == 0)) {
print_usage(argv[0]);
return 0;
}
mnemonic_init(&mnemonic);
if (prompt_load_mnemonic(&mnemonic) != 0) {
mnemonic_unload(&mnemonic);
return 1;
}
role_table_init(&role_table);
if (setup_default_role(&role_table) != 0) {
fprintf(stderr, "Failed to initialize default role\n");
mnemonic_unload(&mnemonic);
return 1;
}
dispatcher_init(&dispatcher, &role_table, &mnemonic);
owner_uid = getuid();
policy_init_default(&policy, owner_uid);
server_init(&server, NSIGNER_SOCKET_NAME, &dispatcher, &policy);
if (server_start(&server) != 0) {
fprintf(stderr, "Failed to start server\n");
mnemonic_unload(&mnemonic);
return 1;
}
(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("Listening: '%s'\n", NSIGNER_SOCKET_NAME);
printf("\n");
printf("Activity:\n");
fflush(stdout);
pfd.fd = server.listen_fd;
pfd.events = POLLIN;
while (g_running && server.running) {
int prc = poll(&pfd, 1, 200);
if (prc < 0) {
if (errno == EINTR) {
continue;
}
break;
}
if (prc > 0 && (pfd.revents & POLLIN)) {
int hrc = server_handle_one(&server, activity_log_cb, NULL);
if (hrc < 0) {
break;
}
}
}
server_stop(&server);
mnemonic_unload(&mnemonic);
printf("Shutdown. All secrets wiped.\n");
return 0;
}

16
src/main.h Normal file
View File

@@ -0,0 +1,16 @@
/*
* nsigner main header - version information
*
* Version macros are auto-updated by increment_and_push.sh.
*/
#ifndef NSIGNER_MAIN_H
#define NSIGNER_MAIN_H
/* Version information (auto-updated by build/version tooling) */
#define NSIGNER_VERSION_MAJOR 0
#define NSIGNER_VERSION_MINOR 0
#define NSIGNER_VERSION_PATCH 1
#define NSIGNER_VERSION "v0.0.1"
#endif /* NSIGNER_MAIN_H */

103
src/mnemonic.c Normal file
View File

@@ -0,0 +1,103 @@
#include "mnemonic.h"
#include <stddef.h>
#include <string.h>
/* Return 1 if the word count is an allowed BIP39 mnemonic length. */
static int mnemonic_is_valid_word_count(int count) {
return (count == 12 || count == 15 || count == 18 || count == 21 || count == 24);
}
/* Count words separated by spaces, collapsing multiple spaces. */
static int mnemonic_count_words(const char *phrase) {
int count = 0;
int in_word = 0;
const char *p = phrase;
while (*p != '\0') {
if (*p == ' ') {
in_word = 0;
} else if (!in_word) {
in_word = 1;
++count;
}
++p;
}
return count;
}
/* Initialize mnemonic state to an empty value. */
void mnemonic_init(mnemonic_state_t *state) {
if (state == NULL) {
return;
}
memset(state, 0, sizeof(*state));
}
/*
* Load mnemonic phrase into secure storage.
* Returns 0 on success, -1 on invalid input, -2 on secure allocation failure.
*/
int mnemonic_load(mnemonic_state_t *state, const char *phrase) {
size_t phrase_len;
int words;
if (state == NULL || phrase == NULL) {
return -1;
}
if (state->loaded) {
mnemonic_unload(state);
}
phrase_len = strlen(phrase);
if (phrase_len == 0 || phrase_len >= MNEMONIC_MAX_LEN) {
return -1;
}
words = mnemonic_count_words(phrase);
if (!mnemonic_is_valid_word_count(words)) {
return -1;
}
if (secure_buf_alloc(&state->buf, phrase_len + 1) != 0) {
return -2;
}
memcpy(state->buf.data, phrase, phrase_len + 1);
state->loaded = 1;
state->word_count = words;
return 0;
}
/* Zeroize and unload currently loaded mnemonic state. */
void mnemonic_unload(mnemonic_state_t *state) {
if (state == NULL) {
return;
}
secure_buf_free(&state->buf);
state->loaded = 0;
state->word_count = 0;
}
/* Return non-zero when a mnemonic is loaded, otherwise zero. */
int mnemonic_is_loaded(const mnemonic_state_t *state) {
if (state == NULL) {
return 0;
}
return state->loaded;
}
/* Return the mnemonic phrase pointer if loaded, otherwise NULL. */
const char *mnemonic_get_phrase(const mnemonic_state_t *state) {
if (state == NULL || !state->loaded) {
return NULL;
}
return (const char *)state->buf.data;
}

35
src/mnemonic.h Normal file
View File

@@ -0,0 +1,35 @@
#ifndef NSIGNER_MNEMONIC_H
#define NSIGNER_MNEMONIC_H
#include "secure_mem.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);
#endif /* NSIGNER_MNEMONIC_H */

191
src/policy.c Normal file
View File

@@ -0,0 +1,191 @@
#include "policy.h"
#include <stdio.h>
#include <string.h>
static void copy_str(char *dst, size_t dst_sz, const char *src) {
if (dst == NULL || dst_sz == 0) {
return;
}
if (src == NULL) {
dst[0] = '\0';
return;
}
strncpy(dst, src, dst_sz - 1);
dst[dst_sz - 1] = '\0';
}
static int string_in_verbs(const char *needle, char haystack[][POLICY_VERB_MAX_LEN], int count) {
int i;
if (needle == NULL) {
return 0;
}
for (i = 0; i < count; ++i) {
if (strcmp(haystack[i], needle) == 0) {
return 1;
}
}
return 0;
}
static int string_in_roles(const char *needle, char haystack[][ROLE_NAME_MAX], int count) {
int i;
if (needle == NULL) {
return 0;
}
for (i = 0; i < count; ++i) {
if (strcmp(haystack[i], needle) == 0) {
return 1;
}
}
return 0;
}
static int string_in_purposes(const char *needle, char haystack[][ROLE_PURPOSE_MAX], int count) {
int i;
if (needle == NULL) {
return 0;
}
for (i = 0; i < count; ++i) {
if (strcmp(haystack[i], needle) == 0) {
return 1;
}
}
return 0;
}
prompt_mode_t prompt_mode_from_str(const char *s) {
if (s == NULL) {
return PROMPT_DENY;
}
if (strcmp(s, "never") == 0) {
return PROMPT_NEVER;
}
if (strcmp(s, "first_per_boot") == 0) {
return PROMPT_FIRST_PER_BOOT;
}
if (strcmp(s, "every_request") == 0) {
return PROMPT_EVERY_REQUEST;
}
if (strcmp(s, "deny") == 0) {
return PROMPT_DENY;
}
return PROMPT_DENY;
}
const char *prompt_mode_to_str(prompt_mode_t m) {
switch (m) {
case PROMPT_NEVER:
return "never";
case PROMPT_FIRST_PER_BOOT:
return "first_per_boot";
case PROMPT_EVERY_REQUEST:
return "every_request";
case PROMPT_DENY:
default:
return "deny";
}
}
void policy_table_init(policy_table_t *table) {
if (table == NULL) {
return;
}
memset(table, 0, sizeof(*table));
}
void policy_init_default(policy_table_t *table, uid_t owner_uid) {
policy_entry_t allow_owner;
policy_entry_t deny_all;
if (table == NULL) {
return;
}
policy_table_init(table);
memset(&allow_owner, 0, sizeof(allow_owner));
(void)snprintf(allow_owner.caller, sizeof(allow_owner.caller), "uid:%u", (unsigned int)owner_uid);
allow_owner.prompt = PROMPT_NEVER;
(void)policy_table_add(table, &allow_owner);
memset(&deny_all, 0, sizeof(deny_all));
copy_str(deny_all.caller, sizeof(deny_all.caller), "*");
deny_all.prompt = PROMPT_DENY;
(void)policy_table_add(table, &deny_all);
}
int policy_table_add(policy_table_t *table, const policy_entry_t *entry) {
if (table == NULL || entry == NULL) {
return -1;
}
if (table->count >= POLICY_MAX_ENTRIES) {
return -1;
}
table->entries[table->count] = *entry;
table->count++;
return 0;
}
int policy_check(const policy_table_t *table, const char *caller_id,
const char *verb, const char *role_name, const char *purpose) {
int i;
if (table == NULL || caller_id == NULL || verb == NULL || role_name == NULL || purpose == NULL) {
return POLICY_NO_MATCH;
}
for (i = 0; i < table->count; ++i) {
const policy_entry_t *entry = &table->entries[i];
int caller_ok;
int verb_ok;
int role_ok;
int purpose_ok;
caller_ok = (strcmp(entry->caller, "*") == 0) || (strcmp(entry->caller, caller_id) == 0);
if (!caller_ok) {
continue;
}
verb_ok = (entry->verb_count == 0) || string_in_verbs(verb, (char (*)[POLICY_VERB_MAX_LEN])entry->verbs, entry->verb_count);
if (!verb_ok) {
continue;
}
role_ok = (entry->role_count == 0) || string_in_roles(role_name, (char (*)[ROLE_NAME_MAX])entry->roles, entry->role_count);
if (!role_ok) {
continue;
}
purpose_ok = (entry->purpose_count == 0) || string_in_purposes(purpose, (char (*)[ROLE_PURPOSE_MAX])entry->purposes, entry->purpose_count);
if (!purpose_ok) {
continue;
}
if (entry->prompt == PROMPT_DENY) {
return POLICY_DENY;
}
if (entry->prompt == PROMPT_NEVER) {
return POLICY_ALLOW;
}
return POLICY_PROMPT;
}
return POLICY_NO_MATCH;
}

68
src/policy.h Normal file
View File

@@ -0,0 +1,68 @@
#ifndef NSIGNER_POLICY_H
#define NSIGNER_POLICY_H
#include "role_table.h"
#include <sys/types.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);
#endif /* NSIGNER_POLICY_H */

158
src/role_table.c Normal file
View File

@@ -0,0 +1,158 @@
#include "role_table.h"
#include <string.h>
static int str_eq(const char *a, const char *b) {
if (a == NULL || b == NULL) {
return 0;
}
return strcmp(a, b) == 0;
}
void role_table_init(role_table_t *table) {
if (table == NULL) {
return;
}
memset(table, 0, sizeof(*table));
}
int role_table_add(role_table_t *table, const role_entry_t *entry) {
int i;
if (table == NULL || entry == NULL) {
return -1;
}
if (table->count >= ROLE_TABLE_MAX_ENTRIES) {
return -1;
}
for (i = 0; i < table->count; ++i) {
if (strcmp(table->entries[i].name, entry->name) == 0) {
return -2;
}
}
table->entries[table->count] = *entry;
table->count++;
return 0;
}
role_entry_t *role_table_find_by_name(role_table_t *table, const char *name) {
int i;
if (table == NULL || name == NULL) {
return NULL;
}
for (i = 0; i < table->count; ++i) {
if (strcmp(table->entries[i].name, name) == 0) {
return &table->entries[i];
}
}
return NULL;
}
role_entry_t *role_table_find_by_nostr_index(role_table_t *table, int index) {
int i;
if (table == NULL) {
return NULL;
}
for (i = 0; i < table->count; ++i) {
role_entry_t *entry = &table->entries[i];
if (entry->selector_type == SELECTOR_NOSTR_INDEX && entry->nostr_index == index) {
return entry;
}
}
return NULL;
}
role_entry_t *role_table_find_by_path(role_table_t *table, const char *path) {
int i;
if (table == NULL || path == NULL) {
return NULL;
}
for (i = 0; i < table->count; ++i) {
role_entry_t *entry = &table->entries[i];
if (entry->selector_type == SELECTOR_ROLE_PATH && strcmp(entry->role_path, path) == 0) {
return entry;
}
}
return NULL;
}
role_entry_t *role_table_get_default(role_table_t *table) {
return role_table_find_by_name(table, "main");
}
role_purpose_t role_purpose_from_str(const char *s) {
if (str_eq(s, "nostr")) {
return PURPOSE_NOSTR;
}
if (str_eq(s, "bitcoin")) {
return PURPOSE_BITCOIN;
}
if (str_eq(s, "ssh")) {
return PURPOSE_SSH;
}
if (str_eq(s, "age")) {
return PURPOSE_AGE;
}
if (str_eq(s, "fips")) {
return PURPOSE_FIPS;
}
return PURPOSE_UNKNOWN;
}
role_curve_t role_curve_from_str(const char *s) {
if (str_eq(s, "secp256k1")) {
return CURVE_SECP256K1;
}
if (str_eq(s, "ed25519")) {
return CURVE_ED25519;
}
if (str_eq(s, "x25519")) {
return CURVE_X25519;
}
return CURVE_UNKNOWN;
}
const char *role_purpose_to_str(role_purpose_t p) {
switch (p) {
case PURPOSE_NOSTR:
return "nostr";
case PURPOSE_BITCOIN:
return "bitcoin";
case PURPOSE_SSH:
return "ssh";
case PURPOSE_AGE:
return "age";
case PURPOSE_FIPS:
return "fips";
case PURPOSE_UNKNOWN:
default:
return "unknown";
}
}
const char *role_curve_to_str(role_curve_t c) {
switch (c) {
case CURVE_SECP256K1:
return "secp256k1";
case CURVE_ED25519:
return "ed25519";
case CURVE_X25519:
return "x25519";
case CURVE_UNKNOWN:
default:
return "unknown";
}
}

89
src/role_table.h Normal file
View File

@@ -0,0 +1,89 @@
#ifndef NSIGNER_ROLE_TABLE_H
#define NSIGNER_ROLE_TABLE_H
#include <stddef.h>
#include <stdint.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);
#endif /* NSIGNER_ROLE_TABLE_H */

82
src/secure_mem.c Normal file
View File

@@ -0,0 +1,82 @@
#define _GNU_SOURCE
#include "secure_mem.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
/*
* Zeroize memory in a way the compiler cannot optimize out.
* Uses explicit_bzero when available, with a volatile fallback.
*/
void secure_memzero(void *ptr, size_t len) {
if (ptr == NULL || len == 0) {
return;
}
#if defined(__GLIBC__) || defined(__linux__)
explicit_bzero(ptr, len);
#else
size_t i;
volatile unsigned char *p = (volatile unsigned char *)ptr;
for (i = 0; i < len; ++i) {
p[i] = 0;
}
#endif
}
/*
* Allocate secure memory and attempt to lock it in RAM.
* Returns 0 on success, -1 on allocation/argument failure.
*/
int secure_buf_alloc(secure_buf_t *buf, size_t size) {
if (buf == NULL || size == 0) {
return -1;
}
buf->data = malloc(size);
if (buf->data == NULL) {
buf->size = 0;
buf->locked = 0;
return -1;
}
buf->size = size;
buf->locked = 0;
if (mlock(buf->data, buf->size) == 0) {
buf->locked = 1;
} else {
fprintf(stderr, "warning: secure_buf_alloc: mlock failed; continuing unlocked\n");
}
memset(buf->data, 0, buf->size);
return 0;
}
/*
* Zeroize and release secure memory.
* Safe to call multiple times.
*/
void secure_buf_free(secure_buf_t *buf) {
if (buf == NULL) {
return;
}
if (buf->data != NULL && buf->size > 0) {
secure_memzero(buf->data, buf->size);
if (buf->locked) {
(void)munlock(buf->data, buf->size);
}
free(buf->data);
}
buf->data = NULL;
buf->size = 0;
buf->locked = 0;
}

25
src/secure_mem.h Normal file
View File

@@ -0,0 +1,25 @@
#ifndef NSIGNER_SECURE_MEM_H
#define NSIGNER_SECURE_MEM_H
#include <stddef.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);
#endif /* NSIGNER_SECURE_MEM_H */

72
src/selector.c Normal file
View File

@@ -0,0 +1,72 @@
#include "selector.h"
#include <string.h>
void selector_request_init(selector_request_t *req) {
if (req == NULL) {
return;
}
memset(req, 0, sizeof(*req));
}
int selector_resolve(const selector_request_t *req, role_table_t *table, role_entry_t **out) {
int selector_count = 0;
role_entry_t *match = NULL;
if (out != NULL) {
*out = NULL;
}
if (req == NULL || table == NULL || out == NULL) {
return SELECTOR_ERR_NOT_FOUND;
}
selector_count += req->has_role ? 1 : 0;
selector_count += req->has_nostr_index ? 1 : 0;
selector_count += req->has_role_path ? 1 : 0;
if (selector_count > 1) {
return SELECTOR_ERR_AMBIGUOUS;
}
if (selector_count == 1) {
if (req->has_role) {
match = role_table_find_by_name(table, req->role_name);
} else if (req->has_nostr_index) {
match = role_table_find_by_nostr_index(table, req->nostr_index);
} else if (req->has_role_path) {
match = role_table_find_by_path(table, req->role_path);
}
if (match == NULL) {
return SELECTOR_ERR_NOT_FOUND;
}
*out = match;
return SELECTOR_OK;
}
match = role_table_get_default(table);
if (match == NULL) {
return SELECTOR_ERR_NO_DEFAULT;
}
*out = match;
return SELECTOR_OK;
}
const char *selector_strerror(int err) {
switch (err) {
case SELECTOR_OK:
return "ok";
case SELECTOR_ERR_AMBIGUOUS:
return "ambiguous_role_selector";
case SELECTOR_ERR_NOT_FOUND:
return "role_not_found";
case SELECTOR_ERR_NO_DEFAULT:
return "no_default_role";
default:
return "unknown_selector_error";
}
}

39
src/selector.h Normal file
View File

@@ -0,0 +1,39 @@
#ifndef NSIGNER_SELECTOR_H
#define NSIGNER_SELECTOR_H
#include "role_table.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);
#endif /* NSIGNER_SELECTOR_H */

366
src/server.c Normal file
View File

@@ -0,0 +1,366 @@
#define _GNU_SOURCE
#include "server.h"
#include "enforcement.h"
#include "selector.h"
#include "cjson/cJSON.h"
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
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 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;
}
return 0;
}
static void json_copy_string(char *dst, size_t dst_sz, const char *src, const char *fallback) {
const char *s = (src != NULL) ? src : fallback;
if (dst == NULL || dst_sz == 0) {
return;
}
if (s == NULL) {
dst[0] = '\0';
return;
}
strncpy(dst, s, dst_sz - 1);
dst[dst_sz - 1] = '\0';
}
static int extract_method_and_selector(const char *json,
char *method,
size_t method_sz,
selector_request_t *selector_req) {
cJSON *root;
cJSON *method_item;
cJSON *params_item;
cJSON *options_item;
cJSON *tmp;
if (json == NULL || method == NULL || selector_req == NULL) {
return -1;
}
method[0] = '\0';
selector_request_init(selector_req);
root = cJSON_Parse(json);
if (root == NULL) {
return -1;
}
method_item = cJSON_GetObjectItemCaseSensitive(root, "method");
if (!cJSON_IsString(method_item) || method_item->valuestring == NULL) {
cJSON_Delete(root);
return -1;
}
json_copy_string(method, method_sz, method_item->valuestring, "unknown");
params_item = cJSON_GetObjectItemCaseSensitive(root, "params");
if (cJSON_IsArray(params_item)) {
options_item = cJSON_GetArrayItem(params_item, 1);
if (cJSON_IsObject(options_item)) {
tmp = cJSON_GetObjectItemCaseSensitive(options_item, "role");
if (cJSON_IsString(tmp) && tmp->valuestring != NULL) {
selector_req->has_role = 1;
json_copy_string(selector_req->role_name, sizeof(selector_req->role_name), tmp->valuestring, "");
}
tmp = cJSON_GetObjectItemCaseSensitive(options_item, "nostr_index");
if (cJSON_IsNumber(tmp)) {
selector_req->has_nostr_index = 1;
selector_req->nostr_index = tmp->valueint;
}
tmp = cJSON_GetObjectItemCaseSensitive(options_item, "role_path");
if (cJSON_IsString(tmp) && tmp->valuestring != NULL) {
selector_req->has_role_path = 1;
json_copy_string(selector_req->role_path, sizeof(selector_req->role_path), tmp->valuestring, "");
}
}
}
cJSON_Delete(root);
return 0;
}
void server_init(server_ctx_t *ctx, const char *socket_name,
dispatcher_ctx_t *dispatcher, policy_table_t *policy) {
if (ctx == NULL) {
return;
}
memset(ctx, 0, sizeof(*ctx));
if (socket_name != NULL) {
strncpy(ctx->socket_name, socket_name, sizeof(ctx->socket_name) - 1);
ctx->socket_name[sizeof(ctx->socket_name) - 1] = '\0';
}
ctx->listen_fd = -1;
ctx->dispatcher = dispatcher;
ctx->policy = policy;
}
int server_start(server_ctx_t *ctx) {
int fd;
struct sockaddr_un addr;
socklen_t addr_len;
int flags;
if (ctx == NULL || ctx->dispatcher == NULL || ctx->policy == NULL || ctx->socket_name[0] == '\0') {
return -1;
}
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], ctx->socket_name, sizeof(addr.sun_path) - 2);
addr.sun_path[sizeof(addr.sun_path) - 1] = '\0';
addr_len = (socklen_t)(sizeof(sa_family_t) + 1 + strlen(ctx->socket_name));
if (bind(fd, (struct sockaddr *)&addr, addr_len) != 0) {
close(fd);
return -1;
}
if (listen(fd, 5) != 0) {
close(fd);
return -1;
}
flags = fcntl(fd, F_GETFL, 0);
if (flags < 0 || fcntl(fd, F_SETFL, flags | O_NONBLOCK) != 0) {
close(fd);
return -1;
}
ctx->listen_fd = fd;
ctx->running = 1;
return 0;
}
int server_get_caller(int fd, caller_identity_t *out) {
struct ucred cred;
socklen_t len = sizeof(cred);
if (out == NULL) {
return -1;
}
memset(out, 0, sizeof(*out));
if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &cred, &len) != 0) {
return -1;
}
out->uid = cred.uid;
out->gid = cred.gid;
out->pid = cred.pid;
(void)snprintf(out->caller_id, sizeof(out->caller_id), "uid:%u", (unsigned int)out->uid);
return 0;
}
int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
int client_fd;
caller_identity_t caller;
char *request = NULL;
char *response = NULL;
char method[64];
char role_name[ROLE_NAME_MAX];
char purpose[ROLE_PURPOSE_MAX];
selector_request_t selector_req;
role_entry_t *role = NULL;
int pchk;
char activity[256];
const char *verdict = "DENIED";
if (ctx == NULL || ctx->listen_fd < 0 || ctx->dispatcher == NULL || ctx->policy == NULL) {
return -1;
}
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);
return -1;
}
if (recv_framed(client_fd, &request) != 0) {
response = strdup("{\"id\":\"null\",\"error\":{\"code\":-32700,\"message\":\"parse_error\"}}");
if (response != NULL) {
(void)send_framed(client_fd, response);
free(response);
}
close(client_fd);
return 1;
}
json_copy_string(method, sizeof(method), "unknown", "unknown");
json_copy_string(role_name, sizeof(role_name), "unknown", "unknown");
json_copy_string(purpose, sizeof(purpose), "unknown", "unknown");
if (extract_method_and_selector(request, method, sizeof(method), &selector_req) == 0) {
if (ctx->dispatcher->role_table != NULL &&
selector_resolve(&selector_req, ctx->dispatcher->role_table, &role) == SELECTOR_OK &&
role != NULL) {
json_copy_string(role_name, sizeof(role_name), role->name, "main");
json_copy_string(purpose, sizeof(purpose), role_purpose_to_str(role->purpose), "nostr");
}
}
pchk = policy_check(ctx->policy, caller.caller_id, method, role_name, purpose);
if (pchk == POLICY_ALLOW) {
verdict = "ALLOWED";
response = dispatcher_handle_request(ctx->dispatcher, request);
if (response == NULL) {
response = strdup("{\"id\":\"null\",\"error\":{\"code\":-32603,\"message\":\"internal_error\"}}");
}
} else {
response = strdup("{\"id\":\"null\",\"error\":{\"code\":2001,\"message\":\"policy_denied\"}}");
}
if (response != NULL) {
(void)send_framed(client_fd, response);
}
(void)snprintf(activity,
sizeof(activity),
"uid=%u pid=%d %s(%s) %s",
(unsigned int)caller.uid,
(int)caller.pid,
method,
role_name,
verdict);
if (cb != NULL) {
cb(activity, cb_data);
}
free(request);
free(response);
close(client_fd);
return 1;
}
void server_stop(server_ctx_t *ctx) {
if (ctx == NULL) {
return;
}
if (ctx->listen_fd >= 0) {
close(ctx->listen_fd);
ctx->listen_fd = -1;
}
ctx->running = 0;
}

46
src/server.h Normal file
View File

@@ -0,0 +1,46 @@
#ifndef NSIGNER_SERVER_H
#define NSIGNER_SERVER_H
#include "dispatcher.h"
#include "policy.h"
#include <sys/types.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) */
int listen_fd;
int running;
dispatcher_ctx_t *dispatcher;
policy_table_t *policy;
} server_ctx_t;
/* Initialize server context. socket_name is the abstract namespace name (e.g. "nsigner") */
void server_init(server_ctx_t *ctx, const char *socket_name,
dispatcher_ctx_t *dispatcher, policy_table_t *policy);
/* Start listening. Returns 0 on success, -1 on error. */
int server_start(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);
#endif /* NSIGNER_SERVER_H */

158
tests/test_dispatcher.c Normal file
View File

@@ -0,0 +1,158 @@
#include "../src/dispatcher.h"
#include "../src/enforcement.h"
#include "../src/role_table.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;
const char *valid_12 = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
char *resp;
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);
dispatcher_init(&dispatcher, &table, &mnemonic);
/* 1. Valid get_public_key no selector -> default main, not_yet_derived */
resp = dispatcher_handle_request(&dispatcher,
"{\"id\":\"1\",\"method\":\"get_public_key\",\"params\":[\"\"]}");
check_condition("get_public_key default role returns not_yet_derived",
response_has(resp, "\"id\":\"1\"") && response_has(resp, "\"result\":\"not_yet_derived\""));
free(resp);
/* 2. sign_event with role main */
resp = dispatcher_handle_request(&dispatcher,
"{\"id\":\"2\",\"method\":\"sign_event\",\"params\":[\"{}\",{\"role\":\"main\"}]}");
check_condition("sign_event with role=main returns stub",
response_has(resp, "\"id\":\"2\"") && response_has(resp, "\"result\":\"stub:sign_event_ok\""));
free(resp);
/* 3. sign_event with nostr_index 0 */
resp = dispatcher_handle_request(&dispatcher,
"{\"id\":\"3\",\"method\":\"sign_event\",\"params\":[\"{}\",{\"nostr_index\":0}]}");
check_condition("sign_event with nostr_index=0 returns stub",
response_has(resp, "\"id\":\"3\"") && response_has(resp, "\"result\":\"stub:sign_event_ok\""));
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. role not found */
resp = dispatcher_handle_request(&dispatcher,
"{\"id\":\"5\",\"method\":\"sign_event\",\"params\":[\"{}\",{\"role\":\"nonexistent\"}]}");
check_condition("role not found returns 1002",
response_has(resp, "\"id\":\"5\"") && response_has(resp, "\"code\":1002"));
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);
printf("%d/10 tests passed\n", g_passes);
return (g_passes == 10 && g_total == 10) ? 0 : 1;
}

92
tests/test_enforcement.c Normal file
View File

@@ -0,0 +1,92 @@
#include "../src/enforcement.h"
#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;
}

256
tests/test_integration.c Normal file
View File

@@ -0,0 +1,256 @@
#define _GNU_SOURCE
#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 "nsigner"
#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(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;
const char *mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about\n\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);
}
dup2(stdin_pipe[0], STDIN_FILENO);
close(stdin_pipe[0]);
close(stdin_pipe[1]);
execl("./build/nsigner", "./build/nsigner", (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]);
sleep_ms(800);
if (request_roundtrip("{\"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("{\"id\":\"2\",\"method\":\"sign_event\",\"params\":[\"{}\",{\"role\":\"main\"}]}", &resp) == 0) {
check_condition("sign_event response id", strstr(resp, "\"id\":\"2\"") != NULL);
check_condition("sign_event stub result", strstr(resp, "stub:sign_event_ok") != NULL);
} else {
check_condition("sign_event request roundtrip", 0);
}
free(resp);
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);
}
if (g_failures == 0) {
printf("ALL TESTS PASSED\n");
return 0;
}
printf("TESTS FAILED: %d\n", g_failures);
return 1;
}

83
tests/test_mnemonic.c Normal file
View File

@@ -0,0 +1,83 @@
#include "../src/mnemonic.h"
#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;
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);
if (g_failures == 0) {
printf("ALL TESTS PASSED\n");
return 0;
}
printf("TESTS FAILED: %d\n", g_failures);
return 1;
}

127
tests/test_policy.c Normal file
View File

@@ -0,0 +1,127 @@
#include "../src/policy.h"
#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;
}

135
tests/test_role_table.c Normal file
View File

@@ -0,0 +1,135 @@
#include "../src/role_table.h"
#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;
}

170
tests/test_selector.c Normal file
View File

@@ -0,0 +1,170 @@
#include "../src/selector.h"
#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;
}