From ffc1f8cf23d7adbb72fdee203797cda99184032c Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Tue, 11 Aug 2026 03:58:12 +0000 Subject: [PATCH 1/4] Verify the zig download before extracting it The OpenWrt cross-compile fetched zig through `curl | sudo tar xJ`, so a short read reached tar as a truncated archive and failed the build with "Unexpected EOF in archive". A pipe leaves nowhere to check the bytes, and curl's own --retry does not cover it: exit 18 is not in its transient set. Download to a staging directory first, verify a pinned SHA-256, then extract. Each architecture now sets its hash on the same case branch that sets its name, so an architecture cannot be added without one, and a guard fails with the jq recipe for deriving it if the hash is ever empty. Three attempts with 10s and 20s backoff, matching the retry idiom already in this workflow, and an early exit when two attempts return identical bytes, since a stable mismatch is a wrong pin rather than a bad transfer. The step also gains `set -euo pipefail` and a trap that removes the staging directory on every exit path. It previously ran under the default shell without pipefail, so a failure inside the pipe could be masked by tar. The hashes come from ziglang.org's download index and were checked against the bytes of both tarballs. That is integrity, not authenticity: index and archive share an origin, and upstream publishes no detached sums. --- .github/workflows/package-openwrt.yml | 85 +++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 4 deletions(-) diff --git a/.github/workflows/package-openwrt.yml b/.github/workflows/package-openwrt.yml index e28100f..1552a44 100644 --- a/.github/workflows/package-openwrt.yml +++ b/.github/workflows/package-openwrt.yml @@ -138,15 +138,92 @@ jobs: run: cargo install cargo-zigbuild --version 0.19.8 --locked - name: Install zig (required by cargo-zigbuild) + shell: bash run: | + set -euo pipefail ZIG_VERSION="0.13.0" + # Each arch carries the expected SHA-256 of its upstream tarball, + # taken from ziglang.org's own https://ziglang.org/download/index.json, + # field .[""]["-linux"].shasum: + # jq -r '.["0.13.0"]["x86_64-linux"].shasum' index.json + # Upstream publishes no .sha256 sidecar and no SHA256SUMS, so + # index.json is the only checksum document offered, and it lists only + # recent releases: once a version ages out of it the pin can no longer + # be re-derived upstream. Bumping ZIG_VERSION means replacing every + # hash below, and adding an arch means adding its hash here too. ARCH=$(uname -m) case "$ARCH" in - x86_64|amd64) ZIG_ARCH="x86_64" ;; - aarch64|arm64) ZIG_ARCH="aarch64" ;; - *) echo "Unsupported architecture: $ARCH"; exit 1 ;; + x86_64|amd64) + ZIG_ARCH="x86_64" + ZIG_SHA256="d45312e61ebcc48032b77bc4cf7fd6915c11fa16e4aad116b66c9468211230ea" + ;; + aarch64|arm64) + ZIG_ARCH="aarch64" + ZIG_SHA256="041ac42323837eb5624068acd8b00cd5777dac4cf91179e8dad7a7e90dd0c556" + ;; + *) + echo "Unsupported architecture: $ARCH" + exit 1 + ;; esac - curl -fsSL "https://ziglang.org/download/${ZIG_VERSION}/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}.tar.xz" | sudo tar xJ -C /opt + if [ -z "${ZIG_SHA256:-}" ]; then + echo "No SHA-256 pinned for zig ${ZIG_VERSION} on ${ZIG_ARCH}." + echo "Add one to the case above, from https://ziglang.org/download/index.json:" + echo " jq -r '.[\"${ZIG_VERSION}\"][\"${ZIG_ARCH}-linux\"].shasum'" + exit 1 + fi + + NAME="zig-linux-${ZIG_ARCH}-${ZIG_VERSION}.tar.xz" + URL="https://ziglang.org/download/${ZIG_VERSION}/${NAME}" + # Stage outside the checkout so a failed attempt cannot leave a stray + # tarball in the working tree. + ZIG_TMP="$(mktemp -d)" + trap 'rm -rf "$ZIG_TMP"' EXIT + TARBALL="${ZIG_TMP}/${NAME}" + + # Download to a file and check it before anything consumes it: piping + # curl straight into tar let a truncated transfer reach the extractor, + # which is how this step failed. curl's own --retry does not cover a + # short read (exit 18), and a checksum mismatch needs a fresh download + # anyway, so the retry is an explicit bounded loop. + verified="" + previous="" + for attempt in 1 2 3; do + rm -f "$TARBALL" + if curl -fsSL -o "$TARBALL" "$URL" && [ -s "$TARBALL" ]; then + actual="$(sha256sum < "$TARBALL" | cut -d' ' -f1)" + if [ "$actual" = "$ZIG_SHA256" ]; then + echo "zig tarball matches its pinned SHA-256 (${actual})" + verified=yes + break + fi + echo "zig tarball failed its checksum on attempt ${attempt}:" + echo " expected ${ZIG_SHA256}" + echo " actual ${actual}" + echo " size $(wc -c < "$TARBALL") bytes" + if [ "$actual" = "$previous" ]; then + echo "Two attempts fetched byte-identical content, so retrying is not" + echo "going to help: the pin is stale, upstream re-published, or the" + echo "source is serving the same bad file every time." + break + fi + previous="$actual" + else + echo "zig tarball download failed on attempt ${attempt}" + fi + if [ "$attempt" -lt 3 ]; then + sleep $((attempt * 10)) + fi + done + + if [ -z "$verified" ]; then + echo "zig ${ZIG_VERSION} (${ZIG_ARCH}) did not download with its pinned" + echo "SHA-256 after ${attempt} attempt(s). Refusing to extract a tarball" + echo "that does not match the pin; failing the build." + exit 1 + fi + + sudo tar xJ -C /opt -f "$TARBALL" sudo ln -sf /opt/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}/zig /usr/local/bin/zig zig version From e1c4ed6e20f716b94788de82af991969b6959dd2 Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Tue, 11 Aug 2026 04:04:01 +0000 Subject: [PATCH 2/4] Stop a failed log write from panicking the thread that logged The subscriber is built with the default internal-error reporting, which sends a failed log write to `eprintln!`. That macro panics when stderr also fails, so on a full disk a single WARN can unwind whichever thread emitted it. The shipped macOS plist points StandardOutPath and StandardErrorPath at the same unrotated file and systemd routes both to journald, so the two sinks fail together rather than independently. A crypto worker is the case that matters. It logs a WARN on send backpressure, and a worker that dies takes its share of the peer space with it permanently: dispatch keeps hashing peers onto a channel nobody is reading. The panic message is discarded along the same broken path, so nothing records why. Turning off internal-error reporting closes that path without touching dispatch or the deliberate blocking backpressure on a full worker channel. It does not make worker death survivable, which is a separate concern. --- src/bin/fips.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/bin/fips.rs b/src/bin/fips.rs index 9cc98fd..b43f528 100644 --- a/src/bin/fips.rs +++ b/src/bin/fips.rs @@ -99,7 +99,16 @@ async fn run_daemon( _ => filter, }; - fmt().with_env_filter(filter).with_target(true).init(); + // Never let a failed log write panic the thread that logged. The default + // is to report a write failure with `eprintln!`, which itself panics when + // stderr fails too — and the shipped supervisor configs point stdout and + // stderr at the same place, so one full disk satisfies both. A worker + // thread killed that way takes its share of the peer space with it. + fmt() + .with_env_filter(filter) + .with_target(true) + .log_internal_errors(false) + .init(); info!("FIPS {} starting", version::short_version()); From 7d225602ecdd70e95d66d4e354dca040ae48872d Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Tue, 11 Aug 2026 06:43:59 +0000 Subject: [PATCH 3/4] Stop a failed log write from panicking a gateway task fips-gateway builds its subscriber the same way the daemon did, with the default internal-error reporting that sends a failed log write to `eprintln!`. Both shipped units set StandardOutput=journal and StandardError=journal, so one full disk fails both sinks together, which is the same precondition as on the daemon side. What dies here is a spawned task rather than a thread. The DNS resolver, the control accept loop and the pool tick are spawned and then not looked at again: their handles are first touched at shutdown, as `let _ = task.await`, which discards the JoinError. A task lost this way leaves the process running and reporting healthy with mesh name resolution or lease expiry and NAT cleanup stopped, and nothing recording that it stopped. Whether each task reaches a log site while the disk is full is not enumerated here; the resolver logs on exactly the error paths a full disk makes likely. --- src/bin/fips-gateway.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/bin/fips-gateway.rs b/src/bin/fips-gateway.rs index 7d8bc83..02bfbf0 100644 --- a/src/bin/fips-gateway.rs +++ b/src/bin/fips-gateway.rs @@ -67,7 +67,16 @@ async fn main() { ) .from_env_lossy(); - fmt().with_env_filter(filter).with_target(true).init(); + // As in the daemon: a failed log write must not panic whoever logged. The + // default reports write failures with `eprintln!`, which panics when stderr + // fails too, and both units send stdout and stderr to journald. Here the + // casualty is a spawned task — the DNS resolver or the pool tick — whose + // handle nothing observes until shutdown. + fmt() + .with_env_filter(filter) + .with_target(true) + .log_internal_errors(false) + .init(); info!("fips-gateway {} starting", version::short_version()); From ecda3925019031cc675b9f84a872ee8e6c80e52f Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Tue, 11 Aug 2026 06:57:44 +0000 Subject: [PATCH 4/4] Cover the rest of the 0.4.2 cycle in the changelog The Unreleased section carried the msg1 metering, the macOS path fixes, the traversal clock and the hop-limit change, but not the work that landed alongside them. Adds the rekey config validation, the three tick body performance changes, the richer socket adoption log line, the traversal relay filtering, the log write panic, and a Security entry for the FSP session address binding, which is the one an operator most needs to see before deciding when to upgrade. Left out deliberately: the CI and test harness commits, which are the bulk of the cycle by count and none of which changes shipped behaviour, and the zig download integrity fix, which hardens how the OpenWrt packages are built rather than what they contain. The 0.4.1 section sets that precedent by carrying no CI entries. --- CHANGELOG.md | 101 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dd8ff2..c145f6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Config validation now rejects two `node.rekey` settings that appear to + disable the trigger and in fact fire it continuously. `after_messages` of + zero makes the message-count arm true on every poll, because the trigger + compares the counter with greater-or-equal. `after_secs` at or below the + per-session jitter bound is the same trap on the timer arm: each session + offsets the interval by a random value within plus or minus that bound, so a + smaller interval saturates to zero on a negative draw and rekeys on sight, + for roughly half of sessions. Both are checked whether or not rekey is + enabled, so switching it on later cannot surface the error at a surprising + moment, and neither gains an upper bound — a very large value remains the + supported way to disable one arm. A config carrying either setting now fails + to load instead of starting a node that rekeys constantly. + +- Peer bloom filters are computed for every recipient in one prefix and suffix + union sweep rather than rebuilt per recipient. Announcing to R peers + previously did R full map builds and R by T merges; at 240 peers that was + 20.6 ms per tick, roughly half the tick body, with a median per-interval + maximum of 34.5 ms. The result is exactly equal rather than approximately: + merging is a bytewise OR, so regrouping the unions cannot change it. The + trade-off, measured rather than assumed, is that the sweep does its full work + regardless of how many peers are ready, so a tick announcing to one or two + peers now costs about twice what it did; break-even is around three ready + peers. Cadence, the debounce, the sequence rule and the fill-ratio cap are + unchanged. + +- Each peer's npub is derived once at construction instead of once per tick. + The per-tick stats snapshot ran a bech32 encode for every tracked peer, and a + second one for the common peer with no hosts-file entry and no alias, since + the display-name fallback bottoms out in the same encode: 14.1 ms per tick at + 240 peers. The display name itself is deliberately not cached, because the + alias map and the host map both mutate at runtime. + +- The peer-retry tick no longer awaits the Nostr advert refetch. It ran inline + on the 1-second rx-loop tick, awaiting a fetch with a 2-second timeout for + each due peer and discarding the result; with up to sixteen due peers the + timeouts stacked, and field profiling measured single 2.00 s stalls as the + common case and a worst tick of 12.4 s against a 1 s period, delaying every + other rx-loop arm by as much as 4.2 s. The refetch is now spawned, so a dial + uses the advert cached at that moment and the refreshed one lands for that + peer's next retry. + +- The `Adopted NAT traversal socket` log line now carries the transport id and + the local address alongside the peer npub. Without the local address an + operator cannot join a host socket table against adoption events, and without + the transport id several peers sharing one adopted transport are + indistinguishable from several separate adopted transports. + - Inbound msg1 is classified before it is rate limited, and rekey or restart msg1 arriving on an established link now draws on its own token bucket instead of competing with stranger admission for a single shared one. On a @@ -37,6 +84,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Nostr NAT traversal signals are now sent only to relays the client pool + actually holds. A signal is addressed to the merge of the peer's NIP-17 inbox + relays, the relays its advert nominates for signaling, and our own DM relays, + but the pool is built once at startup from the configured relays and the send + is rejected outright, before anything is contacted, if any single URL in that + list is outside it. One unconfigured relay anywhere in the merge therefore + killed the whole attempt, including the sends to relays both sides shared. On + a public node in open mode this made discovery non-functional: 309 traversal + attempts, 290 explicit failures, zero successes, every failure on `relay not + found`. Configured peers were unaffected, since they run a matching relay + set. Comparison is on the normalized relay URL rather than the raw string, so + a configured relay spelled with a trailing slash or different host case is + not discarded. Two smaller fixes ride along: the responder resolves its + relays before binding a socket and running STUN, rather than spending a STUN + round trip and holding an offer slot only to find it has nowhere to answer, + and it gained the empty-relay-list guard the initiator already had. + +- A failed log write can no longer panic the thread or task that logged. The + subscriber was built with the default internal-error reporting, which sends a + failed write to `eprintln!`, and that macro panics when stderr has also + failed. The shipped supervisor configurations make that a single condition + rather than two: the macOS plist points both standard streams at one + unrotated file, and the systemd units route both to journald, so one full + disk fails both sinks together. In the daemon a crypto worker was the case + that mattered — it logs a warning on send backpressure, and a worker that + dies takes its share of the peer space with it permanently, while the panic + message is discarded along the same broken path. In `fips-gateway`, which + built its subscriber the same way, the casualty is a spawned task: the DNS + resolver, the control accept loop or the pool tick, none of which is observed + until shutdown, so the process would keep running and reporting healthy with + mesh name resolution or lease expiry and NAT cleanup silently stopped. + - macOS: `peers.allow`, `peers.deny`, and the `hosts` file are now read from `/usr/local/etc/fips/`, matching the install layout the macOS packaging ships (`packaging/macos/`). The default-path constants were @@ -114,6 +193,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 counter now charges at the node that makes the decision rather than at the hop after it. +### Security + +- The FSP session address is now bound to the peer key the Noise handshake + authenticated, on both the initial and the rekey path. The responder recorded + a session under the source address carried in the datagram without ever + checking that address against the static key it had just authenticated, so a + peer could complete a genuine handshake while claiming another node's + address, and the identity cache, the session map and the address the IPv6 + shim reconstructs on delivery would all attribute its traffic to the node it + named. The address is now derived from the authenticated key at the point it + first becomes available in msg3, and a mismatch drops the half-open session + without recording either the identity or the session. The rekey responder + needed its own check: it returns before that code is reached and never read + the peer's static key at all, so a rekey could complete under an established + session with a different key than the one that opened it. It now requires the + key to be unchanged and abandons the rekey while leaving the existing session + intact, rather than tearing the session down, which would have handed an + attacker a way to kill established sessions. Both comparisons are on x-only + keys, because a stored key may carry a synthesized parity while the handshake + learns the true point. The two rejections are counted separately in the + session reject statistics. + ## [0.4.1] - 2026-07-19 ### Changed