v0.0.7 - Allow TCP non-loopback IPv4/IPv6 binds for FIPS reachability

This commit is contained in:
Laan Tungir
2026-05-03 11:41:42 -04:00
parent b089bf36e3
commit f9d7b94962
6 changed files with 312 additions and 56 deletions

View File

@@ -239,7 +239,7 @@ 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:127.0.0.1:PORT` (or `tcp:[::1]:PORT`) enables loopback-only TCP listening for non-AF_UNIX clients.
- `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
@@ -303,10 +303,10 @@ Generic stdio transport mode (single framed request over stdin/stdout):
nsigner --listen stdio
```
TCP loopback transport mode (no TUI; serves requests until terminated):
TCP transport mode (no TUI; serves requests until terminated):
```bash
nsigner --listen tcp:127.0.0.1:8080
nsigner --listen tcp:[::]:8080
```
### 9.2 Send a request (client mode)

View File

@@ -2,17 +2,17 @@
## 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.
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:127.0.0.1:PORT` or `--listen tcp:[::1]:PORT`).
- 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:
- Remote non-loopback TCP exposure (`--allow-remote`, TLS) planned for later phase.
- Mandatory transport-level TLS/authentication hardening (still planned for a later phase).
- Automatic caller->npub enrichment from FIPS session metadata.
---
@@ -22,17 +22,17 @@ Out of scope in this document:
Two cooperating layers:
1. **Signer process layer** (`nsigner`)
- Listens on loopback TCP only.
- 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 a local loopback endpoint on each side.
- Carries application traffic to the configured signer TCP endpoint.
Conceptually:
`client app -> local FIPS endpoint -> FIPS mesh -> remote FIPS endpoint -> 127.0.0.1:PORT -> nsigner`
`client app -> local FIPS endpoint -> FIPS mesh -> remote FIPS endpoint -> signer TCP endpoint -> nsigner`
---
@@ -58,21 +58,21 @@ Operational assumptions:
## 4. Start signer in Tier-1 TCP mode
Run `nsigner` in loopback TCP listen mode:
Run `nsigner` in TCP listen mode:
```bash
./build/nsigner --listen tcp:127.0.0.1:8080
./build/nsigner --listen tcp:[::]:8080
```
Or IPv6 loopback:
Or bind to a specific FIPS ULA address:
```bash
./build/nsigner --listen tcp:[::1]:8080
./build/nsigner --listen tcp:[fd00::1234]:8080
```
Behavior notes:
- Non-loopback values are rejected by design.
- 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.
@@ -82,14 +82,14 @@ Behavior notes:
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.
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 -> signer loopback endpoint succeeds.
- Transport path from caller -> configured signer endpoint succeeds.
- `nsigner` receives framed request and returns framed response.
---
@@ -134,7 +134,7 @@ Trigger a request path that requires prompt/denial and confirm client handles po
## 7. Security guardrails
- Keep listener loopback-only in Tier-1.
- 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.
@@ -147,20 +147,12 @@ Trigger a request path that requires prompt/denial and confirm client handles po
### 8.1 `invalid tcp listen target`
Cause:
- `--listen` argument does not match `tcp:HOST:PORT` or `tcp:[::1]:PORT`.
- `--listen` argument does not match `tcp:HOST:PORT` or `tcp:[IPv6]:PORT`.
Fix:
- Use explicit loopback host and valid numeric port.
- Use valid numeric port and valid IPv4/IPv6 literal host.
### 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`)
### 8.2 Framing parse failures (`parse_error`)
Cause:
- Client sent line-delimited/raw JSON instead of framed JSON.
@@ -168,7 +160,7 @@ Cause:
Fix:
- Send 4-byte big-endian length prefix followed by exact UTF-8 JSON payload bytes.
### 8.4 FIPS path up, signer path down
### 8.3 FIPS path up, signer path down
Cause:
- FIPS session exists but forwarding/service mapping to signer loopback endpoint is missing.

271
install_qube_fips_nsigner.sh Executable file
View File

