Compare commits

...

8 Commits

18 changed files with 1693 additions and 290 deletions

View File

@@ -46,7 +46,10 @@ RUN cd /tmp && \
# Copy and build nostr_core_lib from project resources
COPY resources/nostr_core_lib /build/nostr_core_lib/
RUN cd /build/nostr_core_lib && \
RUN if [ "$(uname -m)" = "aarch64" ] && ! command -v aarch64-linux-gnu-gcc >/dev/null 2>&1; then \
ln -s "$(command -v gcc)" /usr/local/bin/aarch64-linux-gnu-gcc; \
fi && \
cd /build/nostr_core_lib && \
chmod +x ./build.sh && \
./build.sh --nips=1,4,6,19,44
@@ -54,25 +57,33 @@ RUN cd /build/nostr_core_lib && \
COPY src/ /build/src/
# Build nsigner as a fully static binary
RUN gcc -static -Os -ffunction-sections -fdata-sections -Wl,--gc-sections -s -Wall -Wextra -std=c99 \
-I/build/nostr_core_lib \
-I/build/nostr_core_lib/nostr_core \
-I/build/nostr_core_lib/cjson \
/build/src/main.c \
/build/src/secure_mem.c \
/build/src/mnemonic.c \
/build/src/role_table.c \
/build/src/selector.c \
/build/src/enforcement.c \
/build/src/dispatcher.c \
/build/src/policy.c \
/build/src/server.c \
/build/src/key_store.c \
/build/src/socket_name.c \
/build/nostr_core_lib/libnostr_core_x64.a \
-o /build/nsigner_static \
$(pkg-config --static --libs libcurl openssl) \
-lsecp256k1 -lsqlite3 -lz -lpthread -lm
RUN ARCH="$(uname -m)"; \
case "$ARCH" in \
aarch64|arm64) NOSTR_LIB="/build/nostr_core_lib/libnostr_core_arm64.a" ;; \
x86_64|amd64) NOSTR_LIB="/build/nostr_core_lib/libnostr_core_x64.a" ;; \
*) echo "Unsupported build arch: $ARCH"; exit 1 ;; \
esac; \
[ -f "$NOSTR_LIB" ] || { echo "Missing nostr core lib: $NOSTR_LIB"; ls -la /build/nostr_core_lib; exit 1; }; \
gcc -static -Os -ffunction-sections -fdata-sections -Wl,--gc-sections -s -Wall -Wextra -std=c99 \
-I/build/nostr_core_lib \
-I/build/nostr_core_lib/nostr_core \
-I/build/nostr_core_lib/cjson \
/build/src/main.c \
/build/src/secure_mem.c \
/build/src/mnemonic.c \
/build/src/role_table.c \
/build/src/selector.c \
/build/src/enforcement.c \
/build/src/dispatcher.c \
/build/src/policy.c \
/build/src/server.c \
/build/src/transport_frame.c \
/build/src/key_store.c \
/build/src/socket_name.c \
"$NOSTR_LIB" \
-o /build/nsigner_static \
$(pkg-config --static --libs libcurl openssl) \
-lsecp256k1 -lsqlite3 -lz -lpthread -lm
RUN strip /build/nsigner_static || true
RUN file /build/nsigner_static && \

View File

@@ -18,6 +18,7 @@ SOURCES := \
$(SRC_DIR)/dispatcher.c \
$(SRC_DIR)/policy.c \
$(SRC_DIR)/server.c \
$(SRC_DIR)/transport_frame.c \
$(SRC_DIR)/key_store.c \
$(SRC_DIR)/socket_name.c

View File

