From b089bf36e3f87f455e118650516374f115c2393c Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Sat, 2 May 2026 18:14:20 -0400 Subject: [PATCH] v0.0.6 - Tier-1 TCP listener + FIPS deployment documentation --- Dockerfile.alpine-musl | 1 + Makefile | 1 + README.md | 24 ++ .../CLIENT_IMPLEMENTATION.md | 16 +- documents/FIPS_DEPLOYMENT.md | 185 +++++++++ documents/QUBES_OS.md | 179 +++++++++ documents/qubes_client_examples.md | 93 +++++ packaging/qubes/install-policy.sh | 17 + packaging/qubes/install-service.sh | 15 + packaging/qubes/policy.d/40-nsigner.policy | 5 + packaging/qubes/rpc/qubes.NsignerRpc | 2 + plans/nsigner.md | 116 ++++++ src/main.c | 252 ++++++------ src/server.c | 359 +++++++++++++----- src/transport_frame.c | 104 +++++ 15 files changed, 1166 insertions(+), 203 deletions(-) rename CLIENT_IMPLEMENTATION.md => documents/CLIENT_IMPLEMENTATION.md (94%) create mode 100644 documents/FIPS_DEPLOYMENT.md create mode 100644 documents/QUBES_OS.md create mode 100644 documents/qubes_client_examples.md create mode 100644 packaging/qubes/install-policy.sh create mode 100644 packaging/qubes/install-service.sh create mode 100644 packaging/qubes/policy.d/40-nsigner.policy create mode 100644 packaging/qubes/rpc/qubes.NsignerRpc create mode 100644 src/transport_frame.c diff --git a/Dockerfile.alpine-musl b/Dockerfile.alpine-musl index bcc4c96..c7f6935 100644 --- a/Dockerfile.alpine-musl +++ b/Dockerfile.alpine-musl @@ -67,6 +67,7 @@ RUN gcc -static -Os -ffunction-sections -fdata-sections -Wl,--gc-sections -s -Wa /build/src/dispatcher.c \ /build/src/policy.c \ /build/src/server.c \ + /build/src/transport_frame.c \ /build/src/key_store.c \ /build/src/socket_name.c \ /build/nostr_core_lib/libnostr_core_x64.a \ diff --git a/Makefile b/Makefile index 6f6296e..0482e19 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 44b46ba..8c1c4c4 100644 --- a/README.md +++ b/README.md @@ -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:`). +- `nsigner --listen tcp:127.0.0.1:PORT` (or `tcp:[::1]:PORT`) enables loopback-only TCP listening for non-AF_UNIX clients. ### 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 loopback transport mode (no TUI; serves requests until terminated): + +```bash +nsigner --listen tcp:127.0.0.1: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 diff --git a/CLIENT_IMPLEMENTATION.md b/documents/CLIENT_IMPLEMENTATION.md similarity index 94% rename from CLIENT_IMPLEMENTATION.md rename to documents/CLIENT_IMPLEMENTATION.md index c01f24c..ae7cf51 100644 --- a/CLIENT_IMPLEMENTATION.md +++ b/documents/CLIENT_IMPLEMENTATION.md @@ -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:`. + +This mode is server-side only in the current CLI (the `client` subcommand still targets AF_UNIX). + --- ## 3. Transport framing diff --git a/documents/FIPS_DEPLOYMENT.md b/documents/FIPS_DEPLOYMENT.md new file mode 100644 index 0000000..1c46640 --- /dev/null +++ b/documents/FIPS_DEPLOYMENT.md @@ -0,0 +1,185 @@ +# FIPS_DEPLOYMENT.md + +## 1. Scope + +This runbook covers a practical Tier-1 deployment of `nsigner` over a loopback TCP listener, with connectivity provided by FIPS as the network substrate. + +Tier-1 objective: + +- Keep `nsigner` transport simple (`--listen tcp:127.0.0.1:PORT` or `--listen tcp:[::1]:PORT`). +- Use FIPS to carry traffic between peers. +- Do not add FIPS runtime dependencies into `nsigner`. + +Out of scope in this document: + +- Remote non-loopback TCP exposure (`--allow-remote`, TLS) planned for later phase. +- Automatic caller->npub enrichment from FIPS session metadata. + +--- + +## 2. Architecture + +Two cooperating layers: + +1. **Signer process layer** (`nsigner`) + - Listens on loopback TCP only. + - 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 a local loopback endpoint on each side. + +Conceptually: + +`client app -> local FIPS endpoint -> FIPS mesh -> remote FIPS endpoint -> 127.0.0.1:PORT -> 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 loopback TCP listen mode: + +```bash +./build/nsigner --listen tcp:127.0.0.1:8080 +``` + +Or IPv6 loopback: + +```bash +./build/nsigner --listen tcp:[::1]:8080 +``` + +Behavior notes: + +- Non-loopback values are rejected by design. +- 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 loopback in signer environment. +2. Configure FIPS service/forwarding so remote authenticated peer traffic is delivered to that loopback 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 -> signer loopback 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":""} +``` + +### 6.2 Signing check + +Send `sign_event` with explicit role selector: + +```json +{ + "id": "2", + "method": "sign_event", + "params": ["", {"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 + +- Keep listener loopback-only in Tier-1. +- 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:[::1]:PORT`. + +Fix: +- Use explicit loopback host and valid numeric port. + +### 8.2 `non-loopback TCP bind denied` + +Cause: +- Attempt to bind non-loopback target in Tier-1 mode. + +Fix: +- Switch to `127.x.x.x` or `::1` target. + +### 8.3 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.4 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. diff --git a/documents/QUBES_OS.md b/documents/QUBES_OS.md new file mode 100644 index 0000000..7acf396 --- /dev/null +++ b/documents/QUBES_OS.md @@ -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:` 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:` 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:` 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:` 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:` in activity. +6. README + docs include full setup and troubleshooting. diff --git a/documents/qubes_client_examples.md b/documents/qubes_client_examples.md new file mode 100644 index 0000000..f101cfe --- /dev/null +++ b/documents/qubes_client_examples.md @@ -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)) +``` diff --git a/packaging/qubes/install-policy.sh b/packaging/qubes/install-policy.sh new file mode 100644 index 0000000..c9b426e --- /dev/null +++ b/packaging/qubes/install-policy.sh @@ -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." diff --git a/packaging/qubes/install-service.sh b/packaging/qubes/install-service.sh new file mode 100644 index 0000000..36ada11 --- /dev/null +++ b/packaging/qubes/install-service.sh @@ -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." diff --git a/packaging/qubes/policy.d/40-nsigner.policy b/packaging/qubes/policy.d/40-nsigner.policy new file mode 100644 index 0000000..5e85bac --- /dev/null +++ b/packaging/qubes/policy.d/40-nsigner.policy @@ -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 diff --git a/packaging/qubes/rpc/qubes.NsignerRpc b/packaging/qubes/rpc/qubes.NsignerRpc new file mode 100644 index 0000000..a345075 --- /dev/null +++ b/packaging/qubes/rpc/qubes.NsignerRpc @@ -0,0 +1,2 @@ +#!/bin/sh +exec /usr/local/bin/nsigner --listen qrexec diff --git a/plans/nsigner.md b/plans/nsigner.md index 241a3bd..716f87d 100644 --- a/plans/nsigner.md +++ b/plans/nsigner.md @@ -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 loopback transport + +Smallest IP-based step; on-ramp for non-Linux clients and for FIPS later. + +- New CLI: `nsigner --listen tcp:127.0.0.1:PORT`. +- Default-deny non-loopback binds (reject `0.0.0.0` / non-`127.x` / non-`::1` unless `--allow-remote`, see T3). +- Caller identity for loopback: `tcp_local { addr }`. 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`, 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] Reject invalid/non-loopback 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 --tls-key `. +- 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 TCP loopback 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 loopback 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? diff --git a/src/main.c b/src/main.c index 161e6fd..400f84e 100644 --- a/src/main.c +++ b/src/main.c @@ -373,12 +373,19 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request) #define SERVER_SOCKET_NAME_MAX 108 #define SERVER_MAX_MSG_SIZE 65536 +#define NSIGNER_LISTEN_UNIX 0 +#define NSIGNER_LISTEN_STDIO 1 +#define NSIGNER_LISTEN_QREXEC 2 +#define NSIGNER_LISTEN_TCP 3 + /* Caller identity */ typedef struct { uid_t uid; gid_t gid; pid_t pid; - char caller_id[64]; /* "uid:" */ + int kind; + char caller_id[64]; /* "uid:" or "qubes:" */ + 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 6 +#define NSIGNER_VERSION "v0.0.6" /* 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 #include @@ -462,6 +475,7 @@ int socket_name_random(char *out, size_t out_len); #include #include #include +#include #include #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 ] Run signer server + built-in TUI\n", program_name); + printf(" %s [--socket-name|--name|-n ] [--listen ]\n", program_name); + printf(" Run signer server (unix mode has TUI)\n"); printf(" %s [--socket-name|--name|-n ] client '' Send JSON-RPC request\n", program_name); printf(" %s [--socket-name|--name|-n ] 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,15 +720,29 @@ 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++; } @@ -878,7 +813,7 @@ 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]; + char mode[MNEMONIC_MAX_LEN]; struct termios old_term; struct termios new_term; int have_term = 0; @@ -887,13 +822,21 @@ static int prompt_load_mnemonic(mnemonic_state_t *mnemonic) { return -1; } - printf("Mnemonic source: [E]nter existing or [G]enerate new (default E): "); + 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; } + if (strchr(mode, ' ') != NULL && mode[0] != 'g' && mode[0] != 'G') { + if (mnemonic_load(mnemonic, mode) != 0) { + fprintf(stderr, "Invalid mnemonic (must be 12/15/18/21/24 words)\n"); + return -1; + } + return 0; + } + if (mode[0] == 'g' || mode[0] == 'G') { int idx = 1; char *ctx = NULL; @@ -1017,6 +960,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 +977,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(); } @@ -1091,11 +1065,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 +1089,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 +1119,41 @@ int main(int argc, char *argv[]) { (void)signal(SIGINT, handle_signal); (void)signal(SIGTERM, handle_signal); + 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, NULL, 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); diff --git a/src/server.c b/src/server.c index a3f854d..8285d84 100644 --- a/src/server.c +++ b/src/server.c @@ -373,12 +373,19 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request) #define SERVER_SOCKET_NAME_MAX 108 #define SERVER_MAX_MSG_SIZE 65536 +#define NSIGNER_LISTEN_UNIX 0 +#define NSIGNER_LISTEN_STDIO 1 +#define NSIGNER_LISTEN_QREXEC 2 +#define NSIGNER_LISTEN_TCP 3 + /* Caller identity */ typedef struct { uid_t uid; gid_t gid; pid_t pid; - char caller_id[64]; /* "uid:" */ + int kind; + char caller_id[64]; /* "uid:" or "qubes:" */ + 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 #include #include @@ -527,98 +540,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; + } + + if (strcmp(out_host, "::1") == 0) { + *out_family = AF_INET6; + } else { + struct in_addr addr4; + if (inet_pton(AF_INET, out_host, &addr4) != 1) { 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; + if ((ntohl(addr4.s_addr) & 0xff000000U) != 0x7f000000U) { + return -2; + } + *out_family = AF_INET; } + *out_port = (uint16_t)port_long; return 0; } @@ -697,6 +693,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 +706,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 +731,112 @@ 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 == -2) { + (void)snprintf(ctx->last_error, + sizeof(ctx->last_error), + "non-loopback TCP bind denied: %s (use 127.x.x.x or ::1)", + ctx->socket_name); + return -1; + } + if (prc != 0) { + (void)snprintf(ctx->last_error, + sizeof(ctx->last_error), + "invalid tcp listen target: %s (expected tcp:127.0.0.1:PORT or tcp:[::1]: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 +940,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 +997,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 +1020,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 +1085,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 +1102,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 +1114,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; diff --git a/src/transport_frame.c b/src/transport_frame.c new file mode 100644 index 0000000..625233c --- /dev/null +++ b/src/transport_frame.c @@ -0,0 +1,104 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include + +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; +}