@@ -0,0 +1,271 @@
#!/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
# - fips/fipsctl/fipstop -> ~/.local/bin/
# - fips config -> ~/.config/fips/fips.yaml
# - user service -> ~/.config/systemd/user/fips.service
#
# Usage:
# bash install_qube_fips_nsigner.sh
# bash install_qube_fips_nsigner.sh --help
#
# Optional env vars:
# NSIGNER_VERSION=v0.0.6
# FIPS_VERSION=v0.2.0-rel
# NSIGNER_GITEA_TOKEN=<token> # if n_signer release assets are private
# NSIGNER_BINARY_URL=<direct url to nsigner_static_x86_64>
NSIGNER_VERSION="${NSIGNER_VERSION:-v0.0.6}"
FIPS_VERSION="${FIPS_VERSION:-v0.2.0-rel}"
NSIGNER_RELEASE_PAGE="https://git.laantungir.net/laantungir/n_signer/releases/tag/${NSIGNER_VERSION}"
NSIGNER_API_TAG_URL="https://git.laantungir.net/api/v1/repos/laantungir/n_signer/releases/tags/${NSIGNER_VERSION}"
NSIGNER_GIT_URL="https://git.laantungir.net/laantungir/n_signer.git"
FIPS_RELEASE_PAGE="https://github.com/jmcorgan/fips/releases/tag/${FIPS_VERSION}"
FIPS_ARCHIVE_URL="https://github.com/jmcorgan/fips/archive/refs/tags/${FIPS_VERSION}.tar.gz"
PREFIX_BIN="${HOME}/.local/bin"
PREFIX_CFG="${HOME}/.config"
PREFIX_DATA="${HOME}/.local/share/nsigner-fips-install"
SYSTEMD_USER_DIR="${HOME}/.config/systemd/user"
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}
- FIPS ${FIPS_VERSION}
- user-level systemd service for FIPS
Options:
-h, --help Show this help and exit
Optional env vars:
NSIGNER_VERSION=v0.0.6
FIPS_VERSION=v0.2.0-rel
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
~/.local/bin/fips
~/.local/bin/fipsctl
~/.local/bin/fipstop
~/.config/fips/fips.yaml
~/.config/systemd/user/fips.service
EOF
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || {
err "Missing command: $1"
exit 1
}
}
install_build_deps() {
if command -v apt-get >/dev/null 2>&1; then
log "Installing build/runtime dependencies via apt"
sudo apt-get update
sudo apt-get install -y \
ca-certificates curl jq tar gzip git python3 \
build-essential pkg-config libssl-dev zlib1g-dev \
rustc cargo
elif command -v dnf >/dev/null 2>&1; then
log "Installing build/runtime dependencies via dnf"
sudo dnf install -y \
ca-certificates curl jq tar gzip git python3 \
gcc make pkgconf-pkg-config openssl-devel zlib-devel \
rust cargo
else
err "Unsupported distro: need apt-get or dnf to install build dependencies"
exit 1
fi
}
prepare_dirs() {
mkdir -p "${PREFIX_BIN}" "${PREFIX_CFG}/fips" "${PREFIX_DATA}" "${SYSTEMD_USER_DIR}"
}
download_nsigner_asset_url() {
local headers=()
if [[ -n "${NSIGNER_GITEA_TOKEN:-}" ]]; then
headers=(-H "Authorization: token ${NSIGNER_GITEA_TOKEN}")
fi
curl -fsSL "${headers[@]}" "${NSIGNER_API_TAG_URL}" \
| jq -r '.assets[]?.browser_download_url // empty' \
| grep -E 'nsigner_static_x86_64$' \
| head -n1 || true
}
install_nsigner() {
log "Installing n_signer ${NSIGNER_VERSION}"
log "Release page: ${NSIGNER_RELEASE_PAGE}"
local asset_url="${NSIGNER_BINARY_URL:-}"
if [[ -z "${asset_url}" ]]; then
asset_url="$(download_nsigner_asset_url)"
fi
if [[ -z "${asset_url}" ]]; then
err "Could not find downloadable n_signer x86_64 release binary for ${NSIGNER_VERSION}."
err "Provide NSIGNER_BINARY_URL or NSIGNER_GITEA_TOKEN so the release asset can be resolved."
exit 1
fi
log "Using n_signer binary URL: ${asset_url}"
if [[ -n "${NSIGNER_GITEA_TOKEN:-}" ]]; then
curl -fL -H "Authorization: token ${NSIGNER_GITEA_TOKEN}" -o "${PREFIX_BIN}/nsigner" "${asset_url}"
else
curl -fL -o "${PREFIX_BIN}/nsigner" "${asset_url}"
fi
chmod 0755 "${PREFIX_BIN}/nsigner"
log "Installed ${PREFIX_BIN}/nsigner from release binary"
}
install_fips() {
log "Installing FIPS ${FIPS_VERSION}"
log "Release page: ${FIPS_RELEASE_PAGE}"
rm -rf "${PREFIX_DATA}/fips-src"
mkdir -p "${PREFIX_DATA}/fips-src"
curl -fL -o "${PREFIX_DATA}/fips.tar.gz" "${FIPS_ARCHIVE_URL}"
tar -xzf "${PREFIX_DATA}/fips.tar.gz" -C "${PREFIX_DATA}/fips-src" --strip-components=1
(
cd "${PREFIX_DATA}/fips-src"
cargo build --release
install -m 0755 ./target/release/fips "${PREFIX_BIN}/fips"
install -m 0755 ./target/release/fipsctl "${PREFIX_BIN}/fipsctl"
if [[ -x ./target/release/fipstop ]]; then
install -m 0755 ./target/release/fipstop "${PREFIX_BIN}/fipstop"
fi
)
log "Installed FIPS binaries into ${PREFIX_BIN}"
}
write_fips_user_config() {
local cfg="${PREFIX_CFG}/fips/fips.yaml"
if [[ -f "${cfg}" ]]; then
warn "Keeping existing ${cfg}"
return
fi
cat >"${cfg}" <<'EOF'
# User-local FIPS config for AppVM persistence
# Edit peers to join your mesh.
node:
identity:
persistent: true
# Try userspace tun name; adjust if your environment requires a different setting.
tun:
enabled: true
name: fips0
mtu: 1280
dns:
enabled: true
bind_addr: "127.0.0.1"
port: 5354
transports:
udp:
bind_addr: "0.0.0.0:2121"
tcp:
bind_addr: "0.0.0.0:8443"
peers: []
EOF
log "Wrote ${cfg}"
}
write_fips_user_service() {
local svc="${SYSTEMD_USER_DIR}/fips.service"
cat >"${svc}" <<EOF
[Unit]
Description=FIPS mesh daemon (user)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=${PREFIX_BIN}/fips --config ${PREFIX_CFG}/fips/fips.yaml
Restart=on-failure
RestartSec=2
Environment=RUST_LOG=info
[Install]
WantedBy=default.target
EOF
log "Wrote ${svc}"
}
start_fips_user_service() {
require_cmd systemctl
systemctl --user daemon-reload
systemctl --user enable --now fips
if systemctl --user is-active --quiet fips; then
log "fips user service: active"
else
warn "fips user service is not active"
warn "Check logs with: journalctl --user -u fips -n 200 --no-pager"
fi
}
post_checks() {
export PATH="${PREFIX_BIN}:${PATH}"
log "Running post-install checks"
require_cmd nsigner
require_cmd fips
require_cmd fipsctl
nsigner --version || true
fips --version || true
log "User binaries installed in: ${PREFIX_BIN}"
log "If needed, add to shell PATH: export PATH=\"${PREFIX_BIN}:\$PATH\""
log "Edit peers in: ${PREFIX_CFG}/fips/fips.yaml"
}
main() {
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
show_help
exit 0
fi
install_build_deps
prepare_dirs
install_nsigner
install_fips
write_fips_user_config
write_fips_user_service
start_fips_user_service
post_checks
log "Completed user-only install of n_signer + FIPS"
}
main "$@"