@@ -237,6 +237,9 @@ Naming rules:
Discovery:
- `nsigner list` enumerates currently bound `nsigner_*` abstract sockets by reading `/proc/net/unix`.
- `nsigner --listen stdio` runs one framed JSON-RPC request/response over stdin/stdout.
- `nsigner --listen qrexec` is the same stdio framing mode, but caller identity can be derived from `QREXEC_REMOTE_DOMAIN` (displayed as `qubes:<source-vm>`).
- `nsigner --listen tcp:IPv4:PORT` or `tcp:[IPv6]:PORT` enables TCP listening for non-AF_UNIX clients (for example `tcp:127.0.0.1:8080`, `tcp:[::]:8080`, or `tcp:[fd00::1234]:8080`).
### 7.2 ESP32 MCU: USB-CDC serial
@@ -288,6 +291,24 @@ To force a specific socket name (e.g. for scripted clients):
nsigner --name my_test_signer
```
Qubes/qrexec service mode (single framed request over stdin/stdout):
```bash
nsigner --listen qrexec
```
Generic stdio transport mode (single framed request over stdin/stdout):
```bash
nsigner --listen stdio
```
TCP transport mode (no TUI; serves requests until terminated):
```bash
nsigner --listen tcp:[::]:8080
```
### 9.2 Send a request (client mode)
From another terminal, target the signer by its socket name:
@@ -365,6 +386,9 @@ Static build:
## 11. Document map
- [`README.md`](README.md): authoritative behavior specification for the foreground single-program model
- [`documents/CLIENT_IMPLEMENTATION.md`](documents/CLIENT_IMPLEMENTATION.md): client integration contract and framing behavior
- [`documents/QUBES_OS.md`](documents/QUBES_OS.md): Qubes OS deployment/integration checklist for dedicated signer qubes
- [`documents/FIPS_DEPLOYMENT.md`](documents/FIPS_DEPLOYMENT.md): Tier-1 FIPS deployment runbook using loopback TCP listener
- [`plans/nsigner.md`](plans/nsigner.md): implementation plan and sequencing
- [`plans/seed_phrase_uses.md`](plans/seed_phrase_uses.md): seed phrase domain/use catalog and caveats
- [`firmware/README.md`](firmware/README.md): firmware-side notes for MCU transport/UI integration

View File

@@ -105,12 +105,29 @@ echo "Platform: $PLATFORM"
echo "Output: $BUILD_DIR/$OUTPUT_NAME"
echo ""
if [ "$ARCH" != "$HOST_ARCH" ]; then
echo "[0/3] Preparing buildx + QEMU for cross-architecture build"
if ! docker buildx inspect >/dev/null 2>&1; then
echo "ERROR: docker buildx is not available"
exit 1
fi
docker run --privileged --rm tonistiigi/binfmt --install all >/dev/null
if ! docker buildx inspect nsigner-builder >/dev/null 2>&1; then
docker buildx create --name nsigner-builder --driver docker-container --use >/dev/null
else
docker buildx use nsigner-builder >/dev/null
fi
docker buildx inspect --bootstrap >/dev/null
fi
echo "[1/3] Building builder stage from project root context"
docker build \
docker buildx build \
--platform "$PLATFORM" \
--target builder \
-f "$DOCKERFILE" \
-t "$IMAGE_TAG" \
--load \
"$SCRIPT_DIR"
echo "[2/3] Extracting static binary"

View File

@@ -10,7 +10,12 @@ It is written for agent/tool authors implementing robust request flows against t
## 2. Discovery and socket targeting
`nsigner` listens on Linux AF_UNIX **abstract namespace** sockets.
`nsigner` currently supports two transport families:
- Linux AF_UNIX **abstract namespace** sockets.
- Stdio framed mode (`--listen stdio` and `--listen qrexec`) for one request/response exchange.
For AF_UNIX:
- Socket names are exposed in `/proc/net/unix` with a leading `@`.
- Typical runtime names: `@nsigner_hairy_dog`, `@nsigner_brave_canyon`.
@@ -40,6 +45,15 @@ Expected output format (one per line):
Clients should accept both `@nsigner` and `@nsigner_*` names.
### 2.3 Stdio / qrexec mode
In server mode:
- `nsigner --listen stdio`: reads exactly one framed request from stdin and writes one framed response to stdout.
- `nsigner --listen qrexec`: same behavior, but caller identity may be tagged from `QREXEC_REMOTE_DOMAIN` as `qubes:<vm-name>`.
This mode is server-side only in the current CLI (the `client` subcommand still targets AF_UNIX).
---
## 3. Transport framing

View File

@@ -0,0 +1,177 @@
# FIPS_DEPLOYMENT.md
## 1. Scope
This runbook covers a practical Tier-1 deployment of `nsigner` over a TCP listener, with connectivity provided by FIPS as the network substrate.
Tier-1 objective:
- Keep `nsigner` transport simple (`--listen tcp:IPv4:PORT` or `--listen tcp:[IPv6]:PORT`).
- Use FIPS to carry traffic between peers.
- Do not add FIPS runtime dependencies into `nsigner`.
Out of scope in this document:
- Mandatory transport-level TLS/authentication hardening (still planned for a later phase).
- Automatic caller->npub enrichment from FIPS session metadata.
---
## 2. Architecture
Two cooperating layers:
1. **Signer process layer** (`nsigner`)
- Listens on operator-selected TCP endpoint (IPv4 or IPv6).
- Uses existing 4-byte big-endian framed JSON-RPC protocol.
- Keeps existing policy/prompt behavior.
2. **Network substrate layer** (FIPS)
- Establishes peer connectivity between nodes/qubes.
- Carries application traffic to the configured signer TCP endpoint.
Conceptually:
`client app -> local FIPS endpoint -> FIPS mesh -> remote FIPS endpoint -> signer TCP endpoint -> nsigner`
---
## 3. Prerequisites
On signer host/qube:
- Built `nsigner` binary.
- FIPS installed and running.
- Local firewall policy that keeps signer listener local-only.
On caller host/qube:
- FIPS installed and peered with signer host/qube.
- A client implementation that speaks `nsigner` framed JSON-RPC (see `documents/CLIENT_IMPLEMENTATION.md`).
Operational assumptions:
- Operator controls both endpoints.
- Manual verification of peer identity is performed in FIPS tooling before enabling signer traffic.
---
## 4. Start signer in Tier-1 TCP mode
Run `nsigner` in TCP listen mode:
```bash
./build/nsigner --listen tcp:[::]:8080
```
Or bind to a specific FIPS ULA address:
```bash
./build/nsigner --listen tcp:[fd00::1234]:8080
```
Behavior notes:
- Bind target is operator-controlled; pick the narrowest reachable address that satisfies your topology.
- No TUI hotkey loop is required in TCP mode; process serves requests until terminated.
- Caller identity is shown as a TCP endpoint descriptor in activity/prompt context.
---
## 5. FIPS substrate wiring pattern
Because FIPS deployment topologies vary, use this generic pattern:
1. Bind `nsigner` on a deliberate signer endpoint (`[::]:PORT` for broad reach, or specific `fd..` for tighter scope).
2. Configure FIPS service/forwarding so remote authenticated peer traffic is delivered to that signer endpoint.
3. On caller side, direct client traffic to the local FIPS ingress endpoint for that remote service.
Validation checklist:
- FIPS session is established between caller and signer nodes.
- Transport path from caller -> configured signer endpoint succeeds.
- `nsigner` receives framed request and returns framed response.
---
## 6. Minimal validation flow
### 6.1 Liveness check
From caller side, send a framed `get_public_key` request through the FIPS-backed endpoint.
Request JSON:
```json
{"id":"1","method":"get_public_key","params":[]}
```
Expected response:
```json
{"id":"1","result":"<hex_pubkey>"}
```
### 6.2 Signing check
Send `sign_event` with explicit role selector:
```json
{
"id": "2",
"method": "sign_event",
"params": ["<event_json>", {"role":"main"}]
}
```
Expected result: signed event JSON in `result`.
### 6.3 Negative check (policy)
Trigger a request path that requires prompt/denial and confirm client handles policy denial as a normal result path.
---
## 7. Security guardrails
- Prefer binding to a specific FIPS IPv6 ULA (`fd..`) rather than wildcard (`[::]`) when possible.
- Do not expose signer port directly on LAN/WAN.
- Keep FIPS peer allowlist tight; avoid broad trust domains.
- Treat FIPS connectivity as transport, not authorization bypass.
- Preserve interactive approval where required by policy.
---
## 8. Troubleshooting
### 8.1 `invalid tcp listen target`
Cause:
- `--listen` argument does not match `tcp:HOST:PORT` or `tcp:[IPv6]:PORT`.
Fix:
- Use valid numeric port and valid IPv4/IPv6 literal host.
### 8.2 Framing parse failures (`parse_error`)
Cause:
- Client sent line-delimited/raw JSON instead of framed JSON.
Fix:
- Send 4-byte big-endian length prefix followed by exact UTF-8 JSON payload bytes.
### 8.3 FIPS path up, signer path down
Cause:
- FIPS session exists but forwarding/service mapping to signer loopback endpoint is missing.
Fix:
- Verify substrate service routing config and local endpoint mapping.
---
## 9. Next hardening steps (post Tier-1)
- Add automated two-node validation script for operator smoke checks.
- Add optional identity enrichment from FIPS session metadata (`peer_npub`).
- Introduce remote TCP mode only with mandatory TLS + authenticated caller key flow.

179
documents/QUBES_OS.md Normal file
View File

@@ -0,0 +1,179 @@
# QUBES_OS.md
## 1. Goal
Run `n_signer` inside a dedicated Qubes OS qube (for example `vault`-like behavior), and let caller qubes access signing via qrexec with explicit policy control.
This doc outlines what must be implemented/packaged for a reliable Qubes deployment path.
---
## 2. Current status (where we are now)
Implemented in current codebase:
- `nsigner` supports `--listen qrexec` and `--listen stdio`.
- Framing is transport-agnostic and shared via length-prefixed JSON (`4-byte big-endian length + payload`).
- In qrexec/stdio mode, server handles one framed request-response exchange.
- Caller identity extraction supports `QREXEC_REMOTE_DOMAIN`, surfaced as `qubes:<source-vm>` when available.
Still missing for complete Qubes integration:
- qrexec service file + wrapper script artifacts.
- dom0 qrexec policy artifacts with sane defaults.
- install/uninstall guidance and verification flow for real Qubes deployment.
- packaging path (`packaging/qubes/`) and docs wired into README map.
---
## 3. Architecture in Qubes
### 3.1 Components
- **Signer qube** (target): runs `nsigner` service entrypoint.
- **Caller qube(s)**: apps/tools invoking qrexec service.
- **dom0 policy**: controls which caller qubes may invoke signer service.
### 3.2 Request path
1. Caller qube invokes qrexec service (e.g. `qubes.NsignerRpc`).
2. qrexec starts service command inside signer qube.
3. Service command runs `nsigner --listen qrexec`.
4. Caller sends framed JSON-RPC request over qrexec stdio channel.
5. `nsigner` returns framed JSON-RPC response.
### 3.3 Trust and identity
- Source qube identity comes from `QREXEC_REMOTE_DOMAIN`.
- `n_signer` maps caller as `qubes:<source-vm>` where available.
- qrexec policy in dom0 remains first enforcement boundary.
- `n_signer` policy/approval remains second boundary.
---
## 4. Required implementation tasks
## 4.1 Service entrypoint artifacts ✅ Implemented
Implemented repo artifacts:
- `packaging/qubes/rpc/qubes.NsignerRpc`
- `packaging/qubes/install-service.sh`
`qubes.NsignerRpc` runs:
- `exec /usr/local/bin/nsigner --listen qrexec`
Install inside the signer qube:
```bash
sudo sh packaging/qubes/install-service.sh
```
This installs the qrexec service to `/etc/qubes-rpc/qubes.NsignerRpc` with executable permissions.
## 4.2 dom0 policy artifacts ✅ Implemented
Implemented repo artifacts:
- `packaging/qubes/policy.d/40-nsigner.policy`
- `packaging/qubes/install-policy.sh`
Policy defaults now use explicit `ask` plus deny catch-all:
- `qubes.NsignerRpc * @anyvm @tag:nsigner-signer ask default_target=nsigner-vault`
- `qubes.NsignerRpc * @anyvm @anyvm deny`
Install in dom0:
```bash
sudo sh packaging/qubes/install-policy.sh
```
This installs `/etc/qubes/policy.d/40-nsigner.policy` and prints signer-tag guidance.
## 4.3 Policy model inside n_signer for qubes callers ✅ Implemented
Current code reads caller as `qubes:<vm>` and qrexec default behavior is hardened.
In qrexec mode, default prompt behavior is now:
- `PROMPT_EVERY_REQUEST`
This replaces the previous permissive `PROMPT_NEVER` temporary setting.
## 4.4 Client helper examples ✅ Implemented
Added:
- `documents/qubes_client_examples.md`
Includes:
- shell helper example invoking `qrexec-client-vm` with framed request/response handling
- Python helper example implementing frame encode/decode over qrexec stdio channel
- reference to `documents/CLIENT_IMPLEMENTATION.md` for full protocol details
---
## 5. Operational runbook
## 5.1 Setup signer qube
- install `nsigner` binary at `/usr/local/bin/nsigner`
- run `sudo sh packaging/qubes/install-service.sh`
- verify `/etc/qubes-rpc/qubes.NsignerRpc` exists and is executable
## 5.2 Setup dom0 policy
- run `sudo sh packaging/qubes/install-policy.sh`
- tag signer qube (example): `qvm-tags nsigner-vault add nsigner-signer`
- reload qrexec policy per Qubes procedure/version
## 5.3 Verification
- from caller qube, invoke test request (`get_public_key`)
- confirm signer qube receives request
- confirm activity log displays `qubes:<source-vm>` caller prefix
- validate deny behavior from unauthorized qube
## 5.4 Failure checks
- malformed frame -> parse error response
- missing policy -> deny path
- missing `QREXEC_REMOTE_DOMAIN` -> fallback identity path
---
## 6. Security requirements
- Never run signer service in disposable qube if mnemonic persistence is expected.
- Prefer dedicated minimal template for signer qube.
- Keep qrexec policy narrowly scoped (explicit source + target).
- Require user approval for sensitive methods unless explicitly intended otherwise.
- Log caller identity and method (without secret payload logging).
---
## 7. Documentation tasks
Update these after packaging lands:
- `README.md`
- add Qubes deployment subsection under transport/usage
- add `documents/QUBES_OS.md` and moved `documents/CLIENT_IMPLEMENTATION.md` in document map
- `plans/nsigner.md`
- mark T1 done with packaging status clearly separated
---
## 8. Definition of done (Qubes)
Qubes integration is considered complete when:
1. qrexec service artifact exists and is installable.
2. dom0 policy artifact exists with secure default pattern.
3. End-to-end call from allowed caller qube succeeds.
4. Call from unauthorized qube is denied.
5. Caller displayed as `qubes:<vm>` in activity.
6. README + docs include full setup and troubleshooting.

View File

@@ -0,0 +1,93 @@
# qubes_client_examples.md
This document shows minimal caller-qube examples for invoking `nsigner --listen qrexec` through Qubes qrexec.
For complete protocol details (framing, JSON-RPC, error handling), see `documents/CLIENT_IMPLEMENTATION.md`.
---
## 1) Shell example (`qrexec-client-vm` + framed JSON)
This sends one `get_public_key` request and decodes one framed response.
```bash
#!/bin/sh
set -eu
TARGET_QUBE="nsigner-vault"
SERVICE="qubes.NsignerRpc"
REQ='{"id":"1","method":"get_public_key","params":[]}'
python3 - "$TARGET_QUBE" "$SERVICE" "$REQ" <<'PY'
import json
import struct
import subprocess
import sys
target, service, req_json = sys.argv[1], sys.argv[2], sys.argv[3]
frame = struct.pack(">I", len(req_json.encode("utf-8"))) + req_json.encode("utf-8")
p = subprocess.Popen(
["qrexec-client-vm", target, service],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
)
out, _ = p.communicate(frame)
if p.returncode != 0:
raise SystemExit(f"qrexec-client-vm failed: {p.returncode}")
if len(out) < 4:
raise SystemExit("short response (missing frame header)")
n = struct.unpack(">I", out[:4])[0]
payload = out[4:4+n]
if len(payload) != n:
raise SystemExit("short response payload")
print(json.dumps(json.loads(payload.decode("utf-8")), indent=2))
PY
```
---
## 2) Python example (explicit frame helpers over qrexec stdio)
```python
#!/usr/bin/env python3
import json
import struct
import subprocess
def frame_encode(obj: dict) -> bytes:
payload = json.dumps(obj, separators=(",", ":")).encode("utf-8")
return struct.pack(">I", len(payload)) + payload
def frame_decode(buf: bytes) -> dict:
if len(buf) < 4:
raise ValueError("missing frame header")
n = struct.unpack(">I", buf[:4])[0]
payload = buf[4:4 + n]
if len(payload) != n:
raise ValueError("short frame payload")
return json.loads(payload.decode("utf-8"))
def call_nsigner_qrexec(target_qube: str, request: dict) -> dict:
proc = subprocess.Popen(
["qrexec-client-vm", target_qube, "qubes.NsignerRpc"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
out, err = proc.communicate(frame_encode(request))
if proc.returncode != 0:
raise RuntimeError(f"qrexec failed ({proc.returncode}): {err.decode('utf-8', 'replace')}")
return frame_decode(out)
if __name__ == "__main__":
req = {"id": "1", "method": "get_public_key", "params": []}
resp = call_nsigner_qrexec("nsigner-vault", req)
print(json.dumps(resp, indent=2))
```

View File

@@ -244,13 +244,7 @@ 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
increment_version "$VERSION_INCREMENT_TYPE"
if git tag "$NEW_VERSION" > /dev/null 2>&1; then
print_success "Created tag: $NEW_VERSION"

223
install_qube_fips_nsigner.sh Executable file
View File

@@ -0,0 +1,223 @@
#!/usr/bin/env bash
set -euo pipefail
# User-only installer for Qubes AppVM persistence model.
# Nothing is written to /usr, /etc, or other root-owned paths.
#
# Installs into $HOME:
# - nsigner -> ~/.local/bin/nsigner
# - startup helper -> ~/start_nsigner.sh
#
# Usage:
# bash install_qube_fips_nsigner.sh
# bash install_qube_fips_nsigner.sh --help
#
# Optional env vars:
# NSIGNER_VERSION=vX.Y.Z # optional override; default is latest release tag
# NSIGNER_GITEA_TOKEN=<token> # if n_signer release assets are private
# NSIGNER_BINARY_URL=<direct url to nsigner_static_x86_64>
NSIGNER_VERSION="${NSIGNER_VERSION:-}"
PREFIX_BIN="${HOME}/.local/bin"
log() { printf "\033[1;34m[INFO]\033[0m %s\n" "$*"; }
warn() { printf "\033[1;33m[WARN]\033[0m %s\n" "$*"; }
err() { printf "\033[1;31m[ERR ]\033[0m %s\n" "$*"; }
show_help() {
cat <<EOF
Usage: bash install_qube_fips_nsigner.sh [options]
User-only install (Qubes AppVM friendly):
- n_signer ${NSIGNER_VERSION}
- signer startup helper script
Options:
-h, --help Show this help and exit
Optional env vars:
NSIGNER_VERSION=vX.Y.Z # optional override; default is latest release tag
NSIGNER_GITEA_TOKEN=<token> # required if n_signer release assets are private
NSIGNER_BINARY_URL=<direct url to nsigner_static_x86_64>
Install paths:
~/.local/bin/nsigner
~/start_nsigner.sh
EOF
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || {
err "Missing command: $1"
exit 1
}
}
install_runtime_deps() {
if command -v apt-get >/dev/null 2>&1; then
log "Installing runtime dependencies via apt"
sudo apt-get update
sudo apt-get install -y ca-certificates curl jq
elif command -v dnf >/dev/null 2>&1; then
log "Installing runtime dependencies via dnf"
sudo dnf install -y ca-certificates curl jq
else
err "Unsupported distro: need apt-get or dnf to install runtime dependencies"
exit 1
fi
}
prepare_dirs() {
mkdir -p "${PREFIX_BIN}"
}
resolve_nsigner_version() {
local headers=()
local latest_tag=""
if [[ -n "${NSIGNER_VERSION}" ]]; then
return 0
fi
if [[ -n "${NSIGNER_GITEA_TOKEN:-}" ]]; then
headers=(-H "Authorization: token ${NSIGNER_GITEA_TOKEN}")
fi
latest_tag="$(curl -fsSL "${headers[@]}" "https://git.laantungir.net/api/v1/repos/laantungir/n_signer/releases" \
| jq -r '.[0].tag_name // empty' || true)"
if [[ -z "${latest_tag}" ]]; then
err "Could not resolve latest n_signer release tag from API."
err "Set NSIGNER_VERSION explicitly (e.g. NSIGNER_VERSION=v0.0.11)."
exit 1
fi
NSIGNER_VERSION="${latest_tag}"
}
download_nsigner_asset_url() {
local headers=()
local api_tag_url="https://git.laantungir.net/api/v1/repos/laantungir/n_signer/releases/tags/${NSIGNER_VERSION}"
if [[ -n "${NSIGNER_GITEA_TOKEN:-}" ]]; then
headers=(-H "Authorization: token ${NSIGNER_GITEA_TOKEN}")
fi
curl -fsSL "${headers[@]}" "${api_tag_url}" \
| jq -r '.assets[]?.browser_download_url // empty' \
| grep -E 'nsigner_static_x86_64$' \
| head -n1 || true
}
install_nsigner() {
local release_page=""
resolve_nsigner_version
release_page="https://git.laantungir.net/laantungir/n_signer/releases/tag/${NSIGNER_VERSION}"
log "Installing n_signer ${NSIGNER_VERSION}"
log "Release page: ${release_page}"
local asset_url="${NSIGNER_BINARY_URL:-}"
if [[ -z "${asset_url}" ]]; then
asset_url="$(download_nsigner_asset_url)"
fi
if [[ -z "${asset_url}" ]]; then
err "Could not find downloadable n_signer x86_64 release binary for ${NSIGNER_VERSION}."
err "Provide NSIGNER_BINARY_URL or NSIGNER_GITEA_TOKEN so the release asset can be resolved."
exit 1
fi
log "Using n_signer binary URL: ${asset_url}"
if [[ -n "${NSIGNER_GITEA_TOKEN:-}" ]]; then
curl -fL -H "Authorization: token ${NSIGNER_GITEA_TOKEN}" -o "${PREFIX_BIN}/nsigner" "${asset_url}"
else
curl -fL -o "${PREFIX_BIN}/nsigner" "${asset_url}"
fi
chmod 0755 "${PREFIX_BIN}/nsigner"
log "Installed ${PREFIX_BIN}/nsigner from release binary"
}
write_signer_start_script() {
local script_path="${HOME}/start_nsigner.sh"
cat >"${script_path}" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
export PATH="$HOME/.local/bin:$PATH"
LISTEN_TARGET="${NSIGNER_LISTEN_TARGET:-tcp:[::]:8080}"
echo "=== n_signer startup ==="
echo "listen target: ${LISTEN_TARGET}"
# Optional: print current FIPS identity info if fipsctl is available.
if command -v fipsctl >/dev/null 2>&1; then
if fipsctl show status >/dev/null 2>&1; then
STATUS_JSON="$(fipsctl show status)"
elif sudo -n fipsctl show status >/dev/null 2>&1; then
STATUS_JSON="$(sudo -n fipsctl show status)"
else
STATUS_JSON=""
fi
if [[ -n "${STATUS_JSON}" ]]; then
FIPS_IPV6="$(printf '%s\n' "${STATUS_JSON}" | sed -n 's/.*"ipv6_addr": "\([^"]*\)".*/\1/p')"
FIPS_NPUB="$(printf '%s\n' "${STATUS_JSON}" | sed -n 's/.*"npub": "\([^"]*\)".*/\1/p')"
LISTEN_PORT="$(printf '%s\n' "${LISTEN_TARGET}" | sed -n 's/.*:\([0-9][0-9]*\)$/\1/p')"
[[ -n "${FIPS_IPV6}" ]] && echo "fips ipv6: ${FIPS_IPV6}"
[[ -n "${FIPS_NPUB}" ]] && echo "fips npub: ${FIPS_NPUB}"
if [[ -n "${FIPS_NPUB}" && -n "${LISTEN_PORT}" ]]; then
echo "fips address: http://${FIPS_NPUB}.fips:${LISTEN_PORT}"
fi
else
echo "fips status: unavailable (run as user in fips group or with sudo)"
fi
fi
echo
echo "Starting signer..."
echo "On first remote request, approve in prompt with [y] or [a]."
exec "$HOME/.local/bin/nsigner" --listen "${LISTEN_TARGET}"
EOF
chmod 0755 "${script_path}"
log "Wrote ${script_path}"
}
post_checks() {
export PATH="${PREFIX_BIN}:${PATH}"
log "Running post-install checks"
require_cmd nsigner
nsigner --version || true
log "User binaries installed in: ${PREFIX_BIN}"
log "If needed, add to shell PATH: export PATH=\"${PREFIX_BIN}:\$PATH\""
}
main() {
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
show_help
exit 0
fi
if [[ $# -gt 0 ]]; then
err "Unknown option: $1"
show_help
exit 1
fi
install_runtime_deps
prepare_dirs
install_nsigner
write_signer_start_script
post_checks
log "Completed user-only install of n_signer"
log "Start signer with: ~/start_nsigner.sh"
}
main "$@"

View File

@@ -0,0 +1,17 @@
#!/bin/sh
set -eu
POLICY_SRC="packaging/qubes/policy.d/40-nsigner.policy"
POLICY_DST="/etc/qubes/policy.d/40-nsigner.policy"
if [ ! -f "$POLICY_SRC" ]; then
echo "Missing policy source: $POLICY_SRC" >&2
exit 1
fi
install -m 0644 "$POLICY_SRC" "$POLICY_DST"
echo "Installed qrexec policy to $POLICY_DST"
echo "Tag your signer qube in dom0, for example:"
echo " qvm-tags nsigner-vault add nsigner-signer"
echo "Then reload policy per your Qubes OS version procedures."

View File

@@ -0,0 +1,15 @@
#!/bin/sh
set -eu
SERVICE_SRC="packaging/qubes/rpc/qubes.NsignerRpc"
SERVICE_DST="/etc/qubes-rpc/qubes.NsignerRpc"
if [ ! -f "$SERVICE_SRC" ]; then
echo "Missing service source: $SERVICE_SRC" >&2
exit 1
fi
install -m 0755 "$SERVICE_SRC" "$SERVICE_DST"
echo "Installed qrexec service to $SERVICE_DST"
echo "Executable bit set via install -m 0755."

View File

@@ -0,0 +1,5 @@
# Qubes OS qrexec policy for nsigner
# Syntax: service +argument source target action
# Allow specific qubes to reach the signer qube with user confirmation
qubes.NsignerRpc * @anyvm @tag:nsigner-signer ask default_target=nsigner-vault
qubes.NsignerRpc * @anyvm @anyvm deny

View File

@@ -0,0 +1,2 @@
#!/bin/sh
exec /usr/local/bin/nsigner --listen qrexec

View File

@@ -174,3 +174,119 @@ Privacy/UX notes:
- Expand integration coverage for NIP-04/NIP-44 edge cases and negative-path errors.
- Document and test static artifact size budgets across targets.
- Define MCU transport adapter contract to prepare desktop/firmware parity.
## 7. Transport expansion roadmap
Goal: keep one signer core, swap transports underneath without touching dispatcher, policy, or role layers. The wire contract in [`CLIENT_IMPLEMENTATION.md`](../CLIENT_IMPLEMENTATION.md) (4-byte length-prefixed JSON-RPC) stays identical across every transport; only listener and `caller_identity_t` change.
### 7.0 Prerequisite — transport abstraction (Phase T0)
Before adding any new transport, factor a small adapter contract out of [`src/server.c`](../src/server.c) and [`src/main.c`](../src/main.c).
- New header `src/transport.h` declaring an opaque `nsigner_transport_t` with:
- `accept(listener) -> connection`
- `recv_frame(connection) -> bytes`
- `send_frame(connection, bytes)`
- `peer_identity(connection) -> caller_identity_t`
- `close(connection)` / `shutdown(listener)`
- Generalize `caller_identity_t` to a tagged union of:
- `unix_peer { uid, pid, comm }` (current behavior)
- `qubes { source_qube_name }`
- `tcp_local { addr }`
- `tcp_remote { addr, authenticated_pubkey }`
- `fips { peer_npub }`
- `usb_serial { device_path, asserted_caller }`
- Move `recv_framed` / `send_framed` from `server.c` and `main.c` into a single shared `transport_frame.c` so client and server share one framing implementation.
- Server main loop becomes transport-agnostic (`while accept; recv; dispatch; send`).
- Tests: extend [`tests/test_integration.c`](../tests/test_integration.c) with a transport-loopback fake to validate the abstraction without binding any real socket.
This refactor is purely internal — no observable change.
### 7.1 Phase T1 — Qubes OS qrexec transport
Use Qubes' native inter-qube primitive instead of inventing one.
- Add a qrexec service script (e.g. `qubes.NsignerRpc`) that execs `nsigner` in a "stdio transport" mode where stdin/stdout carry the existing length-prefixed frame protocol.
- New CLI: `nsigner --listen stdio` (and `nsigner --listen qrexec`, behaving identically; `qrexec` value is for documentation/intent).
- Caller identity comes from qrexec environment (`QREXEC_REMOTE_DOMAIN`) and is mapped to `caller_identity_t.kind=qubes`.
- Reference policy file under `packaging/qubes/policy.d/40-nsigner.policy` showing `ask` / `allow` per source qube.
- No new attack surface inside nsigner: dom0 enforces who can even invoke the service.
- Tests: a unit test that injects fake qrexec env vars and a stdio framing harness; an integration script that documents end-to-end install in a Qubes VM (manual, not in CI).
- Docs: add a "Qubes deployment" section to [`README.md`](../README.md) and to [`CLIENT_IMPLEMENTATION.md`](../CLIENT_IMPLEMENTATION.md).
### 7.2 Phase T2 — TCP transport
Smallest IP-based step; on-ramp for non-Linux clients and for FIPS later.
- New CLI: `nsigner --listen tcp:HOST:PORT` (IPv4 literal) or `nsigner --listen tcp:[IPv6]:PORT`.
- Current behavior: accepts operator-selected local/remote bind addresses (including `[::]` and `fd..`), pending later transport hardening.
- Caller identity for TCP: endpoint address/port in caller descriptor. Approval prompt still mandatory.
- `nsigner list` extended to enumerate active TCP listeners (from internal registry; not from `/proc/net/tcp`).
- Same framing as AF_UNIX path; no protocol changes.
- Tests: integration coverage that spawns a child signer with `--listen tcp:127.0.0.1:0` (and one IPv6 case), captures the bound port, runs the same NIP-04/NIP-44/sign_event matrix as AF_UNIX.
- Docs: extend [`documents/CLIENT_IMPLEMENTATION.md`](../documents/CLIENT_IMPLEMENTATION.md) section 2 with `tcp:` discovery rules and section 3 confirming framing parity.
Implementation checklist (Tier-1 delivery):
- [x] Parse `--listen tcp:HOST:PORT` in [`src/main.c`](../src/main.c).
- [x] Parse and validate literal IPv4/IPv6 listen targets in [`src/server.c`](../src/server.c).
- [x] Bind/listen non-blocking TCP sockets and run server loop without TUI dependence.
- [x] Keep existing framed JSON-RPC protocol unchanged via shared [`src/transport_frame.c`](../src/transport_frame.c).
- [ ] Add integration test coverage for `tcp:127.0.0.1:PORT` request flow.
### 7.3 Phase T3 — TCP remote with TLS + caller-pubkey auth
Only after T2 is solid.
- New CLI: `nsigner --listen tcp:0.0.0.0:PORT --allow-remote --tls-cert <pem> --tls-key <pem>`.
- Mandatory: TLS for any non-loopback bind. Refuse to start otherwise.
- Caller authentication: client must sign a per-connection challenge with its declared npub (Schnorr/secp256k1) before any signer verb is dispatched. Identity becomes `tcp_remote { addr, authenticated_pubkey }`.
- Failure modes: `transport_tls_required`, `caller_auth_failed`, `caller_auth_timeout` — all surfaced with new error names in dispatcher and documented in [`CLIENT_IMPLEMENTATION.md`](../CLIENT_IMPLEMENTATION.md).
- Approval prompt now displays `caller=npub:abcd…wxyz` instead of `uid:1000`.
- Tests: integration test that exercises happy path, wrong-pubkey, replayed-challenge, expired-challenge.
- Docs: dedicated "Remote TCP deployment" section in `README.md` with strong "do not expose to the public internet without firewalling" warning.
### 7.4 Phase T4 — FIPS substrate integration
FIPS is a *substrate* for an existing TCP listener, not a new transport in nsigner code.
- Deployment topology: nsigner binds a chosen TCP endpoint inside the FIPS network namespace (or on a host where `fips0` is up); peers reach it via `fd00::/8` IPv6 derived from the signer's npub.
- Optional `caller_kind=fips` enrichment: a small sidecar query (`fipsctl show sessions` style) maps the connecting IPv6 address to a peer npub and feeds it into `caller_identity_t.fips { peer_npub }`. If unavailable, fall back to `tcp_remote` identity.
- nsigner does not embed FIPS, does not depend on libfips, and does not require Rust.
- New optional flag: `--peer-id-source fips:/var/run/fips/fips.sock` (path/method TBD per FIPS API).
- Tests: a Docker-compose fixture borrowed from `resources/fips/testing/` that boots two FIPS nodes, runs nsigner on one, runs a Python client (per snippet in [`documents/CLIENT_IMPLEMENTATION.md`](../documents/CLIENT_IMPLEMENTATION.md)) on the other, and exercises the same verb matrix.
- Docs: new [`documents/FIPS_DEPLOYMENT.md`](../documents/FIPS_DEPLOYMENT.md) deep-dive describing identity mapping, npub-as-caller, and operator setup. Cross-link from [`README.md`](../README.md) section 7 (Transport).
Execution tasks for initial FIPS trial:
- [x] Deliver T2 TCP listener as FIPS substrate prerequisite.
- [x] Document signer/caller qube deployment flow in [`documents/FIPS_DEPLOYMENT.md`](../documents/FIPS_DEPLOYMENT.md).
- [ ] Add two-node operator validation script (manual) using `fipsctl` + framed JSON-RPC client.
- [ ] Evaluate optional caller identity enrichment from FIPS session metadata.
### 7.5 Phase T5 — USB / serial transport
Two distinct sub-tracks; do not conflate.
- T5a (firmware-side, MCU): ESP32/USB-CDC. Already in the [`firmware/`](../firmware/) track. Same dispatcher; transport adapter is UART read/write loop. `caller_identity_t.kind=usb_serial` with `asserted_caller` because the host claims the identity.
- T5b (host-side optional): `nsigner --listen serial:/dev/ttyACM0,baud=115200`. Useful for desktop signer reachable by a USB-tethered client. Same frame protocol over the serial line. Marks identity as asserted (low trust) and forces approval prompt.
- USB-as-Ethernet (gadget mode, RNDIS/ECM) is **not** a separate transport — it reduces to T2/T3.
- Tests: loopback pty pair (`openpty`) for T5b unit/integration coverage; firmware-side covered in firmware track.
### 7.6 Cross-cutting concerns
Apply once per phase as needed:
- Transport-aware approval prompt: clear visual indication of transport kind and identity (uid vs qube vs npub vs serial-asserted). No silent identity-source confusion.
- Per-transport policy gates: deny-by-default for new identity kinds until operator explicitly enables them in policy.
- Discovery (`nsigner list`) becomes per-transport pluggable (proc/net/unix today, internal registry for tcp, qrexec service announce for qubes, fips peer table for fips).
- Audit logging: include transport kind and identity descriptor in every approval/decision record.
- Error name parity: every new transport introduces only well-named errors (extend the table in [`CLIENT_IMPLEMENTATION.md`](../CLIENT_IMPLEMENTATION.md) section 5).
### 7.7 Decision points (open)
- D1: Land T0 (refactor) before any transport, or in parallel with T1?
- D2: Bundle T2 and T3 as one phase, or hard split (loopback-only first, then remote-with-TLS later)?
- D3: T4 FIPS — embed an explicit `caller_kind=fips` path in nsigner now, or treat FIPS as plain TCP and revisit identity enrichment after a working deployment?
- D4: T5b host-side serial — in scope for desktop nsigner, or strictly firmware track?
- D5: Qubes packaging — ship `packaging/qubes/` artifacts in this repo, or document only and let operators wire it up?

View File

@@ -373,12 +373,19 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
#define SERVER_SOCKET_NAME_MAX 108
#define SERVER_MAX_MSG_SIZE 65536
#define NSIGNER_LISTEN_UNIX 0
#define NSIGNER_LISTEN_STDIO 1
#define NSIGNER_LISTEN_QREXEC 2
#define NSIGNER_LISTEN_TCP 3
/* Caller identity */
typedef struct {
uid_t uid;
gid_t gid;
pid_t pid;
char caller_id[64]; /* "uid:<n>" */
int kind;
char caller_id[64]; /* "uid:<n>" or "qubes:<vm>" */
char source_qube[64];
} caller_identity_t;
/* Server context */
@@ -387,6 +394,8 @@ typedef struct {
char last_error[256];
int listen_fd;
int running;
int listen_mode;
int stdio_handled;
dispatcher_ctx_t *dispatcher;
policy_table_t *policy;
int socket_name_explicit;
@@ -395,6 +404,7 @@ typedef struct {
/* Initialize server context. socket_name is the abstract namespace name (e.g. "nsigner").
* socket_name_explicit should be non-zero when provided via --socket-name override. */
void server_init(server_ctx_t *ctx, const char *socket_name, int socket_name_explicit,
int listen_mode,
dispatcher_ctx_t *dispatcher, policy_table_t *policy);
/* Start listening. Returns 0 on success, -1 on error. */
@@ -441,12 +451,15 @@ int socket_name_random(char *out, size_t out_len);
/* Version information (auto-updated by build/version tooling) */
#define NSIGNER_VERSION_MAJOR 0
#define NSIGNER_VERSION_MINOR 0
#define NSIGNER_VERSION_PATCH 5
#define NSIGNER_VERSION "v0.0.5"
#define NSIGNER_VERSION_PATCH 12
#define NSIGNER_VERSION "v0.0.12"
/* NSIGNER_HEADERLESS_DECLS_END */
int transport_send_framed(int fd, const char *payload);
int transport_recv_framed(int fd, char **out_payload, size_t max_size);
#include <nostr_core/nostr_common.h>
#include <arpa/inet.h>
@@ -462,6 +475,7 @@ int socket_name_random(char *out, size_t out_len);
#include <sys/types.h>
#include <sys/un.h>
#include <termios.h>
#include <time.h>
#include <unistd.h>
#define NSIGNER_DEFAULT_SOCKET_NAME "nsigner"
@@ -501,100 +515,6 @@ static int read_line_stdin(char *buf, size_t buf_sz) {
return 0;
}
static int read_full(int fd, void *buf, size_t len) {
unsigned char *p = (unsigned char *)buf;
size_t off = 0;
while (off < len) {
ssize_t n = read(fd, p + off, len - off);
if (n == 0) {
return -1;
}
if (n < 0) {
if (errno == EINTR) {
continue;
}
return -1;
}
off += (size_t)n;
}
return 0;
}
static int write_full(int fd, const void *buf, size_t len) {
const unsigned char *p = (const unsigned char *)buf;
size_t off = 0;
while (off < len) {
ssize_t n = write(fd, p + off, len - off);
if (n < 0) {
if (errno == EINTR) {
continue;
}
return -1;
}
off += (size_t)n;
}
return 0;
}
static int send_framed(int fd, const char *payload) {
uint32_t len;
uint32_t be_len;
if (payload == NULL) {
return -1;
}
len = (uint32_t)strlen(payload);
be_len = htonl(len);
if (write_full(fd, &be_len, sizeof(be_len)) != 0) {
return -1;
}
if (write_full(fd, payload, len) != 0) {
return -1;
}
return 0;
}
static int recv_framed(int fd, char **out_payload) {
uint32_t be_len;
uint32_t len;
char *payload;
if (out_payload == NULL) {
return -1;
}
*out_payload = NULL;
if (read_full(fd, &be_len, sizeof(be_len)) != 0) {
return -1;
}
len = ntohl(be_len);
if (len == 0 || len > SERVER_MAX_MSG_SIZE) {
return -1;
}
payload = (char *)malloc((size_t)len + 1U);
if (payload == NULL) {
return -1;
}
if (read_full(fd, payload, len) != 0) {
free(payload);
return -1;
}
payload[len] = '\0';
*out_payload = payload;
return 0;
}
static int connect_abstract_socket(const char *name) {
int fd;
@@ -628,7 +548,8 @@ static int connect_abstract_socket(const char *name) {
static void print_usage(const char *program_name) {
printf("nsigner - single-binary signer program\n");
printf("Usage:\n");
printf(" %s [--socket-name|--name|-n <name>] Run signer server + built-in TUI\n", program_name);
printf(" %s [--socket-name|--name|-n <name>] [--listen <unix|stdio|qrexec|tcp:HOST:PORT>]\n", program_name);
printf(" Run signer server (unix mode has TUI)\n");
printf(" %s [--socket-name|--name|-n <name>] client '<json>' Send JSON-RPC request\n", program_name);
printf(" %s [--socket-name|--name|-n <name>] client - Read JSON-RPC request from stdin\n", program_name);
printf(" %s list List running nsigner abstract sockets\n", program_name);
@@ -778,13 +699,13 @@ static int client_main(int argc, char *argv[], const char *socket_name, int sock
return 1;
}
if (send_framed(fd, request) != 0) {
if (transport_send_framed(fd, request) != 0) {
perror("send");
close(fd);
return 1;
}
if (recv_framed(fd, &response) != 0) {
if (transport_recv_framed(fd, &response, SERVER_MAX_MSG_SIZE) != 0) {
perror("recv");
close(fd);
return 1;
@@ -799,18 +720,54 @@ static int client_main(int argc, char *argv[], const char *socket_name, int sock
static void activity_log_cb(const char *message, void *user_data) {
int idx;
time_t now;
struct tm tm_now;
char ts[16];
(void)user_data;
if (message == NULL) {
return;
}
now = time(NULL);
if (localtime_r(&now, &tm_now) != NULL) {
(void)strftime(ts, sizeof(ts), "%m%d-%H%M%S", &tm_now);
} else {
strncpy(ts, "0000-000000", sizeof(ts) - 1);
ts[sizeof(ts) - 1] = '\0';
}
idx = g_activity_log.count % ACTIVITY_LOG_CAP;
strncpy(g_activity_log.lines[idx], message, sizeof(g_activity_log.lines[idx]) - 1);
g_activity_log.lines[idx][sizeof(g_activity_log.lines[idx]) - 1] = '\0';
(void)snprintf(g_activity_log.lines[idx],
sizeof(g_activity_log.lines[idx]),
"%s %s",
ts,
message);
g_activity_log.count++;
}
static void tcp_activity_stdout_cb(const char *message, void *user_data) {
time_t now;
struct tm tm_now;
char ts[32];
(void)user_data;
if (message == NULL) {
return;
}
now = time(NULL);
if (localtime_r(&now, &tm_now) != NULL) {
(void)strftime(ts, sizeof(ts), "%Y-%m-%d %H:%M:%S", &tm_now);
} else {
strncpy(ts, "0000-00-00 00:00:00", sizeof(ts) - 1);
ts[sizeof(ts) - 1] = '\0';
}
printf("[%s] %s\n", ts, message);
fflush(stdout);
}
static void render_status(const role_table_t *role_table,
const mnemonic_state_t *mnemonic,
int derived_count,
@@ -850,7 +807,7 @@ static void render_status(const role_table_t *role_table,
}
printf("\nHotkeys\n-------\n");
printf("q quit l lock/reunlock r refresh a toggle auto-approve(prompt): %s\n",
printf("q/x quit l lock/reunlock r refresh a toggle auto-approve(prompt): %s\n",
g_auto_approve ? "ON" : "OFF");
fflush(stdout);
}
@@ -878,87 +835,105 @@ static int setup_default_role(role_table_t *role_table) {
static int prompt_load_mnemonic(mnemonic_state_t *mnemonic) {
char phrase[MNEMONIC_MAX_LEN];
char phrase_copy[MNEMONIC_MAX_LEN];
char mode[16];
struct termios old_term;
struct termios new_term;
int have_term = 0;
char mode[MNEMONIC_MAX_LEN];
int invalid_attempts = 0;
const int max_invalid_attempts = 10;
if (mnemonic == NULL) {
return -1;
}
printf("Mnemonic source: [E]nter existing or [G]enerate new (default E): ");
fflush(stdout);
if (read_line_stdin(mode, sizeof(mode)) != 0) {
fprintf(stderr, "Failed to read mnemonic source choice\n");
return -1;
}
if (mode[0] == 'g' || mode[0] == 'G') {
int idx = 1;
char *ctx = NULL;
char *word;
if (mnemonic_generate(12, phrase, sizeof(phrase)) != 0) {
fprintf(stderr, "Failed to generate mnemonic\n");
while (invalid_attempts < max_invalid_attempts) {
printf("Mnemonic source: [E]nter existing or [G]enerate new (default E; you can also paste mnemonic here): ");
fflush(stdout);
if (read_line_stdin(mode, sizeof(mode)) != 0) {
fprintf(stderr, "Failed to read mnemonic source choice\n");
return -1;
}
strncpy(phrase_copy, phrase, sizeof(phrase_copy) - 1);
phrase_copy[sizeof(phrase_copy) - 1] = '\0';
printf("\nGenerated mnemonic (WRITE THIS DOWN - it will not be shown again):\n");
word = strtok_r(phrase_copy, " ", &ctx);
while (word != NULL) {
printf("%2d. %s\n", idx, word);
idx++;
word = strtok_r(NULL, " ", &ctx);
if ((mode[0] == 'q' || mode[0] == 'Q' || mode[0] == 'x' || mode[0] == 'X') && mode[1] == '\0') {
fprintf(stderr, "User requested exit.\n");
return -1;
}
if (mnemonic_load(mnemonic, phrase) != 0) {
if (strchr(mode, ' ') != NULL && mode[0] != 'g' && mode[0] != 'G') {
if (mnemonic_load(mnemonic, mode) == 0) {
printf("Seed phrase is valid and accepted.\n");
return 0;
}
invalid_attempts++;
fprintf(stderr,
"Invalid mnemonic (must be 12/15/18/21/24 words). Attempts: %d/%d\n",
invalid_attempts,
max_invalid_attempts);
continue;
}
if (mode[0] == 'g' || mode[0] == 'G') {
int idx = 1;
char *ctx = NULL;
char *word;
if (mnemonic_generate(12, phrase, sizeof(phrase)) != 0) {
fprintf(stderr, "Failed to generate mnemonic\n");
return -1;
}
strncpy(phrase_copy, phrase, sizeof(phrase_copy) - 1);
phrase_copy[sizeof(phrase_copy) - 1] = '\0';
printf("\nGenerated mnemonic (WRITE THIS DOWN - it will not be shown again):\n");
word = strtok_r(phrase_copy, " ", &ctx);
while (word != NULL) {
printf("%2d. %s\n", idx, word);
idx++;
word = strtok_r(NULL, " ", &ctx);
}
if (mnemonic_load(mnemonic, phrase) != 0) {
memset(phrase, 0, sizeof(phrase));
memset(phrase_copy, 0, sizeof(phrase_copy));
fprintf(stderr, "Failed to load generated mnemonic\n");
return -1;
}
printf("Seed phrase is valid and accepted.\n");
memset(phrase, 0, sizeof(phrase));
memset(phrase_copy, 0, sizeof(phrase_copy));
fprintf(stderr, "Failed to load generated mnemonic\n");
return 0;
}
printf("Enter mnemonic (12/15/18/21/24 words): ");
fflush(stdout);
if (read_line_stdin(phrase, sizeof(phrase)) != 0) {
fprintf(stderr, "Failed to read mnemonic\n");
return -1;
}
memset(phrase, 0, sizeof(phrase));
memset(phrase_copy, 0, sizeof(phrase_copy));
return 0;
}
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 ((phrase[0] == 'q' || phrase[0] == 'Q' || phrase[0] == 'x' || phrase[0] == 'X') && phrase[1] == '\0') {
memset(phrase, 0, sizeof(phrase));
fprintf(stderr, "User requested exit.\n");
return -1;
}
}
if (read_line_stdin(phrase, sizeof(phrase)) != 0) {
if (have_term) {
(void)tcsetattr(STDIN_FILENO, TCSANOW, &old_term);
if (mnemonic_load(mnemonic, phrase) == 0) {
printf("Seed phrase is valid and accepted.\n");
memset(phrase, 0, sizeof(phrase));
return 0;
}
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;
invalid_attempts++;
fprintf(stderr,
"Invalid mnemonic (must be 12/15/18/21/24 words). Attempts: %d/%d\n",
invalid_attempts,
max_invalid_attempts);
}
memset(phrase, 0, sizeof(phrase));
return 0;
fprintf(stderr, "Too many invalid mnemonic attempts (%d). Exiting.\n", max_invalid_attempts);
return -1;
}
static void apply_test_overrides(policy_table_t *policy) {
@@ -1017,6 +992,8 @@ int main(int argc, char *argv[]) {
const char *socket_name = NSIGNER_DEFAULT_SOCKET_NAME;
char generated_socket_name[SERVER_SOCKET_NAME_MAX];
int socket_name_explicit = 0;
int listen_mode = NSIGNER_LISTEN_UNIX;
const char *listen_target = NSIGNER_DEFAULT_SOCKET_NAME;
int argi = 1;
while (argi < argc) {
@@ -1032,14 +1009,43 @@ int main(int argc, char *argv[]) {
argi += 2;
continue;
}
if (strcmp(argv[argi], "--listen") == 0) {
if (argi + 1 >= argc) {
fprintf(stderr, "Missing value for %s\n", argv[argi]);
return 1;
}
if (strcmp(argv[argi + 1], "unix") == 0) {
listen_mode = NSIGNER_LISTEN_UNIX;
} else if (strcmp(argv[argi + 1], "stdio") == 0) {
listen_mode = NSIGNER_LISTEN_STDIO;
} else if (strcmp(argv[argi + 1], "qrexec") == 0) {
listen_mode = NSIGNER_LISTEN_QREXEC;
} else if (strncmp(argv[argi + 1], "tcp:", 4) == 0) {
listen_mode = NSIGNER_LISTEN_TCP;
listen_target = argv[argi + 1];
} else {
fprintf(stderr, "Invalid --listen mode: %s (expected unix|stdio|qrexec|tcp:HOST:PORT)\n", argv[argi + 1]);
return 1;
}
argi += 2;
continue;
}
break;
}
if (argi < argc && strcmp(argv[argi], "client") == 0) {
if (listen_mode != NSIGNER_LISTEN_UNIX) {
fprintf(stderr, "--listen is server-only; client mode uses unix abstract sockets\n");
return 1;
}
return client_main(argc - argi - 1, argv + argi + 1, socket_name, socket_name_explicit);
}
if (argi < argc && strcmp(argv[argi], "list") == 0) {
if (listen_mode != NSIGNER_LISTEN_UNIX) {
fprintf(stderr, "--listen is server-only; list inspects unix abstract sockets\n");
return 1;
}
return list_sockets_main();
}
@@ -1059,6 +1065,9 @@ int main(int argc, char *argv[]) {
return 1;
}
printf("nsigner %s\n", NSIGNER_VERSION);
fflush(stdout);
mnemonic_init(&mnemonic);
if (prompt_load_mnemonic(&mnemonic) != 0) {
mnemonic_unload(&mnemonic);
@@ -1091,11 +1100,20 @@ int main(int argc, char *argv[]) {
dispatcher_init(&dispatcher, &role_table, &mnemonic, &key_store);
owner_uid = getuid();
policy_init_default(&policy, owner_uid);
if (listen_mode == NSIGNER_LISTEN_QREXEC || listen_mode == NSIGNER_LISTEN_TCP) {
policy_entry_t e;
policy_table_init(&policy);
memset(&e, 0, sizeof(e));
strncpy(e.caller, "*", sizeof(e.caller) - 1);
e.prompt = PROMPT_EVERY_REQUEST;
(void)policy_table_add(&policy, &e);
} else {
policy_init_default(&policy, owner_uid);
}
apply_test_overrides(&policy);
if (!socket_name_explicit) {
if (listen_mode == NSIGNER_LISTEN_UNIX && !socket_name_explicit) {
if (socket_name_random(generated_socket_name, sizeof(generated_socket_name)) != 0) {
fprintf(stderr, "Failed to generate random socket name\n");
crypto_wipe(&key_store);
@@ -1106,9 +1124,27 @@ int main(int argc, char *argv[]) {
socket_name = generated_socket_name;
}
server_init(&server, socket_name, socket_name_explicit, &dispatcher, &policy);
if (listen_mode == NSIGNER_LISTEN_UNIX) {
listen_target = socket_name;
} else if (listen_mode == NSIGNER_LISTEN_TCP && socket_name_explicit) {
fprintf(stderr, "--socket-name is only valid with unix listen mode\n");
crypto_wipe(&key_store);
nostr_cleanup();
mnemonic_unload(&mnemonic);
return 1;
}
server_init(&server, listen_target, socket_name_explicit, listen_mode, &dispatcher, &policy);
if (server_start(&server) != 0) {
fprintf(stderr, "Failed to start server on @%s: %s\n", socket_name, server_last_error(&server));
if (listen_mode == NSIGNER_LISTEN_UNIX) {
fprintf(stderr, "Failed to start server on @%s: %s\n", socket_name, server_last_error(&server));
} else if (listen_mode == NSIGNER_LISTEN_TCP) {
fprintf(stderr, "Failed to start server on %s: %s\n", listen_target, server_last_error(&server));
} else {
fprintf(stderr, "Failed to start server (%s): %s\n",
(listen_mode == NSIGNER_LISTEN_QREXEC) ? "qrexec" : "stdio",
server_last_error(&server));
}
crypto_wipe(&key_store);
nostr_cleanup();
mnemonic_unload(&mnemonic);
@@ -1118,6 +1154,52 @@ int main(int argc, char *argv[]) {
(void)signal(SIGINT, handle_signal);
(void)signal(SIGTERM, handle_signal);
if (listen_mode == NSIGNER_LISTEN_UNIX) {
printf("System is ready and waiting for connections on @%s.\n", socket_name);
} else if (listen_mode == NSIGNER_LISTEN_TCP) {
printf("System is ready and waiting for connections on %s.\n", listen_target);
} else if (listen_mode == NSIGNER_LISTEN_QREXEC) {
printf("System is ready and waiting for a qrexec request.\n");
} else {
printf("System is ready and waiting for a stdio request.\n");
}
fflush(stdout);
if (listen_mode == NSIGNER_LISTEN_STDIO || listen_mode == NSIGNER_LISTEN_QREXEC) {
int hrc = server_handle_one(&server, NULL, NULL);
server_stop(&server);
crypto_wipe(&key_store);
nostr_cleanup();
mnemonic_unload(&mnemonic);
return (hrc < 0) ? 1 : 0;
}
if (listen_mode == NSIGNER_LISTEN_TCP) {
pfds[0].fd = server.listen_fd;
pfds[0].events = POLLIN;
while (g_running && server.running) {
int prc = poll(pfds, 1, 200);
if (prc < 0) {
if (errno == EINTR) {
continue;
}
break;
}
if (prc > 0 && (pfds[0].revents & POLLIN)) {
if (server_handle_one(&server, tcp_activity_stdout_cb, NULL) < 0) {
break;
}
}
}
server_stop(&server);
crypto_wipe(&key_store);
nostr_cleanup();
mnemonic_unload(&mnemonic);
return 0;
}
memset(&g_activity_log, 0, sizeof(g_activity_log));
g_auto_approve = 0;
server_set_prompt_always_allow(0);
@@ -1149,7 +1231,7 @@ int main(int argc, char *argv[]) {
char ch = '\0';
if (read(STDIN_FILENO, &ch, 1) > 0) {
ch = (char)tolower((unsigned char)ch);
if (ch == 'q') {
if (ch == 'q' || ch == 'x') {
g_running = 0;
} else if (ch == 'r') {
render_status(&role_table, &mnemonic, derived_count, socket_name);

View File

@@ -373,12 +373,19 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request)
#define SERVER_SOCKET_NAME_MAX 108
#define SERVER_MAX_MSG_SIZE 65536
#define NSIGNER_LISTEN_UNIX 0
#define NSIGNER_LISTEN_STDIO 1
#define NSIGNER_LISTEN_QREXEC 2
#define NSIGNER_LISTEN_TCP 3
/* Caller identity */
typedef struct {
uid_t uid;
gid_t gid;
pid_t pid;
char caller_id[64]; /* "uid:<n>" */
int kind;
char caller_id[64]; /* "uid:<n>" or "qubes:<vm>" */
char source_qube[64];
} caller_identity_t;
/* Server context */
@@ -387,6 +394,8 @@ typedef struct {
char last_error[256];
int listen_fd;
int running;
int listen_mode;
int stdio_handled;
dispatcher_ctx_t *dispatcher;
policy_table_t *policy;
int socket_name_explicit;
@@ -395,6 +404,7 @@ typedef struct {
/* Initialize server context. socket_name is the abstract namespace name (e.g. "nsigner").
* socket_name_explicit should be non-zero when provided via --socket-name override. */
void server_init(server_ctx_t *ctx, const char *socket_name, int socket_name_explicit,
int listen_mode,
dispatcher_ctx_t *dispatcher, policy_table_t *policy);
/* Start listening. Returns 0 on success, -1 on error. */
@@ -442,6 +452,9 @@ int socket_name_random(char *out, size_t out_len);
/* NSIGNER_HEADERLESS_DECLS_END */
int transport_send_framed(int fd, const char *payload);
int transport_recv_framed(int fd, char **out_payload, size_t max_size);
#include <arpa/inet.h>
#include <ctype.h>
#include <errno.h>
@@ -456,6 +469,157 @@ int socket_name_random(char *out, size_t out_len);
static int g_prompt_always_allow = 0;
static int g_noninteractive_prompt_default = -1;
static int caller_id_extract_ipv6(const char *caller_id, char *out_ipv6, size_t out_sz) {
const char *start;
const char *end;
size_t len;
if (caller_id == NULL || out_ipv6 == NULL || out_sz == 0) {
return -1;
}
if (strncmp(caller_id, "tcp:[", 5) != 0) {
return -1;
}
start = caller_id + 5;
end = strchr(start, ']');
if (end == NULL || end[1] != ':') {
return -1;
}
len = (size_t)(end - start);
if (len == 0 || len >= out_sz) {
return -1;
}
memcpy(out_ipv6, start, len);
out_ipv6[len] = '\0';
return 0;
}
static int read_cmd_output(const char *cmd, char **out_buf) {
FILE *fp;
char chunk[512];
char *buf = NULL;
size_t used = 0;
size_t cap = 0;
if (cmd == NULL || out_buf == NULL) {
return -1;
}
*out_buf = NULL;
fp = popen(cmd, "r");
if (fp == NULL) {
return -1;
}
while (fgets(chunk, sizeof(chunk), fp) != NULL) {
size_t n = strlen(chunk);
if (used + n + 1 > cap) {
size_t new_cap = (cap == 0) ? 2048 : cap * 2;
while (new_cap < used + n + 1) {
new_cap *= 2;
}
{
char *tmp = (char *)realloc(buf, new_cap);
if (tmp == NULL) {
free(buf);
(void)pclose(fp);
return -1;
}
buf = tmp;
cap = new_cap;
}
}
memcpy(buf + used, chunk, n);
used += n;
}
(void)pclose(fp);
if (buf == NULL) {
return -1;
}
buf[used] = '\0';
*out_buf = buf;
return 0;
}
static int lookup_fips_peer_for_ipv6(const char *ipv6,
char *out_npub,
size_t out_npub_sz,
char *out_name,
size_t out_name_sz) {
char *json = NULL;
cJSON *root = NULL;
cJSON *peers = NULL;
int i;
if (ipv6 == NULL || out_npub == NULL || out_npub_sz == 0 || out_name == NULL || out_name_sz == 0) {
return -1;
}
out_npub[0] = '\0';
out_name[0] = '\0';
if (read_cmd_output("fipsctl show peers 2>/dev/null", &json) != 0) {
return -1;
}
root = cJSON_Parse(json);
free(json);
if (root == NULL) {
return -1;
}
peers = cJSON_GetObjectItemCaseSensitive(root, "peers");
if (!cJSON_IsArray(peers)) {
cJSON_Delete(root);
return -1;
}
for (i = 0; i < cJSON_GetArraySize(peers); ++i) {
cJSON *peer = cJSON_GetArrayItem(peers, i);
cJSON *peer_ip;
cJSON *peer_npub;
cJSON *peer_name;
if (!cJSON_IsObject(peer)) {
continue;
}
peer_ip = cJSON_GetObjectItemCaseSensitive(peer, "ipv6_addr");
if (!cJSON_IsString(peer_ip) || peer_ip->valuestring == NULL) {
continue;
}
if (strcmp(peer_ip->valuestring, ipv6) != 0) {
continue;
}
peer_npub = cJSON_GetObjectItemCaseSensitive(peer, "npub");
peer_name = cJSON_GetObjectItemCaseSensitive(peer, "display_name");
if (cJSON_IsString(peer_npub) && peer_npub->valuestring != NULL) {
strncpy(out_npub, peer_npub->valuestring, out_npub_sz - 1);
out_npub[out_npub_sz - 1] = '\0';
}
if (cJSON_IsString(peer_name) && peer_name->valuestring != NULL) {
strncpy(out_name, peer_name->valuestring, out_name_sz - 1);
out_name[out_name_sz - 1] = '\0';
}
cJSON_Delete(root);
return (out_npub[0] != '\0') ? 0 : -1;
}
cJSON_Delete(root);
return -1;
}
void server_set_prompt_always_allow(int enabled) {
g_prompt_always_allow = enabled ? 1 : 0;
}
@@ -488,6 +652,20 @@ static int prompt_for_policy_decision(const caller_identity_t *caller,
printf("\nApproval required\n");
printf("caller: %s\n", (caller != NULL) ? caller->caller_id : "unknown");
if (caller != NULL && caller->kind == NSIGNER_LISTEN_TCP) {
char ipv6[INET6_ADDRSTRLEN];
char npub[128];
char display_name[128];
if (caller_id_extract_ipv6(caller->caller_id, ipv6, sizeof(ipv6)) == 0 &&
lookup_fips_peer_for_ipv6(ipv6, npub, sizeof(npub), display_name, sizeof(display_name)) == 0) {
if (display_name[0] != '\0') {
printf("fips peer: %s (%s)\n", npub, display_name);
} else {
printf("fips peer: %s\n", npub);
}
}
}
printf("method: %s\n", (method != NULL) ? method : "unknown");
printf("role: %s\n", (role_name != NULL) ? role_name : "unknown");
printf("purpose: %s\n", (purpose != NULL) ? purpose : "unknown");
@@ -527,98 +705,81 @@ static void server_set_error(server_ctx_t *ctx, const char *msg) {
ctx->last_error[sizeof(ctx->last_error) - 1] = '\0';
}
static int read_full(int fd, void *buf, size_t len) {
unsigned char *p = (unsigned char *)buf;
size_t off = 0;
while (off < len) {
ssize_t n = read(fd, p + off, len - off);
if (n == 0) {
static int parse_tcp_target(const char *target,
int *out_family,
char *out_host,
size_t out_host_sz,
uint16_t *out_port) {
const char *p;
const char *host_start;
const char *host_end;
const char *port_start;
char port_buf[16];
size_t host_len;
size_t port_len;
char *endptr = NULL;
long port_long;
if (target == NULL || out_family == NULL || out_host == NULL || out_port == NULL ||
out_host_sz == 0) {
return -1;
}
if (strncmp(target, "tcp:", 4) != 0) {
return -1;
}
p = target + 4;
if (*p == '[') {
host_start = p + 1;
host_end = strchr(host_start, ']');
if (host_end == NULL || host_end[1] != ':') {
return -1;
}
if (n < 0) {
if (errno == EINTR) {
continue;
}
port_start = host_end + 2;
} else {
host_start = p;
host_end = strrchr(p, ':');
if (host_end == NULL || host_end == host_start) {
return -1;
}
off += (size_t)n;
port_start = host_end + 1;
}
return 0;
}
host_len = (size_t)(host_end - host_start);
if (host_len == 0 || host_len >= out_host_sz) {
return -1;
}
memcpy(out_host, host_start, host_len);
out_host[host_len] = '\0';
static int write_full(int fd, const void *buf, size_t len) {
const unsigned char *p = (const unsigned char *)buf;
size_t off = 0;
port_len = strlen(port_start);
if (port_len == 0 || port_len >= sizeof(port_buf)) {
return -1;
}
memcpy(port_buf, port_start, port_len + 1);
while (off < len) {
ssize_t n = write(fd, p + off, len - off);
if (n < 0) {
if (errno == EINTR) {
continue;
}
errno = 0;
port_long = strtol(port_buf, &endptr, 10);
if (errno != 0 || endptr == port_buf || *endptr != '\0' || port_long < 1 || port_long > 65535) {
return -1;
}
{
struct in6_addr addr6;
struct in_addr addr4;
if (inet_pton(AF_INET6, out_host, &addr6) == 1) {
*out_family = AF_INET6;
} else if (inet_pton(AF_INET, out_host, &addr4) == 1) {
*out_family = AF_INET;
} else {
return -1;
}
off += (size_t)n;
}
return 0;
}
static int recv_framed(int fd, char **out_payload) {
uint32_t be_len;
uint32_t len;
char *payload;
if (out_payload == NULL) {
return -1;
}
*out_payload = NULL;
if (read_full(fd, &be_len, sizeof(be_len)) != 0) {
return -1;
}
len = ntohl(be_len);
if (len == 0 || len > SERVER_MAX_MSG_SIZE) {
return -1;
}
payload = (char *)malloc((size_t)len + 1U);
if (payload == NULL) {
return -1;
}
if (read_full(fd, payload, len) != 0) {
free(payload);
return -1;
}
payload[len] = '\0';
*out_payload = payload;
return 0;
}
static int send_framed(int fd, const char *payload) {
uint32_t len;
uint32_t be_len;
if (payload == NULL) {
return -1;
}
len = (uint32_t)strlen(payload);
be_len = htonl(len);
if (write_full(fd, &be_len, sizeof(be_len)) != 0) {
return -1;
}
if (write_full(fd, payload, len) != 0) {
return -1;
}
*out_port = (uint16_t)port_long;
return 0;
}
@@ -697,6 +858,7 @@ static int extract_method_and_selector(const char *json,
}
void server_init(server_ctx_t *ctx, const char *socket_name, int socket_name_explicit,
int listen_mode,
dispatcher_ctx_t *dispatcher, policy_table_t *policy) {
if (ctx == NULL) {
return;
@@ -709,6 +871,8 @@ void server_init(server_ctx_t *ctx, const char *socket_name, int socket_name_exp
ctx->socket_name[sizeof(ctx->socket_name) - 1] = '\0';
}
ctx->listen_fd = -1;
ctx->listen_mode = listen_mode;
ctx->stdio_handled = 0;
ctx->dispatcher = dispatcher;
ctx->policy = policy;
ctx->socket_name_explicit = socket_name_explicit ? 1 : 0;
@@ -732,6 +896,105 @@ int server_start(server_ctx_t *ctx) {
server_set_error(ctx, NULL);
if (ctx->listen_mode == NSIGNER_LISTEN_STDIO || ctx->listen_mode == NSIGNER_LISTEN_QREXEC) {
ctx->listen_fd = STDIN_FILENO;
ctx->running = 1;
ctx->stdio_handled = 0;
server_set_error(ctx, NULL);
return 0;
}
if (ctx->listen_mode == NSIGNER_LISTEN_TCP) {
int family;
uint16_t port;
char host[64];
int one = 1;
int prc = parse_tcp_target(ctx->socket_name, &family, host, sizeof(host), &port);
if (prc != 0) {
(void)snprintf(ctx->last_error,
sizeof(ctx->last_error),
"invalid tcp listen target: %s (expected tcp:IPv4:PORT or tcp:[IPv6]:PORT)",
ctx->socket_name);
return -1;
}
fd = socket(family, SOCK_STREAM, 0);
if (fd < 0) {
(void)snprintf(ctx->last_error,
sizeof(ctx->last_error),
"socket(tcp) failed: %s",
strerror(errno));
return -1;
}
(void)setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
if (family == AF_INET) {
struct sockaddr_in addr4;
memset(&addr4, 0, sizeof(addr4));
addr4.sin_family = AF_INET;
addr4.sin_port = htons(port);
if (inet_pton(AF_INET, host, &addr4.sin_addr) != 1) {
close(fd);
server_set_error(ctx, "inet_pton(AF_INET) failed for listen target");
return -1;
}
if (bind(fd, (struct sockaddr *)&addr4, sizeof(addr4)) != 0) {
(void)snprintf(ctx->last_error,
sizeof(ctx->last_error),
"bind(%s) failed: %s",
ctx->socket_name,
strerror(errno));
close(fd);
return -1;
}
} else {
struct sockaddr_in6 addr6;
memset(&addr6, 0, sizeof(addr6));
addr6.sin6_family = AF_INET6;
addr6.sin6_port = htons(port);
if (inet_pton(AF_INET6, host, &addr6.sin6_addr) != 1) {
close(fd);
server_set_error(ctx, "inet_pton(AF_INET6) failed for listen target");
return -1;
}
if (bind(fd, (struct sockaddr *)&addr6, sizeof(addr6)) != 0) {
(void)snprintf(ctx->last_error,
sizeof(ctx->last_error),
"bind(%s) failed: %s",
ctx->socket_name,
strerror(errno));
close(fd);
return -1;
}
}
if (listen(fd, 16) != 0) {
(void)snprintf(ctx->last_error,
sizeof(ctx->last_error),
"listen() failed: %s",
strerror(errno));
close(fd);
return -1;
}
flags = fcntl(fd, F_GETFL, 0);
if (flags < 0 || fcntl(fd, F_SETFL, flags | O_NONBLOCK) != 0) {
(void)snprintf(ctx->last_error,
sizeof(ctx->last_error),
"fcntl(O_NONBLOCK) failed: %s",
strerror(errno));
close(fd);
return -1;
}
ctx->listen_fd = fd;
ctx->running = 1;
server_set_error(ctx, NULL);
return 0;
}
fd = socket(AF_UNIX, SOCK_STREAM, 0);
if (fd < 0) {
(void)snprintf(ctx->last_error,
@@ -835,6 +1098,56 @@ int server_get_caller(int fd, caller_identity_t *out) {
memset(out, 0, sizeof(*out));
if (fd == STDIN_FILENO) {
const char *src = getenv("QREXEC_REMOTE_DOMAIN");
out->kind = NSIGNER_LISTEN_STDIO;
if (src != NULL && src[0] != '\0') {
out->kind = NSIGNER_LISTEN_QREXEC;
strncpy(out->source_qube, src, sizeof(out->source_qube) - 1);
out->source_qube[sizeof(out->source_qube) - 1] = '\0';
(void)snprintf(out->caller_id, sizeof(out->caller_id), "qubes:%.57s", out->source_qube);
return 0;
}
out->uid = getuid();
out->gid = getgid();
out->pid = getpid();
(void)snprintf(out->caller_id, sizeof(out->caller_id), "uid:%u", (unsigned int)out->uid);
return 0;
}
{
struct sockaddr_storage peer;
socklen_t peer_len = sizeof(peer);
if (getpeername(fd, (struct sockaddr *)&peer, &peer_len) == 0) {
if (peer.ss_family == AF_INET) {
const struct sockaddr_in *in4 = (const struct sockaddr_in *)&peer;
char ip[INET_ADDRSTRLEN];
if (inet_ntop(AF_INET, &in4->sin_addr, ip, sizeof(ip)) != NULL) {
out->kind = NSIGNER_LISTEN_TCP;
(void)snprintf(out->caller_id,
sizeof(out->caller_id),
"tcp:%s:%u",
ip,
(unsigned int)ntohs(in4->sin_port));
return 0;
}
} else if (peer.ss_family == AF_INET6) {
const struct sockaddr_in6 *in6 = (const struct sockaddr_in6 *)&peer;
char ip6[INET6_ADDRSTRLEN];
if (inet_ntop(AF_INET6, &in6->sin6_addr, ip6, sizeof(ip6)) != NULL) {
out->kind = NSIGNER_LISTEN_TCP;
(void)snprintf(out->caller_id,
sizeof(out->caller_id),
"tcp:[%s]:%u",
ip6,
(unsigned int)ntohs(in6->sin6_port));
return 0;
}
}
}
}
if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &cred, &len) != 0) {
return -1;
}
@@ -842,6 +1155,7 @@ int server_get_caller(int fd, caller_identity_t *out) {
out->uid = cred.uid;
out->gid = cred.gid;
out->pid = cred.pid;
out->kind = NSIGNER_LISTEN_UNIX;
(void)snprintf(out->caller_id, sizeof(out->caller_id), "uid:%u", (unsigned int)out->uid);
return 0;
}
@@ -864,26 +1178,38 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
return -1;
}
client_fd = accept(ctx->listen_fd, NULL, NULL);
if (client_fd < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
if (ctx->listen_mode == NSIGNER_LISTEN_STDIO || ctx->listen_mode == NSIGNER_LISTEN_QREXEC) {
if (ctx->stdio_handled) {
return 0;
}
return -1;
client_fd = STDIN_FILENO;
ctx->stdio_handled = 1;
} else {
client_fd = accept(ctx->listen_fd, NULL, NULL);
if (client_fd < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
return 0;
}
return -1;
}
}
if (server_get_caller(client_fd, &caller) != 0) {
close(client_fd);
if (client_fd != STDIN_FILENO) {
close(client_fd);
}
return -1;
}
if (recv_framed(client_fd, &request) != 0) {
if (transport_recv_framed(client_fd, &request, SERVER_MAX_MSG_SIZE) != 0) {
response = strdup("{\"id\":\"null\",\"error\":{\"code\":-32700,\"message\":\"parse_error\"}}");
if (response != NULL) {
(void)send_framed(client_fd, response);
(void)transport_send_framed((client_fd == STDIN_FILENO) ? STDOUT_FILENO : client_fd, response);
free(response);
}
close(client_fd);
if (client_fd != STDIN_FILENO) {
close(client_fd);
}
return 1;
}
@@ -917,14 +1243,13 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
}
if (response != NULL) {
(void)send_framed(client_fd, response);
(void)transport_send_framed((client_fd == STDIN_FILENO) ? STDOUT_FILENO : client_fd, response);
}
(void)snprintf(activity,
sizeof(activity),
"uid=%u pid=%d %s(%s) %s",
(unsigned int)caller.uid,
(int)caller.pid,
"%s %s(%s) %s",
caller.caller_id,
method,
role_name,
verdict);
@@ -935,7 +1260,9 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) {
free(request);
free(response);
close(client_fd);
if (client_fd != STDIN_FILENO) {
close(client_fd);
}
return 1;
}
@@ -945,7 +1272,9 @@ void server_stop(server_ctx_t *ctx) {
}
if (ctx->listen_fd >= 0) {
close(ctx->listen_fd);
if (ctx->listen_mode == NSIGNER_LISTEN_UNIX || ctx->listen_mode == NSIGNER_LISTEN_TCP) {
close(ctx->listen_fd);
}
ctx->listen_fd = -1;
}
ctx->running = 0;

104
src/transport_frame.c Normal file
View File

@@ -0,0 +1,104 @@
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
static int transport_read_full(int fd, void *buf, size_t len) {
unsigned char *p = (unsigned char *)buf;
size_t off = 0;
while (off < len) {
ssize_t n = read(fd, p + off, len - off);
if (n == 0) {
return -1;
}
if (n < 0) {
if (errno == EINTR) {
continue;
}
return -1;
}
off += (size_t)n;
}
return 0;
}
static int transport_write_full(int fd, const void *buf, size_t len) {
const unsigned char *p = (const unsigned char *)buf;
size_t off = 0;
while (off < len) {
ssize_t n = write(fd, p + off, len - off);
if (n < 0) {
if (errno == EINTR) {
continue;
}
return -1;
}
off += (size_t)n;
}
return 0;
}
int transport_send_framed(int fd, const char *payload) {
uint32_t len;
uint32_t be_len;
if (payload == NULL) {
return -1;
}
len = (uint32_t)strlen(payload);
be_len = htonl(len);
if (transport_write_full(fd, &be_len, sizeof(be_len)) != 0) {
return -1;
}
if (transport_write_full(fd, payload, len) != 0) {
return -1;
}
return 0;
}
int transport_recv_framed(int fd, char **out_payload, size_t max_size) {
uint32_t be_len;
uint32_t len;
char *payload;
if (out_payload == NULL || max_size == 0) {
return -1;
}
*out_payload = NULL;
if (transport_read_full(fd, &be_len, sizeof(be_len)) != 0) {
return -1;
}
len = ntohl(be_len);
if (len == 0 || len > max_size) {
return -1;
}
payload = (char *)malloc((size_t)len + 1U);
if (payload == NULL) {
return -1;
}
if (transport_read_full(fd, payload, len) != 0) {
free(payload);
return -1;
}
payload[len] = '\0';
*out_payload = payload;
return 0;
}