View File

@@ -214,22 +214,22 @@ Use Qubes' native inter-qube primitive instead of inventing one.
- 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
### 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: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.
- 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`, captures the bound port, runs the same NIP-04/NIP-44/sign_event matrix as AF_UNIX.
- 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] Reject invalid/non-loopback listen targets in [`src/server.c`](../src/server.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.
@@ -250,7 +250,7 @@ Only after T2 is solid.
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.
- 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).
@@ -259,7 +259,7 @@ FIPS is a *substrate* for an existing TCP listener, not a new transport in nsign
Execution tasks for initial FIPS trial:
- [x] Deliver T2 TCP loopback listener as FIPS substrate prerequisite.
- [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.

View File

@@ -451,8 +451,8 @@ 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 6
#define NSIGNER_VERSION "v0.0.6"
#define NSIGNER_VERSION_PATCH 7
#define NSIGNER_VERSION "v0.0.7"
/* NSIGNER_HEADERLESS_DECLS_END */

View File

@@ -601,17 +601,17 @@ static int parse_tcp_target(const char *target,
return -1;
}
if (strcmp(out_host, "::1") == 0) {
*out_family = AF_INET6;
} else {
{
struct in6_addr addr6;
struct in_addr addr4;
if (inet_pton(AF_INET, out_host, &addr4) != 1) {
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;
}
if ((ntohl(addr4.s_addr) & 0xff000000U) != 0x7f000000U) {
return -2;
}
*out_family = AF_INET;
}
*out_port = (uint16_t)port_long;
@@ -746,17 +746,10 @@ int server_start(server_ctx_t *ctx) {
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)",
"invalid tcp listen target: %s (expected tcp:IPv4:PORT or tcp:[IPv6]:PORT)",
ctx->socket_name);
return -1;
}